From a532790d99ae7af58f3ac5ffa6745370dc91057d Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:45:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(matrix-zitare-bot):=20add=20Ma?= =?UTF-8?q?trix=20bot=20for=20daily=20inspiration=20quotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Random quotes and daily quote of the day - 10 categories (motivation, wisdom, love, life, success, etc.) - Search functionality - Login integration with Zitare backend - Favorites and lists management - Voice note transcription via mana-stt - Natural language command support (German/English) --- services/matrix-zitare-bot/.dockerignore | 6 + services/matrix-zitare-bot/.gitignore | 7 + services/matrix-zitare-bot/CLAUDE.md | 178 +++++ services/matrix-zitare-bot/Dockerfile | 41 + services/matrix-zitare-bot/nest-cli.json | 8 + services/matrix-zitare-bot/package.json | 39 + services/matrix-zitare-bot/src/app.module.ts | 17 + .../matrix-zitare-bot/src/bot/bot.module.ts | 12 + .../src/bot/matrix.service.ts | 714 ++++++++++++++++++ .../src/config/configuration.ts | 294 ++++++++ .../src/health.controller.ts | 13 + services/matrix-zitare-bot/src/main.ts | 17 + .../src/quotes/quotes.module.ts | 9 + .../src/quotes/quotes.service.ts | 113 +++ .../src/quotes/zitare.service.ts | 177 +++++ .../src/session/session.module.ts | 8 + .../src/session/session.service.ts | 113 +++ .../src/transcription/transcription.module.ts | 8 + .../transcription/transcription.service.ts | 37 + services/matrix-zitare-bot/tsconfig.json | 23 + 20 files changed, 1834 insertions(+) create mode 100644 services/matrix-zitare-bot/.dockerignore create mode 100644 services/matrix-zitare-bot/.gitignore create mode 100644 services/matrix-zitare-bot/CLAUDE.md create mode 100644 services/matrix-zitare-bot/Dockerfile create mode 100644 services/matrix-zitare-bot/nest-cli.json create mode 100644 services/matrix-zitare-bot/package.json create mode 100644 services/matrix-zitare-bot/src/app.module.ts create mode 100644 services/matrix-zitare-bot/src/bot/bot.module.ts create mode 100644 services/matrix-zitare-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-zitare-bot/src/config/configuration.ts create mode 100644 services/matrix-zitare-bot/src/health.controller.ts create mode 100644 services/matrix-zitare-bot/src/main.ts create mode 100644 services/matrix-zitare-bot/src/quotes/quotes.module.ts create mode 100644 services/matrix-zitare-bot/src/quotes/quotes.service.ts create mode 100644 services/matrix-zitare-bot/src/quotes/zitare.service.ts create mode 100644 services/matrix-zitare-bot/src/session/session.module.ts create mode 100644 services/matrix-zitare-bot/src/session/session.service.ts create mode 100644 services/matrix-zitare-bot/src/transcription/transcription.module.ts create mode 100644 services/matrix-zitare-bot/src/transcription/transcription.service.ts create mode 100644 services/matrix-zitare-bot/tsconfig.json diff --git a/services/matrix-zitare-bot/.dockerignore b/services/matrix-zitare-bot/.dockerignore new file mode 100644 index 000000000..d6a8859ae --- /dev/null +++ b/services/matrix-zitare-bot/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.log +.env* +data diff --git a/services/matrix-zitare-bot/.gitignore b/services/matrix-zitare-bot/.gitignore new file mode 100644 index 000000000..112abfa38 --- /dev/null +++ b/services/matrix-zitare-bot/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.turbo +*.log +.env* +!.env.example +data diff --git a/services/matrix-zitare-bot/CLAUDE.md b/services/matrix-zitare-bot/CLAUDE.md new file mode 100644 index 000000000..0acc02130 --- /dev/null +++ b/services/matrix-zitare-bot/CLAUDE.md @@ -0,0 +1,178 @@ +# Matrix Zitare Bot - Claude Code Guidelines + +## Overview + +Matrix Zitare Bot provides daily inspirational quotes via Matrix chat. It includes a built-in collection of German quotes and integrates with the Zitare backend for user favorites and lists management. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Storage**: Built-in quotes + Zitare Backend API +- **Auth**: Mana Core Auth (JWT) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-zitare-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3317) +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration, help text, quotes data +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ ├── quotes/ +│ │ ├── quotes.module.ts +│ │ ├── quotes.service.ts # Local quotes management +│ │ └── zitare.service.ts # Zitare Backend API client +│ └── session/ +│ ├── session.module.ts +│ └── session.service.ts # User session & auth management +├── Dockerfile +└── package.json +``` + +## Bot Commands + +| Command | Description | +|---------|-------------| +| `!help` | Show help message | +| `!zitat` | Random quote | +| `!heute` | Quote of the day | +| `!suche [text]` | Search quotes | +| `!kategorie [name]` | Quotes by category | +| `!kategorien` | Show all categories | +| `!login email pass` | Login to Zitare | +| `!logout` | Logout | +| `!favorit` | Save last quote to favorites | +| `!favoriten` | Show favorites | +| `!listen` | Show lists | +| `!liste [name]` | Create new list | +| `!addliste [nr]` | Add last quote to list | +| `!status` | Bot status | + +## Natural Language Keywords + +The bot responds to natural language (German + English): +- "zitat", "inspiration" -> Random quote +- "heute", "tageszitat" -> Daily quote +- "motiviere mich" -> Motivation quote +- "guten morgen" -> Motivation quote +- "kategorien" -> Show categories +- "hilfe", "help" -> Help message + +## Voice Notes + +Voice notes are transcribed via mana-stt service and parsed as commands: +- Say category names (e.g., "Motivation", "Liebe") for themed quotes +- Say search terms to find matching quotes +- Use natural language commands + +## Quote Categories + +- `motivation` - Motivationszitate +- `weisheit` - Weisheiten +- `liebe` - Liebeszitate +- `leben` - Lebenszitate +- `erfolg` - Erfolgszitate +- `glueck` - Gluckszitate +- `freundschaft` - Freundschaft +- `mut` - Mutzitate +- `hoffnung` - Hoffnungszitate +- `natur` - Naturzitate + +## Environment Variables + +```env +# Server +PORT=3317 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#zitare:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Zitare Backend (for favorites/lists) +ZITARE_BACKEND_URL=http://localhost:3007 +ZITARE_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Speech-to-Text +STT_URL=http://localhost:3020 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-zitare-bot/Dockerfile -t matrix-zitare-bot services/matrix-zitare-bot + +# Run +docker run -p 3317:3317 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e ZITARE_BACKEND_URL=http://zitare-backend:3007 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-zitare-bot-data:/app/data \ + matrix-zitare-bot +``` + +## Health Check + +```bash +curl http://localhost:3317/health +``` + +## Getting a Matrix Access Token + +```bash +# Login to get access token +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "zitare-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Zitare Backend API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/api/v1/favorites` | GET | Get user favorites | +| `/api/v1/favorites` | POST | Add favorite | +| `/api/v1/favorites/:id` | DELETE | Remove favorite | +| `/api/v1/lists` | GET | Get user lists | +| `/api/v1/lists` | POST | Create list | +| `/api/v1/lists/:id/quotes` | POST | Add quote to list | + +## GDPR Compliance + +- Built-in quotes stored locally (no external API) +- User favorites/lists stored in Zitare Backend database +- All data under user control +- No third-party tracking diff --git a/services/matrix-zitare-bot/Dockerfile b/services/matrix-zitare-bot/Dockerfile new file mode 100644 index 000000000..88740d71f --- /dev/null +++ b/services/matrix-zitare-bot/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files and install production dependencies only +COPY package.json ./ +RUN npm install --omit=dev + +# Copy built application from builder +COPY --from=builder /app/dist ./dist + +# Create data directory +RUN mkdir -p /app/data + +# Expose port +EXPOSE 3317 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3317/health || exit 1 + +# Start the application +CMD ["node", "dist/main.js"] diff --git a/services/matrix-zitare-bot/nest-cli.json b/services/matrix-zitare-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-zitare-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-zitare-bot/package.json b/services/matrix-zitare-bot/package.json new file mode 100644 index 000000000..6faaddb82 --- /dev/null +++ b/services/matrix-zitare-bot/package.json @@ -0,0 +1,39 @@ +{ + "name": "@manacore/matrix-zitare-bot", + "version": "1.0.0", + "description": "Matrix bot for daily inspiration quotes", + "private": true, + "pnpm": { + "neverBuiltDependencies": [ + "@matrix-org/matrix-sdk-crypto-nodejs" + ], + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + } + }, + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + }, + "scripts": { + "prebuild": "rm -rf dist || true", + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/services/matrix-zitare-bot/src/app.module.ts b/services/matrix-zitare-bot/src/app.module.ts new file mode 100644 index 000000000..09bbe3a8c --- /dev/null +++ b/services/matrix-zitare-bot/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-zitare-bot/src/bot/bot.module.ts b/services/matrix-zitare-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..d58e1b870 --- /dev/null +++ b/services/matrix-zitare-bot/src/bot/bot.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { QuotesModule } from '../quotes/quotes.module'; +import { SessionModule } from '../session/session.module'; +import { TranscriptionModule } from '../transcription/transcription.module'; + +@Module({ + imports: [QuotesModule, SessionModule, TranscriptionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-zitare-bot/src/bot/matrix.service.ts b/services/matrix-zitare-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..67c410402 --- /dev/null +++ b/services/matrix-zitare-bot/src/bot/matrix.service.ts @@ -0,0 +1,714 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + RichConsoleLogger, + LogService, + LogLevel, +} from 'matrix-bot-sdk'; +import { QuotesService } from '../quotes/quotes.service'; +import { ZitareService } from '../quotes/zitare.service'; +import { SessionService } from '../session/session.service'; +import { TranscriptionService } from '../transcription/transcription.service'; +import { HELP_MESSAGE, Category } from '../config/configuration'; + +// Natural language keywords that trigger commands +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'befehle', 'commands'], command: 'help' }, + { keywords: ['zitat', 'quote', 'inspiration', 'inspiriere'], command: 'zitat' }, + { keywords: ['heute', 'today', 'tages', 'tageszitat'], command: 'heute' }, + { keywords: ['motiviere', 'motivation', 'motivier mich'], command: 'motivation' }, + { keywords: ['guten morgen', 'morgen', 'good morning'], command: 'morgen' }, + { keywords: ['kategorien', 'categories', 'themen'], command: 'kategorien' }, + { keywords: ['favoriten', 'favorites', 'meine favoriten'], command: 'favoriten' }, + { keywords: ['listen', 'lists', 'meine listen'], command: 'listen' }, + { keywords: ['status', 'info'], command: 'status' }, +]; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private readonly allowedRooms: string[]; + private botUserId: string = ''; + + // Track last shown quote per user for favorites + private lastQuotes: Map = new Map(); + + constructor( + private configService: ConfigService, + private quotesService: QuotesService, + private zitareService: ZitareService, + private sessionService: SessionService, + private transcriptionService: TranscriptionService + ) { + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + // Setup logging + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogLevel.INFO); + + // Storage for sync token persistence + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + + // Create Matrix client + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + // Auto-join rooms when invited + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); + + setTimeout(async () => { + try { + await this.sendBotIntroduction(roomId); + } catch (error) { + this.logger.error(`Failed to send introduction to ${roomId}:`, error); + } + }, 2000); + }); + + // Get bot's user ID + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + // Setup message handler + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + // Start the client + await this.client.start(); + this.logger.log('Matrix Zitare Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix bot stopped'); + } + } + + private async sendBotIntroduction(roomId: string) { + const dailyQuote = this.quotesService.getDailyQuote(); + + const introText = `**Zitare Bot - Tagliche 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); + } + + 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; 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)}...`); + + // Handle commands with ! prefix + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + return; + } + + // Check for natural language keywords + const keywordCommand = this.detectKeywordCommand(body); + if (keywordCommand) { + await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + return; + } + + // Don't respond to random messages + } + + 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(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'hilfe': + case 'start': + await this.sendHelp(roomId); + break; + + case 'zitat': + case 'quote': + await this.handleRandomQuote(roomId, sender); + break; + + case 'heute': + case 'today': + await this.handleDailyQuote(roomId, sender); + break; + + case 'suche': + case 'search': + await this.handleSearch(roomId, sender, argString); + break; + + case 'kategorie': + case 'category': + await this.handleCategory(roomId, sender, argString); + break; + + case 'kategorien': + case 'categories': + await this.handleCategories(roomId); + break; + + case 'motivation': + case 'morgen': + await this.handleCategoryQuote(roomId, sender, 'motivation'); + break; + + case 'login': + await this.handleLogin(roomId, sender, args); + break; + + case 'logout': + this.sessionService.logout(sender); + await this.sendMessage(roomId, 'Du wurdest abgemeldet.'); + break; + + case 'favorit': + case 'fav': + await this.handleAddFavorite(roomId, sender); + break; + + case 'favoriten': + case 'favorites': + await this.handleFavorites(roomId, sender); + break; + + case 'listen': + case 'lists': + await this.handleLists(roomId, sender); + break; + + case 'liste': + case 'list': + await this.handleCreateList(roomId, sender, argString); + break; + + case 'addliste': + case 'addlist': + await this.handleAddToList(roomId, sender, args); + break; + + case 'status': + await this.handleStatus(roomId, sender); + break; + + case 'pin': + await this.pinHelpMessage(roomId); + break; + + default: + await this.sendMessage( + roomId, + `Unbekannter Befehl: !${command}\n\nSag "hilfe" fur 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); + } + + private async handleRandomQuote(roomId: string, sender: string) { + const quote = this.quotesService.getRandomQuote(); + this.lastQuotes.set(sender, quote.id); + await this.sendMessage(roomId, this.quotesService.formatQuote(quote)); + } + + private async handleDailyQuote(roomId: string, sender: string) { + const quote = this.quotesService.getDailyQuote(); + this.lastQuotes.set(sender, quote.id); + + const dateStr = new Date().toLocaleDateString('de-DE', { + weekday: 'long', + day: 'numeric', + month: 'long', + }); + + await this.sendMessage( + roomId, + `**Zitat des Tages - ${dateStr}**\n\n${this.quotesService.formatQuote(quote)}` + ); + } + + private async handleSearch(roomId: string, sender: string, searchText: string) { + if (!searchText.trim()) { + await this.sendMessage(roomId, '**Verwendung:** `!suche [text]`\n\nBeispiel: `!suche Gluck`'); + return; + } + + const results = this.quotesService.searchQuotes(searchText); + + if (results.length === 0) { + await this.sendMessage(roomId, `Keine Zitate gefunden fur: "${searchText}"`); + return; + } + + let text = `**Suchergebnisse fur "${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`; + } + + if (results.length > 5) { + text += `_...und ${results.length - 5} weitere_`; + } + + // Store first result for favorites + if (results.length > 0) { + this.lastQuotes.set(sender, results[0].id); + } + + await this.sendMessage(roomId, text); + } + + private async handleCategory(roomId: string, sender: string, categoryName: string) { + if (!categoryName.trim()) { + await this.handleCategories(roomId); + return; + } + + const category = this.quotesService.getCategoryByName(categoryName); + if (!category) { + await this.sendMessage( + roomId, + `Kategorie "${categoryName}" nicht gefunden.\n\nNutze \`!kategorien\` fur alle Kategorien.` + ); + return; + } + + await this.handleCategoryQuote(roomId, sender, category); + } + + private async handleCategoryQuote(roomId: string, sender: string, category: Category) { + const quote = this.quotesService.getRandomQuoteByCategory(category); + if (!quote) { + await this.sendMessage(roomId, `Keine Zitate in Kategorie "${category}" gefunden.`); + return; + } + + this.lastQuotes.set(sender, quote.id); + await this.sendMessage(roomId, this.quotesService.formatQuote(quote)); + } + + private async handleCategories(roomId: string) { + const categories = this.quotesService.getAllCategories(); + + let text = `**Verfugbare Kategorien:**\n\n`; + for (const { category, label, count } of categories) { + text += `- **${label}** (\`!kategorie ${category}\`) - ${count} Zitate\n`; + } + + text += `\n**Gesamt:** ${this.quotesService.getTotalCount()} Zitate`; + + await this.sendMessage(roomId, text); + } + + private async handleLogin(roomId: string, sender: string, args: string[]) { + if (args.length < 2) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!login email passwort\`\n\nBeispiel: \`!login nutzer@example.com meinpasswort\`` + ); + return; + } + + const [email, password] = args; + + await this.sendMessage(roomId, 'Anmeldung lauft...'); + + const result = await this.sessionService.login(sender, email, password); + + if (result.success) { + await this.sendMessage( + roomId, + `Erfolgreich angemeldet!\n\nDu kannst jetzt Favoriten speichern und Listen verwalten.` + ); + } else { + await this.sendMessage(roomId, `Anmeldung fehlgeschlagen: ${result.error}`); + } + } + + private async handleAddFavorite(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + const lastQuoteId = this.lastQuotes.get(sender); + if (!lastQuoteId) { + await this.sendMessage( + roomId, + `Kein Zitat zum Speichern. Lass dir erst ein Zitat mit \`!zitat\` oder \`!heute\` anzeigen.` + ); + return; + } + + try { + await this.zitareService.addFavorite(lastQuoteId, token); + const quote = this.quotesService.getQuoteById(lastQuoteId); + await this.sendMessage( + roomId, + `Zu Favoriten hinzugefugt!\n\n"${quote?.text.substring(0, 50)}..."` + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleFavorites(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const favorites = await this.zitareService.getFavorites(token); + + if (favorites.length === 0) { + await this.sendMessage( + roomId, + `Du hast noch keine Favoriten.\n\nNutze \`!favorit\` um das letzte angezeigte Zitat zu speichern.` + ); + return; + } + + let text = `**Deine Favoriten (${favorites.length}):**\n\n`; + + for (let i = 0; i < Math.min(favorites.length, 10); i++) { + 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`; + } + } + + if (favorites.length > 10) { + text += `_...und ${favorites.length - 10} weitere_`; + } + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleLists(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const lists = await this.zitareService.getLists(token); + + if (lists.length === 0) { + await this.sendMessage( + roomId, + `Du hast noch keine Listen.\n\nNutze \`!liste [name]\` um eine neue Liste zu erstellen.` + ); + return; + } + + let text = `**Deine Listen (${lists.length}):**\n\n`; + + for (let i = 0; i < lists.length; i++) { + const list = lists[i]; + text += `**${i + 1}. ${list.name}** - ${list.quoteIds.length} Zitate\n`; + if (list.description) { + text += ` _${list.description}_\n`; + } + } + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleCreateList(roomId: string, sender: string, name: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (!name.trim()) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!liste [name]\`\n\nBeispiel: \`!liste Meine Lieblingszitate\`` + ); + return; + } + + try { + 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.` + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleAddToList(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!addliste [listen-nr]\`\n\nFugt 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.`); + return; + } + + const lastQuoteId = this.lastQuotes.get(sender); + if (!lastQuoteId) { + await this.sendMessage( + roomId, + `Kein Zitat zum Hinzufugen. Lass dir erst ein Zitat anzeigen.` + ); + return; + } + + try { + const lists = await this.zitareService.getLists(token); + if (listIndex > lists.length) { + await this.sendMessage(roomId, `Liste ${listIndex} existiert nicht.`); + return; + } + + const list = lists[listIndex - 1]; + await this.zitareService.addQuoteToList(list.id, lastQuoteId, token); + + const quote = this.quotesService.getQuoteById(lastQuoteId); + await this.sendMessage( + roomId, + `Zitat zu "${list.name}" hinzugefugt!\n\n"${quote?.text.substring(0, 50)}..."` + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleStatus(roomId: string, sender: string) { + const backendHealthy = await this.zitareService.checkHealth(); + const isLoggedIn = this.sessionService.isLoggedIn(sender); + const sessionCount = this.sessionService.getSessionCount(); + const totalQuotes = this.quotesService.getTotalCount(); + + const statusText = `**Zitare Bot Status** + +**Backend:** ${backendHealthy ? 'Online' : 'Offline'} +**Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'} +**Aktive Sessions:** ${sessionCount} +**Verfugbare Zitate:** ${totalQuotes} + +${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; + + await this.sendMessage(roomId, statusText); + } + + 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, + }); + + 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:`, error); + await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.'); + } + } + + private async sendMessage(roomId: string, message: string) { + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return ( + markdown + // Code blocks + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Bold + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic + .replace(/\*([^*]+)\*/g, '$1') + // Underscore italic + .replace(/_([^_]+)_/g, '$1') + // Line breaks + .replace(/\n/g, '
') + ); + } +} diff --git a/services/matrix-zitare-bot/src/config/configuration.ts b/services/matrix-zitare-bot/src/config/configuration.ts new file mode 100644 index 000000000..25c0fbd82 --- /dev/null +++ b/services/matrix-zitare-bot/src/config/configuration.ts @@ -0,0 +1,294 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3317', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean), + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + zitare: { + backendUrl: process.env.ZITARE_BACKEND_URL || 'http://localhost:3007', + apiPrefix: process.env.ZITARE_API_PREFIX || '/api/v1', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, + stt: { + url: process.env.STT_URL || 'http://localhost:3020', + }, +}); + +export const HELP_MESSAGE = `**Zitare Bot - Tagliche Inspiration** + +**Zitate:** +- \`!zitat\` - Zufalliges Zitat +- \`!heute\` - Zitat des Tages +- \`!suche [text]\` - Zitate suchen +- \`!kategorie [name]\` - Zitate nach Kategorie +- \`!kategorien\` - Alle Kategorien + +**Favoriten:** (Login erforderlich) +- \`!login email passwort\` - Anmelden +- \`!logout\` - Abmelden +- \`!favorit\` - Letztes Zitat speichern +- \`!favoriten\` - Alle Favoriten anzeigen + +**Listen:** (Login erforderlich) +- \`!listen\` - Alle Listen anzeigen +- \`!liste [name]\` - Neue Liste erstellen +- \`!addliste [nr] [zitat-nr]\` - Zitat zur Liste hinzufugen + +**Sonstiges:** +- \`!status\` - Bot-Status +- \`!help\` - Diese Hilfe + +**Sprachnotizen:** +Sende eine Sprachnotiz mit Befehlen wie "Zitat", "Motivation" oder einem Suchbegriff. + +**Naturliche Sprache:** +- "zitat", "inspiration" -> Zufalliges Zitat +- "motiviere mich" -> Motivation-Zitat +- "guten morgen" -> Morgenzitat`; + +// Quote categories +export const CATEGORIES = [ + 'motivation', + 'weisheit', + 'liebe', + 'leben', + 'erfolg', + 'glueck', + 'freundschaft', + 'mut', + 'hoffnung', + 'natur', +] as const; + +export type Category = (typeof CATEGORIES)[number]; + +// German inspirational quotes collection +export interface Quote { + id: string; + text: string; + author: string; + category: Category; +} + +export const QUOTES: Quote[] = [ + // Motivation + { + id: 'mot-1', + text: 'Der einzige Weg, grossartige Arbeit zu leisten, ist zu lieben, was man tut.', + author: 'Steve Jobs', + category: 'motivation', + }, + { + id: 'mot-2', + text: 'Erfolg ist nicht endgultig, Misserfolg ist nicht fatal: Was zahlt, ist der Mut weiterzumachen.', + author: 'Winston Churchill', + category: 'motivation', + }, + { + id: 'mot-3', + text: 'Die Zukunft gehort denen, die an die Schonheit ihrer Traume glauben.', + author: 'Eleanor Roosevelt', + category: 'motivation', + }, + { + id: 'mot-4', + text: 'Es ist nie zu spat, das zu werden, was man hatte sein konnen.', + author: 'George Eliot', + category: 'motivation', + }, + { + id: 'mot-5', + text: 'Gib jedem Tag die Chance, der schonste deines Lebens zu werden.', + author: 'Mark Twain', + category: 'motivation', + }, + // Weisheit + { + id: 'weis-1', + text: 'Der Weg ist das Ziel.', + author: 'Konfuzius', + category: 'weisheit', + }, + { + id: 'weis-2', + text: 'Wer kampft, kann verlieren. Wer nicht kampft, hat schon verloren.', + author: 'Bertolt Brecht', + category: 'weisheit', + }, + { + id: 'weis-3', + text: 'Man sieht nur mit dem Herzen gut. Das Wesentliche ist fur die Augen unsichtbar.', + author: 'Antoine de Saint-Exupery', + category: 'weisheit', + }, + { + id: 'weis-4', + text: 'Nicht weil es schwer ist, wagen wir es nicht, sondern weil wir es nicht wagen, ist es schwer.', + author: 'Seneca', + category: 'weisheit', + }, + { + id: 'weis-5', + text: 'Wissen ist Macht.', + author: 'Francis Bacon', + category: 'weisheit', + }, + // Liebe + { + id: 'liebe-1', + text: 'Wo Liebe ist, da ist auch Leben.', + author: 'Mahatma Gandhi', + category: 'liebe', + }, + { + id: 'liebe-2', + text: 'Die Liebe allein versteht das Geheimnis, andere zu beschenken und dabei selbst reich zu werden.', + author: 'Clemens Brentano', + category: 'liebe', + }, + { + id: 'liebe-3', + text: 'Es gibt nur ein Gluck in diesem Leben: zu lieben und geliebt zu werden.', + author: 'George Sand', + category: 'liebe', + }, + // Leben + { + id: 'leben-1', + text: 'Das Leben ist wie Fahrrad fahren. Um die Balance zu halten, musst du in Bewegung bleiben.', + author: 'Albert Einstein', + category: 'leben', + }, + { + id: 'leben-2', + text: 'Leben ist das, was passiert, wahrend du damit beschaftigt bist, andere Plane zu machen.', + author: 'John Lennon', + category: 'leben', + }, + { + id: 'leben-3', + text: 'Das Leben ist zu kurz fur spater.', + author: 'Alexandra Reinwarth', + category: 'leben', + }, + { + id: 'leben-4', + text: 'Lebe jeden Tag, als ware es dein letzter.', + author: 'Marcus Aurelius', + category: 'leben', + }, + // Erfolg + { + id: 'erfolg-1', + text: 'Erfolg besteht darin, dass man genau die Fahigkeiten hat, die im Moment gefragt sind.', + author: 'Henry Ford', + category: 'erfolg', + }, + { + id: 'erfolg-2', + text: 'Der Preis des Erfolges ist Hingabe, harte Arbeit und unablassiger Einsatz.', + author: 'Frank Lloyd Wright', + category: 'erfolg', + }, + { + id: 'erfolg-3', + text: 'Ich habe nicht versagt. Ich habe nur 10.000 Wege gefunden, die nicht funktionieren.', + author: 'Thomas Edison', + category: 'erfolg', + }, + // Glueck + { + id: 'glueck-1', + text: 'Gluck ist das Einzige, das sich verdoppelt, wenn man es teilt.', + author: 'Albert Schweitzer', + category: 'glueck', + }, + { + id: 'glueck-2', + text: 'Gluck ist kein Ziel, sondern ein Weg.', + author: 'Buddha', + category: 'glueck', + }, + { + id: 'glueck-3', + text: 'Nicht die Glucklichen sind dankbar. Es sind die Dankbaren, die glucklich sind.', + author: 'Francis Bacon', + category: 'glueck', + }, + // Freundschaft + { + id: 'freund-1', + text: 'Ein wahrer Freund ist jemand, der die Melodie deines Herzens kennt und sie dir vorsingt, wenn du sie vergessen hast.', + author: 'Albert Einstein', + category: 'freundschaft', + }, + { + id: 'freund-2', + text: 'Freundschaft ist eine Seele in zwei Korpern.', + author: 'Aristoteles', + category: 'freundschaft', + }, + // Mut + { + id: 'mut-1', + text: 'Mut steht am Anfang des Handelns, Gluck am Ende.', + author: 'Demokrit', + category: 'mut', + }, + { + id: 'mut-2', + text: 'Wer wagt, gewinnt.', + author: 'Deutsches Sprichwort', + category: 'mut', + }, + { + id: 'mut-3', + text: 'Der Mutige hat nicht weniger Angst, er handelt trotzdem.', + author: 'Mark Twain', + category: 'mut', + }, + // Hoffnung + { + id: 'hoff-1', + text: 'Hoffnung ist ein Vogel, der singt, wenn die Nacht noch dunkel ist.', + author: 'Rabindranath Tagore', + category: 'hoffnung', + }, + { + id: 'hoff-2', + text: 'Nach jedem Sturm scheint auch wieder die Sonne.', + author: 'Deutsches Sprichwort', + category: 'hoffnung', + }, + // Natur + { + id: 'natur-1', + text: 'In der Natur ist nichts isoliert; alles hangt mit allem zusammen.', + author: 'Johann Wolfgang von Goethe', + category: 'natur', + }, + { + id: 'natur-2', + text: 'Schau tief in die Natur, und dann wirst du alles besser verstehen.', + author: 'Albert Einstein', + category: 'natur', + }, +]; + +// Category labels in German +export const CATEGORY_LABELS: Record = { + motivation: 'Motivation', + weisheit: 'Weisheit', + liebe: 'Liebe', + leben: 'Leben', + erfolg: 'Erfolg', + glueck: 'Gluck', + freundschaft: 'Freundschaft', + mut: 'Mut', + hoffnung: 'Hoffnung', + natur: 'Natur', +}; diff --git a/services/matrix-zitare-bot/src/health.controller.ts b/services/matrix-zitare-bot/src/health.controller.ts new file mode 100644 index 000000000..a513f0fd1 --- /dev/null +++ b/services/matrix-zitare-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-zitare-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-zitare-bot/src/main.ts b/services/matrix-zitare-bot/src/main.ts new file mode 100644 index 000000000..ef33adcb7 --- /dev/null +++ b/services/matrix-zitare-bot/src/main.ts @@ -0,0 +1,17 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3317; + await app.listen(port); + + logger.log(`Matrix Zitare Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/services/matrix-zitare-bot/src/quotes/quotes.module.ts b/services/matrix-zitare-bot/src/quotes/quotes.module.ts new file mode 100644 index 000000000..31b4696b8 --- /dev/null +++ b/services/matrix-zitare-bot/src/quotes/quotes.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { QuotesService } from './quotes.service'; +import { ZitareService } from './zitare.service'; + +@Module({ + providers: [QuotesService, ZitareService], + exports: [QuotesService, ZitareService], +}) +export class QuotesModule {} diff --git a/services/matrix-zitare-bot/src/quotes/quotes.service.ts b/services/matrix-zitare-bot/src/quotes/quotes.service.ts new file mode 100644 index 000000000..376c8cbd5 --- /dev/null +++ b/services/matrix-zitare-bot/src/quotes/quotes.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { QUOTES, Quote, Category, CATEGORIES, CATEGORY_LABELS } from '../config/configuration'; + +@Injectable() +export class QuotesService { + private readonly logger = new Logger(QuotesService.name); + private dailyQuoteCache: { date: string; quote: Quote } | null = null; + + getRandomQuote(): Quote { + const index = Math.floor(Math.random() * QUOTES.length); + return QUOTES[index]; + } + + getDailyQuote(): Quote { + const today = new Date().toISOString().split('T')[0]; + + // Return cached daily quote if same day + if (this.dailyQuoteCache && this.dailyQuoteCache.date === today) { + return this.dailyQuoteCache.quote; + } + + // Generate deterministic quote based on date + const dateHash = this.hashDate(today); + const index = dateHash % QUOTES.length; + const quote = QUOTES[index]; + + this.dailyQuoteCache = { date: today, quote }; + this.logger.log(`Daily quote for ${today}: "${quote.text.substring(0, 30)}..."`); + + return quote; + } + + private hashDate(dateStr: string): number { + let hash = 0; + for (let i = 0; i < dateStr.length; i++) { + const char = dateStr.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); + } + + getQuotesByCategory(category: Category): Quote[] { + return QUOTES.filter((q) => q.category === category); + } + + getRandomQuoteByCategory(category: Category): Quote | null { + const quotes = this.getQuotesByCategory(category); + if (quotes.length === 0) return null; + const index = Math.floor(Math.random() * quotes.length); + return quotes[index]; + } + + searchQuotes(searchText: string): Quote[] { + const lowerSearch = searchText.toLowerCase(); + return QUOTES.filter( + (q) => + q.text.toLowerCase().includes(lowerSearch) || q.author.toLowerCase().includes(lowerSearch) + ); + } + + getQuoteById(id: string): Quote | undefined { + return QUOTES.find((q) => q.id === id); + } + + getQuoteByIndex(index: number): Quote | null { + if (index < 1 || index > QUOTES.length) return null; + return QUOTES[index - 1]; + } + + getAllCategories(): { category: Category; label: string; count: number }[] { + return CATEGORIES.map((category) => ({ + category, + label: CATEGORY_LABELS[category], + count: QUOTES.filter((q) => q.category === category).length, + })); + } + + getCategoryByName(name: string): Category | null { + const lowerName = name.toLowerCase(); + + // Try exact match first + if (CATEGORIES.includes(lowerName as Category)) { + return lowerName as Category; + } + + // Try partial match + for (const category of CATEGORIES) { + if ( + category.startsWith(lowerName) || + CATEGORY_LABELS[category].toLowerCase().startsWith(lowerName) + ) { + return category; + } + } + + return null; + } + + getTotalCount(): number { + return QUOTES.length; + } + + formatQuote(quote: Quote): string { + const categoryLabel = CATEGORY_LABELS[quote.category]; + return `"${quote.text}"\n\n— *${quote.author}*\n\n[${categoryLabel}]`; + } + + formatQuoteWithNumber(quote: Quote, number: number): string { + const categoryLabel = CATEGORY_LABELS[quote.category]; + return `**#${number}**\n"${quote.text}"\n\n— *${quote.author}* [${categoryLabel}]`; + } +} diff --git a/services/matrix-zitare-bot/src/quotes/zitare.service.ts b/services/matrix-zitare-bot/src/quotes/zitare.service.ts new file mode 100644 index 000000000..9c60f944d --- /dev/null +++ b/services/matrix-zitare-bot/src/quotes/zitare.service.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface Favorite { + id: string; + userId: string; + quoteId: string; + createdAt: string; +} + +export interface UserList { + id: string; + userId: string; + name: string; + description?: string; + quoteIds: string[]; + createdAt: string; + updatedAt: string; +} + +@Injectable() +export class ZitareService { + private readonly logger = new Logger(ZitareService.name); + private readonly baseUrl: string; + + constructor(private configService: ConfigService) { + const backendUrl = + this.configService.get('zitare.backendUrl') || 'http://localhost:3007'; + const apiPrefix = this.configService.get('zitare.apiPrefix') || '/api/v1'; + this.baseUrl = `${backendUrl}${apiPrefix}`; + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.baseUrl.replace('/api/v1', '')}/health`); + return response.ok; + } catch { + return false; + } + } + + // Favorites + + async getFavorites(token: string): Promise { + const response = await fetch(`${this.baseUrl}/favorites`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to get favorites: ${response.status}`); + } + + const data = await response.json(); + return data.favorites || []; + } + + async addFavorite(quoteId: string, token: string): Promise { + const response = await fetch(`${this.baseUrl}/favorites`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ quoteId }), + }); + + if (response.status === 409) { + throw new Error('Dieses Zitat ist bereits in deinen Favoriten'); + } + + if (!response.ok) { + throw new Error(`Failed to add favorite: ${response.status}`); + } + + return response.json(); + } + + async removeFavorite(quoteId: string, token: string): Promise { + const response = await fetch(`${this.baseUrl}/favorites/${quoteId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to remove favorite: ${response.status}`); + } + } + + // Lists + + async getLists(token: string): Promise { + const response = await fetch(`${this.baseUrl}/lists`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to get lists: ${response.status}`); + } + + const data = await response.json(); + return data.lists || []; + } + + async getList(listId: string, token: string): Promise { + const response = await fetch(`${this.baseUrl}/lists/${listId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to get list: ${response.status}`); + } + + return response.json(); + } + + async createList( + name: string, + description: string | undefined, + token: string + ): Promise { + const response = await fetch(`${this.baseUrl}/lists`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name, description }), + }); + + if (!response.ok) { + throw new Error(`Failed to create list: ${response.status}`); + } + + return response.json(); + } + + async deleteList(listId: string, token: string): Promise { + const response = await fetch(`${this.baseUrl}/lists/${listId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to delete list: ${response.status}`); + } + } + + async addQuoteToList(listId: string, quoteId: string, token: string): Promise { + const response = await fetch(`${this.baseUrl}/lists/${listId}/quotes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ quoteId }), + }); + + if (!response.ok) { + throw new Error(`Failed to add quote to list: ${response.status}`); + } + + return response.json(); + } + + async removeQuoteFromList(listId: string, quoteId: string, token: string): Promise { + const response = await fetch(`${this.baseUrl}/lists/${listId}/quotes/${quoteId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to remove quote from list: ${response.status}`); + } + + return response.json(); + } +} diff --git a/services/matrix-zitare-bot/src/session/session.module.ts b/services/matrix-zitare-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-zitare-bot/src/session/session.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SessionService } from './session.service'; + +@Module({ + providers: [SessionService], + exports: [SessionService], +}) +export class SessionModule {} diff --git a/services/matrix-zitare-bot/src/session/session.service.ts b/services/matrix-zitare-bot/src/session/session.service.ts new file mode 100644 index 000000000..68c047813 --- /dev/null +++ b/services/matrix-zitare-bot/src/session/session.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface UserSession { + token: string; + email: string; + expiresAt: Date; + lastQuoteId?: string; +} + +@Injectable() +export class SessionService { + private readonly logger = new Logger(SessionService.name); + private sessions: Map = new Map(); + private authUrl: string; + + constructor(private configService: ConfigService) { + this.authUrl = this.configService.get('auth.url') || 'http://localhost:3001'; + } + + async login( + matrixUserId: string, + email: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch(`${this.authUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.message || 'Authentifizierung fehlgeschlagen', + }; + } + + const data = await response.json(); + const token = data.accessToken || data.token; + + if (!token) { + return { success: false, error: 'Kein Token erhalten' }; + } + + // Store session (7 days expiry) + this.sessions.set(matrixUserId, { + token, + email, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }); + + this.logger.log(`User ${matrixUserId} logged in as ${email}`); + return { success: true }; + } catch (error) { + this.logger.error(`Login failed for ${matrixUserId}:`, error); + return { + success: false, + error: 'Verbindung zum Auth-Server fehlgeschlagen', + }; + } + } + + logout(matrixUserId: string): void { + this.sessions.delete(matrixUserId); + this.logger.log(`User ${matrixUserId} logged out`); + } + + getToken(matrixUserId: string): string | null { + const session = this.sessions.get(matrixUserId); + + if (!session) return null; + + // Check if token expired + if (session.expiresAt < new Date()) { + this.sessions.delete(matrixUserId); + return null; + } + + return session.token; + } + + isLoggedIn(matrixUserId: string): boolean { + return this.getToken(matrixUserId) !== null; + } + + setLastQuoteId(matrixUserId: string, quoteId: string): void { + const session = this.sessions.get(matrixUserId); + if (session) { + session.lastQuoteId = quoteId; + } + } + + getLastQuoteId(matrixUserId: string): string | null { + const session = this.sessions.get(matrixUserId); + return session?.lastQuoteId || null; + } + + getSessionCount(): number { + return this.sessions.size; + } + + getLoggedInCount(): number { + const now = new Date(); + let count = 0; + for (const session of this.sessions.values()) { + if (session.expiresAt > now) count++; + } + return count; + } +} diff --git a/services/matrix-zitare-bot/src/transcription/transcription.module.ts b/services/matrix-zitare-bot/src/transcription/transcription.module.ts new file mode 100644 index 000000000..fb5aeeaf1 --- /dev/null +++ b/services/matrix-zitare-bot/src/transcription/transcription.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TranscriptionService } from './transcription.service'; + +@Module({ + providers: [TranscriptionService], + exports: [TranscriptionService], +}) +export class TranscriptionModule {} diff --git a/services/matrix-zitare-bot/src/transcription/transcription.service.ts b/services/matrix-zitare-bot/src/transcription/transcription.service.ts new file mode 100644 index 000000000..fc5e761c4 --- /dev/null +++ b/services/matrix-zitare-bot/src/transcription/transcription.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TranscriptionService { + private readonly logger = new Logger(TranscriptionService.name); + private readonly sttUrl: string; + + constructor(private configService: ConfigService) { + this.sttUrl = this.configService.get('stt.url') || 'http://localhost:3020'; + } + + async transcribe(audioBuffer: Buffer, language: string = 'de'): Promise { + try { + const formData = new FormData(); + const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/ogg' }); + formData.append('file', blob, 'audio.ogg'); + formData.append('language', language); + + const response = await fetch(`${this.sttUrl}/transcribe`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`STT service error: ${response.status}`); + } + + const result = (await response.json()) as { text: string }; + this.logger.log(`Transcription result: ${result.text}`); + return result.text; + } catch (error) { + this.logger.error('Transcription failed:', error); + throw error; + } + } +} diff --git a/services/matrix-zitare-bot/tsconfig.json b/services/matrix-zitare-bot/tsconfig.json new file mode 100644 index 000000000..38c2b55d7 --- /dev/null +++ b/services/matrix-zitare-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +}