diff --git a/services/matrix-calendar-bot/src/bot/matrix.service.ts b/services/matrix-calendar-bot/src/bot/matrix.service.ts index c637c57ee..75946de32 100644 --- a/services/matrix-calendar-bot/src/bot/matrix.service.ts +++ b/services/matrix-calendar-bot/src/bot/matrix.service.ts @@ -1,13 +1,6 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import * as path from 'path'; -import * as fs from 'fs'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { CalendarService, CalendarEvent } from '../calendar/calendar.service'; import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; @@ -28,152 +21,46 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private readonly homeserverUrl: string; - private readonly accessToken: string; - private readonly allowedRooms: string[]; - private readonly storagePath: string; - +export class MatrixService extends BaseMatrixService { constructor( - private configService: ConfigService, + configService: ConfigService, private calendarService: CalendarService ) { - this.homeserverUrl = this.configService.get( - 'matrix.homeserverUrl', - 'http://localhost:8008' - ); - this.accessToken = this.configService.get('matrix.accessToken', ''); - this.allowedRooms = this.configService.get('matrix.allowedRooms', []); - this.storagePath = this.configService.get( - 'matrix.storagePath', - './data/bot-storage.json' - ); + super(configService); } - async onModuleInit() { - if (!this.accessToken) { - this.logger.warn('No Matrix access token configured. Bot will not start.'); + 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 | null { + return BOT_INTRODUCTION; + } + + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { + // Check for ! commands first (before keyword detection) + if (message.startsWith('!')) { + const [command, ...args] = message.slice(1).split(' '); + await this.executeCommand(roomId, event, sender, command.toLowerCase(), args.join(' ')); return; } - await this.initializeClient(); - } - - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - } - } - - private async initializeClient() { - try { - // Ensure storage directory exists - const storageDir = path.dirname(this.storagePath); - if (!fs.existsSync(storageDir)) { - fs.mkdirSync(storageDir, { recursive: true }); - } - - const storage = new SimpleFsStorageProvider(this.storagePath); - this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); - - // Auto-join rooms when invited - AutojoinRoomsMixin.setupOnClient(this.client); - - // Handle room invites with introduction - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - // Send introduction after a short delay - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Handle member joins for welcome message - this.client.on('room.event', async (roomId: string, event: any) => { - if (event.type === 'm.room.member' && event.content?.membership === 'join') { - const odUser = event.state_key; - const botUserId = await this.client.getUserId(); - - // Don't welcome the bot itself - if (odUser === botUserId) return; - - // Check if this is a new join (not just profile update) - if (event.unsigned?.prev_content?.membership !== 'join') { - await this.sendWelcomeMessage(roomId, odUser); - } - } - }); - - // Set up message handler - this.client.on('room.message', async (roomId: string, event: any) => { - await this.handleMessage(roomId, event); - }); - - await this.client.start(); - this.logger.log(`Matrix Calendar Bot connected to ${this.homeserverUrl}`); - - const odUser = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${odUser}`); - - if (this.allowedRooms.length > 0) { - this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`); - } else { - this.logger.log('No room restrictions - bot will respond in all rooms'); - } - } catch (error) { - this.logger.error('Failed to initialize Matrix client:', error); - } - } - - private async handleMessage(roomId: string, event: any) { - // Ignore messages from the bot itself - const botUserId = await this.client.getUserId(); - if (event.sender === botUserId) return; - - // Check if room is allowed - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + // Check for natural language keywords + const keywordCommand = this.detectKeywordCommand(message); + if (keywordCommand) { + await this.executeCommand(roomId, event, sender, keywordCommand, ''); return; } - - // Only handle text messages - if (event.content?.msgtype !== 'm.text') return; - - const body = event.content.body?.trim(); - if (!body) return; - - const odUser = event.sender; - - try { - // Check for ! commands first (before keyword detection) - if (body.startsWith('!')) { - const [command, ...args] = body.slice(1).split(' '); - await this.executeCommand(roomId, event, odUser, command.toLowerCase(), args.join(' ')); - return; - } - - // Check for natural language keywords - const keywordCommand = this.detectKeywordCommand(body); - if (keywordCommand) { - await this.executeCommand(roomId, event, odUser, keywordCommand, ''); - return; - } - } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendReply( - roomId, - event, - '❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.' - ); - } } private detectKeywordCommand(message: string): string | null { @@ -199,7 +86,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { private async executeCommand( roomId: string, - event: any, + event: MatrixRoomEvent, userId: string, command: string, args: string @@ -268,7 +155,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleTodayEvents(roomId: string, event: any, userId: string) { + private async handleTodayEvents(roomId: string, event: MatrixRoomEvent, userId: string) { const events = await this.calendarService.getTodayEvents(userId); if (events.length === 0) { @@ -284,7 +171,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleTomorrowEvents(roomId: string, event: any, userId: string) { + private async handleTomorrowEvents(roomId: string, event: MatrixRoomEvent, userId: string) { const events = await this.calendarService.getTomorrowEvents(userId); if (events.length === 0) { @@ -300,7 +187,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleWeekEvents(roomId: string, event: any, userId: string) { + private async handleWeekEvents(roomId: string, event: MatrixRoomEvent, userId: string) { const events = await this.calendarService.getWeekEvents(userId); if (events.length === 0) { @@ -316,7 +203,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleUpcomingEvents(roomId: string, event: any, userId: string) { + private async handleUpcomingEvents(roomId: string, event: MatrixRoomEvent, userId: string) { const events = await this.calendarService.getUpcomingEvents(userId, 14); if (events.length === 0) { @@ -332,7 +219,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleCreateEvent(roomId: string, event: any, userId: string, input: string) { + private async handleCreateEvent(roomId: string, event: MatrixRoomEvent, userId: string, input: string) { if (!input.trim()) { await this.sendReply( roomId, @@ -372,7 +259,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, `✅ Termin erstellt: **${title}**\n📆 ${timeStr}`); } - private async handleEventDetails(roomId: string, event: any, userId: string, args: string) { + private async handleEventDetails(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { const eventNumber = parseInt(args.trim()); if (isNaN(eventNumber) || eventNumber < 1) { @@ -407,7 +294,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleDeleteEvent(roomId: string, event: any, userId: string, args: string) { + private async handleDeleteEvent(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { const eventNumber = parseInt(args.trim()); if (isNaN(eventNumber) || eventNumber < 1) { @@ -429,7 +316,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, `🗑️ Gelöscht: ${deletedEvent.title}`); } - private async handleCalendars(roomId: string, event: any, userId: string) { + private async handleCalendars(roomId: string, event: MatrixRoomEvent, userId: string) { const calendars = await this.calendarService.getCalendars(userId); let response = '📁 **Deine Kalender:**\n\n'; @@ -440,7 +327,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleStatus(roomId: string, event: any, userId: string) { + private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) { const events = await this.calendarService.getUpcomingEvents(userId, 7); const todayEvents = await this.calendarService.getTodayEvents(userId); @@ -454,18 +341,13 @@ Bot: ✅ Online`; await this.sendReply(roomId, event, response); } - private async handlePinHelp(roomId: string, event: any) { + private async handlePinHelp(roomId: string, event: MatrixRoomEvent) { try { // Send help message - const helpEventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(HELP_TEXT), - }); + const helpEventId = await this.sendMessage(roomId, HELP_TEXT); // Pin it - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + await this.getClient().sendStateEvent(roomId, 'm.room.pinned_events', '', { pinned: [helpEventId], }); @@ -493,58 +375,13 @@ Bot: ✅ Online`; return response; } - private async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); - } - - private async sendWelcomeMessage(roomId: string, odUser: string) { + // Public method to send welcome message to new users + async sendWelcomeMessage(roomId: string, userId: string) { try { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: WELCOME_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(WELCOME_TEXT), - }); - this.logger.log(`Sent welcome message to ${odUser} in ${roomId}`); + await this.sendMessage(roomId, WELCOME_TEXT); + this.logger.log(`Sent welcome message to ${userId} in ${roomId}`); } catch (error) { this.logger.error(`Failed to send welcome message: ${error}`); } } - - private async sendBotIntroduction(roomId: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: BOT_INTRODUCTION, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(BOT_INTRODUCTION), - }); - - // Try to pin the help message - try { - const helpEventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(HELP_TEXT), - }); - - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [helpEventId], - }); - this.logger.log(`Pinned help message in ${roomId}`); - } catch (error) { - this.logger.debug(`Could not pin help (might lack permissions): ${error}`); - } - } - - private markdownToHtml(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\n/g, '
'); - } } diff --git a/services/matrix-chat-bot/src/bot/matrix.service.ts b/services/matrix-chat-bot/src/bot/matrix.service.ts index 01cac0c31..6dfbcd9a1 100644 --- a/services/matrix-chat-bot/src/bot/matrix.service.ts +++ b/services/matrix-chat-bot/src/bot/matrix.service.ts @@ -1,26 +1,28 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import { ChatService, Model, Conversation, Message } from '../chat/chat.service'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; +import { ChatService, Conversation } from '../chat/chat.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE, BRANCH_ICONS } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { constructor( - private configService: ConfigService, + configService: ConfigService, private chatService: ChatService, private sessionService: SessionService - ) {} + ) { + super(configService); + } + + 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') || [], + }; + } // Session data helper methods (wrapping the generic setSessionData/getSessionData) private getCurrentConversation(sender: string): string | null { @@ -59,160 +61,124 @@ export class MatrixService implements OnModuleInit { return ids[number - 1]; } - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { + if (!message.startsWith('!')) return; - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Chat Bot started'); - } - - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; - - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; - - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } - - const sender = event.sender; - const [command, ...args] = body.slice(1).split(/\s+/); + const [command, ...args] = message.slice(1).split(/\s+/); const argString = args.join(' '); - try { - let response: string; + let response: string; - switch (command.toLowerCase()) { - case 'help': - case 'hilfe': - response = HELP_MESSAGE; - break; + switch (command.toLowerCase()) { + case 'help': + case 'hilfe': + response = HELP_MESSAGE; + break; - case 'login': - response = await this.handleLogin(sender, args); - break; + case 'login': + response = await this.handleLogin(sender, args); + break; - case 'logout': - response = this.handleLogout(sender); - break; + case 'logout': + response = this.handleLogout(sender); + break; - case 'status': - response = this.handleStatus(sender); - break; + case 'status': + response = this.handleStatus(sender); + break; - case 'chat': - case 'fragen': - case 'ask': - response = await this.handleQuickChat(sender, argString); - break; + case 'chat': + case 'fragen': + case 'ask': + response = await this.handleQuickChat(sender, argString); + break; - case 'neu': - case 'new': - response = await this.handleNewConversation(sender, argString); - break; + case 'neu': + case 'new': + response = await this.handleNewConversation(sender, argString); + break; - case 'gespraeche': - case 'gespräche': - case 'conversations': - case 'liste': - response = await this.handleListConversations(sender); - break; + case 'gespraeche': + case 'gespräche': + case 'conversations': + case 'liste': + response = await this.handleListConversations(sender); + break; - case 'gespraech': - case 'gespräch': - case 'conversation': - case 'select': - response = await this.handleSelectConversation(sender, args[0]); - break; + case 'gespraech': + case 'gespräch': + case 'conversation': + case 'select': + response = await this.handleSelectConversation(sender, args[0]); + break; - case 'senden': - case 'send': - case 's': - response = await this.handleSendMessage(sender, argString); - break; + case 'senden': + case 'send': + case 's': + response = await this.handleSendMessage(sender, argString); + break; - case 'verlauf': - case 'history': - case 'nachrichten': - response = await this.handleShowHistory(sender, args[0]); - break; + case 'verlauf': + case 'history': + case 'nachrichten': + response = await this.handleShowHistory(sender, args[0]); + break; - case 'titel': - case 'title': - response = await this.handleUpdateTitle(sender, args[0], args.slice(1).join(' ')); - break; + case 'titel': + case 'title': + response = await this.handleUpdateTitle(sender, args[0], args.slice(1).join(' ')); + break; - case 'archiv': - case 'archive': - response = await this.handleArchive(sender, args[0]); - break; + case 'archiv': + case 'archive': + response = await this.handleArchive(sender, args[0]); + break; - case 'archiviert': - case 'archived': - response = await this.handleListArchived(sender); - break; + case 'archiviert': + case 'archived': + response = await this.handleListArchived(sender); + break; - case 'wiederherstellen': - case 'restore': - case 'unarchive': - response = await this.handleUnarchive(sender, args[0]); - break; + case 'wiederherstellen': + case 'restore': + case 'unarchive': + response = await this.handleUnarchive(sender, args[0]); + break; - case 'pin': - response = await this.handlePin(sender, args[0]); - break; + case 'pin': + response = await this.handlePin(sender, args[0]); + break; - case 'unpin': - response = await this.handleUnpin(sender, args[0]); - break; + case 'unpin': + response = await this.handleUnpin(sender, args[0]); + break; - case 'loeschen': - case 'löschen': - case 'delete': - response = await this.handleDelete(sender, args[0]); - break; + case 'loeschen': + case 'löschen': + case 'delete': + response = await this.handleDelete(sender, args[0]); + break; - case 'modelle': - case 'models': - response = await this.handleListModels(sender); - break; + case 'modelle': + case 'models': + response = await this.handleListModels(sender); + break; - case 'modell': - case 'model': - response = await this.handleSelectModel(sender, args[0]); - break; + case 'modell': + case 'model': + response = await this.handleSelectModel(sender, args[0]); + break; - default: - response = `Unbekannter Befehl: ${command}\nNutze \`!help\` fuer eine Uebersicht.`; - } - - await this.sendReply(roomId, event, response); - } catch (error) { - this.logger.error(`Error handling command ${command}:`, error); - await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.'); + default: + response = `Unbekannter Befehl: ${command}\nNutze \`!help\` fuer eine Uebersicht.`; } - } - private async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, message); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); + await this.sendReply(roomId, event, response); } // Auth handlers diff --git a/services/matrix-clock-bot/src/bot/matrix.service.ts b/services/matrix-clock-bot/src/bot/matrix.service.ts index 08b5fb9dc..ee13eb6e8 100644 --- a/services/matrix-clock-bot/src/bot/matrix.service.ts +++ b/services/matrix-clock-bot/src/bot/matrix.service.ts @@ -1,14 +1,7 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import * as path from 'path'; -import * as fs from 'fs'; -import { ClockService, Timer, Alarm } from '../clock/clock.service'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; +import { ClockService } from '../clock/clock.service'; import { TranscriptionService } from '@manacore/bot-services'; import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration'; @@ -22,124 +15,125 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private readonly homeserverUrl: string; - private readonly accessToken: string; - private readonly allowedRooms: string[]; - private readonly storagePath: string; - private botUserId: string = ''; - +export class MatrixService extends BaseMatrixService { // Demo token for development (TODO: implement proper auth) private readonly demoToken = process.env.CLOCK_API_TOKEN || ''; constructor( - private configService: ConfigService, + configService: ConfigService, private clockService: ClockService, private transcriptionService: TranscriptionService ) { - this.homeserverUrl = this.configService.get( - 'matrix.homeserverUrl', - 'http://localhost:8008' - ); - this.accessToken = this.configService.get('matrix.accessToken', ''); - this.allowedRooms = this.configService.get('matrix.allowedRooms', []); - this.storagePath = this.configService.get( - 'matrix.storagePath', - './data/bot-storage.json' - ); + super(configService); } - async onModuleInit() { - if (!this.accessToken) { - this.logger.warn('No Matrix access token configured. Bot will not start.'); + 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 | null { + return WELCOME_TEXT; + } + + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { + // Check keywords first + const keywordCommand = this.detectKeywordCommand(message); + if (keywordCommand) { + await this.executeCommand(roomId, event, sender, keywordCommand, ''); return; } - await this.initializeClient(); - } - - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); + // Handle ! commands + if (message.startsWith('!')) { + const [command, ...args] = message.slice(1).split(' '); + await this.executeCommand(roomId, event, sender, command.toLowerCase(), args.join(' ')); + return; } + + // Try to parse as natural timer/alarm command + await this.handleNaturalLanguage(roomId, event, sender, message); } - private async initializeClient() { + protected async handleAudioMessage( + roomId: string, + event: MatrixRoomEvent, + sender: string + ): Promise { try { - const storageDir = path.dirname(this.storagePath); - if (!fs.existsSync(storageDir)) { - fs.mkdirSync(storageDir, { recursive: true }); - } + await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); - const storage = new SimpleFsStorageProvider(this.storagePath); - this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - await this.sendWelcome(roomId); - }, 2000); - }); - - this.client.on('room.message', async (roomId: string, event: any) => { - await this.handleMessage(roomId, event); - }); - - await this.client.start(); - this.botUserId = await this.client.getUserId(); - this.logger.log(`Matrix Clock Bot connected as ${this.botUserId}`); - } catch (error) { - this.logger.error('Failed to initialize Matrix client:', error); - } - } - - private async handleMessage(roomId: string, event: any) { - if (event.sender === this.botUserId) return; - - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } - - const userId = event.sender; - const msgtype = event.content?.msgtype; - - // Handle audio messages - if (msgtype === 'm.audio' && event.content?.url) { - await this.handleAudioMessage(roomId, event, userId); - return; - } - - if (msgtype !== 'm.text') return; - - const body = event.content.body?.trim(); - if (!body) return; - - try { - // Check keywords first - const keywordCommand = this.detectKeywordCommand(body); - if (keywordCommand) { - await this.executeCommand(roomId, event, userId, keywordCommand, ''); + const mxcUrl = event.content.url; + if (!mxcUrl) { + await this.sendReply(roomId, event, 'Keine Audio-URL gefunden.'); return; } - // Handle ! commands - if (body.startsWith('!')) { - const [command, ...args] = body.slice(1).split(' '); - await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); + const buffer = await this.downloadMedia(mxcUrl); + const transcription = await this.transcriptionService.transcribe(buffer); + + if (!transcription.trim()) { + await this.sendReply(roomId, event, 'Konnte keine Sprache erkennen.'); return; } - // Try to parse as natural timer/alarm command - await this.handleNaturalLanguage(roomId, event, userId, body); + this.logger.log(`Transcription: ${transcription}`); + + // Try to parse as command + const lower = transcription.toLowerCase(); + + // Check for timer + const duration = this.clockService.parseDuration(transcription); + if ( + duration && + (lower.includes('timer') || + lower.includes('minute') || + lower.includes('stunde') || + lower.match(/\d+\s*(m|min|h)/)) + ) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleTimerCommand(roomId, event, sender, transcription); + return; + } + + // Check for alarm + const time = this.clockService.parseAlarmTime(transcription); + if (time && (lower.includes('wecker') || lower.includes('alarm') || lower.includes('uhr'))) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleAlarmCommand(roomId, event, sender, transcription); + return; + } + + // Check for stop/status commands + if (lower.includes('stop') || lower.includes('stopp') || lower.includes('pause')) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleStopCommand(roomId, event, sender); + return; + } + + if (lower.includes('status') || lower.includes('wie viel')) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleStatusCommand(roomId, event, sender); + return; + } + + await this.sendReply( + roomId, + event, + `"${transcription}"\n\nKonnte Befehl nicht verstehen. Versuche "Timer 25 Minuten" oder "Wecker 7 Uhr".` + ); } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten.'); + this.logger.error('Audio processing failed:', error); + await this.sendReply(roomId, event, 'Fehler bei der Sprachverarbeitung.'); } } @@ -159,7 +153,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { private async executeCommand( roomId: string, - event: any, + event: MatrixRoomEvent, userId: string, command: string, args: string @@ -226,7 +220,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleTimerCommand(roomId: string, event: any, userId: string, args: string) { + private async handleTimerCommand(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { if (!args.trim()) { await this.sendReply( roomId, @@ -254,7 +248,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { // Create and start timer const timer = await this.clockService.createTimer(durationSeconds, label, token); - const startedTimer = await this.clockService.startTimer(timer.id, token); + await this.clockService.startTimer(timer.id, token); const durationStr = this.clockService.formatDuration(durationSeconds); let response = `**Timer gestartet!**\n\nDauer: ${durationStr}`; @@ -268,7 +262,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleStopCommand(roomId: string, event: any, userId: string) { + private async handleStopCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -305,7 +299,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleResumeCommand(roomId: string, event: any, userId: string) { + private async handleResumeCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -329,7 +323,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleResetCommand(roomId: string, event: any, userId: string) { + private async handleResetCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -351,7 +345,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleStatusCommand(roomId: string, event: any, userId: string) { + private async handleStatusCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -381,7 +375,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleTimersCommand(roomId: string, event: any, userId: string) { + private async handleTimersCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -410,7 +404,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleAlarmCommand(roomId: string, event: any, userId: string, args: string) { + private async handleAlarmCommand(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { const parts = args.trim().split(' '); // Handle !alarm off/on/delete commands @@ -440,7 +434,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { return; } - const alarm = await this.clockService.createAlarm(time, label, token); + await this.clockService.createAlarm(time, label, token); let response = `**Alarm gestellt!**\n\nZeit: ${time.substring(0, 5)} Uhr`; if (label) response += `\nLabel: ${label}`; @@ -451,7 +445,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleAlarmsCommand(roomId: string, event: any, userId: string) { + private async handleAlarmsCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -480,7 +474,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleTimeCommand(roomId: string, event: any, userId: string) { + private async handleTimeCommand(roomId: string, event: MatrixRoomEvent, userId: string) { const now = new Date(); const timeStr = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const dateStr = now.toLocaleDateString('de-DE', { @@ -514,7 +508,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply(roomId, event, response); } - private async handleWorldClockCommand(roomId: string, event: any, userId: string, args: string) { + private async handleWorldClockCommand(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { if (!args.trim()) { await this.sendReply( roomId, @@ -546,7 +540,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleWorldClocksCommand(roomId: string, event: any, userId: string) { + private async handleWorldClocksCommand(roomId: string, event: MatrixRoomEvent, userId: string) { try { const token = this.getToken(userId); if (!token) { @@ -581,7 +575,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleNaturalLanguage(roomId: string, event: any, userId: string, text: string) { + private async handleNaturalLanguage(roomId: string, event: MatrixRoomEvent, userId: string, text: string) { const lower = text.toLowerCase(); // Try to detect timer intent @@ -616,77 +610,6 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { // No match - don't respond to random messages } - private async handleAudioMessage(roomId: string, event: any, userId: string) { - try { - await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); - - const mxcUrl = event.content.url; - const httpUrl = this.client.mxcToHttp(mxcUrl); - - const response = await fetch(httpUrl); - if (!response.ok) { - throw new Error(`Failed to download audio: ${response.status}`); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - const transcription = await this.transcriptionService.transcribe(buffer); - - if (!transcription.trim()) { - await this.sendReply(roomId, event, 'Konnte keine Sprache erkennen.'); - return; - } - - this.logger.log(`Transcription: ${transcription}`); - - // Try to parse as command - const lower = transcription.toLowerCase(); - - // Check for timer - const duration = this.clockService.parseDuration(transcription); - if ( - duration && - (lower.includes('timer') || - lower.includes('minute') || - lower.includes('stunde') || - lower.match(/\d+\s*(m|min|h)/)) - ) { - await this.sendReply(roomId, event, `"${transcription}"`); - await this.handleTimerCommand(roomId, event, userId, transcription); - return; - } - - // Check for alarm - const time = this.clockService.parseAlarmTime(transcription); - if (time && (lower.includes('wecker') || lower.includes('alarm') || lower.includes('uhr'))) { - await this.sendReply(roomId, event, `"${transcription}"`); - await this.handleAlarmCommand(roomId, event, userId, transcription); - return; - } - - // Check for stop/status commands - if (lower.includes('stop') || lower.includes('stopp') || lower.includes('pause')) { - await this.sendReply(roomId, event, `"${transcription}"`); - await this.handleStopCommand(roomId, event, userId); - return; - } - - if (lower.includes('status') || lower.includes('wie viel')) { - await this.sendReply(roomId, event, `"${transcription}"`); - await this.handleStatusCommand(roomId, event, userId); - return; - } - - await this.sendReply( - roomId, - event, - `"${transcription}"\n\nKonnte Befehl nicht verstehen. Versuche "Timer 25 Minuten" oder "Wecker 7 Uhr".` - ); - } catch (error) { - this.logger.error('Audio processing failed:', error); - await this.sendReply(roomId, event, 'Fehler bei der Sprachverarbeitung.'); - } - } - private getToken(userId: string): string | null { // First check if user has a stored token const storedToken = this.clockService.getUserToken(userId); @@ -695,31 +618,4 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { // Fall back to demo token for development return this.demoToken || null; } - - private async sendWelcome(roomId: string) { - try { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: WELCOME_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(WELCOME_TEXT), - }); - } catch (error) { - this.logger.error('Failed to send welcome:', error); - } - } - - private async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); - } - - private markdownToHtml(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\n/g, '
'); - } } diff --git a/services/matrix-contacts-bot/src/bot/matrix.service.ts b/services/matrix-contacts-bot/src/bot/matrix.service.ts index fff7dbcfd..86e625290 100644 --- a/services/matrix-contacts-bot/src/bot/matrix.service.ts +++ b/services/matrix-contacts-bot/src/bot/matrix.service.ts @@ -1,12 +1,6 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - RichConsoleLogger, - LogService, - LogLevel, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { ContactsService, Contact } from '../contacts/contacts.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @@ -21,70 +15,29 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private readonly allowedRooms: string[]; - private botUserId: string = ''; - +export class MatrixService extends BaseMatrixService { // Store last shown contacts per user for reference by number private lastContactsList: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private contactsService: ContactsService, private sessionService: SessionService ) { - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + super(configService); } - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - - if (!accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } - - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.INFO); - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl!, accessToken, storage); - - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - this.client.on('room.message', this.handleRoomMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Contacts Bot started successfully'); + 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') || [], + }; } - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix bot stopped'); - } - } - - private async sendBotIntroduction(roomId: string) { - const introText = `**Contacts Bot - Kontaktverwaltung** + protected getIntroductionMessage(): string | null { + return `**Contacts Bot - Kontaktverwaltung** Ich helfe dir, deine Kontakte zu verwalten! @@ -94,40 +47,22 @@ Ich helfe dir, deine Kontakte zu verwalten! \`!neu Vorname Nachname\` - Neuen Kontakt Sag "hilfe" fur alle Befehle!`; - - await this.sendMessage(roomId, introText); } - private isRoomAllowed(roomId: string): boolean { - if (this.allowedRooms.length === 0) return true; - return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); - } - - private async handleRoomMessage(roomId: string, event: any) { - if (event.sender === this.botUserId) return; - - if (!this.isRoomAllowed(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { + if (message.startsWith('!')) { + await this.handleCommand(roomId, event, sender, message); return; } - const content = event.content as { msgtype?: string; body?: string }; - - if (content.msgtype !== 'm.text') return; - - const body = content.body; - if (!body) return; - - this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); - - if (body.startsWith('!')) { - await this.handleCommand(roomId, event.sender, body); - return; - } - - const keywordCommand = this.detectKeywordCommand(body); + const keywordCommand = this.detectKeywordCommand(message); if (keywordCommand) { - await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + await this.handleCommand(roomId, event, sender, `!${keywordCommand}`); return; } } @@ -147,7 +82,7 @@ Sag "hilfe" fur alle Befehle!`; return null; } - private async handleCommand(roomId: string, sender: string, body: string) { + private async handleCommand(roomId: string, event: MatrixRoomEvent, sender: string, body: string) { const [command, ...args] = body.slice(1).split(' '); const argString = args.join(' '); @@ -155,89 +90,90 @@ Sag "hilfe" fur alle Befehle!`; case 'help': case 'hilfe': case 'start': - await this.sendHelp(roomId); + await this.sendReply(roomId, event, HELP_MESSAGE); break; case 'kontakte': case 'contacts': case 'liste': case 'list': - await this.handleListContacts(roomId, sender); + await this.handleListContacts(roomId, event, sender); break; case 'suche': case 'search': - await this.handleSearch(roomId, sender, argString); + await this.handleSearch(roomId, event, sender, argString); break; case 'favoriten': case 'favorites': case 'favs': - await this.handleFavorites(roomId, sender); + await this.handleFavorites(roomId, event, sender); break; case 'kontakt': case 'contact': case 'details': - await this.handleContactDetails(roomId, sender, args); + await this.handleContactDetails(roomId, event, sender, args); break; case 'neu': case 'new': case 'add': - await this.handleCreateContact(roomId, sender, args); + await this.handleCreateContact(roomId, event, sender, args); break; case 'edit': case 'bearbeiten': - await this.handleEditContact(roomId, sender, args); + await this.handleEditContact(roomId, event, sender, args); break; case 'loeschen': case 'delete': case 'del': - await this.handleDeleteContact(roomId, sender, args); + await this.handleDeleteContact(roomId, event, sender, args); break; case 'fav': case 'favorit': - await this.handleToggleFavorite(roomId, sender, args); + await this.handleToggleFavorite(roomId, event, sender, args); break; case 'archiv': case 'archive': - await this.handleToggleArchive(roomId, sender, args); + await this.handleToggleArchive(roomId, event, sender, args); break; case 'login': - await this.handleLogin(roomId, sender, args); + await this.handleLogin(roomId, event, sender, args); break; case 'logout': this.sessionService.logout(sender); - await this.sendMessage(roomId, 'Du wurdest abgemeldet.'); + await this.sendReply(roomId, event, 'Du wurdest abgemeldet.'); break; case 'status': - await this.handleStatus(roomId, sender); + await this.handleStatus(roomId, event, sender); break; case 'pin': - await this.pinHelpMessage(roomId); + await this.pinHelpMessage(roomId, event); break; default: - await this.sendMessage( + await this.sendReply( roomId, + event, `Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.` ); } } - private async handleListContacts(roomId: string, sender: string) { + private async handleListContacts(roomId: string, event: MatrixRoomEvent, sender: string) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } @@ -246,8 +182,9 @@ Sag "hilfe" fur alle Befehle!`; const contacts = result.contacts; if (contacts.length === 0) { - await this.sendMessage( + await this.sendReply( roomId, + event, `Du hast noch keine Kontakte.\n\nNutze \`!neu Vorname Nachname\` um einen zu erstellen.` ); return; @@ -271,22 +208,22 @@ Sag "hilfe" fur alle Befehle!`; text += `\n\nNutze \`!kontakt [nr]\` fur Details.`; - await this.sendMessage(roomId, text); + await this.sendReply(roomId, event, text); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleSearch(roomId: string, sender: string, searchTerm: string) { + private async handleSearch(roomId: string, event: MatrixRoomEvent, sender: string, searchTerm: string) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (!searchTerm.trim()) { - await this.sendMessage(roomId, `**Verwendung:** \`!suche [text]\`\n\nBeispiel: \`!suche Max\``); + await this.sendReply(roomId, event, `**Verwendung:** \`!suche [text]\`\n\nBeispiel: \`!suche Max\``); return; } @@ -295,7 +232,7 @@ Sag "hilfe" fur alle Befehle!`; const contacts = result.contacts; if (contacts.length === 0) { - await this.sendMessage(roomId, `Keine Kontakte gefunden fur: "${searchTerm}"`); + await this.sendReply(roomId, event, `Keine Kontakte gefunden fur: "${searchTerm}"`); return; } @@ -310,17 +247,17 @@ Sag "hilfe" fur alle Befehle!`; text += `**${i + 1}.** ${name}${favIcon}${email}\n`; } - await this.sendMessage(roomId, text); + await this.sendReply(roomId, event, text); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleFavorites(roomId: string, sender: string) { + private async handleFavorites(roomId: string, event: MatrixRoomEvent, sender: string) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } @@ -329,8 +266,9 @@ Sag "hilfe" fur alle Befehle!`; const contacts = result.contacts; if (contacts.length === 0) { - await this.sendMessage( + await this.sendReply( roomId, + event, `Du hast noch keine Favoriten.\n\nNutze \`!fav [nr]\` um einen Kontakt als Favorit zu markieren.` ); return; @@ -346,34 +284,34 @@ Sag "hilfe" fur alle Befehle!`; text += `**${i + 1}.** ★ ${name}${phone ? ` - ${phone}` : ''}\n`; } - await this.sendMessage(roomId, text); + await this.sendReply(roomId, event, text); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleContactDetails(roomId: string, sender: string, args: string[]) { + private async handleContactDetails(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (args.length < 1) { - await this.sendMessage(roomId, `**Verwendung:** \`!kontakt [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + await this.sendReply(roomId, event, `**Verwendung:** \`!kontakt [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); return; } const index = parseInt(args[0], 10); if (isNaN(index) || index < 1) { - await this.sendMessage(roomId, `Ungultige Nummer.`); + await this.sendReply(roomId, event, `Ungultige Nummer.`); return; } const contacts = this.lastContactsList.get(sender); if (!contacts || index > contacts.length) { - await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); return; } @@ -406,23 +344,24 @@ Sag "hilfe" fur alle Befehle!`; if (details.birthday) text += `**Geburtstag:** ${new Date(details.birthday).toLocaleDateString('de-DE')}\n`; if (details.notes) text += `\n**Notizen:** ${details.notes}\n`; - await this.sendMessage(roomId, text); + await this.sendReply(roomId, event, text); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleCreateContact(roomId: string, sender: string, args: string[]) { + private async handleCreateContact(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (args.length < 1) { - await this.sendMessage( + await this.sendReply( roomId, + event, `**Verwendung:** \`!neu Vorname [Nachname]\`\n\nBeispiel: \`!neu Max Mustermann\`` ); return; @@ -438,26 +377,28 @@ Sag "hilfe" fur alle Befehle!`; }); const name = contact.displayName || `${firstName} ${lastName || ''}`.trim(); - await this.sendMessage( + await this.sendReply( roomId, + event, `Kontakt **${name}** erstellt!\n\nNutze \`!kontakte\` um die Liste zu sehen oder \`!edit\` um weitere Daten hinzuzufugen.` ); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleEditContact(roomId: string, sender: string, args: string[]) { + private async handleEditContact(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (args.length < 3) { - await this.sendMessage( + await this.sendReply( roomId, + event, `**Verwendung:** \`!edit [nr] [feld] [wert]\`\n\n**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday\n\n**Beispiel:** \`!edit 1 email max@example.com\`` ); return; @@ -468,13 +409,13 @@ Sag "hilfe" fur alle Befehle!`; const value = args.slice(2).join(' '); if (isNaN(index) || index < 1) { - await this.sendMessage(roomId, `Ungultige Nummer.`); + await this.sendReply(roomId, event, `Ungultige Nummer.`); return; } const contacts = this.lastContactsList.get(sender); if (!contacts || index > contacts.length) { - await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); return; } @@ -514,7 +455,7 @@ Sag "hilfe" fur alle Befehle!`; const mappedField = fieldMap[field]; if (!mappedField) { - await this.sendMessage(roomId, `Unbekanntes Feld: ${field}\n\n**Gultige Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`); + await this.sendReply(roomId, event, `Unbekanntes Feld: ${field}\n\n**Gultige Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`); return; } @@ -524,34 +465,34 @@ Sag "hilfe" fur alle Befehle!`; }); const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); - await this.sendMessage(roomId, `Kontakt **${name}** aktualisiert!\n\n**${field}:** ${value}`); + await this.sendReply(roomId, event, `Kontakt **${name}** aktualisiert!\n\n**${field}:** ${value}`); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleDeleteContact(roomId: string, sender: string, args: string[]) { + private async handleDeleteContact(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (args.length < 1) { - await this.sendMessage(roomId, `**Verwendung:** \`!loeschen [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + await this.sendReply(roomId, event, `**Verwendung:** \`!loeschen [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); return; } const index = parseInt(args[0], 10); if (isNaN(index) || index < 1) { - await this.sendMessage(roomId, `Ungultige Nummer.`); + await this.sendReply(roomId, event, `Ungultige Nummer.`); return; } const contacts = this.lastContactsList.get(sender); if (!contacts || index > contacts.length) { - await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); return; } @@ -560,34 +501,34 @@ Sag "hilfe" fur alle Befehle!`; try { await this.contactsService.deleteContact(token, contact.id); - await this.sendMessage(roomId, `Kontakt **${name}** geloscht.`); + await this.sendReply(roomId, event, `Kontakt **${name}** geloscht.`); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleToggleFavorite(roomId: string, sender: string, args: string[]) { + private async handleToggleFavorite(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (args.length < 1) { - await this.sendMessage(roomId, `**Verwendung:** \`!fav [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + await this.sendReply(roomId, event, `**Verwendung:** \`!fav [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); return; } const index = parseInt(args[0], 10); if (isNaN(index) || index < 1) { - await this.sendMessage(roomId, `Ungultige Nummer.`); + await this.sendReply(roomId, event, `Ungultige Nummer.`); return; } const contacts = this.lastContactsList.get(sender); if (!contacts || index > contacts.length) { - await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); return; } @@ -597,34 +538,34 @@ Sag "hilfe" fur alle Befehle!`; const updated = await this.contactsService.toggleFavorite(token, contact.id); const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); const status = updated.isFavorite ? 'als Favorit markiert ★' : 'aus Favoriten entfernt'; - await this.sendMessage(roomId, `**${name}** ${status}`); + await this.sendReply(roomId, event, `**${name}** ${status}`); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async handleToggleArchive(roomId: string, sender: string, args: string[]) { + private async handleToggleArchive(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { const token = this.sessionService.getToken(sender); if (!token) { - await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + await this.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); return; } if (args.length < 1) { - await this.sendMessage(roomId, `**Verwendung:** \`!archiv [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + await this.sendReply(roomId, event, `**Verwendung:** \`!archiv [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); return; } const index = parseInt(args[0], 10); if (isNaN(index) || index < 1) { - await this.sendMessage(roomId, `Ungultige Nummer.`); + await this.sendReply(roomId, event, `Ungultige Nummer.`); return; } const contacts = this.lastContactsList.get(sender); if (!contacts || index > contacts.length) { - await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); return; } @@ -634,21 +575,18 @@ Sag "hilfe" fur alle Befehle!`; const updated = await this.contactsService.toggleArchive(token, contact.id); const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); const status = updated.isArchived ? 'archiviert' : 'aus dem Archiv geholt'; - await this.sendMessage(roomId, `**${name}** ${status}`); + await this.sendReply(roomId, event, `**${name}** ${status}`); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); } } - private async sendHelp(roomId: string) { - await this.sendMessage(roomId, HELP_MESSAGE); - } - - private async handleLogin(roomId: string, sender: string, args: string[]) { + private async handleLogin(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) { if (args.length < 2) { - await this.sendMessage( + await this.sendReply( roomId, + event, `**Verwendung:** \`!login email passwort\`\n\nBeispiel: \`!login nutzer@example.com meinpasswort\`` ); return; @@ -656,21 +594,22 @@ Sag "hilfe" fur alle Befehle!`; const [email, password] = args; - await this.sendMessage(roomId, 'Anmeldung lauft...'); + await this.sendReply(roomId, event, 'Anmeldung lauft...'); const result = await this.sessionService.login(sender, email, password); if (result.success) { - await this.sendMessage( + await this.sendReply( roomId, + event, `Erfolgreich angemeldet!\n\nNutze \`!kontakte\` um deine Kontakte zu sehen.` ); } else { - await this.sendMessage(roomId, `Anmeldung fehlgeschlagen: ${result.error}`); + await this.sendReply(roomId, event, `Anmeldung fehlgeschlagen: ${result.error}`); } } - private async handleStatus(roomId: string, sender: string) { + private async handleStatus(roomId: string, event: MatrixRoomEvent, sender: string) { const backendHealthy = await this.contactsService.checkHealth(); const isLoggedIn = this.sessionService.isLoggedIn(sender); const sessionCount = this.sessionService.getSessionCount(); @@ -683,51 +622,21 @@ Sag "hilfe" fur alle Befehle!`; ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; - await this.sendMessage(roomId, statusText); + await this.sendReply(roomId, event, statusText); } - private async pinHelpMessage(roomId: string) { + private async pinHelpMessage(roomId: string, event: MatrixRoomEvent) { try { - const htmlBody = this.markdownToHtml(HELP_MESSAGE); + const eventId = await this.sendMessage(roomId, HELP_MESSAGE); - const eventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_MESSAGE, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + await this.getClient().sendStateEvent(roomId, 'm.room.pinned_events', '', { pinned: [eventId], }); this.logger.log(`Pinned help message in room ${roomId}`); } catch (error) { this.logger.error(`Failed to pin help message:`, error); - await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.'); + await this.sendReply(roomId, event, 'Fehler beim Pinnen der Hilfe.'); } } - - private async sendMessage(roomId: string, message: string) { - const htmlBody = this.markdownToHtml(message); - - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - } - - private markdownToHtml(markdown: string): string { - return ( - markdown - .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/_([^_]+)_/g, '$1') - .replace(/\n/g, '
') - ); - } } diff --git a/services/matrix-mana-bot/src/bot/matrix.service.ts b/services/matrix-mana-bot/src/bot/matrix.service.ts index 6a741fa51..65563d155 100644 --- a/services/matrix-mana-bot/src/bot/matrix.service.ts +++ b/services/matrix-mana-bot/src/bot/matrix.service.ts @@ -1,132 +1,77 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject, forwardRef } from '@nestjs/common'; +import { Injectable, Inject, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import * as path from 'path'; -import * as fs from 'fs'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { CommandRouterService, CommandContext } from './command-router.service'; import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private botUserId: string = ''; - private readonly homeserverUrl: string; - private readonly accessToken: string; - private readonly allowedRooms: string[]; - private readonly storagePath: string; - +export class MatrixService extends BaseMatrixService { constructor( - private configService: ConfigService, + configService: ConfigService, @Inject(forwardRef(() => CommandRouterService)) private commandRouter: CommandRouterService ) { - this.homeserverUrl = this.configService.get('matrix.homeserverUrl', 'http://localhost:8008'); - this.accessToken = this.configService.get('matrix.accessToken', ''); - this.allowedRooms = this.configService.get('matrix.allowedRooms', []); - this.storagePath = this.configService.get('matrix.storagePath', './data/mana-bot-storage.json'); + super(configService); + } + + 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/mana-bot-storage.json', + allowedRooms: this.configService.get('matrix.allowedRooms') || [], + }; } async onModuleInit() { - if (!this.accessToken) { - this.logger.warn('No Matrix access token configured. Bot will not start.'); - return; - } + await super.onModuleInit(); - await this.initializeClient(); - } + if (!this.client) return; - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix client stopped'); - } - } + // Handle room invites with introduction + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); - private async initializeClient() { - try { - // Ensure storage directory exists - const storageDir = path.dirname(this.storagePath); - if (!fs.existsSync(storageDir)) { - fs.mkdirSync(storageDir, { recursive: true }); - } - - const storage = new SimpleFsStorageProvider(this.storagePath); - this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); - - // Auto-join rooms when invited - AutojoinRoomsMixin.setupOnClient(this.client); - - // Handle room invites with introduction - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Handle member joins for welcome message - this.client.on('room.event', async (roomId: string, event: any) => { - if (event.type === 'm.room.member' && event.content?.membership === 'join') { - const userId = event.state_key; - if (userId === this.botUserId) return; - if (event.unsigned?.prev_content?.membership !== 'join') { - await this.sendWelcomeMessage(roomId, userId); - } + setTimeout(async () => { + try { + await this.sendBotIntroduction(roomId); + } catch (error) { + this.logger.error(`Failed to send introduction to ${roomId}:`, error); } - }); + }, 2000); + }); - // Set up message handler - this.client.on('room.message', async (roomId: string, event: any) => { - await this.handleMessage(roomId, event); - }); - - await this.client.start(); - this.botUserId = await this.client.getUserId(); - - this.logger.log(`Mana Gateway Bot connected to ${this.homeserverUrl}`); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - if (this.allowedRooms.length > 0) { - this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`); - } else { - this.logger.log('No room restrictions - bot will respond in all rooms'); + // Handle member joins for welcome message + this.client.on('room.event', async (roomId: string, event: any) => { + if (event.type === 'm.room.member' && event.content?.membership === 'join') { + const userId = event.state_key; + if (userId === this.botUserId) return; + if (event.unsigned?.prev_content?.membership !== 'join') { + await this.sendWelcomeMessage(roomId, userId); + } } - } catch (error) { - this.logger.error('Failed to initialize Matrix client:', error); - } + }); + + this.botUserId = await this.client.getUserId(); + this.logger.log(`Mana Gateway Bot connected`); + this.logger.log(`Bot user ID: ${this.botUserId}`); } - private async handleMessage(roomId: string, event: any) { - // Ignore messages from the bot itself - if (event.sender === this.botUserId) return; - - // Check if room is allowed - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } - - const msgtype = event.content?.msgtype; - const body = event.content?.body?.trim(); - - // Only handle text messages for now - if (msgtype !== 'm.text' || !body) return; - + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { const ctx: CommandContext = { roomId, - userId: event.sender, - message: body, + userId: sender, + message, event, }; @@ -154,21 +99,6 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); - } - - async sendMessage(roomId: string, message: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(message), - }); - } - private async sendWelcomeMessage(roomId: string, userId: string) { try { await this.sendMessage(roomId, WELCOME_TEXT); @@ -187,7 +117,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { msgtype: 'm.text', body: HELP_TEXT, format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(HELP_TEXT), + formatted_body: this.markdownToHtmlPublic(HELP_TEXT), }); await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { @@ -199,7 +129,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private markdownToHtml(text: string): string { + private markdownToHtmlPublic(text: string): string { return text .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') .replace(/`([^`]+)`/g, '$1') @@ -209,7 +139,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { .replace(/\n/g, '
'); } - getClient(): MatrixClient { + getClient() { return this.client; } } diff --git a/services/matrix-manadeck-bot/src/bot/matrix.service.ts b/services/matrix-manadeck-bot/src/bot/matrix.service.ts index ab7447fd4..7e272a826 100644 --- a/services/matrix-manadeck-bot/src/bot/matrix.service.ts +++ b/services/matrix-manadeck-bot/src/bot/matrix.service.ts @@ -1,66 +1,46 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, -} from 'matrix-bot-sdk'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { ManadeckService, Deck, Card } from '../manadeck/manadeck.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { // Store last shown decks/cards per user for reference by number private lastDecksList: Map = new Map(); private lastCardsList: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private manadeckService: ManadeckService, private sessionService: SessionService - ) {} - - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; - - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix ManaDeck Bot started'); + ) { + super(configService); } - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; + 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') || [], + }; + } - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; + protected async handleTextMessage( + roomId: string, + _event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { + if (!message.startsWith('!')) return; - // Check allowed rooms - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } - - const sender = event.sender; - const parts = body.slice(1).split(/\s+/); + const parts = message.slice(1).split(/\s+/); const command = parts[0].toLowerCase(); const args = parts.slice(1); const argString = args.join(' '); @@ -161,7 +141,7 @@ export class MatrixService implements OnModuleInit { } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); - await this.sendHtml(roomId, `

Fehler: ${error.message}

`); + await this.sendHtml(roomId, `

Fehler: ${(error as Error).message}

`); } } diff --git a/services/matrix-nutriphi-bot/src/bot/matrix.service.ts b/services/matrix-nutriphi-bot/src/bot/matrix.service.ts index 4563396a7..0c8c3e2fc 100644 --- a/services/matrix-nutriphi-bot/src/bot/matrix.service.ts +++ b/services/matrix-nutriphi-bot/src/bot/matrix.service.ts @@ -1,12 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - RichConsoleLogger, - LogService, - LogLevel, -} from 'matrix-bot-sdk'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { NutriPhiService, AIAnalysisResult, @@ -28,76 +26,27 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private readonly allowedRooms: string[]; - private botUserId: string = ''; - +export class MatrixService extends BaseMatrixService { constructor( - private configService: ConfigService, + configService: ConfigService, private nutriphiService: NutriPhiService, private sessionService: SessionService, private transcriptionService: TranscriptionService ) { - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + super(configService); } - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - - if (!accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } - - // Setup logging - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.INFO); - - // Storage for sync token persistence - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - - // Create Matrix client - this.client = new MatrixClient(homeserverUrl!, accessToken, storage); - - // Auto-join rooms when invited - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Get bot's user ID - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - // Setup message handler - this.client.on('room.message', this.handleRoomMessage.bind(this)); - - // Start the client - await this.client.start(); - this.logger.log('Matrix NutriPhi Bot started successfully'); + 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') || [], + }; } - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix bot stopped'); - } - } - - private async sendBotIntroduction(roomId: string) { - const introText = `**NutriPhi Bot - KI-Ernahrungsassistent** + protected getIntroductionMessage(): string | null { + return `**NutriPhi Bot - KI-Ernahrungsassistent** Analysiere deine Mahlzeiten mit KI und tracke deine Ernahrung! @@ -107,70 +56,112 @@ Analysiere deine Mahlzeiten mit KI und tracke deine Ernahrung! 3. \`!analyze\` - Nahrwerte erhalten Sag "hilfe" fur alle Befehle!`; - - await this.sendMessage(roomId, introText); } - private isRoomAllowed(roomId: string): boolean { - if (this.allowedRooms.length === 0) return true; - return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); - } + async onModuleInit() { + await super.onModuleInit(); - private async handleRoomMessage(roomId: string, event: any) { - // Ignore messages from self - if (event.sender === this.botUserId) return; - - // Check if room is allowed - if (!this.isRoomAllowed(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); - return; - } - - const content = event.content as { - msgtype?: string; - body?: string; - url?: string; - info?: { mimetype?: string; duration?: number }; - }; + if (!this.client) return; // Handle image messages - if (content.msgtype === 'm.image' && content.url) { - this.sessionService.setSessionData(event.sender, 'pendingImage', { - url: content.url, - mimeType: content.info?.mimetype || 'image/png', - }); - this.logger.log(`Image received from ${event.sender}`); + this.client.on('room.message', async (roomId: string, event: any) => { + if (event.sender === await this.client.getUserId()) return; + + const content = event.content as { + msgtype?: string; + body?: string; + url?: string; + info?: { mimetype?: string; duration?: number }; + }; + + // Handle image messages + if (content.msgtype === 'm.image' && content.url) { + this.sessionService.setSessionData(event.sender, 'pendingImage', { + url: content.url, + mimeType: content.info?.mimetype || 'image/png', + }); + this.logger.log(`Image received from ${event.sender}`); + await this.sendMessage( + roomId, + `Bild empfangen! Nutze jetzt \`!analyze\` um es zu analysieren, oder \`!analyze Beschreibung\` um zusatzlichen Kontext zu geben.` + ); + } + }); + } + + protected async handleAudioMessage( + roomId: string, + event: MatrixRoomEvent, + sender: string + ): Promise { + const token = this.sessionService.getToken(sender); + if (!token) { await this.sendMessage( roomId, - `Bild empfangen! Nutze jetzt \`!analyze\` um es zu analysieren, oder \`!analyze Beschreibung\` um zusatzlichen Kontext zu geben.` + `Du bist nicht angemeldet. Nutze \`!login email passwort\` um dich anzumelden.` ); return; } - // Handle audio/voice messages - if (content.msgtype === 'm.audio' && content.url) { - await this.handleAudioMessage(roomId, event.sender, content); - return; + await this.sendMessage(roomId, 'Verarbeite Sprachnotiz...'); + await this.client.setTyping(roomId, true, 60000); + + try { + // Download audio from Matrix + const mxcUrl = event.content.url!; + const httpUrl = this.client.mxcToHttp(mxcUrl); + this.logger.log(`Downloading audio from ${httpUrl}`); + + const response = await fetch(httpUrl); + if (!response.ok) { + throw new Error(`Failed to download audio: ${response.status}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + + // Transcribe audio + const transcription = await this.transcriptionService.transcribe(buffer); + this.logger.log(`Transcription: ${transcription.substring(0, 50)}...`); + + if (!transcription.trim()) { + await this.client.setTyping(roomId, false); + await this.sendMessage(roomId, 'Konnte keine Sprache erkennen. Bitte versuche es erneut.'); + return; + } + + // Analyze the transcribed text as a meal + await this.sendMessage(roomId, `Transkription: "${transcription}"\n\nAnalysiere...`); + + const result = await this.nutriphiService.analyzeText(transcription, token); + await this.client.setTyping(roomId, false); + + // Format and send result + const formattedResult = this.formatAnalysisResult(result); + await this.sendMessage(roomId, formattedResult); + } catch (error) { + await this.client.setTyping(roomId, false); + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + this.logger.error('Audio processing failed:', error); + await this.sendMessage(roomId, `Fehler bei der Verarbeitung: ${errorMsg}`); } + } - // Only handle text messages - if (content.msgtype !== 'm.text') return; - - const body = content.body; - if (!body) return; - - this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); - + protected async handleTextMessage( + roomId: string, + _event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { // Handle commands with ! prefix - if (body.startsWith('!')) { - await this.handleCommand(roomId, event.sender, body); + if (message.startsWith('!')) { + await this.handleCommand(roomId, sender, message); return; } // Check for natural language keywords - const keywordCommand = this.detectKeywordCommand(body); + const keywordCommand = this.detectKeywordCommand(message); if (keywordCommand) { - await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + await this.handleCommand(roomId, sender, `!${keywordCommand}`); return; } @@ -650,7 +641,7 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; private async pinHelpMessage(roomId: string) { try { - const htmlBody = this.markdownToHtml(HELP_MESSAGE); + const htmlBody = this.markdownToHtmlLocal(HELP_MESSAGE); const eventId = await this.client.sendMessage(roomId, { msgtype: 'm.text', @@ -670,63 +661,6 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; } } - private async handleAudioMessage( - roomId: string, - sender: string, - content: { url?: string; info?: { mimetype?: string; duration?: number } } - ) { - const token = this.sessionService.getToken(sender); - if (!token) { - await this.sendMessage( - roomId, - `Du bist nicht angemeldet. Nutze \`!login email passwort\` um dich anzumelden.` - ); - return; - } - - await this.sendMessage(roomId, 'Verarbeite Sprachnotiz...'); - await this.client.setTyping(roomId, true, 60000); - - try { - // Download audio from Matrix - const mxcUrl = content.url!; - const httpUrl = this.client.mxcToHttp(mxcUrl); - this.logger.log(`Downloading audio from ${httpUrl}`); - - const response = await fetch(httpUrl); - if (!response.ok) { - throw new Error(`Failed to download audio: ${response.status}`); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - - // Transcribe audio - const transcription = await this.transcriptionService.transcribe(buffer); - this.logger.log(`Transcription: ${transcription.substring(0, 50)}...`); - - if (!transcription.trim()) { - await this.client.setTyping(roomId, false); - await this.sendMessage(roomId, 'Konnte keine Sprache erkennen. Bitte versuche es erneut.'); - return; - } - - // Analyze the transcribed text as a meal - await this.sendMessage(roomId, `Transkription: "${transcription}"\n\nAnalysiere...`); - - const result = await this.nutriphiService.analyzeText(transcription, token); - await this.client.setTyping(roomId, false); - - // Format and send result - const formattedResult = this.formatAnalysisResult(result); - await this.sendMessage(roomId, formattedResult); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - this.logger.error('Audio processing failed:', error); - await this.sendMessage(roomId, `Fehler bei der Verarbeitung: ${errorMsg}`); - } - } - private async downloadMatrixImage(mxcUrl: string): Promise { const httpUrl = this.client.mxcToHttp(mxcUrl); this.logger.log(`Downloading image from ${httpUrl}`); @@ -741,18 +675,7 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; return base64; } - private async sendMessage(roomId: string, message: string) { - const htmlBody = this.markdownToHtml(message); - - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - } - - private markdownToHtml(markdown: string): string { + private markdownToHtmlLocal(markdown: string): string { return ( markdown // Code blocks diff --git a/services/matrix-ollama-bot/src/bot/matrix.service.ts b/services/matrix-ollama-bot/src/bot/matrix.service.ts index 1d29d53f5..b1ed0375f 100644 --- a/services/matrix-ollama-bot/src/bot/matrix.service.ts +++ b/services/matrix-ollama-bot/src/bot/matrix.service.ts @@ -1,13 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichConsoleLogger, - LogService, - LogLevel, -} from 'matrix-bot-sdk'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { OllamaService } from '../ollama/ollama.service'; import { SYSTEM_PROMPTS } from '../config/configuration'; @@ -33,68 +30,72 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; +export class MatrixService extends BaseMatrixService { private sessions: Map = new Map(); - private readonly allowedRooms: string[]; - private botUserId: string = ''; constructor( - private configService: ConfigService, + configService: ConfigService, private ollamaService: OllamaService ) { - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + super(configService); + } + + 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 | null { + return `**Hallo! Ich bin Manai, eure lokale KI-Assistentin.** + +Alle Daten bleiben auf diesem Server - 100% DSGVO-konform! + +**Quick Start:** +- Schreibt einfach eine Nachricht +- Sagt "hilfe" für alle Befehle +- Sagt "modelle" um KI-Modelle zu sehen`; } async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); + await super.onModuleInit(); - if (!accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } + if (!this.client) return; - // Setup logging - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.INFO); - - // Storage for sync token persistence - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - - // Create Matrix client - this.client = new MatrixClient(homeserverUrl!, accessToken, storage); - - // Auto-join rooms when invited and send welcome - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - // Wait a bit for the join to complete, then send intro and pin help - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Get bot's user ID this.botUserId = await this.client.getUserId(); this.logger.log(`Bot user ID: ${this.botUserId}`); - // Setup message handler - this.client.on('room.message', this.handleRoomMessage.bind(this)); - // Setup room join handler for welcome message this.client.on('room.join', this.handleRoomJoin.bind(this)); - // Start the client - await this.client.start(); - this.logger.log('Matrix bot started successfully'); + // Handle image messages + this.client.on('room.message', async (roomId: string, event: any) => { + if (event.sender === this.botUserId) return; + + const content = event.content as { + msgtype?: string; + body?: string; + url?: string; + info?: { mimetype?: string }; + }; + + // Handle image messages - store for later use with !vision + if (content.msgtype === 'm.image' && content.url) { + const session = this.getSession(event.sender); + session.pendingImage = { + url: content.url, + mimeType: content.info?.mimetype || 'image/png', + }; + this.logger.log(`Image received from ${event.sender}, stored for !vision command`); + await this.sendMessage( + roomId, + `Bild empfangen! Nutze jetzt:\n- \`!vision [Frage zum Bild]\` - Bild mit einem Modell analysieren\n- \`!vision:all [Frage]\` - Bild mit allen Vision-Modellen vergleichen` + ); + } + }); } private async handleRoomJoin(roomId: string, event: any) { @@ -109,21 +110,21 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } private async sendWelcomeMessage(roomId: string, userId: string) { - const welcomeText = `👋 **Willkommen im Mana Chat, ${this.extractUsername(userId)}!** + const welcomeText = `**Willkommen im Mana Chat, ${this.extractUsername(userId)}!** Ich bin **Manai**, deine lokale KI-Assistentin (100% DSGVO-konform). **So nutzt du mich:** -• Schreib einfach eine Nachricht - ich antworte! -• Sag "hilfe" oder "modelle" für mehr Infos -• Oder nutze Befehle wie \`!help\` +- Schreib einfach eine Nachricht - ich antworte! +- Sag "hilfe" oder "modelle" für mehr Infos +- Oder nutze Befehle wie \`!help\` **Quick Start:** -• "Was ist TypeScript?" → Ich erkläre es dir -• "modelle" → Zeigt verfügbare KI-Modelle -• \`!all Erkläre Recursion\` → Vergleicht alle Modelle +- "Was ist TypeScript?" -> Ich erkläre es dir +- "modelle" -> Zeigt verfügbare KI-Modelle +- \`!all Erkläre Recursion\` -> Vergleicht alle Modelle -Viel Spaß! 🚀`; +Viel Spass!`; await this.sendMessage(roomId, welcomeText); } @@ -134,77 +135,6 @@ Viel Spaß! 🚀`; return match ? match[1] : userId; } - private async sendBotIntroduction(roomId: string) { - const introText = `🤖 **Hallo! Ich bin Manai, eure lokale KI-Assistentin.** - -Alle Daten bleiben auf diesem Server - 100% DSGVO-konform! - -**Quick Start:** -• Schreibt einfach eine Nachricht -• Sagt "hilfe" für alle Befehle -• Sagt "modelle" um KI-Modelle zu sehen - -Ich pinne jetzt die Hilfe für euch an! 📌`; - - await this.sendMessage(roomId, introText); - - // Pin the help message - await this.pinHelpMessage(roomId); - } - - private async pinHelpMessage(roomId: string) { - try { - // Send the help message and get its event ID - const helpContent = this.getHelpContent(); - const htmlBody = this.markdownToHtml(helpContent); - - const eventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: helpContent, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - - // Pin the message - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [eventId], - }); - - this.logger.log(`Pinned help message in room ${roomId}`); - } catch (error) { - this.logger.error(`Failed to pin help message in ${roomId}:`, error); - } - } - - private getHelpContent(): string { - return `📌 **Manai - Befehls-Übersicht** - -**Einfach sagen:** -• "hilfe" - Diese Übersicht -• "modelle" - Verfügbare KI-Modelle -• "status" - Bot-Status -• "lösche verlauf" - Chat zurücksetzen - -**Power-User (mit !):** -• \`!model [name]\` - Modell wechseln -• \`!all [frage]\` - Alle Modelle vergleichen -• \`!vision [frage]\` - Bild analysieren - -**Nutzung:** Einfach schreiben und ich antworte!`; - } - - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix bot stopped'); - } - } - - private isRoomAllowed(roomId: string): boolean { - if (this.allowedRooms.length === 0) return true; - return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); - } - private getSession(senderId: string): UserSession { if (!this.sessions.has(senderId)) { this.sessions.set(senderId, { @@ -216,61 +146,27 @@ Ich pinne jetzt die Hilfe für euch an! 📌`; return this.sessions.get(senderId)!; } - private async handleRoomMessage(roomId: string, event: any) { - // Ignore messages from self - if (event.sender === this.botUserId) return; - - // Check if room is allowed - if (!this.isRoomAllowed(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); - return; - } - - const content = event.content as { - msgtype?: string; - body?: string; - url?: string; - info?: { mimetype?: string }; - }; - - // Handle image messages - store for later use with !vision - if (content.msgtype === 'm.image' && content.url) { - const session = this.getSession(event.sender); - session.pendingImage = { - url: content.url, - mimeType: content.info?.mimetype || 'image/png', - }; - this.logger.log(`Image received from ${event.sender}, stored for !vision command`); - await this.sendMessage( - roomId, - `📷 Bild empfangen! Nutze jetzt:\n- \`!vision [Frage zum Bild]\` - Bild mit einem Modell analysieren\n- \`!vision:all [Frage]\` - Bild mit allen Vision-Modellen vergleichen` - ); - return; - } - - // Only handle text messages - if (content.msgtype !== 'm.text') return; - - const body = content.body; - if (!body) return; - - this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); - + protected async handleTextMessage( + roomId: string, + _event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { // Handle commands with ! prefix - if (body.startsWith('!')) { - await this.handleCommand(roomId, event.sender, body); + if (message.startsWith('!')) { + await this.handleCommand(roomId, sender, message); return; } // Check for natural language keywords - const keywordCommand = this.detectKeywordCommand(body); + const keywordCommand = this.detectKeywordCommand(message); if (keywordCommand) { - await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + await this.handleCommand(roomId, sender, `!${keywordCommand}`); return; } // Regular chat message - await this.handleChat(roomId, event.sender, body); + await this.handleChat(roomId, sender, message); } private detectKeywordCommand(message: string): string | null { @@ -334,7 +230,7 @@ Ich pinne jetzt die Hilfe für euch an! 📌`; case 'pin': await this.pinHelpMessage(roomId); - await this.sendMessage(roomId, '📌 Hilfe wurde angepinnt!'); + await this.sendMessage(roomId, 'Hilfe wurde angepinnt!'); break; default: @@ -349,15 +245,15 @@ Ich pinne jetzt die Hilfe für euch an! 📌`; const helpText = `**Manai - Lokale KI (100% DSGVO-konform)** **Einfache Befehle** (sag einfach): -• "hilfe" - Diese Hilfe -• "modelle" - Verfügbare KI-Modelle -• "status" - Verbindungsstatus -• "lösche verlauf" - Chat zurücksetzen +- "hilfe" - Diese Hilfe +- "modelle" - Verfügbare KI-Modelle +- "status" - Verbindungsstatus +- "lösche verlauf" - Chat zurücksetzen **Power-User Befehle** (mit !): -• \`!model [name]\` - Modell wechseln -• \`!all [frage]\` - Alle Modelle vergleichen -• \`!mode [modus]\` - Modus ändern (default/code/translate/summarize) +- \`!model [name]\` - Modell wechseln +- \`!all [frage]\` - Alle Modelle vergleichen +- \`!mode [modus]\` - Modus ändern (default/code/translate/summarize) **Bild-Analyse:** 1. Sende ein Bild @@ -367,9 +263,9 @@ Ich pinne jetzt die Hilfe für euch an! 📌`; Schreibe einfach eine Nachricht und ich antworte! **Beispiele:** -• "Was ist Kubernetes?" → Direkte Antwort -• "modelle" → Zeigt alle Modelle -• \`!all Erkläre Docker\` → Vergleicht alle Modelle +- "Was ist Kubernetes?" -> Direkte Antwort +- "modelle" -> Zeigt alle Modelle +- \`!all Erkläre Docker\` -> Vergleicht alle Modelle **Aktuelles Modell:** \`${this.ollamaService.getDefaultModel()}\``; @@ -475,11 +371,11 @@ Schreibe einfach eine Nachricht und ich antworte! const statusText = `**Ollama Status** -**Verbindung:** ${connected ? '✅ Online' : '❌ Offline'} +**Verbindung:** ${connected ? 'Online' : 'Offline'} **Modelle:** ${models.length} **Dein Modell:** \`${session.model}\` **Chat-Verlauf:** ${session.history.length} Nachrichten -**DSGVO:** ✅ Alle Daten lokal`; +**DSGVO:** Alle Daten lokal`; await this.sendMessage(roomId, statusText); } @@ -498,7 +394,7 @@ Schreibe einfach eine Nachricht und ich antworte! const models = allModels.filter((m) => !NON_CHAT_MODELS.includes(m.name)); if (models.length === 0) { - await this.sendMessage(roomId, '❌ Keine Chat-Modelle gefunden. Ist Ollama gestartet?'); + await this.sendMessage(roomId, 'Keine Chat-Modelle gefunden. Ist Ollama gestartet?'); return; } @@ -507,7 +403,7 @@ Schreibe einfach eine Nachricht und ich antworte! await this.sendMessage( roomId, - `🔄 **Vergleiche ${models.length} Chat-Modelle...**${skippedNote}\n\nFrage: "${message}"` + `**Vergleiche ${models.length} Chat-Modelle...**${skippedNote}\n\nFrage: "${message}"` ); // Send typing indicator @@ -538,19 +434,19 @@ Schreibe einfach eine Nachricht und ich antworte! await this.client.setTyping(roomId, false); // Format results - let resultText = `**📊 Modellvergleich**\n\n**Frage:** "${message}"\n\n---\n\n`; + let resultText = `**Modellvergleich**\n\n**Frage:** "${message}"\n\n---\n\n`; for (const result of results) { const durationSec = (result.duration / 1000).toFixed(1); if (result.error) { - resultText += `**${result.model}** ⏱️ ${durationSec}s\n❌ Fehler: ${result.error}\n\n---\n\n`; + resultText += `**${result.model}** ${durationSec}s\nFehler: ${result.error}\n\n---\n\n`; } else { // Truncate long responses for readability const truncatedResponse = result.response.length > 500 ? result.response.substring(0, 500) + '...' : result.response; - resultText += `**${result.model}** ⏱️ ${durationSec}s\n${truncatedResponse}\n\n---\n\n`; + resultText += `**${result.model}** ${durationSec}s\n${truncatedResponse}\n\n---\n\n`; } } @@ -592,7 +488,7 @@ Schreibe einfach eine Nachricht und ich antworte! await this.client.setTyping(roomId, false); this.logger.error(`Error processing message:`, error); const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `❌ Fehler: ${errorMessage}`); + await this.sendMessage(roomId, `Fehler: ${errorMessage}`); } } @@ -602,7 +498,7 @@ Schreibe einfach eine Nachricht und ich antworte! if (!session.pendingImage) { await this.sendMessage( roomId, - `❌ Kein Bild vorhanden!\n\nSende zuerst ein Bild, dann nutze \`!vision [Frage zum Bild]\`` + `Kein Bild vorhanden!\n\nSende zuerst ein Bild, dann nutze \`!vision [Frage zum Bild]\`` ); return; } @@ -622,13 +518,13 @@ Schreibe einfach eine Nachricht und ich antworte! if (visionModels.length === 0) { await this.sendMessage( roomId, - `❌ Keine Vision-Modelle gefunden!\n\nInstalliere ein Vision-Modell mit:\n\`ollama pull llava\`` + `Keine Vision-Modelle gefunden!\n\nInstalliere ein Vision-Modell mit:\n\`ollama pull llava\`` ); return; } const model = visionModels[0].name; - await this.sendMessage(roomId, `🔍 Analysiere Bild mit \`${model}\`...`); + await this.sendMessage(roomId, `Analysiere Bild mit \`${model}\`...`); await this.client.setTyping(roomId, true, 120000); try { @@ -642,7 +538,7 @@ Schreibe einfach eine Nachricht und ich antworte! } catch (error) { await this.client.setTyping(roomId, false); const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `❌ Fehler bei der Bildanalyse: ${errorMsg}`); + await this.sendMessage(roomId, `Fehler bei der Bildanalyse: ${errorMsg}`); } } @@ -652,7 +548,7 @@ Schreibe einfach eine Nachricht und ich antworte! if (!session.pendingImage) { await this.sendMessage( roomId, - `❌ Kein Bild vorhanden!\n\nSende zuerst ein Bild, dann nutze \`!vision:all [Frage zum Bild]\`` + `Kein Bild vorhanden!\n\nSende zuerst ein Bild, dann nutze \`!vision:all [Frage zum Bild]\`` ); return; } @@ -672,14 +568,14 @@ Schreibe einfach eine Nachricht und ich antworte! if (visionModels.length === 0) { await this.sendMessage( roomId, - `❌ Keine Vision-Modelle gefunden!\n\nInstalliere Vision-Modelle mit:\n\`ollama pull llava\`\n\`ollama pull moondream\`` + `Keine Vision-Modelle gefunden!\n\nInstalliere Vision-Modelle mit:\n\`ollama pull llava\`\n\`ollama pull moondream\`` ); return; } await this.sendMessage( roomId, - `🔄 **Vergleiche ${visionModels.length} Vision-Modelle...**\n\nFrage: "${prompt}"` + `**Vergleiche ${visionModels.length} Vision-Modelle...**\n\nFrage: "${prompt}"` ); await this.client.setTyping(roomId, true, 300000); @@ -706,18 +602,18 @@ Schreibe einfach eine Nachricht und ich antworte! await this.client.setTyping(roomId, false); // Format results - let resultText = `**📊 Vision-Modellvergleich**\n\n**Frage:** "${prompt}"\n\n---\n\n`; + let resultText = `**Vision-Modellvergleich**\n\n**Frage:** "${prompt}"\n\n---\n\n`; for (const result of results) { const durationSec = (result.duration / 1000).toFixed(1); if (result.error) { - resultText += `**${result.model}** ⏱️ ${durationSec}s\n❌ Fehler: ${result.error}\n\n---\n\n`; + resultText += `**${result.model}** ${durationSec}s\nFehler: ${result.error}\n\n---\n\n`; } else { const truncatedResponse = result.response.length > 500 ? result.response.substring(0, 500) + '...' : result.response; - resultText += `**${result.model}** ⏱️ ${durationSec}s\n${truncatedResponse}\n\n---\n\n`; + resultText += `**${result.model}** ${durationSec}s\n${truncatedResponse}\n\n---\n\n`; } } @@ -725,7 +621,7 @@ Schreibe einfach eine Nachricht und ich antworte! } catch (error) { await this.client.setTyping(roomId, false); const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `❌ Fehler: ${errorMsg}`); + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); } } @@ -744,19 +640,46 @@ Schreibe einfach eine Nachricht und ich antworte! return base64; } - private async sendMessage(roomId: string, message: string) { - // Convert markdown to basic HTML for Matrix - const htmlBody = this.markdownToHtml(message); + private async pinHelpMessage(roomId: string) { + try { + const helpContent = this.getHelpContent(); + const htmlBody = this.markdownToHtmlLocal(helpContent); - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); + const eventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: helpContent, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [eventId], + }); + + this.logger.log(`Pinned help message in room ${roomId}`); + } catch (error) { + this.logger.error(`Failed to pin help message in ${roomId}:`, error); + } } - private markdownToHtml(markdown: string): string { + private getHelpContent(): string { + return `**Manai - Befehls-Übersicht** + +**Einfach sagen:** +- "hilfe" - Diese Übersicht +- "modelle" - Verfügbare KI-Modelle +- "status" - Bot-Status +- "lösche verlauf" - Chat zurücksetzen + +**Power-User (mit !):** +- \`!model [name]\` - Modell wechseln +- \`!all [frage]\` - Alle Modelle vergleichen +- \`!vision [frage]\` - Bild analysieren + +**Nutzung:** Einfach schreiben und ich antworte!`; + } + + private markdownToHtmlLocal(markdown: string): string { return ( markdown // Code blocks diff --git a/services/matrix-picture-bot/src/bot/matrix.service.ts b/services/matrix-picture-bot/src/bot/matrix.service.ts index 0357f2403..5bcff0f22 100644 --- a/services/matrix-picture-bot/src/bot/matrix.service.ts +++ b/services/matrix-picture-bot/src/bot/matrix.service.ts @@ -1,12 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - RichConsoleLogger, - LogService, - LogLevel, -} from 'matrix-bot-sdk'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { PictureService } from '../picture/picture.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @@ -30,80 +28,31 @@ interface ParsedPrompt { } @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private readonly allowedRooms: string[]; - private botUserId: string = ''; - +export class MatrixService extends BaseMatrixService { // Track active generations per user private activeGenerations: Map = new Map(); // Track selected model per user private userModels: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private pictureService: PictureService, private sessionService: SessionService ) { - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + super(configService); } - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - - if (!accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } - - // Setup logging - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.INFO); - - // Storage for sync token persistence - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - - // Create Matrix client - this.client = new MatrixClient(homeserverUrl!, accessToken, storage); - - // Auto-join rooms when invited - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Get bot's user ID - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - // Setup message handler - this.client.on('room.message', this.handleRoomMessage.bind(this)); - - // Start the client - await this.client.start(); - this.logger.log('Matrix Picture Bot started successfully'); + 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') || [], + }; } - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix bot stopped'); - } - } - - private async sendBotIntroduction(roomId: string) { - const introText = `**Picture Bot - AI-Bildgenerierung** + protected getIntroductionMessage(): string | null { + return `**Picture Bot - AI-Bildgenerierung** Ich generiere Bilder mit AI fur dich! @@ -112,45 +61,24 @@ Ich generiere Bilder mit AI fur dich! \`!bild Ein niedlicher Hund\` Sag "hilfe" fur alle Befehle!`; - - await this.sendMessage(roomId, introText); } - private isRoomAllowed(roomId: string): boolean { - if (this.allowedRooms.length === 0) return true; - return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); - } - - private async handleRoomMessage(roomId: string, event: any) { - // Ignore messages from self - if (event.sender === this.botUserId) return; - - // Check if room is allowed - if (!this.isRoomAllowed(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); - return; - } - - const content = event.content as { msgtype?: string; body?: string }; - - // Only handle text messages - if (content.msgtype !== 'm.text') return; - - const body = content.body; - if (!body) return; - - this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); - + protected async handleTextMessage( + roomId: string, + _event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { // Handle commands with ! prefix - if (body.startsWith('!')) { - await this.handleCommand(roomId, event.sender, body); + if (message.startsWith('!')) { + await this.handleCommand(roomId, sender, message); return; } // Check for natural language keywords - const keywordCommand = this.detectKeywordCommand(body); + const keywordCommand = this.detectKeywordCommand(message); if (keywordCommand) { - await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + await this.handleCommand(roomId, sender, `!${keywordCommand}`); return; } @@ -597,7 +525,7 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; private async pinHelpMessage(roomId: string) { try { - const htmlBody = this.markdownToHtml(HELP_MESSAGE); + const htmlBody = this.markdownToHtmlLocal(HELP_MESSAGE); const eventId = await this.client.sendMessage(roomId, { msgtype: 'm.text', @@ -617,18 +545,7 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; } } - private async sendMessage(roomId: string, message: string) { - const htmlBody = this.markdownToHtml(message); - - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - } - - private markdownToHtml(markdown: string): string { + private markdownToHtmlLocal(markdown: string): string { return ( markdown // Code blocks diff --git a/services/matrix-planta-bot/src/bot/matrix.service.ts b/services/matrix-planta-bot/src/bot/matrix.service.ts index 705ca7574..31bc5415c 100644 --- a/services/matrix-planta-bot/src/bot/matrix.service.ts +++ b/services/matrix-planta-bot/src/bot/matrix.service.ts @@ -1,20 +1,12 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { PlantaService, Plant } from '../planta/planta.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { // Store last shown plants per user for reference by number private lastPlantsList: Map = new Map(); @@ -39,44 +31,28 @@ export class MatrixService implements OnModuleInit { }; constructor( - private configService: ConfigService, + configService: ConfigService, private plantaService: PlantaService, private sessionService: SessionService - ) {} - - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; - - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Planta Bot started'); + ) { + super(configService); } - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; + 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') || [], + }; + } - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; - - // Check allowed rooms - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + if (!body.startsWith('!')) return; const sender = event.sender; const parts = body.slice(1).split(/\s+/); @@ -88,7 +64,7 @@ export class MatrixService implements OnModuleInit { switch (command) { case 'help': case 'hilfe': - await this.sendHtml(roomId, HELP_MESSAGE); + await this.sendMessage(roomId, HELP_MESSAGE); break; case 'login': @@ -97,7 +73,7 @@ export class MatrixService implements OnModuleInit { case 'logout': this.sessionService.logout(sender); - await this.sendHtml(roomId, '

Erfolgreich abgemeldet.

'); + await this.sendMessage(roomId, '

Erfolgreich abgemeldet.

'); break; case 'status': @@ -157,26 +133,17 @@ export class MatrixService implements OnModuleInit { break; default: - await this.sendHtml( + await this.sendMessage( roomId, `

Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

` ); } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); - await this.sendHtml(roomId, `

Fehler: ${error.message}

`); + await this.sendMessage(roomId, `

Fehler: ${(error as Error).message}

`); } } - private async sendHtml(roomId: string, html: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: html.replace(/<[^>]*>/g, ''), - format: 'org.matrix.custom.html', - formatted_body: html, - }); - } - private requireAuth(sender: string): string { const token = this.sessionService.getToken(sender); if (!token) { @@ -188,7 +155,7 @@ export class MatrixService implements OnModuleInit { // Auth handlers private async handleLogin(roomId: string, sender: string, args: string[]) { if (args.length < 2) { - await this.sendHtml(roomId, '

Verwendung: !login email passwort

'); + await this.sendMessage(roomId, '

Verwendung: !login email passwort

'); return; } @@ -196,9 +163,9 @@ export class MatrixService implements OnModuleInit { const result = await this.sessionService.login(sender, email, password); if (result.success) { - await this.sendHtml(roomId, `

Erfolgreich angemeldet als ${email}

`); + await this.sendMessage(roomId, `

Erfolgreich angemeldet als ${email}

`); } else { - await this.sendHtml(roomId, `

Login fehlgeschlagen: ${result.error}

`); + await this.sendMessage(roomId, `

Login fehlgeschlagen: ${result.error}

`); } } @@ -207,7 +174,7 @@ export class MatrixService implements OnModuleInit { const loggedIn = this.sessionService.isLoggedIn(sender); const sessions = this.sessionService.getSessionCount(); - await this.sendHtml( + await this.sendMessage( roomId, `

Planta Bot Status

    @@ -224,7 +191,7 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.getPlants(token); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } @@ -232,7 +199,7 @@ export class MatrixService implements OnModuleInit { this.lastPlantsList.set(sender, plants); if (plants.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Keine Pflanzen vorhanden. Fuege eine mit !neu Name hinzu.

    ' ); @@ -248,7 +215,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

    Nutze !pflanze [nr] fuer Details oder !faellig fuer Giess-Status

    '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handlePlantDetails(roomId: string, sender: string, numberStr: string) { @@ -256,7 +223,7 @@ export class MatrixService implements OnModuleInit { const plant = this.getPlantByNumber(sender, numberStr); if (!plant) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Ungueltige Nummer. Nutze zuerst !pflanzen

    ' ); @@ -265,7 +232,7 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.getPlant(token, plant.id); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } @@ -289,12 +256,12 @@ export class MatrixService implements OnModuleInit { html += `

    Notizen: ${p.careNotes}

    `; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleAddPlant(roomId: string, sender: string, name: string) { if (!name) { - await this.sendHtml(roomId, '

    Verwendung: !neu Pflanzenname

    '); + await this.sendMessage(roomId, '

    Verwendung: !neu Pflanzenname

    '); return; } @@ -302,13 +269,13 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.createPlant(token, name); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } // Clear cached list this.lastPlantsList.delete(sender); - await this.sendHtml( + await this.sendMessage( roomId, `

    Pflanze ${result.data!.name} hinzugefuegt!

    Nutze !edit um Details wie Licht, Wasser etc. zu setzen.

    ` @@ -320,7 +287,7 @@ export class MatrixService implements OnModuleInit { const plant = this.getPlantByNumber(sender, numberStr); if (!plant) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Ungueltige Nummer. Nutze zuerst !pflanzen

    ' ); @@ -330,18 +297,18 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.deletePlant(token, plant.id); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } // Clear cached list this.lastPlantsList.delete(sender); - await this.sendHtml(roomId, `

    Pflanze ${plant.name} entfernt.

    `); + await this.sendMessage(roomId, `

    Pflanze ${plant.name} entfernt.

    `); } private async handleEditPlant(roomId: string, sender: string, args: string[]) { if (args.length < 3) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Verwendung: !edit [nr] [feld] [wert]

    Felder: name, art, licht, wasser, notizen

    ' ); @@ -352,7 +319,7 @@ export class MatrixService implements OnModuleInit { const plant = this.getPlantByNumber(sender, args[0]); if (!plant) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Ungueltige Nummer. Nutze zuerst !pflanzen

    ' ); @@ -364,7 +331,7 @@ export class MatrixService implements OnModuleInit { const value = args.slice(2).join(' '); if (!field) { - await this.sendHtml( + await this.sendMessage( roomId, `

    Unbekanntes Feld: ${fieldInput}

    Verfuegbar: name, art, licht, wasser, notizen

    ` ); @@ -372,11 +339,11 @@ export class MatrixService implements OnModuleInit { } // Validate and convert values - let updateValue: any = value; + let updateValue: string | number = value; if (field === 'wateringFrequencyDays') { updateValue = parseInt(value, 10); if (isNaN(updateValue) || updateValue < 1) { - await this.sendHtml(roomId, '

    Wasser-Intervall muss eine positive Zahl sein.

    '); + await this.sendMessage(roomId, '

    Wasser-Intervall muss eine positive Zahl sein.

    '); return; } } else if (field === 'lightRequirements') { @@ -388,7 +355,7 @@ export class MatrixService implements OnModuleInit { }; updateValue = lightMap[value.toLowerCase()]; if (!updateValue) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Licht-Werte: wenig/low, mittel/medium, hell/bright, direkt/direct

    ' ); @@ -402,7 +369,7 @@ export class MatrixService implements OnModuleInit { }; updateValue = humidityMap[value.toLowerCase()]; if (!updateValue) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Feuchtigkeits-Werte: niedrig/low, mittel/medium, hoch/high

    ' ); @@ -415,11 +382,11 @@ export class MatrixService implements OnModuleInit { }); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } - await this.sendHtml( + await this.sendMessage( roomId, `

    ${plant.name}: ${fieldInput} aktualisiert.

    ` ); @@ -431,7 +398,7 @@ export class MatrixService implements OnModuleInit { const plant = this.getPlantByNumber(sender, numberStr); if (!plant) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Ungueltige Nummer. Nutze zuerst !pflanzen

    ' ); @@ -441,7 +408,7 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.waterPlant(token, plant.id, notes || undefined); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } @@ -450,7 +417,7 @@ export class MatrixService implements OnModuleInit { html += `

    Notiz: ${notes}

    `; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleUpcomingWaterings(roomId: string, sender: string) { @@ -458,14 +425,14 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.getUpcomingWaterings(token); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } const upcoming = result.data || []; if (upcoming.length === 0) { - await this.sendHtml(roomId, '

    Keine Pflanzen muessen in den naechsten Tagen gegossen werden.

    '); + await this.sendMessage(roomId, '

    Keine Pflanzen muessen in den naechsten Tagen gegossen werden.

    '); return; } @@ -483,7 +450,7 @@ export class MatrixService implements OnModuleInit { // Store plants for reference this.lastPlantsList.set(sender, upcoming.map(u => u.plant)); - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleWateringHistory(roomId: string, sender: string, numberStr: string) { @@ -491,7 +458,7 @@ export class MatrixService implements OnModuleInit { const plant = this.getPlantByNumber(sender, numberStr); if (!plant) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Ungueltige Nummer. Nutze zuerst !pflanzen

    ' ); @@ -501,14 +468,14 @@ export class MatrixService implements OnModuleInit { const result = await this.plantaService.getWateringHistory(token, plant.id); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } const logs = result.data || []; if (logs.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, `

    ${plant.name} wurde noch nie gegossen.

    ` ); @@ -533,12 +500,12 @@ export class MatrixService implements OnModuleInit { html += `

    ...und ${logs.length - 10} weitere Eintraege

    `; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleSetInterval(roomId: string, sender: string, numberStr: string, daysStr: string) { if (!numberStr || !daysStr) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Verwendung: !intervall [nr] [tage]

    ' ); @@ -549,7 +516,7 @@ export class MatrixService implements OnModuleInit { const plant = this.getPlantByNumber(sender, numberStr); if (!plant) { - await this.sendHtml( + await this.sendMessage( roomId, '

    Ungueltige Nummer. Nutze zuerst !pflanzen

    ' ); @@ -558,18 +525,18 @@ export class MatrixService implements OnModuleInit { const days = parseInt(daysStr, 10); if (isNaN(days) || days < 1) { - await this.sendHtml(roomId, '

    Tage muss eine positive Zahl sein.

    '); + await this.sendMessage(roomId, '

    Tage muss eine positive Zahl sein.

    '); return; } const result = await this.plantaService.updateWateringSchedule(token, plant.id, days); if (result.error) { - await this.sendHtml(roomId, `

    Fehler: ${result.error}

    `); + await this.sendMessage(roomId, `

    Fehler: ${result.error}

    `); return; } - await this.sendHtml( + await this.sendMessage( roomId, `

    Giess-Intervall fuer ${plant.name} auf ${days} Tage gesetzt.

    ` ); diff --git a/services/matrix-presi-bot/src/bot/matrix.service.ts b/services/matrix-presi-bot/src/bot/matrix.service.ts index 417b657e0..be78193f3 100644 --- a/services/matrix-presi-bot/src/bot/matrix.service.ts +++ b/services/matrix-presi-bot/src/bot/matrix.service.ts @@ -1,62 +1,39 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { PresiService, Deck, Theme, SlideContent } from '../presi/presi.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { // Store last shown items per user for reference by number private lastDecksList: Map = new Map(); private lastThemesList: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private presiService: PresiService, private sessionService: SessionService - ) {} - - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; - - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Presi Bot started'); + ) { + super(configService); } - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; + 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') || [], + }; + } - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; - - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + if (!body.startsWith('!')) return; const sender = event.sender; const parts = body.slice(1).split(/\s+/); @@ -68,7 +45,7 @@ export class MatrixService implements OnModuleInit { switch (command) { case 'help': case 'hilfe': - await this.sendHtml(roomId, HELP_MESSAGE); + await this.sendMessage(roomId, HELP_MESSAGE); break; case 'login': @@ -77,7 +54,7 @@ export class MatrixService implements OnModuleInit { case 'logout': this.sessionService.logout(sender); - await this.sendHtml(roomId, '

    Erfolgreich abgemeldet.

    '); + await this.sendMessage(roomId, '

    Erfolgreich abgemeldet.

    '); break; case 'status': @@ -147,26 +124,17 @@ export class MatrixService implements OnModuleInit { break; default: - await this.sendHtml( + await this.sendMessage( roomId, `

    Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

    ` ); } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); - await this.sendHtml(roomId, `

    Fehler: ${error.message}

    `); + await this.sendMessage(roomId, `

    Fehler: ${(error as Error).message}

    `); } } - private async sendHtml(roomId: string, html: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: html.replace(/<[^>]*>/g, ''), - format: 'org.matrix.custom.html', - formatted_body: html, - }); - } - private requireAuth(sender: string): string { const token = this.sessionService.getToken(sender); if (!token) { @@ -178,7 +146,7 @@ export class MatrixService implements OnModuleInit { // Auth handlers private async handleLogin(roomId: string, sender: string, args: string[]) { if (args.length < 2) { - await this.sendHtml(roomId, '

    Verwendung: !login email passwort

    '); + await this.sendMessage(roomId, '

    Verwendung: !login email passwort

    '); return; } @@ -186,9 +154,9 @@ export class MatrixService implements OnModuleInit { const result = await this.sessionService.login(sender, email, password); if (result.success) { - await this.sendHtml(roomId, `

    Erfolgreich angemeldet als ${email}

    `); + await this.sendMessage(roomId, `

    Erfolgreich angemeldet als ${email}

    `); } else { - await this.sendHtml(roomId, `

    Login fehlgeschlagen: ${result.error}

    `); + await this.sendMessage(roomId, `

    Login fehlgeschlagen: ${result.error}

    `); } } @@ -197,7 +165,7 @@ export class MatrixService implements OnModuleInit { const loggedIn = this.sessionService.isLoggedIn(sender); const sessions = this.sessionService.getSessionCount(); - await this.sendHtml( + await this.sendMessage( roomId, `

    Presi Bot Status

      @@ -214,7 +182,7 @@ export class MatrixService implements OnModuleInit { const result = await this.presiService.getDecks(token); if (result.error) { - await this.sendHtml(roomId, `

      Fehler: ${result.error}

      `); + await this.sendMessage(roomId, `

      Fehler: ${result.error}

      `); return; } @@ -222,7 +190,7 @@ export class MatrixService implements OnModuleInit { this.lastDecksList.set(sender, decks); if (decks.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, '

      Keine Praesentationen vorhanden. Erstelle eine mit !neu Titel

      ' ); @@ -238,7 +206,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

      Nutze !presi [nr] fuer Details

      '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleDeckDetails(roomId: string, sender: string, numberStr: string) { @@ -246,13 +214,13 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, numberStr); if (!deck) { - await this.sendHtml(roomId, '

      Ungueltige Nummer. Nutze zuerst !presis

      '); + await this.sendMessage(roomId, '

      Ungueltige Nummer. Nutze zuerst !presis

      '); return; } const result = await this.presiService.getDeck(token, deck.id); if (result.error) { - await this.sendHtml(roomId, `

      Fehler: ${result.error}

      `); + await this.sendMessage(roomId, `

      Fehler: ${result.error}

      `); return; } @@ -278,12 +246,12 @@ export class MatrixService implements OnModuleInit { html += `

      Nutze !folie ${numberStr} typ Inhalt um Folien hinzuzufuegen

      `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleCreateDeck(roomId: string, sender: string, input: string) { if (!input) { - await this.sendHtml(roomId, '

      Verwendung: !neu Titel | Beschreibung

      '); + await this.sendMessage(roomId, '

      Verwendung: !neu Titel | Beschreibung

      '); return; } @@ -295,12 +263,12 @@ export class MatrixService implements OnModuleInit { const result = await this.presiService.createDeck(token, title, description); if (result.error) { - await this.sendHtml(roomId, `

      Fehler: ${result.error}

      `); + await this.sendMessage(roomId, `

      Fehler: ${result.error}

      `); return; } this.lastDecksList.delete(sender); - await this.sendHtml( + await this.sendMessage( roomId, `

      Praesentation ${result.data!.title} erstellt!

      Nutze !presis und dann !folie [nr] typ Inhalt

      ` @@ -312,24 +280,24 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, numberStr); if (!deck) { - await this.sendHtml(roomId, '

      Ungueltige Nummer. Nutze zuerst !presis

      '); + await this.sendMessage(roomId, '

      Ungueltige Nummer. Nutze zuerst !presis

      '); return; } const result = await this.presiService.deleteDeck(token, deck.id); if (result.error) { - await this.sendHtml(roomId, `

      Fehler: ${result.error}

      `); + await this.sendMessage(roomId, `

      Fehler: ${result.error}

      `); return; } this.lastDecksList.delete(sender); - await this.sendHtml(roomId, `

      Praesentation ${deck.title} geloescht.

      `); + await this.sendMessage(roomId, `

      Praesentation ${deck.title} geloescht.

      `); } private async handleRenameDeck(roomId: string, sender: string, numberStr: string, newTitle: string) { if (!newTitle) { - await this.sendHtml(roomId, '

      Verwendung: !umbenennen [nr] Neuer Titel

      '); + await this.sendMessage(roomId, '

      Verwendung: !umbenennen [nr] Neuer Titel

      '); return; } @@ -337,18 +305,18 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, numberStr); if (!deck) { - await this.sendHtml(roomId, '

      Ungueltige Nummer. Nutze zuerst !presis

      '); + await this.sendMessage(roomId, '

      Ungueltige Nummer. Nutze zuerst !presis

      '); return; } const result = await this.presiService.updateDeck(token, deck.id, { title: newTitle }); if (result.error) { - await this.sendHtml(roomId, `

      Fehler: ${result.error}

      `); + await this.sendMessage(roomId, `

      Fehler: ${result.error}

      `); return; } - await this.sendHtml( + await this.sendMessage( roomId, `

      ${deck.title} umbenannt zu ${newTitle}

      ` ); @@ -357,7 +325,7 @@ export class MatrixService implements OnModuleInit { // Slide handlers private async handleAddSlide(roomId: string, sender: string, args: string[]) { if (args.length < 2) { - await this.sendHtml( + await this.sendMessage( roomId, `

      Verwendung:

        @@ -373,7 +341,7 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, args[0]); if (!deck) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !presis

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !presis

        '); return; } @@ -424,7 +392,7 @@ export class MatrixService implements OnModuleInit { break; default: - await this.sendHtml( + await this.sendMessage( roomId, '

        Unbekannter Folien-Typ. Verfuegbar: titel, text, punkte, bild

        ' ); @@ -434,11 +402,11 @@ export class MatrixService implements OnModuleInit { const result = await this.presiService.addSlide(token, deck.id, content); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml( + await this.sendMessage( roomId, `

        Folie zu ${deck.title} hinzugefuegt (Position ${result.data!.order + 1})

        ` ); @@ -446,7 +414,7 @@ export class MatrixService implements OnModuleInit { private async handleDeleteSlide(roomId: string, sender: string, deckNumStr: string, slideNumStr: string) { if (!deckNumStr || !slideNumStr) { - await this.sendHtml(roomId, '

        Verwendung: !folieloeschen [presi-nr] [folien-nr]

        '); + await this.sendMessage(roomId, '

        Verwendung: !folieloeschen [presi-nr] [folien-nr]

        '); return; } @@ -454,20 +422,20 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, deckNumStr); if (!deck) { - await this.sendHtml(roomId, '

        Ungueltige Praesentation-Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Praesentation-Nummer.

        '); return; } // Get deck with slides const deckResult = await this.presiService.getDeck(token, deck.id); if (deckResult.error || !deckResult.data?.slides) { - await this.sendHtml(roomId, `

        Fehler: ${deckResult.error || 'Keine Folien'}

        `); + await this.sendMessage(roomId, `

        Fehler: ${deckResult.error || 'Keine Folien'}

        `); return; } const slideIndex = parseInt(slideNumStr, 10) - 1; if (isNaN(slideIndex) || slideIndex < 0 || slideIndex >= deckResult.data.slides.length) { - await this.sendHtml(roomId, '

        Ungueltige Folien-Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Folien-Nummer.

        '); return; } @@ -475,11 +443,11 @@ export class MatrixService implements OnModuleInit { const result = await this.presiService.deleteSlide(token, slide.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml(roomId, `

        Folie ${slideNumStr} aus ${deck.title} geloescht.

        `); + await this.sendMessage(roomId, `

        Folie ${slideNumStr} aus ${deck.title} geloescht.

        `); } // Theme handlers @@ -487,7 +455,7 @@ export class MatrixService implements OnModuleInit { const result = await this.presiService.getThemes(); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -495,7 +463,7 @@ export class MatrixService implements OnModuleInit { this.lastThemesList.set(sender, themes); if (themes.length === 0) { - await this.sendHtml(roomId, '

        Keine Themes verfuegbar.

        '); + await this.sendMessage(roomId, '

        Keine Themes verfuegbar.

        '); return; } @@ -507,12 +475,12 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

        Nutze !theme [presi-nr] [theme-nr]

        '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleApplyTheme(roomId: string, sender: string, deckNumStr: string, themeNumStr: string) { if (!deckNumStr || !themeNumStr) { - await this.sendHtml(roomId, '

        Verwendung: !theme [presi-nr] [theme-nr]

        '); + await this.sendMessage(roomId, '

        Verwendung: !theme [presi-nr] [theme-nr]

        '); return; } @@ -521,23 +489,23 @@ export class MatrixService implements OnModuleInit { const theme = this.getThemeByNumber(sender, themeNumStr); if (!deck) { - await this.sendHtml(roomId, '

        Ungueltige Praesentation-Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Praesentation-Nummer.

        '); return; } if (!theme) { - await this.sendHtml(roomId, '

        Ungueltige Theme-Nummer. Nutze zuerst !themes

        '); + await this.sendMessage(roomId, '

        Ungueltige Theme-Nummer. Nutze zuerst !themes

        '); return; } const result = await this.presiService.updateDeck(token, deck.id, { themeId: theme.id }); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml( + await this.sendMessage( roomId, `

        Theme ${theme.name} auf ${deck.title} angewendet.

        ` ); @@ -552,7 +520,7 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, numberStr); if (!deck) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !presis

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !presis

        '); return; } @@ -570,7 +538,7 @@ export class MatrixService implements OnModuleInit { const result = await this.presiService.createShareLink(token, deck.id, expiresAt); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -581,12 +549,12 @@ export class MatrixService implements OnModuleInit { html += `

        Gueltig bis: ${new Date(result.data!.expiresAt).toLocaleDateString('de-DE')}

        `; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleListShares(roomId: string, sender: string, numberStr: string) { if (!numberStr) { - await this.sendHtml(roomId, '

        Verwendung: !links [presi-nr]

        '); + await this.sendMessage(roomId, '

        Verwendung: !links [presi-nr]

        '); return; } @@ -594,21 +562,21 @@ export class MatrixService implements OnModuleInit { const deck = this.getDeckByNumber(sender, numberStr); if (!deck) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !presis

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !presis

        '); return; } const result = await this.presiService.getShareLinks(token, deck.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } const links = result.data || []; if (links.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, `

        Keine Share-Links fuer ${deck.title}. Nutze !teilen ${numberStr}

        ` ); @@ -625,7 +593,7 @@ export class MatrixService implements OnModuleInit { } html += ''; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } // Helper methods diff --git a/services/matrix-project-doc-bot/src/bot/matrix.service.ts b/services/matrix-project-doc-bot/src/bot/matrix.service.ts index ef0dc686d..9fb9154a5 100644 --- a/services/matrix-project-doc-bot/src/bot/matrix.service.ts +++ b/services/matrix-project-doc-bot/src/bot/matrix.service.ts @@ -1,68 +1,35 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichConsoleLogger, - LogService, - LogLevel, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { ProjectService } from '../project/project.service'; import { MediaService } from '../media/media.service'; import { GenerationService } from '../generation/generation.service'; import { BLOG_STYLES } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private botUserId: string = ''; +export class MatrixService extends BaseMatrixService { private readonly allowedUsers: string[]; // Active project per user (matrixUserId -> projectId) private activeProjects: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private projectService: ProjectService, private mediaService: MediaService, private generationService: GenerationService ) { + super(configService); this.allowedUsers = this.configService.get('matrix.allowedUsers') || []; } - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - - if (!accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } - - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.INFO); - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl!, accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - this.client.on('room.message', this.handleRoomMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Project Doc Bot started successfully'); - } - - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - } + 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 bot uses allowedUsers instead + }; } private isAllowed(userId: string): boolean { @@ -70,24 +37,50 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { return this.allowedUsers.includes(userId); } - private async handleRoomMessage(roomId: string, event: any) { + /** + * Override onRoomMessage to handle images and audio in addition to text + */ + protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise { + // Ignore own messages if (event.sender === this.botUserId) return; + + // Check user permissions if (!this.isAllowed(event.sender)) return; - const content = event.content as { msgtype?: string; body?: string; url?: string; info?: any }; - const msgtype = content.msgtype; + const msgtype = event.content?.msgtype; - if (msgtype === 'm.text') { - const body = content.body || ''; - if (body.startsWith('!')) { - await this.handleCommand(roomId, event.sender, body); - } else { - await this.handleTextMessage(roomId, event.sender, body); + try { + if (msgtype === 'm.text') { + const body = event.content.body || ''; + await this.handleTextMessage(roomId, event, body, event.sender); + } else if (msgtype === 'm.image') { + await this.handleImage(roomId, event.sender, { + url: event.content.url || '', + info: event.content.info as { mimetype?: string } | undefined, + body: event.content.body, + }); + } else if (msgtype === 'm.audio') { + await this.handleAudio(roomId, event.sender, { + url: event.content.url || '', + info: event.content.info as { mimetype?: string; duration?: number } | undefined, + }); } - } else if (msgtype === 'm.image') { - await this.handleImage(roomId, event.sender, content); - } else if (msgtype === 'm.audio') { - await this.handleAudio(roomId, event.sender, content); + } catch (error) { + this.logger.error(`Error handling message: ${error}`); + await this.sendMessage(roomId, 'Fehler bei der Verarbeitung der Nachricht.'); + } + } + + protected async handleTextMessage( + roomId: string, + _event: MatrixRoomEvent, + body: string, + sender: string + ): Promise { + if (body.startsWith('!')) { + await this.handleCommand(roomId, sender, body); + } else { + await this.handleTextNote(roomId, sender, body); } } @@ -134,7 +127,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { .map(([key, value]) => `- \`${key}\` - ${value.name}`) .join('\n'); - const helpText = `**📸 Project Doc Bot (DSGVO-konform)** + const helpText = `**Project Doc Bot (DSGVO-konform)** Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge. @@ -146,9 +139,9 @@ Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blo - \`!archive\` - Aktives Projekt archivieren **Content:** -📷 Foto senden - Wird gespeichert -🎤 Sprachnotiz - Wird transkribiert -💬 Text-Nachricht - Als Notiz gespeichert +Foto senden - Wird gespeichert +Sprachnotiz - Wird transkribiert +Text-Nachricht - Als Notiz gespeichert **Generierung:** - \`!generate\` - Blogbeitrag erstellen @@ -183,13 +176,13 @@ ${styles} await this.sendMessage( roomId, - `✅ **Projekt erstellt!**\n\n**Name:** ${project.name}\n**ID:** \`${project.id.slice(0, 8)}\`\n\nSende jetzt:\n📷 Fotos\n🎤 Sprachnotizen\n💬 Text-Nachrichten\n\nMit \`!generate\` erstellst du den Blogbeitrag.` + `**Projekt erstellt!**\n\n**Name:** ${project.name}\n**ID:** \`${project.id.slice(0, 8)}\`\n\nSende jetzt:\nFotos\nSprachnotizen\nText-Nachrichten\n\nMit \`!generate\` erstellst du den Blogbeitrag.` ); } catch (error) { this.logger.error('Failed to create project:', error); await this.sendMessage( roomId, - `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}` + `Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}` ); } } @@ -207,15 +200,15 @@ ${styles} const projectList = await Promise.all( projects.map(async (p) => { const stats = await this.projectService.getStats(p.id); - const active = p.id === activeId ? ' ✓' : ''; - const status = p.status === 'archived' ? ' 📦' : ''; + const active = p.id === activeId ? ' (aktiv)' : ''; + const status = p.status === 'archived' ? ' [archiviert]' : ''; return `- **${p.name}**${active}${status}\n ID: \`${p.id.slice(0, 8)}\` | ${stats.total} Einträge`; }) ); await this.sendMessage( roomId, - `**📂 Deine Projekte:**\n\n${projectList.join('\n\n')}\n\nWechseln mit: \`!switch [ID]\`` + `**Deine Projekte:**\n\n${projectList.join('\n\n')}\n\nWechseln mit: \`!switch [ID]\`` ); } @@ -241,7 +234,7 @@ ${styles} await this.sendMessage( roomId, - `✅ Gewechselt zu: **${project.name}**\n\n📷 ${stats.photos} Fotos\n🎤 ${stats.voices} Sprachnotizen\n📝 ${stats.texts} Textnotizen` + `Gewechselt zu: **${project.name}**\n\n${stats.photos} Fotos\n${stats.voices} Sprachnotizen\n${stats.texts} Textnotizen` ); } @@ -262,7 +255,7 @@ ${styles} const stats = await this.projectService.getStats(projectId); const latest = await this.generationService.getLatestGeneration(projectId); - let statusText = `**📊 Projekt-Status**\n\n**Name:** ${project.name}\n**Status:** ${project.status}\n**Erstellt:** ${project.createdAt.toLocaleDateString('de-DE')}\n\n**Inhalte:**\n📷 ${stats.photos} Fotos\n🎤 ${stats.voices} Sprachnotizen\n📝 ${stats.texts} Textnotizen\n**Gesamt:** ${stats.total} Einträge`; + let statusText = `**Projekt-Status**\n\n**Name:** ${project.name}\n**Status:** ${project.status}\n**Erstellt:** ${project.createdAt.toLocaleDateString('de-DE')}\n\n**Inhalte:**\n${stats.photos} Fotos\n${stats.voices} Sprachnotizen\n${stats.texts} Textnotizen\n**Gesamt:** ${stats.total} Einträge`; if (latest) { statusText += `\n\n**Letzte Generierung:**\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`; @@ -281,7 +274,7 @@ ${styles} await this.projectService.update(projectId, { status: 'archived' }); this.activeProjects.delete(sender); - await this.sendMessage(roomId, '📦 Projekt archiviert.\n\nStarte ein neues mit `!new`'); + await this.sendMessage(roomId, 'Projekt archiviert.\n\nStarte ein neues mit `!new`'); } private async showStyles(roomId: string) { @@ -291,7 +284,7 @@ ${styles} await this.sendMessage( roomId, - `**📝 Verfügbare Blog-Stile:**\n\n${styles}\n\nVerwendung: \`!generate [stil]\`` + `**Verfügbare Blog-Stile:**\n\n${styles}\n\nVerwendung: \`!generate [stil]\`` ); } @@ -313,7 +306,7 @@ ${styles} return; } - await this.sendMessage(roomId, '🚀 Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.'); + await this.sendMessage(roomId, 'Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.'); await this.client.setTyping(roomId, true, 60000); try { @@ -321,13 +314,13 @@ ${styles} await this.client.setTyping(roomId, false); await this.sendMessage(roomId, content); - await this.sendMessage(roomId, '✅ Blogbeitrag erstellt!\n\nExportieren mit `!export`'); + await this.sendMessage(roomId, 'Blogbeitrag erstellt!\n\nExportieren mit `!export`'); } catch (error) { await this.client.setTyping(roomId, false); this.logger.error('Generation failed:', error); await this.sendMessage( roomId, - `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}` + `Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}` ); } } @@ -366,24 +359,24 @@ ${styles} }); } - private async handleTextMessage(roomId: string, sender: string, text: string) { + private async handleTextNote(roomId: string, sender: string, text: string) { const projectId = this.activeProjects.get(sender); if (!projectId) { - await this.sendMessage(roomId, '💡 Tipp: Starte ein Projekt mit `!new Projektname`'); + await this.sendMessage(roomId, 'Tipp: Starte ein Projekt mit `!new Projektname`'); return; } try { await this.mediaService.addTextNote(projectId, text); const stats = await this.projectService.getStats(projectId); - await this.sendMessage(roomId, `📝 Notiz gespeichert! (${stats.texts} Notizen gesamt)`); + await this.sendMessage(roomId, `Notiz gespeichert! (${stats.texts} Notizen gesamt)`); } catch (error) { this.logger.error('Failed to add text note:', error); - await this.sendMessage(roomId, '❌ Fehler beim Speichern der Notiz.'); + await this.sendMessage(roomId, 'Fehler beim Speichern der Notiz.'); } } - private async handleImage(roomId: string, sender: string, content: any) { + private async handleImage(roomId: string, sender: string, content: { url: string; info?: { mimetype?: string }; body?: string }) { const projectId = this.activeProjects.get(sender); if (!projectId) { await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); @@ -400,21 +393,21 @@ ${styles} await this.mediaService.processPhoto(projectId, buffer, contentType, mxcUrl, content.body); const stats = await this.projectService.getStats(projectId); - await this.sendMessage(roomId, `📷 Foto gespeichert! (${stats.photos} Fotos gesamt)`); + await this.sendMessage(roomId, `Foto gespeichert! (${stats.photos} Fotos gesamt)`); } catch (error) { this.logger.error('Failed to process image:', error); - await this.sendMessage(roomId, '❌ Fehler beim Speichern des Fotos.'); + await this.sendMessage(roomId, 'Fehler beim Speichern des Fotos.'); } } - private async handleAudio(roomId: string, sender: string, content: any) { + private async handleAudio(roomId: string, sender: string, content: { url: string; info?: { mimetype?: string; duration?: number } }) { const projectId = this.activeProjects.get(sender); if (!projectId) { await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); return; } - await this.sendMessage(roomId, '🎤 Verarbeite Sprachnotiz...'); + await this.sendMessage(roomId, 'Verarbeite Sprachnotiz...'); try { const mxcUrl = content.url; @@ -433,36 +426,16 @@ ${styles} ); const stats = await this.projectService.getStats(projectId); - let reply = `✅ Sprachnotiz gespeichert! (${stats.voices} gesamt)`; + let reply = `Sprachnotiz gespeichert! (${stats.voices} gesamt)`; if (item.content) { - reply += `\n\n📝 Transkription:\n"${item.content}"`; + reply += `\n\nTranskription:\n"${item.content}"`; } await this.sendMessage(roomId, reply); } catch (error) { this.logger.error('Failed to process audio:', error); - await this.sendMessage(roomId, '❌ Fehler beim Verarbeiten der Sprachnotiz.'); + await this.sendMessage(roomId, 'Fehler beim Verarbeiten der Sprachnotiz.'); } } - - private async sendMessage(roomId: string, message: string) { - const htmlBody = this.markdownToHtml(message); - - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - } - - private markdownToHtml(markdown: string): string { - return markdown - .replace(/```(\w+)?\n([\s\S]*?)```/g, '
        $2
        ') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/_([^_]+)_/g, '$1') - .replace(/\n/g, '
        '); - } } diff --git a/services/matrix-questions-bot/src/bot/matrix.service.ts b/services/matrix-questions-bot/src/bot/matrix.service.ts index 7c56688e5..c6c28c364 100644 --- a/services/matrix-questions-bot/src/bot/matrix.service.ts +++ b/services/matrix-questions-bot/src/bot/matrix.service.ts @@ -1,64 +1,40 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { QuestionsService, Question, Collection, Answer } from '../questions/questions.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { // Store last shown items per user for reference by number private lastQuestionsList: Map = new Map(); private lastCollectionsList: Map = new Map(); private lastAnswersList: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private questionsService: QuestionsService, private sessionService: SessionService - ) {} - - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; - - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Questions Bot started'); + ) { + super(configService); } - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; + 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') || [], + }; + } - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; - - // Check allowed rooms - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + if (!body.startsWith('!')) return; const sender = event.sender; const parts = body.slice(1).split(/\s+/); @@ -70,7 +46,7 @@ export class MatrixService implements OnModuleInit { switch (command) { case 'help': case 'hilfe': - await this.sendHtml(roomId, HELP_MESSAGE); + await this.sendMessage(roomId, HELP_MESSAGE); break; case 'login': @@ -79,7 +55,7 @@ export class MatrixService implements OnModuleInit { case 'logout': this.sessionService.logout(sender); - await this.sendHtml(roomId, '

        Erfolgreich abgemeldet.

        '); + await this.sendMessage(roomId, '

        Erfolgreich abgemeldet.

        '); break; case 'status': @@ -165,26 +141,17 @@ export class MatrixService implements OnModuleInit { break; default: - await this.sendHtml( + await this.sendMessage( roomId, `

        Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

        ` ); } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); - await this.sendHtml(roomId, `

        Fehler: ${error.message}

        `); + await this.sendMessage(roomId, `

        Fehler: ${(error as Error).message}

        `); } } - private async sendHtml(roomId: string, html: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: html.replace(/<[^>]*>/g, ''), - format: 'org.matrix.custom.html', - formatted_body: html, - }); - } - private requireAuth(sender: string): string { const token = this.sessionService.getToken(sender); if (!token) { @@ -196,7 +163,7 @@ export class MatrixService implements OnModuleInit { // Auth handlers private async handleLogin(roomId: string, sender: string, args: string[]) { if (args.length < 2) { - await this.sendHtml(roomId, '

        Verwendung: !login email passwort

        '); + await this.sendMessage(roomId, '

        Verwendung: !login email passwort

        '); return; } @@ -204,9 +171,9 @@ export class MatrixService implements OnModuleInit { const result = await this.sessionService.login(sender, email, password); if (result.success) { - await this.sendHtml(roomId, `

        Erfolgreich angemeldet als ${email}

        `); + await this.sendMessage(roomId, `

        Erfolgreich angemeldet als ${email}

        `); } else { - await this.sendHtml(roomId, `

        Login fehlgeschlagen: ${result.error}

        `); + await this.sendMessage(roomId, `

        Login fehlgeschlagen: ${result.error}

        `); } } @@ -215,7 +182,7 @@ export class MatrixService implements OnModuleInit { const loggedIn = this.sessionService.isLoggedIn(sender); const sessions = this.sessionService.getSessionCount(); - await this.sendHtml( + await this.sendMessage( roomId, `

        Questions Bot Status

          @@ -230,7 +197,7 @@ export class MatrixService implements OnModuleInit { private async handleListQuestions(roomId: string, sender: string, statusFilter?: string) { const token = this.requireAuth(sender); - const options: any = {}; + const options: Record = {}; if (statusFilter) { const statusMap: Record = { offen: 'open', @@ -248,7 +215,7 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.getQuestions(token, options); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -256,7 +223,7 @@ export class MatrixService implements OnModuleInit { this.lastQuestionsList.set(sender, questions); if (questions.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, '

          Keine Fragen vorhanden. Stelle eine mit !neu Frage?

          ' ); @@ -272,7 +239,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

          Nutze !frage [nr] fuer Details oder !recherche [nr]

          '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleQuestionDetails(roomId: string, sender: string, numberStr: string) { @@ -280,13 +247,13 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); return; } const result = await this.questionsService.getQuestion(token, question.id); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -308,12 +275,12 @@ export class MatrixService implements OnModuleInit { html += `

          Nutze !recherche ${numberStr} um eine Recherche zu starten

          `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleCreateQuestion(roomId: string, sender: string, title: string) { if (!title) { - await this.sendHtml(roomId, '

          Verwendung: !neu Deine Frage?

          '); + await this.sendMessage(roomId, '

          Verwendung: !neu Deine Frage?

          '); return; } @@ -321,12 +288,12 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.createQuestion(token, title); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } this.lastQuestionsList.delete(sender); - await this.sendHtml( + await this.sendMessage( roomId, `

          Frage erstellt: ${result.data!.title}

          Nutze !fragen und dann !recherche [nr] um zu recherchieren.

          ` @@ -338,19 +305,19 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); return; } const result = await this.questionsService.deleteQuestion(token, question.id); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } this.lastQuestionsList.delete(sender); - await this.sendHtml(roomId, `

          Frage geloescht: ${question.title}

          `); + await this.sendMessage(roomId, `

          Frage geloescht: ${question.title}

          `); } private async handleArchiveQuestion(roomId: string, sender: string, numberStr: string) { @@ -358,18 +325,18 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); return; } const result = await this.questionsService.updateQuestionStatus(token, question.id, 'archived'); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } - await this.sendHtml(roomId, `

          Frage archiviert: ${question.title}

          `); + await this.sendMessage(roomId, `

          Frage archiviert: ${question.title}

          `); } // Research handlers @@ -378,7 +345,7 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); return; } @@ -392,12 +359,12 @@ export class MatrixService implements OnModuleInit { }; const depth = depthMap[depthStr?.toLowerCase() || ''] || 'quick'; - await this.sendHtml(roomId, `

          Starte ${depth}-Recherche fuer: ${question.title}...

          `); + await this.sendMessage(roomId, `

          Starte ${depth}-Recherche fuer: ${question.title}...

          `); const result = await this.questionsService.startResearch(token, question.id, depth); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -426,7 +393,7 @@ export class MatrixService implements OnModuleInit { html += `

          Nutze !quellen ${numberStr} fuer die Quellen

          `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleResearchResult(roomId: string, sender: string, numberStr: string) { @@ -434,21 +401,21 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !fragen

          '); return; } const result = await this.questionsService.getResearchResults(token, question.id); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } const results = result.data || []; if (results.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, `

          Keine Recherche-Ergebnisse. Nutze !recherche ${numberStr}

          ` ); @@ -471,7 +438,7 @@ export class MatrixService implements OnModuleInit { html += '
        '; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleSources(roomId: string, sender: string, numberStr: string) { @@ -479,21 +446,21 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !fragen

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !fragen

        '); return; } const result = await this.questionsService.getSources(token, question.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } const sources = result.data || []; if (sources.length === 0) { - await this.sendHtml(roomId, '

        Keine Quellen vorhanden.

        '); + await this.sendMessage(roomId, '

        Keine Quellen vorhanden.

        '); return; } @@ -508,7 +475,7 @@ export class MatrixService implements OnModuleInit { html += `

        ...und ${sources.length - 10} weitere Quellen

        `; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } // Answer handlers @@ -517,14 +484,14 @@ export class MatrixService implements OnModuleInit { const question = this.getQuestionByNumber(sender, numberStr); if (!question) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !fragen

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !fragen

        '); return; } const result = await this.questionsService.getAnswers(token, question.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -532,7 +499,7 @@ export class MatrixService implements OnModuleInit { this.lastAnswersList.set(sender, answers); if (answers.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, `

        Keine Antworten. Starte zuerst eine Recherche mit !recherche ${numberStr}

        ` ); @@ -560,7 +527,7 @@ export class MatrixService implements OnModuleInit { html += `

        Nutze !bewerten ${numberStr} 1-5 zum Bewerten

        `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleRateAnswer(roomId: string, sender: string, numberStr: string, ratingStr: string) { @@ -568,13 +535,13 @@ export class MatrixService implements OnModuleInit { const answers = this.lastAnswersList.get(sender); if (!answers || answers.length === 0) { - await this.sendHtml(roomId, '

        Zeige zuerst eine Antwort mit !antwort [nr]

        '); + await this.sendMessage(roomId, '

        Zeige zuerst eine Antwort mit !antwort [nr]

        '); return; } const rating = parseInt(ratingStr, 10); if (isNaN(rating) || rating < 1 || rating > 5) { - await this.sendHtml(roomId, '

        Bewertung muss zwischen 1 und 5 sein.

        '); + await this.sendMessage(roomId, '

        Bewertung muss zwischen 1 und 5 sein.

        '); return; } @@ -582,11 +549,11 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.rateAnswer(token, answer.id, rating); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml(roomId, `

        Antwort mit ${rating} Sternen bewertet.

        `); + await this.sendMessage(roomId, `

        Antwort mit ${rating} Sternen bewertet.

        `); } private async handleAcceptAnswer(roomId: string, sender: string, numberStr: string) { @@ -594,7 +561,7 @@ export class MatrixService implements OnModuleInit { const answers = this.lastAnswersList.get(sender); if (!answers || answers.length === 0) { - await this.sendHtml(roomId, '

        Zeige zuerst eine Antwort mit !antwort [nr]

        '); + await this.sendMessage(roomId, '

        Zeige zuerst eine Antwort mit !antwort [nr]

        '); return; } @@ -602,11 +569,11 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.acceptAnswer(token, answer.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml(roomId, '

        Antwort als Loesung akzeptiert. ✅

        '); + await this.sendMessage(roomId, '

        Antwort als Loesung akzeptiert. ✅

        '); } // Collection handlers @@ -615,7 +582,7 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.getCollections(token); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -623,7 +590,7 @@ export class MatrixService implements OnModuleInit { this.lastCollectionsList.set(sender, collections); if (collections.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, '

        Keine Sammlungen. Erstelle eine mit !sammlung Name

        ' ); @@ -638,12 +605,12 @@ export class MatrixService implements OnModuleInit { } html += ''; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleCreateCollection(roomId: string, sender: string, name: string) { if (!name) { - await this.sendHtml(roomId, '

        Verwendung: !sammlung Name

        '); + await this.sendMessage(roomId, '

        Verwendung: !sammlung Name

        '); return; } @@ -651,18 +618,18 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.createCollection(token, name); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastCollectionsList.delete(sender); - await this.sendHtml(roomId, `

        Sammlung ${result.data!.name} erstellt.

        `); + await this.sendMessage(roomId, `

        Sammlung ${result.data!.name} erstellt.

        `); } // Search handler private async handleSearch(roomId: string, sender: string, query: string) { if (!query) { - await this.sendHtml(roomId, '

        Verwendung: !suche Begriff

        '); + await this.sendMessage(roomId, '

        Verwendung: !suche Begriff

        '); return; } @@ -670,7 +637,7 @@ export class MatrixService implements OnModuleInit { const result = await this.questionsService.getQuestions(token, { search: query }); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -678,7 +645,7 @@ export class MatrixService implements OnModuleInit { this.lastQuestionsList.set(sender, questions); if (questions.length === 0) { - await this.sendHtml(roomId, `

        Keine Fragen gefunden fuer "${query}"

        `); + await this.sendMessage(roomId, `

        Keine Fragen gefunden fuer "${query}"

        `); return; } @@ -689,7 +656,7 @@ export class MatrixService implements OnModuleInit { } html += ''; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } // Helper methods diff --git a/services/matrix-skilltree-bot/src/bot/matrix.service.ts b/services/matrix-skilltree-bot/src/bot/matrix.service.ts index 6684a0bdd..6ed840459 100644 --- a/services/matrix-skilltree-bot/src/bot/matrix.service.ts +++ b/services/matrix-skilltree-bot/src/bot/matrix.service.ts @@ -1,20 +1,12 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { SkilltreeService, Skill, SkillBranch } from '../skilltree/skilltree.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { // Store last shown skills per user for reference by number private lastSkillsList: Map = new Map(); @@ -44,43 +36,28 @@ export class MatrixService implements OnModuleInit { }; constructor( - private configService: ConfigService, + configService: ConfigService, private skilltreeService: SkilltreeService, private sessionService: SessionService - ) {} - - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; - - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Skilltree Bot started'); + ) { + super(configService); } - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; + 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') || [], + }; + } - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; - - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + if (!body.startsWith('!')) return; const sender = event.sender; const parts = body.slice(1).split(/\s+/); @@ -92,7 +69,7 @@ export class MatrixService implements OnModuleInit { switch (command) { case 'help': case 'hilfe': - await this.sendHtml(roomId, HELP_MESSAGE); + await this.sendMessage(roomId, HELP_MESSAGE); break; case 'login': @@ -101,7 +78,7 @@ export class MatrixService implements OnModuleInit { case 'logout': this.sessionService.logout(sender); - await this.sendHtml(roomId, '

        Erfolgreich abgemeldet.

        '); + await this.sendMessage(roomId, '

        Erfolgreich abgemeldet.

        '); break; case 'status': @@ -151,26 +128,17 @@ export class MatrixService implements OnModuleInit { break; default: - await this.sendHtml( + await this.sendMessage( roomId, `

        Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

        ` ); } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); - await this.sendHtml(roomId, `

        Fehler: ${error.message}

        `); + await this.sendMessage(roomId, `

        Fehler: ${(error as Error).message}

        `); } } - private async sendHtml(roomId: string, html: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: html.replace(/<[^>]*>/g, ''), - format: 'org.matrix.custom.html', - formatted_body: html, - }); - } - private requireAuth(sender: string): string { const token = this.sessionService.getToken(sender); if (!token) { @@ -182,7 +150,7 @@ export class MatrixService implements OnModuleInit { // Auth handlers private async handleLogin(roomId: string, sender: string, args: string[]) { if (args.length < 2) { - await this.sendHtml(roomId, '

        Verwendung: !login email passwort

        '); + await this.sendMessage(roomId, '

        Verwendung: !login email passwort

        '); return; } @@ -190,9 +158,9 @@ export class MatrixService implements OnModuleInit { const result = await this.sessionService.login(sender, email, password); if (result.success) { - await this.sendHtml(roomId, `

        Erfolgreich angemeldet als ${email}

        `); + await this.sendMessage(roomId, `

        Erfolgreich angemeldet als ${email}

        `); } else { - await this.sendHtml(roomId, `

        Login fehlgeschlagen: ${result.error}

        `); + await this.sendMessage(roomId, `

        Login fehlgeschlagen: ${result.error}

        `); } } @@ -201,7 +169,7 @@ export class MatrixService implements OnModuleInit { const loggedIn = this.sessionService.isLoggedIn(sender); const sessions = this.sessionService.getSessionCount(); - await this.sendHtml( + await this.sendMessage( roomId, `

        Skilltree Bot Status

          @@ -220,7 +188,7 @@ export class MatrixService implements OnModuleInit { if (branchFilter) { branch = this.branchMappings[branchFilter.toLowerCase()]; if (!branch) { - await this.sendHtml( + await this.sendMessage( roomId, '

          Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom

          ' ); @@ -231,7 +199,7 @@ export class MatrixService implements OnModuleInit { const result = await this.skilltreeService.getSkills(token, branch); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -239,7 +207,7 @@ export class MatrixService implements OnModuleInit { this.lastSkillsList.set(sender, skills); if (skills.length === 0) { - await this.sendHtml( + await this.sendMessage( roomId, '

          Keine Skills vorhanden. Erstelle einen mit !neu Name | Branch

          ' ); @@ -256,7 +224,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

          Nutze !skill [nr] fuer Details oder !xp [nr] 50 Aktivitaet

          '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleSkillDetails(roomId: string, sender: string, numberStr: string) { @@ -264,13 +232,13 @@ export class MatrixService implements OnModuleInit { const skill = this.getSkillByNumber(sender, numberStr); if (!skill) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !skills

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !skills

          '); return; } const result = await this.skilltreeService.getSkill(token, skill.id); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -293,12 +261,12 @@ export class MatrixService implements OnModuleInit { html += `

          Nutze !xp ${numberStr} [xp] [aktivitaet] um XP hinzuzufuegen

          `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleCreateSkill(roomId: string, sender: string, input: string) { if (!input) { - await this.sendHtml( + await this.sendMessage( roomId, '

          Verwendung: !neu Name | Branch

          Branches: intellect, body, creativity, social, practical, mindset, custom

          ' ); @@ -312,7 +280,7 @@ export class MatrixService implements OnModuleInit { const branch = this.branchMappings[branchInput]; if (!branch) { - await this.sendHtml( + await this.sendMessage( roomId, '

          Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom

          ' ); @@ -324,13 +292,13 @@ export class MatrixService implements OnModuleInit { const result = await this.skilltreeService.createSkill(token, name, branch, description); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } this.lastSkillsList.delete(sender); const branchIcon = this.getBranchIcon(branch); - await this.sendHtml( + await this.sendMessage( roomId, `

          ${branchIcon} Skill ${result.data!.skill.name} erstellt!

          Nutze !skills und dann !xp [nr] [xp] [aktivitaet]

          ` @@ -342,19 +310,19 @@ export class MatrixService implements OnModuleInit { const skill = this.getSkillByNumber(sender, numberStr); if (!skill) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !skills

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !skills

          '); return; } const result = await this.skilltreeService.deleteSkill(token, skill.id); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } this.lastSkillsList.delete(sender); - await this.sendHtml(roomId, `

          Skill ${skill.name} geloescht.

          `); + await this.sendMessage(roomId, `

          Skill ${skill.name} geloescht.

          `); } // XP handler @@ -362,7 +330,7 @@ export class MatrixService implements OnModuleInit { const args = argString.split(/\s+/); if (args.length < 3) { - await this.sendHtml( + await this.sendMessage( roomId, '

          Verwendung: !xp [nr] [xp] [aktivitaet]

          Optional: --min 60 fuer Dauer

          ' ); @@ -373,13 +341,13 @@ export class MatrixService implements OnModuleInit { const skill = this.getSkillByNumber(sender, args[0]); if (!skill) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !skills

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !skills

          '); return; } const xp = parseInt(args[1], 10); if (isNaN(xp) || xp < 1 || xp > 10000) { - await this.sendHtml(roomId, '

          XP muss zwischen 1 und 10000 liegen.

          '); + await this.sendMessage(roomId, '

          XP muss zwischen 1 und 10000 liegen.

          '); return; } @@ -401,7 +369,7 @@ export class MatrixService implements OnModuleInit { const result = await this.skilltreeService.addXp(token, skill.id, xp, description, duration); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -414,7 +382,7 @@ export class MatrixService implements OnModuleInit { html += `

          🎉 LEVEL UP! Du bist jetzt Level ${newLevel} (${levelName})!

          `; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } // Stats handler @@ -423,7 +391,7 @@ export class MatrixService implements OnModuleInit { const result = await this.skilltreeService.getStats(token); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -438,7 +406,7 @@ export class MatrixService implements OnModuleInit { } html += '
        '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } // Activities handler @@ -451,7 +419,7 @@ export class MatrixService implements OnModuleInit { if (numberStr) { const skill = this.getSkillByNumber(sender, numberStr); if (!skill) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !skills

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !skills

        '); return; } result = await this.skilltreeService.getSkillActivities(token, skill.id); @@ -461,14 +429,14 @@ export class MatrixService implements OnModuleInit { } if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } const activities = result.data?.activities || []; if (activities.length === 0) { - await this.sendHtml(roomId, '

        Keine Aktivitaeten vorhanden.

        '); + await this.sendMessage(roomId, '

        Keine Aktivitaeten vorhanden.

        '); return; } @@ -487,7 +455,7 @@ export class MatrixService implements OnModuleInit { } html += ''; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } // Helper methods diff --git a/services/matrix-storage-bot/src/bot/matrix.service.ts b/services/matrix-storage-bot/src/bot/matrix.service.ts index f8af7b7b3..2393e3588 100644 --- a/services/matrix-storage-bot/src/bot/matrix.service.ts +++ b/services/matrix-storage-bot/src/bot/matrix.service.ts @@ -1,22 +1,12 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, -} from 'matrix-bot-sdk'; +import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; import { StorageService, StorageFile, Folder, ShareLink, TrashItem } from '../storage/storage.service'; import { SessionService } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; -type ListItem = StorageFile | Folder; - @Injectable() -export class MatrixService implements OnModuleInit { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private allowedRooms: string[]; - +export class MatrixService extends BaseMatrixService { // Store last shown items per user for reference by number private lastFilesList: Map = new Map(); private lastFoldersList: Map = new Map(); @@ -25,44 +15,28 @@ export class MatrixService implements OnModuleInit { private currentFolder: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private storageService: StorageService, private sessionService: SessionService - ) {} - - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; - - if (!accessToken) { - this.logger.warn('No Matrix access token configured, bot disabled'); - return; - } - - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - this.client = new MatrixClient(homeserverUrl || 'http://localhost:8008', accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - this.logger.log('Matrix Storage Bot started'); + ) { + super(configService); } - private async handleMessage(roomId: string, event: any) { - if (event.sender === (await this.client.getUserId())) return; - if (event.content?.msgtype !== 'm.text') return; + 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') || [], + }; + } - const body = event.content.body?.trim(); - if (!body?.startsWith('!')) return; - - // Check allowed rooms - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + if (!body.startsWith('!')) return; const sender = event.sender; const parts = body.slice(1).split(/\s+/); @@ -74,7 +48,7 @@ export class MatrixService implements OnModuleInit { switch (command) { case 'help': case 'hilfe': - await this.sendHtml(roomId, HELP_MESSAGE); + await this.sendMessage(roomId, HELP_MESSAGE); break; case 'login': @@ -83,7 +57,7 @@ export class MatrixService implements OnModuleInit { case 'logout': this.sessionService.logout(sender); - await this.sendHtml(roomId, '

        Erfolgreich abgemeldet.

        '); + await this.sendMessage(roomId, '

        Erfolgreich abgemeldet.

        '); break; case 'status': @@ -194,26 +168,17 @@ export class MatrixService implements OnModuleInit { break; default: - await this.sendHtml( + await this.sendMessage( roomId, `

        Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

        ` ); } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); - await this.sendHtml(roomId, `

        Fehler: ${error.message}

        `); + await this.sendMessage(roomId, `

        Fehler: ${(error as Error).message}

        `); } } - private async sendHtml(roomId: string, html: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: html.replace(/<[^>]*>/g, ''), - format: 'org.matrix.custom.html', - formatted_body: html, - }); - } - private requireAuth(sender: string): string { const token = this.sessionService.getToken(sender); if (!token) { @@ -225,7 +190,7 @@ export class MatrixService implements OnModuleInit { // Auth handlers private async handleLogin(roomId: string, sender: string, args: string[]) { if (args.length < 2) { - await this.sendHtml(roomId, '

        Verwendung: !login email passwort

        '); + await this.sendMessage(roomId, '

        Verwendung: !login email passwort

        '); return; } @@ -233,9 +198,9 @@ export class MatrixService implements OnModuleInit { const result = await this.sessionService.login(sender, email, password); if (result.success) { - await this.sendHtml(roomId, `

        Erfolgreich angemeldet als ${email}

        `); + await this.sendMessage(roomId, `

        Erfolgreich angemeldet als ${email}

        `); } else { - await this.sendHtml(roomId, `

        Login fehlgeschlagen: ${result.error}

        `); + await this.sendMessage(roomId, `

        Login fehlgeschlagen: ${result.error}

        `); } } @@ -244,7 +209,7 @@ export class MatrixService implements OnModuleInit { const loggedIn = this.sessionService.isLoggedIn(sender); const sessions = this.sessionService.getSessionCount(); - await this.sendHtml( + await this.sendMessage( roomId, `

        Storage Bot Status

          @@ -263,7 +228,7 @@ export class MatrixService implements OnModuleInit { if (folderNumStr) { const folder = this.getFolderByNumber(sender, folderNumStr); if (!folder) { - await this.sendHtml(roomId, '

          Ungueltige Ordner-Nummer.

          '); + await this.sendMessage(roomId, '

          Ungueltige Ordner-Nummer.

          '); return; } parentFolderId = folder.id; @@ -275,7 +240,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.getFiles(token, parentFolderId); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -283,7 +248,7 @@ export class MatrixService implements OnModuleInit { this.lastFilesList.set(sender, files); if (files.length === 0) { - await this.sendHtml(roomId, '

          Keine Dateien vorhanden.

          '); + await this.sendMessage(roomId, '

          Keine Dateien vorhanden.

          '); return; } @@ -296,7 +261,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

          Nutze !datei [nr] fuer Details oder !download [nr]

          '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleFileDetails(roomId: string, sender: string, numberStr: string) { @@ -304,13 +269,13 @@ export class MatrixService implements OnModuleInit { const file = this.getFileByNumber(sender, numberStr); if (!file) { - await this.sendHtml(roomId, '

          Ungueltige Nummer. Nutze zuerst !dateien

          '); + await this.sendMessage(roomId, '

          Ungueltige Nummer. Nutze zuerst !dateien

          '); return; } const result = await this.storageService.getFile(token, file.id); if (result.error) { - await this.sendHtml(roomId, `

          Fehler: ${result.error}

          `); + await this.sendMessage(roomId, `

          Fehler: ${result.error}

          `); return; } @@ -325,7 +290,7 @@ export class MatrixService implements OnModuleInit { html += '
        '; html += `

        Nutze !download ${numberStr} fuer Download-Link

        `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleDownload(roomId: string, sender: string, numberStr: string) { @@ -333,18 +298,18 @@ export class MatrixService implements OnModuleInit { const file = this.getFileByNumber(sender, numberStr); if (!file) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); return; } const result = await this.storageService.getDownloadUrl(token, file.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml( + await this.sendMessage( roomId, `

        ${file.name}

        Download: ${result.data!.url}

        ` ); @@ -355,24 +320,24 @@ export class MatrixService implements OnModuleInit { const file = this.getFileByNumber(sender, numberStr); if (!file) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); return; } const result = await this.storageService.deleteFile(token, file.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastFilesList.delete(sender); - await this.sendHtml(roomId, `

        ${file.name} in Papierkorb verschoben.

        `); + await this.sendMessage(roomId, `

        ${file.name} in Papierkorb verschoben.

        `); } private async handleRenameFile(roomId: string, sender: string, numberStr: string, newName: string) { if (!newName) { - await this.sendHtml(roomId, '

        Verwendung: !umbenennen [nr] neuer name

        '); + await this.sendMessage(roomId, '

        Verwendung: !umbenennen [nr] neuer name

        '); return; } @@ -380,18 +345,18 @@ export class MatrixService implements OnModuleInit { const file = this.getFileByNumber(sender, numberStr); if (!file) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); return; } const result = await this.storageService.renameFile(token, file.id, newName); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml(roomId, `

        ${file.name} umbenannt zu ${newName}

        `); + await this.sendMessage(roomId, `

        ${file.name} umbenannt zu ${newName}

        `); } private async handleMoveFile(roomId: string, sender: string, fileNumStr: string, folderNumStr: string) { @@ -399,7 +364,7 @@ export class MatrixService implements OnModuleInit { const file = this.getFileByNumber(sender, fileNumStr); if (!file) { - await this.sendHtml(roomId, '

        Ungueltige Datei-Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Datei-Nummer.

        '); return; } @@ -409,7 +374,7 @@ export class MatrixService implements OnModuleInit { if (folderNumStr && folderNumStr !== '0' && folderNumStr.toLowerCase() !== 'root') { const folder = this.getFolderByNumber(sender, folderNumStr); if (!folder) { - await this.sendHtml(roomId, '

        Ungueltige Ordner-Nummer. Nutze 0 oder root fuer Root.

        '); + await this.sendMessage(roomId, '

        Ungueltige Ordner-Nummer. Nutze 0 oder root fuer Root.

        '); return; } parentFolderId = folder.id; @@ -419,11 +384,11 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.moveFile(token, file.id, parentFolderId); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } - await this.sendHtml(roomId, `

        ${file.name} nach ${folderName} verschoben.

        `); + await this.sendMessage(roomId, `

        ${file.name} nach ${folderName} verschoben.

        `); } // Folder handlers @@ -434,7 +399,7 @@ export class MatrixService implements OnModuleInit { if (folderNumStr) { const folder = this.getFolderByNumber(sender, folderNumStr); if (!folder) { - await this.sendHtml(roomId, '

        Ungueltige Ordner-Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Ordner-Nummer.

        '); return; } parentFolderId = folder.id; @@ -443,7 +408,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.getFolders(token, parentFolderId); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -451,7 +416,7 @@ export class MatrixService implements OnModuleInit { this.lastFoldersList.set(sender, folders); if (folders.length === 0) { - await this.sendHtml(roomId, '

        Keine Ordner vorhanden.

        '); + await this.sendMessage(roomId, '

        Keine Ordner vorhanden.

        '); return; } @@ -464,12 +429,12 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

        Nutze !dateien [nr] um Dateien im Ordner zu sehen

        '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleCreateFolder(roomId: string, sender: string, args: string[]) { if (args.length === 0) { - await this.sendHtml(roomId, '

        Verwendung: !neuordner Name [in-ordner-nr]

        '); + await this.sendMessage(roomId, '

        Verwendung: !neuordner Name [in-ordner-nr]

        '); return; } @@ -491,12 +456,12 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.createFolder(token, name, parentFolderId); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastFoldersList.delete(sender); - await this.sendHtml(roomId, `

        Ordner ${result.data!.name} erstellt.

        `); + await this.sendMessage(roomId, `

        Ordner ${result.data!.name} erstellt.

        `); } private async handleDeleteFolder(roomId: string, sender: string, numberStr: string) { @@ -504,19 +469,19 @@ export class MatrixService implements OnModuleInit { const folder = this.getFolderByNumber(sender, numberStr); if (!folder) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !ordner

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !ordner

        '); return; } const result = await this.storageService.deleteFolder(token, folder.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastFoldersList.delete(sender); - await this.sendHtml(roomId, `

        Ordner ${folder.name} in Papierkorb verschoben.

        `); + await this.sendMessage(roomId, `

        Ordner ${folder.name} in Papierkorb verschoben.

        `); } // Share handlers @@ -529,11 +494,11 @@ export class MatrixService implements OnModuleInit { const file = this.getFileByNumber(sender, numberStr); if (!file) { - await this.sendHtml(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer. Nutze zuerst !dateien

        '); return; } - const options: any = {}; + const options: { expiresInDays?: number; password?: string; maxDownloads?: number } = {}; // Parse --tage N const daysMatch = argString.match(/--tage\s+(\d+)/i); @@ -556,7 +521,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.createShare(token, file.id, options); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -569,7 +534,7 @@ export class MatrixService implements OnModuleInit { if (options.password) html += `

        Passwort geschuetzt

        `; if (options.maxDownloads) html += `

        Max Downloads: ${options.maxDownloads}

        `; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleListShares(roomId: string, sender: string) { @@ -577,7 +542,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.getShares(token); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -585,7 +550,7 @@ export class MatrixService implements OnModuleInit { this.lastSharesList.set(sender, shares); if (shares.length === 0) { - await this.sendHtml(roomId, '

        Keine Share-Links vorhanden.

        '); + await this.sendMessage(roomId, '

        Keine Share-Links vorhanden.

        '); return; } @@ -598,7 +563,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

        Nutze !linkloeschen [nr] zum Loeschen

        '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleDeleteShare(roomId: string, sender: string, numberStr: string) { @@ -606,13 +571,13 @@ export class MatrixService implements OnModuleInit { const shares = this.lastSharesList.get(sender); if (!shares) { - await this.sendHtml(roomId, '

        Nutze zuerst !links

        '); + await this.sendMessage(roomId, '

        Nutze zuerst !links

        '); return; } const index = parseInt(numberStr, 10) - 1; if (isNaN(index) || index < 0 || index >= shares.length) { - await this.sendHtml(roomId, '

        Ungueltige Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer.

        '); return; } @@ -620,18 +585,18 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.deleteShare(token, share.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastSharesList.delete(sender); - await this.sendHtml(roomId, '

        Share-Link geloescht.

        '); + await this.sendMessage(roomId, '

        Share-Link geloescht.

        '); } // Search & Favorites private async handleSearch(roomId: string, sender: string, query: string) { if (!query) { - await this.sendHtml(roomId, '

        Verwendung: !suche Begriff

        '); + await this.sendMessage(roomId, '

        Verwendung: !suche Begriff

        '); return; } @@ -639,7 +604,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.search(token, query); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -648,7 +613,7 @@ export class MatrixService implements OnModuleInit { this.lastFoldersList.set(sender, folders); if (files.length === 0 && folders.length === 0) { - await this.sendHtml(roomId, `

        Keine Ergebnisse fuer "${query}"

        `); + await this.sendMessage(roomId, `

        Keine Ergebnisse fuer "${query}"

        `); return; } @@ -670,7 +635,7 @@ export class MatrixService implements OnModuleInit { html += ''; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleFavorites(roomId: string, sender: string) { @@ -678,7 +643,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.getFavorites(token); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -687,7 +652,7 @@ export class MatrixService implements OnModuleInit { this.lastFoldersList.set(sender, folders); if (files.length === 0 && folders.length === 0) { - await this.sendHtml(roomId, '

        Keine Favoriten vorhanden.

        '); + await this.sendMessage(roomId, '

        Keine Favoriten vorhanden.

        '); return; } @@ -709,7 +674,7 @@ export class MatrixService implements OnModuleInit { html += ''; } - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleToggleFavorite(roomId: string, sender: string, numberStr: string) { @@ -720,11 +685,11 @@ export class MatrixService implements OnModuleInit { if (file) { const result = await this.storageService.toggleFileFavorite(token, file.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } const status = result.data!.isFavorite ? 'hinzugefuegt' : 'entfernt'; - await this.sendHtml(roomId, `

        ${file.name}: Favorit ${status}

        `); + await this.sendMessage(roomId, `

        ${file.name}: Favorit ${status}

        `); return; } @@ -733,15 +698,15 @@ export class MatrixService implements OnModuleInit { if (folder) { const result = await this.storageService.toggleFolderFavorite(token, folder.id); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } const status = result.data!.isFavorite ? 'hinzugefuegt' : 'entfernt'; - await this.sendHtml(roomId, `

        ${folder.name}: Favorit ${status}

        `); + await this.sendMessage(roomId, `

        ${folder.name}: Favorit ${status}

        `); return; } - await this.sendHtml(roomId, '

        Ungueltige Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer.

        '); } // Trash handlers @@ -750,7 +715,7 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.getTrash(token); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } @@ -758,7 +723,7 @@ export class MatrixService implements OnModuleInit { this.lastTrashList.set(sender, items); if (items.length === 0) { - await this.sendHtml(roomId, '

        Papierkorb ist leer.

        '); + await this.sendMessage(roomId, '

        Papierkorb ist leer.

        '); return; } @@ -771,7 +736,7 @@ export class MatrixService implements OnModuleInit { html += ''; html += '

        Nutze !wiederherstellen [nr] oder !leeren

        '; - await this.sendHtml(roomId, html); + await this.sendMessage(roomId, html); } private async handleRestore(roomId: string, sender: string, numberStr: string) { @@ -779,13 +744,13 @@ export class MatrixService implements OnModuleInit { const items = this.lastTrashList.get(sender); if (!items) { - await this.sendHtml(roomId, '

        Nutze zuerst !papierkorb

        '); + await this.sendMessage(roomId, '

        Nutze zuerst !papierkorb

        '); return; } const index = parseInt(numberStr, 10) - 1; if (isNaN(index) || index < 0 || index >= items.length) { - await this.sendHtml(roomId, '

        Ungueltige Nummer.

        '); + await this.sendMessage(roomId, '

        Ungueltige Nummer.

        '); return; } @@ -793,12 +758,12 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.restoreFromTrash(token, item.id, item.type); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastTrashList.delete(sender); - await this.sendHtml(roomId, `

        ${item.name} wiederhergestellt.

        `); + await this.sendMessage(roomId, `

        ${item.name} wiederhergestellt.

        `); } private async handleEmptyTrash(roomId: string, sender: string) { @@ -806,12 +771,12 @@ export class MatrixService implements OnModuleInit { const result = await this.storageService.emptyTrash(token); if (result.error) { - await this.sendHtml(roomId, `

        Fehler: ${result.error}

        `); + await this.sendMessage(roomId, `

        Fehler: ${result.error}

        `); return; } this.lastTrashList.delete(sender); - await this.sendHtml(roomId, '

        Papierkorb geleert.

        '); + await this.sendMessage(roomId, '

        Papierkorb geleert.

        '); } // Helper methods diff --git a/services/matrix-todo-bot/src/bot/matrix.service.ts b/services/matrix-todo-bot/src/bot/matrix.service.ts index 4b8cb1fe9..907155998 100644 --- a/services/matrix-todo-bot/src/bot/matrix.service.ts +++ b/services/matrix-todo-bot/src/bot/matrix.service.ts @@ -1,13 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import * as path from 'path'; -import * as fs from 'fs'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { TodoService, Task } from '../todo/todo.service'; import { TranscriptionService } from '@manacore/bot-services'; import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; @@ -26,137 +23,34 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client: MatrixClient; - private readonly homeserverUrl: string; - private readonly accessToken: string; - private readonly allowedRooms: string[]; - private readonly storagePath: string; - +export class MatrixService extends BaseMatrixService { constructor( - private configService: ConfigService, + configService: ConfigService, private todoService: TodoService, private transcriptionService: TranscriptionService ) { - this.homeserverUrl = this.configService.get( - 'matrix.homeserverUrl', - 'http://localhost:8008' - ); - this.accessToken = this.configService.get('matrix.accessToken', ''); - this.allowedRooms = this.configService.get('matrix.allowedRooms', []); - this.storagePath = this.configService.get( - 'matrix.storagePath', - './data/bot-storage.json' - ); + super(configService); } - async onModuleInit() { - if (!this.accessToken) { - this.logger.warn('No Matrix access token configured. Bot will not start.'); - return; - } - - await this.initializeClient(); + 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') || [], + }; } - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - } + protected getIntroductionMessage(): string { + return BOT_INTRODUCTION; } - private async initializeClient() { - try { - // Ensure storage directory exists - const storageDir = path.dirname(this.storagePath); - if (!fs.existsSync(storageDir)) { - fs.mkdirSync(storageDir, { recursive: true }); - } - - const storage = new SimpleFsStorageProvider(this.storagePath); - this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); - - // Auto-join rooms when invited - AutojoinRoomsMixin.setupOnClient(this.client); - - // Handle room invites with introduction - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - // Send introduction after a short delay - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Handle member joins for welcome message - this.client.on('room.event', async (roomId: string, event: any) => { - if (event.type === 'm.room.member' && event.content?.membership === 'join') { - const userId = event.state_key; - const botUserId = await this.client.getUserId(); - - // Don't welcome the bot itself - if (userId === botUserId) return; - - // Check if this is a new join (not just profile update) - if (event.unsigned?.prev_content?.membership !== 'join') { - await this.sendWelcomeMessage(roomId, userId); - } - } - }); - - // Set up message handler - this.client.on('room.message', async (roomId: string, event: any) => { - await this.handleMessage(roomId, event); - }); - - await this.client.start(); - this.logger.log(`Matrix Todo Bot connected to ${this.homeserverUrl}`); - - const userId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${userId}`); - - if (this.allowedRooms.length > 0) { - this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`); - } else { - this.logger.log('No room restrictions - bot will respond in all rooms'); - } - } catch (error) { - this.logger.error('Failed to initialize Matrix client:', error); - } - } - - private async handleMessage(roomId: string, event: any) { - // Ignore messages from the bot itself - const botUserId = await this.client.getUserId(); - if (event.sender === botUserId) return; - - // Check if room is allowed - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); - return; - } - + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { const userId = event.sender; - const msgtype = event.content?.msgtype; - - // Handle audio/voice messages - if (msgtype === 'm.audio' && event.content?.url) { - await this.handleAudioMessage(roomId, event, userId); - return; - } - - // Only handle text messages - if (msgtype !== 'm.text') return; - - const body = event.content.body?.trim(); - if (!body) return; try { // Check for natural language keywords first @@ -176,11 +70,76 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { await this.sendReply( roomId, event, - '❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.' + 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.' ); } } + protected async handleAudioMessage( + roomId: string, + event: MatrixRoomEvent, + sender: string + ): Promise { + const content = event.content; + if (!content?.url) return; + + try { + await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); + + // Download audio from Matrix + const mxcUrl = content.url; + const httpUrl = this.client.mxcToHttp(mxcUrl); + this.logger.log(`Downloading audio from ${httpUrl}`); + + const response = await fetch(httpUrl); + if (!response.ok) { + throw new Error(`Failed to download audio: ${response.status}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + + // 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; + } + + // Parse the transcription as a task input + const { title, priority, dueDate, project } = this.todoService.parseTaskInput(transcription); + + // Create the task + const task = await this.todoService.createTask(sender, title, { + priority, + dueDate, + project, + }); + + let responseText = `Transkription: "${transcription}"\n\nAufgabe erstellt: **${task.title}**`; + + const details: string[] = []; + if (priority < 4) details.push(`Prioritat ${priority}`); + if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`); + if (project) details.push(`Projekt: ${project}`); + + if (details.length > 0) { + responseText += `\n${details.join(' | ')}`; + } + + await this.sendReply(roomId, event, 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 detectKeywordCommand(message: string): string | null { const lowerMessage = message.toLowerCase().trim(); @@ -204,7 +163,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { private async executeCommand( roomId: string, - event: any, + event: MatrixRoomEvent, userId: string, command: string, args: string @@ -273,12 +232,12 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleAddTask(roomId: string, event: any, userId: string, input: string) { + 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: `!add Einkaufen gehen`' + 'Bitte gib eine Aufgabe an.\n\nBeispiel: `!add Einkaufen gehen`' ); return; } @@ -291,72 +250,72 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { project, }); - let response = `✅ Aufgabe erstellt: **${task.title}**`; + let response = `Aufgabe erstellt: **${task.title}**`; const details: string[] = []; - if (priority < 4) details.push(`Priorität ${priority}`); + if (priority < 4) details.push(`Prioritaet ${priority}`); if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`); if (project) details.push(`Projekt: ${project}`); if (details.length > 0) { - response += `\n📋 ${details.join(' | ')}`; + response += `\n${details.join(' | ')}`; } await this.sendReply(roomId, event, response); } - private async handleListTasks(roomId: string, event: any, userId: string) { + private async handleListTasks(roomId: string, event: MatrixRoomEvent, userId: string) { const tasks = await this.todoService.getAllPendingTasks(userId); if (tasks.length === 0) { await this.sendReply( roomId, event, - '📭 Keine offenen Aufgaben.\n\nErstelle eine mit `!add [Aufgabe]`' + 'Keine offenen Aufgaben.\n\nErstelle eine mit `!add [Aufgabe]`' ); return; } - const response = this.formatTaskList('📋 **Alle offenen Aufgaben:**', tasks); + const response = this.formatTaskList('**Alle offenen Aufgaben:**', tasks); await this.sendReply(roomId, event, response); } - private async handleTodayTasks(roomId: string, event: any, userId: string) { + private async handleTodayTasks(roomId: string, event: MatrixRoomEvent, userId: string) { const tasks = await this.todoService.getTodayTasks(userId); if (tasks.length === 0) { await this.sendReply( roomId, event, - '📭 Keine Aufgaben für heute.\n\nErstelle eine mit `!add Aufgabe @heute`' + 'Keine Aufgaben fuer heute.\n\nErstelle eine mit `!add Aufgabe @heute`' ); return; } - const response = this.formatTaskList('📅 **Aufgaben für heute:**', tasks); + const response = this.formatTaskList('**Aufgaben fuer heute:**', tasks); await this.sendReply(roomId, event, response); } - private async handleInboxTasks(roomId: string, event: any, userId: string) { + private async handleInboxTasks(roomId: string, event: MatrixRoomEvent, userId: string) { const tasks = await this.todoService.getInboxTasks(userId); if (tasks.length === 0) { - await this.sendReply(roomId, event, '📭 Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'); + await this.sendReply(roomId, event, 'Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'); return; } - const response = this.formatTaskList('📥 **Inbox (ohne Datum):**', tasks); + const response = this.formatTaskList('**Inbox (ohne Datum):**', tasks); await this.sendReply(roomId, event, response); } - private async handleCompleteTask(roomId: string, event: any, userId: string, args: string) { + 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 gültige Aufgabennummer an.\n\nBeispiel: `!done 1`' + 'Bitte gib eine gueltige Aufgabennummer an.\n\nBeispiel: `!done 1`' ); return; } @@ -364,21 +323,21 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { const task = await this.todoService.completeTask(userId, taskNumber); if (!task) { - await this.sendReply(roomId, event, `❌ Aufgabe #${taskNumber} nicht gefunden.`); + await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`); return; } - await this.sendReply(roomId, event, `✅ Erledigt: ~~${task.title}~~`); + await this.sendReply(roomId, event, `Erledigt: ~~${task.title}~~`); } - private async handleDeleteTask(roomId: string, event: any, userId: string, args: string) { + 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 gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`' + 'Bitte gib eine gueltige Aufgabennummer an.\n\nBeispiel: `!delete 1`' ); return; } @@ -386,42 +345,42 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { const task = await this.todoService.deleteTask(userId, taskNumber); if (!task) { - await this.sendReply(roomId, event, `❌ Aufgabe #${taskNumber} nicht gefunden.`); + await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`); return; } - await this.sendReply(roomId, event, `🗑️ Gelöscht: ${task.title}`); + await this.sendReply(roomId, event, `Geloescht: ${task.title}`); } - private async handleProjects(roomId: string, event: any, userId: string) { + private async handleProjects(roomId: string, event: MatrixRoomEvent, userId: string) { const projects = await this.todoService.getProjects(userId); if (projects.length === 0) { await this.sendReply( roomId, event, - '📭 Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!add Aufgabe #projektname`' + 'Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!add Aufgabe #projektname`' ); return; } - let response = '📁 **Deine Projekte:**\n\n'; + let response = '**Deine Projekte:**\n\n'; for (const project of projects) { - response += `• ${project.name}\n`; + response += `- ${project.name}\n`; } response += '\nZeige Projektaufgaben mit `!project [Name]`'; await this.sendReply(roomId, event, response); } - private async handleProjectTasks(roomId: string, event: any, userId: string, args: string) { + 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: `!project Arbeit`' + 'Bitte gib einen Projektnamen an.\n\nBeispiel: `!project Arbeit`' ); return; } @@ -429,51 +388,46 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { const tasks = await this.todoService.getProjectTasks(userId, projectName); if (tasks.length === 0) { - await this.sendReply(roomId, event, `📭 Keine Aufgaben im Projekt "${projectName}".`); + await this.sendReply(roomId, event, `Keine Aufgaben im Projekt "${projectName}".`); return; } - const response = this.formatTaskList(`📁 **Projekt: ${projectName}**`, tasks); + const response = this.formatTaskList(`**Projekt: ${projectName}**`, tasks); await this.sendReply(roomId, event, response); } - private async handleStatus(roomId: string, event: any, userId: string) { + private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) { const stats = await this.todoService.getStats(userId); - const response = `📊 **Status** + const response = `**Status** -• Offene Aufgaben: ${stats.pending} -• Heute fällig: ${stats.today} -• Erledigt: ${stats.completed} -• Gesamt: ${stats.total} +- Offene Aufgaben: ${stats.pending} +- Heute faellig: ${stats.today} +- Erledigt: ${stats.completed} +- Gesamt: ${stats.total} -Bot: ✅ Online`; +Bot: Online`; await this.sendReply(roomId, event, response); } - private async handlePinHelp(roomId: string, event: any) { + private async handlePinHelp(roomId: string, event: MatrixRoomEvent) { try { // Send help message - const helpEventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(HELP_TEXT), - }); + 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!'); + 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?)' + 'Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)' ); } } @@ -483,14 +437,14 @@ Bot: ✅ Online`; 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}` : ''; + 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 += `\n✅ Erledigen: \`!done [Nr]\` | 🗑️ Löschen: \`!delete [Nr]\``; + response += `\nErledigen: \`!done [Nr]\` | Loeschen: \`!delete [Nr]\``; return response; } @@ -508,117 +462,4 @@ Bot: ✅ Online`; return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); } - - private async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); - } - - private async sendWelcomeMessage(roomId: string, userId: string) { - try { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: WELCOME_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(WELCOME_TEXT), - }); - this.logger.log(`Sent welcome message to ${userId} in ${roomId}`); - } catch (error) { - this.logger.error(`Failed to send welcome message: ${error}`); - } - } - - private async sendBotIntroduction(roomId: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: BOT_INTRODUCTION, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(BOT_INTRODUCTION), - }); - - // Try to pin the help message - try { - const helpEventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(HELP_TEXT), - }); - - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [helpEventId], - }); - this.logger.log(`Pinned help message in ${roomId}`); - } catch (error) { - this.logger.debug(`Could not pin help (might lack permissions): ${error}`); - } - } - - private async handleAudioMessage(roomId: string, event: any, userId: string) { - try { - await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); - - // Download audio from Matrix - const mxcUrl = event.content.url; - const httpUrl = this.client.mxcToHttp(mxcUrl); - this.logger.log(`Downloading audio from ${httpUrl}`); - - const response = await fetch(httpUrl); - if (!response.ok) { - throw new Error(`Failed to download audio: ${response.status}`); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - - // 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; - } - - // Parse the transcription as a task input - const { title, priority, dueDate, project } = this.todoService.parseTaskInput(transcription); - - // Create the task - const task = await this.todoService.createTask(userId, title, { - priority, - dueDate, - project, - }); - - let responseText = `Transkription: "${transcription}"\n\n✅ Aufgabe erstellt: **${task.title}**`; - - const details: string[] = []; - if (priority < 4) details.push(`Prioritat ${priority}`); - if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`); - if (project) details.push(`Projekt: ${project}`); - - if (details.length > 0) { - responseText += `\n${details.join(' | ')}`; - } - - await this.sendReply(roomId, event, 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 markdownToHtml(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\n/g, '
        '); - } } diff --git a/services/matrix-tts-bot/src/bot/matrix.service.ts b/services/matrix-tts-bot/src/bot/matrix.service.ts index 2110602ee..2eb6ee5df 100644 --- a/services/matrix-tts-bot/src/bot/matrix.service.ts +++ b/services/matrix-tts-bot/src/bot/matrix.service.ts @@ -1,13 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import * as path from 'path'; -import * as fs from 'fs'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { TtsService } from '../tts/tts.service'; import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration'; @@ -17,17 +14,10 @@ interface UserSettings { } @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private readonly homeserverUrl: string; - private readonly accessToken: string; - private readonly allowedRooms: string[]; - private readonly storagePath: string; +export class MatrixService extends BaseMatrixService { private readonly defaultVoice: string; private readonly defaultSpeed: number; private readonly maxTextLength: number; - private botUserId: string = ''; // User settings storage (in-memory) private userSettings: Map = new Map(); @@ -36,73 +26,29 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { private processedEvents: Set = new Set(); constructor( - private configService: ConfigService, + configService: ConfigService, private ttsService: TtsService ) { - this.homeserverUrl = this.configService.get( - 'matrix.homeserverUrl', - 'http://localhost:8008' - ); - this.accessToken = this.configService.get('matrix.accessToken', ''); - this.allowedRooms = this.configService.get('matrix.allowedRooms', []); - this.storagePath = this.configService.get( - 'matrix.storagePath', - './data/bot-storage.json' - ); - this.defaultVoice = this.configService.get('tts.defaultVoice', 'af_heart'); - this.defaultSpeed = this.configService.get('tts.defaultSpeed', 1.0); - this.maxTextLength = this.configService.get('tts.maxTextLength', 500); + super(configService); + this.defaultVoice = this.configService.get('tts.defaultVoice') || 'af_heart'; + this.defaultSpeed = this.configService.get('tts.defaultSpeed') || 1.0; + this.maxTextLength = this.configService.get('tts.maxTextLength') || 500; } - async onModuleInit() { - if (!this.accessToken) { - this.logger.warn('No Matrix access token configured. Bot will not start.'); - return; - } - - await this.initializeClient(); + 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') || [], + }; } - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - } + protected getIntroductionMessage(): string { + return WELCOME_TEXT; } - private async initializeClient() { - try { - const storageDir = path.dirname(this.storagePath); - if (!fs.existsSync(storageDir)) { - fs.mkdirSync(storageDir, { recursive: true }); - } - - const storage = new SimpleFsStorageProvider(this.storagePath); - this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); - - AutojoinRoomsMixin.setupOnClient(this.client); - - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - await this.sendWelcome(roomId); - }, 2000); - }); - - this.client.on('room.message', async (roomId: string, event: any) => { - await this.handleMessage(roomId, event); - }); - - await this.client.start(); - this.botUserId = await this.client.getUserId(); - this.logger.log(`Matrix TTS Bot connected as ${this.botUserId}`); - } catch (error) { - this.logger.error('Failed to initialize Matrix client:', error); - } - } - - private async handleMessage(roomId: string, event: any) { + protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise { // Ignore own messages if (event.sender === this.botUserId) return; @@ -116,24 +62,36 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { // Clean up old events (keep last 1000) if (this.processedEvents.size > 1000) { const iterator = this.processedEvents.values(); - this.processedEvents.delete(iterator.next().value); + const firstValue = iterator.next().value; + if (firstValue) { + this.processedEvents.delete(firstValue); + } } } // Check room allowlist - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + if (!this.isRoomAllowed(roomId)) { return; } - const userId = event.sender; const msgtype = event.content?.msgtype; // Only handle text messages if (msgtype !== 'm.text') return; - const body = event.content.body?.trim(); + const body = event.content?.body?.trim(); if (!body) return; + await this.handleTextMessage(roomId, event, body); + } + + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + const userId = event.sender; + try { // Handle ! commands if (body.startsWith('!')) { @@ -152,7 +110,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { private async executeCommand( roomId: string, - event: any, + event: MatrixRoomEvent, userId: string, command: string, args: string @@ -188,7 +146,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleVoiceCommand(roomId: string, event: any, userId: string, args: string) { + private async handleVoiceCommand(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { if (!args.trim()) { await this.sendReply( roomId, @@ -216,14 +174,14 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { settings.voice = voiceName; this.userSettings.set(userId, settings); - await this.sendReply(roomId, event, `Stimme geandert zu: **${voiceName}**`); + await this.sendReply(roomId, event, `Stimme geaendert zu: **${voiceName}**`); } - private async handleVoicesCommand(roomId: string, event: any) { + private async handleVoicesCommand(roomId: string, event: MatrixRoomEvent) { try { const voices = await this.ttsService.getVoices(); - let response = '**Verfugbare Stimmen:**\n\n'; + let response = '**Verfuegbare Stimmen:**\n\n'; if (voices.kokoro_voices.length > 0) { response += '**Kokoro (schnell):**\n'; @@ -252,7 +210,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } } - private async handleSpeedCommand(roomId: string, event: any, userId: string, args: string) { + private async handleSpeedCommand(roomId: string, event: MatrixRoomEvent, userId: string, args: string) { if (!args.trim()) { await this.sendReply( roomId, @@ -272,23 +230,23 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { settings.speed = speed; this.userSettings.set(userId, settings); - await this.sendReply(roomId, event, `Geschwindigkeit geandert zu: **${speed}x**`); + await this.sendReply(roomId, event, `Geschwindigkeit geaendert zu: **${speed}x**`); } - private async handleStatusCommand(roomId: string, event: any, userId: string) { + private async handleStatusCommand(roomId: string, event: MatrixRoomEvent, userId: string) { const settings = this.getUserSettings(userId); const ttsHealthy = await this.ttsService.isHealthy(); let response = '**Aktuelle Einstellungen:**\n\n'; response += `Stimme: \`${settings.voice}\`\n`; response += `Geschwindigkeit: ${settings.speed}x\n`; - response += `Max. Textlange: ${this.maxTextLength} Zeichen\n\n`; + response += `Max. Textlaenge: ${this.maxTextLength} Zeichen\n\n`; response += `TTS-Service: ${ttsHealthy ? 'Online' : 'Offline'}`; await this.sendReply(roomId, event, response); } - private async handleTextToSpeech(roomId: string, event: any, userId: string, text: string) { + private async handleTextToSpeech(roomId: string, event: MatrixRoomEvent, userId: string, text: string) { // Check text length if (text.length > this.maxTextLength) { await this.sendReply( @@ -350,31 +308,4 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy { } return this.userSettings.get(userId)!; } - - private async sendWelcome(roomId: string) { - try { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: WELCOME_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(WELCOME_TEXT), - }); - } catch (error) { - this.logger.error('Failed to send welcome:', error); - } - } - - private async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); - } - - private markdownToHtml(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\n/g, '
        '); - } } diff --git a/services/matrix-zitare-bot/src/bot/matrix.service.ts b/services/matrix-zitare-bot/src/bot/matrix.service.ts index 6f586777e..30d895189 100644 --- a/services/matrix-zitare-bot/src/bot/matrix.service.ts +++ b/services/matrix-zitare-bot/src/bot/matrix.service.ts @@ -1,12 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - MatrixClient, - SimpleFsStorageProvider, - RichConsoleLogger, - LogService, - LogLevel, -} from 'matrix-bot-sdk'; + BaseMatrixService, + MatrixBotConfig, + MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; import { QuotesService } from '../quotes/quotes.service'; import { ZitareService } from '../quotes/zitare.service'; import { SessionService, TranscriptionService } from '@manacore/bot-services'; @@ -26,140 +24,135 @@ const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ ]; @Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MatrixService.name); - private client!: MatrixClient; - private readonly allowedRooms: string[]; - private botUserId: string = ''; - +export class MatrixService extends BaseMatrixService { // Track last shown quote per user for favorites private lastQuotes: Map = new Map(); constructor( - private configService: ConfigService, + configService: ConfigService, private quotesService: QuotesService, private zitareService: ZitareService, private sessionService: SessionService, private transcriptionService: TranscriptionService ) { - this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + super(configService); } - async onModuleInit() { - const homeserverUrl = this.configService.get('matrix.homeserverUrl'); - const accessToken = this.configService.get('matrix.accessToken'); - const storagePath = this.configService.get('matrix.storagePath'); - - if (!accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } - - // Setup logging - LogService.setLogger(new RichConsoleLogger()); - LogService.setLevel(LogLevel.INFO); - - // Storage for sync token persistence - const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); - - // Create Matrix client - this.client = new MatrixClient(homeserverUrl!, accessToken, storage); - - // Auto-join rooms when invited - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Get bot's user ID - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - // Setup message handler - this.client.on('room.message', this.handleRoomMessage.bind(this)); - - // Start the client - await this.client.start(); - this.logger.log('Matrix Zitare Bot started successfully'); + 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') || [], + }; } - async onModuleDestroy() { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix bot stopped'); - } - } - - private async sendBotIntroduction(roomId: string) { + protected getIntroductionMessage(): string { const dailyQuote = this.quotesService.getDailyQuote(); - const introText = `**Zitare Bot - Tagliche Inspiration** + return `**Zitare Bot - Taegliche Inspiration** Ich bringe dir jeden Tag neue Inspiration! **Zitat des Tages:** ${this.quotesService.formatQuote(dailyQuote)} -Sag "hilfe" fur alle Befehle!`; - - await this.sendMessage(roomId, introText); +Sag "hilfe" fuer alle Befehle!`; } - private isRoomAllowed(roomId: string): boolean { - if (this.allowedRooms.length === 0) return true; - return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); - } + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + body: string + ): Promise { + const sender = event.sender; - private async handleRoomMessage(roomId: string, event: any) { - // Ignore messages from self - if (event.sender === this.botUserId) return; - - // Check if room is allowed - if (!this.isRoomAllowed(roomId)) { - this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); - return; - } - - const content = event.content as { msgtype?: string; body?: string; url?: string }; - - // Handle audio/voice messages - if (content.msgtype === 'm.audio') { - await this.handleAudioMessage(roomId, event.sender, content); - return; - } - - // Only handle text messages - if (content.msgtype !== 'm.text') return; - - const body = content.body; - if (!body) return; - - this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); + this.logger.log(`Message from ${sender} in ${roomId}: ${body.substring(0, 50)}...`); // Handle commands with ! prefix if (body.startsWith('!')) { - await this.handleCommand(roomId, event.sender, body); + await this.handleCommand(roomId, sender, body); return; } // Check for natural language keywords const keywordCommand = this.detectKeywordCommand(body); if (keywordCommand) { - await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + await this.handleCommand(roomId, sender, `!${keywordCommand}`); return; } // Don't respond to random messages } + protected async handleAudioMessage( + roomId: string, + event: MatrixRoomEvent, + sender: string + ): Promise { + const content = event.content; + if (!content?.url) { + this.logger.warn('Audio message without URL'); + return; + } + + this.logger.log(`Processing voice message from ${sender}`); + + try { + // Download audio from Matrix + const httpUrl = this.client.mxcToHttp(content.url); + const response = await fetch(httpUrl); + if (!response.ok) { + throw new Error(`Failed to download audio: ${response.status}`); + } + + const audioBuffer = Buffer.from(await response.arrayBuffer()); + + // Transcribe + await this.sendMessage(roomId, 'Transkribiere Sprachnotiz...'); + const transcription = await this.transcriptionService.transcribe(audioBuffer); + + if (!transcription || transcription.trim().length === 0) { + await this.sendMessage(roomId, 'Konnte keine Sprache erkennen.'); + return; + } + + this.logger.log(`Transcription: ${transcription}`); + await this.sendMessage(roomId, `"${transcription}"`); + + // Check for commands in transcription + const cleanText = transcription.trim(); + + // Check for keyword commands in the transcription + const keywordCommand = this.detectKeywordCommand(cleanText); + if (keywordCommand) { + await this.handleCommand(roomId, sender, `!${keywordCommand}`); + return; + } + + // Check for category names + const category = this.quotesService.getCategoryByName(cleanText); + if (category) { + await this.handleCategoryQuote(roomId, sender, category); + return; + } + + // Search for the transcribed text + const results = this.quotesService.searchQuotes(cleanText); + if (results.length > 0) { + const quote = results[0]; + this.lastQuotes.set(sender, quote.id); + await this.sendMessage(roomId, `**Gefunden:**\n\n${this.quotesService.formatQuote(quote)}`); + } else { + // Default to a random quote + await this.handleRandomQuote(roomId, sender); + } + } catch (error) { + this.logger.error('Failed to process audio message:', error); + await this.sendMessage(roomId, 'Fehler bei der Verarbeitung der Sprachnotiz.'); + } + } + private detectKeywordCommand(message: string): string | null { const lowerMessage = message.toLowerCase().trim(); @@ -263,78 +256,11 @@ Sag "hilfe" fur alle Befehle!`; default: await this.sendMessage( roomId, - `Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.` + `Unbekannter Befehl: !${command}\n\nSag "hilfe" fuer alle Befehle.` ); } } - private async handleAudioMessage( - roomId: string, - sender: string, - content: { url?: string; body?: string } - ) { - if (!content.url) { - this.logger.warn('Audio message without URL'); - return; - } - - this.logger.log(`Processing voice message from ${sender}`); - - try { - // Download audio from Matrix - const httpUrl = this.client.mxcToHttp(content.url); - const response = await fetch(httpUrl); - if (!response.ok) { - throw new Error(`Failed to download audio: ${response.status}`); - } - - const audioBuffer = Buffer.from(await response.arrayBuffer()); - - // Transcribe - await this.sendMessage(roomId, '🎤 Transkribiere Sprachnotiz...'); - const transcription = await this.transcriptionService.transcribe(audioBuffer); - - if (!transcription || transcription.trim().length === 0) { - await this.sendMessage(roomId, 'Konnte keine Sprache erkennen.'); - return; - } - - this.logger.log(`Transcription: ${transcription}`); - await this.sendMessage(roomId, `📝 "${transcription}"`); - - // Check for commands in transcription - const cleanText = transcription.trim(); - - // Check for keyword commands in the transcription - const keywordCommand = this.detectKeywordCommand(cleanText); - if (keywordCommand) { - await this.handleCommand(roomId, sender, `!${keywordCommand}`); - return; - } - - // Check for category names - const category = this.quotesService.getCategoryByName(cleanText); - if (category) { - await this.handleCategoryQuote(roomId, sender, category); - return; - } - - // Search for the transcribed text - const results = this.quotesService.searchQuotes(cleanText); - if (results.length > 0) { - const quote = results[0]; - this.lastQuotes.set(sender, quote.id); - await this.sendMessage(roomId, `**Gefunden:**\n\n${this.quotesService.formatQuote(quote)}`); - } else { - // Default to a random quote - await this.handleRandomQuote(roomId, sender); - } - } catch (error) { - this.logger.error('Failed to process audio message:', error); - await this.sendMessage(roomId, 'Fehler bei der Verarbeitung der Sprachnotiz.'); - } - } - private async sendHelp(roomId: string) { await this.sendMessage(roomId, HELP_MESSAGE); } @@ -363,23 +289,23 @@ Sag "hilfe" fur alle Befehle!`; private async handleSearch(roomId: string, sender: string, searchText: string) { if (!searchText.trim()) { - await this.sendMessage(roomId, '**Verwendung:** `!suche [text]`\n\nBeispiel: `!suche Gluck`'); + await this.sendMessage(roomId, '**Verwendung:** `!suche [text]`\n\nBeispiel: `!suche Glueck`'); return; } const results = this.quotesService.searchQuotes(searchText); if (results.length === 0) { - await this.sendMessage(roomId, `Keine Zitate gefunden fur: "${searchText}"`); + await this.sendMessage(roomId, `Keine Zitate gefunden fuer: "${searchText}"`); return; } - let text = `**Suchergebnisse fur "${searchText}" (${results.length}):**\n\n`; + let text = `**Suchergebnisse fuer "${searchText}" (${results.length}):**\n\n`; const maxResults = Math.min(results.length, 5); for (let i = 0; i < maxResults; i++) { const quote = results[i]; - text += `**${i + 1}.** "${quote.text.substring(0, 80)}${quote.text.length > 80 ? '...' : ''}"\n— *${quote.author}*\n\n`; + text += `**${i + 1}.** "${quote.text.substring(0, 80)}${quote.text.length > 80 ? '...' : ''}"\n-- *${quote.author}*\n\n`; } if (results.length > 5) { @@ -404,7 +330,7 @@ Sag "hilfe" fur alle Befehle!`; if (!category) { await this.sendMessage( roomId, - `Kategorie "${categoryName}" nicht gefunden.\n\nNutze \`!kategorien\` fur alle Kategorien.` + `Kategorie "${categoryName}" nicht gefunden.\n\nNutze \`!kategorien\` fuer alle Kategorien.` ); return; } @@ -426,7 +352,7 @@ Sag "hilfe" fur alle Befehle!`; private async handleCategories(roomId: string) { const categories = this.quotesService.getAllCategories(); - let text = `**Verfugbare Kategorien:**\n\n`; + let text = `**Verfuegbare Kategorien:**\n\n`; for (const { category, label, count } of categories) { text += `- **${label}** (\`!kategorie ${category}\`) - ${count} Zitate\n`; } @@ -447,7 +373,7 @@ Sag "hilfe" fur alle Befehle!`; const [email, password] = args; - await this.sendMessage(roomId, 'Anmeldung lauft...'); + await this.sendMessage(roomId, 'Anmeldung laeuft...'); const result = await this.sessionService.login(sender, email, password); @@ -482,7 +408,7 @@ Sag "hilfe" fur alle Befehle!`; const quote = this.quotesService.getQuoteById(lastQuoteId); await this.sendMessage( roomId, - `Zu Favoriten hinzugefugt!\n\n"${quote?.text.substring(0, 50)}..."` + `Zu Favoriten hinzugefuegt!\n\n"${quote?.text.substring(0, 50)}..."` ); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; @@ -514,7 +440,7 @@ Sag "hilfe" fur alle Befehle!`; const fav = favorites[i]; const quote = this.quotesService.getQuoteById(fav.quoteId); if (quote) { - text += `**${i + 1}.** "${quote.text.substring(0, 60)}${quote.text.length > 60 ? '...' : ''}"\n— *${quote.author}*\n\n`; + text += `**${i + 1}.** "${quote.text.substring(0, 60)}${quote.text.length > 60 ? '...' : ''}"\n-- *${quote.author}*\n\n`; } } @@ -583,7 +509,7 @@ Sag "hilfe" fur alle Befehle!`; const list = await this.zitareService.createList(name.trim(), undefined, token); await this.sendMessage( roomId, - `Liste "${list.name}" erstellt!\n\nNutze \`!addliste 1 [zitat-id]\` um Zitate hinzuzufugen.` + `Liste "${list.name}" erstellt!\n\nNutze \`!addliste 1 [zitat-id]\` um Zitate hinzuzufuegen.` ); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; @@ -601,14 +527,14 @@ Sag "hilfe" fur alle Befehle!`; if (args.length < 1) { await this.sendMessage( roomId, - `**Verwendung:** \`!addliste [listen-nr]\`\n\nFugt das letzte angezeigte Zitat zur Liste hinzu.` + `**Verwendung:** \`!addliste [listen-nr]\`\n\nFuegt das letzte angezeigte Zitat zur Liste hinzu.` ); return; } const listIndex = parseInt(args[0], 10); if (isNaN(listIndex) || listIndex < 1) { - await this.sendMessage(roomId, `Ungultige Listennummer.`); + await this.sendMessage(roomId, `Ungueltige Listennummer.`); return; } @@ -616,7 +542,7 @@ Sag "hilfe" fur alle Befehle!`; if (!lastQuoteId) { await this.sendMessage( roomId, - `Kein Zitat zum Hinzufugen. Lass dir erst ein Zitat anzeigen.` + `Kein Zitat zum Hinzufuegen. Lass dir erst ein Zitat anzeigen.` ); return; } @@ -634,7 +560,7 @@ Sag "hilfe" fur alle Befehle!`; const quote = this.quotesService.getQuoteById(lastQuoteId); await this.sendMessage( roomId, - `Zitat zu "${list.name}" hinzugefugt!\n\n"${quote?.text.substring(0, 50)}..."` + `Zitat zu "${list.name}" hinzugefuegt!\n\n"${quote?.text.substring(0, 50)}..."` ); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; @@ -653,7 +579,7 @@ Sag "hilfe" fur alle Befehle!`; **Backend:** ${backendHealthy ? 'Online' : 'Offline'} **Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'} **Aktive Sessions:** ${sessionCount} -**Verfugbare Zitate:** ${totalQuotes} +**Verfuegbare Zitate:** ${totalQuotes} ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; @@ -662,14 +588,7 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; private async pinHelpMessage(roomId: string) { try { - const htmlBody = this.markdownToHtml(HELP_MESSAGE); - - const eventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_MESSAGE, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); + const eventId = await this.sendMessage(roomId, HELP_MESSAGE); await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { pinned: [eventId], @@ -681,33 +600,4 @@ ${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.'); } } - - private async sendMessage(roomId: string, message: string) { - const htmlBody = this.markdownToHtml(message); - - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - } - - private markdownToHtml(markdown: string): string { - return ( - markdown - // Code blocks - .replace(/```(\w+)?\n([\s\S]*?)```/g, '
        $2
        ') - // Inline code - .replace(/`([^`]+)`/g, '$1') - // Bold - .replace(/\*\*([^*]+)\*\*/g, '$1') - // Italic - .replace(/\*([^*]+)\*/g, '$1') - // Underscore italic - .replace(/_([^_]+)_/g, '$1') - // Line breaks - .replace(/\n/g, '
        ') - ); - } }