feat(matrix-bots): add i18n system and direct message fallback

- Add I18nService with per-user language preferences (de/en)
- Add !language/!sprache command to all 4 bots (todo, calendar, contacts, clock)
- Add fallback behavior: messages without commands create tasks/events/contacts/timers
- Improve clock bot duration parsing to accept bare numbers as minutes (e.g. "25" = 25min)
- Add support for more duration formats: "25 minuten", "1 stunde", etc.

Language preferences stored in SessionService, default configurable via BOT_DEFAULT_LANGUAGE env var.
This commit is contained in:
Till-JS 2026-02-02 16:07:27 +01:00
parent 5c688d713e
commit c2c80efc50
17 changed files with 1626 additions and 18 deletions

View file

@ -1,10 +1,21 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { ClockModule } from '../clock/clock.module';
import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services';
import {
TranscriptionModule,
SessionModule,
CreditModule,
I18nModule,
} from '@manacore/bot-services';
@Module({
imports: [ClockModule, TranscriptionModule.forRoot(), SessionModule.forRoot(), CreditModule.forRoot()],
imports: [
ClockModule,
TranscriptionModule.forRoot(),
SessionModule.forRoot(),
CreditModule.forRoot(),
I18nModule.forRoot(),
],
providers: [MatrixService],
exports: [MatrixService],
})

View file

@ -8,7 +8,14 @@ import {
COMMON_KEYWORDS,
} from '@manacore/matrix-bot-common';
import { ClockService } from '../clock/clock.service';
import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services';
import {
TranscriptionService,
SessionService,
CreditService,
I18nService,
Language,
LANGUAGE_NAMES,
} from '@manacore/bot-services';
import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration';
@Injectable()
@ -30,7 +37,8 @@ export class MatrixService extends BaseMatrixService {
private clockService: ClockService,
private transcriptionService: TranscriptionService,
private sessionService: SessionService,
private creditService: CreditService
private creditService: CreditService,
private i18nService: I18nService
) {
super(configService);
}
@ -218,6 +226,12 @@ export class MatrixService extends BaseMatrixService {
await this.handleWorldClocksCommand(roomId, event, userId);
break;
case 'language':
case 'sprache':
case 'lang':
await this.handleLanguage(roomId, event, userId, args);
break;
default:
// Silently ignore unknown commands
break;
@ -676,7 +690,12 @@ export class MatrixService extends BaseMatrixService {
}
}
// No match - don't respond to random messages
// Fallback: try to parse any message as a timer duration
const duration = this.clockService.parseDuration(text);
if (duration) {
await this.handleTimerCommand(roomId, event, userId, text);
return;
}
}
private async getToken(userId: string): Promise<string | null> {
@ -691,4 +710,47 @@ export class MatrixService extends BaseMatrixService {
// Entwicklungs-Fallback
return this.demoToken || null;
}
private async handleLanguage(
roomId: string,
event: MatrixRoomEvent,
userId: string,
args: string
) {
const lang = args.trim().toLowerCase();
if (!lang) {
const currentLang = await this.i18nService.getLanguage(userId);
const langName = LANGUAGE_NAMES[currentLang];
const available = this.i18nService
.getAvailableLanguages()
.map((l) => `${l} (${LANGUAGE_NAMES[l]})`)
.join(', ');
await this.sendReply(
roomId,
event,
`**Sprache / Language:** ${langName}\n\n**Verfügbar / Available:** ${available}\n\nÄndern / Change: \`!language de\` oder / or \`!language en\``
);
return;
}
if (!this.i18nService.isValidLanguage(lang)) {
const available = this.i18nService.getAvailableLanguages().join(', ');
await this.sendReply(
roomId,
event,
`Unbekannte Sprache / Unknown language: ${lang}\n\nVerfügbar / Available: ${available}`
);
return;
}
await this.i18nService.setLanguage(userId, lang as Language);
const langName = LANGUAGE_NAMES[lang as Language];
if (lang === 'de') {
await this.sendReply(roomId, event, `Sprache geändert zu: **${langName}**`);
} else {
await this.sendReply(roomId, event, `Language changed to: **${langName}**`);
}
}
}

View file

@ -186,27 +186,27 @@ export class ClockService {
parseDuration(input: string): number | null {
let totalSeconds = 0;
// Match hours
const hoursMatch = input.match(/(\d+)\s*h/i);
// Match hours: 1h, 1 h, 1 stunde, 1 stunden, 1 hour, 1 hours
const hoursMatch = input.match(/(\d+)\s*(?:h|stunde[n]?|hour[s]?)\b/i);
if (hoursMatch) {
totalSeconds += parseInt(hoursMatch[1], 10) * 3600;
}
// Match minutes
const minutesMatch = input.match(/(\d+)\s*m(?:in)?/i);
// Match minutes: 25m, 25 m, 25min, 25 min, 25 minuten, 25 minute, 25 minutes
const minutesMatch = input.match(/(\d+)\s*(?:m|min|minute[n]?|minutes?)\b/i);
if (minutesMatch) {
totalSeconds += parseInt(minutesMatch[1], 10) * 60;
}
// Match seconds
const secondsMatch = input.match(/(\d+)\s*s(?:ec)?/i);
// Match seconds: 30s, 30 s, 30sec, 30 sec, 30 sekunden, 30 seconds
const secondsMatch = input.match(/(\d+)\s*(?:s|sec|sekunde[n]?|seconds?)\b/i);
if (secondsMatch) {
totalSeconds += parseInt(secondsMatch[1], 10);
}
// If just a number, assume minutes
// If just a number (with optional whitespace), assume minutes
if (totalSeconds === 0) {
const justNumber = input.match(/^(\d+)$/);
const justNumber = input.trim().match(/^(\d+)$/);
if (justNumber) {
totalSeconds = parseInt(justNumber[1], 10) * 60;
}