import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent, KeywordCommandDetector, COMMON_KEYWORDS, handleCreditCommand, handleGiftCommand, type CreditCommandsHost, type GiftCommandsHost, } from '@manacore/matrix-bot-common'; import { TranscriptionService, SessionService, CreditService, GiftService, TodoApiService, Task as ApiTask, I18nService, Language, LANGUAGE_NAMES, } from '@manacore/bot-services'; import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; // Credit cost for task creation (micro-credits) const TASK_CREATE_CREDITS = 0.02; // Alias for consistency type Task = ApiTask; @Injectable() export class MatrixService extends BaseMatrixService implements CreditCommandsHost, GiftCommandsHost { // Expose services for credit and gift commands mixins public creditService: CreditService; public giftService: GiftService; public i18nService: I18nService; public sessionService: SessionService; private readonly keywordDetector = new KeywordCommandDetector( [ ...COMMON_KEYWORDS, { keywords: ['was kannst du', 'hilfe', 'help'], command: 'help' }, { keywords: [ 'zeige aufgaben', 'meine aufgaben', 'was muss ich', 'show tasks', 'list', 'liste', 'alle', ], command: 'list', }, { keywords: ['heute', 'today', 'was steht an'], command: 'today' }, { keywords: ['inbox', 'eingang', 'ohne datum'], command: 'inbox' }, { keywords: ['projekte', 'projects'], command: 'projects' }, { keywords: ['verbindung', 'connection', 'status'], command: 'status' }, { keywords: ['neu', 'neue', 'add'], command: 'add' }, { keywords: ['erledigt', 'fertig', 'done'], command: 'done' }, { keywords: ['löschen', 'entfernen', 'delete'], command: 'delete' }, { keywords: ['projekt', 'project'], command: 'project' }, { keywords: ['pin'], command: 'pin' }, { keywords: ['login', 'anmelden'], command: 'login' }, { keywords: ['logout', 'abmelden'], command: 'logout' }, { keywords: ['sprache', 'language', 'lang'], command: 'language' }, { keywords: ['credits', 'guthaben', 'kontostand'], command: 'credits' }, { keywords: ['packages', 'pakete', 'preise'], command: 'packages' }, { keywords: ['kaufen', 'buy'], command: 'buy' }, ], { partialMatch: true } ); constructor( configService: ConfigService, private todoApiService: TodoApiService, private transcriptionService: TranscriptionService, sessionService: SessionService, creditService: CreditService, giftService: GiftService, i18nService: I18nService ) { super(configService); // Assign to public properties for credit and gift commands mixins this.sessionService = sessionService; this.creditService = creditService; this.giftService = giftService; this.i18nService = i18nService; } // ============================================================================ // CreditCommandsHost interface implementation // ============================================================================ /** * Send a credit message (delegates to protected sendMessage) */ async sendCreditMessage(roomId: string, message: string): Promise { await this.sendMessage(roomId, message); } /** * Send a credit reply (delegates to protected sendReply) */ async sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise { await this.sendReply(roomId, event, message); } // ============================================================================ // GiftCommandsHost interface implementation // ============================================================================ /** * Send a gift message (delegates to protected sendMessage) */ async sendGiftMessage(roomId: string, message: string): Promise { await this.sendMessage(roomId, message); } /** * Send a gift reply (delegates to protected sendReply) */ async sendGiftReply(roomId: string, event: MatrixRoomEvent, message: string): Promise { await this.sendReply(roomId, event, message); } // ============================================================================ // Private helpers // ============================================================================ /** * Check if user is logged in and has a valid token for API access */ private async getToken(userId: string): Promise { return this.sessionService.getToken(userId); } /** * Require login - returns token or sends login prompt and returns null */ private async requireLogin( roomId: string, event: MatrixRoomEvent, userId: string ): Promise { const token = await this.getToken(userId); if (!token) { await this.sendReply( roomId, event, '🔐 **Login erforderlich**\n\n' + 'Um Aufgaben zu verwalten, melde dich bitte an:\n\n' + '`login deine@email.de deinpasswort`\n\n' + 'Deine Aufgaben werden dann mit der Todo-App synchronisiert.' ); return null; } return token; } /** * Normalize task from API format */ private normalizeTask(task: ApiTask): Task { return { id: task.id, title: task.title, completed: task.completed, priority: task.priority, dueDate: task.dueDate, project: task.project, labels: task.labels || [], createdAt: task.createdAt, completedAt: task.completedAt || null, userId: task.userId, }; } protected getConfig(): MatrixBotConfig { return { homeserverUrl: this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', accessToken: this.configService.get('matrix.accessToken') || '', storagePath: this.configService.get('matrix.storagePath') || './data/bot-storage.json', allowedRooms: this.configService.get('matrix.allowedRooms') || [], }; } protected getIntroductionMessage(): string { return BOT_INTRODUCTION; } // Commands that can be used without ! prefix private readonly directCommands = [ 'help', 'hilfe', 'add', 'neu', 'neue', 'list', 'liste', 'alle', 'heute', 'today', 'inbox', 'eingang', 'done', 'erledigt', 'fertig', 'delete', 'löschen', 'entfernen', 'projects', 'projekte', 'project', 'projekt', 'status', 'pin', 'login', 'logout', 'language', 'sprache', 'lang', // Credit commands 'credits', 'guthaben', 'packages', 'pakete', 'buy', 'kaufen', // Gift commands 'geschenk', 'gift', 'einloesen', 'redeem', 'meine-geschenke', 'my-gifts', ]; protected async handleTextMessage( roomId: string, event: MatrixRoomEvent, body: string ): Promise { const userId = event.sender; try { // Check for ! commands first if (body.startsWith('!')) { const [command, ...args] = body.slice(1).split(' '); await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); return; } // Check for direct commands (without ! prefix) const trimmedBody = body.trim(); const words = trimmedBody.split(/\s+/); const firstWord = words[0].toLowerCase(); if (this.directCommands.includes(firstWord)) { const args = words.slice(1).join(' '); await this.executeCommand(roomId, event, userId, firstWord, args); return; } // Check for natural language keywords const keywordCommand = this.keywordDetector.detect(body); if (keywordCommand) { // For commands that need args, try to extract from the message const args = this.extractArgsAfterKeyword(body, keywordCommand); await this.executeCommand(roomId, event, userId, keywordCommand, args); return; } // Fallback: treat any message as a task await this.handleAddTask(roomId, event, userId, body); } catch (error) { this.logger.error(`Error handling message: ${error}`); await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.'); } } /** * Extract arguments after a keyword match */ private extractArgsAfterKeyword(body: string, command: string): string { // Map commands to their trigger keywords const commandKeywords: Record = { add: ['neu', 'neue', 'add'], done: ['erledigt', 'fertig', 'done'], delete: ['löschen', 'entfernen', 'delete'], project: ['projekt', 'project'], login: ['login', 'anmelden'], language: ['sprache', 'language', 'lang'], }; const keywords = commandKeywords[command]; if (!keywords) return ''; const lowerBody = body.toLowerCase(); for (const keyword of keywords) { const index = lowerBody.indexOf(keyword); if (index !== -1) { return body.substring(index + keyword.length).trim(); } } return ''; } protected async handleAudioMessage( roomId: string, event: MatrixRoomEvent, sender: string ): Promise { const content = event.content; if (!content?.url) return; try { // Require login for audio messages const token = await this.requireLogin(roomId, event, sender); if (!token) return; await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); // Download audio from Matrix using authenticated API const mxcUrl = content.url; this.logger.log(`Downloading audio from ${mxcUrl}`); const buffer = await this.downloadMedia(mxcUrl); // Transcribe audio const transcription = await this.transcriptionService.transcribe(buffer); this.logger.log(`Transcription: ${transcription.substring(0, 50)}...`); if (!transcription.trim()) { await this.sendReply( roomId, event, 'Konnte keine Sprache erkennen. Bitte versuche es erneut.' ); return; } // Check credits const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS); if (!validation.hasCredits) { const errorMsg = this.creditService.formatInsufficientCreditsError( TASK_CREATE_CREDITS, validation.availableCredits, 'Aufgabe erstellen' ); await this.sendReply( roomId, event, `Transkription: "${transcription}"\n\n${errorMsg.text}` ); return; } // Use API service (syncs with todo-web and mobile) const { title, priority, dueDate, project } = this.todoApiService.parseTaskInput(transcription); const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate }); if (!apiTask) { await this.sendReply( roomId, event, `Transkription: "${transcription}"\n\nFehler beim Erstellen der Aufgabe.` ); return; } const task = this.normalizeTask(apiTask); task.project = project; let responseText = `Aufgabe erstellt: **${task.title}**`; const details: string[] = []; if (task.priority < 4) details.push(`Priorität ${task.priority}`); if (task.dueDate) details.push(`${this.formatDate(task.dueDate)}`); if (task.project) details.push(`#${task.project}`); if (details.length > 0) { responseText += ` · ${details.join(' · ')}`; } await this.sendMessage(roomId, responseText); } catch (error) { this.logger.error('Audio processing failed:', error); const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; await this.sendReply(roomId, event, `Fehler bei der Verarbeitung: ${errorMsg}`); } } private async executeCommand( roomId: string, event: MatrixRoomEvent, userId: string, command: string, args: string ) { // Handle credit commands first (credits, packages, buy) if (await handleCreditCommand(this, roomId, event, userId, command, args)) { return; } // Handle gift commands (geschenk, einloesen, meine-geschenke) if (await handleGiftCommand(this, roomId, event, userId, command, args)) { return; } switch (command) { case 'help': case 'hilfe': await this.sendReply(roomId, event, HELP_TEXT); break; case 'add': case 'neu': case 'neue': await this.handleAddTask(roomId, event, userId, args); break; case 'list': case 'liste': case 'alle': await this.handleListTasks(roomId, event, userId); break; case 'today': case 'heute': await this.handleTodayTasks(roomId, event, userId); break; case 'inbox': case 'eingang': await this.handleInboxTasks(roomId, event, userId); break; case 'done': case 'erledigt': case 'fertig': await this.handleCompleteTask(roomId, event, userId, args); break; case 'delete': case 'löschen': case 'entfernen': await this.handleDeleteTask(roomId, event, userId, args); break; case 'projects': case 'projekte': await this.handleProjects(roomId, event, userId); break; case 'project': case 'projekt': await this.handleProjectTasks(roomId, event, userId, args); break; case 'status': await this.handleStatus(roomId, event, userId); break; case 'pin': await this.handlePinHelp(roomId, event); break; case 'language': case 'sprache': case 'lang': await this.handleLanguage(roomId, event, userId, args); break; default: // Unknown command - ignore silently or send help break; } } private async handleLanguage( roomId: string, event: MatrixRoomEvent, userId: string, args: string ) { const lang = args.trim().toLowerCase(); // Show current language if no argument 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: \`sprache de\` oder / or \`sprache en\`` ); return; } // Validate and set language 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]; // Respond in the new language if (lang === 'de') { await this.sendReply(roomId, event, `Sprache geändert zu: **${langName}**`); } else { await this.sendReply(roomId, event, `Language changed to: **${langName}**`); } } private async handleAddTask( roomId: string, event: MatrixRoomEvent, userId: string, input: string ) { if (!input.trim()) { await this.sendReply( roomId, event, 'Bitte gib eine Aufgabe an.\n\nBeispiel: `neu Einkaufen gehen`' ); return; } // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; // Check credits const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS); if (!validation.hasCredits) { const errorMsg = this.creditService.formatInsufficientCreditsError( TASK_CREATE_CREDITS, validation.availableCredits, 'Aufgabe erstellen' ); await this.sendReply(roomId, event, errorMsg.text); return; } // Use API service (syncs with todo-web and mobile) const { title, priority, dueDate, project } = this.todoApiService.parseTaskInput(input); const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate }); if (!apiTask) { await this.sendReply( roomId, event, 'Fehler beim Erstellen der Aufgabe. Bitte versuche es erneut.' ); return; } const task = this.normalizeTask(apiTask); task.project = project; // Note: project handling via API needs project ID lookup let response = `Aufgabe erstellt: **${task.title}**`; const details: string[] = []; if (task.priority < 4) details.push(`Priorität ${task.priority}`); if (task.dueDate) details.push(`${this.formatDate(task.dueDate)}`); if (task.project) details.push(`#${task.project}`); if (details.length > 0) { response += ` · ${details.join(' · ')}`; } await this.sendMessage(roomId, response); } private async handleListTasks(roomId: string, event: MatrixRoomEvent, userId: string) { // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; const apiTasks = await this.todoApiService.getTasks(token, { completed: false }); const tasks = apiTasks.map((t) => this.normalizeTask(t)); if (tasks.length === 0) { await this.sendReply( roomId, event, 'Keine offenen Aufgaben.\n\nErstelle eine mit `neu [Aufgabe]`' ); return; } const response = this.formatTaskList('**Alle offenen Aufgaben:**', tasks); await this.sendMessage(roomId, response); } private async handleTodayTasks(roomId: string, event: MatrixRoomEvent, userId: string) { // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; const apiTodayTasks = await this.todoApiService.getTodayTasks(token); const apiInboxTasks = await this.todoApiService.getInboxTasks(token); const todayTasks = apiTodayTasks.map((t) => this.normalizeTask(t)); const inboxTasks = apiInboxTasks.map((t) => this.normalizeTask(t)); const hasTodayTasks = todayTasks.length > 0; const hasInboxTasks = inboxTasks.length > 0; if (!hasTodayTasks && !hasInboxTasks) { await this.sendReply( roomId, event, 'Keine Aufgaben.\n\nErstelle eine mit `neu Aufgabe` oder `neu Aufgabe @heute`' ); return; } let response = ''; if (hasTodayTasks) { response += this.formatTaskList('**Aufgaben fuer heute:**', todayTasks); } if (hasInboxTasks) { if (hasTodayTasks) { response += '\n\n'; } response += this.formatTaskList('**Inbox (ohne Datum):**', inboxTasks); } await this.sendMessage(roomId, response); } private async handleInboxTasks(roomId: string, event: MatrixRoomEvent, userId: string) { // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; const apiTasks = await this.todoApiService.getInboxTasks(token); const tasks = apiTasks.map((t) => this.normalizeTask(t)); if (tasks.length === 0) { await this.sendReply(roomId, event, 'Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'); return; } const response = this.formatTaskList('**Inbox (ohne Datum):**', tasks); await this.sendMessage(roomId, response); } private async handleCompleteTask( roomId: string, event: MatrixRoomEvent, userId: string, args: string ) { const taskNumber = parseInt(args.trim()); if (isNaN(taskNumber) || taskNumber < 1) { await this.sendReply( roomId, event, 'Bitte gib eine gueltige Aufgabennummer an.\n\nBeispiel: `erledigt 1`' ); return; } // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; let task: Task | null = null; // Use API service - need to get task list first to find task by index const apiTasks = await this.todoApiService.getTasks(token, { completed: false }); if (taskNumber > 0 && taskNumber <= apiTasks.length) { const targetTask = apiTasks[taskNumber - 1]; const completedTask = await this.todoApiService.completeTask(token, targetTask.id); if (completedTask) { task = this.normalizeTask(completedTask); } } if (!task) { await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`); return; } await this.sendMessage(roomId, `✓ ~~${task.title}~~`); } private async handleDeleteTask( roomId: string, event: MatrixRoomEvent, userId: string, args: string ) { const taskNumber = parseInt(args.trim()); if (isNaN(taskNumber) || taskNumber < 1) { await this.sendReply( roomId, event, 'Bitte gib eine gueltige Aufgabennummer an.\n\nBeispiel: `löschen 1`' ); return; } // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; let task: Task | null = null; // Use API service - need to get task list first to find task by index const apiTasks = await this.todoApiService.getTasks(token, { completed: false }); if (taskNumber > 0 && taskNumber <= apiTasks.length) { const targetTask = apiTasks[taskNumber - 1]; const deleted = await this.todoApiService.deleteTask(token, targetTask.id); if (deleted) { task = this.normalizeTask(targetTask); } } if (!task) { await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`); return; } await this.sendMessage(roomId, `🗑️ ${task.title}`); } private async handleProjects(roomId: string, event: MatrixRoomEvent, userId: string) { // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; const projects = await this.todoApiService.getProjects(token); if (projects.length === 0) { await this.sendReply( roomId, event, 'Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `neu Aufgabe #projektname`' ); return; } let response = '**Deine Projekte:**\n\n'; for (const project of projects) { response += `- ${project.name}\n`; } response += '\nZeige Projektaufgaben mit `projekt [Name]`'; await this.sendMessage(roomId, response); } private async handleProjectTasks( roomId: string, event: MatrixRoomEvent, userId: string, args: string ) { const projectName = args.trim(); if (!projectName) { await this.sendReply( roomId, event, 'Bitte gib einen Projektnamen an.\n\nBeispiel: `projekt Arbeit`' ); return; } // Require login const token = await this.requireLogin(roomId, event, userId); if (!token) return; let tasks: Task[] = []; // Use API service - need to find project ID first const projects = await this.todoApiService.getProjects(token); const project = projects.find((p) => p.name.toLowerCase() === projectName.toLowerCase()); if (project) { const apiTasks = await this.todoApiService.getProjectTasks(token, project.id); tasks = apiTasks.map((t) => this.normalizeTask(t)); } if (tasks.length === 0) { await this.sendReply(roomId, event, `Keine Aufgaben im Projekt "${projectName}".`); return; } const response = this.formatTaskList(`**Projekt: ${projectName}**`, tasks); await this.sendMessage(roomId, response); } private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) { const token = await this.getToken(userId); const isLoggedIn = await this.sessionService.isLoggedIn(userId); const email = this.sessionService.getEmail(userId); if (token) { const stats = await this.todoApiService.getStats(token); const response = `**Status** 👤 ${email} - Offen: ${stats.pending} - Heute: ${stats.today} - Erledigt: ${stats.completed}`; await this.sendMessage(roomId, response); } else { const response = `**Status** 🔐 Nicht angemeldet \`login email passwort\``; await this.sendMessage(roomId, response); } } private async handlePinHelp(roomId: string, event: MatrixRoomEvent) { try { // Send help message const helpEventId = await this.sendMessage(roomId, HELP_TEXT); // Pin it await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { pinned: [helpEventId], }); await this.sendReply(roomId, event, 'Hilfe wurde angepinnt!'); } catch (error) { this.logger.error('Failed to pin help:', error); await this.sendReply(roomId, event, 'Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)'); } } private formatTaskList(header: string, tasks: Task[]): string { let response = `${header}\n\n`; tasks.forEach((task, index) => { const num = index + 1; const priority = task.priority < 4 ? `!`.repeat(4 - task.priority) : ''; const date = task.dueDate ? ` ${this.formatDate(task.dueDate)}` : ''; const project = task.project ? ` ${task.project}` : ''; response += `**${num}.** ${task.title}${priority}${date}${project}\n`; }); response += `\nErledigen: \`erledigt [Nr]\` | Loeschen: \`löschen [Nr]\``; return response; } private formatDate(dateStr: string): string { const date = new Date(dateStr); const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); if (dateStr === today.toISOString().split('T')[0]) { return 'Heute'; } else if (dateStr === tomorrow.toISOString().split('T')[0]) { return 'Morgen'; } return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); } }