♻️ refactor: migrate all 18 Matrix bots to extend BaseMatrixService

All Matrix bots now extend BaseMatrixService from @manacore/matrix-bot-common:
- matrix-calendar-bot
- matrix-chat-bot
- matrix-clock-bot
- matrix-contacts-bot
- matrix-mana-bot
- matrix-manadeck-bot
- matrix-nutriphi-bot
- matrix-ollama-bot
- matrix-picture-bot
- matrix-planta-bot
- matrix-presi-bot
- matrix-project-doc-bot
- matrix-questions-bot
- matrix-skilltree-bot
- matrix-storage-bot
- matrix-todo-bot
- matrix-tts-bot
- matrix-zitare-bot

Consolidated code:
- Matrix client initialization (onModuleInit)
- Graceful shutdown (onModuleDestroy)
- sendMessage/sendReply/sendNotice methods
- markdownToHtml conversion
- Room permission checking
- Media upload/download

Estimated code reduction: ~1,500+ lines of duplicate code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-01 02:47:11 +01:00
parent f4d8ed491c
commit 2567ea622c
18 changed files with 1472 additions and 2721 deletions

View file

@ -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<string>(
'matrix.homeserverUrl',
'http://localhost:8008'
);
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
this.storagePath = this.configService.get<string>(
'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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
protected getIntroductionMessage(): string | null {
return BOT_INTRODUCTION;
}
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
// 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, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
}
}

View file

@ -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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
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

View file

@ -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<string>(
'matrix.homeserverUrl',
'http://localhost:8008'
);
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
this.storagePath = this.configService.get<string>(
'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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
protected getIntroductionMessage(): string | null {
return WELCOME_TEXT;
}
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
// 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<void> {
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, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
}
}

View file

@ -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<string, Contact[]> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private contactsService: ContactsService,
private sessionService: SessionService
) {
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
super(configService);
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/_([^_]+)_/g, '<em>$1</em>')
.replace(/\n/g, '<br/>')
);
}
}

View file

@ -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<string>('matrix.homeserverUrl', 'http://localhost:8008');
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
this.storagePath = this.configService.get<string>('matrix.storagePath', './data/mana-bot-storage.json');
super(configService);
}
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/mana-bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
@ -209,7 +139,7 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy {
.replace(/\n/g, '<br>');
}
getClient(): MatrixClient {
getClient() {
return this.client;
}
}

View file

@ -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<string, Deck[]> = new Map();
private lastCardsList: Map<string, { deckId: string; cards: Card[] }> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private manadeckService: ManadeckService,
private sessionService: SessionService
) {}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
const body = event.content.body?.trim();
if (!body?.startsWith('!')) return;
protected async handleTextMessage(
roomId: string,
_event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
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, `<p>Fehler: ${error.message}</p>`);
await this.sendHtml(roomId, `<p>Fehler: ${(error as Error).message}</p>`);
}
}

View file

@ -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<string[]>('matrix.allowedRooms') || [];
super(configService);
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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<void> {
// 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<string> {
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

View file

@ -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<string, UserSession> = new Map();
private readonly allowedRooms: string[];
private botUserId: string = '';
constructor(
private configService: ConfigService,
configService: ConfigService,
private ollamaService: OllamaService
) {
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
super(configService);
}
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('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<void> {
// 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\nFehler: ${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\nFehler: ${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

View file

@ -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<string, string> = new Map();
// Track selected model per user
private userModels: Map<string, string> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private pictureService: PictureService,
private sessionService: SessionService
) {
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
super(configService);
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
// 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

View file

@ -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<string, Plant[]> = 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<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<p>Erfolgreich abgemeldet.</p>');
await this.sendMessage(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
@ -157,26 +133,17 @@ export class MatrixService implements OnModuleInit {
break;
default:
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
);
}
} catch (error) {
this.logger.error(`Error handling command ${command}:`, error);
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${(error as Error).message}</p>`);
}
}
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, '<p>Verwendung: <code>!login email passwort</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
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, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
await this.sendMessage(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
} else {
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
}
}
@ -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,
`<h3>Planta Bot Status</h3>
<ul>
@ -224,7 +191,7 @@ export class MatrixService implements OnModuleInit {
const result = await this.plantaService.getPlants(token);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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,
'<p>Keine Pflanzen vorhanden. Fuege eine mit <code>!neu Name</code> hinzu.</p>'
);
@ -248,7 +215,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!pflanze [nr]</code> fuer Details oder <code>!faellig</code> fuer Giess-Status</em></p>';
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,
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
);
@ -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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -289,12 +256,12 @@ export class MatrixService implements OnModuleInit {
html += `<p><strong>Notizen:</strong> ${p.careNotes}</p>`;
}
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, '<p>Verwendung: <code>!neu Pflanzenname</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!neu Pflanzenname</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
// Clear cached list
this.lastPlantsList.delete(sender);
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Pflanze <strong>${result.data!.name}</strong> hinzugefuegt!</p>
<p><em>Nutze <code>!edit</code> um Details wie Licht, Wasser etc. zu setzen.</em></p>`
@ -320,7 +287,7 @@ export class MatrixService implements OnModuleInit {
const plant = this.getPlantByNumber(sender, numberStr);
if (!plant) {
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
);
@ -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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
// Clear cached list
this.lastPlantsList.delete(sender);
await this.sendHtml(roomId, `<p>Pflanze <strong>${plant.name}</strong> entfernt.</p>`);
await this.sendMessage(roomId, `<p>Pflanze <strong>${plant.name}</strong> entfernt.</p>`);
}
private async handleEditPlant(roomId: string, sender: string, args: string[]) {
if (args.length < 3) {
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Verwendung: <code>!edit [nr] [feld] [wert]</code></p><p>Felder: name, art, licht, wasser, notizen</p>'
);
@ -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,
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
);
@ -364,7 +331,7 @@ export class MatrixService implements OnModuleInit {
const value = args.slice(2).join(' ');
if (!field) {
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Unbekanntes Feld: <code>${fieldInput}</code></p><p>Verfuegbar: name, art, licht, wasser, notizen</p>`
);
@ -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, '<p>Wasser-Intervall muss eine positive Zahl sein.</p>');
await this.sendMessage(roomId, '<p>Wasser-Intervall muss eine positive Zahl sein.</p>');
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,
'<p>Licht-Werte: wenig/low, mittel/medium, hell/bright, direkt/direct</p>'
);
@ -402,7 +369,7 @@ export class MatrixService implements OnModuleInit {
};
updateValue = humidityMap[value.toLowerCase()];
if (!updateValue) {
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Feuchtigkeits-Werte: niedrig/low, mittel/medium, hoch/high</p>'
);
@ -415,11 +382,11 @@ export class MatrixService implements OnModuleInit {
});
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
await this.sendMessage(
roomId,
`<p><strong>${plant.name}</strong>: ${fieldInput} aktualisiert.</p>`
);
@ -431,7 +398,7 @@ export class MatrixService implements OnModuleInit {
const plant = this.getPlantByNumber(sender, numberStr);
if (!plant) {
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
);
@ -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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -450,7 +417,7 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Notiz: ${notes}</em></p>`;
}
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const upcoming = result.data || [];
if (upcoming.length === 0) {
await this.sendHtml(roomId, '<p>Keine Pflanzen muessen in den naechsten Tagen gegossen werden.</p>');
await this.sendMessage(roomId, '<p>Keine Pflanzen muessen in den naechsten Tagen gegossen werden.</p>');
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,
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
);
@ -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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const logs = result.data || [];
if (logs.length === 0) {
await this.sendHtml(
await this.sendMessage(
roomId,
`<p><strong>${plant.name}</strong> wurde noch nie gegossen.</p>`
);
@ -533,12 +500,12 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>...und ${logs.length - 10} weitere Eintraege</em></p>`;
}
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,
'<p>Verwendung: <code>!intervall [nr] [tage]</code></p>'
);
@ -549,7 +516,7 @@ export class MatrixService implements OnModuleInit {
const plant = this.getPlantByNumber(sender, numberStr);
if (!plant) {
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
);
@ -558,18 +525,18 @@ export class MatrixService implements OnModuleInit {
const days = parseInt(daysStr, 10);
if (isNaN(days) || days < 1) {
await this.sendHtml(roomId, '<p>Tage muss eine positive Zahl sein.</p>');
await this.sendMessage(roomId, '<p>Tage muss eine positive Zahl sein.</p>');
return;
}
const result = await this.plantaService.updateWateringSchedule(token, plant.id, days);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Giess-Intervall fuer <strong>${plant.name}</strong> auf ${days} Tage gesetzt.</p>`
);

View file

@ -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<string, Deck[]> = new Map();
private lastThemesList: Map<string, Theme[]> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private presiService: PresiService,
private sessionService: SessionService
) {}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<p>Erfolgreich abgemeldet.</p>');
await this.sendMessage(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
@ -147,26 +124,17 @@ export class MatrixService implements OnModuleInit {
break;
default:
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
);
}
} catch (error) {
this.logger.error(`Error handling command ${command}:`, error);
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${(error as Error).message}</p>`);
}
}
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, '<p>Verwendung: <code>!login email passwort</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
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, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
await this.sendMessage(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
} else {
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
}
}
@ -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,
`<h3>Presi Bot Status</h3>
<ul>
@ -214,7 +182,7 @@ export class MatrixService implements OnModuleInit {
const result = await this.presiService.getDecks(token);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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,
'<p>Keine Praesentationen vorhanden. Erstelle eine mit <code>!neu Titel</code></p>'
);
@ -238,7 +206,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!presi [nr]</code> fuer Details</em></p>';
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const result = await this.presiService.getDeck(token, deck.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -278,12 +246,12 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Nutze <code>!folie ${numberStr} typ Inhalt</code> um Folien hinzuzufuegen</em></p>`;
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, '<p>Verwendung: <code>!neu Titel | Beschreibung</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!neu Titel | Beschreibung</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastDecksList.delete(sender);
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Praesentation <strong>${result.data!.title}</strong> erstellt!</p>
<p><em>Nutze <code>!presis</code> und dann <code>!folie [nr] typ Inhalt</code></em></p>`
@ -312,24 +280,24 @@ export class MatrixService implements OnModuleInit {
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const result = await this.presiService.deleteDeck(token, deck.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastDecksList.delete(sender);
await this.sendHtml(roomId, `<p>Praesentation <strong>${deck.title}</strong> geloescht.</p>`);
await this.sendMessage(roomId, `<p>Praesentation <strong>${deck.title}</strong> geloescht.</p>`);
}
private async handleRenameDeck(roomId: string, sender: string, numberStr: string, newTitle: string) {
if (!newTitle) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!umbenennen [nr] Neuer Titel</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!umbenennen [nr] Neuer Titel</code></p>');
return;
}
@ -337,18 +305,18 @@ export class MatrixService implements OnModuleInit {
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const result = await this.presiService.updateDeck(token, deck.id, { title: newTitle });
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
await this.sendMessage(
roomId,
`<p><strong>${deck.title}</strong> umbenannt zu <strong>${newTitle}</strong></p>`
);
@ -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,
`<p>Verwendung:</p>
<ul>
@ -373,7 +341,7 @@ export class MatrixService implements OnModuleInit {
const deck = this.getDeckByNumber(sender, args[0]);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
@ -424,7 +392,7 @@ export class MatrixService implements OnModuleInit {
break;
default:
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Unbekannter Folien-Typ. Verfuegbar: titel, text, punkte, bild</p>'
);
@ -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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Folie zu <strong>${deck.title}</strong> hinzugefuegt (Position ${result.data!.order + 1})</p>`
);
@ -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, '<p>Verwendung: <code>!folieloeschen [presi-nr] [folien-nr]</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!folieloeschen [presi-nr] [folien-nr]</code></p>');
return;
}
@ -454,20 +422,20 @@ export class MatrixService implements OnModuleInit {
const deck = this.getDeckByNumber(sender, deckNumStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
return;
}
// Get deck with slides
const deckResult = await this.presiService.getDeck(token, deck.id);
if (deckResult.error || !deckResult.data?.slides) {
await this.sendHtml(roomId, `<p>Fehler: ${deckResult.error || 'Keine Folien'}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${deckResult.error || 'Keine Folien'}</p>`);
return;
}
const slideIndex = parseInt(slideNumStr, 10) - 1;
if (isNaN(slideIndex) || slideIndex < 0 || slideIndex >= deckResult.data.slides.length) {
await this.sendHtml(roomId, '<p>Ungueltige Folien-Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Folien-Nummer.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, `<p>Folie ${slideNumStr} aus <strong>${deck.title}</strong> geloescht.</p>`);
await this.sendMessage(roomId, `<p>Folie ${slideNumStr} aus <strong>${deck.title}</strong> geloescht.</p>`);
}
// Theme handlers
@ -487,7 +455,7 @@ export class MatrixService implements OnModuleInit {
const result = await this.presiService.getThemes();
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -495,7 +463,7 @@ export class MatrixService implements OnModuleInit {
this.lastThemesList.set(sender, themes);
if (themes.length === 0) {
await this.sendHtml(roomId, '<p>Keine Themes verfuegbar.</p>');
await this.sendMessage(roomId, '<p>Keine Themes verfuegbar.</p>');
return;
}
@ -507,12 +475,12 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!theme [presi-nr] [theme-nr]</code></em></p>';
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, '<p>Verwendung: <code>!theme [presi-nr] [theme-nr]</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!theme [presi-nr] [theme-nr]</code></p>');
return;
}
@ -521,23 +489,23 @@ export class MatrixService implements OnModuleInit {
const theme = this.getThemeByNumber(sender, themeNumStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
return;
}
if (!theme) {
await this.sendHtml(roomId, '<p>Ungueltige Theme-Nummer. Nutze zuerst <code>!themes</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Theme-Nummer. Nutze zuerst <code>!themes</code></p>');
return;
}
const result = await this.presiService.updateDeck(token, deck.id, { themeId: theme.id });
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Theme <strong>${theme.name}</strong> auf <strong>${deck.title}</strong> angewendet.</p>`
);
@ -552,7 +520,7 @@ export class MatrixService implements OnModuleInit {
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -581,12 +549,12 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Gueltig bis: ${new Date(result.data!.expiresAt).toLocaleDateString('de-DE')}</em></p>`;
}
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, '<p>Verwendung: <code>!links [presi-nr]</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!links [presi-nr]</code></p>');
return;
}
@ -594,21 +562,21 @@ export class MatrixService implements OnModuleInit {
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const result = await this.presiService.getShareLinks(token, deck.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const links = result.data || [];
if (links.length === 0) {
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Keine Share-Links fuer <strong>${deck.title}</strong>. Nutze <code>!teilen ${numberStr}</code></p>`
);
@ -625,7 +593,7 @@ export class MatrixService implements OnModuleInit {
}
html += '</ol>';
await this.sendHtml(roomId, html);
await this.sendMessage(roomId, html);
}
// Helper methods

View file

@ -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<string, string> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private projectService: ProjectService,
private mediaService: MediaService,
private generationService: GenerationService
) {
super(configService);
this.allowedUsers = this.configService.get<string[]>('matrix.allowedUsers') || [];
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('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<void> {
// 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<void> {
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, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/_([^_]+)_/g, '<em>$1</em>')
.replace(/\n/g, '<br/>');
}
}

View file

@ -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<string, Question[]> = new Map();
private lastCollectionsList: Map<string, Collection[]> = new Map();
private lastAnswersList: Map<string, Answer[]> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private questionsService: QuestionsService,
private sessionService: SessionService
) {}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<p>Erfolgreich abgemeldet.</p>');
await this.sendMessage(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
@ -165,26 +141,17 @@ export class MatrixService implements OnModuleInit {
break;
default:
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
);
}
} catch (error) {
this.logger.error(`Error handling command ${command}:`, error);
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${(error as Error).message}</p>`);
}
}
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, '<p>Verwendung: <code>!login email passwort</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
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, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
await this.sendMessage(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
} else {
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
}
}
@ -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,
`<h3>Questions Bot Status</h3>
<ul>
@ -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<string, string> = {};
if (statusFilter) {
const statusMap: Record<string, string> = {
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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,
'<p>Keine Fragen vorhanden. Stelle eine mit <code>!neu Frage?</code></p>'
);
@ -272,7 +239,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!frage [nr]</code> fuer Details oder <code>!recherche [nr]</code></em></p>';
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
const result = await this.questionsService.getQuestion(token, question.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -308,12 +275,12 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Nutze <code>!recherche ${numberStr}</code> um eine Recherche zu starten</em></p>`;
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, '<p>Verwendung: <code>!neu Deine Frage?</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!neu Deine Frage?</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastQuestionsList.delete(sender);
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Frage erstellt: <strong>${result.data!.title}</strong></p>
<p><em>Nutze <code>!fragen</code> und dann <code>!recherche [nr]</code> um zu recherchieren.</em></p>`
@ -338,19 +305,19 @@ export class MatrixService implements OnModuleInit {
const question = this.getQuestionByNumber(sender, numberStr);
if (!question) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
const result = await this.questionsService.deleteQuestion(token, question.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastQuestionsList.delete(sender);
await this.sendHtml(roomId, `<p>Frage geloescht: <strong>${question.title}</strong></p>`);
await this.sendMessage(roomId, `<p>Frage geloescht: <strong>${question.title}</strong></p>`);
}
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
const result = await this.questionsService.updateQuestionStatus(token, question.id, 'archived');
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, `<p>Frage archiviert: <strong>${question.title}</strong></p>`);
await this.sendMessage(roomId, `<p>Frage archiviert: <strong>${question.title}</strong></p>`);
}
// Research handlers
@ -378,7 +345,7 @@ export class MatrixService implements OnModuleInit {
const question = this.getQuestionByNumber(sender, numberStr);
if (!question) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
@ -392,12 +359,12 @@ export class MatrixService implements OnModuleInit {
};
const depth = depthMap[depthStr?.toLowerCase() || ''] || 'quick';
await this.sendHtml(roomId, `<p>Starte ${depth}-Recherche fuer: <strong>${question.title}</strong>...</p>`);
await this.sendMessage(roomId, `<p>Starte ${depth}-Recherche fuer: <strong>${question.title}</strong>...</p>`);
const result = await this.questionsService.startResearch(token, question.id, depth);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -426,7 +393,7 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Nutze <code>!quellen ${numberStr}</code> fuer die Quellen</em></p>`;
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
const result = await this.questionsService.getResearchResults(token, question.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const results = result.data || [];
if (results.length === 0) {
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Keine Recherche-Ergebnisse. Nutze <code>!recherche ${numberStr}</code></p>`
);
@ -471,7 +438,7 @@ export class MatrixService implements OnModuleInit {
html += '</ul>';
}
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
const result = await this.questionsService.getSources(token, question.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const sources = result.data || [];
if (sources.length === 0) {
await this.sendHtml(roomId, '<p>Keine Quellen vorhanden.</p>');
await this.sendMessage(roomId, '<p>Keine Quellen vorhanden.</p>');
return;
}
@ -508,7 +475,7 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>...und ${sources.length - 10} weitere Quellen</em></p>`;
}
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
return;
}
const result = await this.questionsService.getAnswers(token, question.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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,
`<p>Keine Antworten. Starte zuerst eine Recherche mit <code>!recherche ${numberStr}</code></p>`
);
@ -560,7 +527,7 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Nutze <code>!bewerten ${numberStr} 1-5</code> zum Bewerten</em></p>`;
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, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
await this.sendMessage(roomId, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
return;
}
const rating = parseInt(ratingStr, 10);
if (isNaN(rating) || rating < 1 || rating > 5) {
await this.sendHtml(roomId, '<p>Bewertung muss zwischen 1 und 5 sein.</p>');
await this.sendMessage(roomId, '<p>Bewertung muss zwischen 1 und 5 sein.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, `<p>Antwort mit ${rating} Sternen bewertet.</p>`);
await this.sendMessage(roomId, `<p>Antwort mit ${rating} Sternen bewertet.</p>`);
}
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, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
await this.sendMessage(roomId, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, '<p>Antwort als Loesung akzeptiert. &#9989;</p>');
await this.sendMessage(roomId, '<p>Antwort als Loesung akzeptiert. &#9989;</p>');
}
// 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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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,
'<p>Keine Sammlungen. Erstelle eine mit <code>!sammlung Name</code></p>'
);
@ -638,12 +605,12 @@ export class MatrixService implements OnModuleInit {
}
html += '</ol>';
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, '<p>Verwendung: <code>!sammlung Name</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!sammlung Name</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastCollectionsList.delete(sender);
await this.sendHtml(roomId, `<p>Sammlung <strong>${result.data!.name}</strong> erstellt.</p>`);
await this.sendMessage(roomId, `<p>Sammlung <strong>${result.data!.name}</strong> erstellt.</p>`);
}
// Search handler
private async handleSearch(roomId: string, sender: string, query: string) {
if (!query) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!suche Begriff</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!suche Begriff</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -678,7 +645,7 @@ export class MatrixService implements OnModuleInit {
this.lastQuestionsList.set(sender, questions);
if (questions.length === 0) {
await this.sendHtml(roomId, `<p>Keine Fragen gefunden fuer "${query}"</p>`);
await this.sendMessage(roomId, `<p>Keine Fragen gefunden fuer "${query}"</p>`);
return;
}
@ -689,7 +656,7 @@ export class MatrixService implements OnModuleInit {
}
html += '</ol>';
await this.sendHtml(roomId, html);
await this.sendMessage(roomId, html);
}
// Helper methods

View file

@ -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<string, Skill[]> = 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<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<p>Erfolgreich abgemeldet.</p>');
await this.sendMessage(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
@ -151,26 +128,17 @@ export class MatrixService implements OnModuleInit {
break;
default:
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
);
}
} catch (error) {
this.logger.error(`Error handling command ${command}:`, error);
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${(error as Error).message}</p>`);
}
}
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, '<p>Verwendung: <code>!login email passwort</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
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, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
await this.sendMessage(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
} else {
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
}
}
@ -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,
`<h3>Skilltree Bot Status</h3>
<ul>
@ -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,
'<p>Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom</p>'
);
@ -231,7 +199,7 @@ export class MatrixService implements OnModuleInit {
const result = await this.skilltreeService.getSkills(token, branch);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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,
'<p>Keine Skills vorhanden. Erstelle einen mit <code>!neu Name | Branch</code></p>'
);
@ -256,7 +224,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!skill [nr]</code> fuer Details oder <code>!xp [nr] 50 Aktivitaet</code></em></p>';
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
const result = await this.skilltreeService.getSkill(token, skill.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -293,12 +261,12 @@ export class MatrixService implements OnModuleInit {
html += `<p><em>Nutze <code>!xp ${numberStr} [xp] [aktivitaet]</code> um XP hinzuzufuegen</em></p>`;
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,
'<p>Verwendung: <code>!neu Name | Branch</code></p><p>Branches: intellect, body, creativity, social, practical, mindset, custom</p>'
);
@ -312,7 +280,7 @@ export class MatrixService implements OnModuleInit {
const branch = this.branchMappings[branchInput];
if (!branch) {
await this.sendHtml(
await this.sendMessage(
roomId,
'<p>Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom</p>'
);
@ -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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastSkillsList.delete(sender);
const branchIcon = this.getBranchIcon(branch);
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>${branchIcon} Skill <strong>${result.data!.skill.name}</strong> erstellt!</p>
<p><em>Nutze <code>!skills</code> und dann <code>!xp [nr] [xp] [aktivitaet]</code></em></p>`
@ -342,19 +310,19 @@ export class MatrixService implements OnModuleInit {
const skill = this.getSkillByNumber(sender, numberStr);
if (!skill) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
const result = await this.skilltreeService.deleteSkill(token, skill.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastSkillsList.delete(sender);
await this.sendHtml(roomId, `<p>Skill <strong>${skill.name}</strong> geloescht.</p>`);
await this.sendMessage(roomId, `<p>Skill <strong>${skill.name}</strong> geloescht.</p>`);
}
// 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,
'<p>Verwendung: <code>!xp [nr] [xp] [aktivitaet]</code></p><p>Optional: <code>--min 60</code> fuer Dauer</p>'
);
@ -373,13 +341,13 @@ export class MatrixService implements OnModuleInit {
const skill = this.getSkillByNumber(sender, args[0]);
if (!skill) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
const xp = parseInt(args[1], 10);
if (isNaN(xp) || xp < 1 || xp > 10000) {
await this.sendHtml(roomId, '<p>XP muss zwischen 1 und 10000 liegen.</p>');
await this.sendMessage(roomId, '<p>XP muss zwischen 1 und 10000 liegen.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -414,7 +382,7 @@ export class MatrixService implements OnModuleInit {
html += `<p>&#127881; <strong>LEVEL UP!</strong> Du bist jetzt Level ${newLevel} (${levelName})!</p>`;
}
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -438,7 +406,7 @@ export class MatrixService implements OnModuleInit {
}
html += '</ul>';
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const activities = result.data?.activities || [];
if (activities.length === 0) {
await this.sendHtml(roomId, '<p>Keine Aktivitaeten vorhanden.</p>');
await this.sendMessage(roomId, '<p>Keine Aktivitaeten vorhanden.</p>');
return;
}
@ -487,7 +455,7 @@ export class MatrixService implements OnModuleInit {
}
html += '</ol>';
await this.sendHtml(roomId, html);
await this.sendMessage(roomId, html);
}
// Helper methods

View file

@ -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<string, StorageFile[]> = new Map();
private lastFoldersList: Map<string, Folder[]> = new Map();
@ -25,44 +15,28 @@ export class MatrixService implements OnModuleInit {
private currentFolder: Map<string, string | null> = new Map();
constructor(
private configService: ConfigService,
configService: ConfigService,
private storageService: StorageService,
private sessionService: SessionService
) {}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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, '<p>Erfolgreich abgemeldet.</p>');
await this.sendMessage(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
@ -194,26 +168,17 @@ export class MatrixService implements OnModuleInit {
break;
default:
await this.sendHtml(
await this.sendMessage(
roomId,
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
);
}
} catch (error) {
this.logger.error(`Error handling command ${command}:`, error);
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${(error as Error).message}</p>`);
}
}
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, '<p>Verwendung: <code>!login email passwort</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
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, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
await this.sendMessage(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
} else {
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
}
}
@ -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,
`<h3>Storage Bot Status</h3>
<ul>
@ -263,7 +228,7 @@ export class MatrixService implements OnModuleInit {
if (folderNumStr) {
const folder = this.getFolderByNumber(sender, folderNumStr);
if (!folder) {
await this.sendHtml(roomId, '<p>Ungueltige Ordner-Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Ordner-Nummer.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -283,7 +248,7 @@ export class MatrixService implements OnModuleInit {
this.lastFilesList.set(sender, files);
if (files.length === 0) {
await this.sendHtml(roomId, '<p>Keine Dateien vorhanden.</p>');
await this.sendMessage(roomId, '<p>Keine Dateien vorhanden.</p>');
return;
}
@ -296,7 +261,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!datei [nr]</code> fuer Details oder <code>!download [nr]</code></em></p>';
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
return;
}
const result = await this.storageService.getFile(token, file.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -325,7 +290,7 @@ export class MatrixService implements OnModuleInit {
html += '</ul>';
html += `<p><em>Nutze <code>!download ${numberStr}</code> fuer Download-Link</em></p>`;
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
return;
}
const result = await this.storageService.getDownloadUrl(token, file.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
await this.sendMessage(
roomId,
`<p><strong>${file.name}</strong></p><p>Download: <a href="${result.data!.url}">${result.data!.url}</a></p>`
);
@ -355,24 +320,24 @@ export class MatrixService implements OnModuleInit {
const file = this.getFileByNumber(sender, numberStr);
if (!file) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
return;
}
const result = await this.storageService.deleteFile(token, file.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastFilesList.delete(sender);
await this.sendHtml(roomId, `<p><strong>${file.name}</strong> in Papierkorb verschoben.</p>`);
await this.sendMessage(roomId, `<p><strong>${file.name}</strong> in Papierkorb verschoben.</p>`);
}
private async handleRenameFile(roomId: string, sender: string, numberStr: string, newName: string) {
if (!newName) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!umbenennen [nr] neuer name</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!umbenennen [nr] neuer name</code></p>');
return;
}
@ -380,18 +345,18 @@ export class MatrixService implements OnModuleInit {
const file = this.getFileByNumber(sender, numberStr);
if (!file) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
return;
}
const result = await this.storageService.renameFile(token, file.id, newName);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, `<p><strong>${file.name}</strong> umbenannt zu <strong>${newName}</strong></p>`);
await this.sendMessage(roomId, `<p><strong>${file.name}</strong> umbenannt zu <strong>${newName}</strong></p>`);
}
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, '<p>Ungueltige Datei-Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Datei-Nummer.</p>');
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, '<p>Ungueltige Ordner-Nummer. Nutze 0 oder root fuer Root.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Ordner-Nummer. Nutze 0 oder root fuer Root.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, `<p><strong>${file.name}</strong> nach <strong>${folderName}</strong> verschoben.</p>`);
await this.sendMessage(roomId, `<p><strong>${file.name}</strong> nach <strong>${folderName}</strong> verschoben.</p>`);
}
// 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, '<p>Ungueltige Ordner-Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Ordner-Nummer.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -451,7 +416,7 @@ export class MatrixService implements OnModuleInit {
this.lastFoldersList.set(sender, folders);
if (folders.length === 0) {
await this.sendHtml(roomId, '<p>Keine Ordner vorhanden.</p>');
await this.sendMessage(roomId, '<p>Keine Ordner vorhanden.</p>');
return;
}
@ -464,12 +429,12 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!dateien [nr]</code> um Dateien im Ordner zu sehen</em></p>';
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, '<p>Verwendung: <code>!neuordner Name [in-ordner-nr]</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!neuordner Name [in-ordner-nr]</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastFoldersList.delete(sender);
await this.sendHtml(roomId, `<p>Ordner <strong>${result.data!.name}</strong> erstellt.</p>`);
await this.sendMessage(roomId, `<p>Ordner <strong>${result.data!.name}</strong> erstellt.</p>`);
}
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!ordner</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!ordner</code></p>');
return;
}
const result = await this.storageService.deleteFolder(token, folder.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastFoldersList.delete(sender);
await this.sendHtml(roomId, `<p>Ordner <strong>${folder.name}</strong> in Papierkorb verschoben.</p>`);
await this.sendMessage(roomId, `<p>Ordner <strong>${folder.name}</strong> in Papierkorb verschoben.</p>`);
}
// Share handlers
@ -529,11 +494,11 @@ export class MatrixService implements OnModuleInit {
const file = this.getFileByNumber(sender, numberStr);
if (!file) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!dateien</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -569,7 +534,7 @@ export class MatrixService implements OnModuleInit {
if (options.password) html += `<p><em>Passwort geschuetzt</em></p>`;
if (options.maxDownloads) html += `<p><em>Max Downloads: ${options.maxDownloads}</em></p>`;
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -585,7 +550,7 @@ export class MatrixService implements OnModuleInit {
this.lastSharesList.set(sender, shares);
if (shares.length === 0) {
await this.sendHtml(roomId, '<p>Keine Share-Links vorhanden.</p>');
await this.sendMessage(roomId, '<p>Keine Share-Links vorhanden.</p>');
return;
}
@ -598,7 +563,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!linkloeschen [nr]</code> zum Loeschen</em></p>';
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, '<p>Nutze zuerst <code>!links</code></p>');
await this.sendMessage(roomId, '<p>Nutze zuerst <code>!links</code></p>');
return;
}
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= shares.length) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastSharesList.delete(sender);
await this.sendHtml(roomId, '<p>Share-Link geloescht.</p>');
await this.sendMessage(roomId, '<p>Share-Link geloescht.</p>');
}
// Search & Favorites
private async handleSearch(roomId: string, sender: string, query: string) {
if (!query) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!suche Begriff</code></p>');
await this.sendMessage(roomId, '<p>Verwendung: <code>!suche Begriff</code></p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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, `<p>Keine Ergebnisse fuer "${query}"</p>`);
await this.sendMessage(roomId, `<p>Keine Ergebnisse fuer "${query}"</p>`);
return;
}
@ -670,7 +635,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
}
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
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, '<p>Keine Favoriten vorhanden.</p>');
await this.sendMessage(roomId, '<p>Keine Favoriten vorhanden.</p>');
return;
}
@ -709,7 +674,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
}
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const status = result.data!.isFavorite ? 'hinzugefuegt' : 'entfernt';
await this.sendHtml(roomId, `<p><strong>${file.name}</strong>: Favorit ${status}</p>`);
await this.sendMessage(roomId, `<p><strong>${file.name}</strong>: Favorit ${status}</p>`);
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const status = result.data!.isFavorite ? 'hinzugefuegt' : 'entfernt';
await this.sendHtml(roomId, `<p><strong>${folder.name}</strong>: Favorit ${status}</p>`);
await this.sendMessage(roomId, `<p><strong>${folder.name}</strong>: Favorit ${status}</p>`);
return;
}
await this.sendHtml(roomId, '<p>Ungueltige Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer.</p>');
}
// 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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
@ -758,7 +723,7 @@ export class MatrixService implements OnModuleInit {
this.lastTrashList.set(sender, items);
if (items.length === 0) {
await this.sendHtml(roomId, '<p>Papierkorb ist leer.</p>');
await this.sendMessage(roomId, '<p>Papierkorb ist leer.</p>');
return;
}
@ -771,7 +736,7 @@ export class MatrixService implements OnModuleInit {
html += '</ol>';
html += '<p><em>Nutze <code>!wiederherstellen [nr]</code> oder <code>!leeren</code></em></p>';
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, '<p>Nutze zuerst <code>!papierkorb</code></p>');
await this.sendMessage(roomId, '<p>Nutze zuerst <code>!papierkorb</code></p>');
return;
}
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= items.length) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer.</p>');
await this.sendMessage(roomId, '<p>Ungueltige Nummer.</p>');
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastTrashList.delete(sender);
await this.sendHtml(roomId, `<p><strong>${item.name}</strong> wiederhergestellt.</p>`);
await this.sendMessage(roomId, `<p><strong>${item.name}</strong> wiederhergestellt.</p>`);
}
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, `<p>Fehler: ${result.error}</p>`);
await this.sendMessage(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastTrashList.delete(sender);
await this.sendHtml(roomId, '<p>Papierkorb geleert.</p>');
await this.sendMessage(roomId, '<p>Papierkorb geleert.</p>');
}
// Helper methods

View file

@ -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<string>(
'matrix.homeserverUrl',
'http://localhost:8008'
);
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
this.storagePath = this.configService.get<string>(
'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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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<void> {
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 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 += `\nErledigen: \`!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, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
}
}

View file

@ -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<string, UserSettings> = new Map();
@ -36,73 +26,29 @@ export class MatrixService implements OnModuleInit, OnModuleDestroy {
private processedEvents: Set<string> = new Set();
constructor(
private configService: ConfigService,
configService: ConfigService,
private ttsService: TtsService
) {
this.homeserverUrl = this.configService.get<string>(
'matrix.homeserverUrl',
'http://localhost:8008'
);
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
this.storagePath = this.configService.get<string>(
'matrix.storagePath',
'./data/bot-storage.json'
);
this.defaultVoice = this.configService.get<string>('tts.defaultVoice', 'af_heart');
this.defaultSpeed = this.configService.get<number>('tts.defaultSpeed', 1.0);
this.maxTextLength = this.configService.get<number>('tts.maxTextLength', 500);
super(configService);
this.defaultVoice = this.configService.get<string>('tts.defaultVoice') || 'af_heart';
this.defaultSpeed = this.configService.get<number>('tts.defaultSpeed') || 1.0;
this.maxTextLength = this.configService.get<number>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
// 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<void> {
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, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
}
}

View file

@ -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<string, string> = 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<string[]>('matrix.allowedRooms') || [];
super(configService);
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('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<void> {
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<void> {
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, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Bold
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
// Underscore italic
.replace(/_([^_]+)_/g, '<em>$1</em>')
// Line breaks
.replace(/\n/g, '<br/>')
);
}
}