♻️ refactor: migrate 5 Matrix bots to shared utilities

Migrate bots to use KeywordCommandDetector and UserListMapper from
@manacore/matrix-bot-common, reducing duplicate code.

KeywordCommandDetector (natural language command detection):
- matrix-ollama-bot
- matrix-nutriphi-bot
- matrix-contacts-bot

UserListMapper (number-based reference system):
- matrix-presi-bot (decks + themes)
- matrix-skilltree-bot (skills)
- matrix-contacts-bot (contacts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-01 02:57:21 +01:00
parent 4d8c7f1a7c
commit f04c27fe26
5 changed files with 325 additions and 246 deletions

View file

@ -1,23 +1,29 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common';
import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
KeywordCommandDetector,
COMMON_KEYWORDS,
UserListMapper,
} from '@manacore/matrix-bot-common';
import { ContactsService, Contact } from '../contacts/contacts.service';
import { SessionService } from '@manacore/bot-services';
import { HELP_MESSAGE } from '../config/configuration';
// Natural language keywords
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
{ keywords: ['hilfe', 'help', 'befehle', 'commands'], command: 'help' },
// Natural language keyword detector
const keywordDetector = new KeywordCommandDetector([
...COMMON_KEYWORDS,
{ keywords: ['kontakte', 'contacts', 'alle'], command: 'kontakte' },
{ keywords: ['favoriten', 'favorites', 'favs'], command: 'favoriten' },
{ keywords: ['suche', 'search', 'finde'], command: 'suche' },
{ keywords: ['status', 'info'], command: 'status' },
];
]);
@Injectable()
export class MatrixService extends BaseMatrixService {
// Store last shown contacts per user for reference by number
private lastContactsList: Map<string, Contact[]> = new Map();
// User list mapper for number-based reference
private contactsMapper = new UserListMapper<Contact>();
constructor(
configService: ConfigService,
@ -29,9 +35,11 @@ export class MatrixService extends BaseMatrixService {
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
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',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
@ -60,29 +68,20 @@ Sag "hilfe" fur alle Befehle!`;
return;
}
const keywordCommand = this.detectKeywordCommand(message);
if (keywordCommand) {
await this.handleCommand(roomId, event, sender, `!${keywordCommand}`);
const detectedCommand = keywordDetector.detect(message);
if (detectedCommand) {
this.logger.log(`Detected keyword command: ${detectedCommand}`);
await this.handleCommand(roomId, event, sender, `!${detectedCommand}`);
return;
}
}
private detectKeywordCommand(message: string): string | null {
const lowerMessage = message.toLowerCase().trim();
if (lowerMessage.length > 30) return null;
for (const { keywords, command } of KEYWORD_COMMANDS) {
for (const keyword of keywords) {
if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) {
return command;
}
}
}
return null;
}
private async handleCommand(roomId: string, event: MatrixRoomEvent, 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(' ');
@ -191,12 +190,13 @@ Sag "hilfe" fur alle Befehle!`;
}
// Store for reference
this.lastContactsList.set(sender, contacts);
this.contactsMapper.setList(sender, contacts);
let text = `**Deine Kontakte (${result.total}):**\n\n`;
for (let i = 0; i < contacts.length; i++) {
const c = contacts[i];
const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const name =
c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const favIcon = c.isFavorite ? ' ★' : '';
const company = c.company ? ` - ${c.company}` : '';
text += `**${i + 1}.** ${name}${favIcon}${company}\n`;
@ -215,7 +215,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleSearch(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -223,12 +228,19 @@ Sag "hilfe" fur alle Befehle!`;
}
if (!searchTerm.trim()) {
await this.sendReply(roomId, event, `**Verwendung:** \`!suche [text]\`\n\nBeispiel: \`!suche Max\``);
await this.sendReply(
roomId,
event,
`**Verwendung:** \`!suche [text]\`\n\nBeispiel: \`!suche Max\``
);
return;
}
try {
const result = await this.contactsService.getContacts(token, { search: searchTerm, limit: 20 });
const result = await this.contactsService.getContacts(token, {
search: searchTerm,
limit: 20,
});
const contacts = result.contacts;
if (contacts.length === 0) {
@ -236,12 +248,13 @@ Sag "hilfe" fur alle Befehle!`;
return;
}
this.lastContactsList.set(sender, contacts);
this.contactsMapper.setList(sender, contacts);
let text = `**Suchergebnisse fur "${searchTerm}" (${contacts.length}):**\n\n`;
for (let i = 0; i < contacts.length; i++) {
const c = contacts[i];
const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const name =
c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const favIcon = c.isFavorite ? ' ★' : '';
const email = c.email ? ` (${c.email})` : '';
text += `**${i + 1}.** ${name}${favIcon}${email}\n`;
@ -274,12 +287,13 @@ Sag "hilfe" fur alle Befehle!`;
return;
}
this.lastContactsList.set(sender, contacts);
this.contactsMapper.setList(sender, contacts);
let text = `**Deine Favoriten (${contacts.length}):**\n\n`;
for (let i = 0; i < contacts.length; i++) {
const c = contacts[i];
const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const name =
c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const phone = c.phone || c.mobile || '';
text += `**${i + 1}.** ★ ${name}${phone ? ` - ${phone}` : ''}\n`;
}
@ -291,7 +305,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleContactDetails(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -299,24 +318,26 @@ Sag "hilfe" fur alle Befehle!`;
}
if (args.length < 1) {
await this.sendReply(roomId, event, `**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.sendReply(roomId, event, `Ungultige Nummer.`);
const number = parseInt(args[0], 10);
const contact = this.contactsMapper.getByNumber(sender, number);
if (!contact) {
await this.sendReply(
roomId,
event,
`Kontakt ${args[0]} nicht gefunden. Nutze \`!kontakte\` zuerst.`
);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
try {
const details = await this.contactsService.getContact(token, contact.id);
@ -334,14 +355,19 @@ Sag "hilfe" fur alle Befehle!`;
if (details.mobile) text += `**Mobil:** ${details.mobile}\n`;
if (details.street || details.city) {
const address = [details.street, `${details.postalCode || ''} ${details.city || ''}`.trim(), details.country]
const address = [
details.street,
`${details.postalCode || ''} ${details.city || ''}`.trim(),
details.country,
]
.filter(Boolean)
.join(', ');
if (address) text += `**Adresse:** ${address}\n`;
}
if (details.website) text += `**Website:** ${details.website}\n`;
if (details.birthday) text += `**Geburtstag:** ${new Date(details.birthday).toLocaleDateString('de-DE')}\n`;
if (details.birthday)
text += `**Geburtstag:** ${new Date(details.birthday).toLocaleDateString('de-DE')}\n`;
if (details.notes) text += `\n**Notizen:** ${details.notes}\n`;
await this.sendReply(roomId, event, text);
@ -351,7 +377,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleCreateContact(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -388,7 +419,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleEditContact(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -404,23 +440,20 @@ Sag "hilfe" fur alle Befehle!`;
return;
}
const index = parseInt(args[0], 10);
const number = parseInt(args[0], 10);
const field = args[1].toLowerCase();
const value = args.slice(2).join(' ');
if (isNaN(index) || index < 1) {
await this.sendReply(roomId, event, `Ungultige Nummer.`);
const contact = this.contactsMapper.getByNumber(sender, number);
if (!contact) {
await this.sendReply(
roomId,
event,
`Kontakt ${args[0]} nicht gefunden. Nutze \`!kontakte\` zuerst.`
);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
const fieldMap: Record<string, string> = {
email: 'email',
phone: 'phone',
@ -455,7 +488,11 @@ Sag "hilfe" fur alle Befehle!`;
const mappedField = fieldMap[field];
if (!mappedField) {
await this.sendReply(roomId, event, `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;
}
@ -464,15 +501,25 @@ Sag "hilfe" fur alle Befehle!`;
[mappedField]: value,
});
const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
await this.sendReply(roomId, event, `Kontakt **${name}** aktualisiert!\n\n**${field}:** ${value}`);
const name =
updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
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.sendReply(roomId, event, `Fehler: ${errorMsg}`);
}
}
private async handleDeleteContact(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -480,24 +527,27 @@ Sag "hilfe" fur alle Befehle!`;
}
if (args.length < 1) {
await this.sendReply(roomId, event, `**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.sendReply(roomId, event, `Ungultige Nummer.`);
const number = parseInt(args[0], 10);
const contact = this.contactsMapper.getByNumber(sender, number);
if (!contact) {
await this.sendReply(
roomId,
event,
`Kontakt ${args[0]} nicht gefunden. Nutze \`!kontakte\` zuerst.`
);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
const name = contact.displayName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
const name =
contact.displayName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
try {
await this.contactsService.deleteContact(token, contact.id);
@ -508,7 +558,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleToggleFavorite(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -516,27 +571,29 @@ Sag "hilfe" fur alle Befehle!`;
}
if (args.length < 1) {
await this.sendReply(roomId, event, `**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.sendReply(roomId, event, `Ungultige Nummer.`);
const number = parseInt(args[0], 10);
const contact = this.contactsMapper.getByNumber(sender, number);
if (!contact) {
await this.sendReply(
roomId,
event,
`Kontakt ${args[0]} nicht gefunden. Nutze \`!kontakte\` zuerst.`
);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
try {
const updated = await this.contactsService.toggleFavorite(token, contact.id);
const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
const name =
updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
const status = updated.isFavorite ? 'als Favorit markiert ★' : 'aus Favoriten entfernt';
await this.sendReply(roomId, event, `**${name}** ${status}`);
} catch (error) {
@ -545,7 +602,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleToggleArchive(roomId: string, event: MatrixRoomEvent, 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.sendReply(roomId, event, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
@ -553,27 +615,29 @@ Sag "hilfe" fur alle Befehle!`;
}
if (args.length < 1) {
await this.sendReply(roomId, event, `**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.sendReply(roomId, event, `Ungultige Nummer.`);
const number = parseInt(args[0], 10);
const contact = this.contactsMapper.getByNumber(sender, number);
if (!contact) {
await this.sendReply(
roomId,
event,
`Kontakt ${args[0]} nicht gefunden. Nutze \`!kontakte\` zuerst.`
);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendReply(roomId, event, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
try {
const updated = await this.contactsService.toggleArchive(token, contact.id);
const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
const name =
updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
const status = updated.isArchived ? 'archiviert' : 'aus dem Archiv geholt';
await this.sendReply(roomId, event, `**${name}** ${status}`);
} catch (error) {
@ -582,7 +646,12 @@ Sag "hilfe" fur alle Befehle!`;
}
}
private async handleLogin(roomId: string, event: MatrixRoomEvent, sender: string, args: string[]) {
private async handleLogin(
roomId: string,
event: MatrixRoomEvent,
sender: string,
args: string[]
) {
if (args.length < 2) {
await this.sendReply(
roomId,

View file

@ -4,6 +4,8 @@ import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
KeywordCommandDetector,
COMMON_KEYWORDS,
} from '@manacore/matrix-bot-common';
import {
NutriPhiService,
@ -14,16 +16,16 @@ import {
import { SessionService, TranscriptionService } from '@manacore/bot-services';
import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration';
// Natural language keywords that trigger commands (German + English)
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
{ keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' },
// Natural language keyword detector
const keywordDetector = new KeywordCommandDetector([
...COMMON_KEYWORDS,
{ keywords: ['heute', 'today', 'tages', 'tagesübersicht'], command: 'today' },
{ keywords: ['woche', 'week', 'wochen', 'wochenübersicht'], command: 'week' },
{ keywords: ['ziele', 'goals', 'meine ziele'], command: 'goals' },
{ keywords: ['favoriten', 'favorites', 'lieblings'], command: 'favorites' },
{ keywords: ['tipps', 'tips', 'empfehlungen', 'ratschläge'], command: 'tips' },
{ keywords: ['status', 'verbindung'], command: 'status' },
];
{ keywords: ['verbindung'], command: 'status' },
]);
@Injectable()
export class MatrixService extends BaseMatrixService {
@ -38,9 +40,11 @@ export class MatrixService extends BaseMatrixService {
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
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',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
@ -65,7 +69,7 @@ Sag "hilfe" fur alle Befehle!`;
// Handle image messages
this.client.on('room.message', async (roomId: string, event: any) => {
if (event.sender === await this.client.getUserId()) return;
if (event.sender === (await this.client.getUserId())) return;
const content = event.content as {
msgtype?: string;
@ -159,32 +163,16 @@ Sag "hilfe" fur alle Befehle!`;
}
// Check for natural language keywords
const keywordCommand = this.detectKeywordCommand(message);
if (keywordCommand) {
await this.handleCommand(roomId, sender, `!${keywordCommand}`);
const detectedCommand = keywordDetector.detect(message);
if (detectedCommand) {
this.logger.log(`Detected keyword command: ${detectedCommand}`);
await this.handleCommand(roomId, sender, `!${detectedCommand}`);
return;
}
// Don't respond to random messages - only commands
}
private detectKeywordCommand(message: string): string | null {
const lowerMessage = message.toLowerCase().trim();
// Only match if the message is short
if (lowerMessage.length > 50) return null;
for (const { keywords, command } of KEYWORD_COMMANDS) {
for (const keyword of keywords) {
if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) {
this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`);
return command;
}
}
}
return null;
}
private async handleCommand(roomId: string, sender: string, body: string) {
const [command, ...args] = body.slice(1).split(' ');
const argString = args.join(' ');

View file

@ -4,6 +4,8 @@ import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
KeywordCommandDetector,
COMMON_KEYWORDS,
} from '@manacore/matrix-bot-common';
import { OllamaService } from '../ollama/ollama.service';
import { SYSTEM_PROMPTS } from '../config/configuration';
@ -21,13 +23,13 @@ const NON_CHAT_MODELS = ['deepseek-r1:1.5b'];
// Models that support vision/image input
const VISION_MODELS = ['llava', 'llava:7b', 'llava:13b', 'bakllava', 'moondream'];
// Natural language keywords that trigger commands (German + English)
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
{ keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' },
// Natural language keyword detector
const keywordDetector = new KeywordCommandDetector([
...COMMON_KEYWORDS,
{ keywords: ['modelle', 'models', 'welche modelle', 'liste modelle'], command: 'models' },
{ keywords: ['status', 'verbindung', 'connection', 'online'], command: 'status' },
{ keywords: ['verbindung', 'connection', 'online'], command: 'status' },
{ keywords: ['lösche verlauf', 'clear', 'neustart', 'reset', 'vergiss alles'], command: 'clear' },
];
]);
@Injectable()
export class MatrixService extends BaseMatrixService {
@ -42,9 +44,11 @@ export class MatrixService extends BaseMatrixService {
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
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',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
@ -159,9 +163,10 @@ Viel Spass!`;
}
// Check for natural language keywords
const keywordCommand = this.detectKeywordCommand(message);
if (keywordCommand) {
await this.handleCommand(roomId, sender, `!${keywordCommand}`);
const detectedCommand = keywordDetector.detect(message);
if (detectedCommand) {
this.logger.log(`Detected keyword command: ${detectedCommand}`);
await this.handleCommand(roomId, sender, `!${detectedCommand}`);
return;
}
@ -169,23 +174,6 @@ Viel Spass!`;
await this.handleChat(roomId, sender, message);
}
private detectKeywordCommand(message: string): string | null {
const lowerMessage = message.toLowerCase().trim();
// Only match if the message is short (likely a command, not a question containing a keyword)
if (lowerMessage.length > 50) return null;
for (const { keywords, command } of KEYWORD_COMMANDS) {
for (const keyword of keywords) {
if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) {
this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`);
return command;
}
}
}
return null;
}
private async handleCommand(roomId: string, sender: string, body: string) {
const [command, ...args] = body.slice(1).split(' ');
const argString = args.join(' ');

View file

@ -1,15 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common';
import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
UserListMapper,
} 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 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();
// User list mappers for number-based reference
private decksMapper = new UserListMapper<Deck>();
private themesMapper = new UserListMapper<Theme>();
constructor(
configService: ConfigService,
@ -21,9 +26,11 @@ export class MatrixService extends BaseMatrixService {
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
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',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
@ -187,7 +194,7 @@ export class MatrixService extends BaseMatrixService {
}
const decks = result.data || [];
this.lastDecksList.set(sender, decks);
this.decksMapper.setList(sender, decks);
if (decks.length === 0) {
await this.sendMessage(
@ -211,7 +218,8 @@ export class MatrixService extends BaseMatrixService {
private async handleDeckDetails(roomId: string, sender: string, numberStr: string) {
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const deck = this.decksMapper.getByNumber(sender, number);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
@ -238,7 +246,8 @@ export class MatrixService extends BaseMatrixService {
if (d.slides && d.slides.length > 0) {
html += '<p><strong>Folien:</strong></p><ol>';
for (const slide of d.slides) {
const title = slide.content.title || slide.content.body?.substring(0, 30) || `(${slide.content.type})`;
const title =
slide.content.title || slide.content.body?.substring(0, 30) || `(${slide.content.type})`;
html += `<li>${title}</li>`;
}
html += '</ol>';
@ -267,7 +276,7 @@ export class MatrixService extends BaseMatrixService {
return;
}
this.lastDecksList.delete(sender);
this.decksMapper.clearList(sender);
await this.sendMessage(
roomId,
`<p>Praesentation <strong>${result.data!.title}</strong> erstellt!</p>
@ -277,7 +286,8 @@ export class MatrixService extends BaseMatrixService {
private async handleDeleteDeck(roomId: string, sender: string, numberStr: string) {
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const deck = this.decksMapper.getByNumber(sender, number);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
@ -291,18 +301,30 @@ export class MatrixService extends BaseMatrixService {
return;
}
this.lastDecksList.delete(sender);
await this.sendMessage(roomId, `<p>Praesentation <strong>${deck.title}</strong> geloescht.</p>`);
this.decksMapper.clearList(sender);
await this.sendMessage(
roomId,
`<p>Praesentation <strong>${deck.title}</strong> geloescht.</p>`
);
}
private async handleRenameDeck(roomId: string, sender: string, numberStr: string, newTitle: string) {
private async handleRenameDeck(
roomId: string,
sender: string,
numberStr: string,
newTitle: string
) {
if (!newTitle) {
await this.sendMessage(roomId, '<p>Verwendung: <code>!umbenennen [nr] Neuer Titel</code></p>');
await this.sendMessage(
roomId,
'<p>Verwendung: <code>!umbenennen [nr] Neuer Titel</code></p>'
);
return;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const deck = this.decksMapper.getByNumber(sender, number);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
@ -338,7 +360,8 @@ export class MatrixService extends BaseMatrixService {
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, args[0]);
const number = parseInt(args[0], 10);
const deck = this.decksMapper.getByNumber(sender, number);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
@ -412,14 +435,23 @@ export class MatrixService extends BaseMatrixService {
);
}
private async handleDeleteSlide(roomId: string, sender: string, deckNumStr: string, slideNumStr: string) {
private async handleDeleteSlide(
roomId: string,
sender: string,
deckNumStr: string,
slideNumStr: string
) {
if (!deckNumStr || !slideNumStr) {
await this.sendMessage(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;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, deckNumStr);
const deckNumber = parseInt(deckNumStr, 10);
const deck = this.decksMapper.getByNumber(sender, deckNumber);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
@ -447,7 +479,10 @@ export class MatrixService extends BaseMatrixService {
return;
}
await this.sendMessage(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
@ -460,7 +495,7 @@ export class MatrixService extends BaseMatrixService {
}
const themes = result.data || [];
this.lastThemesList.set(sender, themes);
this.themesMapper.setList(sender, themes);
if (themes.length === 0) {
await this.sendMessage(roomId, '<p>Keine Themes verfuegbar.</p>');
@ -478,15 +513,25 @@ export class MatrixService extends BaseMatrixService {
await this.sendMessage(roomId, html);
}
private async handleApplyTheme(roomId: string, sender: string, deckNumStr: string, themeNumStr: string) {
private async handleApplyTheme(
roomId: string,
sender: string,
deckNumStr: string,
themeNumStr: string
) {
if (!deckNumStr || !themeNumStr) {
await this.sendMessage(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;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, deckNumStr);
const theme = this.getThemeByNumber(sender, themeNumStr);
const deckNumber = parseInt(deckNumStr, 10);
const themeNumber = parseInt(themeNumStr, 10);
const deck = this.decksMapper.getByNumber(sender, deckNumber);
const theme = this.themesMapper.getByNumber(sender, themeNumber);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
@ -494,7 +539,10 @@ export class MatrixService extends BaseMatrixService {
}
if (!theme) {
await this.sendMessage(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;
}
@ -517,7 +565,8 @@ export class MatrixService extends BaseMatrixService {
const numberStr = args[0];
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const deck = this.decksMapper.getByNumber(sender, number);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
@ -559,7 +608,8 @@ export class MatrixService extends BaseMatrixService {
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const deck = this.decksMapper.getByNumber(sender, number);
if (!deck) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
@ -595,25 +645,4 @@ export class MatrixService extends BaseMatrixService {
await this.sendMessage(roomId, html);
}
// Helper methods
private getDeckByNumber(sender: string, numberStr: string): Deck | null {
const decks = this.lastDecksList.get(sender);
if (!decks) return null;
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= decks.length) return null;
return decks[index];
}
private getThemeByNumber(sender: string, numberStr: string): Theme | null {
const themes = this.lastThemesList.get(sender);
if (!themes) return null;
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= themes.length) return null;
return themes[index];
}
}

View file

@ -1,14 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common';
import {
BaseMatrixService,
MatrixBotConfig,
MatrixRoomEvent,
UserListMapper,
} 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 extends BaseMatrixService {
// Store last shown skills per user for reference by number
private lastSkillsList: Map<string, Skill[]> = new Map();
// User list mapper for number-based reference
private skillsMapper = new UserListMapper<Skill>();
// Branch name mappings (German/English)
private readonly branchMappings: Record<string, SkillBranch> = {
@ -45,9 +50,11 @@ export class MatrixService extends BaseMatrixService {
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
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',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
@ -204,7 +211,7 @@ export class MatrixService extends BaseMatrixService {
}
const skills = result.data?.skills || [];
this.lastSkillsList.set(sender, skills);
this.skillsMapper.setList(sender, skills);
if (skills.length === 0) {
await this.sendMessage(
@ -222,14 +229,16 @@ export class MatrixService extends BaseMatrixService {
html += `<li>${branchIcon} <strong>${skill.name}</strong> - Lvl ${skill.level} (${levelName}) ${progress}</li>`;
}
html += '</ol>';
html += '<p><em>Nutze <code>!skill [nr]</code> fuer Details oder <code>!xp [nr] 50 Aktivitaet</code></em></p>';
html +=
'<p><em>Nutze <code>!skill [nr]</code> fuer Details oder <code>!xp [nr] 50 Aktivitaet</code></em></p>';
await this.sendMessage(roomId, html);
}
private async handleSkillDetails(roomId: string, sender: string, numberStr: string) {
const token = this.requireAuth(sender);
const skill = this.getSkillByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const skill = this.skillsMapper.getByNumber(sender, number);
if (!skill) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
@ -296,7 +305,7 @@ export class MatrixService extends BaseMatrixService {
return;
}
this.lastSkillsList.delete(sender);
this.skillsMapper.clearList(sender);
const branchIcon = this.getBranchIcon(branch);
await this.sendMessage(
roomId,
@ -307,7 +316,8 @@ export class MatrixService extends BaseMatrixService {
private async handleDeleteSkill(roomId: string, sender: string, numberStr: string) {
const token = this.requireAuth(sender);
const skill = this.getSkillByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const skill = this.skillsMapper.getByNumber(sender, number);
if (!skill) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
@ -321,7 +331,7 @@ export class MatrixService extends BaseMatrixService {
return;
}
this.lastSkillsList.delete(sender);
this.skillsMapper.clearList(sender);
await this.sendMessage(roomId, `<p>Skill <strong>${skill.name}</strong> geloescht.</p>`);
}
@ -338,7 +348,8 @@ export class MatrixService extends BaseMatrixService {
}
const token = this.requireAuth(sender);
const skill = this.getSkillByNumber(sender, args[0]);
const number = parseInt(args[0], 10);
const skill = this.skillsMapper.getByNumber(sender, number);
if (!skill) {
await this.sendMessage(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
@ -417,9 +428,13 @@ export class MatrixService extends BaseMatrixService {
let skillName = '';
if (numberStr) {
const skill = this.getSkillByNumber(sender, numberStr);
const number = parseInt(numberStr, 10);
const skill = this.skillsMapper.getByNumber(sender, number);
if (!skill) {
await this.sendMessage(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);
@ -459,16 +474,6 @@ export class MatrixService extends BaseMatrixService {
}
// Helper methods
private getSkillByNumber(sender: string, numberStr: string): Skill | null {
const skills = this.lastSkillsList.get(sender);
if (!skills) return null;
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= skills.length) return null;
return skills[index];
}
private getLevelName(level: number): string {
const names: Record<number, string> = {
0: 'Unbekannt',
@ -494,13 +499,13 @@ export class MatrixService extends BaseMatrixService {
private getBranchIcon(branch: string): string {
const icons: Record<string, string> = {
intellect: '&#129504;', // Brain
body: '&#128170;', // Flexed biceps
intellect: '&#129504;', // Brain
body: '&#128170;', // Flexed biceps
creativity: '&#127912;', // Artist palette
social: '&#128101;', // Busts in silhouette
practical: '&#128295;', // Wrench
mindset: '&#128150;', // Heart
custom: '&#11088;', // Star
social: '&#128101;', // Busts in silhouette
practical: '&#128295;', // Wrench
mindset: '&#128150;', // Heart
custom: '&#11088;', // Star
};
return icons[branch] || '&#11088;';
}