diff --git a/.claude/plans/voice-integration-mana-bot.md b/.claude/plans/voice-integration-mana-bot.md deleted file mode 100644 index f3747cca1..000000000 --- a/.claude/plans/voice-integration-mana-bot.md +++ /dev/null @@ -1,475 +0,0 @@ -# Voice Integration für matrix-mana-bot - -## Übersicht - -Integration des mana-voice-bot Service (Port 3050) in den matrix-mana-bot Gateway, um vollständige Voice-to-Voice Interaktion zu ermöglichen. - -## Architektur - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Matrix Client (Element) │ -│ │ -│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ -│ │ Text Message │ │ Voice Note │ │ Audio Reply │ │ -│ │ "!heute" │ │ 🎤 "Was..." │ │ 🔊 Response │ │ -│ └───────┬────────┘ └───────┬────────┘ └───────▲────────┘ │ -└──────────┼────────────────────┼────────────────────┼────────────────────┘ - │ │ │ - ▼ ▼ │ -┌──────────────────────────────────────────────────────────────────────────┐ -│ matrix-mana-bot (Port 3310) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ MatrixService │ │ -│ │ handleTextMessage() │ handleAudioMessage() │ sendAudioReply() │ │ -│ └───────────┬───────────────────┬───────────────────────▲─────────────┘ │ -│ │ │ │ │ -│ ▼ ▼ │ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ VoiceService (NEU) │ │ -│ │ • transcribeAudio() → mana-stt (3020) │ │ -│ │ • synthesizeSpeech() → mana-voice-bot (3050) │ │ -│ │ • User preferences (voice, speed) │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ CommandRouter │ │ -│ │ route(ctx) → AI | Todo | Calendar | Clock | Orchestration │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -## User Flow - -### Flow 1: Voice Input → Text + Audio Output - -``` -User Bot Services - │ │ │ - │ 🎤 Voice Note │ │ - │ "Was steht heute an?" │ │ - │ ───────────────────────────>│ │ - │ │ │ - │ │ Download Audio │ - │ │ ────────────────────────────>│ Matrix - │ │<──────────────────────────── │ - │ │ │ - │ │ POST /transcribe │ - │ │ ────────────────────────────>│ mana-stt - │ │ "Was steht heute an?" │ - │ │<──────────────────────────── │ - │ │ │ - │ │ route("Was steht heute an?")│ - │ │ ──────────────────────────> │ CommandRouter - │ │ 📋 Termine + Aufgaben │ - │ │<────────────────────────────>│ - │ │ │ - │ 📝 Text Response │ │ - │ "Heute hast du..." │ │ - │<─────────────────────────── │ │ - │ │ │ - │ │ POST /tts │ - │ │ ────────────────────────────>│ mana-voice-bot - │ │ [audio/mpeg] │ - │ │<──────────────────────────── │ - │ │ │ - │ │ Upload Audio │ - │ │ ────────────────────────────>│ Matrix - │ │ mxc://... │ - │ │<──────────────────────────── │ - │ │ │ - │ 🔊 Audio Response │ │ - │ "Heute hast du..." │ │ - │<─────────────────────────── │ │ -``` - -### Flow 2: Text Input mit Voice Response (Optional) - -``` -User Bot - │ │ - │ "!heute" │ - │ ───────────────────────────>│ - │ │ - │ 📝 Text: "Heute hast..." │ - │<─────────────────────────── │ - │ │ - │ (Voice Response optional) │ - │ 🔊 Audio wenn aktiviert │ - │<─────────────────────────── │ -``` - -## Neue Befehle - -### Voice-Einstellungen - -| Befehl | Beschreibung | -| -------------------------- | ------------------------------------- | -| `!voice` | Zeigt aktuelle Voice-Einstellungen | -| `!voice an` / `!voice aus` | Aktiviert/deaktiviert Audio-Antworten | -| `!stimme [name]` | Wählt TTS-Stimme | -| `!stimmen` | Zeigt verfügbare Stimmen | -| `!speed [0.5-2.0]` | Sprechgeschwindigkeit | - -### Beispiel-Session - -``` -User: 🎤 "Mana, was habe ich heute vor?" - -Bot: 📝 **Dein Tag heute (15. Februar):** - - **Termine:** - • 10:00 - Team Meeting - • 14:30 - Zahnarzt - - **Aufgaben:** - 1. Einkaufen gehen !p1 - 2. Report fertigstellen @heute - -Bot: 🔊 [Audio: "Heute hast du zwei Termine: Um zehn Uhr Team Meeting - und um halb drei Zahnarzt. Außerdem stehen zwei Aufgaben an: - Einkaufen gehen mit hoher Priorität und Report fertigstellen."] -``` - -## UX-Prinzipien - -### 1. Text + Audio (Dual Response) - -Bei Voice-Input immer **beides** senden: - -- **Text zuerst** → Sofortiges visuelles Feedback, scrollbar, kopierbar -- **Audio danach** → Natürliche Sprachausgabe - -Vorteile: - -- User kann sofort lesen während Audio lädt -- Referenz bleibt im Chat-Verlauf -- Accessibility für verschiedene Situationen - -### 2. Intelligente Audio-Länge - -| Antwort-Typ | Audio | Begründung | -| ---------------------- | ----------------------- | ------------------- | -| Kurz (< 200 Zeichen) | Ja | Schnell, natürlich | -| Mittel (< 500 Zeichen) | Ja | Noch angenehm | -| Lang (> 500 Zeichen) | Zusammenfassung | Voller Text zu lang | -| Listen (> 5 Items) | Top 3 + "und X weitere" | Fokus auf Wichtiges | -| Fehler | Kurze Erklärung | Klar und hilfreich | - -### 3. Kontext-Sensitive Antworten - -```typescript -// Kurze Bestätigung -"Aufgabe hinzugefügt: Einkaufen gehen" -→ 🔊 "Erledigt, Einkaufen gehen wurde hinzugefügt." - -// Liste mit vielen Items -"Du hast 12 Aufgaben..." -→ 🔊 "Du hast zwölf Aufgaben, davon drei mit hoher Priorität. - Die wichtigsten sind: Erstens, Report fertigstellen. - Zweitens, Meeting vorbereiten. Drittens, E-Mails beantworten." - -// AI-Antwort -[Lange Erklärung...] -→ 🔊 [Gekürzte Version, max 30 Sekunden] -``` - -### 4. Natürliche Deutsche Sprache - -Voice-Antworten werden für Sprache optimiert: - -```typescript -// Text-Format -'10:00 - Team Meeting'; -'14:30 - Zahnarzt'; - -// Voice-Format -'Um zehn Uhr Team Meeting und um halb drei Zahnarzt'; - -// Text-Format -'!p1 @heute #arbeit'; - -// Voice-Format -'mit hoher Priorität, fällig heute, im Projekt Arbeit'; -``` - -### 5. Feedback-Sounds - -Kurze Audio-Cues für Aktionen: - -| Aktion | Sound | -| ---------------- | ---------------------- | -| Aufgabe erledigt | ✅ Kurzer "Done"-Sound | -| Timer gestartet | 🔔 Start-Ton | -| Timer abgelaufen | 🔔 Alarm-Ton | -| Fehler | ❌ Sanfter Error-Ton | - -## User Preferences - -### Persistente Einstellungen pro User - -```typescript -interface VoicePreferences { - // Voice Response - voiceEnabled: boolean; // Default: true bei Voice-Input - alwaysVoice: boolean; // Default: false (nur bei Voice-Input) - - // TTS Settings - voice: string; // Default: "de-DE-ConradNeural" - speed: number; // Default: 1.0 - - // Behavior - readLongTexts: boolean; // Default: false (Zusammenfassung) - maxAudioLength: number; // Default: 30 (Sekunden) - feedbackSounds: boolean; // Default: true -} -``` - -### Speicherung - -- In-Memory für aktuelle Session -- Optional: Persistierung in User-Settings-Datei - -## Implementierungs-Plan - -### Phase 1: Grundlegende Voice-Input - -**Ziel:** Voice Notes werden transkribiert und als Text verarbeitet - -1. `VoiceModule` erstellen -2. `VoiceService` mit STT-Integration -3. `handleAudioMessage()` in MatrixService überschreiben -4. Transkribierte Nachricht durch CommandRouter leiten - -**Aufwand:** ~2-3 Stunden - -### Phase 2: Voice-Output - -**Ziel:** Antworten werden als Audio zurückgesendet - -1. TTS-Integration in VoiceService -2. Audio-Upload zu Matrix -3. `sendAudioReply()` Methode -4. Dual-Response (Text + Audio) - -**Aufwand:** ~2-3 Stunden - -### Phase 3: Smart Formatting - -**Ziel:** Antworten werden für Sprache optimiert - -1. `VoiceFormatter` Service -2. Zahlen → Wörter ("10:00" → "zehn Uhr") -3. Listen-Zusammenfassung -4. Markdown-Entfernung für TTS - -**Aufwand:** ~2 Stunden - -### Phase 4: User Preferences - -**Ziel:** User können Voice-Einstellungen anpassen - -1. Preference-Speicherung -2. `!voice`, `!stimme`, `!stimmen` Befehle -3. Automatische Aktivierung bei Voice-Input - -**Aufwand:** ~1-2 Stunden - -### Phase 5: Polish & Testing - -**Ziel:** Optimierte User Experience - -1. Latenz-Optimierung (parallel Processing) -2. Error Handling -3. Edge Cases (leere Audio, etc.) -4. Testing mit verschiedenen Stimmen - -**Aufwand:** ~2 Stunden - -## Technische Details - -### Neue Dateien - -``` -services/matrix-mana-bot/src/ -├── voice/ -│ ├── voice.module.ts -│ ├── voice.service.ts # STT + TTS Orchestration -│ ├── voice-formatter.ts # Text → Speech-optimized -│ └── voice-preferences.ts # User Settings -``` - -### Environment Variables - -```env -# Voice Bot (bestehend) -VOICE_BOT_URL=http://localhost:3050 - -# STT (bestehend) -STT_URL=http://localhost:3020 - -# Voice Settings -VOICE_ENABLED=true -DEFAULT_VOICE=de-DE-ConradNeural -DEFAULT_SPEED=1.0 -MAX_AUDIO_LENGTH=30 -``` - -### Dependencies - -Keine neuen Dependencies nötig - alles via HTTP APIs: - -- mana-stt (Port 3020) - bereits vorhanden -- mana-voice-bot (Port 3050) - gerade erstellt - -## Audio-Nachricht Format - -### Matrix Audio Message - -```typescript -// Upload Audio zu Matrix -const mxcUrl = await this.client.uploadContent(audioBuffer, 'audio/mpeg', 'response.mp3'); - -// Send Audio Message -await this.client.sendMessage(roomId, { - msgtype: 'm.audio', - body: 'Voice Response', - url: mxcUrl, - info: { - mimetype: 'audio/mpeg', - size: audioBuffer.length, - duration: durationMs, // Optional - }, - // Reply to original message - 'm.relates_to': { - 'm.in_reply_to': { - event_id: originalEventId, - }, - }, -}); -``` - -## Performance-Optimierungen - -### Parallel Processing - -```typescript -async handleVoiceMessage(roomId: string, event: MatrixRoomEvent) { - // 1. Download + Transcribe - const audioBuffer = await this.downloadMedia(event.content.url); - const transcript = await this.voiceService.transcribe(audioBuffer); - - // 2. Process Command (get text response) - const textResponse = await this.commandRouter.route({ - roomId, - userId: event.sender, - message: transcript, - event, - }); - - // 3. Send Text immediately - await this.sendReply(roomId, event, textResponse); - - // 4. Generate Audio in parallel (don't await for user) - this.generateAndSendAudio(roomId, event, textResponse) - .catch(err => this.logger.error('Audio generation failed:', err)); -} -``` - -### Caching - -- Voice-Preferences pro User cachen -- Häufige kurze Antworten cachen ("Erledigt", "Hinzugefügt", etc.) - -## Fallback-Verhalten - -| Situation | Verhalten | -| -------------------- | --------------------------------- | -| STT nicht erreichbar | Fehlermeldung, nur Text | -| TTS nicht erreichbar | Nur Text-Antwort, kein Audio | -| Leeres Audio | "Ich konnte dich nicht verstehen" | -| Zu langes Audio | Transkribieren + Warnung | -| Unbekannte Sprache | Auf Deutsch antworten | - -## Beispiel-Interaktionen - -### Morgen-Routine - -``` -User: 🎤 "Guten Morgen Mana, was steht heute an?" - -Bot: 📝 ☀️ **Guten Morgen!** - - **Deine Termine:** - • 09:00 Daily Standup - • 11:00 Code Review - • 15:00 Sprint Planning - - **Wichtige Aufgaben:** - 1. Bug-Fix für Login !p1 @heute - 2. Dokumentation aktualisieren - - **Dein Tag sieht machbar aus!** 💪 - -Bot: 🔊 "Guten Morgen! Heute hast du drei Termine: Um neun das Daily, - um elf Code Review und um drei Sprint Planning. - Außerdem zwei wichtige Aufgaben: Der Bug-Fix für den Login - hat hohe Priorität und die Dokumentation sollte aktualisiert werden. - Dein Tag sieht machbar aus!" -``` - -### Quick Task - -``` -User: 🎤 "Neue Aufgabe: Milch kaufen" - -Bot: 📝 ✅ Aufgabe hinzugefügt: - **Milch kaufen** (Inbox) - -Bot: 🔊 "Erledigt, Milch kaufen wurde hinzugefügt." -``` - -### Timer - -``` -User: 🎤 "Timer 25 Minuten für Pomodoro" - -Bot: 📝 ⏱️ Timer gestartet: **25 Minuten** (Pomodoro) - Endet um 14:55 - -Bot: 🔊 [Start-Sound] + "Timer für 25 Minuten gestartet." - ---- 25 Minuten später --- - -Bot: 📝 🔔 **Timer abgelaufen!** Pomodoro (25 min) - -Bot: 🔊 [Alarm-Sound] + "Dein Pomodoro Timer ist abgelaufen." -``` - -## Erfolgs-Metriken - -- **Latenz:** Voice-Input → Text-Response < 3s -- **Latenz:** Text-Response → Audio-Response < 2s -- **Transkription:** > 95% Genauigkeit für Deutsche Sprache -- **Audio-Qualität:** Natürlich klingende Stimme - -## Offene Fragen - -1. **Wakeword?** - - Optional: "Hey Mana" am Anfang der Voice Note? - - Oder: Jede Voice Note wird verarbeitet? - -2. **Audio-Format?** - - MP3 (klein, universell) ✓ - - WAV (schneller zu generieren) - - Opus (noch kleiner, nicht überall unterstützt) - -3. **Stimmen-Auswahl?** - - Alle 11 deutschen Stimmen anbieten? - - Oder nur 3-4 beste? - -4. **Multi-User Room?** - - Voice-Antwort nur an den fragenden User? - - Oder für alle im Room? diff --git a/.github/workflows/cd-macmini.yml b/.github/workflows/cd-macmini.yml index 7f49aa0e8..163c60266 100644 --- a/.github/workflows/cd-macmini.yml +++ b/.github/workflows/cd-macmini.yml @@ -40,7 +40,7 @@ on: - mukke-web - storage-backend - storage-web - - matrix-mana-bot + - mana-matrix-bot concurrency: group: cd-macmini @@ -80,7 +80,7 @@ jobs: mukke-web: ${{ steps.changes.outputs.mukke-web }} storage-backend: ${{ steps.changes.outputs.storage-backend }} storage-web: ${{ steps.changes.outputs.storage-web }} - matrix-mana-bot: ${{ steps.changes.outputs.matrix-mana-bot }} + mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Check for changes @@ -137,12 +137,12 @@ jobs: check_changes "mukke-web" "apps/mukke/apps/web/" "apps/mukke/packages/" check_changes "storage-backend" "apps/storage/apps/backend/" "apps/storage/packages/" check_changes "storage-web" "apps/storage/apps/web/" "apps/storage/packages/" - check_changes "matrix-mana-bot" "services/matrix-mana-bot/" "packages/matrix-bot-common/" + check_changes "mana-matrix-bot" "services/mana-matrix-bot/" check_changes "mana-landing-builder" "services/mana-landing-builder/" "packages/shared-types/" "packages/shared-landing-ui/" # Check if anything needs deploying ANY="false" - for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web matrix-mana-bot mana-landing-builder; do + for svc in matrix-web mana-core-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-backend clock-web contacts-backend contacts-web mukke-backend mukke-web storage-backend storage-web mana-matrix-bot mana-landing-builder; do val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2) if [ "$val" == "true" ]; then ANY="true" @@ -219,7 +219,7 @@ jobs: if [ "${{ needs.detect-changes.outputs.mukke-web }}" == "true" ]; then SERVICES="$SERVICES mukke-web"; fi if [ "${{ needs.detect-changes.outputs.storage-backend }}" == "true" ]; then SERVICES="$SERVICES storage-backend"; fi if [ "${{ needs.detect-changes.outputs.storage-web }}" == "true" ]; then SERVICES="$SERVICES storage-web"; fi - if [ "${{ needs.detect-changes.outputs.matrix-mana-bot }}" == "true" ]; then SERVICES="$SERVICES matrix-mana-bot"; fi + if [ "${{ needs.detect-changes.outputs.mana-matrix-bot }}" == "true" ]; then SERVICES="$SERVICES mana-matrix-bot"; fi if [ "${{ needs.detect-changes.outputs.mana-landing-builder }}" == "true" ]; then SERVICES="$SERVICES mana-landing-builder"; fi fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51f8e1e38..3342ce302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,16 +72,7 @@ jobs: nutriphi-web: ${{ steps.changes.outputs.nutriphi-web }} skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }} skilltree-web: ${{ steps.changes.outputs.skilltree-web }} - matrix-mana-bot: ${{ steps.changes.outputs.matrix-mana-bot }} - matrix-ollama-bot: ${{ steps.changes.outputs.matrix-ollama-bot }} - matrix-stats-bot: ${{ steps.changes.outputs.matrix-stats-bot }} - matrix-project-doc-bot: ${{ steps.changes.outputs.matrix-project-doc-bot }} - matrix-todo-bot: ${{ steps.changes.outputs.matrix-todo-bot }} - matrix-calendar-bot: ${{ steps.changes.outputs.matrix-calendar-bot }} - matrix-nutriphi-bot: ${{ steps.changes.outputs.matrix-nutriphi-bot }} - matrix-zitare-bot: ${{ steps.changes.outputs.matrix-zitare-bot }} - matrix-clock-bot: ${{ steps.changes.outputs.matrix-clock-bot }} - matrix-tts-bot: ${{ steps.changes.outputs.matrix-tts-bot }} + mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }} zitare-backend: ${{ steps.changes.outputs.zitare-backend }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: @@ -119,16 +110,7 @@ jobs: echo "nutriphi-web=true" >> $GITHUB_OUTPUT echo "skilltree-backend=true" >> $GITHUB_OUTPUT echo "skilltree-web=true" >> $GITHUB_OUTPUT - echo "matrix-mana-bot=true" >> $GITHUB_OUTPUT - echo "matrix-ollama-bot=true" >> $GITHUB_OUTPUT - echo "matrix-stats-bot=true" >> $GITHUB_OUTPUT - echo "matrix-project-doc-bot=true" >> $GITHUB_OUTPUT - echo "matrix-todo-bot=true" >> $GITHUB_OUTPUT - echo "matrix-calendar-bot=true" >> $GITHUB_OUTPUT - echo "matrix-nutriphi-bot=true" >> $GITHUB_OUTPUT - echo "matrix-zitare-bot=true" >> $GITHUB_OUTPUT - echo "matrix-clock-bot=true" >> $GITHUB_OUTPUT - echo "matrix-tts-bot=true" >> $GITHUB_OUTPUT + echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT echo "zitare-backend=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 @@ -170,16 +152,7 @@ jobs: echo "nutriphi-web=true" >> $GITHUB_OUTPUT echo "skilltree-backend=true" >> $GITHUB_OUTPUT echo "skilltree-web=true" >> $GITHUB_OUTPUT - echo "matrix-mana-bot=true" >> $GITHUB_OUTPUT - echo "matrix-ollama-bot=true" >> $GITHUB_OUTPUT - echo "matrix-stats-bot=true" >> $GITHUB_OUTPUT - echo "matrix-project-doc-bot=true" >> $GITHUB_OUTPUT - echo "matrix-todo-bot=true" >> $GITHUB_OUTPUT - echo "matrix-calendar-bot=true" >> $GITHUB_OUTPUT - echo "matrix-nutriphi-bot=true" >> $GITHUB_OUTPUT - echo "matrix-zitare-bot=true" >> $GITHUB_OUTPUT - echo "matrix-clock-bot=true" >> $GITHUB_OUTPUT - echo "matrix-tts-bot=true" >> $GITHUB_OUTPUT + echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT echo "zitare-backend=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 @@ -195,7 +168,6 @@ jobs: SHARED_AUTH_PATTERN="packages/shared-auth/|packages/shared-types/" SHARED_UI_PATTERN="packages/shared-ui/|packages/shared-theme/|packages/shared-icons/|packages/shared-tailwind/|packages/shared-branding/" SHARED_WEB_PATTERN="packages/shared-auth-ui/|packages/shared-theme-ui/|packages/shared-feedback-ui/|packages/shared-profile-ui/|packages/shared-subscription-ui/|packages/shared-splitscreen/" - SHARED_BOT_PATTERN="packages/bot-services/|packages/matrix-bot-common/" # Function to check if any pattern matches check_pattern() { @@ -208,13 +180,11 @@ jobs: SHARED_UI_CHANGED=$(check_pattern "$SHARED_UI_PATTERN") SHARED_WEB_CHANGED=$(check_pattern "$SHARED_WEB_PATTERN") - SHARED_BOT_CHANGED=$(check_pattern "$SHARED_BOT_PATTERN") echo "Common changed: $COMMON_CHANGED" echo "Shared auth changed: $SHARED_AUTH_CHANGED" echo "Shared UI changed: $SHARED_UI_CHANGED" echo "Shared web changed: $SHARED_WEB_CHANGED" - echo "Shared bot changed: $SHARED_BOT_CHANGED" # mana-core-auth: services/mana-core-auth + packages/shared-nestjs-auth AUTH_CHANGED=$(check_pattern "services/mana-core-auth/|packages/shared-nestjs-auth/") @@ -400,84 +370,12 @@ jobs: echo "skilltree-web=false" >> $GITHUB_OUTPUT fi - # matrix-mana-bot - MATRIX_MANA_BOT_CHANGED=$(check_pattern "services/matrix-mana-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_MANA_BOT_CHANGED" == "true" ]; then - echo "matrix-mana-bot=true" >> $GITHUB_OUTPUT + # mana-matrix-bot (consolidated Go bot) + MANA_MATRIX_BOT_CHANGED=$(check_pattern "services/mana-matrix-bot/") + if [ "$MANA_MATRIX_BOT_CHANGED" == "true" ]; then + echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT else - echo "matrix-mana-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-ollama-bot - MATRIX_OLLAMA_BOT_CHANGED=$(check_pattern "services/matrix-ollama-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_OLLAMA_BOT_CHANGED" == "true" ]; then - echo "matrix-ollama-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-ollama-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-stats-bot - MATRIX_STATS_BOT_CHANGED=$(check_pattern "services/matrix-stats-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_STATS_BOT_CHANGED" == "true" ]; then - echo "matrix-stats-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-stats-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-project-doc-bot - MATRIX_PROJECT_DOC_BOT_CHANGED=$(check_pattern "services/matrix-project-doc-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_PROJECT_DOC_BOT_CHANGED" == "true" ]; then - echo "matrix-project-doc-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-project-doc-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-todo-bot - MATRIX_TODO_BOT_CHANGED=$(check_pattern "services/matrix-todo-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_TODO_BOT_CHANGED" == "true" ]; then - echo "matrix-todo-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-todo-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-calendar-bot - MATRIX_CALENDAR_BOT_CHANGED=$(check_pattern "services/matrix-calendar-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_CALENDAR_BOT_CHANGED" == "true" ]; then - echo "matrix-calendar-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-calendar-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-nutriphi-bot - MATRIX_NUTRIPHI_BOT_CHANGED=$(check_pattern "services/matrix-nutriphi-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_NUTRIPHI_BOT_CHANGED" == "true" ]; then - echo "matrix-nutriphi-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-nutriphi-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-zitare-bot - MATRIX_ZITARE_BOT_CHANGED=$(check_pattern "services/matrix-zitare-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_ZITARE_BOT_CHANGED" == "true" ]; then - echo "matrix-zitare-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-zitare-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-clock-bot - MATRIX_CLOCK_BOT_CHANGED=$(check_pattern "services/matrix-clock-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_CLOCK_BOT_CHANGED" == "true" ]; then - echo "matrix-clock-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-clock-bot=false" >> $GITHUB_OUTPUT - fi - - # matrix-tts-bot - MATRIX_TTS_BOT_CHANGED=$(check_pattern "services/matrix-tts-bot/") - if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_BOT_CHANGED" == "true" ] || [ "$MATRIX_TTS_BOT_CHANGED" == "true" ]; then - echo "matrix-tts-bot=true" >> $GITHUB_OUTPUT - else - echo "matrix-tts-bot=false" >> $GITHUB_OUTPUT + echo "mana-matrix-bot=false" >> $GITHUB_OUTPUT fi # zitare-backend @@ -524,16 +422,6 @@ jobs: echo "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $GITHUB_STEP_SUMMARY echo "| skilltree-backend | ${{ steps.changes.outputs.skilltree-backend }} |" >> $GITHUB_STEP_SUMMARY echo "| skilltree-web | ${{ steps.changes.outputs.skilltree-web }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-mana-bot | ${{ steps.changes.outputs.matrix-mana-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-ollama-bot | ${{ steps.changes.outputs.matrix-ollama-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-stats-bot | ${{ steps.changes.outputs.matrix-stats-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-project-doc-bot | ${{ steps.changes.outputs.matrix-project-doc-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-todo-bot | ${{ steps.changes.outputs.matrix-todo-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-calendar-bot | ${{ steps.changes.outputs.matrix-calendar-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-nutriphi-bot | ${{ steps.changes.outputs.matrix-nutriphi-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-zitare-bot | ${{ steps.changes.outputs.matrix-zitare-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-clock-bot | ${{ steps.changes.outputs.matrix-clock-bot }} |" >> $GITHUB_STEP_SUMMARY - echo "| matrix-tts-bot | ${{ steps.changes.outputs.matrix-tts-bot }} |" >> $GITHUB_STEP_SUMMARY echo "| zitare-backend | ${{ steps.changes.outputs.zitare-backend }} |" >> $GITHUB_STEP_SUMMARY # =========================================== @@ -1272,11 +1160,11 @@ jobs: # Matrix Bots # =========================================== - build-matrix-mana-bot: - name: Build matrix-mana-bot + build-mana-matrix-bot: + name: Build mana-matrix-bot (Go) runs-on: ubuntu-latest needs: detect-changes - if: needs.detect-changes.outputs.matrix-mana-bot == 'true' + if: needs.detect-changes.outputs.mana-matrix-bot == 'true' steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 @@ -1289,284 +1177,13 @@ jobs: - uses: docker/metadata-action@v5 id: meta with: - images: ghcr.io/${{ github.repository_owner }}/matrix-mana-bot + images: ghcr.io/${{ github.repository_owner }}/mana-matrix-bot tags: type=raw,value=latest - uses: docker/build-push-action@v5 with: context: . - file: services/matrix-mana-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native dependencies - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-ollama-bot: - name: Build matrix-ollama-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-ollama-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-ollama-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-ollama-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-stats-bot: - name: Build matrix-stats-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-stats-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-stats-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-stats-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-project-doc-bot: - name: Build matrix-project-doc-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-project-doc-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-project-doc-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-project-doc-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-todo-bot: - name: Build matrix-todo-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-todo-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-todo-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-todo-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-calendar-bot: - name: Build matrix-calendar-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-calendar-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-calendar-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-calendar-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-nutriphi-bot: - name: Build matrix-nutriphi-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-nutriphi-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-nutriphi-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-nutriphi-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-zitare-bot: - name: Build matrix-zitare-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-zitare-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-zitare-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-zitare-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-clock-bot: - name: Build matrix-clock-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-clock-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-clock-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-clock-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-matrix-tts-bot: - name: Build matrix-tts-bot - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.matrix-tts-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/matrix-tts-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/matrix-tts-bot/Dockerfile - # Note: arm64 disabled due to QEMU emulation issues with native deps - platforms: linux/amd64 + file: services/mana-matrix-bot/Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha diff --git a/apps/manacore/apps/landing/src/content/devlog/2026-03-27-matrix-bot-go-consolidation.md b/apps/manacore/apps/landing/src/content/devlog/2026-03-27-matrix-bot-go-consolidation.md new file mode 100644 index 000000000..6748b73a2 --- /dev/null +++ b/apps/manacore/apps/landing/src/content/devlog/2026-03-27-matrix-bot-go-consolidation.md @@ -0,0 +1,353 @@ +--- +title: 'Matrix Bot Konsolidierung: 21 NestJS-Bots → 1 Go Binary' +description: 'Komplette Neuentwicklung des Matrix-Bot-Systems in Go mit Plugin-Architektur. 21 separate NestJS-Prozesse (~2.1 GB RAM, ~4.2 GB Docker Images) ersetzt durch ein einziges Go-Binary (8.6 MB, ~30 MB RAM). Inklusive Redis Sessions, CI/CD, Docker-Migration und Legacy-Cleanup.' +date: 2026-03-27 +author: 'Till Schneider' +category: 'architecture' +tags: + [ + 'go', + 'matrix', + 'bots', + 'performance', + 'infrastructure', + 'docker', + 'consolidation', + 'plugin-architecture', + 'ci-cd', + 'cleanup', + ] +featured: true +readTime: 12 +stats: + filesChanged: 458 + linesAdded: 8665 + linesRemoved: 47222 +contributors: + - name: 'Till Schneider' + handle: 'Till-JS' +workingHours: + start: '2026-03-27T09:00' + end: '2026-03-27T17:30' +--- + +Einer der größten Architektur-Umbauten im ManaCore-Monorepo: **21 separate NestJS Matrix-Bot-Prozesse** komplett ersetzt durch **ein einziges Go-Binary mit Plugin-Architektur**. Dazu Legacy-Code aufgeräumt, CI/CD migriert und Docker-Compose aktualisiert. + +--- + +## Das Problem: 21 NestJS-Prozesse + +Jeder Matrix-Bot lief als eigenständiger NestJS-Service mit eigenem Docker-Container: + +| Bot | Funktion | Port | +| ---------------------- | ----------------------------------------------------------------------- | ---- | +| matrix-mana-bot | Gateway: AI Chat + Todo + Calendar + Clock + Voice | 4010 | +| matrix-ollama-bot | LLM Chat (Ollama) | 4011 | +| matrix-stats-bot | System-Statistiken | 4012 | +| matrix-project-doc-bot | Projekt-Dokumentation | 4013 | +| matrix-todo-bot | Aufgabenverwaltung | 4014 | +| matrix-calendar-bot | Kalender | 4015 | +| matrix-nutriphi-bot | Ernährungstracking | 4016 | +| matrix-zitare-bot | Zitate & Inspiration | 4017 | +| matrix-clock-bot | Timer, Alarme, Weltuhren | 4018 | +| matrix-tts-bot | Text-to-Speech | 4019 | +| matrix-stt-bot | Speech-to-Text | 4021 | +| matrix-onboarding-bot | User Onboarding | 4020 | +| matrix-planta-bot | Pflanzenpflege | 4022 | +| + 8 weitere | Chat, Contacts, ManaDeck, Picture, Presi, Questions, Skilltree, Storage | — | + +### Ressourcenverbrauch vorher + +| Metrik | Wert | +| -------------------- | -------------------------------- | +| **Prozesse** | 21 (+ 13 in docker-compose) | +| **RAM gesamt** | ~2.1 GB (je ~80-120 MB pro Bot) | +| **Docker Images** | ~21 × 200 MB = **~4.2 GB** Disk | +| **Docker Container** | 13 aktive Container | +| **Ports belegt** | 4010-4022 (13 Ports) | +| **Startup-Zeit** | ~30s pro Bot, ~5 Min gesamt | +| **Source Code** | ~21 Services + 2 Shared Packages | +| **node_modules** | ~21 × node_modules Kopien | +| **CI Build Jobs** | 10 separate Docker-Build Jobs | + +--- + +## Die Lösung: Go Binary mit Plugin-Architektur + +### Architektur-Entscheidungen + +| Entscheidung | Gewählt | Alternativen verworfen | +| ----------------- | ----------------------------- | ---------------------------------------- | +| **Sprache** | Go 1.25 | NestJS Monolith, Rust | +| **Matrix SDK** | mautrix-go | Raw CS API | +| **Plugin-System** | Compile-time Interfaces | Go `plugin` Package, hashicorp/go-plugin | +| **Identities** | Ein mautrix.Client pro Plugin | Shared Client mit Routing | +| **Sessions** | In-Memory + Redis | Nur In-Memory | +| **Config** | Environment Variables | YAML Config | + +### Warum Go? + +- **RAM:** Go-Prozesse brauchen ~10-30 MB statt ~100 MB pro NestJS-Instanz +- **Binary:** Ein statisch kompiliertes Binary, kein node_modules +- **Concurrency:** 21 Matrix `/sync`-Loops als Goroutines (quasi kostenlos) +- **Startup:** <1 Sekunde für alle 21 Plugins +- **Docker:** Alpine-basiert, 7.5 MB statt ~200 MB pro Image +- **Konsistenz:** Passt zum bestehenden Go Sync-Server (mana-sync) + +--- + +## Projektstruktur + +``` +services/mana-matrix-bot/ # 50 Dateien, 7.620 Zeilen Go +├── cmd/server/main.go # Entry Point (21 Plugin-Imports) +├── internal/ +│ ├── config/config.go # Env-Config mit Legacy-Token-Support +│ ├── runtime/ +│ │ ├── runtime.go # Plugin-Orchestrator, Sync-Loops, Event-Routing +│ │ └── health.go # Health + Prometheus Metrics +│ ├── matrix/ +│ │ ├── client.go # mautrix-go Wrapper +│ │ ├── markdown.go # Markdown→HTML (Port von TypeScript) +│ │ └── types.go # IsBot, IsEdit, IsText Guards +│ ├── plugin/ +│ │ ├── plugin.go # Plugin Interface + SessionManager +│ │ ├── registry.go # Compile-time Registration +│ │ ├── command.go # !command Router +│ │ └── keyword.go # Keyword-Detector (DE/EN) +│ ├── session/ +│ │ ├── session.go # In-Memory Store +│ │ └── redis.go # Redis Store (Cross-Bot SSO) +│ ├── services/ +│ │ ├── backend.go # Generic HTTP Client +│ │ ├── voice.go # STT/TTS Client +│ │ ├── auth.go # Login via mana-core-auth +│ │ └── credit.go # Credit Balance & Consumption +│ └── plugins/ # 21 Plugin-Verzeichnisse +│ ├── gateway/ # Composite: AI + Todo + Cal + Clock + Voice +│ ├── todo/ # Vollständig: !todo, !list, !done, !delete, etc. +│ ├── calendar/ # !heute, !morgen, !woche, !termine +│ ├── clock/ # !timer, !stop, !alarm, !zeit +│ ├── contacts/ # !kontakte, !suche, !favoriten, !edit +│ ├── zitare/ # !zitat, !suche, !kategorie, !favoriten +│ ├── planta/ # !pflanzen, !giessen, !fällig, !historie +│ ├── ollama/ # AI Chat, !models, !all, !mode +│ ├── stt/ # Audio→Text, !language, !model +│ ├── tts/ # Text→Audio, !voice, !speed +│ └── ... (11 weitere) +├── Dockerfile # Multi-stage Alpine Build +├── go.mod / go.sum +└── CLAUDE.md +``` + +--- + +## Vorher → Nachher: Die Zahlen + +### Ressourcen + +| Metrik | Vorher (NestJS) | Nachher (Go) | Ersparnis | +| -------------------- | ------------------ | ------------ | ----------- | +| **Prozesse** | 21 | 1 | **-95%** | +| **RAM** | ~2.1 GB | ~30 MB | **-98.6%** | +| **Docker Images** | ~4.2 GB (21×200MB) | 7.5 MB | **-99.8%** | +| **Docker Container** | 13 | 1 | **-92%** | +| **Ports** | 13 (4010-4022) | 1 (4000) | **-92%** | +| **Startup** | ~5 Min | <1 Sek | **-99%** | +| **Binary** | — | 8.6 MB | Single file | + +### Codebase + +| Metrik | Vorher (NestJS) | Nachher (Go) | Delta | +| ------------------------- | ----------------- | ------------------- | -------- | +| **Service-Verzeichnisse** | 21 + 2 Packages | 1 | **-22** | +| **Source Files** | ~400+ (TS) | 39 (Go) | **-90%** | +| **Test Files** | verteilt | 5 | — | +| **Source Lines** | ~15.000+ | 7.293 | **-51%** | +| **Test Lines** | — | 327 | — | +| **Dependencies** | 21 × package.json | 1 × go.mod (4 deps) | **-99%** | +| **CI Build Jobs** | 10 | 1 | **-90%** | + +### Docker Compose + +| Metrik | Vorher | Nachher | +| -------------------- | ------------------------- | -------------------------- | +| **Bot-Services** | 13 Definitionen | 1 Definition | +| **YAML Zeilen** | ~475 | ~90 | +| **Environment Vars** | verteilt über 13 Services | zentralisiert in 1 Service | +| **Volume Mounts** | 13 × matrix_bots_data | 1 × matrix_bots_data | + +--- + +## Plugin Interface + +Jedes Plugin implementiert ein minimales Interface: + +```go +type Plugin interface { + Name() string + Init(ctx context.Context, cfg PluginConfig) error + HandleTextMessage(ctx context.Context, mc *MessageContext) error + Commands() []CommandDef +} + +// Optional: Audio und Image Handler +type AudioHandler interface { + HandleAudioMessage(ctx context.Context, mc *MessageContext, audioData []byte) error +} +``` + +Neues Plugin hinzufügen = 3 Schritte: + +1. `internal/plugins/mybot/mybot.go` erstellen +2. `plugin.Register("mybot", ...)` in `init()` +3. Import in `main.go` + +--- + +## Feature-Vollständigkeit der Plugins + +### Voll portiert (mit allen Commands) + +| Plugin | Commands | Features | +| ------------ | ------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| **todo** | !todo, !list, !today, !inbox, !done, !delete, !projekte | German date parsing, Priority, Projects | +| **calendar** | !heute, !morgen, !woche, !termine, !termin, !löschen, !kalender | Event creation, Date parsing | +| **clock** | !timer, !stop, !resume, !reset, !timers, !alarm, !alarme, !zeit | Duration parsing (25m, 1h30m) | +| **contacts** | !kontakte, !suche, !favoriten, !kontakt, !neu, !edit, !fav, !delete | 16 editierbare Felder, Number-References | +| **zitare** | !zitat, !heute, !suche, !kategorie, !kategorien, !motivation, !favorit, !favoriten, !listen | Categories, Favorites, Lists | +| **planta** | !pflanzen, !pflanze, !neu, !giessen, !fällig, !historie, !intervall, !edit, !delete | Watering schedule, Health tracking | +| **ollama** | AI Chat, !models, !model, !clear, !all, !mode | Chat history, System prompts, Model comparison | +| **stt** | Audio→Text, !language, !model, !status | Whisper/Voxtral, Language selection | +| **tts** | Text→Audio, !voice, !voices, !speed, !status | Voice selection, Speed control | +| **gateway** | Alles oben + !morning, Voice pipeline | Composite: AI + Todo + Calendar + Clock + Voice | + +### Skeleton (Grundgerüst mit !help, !status) + +stats, chat, manadeck, nutriphi, picture, presi, questions, skilltree, storage, projectdoc, onboarding + +--- + +## Runtime-Architektur + +``` +┌─────────────────────────────────────────────────┐ +│ main.go │ +│ 21 Plugin-Imports → init() Registration │ +├─────────────────────────────────────────────────┤ +│ Runtime │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Plugin 1│ │ Plugin 2│ │Plugin 21│ ... │ +│ │ @mana- │ │ @todo- │ │ @tts- │ │ +│ │ bot │ │ bot │ │ bot │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ +│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ +│ │mautrix │ │mautrix │ │mautrix │ │ +│ │Client 1 │ │Client 2 │ │Client 21│ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ │ +├───────┼────────────┼────────────┼────────────────┤ +│ └────────────┼────────────┘ │ +│ Matrix /sync │ +│ (21 Goroutines, ~0 RAM) │ +├──────────────────────────────────────────────────┤ +│ Health :4000 │ Sessions (Redis/Memory) │ +│ Metrics │ Auth Client → mana-core-auth │ +│ Login/Logout │ Credit Client │ +└──────────────────────────────────────────────────┘ +``` + +**Event-Flow:** + +1. Matrix `/sync` empfängt Event +2. Runtime filtert: eigene Messages, Bot-Messages, Edits +3. Room-Allowlist-Check +4. `!login`/`!logout` global abgefangen +5. Text → Plugin.HandleTextMessage() +6. Audio → Plugin.HandleAudioMessage() (wenn implementiert) + +--- + +## Globale Features + +### Login/Logout (Runtime-Level) + +``` +User: !login user@example.com meinpasswort +Bot: ✅ Angemeldet als user@example.com + +User: !logout +Bot: ✅ Abgemeldet. +``` + +Wird im Runtime abgefangen, bevor es an Plugins geht. Token in Redis gespeichert → alle Plugins haben sofort Zugriff. + +### Redis Sessions + +Sessions persistent über Container-Restarts. Alle 21 Plugins teilen sich den Session-Store: + +``` +Redis Key: mana-bot:session:token:@user:mana.how +Value: {token: "eyJ...", expires_at: "2026-03-28T..."} +``` + +--- + +## Legacy Cleanup + +### Gelöscht + +| Was | Menge | +| ----------------------------- | ----------------------------------------------- | +| `services/matrix-*-bot/` | 21 Verzeichnisse | +| `packages/matrix-bot-common/` | 1 Package (~30 Dateien) | +| `packages/bot-services/` | 1 Package (~50 Dateien) | +| Docker-Compose Bot-Services | 13 Service-Definitionen (~475 Zeilen) | +| CI Build Jobs | 10 Jobs (~300 Zeilen) | +| CI Change Detection | 10 Blöcke + Outputs (~100 Zeilen) | +| Root package.json Scripts | 10 Legacy-Bot-Scripts | +| Deploy Scripts | setup-mana-bot.sh, deploy-mana-bot.sh | +| **Netto** | **-47.222 Zeilen gelöscht, +8.665 hinzugefügt** | + +### Was bleibt + +- `services/mana-matrix-bot/` — Der Go-Service +- Ein `mana-matrix-bot` Service in docker-compose +- Ein `build-mana-matrix-bot` CI Job +- Historische Devlog-Referenzen (als Dokumentation) + +--- + +## CI/CD + +### GitHub Actions + +- **CI:** `build-mana-matrix-bot` Job mit Docker Multi-Platform Build (amd64 + arm64) +- **CD:** Auto-Deploy bei Changes in `services/mana-matrix-bot/` +- **Change Detection:** Nur Go-Service, keine Shared-Package-Dependencies mehr + +### Deployment + +```bash +ssh mana-server +cd ~/projects/manacore-monorepo && git pull +./scripts/mac-mini/build-app.sh mana-matrix-bot +curl http://localhost:4000/health +# → {"status":"ok","plugins":["todo","calendar",...],"count":21} +``` + +--- + +## Fazit + +| Aspekt | Bewertung | +| ------------------- | --------------------------------------------- | +| **RAM-Ersparnis** | ~2 GB frei auf dem Mac Mini | +| **Disk-Ersparnis** | ~4.2 GB Docker Images weniger | +| **Wartbarkeit** | 1 Service statt 21 | +| **Deployment** | 1 Container statt 13 | +| **Startup** | <1s statt ~5 Min | +| **Erweiterbarkeit** | Neues Plugin = 1 Go-Datei + Import | +| **Codebase** | -47.222 Zeilen, netto -38.557 Zeilen sauberer | + +Die 2 GB RAM-Ersparnis auf dem Mac Mini sind besonders wertvoll — das ist Platz für 2-3 weitere Web-Apps oder ein größeres Ollama-Modell. diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 764f138e7..0b451f103 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -213,7 +213,7 @@ services: SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: Mana - CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how + CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how DUCKDB_PATH: /data/analytics/metrics.duckdb SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-} # Backend URLs for user data aggregation (GDPR self-service) @@ -247,41 +247,39 @@ services: # Tier 2: Gateway & Search Services (Ports 3010-3029) # ============================================ - # NOTE: api-gateway disabled - no GHCR image available, no Dockerfile exists - # To re-enable: create Dockerfile in services/api-gateway/ and build locally api-gateway: - profiles: ["disabled"] - image: ghcr.io/memo-2023/api-gateway:latest - container_name: mana-core-gateway + build: + context: . + dockerfile: services/mana-api-gateway-go/Dockerfile + image: mana-api-gateway-go:local + container_name: mana-api-gateway restart: always depends_on: - mana-auth: + postgres: + condition: service_healthy + redis: condition: service_healthy - # Removed: postgres, redis, mana-search - lazy connect with retry environment: - NODE_ENV: production + TZ: Europe/Berlin PORT: 3010 - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/manacore REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} MANA_CORE_AUTH_URL: http://mana-auth:3001 SEARCH_SERVICE_URL: http://mana-search:3020 - STT_SERVICE_URL: http://host.docker.internal:3021 + STT_SERVICE_URL: http://host.docker.internal:3026 TTS_SERVICE_URL: http://host.docker.internal:3022 - IMAGE_GEN_SERVICE_URL: http://host.docker.internal:3025 CORS_ORIGINS: https://api.mana.how,https://mana.how - # Retry config for lazy dependencies - DB_RETRY_ATTEMPTS: 5 - DB_RETRY_DELAY: 3000 + ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ports: - "3010:3010" healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3010/health"] - interval: 120s - timeout: 10s + interval: 60s + timeout: 5s retries: 3 - start_period: 40s + start_period: 5s searxng: image: searxng/searxng:latest @@ -952,467 +950,95 @@ services: retries: 3 start_period: 45s - # Matrix Bots (Ports 4010-4029) - matrix-mana-bot: + # ============================================ + # Matrix Bots — Consolidated Go Service + # Replaces 21 separate NestJS bot containers + # ============================================ + + mana-matrix-bot: build: context: . - dockerfile: services/matrix-mana-bot/Dockerfile - image: matrix-mana-bot:local - platform: linux/arm64 - container_name: mana-matrix-bot-mana + dockerfile: services/mana-matrix-bot/Dockerfile + image: mana-matrix-bot:local + container_name: mana-matrix-bot restart: always depends_on: synapse: condition: service_healthy - environment: - NODE_ENV: production - PORT: 4010 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_MANA_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-} - MATRIX_STORAGE_PATH: /app/data/mana-bot-storage.json - OLLAMA_URL: http://host.docker.internal:11434 - OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b} - OLLAMA_TIMEOUT: 120000 - CLOCK_API_URL: http://mana-matrix-bot-clock:4018/api/v1 - TODO_STORAGE_PATH: /app/data/todos.json - CALENDAR_STORAGE_PATH: /app/data/calendar.json - STT_URL: http://host.docker.internal:3026 - STT_API_KEY: ${STT_INTERNAL_API_KEY:-} - TTS_URL: http://host.docker.internal:3022 - volumes: - - matrix_bots_data:/app/data - ports: - - "4010:4010" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4010/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 15s - - matrix-ollama-bot: - image: ghcr.io/memo-2023/matrix-ollama-bot:latest - platform: linux/amd64 - container_name: mana-matrix-bot-ollama - restart: always - depends_on: - synapse: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4011 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_OLLAMA_BOT_ROOMS:-} - OLLAMA_URL: http://host.docker.internal:11434 - OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b} - OLLAMA_TIMEOUT: 120000 - volumes: - - matrix_bots_data:/app/data - ports: - - "4011:4011" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4011/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 15s - - matrix-stats-bot: - image: matrix-stats-bot:local - #platform: linux/amd64 - container_name: mana-matrix-bot-stats - restart: always - depends_on: - synapse: - condition: service_healthy - victoriametrics: - condition: service_healthy redis: condition: service_healthy environment: - NODE_ENV: production - PORT: 4012 TZ: Europe/Berlin - # Redis for session storage (Matrix-SSO-Link) - REDIS_HOST: redis - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - # Mana Core Auth for Matrix-SSO-Link auto-login + PORT: 4000 + # Matrix + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_STORAGE_PATH: /app/data + # Auth & Redis MANA_CORE_AUTH_URL: http://mana-auth:3001 MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_STATS_BOT_TOKEN} - MATRIX_REPORT_ROOM_ID: ${MATRIX_STATS_REPORT_ROOM:-} - UMAMI_API_URL: http://umami:3000 - UMAMI_USERNAME: ${UMAMI_USERNAME:-admin} - UMAMI_PASSWORD: ${UMAMI_PASSWORD} - PROMETHEUS_URL: http://victoriametrics:9090 - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_auth - volumes: - - matrix_bots_data:/app/data - ports: - - "4012:4012" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4012/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 15s - - matrix-project-doc-bot: - image: ghcr.io/memo-2023/matrix-project-doc-bot:latest - platform: linux/amd64 - container_name: mana-matrix-bot-projectdoc - restart: always - depends_on: - synapse: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4013 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_PROJECT_DOC_BOT_TOKEN} - MATRIX_ALLOWED_USERS: ${MATRIX_PROJECT_DOC_ALLOWED_USERS:-} - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/project_doc_bot - S3_ENDPOINT: http://minio:9000 - S3_REGION: us-east-1 - S3_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} - S3_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} - S3_BUCKET: project-doc-bot - MANA_LLM_URL: http://mana-llm:3025 - volumes: - - matrix_bots_data:/app/data - ports: - - "4013:4013" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4013/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 25s - - matrix-todo-bot: - image: matrix-todo-bot:local - build: - context: . - dockerfile: services/matrix-todo-bot/Dockerfile - platform: linux/amd64 - container_name: mana-matrix-bot-todo - restart: always - depends_on: - synapse: - condition: service_healthy - todo-backend: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4014 - TZ: Europe/Berlin REDIS_HOST: redis + REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - MANA_CORE_AUTH_URL: http://mana-auth:3001 - MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} + # Voice services + STT_URL: http://host.docker.internal:3026 + TTS_URL: http://host.docker.internal:3022 + # AI + OLLAMA_URL: http://host.docker.internal:11434 + OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b} + # Plugin tokens (all 21 bot identities) + MATRIX_MANA_BOT_TOKEN: ${MATRIX_MANA_BOT_TOKEN} + MATRIX_MANA_BOT_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-} + MATRIX_TODO_BOT_TOKEN: ${MATRIX_TODO_BOT_TOKEN} + MATRIX_TODO_BOT_ROOMS: ${MATRIX_TODO_BOT_ROOMS:-} + MATRIX_CALENDAR_BOT_TOKEN: ${MATRIX_CALENDAR_BOT_TOKEN} + MATRIX_CALENDAR_BOT_ROOMS: ${MATRIX_CALENDAR_BOT_ROOMS:-} + MATRIX_CLOCK_BOT_TOKEN: ${MATRIX_CLOCK_BOT_TOKEN} + MATRIX_CLOCK_BOT_ROOMS: ${MATRIX_CLOCK_BOT_ROOMS:-} + MATRIX_OLLAMA_BOT_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} + MATRIX_OLLAMA_BOT_ROOMS: ${MATRIX_OLLAMA_BOT_ROOMS:-} + MATRIX_STATS_BOT_TOKEN: ${MATRIX_STATS_BOT_TOKEN} + MATRIX_STATS_BOT_ROOMS: ${MATRIX_STATS_BOT_ROOMS:-} + MATRIX_CONTACTS_BOT_TOKEN: ${MATRIX_CONTACTS_BOT_TOKEN:-} + MATRIX_CONTACTS_BOT_ROOMS: ${MATRIX_CONTACTS_BOT_ROOMS:-} + MATRIX_CHAT_BOT_TOKEN: ${MATRIX_CHAT_BOT_TOKEN:-} + MATRIX_MANADECK_BOT_TOKEN: ${MATRIX_MANADECK_BOT_TOKEN:-} + MATRIX_NUTRIPHI_BOT_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN} + MATRIX_NUTRIPHI_BOT_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-} + MATRIX_PICTURE_BOT_TOKEN: ${MATRIX_PICTURE_BOT_TOKEN:-} + MATRIX_PLANTA_BOT_TOKEN: ${MATRIX_PLANTA_BOT_TOKEN} + MATRIX_PLANTA_BOT_ROOMS: ${MATRIX_PLANTA_BOT_ROOMS:-} + MATRIX_PRESI_BOT_TOKEN: ${MATRIX_PRESI_BOT_TOKEN:-} + MATRIX_QUESTIONS_BOT_TOKEN: ${MATRIX_QUESTIONS_BOT_TOKEN:-} + MATRIX_SKILLTREE_BOT_TOKEN: ${MATRIX_SKILLTREE_BOT_TOKEN:-} + MATRIX_STORAGE_BOT_TOKEN: ${MATRIX_STORAGE_BOT_TOKEN:-} + MATRIX_PROJECT_DOC_BOT_TOKEN: ${MATRIX_PROJECT_DOC_BOT_TOKEN} + MATRIX_STT_BOT_TOKEN: ${MATRIX_STT_BOT_TOKEN} + MATRIX_STT_BOT_ROOMS: ${MATRIX_STT_BOT_ROOMS:-} + MATRIX_TTS_BOT_TOKEN: ${MATRIX_TTS_BOT_TOKEN} + MATRIX_TTS_BOT_ROOMS: ${MATRIX_TTS_BOT_ROOMS:-} + MATRIX_ZITARE_BOT_TOKEN: ${MATRIX_ZITARE_BOT_TOKEN} + MATRIX_ZITARE_BOT_ROOMS: ${MATRIX_ZITARE_BOT_ROOMS:-} + MATRIX_ONBOARDING_BOT_TOKEN: ${MATRIX_ONBOARDING_BOT_TOKEN} + MATRIX_ONBOARDING_BOT_ROOMS: ${MATRIX_ONBOARDING_BOT_ROOMS:-} + # Backend URLs TODO_BACKEND_URL: http://todo-backend:3031 - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_TODO_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_TODO_BOT_ROOMS:-} - volumes: - - matrix_bots_data:/app/data - ports: - - "4014:4014" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4014/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 25s - - matrix-calendar-bot: - image: matrix-calendar-bot:local - pull_policy: never - platform: linux/amd64 - container_name: mana-matrix-bot-calendar - restart: always - depends_on: - synapse: - condition: service_healthy - calendar-backend: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4015 - TZ: Europe/Berlin - REDIS_HOST: redis - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - MANA_CORE_AUTH_URL: http://mana-auth:3001 - MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} CALENDAR_BACKEND_URL: http://calendar-backend:3032 - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_CALENDAR_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_CALENDAR_BOT_ROOMS:-} - volumes: - - matrix_bots_data:/app/data - ports: - - "4015:4015" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4015/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 25s - - matrix-nutriphi-bot: - image: ghcr.io/memo-2023/matrix-nutriphi-bot:latest - platform: linux/amd64 - container_name: mana-matrix-bot-nutriphi - restart: always - depends_on: - synapse: - condition: service_healthy - mana-media: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4016 - TZ: Europe/Berlin - REDIS_HOST: redis - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - MANA_CORE_AUTH_URL: http://mana-auth:3001 - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-} - NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037 - MANA_MEDIA_URL: http://mana-media:3015 - volumes: - - matrix_bots_data:/app/data - ports: - - "4016:4016" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4016/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 35s - - matrix-zitare-bot: - image: ghcr.io/memo-2023/matrix-zitare-bot:latest - platform: linux/amd64 - container_name: mana-matrix-bot-zitare - restart: always - depends_on: - synapse: - condition: service_healthy - zitare-backend: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4017 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_ZITARE_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_ZITARE_BOT_ROOMS:-} + CLOCK_BACKEND_URL: http://clock-backend:3033 + CONTACTS_BACKEND_URL: http://contacts-backend:3034 ZITARE_BACKEND_URL: http://zitare-backend:3007 - MANA_CORE_AUTH_URL: http://mana-auth:3001 - volumes: - - matrix_bots_data:/app/data - ports: - - "4017:4017" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4017/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 35s - - matrix-clock-bot: - build: - context: . - dockerfile: services/matrix-clock-bot/Dockerfile - image: matrix-clock-bot:local - container_name: mana-matrix-bot-clock - restart: always - depends_on: - synapse: - condition: service_healthy - mana-auth: - condition: service_healthy - redis: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4018 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_CLOCK_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_CLOCK_BOT_ROOMS:-} - CLOCK_API_URL: http://clock-backend:3033/api/v1 - STT_URL: http://host.docker.internal:3026 - STT_API_KEY: ${STT_INTERNAL_API_KEY:-} - MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - WIDGET_PUBLIC_URL: ${CLOCK_BOT_WIDGET_URL:-https://clock-bot.mana.how/widget} - volumes: - - matrix_bots_data:/app/data - ports: - - "4018:4018" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4018/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 35s - - matrix-tts-bot: - build: - context: . - dockerfile: services/matrix-tts-bot/Dockerfile - image: matrix-tts-bot:local - platform: linux/amd64 - container_name: mana-matrix-bot-tts - restart: always - depends_on: - synapse: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4019 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_TTS_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_TTS_BOT_ROOMS:-} - TTS_URL: http://host.docker.internal:3022 - TTS_API_KEY: ${TTS_INTERNAL_API_KEY:-} - DEFAULT_VOICE: de_kerstin - DEFAULT_SPEED: 1.0 - MAX_TEXT_LENGTH: 500 - volumes: - - matrix_bots_data:/app/data - ports: - - "4019:4019" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4019/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 35s - - matrix-stt-bot: - build: - context: . - dockerfile: services/matrix-stt-bot/Dockerfile - image: matrix-stt-bot:local - platform: linux/amd64 - container_name: mana-matrix-bot-stt - restart: always - depends_on: - synapse: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4021 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_STT_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_STT_BOT_ROOMS:-} - STT_URL: http://host.docker.internal:3026 - STT_API_KEY: ${STT_INTERNAL_API_KEY:-} - DEFAULT_LANGUAGE: de - DEFAULT_MODEL: whisper - volumes: - - matrix_bots_data:/app/data - ports: - - "4021:4021" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4021/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 35s - - matrix-onboarding-bot: - build: - context: . - dockerfile: services/matrix-onboarding-bot/Dockerfile - image: matrix-onboarding-bot:local - container_name: mana-matrix-bot-onboarding - restart: always - depends_on: - synapse: - condition: service_healthy - mana-auth: - condition: service_healthy - redis: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4020 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_ONBOARDING_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_ONBOARDING_BOT_ROOMS:-} - MANA_CORE_AUTH_URL: http://mana-auth:3001 - MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - volumes: - - matrix_bots_data:/app/data - ports: - - "4020:4020" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4020/health"] - interval: 300s - timeout: 10s - retries: 3 - start_period: 35s - - matrix-planta-bot: - build: - context: . - dockerfile: services/matrix-planta-bot/Dockerfile - image: matrix-planta-bot:local - container_name: mana-matrix-bot-planta - restart: always - depends_on: - synapse: - condition: service_healthy - mana-auth: - condition: service_healthy - redis: - condition: service_healthy - planta-backend: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4022 - TZ: Europe/Berlin - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_PLANTA_BOT_TOKEN} - MATRIX_ALLOWED_ROOMS: ${MATRIX_PLANTA_BOT_ROOMS:-} PLANTA_BACKEND_URL: http://planta-backend:3022 - PLANTA_API_PREFIX: /api - MANA_CORE_AUTH_URL: http://mana-auth:3001 - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} + NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037 + STORAGE_BACKEND_URL: http://storage-backend:3035 volumes: - matrix_bots_data:/app/data ports: - - "4022:4022" + - "4000:4000" healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4022/health"] - interval: 300s - timeout: 10s + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4000/health"] + interval: 60s + timeout: 5s retries: 3 - start_period: 35s + start_period: 10s # ============================================ # Tier 5: Web Frontends (Ports 5000-5099) @@ -1895,6 +1521,30 @@ services: retries: 3 start_period: 35s + inventar-web: + build: + context: . + dockerfile: apps/inventar/apps/web/Dockerfile + image: inventar-web:local + container_name: mana-app-inventar-web + restart: always + depends_on: + mana-auth: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 5190 + PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how + ports: + - "5190:5190" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5190/health"] + interval: 180s + timeout: 10s + retries: 3 + start_period: 20s + mana-llm: build: context: ./services/mana-llm diff --git a/package.json b/package.json index 74398bda1..5abbe95de 100644 --- a/package.json +++ b/package.json @@ -286,16 +286,9 @@ "dev:skilltree:full": "./scripts/setup-databases.sh skilltree && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:skilltree:backend\" \"pnpm dev:skilltree:web\"", "skilltree:db:push": "pnpm --filter @skilltree/backend db:push", "skilltree:db:studio": "pnpm --filter @skilltree/backend db:studio", - "dev:matrix:mana": "pnpm --filter matrix-mana-bot start:dev", - "dev:matrix:ollama": "pnpm --filter matrix-ollama-bot start:dev", - "dev:matrix:todo": "pnpm --filter matrix-todo-bot start:dev", - "dev:matrix:calendar": "pnpm --filter matrix-calendar-bot start:dev", - "dev:matrix:clock": "pnpm --filter matrix-clock-bot start:dev", - "dev:matrix:stats": "pnpm --filter matrix-stats-bot start:dev", - "dev:matrix:zitare": "pnpm --filter matrix-zitare-bot start:dev", - "dev:matrix:nutriphi": "pnpm --filter matrix-nutriphi-bot start:dev", - "build:matrix:mana": "pnpm --filter matrix-mana-bot build", - "build:matrix:all": "pnpm --filter 'matrix-*-bot' build", + "dev:matrix": "cd services/mana-matrix-bot && go run ./cmd/server", + "build:matrix": "cd services/mana-matrix-bot && go build -ldflags=\"-s -w\" -o dist/mana-matrix-bot ./cmd/server", + "test:matrix": "cd services/mana-matrix-bot && go test ./...", "dev:llm-playground": "pnpm --filter @mana-llm/playground dev", "build:llm-playground": "pnpm --filter @mana-llm/playground build", "prepare": "husky" diff --git a/packages/bot-services/CLAUDE.md b/packages/bot-services/CLAUDE.md deleted file mode 100644 index 1cd6266ae..000000000 --- a/packages/bot-services/CLAUDE.md +++ /dev/null @@ -1,334 +0,0 @@ -# @manacore/bot-services - -Shared business logic services for Matrix bots and the Gateway. - -## Purpose - -This package provides **transport-agnostic** services that contain all business logic for the Matrix bot ecosystem. Services in this package: - -- Have no Matrix-specific code -- Can be used by individual bots OR the unified Gateway -- Support pluggable storage (file-based, in-memory, database) -- Are fully testable in isolation - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ @manacore/bot-services │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ TodoService │ │ CalendarSvc │ │ AiService │ │ ClockService│ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ -│ │ -│ Pure business logic - no Matrix code! │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ Gateway │ │ Todo Bot │ │ CLI │ - │ (Matrix) │ │ (Matrix) │ │ Tool │ - └──────────┘ └──────────┘ └──────────┘ -``` - -## Available Services - -### Business Logic Services - -| Service | Storage | Description | -|---------|---------|-------------| -| `TodoService` | File (JSON) | Task management with projects, priorities, dates | -| `CalendarService` | File (JSON) | Events, calendars, reminders | -| `AiService` | In-memory | Ollama LLM integration, chat sessions, vision | -| `ClockService` | External API | Timers, alarms, world clocks | - -### Infrastructure Services - -| Service | Storage | Description | -|---------|---------|-------------| -| `SessionService` | In-memory | User authentication via mana-core-auth | -| `TranscriptionService` | External API | Speech-to-text via mana-stt service | - -### Placeholder Services (to be implemented) - -| Service | Description | -|---------|-------------| -| `NutritionService` | Meal tracking | -| `QuotesService` | Daily quotes | -| `StatsService` | Analytics reports | -| `DocsService` | Documentation generation | - -## Usage - -### In NestJS (Bot or Gateway) - -```typescript -import { Module } from '@nestjs/common'; -import { - TodoModule, - CalendarModule, - AiModule, - ClockModule, - SessionModule, - TranscriptionModule, -} from '@manacore/bot-services'; - -@Module({ - imports: [ - // File-based storage (default) - TodoModule.register({ storagePath: './data/todos.json' }), - CalendarModule.register({ storagePath: './data/calendar.json' }), - - // External services - AiModule.register({ baseUrl: 'http://ollama:11434' }), - ClockModule.register({ apiUrl: 'http://clock-backend:3017/api/v1' }), - - // Infrastructure services (use ConfigService by default) - SessionModule.forRoot(), - TranscriptionModule.forRoot(), - ], -}) -export class AppModule {} -``` - -### Session Service (Authentication) - -```typescript -import { SessionService } from '@manacore/bot-services'; - -// Login a Matrix user -const result = await sessionService.login( - '@user:matrix.org', - 'email@example.com', - 'password' -); - -if (result.success) { - // Get token for API calls - const token = sessionService.getToken('@user:matrix.org'); - - // Check if logged in - const isLoggedIn = sessionService.isLoggedIn('@user:matrix.org'); -} - -// Logout -sessionService.logout('@user:matrix.org'); - -// Store custom session data -sessionService.setSessionData('@user:matrix.org', 'currentConversationId', 'abc123'); -const convId = sessionService.getSessionData('@user:matrix.org', 'currentConversationId'); -``` - -### Transcription Service (Speech-to-Text) - -```typescript -import { TranscriptionService, TranscriptionModule } from '@manacore/bot-services'; - -// Module registration with API key -TranscriptionModule.register({ - sttUrl: 'http://mana-stt:3020', - apiKey: process.env.STT_API_KEY, // Optional: for authenticated STT service - defaultLanguage: 'de' -}) - -// Or use forRoot() which reads from config: -// - stt.url / STT_URL -// - stt.apiKey / STT_API_KEY - -// Transcribe audio buffer -const text = await transcriptionService.transcribe(audioBuffer, { language: 'de' }); - -// Get full response with metadata -const result = await transcriptionService.transcribeWithMetadata(audioBuffer); -console.log(result.text, result.language, result.model); - -// Health check -const isHealthy = await transcriptionService.checkHealth(); -``` - -### Direct Service Usage - -```typescript -import { TodoService } from '@manacore/bot-services/todo'; -import { AiService } from '@manacore/bot-services/ai'; - -// Create task -const task = await todoService.createTask('@user:matrix.org', { - title: 'Buy groceries', - priority: 2, - dueDate: '2025-01-30', -}); - -// AI chat -const response = await aiService.chatSimple('@user:matrix.org', 'What is TypeScript?'); -``` - -### Custom Storage Provider - -```typescript -import { TodoModule, StorageProvider, TodoData } from '@manacore/bot-services'; - -// PostgreSQL storage example -class PostgresTodoStorage implements StorageProvider { - async load(): Promise { - // Load from database - } - async save(data: TodoData): Promise { - // Save to database - } -} - -// Use custom storage -TodoModule.forRoot(new PostgresTodoStorage()); -``` - -## Input Parsing - -Services include German-language natural input parsing: - -### Todo - -```typescript -const parsed = todoService.parseTaskInput('Einkaufen !p1 @morgen #haushalt'); -// { title: 'Einkaufen', priority: 1, dueDate: '2025-01-30', project: 'haushalt' } -``` - -### Calendar - -```typescript -const parsed = calendarService.parseEventInput('Meeting morgen um 14:30'); -// { title: 'Meeting', startTime: Date, endTime: Date, isAllDay: false } -``` - -### Clock - -```typescript -const seconds = clockService.parseDuration('1h30m'); // 5400 -const time = clockService.parseAlarmTime('14 Uhr 30'); // '14:30:00' -``` - -## Development - -```bash -# Type check -pnpm --filter @manacore/bot-services type-check - -# Install in a bot -pnpm --filter matrix-todo-bot add @manacore/bot-services -``` - -## Adding New Services - -1. Create directory: `src/{service}/` -2. Add files: - - `types.ts` - Interfaces and types - - `{service}.service.ts` - Business logic - - `{service}.module.ts` - NestJS module - - `index.ts` - Exports -3. Export from `src/index.ts` -4. Update `package.json` exports - -## File Structure - -``` -packages/bot-services/ -├── src/ -│ ├── index.ts # Main exports -│ ├── shared/ -│ │ ├── types.ts # Common types -│ │ ├── storage.ts # Storage providers -│ │ ├── utils.ts # Utility functions -│ │ └── index.ts -│ ├── todo/ -│ ├── calendar/ -│ ├── ai/ -│ ├── clock/ -│ ├── session/ # NEW: User authentication -│ │ ├── types.ts -│ │ ├── session.service.ts -│ │ ├── session.module.ts -│ │ └── index.ts -│ ├── transcription/ # NEW: Speech-to-text -│ │ ├── types.ts -│ │ ├── transcription.service.ts -│ │ ├── transcription.module.ts -│ │ └── index.ts -│ └── ... -├── package.json -├── tsconfig.json -└── CLAUDE.md -``` - -## Migrating Bots to Shared Services - -To migrate a bot from local services to shared services: - -### 1. Add dependency - -```bash -# In package.json -"dependencies": { - "@manacore/bot-services": "workspace:*", - ... -} -``` - -### 2. Update module imports - -```typescript -// bot.module.ts - BEFORE -import { SessionModule } from '../session/session.module'; -import { TranscriptionModule } from '../transcription/transcription.module'; - -@Module({ - imports: [SessionModule, TranscriptionModule], -}) - -// bot.module.ts - AFTER -import { SessionModule, TranscriptionModule } from '@manacore/bot-services'; - -@Module({ - imports: [SessionModule.forRoot(), TranscriptionModule.forRoot()], -}) -``` - -### 3. Update service imports - -```typescript -// matrix.service.ts - BEFORE -import { SessionService } from '../session/session.service'; -import { TranscriptionService } from '../transcription/transcription.service'; - -// matrix.service.ts - AFTER -import { SessionService, TranscriptionService } from '@manacore/bot-services'; -``` - -### 4. Delete local modules - -```bash -rm -rf src/session/ -rm -rf src/transcription/ -``` - -### Migrated Bots - -| Bot | SessionService | TranscriptionService | -|-----|----------------|---------------------| -| matrix-todo-bot | - | ✅ | -| matrix-picture-bot | ✅ | - | -| matrix-clock-bot | - | ✅ | -| matrix-zitare-bot | ✅ | ✅ | -| matrix-chat-bot | ✅ | - | -| matrix-contacts-bot | ✅ | - | -| matrix-nutriphi-bot | ✅ | ✅ | -| matrix-project-doc-bot | - | ✅ | -| matrix-skilltree-bot | ✅ | - | -| matrix-presi-bot | ✅ | - | -| matrix-questions-bot | ✅ | - | -| matrix-storage-bot | ✅ | - | -| matrix-planta-bot | ✅ | - | -| matrix-manadeck-bot | ✅ | - | - -**All bots with SessionService and TranscriptionService have been migrated.** diff --git a/packages/bot-services/package.json b/packages/bot-services/package.json deleted file mode 100644 index 08954c764..000000000 --- a/packages/bot-services/package.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "name": "@manacore/bot-services", - "version": "0.1.0", - "private": true, - "description": "Shared business logic services for Matrix bots and Gateway", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./todo": { - "types": "./dist/todo/index.d.ts", - "default": "./dist/todo/index.js" - }, - "./calendar": { - "types": "./dist/calendar/index.d.ts", - "default": "./dist/calendar/index.js" - }, - "./clock": { - "types": "./dist/clock/index.d.ts", - "default": "./dist/clock/index.js" - }, - "./ai": { - "types": "./dist/ai/index.d.ts", - "default": "./dist/ai/index.js" - }, - "./session": { - "types": "./dist/session/index.d.ts", - "default": "./dist/session/index.js" - }, - "./transcription": { - "types": "./dist/transcription/index.d.ts", - "default": "./dist/transcription/index.js" - }, - "./credit": { - "types": "./dist/credit/index.d.ts", - "default": "./dist/credit/index.js" - }, - "./i18n": { - "types": "./dist/i18n/index.d.ts", - "default": "./dist/i18n/index.js" - }, - "./nutrition": { - "types": "./dist/nutrition/index.d.ts", - "default": "./dist/nutrition/index.js" - }, - "./quotes": { - "types": "./dist/quotes/index.d.ts", - "default": "./dist/quotes/index.js" - }, - "./stats": { - "types": "./dist/stats/index.d.ts", - "default": "./dist/stats/index.js" - }, - "./docs": { - "types": "./dist/docs/index.d.ts", - "default": "./dist/docs/index.js" - }, - "./shared": { - "types": "./dist/shared/index.d.ts", - "default": "./dist/shared/index.js" - } - }, - "scripts": { - "build": "tsc", - "type-check": "tsc --noEmit", - "clean": "rm -rf dist", - "lint": "eslint .", - "prepublishOnly": "pnpm build" - }, - "dependencies": { - "@manacore/shared-llm": "workspace:^", - "@nestjs/common": "^11.0.20", - "@nestjs/config": "^4.0.2", - "date-fns": "^4.1.0", - "ioredis": "^5.4.2" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/config": "^3.0.0 || ^4.0.0" - }, - "devDependencies": { - "@types/ioredis": "^5.0.0", - "@types/node": "^24.10.1", - "typescript": "^5.9.3" - } -} diff --git a/packages/bot-services/src/ai/ai.module.ts b/packages/bot-services/src/ai/ai.module.ts deleted file mode 100644 index bd393d140..000000000 --- a/packages/bot-services/src/ai/ai.module.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Module, DynamicModule, Provider, Type, ModuleMetadata } from '@nestjs/common'; -import { AiService } from './ai.service'; -import { AiServiceConfig } from './types'; - -export type AiModuleOptions = Partial; - -export interface AiModuleAsyncOptions extends Pick { - useFactory: (...args: unknown[]) => Promise | AiModuleOptions; - inject?: (Type | string | symbol)[]; -} - -@Module({}) -export class AiModule { - /** - * Register with default configuration (uses environment variables) - */ - static register(options?: AiModuleOptions): DynamicModule { - return { - module: AiModule, - providers: [ - { - provide: 'AI_SERVICE_CONFIG', - useValue: options ?? {}, - }, - { - provide: AiService, - useFactory: (config: Partial) => new AiService(config), - inject: ['AI_SERVICE_CONFIG'], - }, - ], - exports: [AiService], - }; - } - - /** - * Register with explicit configuration - */ - static forRoot(config: AiServiceConfig): DynamicModule { - return { - module: AiModule, - providers: [ - { - provide: AiService, - useFactory: () => new AiService(config), - }, - ], - exports: [AiService], - }; - } - - /** - * Register asynchronously with factory function - */ - static registerAsync(options: AiModuleAsyncOptions): DynamicModule { - const configProvider: Provider = { - provide: 'AI_SERVICE_CONFIG', - useFactory: options.useFactory, - inject: options.inject || [], - }; - - return { - module: AiModule, - imports: options.imports || [], - providers: [ - configProvider, - { - provide: AiService, - useFactory: (config: Partial) => new AiService(config), - inject: ['AI_SERVICE_CONFIG'], - }, - ], - exports: [AiService], - }; - } -} diff --git a/packages/bot-services/src/ai/ai.service.ts b/packages/bot-services/src/ai/ai.service.ts deleted file mode 100644 index d73e1b533..000000000 --- a/packages/bot-services/src/ai/ai.service.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { LlmClient, resolveOptions } from '@manacore/shared-llm'; -import type { ModelInfo } from '@manacore/shared-llm'; -import { - OllamaModel, - ChatMessage, - ChatOptions, - ChatResult, - AiServiceConfig, - UserAiSession, - SYSTEM_PROMPTS, - VISION_MODELS, - NON_CHAT_MODELS, -} from './types'; - -@Injectable() -export class AiService implements OnModuleInit { - private readonly logger = new Logger(AiService.name); - private readonly config: AiServiceConfig; - private readonly llm: LlmClient; - private sessions: Map = new Map(); - - constructor(config?: Partial) { - this.config = { - baseUrl: - config?.baseUrl ?? - process.env.MANA_LLM_URL ?? - process.env.OLLAMA_URL ?? - 'http://localhost:3025', - defaultModel: config?.defaultModel ?? process.env.OLLAMA_MODEL ?? 'gemma3:4b', - timeout: config?.timeout ?? parseInt(process.env.OLLAMA_TIMEOUT ?? '120000'), - }; - - this.llm = new LlmClient( - resolveOptions({ - manaLlmUrl: this.config.baseUrl, - defaultModel: this.normalizeModel(this.config.defaultModel), - timeout: this.config.timeout, - maxRetries: 1, - }) - ); - } - - async onModuleInit() { - await this.checkConnection(); - } - - // ===== Connection ===== - - async checkConnection(): Promise { - try { - const health = await this.llm.health(); - const isConnected = health.status === 'healthy' || health.status === 'degraded'; - if (isConnected) { - const providers = Object.keys(health.providers || {}).join(', '); - this.logger.log(`mana-llm connected: ${health.status}, providers: ${providers}`); - } - return isConnected; - } catch (error) { - this.logger.error(`Failed to connect to mana-llm at ${this.config.baseUrl}:`, error); - return false; - } - } - - // ===== Models ===== - - async listModels(): Promise { - try { - const models = await this.llm.listModels(); - return models.map((m: ModelInfo) => ({ - name: m.id, - size: 0, - modified_at: new Date(m.created * 1000).toISOString(), - })); - } catch (error) { - this.logger.error('Failed to list models:', error); - return []; - } - } - - async getChatModels(): Promise { - const models = await this.listModels(); - return models.filter((m) => !NON_CHAT_MODELS.includes(m.name)); - } - - async getVisionModels(): Promise { - const models = await this.listModels(); - return models.filter((m) => VISION_MODELS.some((v) => m.name.includes(v))); - } - - getDefaultModel(): string { - return this.config.defaultModel; - } - - // ===== Chat ===== - - async chat(messages: ChatMessage[], options?: ChatOptions): Promise { - const model = options?.model ?? this.config.defaultModel; - const normalizedModel = this.normalizeModel(model); - - const result = await this.llm.chatMessages( - messages.map((m) => ({ - role: m.role, - content: m.content, - })), - { - model: normalizedModel, - temperature: options?.temperature, - maxTokens: options?.maxTokens, - } - ); - - const meta = { - model, - evalCount: result.usage.completion_tokens, - evalDuration: undefined as number | undefined, - tokensPerSecond: undefined as number | undefined, - }; - - if (meta.evalCount && result.latencyMs > 0) { - meta.tokensPerSecond = (meta.evalCount / result.latencyMs) * 1000; - this.logger.debug( - `Generated ${meta.evalCount} tokens at ${meta.tokensPerSecond.toFixed(1)} t/s` - ); - } - - return { - content: result.content, - meta, - }; - } - - async chatSimple(userId: string, message: string, options?: ChatOptions): Promise { - const session = this.getSession(userId); - - // Add user message to history - session.history.push({ role: 'user', content: message }); - - // Keep only last 10 messages - if (session.history.length > 10) { - session.history = session.history.slice(-10); - } - - // Build messages with system prompt - const messages: ChatMessage[] = [ - { role: 'system', content: options?.systemPrompt ?? session.systemPrompt }, - ...session.history, - ]; - - const result = await this.chat(messages, { - ...options, - model: options?.model ?? session.model, - }); - - // Add assistant response to history - session.history.push({ role: 'assistant', content: result.content }); - - return result.content; - } - - // ===== Vision ===== - - async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise { - const selectedModel = model ?? this.config.defaultModel; - const normalizedModel = this.normalizeModel(selectedModel); - - const result = await this.llm.vision(prompt, imageBase64, 'image/png', { - model: normalizedModel, - }); - - const meta = { - model: selectedModel, - evalCount: result.usage.completion_tokens, - evalDuration: undefined as number | undefined, - tokensPerSecond: undefined as number | undefined, - }; - - if (meta.evalCount && result.latencyMs > 0) { - meta.tokensPerSecond = (meta.evalCount / result.latencyMs) * 1000; - } - - return { - content: result.content, - meta, - }; - } - - // ===== Compare Models ===== - - async compareModels( - message: string, - systemPrompt?: string - ): Promise<{ model: string; response: string; duration: number; error?: string }[]> { - const models = await this.getChatModels(); - const results: { model: string; response: string; duration: number; error?: string }[] = []; - - const messages: ChatMessage[] = [ - { role: 'system', content: systemPrompt ?? SYSTEM_PROMPTS.default }, - { role: 'user', content: message }, - ]; - - for (const model of models) { - const startTime = Date.now(); - try { - this.logger.log(`Querying model ${model.name}...`); - const result = await this.chat(messages, { model: model.name }); - const duration = Date.now() - startTime; - results.push({ model: model.name, response: result.content, duration }); - } catch (error) { - const duration = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - results.push({ model: model.name, response: '', duration, error: errorMessage }); - } - } - - return results; - } - - // ===== Session Management ===== - - getSession(userId: string): UserAiSession { - if (!this.sessions.has(userId)) { - this.sessions.set(userId, { - systemPrompt: SYSTEM_PROMPTS.default, - model: this.config.defaultModel, - history: [], - }); - } - return this.sessions.get(userId)!; - } - - setSessionModel(userId: string, model: string): void { - const session = this.getSession(userId); - session.model = model; - session.history = []; - } - - setSessionSystemPrompt(userId: string, prompt: string): void { - const session = this.getSession(userId); - session.systemPrompt = prompt; - session.history = []; - } - - setSessionMode(userId: string, mode: string): boolean { - const prompt = SYSTEM_PROMPTS[mode.toLowerCase()]; - if (!prompt) return false; - - this.setSessionSystemPrompt(userId, prompt); - return true; - } - - clearSessionHistory(userId: string): void { - const session = this.getSession(userId); - session.history = []; - } - - setPendingImage(userId: string, url: string, mimeType: string, base64?: string): void { - const session = this.getSession(userId); - session.pendingImage = { url, mimeType, base64 }; - } - - getPendingImage(userId: string): UserAiSession['pendingImage'] { - return this.getSession(userId).pendingImage; - } - - clearPendingImage(userId: string): void { - const session = this.getSession(userId); - session.pendingImage = undefined; - } - - // ===== Utilities ===== - - getAvailableModes(): string[] { - return Object.keys(SYSTEM_PROMPTS); - } - - getCurrentMode(userId: string): string { - const session = this.getSession(userId); - const entry = Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt); - return entry ? entry[0] : 'custom'; - } - - private normalizeModel(model: string): string { - if (model.includes('/')) return model; - return `ollama/${model}`; - } -} diff --git a/packages/bot-services/src/ai/index.ts b/packages/bot-services/src/ai/index.ts deleted file mode 100644 index 345be5ff5..000000000 --- a/packages/bot-services/src/ai/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Module -export { AiModule, AiModuleOptions } from './ai.module'; - -// Service -export { AiService } from './ai.service'; - -// Types -export * from './types'; diff --git a/packages/bot-services/src/ai/types.ts b/packages/bot-services/src/ai/types.ts deleted file mode 100644 index 8b720eb08..000000000 --- a/packages/bot-services/src/ai/types.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * AI/Ollama service types - */ - -/** - * Ollama model info - */ -export interface OllamaModel { - name: string; - size: number; - modified_at: string; - digest?: string; - details?: { - format: string; - family: string; - parameter_size: string; - quantization_level: string; - }; -} - -/** - * Chat message - */ -export interface ChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - images?: string[]; // Base64 encoded images for vision -} - -/** - * Chat completion options - */ -export interface ChatOptions { - model?: string; - temperature?: number; - maxTokens?: number; - systemPrompt?: string; -} - -/** - * Chat response metadata - */ -export interface ChatResponseMeta { - model: string; - evalCount?: number; - evalDuration?: number; - tokensPerSecond?: number; -} - -/** - * Chat completion result - */ -export interface ChatResult { - content: string; - meta: ChatResponseMeta; -} - -/** - * AI service configuration - */ -export interface AiServiceConfig { - baseUrl: string; - defaultModel: string; - timeout: number; -} - -/** - * User AI session (for conversation history) - */ -export interface UserAiSession { - systemPrompt: string; - model: string; - history: ChatMessage[]; - pendingImage?: { - url: string; - mimeType: string; - base64?: string; - }; -} - -/** - * System prompt presets - */ -export interface SystemPromptPreset { - name: string; - prompt: string; - description: string; -} - -/** - * Default system prompts - */ -export const SYSTEM_PROMPTS: Record = { - default: `Du bist Manai, ein freundlicher und hilfreicher KI-Assistent. -Du antwortest auf Deutsch, es sei denn, der Nutzer schreibt auf Englisch. -Du bist präzise, hilfreich und freundlich. -Halte deine Antworten kompakt, aber informativ.`, - - code: `Du bist ein erfahrener Software-Entwickler und Code-Assistent. -Du hilfst beim Schreiben, Debuggen und Erklären von Code. -Gib klare, gut kommentierte Code-Beispiele. -Erkläre technische Konzepte verständlich.`, - - translate: `Du bist ein professioneller Übersetzer. -Übersetze Texte präzise und natürlich klingend. -Bewahre den Stil und Ton des Originals. -Bei Unklarheiten frage nach der gewünschten Zielsprache.`, - - summarize: `Du bist ein Experte für das Zusammenfassen von Texten. -Erstelle klare, prägnante Zusammenfassungen. -Behalte die wichtigsten Punkte bei. -Strukturiere die Zusammenfassung übersichtlich.`, - - creative: `Du bist ein kreativer Schreibassistent. -Hilf beim Verfassen von Geschichten, Gedichten und kreativen Texten. -Sei fantasievoll und inspirierend. -Passe deinen Stil an die gewünschte Textart an.`, -}; - -/** - * Vision-capable model names - */ -export const VISION_MODELS = ['llava', 'llava:7b', 'llava:13b', 'bakllava', 'moondream']; - -/** - * Ollama API response types - */ -export interface OllamaVersionResponse { - version: string; -} - -export interface OllamaTagsResponse { - models: OllamaModel[]; -} - -export interface OllamaChatResponse { - model: string; - message?: { - role: string; - content: string; - }; - eval_count?: number; - eval_duration?: number; - total_duration?: number; - load_duration?: number; - prompt_eval_count?: number; -} - -/** - * Models excluded from comparison (specialized, not for general chat) - */ -export const NON_CHAT_MODELS = ['deepseek-r1:1.5b']; diff --git a/packages/bot-services/src/calendar/calendar-api.service.ts b/packages/bot-services/src/calendar/calendar-api.service.ts deleted file mode 100644 index ee1ada8db..000000000 --- a/packages/bot-services/src/calendar/calendar-api.service.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - CalendarEvent, - Calendar, - CreateEventInput, - UpdateEventInput, - ParsedEventInput, -} from './types'; -import { parseGermanDateKeyword, getTodayISO, addDays } from '../shared/utils'; - -/** - * Calendar API Service - * - * Connects to the calendar-backend API for event management. - * This service is used when the user is logged in and has a valid JWT token. - * - * @example - * ```typescript - * // Get events for a user (requires JWT token) - * const events = await calendarApiService.getEvents(token, { start: '2024-01-01', end: '2024-01-31' }); - * - * // Create an event - * const event = await calendarApiService.createEvent(token, { - * title: 'Meeting', - * startTime: new Date('2024-01-15T10:00:00'), - * endTime: new Date('2024-01-15T11:00:00'), - * }); - * ``` - */ -@Injectable() -export class CalendarApiService { - private readonly logger = new Logger(CalendarApiService.name); - private readonly baseUrl: string; - - constructor(baseUrl = 'http://localhost:3014') { - this.baseUrl = baseUrl; - this.logger.log(`Calendar API Service initialized with URL: ${baseUrl}`); - } - - // ===== Event Operations ===== - - /** - * Get events within a date range - * Note: The calendar backend doesn't support date filtering via query params, - * so we fetch all events and filter client-side. - */ - async getEvents( - token: string, - filter?: { start?: string; end?: string; calendarId?: string } - ): Promise { - try { - const params = new URLSearchParams(); - // Only calendarId is supported as query param - if (filter?.calendarId) params.append('calendarId', filter.calendarId); - - const queryString = params.toString(); - const url = queryString - ? `${this.baseUrl}/api/v1/events?${queryString}` - : `${this.baseUrl}/api/v1/events`; - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { events?: unknown[] }; - let events = this.mapApiEvents(data.events || []); - - // Client-side date filtering - if (filter?.start || filter?.end) { - const startDate = filter.start ? new Date(filter.start + 'T00:00:00') : null; - const endDate = filter.end ? new Date(filter.end + 'T23:59:59') : null; - - events = events.filter((event) => { - const eventStart = new Date(event.startTime); - const eventEnd = new Date(event.endTime); - - if (startDate && eventEnd < startDate) return false; - if (endDate && eventStart > endDate) return false; - return true; - }); - } - - return events; - } catch (error) { - this.logger.error('Failed to get events:', error); - return []; - } - } - - /** - * Get today's events - */ - async getTodayEvents(token: string): Promise { - const today = getTodayISO(); - return this.getEvents(token, { start: today, end: today }); - } - - /** - * Get upcoming events (next 7 days) - */ - async getUpcomingEvents(token: string, days = 7): Promise { - const today = getTodayISO(); - const end = addDays(new Date(), days).toISOString().split('T')[0]; - return this.getEvents(token, { start: today, end }); - } - - /** - * Create a new event - */ - async createEvent(token: string, input: CreateEventInput): Promise { - try { - const body: Record = { - title: input.title, - startTime: - input.startTime instanceof Date ? input.startTime.toISOString() : input.startTime, - endTime: input.endTime instanceof Date ? input.endTime.toISOString() : input.endTime, - isAllDay: input.isAllDay || false, - }; - - if (input.description) body.description = input.description; - if (input.location) body.location = input.location; - if (input.calendarId) body.calendarId = input.calendarId; - - const response = await fetch(`${this.baseUrl}/api/v1/events`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { event: unknown }; - return this.mapApiEvent(data.event); - } catch (error) { - this.logger.error('Failed to create event:', error); - return null; - } - } - - /** - * Update an event - */ - async updateEvent( - token: string, - eventId: string, - input: UpdateEventInput - ): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/events/${eventId}`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(input), - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { event: unknown }; - return this.mapApiEvent(data.event); - } catch (error) { - this.logger.error('Failed to update event:', error); - return null; - } - } - - /** - * Delete an event - */ - async deleteEvent(token: string, eventId: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/events/${eventId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - - return response.ok; - } catch (error) { - this.logger.error('Failed to delete event:', error); - return false; - } - } - - // ===== Calendar Operations ===== - - /** - * Get all calendars - */ - async getCalendars(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/calendars`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { calendars?: any[] }; - return (data.calendars || []).map((c: any) => ({ - id: c.id, - name: c.name, - color: c.color, - userId: c.userId || '', - isDefault: c.isDefault || false, - })); - } catch (error) { - this.logger.error('Failed to get calendars:', error); - return []; - } - } - - // ===== Parsing ===== - - /** - * Parse natural language event input - */ - parseEventInput(input: string): ParsedEventInput { - let title = input; - let startTime: Date | null = null; - let endTime: Date | null = null; - let isAllDay = false; - let location: string | null = null; - - // Extract date (@heute, @morgen, etc.) - const dateMatch = title.match(/@(\S+)/); - if (dateMatch) { - const dateStr = dateMatch[1].toLowerCase(); - const parsedDate = parseGermanDateKeyword(dateStr); - - if (parsedDate) { - // Default to 9:00-10:00 for the parsed date - startTime = new Date(`${parsedDate}T09:00:00`); - endTime = new Date(`${parsedDate}T10:00:00`); - } - title = title.replace(dateMatch[0], '').trim(); - } - - // Extract time (um 14 Uhr, 14:00, etc.) - const timeMatch = title.match(/(?:um\s+)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?/i); - if (timeMatch) { - const hours = parseInt(timeMatch[1]); - const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0; - - if (startTime) { - startTime.setHours(hours, minutes, 0, 0); - endTime = new Date(startTime); - endTime.setHours(hours + 1); // Default 1 hour duration - } else { - // If no date specified, assume today - startTime = new Date(); - startTime.setHours(hours, minutes, 0, 0); - endTime = new Date(startTime); - endTime.setHours(hours + 1); - } - title = title.replace(timeMatch[0], '').trim(); - } - - // Extract location (in ...) - const locationMatch = title.match(/\bin\s+([^,]+)/i); - if (locationMatch) { - location = locationMatch[1].trim(); - title = title.replace(locationMatch[0], '').trim(); - } - - // If no time specified, treat as all-day event - if (!startTime) { - startTime = new Date(); - startTime.setHours(0, 0, 0, 0); - endTime = new Date(startTime); - endTime.setHours(23, 59, 59, 999); - isAllDay = true; - } - - return { - title: title.trim(), - startTime: startTime!, - endTime: endTime!, - isAllDay, - location, - }; - } - - // ===== Private Helpers ===== - - /** - * Map API event format to internal CalendarEvent format - */ - private mapApiEvent(apiEvent: any): CalendarEvent { - return { - id: apiEvent.id, - userId: apiEvent.userId || '', - calendarId: apiEvent.calendarId, - calendarName: apiEvent.calendar?.name || 'Kalender', - title: apiEvent.title, - description: apiEvent.description || null, - location: apiEvent.location || null, - startTime: apiEvent.startTime, - endTime: apiEvent.endTime, - isAllDay: apiEvent.isAllDay || false, - createdAt: apiEvent.createdAt, - }; - } - - /** - * Map array of API events - */ - private mapApiEvents(apiEvents: any[]): CalendarEvent[] { - return apiEvents.map((e) => this.mapApiEvent(e)); - } -} diff --git a/packages/bot-services/src/calendar/calendar.module.ts b/packages/bot-services/src/calendar/calendar.module.ts deleted file mode 100644 index bac494f9f..000000000 --- a/packages/bot-services/src/calendar/calendar.module.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Module, DynamicModule, Provider, Type, ModuleMetadata } from '@nestjs/common'; -import { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service'; -import { StorageProvider } from '../shared/types'; -import { FileStorageProvider } from '../shared/storage'; -import { CalendarData } from './types'; - -export interface CalendarModuleOptions { - storagePath?: string; - storageProvider?: StorageProvider; -} - -export interface CalendarModuleAsyncOptions extends Pick { - useFactory: (...args: unknown[]) => Promise | CalendarModuleOptions; - inject?: (Type | string | symbol)[]; -} - -@Module({}) -export class CalendarModule { - /** - * Register with default file storage - */ - static register(options?: CalendarModuleOptions): DynamicModule { - const storagePath = options?.storagePath ?? './data/calendar-data.json'; - const defaultData: CalendarData = { events: [], calendars: [] }; - - return { - module: CalendarModule, - providers: [ - { - provide: CALENDAR_STORAGE_PROVIDER, - useValue: - options?.storageProvider ?? - new FileStorageProvider(storagePath, defaultData), - }, - CalendarService, - ], - exports: [CalendarService], - }; - } - - /** - * Register with custom storage provider - */ - static forRoot(storageProvider: StorageProvider): DynamicModule { - return { - module: CalendarModule, - providers: [ - { - provide: CALENDAR_STORAGE_PROVIDER, - useValue: storageProvider, - }, - CalendarService, - ], - exports: [CalendarService], - }; - } - - /** - * Register asynchronously with factory function - */ - static registerAsync(options: CalendarModuleAsyncOptions): DynamicModule { - const storageProvider: Provider = { - provide: CALENDAR_STORAGE_PROVIDER, - useFactory: async (...args: unknown[]) => { - const moduleOptions = await options.useFactory(...args); - const storagePath = moduleOptions?.storagePath ?? './data/calendar-data.json'; - const defaultData: CalendarData = { events: [], calendars: [] }; - return ( - moduleOptions?.storageProvider ?? - new FileStorageProvider(storagePath, defaultData) - ); - }, - inject: options.inject || [], - }; - - return { - module: CalendarModule, - imports: options.imports || [], - providers: [storageProvider, CalendarService], - exports: [CalendarService], - }; - } -} diff --git a/packages/bot-services/src/calendar/calendar.service.ts b/packages/bot-services/src/calendar/calendar.service.ts deleted file mode 100644 index f07de4130..000000000 --- a/packages/bot-services/src/calendar/calendar.service.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common'; -import { StorageProvider } from '../shared/types'; -import { FileStorageProvider } from '../shared/storage'; -import { - generateId, - startOfDay, - endOfDay, - addDays, - isToday, - isTomorrow, - formatDateDE, - formatTimeDE, -} from '../shared/utils'; -import { - CalendarEvent, - Calendar, - CalendarData, - CreateEventInput, - UpdateEventInput, - EventFilter, - ParsedEventInput, -} from './types'; - -export const CALENDAR_STORAGE_PROVIDER = 'CALENDAR_STORAGE_PROVIDER'; - -@Injectable() -export class CalendarService implements OnModuleInit { - private readonly logger = new Logger(CalendarService.name); - private data: CalendarData = { events: [], calendars: [] }; - private storage: StorageProvider; - - constructor( - @Optional() - @Inject(CALENDAR_STORAGE_PROVIDER) - storage?: StorageProvider - ) { - this.storage = - storage || - new FileStorageProvider('./data/calendar-data.json', { - events: [], - calendars: [], - }); - } - - async onModuleInit() { - await this.loadData(); - } - - private async loadData(): Promise { - try { - this.data = await this.storage.load(); - this.logger.log( - `Loaded ${this.data.events.length} events, ${this.data.calendars.length} calendars` - ); - } catch (error) { - this.logger.error('Failed to load calendar data:', error); - this.data = { events: [], calendars: [] }; - } - } - - private async saveData(): Promise { - try { - await this.storage.save(this.data); - } catch (error) { - this.logger.error('Failed to save calendar data:', error); - } - } - - private ensureDefaultCalendar(userId: string): Calendar { - let calendar = this.data.calendars.find((c) => c.userId === userId); - if (!calendar) { - calendar = { - id: generateId(), - name: 'Mein Kalender', - color: '#3B82F6', - userId, - }; - this.data.calendars.push(calendar); - this.saveData(); - } - return calendar; - } - - // ===== Event CRUD Operations ===== - - async createEvent(userId: string, input: CreateEventInput): Promise { - const calendar = this.ensureDefaultCalendar(userId); - - // Calculate endTime if not provided - let endTime: Date; - if (input.endTime) { - endTime = input.endTime; - } else if (input.isAllDay) { - // For all-day events, end at end of the same day - endTime = new Date(input.startTime); - endTime.setHours(23, 59, 59, 999); - } else { - // Default to 1 hour after start - endTime = new Date(input.startTime.getTime() + 60 * 60 * 1000); - } - - const event: CalendarEvent = { - id: generateId(), - userId, - title: input.title, - description: input.description ?? null, - location: input.location ?? null, - startTime: input.startTime.toISOString(), - endTime: endTime.toISOString(), - isAllDay: input.isAllDay ?? false, - calendarId: input.calendarId ?? calendar.id, - calendarName: calendar.name, - createdAt: new Date().toISOString(), - }; - - this.data.events.push(event); - await this.saveData(); - this.logger.log(`Created event "${event.title}" for user ${userId}`); - return event; - } - - async updateEvent( - userId: string, - eventId: string, - input: UpdateEventInput - ): Promise { - const event = this.data.events.find((e) => e.id === eventId && e.userId === userId); - if (!event) return null; - - if (input.title !== undefined) event.title = input.title; - if (input.startTime !== undefined) event.startTime = input.startTime.toISOString(); - if (input.endTime !== undefined) event.endTime = input.endTime.toISOString(); - if (input.description !== undefined) event.description = input.description; - if (input.location !== undefined) event.location = input.location; - if (input.isAllDay !== undefined) event.isAllDay = input.isAllDay; - event.updatedAt = new Date().toISOString(); - - await this.saveData(); - return event; - } - - async deleteEvent(userId: string, eventId: string): Promise { - const eventIndex = this.data.events.findIndex((e) => e.id === eventId && e.userId === userId); - if (eventIndex === -1) return null; - - const [event] = this.data.events.splice(eventIndex, 1); - await this.saveData(); - this.logger.log(`Deleted event "${event.title}" for user ${userId}`); - return event; - } - - async deleteEventByIndex(userId: string, index: number): Promise { - const events = await this.getUpcomingEvents(userId, 30); - if (index < 1 || index > events.length) return null; - - const event = events[index - 1]; - return this.deleteEvent(userId, event.id); - } - - // ===== Event Queries ===== - - async getEvent(userId: string, eventId: string): Promise { - return this.data.events.find((e) => e.id === eventId && e.userId === userId) ?? null; - } - - async getEventByIndex(userId: string, index: number): Promise { - const events = await this.getUpcomingEvents(userId, 30); - if (index < 1 || index > events.length) return null; - return events[index - 1]; - } - - async getEvents(userId: string, filter?: EventFilter): Promise { - let events = this.data.events.filter((e) => e.userId === userId); - - if (filter) { - if (filter.calendarId) { - events = events.filter((e) => e.calendarId === filter.calendarId); - } - if (filter.startAfter) { - events = events.filter((e) => new Date(e.startTime) >= filter.startAfter!); - } - if (filter.startBefore) { - events = events.filter((e) => new Date(e.startTime) <= filter.startBefore!); - } - if (filter.isAllDay !== undefined) { - events = events.filter((e) => e.isAllDay === filter.isAllDay); - } - } - - return events.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); - } - - async getEventsInRange(userId: string, start: Date, end: Date): Promise { - return this.data.events - .filter((e) => { - if (e.userId !== userId) return false; - const eventStart = new Date(e.startTime); - const eventEnd = new Date(e.endTime); - return eventStart < end && eventEnd > start; - }) - .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); - } - - async getTodayEvents(userId: string): Promise { - const today = startOfDay(); - const tomorrow = addDays(today, 1); - return this.getEventsInRange(userId, today, tomorrow); - } - - async getTomorrowEvents(userId: string): Promise { - const tomorrow = startOfDay(addDays(new Date(), 1)); - const dayAfter = addDays(tomorrow, 1); - return this.getEventsInRange(userId, tomorrow, dayAfter); - } - - async getWeekEvents(userId: string): Promise { - const today = startOfDay(); - const weekEnd = addDays(today, 7); - return this.getEventsInRange(userId, today, weekEnd); - } - - async getUpcomingEvents(userId: string, days = 7): Promise { - const now = new Date(); - const endDate = addDays(now, days); - return this.getEventsInRange(userId, now, endDate); - } - - // ===== Calendars ===== - - async getCalendars(userId: string): Promise { - this.ensureDefaultCalendar(userId); - return this.data.calendars.filter((c) => c.userId === userId); - } - - async createCalendar(userId: string, name: string, color?: string): Promise { - const calendar: Calendar = { - id: generateId(), - name, - color: color ?? '#808080', - userId, - }; - this.data.calendars.push(calendar); - await this.saveData(); - return calendar; - } - - // ===== Formatting ===== - - formatEventTime(event: CalendarEvent): string { - const start = new Date(event.startTime); - - let dateStr: string; - if (isToday(start)) { - dateStr = 'Heute'; - } else if (isTomorrow(start)) { - dateStr = 'Morgen'; - } else { - dateStr = formatDateDE(start, { weekday: 'short', day: '2-digit', month: '2-digit' }); - } - - if (event.isAllDay) { - return `${dateStr} (ganztägig)`; - } - - return `${dateStr}, ${formatTimeDE(start)}`; - } - - // ===== Input Parsing ===== - - /** - * Parse natural language event input - * Supports: "am DD.MM.", "heute/morgen/übermorgen", "um HH:MM", "ganztägig" - */ - parseEventInput(input: string): ParsedEventInput { - let title = input; - let startTime: Date | null = null; - let endTime: Date | null = null; - let isAllDay = false; - - const now = new Date(); - - // Check for "ganztägig" (all-day) - if (/ganztägig/i.test(title)) { - isAllDay = true; - title = title.replace(/ganztägig/gi, '').trim(); - } - - // Parse date patterns - // "am DD.MM." or "am DD.MM.YYYY" - const dateMatch = title.match(/am\s+(\d{1,2})\.(\d{1,2})\.?(\d{4})?/i); - // "heute", "morgen", "übermorgen" - const relativeMatch = title.match(/(heute|morgen|übermorgen)/i); - // Time: "um HH:MM" or "um HH Uhr" - const timeMatch = title.match(/um\s+(\d{1,2})[:.]?(\d{2})?\s*(uhr)?/i); - - if (dateMatch) { - const day = parseInt(dateMatch[1]); - const month = parseInt(dateMatch[2]) - 1; - const year = dateMatch[3] ? parseInt(dateMatch[3]) : now.getFullYear(); - - startTime = new Date(year, month, day); - - // If date is in the past this year, assume next year - if (startTime < now && !dateMatch[3]) { - startTime.setFullYear(startTime.getFullYear() + 1); - } - - title = title.replace(/am\s+\d{1,2}\.\d{1,2}\.?\d{0,4}/i, '').trim(); - } else if (relativeMatch) { - const relative = relativeMatch[1].toLowerCase(); - startTime = startOfDay(); - - if (relative === 'morgen') { - startTime = addDays(startTime, 1); - } else if (relative === 'übermorgen') { - startTime = addDays(startTime, 2); - } - - title = title.replace(/(heute|morgen|übermorgen)/i, '').trim(); - } - - if (timeMatch && startTime) { - const hours = parseInt(timeMatch[1]); - const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0; - - startTime.setHours(hours, minutes, 0, 0); - isAllDay = false; - - title = title.replace(/um\s+\d{1,2}[:.]?\d{0,2}\s*(uhr)?/i, '').trim(); - } else if (startTime && !isAllDay) { - // Default to 9:00 if no time specified - startTime.setHours(9, 0, 0, 0); - } - - // Set end time (1 hour later for timed events, end of day for all-day) - if (startTime) { - endTime = new Date(startTime); - if (isAllDay) { - endTime = endOfDay(startTime); - } else { - endTime.setHours(endTime.getHours() + 1); - } - } - - // Clean up title - title = title.replace(/\s+/g, ' ').trim(); - - return { title, startTime, endTime, isAllDay, location: null }; - } -} diff --git a/packages/bot-services/src/calendar/index.ts b/packages/bot-services/src/calendar/index.ts deleted file mode 100644 index 4c847d28f..000000000 --- a/packages/bot-services/src/calendar/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Module -export { CalendarModule, CalendarModuleOptions } from './calendar.module'; - -// Services -export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service'; -export { CalendarApiService } from './calendar-api.service'; - -// Types -export * from './types'; diff --git a/packages/bot-services/src/calendar/types.ts b/packages/bot-services/src/calendar/types.ts deleted file mode 100644 index ed16e96df..000000000 --- a/packages/bot-services/src/calendar/types.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type UserEntity } from '../shared/types'; - -/** - * Calendar event entity - */ -export interface CalendarEvent extends UserEntity { - title: string; - description: string | null; - location: string | null; - startTime: string; // ISO datetime - endTime: string; // ISO datetime - isAllDay: boolean; - calendarId: string; - calendarName: string; -} - -/** - * Calendar entity - */ -export interface Calendar { - id: string; - name: string; - color: string; - userId: string; -} - -/** - * Calendar data storage structure - */ -export interface CalendarData { - events: CalendarEvent[]; - calendars: Calendar[]; -} - -/** - * Create event input - */ -export interface CreateEventInput { - title: string; - startTime: Date; - endTime?: Date; // Optional - defaults to startTime + 1 hour, or end of day for all-day events - description?: string | null; - location?: string | null; - isAllDay?: boolean; - calendarId?: string; -} - -/** - * Update event input - */ -export interface UpdateEventInput { - title?: string; - startTime?: Date; - endTime?: Date; - description?: string | null; - location?: string | null; - isAllDay?: boolean; -} - -/** - * Event filter options - */ -export interface EventFilter { - calendarId?: string; - startAfter?: Date; - startBefore?: Date; - isAllDay?: boolean; -} - -/** - * Parsed event input (from natural language) - */ -export interface ParsedEventInput { - title: string; - startTime: Date | null; - endTime: Date | null; - isAllDay: boolean; - location: string | null; -} diff --git a/packages/bot-services/src/clock/clock.module.ts b/packages/bot-services/src/clock/clock.module.ts deleted file mode 100644 index a0c2591e8..000000000 --- a/packages/bot-services/src/clock/clock.module.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Module, DynamicModule, Provider, Type, ModuleMetadata } from '@nestjs/common'; -import { ClockService } from './clock.service'; -import { ClockServiceConfig } from './types'; - -export type ClockModuleOptions = Partial; - -export interface ClockModuleAsyncOptions extends Pick { - useFactory: (...args: unknown[]) => Promise | ClockModuleOptions; - inject?: (Type | string | symbol)[]; -} - -@Module({}) -export class ClockModule { - /** - * Register with default configuration (uses environment variables) - */ - static register(options?: ClockModuleOptions): DynamicModule { - return { - module: ClockModule, - providers: [ - { - provide: 'CLOCK_SERVICE_CONFIG', - useValue: options ?? {}, - }, - { - provide: ClockService, - useFactory: (config: Partial) => new ClockService(config), - inject: ['CLOCK_SERVICE_CONFIG'], - }, - ], - exports: [ClockService], - }; - } - - /** - * Register with explicit configuration - */ - static forRoot(config: ClockServiceConfig): DynamicModule { - return { - module: ClockModule, - providers: [ - { - provide: ClockService, - useFactory: () => new ClockService(config), - }, - ], - exports: [ClockService], - }; - } - - /** - * Register asynchronously with factory function - */ - static registerAsync(options: ClockModuleAsyncOptions): DynamicModule { - const configProvider: Provider = { - provide: 'CLOCK_SERVICE_CONFIG', - useFactory: options.useFactory, - inject: options.inject || [], - }; - - return { - module: ClockModule, - imports: options.imports || [], - providers: [ - configProvider, - { - provide: ClockService, - useFactory: (config: Partial) => new ClockService(config), - inject: ['CLOCK_SERVICE_CONFIG'], - }, - ], - exports: [ClockService], - }; - } -} diff --git a/packages/bot-services/src/clock/clock.service.ts b/packages/bot-services/src/clock/clock.service.ts deleted file mode 100644 index 1237208fb..000000000 --- a/packages/bot-services/src/clock/clock.service.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - Timer, - Alarm, - WorldClock, - TimezoneResult, - CreateTimerInput, - CreateAlarmInput, - CreateWorldClockInput, - ClockServiceConfig, - TimeTrackingSummary, -} from './types'; - -@Injectable() -export class ClockService { - private readonly logger = new Logger(ClockService.name); - private readonly apiUrl: string; - - // In-memory token storage per user - private userTokens: Map = new Map(); - - constructor(config?: Partial) { - this.apiUrl = config?.apiUrl ?? process.env.CLOCK_API_URL ?? 'http://localhost:3017/api/v1'; - this.logger.log(`Clock API URL: ${this.apiUrl}`); - } - - // ===== Auth Token Management ===== - - setUserToken(userId: string, token: string): void { - this.userTokens.set(userId, token); - } - - getUserToken(userId: string): string | undefined { - return this.userTokens.get(userId); - } - - // ===== API Helper ===== - - private async apiCall( - endpoint: string, - method = 'GET', - token?: string, - body?: unknown - ): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${this.apiUrl}${endpoint}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Clock API error: ${response.status} - ${errorText}`); - } - - return response.json() as Promise; - } - - // ===== Health ===== - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`); - return response.ok; - } catch { - return false; - } - } - - // ===== Timers ===== - - async getTimers(token: string): Promise { - return this.apiCall('/timers', 'GET', token); - } - - async getTimer(id: string, token: string): Promise { - return this.apiCall(`/timers/${id}`, 'GET', token); - } - - async createTimer(input: CreateTimerInput, token: string): Promise { - return this.apiCall('/timers', 'POST', token, { - durationSeconds: input.durationSeconds, - label: input.label, - }); - } - - async startTimer(id: string, token: string): Promise { - return this.apiCall(`/timers/${id}/start`, 'POST', token); - } - - async pauseTimer(id: string, token: string): Promise { - return this.apiCall(`/timers/${id}/pause`, 'POST', token); - } - - async resetTimer(id: string, token: string): Promise { - return this.apiCall(`/timers/${id}/reset`, 'POST', token); - } - - async deleteTimer(id: string, token: string): Promise { - await this.apiCall(`/timers/${id}`, 'DELETE', token); - } - - async getRunningTimer(token: string): Promise { - const timers = await this.getTimers(token); - return timers.find((t) => t.status === 'running' || t.status === 'paused') || null; - } - - // ===== Alarms ===== - - async getAlarms(token: string): Promise { - return this.apiCall('/alarms', 'GET', token); - } - - async createAlarm(input: CreateAlarmInput, token: string): Promise { - return this.apiCall('/alarms', 'POST', token, { - time: input.time, - label: input.label, - enabled: true, - repeatDays: input.repeatDays, - }); - } - - async toggleAlarm(id: string, token: string): Promise { - return this.apiCall(`/alarms/${id}/toggle`, 'PATCH', token); - } - - async deleteAlarm(id: string, token: string): Promise { - await this.apiCall(`/alarms/${id}`, 'DELETE', token); - } - - // ===== World Clocks ===== - - async getWorldClocks(token: string): Promise { - return this.apiCall('/world-clocks', 'GET', token); - } - - async addWorldClock(input: CreateWorldClockInput, token: string): Promise { - return this.apiCall('/world-clocks', 'POST', token, { - timezone: input.timezone, - cityName: input.cityName, - }); - } - - async deleteWorldClock(id: string, token: string): Promise { - await this.apiCall(`/world-clocks/${id}`, 'DELETE', token); - } - - // ===== Timezone Search ===== - - async searchTimezones(query: string): Promise { - return this.apiCall(`/timezones/search?q=${encodeURIComponent(query)}`); - } - - // ===== Time Tracking Summary ===== - - async getTodayTracked(token: string): Promise { - // This would aggregate timer data for today - // For now, return a placeholder - implement based on actual API - const timers = await this.getTimers(token); - const finishedToday = timers.filter((t) => { - if (t.status !== 'finished') return false; - const finishedAt = new Date(t.updatedAt); - const today = new Date(); - return finishedAt.toDateString() === today.toDateString(); - }); - - const totalMinutes = finishedToday.reduce( - (sum, t) => sum + Math.floor(t.durationSeconds / 60), - 0 - ); - - return { - totalMinutes, - sessions: finishedToday.length, - }; - } - - // ===== Parsing Utilities ===== - - /** - * Parse duration string to seconds - * Supports: "25m", "1h30m", "90s", "25" (assumes minutes) - */ - parseDuration(input: string): number | null { - let totalSeconds = 0; - - // Match hours - const hoursMatch = input.match(/(\d+)\s*h/i); - if (hoursMatch) { - totalSeconds += parseInt(hoursMatch[1], 10) * 3600; - } - - // Match minutes - const minutesMatch = input.match(/(\d+)\s*m(?:in)?/i); - if (minutesMatch) { - totalSeconds += parseInt(minutesMatch[1], 10) * 60; - } - - // Match seconds - const secondsMatch = input.match(/(\d+)\s*s(?:ec)?/i); - if (secondsMatch) { - totalSeconds += parseInt(secondsMatch[1], 10); - } - - // If just a number, assume minutes - if (totalSeconds === 0) { - const justNumber = input.match(/^(\d+)$/); - if (justNumber) { - totalSeconds = parseInt(justNumber[1], 10) * 60; - } - } - - return totalSeconds > 0 ? totalSeconds : null; - } - - /** - * Parse time string to HH:MM:SS - * Supports: "14:30", "9:00", "14 Uhr 30" - */ - parseAlarmTime(input: string): string | null { - // Try HH:MM format - let match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/); - if (match) { - const hours = parseInt(match[1], 10); - const minutes = parseInt(match[2], 10); - const seconds = match[3] ? parseInt(match[3], 10) : 0; - - if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - } - - // Try "X Uhr Y" format (German) - match = input.match(/(\d{1,2})\s*uhr(?:\s*(\d{1,2}))?/i); - if (match) { - const hours = parseInt(match[1], 10); - const minutes = match[2] ? parseInt(match[2], 10) : 0; - - if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; - } - } - - return null; - } - - /** - * Format seconds to human readable - */ - formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - const parts: string[] = []; - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); - - return parts.join(' '); - } - - // ===== Convenience Methods for Bot Handlers ===== - - /** - * Start a timer from natural language input - * Parses duration and optional label from input like "25m Pomodoro" - */ - async startTimerForUser(userId: string, input: string): Promise { - const token = this.getUserToken(userId); - if (!token) { - throw new Error('Nicht authentifiziert. Bitte zuerst anmelden.'); - } - - // Parse duration from input - const durationSeconds = this.parseDuration(input); - if (!durationSeconds) { - throw new Error('Ungültiges Dauer-Format. Beispiele: 25m, 1h30m, 90s'); - } - - // Extract label (everything after duration pattern) - const label = input.replace(/\d+\s*[hms]?(?:in)?/gi, '').trim() || null; - - const timer = await this.createTimer({ durationSeconds, label }, token); - // Start the timer immediately - const started = await this.startTimer(timer.id, token); - return { ...started, name: started.label ?? undefined }; - } - - /** - * Stop the running timer for a user - */ - async stopTimerForUser(userId: string, timerName?: string): Promise { - const token = this.getUserToken(userId); - if (!token) { - throw new Error('Nicht authentifiziert. Bitte zuerst anmelden.'); - } - - const timers = await this.getTimers(token); - let timer: Timer | undefined; - - if (timerName) { - timer = timers.find( - (t) => - (t.status === 'running' || t.status === 'paused') && - t.label?.toLowerCase().includes(timerName.toLowerCase()) - ); - } else { - timer = timers.find((t) => t.status === 'running' || t.status === 'paused'); - } - - if (!timer) { - throw new Error('Kein aktiver Timer gefunden.'); - } - - await this.deleteTimer(timer.id, token); - return { ...timer, name: timer.label ?? undefined }; - } - - /** - * Set an alarm from natural language input - * Parses time and optional label from input like "14:30 Meeting" - */ - async setAlarmForUser(userId: string, input: string): Promise { - const token = this.getUserToken(userId); - if (!token) { - throw new Error('Nicht authentifiziert. Bitte zuerst anmelden.'); - } - - const time = this.parseAlarmTime(input); - if (!time) { - throw new Error('Ungültiges Zeit-Format. Beispiele: 14:30, 9:00, 14 Uhr 30'); - } - - // Extract label (everything after time pattern) - const label = - input - .replace(/\d{1,2}:\d{2}(:\d{2})?/g, '') - .replace(/\d{1,2}\s*uhr(\s*\d{1,2})?/gi, '') - .trim() || null; - - const alarm = await this.createAlarm({ time, label }, token); - return { ...alarm, name: alarm.label ?? undefined }; - } - - /** - * Get time for a specific city/timezone - */ - async getWorldClockTime(city: string): Promise<{ city: string; time: string; date: string }> { - // Search for timezone - const results = await this.searchTimezones(city); - if (results.length === 0) { - throw new Error(`Stadt "${city}" nicht gefunden.`); - } - - const tz = results[0]; - const now = new Date(); - - const time = now.toLocaleTimeString('de-DE', { - timeZone: tz.timezone, - hour: '2-digit', - minute: '2-digit', - }); - - const date = now.toLocaleDateString('de-DE', { - timeZone: tz.timezone, - weekday: 'long', - day: 'numeric', - month: 'long', - }); - - return { city: tz.city, time, date }; - } -} diff --git a/packages/bot-services/src/clock/index.ts b/packages/bot-services/src/clock/index.ts deleted file mode 100644 index b7d84fc18..000000000 --- a/packages/bot-services/src/clock/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Module -export { ClockModule, ClockModuleOptions } from './clock.module'; - -// Service -export { ClockService } from './clock.service'; - -// Types -export * from './types'; diff --git a/packages/bot-services/src/clock/types.ts b/packages/bot-services/src/clock/types.ts deleted file mode 100644 index 090546698..000000000 --- a/packages/bot-services/src/clock/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Clock service types - */ - -/** - * Timer entity - */ -export interface Timer { - id: string; - userId: string; - label: string | null; - durationSeconds: number; - remainingSeconds: number; - status: 'idle' | 'running' | 'paused' | 'finished'; - startedAt: string | null; - pausedAt: string | null; - sound: string; - createdAt: string; - updatedAt: string; -} - -/** - * Alarm entity - */ -export interface Alarm { - id: string; - userId: string; - label: string | null; - time: string; // HH:MM:SS - enabled: boolean; - repeatDays: number[]; // 0-6, Sunday = 0 - snoozeMinutes: number; - sound: string; - vibrate: boolean; - createdAt: string; - updatedAt: string; -} - -/** - * World clock entity - */ -export interface WorldClock { - id: string; - userId: string; - timezone: string; - cityName: string; - sortOrder: number; - createdAt: string; -} - -/** - * Timezone search result - */ -export interface TimezoneResult { - timezone: string; - city: string; -} - -/** - * Create timer input - */ -export interface CreateTimerInput { - durationSeconds: number; - label?: string | null; -} - -/** - * Create alarm input - */ -export interface CreateAlarmInput { - time: string; // HH:MM:SS - label?: string | null; - repeatDays?: number[]; -} - -/** - * Create world clock input - */ -export interface CreateWorldClockInput { - timezone: string; - cityName: string; -} - -/** - * Clock service configuration - */ -export interface ClockServiceConfig { - apiUrl: string; -} - -/** - * Time tracking summary - */ -export interface TimeTrackingSummary { - totalMinutes: number; - sessions: number; -} diff --git a/packages/bot-services/src/contacts/contacts-api.service.ts b/packages/bot-services/src/contacts/contacts-api.service.ts deleted file mode 100644 index fb363e292..000000000 --- a/packages/bot-services/src/contacts/contacts-api.service.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { - Contact, - ContactBirthday, - ContactsModuleOptions, - CONTACTS_MODULE_OPTIONS, - DEFAULT_CONTACTS_API_URL, -} from './types'; - -/** - * Contacts API Service - * - * Connects to the contacts-backend API for contact management. - * Used by the morning summary to show birthdays. - * - * @example - * ```typescript - * // Get today's birthdays (requires JWT token) - * const birthdays = await contactsApiService.getBirthdaysToday(token); - * - * // Get all contacts - * const contacts = await contactsApiService.getContacts(token); - * ``` - */ -@Injectable() -export class ContactsApiService { - private readonly logger = new Logger(ContactsApiService.name); - private readonly baseUrl: string; - - constructor(@Optional() @Inject(CONTACTS_MODULE_OPTIONS) options?: ContactsModuleOptions) { - this.baseUrl = options?.apiUrl || DEFAULT_CONTACTS_API_URL; - this.logger.log(`Contacts API Service initialized with URL: ${this.baseUrl}`); - } - - /** - * Get today's birthdays - * Uses the dedicated /contacts/birthdays endpoint and filters for today - */ - async getBirthdaysToday(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/contacts/birthdays`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as ContactBirthday[]; - - // Filter for today's birthdays - const today = new Date(); - const todayMonth = today.getMonth() + 1; - const todayDay = today.getDate(); - - return data - .filter((contact) => { - if (!contact.birthday) return false; - const [, month, day] = contact.birthday.split('-').map(Number); - return month === todayMonth && day === todayDay; - }) - .map((contact) => ({ - ...contact, - age: this.calculateAge(contact.birthday), - })); - } catch (error) { - this.logger.error('Failed to get birthdays today:', error); - return []; - } - } - - /** - * Get upcoming birthdays (next 7 days) - */ - async getUpcomingBirthdays(token: string, days = 7): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/contacts/birthdays`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as ContactBirthday[]; - - // Filter for upcoming birthdays - const today = new Date(); - today.setHours(0, 0, 0, 0); - const endDate = new Date(today); - endDate.setDate(endDate.getDate() + days); - - return data - .filter((contact) => { - if (!contact.birthday) return false; - - // Create this year's birthday date - const [_year, month, day] = contact.birthday.split('-').map(Number); - const birthdayThisYear = new Date(today.getFullYear(), month - 1, day); - - // If birthday already passed this year, check next year - if (birthdayThisYear < today) { - birthdayThisYear.setFullYear(today.getFullYear() + 1); - } - - return birthdayThisYear >= today && birthdayThisYear <= endDate; - }) - .map((contact) => ({ - ...contact, - age: this.calculateAge(contact.birthday), - })); - } catch (error) { - this.logger.error('Failed to get upcoming birthdays:', error); - return []; - } - } - - /** - * Get all contacts - */ - async getContacts( - token: string, - options?: { limit?: number; search?: string } - ): Promise { - try { - const params = new URLSearchParams(); - if (options?.limit) params.append('limit', String(options.limit)); - if (options?.search) params.append('search', options.search); - - const queryString = params.toString(); - const url = queryString - ? `${this.baseUrl}/api/v1/contacts?${queryString}` - : `${this.baseUrl}/api/v1/contacts`; - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { contacts?: Contact[] }; - return data.contacts || []; - } catch (error) { - this.logger.error('Failed to get contacts:', error); - return []; - } - } - - /** - * Get a single contact by ID - */ - async getContact(token: string, contactId: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/contacts/${contactId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - return (await response.json()) as Contact; - } catch (error) { - this.logger.error(`Failed to get contact ${contactId}:`, error); - return null; - } - } - - /** - * Format birthdays for display - */ - formatBirthdays(birthdays: ContactBirthday[]): string { - if (birthdays.length === 0) { - return ''; - } - - const lines: string[] = ['**Geburtstage** 🎂']; - - for (const contact of birthdays) { - const name = contact.displayName || `${contact.firstName} ${contact.lastName}`.trim(); - const ageText = contact.age ? ` wird ${contact.age}` : ''; - lines.push(`• ${name}${ageText}`); - } - - return lines.join('\n'); - } - - /** - * Calculate age from birthday string (YYYY-MM-DD) - */ - private calculateAge(birthday: string): number | undefined { - const [year] = birthday.split('-').map(Number); - if (!year || year < 1900) return undefined; - - const today = new Date(); - const birthDate = new Date(birthday); - let age = today.getFullYear() - birthDate.getFullYear(); - - // Adjust if birthday hasn't occurred this year - const monthDiff = today.getMonth() - birthDate.getMonth(); - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { - age--; - } - - // Return next age (what they will be turning) - return age + 1; - } -} diff --git a/packages/bot-services/src/contacts/contacts.module.ts b/packages/bot-services/src/contacts/contacts.module.ts deleted file mode 100644 index 9e999b5b0..000000000 --- a/packages/bot-services/src/contacts/contacts.module.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ContactsApiService } from './contacts-api.service'; -import { ContactsModuleOptions, CONTACTS_MODULE_OPTIONS } from './types'; - -/** - * Contacts Module - * - * Contact management and birthday tracking API client. - * - * @example - * ```typescript - * // Basic usage - * @Module({ - * imports: [ContactsModule.register()] - * }) - * - * // With custom API URL - * @Module({ - * imports: [ - * ContactsModule.register({ - * apiUrl: 'http://contacts-backend:3015', - * }) - * ] - * }) - * ``` - */ -@Global() -@Module({}) -export class ContactsModule { - /** - * Register module with explicit options - */ - static register(options: ContactsModuleOptions = {}): DynamicModule { - return { - module: ContactsModule, - providers: [ - { - provide: CONTACTS_MODULE_OPTIONS, - useValue: options, - }, - ContactsApiService, - ], - exports: [ContactsApiService], - }; - } - - /** - * Register module with async configuration - */ - static registerAsync(options: { - imports?: any[]; - useFactory: (...args: any[]) => Promise | ContactsModuleOptions; - inject?: any[]; - }): DynamicModule { - return { - module: ContactsModule, - imports: [...(options.imports || [])], - providers: [ - { - provide: CONTACTS_MODULE_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }, - ContactsApiService, - ], - exports: [ContactsApiService], - }; - } - - /** - * Register with ConfigService reading from environment - * - * Environment variables: - * - CONTACTS_API_URL: Contacts backend URL - */ - static forRoot(): DynamicModule { - return this.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - apiUrl: - config.get('contacts.apiUrl') || - config.get('CONTACTS_API_URL') || - 'http://localhost:3015', - }), - inject: [ConfigService], - }); - } -} diff --git a/packages/bot-services/src/contacts/index.ts b/packages/bot-services/src/contacts/index.ts deleted file mode 100644 index 6b5d833a7..000000000 --- a/packages/bot-services/src/contacts/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Contacts API Service - * - * Contact management and birthday tracking API client. - * - * @example - * ```typescript - * import { ContactsModule, ContactsApiService } from '@manacore/bot-services/contacts'; - * - * // In module - * @Module({ - * imports: [ContactsModule.forRoot()] - * }) - * - * // In service - * const birthdays = await contactsApiService.getBirthdaysToday(token); - * ``` - */ - -export { ContactsModule } from './contacts.module.js'; -export { ContactsApiService } from './contacts-api.service.js'; -export { - ContactsModuleOptions, - Contact, - ContactBirthday, - CONTACTS_MODULE_OPTIONS, - DEFAULT_CONTACTS_API_URL, -} from './types.js'; diff --git a/packages/bot-services/src/contacts/types.ts b/packages/bot-services/src/contacts/types.ts deleted file mode 100644 index b9c217196..000000000 --- a/packages/bot-services/src/contacts/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Contacts API Service Types - * - * Types for contact management and birthday tracking - */ - -/** - * Contact with basic info - */ -export interface Contact { - id: string; - firstName?: string; - lastName?: string; - displayName?: string; - nickname?: string; - email?: string; - phone?: string; - mobile?: string; - birthday?: string; - photoUrl?: string; - company?: string; - jobTitle?: string; - isFavorite: boolean; -} - -/** - * Contact birthday summary (lightweight) - */ -export interface ContactBirthday { - id: string; - displayName: string | null; - firstName: string | null; - lastName: string | null; - birthday: string; - photoUrl: string | null; - age?: number; -} - -/** - * Contacts API module options - */ -export interface ContactsModuleOptions { - apiUrl?: string; -} - -/** - * Injection token for Contacts module options - */ -export const CONTACTS_MODULE_OPTIONS = 'CONTACTS_MODULE_OPTIONS'; - -/** - * Default API URL - */ -export const DEFAULT_CONTACTS_API_URL = 'http://localhost:3015'; diff --git a/packages/bot-services/src/credit/credit.module.ts b/packages/bot-services/src/credit/credit.module.ts deleted file mode 100644 index 999933ba5..000000000 --- a/packages/bot-services/src/credit/credit.module.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { CreditService } from './credit.service'; -import { CreditModuleOptions, CREDIT_MODULE_OPTIONS } from './types'; - -/** - * Shared credit management module for Matrix bots - * - * Provides CreditService for querying credit balances and formatting - * credit-related messages for Matrix chat display. - * - * @example - * ```typescript - * // With explicit configuration - * @Module({ - * imports: [ - * CreditModule.register({ - * authUrl: 'http://mana-core-auth:3001', - * }) - * ] - * }) - * - * // With ConfigService (reads from auth.url or MANA_CORE_AUTH_URL) - * @Module({ - * imports: [CreditModule.forRoot()] - * }) - * ``` - */ -@Global() -@Module({}) -export class CreditModule { - /** - * Register module with explicit options - */ - static register(options: CreditModuleOptions = {}): DynamicModule { - return { - module: CreditModule, - imports: [ConfigModule], - providers: [ - { - provide: CREDIT_MODULE_OPTIONS, - useValue: options, - }, - CreditService, - ], - exports: [CreditService], - }; - } - - /** - * Register module with ConfigService (reads MANA_CORE_AUTH_URL from config) - */ - static forRoot(): DynamicModule { - return { - module: CreditModule, - imports: [ConfigModule], - providers: [CreditService], - exports: [CreditService], - }; - } -} diff --git a/packages/bot-services/src/credit/credit.service.ts b/packages/bot-services/src/credit/credit.service.ts deleted file mode 100644 index 81a44d7ed..000000000 --- a/packages/bot-services/src/credit/credit.service.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - CreditBalance, - CreditValidationResult, - CreditModuleOptions, - CreditStatusMessage, - CreditErrorCode, - CreditPackage, - PaymentLinkResult, - PurchaseStatusResult, - CREDIT_MODULE_OPTIONS, -} from './types'; - -/** - * Shared credit management service for Matrix bots - * - * Provides credit balance queries, validation, and formatted messages - * for displaying credit information in Matrix chat. - * - * @example - * ```typescript - * // In NestJS module - * imports: [CreditModule.register({ authUrl: 'http://mana-core-auth:3001' })] - * - * // In service/controller - * const balance = await creditService.getBalance(token); - * const statusMsg = creditService.formatStatusMessage(balance); - * ``` - */ -@Injectable() -export class CreditService { - private readonly logger = new Logger(CreditService.name); - private readonly authUrl: string; - private readonly serviceKey?: string; - private readonly appId?: string; - - constructor( - @Optional() private configService: ConfigService, - @Optional() @Inject(CREDIT_MODULE_OPTIONS) private options?: CreditModuleOptions - ) { - // Priority: module options > config > environment > default - this.authUrl = - options?.authUrl || - this.configService?.get('auth.url') || - this.configService?.get('MANA_CORE_AUTH_URL') || - 'http://localhost:3001'; - - this.serviceKey = - options?.serviceKey || this.configService?.get('MANA_CORE_SERVICE_KEY'); - - this.appId = options?.appId || this.configService?.get('APP_ID'); - - this.logger.log(`Credit service initialized with auth URL: ${this.authUrl}`); - } - - /** - * Get credit balance for a user - * - * @param token - User's JWT token - * @returns Credit balance information - */ - async getBalance(token: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/credits/balance`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to get credit balance: ${response.status}`); - return { balance: 0, hasCredits: false }; - } - - const data = (await response.json()) as { balance?: number; tier?: string }; - const balance = data.balance ?? 0; - - return { - balance, - hasCredits: balance > 0, - tier: data.tier, - }; - } catch (error) { - this.logger.error('Error getting credit balance:', error); - return { balance: 0, hasCredits: false }; - } - } - - /** - * Validate if user has enough credits for an operation - * - * @param token - User's JWT token - * @param requiredCredits - Credits required for the operation - * @returns Validation result - */ - async validateCredits(token: string, requiredCredits: number): Promise { - const balance = await this.getBalance(token); - - return { - hasCredits: balance.balance >= requiredCredits, - availableCredits: balance.balance, - requiredCredits, - error: - balance.balance < requiredCredits - ? `Nicht genug Credits. Benötigt: ${requiredCredits}, Vorhanden: ${balance.balance.toFixed(2)}` - : undefined, - }; - } - - /** - * Format credit balance as a status message for Matrix - * - * @param balance - Credit balance or number - * @returns Formatted message with text and HTML versions - */ - formatBalanceMessage(balance: CreditBalance | number): CreditStatusMessage { - const credits = typeof balance === 'number' ? balance : balance.balance; - const hasCredits = credits > 0; - - const icon = hasCredits ? '⚡' : '⚠️'; - const creditsFormatted = credits.toFixed(2); - - const text = `${icon} Credits: ${creditsFormatted}`; - const html = `${icon} Credits: ${creditsFormatted}`; - - return { text, html }; - } - - /** - * Format a full status message with credit information - * - * @param email - User's email (logged in as) - * @param balance - Credit balance - * @param additionalInfo - Additional status info - * @returns Formatted status message - */ - formatStatusMessage( - email: string, - balance: CreditBalance, - additionalInfo?: Record - ): CreditStatusMessage { - const lines: string[] = []; - const htmlLines: string[] = []; - - // Header - lines.push('🤖 Bot Status'); - htmlLines.push('🤖 Bot Status'); - - // User info - lines.push(`👤 User: ${email}`); - htmlLines.push(`👤 User: ${email}`); - - // Credits - const creditIcon = balance.hasCredits ? '⚡' : '⚠️'; - const creditsFormatted = balance.balance.toFixed(2); - lines.push(`${creditIcon} Credits: ${creditsFormatted}`); - htmlLines.push(`${creditIcon} Credits: ${creditsFormatted}`); - - // Tier if available - if (balance.tier) { - lines.push(`📊 Tier: ${balance.tier}`); - htmlLines.push(`📊 Tier: ${balance.tier}`); - } - - // Additional info - if (additionalInfo) { - for (const [key, value] of Object.entries(additionalInfo)) { - lines.push(`${key}: ${value}`); - htmlLines.push(`${key}: ${value}`); - } - } - - // Low credits warning - if (balance.balance < 10 && balance.balance > 0) { - lines.push(''); - lines.push('⚠️ Nur noch wenig Credits!'); - lines.push('👉 Credits kaufen: https://mana.how/credits'); - htmlLines.push('
'); - htmlLines.push('⚠️ Nur noch wenig Credits!'); - htmlLines.push('👉 Credits kaufen'); - } - - // No credits warning - if (!balance.hasCredits) { - lines.push(''); - lines.push('❌ Keine Credits mehr!'); - lines.push('👉 Credits kaufen: https://mana.how/credits'); - htmlLines.push('
'); - htmlLines.push('❌ Keine Credits mehr!'); - htmlLines.push('👉 Credits kaufen'); - } - - return { - text: lines.join('\n'), - html: htmlLines.join('
'), - }; - } - - /** - * Format an error message for insufficient credits - * - * @param required - Required credits - * @param available - Available credits - * @param operation - Operation name (optional) - * @returns Formatted error message - */ - formatInsufficientCreditsError( - required: number, - available: number, - operation?: string - ): CreditStatusMessage { - const lines: string[] = []; - const htmlLines: string[] = []; - - lines.push('❌ Nicht genug Credits'); - htmlLines.push('❌ Nicht genug Credits'); - - if (operation) { - lines.push(`Operation: ${operation}`); - htmlLines.push(`Operation: ${operation}`); - } - - lines.push(`Benötigt: ${required.toFixed(2)} Credits`); - lines.push(`Vorhanden: ${available.toFixed(2)} Credits`); - htmlLines.push(`Benötigt: ${required.toFixed(2)} Credits`); - htmlLines.push(`Vorhanden: ${available.toFixed(2)} Credits`); - - lines.push(''); - lines.push('👉 Credits kaufen: https://mana.how/credits'); - htmlLines.push('
'); - htmlLines.push('👉 Credits kaufen'); - - return { - text: lines.join('\n'), - html: htmlLines.join('
'), - }; - } - - /** - * Format a success message after credit consumption - * - * @param consumed - Credits consumed - * @param remaining - Remaining credits - * @param operation - Operation description (optional) - * @returns Formatted success message - */ - formatCreditConsumedMessage( - consumed: number, - remaining: number, - operation?: string - ): CreditStatusMessage { - const text = operation - ? `✅ ${operation}\n⚡ -${consumed.toFixed(2)} Credits (${remaining.toFixed(2)} verbleibend)` - : `⚡ -${consumed.toFixed(2)} Credits (${remaining.toFixed(2)} verbleibend)`; - - const html = operation - ? `✅ ${operation}
⚡ -${consumed.toFixed(2)} Credits (${remaining.toFixed(2)} verbleibend)` - : `⚡ -${consumed.toFixed(2)} Credits (${remaining.toFixed(2)} verbleibend)`; - - return { text, html }; - } - - /** - * Get error code from HTTP status - * - * @param status - HTTP status code - * @returns Credit error code - */ - getErrorCodeFromStatus(status: number): CreditErrorCode { - switch (status) { - case 401: - return CreditErrorCode.NOT_LOGGED_IN; - case 402: - return CreditErrorCode.INSUFFICIENT_CREDITS; - case 400: - return CreditErrorCode.INVALID_OPERATION; - default: - return CreditErrorCode.SERVICE_UNAVAILABLE; - } - } - - /** - * Format a generic credit error message - * - * @param errorCode - Credit error code - * @returns Formatted error message - */ - formatErrorMessage(errorCode: CreditErrorCode): CreditStatusMessage { - let text: string; - let html: string; - - switch (errorCode) { - case CreditErrorCode.INSUFFICIENT_CREDITS: - text = '❌ Nicht genug Credits\n👉 Credits kaufen: https://mana.how/credits'; - html = - '❌ Nicht genug Credits
👉 Credits kaufen'; - break; - case CreditErrorCode.NOT_LOGGED_IN: - text = '❌ Bitte zuerst einloggen: !login email passwort'; - html = '❌ Bitte zuerst einloggen: !login email passwort'; - break; - case CreditErrorCode.INVALID_OPERATION: - text = '❌ Ungültige Operation'; - html = '❌ Ungültige Operation'; - break; - case CreditErrorCode.SERVICE_UNAVAILABLE: - default: - text = '❌ Service temporär nicht verfügbar. Bitte später erneut versuchen.'; - html = '❌ Service temporär nicht verfügbar. Bitte später erneut versuchen.'; - break; - } - - return { text, html }; - } - - // ============================================================================ - // PACKAGE & PAYMENT LINK METHODS (for bot credit purchasing) - // ============================================================================ - - /** - * Get available credit packages - * - * @returns List of available credit packages - */ - async getPackages(): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/credits/packages`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to get packages: ${response.status}`); - return []; - } - - const packages = (await response.json()) as Array<{ - id: string; - name: string; - credits: number; - priceEuroCents: number; - sortOrder: number; - }>; - - return packages.map((pkg) => ({ - id: pkg.id, - name: pkg.name, - credits: pkg.credits, - priceEuroCents: pkg.priceEuroCents, - formattedPrice: this.formatPrice(pkg.priceEuroCents), - sortOrder: pkg.sortOrder, - })); - } catch (error) { - this.logger.error('Error getting packages:', error); - return []; - } - } - - /** - * Create a payment link for purchasing credits - * - * @param token - User's JWT token - * @param packageId - ID of the package to purchase - * @param roomId - Optional Matrix room ID for notification after payment - * @returns Payment link result with URL and expiration - */ - async createPaymentLink( - token: string, - packageId: string, - roomId?: string - ): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/credits/payment-link`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - packageId, - roomId, - }), - }); - - if (!response.ok) { - this.logger.warn(`Failed to create payment link: ${response.status}`); - return null; - } - - const result = (await response.json()) as { - url: string; - purchaseId: string; - expiresAt: string; - package: { - name: string; - credits: number; - priceEuroCents: number; - }; - }; - - return { - url: result.url, - purchaseId: result.purchaseId, - expiresAt: new Date(result.expiresAt), - package: result.package, - }; - } catch (error) { - this.logger.error('Error creating payment link:', error); - return null; - } - } - - /** - * Get purchase status - * - * @param token - User's JWT token - * @param purchaseId - Purchase ID to check - * @returns Purchase status or null if not found - */ - async getPurchaseStatus(token: string, purchaseId: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/credits/purchase/${purchaseId}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to get purchase status: ${response.status}`); - return null; - } - - const result = (await response.json()) as { - id: string; - status: 'pending' | 'completed' | 'failed'; - credits: number; - priceEuroCents: number; - createdAt: string; - completedAt?: string; - }; - - return { - id: result.id, - status: result.status, - credits: result.credits, - priceEuroCents: result.priceEuroCents, - createdAt: new Date(result.createdAt), - completedAt: result.completedAt ? new Date(result.completedAt) : undefined, - }; - } catch (error) { - this.logger.error('Error getting purchase status:', error); - return null; - } - } - - /** - * Format price in euro cents to human-readable format - * - * @param priceEuroCents - Price in euro cents - * @returns Formatted price (e.g., "4,99 €") - */ - private formatPrice(priceEuroCents: number): string { - const euros = priceEuroCents / 100; - return `${euros.toFixed(2).replace('.', ',')} €`; - } -} diff --git a/packages/bot-services/src/credit/index.ts b/packages/bot-services/src/credit/index.ts deleted file mode 100644 index 119cc6761..000000000 --- a/packages/bot-services/src/credit/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { CreditService } from './credit.service'; -export { CreditModule } from './credit.module'; -export type { - CreditBalance, - CreditValidationResult, - CreditConsumeResult, - CreditModuleOptions, - CreditStatusMessage, - CreditPackage, - PaymentLinkResult, - PurchaseStatus, - PurchaseStatusResult, -} from './types'; -export { CREDIT_MODULE_OPTIONS, CreditErrorCode } from './types'; diff --git a/packages/bot-services/src/credit/types.ts b/packages/bot-services/src/credit/types.ts deleted file mode 100644 index c9ece375d..000000000 --- a/packages/bot-services/src/credit/types.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Types for credit management in Matrix bots - */ - -/** - * User credit balance information - */ -export interface CreditBalance { - /** Current credit balance */ - balance: number; - /** Whether user has enough credits for basic operations */ - hasCredits: boolean; - /** User's tier (if applicable) */ - tier?: string; -} - -/** - * Result of a credit validation check - */ -export interface CreditValidationResult { - /** Whether user has enough credits */ - hasCredits: boolean; - /** Available credits */ - availableCredits: number; - /** Required credits for the operation */ - requiredCredits: number; - /** Error message if not enough credits */ - error?: string; -} - -/** - * Result of a credit consumption operation - */ -export interface CreditConsumeResult { - /** Whether credits were successfully consumed */ - success: boolean; - /** New balance after consumption */ - newBalance?: number; - /** Error message if failed */ - error?: string; -} - -/** - * Credit module configuration options - */ -export interface CreditModuleOptions { - /** Mana Core Auth URL */ - authUrl?: string; - /** Service key for credit operations */ - serviceKey?: string; - /** App ID for credit operations */ - appId?: string; -} - -export const CREDIT_MODULE_OPTIONS = 'CREDIT_MODULE_OPTIONS'; - -/** - * Credit error codes for structured error handling - */ -export enum CreditErrorCode { - INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS', - NOT_LOGGED_IN = 'NOT_LOGGED_IN', - SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', - INVALID_OPERATION = 'INVALID_OPERATION', -} - -/** - * Formatted credit message for Matrix bots - */ -export interface CreditStatusMessage { - /** Plain text message */ - text: string; - /** HTML formatted message (for Matrix) */ - html: string; -} - -/** - * Credit package available for purchase - */ -export interface CreditPackage { - /** Package ID */ - id: string; - /** Package name (e.g., "Starter", "Standard", "Premium") */ - name: string; - /** Number of credits in package */ - credits: number; - /** Price in euro cents */ - priceEuroCents: number; - /** Human-readable price (e.g., "4,99 €") */ - formattedPrice: string; - /** Display order */ - sortOrder: number; -} - -/** - * Result of creating a payment link - */ -export interface PaymentLinkResult { - /** Stripe Checkout URL */ - url: string; - /** Purchase ID for tracking */ - purchaseId: string; - /** When the link expires */ - expiresAt: Date; - /** Package details */ - package: { - name: string; - credits: number; - priceEuroCents: number; - }; -} - -/** - * Purchase status - */ -export type PurchaseStatus = 'pending' | 'completed' | 'failed'; - -/** - * Purchase status result - */ -export interface PurchaseStatusResult { - /** Purchase ID */ - id: string; - /** Current status */ - status: PurchaseStatus; - /** Credits in package */ - credits: number; - /** Price in euro cents */ - priceEuroCents: number; - /** When purchase was created */ - createdAt: Date; - /** When purchase was completed (if completed) */ - completedAt?: Date; -} diff --git a/packages/bot-services/src/docs/index.ts b/packages/bot-services/src/docs/index.ts deleted file mode 100644 index fa791eb39..000000000 --- a/packages/bot-services/src/docs/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Placeholder - to be implemented -// Will integrate with project documentation generation - -export interface DocsServiceConfig { - openaiApiKey?: string; - s3Config?: { - endpoint: string; - bucket: string; - accessKey: string; - secretKey: string; - }; -} - -export interface ProjectDoc { - id: string; - title: string; - content: string; - format: 'blog' | 'summary' | 'technical'; - createdAt: string; -} - -// Export placeholder module -export const DocsModule = { - register: () => ({ module: class {}, providers: [], exports: [] }), -}; diff --git a/packages/bot-services/src/gift/gift.module.ts b/packages/bot-services/src/gift/gift.module.ts deleted file mode 100644 index b435028d5..000000000 --- a/packages/bot-services/src/gift/gift.module.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Module, DynamicModule, Provider } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { GiftService } from './gift.service'; -import { GiftModuleOptions, GIFT_MODULE_OPTIONS } from './types'; - -@Module({}) -export class GiftModule { - /** - * Register the gift module with options - * - * @param options - Gift module options - * @returns Dynamic module - * - * @example - * ```typescript - * GiftModule.register({ authUrl: 'http://mana-core-auth:3001' }) - * ``` - */ - static register(options?: GiftModuleOptions): DynamicModule { - const optionsProvider: Provider = { - provide: GIFT_MODULE_OPTIONS, - useValue: options || {}, - }; - - return { - module: GiftModule, - imports: [ConfigModule], - providers: [optionsProvider, GiftService], - exports: [GiftService], - }; - } - - /** - * Register the gift module with default configuration - * Uses ConfigService to get auth URL - * - * @returns Dynamic module - * - * @example - * ```typescript - * GiftModule.forRoot() - * ``` - */ - static forRoot(): DynamicModule { - return { - module: GiftModule, - imports: [ConfigModule], - providers: [GiftService], - exports: [GiftService], - }; - } -} diff --git a/packages/bot-services/src/gift/gift.service.ts b/packages/bot-services/src/gift/gift.service.ts deleted file mode 100644 index bb3e6d086..000000000 --- a/packages/bot-services/src/gift/gift.service.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - CreateGiftOptions, - CreateGiftResult, - GiftCodeInfo, - RedeemGiftResult, - CreatedGiftItem, - ReceivedGiftItem, - GiftModuleOptions, - GiftStatusMessage, - GIFT_MODULE_OPTIONS, -} from './types'; - -/** - * Shared gift code management service for Matrix bots - * - * Provides gift code creation, redemption, and listing - * for gifting credits between users via Matrix chat. - * - * @example - * ```typescript - * // In NestJS module - * imports: [GiftModule.register({ authUrl: 'http://mana-core-auth:3001' })] - * - * // In service/controller - * const gift = await giftService.createGift(token, 50, { message: 'Happy birthday!' }); - * const result = await giftService.redeemGift(token, 'ABC123'); - * ``` - */ -@Injectable() -export class GiftService { - private readonly logger = new Logger(GiftService.name); - private readonly authUrl: string; - - constructor( - @Optional() private configService: ConfigService, - @Optional() @Inject(GIFT_MODULE_OPTIONS) private options?: GiftModuleOptions - ) { - // Priority: module options > config > environment > default - this.authUrl = - options?.authUrl || - this.configService?.get('auth.url') || - this.configService?.get('MANA_CORE_AUTH_URL') || - 'http://localhost:3001'; - - this.logger.log(`Gift service initialized with auth URL: ${this.authUrl}`); - } - - /** - * Create a new gift code - * - * @param token - User's JWT token - * @param credits - Total credits to gift - * @param options - Gift options (type, portions, message, etc.) - * @returns Created gift code info - */ - async createGift( - token: string, - credits: number, - options?: CreateGiftOptions - ): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/gifts`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - credits, - ...options, - sourceAppId: 'matrix-bot', - }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - this.logger.warn(`Failed to create gift: ${response.status}`, error); - return null; - } - - return (await response.json()) as CreateGiftResult; - } catch (error) { - this.logger.error('Error creating gift:', error); - return null; - } - } - - /** - * Get gift code info (public, for preview) - * - * @param code - The gift code - * @returns Gift code info or null if not found - */ - async getGiftInfo(code: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/gifts/${code}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - if (response.status === 404) { - return null; - } - this.logger.warn(`Failed to get gift info: ${response.status}`); - return null; - } - - return (await response.json()) as GiftCodeInfo; - } catch (error) { - this.logger.error('Error getting gift info:', error); - return null; - } - } - - /** - * Redeem a gift code - * - * @param token - User's JWT token - * @param code - The gift code to redeem - * @param answer - Riddle answer (if required) - * @param matrixUserId - Matrix user ID (for personalized gifts) - * @returns Redemption result - */ - async redeemGift( - token: string, - code: string, - answer?: string, - matrixUserId?: string - ): Promise { - try { - const headers: Record = { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }; - - if (matrixUserId) { - headers['X-Matrix-User-Id'] = matrixUserId; - } - - const response = await fetch(`${this.authUrl}/api/v1/gifts/${code}/redeem`, { - method: 'POST', - headers, - body: JSON.stringify({ - answer, - sourceAppId: 'matrix-bot', - }), - }); - - if (!response.ok && response.status !== 200) { - const errorData = (await response.json().catch(() => ({}))) as { message?: string }; - return { - success: false, - error: errorData.message || 'Failed to redeem gift code', - }; - } - - return (await response.json()) as RedeemGiftResult; - } catch (error) { - this.logger.error('Error redeeming gift:', error); - return { - success: false, - error: 'Service temporarily unavailable', - }; - } - } - - /** - * Cancel a gift code and get refund - * - * @param token - User's JWT token - * @param codeId - Gift code ID to cancel - * @returns Refunded credits amount - */ - async cancelGift(token: string, codeId: string): Promise<{ refundedCredits: number } | null> { - try { - const response = await fetch(`${this.authUrl}/api/v1/gifts/${codeId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to cancel gift: ${response.status}`); - return null; - } - - return (await response.json()) as { refundedCredits: number }; - } catch (error) { - this.logger.error('Error cancelling gift:', error); - return null; - } - } - - /** - * List gift codes created by the user - * - * @param token - User's JWT token - * @returns List of created gifts - */ - async listCreatedGifts(token: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/gifts/me/created`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to list created gifts: ${response.status}`); - return []; - } - - return (await response.json()) as CreatedGiftItem[]; - } catch (error) { - this.logger.error('Error listing created gifts:', error); - return []; - } - } - - /** - * List gifts received by the user - * - * @param token - User's JWT token - * @returns List of received gifts - */ - async listReceivedGifts(token: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/gifts/me/received`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.warn(`Failed to list received gifts: ${response.status}`); - return []; - } - - return (await response.json()) as ReceivedGiftItem[]; - } catch (error) { - this.logger.error('Error listing received gifts:', error); - return []; - } - } - - // ============================================================================ - // MESSAGE FORMATTING HELPERS - // ============================================================================ - - /** - * Format a gift created success message - */ - formatGiftCreatedMessage(gift: CreateGiftResult): GiftStatusMessage { - const lines: string[] = []; - const htmlLines: string[] = []; - - lines.push('🎁 **Geschenk erstellt!**'); - htmlLines.push('🎁 Geschenk erstellt!'); - - lines.push(''); - htmlLines.push('
'); - - lines.push(`Code: \`${gift.code}\``); - htmlLines.push(`Code: ${gift.code}`); - - lines.push(`Credits: ${gift.creditsPerPortion}${gift.totalPortions > 1 ? ` × ${gift.totalPortions}` : ''}`); - htmlLines.push(`Credits: ${gift.creditsPerPortion}${gift.totalPortions > 1 ? ` × ${gift.totalPortions}` : ''}`); - - lines.push(''); - htmlLines.push('
'); - - lines.push(`Link: ${gift.url}`); - htmlLines.push(`Link: ${gift.url}`); - - return { - text: lines.join('\n'), - html: htmlLines.join('
'), - }; - } - - /** - * Format a gift redeemed success message - */ - formatGiftRedeemedMessage( - credits: number, - newBalance: number, - message?: string - ): GiftStatusMessage { - const lines: string[] = []; - const htmlLines: string[] = []; - - lines.push('🎁 **Geschenk eingelöst!**'); - htmlLines.push('🎁 Geschenk eingelöst!'); - - lines.push(`+${credits} Credits`); - htmlLines.push(`+${credits} Credits`); - - if (message) { - lines.push(''); - lines.push(`"${message}"`); - htmlLines.push('
'); - htmlLines.push(`"${message}"`); - } - - lines.push(''); - lines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`); - htmlLines.push('
'); - htmlLines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`); - - return { - text: lines.join('\n'), - html: htmlLines.join('
'), - }; - } - - /** - * Format gift list message - */ - formatGiftListMessage(gifts: CreatedGiftItem[]): GiftStatusMessage { - const lines: string[] = []; - const htmlLines: string[] = []; - - lines.push('🎁 **Deine Geschenke:**'); - htmlLines.push('🎁 Deine Geschenke:'); - - lines.push(''); - htmlLines.push('
'); - - if (gifts.length === 0) { - lines.push('Keine aktiven Geschenke.'); - htmlLines.push('Keine aktiven Geschenke.'); - } else { - gifts.forEach((gift, index) => { - const statusIcon = gift.status === 'active' ? '✅' : gift.status === 'depleted' ? '✓' : '❌'; - const claimed = `${gift.claimedPortions}/${gift.totalPortions}`; - - lines.push(`${index + 1}. \`${gift.code}\` ${statusIcon} ${gift.creditsPerPortion} Cr · ${claimed}`); - htmlLines.push(`${index + 1}. ${gift.code} ${statusIcon} ${gift.creditsPerPortion} Cr · ${claimed}`); - }); - } - - return { - text: lines.join('\n'), - html: htmlLines.join('
'), - }; - } - - /** - * Format gift code info message (for preview before redeeming) - */ - formatGiftInfoMessage(info: GiftCodeInfo): GiftStatusMessage { - const lines: string[] = []; - const htmlLines: string[] = []; - - lines.push('🎁 **Geschenk-Info:**'); - htmlLines.push('🎁 Geschenk-Info:'); - - lines.push(`Credits: ${info.creditsPerPortion}`); - htmlLines.push(`Credits: ${info.creditsPerPortion}`); - - if (info.totalPortions > 1) { - lines.push(`Verfügbar: ${info.remainingPortions}/${info.totalPortions}`); - htmlLines.push(`Verfügbar: ${info.remainingPortions}/${info.totalPortions}`); - } - - if (info.message) { - lines.push(''); - lines.push(`"${info.message}"`); - htmlLines.push('
'); - htmlLines.push(`"${info.message}"`); - } - - if (info.hasRiddle) { - lines.push(''); - lines.push(`❓ ${info.riddleQuestion}`); - lines.push('Antworte mit: `!einloesen CODE antwort`'); - htmlLines.push('
'); - htmlLines.push(`❓ ${info.riddleQuestion}`); - htmlLines.push('Antworte mit: !einloesen CODE antwort'); - } - - if (info.creatorName) { - lines.push(''); - lines.push(`Von: ${info.creatorName}`); - htmlLines.push('
'); - htmlLines.push(`Von: ${info.creatorName}`); - } - - return { - text: lines.join('\n'), - html: htmlLines.join('
'), - }; - } -} diff --git a/packages/bot-services/src/gift/index.ts b/packages/bot-services/src/gift/index.ts deleted file mode 100644 index ec5e15790..000000000 --- a/packages/bot-services/src/gift/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { GiftService } from './gift.service'; -export { GiftModule } from './gift.module'; -export type { - GiftCodeType, - GiftCodeStatus, - CreateGiftOptions, - CreateGiftResult, - GiftCodeInfo, - RedeemGiftResult, - CreatedGiftItem, - ReceivedGiftItem, - GiftModuleOptions, - GiftStatusMessage, -} from './types'; -export { GIFT_MODULE_OPTIONS } from './types'; diff --git a/packages/bot-services/src/gift/types.ts b/packages/bot-services/src/gift/types.ts deleted file mode 100644 index eb93ee910..000000000 --- a/packages/bot-services/src/gift/types.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Types for gift code management in Matrix bots - */ - -/** - * Gift code types - */ -export type GiftCodeType = 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; - -/** - * Gift code status - */ -export type GiftCodeStatus = 'active' | 'depleted' | 'expired' | 'cancelled' | 'refunded'; - -/** - * Options for creating a gift code - */ -export interface CreateGiftOptions { - /** Gift type (default: 'simple') */ - type?: GiftCodeType; - /** Number of portions for split/first_come (default: 1) */ - portions?: number; - /** Target email for personalized gifts */ - targetEmail?: string; - /** Target Matrix ID for personalized gifts */ - targetMatrixId?: string; - /** Riddle question */ - riddleQuestion?: string; - /** Riddle answer */ - riddleAnswer?: string; - /** Optional message */ - message?: string; - /** Expiration date (ISO string) */ - expiresAt?: string; -} - -/** - * Result of creating a gift code - */ -export interface CreateGiftResult { - /** Gift code ID */ - id: string; - /** The gift code (6 chars) */ - code: string; - /** Short URL (mana.how/g/CODE) */ - url: string; - /** Total credits reserved */ - totalCredits: number; - /** Credits per portion */ - creditsPerPortion: number; - /** Total number of portions */ - totalPortions: number; - /** Gift type */ - type: GiftCodeType; - /** Expiration date */ - expiresAt?: string; -} - -/** - * Gift code info (public, for preview) - */ -export interface GiftCodeInfo { - /** The gift code */ - code: string; - /** Gift type */ - type: GiftCodeType; - /** Current status */ - status: GiftCodeStatus; - /** Credits per portion */ - creditsPerPortion: number; - /** Total portions */ - totalPortions: number; - /** Claimed portions */ - claimedPortions: number; - /** Remaining portions */ - remainingPortions: number; - /** Optional message */ - message?: string; - /** Riddle question (if riddle type) */ - riddleQuestion?: string; - /** Whether gift has a riddle */ - hasRiddle: boolean; - /** Whether gift is for a specific person */ - isPersonalized: boolean; - /** Expiration date */ - expiresAt?: string; - /** Creator name */ - creatorName?: string; -} - -/** - * Result of redeeming a gift code - */ -export interface RedeemGiftResult { - /** Whether redemption was successful */ - success: boolean; - /** Credits received (if successful) */ - credits?: number; - /** Gift message (if any) */ - message?: string; - /** Error message (if failed) */ - error?: string; - /** New credit balance */ - newBalance?: number; -} - -/** - * Gift code in user's created list - */ -export interface CreatedGiftItem { - /** Gift code ID */ - id: string; - /** The gift code */ - code: string; - /** Short URL */ - url: string; - /** Gift type */ - type: GiftCodeType; - /** Current status */ - status: GiftCodeStatus; - /** Total credits reserved */ - totalCredits: number; - /** Credits per portion */ - creditsPerPortion: number; - /** Total portions */ - totalPortions: number; - /** Claimed portions */ - claimedPortions: number; - /** Optional message */ - message?: string; - /** Expiration date */ - expiresAt?: string; - /** Creation date */ - createdAt: string; -} - -/** - * Gift in user's received list - */ -export interface ReceivedGiftItem { - /** Redemption ID */ - id: string; - /** The gift code */ - code: string; - /** Credits received */ - credits: number; - /** Gift message */ - message?: string; - /** Creator name */ - creatorName?: string; - /** When redeemed */ - redeemedAt: string; -} - -/** - * Gift module configuration options - */ -export interface GiftModuleOptions { - /** Mana Core Auth URL */ - authUrl?: string; -} - -export const GIFT_MODULE_OPTIONS = 'GIFT_MODULE_OPTIONS'; - -/** - * Formatted gift message for Matrix bots - */ -export interface GiftStatusMessage { - /** Plain text message */ - text: string; - /** HTML formatted message (for Matrix) */ - html: string; -} diff --git a/packages/bot-services/src/i18n/i18n.module.ts b/packages/bot-services/src/i18n/i18n.module.ts deleted file mode 100644 index b428a8103..000000000 --- a/packages/bot-services/src/i18n/i18n.module.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { I18nService, I18N_OPTIONS } from './i18n.service'; -import { I18nOptions } from './types'; - -/** - * I18n Module for Matrix Bots - * - * Provides multi-language support with per-user language preferences. - * - * NOTE: SessionService is optional. If you want per-user language preferences, - * import SessionModule in your app before I18nModule. Otherwise, the default - * language will be used for all users. - * - * @example - * ```typescript - * // Basic usage (uses default language for all users) - * @Module({ - * imports: [I18nModule.forRoot()], - * }) - * - * // With per-user preferences (requires SessionModule) - * @Module({ - * imports: [ - * SessionModule.forRoot({ storageMode: 'redis' }), - * I18nModule.forRoot({ defaultLanguage: 'en' }), - * ], - * }) - * ``` - */ -@Module({}) -export class I18nModule { - /** - * Register the I18n module - */ - static forRoot(options?: I18nOptions): DynamicModule { - return { - module: I18nModule, - global: true, - imports: [ConfigModule], - providers: [ - { - provide: I18N_OPTIONS, - useValue: options || {}, - }, - I18nService, - ], - exports: [I18nService], - }; - } - - /** - * Register the I18n module with async configuration - */ - static forRootAsync(options: { - imports?: any[]; - useFactory: (...args: any[]) => I18nOptions | Promise; - inject?: any[]; - }): DynamicModule { - return { - module: I18nModule, - global: true, - imports: [...(options.imports || []), ConfigModule], - providers: [ - { - provide: I18N_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }, - I18nService, - ], - exports: [I18nService], - }; - } -} diff --git a/packages/bot-services/src/i18n/i18n.service.ts b/packages/bot-services/src/i18n/i18n.service.ts deleted file mode 100644 index a11ba43df..000000000 --- a/packages/bot-services/src/i18n/i18n.service.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Injectable, Inject, Optional, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - Language, - BotTranslations, - TodoTranslations, - CalendarTranslations, - ContactsTranslations, - ClockTranslations, - GiftTranslations, - I18nOptions, -} from './types'; -import { de } from './locales/de'; -import { en } from './locales/en'; -import { SessionService } from '../session/session.service'; - -/** - * Injection token for I18n options - */ -export const I18N_OPTIONS = 'I18N_OPTIONS'; - -/** - * Session data key for language preference - */ -const LANGUAGE_KEY = 'language'; - -/** - * All available translations - */ -const translations: Record = { de, en }; - -/** - * Language display names - */ -export const LANGUAGE_NAMES: Record = { - de: 'Deutsch', - en: 'English', -}; - -/** - * I18n Service for Matrix Bots - * - * Provides multi-language support with: - * - Per-user language preference (stored in session) - * - Default language from environment variable - * - Placeholder substitution in translations - * - * @example - * ```typescript - * // Get translator for a user - * const t = await i18n.getTranslator(userId, 'todo'); - * - * // Use translations - * const msg = t('taskCreated', { title: 'Buy milk' }); - * // → "Aufgabe erstellt: **Buy milk**" (if user language is German) - * ``` - */ -@Injectable() -export class I18nService { - private readonly logger = new Logger(I18nService.name); - private readonly defaultLanguage: Language; - - constructor( - @Optional() private sessionService?: SessionService, - @Optional() private configService?: ConfigService, - @Optional() @Inject(I18N_OPTIONS) private options?: I18nOptions - ) { - // Priority: options > env > config > 'de' - this.defaultLanguage = - options?.defaultLanguage || - (process.env.BOT_DEFAULT_LANGUAGE as Language) || - this.configService?.get('bot.defaultLanguage') || - 'de'; - - this.logger.log(`Default language: ${this.defaultLanguage}`); - } - - /** - * Get the language for a user - */ - async getLanguage(userId: string): Promise { - if (this.sessionService) { - const lang = await this.sessionService.getSessionData(userId, LANGUAGE_KEY); - if (lang && this.isValidLanguage(lang)) { - return lang; - } - } - return this.defaultLanguage; - } - - /** - * Set the language for a user - */ - async setLanguage(userId: string, language: Language): Promise { - if (!this.isValidLanguage(language)) { - throw new Error( - `Invalid language: ${language}. Available: ${this.getAvailableLanguages().join(', ')}` - ); - } - if (this.sessionService) { - await this.sessionService.setSessionData(userId, LANGUAGE_KEY, language); - this.logger.log(`Language set for ${userId}: ${language}`); - } - } - - /** - * Check if a language code is valid - */ - isValidLanguage(lang: string): lang is Language { - return lang === 'de' || lang === 'en'; - } - - /** - * Get list of available languages - */ - getAvailableLanguages(): Language[] { - return ['de', 'en']; - } - - /** - * Get language display name - */ - getLanguageName(lang: Language): string { - return LANGUAGE_NAMES[lang]; - } - - /** - * Get all translations for a language - */ - getTranslations(language: Language): BotTranslations { - return translations[language] || translations[this.defaultLanguage]; - } - - /** - * Get a translator function for todo bot - */ - async getTodoTranslator( - userId: string - ): Promise<(key: keyof TodoTranslations, params?: Record) => string> { - const lang = await this.getLanguage(userId); - const t = translations[lang].todo; - return (key, params) => this.interpolate(t[key], params); - } - - /** - * Get a translator function for calendar bot - */ - async getCalendarTranslator( - userId: string - ): Promise< - (key: keyof CalendarTranslations, params?: Record) => string - > { - const lang = await this.getLanguage(userId); - const t = translations[lang].calendar; - return (key, params) => this.interpolate(t[key], params); - } - - /** - * Get a translator function for contacts bot - */ - async getContactsTranslator( - userId: string - ): Promise< - (key: keyof ContactsTranslations, params?: Record) => string - > { - const lang = await this.getLanguage(userId); - const t = translations[lang].contacts; - return (key, params) => this.interpolate(t[key], params); - } - - /** - * Get a translator function for clock bot - */ - async getClockTranslator( - userId: string - ): Promise<(key: keyof ClockTranslations, params?: Record) => string> { - const lang = await this.getLanguage(userId); - const t = translations[lang].clock; - return (key, params) => this.interpolate(t[key], params); - } - - /** - * Get a translator function for gift commands - */ - async getGiftTranslator( - userId: string - ): Promise<(key: keyof GiftTranslations, params?: Record) => string> { - const lang = await this.getLanguage(userId); - const t = translations[lang].gift; - return (key, params) => this.interpolate(t[key], params); - } - - /** - * Get translations directly for a bot type - */ - async getTodoTranslations(userId: string): Promise { - const lang = await this.getLanguage(userId); - return translations[lang].todo; - } - - async getCalendarTranslations(userId: string): Promise { - const lang = await this.getLanguage(userId); - return translations[lang].calendar; - } - - async getContactsTranslations(userId: string): Promise { - const lang = await this.getLanguage(userId); - return translations[lang].contacts; - } - - async getClockTranslations(userId: string): Promise { - const lang = await this.getLanguage(userId); - return translations[lang].clock; - } - - async getGiftTranslations(userId: string): Promise { - const lang = await this.getLanguage(userId); - return translations[lang].gift; - } - - /** - * Interpolate placeholders in a string - * - * @example - * interpolate('Hello {name}!', { name: 'World' }) - * // → 'Hello World!' - */ - interpolate(template: string, params?: Record): string { - if (!params) return template; - return template.replace(/\{(\w+)\}/g, (_, key) => { - return params[key]?.toString() ?? `{${key}}`; - }); - } - - /** - * Format a date according to user's language - */ - async formatDate(userId: string, date: Date | string): Promise { - const lang = await this.getLanguage(userId); - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); - } - - /** - * Format a time according to user's language - */ - async formatTime(userId: string, date: Date | string): Promise { - const lang = await this.getLanguage(userId); - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleTimeString(lang === 'de' ? 'de-DE' : 'en-US', { - hour: '2-digit', - minute: '2-digit', - }); - } -} diff --git a/packages/bot-services/src/i18n/index.ts b/packages/bot-services/src/i18n/index.ts deleted file mode 100644 index d437077a9..000000000 --- a/packages/bot-services/src/i18n/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './types'; -export * from './i18n.service'; -export * from './i18n.module'; -export { de } from './locales/de'; -export { en } from './locales/en'; diff --git a/packages/bot-services/src/i18n/locales/de.ts b/packages/bot-services/src/i18n/locales/de.ts deleted file mode 100644 index 7cb2a2f01..000000000 --- a/packages/bot-services/src/i18n/locales/de.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { type BotTranslations } from '../types'; - -export const de: BotTranslations = { - common: { - // General - error: 'Fehler', - errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', - notLoggedIn: 'Du bist nicht angemeldet.', - loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', - loginSuccess: 'Erfolgreich angemeldet als **{email}**', - loginFailed: 'Anmeldung fehlgeschlagen: {error}', - logoutSuccess: 'Erfolgreich abgemeldet.', - invalidCommand: 'Unbekannter Befehl: {command}', - helpHint: 'Sag "hilfe" für alle Befehle.', - - // Credits - credits: 'Credits', - creditsRemaining: '{amount} verbleibend', - insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', - buyCredits: 'Credits kaufen: https://mana.how/credits', - - // Credit purchasing - creditBalance: 'Dein Guthaben: **{balance}** Credits', - creditPackagesTitle: '**Credit-Pakete:**', - creditPackageLine: '{num}. {name} · {credits} Credits · {price}', - creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', - creditPaymentLink: 'Klicke hier um zu bezahlen:', - creditLinkValid: 'Link gültig für 24 Stunden.', - creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', - creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', - creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', - creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', - creditNoPackages: 'Keine Credit-Pakete verfügbar.', - - // Sync - synced: 'Synchronisiert', - localStorage: 'Lokaler Speicher', - - // Status - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Angemeldet als: {email}', - notLoggedInStatus: 'Nicht angemeldet', - - // Language - languageChanged: 'Sprache geändert zu: **{language}**', - currentLanguage: 'Aktuelle Sprache: **{language}**', - availableLanguages: 'Verfügbare Sprachen: {languages}', - - // Dates - today: 'Heute', - tomorrow: 'Morgen', - dayAfterTomorrow: 'Übermorgen', - - // Actions - created: 'Erstellt', - deleted: 'Gelöscht', - updated: 'Aktualisiert', - completed: 'Erledigt', - }, - - todo: { - // Inherit common - error: 'Fehler', - errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', - notLoggedIn: 'Du bist nicht angemeldet.', - loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', - loginSuccess: 'Erfolgreich angemeldet als **{email}**', - loginFailed: 'Anmeldung fehlgeschlagen: {error}', - logoutSuccess: 'Erfolgreich abgemeldet.', - invalidCommand: 'Unbekannter Befehl: {command}', - helpHint: 'Sag "hilfe" für alle Befehle.', - credits: 'Credits', - creditsRemaining: '{amount} verbleibend', - insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', - buyCredits: 'Credits kaufen: https://mana.how/credits', - creditBalance: 'Dein Guthaben: **{balance}** Credits', - creditPackagesTitle: '**Credit-Pakete:**', - creditPackageLine: '{num}. {name} · {credits} Credits · {price}', - creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', - creditPaymentLink: 'Klicke hier um zu bezahlen:', - creditLinkValid: 'Link gültig für 24 Stunden.', - creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', - creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', - creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', - creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', - creditNoPackages: 'Keine Credit-Pakete verfügbar.', - synced: 'Synchronisiert', - localStorage: 'Lokaler Speicher', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Angemeldet als: {email}', - notLoggedInStatus: 'Nicht angemeldet', - languageChanged: 'Sprache geändert zu: **{language}**', - currentLanguage: 'Aktuelle Sprache: **{language}**', - availableLanguages: 'Verfügbare Sprachen: {languages}', - today: 'Heute', - tomorrow: 'Morgen', - dayAfterTomorrow: 'Übermorgen', - created: 'Erstellt', - deleted: 'Gelöscht', - updated: 'Aktualisiert', - completed: 'Erledigt', - - // Tasks - task: 'Aufgabe', - tasks: 'Aufgaben', - taskCreated: 'Aufgabe erstellt: **{title}**', - taskCompleted: 'Erledigt: ~~{title}~~', - taskDeleted: 'Gelöscht: {title}', - noTasks: 'Keine offenen Aufgaben.', - noTasksToday: 'Keine Aufgaben für heute.', - inboxEmpty: 'Inbox ist leer.', - allTasks: 'Alle offenen Aufgaben', - todayTasks: 'Aufgaben für heute', - inbox: 'Inbox (ohne Datum)', - - // Projects - project: 'Projekt', - projects: 'Projekte', - noProjects: 'Keine Projekte.', - projectTasks: 'Projekt: {name}', - - // Priorities - priority: 'Priorität', - date: 'Datum', - - // Help - helpTitle: 'Todo Bot - Hilfe', - helpCommands: `**Befehle:** -• \`!add [Aufgabe]\` - Neue Aufgabe erstellen -• \`!list\` - Alle offenen Aufgaben -• \`!today\` - Heutige Aufgaben -• \`!inbox\` - Aufgaben ohne Datum -• \`!done [Nr]\` - Aufgabe als erledigt markieren -• \`!delete [Nr]\` - Aufgabe löschen -• \`!projects\` - Alle Projekte -• \`!project [Name]\` - Projektaufgaben anzeigen -• \`!status\` - Bot-Status -• \`!language [de/en]\` - Sprache ändern`, - helpSyntax: `**Syntax:** -\`!add Aufgabe !p1 @morgen #projekt\` -• \`!p1-4\` - Priorität (1=höchste) -• \`@heute/@morgen/@übermorgen\` - Datum -• \`#projektname\` - Projekt`, - helpExamples: `**Beispiele:** -• \`Einkaufen gehen\` -• \`Meeting vorbereiten !p1 @morgen\` -• \`Bericht schreiben #arbeit\``, - - // Actions - markDone: 'Erledigen: `!done [Nr]`', - delete: 'Löschen: `!delete [Nr]`', - }, - - calendar: { - // Inherit common - error: 'Fehler', - errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', - notLoggedIn: 'Du bist nicht angemeldet.', - loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', - loginSuccess: 'Erfolgreich angemeldet als **{email}**', - loginFailed: 'Anmeldung fehlgeschlagen: {error}', - logoutSuccess: 'Erfolgreich abgemeldet.', - invalidCommand: 'Unbekannter Befehl: {command}', - helpHint: 'Sag "hilfe" für alle Befehle.', - credits: 'Credits', - creditsRemaining: '{amount} verbleibend', - insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', - buyCredits: 'Credits kaufen: https://mana.how/credits', - creditBalance: 'Dein Guthaben: **{balance}** Credits', - creditPackagesTitle: '**Credit-Pakete:**', - creditPackageLine: '{num}. {name} · {credits} Credits · {price}', - creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', - creditPaymentLink: 'Klicke hier um zu bezahlen:', - creditLinkValid: 'Link gültig für 24 Stunden.', - creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', - creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', - creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', - creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', - creditNoPackages: 'Keine Credit-Pakete verfügbar.', - synced: 'Synchronisiert', - localStorage: 'Lokaler Speicher', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Angemeldet als: {email}', - notLoggedInStatus: 'Nicht angemeldet', - languageChanged: 'Sprache geändert zu: **{language}**', - currentLanguage: 'Aktuelle Sprache: **{language}**', - availableLanguages: 'Verfügbare Sprachen: {languages}', - today: 'Heute', - tomorrow: 'Morgen', - dayAfterTomorrow: 'Übermorgen', - created: 'Erstellt', - deleted: 'Gelöscht', - updated: 'Aktualisiert', - completed: 'Erledigt', - - // Events - event: 'Termin', - events: 'Termine', - eventCreated: 'Termin erstellt: **{title}**', - eventDeleted: 'Gelöscht: {title}', - noEvents: 'Keine anstehenden Termine.', - noEventsToday: 'Keine Termine für heute.', - noEventsTomorrow: 'Keine Termine für morgen.', - noEventsThisWeek: 'Keine Termine diese Woche.', - upcomingEvents: 'Anstehende Termine', - todayEvents: 'Termine heute', - tomorrowEvents: 'Termine morgen', - weekEvents: 'Termine diese Woche', - - // Calendars - calendar: 'Kalender', - calendars: 'Kalender', - yourCalendars: 'Deine Kalender', - - // Time - time: 'Zeit', - allDay: 'ganztägig', - location: 'Ort', - - // Help - helpTitle: 'Kalender Bot - Hilfe', - helpCommands: `**Befehle:** -• \`!add [Termin]\` - Neuen Termin erstellen -• \`!today\` - Heutige Termine -• \`!tomorrow\` - Morgige Termine -• \`!week\` - Termine diese Woche -• \`!events\` - Nächste 14 Tage -• \`!details [Nr]\` - Termindetails -• \`!delete [Nr]\` - Termin löschen -• \`!calendars\` - Alle Kalender -• \`!status\` - Bot-Status -• \`!language [de/en]\` - Sprache ändern`, - helpSyntax: `**Syntax:** -\`Meeting morgen um 14:00\` -\`Zahnarzt am 15.02. um 10:30\` -\`Urlaub am 01.03. ganztägig\``, - helpExamples: `**Beispiele:** -• \`Team Meeting morgen um 10:00\` -• \`Arzt am 20.02. um 15:30\` -• \`Geburtstag am 15.03. ganztägig\``, - - // Parsing errors - couldNotParseDateTime: 'Konnte Datum/Uhrzeit nicht erkennen.', - pleaseProvideTitle: 'Bitte gib einen Titel für den Termin an.', - }, - - contacts: { - // Inherit common - error: 'Fehler', - errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', - notLoggedIn: 'Du bist nicht angemeldet.', - loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', - loginSuccess: 'Erfolgreich angemeldet als **{email}**', - loginFailed: 'Anmeldung fehlgeschlagen: {error}', - logoutSuccess: 'Erfolgreich abgemeldet.', - invalidCommand: 'Unbekannter Befehl: {command}', - helpHint: 'Sag "hilfe" für alle Befehle.', - credits: 'Credits', - creditsRemaining: '{amount} verbleibend', - insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', - buyCredits: 'Credits kaufen: https://mana.how/credits', - creditBalance: 'Dein Guthaben: **{balance}** Credits', - creditPackagesTitle: '**Credit-Pakete:**', - creditPackageLine: '{num}. {name} · {credits} Credits · {price}', - creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', - creditPaymentLink: 'Klicke hier um zu bezahlen:', - creditLinkValid: 'Link gültig für 24 Stunden.', - creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', - creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', - creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', - creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', - creditNoPackages: 'Keine Credit-Pakete verfügbar.', - synced: 'Synchronisiert', - localStorage: 'Lokaler Speicher', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Angemeldet als: {email}', - notLoggedInStatus: 'Nicht angemeldet', - languageChanged: 'Sprache geändert zu: **{language}**', - currentLanguage: 'Aktuelle Sprache: **{language}**', - availableLanguages: 'Verfügbare Sprachen: {languages}', - today: 'Heute', - tomorrow: 'Morgen', - dayAfterTomorrow: 'Übermorgen', - created: 'Erstellt', - deleted: 'Gelöscht', - updated: 'Aktualisiert', - completed: 'Erledigt', - - // Contacts - contact: 'Kontakt', - contacts: 'Kontakte', - contactCreated: 'Kontakt **{name}** erstellt!', - contactDeleted: 'Kontakt **{name}** gelöscht.', - contactUpdated: 'Kontakt **{name}** aktualisiert!', - noContacts: 'Du hast noch keine Kontakte.', - - // Favorites - favorite: 'Favorit', - favorites: 'Favoriten', - noFavorites: 'Du hast noch keine Favoriten.', - markedAsFavorite: '**{name}** als Favorit markiert ★', - removedFromFavorites: '**{name}** aus Favoriten entfernt', - - // Search - search: 'Suche', - searchResults: 'Suchergebnisse für "{query}"', - noSearchResults: 'Keine Kontakte gefunden für: "{query}"', - - // Fields - email: 'E-Mail', - phone: 'Telefon', - mobile: 'Mobil', - company: 'Firma', - jobTitle: 'Beruf', - address: 'Adresse', - website: 'Website', - birthday: 'Geburtstag', - notes: 'Notizen', - - // Help - helpTitle: 'Contacts Bot - Hilfe', - helpCommands: `**Befehle:** -• \`!contacts\` - Alle Kontakte -• \`!search [text]\` - Kontakte suchen -• \`!favorites\` - Favoriten anzeigen -• \`!contact [Nr]\` - Kontaktdetails -• \`!add Vorname Nachname\` - Neuer Kontakt -• \`!edit [Nr] [feld] [wert]\` - Bearbeiten -• \`!delete [Nr]\` - Kontakt löschen -• \`!fav [Nr]\` - Favorit umschalten -• \`!status\` - Bot-Status -• \`!language [de/en]\` - Sprache ändern`, - helpFields: `**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`, - helpExamples: `**Beispiele:** -• \`Max Mustermann\` -• \`!edit 1 email max@example.com\` -• \`!edit 1 phone +49 123 456789\``, - }, - - clock: { - // Inherit common - error: 'Fehler', - errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', - notLoggedIn: 'Du bist nicht angemeldet.', - loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', - loginSuccess: 'Erfolgreich angemeldet als **{email}**', - loginFailed: 'Anmeldung fehlgeschlagen: {error}', - logoutSuccess: 'Erfolgreich abgemeldet.', - invalidCommand: 'Unbekannter Befehl: {command}', - helpHint: 'Sag "hilfe" für alle Befehle.', - credits: 'Credits', - creditsRemaining: '{amount} verbleibend', - insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', - buyCredits: 'Credits kaufen: https://mana.how/credits', - creditBalance: 'Dein Guthaben: **{balance}** Credits', - creditPackagesTitle: '**Credit-Pakete:**', - creditPackageLine: '{num}. {name} · {credits} Credits · {price}', - creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', - creditPaymentLink: 'Klicke hier um zu bezahlen:', - creditLinkValid: 'Link gültig für 24 Stunden.', - creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', - creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', - creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', - creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', - creditNoPackages: 'Keine Credit-Pakete verfügbar.', - synced: 'Synchronisiert', - localStorage: 'Lokaler Speicher', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Angemeldet als: {email}', - notLoggedInStatus: 'Nicht angemeldet', - languageChanged: 'Sprache geändert zu: **{language}**', - currentLanguage: 'Aktuelle Sprache: **{language}**', - availableLanguages: 'Verfügbare Sprachen: {languages}', - today: 'Heute', - tomorrow: 'Morgen', - dayAfterTomorrow: 'Übermorgen', - created: 'Erstellt', - deleted: 'Gelöscht', - updated: 'Aktualisiert', - completed: 'Erledigt', - - // Timer - timer: 'Timer', - timerStarted: 'Timer gestartet!', - timerPaused: 'Timer pausiert', - timerResumed: 'Timer fortgesetzt', - timerReset: 'Timer zurückgesetzt.', - timerFinished: 'Timer beendet!', - noActiveTimer: 'Kein aktiver Timer.', - noPausedTimer: 'Kein pausierter Timer.', - noTimers: 'Keine Timer.', - remaining: 'Verbleibend', - duration: 'Dauer', - label: 'Label', - - // Alarm - alarm: 'Alarm', - alarmSet: 'Alarm gestellt!', - alarmDeleted: 'Alarm gelöscht.', - noAlarms: 'Keine Alarme.', - yourAlarms: 'Deine Alarme', - - // World Clock - worldClock: 'Weltuhr', - worldClocks: 'Weltuhren', - worldClockAdded: 'Weltuhr hinzugefügt: {city}', - noWorldClocks: 'Keine Weltuhren.', - yourWorldClocks: 'Deine Weltuhren', - - // Time - currentTime: 'Aktuelle Zeit', - - // Help - helpTitle: 'Clock Bot - Hilfe', - helpCommands: `**Befehle:** -• \`!timer 25m\` - Timer starten -• \`!stop\` - Timer pausieren -• \`!resume\` - Timer fortsetzen -• \`!reset\` - Timer zurücksetzen -• \`!timers\` - Alle Timer -• \`!alarm 07:30\` - Alarm stellen -• \`!alarms\` - Alle Alarme -• \`!time\` - Aktuelle Zeit -• \`!worldclock Berlin\` - Weltuhr hinzufügen -• \`!worldclocks\` - Alle Weltuhren -• \`!status\` - Bot-Status -• \`!language [de/en]\` - Sprache ändern`, - helpExamples: `**Beispiele:** -• \`25\` (25 Minuten Timer) -• \`1h30m\` (1,5 Stunden Timer) -• \`!alarm 7 Uhr 30\``, - - // Parsing errors - couldNotParseDuration: 'Konnte Zeit nicht verstehen.', - couldNotParseTime: 'Konnte Uhrzeit nicht verstehen.', - }, - - gift: { - // Inherit common - error: 'Fehler', - errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', - notLoggedIn: 'Du bist nicht angemeldet.', - loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', - loginSuccess: 'Erfolgreich angemeldet als **{email}**', - loginFailed: 'Anmeldung fehlgeschlagen: {error}', - logoutSuccess: 'Erfolgreich abgemeldet.', - invalidCommand: 'Unbekannter Befehl: {command}', - helpHint: 'Sag "hilfe" für alle Befehle.', - credits: 'Credits', - creditsRemaining: '{amount} verbleibend', - insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', - buyCredits: 'Credits kaufen: https://mana.how/credits', - creditBalance: 'Dein Guthaben: **{balance}** Credits', - creditPackagesTitle: '**Credit-Pakete:**', - creditPackageLine: '{num}. {name} · {credits} Credits · {price}', - creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', - creditPaymentLink: 'Klicke hier um zu bezahlen:', - creditLinkValid: 'Link gültig für 24 Stunden.', - creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', - creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', - creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', - creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', - creditNoPackages: 'Keine Credit-Pakete verfügbar.', - synced: 'Synchronisiert', - localStorage: 'Lokaler Speicher', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Angemeldet als: {email}', - notLoggedInStatus: 'Nicht angemeldet', - languageChanged: 'Sprache geändert zu: **{language}**', - currentLanguage: 'Aktuelle Sprache: **{language}**', - availableLanguages: 'Verfügbare Sprachen: {languages}', - today: 'Heute', - tomorrow: 'Morgen', - dayAfterTomorrow: 'Übermorgen', - created: 'Erstellt', - deleted: 'Gelöscht', - updated: 'Aktualisiert', - completed: 'Erledigt', - - // Gift creation - giftCreated: '🎁 **Geschenk erstellt!**', - giftCreatedCode: 'Code: `{code}`', - giftCreatedCredits: 'Credits: {credits}', - giftCreatedLink: 'Link: {url}', - giftCreatedSplit: 'Credits: {credits} × {portions}', - giftInvalidCredits: 'Bitte gib eine gültige Credit-Anzahl an (1-10000).', - giftInvalidSyntax: 'Ungültige Syntax. Beispiel: `!geschenk 50` oder `!geschenk 100 /5`', - giftInsufficientCredits: 'Nicht genug Credits. Verfügbar: {available}', - - // Gift redemption - giftRedeemed: '🎁 **Geschenk eingelöst!**', - giftRedeemedCredits: '+{credits} Credits', - giftRedeemedMessage: '"{message}"', - giftInvalidCode: 'Geschenkcode nicht gefunden.', - giftExpired: 'Dieser Geschenkcode ist abgelaufen.', - giftDepleted: 'Dieser Geschenkcode wurde bereits vollständig eingelöst.', - giftAlreadyClaimed: 'Du hast dieses Geschenk bereits eingelöst.', - giftWrongUser: 'Dieser Geschenkcode ist für eine bestimmte Person.', - giftWrongAnswer: 'Falsche Antwort. Versuche es erneut.', - giftRiddleRequired: 'Bitte gib die Antwort auf das Rätsel an.', - giftRiddleQuestion: '❓ {question}', - - // Gift list - giftListTitle: '🎁 **Deine Geschenke:**', - giftListEmpty: 'Keine aktiven Geschenke.', - giftListItem: '{num}. `{code}` {status} {credits} Cr · {claimed}/{total}', - giftReceivedListTitle: '🎁 **Erhaltene Geschenke:**', - giftReceivedListEmpty: 'Keine erhaltenen Geschenke.', - - // Gift info - giftInfoTitle: '🎁 **Geschenk-Info:**', - giftInfoCredits: 'Credits: {credits}', - giftInfoAvailable: 'Verfügbar: {remaining}/{total}', - giftInfoFrom: 'Von: {name}', - - // Gift help - giftHelpTitle: 'Geschenke - Hilfe', - giftHelpCommands: `**Befehle:** -• \`!geschenk [credits]\` - Geschenkcode erstellen -• \`!geschenk [credits] /[anzahl]\` - Split-Geschenk -• \`!geschenk [credits] @email\` - Personalisiert -• \`!geschenk [credits] ?="antwort"\` - Mit Rätsel -• \`!einloesen [code]\` - Code einlösen -• \`!meine-geschenke\` - Deine Geschenke anzeigen`, - giftHelpSyntax: `**Syntax:** -\`!geschenk 50\` - Einfaches Geschenk -\`!geschenk 100 /5\` - 5 Portionen à 20 Cr -\`!geschenk 50 "Alles Gute!"\` - Mit Nachricht`, - giftHelpExamples: `**Beispiele:** -• \`!geschenk 50\` -• \`!geschenk 100 /5 Teilt euch!\` -• \`!einloesen ABC123\``, - - // Gift cancellation - giftCancelled: 'Geschenk storniert.', - giftRefunded: '{credits} Credits zurückerstattet.', - }, -}; diff --git a/packages/bot-services/src/i18n/locales/en.ts b/packages/bot-services/src/i18n/locales/en.ts deleted file mode 100644 index 43e45b561..000000000 --- a/packages/bot-services/src/i18n/locales/en.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { type BotTranslations } from '../types'; - -export const en: BotTranslations = { - common: { - // General - error: 'Error', - errorOccurred: 'An error occurred. Please try again.', - notLoggedIn: 'You are not logged in.', - loginRequired: 'Please log in first with `!login email password`', - loginSuccess: 'Successfully logged in as **{email}**', - loginFailed: 'Login failed: {error}', - logoutSuccess: 'Successfully logged out.', - invalidCommand: 'Unknown command: {command}', - helpHint: 'Say "help" for all commands.', - - // Credits - credits: 'Credits', - creditsRemaining: '{amount} remaining', - insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', - buyCredits: 'Buy credits: https://mana.how/credits', - - // Credit purchasing - creditBalance: 'Your balance: **{balance}** credits', - creditPackagesTitle: '**Credit packages:**', - creditPackageLine: '{num}. {name} · {credits} credits · {price}', - creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', - creditPaymentLink: 'Click here to pay:', - creditLinkValid: 'Link valid for 24 hours.', - creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', - creditNewBalance: 'New balance: **{balance}** credits', - creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', - creditPurchaseError: 'Error creating payment link. Please try again later.', - creditNoPackages: 'No credit packages available.', - - // Sync - synced: 'Synced', - localStorage: 'Local storage', - - // Status - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Logged in as: {email}', - notLoggedInStatus: 'Not logged in', - - // Language - languageChanged: 'Language changed to: **{language}**', - currentLanguage: 'Current language: **{language}**', - availableLanguages: 'Available languages: {languages}', - - // Dates - today: 'Today', - tomorrow: 'Tomorrow', - dayAfterTomorrow: 'Day after tomorrow', - - // Actions - created: 'Created', - deleted: 'Deleted', - updated: 'Updated', - completed: 'Completed', - }, - - todo: { - // Inherit common - error: 'Error', - errorOccurred: 'An error occurred. Please try again.', - notLoggedIn: 'You are not logged in.', - loginRequired: 'Please log in first with `!login email password`', - loginSuccess: 'Successfully logged in as **{email}**', - loginFailed: 'Login failed: {error}', - logoutSuccess: 'Successfully logged out.', - invalidCommand: 'Unknown command: {command}', - helpHint: 'Say "help" for all commands.', - credits: 'Credits', - creditsRemaining: '{amount} remaining', - insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', - buyCredits: 'Buy credits: https://mana.how/credits', - creditBalance: 'Your balance: **{balance}** credits', - creditPackagesTitle: '**Credit packages:**', - creditPackageLine: '{num}. {name} · {credits} credits · {price}', - creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', - creditPaymentLink: 'Click here to pay:', - creditLinkValid: 'Link valid for 24 hours.', - creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', - creditNewBalance: 'New balance: **{balance}** credits', - creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', - creditPurchaseError: 'Error creating payment link. Please try again later.', - creditNoPackages: 'No credit packages available.', - synced: 'Synced', - localStorage: 'Local storage', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Logged in as: {email}', - notLoggedInStatus: 'Not logged in', - languageChanged: 'Language changed to: **{language}**', - currentLanguage: 'Current language: **{language}**', - availableLanguages: 'Available languages: {languages}', - today: 'Today', - tomorrow: 'Tomorrow', - dayAfterTomorrow: 'Day after tomorrow', - created: 'Created', - deleted: 'Deleted', - updated: 'Updated', - completed: 'Completed', - - // Tasks - task: 'Task', - tasks: 'Tasks', - taskCreated: 'Task created: **{title}**', - taskCompleted: 'Completed: ~~{title}~~', - taskDeleted: 'Deleted: {title}', - noTasks: 'No open tasks.', - noTasksToday: 'No tasks for today.', - inboxEmpty: 'Inbox is empty.', - allTasks: 'All open tasks', - todayTasks: 'Tasks for today', - inbox: 'Inbox (no date)', - - // Projects - project: 'Project', - projects: 'Projects', - noProjects: 'No projects.', - projectTasks: 'Project: {name}', - - // Priorities - priority: 'Priority', - date: 'Date', - - // Help - helpTitle: 'Todo Bot - Help', - helpCommands: `**Commands:** -• \`!add [task]\` - Create new task -• \`!list\` - All open tasks -• \`!today\` - Today's tasks -• \`!inbox\` - Tasks without date -• \`!done [Nr]\` - Mark task as done -• \`!delete [Nr]\` - Delete task -• \`!projects\` - All projects -• \`!project [name]\` - Show project tasks -• \`!status\` - Bot status -• \`!language [de/en]\` - Change language`, - helpSyntax: `**Syntax:** -\`!add Task !p1 @tomorrow #project\` -• \`!p1-4\` - Priority (1=highest) -• \`@today/@tomorrow\` - Due date -• \`#projectname\` - Project`, - helpExamples: `**Examples:** -• \`Go shopping\` -• \`Prepare meeting !p1 @tomorrow\` -• \`Write report #work\``, - - // Actions - markDone: 'Complete: `!done [Nr]`', - delete: 'Delete: `!delete [Nr]`', - }, - - calendar: { - // Inherit common - error: 'Error', - errorOccurred: 'An error occurred. Please try again.', - notLoggedIn: 'You are not logged in.', - loginRequired: 'Please log in first with `!login email password`', - loginSuccess: 'Successfully logged in as **{email}**', - loginFailed: 'Login failed: {error}', - logoutSuccess: 'Successfully logged out.', - invalidCommand: 'Unknown command: {command}', - helpHint: 'Say "help" for all commands.', - credits: 'Credits', - creditsRemaining: '{amount} remaining', - insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', - buyCredits: 'Buy credits: https://mana.how/credits', - creditBalance: 'Your balance: **{balance}** credits', - creditPackagesTitle: '**Credit packages:**', - creditPackageLine: '{num}. {name} · {credits} credits · {price}', - creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', - creditPaymentLink: 'Click here to pay:', - creditLinkValid: 'Link valid for 24 hours.', - creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', - creditNewBalance: 'New balance: **{balance}** credits', - creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', - creditPurchaseError: 'Error creating payment link. Please try again later.', - creditNoPackages: 'No credit packages available.', - synced: 'Synced', - localStorage: 'Local storage', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Logged in as: {email}', - notLoggedInStatus: 'Not logged in', - languageChanged: 'Language changed to: **{language}**', - currentLanguage: 'Current language: **{language}**', - availableLanguages: 'Available languages: {languages}', - today: 'Today', - tomorrow: 'Tomorrow', - dayAfterTomorrow: 'Day after tomorrow', - created: 'Created', - deleted: 'Deleted', - updated: 'Updated', - completed: 'Completed', - - // Events - event: 'Event', - events: 'Events', - eventCreated: 'Event created: **{title}**', - eventDeleted: 'Deleted: {title}', - noEvents: 'No upcoming events.', - noEventsToday: 'No events for today.', - noEventsTomorrow: 'No events for tomorrow.', - noEventsThisWeek: 'No events this week.', - upcomingEvents: 'Upcoming events', - todayEvents: "Today's events", - tomorrowEvents: "Tomorrow's events", - weekEvents: "This week's events", - - // Calendars - calendar: 'Calendar', - calendars: 'Calendars', - yourCalendars: 'Your calendars', - - // Time - time: 'Time', - allDay: 'all day', - location: 'Location', - - // Help - helpTitle: 'Calendar Bot - Help', - helpCommands: `**Commands:** -• \`!add [event]\` - Create new event -• \`!today\` - Today's events -• \`!tomorrow\` - Tomorrow's events -• \`!week\` - This week's events -• \`!events\` - Next 14 days -• \`!details [Nr]\` - Event details -• \`!delete [Nr]\` - Delete event -• \`!calendars\` - All calendars -• \`!status\` - Bot status -• \`!language [de/en]\` - Change language`, - helpSyntax: `**Syntax:** -\`Meeting tomorrow at 2pm\` -\`Dentist on 02/15 at 10:30am\` -\`Vacation on 03/01 all day\``, - helpExamples: `**Examples:** -• \`Team meeting tomorrow at 10am\` -• \`Doctor on 02/20 at 3:30pm\` -• \`Birthday on 03/15 all day\``, - - // Parsing errors - couldNotParseDateTime: 'Could not parse date/time.', - pleaseProvideTitle: 'Please provide a title for the event.', - }, - - contacts: { - // Inherit common - error: 'Error', - errorOccurred: 'An error occurred. Please try again.', - notLoggedIn: 'You are not logged in.', - loginRequired: 'Please log in first with `!login email password`', - loginSuccess: 'Successfully logged in as **{email}**', - loginFailed: 'Login failed: {error}', - logoutSuccess: 'Successfully logged out.', - invalidCommand: 'Unknown command: {command}', - helpHint: 'Say "help" for all commands.', - credits: 'Credits', - creditsRemaining: '{amount} remaining', - insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', - buyCredits: 'Buy credits: https://mana.how/credits', - creditBalance: 'Your balance: **{balance}** credits', - creditPackagesTitle: '**Credit packages:**', - creditPackageLine: '{num}. {name} · {credits} credits · {price}', - creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', - creditPaymentLink: 'Click here to pay:', - creditLinkValid: 'Link valid for 24 hours.', - creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', - creditNewBalance: 'New balance: **{balance}** credits', - creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', - creditPurchaseError: 'Error creating payment link. Please try again later.', - creditNoPackages: 'No credit packages available.', - synced: 'Synced', - localStorage: 'Local storage', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Logged in as: {email}', - notLoggedInStatus: 'Not logged in', - languageChanged: 'Language changed to: **{language}**', - currentLanguage: 'Current language: **{language}**', - availableLanguages: 'Available languages: {languages}', - today: 'Today', - tomorrow: 'Tomorrow', - dayAfterTomorrow: 'Day after tomorrow', - created: 'Created', - deleted: 'Deleted', - updated: 'Updated', - completed: 'Completed', - - // Contacts - contact: 'Contact', - contacts: 'Contacts', - contactCreated: 'Contact **{name}** created!', - contactDeleted: 'Contact **{name}** deleted.', - contactUpdated: 'Contact **{name}** updated!', - noContacts: 'You have no contacts yet.', - - // Favorites - favorite: 'Favorite', - favorites: 'Favorites', - noFavorites: 'You have no favorites yet.', - markedAsFavorite: '**{name}** marked as favorite ★', - removedFromFavorites: '**{name}** removed from favorites', - - // Search - search: 'Search', - searchResults: 'Search results for "{query}"', - noSearchResults: 'No contacts found for: "{query}"', - - // Fields - email: 'Email', - phone: 'Phone', - mobile: 'Mobile', - company: 'Company', - jobTitle: 'Job title', - address: 'Address', - website: 'Website', - birthday: 'Birthday', - notes: 'Notes', - - // Help - helpTitle: 'Contacts Bot - Help', - helpCommands: `**Commands:** -• \`!contacts\` - All contacts -• \`!search [text]\` - Search contacts -• \`!favorites\` - Show favorites -• \`!contact [Nr]\` - Contact details -• \`!add FirstName LastName\` - New contact -• \`!edit [Nr] [field] [value]\` - Edit -• \`!delete [Nr]\` - Delete contact -• \`!fav [Nr]\` - Toggle favorite -• \`!status\` - Bot status -• \`!language [de/en]\` - Change language`, - helpFields: `**Fields:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`, - helpExamples: `**Examples:** -• \`John Doe\` -• \`!edit 1 email john@example.com\` -• \`!edit 1 phone +1 123 456 7890\``, - }, - - clock: { - // Inherit common - error: 'Error', - errorOccurred: 'An error occurred. Please try again.', - notLoggedIn: 'You are not logged in.', - loginRequired: 'Please log in first with `!login email password`', - loginSuccess: 'Successfully logged in as **{email}**', - loginFailed: 'Login failed: {error}', - logoutSuccess: 'Successfully logged out.', - invalidCommand: 'Unknown command: {command}', - helpHint: 'Say "help" for all commands.', - credits: 'Credits', - creditsRemaining: '{amount} remaining', - insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', - buyCredits: 'Buy credits: https://mana.how/credits', - creditBalance: 'Your balance: **{balance}** credits', - creditPackagesTitle: '**Credit packages:**', - creditPackageLine: '{num}. {name} · {credits} credits · {price}', - creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', - creditPaymentLink: 'Click here to pay:', - creditLinkValid: 'Link valid for 24 hours.', - creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', - creditNewBalance: 'New balance: **{balance}** credits', - creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', - creditPurchaseError: 'Error creating payment link. Please try again later.', - creditNoPackages: 'No credit packages available.', - synced: 'Synced', - localStorage: 'Local storage', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Logged in as: {email}', - notLoggedInStatus: 'Not logged in', - languageChanged: 'Language changed to: **{language}**', - currentLanguage: 'Current language: **{language}**', - availableLanguages: 'Available languages: {languages}', - today: 'Today', - tomorrow: 'Tomorrow', - dayAfterTomorrow: 'Day after tomorrow', - created: 'Created', - deleted: 'Deleted', - updated: 'Updated', - completed: 'Completed', - - // Timer - timer: 'Timer', - timerStarted: 'Timer started!', - timerPaused: 'Timer paused', - timerResumed: 'Timer resumed', - timerReset: 'Timer reset.', - timerFinished: 'Timer finished!', - noActiveTimer: 'No active timer.', - noPausedTimer: 'No paused timer.', - noTimers: 'No timers.', - remaining: 'Remaining', - duration: 'Duration', - label: 'Label', - - // Alarm - alarm: 'Alarm', - alarmSet: 'Alarm set!', - alarmDeleted: 'Alarm deleted.', - noAlarms: 'No alarms.', - yourAlarms: 'Your alarms', - - // World Clock - worldClock: 'World clock', - worldClocks: 'World clocks', - worldClockAdded: 'World clock added: {city}', - noWorldClocks: 'No world clocks.', - yourWorldClocks: 'Your world clocks', - - // Time - currentTime: 'Current time', - - // Help - helpTitle: 'Clock Bot - Help', - helpCommands: `**Commands:** -• \`!timer 25m\` - Start timer -• \`!stop\` - Pause timer -• \`!resume\` - Resume timer -• \`!reset\` - Reset timer -• \`!timers\` - All timers -• \`!alarm 07:30\` - Set alarm -• \`!alarms\` - All alarms -• \`!time\` - Current time -• \`!worldclock Berlin\` - Add world clock -• \`!worldclocks\` - All world clocks -• \`!status\` - Bot status -• \`!language [de/en]\` - Change language`, - helpExamples: `**Examples:** -• \`25\` (25 minute timer) -• \`1h30m\` (1.5 hour timer) -• \`!alarm 7:30 am\``, - - // Parsing errors - couldNotParseDuration: 'Could not parse duration.', - couldNotParseTime: 'Could not parse time.', - }, - - gift: { - // Inherit common - error: 'Error', - errorOccurred: 'An error occurred. Please try again.', - notLoggedIn: 'You are not logged in.', - loginRequired: 'Please log in first with `!login email password`', - loginSuccess: 'Successfully logged in as **{email}**', - loginFailed: 'Login failed: {error}', - logoutSuccess: 'Successfully logged out.', - invalidCommand: 'Unknown command: {command}', - helpHint: 'Say "help" for all commands.', - credits: 'Credits', - creditsRemaining: '{amount} remaining', - insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', - buyCredits: 'Buy credits: https://mana.how/credits', - creditBalance: 'Your balance: **{balance}** credits', - creditPackagesTitle: '**Credit packages:**', - creditPackageLine: '{num}. {name} · {credits} credits · {price}', - creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', - creditPaymentLink: 'Click here to pay:', - creditLinkValid: 'Link valid for 24 hours.', - creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', - creditNewBalance: 'New balance: **{balance}** credits', - creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', - creditPurchaseError: 'Error creating payment link. Please try again later.', - creditNoPackages: 'No credit packages available.', - synced: 'Synced', - localStorage: 'Local storage', - status: 'Status', - online: 'Online', - offline: 'Offline', - loggedInAs: 'Logged in as: {email}', - notLoggedInStatus: 'Not logged in', - languageChanged: 'Language changed to: **{language}**', - currentLanguage: 'Current language: **{language}**', - availableLanguages: 'Available languages: {languages}', - today: 'Today', - tomorrow: 'Tomorrow', - dayAfterTomorrow: 'Day after tomorrow', - created: 'Created', - deleted: 'Deleted', - updated: 'Updated', - completed: 'Completed', - - // Gift creation - giftCreated: '🎁 **Gift created!**', - giftCreatedCode: 'Code: `{code}`', - giftCreatedCredits: 'Credits: {credits}', - giftCreatedLink: 'Link: {url}', - giftCreatedSplit: 'Credits: {credits} × {portions}', - giftInvalidCredits: 'Please enter a valid credit amount (1-10000).', - giftInvalidSyntax: 'Invalid syntax. Example: `!gift 50` or `!gift 100 /5`', - giftInsufficientCredits: 'Insufficient credits. Available: {available}', - - // Gift redemption - giftRedeemed: '🎁 **Gift redeemed!**', - giftRedeemedCredits: '+{credits} credits', - giftRedeemedMessage: '"{message}"', - giftInvalidCode: 'Gift code not found.', - giftExpired: 'This gift code has expired.', - giftDepleted: 'This gift code has been fully claimed.', - giftAlreadyClaimed: 'You have already claimed this gift.', - giftWrongUser: 'This gift code is for a specific person.', - giftWrongAnswer: 'Wrong answer. Try again.', - giftRiddleRequired: 'Please provide the answer to the riddle.', - giftRiddleQuestion: '❓ {question}', - - // Gift list - giftListTitle: '🎁 **Your gifts:**', - giftListEmpty: 'No active gifts.', - giftListItem: '{num}. `{code}` {status} {credits} Cr · {claimed}/{total}', - giftReceivedListTitle: '🎁 **Received gifts:**', - giftReceivedListEmpty: 'No received gifts.', - - // Gift info - giftInfoTitle: '🎁 **Gift info:**', - giftInfoCredits: 'Credits: {credits}', - giftInfoAvailable: 'Available: {remaining}/{total}', - giftInfoFrom: 'From: {name}', - - // Gift help - giftHelpTitle: 'Gifts - Help', - giftHelpCommands: `**Commands:** -• \`!gift [credits]\` - Create gift code -• \`!gift [credits] /[count]\` - Split gift -• \`!gift [credits] @email\` - Personalized -• \`!gift [credits] ?="answer"\` - With riddle -• \`!redeem [code]\` - Redeem code -• \`!my-gifts\` - Show your gifts`, - giftHelpSyntax: `**Syntax:** -\`!gift 50\` - Simple gift -\`!gift 100 /5\` - 5 portions of 20 Cr -\`!gift 50 "Happy birthday!"\` - With message`, - giftHelpExamples: `**Examples:** -• \`!gift 50\` -• \`!gift 100 /5 Share this!\` -• \`!redeem ABC123\``, - - // Gift cancellation - giftCancelled: 'Gift cancelled.', - giftRefunded: '{credits} credits refunded.', - }, -}; diff --git a/packages/bot-services/src/i18n/types.ts b/packages/bot-services/src/i18n/types.ts deleted file mode 100644 index 9da430173..000000000 --- a/packages/bot-services/src/i18n/types.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Supported languages - */ -export type Language = 'de' | 'en'; - -/** - * Common translations shared across all bots - */ -export interface CommonTranslations { - // General - error: string; - errorOccurred: string; - notLoggedIn: string; - loginRequired: string; - loginSuccess: string; - loginFailed: string; - logoutSuccess: string; - invalidCommand: string; - helpHint: string; - - // Credits - credits: string; - creditsRemaining: string; - insufficientCredits: string; - buyCredits: string; - - // Credit purchasing - creditBalance: string; - creditPackagesTitle: string; - creditPackageLine: string; - creditBuyHelp: string; - creditPaymentLink: string; - creditLinkValid: string; - creditPaymentSuccess: string; - creditNewBalance: string; - creditPackageNotFound: string; - creditPurchaseError: string; - creditNoPackages: string; - - // Sync - synced: string; - localStorage: string; - - // Status - status: string; - online: string; - offline: string; - loggedInAs: string; - notLoggedInStatus: string; - - // Language - languageChanged: string; - currentLanguage: string; - availableLanguages: string; - - // Dates - today: string; - tomorrow: string; - dayAfterTomorrow: string; - - // Actions - created: string; - deleted: string; - updated: string; - completed: string; -} - -/** - * Todo bot translations - */ -export interface TodoTranslations extends CommonTranslations { - // Tasks - task: string; - tasks: string; - taskCreated: string; - taskCompleted: string; - taskDeleted: string; - noTasks: string; - noTasksToday: string; - inboxEmpty: string; - allTasks: string; - todayTasks: string; - inbox: string; - - // Projects - project: string; - projects: string; - noProjects: string; - projectTasks: string; - - // Priorities - priority: string; - date: string; - - // Help - helpTitle: string; - helpCommands: string; - helpSyntax: string; - helpExamples: string; - - // Actions - markDone: string; - delete: string; -} - -/** - * Calendar bot translations - */ -export interface CalendarTranslations extends CommonTranslations { - // Events - event: string; - events: string; - eventCreated: string; - eventDeleted: string; - noEvents: string; - noEventsToday: string; - noEventsTomorrow: string; - noEventsThisWeek: string; - upcomingEvents: string; - todayEvents: string; - tomorrowEvents: string; - weekEvents: string; - - // Calendars - calendar: string; - calendars: string; - yourCalendars: string; - - // Time - time: string; - allDay: string; - location: string; - - // Help - helpTitle: string; - helpCommands: string; - helpSyntax: string; - helpExamples: string; - - // Parsing errors - couldNotParseDateTime: string; - pleaseProvideTitle: string; -} - -/** - * Contacts bot translations - */ -export interface ContactsTranslations extends CommonTranslations { - // Contacts - contact: string; - contacts: string; - contactCreated: string; - contactDeleted: string; - contactUpdated: string; - noContacts: string; - - // Favorites - favorite: string; - favorites: string; - noFavorites: string; - markedAsFavorite: string; - removedFromFavorites: string; - - // Search - search: string; - searchResults: string; - noSearchResults: string; - - // Fields - email: string; - phone: string; - mobile: string; - company: string; - jobTitle: string; - address: string; - website: string; - birthday: string; - notes: string; - - // Help - helpTitle: string; - helpCommands: string; - helpFields: string; - helpExamples: string; -} - -/** - * Clock bot translations - */ -export interface ClockTranslations extends CommonTranslations { - // Timer - timer: string; - timerStarted: string; - timerPaused: string; - timerResumed: string; - timerReset: string; - timerFinished: string; - noActiveTimer: string; - noPausedTimer: string; - noTimers: string; - remaining: string; - duration: string; - label: string; - - // Alarm - alarm: string; - alarmSet: string; - alarmDeleted: string; - noAlarms: string; - yourAlarms: string; - - // World Clock - worldClock: string; - worldClocks: string; - worldClockAdded: string; - noWorldClocks: string; - yourWorldClocks: string; - - // Time - currentTime: string; - - // Help - helpTitle: string; - helpCommands: string; - helpExamples: string; - - // Parsing errors - couldNotParseDuration: string; - couldNotParseTime: string; -} - -/** - * Gift translations - */ -export interface GiftTranslations extends CommonTranslations { - // Gift creation - giftCreated: string; - giftCreatedCode: string; - giftCreatedCredits: string; - giftCreatedLink: string; - giftCreatedSplit: string; - giftInvalidCredits: string; - giftInvalidSyntax: string; - giftInsufficientCredits: string; - - // Gift redemption - giftRedeemed: string; - giftRedeemedCredits: string; - giftRedeemedMessage: string; - giftInvalidCode: string; - giftExpired: string; - giftDepleted: string; - giftAlreadyClaimed: string; - giftWrongUser: string; - giftWrongAnswer: string; - giftRiddleRequired: string; - giftRiddleQuestion: string; - - // Gift list - giftListTitle: string; - giftListEmpty: string; - giftListItem: string; - giftReceivedListTitle: string; - giftReceivedListEmpty: string; - - // Gift info - giftInfoTitle: string; - giftInfoCredits: string; - giftInfoAvailable: string; - giftInfoFrom: string; - - // Gift help - giftHelpTitle: string; - giftHelpCommands: string; - giftHelpSyntax: string; - giftHelpExamples: string; - - // Gift cancellation - giftCancelled: string; - giftRefunded: string; -} - -/** - * All bot translations combined - */ -export interface BotTranslations { - common: CommonTranslations; - todo: TodoTranslations; - calendar: CalendarTranslations; - contacts: ContactsTranslations; - clock: ClockTranslations; - gift: GiftTranslations; -} - -/** - * I18n service options - */ -export interface I18nOptions { - defaultLanguage?: Language; -} diff --git a/packages/bot-services/src/index.ts b/packages/bot-services/src/index.ts deleted file mode 100644 index 388414567..000000000 --- a/packages/bot-services/src/index.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * @manacore/bot-services - * - * Shared business logic services for Matrix bots and the Gateway. - * These services are transport-agnostic and can be used by: - * - Individual Matrix bots (standalone) - * - The Gateway bot (all-in-one) - * - REST APIs - * - CLI tools - * - * @example - * ```typescript - * import { TodoModule, TodoService } from '@manacore/bot-services/todo'; - * import { AiModule, AiService } from '@manacore/bot-services/ai'; - * - * // In NestJS module - * @Module({ - * imports: [ - * TodoModule.register({ storagePath: './data/todos.json' }), - * AiModule.register({ baseUrl: 'http://ollama:11434' }), - * ], - * }) - * export class AppModule {} - * ``` - */ - -// ===== Core Services ===== - -// Todo -export { - TodoModule, - TodoModuleOptions, - TodoService, - TODO_STORAGE_PROVIDER, - TodoApiService, -} from './todo/index.js'; -export type { - Task, - Project, - TodoData, - CreateTaskInput, - UpdateTaskInput, - TaskFilter, - TodoStats, - ParsedTaskInput, -} from './todo/index.js'; - -// Calendar -export { - CalendarModule, - CalendarModuleOptions, - CalendarService, - CalendarApiService, - CALENDAR_STORAGE_PROVIDER, -} from './calendar/index.js'; -export type { - CalendarEvent, - Calendar, - CalendarData, - CreateEventInput, - UpdateEventInput, - EventFilter, - ParsedEventInput, -} from './calendar/index.js'; - -// AI (Ollama) -export { AiModule, AiModuleOptions, AiService } from './ai/index.js'; -export type { - OllamaModel, - ChatMessage, - ChatOptions, - ChatResult, - ChatResponseMeta, - AiServiceConfig, - UserAiSession, - SystemPromptPreset, -} from './ai/index.js'; -export { SYSTEM_PROMPTS, VISION_MODELS, NON_CHAT_MODELS } from './ai/index.js'; - -// Clock -export { ClockModule, ClockModuleOptions, ClockService } from './clock/index.js'; -export type { - Timer, - Alarm, - WorldClock, - TimezoneResult, - CreateTimerInput, - CreateAlarmInput, - CreateWorldClockInput, - ClockServiceConfig, - TimeTrackingSummary, -} from './clock/index.js'; - -// Session (User authentication via mana-core-auth) -export { - SessionModule, - SessionService, - RedisSessionProvider, - REDIS_SESSION_PROVIDER, - REDIS_CLIENT, - SESSION_MODULE_OPTIONS, - DEFAULT_SESSION_EXPIRY_MS, - formatAuthErrorMessage, - AUTH_ERROR_MESSAGES, - // Deprecated - kept for backwards compatibility - formatLoginRequiredMessage, - LOGIN_MESSAGES, -} from './session/index.js'; -export type { - UserSession, - LoginResult, - SessionStats, - SessionModuleOptions, - SessionStorageMode, -} from './session/index.js'; - -// Transcription (Speech-to-Text via mana-stt) -export { - TranscriptionModule, - TranscriptionService, - STT_MODULE_OPTIONS, -} from './transcription/index.js'; -export type { - SttResponse, - TranscriptionOptions, - TranscriptionModuleOptions, -} from './transcription/index.js'; - -// Credit (Credit balance and formatting for Matrix bots) -export { - CreditModule, - CreditService, - CREDIT_MODULE_OPTIONS, - CreditErrorCode, -} from './credit/index.js'; -export type { - CreditBalance, - CreditValidationResult, - CreditConsumeResult, - CreditModuleOptions, - CreditStatusMessage, - CreditPackage, - PaymentLinkResult, - PurchaseStatus, - PurchaseStatusResult, -} from './credit/index.js'; - -// Gift (Gift code management for Matrix bots) -export { GiftModule, GiftService, GIFT_MODULE_OPTIONS } from './gift/index.js'; -export type { - GiftCodeType, - GiftCodeStatus, - CreateGiftOptions, - CreateGiftResult, - GiftCodeInfo, - RedeemGiftResult, - CreatedGiftItem, - ReceivedGiftItem, - GiftModuleOptions, - GiftStatusMessage, -} from './gift/index.js'; - -// I18n (Multi-language support for Matrix bots) -export { I18nModule, I18nService, I18N_OPTIONS, LANGUAGE_NAMES } from './i18n/index.js'; -export type { - Language, - I18nOptions, - BotTranslations, - CommonTranslations, - TodoTranslations, - CalendarTranslations, - ContactsTranslations, - ClockTranslations, - GiftTranslations, -} from './i18n/index.js'; -export { de as deTranslations, en as enTranslations } from './i18n/index.js'; - -// Weather (Open-Meteo API) -export { WeatherModule, WeatherService } from './weather/index.js'; -export type { WeatherModuleOptions, WeatherData, WeatherCode } from './weather/index.js'; -export { - WEATHER_MODULE_OPTIONS, - WEATHER_DESCRIPTIONS_DE, - WEATHER_DESCRIPTIONS_EN, -} from './weather/index.js'; - -// Contacts API (Birthday tracking) -export { ContactsModule, ContactsApiService } from './contacts/index.js'; -export type { ContactsModuleOptions, Contact, ContactBirthday } from './contacts/index.js'; -export { CONTACTS_MODULE_OPTIONS, DEFAULT_CONTACTS_API_URL } from './contacts/index.js'; - -// Planta API (Plant watering) -export { PlantaModule, PlantaApiService } from './planta/index.js'; -export type { PlantaModuleOptions, Plant, PlantWateringStatus } from './planta/index.js'; -export { PLANTA_MODULE_OPTIONS, DEFAULT_PLANTA_API_URL } from './planta/index.js'; - -// Morning Summary (Daily aggregation) -export { - MorningSummaryModule, - MorningSummaryService, - MorningPreferencesService, -} from './morning-summary/index.js'; -export type { - MorningSummaryModuleOptions, - MorningSummaryData, - MorningPreferences, -} from './morning-summary/index.js'; -export { - MORNING_SUMMARY_MODULE_OPTIONS, - DEFAULT_MORNING_PREFERENCES, - MORNING_PREFS_KEY_PREFIX, - DAY_NAMES_DE, - MONTH_NAMES_DE, -} from './morning-summary/index.js'; - -// ===== Placeholder Services (to be implemented) ===== - -export { NutritionModule } from './nutrition/index.js'; -export type { NutritionServiceConfig, Meal, NutritionSummary } from './nutrition/index.js'; - -export { QuotesModule } from './quotes/index.js'; -export type { QuotesServiceConfig, Quote } from './quotes/index.js'; - -export { StatsModule } from './stats/index.js'; -export type { StatsServiceConfig, AnalyticsReport } from './stats/index.js'; - -export { DocsModule } from './docs/index.js'; -export type { DocsServiceConfig, ProjectDoc } from './docs/index.js'; - -// ===== Shared Utilities ===== - -export { FileStorageProvider, MemoryStorageProvider } from './shared/index.js'; -export type { - StorageProvider, - BaseEntity, - UserEntity, - ServiceConfig, - Result, - PaginationOptions, - PaginatedResult, - DateRange, - Priority, - ServiceStats, -} from './shared/index.js'; -export { - generateId, - getTodayISO, - startOfDay, - endOfDay, - addDays, - formatDateDE, - formatTimeDE, - isToday, - isTomorrow, - parseGermanDateKeyword, - getRelativeDateLabel, -} from './shared/index.js'; -export { PRIORITY_VALUES } from './shared/index.js'; diff --git a/packages/bot-services/src/morning-summary/index.ts b/packages/bot-services/src/morning-summary/index.ts deleted file mode 100644 index cf96b6eaa..000000000 --- a/packages/bot-services/src/morning-summary/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Morning Summary Service - * - * Daily morning summary aggregation with user preferences. - * - * @example - * ```typescript - * import { - * MorningSummaryModule, - * MorningSummaryService, - * MorningPreferencesService, - * } from '@manacore/bot-services/morning-summary'; - * - * // In module - * @Module({ - * imports: [MorningSummaryModule.forRoot()] - * }) - * - * // In service - * const summary = await morningSummaryService.generateSummary(matrixUserId, token); - * const formatted = morningSummaryService.formatSummary(summary, 'detailed'); - * - * // Manage preferences - * await preferencesService.setEnabled(matrixUserId, true); - * await preferencesService.setDeliveryTime(matrixUserId, '07:00'); - * await preferencesService.setLocation(matrixUserId, 'Berlin'); - * ``` - */ - -export { MorningSummaryModule } from './morning-summary.module.js'; -export { MorningSummaryService } from './morning-summary.service.js'; -export { MorningPreferencesService } from './preferences.service.js'; -export { - MorningSummaryModuleOptions, - MorningSummaryData, - MorningPreferences, - DEFAULT_MORNING_PREFERENCES, - MORNING_SUMMARY_MODULE_OPTIONS, - MORNING_PREFS_KEY_PREFIX, - DAY_NAMES_DE, - MONTH_NAMES_DE, -} from './types.js'; diff --git a/packages/bot-services/src/morning-summary/morning-summary.module.ts b/packages/bot-services/src/morning-summary/morning-summary.module.ts deleted file mode 100644 index bfdad1bc8..000000000 --- a/packages/bot-services/src/morning-summary/morning-summary.module.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { MorningSummaryService } from './morning-summary.service'; -import { MorningPreferencesService } from './preferences.service'; -import { MorningSummaryModuleOptions, MORNING_SUMMARY_MODULE_OPTIONS } from './types'; - -// Import API services -import { CalendarApiService } from '../calendar/calendar-api.service'; -import { TodoApiService } from '../todo/todo-api.service'; -import { ContactsApiService } from '../contacts/contacts-api.service'; -import { PlantaApiService } from '../planta/planta-api.service'; -import { WeatherService } from '../weather/weather.service'; - -// Note: SessionModule should be imported by the consuming application -// This module assumes SessionService is available globally - -/** - * Morning Summary Module - * - * Provides daily morning summary aggregation service. - * - * @example - * ```typescript - * // Basic usage with all dependencies - * @Module({ - * imports: [ - * MorningSummaryModule.forRoot() - * ] - * }) - * - * // The module requires these services to be available (can be optional): - * // - CalendarApiService (for events) - * // - TodoApiService (for tasks) - * // - ContactsApiService (for birthdays) - * // - PlantaApiService (for plants) - * // - WeatherService (for weather) - * // - SessionService (for preferences storage) - * ``` - */ -@Global() -@Module({}) -export class MorningSummaryModule { - /** - * Register module with explicit options - */ - static register(options: MorningSummaryModuleOptions = {}): DynamicModule { - return { - module: MorningSummaryModule, - providers: [ - { - provide: MORNING_SUMMARY_MODULE_OPTIONS, - useValue: options, - }, - // API Services with configured URLs - { - provide: CalendarApiService, - useFactory: () => new CalendarApiService(options.calendarApiUrl), - }, - { - provide: TodoApiService, - useFactory: () => new TodoApiService(options.todoApiUrl), - }, - { - provide: ContactsApiService, - useFactory: () => { - const service = new ContactsApiService(); - // @ts-expect-error - set apiUrl directly - if (options.contactsApiUrl) service['baseUrl'] = options.contactsApiUrl; - return service; - }, - }, - { - provide: PlantaApiService, - useFactory: () => { - const service = new PlantaApiService(); - // @ts-expect-error - set apiUrl directly - if (options.plantaApiUrl) service['baseUrl'] = options.plantaApiUrl; - return service; - }, - }, - { - provide: WeatherService, - useFactory: () => - new WeatherService({ - defaultLocation: options.defaultLocation || 'Berlin', - }), - }, - MorningPreferencesService, - MorningSummaryService, - ], - exports: [MorningSummaryService, MorningPreferencesService], - }; - } - - /** - * Register with ConfigService reading from environment - * - * Environment variables: - * - TODO_API_URL: Todo backend URL - * - CALENDAR_API_URL: Calendar backend URL - * - CONTACTS_API_URL: Contacts backend URL - * - PLANTA_API_URL: Planta backend URL - * - WEATHER_DEFAULT_LOCATION: Default weather location - */ - static forRoot(): DynamicModule { - return { - module: MorningSummaryModule, - imports: [ConfigModule], - providers: [ - { - provide: MORNING_SUMMARY_MODULE_OPTIONS, - useFactory: (config: ConfigService) => ({ - todoApiUrl: - config.get('services.todo.apiUrl') || - config.get('TODO_API_URL') || - 'http://localhost:3018', - calendarApiUrl: - config.get('services.calendar.apiUrl') || - config.get('CALENDAR_API_URL') || - 'http://localhost:3014', - contactsApiUrl: - config.get('services.contacts.apiUrl') || - config.get('CONTACTS_API_URL') || - 'http://localhost:3015', - plantaApiUrl: - config.get('services.planta.apiUrl') || - config.get('PLANTA_API_URL') || - 'http://localhost:3022', - defaultLocation: - config.get('weather.defaultLocation') || - config.get('WEATHER_DEFAULT_LOCATION') || - 'Berlin', - }), - inject: [ConfigService], - }, - // API Services - { - provide: CalendarApiService, - useFactory: (config: ConfigService) => - new CalendarApiService( - config.get('services.calendar.apiUrl') || - config.get('CALENDAR_API_URL') || - 'http://localhost:3014' - ), - inject: [ConfigService], - }, - { - provide: TodoApiService, - useFactory: (config: ConfigService) => - new TodoApiService( - config.get('services.todo.apiUrl') || - config.get('TODO_API_URL') || - 'http://localhost:3018' - ), - inject: [ConfigService], - }, - { - provide: ContactsApiService, - useFactory: (config: ConfigService) => { - const apiUrl = - config.get('services.contacts.apiUrl') || - config.get('CONTACTS_API_URL') || - 'http://localhost:3015'; - return new ContactsApiService({ apiUrl }); - }, - inject: [ConfigService], - }, - { - provide: PlantaApiService, - useFactory: (config: ConfigService) => { - const apiUrl = - config.get('services.planta.apiUrl') || - config.get('PLANTA_API_URL') || - 'http://localhost:3022'; - return new PlantaApiService({ apiUrl }); - }, - inject: [ConfigService], - }, - { - provide: WeatherService, - useFactory: (config: ConfigService) => - new WeatherService({ - defaultLocation: - config.get('weather.defaultLocation') || - config.get('WEATHER_DEFAULT_LOCATION') || - 'Berlin', - }), - inject: [ConfigService], - }, - MorningPreferencesService, - MorningSummaryService, - ], - exports: [MorningSummaryService, MorningPreferencesService], - }; - } -} diff --git a/packages/bot-services/src/morning-summary/morning-summary.service.ts b/packages/bot-services/src/morning-summary/morning-summary.service.ts deleted file mode 100644 index 1dce299da..000000000 --- a/packages/bot-services/src/morning-summary/morning-summary.service.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Injectable, Logger, Optional } from '@nestjs/common'; -import { CalendarApiService } from '../calendar/calendar-api.service.js'; -import { TodoApiService } from '../todo/todo-api.service.js'; -import { ContactsApiService } from '../contacts/contacts-api.service.js'; -import { PlantaApiService } from '../planta/planta-api.service.js'; -import { WeatherService } from '../weather/weather.service.js'; -import { MorningPreferencesService } from './preferences.service.js'; -import { MorningSummaryData, DAY_NAMES_DE, MONTH_NAMES_DE } from './types.js'; -import { Task } from '../todo/types.js'; - -/** - * Morning Summary Service - * - * Aggregates data from all sources to generate a comprehensive morning summary. - * - * @example - * ```typescript - * // Generate summary for a user - * const summary = await morningSummaryService.generateSummary(matrixUserId, token); - * const formatted = morningSummaryService.formatSummary(summary, 'detailed'); - * ``` - */ -@Injectable() -export class MorningSummaryService { - private readonly logger = new Logger(MorningSummaryService.name); - - constructor( - @Optional() private calendarService: CalendarApiService, - @Optional() private todoService: TodoApiService, - @Optional() private contactsService: ContactsApiService, - @Optional() private plantaService: PlantaApiService, - @Optional() private weatherService: WeatherService, - private preferencesService: MorningPreferencesService - ) { - this.logger.log('Morning Summary Service initialized'); - } - - /** - * Generate a complete morning summary for a user - */ - async generateSummary(matrixUserId: string, token: string): Promise { - const prefs = await this.preferencesService.getPreferences(matrixUserId); - - // Fetch all data in parallel - const [events, tasks, birthdays, plants, weather] = await Promise.all([ - this.fetchEvents(token), - this.fetchTasks(token), - prefs.includeBirthdays ? this.fetchBirthdays(token) : Promise.resolve([]), - prefs.includePlants ? this.fetchPlants(token) : Promise.resolve([]), - prefs.includeWeather && prefs.location - ? this.fetchWeather(prefs.location) - : Promise.resolve(null), - ]); - - // Separate today's tasks from overdue tasks - const today = new Date().toISOString().split('T')[0]; - const todayTasks = tasks.filter((t) => !t.completed && (t.dueDate === today || !t.dueDate)); - const overdueTasks = tasks.filter((t) => !t.completed && t.dueDate && t.dueDate < today); - - return { - events, - tasks: todayTasks, - overdueTasks, - birthdays, - plants, - weather, - generatedAt: new Date(), - }; - } - - /** - * Format summary for display - */ - formatSummary(data: MorningSummaryData, format: 'compact' | 'detailed' = 'detailed'): string { - const today = new Date(); - const dayName = DAY_NAMES_DE[today.getDay()]; - const day = today.getDate(); - const month = MONTH_NAMES_DE[today.getMonth()]; - const year = today.getFullYear(); - - if (format === 'compact') { - return this.formatCompact(data, dayName, day, month); - } - - return this.formatDetailed(data, dayName, day, month, year); - } - - /** - * Format as compact summary - */ - private formatCompact( - data: MorningSummaryData, - dayName: string, - day: number, - month: string - ): string { - const parts: string[] = [ - `**Guten Morgen!** (${dayName.slice(0, 2)}, ${day}. ${month.slice(0, 3)}.)`, - ]; - - const summaryParts: string[] = []; - - // Weather - if (data.weather) { - summaryParts.push( - `${Math.round(data.weather.temperature)}°C ${data.weather.weatherDescription.toLowerCase()}` - ); - } - - // Events - if (data.events.length > 0) { - summaryParts.push(`${data.events.length} Termine`); - } - - // Tasks - if (data.tasks.length > 0) { - summaryParts.push(`${data.tasks.length} Aufgaben`); - } - - // Overdue - if (data.overdueTasks.length > 0) { - summaryParts.push(`${data.overdueTasks.length} ueberfaellig`); - } - - if (summaryParts.length > 0) { - parts.push(summaryParts.join(' | ')); - } - - // Birthdays & Plants - const extraParts: string[] = []; - if (data.birthdays.length > 0) { - const names = data.birthdays.map((b) => { - const name = b.displayName || `${b.firstName || ''} ${b.lastName || ''}`.trim(); - const shortName = - name.split(' ')[0] + (name.split(' ')[1] ? ` ${name.split(' ')[1][0]}.` : ''); - return `${shortName}${b.age ? ` (${b.age})` : ''}`; - }); - extraParts.push(`Geburtstag: ${names.join(', ')}`); - } - - if (data.plants.length > 0) { - extraParts.push(`${data.plants.length} Pflanzen giessen`); - } - - if (extraParts.length > 0) { - parts.push(extraParts.join(' | ')); - } - - return parts.join('\n'); - } - - /** - * Format as detailed summary - */ - private formatDetailed( - data: MorningSummaryData, - dayName: string, - day: number, - month: string, - year: number - ): string { - const sections: string[] = [`**Guten Morgen!** (${dayName}, ${day}. ${month} ${year})`, '']; - - // Weather - if (data.weather && this.weatherService) { - sections.push(this.weatherService.formatWeather(data.weather, 'detailed')); - sections.push(''); - } - - // Events - if (data.events.length > 0) { - sections.push(`**Termine heute (${data.events.length})**`); - for (const event of data.events.slice(0, 5)) { - const time = event.isAllDay - ? 'Ganztaegig' - : new Date(event.startTime).toLocaleTimeString('de-DE', { - hour: '2-digit', - minute: '2-digit', - }); - sections.push(`• ${time} ${event.title}`); - } - if (data.events.length > 5) { - sections.push(` _... und ${data.events.length - 5} weitere_`); - } - sections.push(''); - } - - // Tasks - if (data.tasks.length > 0) { - sections.push(`**Aufgaben heute (${data.tasks.length})**`); - for (const task of data.tasks.slice(0, 5)) { - const priority = task.priority < 4 ? ' ❗'.repeat(4 - task.priority) : ''; - sections.push(`• ${task.title}${priority}`); - } - if (data.tasks.length > 5) { - sections.push(` _... und ${data.tasks.length - 5} weitere_`); - } - sections.push(''); - } - - // Overdue - if (data.overdueTasks.length > 0) { - sections.push(`**Ueberfaellig (${data.overdueTasks.length})**`); - for (const task of data.overdueTasks.slice(0, 3)) { - const daysOverdue = this.getDaysOverdue(task.dueDate!); - const overdueText = daysOverdue === 1 ? 'seit gestern' : `seit ${daysOverdue} Tagen`; - sections.push(`• ${task.title} (${overdueText})`); - } - if (data.overdueTasks.length > 3) { - sections.push(` _... und ${data.overdueTasks.length - 3} weitere_`); - } - sections.push(''); - } - - // Birthdays - if (data.birthdays.length > 0) { - sections.push('**Geburtstage** 🎂'); - for (const birthday of data.birthdays) { - const name = - birthday.displayName || `${birthday.firstName || ''} ${birthday.lastName || ''}`.trim(); - const ageText = birthday.age ? ` wird ${birthday.age}` : ''; - sections.push(`• ${name}${ageText}`); - } - sections.push(''); - } - - // Plants - if (data.plants.length > 0) { - sections.push('**Pflanzen giessen** 🌱'); - const overdue = data.plants.filter((p) => p.isOverdue); - const today = data.plants.filter((p) => !p.isOverdue); - - for (const plant of overdue) { - sections.push(`• ${plant.plantName} (ueberfaellig!)`); - } - for (const plant of today) { - sections.push(`• ${plant.plantName}`); - } - sections.push(''); - } - - // Footer - sections.push('---'); - sections.push('Einstellungen: `!morning-settings`'); - - return sections.join('\n'); - } - - // ===== Data Fetching ===== - - private async fetchEvents(token: string) { - if (!this.calendarService) return []; - try { - return await this.calendarService.getTodayEvents(token); - } catch (error) { - this.logger.error('Failed to fetch events:', error); - return []; - } - } - - private async fetchTasks(token: string): Promise { - if (!this.todoService) return []; - try { - // Get all pending tasks - return await this.todoService.getTasks(token, { completed: false }); - } catch (error) { - this.logger.error('Failed to fetch tasks:', error); - return []; - } - } - - private async fetchBirthdays(token: string) { - if (!this.contactsService) return []; - try { - return await this.contactsService.getBirthdaysToday(token); - } catch (error) { - this.logger.error('Failed to fetch birthdays:', error); - return []; - } - } - - private async fetchPlants(token: string) { - if (!this.plantaService) return []; - try { - return await this.plantaService.getPlantsNeedingWater(token); - } catch (error) { - this.logger.error('Failed to fetch plants:', error); - return []; - } - } - - private async fetchWeather(location: string) { - if (!this.weatherService) return null; - try { - return await this.weatherService.getWeather(location); - } catch (error) { - this.logger.error('Failed to fetch weather:', error); - return null; - } - } - - // ===== Helpers ===== - - private getDaysOverdue(dueDate: string): number { - const due = new Date(dueDate); - const today = new Date(); - today.setHours(0, 0, 0, 0); - due.setHours(0, 0, 0, 0); - return Math.floor((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24)); - } -} diff --git a/packages/bot-services/src/morning-summary/preferences.service.ts b/packages/bot-services/src/morning-summary/preferences.service.ts deleted file mode 100644 index 2d557084b..000000000 --- a/packages/bot-services/src/morning-summary/preferences.service.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { SessionService } from '../session/session.service.js'; -import { MorningPreferences, DEFAULT_MORNING_PREFERENCES } from './types.js'; - -/** - * Morning Preferences Service - * - * Manages user preferences for morning summaries. - * Stores preferences in Redis via SessionService for cross-bot persistence. - * - * @example - * ```typescript - * // Get preferences - * const prefs = await preferencesService.getPreferences(matrixUserId); - * - * // Enable morning summary - * await preferencesService.setEnabled(matrixUserId, true); - * - * // Set delivery time - * await preferencesService.setDeliveryTime(matrixUserId, '07:30'); - * - * // Set weather location - * await preferencesService.setLocation(matrixUserId, 'Berlin'); - * ``` - */ -@Injectable() -export class MorningPreferencesService { - private readonly logger = new Logger(MorningPreferencesService.name); - - constructor(private sessionService: SessionService) {} - - /** - * Get user's morning preferences - */ - async getPreferences(matrixUserId: string): Promise { - try { - const stored = await this.sessionService.getSessionData( - matrixUserId, - 'morningPrefs' - ); - - if (stored) { - // Merge with defaults to ensure all fields exist - return { ...DEFAULT_MORNING_PREFERENCES, ...stored }; - } - - return { ...DEFAULT_MORNING_PREFERENCES }; - } catch (error) { - this.logger.error(`Failed to get preferences for ${matrixUserId}:`, error); - return { ...DEFAULT_MORNING_PREFERENCES }; - } - } - - /** - * Save user's morning preferences - */ - async savePreferences( - matrixUserId: string, - prefs: Partial - ): Promise { - try { - const current = await this.getPreferences(matrixUserId); - const updated = { ...current, ...prefs }; - - await this.sessionService.setSessionData(matrixUserId, 'morningPrefs', updated); - - this.logger.debug(`Saved preferences for ${matrixUserId}`); - return updated; - } catch (error) { - this.logger.error(`Failed to save preferences for ${matrixUserId}:`, error); - throw error; - } - } - - /** - * Enable/disable morning summary - */ - async setEnabled(matrixUserId: string, enabled: boolean): Promise { - return this.savePreferences(matrixUserId, { enabled }); - } - - /** - * Set delivery time (HH:MM format) - */ - async setDeliveryTime(matrixUserId: string, time: string): Promise { - // Validate time format - const match = time.match(/^(\d{1,2}):(\d{2})$/); - if (!match) { - throw new Error('Invalid time format. Use HH:MM (e.g., 07:00)'); - } - - const hours = parseInt(match[1]); - const minutes = parseInt(match[2]); - - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { - throw new Error('Invalid time. Hours must be 0-23, minutes 0-59'); - } - - const deliveryTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; - return this.savePreferences(matrixUserId, { deliveryTime }); - } - - /** - * Set timezone - */ - async setTimezone(matrixUserId: string, timezone: string): Promise { - // Basic validation - check if it's a valid IANA timezone - try { - Intl.DateTimeFormat('en', { timeZone: timezone }); - } catch { - throw new Error(`Invalid timezone: ${timezone}`); - } - - return this.savePreferences(matrixUserId, { timezone }); - } - - /** - * Set weather location - */ - async setLocation(matrixUserId: string, location: string | null): Promise { - return this.savePreferences(matrixUserId, { location }); - } - - /** - * Set summary format - */ - async setFormat( - matrixUserId: string, - format: 'compact' | 'detailed' - ): Promise { - return this.savePreferences(matrixUserId, { format }); - } - - /** - * Get all users with enabled morning summaries - * Note: This requires iterating over all sessions, which is only efficient with Redis - */ - async getEnabledUsers(): Promise { - // This will be implemented via Redis scan in the scheduler - // For now, return from in-memory tracking - const activeUsers = this.sessionService.getActiveUserIds(); - const enabledUsers: string[] = []; - - for (const userId of activeUsers) { - const prefs = await this.getPreferences(userId); - if (prefs.enabled) { - enabledUsers.push(userId); - } - } - - return enabledUsers; - } - - /** - * Check if current time matches a user's delivery time - */ - shouldDeliverNow(prefs: MorningPreferences, currentTime: Date = new Date()): boolean { - if (!prefs.enabled) return false; - - try { - // Get current time in user's timezone - const userTime = currentTime.toLocaleTimeString('en-US', { - timeZone: prefs.timezone, - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); - - // Compare with delivery time (allow 1-minute window) - const [currentHour, currentMinute] = userTime.split(':').map(Number); - const [targetHour, targetMinute] = prefs.deliveryTime.split(':').map(Number); - - return currentHour === targetHour && currentMinute === targetMinute; - } catch (error) { - this.logger.error(`Error checking delivery time:`, error); - return false; - } - } - - /** - * Format preferences for display - */ - formatPreferences(prefs: MorningPreferences): string { - const status = prefs.enabled ? '✅ Aktiviert' : '❌ Deaktiviert'; - const lines = [ - '**Morgenzusammenfassung Einstellungen**', - '', - `Status: ${status}`, - `Uhrzeit: ${prefs.deliveryTime}`, - `Zeitzone: ${prefs.timezone}`, - `Format: ${prefs.format === 'compact' ? 'Kompakt' : 'Ausfuehrlich'}`, - ]; - - if (prefs.location) { - lines.push(`Wetter-Ort: ${prefs.location}`); - } else { - lines.push(`Wetter-Ort: Nicht gesetzt`); - } - - lines.push(''); - lines.push('**Optionen:**'); - lines.push(`Wetter: ${prefs.includeWeather ? '✅' : '❌'}`); - lines.push(`Geburtstage: ${prefs.includeBirthdays ? '✅' : '❌'}`); - lines.push(`Pflanzen: ${prefs.includePlants ? '✅' : '❌'}`); - - return lines.join('\n'); - } -} diff --git a/packages/bot-services/src/morning-summary/types.ts b/packages/bot-services/src/morning-summary/types.ts deleted file mode 100644 index 2c3fb9f01..000000000 --- a/packages/bot-services/src/morning-summary/types.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Morning Summary Service Types - * - * Types for daily morning summary and user preferences - */ - -import { type CalendarEvent } from '../calendar/types.js'; -import { type Task } from '../todo/types.js'; -import { type WeatherData } from '../weather/types.js'; -import { type ContactBirthday } from '../contacts/types.js'; -import { type PlantWateringStatus } from '../planta/types.js'; - -/** - * Morning summary data aggregated from all sources - */ -export interface MorningSummaryData { - /** Today's calendar events */ - events: CalendarEvent[]; - /** Today's tasks */ - tasks: Task[]; - /** Overdue tasks */ - overdueTasks: Task[]; - /** Today's birthdays */ - birthdays: ContactBirthday[]; - /** Plants needing water */ - plants: PlantWateringStatus[]; - /** Weather data (if location set) */ - weather: WeatherData | null; - /** Timestamp when summary was generated */ - generatedAt: Date; -} - -/** - * User morning summary preferences - */ -export interface MorningPreferences { - /** Whether automatic morning summary is enabled (default: false, opt-in) */ - enabled: boolean; - /** Delivery time in HH:MM format (default: '07:00') */ - deliveryTime: string; - /** User's timezone (default: 'Europe/Berlin') */ - timezone: string; - /** Location for weather (optional) */ - location: string | null; - /** Summary format (default: 'detailed') */ - format: 'compact' | 'detailed'; - /** Include weather in summary (default: true) */ - includeWeather: boolean; - /** Include birthdays in summary (default: true) */ - includeBirthdays: boolean; - /** Include plants in summary (default: true) */ - includePlants: boolean; -} - -/** - * Default morning preferences - */ -export const DEFAULT_MORNING_PREFERENCES: MorningPreferences = { - enabled: false, - deliveryTime: '07:00', - timezone: 'Europe/Berlin', - location: null, - format: 'detailed', - includeWeather: true, - includeBirthdays: true, - includePlants: true, -}; - -/** - * Morning summary module options - */ -export interface MorningSummaryModuleOptions { - /** TodoApiService API URL */ - todoApiUrl?: string; - /** CalendarApiService API URL */ - calendarApiUrl?: string; - /** ContactsApiService API URL */ - contactsApiUrl?: string; - /** PlantaApiService API URL */ - plantaApiUrl?: string; - /** Default location for weather */ - defaultLocation?: string; -} - -/** - * Injection token for morning summary module options - */ -export const MORNING_SUMMARY_MODULE_OPTIONS = 'MORNING_SUMMARY_MODULE_OPTIONS'; - -/** - * Redis key prefix for morning preferences - */ -export const MORNING_PREFS_KEY_PREFIX = 'morning:prefs:'; - -/** - * German day names - */ -export const DAY_NAMES_DE = [ - 'Sonntag', - 'Montag', - 'Dienstag', - 'Mittwoch', - 'Donnerstag', - 'Freitag', - 'Samstag', -]; - -/** - * German month names - */ -export const MONTH_NAMES_DE = [ - 'Januar', - 'Februar', - 'Maerz', - 'April', - 'Mai', - 'Juni', - 'Juli', - 'August', - 'September', - 'Oktober', - 'November', - 'Dezember', -]; diff --git a/packages/bot-services/src/nutrition/index.ts b/packages/bot-services/src/nutrition/index.ts deleted file mode 100644 index 2aa2eee38..000000000 --- a/packages/bot-services/src/nutrition/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Placeholder - to be implemented -// Will integrate with NutriPhi backend API - -export interface NutritionServiceConfig { - apiUrl: string; -} - -export interface Meal { - id: string; - userId: string; - description: string; - calories: number; - createdAt: string; -} - -export interface NutritionSummary { - totalCalories: number; - mealCount: number; - meals: Meal[]; -} - -// Export placeholder module -export const NutritionModule = { - register: () => ({ module: class {}, providers: [], exports: [] }), -}; diff --git a/packages/bot-services/src/planta/index.ts b/packages/bot-services/src/planta/index.ts deleted file mode 100644 index 736214fa2..000000000 --- a/packages/bot-services/src/planta/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Planta API Service - * - * Plant care and watering management API client. - * - * @example - * ```typescript - * import { PlantaModule, PlantaApiService } from '@manacore/bot-services/planta'; - * - * // In module - * @Module({ - * imports: [PlantaModule.forRoot()] - * }) - * - * // In service - * const plants = await plantaApiService.getPlantsNeedingWater(token); - * ``` - */ - -export { PlantaModule } from './planta.module.js'; -export { PlantaApiService } from './planta-api.service.js'; -export { - PlantaModuleOptions, - Plant, - PlantWateringStatus, - PLANTA_MODULE_OPTIONS, - DEFAULT_PLANTA_API_URL, -} from './types.js'; diff --git a/packages/bot-services/src/planta/planta-api.service.ts b/packages/bot-services/src/planta/planta-api.service.ts deleted file mode 100644 index 00c799629..000000000 --- a/packages/bot-services/src/planta/planta-api.service.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { - Plant, - PlantWateringStatus, - PlantaModuleOptions, - PLANTA_MODULE_OPTIONS, - DEFAULT_PLANTA_API_URL, -} from './types'; - -/** - * Planta API Service - * - * Connects to the planta-backend API for plant care management. - * Used by the morning summary to show plants that need watering. - * - * @example - * ```typescript - * // Get plants needing water (requires JWT token) - * const plants = await plantaApiService.getPlantsNeedingWater(token); - * - * // Log watering - * await plantaApiService.logWatering(token, plantId); - * ``` - */ -@Injectable() -export class PlantaApiService { - private readonly logger = new Logger(PlantaApiService.name); - private readonly baseUrl: string; - - constructor(@Optional() @Inject(PLANTA_MODULE_OPTIONS) options?: PlantaModuleOptions) { - this.baseUrl = options?.apiUrl || DEFAULT_PLANTA_API_URL; - this.logger.log(`Planta API Service initialized with URL: ${this.baseUrl}`); - } - - /** - * Get plants that need watering (overdue or due today) - */ - async getPlantsNeedingWater(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/watering/upcoming`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as PlantWateringStatus[]; - - // Filter to only overdue and today - return data.filter((p) => p.isOverdue || p.daysUntilWatering === 0); - } catch (error) { - this.logger.error('Failed to get plants needing water:', error); - return []; - } - } - - /** - * Get all upcoming watering (next 3 days) - */ - async getUpcomingWatering(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/watering/upcoming`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - return (await response.json()) as PlantWateringStatus[]; - } catch (error) { - this.logger.error('Failed to get upcoming watering:', error); - return []; - } - } - - /** - * Get all plants - */ - async getPlants(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/plants`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { plants?: Plant[] }; - return data.plants || []; - } catch (error) { - this.logger.error('Failed to get plants:', error); - return []; - } - } - - /** - * Log watering for a plant - */ - async logWatering(token: string, plantId: string, notes?: string): Promise { - try { - const body: Record = {}; - if (notes) body.notes = notes; - - const response = await fetch(`${this.baseUrl}/api/v1/watering/${plantId}/water`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - this.logger.log(`Logged watering for plant ${plantId}`); - return true; - } catch (error) { - this.logger.error(`Failed to log watering for plant ${plantId}:`, error); - return false; - } - } - - /** - * Format plants needing water for display - */ - formatPlantsNeedingWater(plants: PlantWateringStatus[]): string { - if (plants.length === 0) { - return ''; - } - - const overdue = plants.filter((p) => p.isOverdue); - const today = plants.filter((p) => !p.isOverdue && p.daysUntilWatering === 0); - - const lines: string[] = ['**Pflanzen giessen** 🌱']; - - if (overdue.length > 0) { - for (const plant of overdue) { - lines.push(`• ${plant.plantName} (ueberfaellig!)`); - } - } - - if (today.length > 0) { - for (const plant of today) { - lines.push(`• ${plant.plantName}`); - } - } - - return lines.join('\n'); - } -} diff --git a/packages/bot-services/src/planta/planta.module.ts b/packages/bot-services/src/planta/planta.module.ts deleted file mode 100644 index ee5a40fa1..000000000 --- a/packages/bot-services/src/planta/planta.module.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { PlantaApiService } from './planta-api.service'; -import { PlantaModuleOptions, PLANTA_MODULE_OPTIONS } from './types'; - -/** - * Planta Module - * - * Plant care and watering management API client. - * - * @example - * ```typescript - * // Basic usage - * @Module({ - * imports: [PlantaModule.register()] - * }) - * - * // With custom API URL - * @Module({ - * imports: [ - * PlantaModule.register({ - * apiUrl: 'http://planta-backend:3022', - * }) - * ] - * }) - * ``` - */ -@Global() -@Module({}) -export class PlantaModule { - /** - * Register module with explicit options - */ - static register(options: PlantaModuleOptions = {}): DynamicModule { - return { - module: PlantaModule, - providers: [ - { - provide: PLANTA_MODULE_OPTIONS, - useValue: options, - }, - PlantaApiService, - ], - exports: [PlantaApiService], - }; - } - - /** - * Register module with async configuration - */ - static registerAsync(options: { - imports?: any[]; - useFactory: (...args: any[]) => Promise | PlantaModuleOptions; - inject?: any[]; - }): DynamicModule { - return { - module: PlantaModule, - imports: [...(options.imports || [])], - providers: [ - { - provide: PLANTA_MODULE_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }, - PlantaApiService, - ], - exports: [PlantaApiService], - }; - } - - /** - * Register with ConfigService reading from environment - * - * Environment variables: - * - PLANTA_API_URL: Planta backend URL - */ - static forRoot(): DynamicModule { - return this.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - apiUrl: - config.get('planta.apiUrl') || - config.get('PLANTA_API_URL') || - 'http://localhost:3022', - }), - inject: [ConfigService], - }); - } -} diff --git a/packages/bot-services/src/planta/types.ts b/packages/bot-services/src/planta/types.ts deleted file mode 100644 index 388eba318..000000000 --- a/packages/bot-services/src/planta/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Planta API Service Types - * - * Types for plant care and watering management - */ - -/** - * Plant with watering info - */ -export interface Plant { - id: string; - name: string; - scientificName?: string; - healthStatus?: 'healthy' | 'needs_attention' | 'sick'; - photoUrl?: string; -} - -/** - * Plant watering status - */ -export interface PlantWateringStatus { - plantId: string; - plantName: string; - daysUntilWatering: number; - isOverdue: boolean; - lastWateredAt: Date | null; - nextWateringAt: Date | null; - photoUrl?: string; -} - -/** - * Planta API module options - */ -export interface PlantaModuleOptions { - apiUrl?: string; -} - -/** - * Injection token for Planta module options - */ -export const PLANTA_MODULE_OPTIONS = 'PLANTA_MODULE_OPTIONS'; - -/** - * Default API URL - */ -export const DEFAULT_PLANTA_API_URL = 'http://localhost:3022'; diff --git a/packages/bot-services/src/quotes/index.ts b/packages/bot-services/src/quotes/index.ts deleted file mode 100644 index 00659db68..000000000 --- a/packages/bot-services/src/quotes/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Placeholder - to be implemented -// Will integrate with Zitare backend API - -export interface QuotesServiceConfig { - apiUrl: string; -} - -export interface Quote { - id: string; - text: string; - author: string; - category: string; -} - -// Export placeholder module -export const QuotesModule = { - register: () => ({ module: class {}, providers: [], exports: [] }), -}; diff --git a/packages/bot-services/src/session/index.ts b/packages/bot-services/src/session/index.ts deleted file mode 100644 index e5e17ae20..000000000 --- a/packages/bot-services/src/session/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { SessionService, REDIS_SESSION_PROVIDER } from './session.service'; -export { SessionModule } from './session.module'; -export { RedisSessionProvider, REDIS_CLIENT } from './redis-session.provider'; -export type { - UserSession, - LoginResult, - SessionStats, - SessionModuleOptions, - SessionStorageMode, -} from './types'; -export { - SESSION_MODULE_OPTIONS, - DEFAULT_SESSION_EXPIRY_MS, - formatAuthErrorMessage, - AUTH_ERROR_MESSAGES, - // Deprecated - kept for backwards compatibility - formatLoginRequiredMessage, - LOGIN_MESSAGES, -} from './types'; diff --git a/packages/bot-services/src/session/redis-session.provider.ts b/packages/bot-services/src/session/redis-session.provider.ts deleted file mode 100644 index 8606aa638..000000000 --- a/packages/bot-services/src/session/redis-session.provider.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { - Injectable, - Logger, - OnModuleInit, - OnModuleDestroy, - Inject, - Optional, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; -import { - UserSession, - SessionModuleOptions, - SESSION_MODULE_OPTIONS, - DEFAULT_SESSION_EXPIRY_MS, -} from './types'; - -/** - * Injection token for Redis client - */ -export const REDIS_CLIENT = 'REDIS_CLIENT'; - -/** - * Key prefix for bot sessions in Redis - */ -const KEY_PREFIX = 'bot:session:'; - -/** - * Redis-based session provider for cross-bot SSO - * - * Sessions are stored in Redis with automatic TTL expiration. - * All bots using this provider share the same session store. - * - * @example - * ```typescript - * // User logs in via todo-bot - * await sessionProvider.setSession('@user:matrix.mana.how', session); - * - * // Same user in picture-bot - already logged in! - * const session = await sessionProvider.getSession('@user:matrix.mana.how'); - * ``` - */ -@Injectable() -export class RedisSessionProvider implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(RedisSessionProvider.name); - private client: Redis | null = null; - private readonly sessionExpirySeconds: number; - - constructor( - @Optional() private configService: ConfigService, - @Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions - ) { - const expiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS; - this.sessionExpirySeconds = Math.floor(expiryMs / 1000); - } - - async onModuleInit() { - const host = - this.options?.redisHost || this.configService?.get('REDIS_HOST', 'localhost'); - const port = this.options?.redisPort || this.configService?.get('REDIS_PORT', 6379); - const password = - this.options?.redisPassword || this.configService?.get('REDIS_PASSWORD'); - - try { - this.client = new Redis({ - host, - port, - password: password || undefined, - retryStrategy: (times) => { - if (times > 3) { - this.logger.warn('Redis connection failed, falling back to in-memory sessions'); - return null; - } - return Math.min(times * 200, 2000); - }, - maxRetriesPerRequest: 1, - }); - - this.client.on('error', (err) => { - this.logger.error(`Redis error: ${err.message}`); - }); - - this.client.on('connect', () => { - this.logger.log(`Connected to Redis at ${host}:${port} for session storage`); - }); - - // Test connection - await this.client.ping(); - this.logger.log('Redis session provider initialized'); - } catch (error) { - this.logger.warn(`Could not connect to Redis: ${error}. Falling back to in-memory sessions.`); - this.client = null; - } - } - - async onModuleDestroy() { - if (this.client) { - await this.client.quit(); - } - } - - /** - * Build Redis key for a Matrix user ID - */ - private buildKey(matrixUserId: string): string { - return `${KEY_PREFIX}${matrixUserId}`; - } - - /** - * Check if Redis is connected - */ - isConnected(): boolean { - return this.client !== null && this.client.status === 'ready'; - } - - /** - * Store a session in Redis - */ - async setSession(matrixUserId: string, session: UserSession): Promise { - if (!this.client) return; - - try { - const data = { - token: session.token, - email: session.email, - expiresAt: session.expiresAt.toISOString(), - data: session.data || {}, - }; - - await this.client.setex( - this.buildKey(matrixUserId), - this.sessionExpirySeconds, - JSON.stringify(data) - ); - this.logger.debug(`Session stored for ${matrixUserId}`); - } catch (error) { - this.logger.error(`Failed to store session: ${error}`); - } - } - - /** - * Get a session from Redis - */ - async getSession(matrixUserId: string): Promise { - if (!this.client) return null; - - try { - const data = await this.client.get(this.buildKey(matrixUserId)); - if (!data) return null; - - const parsed = JSON.parse(data); - const session: UserSession = { - token: parsed.token, - email: parsed.email, - expiresAt: new Date(parsed.expiresAt), - data: parsed.data, - }; - - // Check if expired (should not happen due to TTL, but double-check) - if (session.expiresAt < new Date()) { - await this.deleteSession(matrixUserId); - return null; - } - - return session; - } catch (error) { - this.logger.error(`Failed to get session: ${error}`); - return null; - } - } - - /** - * Get only the token from a session - */ - async getToken(matrixUserId: string): Promise { - const session = await this.getSession(matrixUserId); - return session?.token ?? null; - } - - /** - * Delete a session from Redis - */ - async deleteSession(matrixUserId: string): Promise { - if (!this.client) return; - - try { - await this.client.del(this.buildKey(matrixUserId)); - this.logger.debug(`Session deleted for ${matrixUserId}`); - } catch (error) { - this.logger.error(`Failed to delete session: ${error}`); - } - } - - /** - * Update session data without changing the token - */ - async updateSessionData(matrixUserId: string, key: string, value: unknown): Promise { - const session = await this.getSession(matrixUserId); - if (!session) return; - - session.data = session.data || {}; - session.data[key] = value; - await this.setSession(matrixUserId, session); - } - - /** - * Get session data - */ - async getSessionData(matrixUserId: string, key: string): Promise { - const session = await this.getSession(matrixUserId); - return (session?.data?.[key] as T) ?? null; - } - - /** - * Get all active session keys (for debugging/stats) - */ - async getActiveSessionCount(): Promise { - if (!this.client) return 0; - - try { - const keys = await this.client.keys(`${KEY_PREFIX}*`); - return keys.length; - } catch (error) { - this.logger.error(`Failed to get session count: ${error}`); - return 0; - } - } - - /** - * Health check - */ - async healthCheck(): Promise<{ status: string; latency: number }> { - if (!this.client) { - return { status: 'disconnected', latency: 0 }; - } - - const start = Date.now(); - try { - await this.client.ping(); - return { status: 'ok', latency: Date.now() - start }; - } catch { - return { status: 'error', latency: Date.now() - start }; - } - } -} diff --git a/packages/bot-services/src/session/session.module.ts b/packages/bot-services/src/session/session.module.ts deleted file mode 100644 index c71ef831d..000000000 --- a/packages/bot-services/src/session/session.module.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { SessionService, REDIS_SESSION_PROVIDER } from './session.service'; -import { RedisSessionProvider } from './redis-session.provider'; -import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types'; - -/** - * Shared session management module for Matrix bots - * - * Provides SessionService for managing user authentication sessions. - * Links Matrix user IDs to mana-core-auth JWT tokens. - * - * @example - * ```typescript - * // Basic usage (in-memory sessions, per bot) - * @Module({ - * imports: [SessionModule.forRoot()] - * }) - * - * // With Redis for cross-bot SSO - * @Module({ - * imports: [ - * SessionModule.forRoot({ - * storageMode: 'redis', - * redisHost: 'localhost', - * redisPort: 6379, - * }) - * ] - * }) - * - * // With Matrix-SSO-Link (automatic login) - * @Module({ - * imports: [ - * SessionModule.forRoot({ - * storageMode: 'redis', - * enableMatrixSsoLink: true, - * serviceKey: process.env.MANA_CORE_SERVICE_KEY, - * }) - * ] - * }) - * ``` - */ -@Global() -@Module({}) -export class SessionModule { - /** - * Register module with explicit options - */ - static register(options: SessionModuleOptions = {}): DynamicModule { - const providers: any[] = [ - { - provide: SESSION_MODULE_OPTIONS, - useValue: options, - }, - ]; - - // Add Redis provider if storage mode is redis - if (options.storageMode === 'redis') { - providers.push({ - provide: REDIS_SESSION_PROVIDER, - useClass: RedisSessionProvider, - }); - } - - providers.push(SessionService); - - return { - module: SessionModule, - imports: [ConfigModule], - providers, - exports: [SessionService], - }; - } - - /** - * Register module with ConfigService - * - * Reads configuration from environment: - * - MANA_CORE_AUTH_URL: Auth service URL - * - REDIS_HOST, REDIS_PORT: Redis for cross-bot SSO - * - MANA_CORE_SERVICE_KEY: For Matrix-SSO-Link - * - SESSION_STORAGE_MODE: 'memory' or 'redis' - */ - static forRoot(options: SessionModuleOptions = {}): DynamicModule { - const providers: any[] = [ - { - provide: SESSION_MODULE_OPTIONS, - useValue: options, - }, - ]; - - // Add Redis provider if storage mode is redis - if (options.storageMode === 'redis') { - providers.push({ - provide: REDIS_SESSION_PROVIDER, - useClass: RedisSessionProvider, - }); - } - - providers.push(SessionService); - - return { - module: SessionModule, - imports: [ConfigModule], - providers, - exports: [SessionService], - }; - } - - /** - * Register module with Redis enabled for cross-bot SSO - * - * Convenience method that enables Redis storage and Matrix-SSO-Link. - */ - static forRootWithRedis(options: Omit = {}): DynamicModule { - return this.forRoot({ - ...options, - storageMode: 'redis', - enableMatrixSsoLink: options.enableMatrixSsoLink ?? true, - }); - } -} diff --git a/packages/bot-services/src/session/session.service.ts b/packages/bot-services/src/session/session.service.ts deleted file mode 100644 index f97ab90bd..000000000 --- a/packages/bot-services/src/session/session.service.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - UserSession, - LoginResult, - SessionStats, - SessionModuleOptions, - SESSION_MODULE_OPTIONS, - DEFAULT_SESSION_EXPIRY_MS, -} from './types'; -import { RedisSessionProvider } from './redis-session.provider'; - -/** - * Injection token for Redis session provider - */ -export const REDIS_SESSION_PROVIDER = 'REDIS_SESSION_PROVIDER'; - -/** - * Shared session management service for Matrix bots - * - * Manages user authentication sessions linking Matrix user IDs to mana-core-auth JWT tokens. - * - * Features: - * - **In-memory mode** (default): Sessions stored per bot instance - * - **Redis mode**: Sessions shared across ALL bots (SSO) - * - **Matrix-SSO-Link**: Automatic login for users who logged into Matrix via OIDC - * - * @example - * ```typescript - * // In NestJS module - with Redis for cross-bot SSO - * imports: [SessionModule.forRoot({ storageMode: 'redis' })] - * - * // In service/controller - * const token = await sessionService.getToken(matrixUserId); - * // Token is available across ALL bots! - * ``` - */ -/** - * Buffer time before JWT expiry to trigger refresh (in seconds) - * Refresh tokens 60 seconds before they expire to avoid edge cases - */ -const JWT_REFRESH_BUFFER_SECONDS = 60; - -@Injectable() -export class SessionService { - private readonly logger = new Logger(SessionService.name); - private sessions: Map = new Map(); - private readonly authUrl: string; - private readonly sessionExpiryMs: number; - private readonly loginPath: string; - private readonly enableMatrixSsoLink: boolean; - private readonly serviceKey: string | undefined; - - constructor( - @Optional() private configService: ConfigService, - @Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions, - @Optional() @Inject(REDIS_SESSION_PROVIDER) private redisProvider?: RedisSessionProvider - ) { - // Priority: module options > config > environment > default - this.authUrl = - options?.authUrl || - this.configService?.get('auth.url') || - this.configService?.get('MANA_CORE_AUTH_URL') || - 'http://localhost:3001'; - - this.sessionExpiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS; - this.loginPath = options?.loginPath || '/api/v1/auth/login'; - - // Matrix-SSO-Link settings - this.enableMatrixSsoLink = options?.enableMatrixSsoLink ?? options?.storageMode === 'redis'; - this.serviceKey = - options?.serviceKey || this.configService?.get('MANA_CORE_SERVICE_KEY'); - - const mode = this.redisProvider?.isConnected() ? 'redis' : 'memory'; - this.logger.log( - `Auth URL: ${this.authUrl}, Storage: ${mode}, Matrix-SSO-Link: ${this.enableMatrixSsoLink}` - ); - } - - /** - * Check if using Redis storage - */ - private useRedis(): boolean { - return this.redisProvider?.isConnected() ?? false; - } - - /** - * Decode JWT and check if it's expired or about to expire - * - * @param token - JWT token string - * @returns true if token is valid and not expired, false otherwise - */ - private isTokenValid(token: string): boolean { - try { - // JWT format: header.payload.signature - const parts = token.split('.'); - if (parts.length !== 3) { - this.logger.debug('Invalid JWT format'); - return false; - } - - // Decode payload (base64url) - const payload = JSON.parse( - Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') - ); - - if (!payload.exp) { - this.logger.debug('JWT has no exp claim'); - return true; // No expiry = valid - } - - // Check if expired (with buffer) - const now = Math.floor(Date.now() / 1000); - const expiresAt = payload.exp; - const isValid = expiresAt > now + JWT_REFRESH_BUFFER_SECONDS; - - if (!isValid) { - this.logger.debug( - `JWT expired or expiring soon: exp=${expiresAt}, now=${now}, buffer=${JWT_REFRESH_BUFFER_SECONDS}s` - ); - } - - return isValid; - } catch (error) { - this.logger.debug(`Failed to decode JWT: ${error}`); - return false; - } - } - - /** - * Get or create a session for a Matrix user - * - * This method tries multiple sources in order: - * 1. Redis cache (if enabled) - validates JWT expiry - * 2. In-memory cache - validates JWT expiry - * 3. Matrix-SSO-Link lookup (automatic login if user logged into Matrix via OIDC) - * - * If a cached token is expired, it automatically fetches a fresh one via SSO-Link. - * - * @param matrixUserId - Matrix user ID (e.g., "@user:matrix.mana.how") - * @returns JWT token or null if not logged in - */ - async getToken(matrixUserId: string): Promise { - // 1. Try Redis first - if (this.useRedis()) { - const token = await this.redisProvider!.getToken(matrixUserId); - if (token) { - // Check if JWT is still valid - if (this.isTokenValid(token)) { - this.logger.debug(`Found valid token in Redis for ${matrixUserId}`); - return token; - } - // Token expired - try to refresh via SSO-Link - this.logger.debug(`Token in Redis expired for ${matrixUserId}, refreshing...`); - const freshToken = await this.refreshToken(matrixUserId); - if (freshToken) { - return freshToken; - } - // Refresh failed - clear invalid session - await this.redisProvider!.deleteSession(matrixUserId); - } - } - - // 2. Try in-memory cache - const session = this.sessions.get(matrixUserId); - if (session) { - if (session.expiresAt < new Date()) { - this.sessions.delete(matrixUserId); - } else if (this.isTokenValid(session.token)) { - this.logger.debug(`Found valid token in memory for ${matrixUserId}`); - return session.token; - } else { - // Token expired - try to refresh via SSO-Link - this.logger.debug(`Token in memory expired for ${matrixUserId}, refreshing...`); - const freshToken = await this.refreshToken(matrixUserId); - if (freshToken) { - return freshToken; - } - // Refresh failed - clear invalid session - this.sessions.delete(matrixUserId); - } - } - - // 3. Try Matrix-SSO-Link (automatic login) - this.logger.debug( - `No cached token for ${matrixUserId}, trying SSO-Link (enabled: ${this.enableMatrixSsoLink}, hasServiceKey: ${!!this.serviceKey})` - ); - if (this.enableMatrixSsoLink) { - const token = await this.fetchMatrixLinkedToken(matrixUserId); - if (token) { - this.logger.log(`Matrix-SSO-Link: auto-login successful for ${matrixUserId}`); - // Cache the token - await this.storeSession(matrixUserId, { - token, - email: '', // Unknown from SSO link - expiresAt: new Date(Date.now() + this.sessionExpiryMs), - }); - return token; - } - } - - return null; - } - - /** - * Refresh an expired token via Matrix-SSO-Link - * - * @param matrixUserId - Matrix user ID - * @returns Fresh JWT token or null if refresh failed - */ - private async refreshToken(matrixUserId: string): Promise { - if (!this.enableMatrixSsoLink) { - this.logger.debug('Cannot refresh token: SSO-Link disabled'); - return null; - } - - const freshToken = await this.fetchMatrixLinkedToken(matrixUserId); - if (freshToken) { - this.logger.log(`Token refreshed via SSO-Link for ${matrixUserId}`); - // Update cached session with fresh token - await this.storeSession(matrixUserId, { - token: freshToken, - email: '', // Unknown from SSO link - expiresAt: new Date(Date.now() + this.sessionExpiryMs), - }); - return freshToken; - } - - this.logger.warn(`Token refresh failed for ${matrixUserId}`); - return null; - } - - /** - * Fetch token via Matrix-SSO-Link from mana-core-auth - * - * If the user logged into Matrix via OIDC (Sign in with Mana Core), - * their Matrix user ID is linked to their Mana account. - * This method fetches a JWT token for that link. - */ - private async fetchMatrixLinkedToken(matrixUserId: string): Promise { - if (!this.serviceKey) { - this.logger.debug('Matrix-SSO-Link disabled: no service key configured'); - return null; - } - - try { - // Note: mana-core-auth has double prefix due to global prefix + controller prefix - const response = await fetch( - `${this.authUrl}/api/v1/api/v1/auth/matrix-session/${encodeURIComponent(matrixUserId)}`, - { - headers: { - 'X-Service-Key': this.serviceKey, - 'Content-Type': 'application/json', - }, - } - ); - - if (!response.ok) { - // 404 = no link exists, which is normal for users who didn't use OIDC - if (response.status !== 404) { - this.logger.warn(`Matrix-SSO-Link lookup failed: ${response.status}`); - } - return null; - } - - const data = (await response.json()) as { token?: string }; - if (data.token) { - this.logger.log(`Matrix-SSO-Link: auto-login for ${matrixUserId}`); - return data.token; - } - - return null; - } catch (error) { - this.logger.debug(`Matrix-SSO-Link lookup error: ${error}`); - return null; - } - } - - /** - * Store session in Redis and/or memory - */ - private async storeSession(matrixUserId: string, session: UserSession): Promise { - // Store in Redis if available - if (this.useRedis()) { - await this.redisProvider!.setSession(matrixUserId, session); - } - - // Also store in memory as fallback - this.sessions.set(matrixUserId, session); - } - - /** - * Login a Matrix user with mana-core-auth credentials - * - * @param matrixUserId - Matrix user ID (e.g., "@user:matrix.mana.how") - * @param email - User's email - * @param password - User's password - * @returns Login result with success status - */ - async login(matrixUserId: string, email: string, password: string): Promise { - try { - const response = await fetch(`${this.authUrl}${this.loginPath}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { message?: string }; - return { - success: false, - error: errorData.message || 'Authentifizierung fehlgeschlagen', - }; - } - - const data = (await response.json()) as { accessToken?: string; token?: string }; - const token = data.accessToken || data.token; - - if (!token) { - return { success: false, error: 'Kein Token erhalten' }; - } - - // Store session - const session: UserSession = { - token, - email, - expiresAt: new Date(Date.now() + this.sessionExpiryMs), - }; - - await this.storeSession(matrixUserId, session); - - // Store persistent link in mana-core-auth for future auto-login - await this.createMatrixUserLink(matrixUserId, token, email); - - this.logger.log(`User ${matrixUserId} logged in as ${email}`); - return { success: true, email }; - } catch (error) { - this.logger.error(`Login failed for ${matrixUserId}:`, error); - return { - success: false, - error: 'Verbindung zum Auth-Server fehlgeschlagen', - }; - } - } - - /** - * Create a persistent link between Matrix user ID and Mana account - * - * This allows the bot to auto-authenticate the user in the future - * without requiring another !login command. - */ - private async createMatrixUserLink( - matrixUserId: string, - token: string, - email: string - ): Promise { - try { - // Note: mana-core-auth has double prefix due to global prefix + controller prefix - const response = await fetch(`${this.authUrl}/api/v1/api/v1/auth/matrix-user-links`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ matrixUserId, email }), - }); - - if (response.ok) { - this.logger.log(`Matrix-SSO-Link: created link for ${matrixUserId}`); - } else { - // Non-critical - log but don't fail the login - this.logger.debug( - `Matrix-SSO-Link: failed to create link for ${matrixUserId}: ${response.status}` - ); - } - } catch (error) { - // Non-critical - log but don't fail the login - this.logger.debug(`Matrix-SSO-Link: error creating link for ${matrixUserId}: ${error}`); - } - } - - /** - * Logout a Matrix user - */ - async logout(matrixUserId: string): Promise { - // Remove from Redis - if (this.useRedis()) { - await this.redisProvider!.deleteSession(matrixUserId); - } - - // Remove from memory - this.sessions.delete(matrixUserId); - this.logger.log(`User ${matrixUserId} logged out`); - } - - /** - * Check if a Matrix user is logged in - */ - async isLoggedIn(matrixUserId: string): Promise { - const token = await this.getToken(matrixUserId); - return token !== null; - } - - /** - * Get the full session object for a Matrix user - */ - async getSession(matrixUserId: string): Promise { - // Try Redis first - if (this.useRedis()) { - const session = await this.redisProvider!.getSession(matrixUserId); - if (session) return session; - } - - // Try memory - const session = this.sessions.get(matrixUserId); - if (!session) return null; - - // Check expiry - if (session.expiresAt < new Date()) { - this.sessions.delete(matrixUserId); - return null; - } - - return session; - } - - /** - * Get email for a logged-in Matrix user - */ - async getEmail(matrixUserId: string): Promise { - const session = await this.getSession(matrixUserId); - return session?.email || null; - } - - /** - * Store custom data in a user's session - */ - async setSessionData(matrixUserId: string, key: string, value: unknown): Promise { - // Update in Redis - if (this.useRedis()) { - await this.redisProvider!.updateSessionData(matrixUserId, key, value); - } - - // Update in memory - const session = this.sessions.get(matrixUserId); - if (session) { - session.data = session.data || {}; - session.data[key] = value; - } - } - - /** - * Get custom data from a user's session - */ - async getSessionData(matrixUserId: string, key: string): Promise { - // Try Redis first - if (this.useRedis()) { - const data = await this.redisProvider!.getSessionData(matrixUserId, key); - if (data !== null) return data; - } - - // Try memory - const session = await this.getSession(matrixUserId); - return (session?.data?.[key] as T) || null; - } - - /** - * Get total session count (including expired in memory) - */ - getSessionCount(): number { - return this.sessions.size; - } - - /** - * Get count of active (non-expired) sessions - */ - async getActiveSessionCount(): Promise { - let count = 0; - - // Count Redis sessions - if (this.useRedis()) { - count = await this.redisProvider!.getActiveSessionCount(); - } - - // If not using Redis, count memory sessions - if (count === 0) { - const now = new Date(); - for (const session of this.sessions.values()) { - if (session.expiresAt > now) count++; - } - } - - return count; - } - - /** - * Get session statistics - */ - async getStats(): Promise { - const active = await this.getActiveSessionCount(); - return { - total: this.getSessionCount(), - active, - storageMode: this.useRedis() ? 'redis' : 'memory', - matrixSsoLinkEnabled: this.enableMatrixSsoLink, - }; - } - - /** - * Clean up expired sessions (only for in-memory, Redis auto-expires) - */ - cleanupExpiredSessions(): number { - const now = new Date(); - let cleaned = 0; - - for (const [userId, session] of this.sessions.entries()) { - if (session.expiresAt < now) { - this.sessions.delete(userId); - cleaned++; - } - } - - if (cleaned > 0) { - this.logger.log(`Cleaned up ${cleaned} expired sessions`); - } - - return cleaned; - } - - /** - * Get all active session user IDs (memory only) - */ - getActiveUserIds(): string[] { - const now = new Date(); - const userIds: string[] = []; - - for (const [userId, session] of this.sessions.entries()) { - if (session.expiresAt > now) { - userIds.push(userId); - } - } - - return userIds; - } - - /** - * Health check - */ - async healthCheck(): Promise<{ - redis: { status: string; latency: number } | null; - memory: number; - }> { - const redisHealth = this.redisProvider ? await this.redisProvider.healthCheck() : null; - return { - redis: redisHealth, - memory: this.sessions.size, - }; - } -} diff --git a/packages/bot-services/src/session/types.ts b/packages/bot-services/src/session/types.ts deleted file mode 100644 index 4b7f21495..000000000 --- a/packages/bot-services/src/session/types.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Types for Matrix user session management - */ - -/** - * User session data stored per Matrix user - */ -export interface UserSession { - /** JWT token from mana-core-auth */ - token: string; - /** User's email address */ - email: string; - /** Token expiration time */ - expiresAt: Date; - /** Additional session data (bot-specific) */ - data?: Record; -} - -/** - * Login result - */ -export interface LoginResult { - success: boolean; - error?: string; - email?: string; -} - -/** - * Session statistics - */ -export interface SessionStats { - /** Total sessions (including expired) */ - total: number; - /** Active (non-expired) sessions */ - active: number; - /** Storage mode being used */ - storageMode?: 'memory' | 'redis'; - /** Whether Matrix-SSO-Link is enabled */ - matrixSsoLinkEnabled?: boolean; -} - -/** - * Session storage mode - */ -export type SessionStorageMode = 'memory' | 'redis'; - -/** - * Session module configuration options - */ -export interface SessionModuleOptions { - /** Mana Core Auth URL */ - authUrl?: string; - /** Session expiry in milliseconds (default: 7 days) */ - sessionExpiryMs?: number; - /** Custom login endpoint path */ - loginPath?: string; - - // Redis configuration (for cross-bot SSO) - /** Storage mode: 'memory' (default) or 'redis' */ - storageMode?: SessionStorageMode; - /** Redis host (default: localhost) */ - redisHost?: string; - /** Redis port (default: 6379) */ - redisPort?: number; - /** Redis password (optional) */ - redisPassword?: string; - - // Matrix-SSO-Link configuration (automatic login via Matrix OIDC) - /** Enable Matrix-SSO-Link lookup (default: true when using Redis) */ - enableMatrixSsoLink?: boolean; - /** Service key for internal API calls to mana-core-auth */ - serviceKey?: string; -} - -export const SESSION_MODULE_OPTIONS = 'SESSION_MODULE_OPTIONS'; - -/** - * Default session expiry: 7 days in milliseconds - */ -export const DEFAULT_SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; - -/** - * Standard auth error message for all bots - * Used when SSO-Link fails to authenticate the user automatically. - * Since all users authenticate via OIDC, this indicates a technical issue. - */ -export function formatAuthErrorMessage(featureDescription: string): string { - return ( - '⚠️ **Authentifizierung fehlgeschlagen**\n\n' + - `Um ${featureDescription} zu nutzen, muss dein Account verknüpft sein.\n\n` + - 'Bitte logge dich in Element neu ein über "Mit Mana Core anmelden".\n\n' + - 'Falls das Problem weiterhin besteht, kontaktiere den Support.' - ); -} - -/** - * Pre-defined auth error messages for all bot types - */ -export const AUTH_ERROR_MESSAGES = { - clock: formatAuthErrorMessage('Timer und Alarme'), - todo: formatAuthErrorMessage('Aufgaben'), - calendar: formatAuthErrorMessage('Termine'), - contacts: formatAuthErrorMessage('Kontakte'), - picture: formatAuthErrorMessage('Bilder'), - zitare: formatAuthErrorMessage('Favoriten'), - nutriphi: formatAuthErrorMessage('Mahlzeiten'), - chat: formatAuthErrorMessage('Chats'), - storage: formatAuthErrorMessage('Dateien'), - skilltree: formatAuthErrorMessage('Skills'), - presi: formatAuthErrorMessage('Präsentationen'), - planta: formatAuthErrorMessage('Pflanzen'), - manadeck: formatAuthErrorMessage('Decks'), - questions: formatAuthErrorMessage('Fragen'), -} as const; - -/** - * @deprecated Use formatAuthErrorMessage instead. Kept for backwards compatibility. - */ -export const formatLoginRequiredMessage = (featureDescription: string, _appName: string): string => - formatAuthErrorMessage(featureDescription); - -/** - * @deprecated Use AUTH_ERROR_MESSAGES instead. Kept for backwards compatibility. - */ -export const LOGIN_MESSAGES = AUTH_ERROR_MESSAGES; diff --git a/packages/bot-services/src/shared/index.ts b/packages/bot-services/src/shared/index.ts deleted file mode 100644 index f3d1741d5..000000000 --- a/packages/bot-services/src/shared/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Shared types -export * from './types'; - -// Storage providers -export { FileStorageProvider, MemoryStorageProvider } from './storage'; - -// Utility functions -export * from './utils'; diff --git a/packages/bot-services/src/shared/storage.ts b/packages/bot-services/src/shared/storage.ts deleted file mode 100644 index ec8a252ce..000000000 --- a/packages/bot-services/src/shared/storage.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Logger } from '@nestjs/common'; -import * as fs from 'fs'; -import * as path from 'path'; -import { StorageProvider } from './types'; - -/** - * File-based JSON storage provider - * Used for local GDPR-compliant data storage - */ -export class FileStorageProvider implements StorageProvider { - private readonly logger = new Logger(FileStorageProvider.name); - private readonly filePath: string; - private readonly defaultData: T; - - constructor(filePath: string, defaultData: T) { - this.filePath = filePath; - this.defaultData = defaultData; - } - - async load(): Promise { - try { - const dir = path.dirname(this.filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - if (fs.existsSync(this.filePath)) { - const content = fs.readFileSync(this.filePath, 'utf-8'); - return JSON.parse(content); - } else { - await this.save(this.defaultData); - return this.defaultData; - } - } catch (error) { - this.logger.error(`Failed to load data from ${this.filePath}:`, error); - return this.defaultData; - } - } - - async save(data: T): Promise { - try { - const dir = path.dirname(this.filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2)); - } catch (error) { - this.logger.error(`Failed to save data to ${this.filePath}:`, error); - throw error; - } - } -} - -/** - * In-memory storage provider (for testing) - */ -export class MemoryStorageProvider implements StorageProvider { - private data: T; - - constructor(defaultData: T) { - this.data = defaultData; - } - - async load(): Promise { - return this.data; - } - - async save(data: T): Promise { - this.data = data; - } -} diff --git a/packages/bot-services/src/shared/types.ts b/packages/bot-services/src/shared/types.ts deleted file mode 100644 index a956f15df..000000000 --- a/packages/bot-services/src/shared/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Common types used across all bot services - */ - -// Base entity interface -export interface BaseEntity { - id: string; - createdAt: string; - updatedAt?: string; -} - -// User-scoped entity -export interface UserEntity extends BaseEntity { - userId: string; -} - -// Storage provider interface - allows swapping file/db storage -export interface StorageProvider { - load(): Promise; - save(data: T): Promise; -} - -// Service configuration -export interface ServiceConfig { - storagePath?: string; - apiUrl?: string; - timeout?: number; -} - -// Result type for operations -export type Result = { success: true; data: T } | { success: false; error: E }; - -// Pagination -export interface PaginationOptions { - limit?: number; - offset?: number; -} - -export interface PaginatedResult { - items: T[]; - total: number; - limit: number; - offset: number; -} - -// Date range filter -export interface DateRange { - start: Date; - end: Date; -} - -// Priority levels -export type Priority = 'low' | 'medium' | 'high' | 'urgent'; -export const PRIORITY_VALUES: Record = { - urgent: 1, - high: 2, - medium: 3, - low: 4, -}; - -// Common stats interface -export interface ServiceStats { - total: number; - active: number; - completed?: number; -} diff --git a/packages/bot-services/src/shared/utils.ts b/packages/bot-services/src/shared/utils.ts deleted file mode 100644 index bbdb65251..000000000 --- a/packages/bot-services/src/shared/utils.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Utility functions for bot services - */ - -/** - * Generate a unique ID - */ -export function generateId(): string { - return Date.now().toString(36) + Math.random().toString(36).substr(2); -} - -/** - * Get ISO date string for today - */ -export function getTodayISO(): string { - return new Date().toISOString().split('T')[0]; -} - -/** - * Get date at start of day - */ -export function startOfDay(date: Date = new Date()): Date { - const result = new Date(date); - result.setHours(0, 0, 0, 0); - return result; -} - -/** - * Get date at end of day - */ -export function endOfDay(date: Date = new Date()): Date { - const result = new Date(date); - result.setHours(23, 59, 59, 999); - return result; -} - -/** - * Add days to a date - */ -export function addDays(date: Date, days: number): Date { - const result = new Date(date); - result.setDate(result.getDate() + days); - return result; -} - -/** - * Format date for German locale - */ -export function formatDateDE(date: Date, options?: Intl.DateTimeFormatOptions): string { - return date.toLocaleDateString('de-DE', options); -} - -/** - * Format time for German locale - */ -export function formatTimeDE(date: Date): string { - return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); -} - -/** - * Check if date is today - */ -export function isToday(date: Date): boolean { - const today = new Date(); - return ( - date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear() - ); -} - -/** - * Check if date is tomorrow - */ -export function isTomorrow(date: Date): boolean { - const tomorrow = addDays(new Date(), 1); - return ( - date.getDate() === tomorrow.getDate() && - date.getMonth() === tomorrow.getMonth() && - date.getFullYear() === tomorrow.getFullYear() - ); -} - -/** - * Parse German date keywords - */ -export function parseGermanDateKeyword(keyword: string): Date | null { - const lower = keyword.toLowerCase().trim(); - const today = startOfDay(); - - switch (lower) { - case 'heute': - return today; - case 'morgen': - return addDays(today, 1); - case 'übermorgen': - return addDays(today, 2); - default: - return null; - } -} - -/** - * Get relative date label in German - */ -export function getRelativeDateLabel(date: Date): string { - if (isToday(date)) return 'Heute'; - if (isTomorrow(date)) return 'Morgen'; - return formatDateDE(date, { weekday: 'short', day: '2-digit', month: '2-digit' }); -} diff --git a/packages/bot-services/src/stats/index.ts b/packages/bot-services/src/stats/index.ts deleted file mode 100644 index 0970825f1..000000000 --- a/packages/bot-services/src/stats/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Placeholder - to be implemented -// Will integrate with Umami analytics API - -export interface StatsServiceConfig { - apiUrl: string; - username: string; - password: string; -} - -export interface AnalyticsReport { - pageviews: number; - visitors: number; - bounceRate: number; - avgDuration: number; -} - -// Export placeholder module -export const StatsModule = { - register: () => ({ module: class {}, providers: [], exports: [] }), -}; diff --git a/packages/bot-services/src/todo/index.ts b/packages/bot-services/src/todo/index.ts deleted file mode 100644 index c533742f2..000000000 --- a/packages/bot-services/src/todo/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Module -export { TodoModule, TodoModuleOptions } from './todo.module'; - -// Services -export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service'; -export { TodoApiService } from './todo-api.service'; - -// Types -export * from './types'; diff --git a/packages/bot-services/src/todo/todo-api.service.ts b/packages/bot-services/src/todo/todo-api.service.ts deleted file mode 100644 index 774e81e63..000000000 --- a/packages/bot-services/src/todo/todo-api.service.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Task, Project, CreateTaskInput, TaskFilter, TodoStats, ParsedTaskInput } from './types'; -import { parseGermanDateKeyword } from '../shared/utils'; - -/** - * Todo API Service - * - * Connects to the todo-backend API for task management. - * This service is used when the user is logged in and has a valid JWT token. - * It provides the same interface as TodoService but uses HTTP calls instead of local storage. - * - * @example - * ```typescript - * // Get tasks for a user (requires JWT token) - * const tasks = await todoApiService.getTasks(token); - * - * // Create a task - * const task = await todoApiService.createTask(token, { title: 'Buy groceries' }); - * ``` - */ -@Injectable() -export class TodoApiService { - private readonly logger = new Logger(TodoApiService.name); - private readonly baseUrl: string; - - constructor(baseUrl = 'http://localhost:3018') { - this.baseUrl = baseUrl; - this.logger.log(`Todo API Service initialized with URL: ${baseUrl}`); - } - - // ===== Task Operations ===== - - /** - * Get all pending tasks for the user - */ - async getTasks(token: string, filter?: TaskFilter): Promise { - try { - const params = new URLSearchParams(); - if (filter?.completed !== undefined) params.append('completed', String(filter.completed)); - if (filter?.project) params.append('projectId', filter.project); - - const response = await fetch(`${this.baseUrl}/api/v1/tasks?${params}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { tasks?: unknown[] }; - return this.mapApiTasks(data.tasks || []); - } catch (error) { - this.logger.error('Failed to get tasks:', error); - return []; - } - } - - /** - * Get today's tasks - */ - async getTodayTasks(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/tasks/today`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { tasks?: any[] }; - return this.mapApiTasks(data.tasks || []); - } catch (error) { - this.logger.error('Failed to get today tasks:', error); - return []; - } - } - - /** - * Get inbox tasks (tasks without a project) - */ - async getInboxTasks(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/tasks/inbox`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { tasks?: any[] }; - return this.mapApiTasks(data.tasks || []); - } catch (error) { - this.logger.error('Failed to get inbox tasks:', error); - return []; - } - } - - /** - * Get upcoming tasks - */ - async getUpcomingTasks(token: string, days = 7): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/tasks/upcoming?days=${days}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { tasks?: any[] }; - return this.mapApiTasks(data.tasks || []); - } catch (error) { - this.logger.error('Failed to get upcoming tasks:', error); - return []; - } - } - - /** - * Create a new task - */ - async createTask(token: string, input: CreateTaskInput): Promise { - try { - const body: Record = { - title: input.title, - priority: this.mapPriorityToApi(input.priority), - }; - - if (input.dueDate) { - body.dueDate = input.dueDate; - } - - // Note: Project handling would need project ID lookup - // For now, we skip project assignment via bot - - const response = await fetch(`${this.baseUrl}/api/v1/tasks`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as Record; - return this.mapApiTask(data.task); - } catch (error) { - this.logger.error('Failed to create task:', error); - return null; - } - } - - /** - * Complete a task - */ - async completeTask(token: string, taskId: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/tasks/${taskId}/complete`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as Record; - return this.mapApiTask(data.task); - } catch (error) { - this.logger.error('Failed to complete task:', error); - return null; - } - } - - /** - * Delete a task - */ - async deleteTask(token: string, taskId: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/tasks/${taskId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - - return response.ok; - } catch (error) { - this.logger.error('Failed to delete task:', error); - return false; - } - } - - // ===== Project Operations ===== - - /** - * Get all projects - */ - async getProjects(token: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/projects`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { projects?: any[] }; - return (data.projects || []).map((p: any) => ({ - id: p.id, - name: p.name, - color: p.color, - userId: '', // Not needed for bot - })); - } catch (error) { - this.logger.error('Failed to get projects:', error); - return []; - } - } - - /** - * Get tasks for a specific project - */ - async getProjectTasks(token: string, projectId: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v1/tasks?projectId=${projectId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = (await response.json()) as { tasks?: any[] }; - return this.mapApiTasks(data.tasks || []); - } catch (error) { - this.logger.error('Failed to get project tasks:', error); - return []; - } - } - - // ===== Stats ===== - - /** - * Get task statistics - */ - async getStats(token: string): Promise { - try { - // Get all tasks and calculate stats - const allTasks = await this.getTasks(token); - const todayTasks = await this.getTodayTasks(token); - - const pending = allTasks.filter((t) => !t.completed).length; - const completed = allTasks.filter((t) => t.completed).length; - - return { - total: allTasks.length, - pending, - completed, - today: todayTasks.length, - overdue: 0, // Would need to calculate based on due dates - }; - } catch (error) { - this.logger.error('Failed to get stats:', error); - return { total: 0, pending: 0, completed: 0, today: 0, overdue: 0 }; - } - } - - // ===== Parsing (reused from TodoService) ===== - - /** - * Parse natural language task input - */ - parseTaskInput(input: string): ParsedTaskInput { - let title = input; - let priority = 4; - let dueDate: string | null = null; - let project: string | null = null; - - // Extract priority (!p1, !p2, !p3, !p4 or !, !!, !!!) - const priorityMatch = title.match(/!p([1-4])\b/i); - if (priorityMatch) { - priority = parseInt(priorityMatch[1]); - title = title.replace(priorityMatch[0], '').trim(); - } else { - const exclamationMatch = title.match(/(!{1,3})(?:\s|$)/); - if (exclamationMatch) { - priority = Math.max(1, 4 - exclamationMatch[1].length); - title = title.replace(exclamationMatch[0], '').trim(); - } - } - - // Extract date (@heute, @morgen, @übermorgen, or date) - const dateMatch = title.match(/@(\S+)/); - if (dateMatch) { - const dateStr = dateMatch[1].toLowerCase(); - const parsedDate = parseGermanDateKeyword(dateStr); - - if (parsedDate) { - dueDate = parsedDate.toISOString().split('T')[0]; - } else { - // Try parsing as date (DD.MM or DD.MM.YYYY) - const dateRegex = /(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?/; - const match = dateStr.match(dateRegex); - if (match) { - const day = parseInt(match[1]); - const month = parseInt(match[2]) - 1; - const year = match[3] ? parseInt(match[3]) : new Date().getFullYear(); - const date = new Date(year, month, day); - dueDate = date.toISOString().split('T')[0]; - } - } - title = title.replace(dateMatch[0], '').trim(); - } - - // Extract project (#projectname) - const projectMatch = title.match(/#(\S+)/); - if (projectMatch) { - project = projectMatch[1]; - title = title.replace(projectMatch[0], '').trim(); - } - - return { title: title.trim(), priority, dueDate, project }; - } - - // ===== Private Helpers ===== - - /** - * Map API task format to internal Task format - */ - private mapApiTask(apiTask: any): Task { - return { - id: apiTask.id, - userId: apiTask.userId || '', - title: apiTask.title, - completed: apiTask.isCompleted || false, - priority: this.mapApiPriority(apiTask.priority), - dueDate: apiTask.dueDate ? apiTask.dueDate.split('T')[0] : null, - project: apiTask.project?.name || null, - labels: apiTask.labels?.map((l: any) => l.name) || [], - createdAt: apiTask.createdAt, - completedAt: apiTask.completedAt, - }; - } - - /** - * Map array of API tasks - */ - private mapApiTasks(apiTasks: any[]): Task[] { - return apiTasks.map((t) => this.mapApiTask(t)); - } - - /** - * Map internal priority (1-4) to API priority (urgent/high/medium/low) - */ - private mapPriorityToApi(priority?: number): string { - switch (priority) { - case 1: - return 'urgent'; - case 2: - return 'high'; - case 3: - return 'medium'; - case 4: - default: - return 'low'; - } - } - - /** - * Map API priority to internal priority (1-4) - */ - private mapApiPriority(apiPriority?: string): number { - switch (apiPriority) { - case 'urgent': - return 1; - case 'high': - return 2; - case 'medium': - return 3; - case 'low': - default: - return 4; - } - } -} diff --git a/packages/bot-services/src/todo/todo.module.ts b/packages/bot-services/src/todo/todo.module.ts deleted file mode 100644 index 774671fa7..000000000 --- a/packages/bot-services/src/todo/todo.module.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Module, DynamicModule, Provider, Type, ModuleMetadata } from '@nestjs/common'; -import { TodoService, TODO_STORAGE_PROVIDER } from './todo.service'; -import { StorageProvider } from '../shared/types'; -import { FileStorageProvider } from '../shared/storage'; -import { TodoData } from './types'; - -export interface TodoModuleOptions { - storagePath?: string; - storageProvider?: StorageProvider; -} - -export interface TodoModuleAsyncOptions extends Pick { - useFactory: (...args: unknown[]) => Promise | TodoModuleOptions; - inject?: (Type | string | symbol)[]; -} - -@Module({}) -export class TodoModule { - /** - * Register with default file storage - */ - static register(options?: TodoModuleOptions): DynamicModule { - const storagePath = options?.storagePath ?? './data/todo-data.json'; - const defaultData: TodoData = { tasks: [], projects: [] }; - - return { - module: TodoModule, - providers: [ - { - provide: TODO_STORAGE_PROVIDER, - useValue: - options?.storageProvider ?? new FileStorageProvider(storagePath, defaultData), - }, - TodoService, - ], - exports: [TodoService], - }; - } - - /** - * Register with custom storage provider - */ - static forRoot(storageProvider: StorageProvider): DynamicModule { - return { - module: TodoModule, - providers: [ - { - provide: TODO_STORAGE_PROVIDER, - useValue: storageProvider, - }, - TodoService, - ], - exports: [TodoService], - }; - } - - /** - * Register asynchronously with factory function - */ - static registerAsync(options: TodoModuleAsyncOptions): DynamicModule { - const storageProvider: Provider = { - provide: TODO_STORAGE_PROVIDER, - useFactory: async (...args: unknown[]) => { - const moduleOptions = await options.useFactory(...args); - const storagePath = moduleOptions?.storagePath ?? './data/todo-data.json'; - const defaultData: TodoData = { tasks: [], projects: [] }; - return ( - moduleOptions?.storageProvider ?? - new FileStorageProvider(storagePath, defaultData) - ); - }, - inject: options.inject || [], - }; - - return { - module: TodoModule, - imports: options.imports || [], - providers: [storageProvider, TodoService], - exports: [TodoService], - }; - } -} diff --git a/packages/bot-services/src/todo/todo.service.ts b/packages/bot-services/src/todo/todo.service.ts deleted file mode 100644 index cd91b7286..000000000 --- a/packages/bot-services/src/todo/todo.service.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common'; -import { StorageProvider } from '../shared/types'; -import { FileStorageProvider } from '../shared/storage'; -import { generateId, getTodayISO, parseGermanDateKeyword, addDays } from '../shared/utils'; -import { - Task, - Project, - TodoData, - CreateTaskInput, - UpdateTaskInput, - TaskFilter, - TodoStats, - ParsedTaskInput, -} from './types'; - -export const TODO_STORAGE_PROVIDER = 'TODO_STORAGE_PROVIDER'; - -@Injectable() -export class TodoService implements OnModuleInit { - private readonly logger = new Logger(TodoService.name); - private data: TodoData = { tasks: [], projects: [] }; - private storage: StorageProvider; - - constructor( - @Optional() - @Inject(TODO_STORAGE_PROVIDER) - storage?: StorageProvider - ) { - // Default to file storage if not injected - this.storage = - storage || new FileStorageProvider('./data/todo-data.json', { tasks: [], projects: [] }); - } - - async onModuleInit() { - await this.loadData(); - } - - private async loadData(): Promise { - try { - this.data = await this.storage.load(); - this.logger.log(`Loaded ${this.data.tasks.length} tasks, ${this.data.projects.length} projects`); - } catch (error) { - this.logger.error('Failed to load todo data:', error); - this.data = { tasks: [], projects: [] }; - } - } - - private async saveData(): Promise { - try { - await this.storage.save(this.data); - } catch (error) { - this.logger.error('Failed to save todo data:', error); - } - } - - // ===== Task CRUD Operations ===== - - async createTask(userId: string, input: CreateTaskInput): Promise { - const task: Task = { - id: generateId(), - userId, - title: input.title, - completed: false, - priority: input.priority ?? 4, - dueDate: input.dueDate ?? null, - project: input.project ?? null, - labels: input.labels ?? [], - createdAt: new Date().toISOString(), - completedAt: null, - }; - - this.data.tasks.push(task); - await this.saveData(); - this.logger.log(`Created task "${task.title}" for user ${userId}`); - return task; - } - - async updateTask(userId: string, taskId: string, input: UpdateTaskInput): Promise { - const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId); - if (!task) return null; - - if (input.title !== undefined) task.title = input.title; - if (input.priority !== undefined) task.priority = input.priority; - if (input.dueDate !== undefined) task.dueDate = input.dueDate; - if (input.project !== undefined) task.project = input.project; - if (input.labels !== undefined) task.labels = input.labels; - task.updatedAt = new Date().toISOString(); - - await this.saveData(); - return task; - } - - async deleteTask(userId: string, taskId: string): Promise { - const taskIndex = this.data.tasks.findIndex((t) => t.id === taskId && t.userId === userId); - if (taskIndex === -1) return null; - - const [task] = this.data.tasks.splice(taskIndex, 1); - await this.saveData(); - this.logger.log(`Deleted task "${task.title}" for user ${userId}`); - return task; - } - - async deleteTaskByIndex(userId: string, index: number): Promise { - const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed); - if (index < 1 || index > userTasks.length) return null; - - const task = userTasks[index - 1]; - return this.deleteTask(userId, task.id); - } - - // ===== Task Completion ===== - - async completeTask(userId: string, taskId: string): Promise { - const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId); - if (!task) return null; - - task.completed = true; - task.completedAt = new Date().toISOString(); - await this.saveData(); - this.logger.log(`Completed task "${task.title}" for user ${userId}`); - return task; - } - - async completeTaskByIndex(userId: string, index: number): Promise { - const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed); - if (index < 1 || index > userTasks.length) return null; - - const task = userTasks[index - 1]; - return this.completeTask(userId, task.id); - } - - async uncompleteTask(userId: string, taskId: string): Promise { - const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId); - if (!task) return null; - - task.completed = false; - task.completedAt = null; - await this.saveData(); - return task; - } - - // ===== Task Queries ===== - - async getTask(userId: string, taskId: string): Promise { - return this.data.tasks.find((t) => t.id === taskId && t.userId === userId) ?? null; - } - - async getTasks(userId: string, filter?: TaskFilter): Promise { - let tasks = this.data.tasks.filter((t) => t.userId === userId); - - if (filter) { - if (filter.completed !== undefined) { - tasks = tasks.filter((t) => t.completed === filter.completed); - } - if (filter.project) { - tasks = tasks.filter((t) => t.project?.toLowerCase() === filter.project!.toLowerCase()); - } - if (filter.dueDate) { - tasks = tasks.filter((t) => t.dueDate?.startsWith(filter.dueDate!)); - } - if (filter.dueBefore) { - tasks = tasks.filter((t) => t.dueDate && t.dueDate < filter.dueBefore!); - } - if (filter.dueAfter) { - tasks = tasks.filter((t) => t.dueDate && t.dueDate > filter.dueAfter!); - } - if (filter.priority) { - tasks = tasks.filter((t) => t.priority === filter.priority); - } - if (filter.labels && filter.labels.length > 0) { - tasks = tasks.filter((t) => filter.labels!.some((l) => t.labels.includes(l))); - } - } - - return tasks; - } - - async getAllPendingTasks(userId: string): Promise { - return this.data.tasks - .filter((t) => t.userId === userId && !t.completed) - .sort((a, b) => { - // Sort by due date first (nulls last), then by priority - if (a.dueDate && !b.dueDate) return -1; - if (!a.dueDate && b.dueDate) return 1; - if (a.dueDate && b.dueDate) { - const dateCompare = a.dueDate.localeCompare(b.dueDate); - if (dateCompare !== 0) return dateCompare; - } - return a.priority - b.priority; - }); - } - - async getTodayTasks(userId: string): Promise { - const today = getTodayISO(); - return this.data.tasks - .filter((t) => t.userId === userId && !t.completed && t.dueDate?.startsWith(today)) - .sort((a, b) => a.priority - b.priority); - } - - async getOverdueTasks(userId: string): Promise { - const today = getTodayISO(); - return this.data.tasks - .filter((t) => t.userId === userId && !t.completed && t.dueDate && t.dueDate < today) - .sort((a, b) => a.dueDate!.localeCompare(b.dueDate!)); - } - - async getInboxTasks(userId: string): Promise { - return this.data.tasks - .filter((t) => t.userId === userId && !t.completed && !t.dueDate && !t.project) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - } - - async getProjectTasks(userId: string, projectName: string): Promise { - return this.data.tasks - .filter( - (t) => t.userId === userId && !t.completed && t.project?.toLowerCase() === projectName.toLowerCase() - ) - .sort((a, b) => a.priority - b.priority); - } - - // ===== Projects ===== - - async getProjects(userId: string): Promise { - const projectNames = new Set(); - this.data.tasks - .filter((t) => t.userId === userId && t.project) - .forEach((t) => projectNames.add(t.project!)); - - return Array.from(projectNames).map((name) => ({ - id: name.toLowerCase(), - name, - color: '#808080', - userId, - })); - } - - // ===== Statistics ===== - - async getStats(userId: string): Promise { - const userTasks = this.data.tasks.filter((t) => t.userId === userId); - const today = getTodayISO(); - - return { - total: userTasks.length, - completed: userTasks.filter((t) => t.completed).length, - pending: userTasks.filter((t) => !t.completed).length, - today: userTasks.filter((t) => !t.completed && t.dueDate?.startsWith(today)).length, - overdue: userTasks.filter((t) => !t.completed && t.dueDate && t.dueDate < today).length, - }; - } - - // ===== Input Parsing ===== - - /** - * Parse natural language task input - * Supports: !p1-4 (priority), @heute/@morgen/@übermorgen (date), #project - */ - parseTaskInput(input: string): ParsedTaskInput { - let title = input; - let priority = 4; - let dueDate: string | null = null; - let project: string | null = null; - - // Parse priority (!p1, !p2, !p3, !p4) - const priorityMatch = title.match(/!p([1-4])/i); - if (priorityMatch) { - priority = parseInt(priorityMatch[1]); - title = title.replace(/!p[1-4]/i, '').trim(); - } - - // Parse date (@heute, @morgen, @übermorgen) - const dateKeywords = ['heute', 'morgen', 'übermorgen']; - for (const keyword of dateKeywords) { - const regex = new RegExp(`@${keyword}`, 'i'); - if (regex.test(title)) { - const date = parseGermanDateKeyword(keyword); - if (date) { - dueDate = date.toISOString().split('T')[0]; - } - title = title.replace(regex, '').trim(); - break; - } - } - - // Parse project (#projektname) - const projectMatch = title.match(/#(\S+)/); - if (projectMatch) { - project = projectMatch[1]; - title = title.replace(/#\S+/, '').trim(); - } - - return { title, priority, dueDate, project }; - } -} diff --git a/packages/bot-services/src/todo/types.ts b/packages/bot-services/src/todo/types.ts deleted file mode 100644 index d7d5f088d..000000000 --- a/packages/bot-services/src/todo/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { UserEntity, Priority } from '../shared/types'; - -/** - * Task entity - */ -export interface Task extends UserEntity { - title: string; - completed: boolean; - priority: number; // 1-4, 1 is highest (for backward compatibility) - dueDate: string | null; // ISO date string - project: string | null; - labels: string[]; - completedAt: string | null; -} - -/** - * Project entity - */ -export interface Project { - id: string; - name: string; - color: string; - userId: string; -} - -/** - * Todo data storage structure - */ -export interface TodoData { - tasks: Task[]; - projects: Project[]; -} - -/** - * Create task input - */ -export interface CreateTaskInput { - title: string; - priority?: number; - dueDate?: string | null; - project?: string | null; - labels?: string[]; -} - -/** - * Update task input - */ -export interface UpdateTaskInput { - title?: string; - priority?: number; - dueDate?: string | null; - project?: string | null; - labels?: string[]; -} - -/** - * Task filter options - */ -export interface TaskFilter { - completed?: boolean; - project?: string; - dueDate?: string; - dueBefore?: string; - dueAfter?: string; - priority?: number; - labels?: string[]; -} - -/** - * Todo statistics - */ -export interface TodoStats { - total: number; - completed: number; - pending: number; - today: number; - overdue: number; -} - -/** - * Parsed task input (from natural language) - */ -export interface ParsedTaskInput { - title: string; - priority: number; - dueDate: string | null; - project: string | null; -} diff --git a/packages/bot-services/src/transcription/index.ts b/packages/bot-services/src/transcription/index.ts deleted file mode 100644 index 26f9080c9..000000000 --- a/packages/bot-services/src/transcription/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { TranscriptionService } from './transcription.service'; -export { TranscriptionModule } from './transcription.module'; -export type { SttResponse, TranscriptionOptions, TranscriptionModuleOptions } from './types'; -export { STT_MODULE_OPTIONS } from './types'; diff --git a/packages/bot-services/src/transcription/transcription.module.ts b/packages/bot-services/src/transcription/transcription.module.ts deleted file mode 100644 index 2f0a2ef91..000000000 --- a/packages/bot-services/src/transcription/transcription.module.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { TranscriptionService } from './transcription.service'; -import { TranscriptionModuleOptions, STT_MODULE_OPTIONS } from './types'; - -/** - * Shared Speech-to-Text transcription module - * - * Provides TranscriptionService for voice command processing in Matrix bots. - * - * @example - * ```typescript - * // With explicit configuration - * @Module({ - * imports: [ - * TranscriptionModule.register({ - * sttUrl: 'http://mana-stt:3020', - * defaultLanguage: 'de' - * }) - * ] - * }) - * - * // With ConfigService (reads from stt.url or STT_URL) - * @Module({ - * imports: [TranscriptionModule.forRoot()] - * }) - * ``` - */ -@Global() -@Module({}) -export class TranscriptionModule { - /** - * Register module with explicit options - */ - static register(options: TranscriptionModuleOptions = {}): DynamicModule { - return { - module: TranscriptionModule, - imports: [ConfigModule], - providers: [ - { - provide: STT_MODULE_OPTIONS, - useValue: options, - }, - TranscriptionService, - ], - exports: [TranscriptionService], - }; - } - - /** - * Register module with ConfigService (reads stt.url or STT_URL from config) - */ - static forRoot(): DynamicModule { - return { - module: TranscriptionModule, - imports: [ConfigModule], - providers: [TranscriptionService], - exports: [TranscriptionService], - }; - } -} diff --git a/packages/bot-services/src/transcription/transcription.service.ts b/packages/bot-services/src/transcription/transcription.service.ts deleted file mode 100644 index 5a0b9f2b9..000000000 --- a/packages/bot-services/src/transcription/transcription.service.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - SttResponse, - TranscriptionOptions, - STT_MODULE_OPTIONS, - TranscriptionModuleOptions, -} from './types'; - -/** - * Shared Speech-to-Text transcription service - * - * Connects to mana-stt service to transcribe audio files. - * Used by Matrix bots for voice command processing. - * - * @example - * ```typescript - * // In NestJS module - * imports: [TranscriptionModule.register({ sttUrl: 'http://mana-stt:3020' })] - * - * // In service - * const text = await transcriptionService.transcribe(audioBuffer, { language: 'de' }); - * ``` - */ -@Injectable() -export class TranscriptionService { - private readonly logger = new Logger(TranscriptionService.name); - private readonly sttUrl: string; - private readonly defaultLanguage: string; - private readonly apiKey: string; - - constructor( - @Optional() private configService: ConfigService, - @Optional() @Inject(STT_MODULE_OPTIONS) private options?: TranscriptionModuleOptions - ) { - // Priority: module options > config > environment > default - this.sttUrl = - options?.sttUrl || - this.configService?.get('stt.url') || - this.configService?.get('STT_URL') || - 'http://localhost:3020'; - - this.defaultLanguage = options?.defaultLanguage || 'de'; - - this.apiKey = - options?.apiKey || - this.configService?.get('stt.apiKey') || - this.configService?.get('STT_API_KEY') || - ''; - - this.logger.log(`STT Service URL: ${this.sttUrl}`); - } - - /** - * Transcribe audio buffer to text - * - * @param audioBuffer - Audio data (supports ogg, wav, mp3, etc.) - * @param options - Transcription options (language, model) - * @returns Transcribed text - */ - async transcribe(audioBuffer: Buffer, options?: TranscriptionOptions): Promise { - const language = options?.language || this.defaultLanguage; - - const formData = new FormData(); - const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/ogg' }); - formData.append('file', blob, 'audio.ogg'); - formData.append('language', language); - - if (options?.model) { - formData.append('model', options.model); - } - - try { - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(`${this.sttUrl}/transcribe`, { - method: 'POST', - headers, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`STT service error: ${response.status} - ${errorText}`); - } - - const result = (await response.json()) as SttResponse; - this.logger.log(`Transcription completed: ${result.text.substring(0, 50)}...`); - return result.text; - } catch (error) { - this.logger.error('Transcription failed:', error); - throw error; - } - } - - /** - * Transcribe audio and return full response with metadata - */ - async transcribeWithMetadata( - audioBuffer: Buffer, - options?: TranscriptionOptions - ): Promise { - const language = options?.language || this.defaultLanguage; - - const formData = new FormData(); - const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/ogg' }); - formData.append('file', blob, 'audio.ogg'); - formData.append('language', language); - - if (options?.model) { - formData.append('model', options.model); - } - - try { - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(`${this.sttUrl}/transcribe`, { - method: 'POST', - headers, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`STT service error: ${response.status} - ${errorText}`); - } - - return (await response.json()) as SttResponse; - } catch (error) { - this.logger.error('Transcription failed:', error); - throw error; - } - } - - /** - * Check if STT service is healthy - */ - async checkHealth(): Promise { - try { - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(`${this.sttUrl}/health`, { headers }); - return response.ok; - } catch { - return false; - } - } - - /** - * Get STT service URL (for debugging/logging) - */ - getSttUrl(): string { - return this.sttUrl; - } -} diff --git a/packages/bot-services/src/transcription/types.ts b/packages/bot-services/src/transcription/types.ts deleted file mode 100644 index b33cdea82..000000000 --- a/packages/bot-services/src/transcription/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Types for Speech-to-Text transcription service - */ - -export interface SttResponse { - text: string; - language?: string; - model?: string; - duration?: number; -} - -export interface TranscriptionOptions { - language?: string; - model?: string; -} - -export interface TranscriptionModuleOptions { - sttUrl?: string; - defaultLanguage?: string; - apiKey?: string; -} - -export const STT_MODULE_OPTIONS = 'STT_MODULE_OPTIONS'; diff --git a/packages/bot-services/src/weather/index.ts b/packages/bot-services/src/weather/index.ts deleted file mode 100644 index bea89ce95..000000000 --- a/packages/bot-services/src/weather/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Weather Service - * - * Open-Meteo weather API integration for morning summaries and weather queries. - * - * @example - * ```typescript - * import { WeatherModule, WeatherService } from '@manacore/bot-services/weather'; - * - * // In module - * @Module({ - * imports: [WeatherModule.register({ defaultLocation: 'Berlin' })] - * }) - * - * // In service - * const weather = await weatherService.getWeather('Berlin'); - * console.log(weatherService.formatWeather(weather)); - * ``` - */ - -export { WeatherModule } from './weather.module.js'; -export { WeatherService } from './weather.service.js'; -export { - WeatherModuleOptions, - WeatherData, - WeatherCode, - GeocodingResult, - WEATHER_MODULE_OPTIONS, - DEFAULT_CACHE_TTL_MS, - WEATHER_DESCRIPTIONS_DE, - WEATHER_DESCRIPTIONS_EN, -} from './types.js'; diff --git a/packages/bot-services/src/weather/types.ts b/packages/bot-services/src/weather/types.ts deleted file mode 100644 index 56a2ec11b..000000000 --- a/packages/bot-services/src/weather/types.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Weather Service Types - * - * Types for Open-Meteo weather API integration - */ - -/** - * Weather condition codes from Open-Meteo - * See: https://open-meteo.com/en/docs#weathervariables - */ -export type WeatherCode = - | 0 // Clear sky - | 1 - | 2 - | 3 // Mainly clear, partly cloudy, overcast - | 45 - | 48 // Fog and depositing rime fog - | 51 - | 53 - | 55 // Drizzle: Light, moderate, dense - | 56 - | 57 // Freezing drizzle: Light, dense - | 61 - | 63 - | 65 // Rain: Slight, moderate, heavy - | 66 - | 67 // Freezing rain: Light, heavy - | 71 - | 73 - | 75 // Snow fall: Slight, moderate, heavy - | 77 // Snow grains - | 80 - | 81 - | 82 // Rain showers: Slight, moderate, violent - | 85 - | 86 // Snow showers: Slight, heavy - | 95 // Thunderstorm: Slight or moderate - | 96 - | 99; // Thunderstorm with slight and heavy hail - -/** - * Weather data structure - */ -export interface WeatherData { - location: string; - temperature: number; - apparentTemperature: number; - humidity: number; - precipitation: number; - precipitationProbability: number; - windSpeed: number; - windDirection: number; - weatherCode: WeatherCode; - weatherDescription: string; - isDay: boolean; - fetchedAt: Date; -} - -/** - * Geocoding result from Open-Meteo - */ -export interface GeocodingResult { - id: number; - name: string; - latitude: number; - longitude: number; - country: string; - countryCode: string; - timezone: string; - admin1?: string; // State/Province -} - -/** - * Open-Meteo geocoding API response - */ -export interface GeocodingApiResponse { - results?: GeocodingResult[]; - generationtime_ms?: number; -} - -/** - * Open-Meteo current weather API response - */ -export interface WeatherApiResponse { - latitude: number; - longitude: number; - timezone: string; - current: { - time: string; - interval: number; - temperature_2m: number; - apparent_temperature: number; - relative_humidity_2m: number; - precipitation: number; - weather_code: WeatherCode; - wind_speed_10m: number; - wind_direction_10m: number; - is_day: number; - }; - hourly?: { - time: string[]; - precipitation_probability: number[]; - }; -} - -/** - * Weather service configuration - */ -export interface WeatherServiceConfig { - defaultLocation?: string; - cacheTtlMs?: number; - language?: 'de' | 'en'; -} - -/** - * Weather module options - */ -export interface WeatherModuleOptions { - defaultLocation?: string; - cacheTtlMs?: number; - language?: 'de' | 'en'; -} - -/** - * Injection token for weather module options - */ -export const WEATHER_MODULE_OPTIONS = 'WEATHER_MODULE_OPTIONS'; - -/** - * Default cache TTL: 30 minutes - */ -export const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000; - -/** - * Weather code to German description mapping - */ -export const WEATHER_DESCRIPTIONS_DE: Record = { - 0: 'Klar', - 1: 'Ueberwiegend klar', - 2: 'Teilweise bewoelkt', - 3: 'Bedeckt', - 45: 'Nebel', - 48: 'Gefrierender Nebel', - 51: 'Leichter Nieselregen', - 53: 'Nieselregen', - 55: 'Starker Nieselregen', - 56: 'Leichter gefrierender Nieselregen', - 57: 'Starker gefrierender Nieselregen', - 61: 'Leichter Regen', - 63: 'Regen', - 65: 'Starker Regen', - 66: 'Leichter gefrierender Regen', - 67: 'Starker gefrierender Regen', - 71: 'Leichter Schneefall', - 73: 'Schneefall', - 75: 'Starker Schneefall', - 77: 'Schneegriesel', - 80: 'Leichte Regenschauer', - 81: 'Regenschauer', - 82: 'Heftige Regenschauer', - 85: 'Leichte Schneeschauer', - 86: 'Starke Schneeschauer', - 95: 'Gewitter', - 96: 'Gewitter mit leichtem Hagel', - 99: 'Gewitter mit starkem Hagel', -}; - -/** - * Weather code to English description mapping - */ -export const WEATHER_DESCRIPTIONS_EN: Record = { - 0: 'Clear sky', - 1: 'Mainly clear', - 2: 'Partly cloudy', - 3: 'Overcast', - 45: 'Fog', - 48: 'Depositing rime fog', - 51: 'Light drizzle', - 53: 'Moderate drizzle', - 55: 'Dense drizzle', - 56: 'Light freezing drizzle', - 57: 'Dense freezing drizzle', - 61: 'Slight rain', - 63: 'Moderate rain', - 65: 'Heavy rain', - 66: 'Light freezing rain', - 67: 'Heavy freezing rain', - 71: 'Slight snow fall', - 73: 'Moderate snow fall', - 75: 'Heavy snow fall', - 77: 'Snow grains', - 80: 'Slight rain showers', - 81: 'Moderate rain showers', - 82: 'Violent rain showers', - 85: 'Slight snow showers', - 86: 'Heavy snow showers', - 95: 'Thunderstorm', - 96: 'Thunderstorm with slight hail', - 99: 'Thunderstorm with heavy hail', -}; diff --git a/packages/bot-services/src/weather/weather.module.ts b/packages/bot-services/src/weather/weather.module.ts deleted file mode 100644 index b1f3cfc72..000000000 --- a/packages/bot-services/src/weather/weather.module.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Module, DynamicModule, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { WeatherService } from './weather.service'; -import { WeatherModuleOptions, WEATHER_MODULE_OPTIONS } from './types'; - -/** - * Weather Module - * - * Provides weather data via Open-Meteo API. - * No API key required - completely free! - * - * @example - * ```typescript - * // Basic usage - * @Module({ - * imports: [WeatherModule.register()] - * }) - * - * // With options - * @Module({ - * imports: [ - * WeatherModule.register({ - * defaultLocation: 'Berlin', - * cacheTtlMs: 15 * 60 * 1000, // 15 minutes - * language: 'de', - * }) - * ] - * }) - * - * // With ConfigService - * @Module({ - * imports: [ - * WeatherModule.registerAsync({ - * imports: [ConfigModule], - * useFactory: (config: ConfigService) => ({ - * defaultLocation: config.get('weather.defaultLocation'), - * }), - * inject: [ConfigService], - * }) - * ] - * }) - * ``` - */ -@Global() -@Module({}) -export class WeatherModule { - /** - * Register module with explicit options - */ - static register(options: WeatherModuleOptions = {}): DynamicModule { - return { - module: WeatherModule, - providers: [ - { - provide: WEATHER_MODULE_OPTIONS, - useValue: options, - }, - WeatherService, - ], - exports: [WeatherService], - }; - } - - /** - * Register module with async configuration - */ - static registerAsync(options: { - imports?: any[]; - useFactory: (...args: any[]) => Promise | WeatherModuleOptions; - inject?: any[]; - }): DynamicModule { - return { - module: WeatherModule, - imports: [...(options.imports || [])], - providers: [ - { - provide: WEATHER_MODULE_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }, - WeatherService, - ], - exports: [WeatherService], - }; - } - - /** - * Register with ConfigService reading from environment - * - * Environment variables: - * - WEATHER_DEFAULT_LOCATION: Default city name - * - WEATHER_CACHE_TTL_MS: Cache TTL in milliseconds - * - WEATHER_LANGUAGE: 'de' or 'en' - */ - static forRoot(): DynamicModule { - return this.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - defaultLocation: - config.get('weather.defaultLocation') || - config.get('WEATHER_DEFAULT_LOCATION') || - 'Berlin', - cacheTtlMs: - config.get('weather.cacheTtlMs') || - config.get('WEATHER_CACHE_TTL_MS') || - 30 * 60 * 1000, - language: - (config.get('weather.language') as 'de' | 'en') || - (config.get('WEATHER_LANGUAGE') as 'de' | 'en') || - 'de', - }), - inject: [ConfigService], - }); - } -} diff --git a/packages/bot-services/src/weather/weather.service.ts b/packages/bot-services/src/weather/weather.service.ts deleted file mode 100644 index aa1c0ac8f..000000000 --- a/packages/bot-services/src/weather/weather.service.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; -import { - WeatherData, - WeatherCode, - GeocodingResult, - GeocodingApiResponse, - WeatherApiResponse, - WeatherModuleOptions, - WEATHER_MODULE_OPTIONS, - DEFAULT_CACHE_TTL_MS, - WEATHER_DESCRIPTIONS_DE, - WEATHER_DESCRIPTIONS_EN, -} from './types'; - -/** - * Weather Service - * - * Provides weather data via Open-Meteo API (free, no API key required). - * - * Features: - * - Geocoding: City name -> coordinates - * - Current weather with detailed conditions - * - In-memory caching (30 min default) - * - German and English weather descriptions - * - * @example - * ```typescript - * const weather = await weatherService.getWeather('Berlin'); - * console.log(`${weather.temperature}°C, ${weather.weatherDescription}`); - * ``` - */ -@Injectable() -export class WeatherService { - private readonly logger = new Logger(WeatherService.name); - private readonly geocodingUrl = 'https://geocoding-api.open-meteo.com/v1/search'; - private readonly weatherUrl = 'https://api.open-meteo.com/v1/forecast'; - - private readonly defaultLocation: string; - private readonly cacheTtlMs: number; - private readonly language: 'de' | 'en'; - - // In-memory cache: location -> { data, expiresAt } - private cache: Map = new Map(); - // Geocoding cache: location -> coordinates - private geocodeCache: Map = new Map(); - - constructor(@Optional() @Inject(WEATHER_MODULE_OPTIONS) options?: WeatherModuleOptions) { - this.defaultLocation = options?.defaultLocation || 'Berlin'; - this.cacheTtlMs = options?.cacheTtlMs || DEFAULT_CACHE_TTL_MS; - this.language = options?.language || 'de'; - - this.logger.log( - `Weather Service initialized (default: ${this.defaultLocation}, cache: ${this.cacheTtlMs / 1000}s)` - ); - } - - /** - * Get weather for a location - * - * @param location - City name (e.g., "Berlin", "New York") - * @returns Weather data or null if location not found - */ - async getWeather(location?: string): Promise { - const loc = (location || this.defaultLocation).toLowerCase().trim(); - - // Check cache first - const cached = this.cache.get(loc); - if (cached && cached.expiresAt > new Date()) { - this.logger.debug(`Cache hit for "${loc}"`); - return cached.data; - } - - // Geocode location - const coordinates = await this.geocode(loc); - if (!coordinates) { - this.logger.warn(`Location not found: "${loc}"`); - return null; - } - - // Fetch weather - const weather = await this.fetchWeather(coordinates); - if (!weather) { - return null; - } - - // Cache result - this.cache.set(loc, { - data: weather, - expiresAt: new Date(Date.now() + this.cacheTtlMs), - }); - - return weather; - } - - /** - * Get weather description for a weather code - */ - getWeatherDescription(code: WeatherCode): string { - const descriptions = this.language === 'de' ? WEATHER_DESCRIPTIONS_DE : WEATHER_DESCRIPTIONS_EN; - return descriptions[code] || 'Unbekannt'; - } - - /** - * Get weather emoji for a weather code - */ - getWeatherEmoji(code: WeatherCode, isDay: boolean): string { - // Clear - if (code === 0) return isDay ? '☀️' : '🌙'; - if (code >= 1 && code <= 2) return isDay ? '🌤️' : '🌙'; - if (code === 3) return '☁️'; - - // Fog - if (code >= 45 && code <= 48) return '🌫️'; - - // Drizzle - if (code >= 51 && code <= 57) return '🌧️'; - - // Rain - if (code >= 61 && code <= 67) return '🌧️'; - - // Snow - if (code >= 71 && code <= 77) return '❄️'; - - // Showers - if (code >= 80 && code <= 82) return '🌦️'; - if (code >= 85 && code <= 86) return '🌨️'; - - // Thunderstorm - if (code >= 95) return '⛈️'; - - return '🌡️'; - } - - /** - * Format weather for display - * - * @param weather - Weather data - * @param format - 'compact' or 'detailed' - */ - formatWeather(weather: WeatherData, format: 'compact' | 'detailed' = 'detailed'): string { - const emoji = this.getWeatherEmoji(weather.weatherCode, weather.isDay); - - if (format === 'compact') { - return `${Math.round(weather.temperature)}°C ${weather.weatherDescription}`; - } - - const lines = [ - `**Wetter in ${weather.location}** ${emoji}`, - `${Math.round(weather.temperature)}°C, ${weather.weatherDescription}`, - `Regen: ${weather.precipitationProbability}% | Wind: ${Math.round(weather.windSpeed)} km/h`, - ]; - - if (weather.apparentTemperature !== weather.temperature) { - const diff = Math.round(weather.apparentTemperature - weather.temperature); - if (Math.abs(diff) >= 2) { - lines.push( - `Gefuehlt: ${Math.round(weather.apparentTemperature)}°C (${diff > 0 ? '+' : ''}${diff}°)` - ); - } - } - - return lines.join('\n'); - } - - /** - * Clear cache (useful for testing) - */ - clearCache(): void { - this.cache.clear(); - this.geocodeCache.clear(); - } - - // ===== Private Methods ===== - - /** - * Geocode a location name to coordinates - */ - private async geocode(location: string): Promise { - // Check geocode cache - const cached = this.geocodeCache.get(location); - if (cached) { - return cached; - } - - try { - const params = new URLSearchParams({ - name: location, - count: '1', - language: this.language, - format: 'json', - }); - - const response = await fetch(`${this.geocodingUrl}?${params}`); - - if (!response.ok) { - this.logger.error(`Geocoding API error: ${response.status}`); - return null; - } - - const data = (await response.json()) as GeocodingApiResponse; - - if (!data.results || data.results.length === 0) { - return null; - } - - const result = data.results[0]; - this.geocodeCache.set(location, result); - return result; - } catch (error) { - this.logger.error(`Geocoding failed for "${location}":`, error); - return null; - } - } - - /** - * Fetch weather data for coordinates - */ - private async fetchWeather(geo: GeocodingResult): Promise { - try { - const params = new URLSearchParams({ - latitude: geo.latitude.toString(), - longitude: geo.longitude.toString(), - current: [ - 'temperature_2m', - 'apparent_temperature', - 'relative_humidity_2m', - 'precipitation', - 'weather_code', - 'wind_speed_10m', - 'wind_direction_10m', - 'is_day', - ].join(','), - hourly: 'precipitation_probability', - forecast_hours: '1', - timezone: 'auto', - }); - - const response = await fetch(`${this.weatherUrl}?${params}`); - - if (!response.ok) { - this.logger.error(`Weather API error: ${response.status}`); - return null; - } - - const data = (await response.json()) as WeatherApiResponse; - - // Get precipitation probability for current hour - const precipProb = data.hourly?.precipitation_probability?.[0] ?? 0; - - const weather: WeatherData = { - location: geo.name, - temperature: data.current.temperature_2m, - apparentTemperature: data.current.apparent_temperature, - humidity: data.current.relative_humidity_2m, - precipitation: data.current.precipitation, - precipitationProbability: precipProb, - windSpeed: data.current.wind_speed_10m, - windDirection: data.current.wind_direction_10m, - weatherCode: data.current.weather_code, - weatherDescription: this.getWeatherDescription(data.current.weather_code), - isDay: data.current.is_day === 1, - fetchedAt: new Date(), - }; - - this.logger.debug( - `Fetched weather for ${geo.name}: ${weather.temperature}°C, ${weather.weatherDescription}` - ); - return weather; - } catch (error) { - this.logger.error(`Weather fetch failed for ${geo.name}:`, error); - return null; - } - } -} diff --git a/packages/bot-services/tsconfig.json b/packages/bot-services/tsconfig.json deleted file mode 100644 index ccdc10015..000000000 --- a/packages/bot-services/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "lib": ["ES2022"], - "types": ["node"], - "declaration": true, - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, - "inlineSources": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "strictPropertyInitialization": false, - "skipLibCheck": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/matrix-bot-common/CLAUDE.md b/packages/matrix-bot-common/CLAUDE.md deleted file mode 100644 index 0a74c7b45..000000000 --- a/packages/matrix-bot-common/CLAUDE.md +++ /dev/null @@ -1,270 +0,0 @@ -# @manacore/matrix-bot-common - -Shared utilities and base classes for Matrix bots. - -## Purpose - -This package consolidates common code patterns found across all 19 Matrix bots: - -- ~4,000 lines of duplicate code reduced to shared utilities -- Consistent behavior across all bots -- Easier maintenance and updates -- Type-safe helpers for common patterns - -## Available Components - -### BaseMatrixService - -Abstract base class that handles Matrix client lifecycle: - -```typescript -import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; - -@Injectable() -export class MyBotService extends BaseMatrixService { - constructor(configService: ConfigService) { - super(configService); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: this.configService.get('matrix.homeserverUrl'), - accessToken: this.configService.get('matrix.accessToken'), - storagePath: this.configService.get('matrix.storagePath'), - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - message: string, - sender: string - ) { - if (message === '!hello') { - await this.sendReply(roomId, event, 'Hello!'); - } - } - - // Optional: Handle voice messages - protected async handleAudioMessage(roomId: string, event: MatrixRoomEvent, sender: string) { - // Transcribe and process - } - - // Optional: Send intro on room join - protected getIntroductionMessage(): string | null { - return 'Hello! I am a bot.'; - } -} -``` - -**Provides:** - -- `onModuleInit()` - Client setup, storage, auto-join -- `onModuleDestroy()` - Graceful shutdown -- `sendMessage(roomId, message)` - Send markdown message -- `sendReply(roomId, event, message)` - Reply to event -- `sendNotice(roomId, message)` - Non-highlighted message -- `downloadMedia(mxcUrl)` - Download from Matrix -- `uploadMedia(buffer, contentType, filename)` - Upload to Matrix -- `isBot(sender)` - Check if sender is a bot (prevents bot-to-bot loops) - -**Bot-to-Bot Loop Prevention:** - -The base class automatically ignores messages from other bots to prevent infinite message loops when multiple bots are in the same room. A sender is considered a bot if their Matrix ID localpart contains `-bot` or ends with `bot` (e.g., `@mana-bot:server`, `@todobot:server`). - -### HealthController - -Shared health endpoint: - -```typescript -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; - -@Module({ - controllers: [HealthController], - providers: [createHealthProvider('matrix-todo-bot')], -}) -export class AppModule {} -``` - -Returns: - -```json -{ - "status": "ok", - "service": "matrix-todo-bot", - "timestamp": "2026-02-01T12:00:00.000Z", - "uptime": 3600 -} -``` - -### MatrixMessageService - -Injectable service for message operations: - -```typescript -import { MatrixMessageService } from '@manacore/matrix-bot-common'; - -@Injectable() -export class MyService { - constructor(private messageService: MatrixMessageService) {} - - async doSomething(client: MatrixClient, roomId: string) { - await this.messageService.sendMessage(client, roomId, '**Bold** message'); - await this.messageService.sendReaction(client, roomId, eventId, '👍'); - await this.messageService.editMessage(client, roomId, eventId, 'Updated text'); - } -} -``` - -### KeywordCommandDetector - -Natural language command detection: - -```typescript -import { KeywordCommandDetector, COMMON_KEYWORDS } from '@manacore/matrix-bot-common'; - -const detector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, // hilfe, help, status, etc. - { keywords: ['liste', 'list', 'zeige'], command: 'list' }, - { keywords: ['neu', 'new', 'erstelle'], command: 'create' }, -]); - -const command = detector.detect('zeige mir alles'); // Returns 'list' -const command2 = detector.detect('random text'); // Returns null -``` - -### Markdown Utilities - -```typescript -import { markdownToHtml, formatNumberedList, formatBulletList } from '@manacore/matrix-bot-common'; - -const html = markdownToHtml('**bold** and *italic*'); -// 'bold and italic' - -const list = formatNumberedList(items, (item, i) => `${item.name} - ${item.status}`); -// '1. Item A - active\n2. Item B - done' -``` - -### SessionHelper - -Type-safe session data wrapper: - -```typescript -import { SessionHelper } from '@manacore/matrix-bot-common'; - -interface MySessionData { - currentItemId: string; - selectedModel: string; - itemList: string[]; -} - -const session = new SessionHelper(sessionService, matrixUserId); - -session.set('currentItemId', 'abc123'); -const itemId = session.get('currentItemId'); // string | null -session.delete('currentItemId'); - -if (session.isLoggedIn()) { - const token = session.getToken(); -} -``` - -### UserListMapper - -Number-based reference system: - -```typescript -import { UserListMapper } from '@manacore/matrix-bot-common'; - -const mapper = new UserListMapper(); - -// After listing contacts to user -mapper.setList(userId, contacts); - -// User says "!select 3" -const contact = mapper.getByNumber(userId, 3); - -// For items with id field -import { UserIdListMapper } from '@manacore/matrix-bot-common'; -const idMapper = new UserIdListMapper<{ id: string; name: string }>(); -const itemId = idMapper.getIdByNumber(userId, 2); -``` - -## Migration Guide - -### Before (duplicate code in each bot) - -```typescript -// matrix.service.ts - 50+ lines duplicated across 12 bots -private markdownToHtml(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - // ... -} - -private async sendReply(roomId: string, event: any, message: string) { - const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); - reply.msgtype = 'm.text'; - await this.client.sendMessage(roomId, reply); -} -``` - -### After (using shared package) - -```typescript -import { markdownToHtml } from '@manacore/matrix-bot-common'; - -// Or extend BaseMatrixService which includes sendReply() -await this.sendReply(roomId, event, message); -``` - -## Installation - -```bash -pnpm --filter matrix-xxx-bot add @manacore/matrix-bot-common -``` - -## File Structure - -``` -packages/matrix-bot-common/ -├── src/ -│ ├── index.ts # Main exports -│ ├── base/ -│ │ ├── base-matrix.service.ts -│ │ ├── types.ts -│ │ └── index.ts -│ ├── health/ -│ │ ├── health.controller.ts -│ │ └── index.ts -│ ├── message/ -│ │ ├── message.service.ts -│ │ └── index.ts -│ ├── markdown/ -│ │ ├── markdown-formatter.ts -│ │ └── index.ts -│ ├── keywords/ -│ │ ├── keyword-detector.ts -│ │ └── index.ts -│ ├── session/ -│ │ ├── session-helper.ts -│ │ └── index.ts -│ └── list-mapper/ -│ ├── list-mapper.ts -│ └── index.ts -├── package.json -├── tsconfig.json -└── CLAUDE.md -``` - -## Development - -```bash -# Type check -pnpm --filter @manacore/matrix-bot-common type-check - -# Add to a bot -pnpm --filter matrix-xxx-bot add @manacore/matrix-bot-common -``` diff --git a/packages/matrix-bot-common/package.json b/packages/matrix-bot-common/package.json deleted file mode 100644 index 82c91df0c..000000000 --- a/packages/matrix-bot-common/package.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "name": "@manacore/matrix-bot-common", - "version": "0.1.0", - "private": true, - "description": "Shared utilities and base classes for Matrix bots", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./base": { - "types": "./dist/base/index.d.ts", - "default": "./dist/base/index.js" - }, - "./health": { - "types": "./dist/health/index.d.ts", - "default": "./dist/health/index.js" - }, - "./message": { - "types": "./dist/message/index.d.ts", - "default": "./dist/message/index.js" - }, - "./markdown": { - "types": "./dist/markdown/index.d.ts", - "default": "./dist/markdown/index.js" - }, - "./keywords": { - "types": "./dist/keywords/index.d.ts", - "default": "./dist/keywords/index.js" - }, - "./session": { - "types": "./dist/session/index.d.ts", - "default": "./dist/session/index.js" - }, - "./list-mapper": { - "types": "./dist/list-mapper/index.d.ts", - "default": "./dist/list-mapper/index.js" - } - }, - "scripts": { - "build": "tsc", - "type-check": "tsc --noEmit", - "clean": "rm -rf dist", - "lint": "eslint .", - "prepublishOnly": "pnpm build" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@nestjs/common": "^11.0.20", - "@nestjs/config": "^4.0.2", - "matrix-bot-sdk": "^0.7.1" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/config": "^3.0.0 || ^4.0.0", - "matrix-bot-sdk": "^0.7.0" - }, - "devDependencies": { - "@types/node": "^24.10.1", - "typescript": "^5.9.3" - } -} diff --git a/packages/matrix-bot-common/src/base/base-matrix.service.ts b/packages/matrix-bot-common/src/base/base-matrix.service.ts deleted file mode 100644 index 7a622e46c..000000000 --- a/packages/matrix-bot-common/src/base/base-matrix.service.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { Logger, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common'; -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichReply, -} from 'matrix-bot-sdk'; -import * as path from 'path'; -import * as fs from 'fs'; -import { - type MatrixBotConfig, - type MatrixRoomEvent, - isTextMessage, - isAudioMessage, - isImageMessage, -} from './types'; -import { markdownToHtml } from '../markdown/markdown-formatter'; - -/** - * Abstract base class for Matrix bot services - * - * Provides common functionality: - * - Matrix client initialization - * - Room join handling - * - Message routing - * - Markdown message sending - * - Graceful shutdown - * - * @example - * ```typescript - * @Injectable() - * export class MyBotService extends BaseMatrixService { - * protected async handleTextMessage(roomId: string, event: MatrixRoomEvent, message: string) { - * if (message.startsWith('!hello')) { - * await this.sendReply(roomId, event, 'Hello!'); - * } - * } - * - * protected getConfig(): MatrixBotConfig { - * return { - * homeserverUrl: this.configService.get('matrix.homeserverUrl'), - * accessToken: this.configService.get('matrix.accessToken'), - * storagePath: this.configService.get('matrix.storagePath'), - * allowedRooms: this.configService.get('matrix.allowedRooms'), - * }; - * } - * } - * ``` - */ -/** - * Interface for config service to support both @nestjs/config v3 and v4 - */ -export interface IConfigService { - get(propertyPath: string): T | undefined; -} - -export abstract class BaseMatrixService implements OnModuleInit, OnModuleDestroy { - protected readonly logger = new Logger(this.constructor.name); - protected client!: MatrixClient; - protected botUserId = ''; - protected readonly allowedRooms: string[]; - - constructor(protected configService: IConfigService) { - this.allowedRooms = this.getConfig().allowedRooms; - } - - /** - * Get Matrix configuration - must be implemented by subclass - */ - protected abstract getConfig(): MatrixBotConfig; - - /** - * Handle a text message - must be implemented by subclass - */ - protected abstract handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - message: string, - sender: string - ): Promise; - - /** - * Handle an audio message (optional override) - */ - protected async handleAudioMessage( - _roomId: string, - _event: MatrixRoomEvent, - _sender: string - ): Promise { - // Default: no-op, override in subclass for voice support - } - - /** - * Handle an image message (optional override) - */ - protected async handleImageMessage( - _roomId: string, - _event: MatrixRoomEvent, - _sender: string - ): Promise { - // Default: no-op, override in subclass for image support - } - - /** - * Get welcome/introduction message (optional override) - */ - protected getIntroductionMessage(): string | null { - return null; - } - - /** - * Initialize the Matrix client - */ - async onModuleInit(): Promise { - const config = this.getConfig(); - - if (!config.accessToken) { - this.logger.error('MATRIX_ACCESS_TOKEN is required'); - return; - } - - // Ensure storage directory exists - const storageDir = path.dirname(config.storagePath); - if (!fs.existsSync(storageDir)) { - fs.mkdirSync(storageDir, { recursive: true }); - this.logger.log(`Created storage directory: ${storageDir}`); - } - - // Initialize client - const storage = new SimpleFsStorageProvider(config.storagePath); - this.client = new MatrixClient(config.homeserverUrl, config.accessToken, storage); - - // Setup auto-join for allowed rooms - AutojoinRoomsMixin.setupOnClient(this.client); - - // Get bot user ID - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - // Setup room join handler - this.client.on('room.join', async (roomId: string) => { - await this.onRoomJoin(roomId); - }); - - // Setup message handler - this.client.on('room.message', async (roomId: string, event: MatrixRoomEvent) => { - await this.onRoomMessage(roomId, event); - }); - this.logger.log('Message handler registered'); - - // Start the client - await this.client.start(); - this.logger.log('Matrix client started and syncing'); - } - - /** - * Graceful shutdown - */ - async onModuleDestroy(): Promise { - if (this.client) { - await this.client.stop(); - this.logger.log('Matrix client stopped'); - } - } - - /** - * Handle room join event - */ - protected async onRoomJoin(roomId: string): Promise { - this.logger.log(`Joined room: ${roomId}`); - - // Send introduction message if defined - const intro = this.getIntroductionMessage(); - if (intro) { - await this.sendMessage(roomId, intro); - } - } - - /** - * Check if a sender is a bot (has "-bot" in the localpart) - * Bots should not respond to each other to avoid infinite loops - */ - protected isBot(sender: string): boolean { - // Extract localpart from @user:server format - const match = sender.match(/^@([^:]+):/); - if (!match) return false; - const localpart = match[1].toLowerCase(); - return localpart.includes('-bot') || localpart.endsWith('bot'); - } - - /** - * Check if event is an edit (message replacement) - */ - protected isEditEvent(event: MatrixRoomEvent): boolean { - return event.content?.['m.relates_to']?.rel_type === 'm.replace'; - } - - /** - * Handle incoming room message - */ - protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise { - // Debug: Log all incoming messages - this.logger.debug( - `[MESSAGE] Room: ${roomId}, Sender: ${event.sender}, Type: ${event.type}, MsgType: ${event.content?.msgtype}` - ); - - // Ignore own messages - if (event.sender === this.botUserId) return; - - // Ignore messages from other bots to prevent infinite loops - if (this.isBot(event.sender)) return; - - // Ignore edit events (message replacements) to prevent duplicate responses - if (this.isEditEvent(event)) return; - - // Check room permissions - if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { - return; - } - - try { - if (isTextMessage(event)) { - const message = event.content.body.trim(); - await this.handleTextMessage(roomId, event, message, event.sender); - } else if (isAudioMessage(event)) { - await this.handleAudioMessage(roomId, event, event.sender); - } else if (isImageMessage(event)) { - await this.handleImageMessage(roomId, event, event.sender); - } - } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendReply(roomId, event, '❌ Ein Fehler ist aufgetreten.'); - } - } - - /** - * Send a message to a room - */ - protected async sendMessage(roomId: string, message: string): Promise { - return this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: markdownToHtml(message), - }); - } - - /** - * Send a reply to an event - */ - protected async sendReply( - roomId: string, - event: MatrixRoomEvent, - message: string - ): Promise { - const reply = RichReply.createFor(roomId, event, message, markdownToHtml(message)); - reply.msgtype = 'm.text'; - return this.client.sendMessage(roomId, reply); - } - - /** - * Send a notice (non-highlighted message) - */ - protected async sendNotice(roomId: string, message: string): Promise { - return this.client.sendMessage(roomId, { - msgtype: 'm.notice', - body: message, - format: 'org.matrix.custom.html', - formatted_body: markdownToHtml(message), - }); - } - - /** - * Edit an existing message - */ - protected async editMessage( - roomId: string, - originalEventId: string, - newMessage: string - ): Promise { - return this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: `* ${newMessage}`, - format: 'org.matrix.custom.html', - formatted_body: `* ${markdownToHtml(newMessage)}`, - 'm.relates_to': { - rel_type: 'm.replace', - event_id: originalEventId, - }, - 'm.new_content': { - msgtype: 'm.text', - body: newMessage, - format: 'org.matrix.custom.html', - formatted_body: markdownToHtml(newMessage), - }, - }); - } - - /** - * Download media from Matrix using authenticated media API (v1) - * Newer Synapse versions require authenticated downloads via /_matrix/client/v1/media/download/ - */ - protected async downloadMedia(mxcUrl: string): Promise { - // Parse mxc:// URL -> mxc://server/mediaId - const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); - if (!match) { - throw new Error(`Invalid mxc URL: ${mxcUrl}`); - } - - const [, serverName, mediaId] = match; - const config = this.getConfig(); - - // Use the new authenticated media API (Matrix spec v1.11+) - const downloadUrl = `${config.homeserverUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`; - - const response = await fetch(downloadUrl, { - headers: { - Authorization: `Bearer ${config.accessToken}`, - }, - }); - - if (!response.ok) { - // Fallback to old API for older servers - this.logger.debug(`v1 media API failed (${response.status}), trying legacy API...`); - try { - const result = await this.client.downloadContent(mxcUrl); - return result.data; - } catch { - throw new Error(`Failed to download media: ${response.status} ${response.statusText}`); - } - } - - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); - } - - /** - * Upload media to Matrix - */ - protected async uploadMedia( - buffer: Buffer, - contentType: string, - filename: string - ): Promise { - return this.client.uploadContent(buffer, contentType, filename); - } - - /** - * Get the Matrix client (for advanced operations) - */ - protected getClient(): MatrixClient { - return this.client; - } - - /** - * Check if a room is allowed - */ - protected isRoomAllowed(roomId: string): boolean { - if (this.allowedRooms.length === 0) return true; - return this.allowedRooms.includes(roomId); - } -} diff --git a/packages/matrix-bot-common/src/base/index.ts b/packages/matrix-bot-common/src/base/index.ts deleted file mode 100644 index bcb5fb95e..000000000 --- a/packages/matrix-bot-common/src/base/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { BaseMatrixService, type IConfigService } from './base-matrix.service'; -export { - type MatrixBotConfig, - type MatrixRoomEvent, - type MatrixMessageEvent, - isTextMessage, - isAudioMessage, - isImageMessage, - isFileMessage, -} from './types'; diff --git a/packages/matrix-bot-common/src/base/types.ts b/packages/matrix-bot-common/src/base/types.ts deleted file mode 100644 index d611feb0d..000000000 --- a/packages/matrix-bot-common/src/base/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Matrix bot configuration - */ -export interface MatrixBotConfig { - /** Matrix homeserver URL */ - homeserverUrl: string; - /** Bot access token */ - accessToken: string; - /** Path to store bot state */ - storagePath: string; - /** Allowed room IDs (empty = all rooms) */ - allowedRooms: string[]; -} - -/** - * Matrix room event - */ -export interface MatrixRoomEvent { - event_id: string; - type: string; - sender: string; - room_id: string; - origin_server_ts: number; - content: { - msgtype?: string; - body?: string; - format?: string; - formatted_body?: string; - url?: string; - info?: Record; - 'm.relates_to'?: { - 'm.in_reply_to'?: { event_id: string }; - rel_type?: string; - event_id?: string; - }; - }; -} - -/** - * Matrix message event (subset of room event) - */ -export interface MatrixMessageEvent extends MatrixRoomEvent { - content: MatrixRoomEvent['content'] & { - msgtype: string; - body: string; - }; -} - -/** - * Check if event is a text message - */ -export function isTextMessage(event: MatrixRoomEvent): event is MatrixMessageEvent { - return event.content?.msgtype === 'm.text' && typeof event.content?.body === 'string'; -} - -/** - * Check if event is an audio message - */ -export function isAudioMessage(event: MatrixRoomEvent): boolean { - return event.content?.msgtype === 'm.audio'; -} - -/** - * Check if event is an image message - */ -export function isImageMessage(event: MatrixRoomEvent): boolean { - return event.content?.msgtype === 'm.image'; -} - -/** - * Check if event is a file message - */ -export function isFileMessage(event: MatrixRoomEvent): boolean { - return event.content?.msgtype === 'm.file'; -} diff --git a/packages/matrix-bot-common/src/credit/credit-commands.mixin.ts b/packages/matrix-bot-common/src/credit/credit-commands.mixin.ts deleted file mode 100644 index 6b69e5bab..000000000 --- a/packages/matrix-bot-common/src/credit/credit-commands.mixin.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { CreditService, I18nService, SessionService, type CreditPackage } from '@manacore/bot-services'; -import { type MatrixRoomEvent } from '../base/types'; - -/** - * Commands that the credit mixin handles - */ -export const CREDIT_COMMANDS = [ - 'credits', - 'guthaben', - 'packages', - 'pakete', - 'buy', - 'kaufen', -] as const; - -export type CreditCommand = (typeof CREDIT_COMMANDS)[number]; - -/** - * Check if a command is a credit command - */ -export function isCreditCommand(command: string): command is CreditCommand { - return CREDIT_COMMANDS.includes(command.toLowerCase() as CreditCommand); -} - -/** - * Interface for classes that can use the credit commands mixin - * - * Bots implementing this interface should expose their protected - * sendMessage/sendReply methods via these wrapper methods. - */ -export interface CreditCommandsHost { - creditService: CreditService; - i18nService: I18nService; - sessionService: SessionService; - - /** - * Send a message to a room (for credit notifications) - */ - sendCreditMessage(roomId: string, message: string): Promise; - - /** - * Send a reply to an event (for credit commands) - */ - sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise; -} - -/** - * Credit commands mixin for Matrix bots - * - * Provides handlers for credit-related commands: - * - !credits / !guthaben - Show credit balance - * - !packages / !pakete - Show available packages - * - !buy N / !kaufen N - Purchase a package - * - * @example - * ```typescript - * // In your MatrixService class: - * - * @Injectable() - * export class MatrixService extends BaseMatrixService implements CreditCommandsHost { - * public creditService: CreditService; - * public i18nService: I18nService; - * public sessionService: SessionService; - * - * constructor( - * configService: ConfigService, - * creditService: CreditService, - * i18nService: I18nService, - * sessionService: SessionService, - * ) { - * super(configService); - * this.creditService = creditService; - * this.i18nService = i18nService; - * this.sessionService = sessionService; - * } - * - * async executeCommand(roomId, event, userId, command, args) { - * // Handle credit commands first - * if (await handleCreditCommand(this, roomId, event, userId, command, args)) { - * return; - * } - * // Then handle bot-specific commands - * // ... - * } - * } - * ``` - */ - -/** - * Handle a credit command if applicable - * @returns true if the command was handled, false otherwise - */ -export async function handleCreditCommand( - host: CreditCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string, - command: string, - args: string -): Promise { - const cmd = command.toLowerCase(); - - switch (cmd) { - case 'credits': - case 'guthaben': - await handleCreditsCommand(host, roomId, event, userId); - return true; - - case 'packages': - case 'pakete': - await handlePackagesCommand(host, roomId, event, userId); - return true; - - case 'buy': - case 'kaufen': - await handleBuyCommand(host, roomId, event, userId, args); - return true; - - default: - return false; - } -} - -/** - * Handle !credits / !guthaben command - show balance - */ -async function handleCreditsCommand( - host: CreditCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string -): Promise { - const token = await host.sessionService.getToken(userId); - const t = await host.i18nService.getTodoTranslator(userId); - - if (!token) { - await sendReply(host, roomId, event, t('loginRequired')); - return; - } - - const balance = await host.creditService.getBalance(token); - const message = t('creditBalance', { balance: balance.balance.toFixed(2) }); - - await sendReply(host, roomId, event, message); -} - -/** - * Handle !packages / !pakete command - show available packages - */ -async function handlePackagesCommand( - host: CreditCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string -): Promise { - const t = await host.i18nService.getTodoTranslator(userId); - - // Packages are public, no token needed - const packages = await host.creditService.getPackages(); - - if (packages.length === 0) { - await sendReply(host, roomId, event, t('creditNoPackages')); - return; - } - - // Store packages for reference in buy command - packageCache.set(userId, packages); - - const lines: string[] = [t('creditPackagesTitle'), '']; - - packages.forEach((pkg, index) => { - lines.push( - t('creditPackageLine', { - num: String(index + 1), - name: pkg.name, - credits: String(pkg.credits), - price: pkg.formattedPrice, - }) - ); - }); - - lines.push(''); - lines.push(t('creditBuyHelp', { num: '1' })); - - await sendReply(host, roomId, event, lines.join('\n')); -} - -/** - * Handle !buy N / !kaufen N command - purchase a package - */ -async function handleBuyCommand( - host: CreditCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string -): Promise { - const token = await host.sessionService.getToken(userId); - const t = await host.i18nService.getTodoTranslator(userId); - - if (!token) { - await sendReply(host, roomId, event, t('loginRequired')); - return; - } - - // Parse package number - const packageNumber = parseInt(args.trim(), 10); - if (isNaN(packageNumber) || packageNumber < 1) { - await sendReply(host, roomId, event, t('creditPackageNotFound')); - return; - } - - // Get cached packages or fetch new ones - let packages = packageCache.get(userId); - if (!packages) { - packages = await host.creditService.getPackages(); - packageCache.set(userId, packages); - } - - // Get selected package - const selectedPackage = packages[packageNumber - 1]; - if (!selectedPackage) { - await sendReply(host, roomId, event, t('creditPackageNotFound')); - return; - } - - // Create payment link - const result = await host.creditService.createPaymentLink(token, selectedPackage.id, roomId); - - if (!result) { - await sendReply(host, roomId, event, t('creditPurchaseError')); - return; - } - - // Format success message - const lines = [ - `**${selectedPackage.name}** (${selectedPackage.credits} Credits)`, - '', - t('creditPaymentLink'), - result.url, - '', - t('creditLinkValid'), - ]; - - await sendReply(host, roomId, event, lines.join('\n')); -} - -/** - * Send a payment success notification to a room - * Called after webhook confirms payment - */ -export async function sendPaymentSuccessNotification( - host: CreditCommandsHost, - roomId: string, - userId: string, - credits: number, - newBalance: number -): Promise { - const t = await host.i18nService.getTodoTranslator(userId); - - const lines = [ - t('creditPaymentSuccess', { credits: String(credits) }), - t('creditNewBalance', { balance: newBalance.toFixed(2) }), - ]; - - await sendMessage(host, roomId, lines.join('\n')); -} - -// ============================================================================ -// Internal helpers -// ============================================================================ - -/** - * Simple cache for packages (per-user) - * Cleared after 5 minutes - */ -const packageCache = new Map(); - -// Clear cache entries after 5 minutes -setInterval( - () => { - packageCache.clear(); - }, - 5 * 60 * 1000 -); - -/** - * Send a message to a room - */ -async function sendMessage(host: CreditCommandsHost, roomId: string, message: string): Promise { - await host.sendCreditMessage(roomId, message); -} - -/** - * Send a reply to an event - */ -async function sendReply( - host: CreditCommandsHost, - roomId: string, - event: MatrixRoomEvent, - message: string -): Promise { - await host.sendCreditReply(roomId, event, message); -} diff --git a/packages/matrix-bot-common/src/credit/index.ts b/packages/matrix-bot-common/src/credit/index.ts deleted file mode 100644 index 8dab6d8f0..000000000 --- a/packages/matrix-bot-common/src/credit/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - handleCreditCommand, - sendPaymentSuccessNotification, - isCreditCommand, - CREDIT_COMMANDS, - type CreditCommand, - type CreditCommandsHost, -} from './credit-commands.mixin.js'; diff --git a/packages/matrix-bot-common/src/gift/gift-commands.mixin.ts b/packages/matrix-bot-common/src/gift/gift-commands.mixin.ts deleted file mode 100644 index 6451e85f4..000000000 --- a/packages/matrix-bot-common/src/gift/gift-commands.mixin.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { - GiftService, - I18nService, - SessionService, - type CreateGiftOptions, - type GiftCodeType, -} from '@manacore/bot-services'; -import { type MatrixRoomEvent } from '../base/types'; - -/** - * Commands that the gift mixin handles - */ -export const GIFT_COMMANDS = [ - 'geschenk', - 'gift', - 'einloesen', - 'redeem', - 'meine-geschenke', - 'my-gifts', -] as const; - -export type GiftCommand = (typeof GIFT_COMMANDS)[number]; - -/** - * Check if a command is a gift command - */ -export function isGiftCommand(command: string): command is GiftCommand { - return GIFT_COMMANDS.includes(command.toLowerCase() as GiftCommand); -} - -/** - * Interface for classes that can use the gift commands mixin - */ -export interface GiftCommandsHost { - giftService: GiftService; - i18nService: I18nService; - sessionService: SessionService; - - /** - * Send a message to a room (for gift notifications) - */ - sendGiftMessage(roomId: string, message: string): Promise; - - /** - * Send a reply to an event (for gift commands) - */ - sendGiftReply(roomId: string, event: MatrixRoomEvent, message: string): Promise; -} - -/** - * Parsed gift command input - */ -interface ParsedGiftInput { - credits: number; - type: GiftCodeType; - portions?: number; - targetEmail?: string; - targetMatrixId?: string; - riddleQuestion?: string; - riddleAnswer?: string; - message?: string; - expiresAt?: string; -} - -/** - * Parse gift command syntax - * - * Syntax examples: - * - `!geschenk 50` - Simple, 50 credits - * - `!geschenk 100 /5` - Split: 5 portions of 20 credits - * - `!geschenk 50 x3` - First come: first 3 get 50 each - * - `!geschenk 50 @user@email.com` - Personalized - * - `!geschenk 50 @morgen` - Expires tomorrow - * - `!geschenk 50 ?="answer"` - With riddle - * - `!geschenk 50 "message"` - With message - */ -function parseGiftInput(input: string): ParsedGiftInput | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - // Extract credits (first number) - const creditsMatch = trimmed.match(/^(\d+)/); - if (!creditsMatch) return null; - - const credits = parseInt(creditsMatch[1], 10); - if (isNaN(credits) || credits < 1 || credits > 10000) return null; - - const result: ParsedGiftInput = { - credits, - type: 'simple', - }; - - const rest = trimmed.substring(creditsMatch[0].length).trim(); - - // Check for split: /N - const splitMatch = rest.match(/\/(\d+)/); - if (splitMatch) { - result.type = 'split'; - result.portions = parseInt(splitMatch[1], 10); - } - - // Check for first come: xN - const firstComeMatch = rest.match(/x(\d+)/i); - if (firstComeMatch) { - result.type = 'first_come'; - result.portions = parseInt(firstComeMatch[1], 10); - } - - // Check for personalized: @email - const emailMatch = rest.match(/@([\w.+-]+@[\w.-]+\.\w+)/); - if (emailMatch) { - result.type = 'personalized'; - result.targetEmail = emailMatch[1]; - } - - // Check for Matrix ID: @user:server - const matrixMatch = rest.match(/@(@[\w.-]+:[.\w-]+)/); - if (matrixMatch && !emailMatch) { - result.type = 'personalized'; - result.targetMatrixId = matrixMatch[1]; - } - - // Check for riddle: ?="answer" - const riddleMatch = rest.match(/\?="([^"]+)"/); - if (riddleMatch) { - result.type = 'riddle'; - result.riddleAnswer = riddleMatch[1]; - // Riddle question will be the remaining text before the riddle - const riddleQuestionMatch = rest.match(/\?([^=]+)="[^"]+"/); - if (riddleQuestionMatch) { - result.riddleQuestion = riddleQuestionMatch[1].trim(); - } - } - - // Check for expiration: @morgen, @tomorrow - const dateKeywords: Record = { - morgen: 1, - tomorrow: 1, - übermorgen: 2, - 'day after tomorrow': 2, - }; - for (const [keyword, days] of Object.entries(dateKeywords)) { - if (rest.toLowerCase().includes(`@${keyword}`)) { - const date = new Date(); - date.setDate(date.getDate() + days); - date.setHours(23, 59, 59, 999); - result.expiresAt = date.toISOString(); - break; - } - } - - // Check for message: "message" - const messageMatch = rest.match(/"([^"]+)"/); - if (messageMatch && !riddleMatch) { - result.message = messageMatch[1]; - } - - return result; -} - -/** - * Handle a gift command if applicable - * @returns true if the command was handled, false otherwise - */ -export async function handleGiftCommand( - host: GiftCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string, - command: string, - args: string -): Promise { - const cmd = command.toLowerCase(); - - switch (cmd) { - case 'geschenk': - case 'gift': - await handleCreateGift(host, roomId, event, userId, args); - return true; - - case 'einloesen': - case 'redeem': - await handleRedeemGift(host, roomId, event, userId, args); - return true; - - case 'meine-geschenke': - case 'my-gifts': - await handleListGifts(host, roomId, event, userId); - return true; - - default: - return false; - } -} - -/** - * Handle !geschenk / !gift command - create a gift - */ -async function handleCreateGift( - host: GiftCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string -): Promise { - const token = await host.sessionService.getToken(userId); - const t = await host.i18nService.getGiftTranslator(userId); - - if (!token) { - await sendReply(host, roomId, event, t('loginRequired')); - return; - } - - // Parse input - const parsed = parseGiftInput(args); - if (!parsed) { - await sendReply(host, roomId, event, t('giftInvalidSyntax')); - return; - } - - // Build options - const options: CreateGiftOptions = { - type: parsed.type, - portions: parsed.portions, - targetEmail: parsed.targetEmail, - targetMatrixId: parsed.targetMatrixId, - riddleQuestion: parsed.riddleQuestion, - riddleAnswer: parsed.riddleAnswer, - message: parsed.message, - expiresAt: parsed.expiresAt, - }; - - // Create gift - const result = await host.giftService.createGift(token, parsed.credits, options); - - if (!result) { - await sendReply(host, roomId, event, t('giftInsufficientCredits', { available: '?' })); - return; - } - - // Format response - const lines: string[] = [t('giftCreated'), '']; - - lines.push(t('giftCreatedCode', { code: result.code })); - - if (result.totalPortions > 1) { - lines.push( - t('giftCreatedSplit', { - credits: String(result.creditsPerPortion), - portions: String(result.totalPortions), - }) - ); - } else { - lines.push(t('giftCreatedCredits', { credits: String(result.totalCredits) })); - } - - lines.push(''); - lines.push(t('giftCreatedLink', { url: result.url })); - - await sendReply(host, roomId, event, lines.join('\n')); -} - -/** - * Handle !einloesen / !redeem command - redeem a gift - */ -async function handleRedeemGift( - host: GiftCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string -): Promise { - const token = await host.sessionService.getToken(userId); - const t = await host.i18nService.getGiftTranslator(userId); - - if (!token) { - await sendReply(host, roomId, event, t('loginRequired')); - return; - } - - // Parse args: CODE [answer] - const parts = args.trim().split(/\s+/); - const code = parts[0]?.toUpperCase(); - const answer = parts.slice(1).join(' '); - - if (!code) { - await sendReply(host, roomId, event, t('giftInvalidSyntax')); - return; - } - - // Check if it looks like a gift code - if (code.length !== 6 || !/^[A-Z0-9]+$/.test(code)) { - // Try to extract code from URL - const urlMatch = args.match(/\/g\/([A-Z0-9]{6})/i); - if (urlMatch) { - // Recurse with extracted code - await handleRedeemGift(host, roomId, event, userId, urlMatch[1]); - return; - } - - await sendReply(host, roomId, event, t('giftInvalidCode')); - return; - } - - // First, get gift info to check if riddle required - if (!answer) { - const info = await host.giftService.getGiftInfo(code); - if (info?.hasRiddle && info.riddleQuestion) { - // Show riddle question - const lines: string[] = [ - t('giftInfoTitle'), - t('giftRiddleQuestion', { question: info.riddleQuestion }), - '', - `\`!einloesen ${code} [antwort]\``, - ]; - await sendReply(host, roomId, event, lines.join('\n')); - return; - } - } - - // Redeem gift - // Get Matrix user ID from event sender - const matrixUserId = event.sender; - - const result = await host.giftService.redeemGift(token, code, answer || undefined, matrixUserId); - - if (!result.success) { - // Map error to translation - const errorMessages: Record = { - 'Gift code not found': t('giftInvalidCode'), - 'This gift code has expired': t('giftExpired'), - 'This gift code has been fully claimed': t('giftDepleted'), - 'You have already claimed this gift': t('giftAlreadyClaimed'), - 'This gift code is for a specific person': t('giftWrongUser'), - 'Incorrect answer': t('giftWrongAnswer'), - 'Please provide the answer to the riddle': t('giftRiddleRequired'), - }; - - const errorMsg = errorMessages[result.error || ''] || result.error || t('errorOccurred'); - await sendReply(host, roomId, event, errorMsg); - return; - } - - // Format success response - const lines: string[] = [t('giftRedeemed')]; - lines.push(t('giftRedeemedCredits', { credits: String(result.credits) })); - - if (result.message) { - lines.push(''); - lines.push(t('giftRedeemedMessage', { message: result.message })); - } - - if (result.newBalance !== undefined) { - lines.push(''); - lines.push(t('creditNewBalance', { balance: result.newBalance.toFixed(2) })); - } - - await sendReply(host, roomId, event, lines.join('\n')); -} - -/** - * Handle !meine-geschenke / !my-gifts command - list user's gifts - */ -async function handleListGifts( - host: GiftCommandsHost, - roomId: string, - event: MatrixRoomEvent, - userId: string -): Promise { - const token = await host.sessionService.getToken(userId); - const t = await host.i18nService.getGiftTranslator(userId); - - if (!token) { - await sendReply(host, roomId, event, t('loginRequired')); - return; - } - - const gifts = await host.giftService.listCreatedGifts(token); - - const lines: string[] = [t('giftListTitle'), '']; - - if (gifts.length === 0) { - lines.push(t('giftListEmpty')); - } else { - // Show only active or recently depleted - const relevantGifts = gifts.filter( - (g) => g.status === 'active' || g.status === 'depleted' - ); - - relevantGifts.forEach((gift, index) => { - const statusIcon = - gift.status === 'active' ? '✅' : gift.status === 'depleted' ? '✓' : '❌'; - - lines.push( - t('giftListItem', { - num: String(index + 1), - code: gift.code, - status: statusIcon, - credits: String(gift.creditsPerPortion), - claimed: String(gift.claimedPortions), - total: String(gift.totalPortions), - }) - ); - }); - } - - await sendReply(host, roomId, event, lines.join('\n')); -} - -// ============================================================================ -// Internal helpers -// ============================================================================ - -/** - * Send a message to a room - */ -async function sendMessage(host: GiftCommandsHost, roomId: string, message: string): Promise { - await host.sendGiftMessage(roomId, message); -} - -/** - * Send a reply to an event - */ -async function sendReply( - host: GiftCommandsHost, - roomId: string, - event: MatrixRoomEvent, - message: string -): Promise { - await host.sendGiftReply(roomId, event, message); -} diff --git a/packages/matrix-bot-common/src/gift/index.ts b/packages/matrix-bot-common/src/gift/index.ts deleted file mode 100644 index ba1718734..000000000 --- a/packages/matrix-bot-common/src/gift/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - handleGiftCommand, - isGiftCommand, - GIFT_COMMANDS, - type GiftCommand, - type GiftCommandsHost, -} from './gift-commands.mixin.js'; diff --git a/packages/matrix-bot-common/src/health/health.controller.ts b/packages/matrix-bot-common/src/health/health.controller.ts deleted file mode 100644 index 5412f564c..000000000 --- a/packages/matrix-bot-common/src/health/health.controller.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Controller, Get, Inject, Optional } from '@nestjs/common'; - -export const HEALTH_SERVICE_NAME = 'HEALTH_SERVICE_NAME'; - -export interface HealthResponse { - status: 'ok' | 'error'; - service: string; - timestamp: string; - uptime?: number; - version?: string; -} - -/** - * Shared health controller for Matrix bots - * - * Returns standardized health check response. - * - * @example - * ```typescript - * // In app.module.ts - * @Module({ - * controllers: [HealthController], - * providers: [ - * { provide: HEALTH_SERVICE_NAME, useValue: 'matrix-todo-bot' } - * ], - * }) - * ``` - */ -@Controller('health') -export class HealthController { - private readonly startTime = Date.now(); - - constructor( - @Optional() - @Inject(HEALTH_SERVICE_NAME) - private readonly serviceName?: string - ) {} - - @Get() - check(): HealthResponse { - return { - status: 'ok', - service: this.serviceName || 'matrix-bot', - timestamp: new Date().toISOString(), - uptime: Math.floor((Date.now() - this.startTime) / 1000), - }; - } -} - -/** - * Create a health controller provider with service name - */ -export function createHealthProvider(serviceName: string) { - return { - provide: HEALTH_SERVICE_NAME, - useValue: serviceName, - }; -} diff --git a/packages/matrix-bot-common/src/health/index.ts b/packages/matrix-bot-common/src/health/index.ts deleted file mode 100644 index 20e33addb..000000000 --- a/packages/matrix-bot-common/src/health/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - HealthController, - HEALTH_SERVICE_NAME, - createHealthProvider, - type HealthResponse, -} from './health.controller'; diff --git a/packages/matrix-bot-common/src/index.ts b/packages/matrix-bot-common/src/index.ts deleted file mode 100644 index 90d4fe78c..000000000 --- a/packages/matrix-bot-common/src/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @manacore/matrix-bot-common - * - * Shared utilities and base classes for Matrix bots. - * Reduces code duplication across 19 Matrix bots. - * - * @example - * ```typescript - * import { - * BaseMatrixService, - * HealthController, - * MatrixMessageService, - * KeywordCommandDetector, - * markdownToHtml, - * SessionHelper, - * UserListMapper, - * } from '@manacore/matrix-bot-common'; - * ``` - */ - -// Base Matrix Service -export { - BaseMatrixService, - type IConfigService, - type MatrixBotConfig, - type MatrixRoomEvent, - type MatrixMessageEvent, - isTextMessage, - isAudioMessage, - isImageMessage, - isFileMessage, -} from './base/index.js'; - -// Health Controller -export { - HealthController, - HEALTH_SERVICE_NAME, - createHealthProvider, - type HealthResponse, -} from './health/index.js'; - -// Message Service -export { - MatrixMessageService, - type MatrixMessageContent, - type SendMessageOptions, -} from './message/index.js'; - -// Markdown Utilities -export { markdownToHtml, escapeHtml, formatNumberedList, formatBulletList } from './markdown/index.js'; - -// Keyword Detection -export { - KeywordCommandDetector, - COMMON_KEYWORDS, - type KeywordCommand, - type KeywordDetectorOptions, -} from './keywords/index.js'; - -// Session Helper -export { SessionHelper, createSessionHelper } from './session/index.js'; - -// List Mapper -export { UserListMapper, UserIdListMapper } from './list-mapper/index.js'; - -// Credit Commands -export { - handleCreditCommand, - sendPaymentSuccessNotification, - isCreditCommand, - CREDIT_COMMANDS, - type CreditCommand, - type CreditCommandsHost, -} from './credit/index.js'; - -// Gift Commands -export { - handleGiftCommand, - isGiftCommand, - GIFT_COMMANDS, - type GiftCommand, - type GiftCommandsHost, -} from './gift/index.js'; diff --git a/packages/matrix-bot-common/src/keywords/index.ts b/packages/matrix-bot-common/src/keywords/index.ts deleted file mode 100644 index 51e2686b9..000000000 --- a/packages/matrix-bot-common/src/keywords/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - KeywordCommandDetector, - COMMON_KEYWORDS, - type KeywordCommand, - type KeywordDetectorOptions, -} from './keyword-detector'; diff --git a/packages/matrix-bot-common/src/keywords/keyword-detector.ts b/packages/matrix-bot-common/src/keywords/keyword-detector.ts deleted file mode 100644 index fb706c46a..000000000 --- a/packages/matrix-bot-common/src/keywords/keyword-detector.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Keyword command mapping - */ -export interface KeywordCommand { - /** Keywords that trigger this command (lowercase) */ - keywords: string[]; - /** Command name to return when matched */ - command: string; -} - -/** - * Options for keyword detection - */ -export interface KeywordDetectorOptions { - /** Maximum message length to check (default: 60) */ - maxLength?: number; - /** Whether to match partial words (default: false) */ - partialMatch?: boolean; -} - -/** - * Detect commands from natural language keywords - * - * Used by Matrix bots to respond to natural language instead of just !commands. - * - * @example - * ```typescript - * const detector = new KeywordCommandDetector([ - * { keywords: ['hilfe', 'help'], command: 'help' }, - * { keywords: ['status', 'info'], command: 'status' }, - * ]); - * - * detector.detect('hilfe bitte'); // Returns 'help' - * detector.detect('was ist los'); // Returns null - * ``` - */ -export class KeywordCommandDetector { - private readonly maxLength: number; - private readonly partialMatch: boolean; - - constructor( - private readonly commands: KeywordCommand[], - options: KeywordDetectorOptions = {} - ) { - this.maxLength = options.maxLength ?? 60; - this.partialMatch = options.partialMatch ?? false; - } - - /** - * Detect a command from a message - * - * @param message - The user's message - * @returns The command name if matched, null otherwise - */ - detect(message: string): string | null { - const lowerMessage = message.toLowerCase().trim(); - - // Skip long messages (likely not commands) - if (lowerMessage.length > this.maxLength) { - return null; - } - - for (const { keywords, command } of this.commands) { - for (const keyword of keywords) { - if (this.matches(lowerMessage, keyword)) { - return command; - } - } - } - - return null; - } - - /** - * Check if message matches a keyword - */ - private matches(message: string, keyword: string): boolean { - // Exact match - if (message === keyword) { - return true; - } - - // Message starts with keyword followed by space - if (message.startsWith(keyword + ' ')) { - return true; - } - - // Partial match (keyword appears anywhere) - if (this.partialMatch && message.includes(keyword)) { - return true; - } - - return false; - } - - /** - * Add more commands dynamically - */ - addCommands(commands: KeywordCommand[]): void { - this.commands.push(...commands); - } - - /** - * Get all registered commands - */ - getCommands(): KeywordCommand[] { - return [...this.commands]; - } -} - -/** - * Common German/English keywords used across bots - */ -export const COMMON_KEYWORDS: KeywordCommand[] = [ - { keywords: ['hilfe', 'help', 'befehle', 'commands', '?'], command: 'help' }, - { keywords: ['status', 'info'], command: 'status' }, - { keywords: ['abbrechen', 'cancel', 'stop'], command: 'cancel' }, -]; diff --git a/packages/matrix-bot-common/src/list-mapper/index.ts b/packages/matrix-bot-common/src/list-mapper/index.ts deleted file mode 100644 index f048338dd..000000000 --- a/packages/matrix-bot-common/src/list-mapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UserListMapper, UserIdListMapper } from './list-mapper'; diff --git a/packages/matrix-bot-common/src/list-mapper/list-mapper.ts b/packages/matrix-bot-common/src/list-mapper/list-mapper.ts deleted file mode 100644 index ba870430a..000000000 --- a/packages/matrix-bot-common/src/list-mapper/list-mapper.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * User list mapper for number-based reference system - * - * Allows users to reference items by number after listing them. - * Used by Matrix bots for commands like "!select 3" or "!delete 2". - * - * @example - * ```typescript - * const mapper = new UserListMapper(); - * - * // After showing a list to the user - * mapper.setList('@user:matrix.org', contacts); - * - * // User says "!select 3" - * const contact = mapper.getByNumber('@user:matrix.org', 3); - * ``` - */ -export class UserListMapper { - private lists: Map = new Map(); - - /** - * Store a list for a user - */ - setList(userId: string, items: T[]): void { - this.lists.set(userId, [...items]); - } - - /** - * Get an item by its 1-based number - * - * @param userId - The user ID - * @param number - 1-based index (as shown to user) - * @returns The item or null if invalid - */ - getByNumber(userId: string, number: number): T | null { - const items = this.lists.get(userId); - if (!items || number < 1 || number > items.length) { - return null; - } - return items[number - 1]; - } - - /** - * Get the full list for a user - */ - getList(userId: string): T[] { - return this.lists.get(userId) || []; - } - - /** - * Check if user has a stored list - */ - hasList(userId: string): boolean { - return this.lists.has(userId) && this.lists.get(userId)!.length > 0; - } - - /** - * Get the count of items in user's list - */ - getCount(userId: string): number { - return this.lists.get(userId)?.length || 0; - } - - /** - * Clear the list for a user - */ - clearList(userId: string): void { - this.lists.delete(userId); - } - - /** - * Clear all lists - */ - clearAll(): void { - this.lists.clear(); - } -} - -/** - * Extended list mapper that also stores IDs separately - * Useful when items have an id field that needs quick lookup - */ -export class UserIdListMapper extends UserListMapper { - private idMaps: Map> = new Map(); - - override setList(userId: string, items: T[]): void { - super.setList(userId, items); - - // Build ID map - const idMap = new Map(); - items.forEach((item, index) => { - idMap.set(index + 1, item.id); - }); - this.idMaps.set(userId, idMap); - } - - /** - * Get just the ID by number (without loading full item) - */ - getIdByNumber(userId: string, number: number): string | null { - return this.idMaps.get(userId)?.get(number) || null; - } - - override clearList(userId: string): void { - super.clearList(userId); - this.idMaps.delete(userId); - } - - override clearAll(): void { - super.clearAll(); - this.idMaps.clear(); - } -} diff --git a/packages/matrix-bot-common/src/markdown/index.ts b/packages/matrix-bot-common/src/markdown/index.ts deleted file mode 100644 index b985125a7..000000000 --- a/packages/matrix-bot-common/src/markdown/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - markdownToHtml, - escapeHtml, - formatNumberedList, - formatBulletList, -} from './markdown-formatter'; diff --git a/packages/matrix-bot-common/src/markdown/markdown-formatter.ts b/packages/matrix-bot-common/src/markdown/markdown-formatter.ts deleted file mode 100644 index 1aeff3949..000000000 --- a/packages/matrix-bot-common/src/markdown/markdown-formatter.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Convert Markdown text to HTML for Matrix messages - * - * Supports: - * - **bold** -> bold - * - *italic* -> italic - * - ~~strikethrough~~ -> strikethrough - * - `code` -> code - * - Newlines ->
- * - * @example - * ```typescript - * const html = markdownToHtml('**Hello** *world*'); - * // Returns: 'Hello world' - * ``` - */ -export function markdownToHtml(text: string): string { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\n/g, '
'); -} - -/** - * Escape HTML special characters to prevent XSS - */ -export function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Format a list of items as numbered markdown list - */ -export function formatNumberedList( - items: T[], - formatter: (item: T, index: number) => string -): string { - return items.map((item, i) => `${i + 1}. ${formatter(item, i)}`).join('\n'); -} - -/** - * Format a list of items as bullet markdown list - */ -export function formatBulletList(items: T[], formatter: (item: T) => string): string { - return items.map((item) => `• ${formatter(item)}`).join('\n'); -} diff --git a/packages/matrix-bot-common/src/message/index.ts b/packages/matrix-bot-common/src/message/index.ts deleted file mode 100644 index 2fcea7736..000000000 --- a/packages/matrix-bot-common/src/message/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - MatrixMessageService, - type MatrixMessageContent, - type SendMessageOptions, -} from './message.service'; diff --git a/packages/matrix-bot-common/src/message/message.service.ts b/packages/matrix-bot-common/src/message/message.service.ts deleted file mode 100644 index 07b5b8d25..000000000 --- a/packages/matrix-bot-common/src/message/message.service.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { MatrixClient, RichReply } from 'matrix-bot-sdk'; -import { markdownToHtml } from '../markdown/markdown-formatter'; - -/** - * Message content for Matrix - */ -export interface MatrixMessageContent { - msgtype: string; - body: string; - format?: string; - formatted_body?: string; - 'm.relates_to'?: { - 'm.in_reply_to'?: { event_id: string }; - event_id?: string; - rel_type?: string; - }; -} - -/** - * Options for sending messages - */ -export interface SendMessageOptions { - /** Convert markdown to HTML (default: true) */ - markdown?: boolean; - /** Message type (default: 'm.text') */ - msgtype?: string; -} - -/** - * Shared message service for Matrix bots - * - * Provides standardized methods for sending messages, replies, and reactions. - * - * @example - * ```typescript - * const messageService = new MatrixMessageService(); - * - * // Send a simple message - * await messageService.sendMessage(client, roomId, 'Hello!'); - * - * // Send a reply to an event - * await messageService.sendReply(client, roomId, event, 'Thanks!'); - * - * // Send a reaction - * await messageService.sendReaction(client, roomId, eventId, '👍'); - * ``` - */ -@Injectable() -export class MatrixMessageService { - private readonly logger = new Logger(MatrixMessageService.name); - - /** - * Send a message to a room - */ - async sendMessage( - client: MatrixClient, - roomId: string, - message: string, - options: SendMessageOptions = {} - ): Promise { - const { markdown = true, msgtype = 'm.text' } = options; - - const content: MatrixMessageContent = { - msgtype, - body: message, - }; - - if (markdown) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = markdownToHtml(message); - } - - return client.sendMessage(roomId, content); - } - - /** - * Send a reply to a specific event - */ - async sendReply( - client: MatrixClient, - roomId: string, - event: { event_id: string; content?: { body?: string } }, - message: string, - options: SendMessageOptions = {} - ): Promise { - const { markdown = true, msgtype = 'm.text' } = options; - - const htmlMessage = markdown ? markdownToHtml(message) : message; - const reply = RichReply.createFor(roomId, event, message, htmlMessage); - reply.msgtype = msgtype; - - return client.sendMessage(roomId, reply); - } - - /** - * Send a reaction to an event - */ - async sendReaction( - client: MatrixClient, - roomId: string, - eventId: string, - emoji: string - ): Promise { - return client.sendEvent(roomId, 'm.reaction', { - 'm.relates_to': { - rel_type: 'm.annotation', - event_id: eventId, - key: emoji, - }, - }); - } - - /** - * Send a notice (non-highlighted message) - */ - async sendNotice( - client: MatrixClient, - roomId: string, - message: string, - options: Omit = {} - ): Promise { - return this.sendMessage(client, roomId, message, { ...options, msgtype: 'm.notice' }); - } - - /** - * Send an image to a room - */ - async sendImage( - client: MatrixClient, - roomId: string, - mxcUrl: string, - filename: string, - info?: { w?: number; h?: number; mimetype?: string; size?: number } - ): Promise { - return client.sendMessage(roomId, { - msgtype: 'm.image', - body: filename, - url: mxcUrl, - info: info || {}, - }); - } - - /** - * Send a file to a room - */ - async sendFile( - client: MatrixClient, - roomId: string, - mxcUrl: string, - filename: string, - info?: { mimetype?: string; size?: number } - ): Promise { - return client.sendMessage(roomId, { - msgtype: 'm.file', - body: filename, - url: mxcUrl, - info: info || {}, - }); - } - - /** - * Edit an existing message - */ - async editMessage( - client: MatrixClient, - roomId: string, - originalEventId: string, - newMessage: string, - options: SendMessageOptions = {} - ): Promise { - const { markdown = true, msgtype = 'm.text' } = options; - - const content: MatrixMessageContent = { - msgtype, - body: `* ${newMessage}`, - 'm.relates_to': { - rel_type: 'm.replace', - event_id: originalEventId, - }, - }; - - if (markdown) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = `* ${markdownToHtml(newMessage)}`; - } - - return client.sendMessage(roomId, { - ...content, - 'm.new_content': { - msgtype, - body: newMessage, - format: markdown ? 'org.matrix.custom.html' : undefined, - formatted_body: markdown ? markdownToHtml(newMessage) : undefined, - }, - }); - } - - /** - * Set room topic - */ - async setRoomTopic(client: MatrixClient, roomId: string, topic: string): Promise { - await client.sendStateEvent(roomId, 'm.room.topic', '', { topic }); - } - - /** - * Pin a message in a room - */ - async pinMessage(client: MatrixClient, roomId: string, eventId: string): Promise { - try { - // Get current pinned events - const pinnedEvents = await client - .getRoomStateEvent(roomId, 'm.room.pinned_events', '') - .catch(() => ({ pinned: [] })); - - const pinned: string[] = pinnedEvents?.pinned || []; - - // Add new event if not already pinned - if (!pinned.includes(eventId)) { - pinned.push(eventId); - await client.sendStateEvent(roomId, 'm.room.pinned_events', '', { pinned }); - } - } catch (error) { - this.logger.warn(`Failed to pin message: ${error}`); - } - } -} diff --git a/packages/matrix-bot-common/src/session/index.ts b/packages/matrix-bot-common/src/session/index.ts deleted file mode 100644 index ac6d69b3f..000000000 --- a/packages/matrix-bot-common/src/session/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SessionHelper, createSessionHelper } from './session-helper'; diff --git a/packages/matrix-bot-common/src/session/session-helper.ts b/packages/matrix-bot-common/src/session/session-helper.ts deleted file mode 100644 index dc01ca13a..000000000 --- a/packages/matrix-bot-common/src/session/session-helper.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type SessionService } from '@manacore/bot-services'; - -/** - * Typed session helper for bot-specific session data - * - * Provides type-safe access to session data stored in SessionService. - * - * @example - * ```typescript - * interface ChatSessionData { - * currentConversationId: string; - * selectedModelId: string; - * conversationList: string[]; - * } - * - * const session = new SessionHelper(sessionService, matrixUserId); - * await session.set('currentConversationId', 'abc123'); - * const convId = await session.get('currentConversationId'); // string | null - * ``` - */ -export class SessionHelper> { - constructor( - private readonly sessionService: SessionService, - private readonly userId: string - ) {} - - /** - * Set a session value - */ - async set(key: K, value: T[K]): Promise { - await this.sessionService.setSessionData(this.userId, key as string, value); - } - - /** - * Get a session value - */ - async get(key: K): Promise { - return this.sessionService.getSessionData(this.userId, key as string); - } - - /** - * Delete a session value - */ - async delete(key: K): Promise { - await this.sessionService.setSessionData(this.userId, key as string, null); - } - - /** - * Check if a session value exists - */ - async has(key: K): Promise { - return (await this.get(key)) !== null; - } - - /** - * Get the underlying user ID - */ - getUserId(): string { - return this.userId; - } - - /** - * Check if user is logged in - */ - async isLoggedIn(): Promise { - return this.sessionService.isLoggedIn(this.userId); - } - - /** - * Get JWT token for API calls - */ - async getToken(): Promise { - return this.sessionService.getToken(this.userId); - } -} - -/** - * Factory function to create session helper - */ -export function createSessionHelper>( - sessionService: SessionService, - userId: string -): SessionHelper { - return new SessionHelper(sessionService, userId); -} diff --git a/packages/matrix-bot-common/tsconfig.json b/packages/matrix-bot-common/tsconfig.json deleted file mode 100644 index 71802e564..000000000 --- a/packages/matrix-bot-common/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strict": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/scripts/mac-mini/build-app.sh b/scripts/mac-mini/build-app.sh index f64bb98ce..fefb38626 100755 --- a/scripts/mac-mini/build-app.sh +++ b/scripts/mac-mini/build-app.sh @@ -103,6 +103,7 @@ if [ $# -eq 0 ]; then echo " $0 todo-web todo-backend # Build & restart both" echo " $0 --base # Rebuild base images" echo " $0 --all-web # Rebuild all web apps" + echo " $0 mana-matrix-bot # Build & restart consolidated Matrix bot (Go)" exit 1 fi diff --git a/scripts/mac-mini/deploy-mana-bot.sh b/scripts/mac-mini/deploy-mana-bot.sh deleted file mode 100755 index 3c7b07d10..000000000 --- a/scripts/mac-mini/deploy-mana-bot.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -# Deploy Matrix Mana Bot (Gateway) to Mac Mini -# This script handles the complete deployment process - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' - -echo "============================================" -echo " Matrix Mana Bot - Full Deployment" -echo "============================================" -echo "" - -cd "$PROJECT_DIR" - -# Check if .env exists and has the token -if ! grep -q "MATRIX_MANA_BOT_TOKEN" .env 2>/dev/null; then - echo -e "${YELLOW}Warning: MATRIX_MANA_BOT_TOKEN not found in .env${NC}" - echo "Run ./scripts/mac-mini/setup-mana-bot.sh first to register the bot." - echo "" - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi -fi - -# Step 1: Pull latest code -echo -e "${CYAN}Step 1: Pulling latest code...${NC}" -git pull --ff-only || { - echo -e "${YELLOW}Git pull failed. You may have local changes.${NC}" - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi -} - -# Step 2: Build shared package -echo "" -echo -e "${CYAN}Step 2: Building @manacore/bot-services...${NC}" -cd "$PROJECT_DIR/packages/bot-services" -pnpm install --frozen-lockfile 2>/dev/null || pnpm install -pnpm build || { - echo -e "${RED}Failed to build bot-services package${NC}" - exit 1 -} -echo -e "${GREEN}bot-services built successfully${NC}" - -# Step 3: Build gateway bot -echo "" -echo -e "${CYAN}Step 3: Building matrix-mana-bot...${NC}" -cd "$PROJECT_DIR/services/matrix-mana-bot" -pnpm install --frozen-lockfile 2>/dev/null || pnpm install -pnpm build || { - echo -e "${RED}Failed to build matrix-mana-bot${NC}" - exit 1 -} -echo -e "${GREEN}matrix-mana-bot built successfully${NC}" - -# Step 4: Build Docker image -echo "" -echo -e "${CYAN}Step 4: Building Docker image...${NC}" -cd "$PROJECT_DIR" -docker build -t matrix-mana-bot:latest ./services/matrix-mana-bot || { - echo -e "${RED}Failed to build Docker image${NC}" - exit 1 -} -echo -e "${GREEN}Docker image built successfully${NC}" - -# Step 5: Stop existing container if running -echo "" -echo -e "${CYAN}Step 5: Stopping existing container...${NC}" -docker compose -f docker-compose.macmini.yml stop matrix-mana-bot 2>/dev/null || true -docker compose -f docker-compose.macmini.yml rm -f matrix-mana-bot 2>/dev/null || true - -# Step 6: Start new container -echo "" -echo -e "${CYAN}Step 6: Starting matrix-mana-bot...${NC}" -docker compose -f docker-compose.macmini.yml up -d matrix-mana-bot || { - echo -e "${RED}Failed to start container${NC}" - exit 1 -} - -# Step 7: Wait for health check -echo "" -echo -e "${CYAN}Step 7: Waiting for health check...${NC}" -for i in {1..30}; do - if curl -s http://localhost:3310/health > /dev/null 2>&1; then - echo -e "${GREEN}Health check passed!${NC}" - break - fi - if [ $i -eq 30 ]; then - echo -e "${RED}Health check failed after 30 seconds${NC}" - echo "Check logs with: docker logs manacore-matrix-mana-bot" - exit 1 - fi - echo -n "." - sleep 1 -done - -# Step 8: Show status -echo "" -echo "============================================" -echo -e "${GREEN} Deployment Complete!${NC}" -echo "============================================" -echo "" -echo "Container Status:" -docker ps --filter "name=manacore-matrix-mana-bot" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" -echo "" -echo "Health Check:" -curl -s http://localhost:3310/health | jq . 2>/dev/null || curl -s http://localhost:3310/health -echo "" -echo "" -echo "Next Steps:" -echo "1. Invite the bot to a Matrix room:" -echo " /invite @mana:mana.how" -echo "" -echo "2. Test with:" -echo " hilfe" -echo " !todo Test aufgabe" -echo " !list" -echo "" -echo "3. View logs with:" -echo " docker logs -f manacore-matrix-mana-bot" -echo "" diff --git a/scripts/mac-mini/setup-mana-bot.sh b/scripts/mac-mini/setup-mana-bot.sh deleted file mode 100755 index 2769d7bdc..000000000 --- a/scripts/mac-mini/setup-mana-bot.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/bin/bash -# Register and setup Matrix Mana Bot (Gateway) -# Run this after Matrix Synapse is running - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' - -echo "============================================" -echo " Matrix Mana Bot Setup" -echo "============================================" -echo "" - -# Default values -HOMESERVER_URL="${MATRIX_HOMESERVER_URL:-http://localhost:8008}" -BOT_USERNAME="mana" -BOT_DISPLAY_NAME="Mana" - -# Check if Synapse is running -echo "Checking Synapse..." -if ! curl -s "${HOMESERVER_URL}/health" > /dev/null 2>&1; then - echo -e "${RED}Error: Synapse is not reachable at ${HOMESERVER_URL}${NC}" - echo "Start it with: docker compose -f docker-compose.macmini.yml up -d synapse" - exit 1 -fi -echo -e "${GREEN}Synapse is running${NC}" -echo "" - -# Check if registration secret is available -if [ -z "$SYNAPSE_REGISTRATION_SECRET" ]; then - echo -e "${YELLOW}SYNAPSE_REGISTRATION_SECRET not set.${NC}" - echo "Please provide the registration secret from your .env file:" - read -sp "Registration Secret: " SYNAPSE_REGISTRATION_SECRET - echo "" -fi - -# Generate bot password -BOT_PASSWORD=$(openssl rand -base64 24) - -echo "Registering bot user @${BOT_USERNAME}..." - -# Generate HMAC for registration -generate_mac() { - local nonce=$1 - local user=$2 - local password=$3 - local user_type=$4 - local admin=$5 - - local mac_input="${nonce}\x00${user}\x00${password}\x00${user_type}\x00${admin}" - echo -n "$mac_input" | openssl dgst -sha1 -hmac "$SYNAPSE_REGISTRATION_SECRET" | cut -d' ' -f2 -} - -# Get nonce -NONCE=$(curl -s "${HOMESERVER_URL}/_synapse/admin/v1/register" | jq -r '.nonce') - -if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then - echo -e "${RED}Failed to get registration nonce. Is admin registration enabled?${NC}" - exit 1 -fi - -# Calculate MAC -MAC=$(generate_mac "$NONCE" "$BOT_USERNAME" "$BOT_PASSWORD" "bot" "false") - -# Register user -REGISTER_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_synapse/admin/v1/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"nonce\": \"${NONCE}\", - \"username\": \"${BOT_USERNAME}\", - \"password\": \"${BOT_PASSWORD}\", - \"displayname\": \"${BOT_DISPLAY_NAME}\", - \"user_type\": \"bot\", - \"admin\": false, - \"mac\": \"${MAC}\" - }") - -# Check if registration was successful -if echo "$REGISTER_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then - ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.access_token') - USER_ID=$(echo "$REGISTER_RESPONSE" | jq -r '.user_id') - - echo -e "${GREEN}Bot registered successfully!${NC}" - echo "" - echo -e "${CYAN}User ID:${NC} ${USER_ID}" - echo "" -else - ERROR=$(echo "$REGISTER_RESPONSE" | jq -r '.error // .errcode // "Unknown error"') - - # Check if user already exists - if echo "$ERROR" | grep -qi "user.*exists\|already.*registered\|M_USER_IN_USE"; then - echo -e "${YELLOW}User @${BOT_USERNAME} already exists. Getting access token via login...${NC}" - - echo "Please enter the existing bot password:" - read -sp "Password: " EXISTING_PASSWORD - echo "" - - LOGIN_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_matrix/client/r0/login" \ - -H "Content-Type: application/json" \ - -d "{ - \"type\": \"m.login.password\", - \"user\": \"${BOT_USERNAME}\", - \"password\": \"${EXISTING_PASSWORD}\" - }") - - if echo "$LOGIN_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then - ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token') - USER_ID=$(echo "$LOGIN_RESPONSE" | jq -r '.user_id') - echo -e "${GREEN}Login successful!${NC}" - else - echo -e "${RED}Login failed. Please check the password.${NC}" - exit 1 - fi - else - echo -e "${RED}Registration failed: ${ERROR}${NC}" - exit 1 - fi -fi - -echo "" -echo "============================================" -echo " Add to .env file" -echo "============================================" -echo "" -echo -e "${CYAN}# Matrix Mana Bot (Gateway)${NC}" -echo "MATRIX_MANA_BOT_TOKEN=${ACCESS_TOKEN}" -echo "" - -# Optional: Set display name and avatar -echo "Setting display name..." -curl -s -X PUT "${HOMESERVER_URL}/_matrix/client/r0/profile/${USER_ID}/displayname" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"displayname\": \"🤖 ${BOT_DISPLAY_NAME}\"}" > /dev/null - -echo "" -echo "============================================" -echo " Next Steps" -echo "============================================" -echo "" -echo "1. Add the MATRIX_MANA_BOT_TOKEN to your .env file" -echo "" -echo "2. Build the bot image:" -echo " docker build -t matrix-mana-bot ./services/matrix-mana-bot" -echo "" -echo "3. Start the bot:" -echo " docker compose -f docker-compose.macmini.yml up -d matrix-mana-bot" -echo "" -echo "4. Invite the bot to a room in Element:" -echo " /invite @mana:mana.how" -echo "" -echo -e "${GREEN}Setup complete!${NC}" diff --git a/services/mana-api-gateway-go/.gitignore b/services/mana-api-gateway-go/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/services/mana-api-gateway-go/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/services/mana-api-gateway-go/CLAUDE.md b/services/mana-api-gateway-go/CLAUDE.md new file mode 100644 index 000000000..e95bdf103 --- /dev/null +++ b/services/mana-api-gateway-go/CLAUDE.md @@ -0,0 +1,53 @@ +# mana-api-gateway (Go) + +Go replacement for the NestJS API Gateway. Handles API key management, rate limiting, credit billing, and service proxying. + +## Architecture + +- **Language:** Go 1.25 +- **Database:** PostgreSQL (pgx v5) +- **Cache/RateLimit:** Redis (sliding window) +- **Port:** 3030 + +## Endpoints + +### Public API (X-API-Key auth) +- `POST /v1/search` — Web search (1 credit) +- `POST /v1/extract` — Content extraction (1 credit) +- `POST /v1/stt/transcribe` — Speech-to-text (10 credits/min) +- `POST /v1/tts/synthesize` — Text-to-speech (1 credit/1000 chars) + +### Management API (JWT auth) +- `POST /api-keys` — Create API key +- `GET /api-keys` — List user's keys +- `DELETE /api-keys/{id}` — Delete key +- `GET /api-keys/{id}/usage` — Daily usage stats + +### System +- `GET /health` — Health check (DB + Redis) +- `GET /metrics` — Prometheus metrics + +## Pricing Tiers + +| Tier | Rate Limit | Monthly Credits | Price | +|------|-----------|-----------------|-------| +| Free | 10 req/min | 100 | €0 | +| Pro | 100 req/min | 5,000 | €19/mo | +| Enterprise | 1,000 req/min | 50,000 | €99/mo | + +## Commands + +```bash +go run ./cmd/server # Dev +go build ./cmd/server # Build +go test ./... # Test +``` + +## Environment Variables + +- `PORT` — Server port (3030) +- `DATABASE_URL` — PostgreSQL connection +- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` +- `SEARCH_SERVICE_URL`, `STT_SERVICE_URL`, `TTS_SERVICE_URL` +- `MANA_CORE_AUTH_URL` — JWT validation +- `ADMIN_USER_IDS` — Comma-separated admin user IDs diff --git a/services/mana-api-gateway-go/Dockerfile b/services/mana-api-gateway-go/Dockerfile new file mode 100644 index 000000000..e55d10c36 --- /dev/null +++ b/services/mana-api-gateway-go/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /app +COPY services/mana-api-gateway-go/go.mod services/mana-api-gateway-go/go.sum ./ +RUN go mod download + +COPY services/mana-api-gateway-go/ . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-api-gateway ./cmd/server + +# Runtime stage +FROM alpine:3.21 + +RUN apk --no-cache add ca-certificates tzdata + +COPY --from=builder /mana-api-gateway /usr/local/bin/mana-api-gateway + +EXPOSE 3030 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:3030/health || exit 1 + +ENTRYPOINT ["mana-api-gateway"] diff --git a/services/mana-api-gateway-go/cmd/server/main.go b/services/mana-api-gateway-go/cmd/server/main.go new file mode 100644 index 000000000..fb3ca6b81 --- /dev/null +++ b/services/mana-api-gateway-go/cmd/server/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/manacore/mana-api-gateway/internal/config" + "github.com/manacore/mana-api-gateway/internal/db" + "github.com/manacore/mana-api-gateway/internal/handler" + "github.com/manacore/mana-api-gateway/internal/middleware" + "github.com/manacore/mana-api-gateway/internal/proxy" + "github.com/manacore/mana-api-gateway/internal/service" + "github.com/redis/go-redis/v9" + "github.com/rs/cors" +) + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + cfg := config.Load() + ctx := context.Background() + + // Database + database, err := db.New(ctx, cfg.DatabaseURL) + if err != nil { + slog.Error("database connection failed", "error", err) + os.Exit(1) + } + defer database.Close() + + if err := database.Migrate(ctx); err != nil { + slog.Error("migration failed", "error", err) + os.Exit(1) + } + + // Redis + rdb := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort), + Password: cfg.RedisPassword, + DB: 0, + }) + if err := rdb.Ping(ctx).Err(); err != nil { + slog.Warn("redis unavailable, rate limiting disabled", "error", err) + } else { + slog.Info("redis connected") + } + defer rdb.Close() + + // Services + apiKeySvc := service.NewApiKeyService(database.Pool, cfg.KeyPrefixLive, cfg.KeyPrefixTest) + usageSvc := service.NewUsageService(database.Pool) + + // Handlers + apiKeysHandler := handler.NewApiKeysHandler(apiKeySvc, usageSvc) + healthHandler := handler.NewHealthHandler(database.Pool, rdb) + + // Proxy + serviceProxy := proxy.NewServiceProxy(cfg.SearchURL, cfg.STTURL, cfg.TTSURL, apiKeySvc, usageSvc) + + // Middleware chains + apiKeyAuth := middleware.ApiKeyMiddleware(apiKeySvc) + rateLimit := middleware.RateLimitMiddleware(rdb, cfg.RedisPrefix) + creditsCheck := middleware.CreditsMiddleware(apiKeySvc) + jwtAuth := middleware.JWTMiddleware(cfg.AuthURL) + + // Chain: API Key → Rate Limit → Credits → Handler + publicChain := func(h http.Handler) http.Handler { + return apiKeyAuth(rateLimit(creditsCheck(h))) + } + + // Routes + mux := http.NewServeMux() + + // Health & Metrics (public, no auth) + mux.HandleFunc("GET /health", healthHandler.Health) + mux.HandleFunc("GET /metrics", healthHandler.Metrics) + + // Public API (API Key auth + rate limit + credits) + mux.Handle("POST /v1/search", publicChain(http.HandlerFunc(serviceProxy.HandleSearch))) + mux.Handle("POST /v1/extract", publicChain(http.HandlerFunc(serviceProxy.HandleSearch))) + mux.Handle("POST /v1/extract/bulk", publicChain(http.HandlerFunc(serviceProxy.HandleSearch))) + mux.Handle("GET /v1/search/engines", publicChain(http.HandlerFunc(serviceProxy.HandleSearch))) + mux.Handle("POST /v1/stt/transcribe", publicChain(http.HandlerFunc(serviceProxy.HandleSTT))) + mux.Handle("GET /v1/stt/models", publicChain(http.HandlerFunc(serviceProxy.HandleSTT))) + mux.Handle("GET /v1/stt/languages", publicChain(http.HandlerFunc(serviceProxy.HandleSTT))) + mux.Handle("POST /v1/tts/synthesize", publicChain(http.HandlerFunc(serviceProxy.HandleTTS))) + mux.Handle("GET /v1/tts/voices", publicChain(http.HandlerFunc(serviceProxy.HandleTTS))) + mux.Handle("GET /v1/tts/languages", publicChain(http.HandlerFunc(serviceProxy.HandleTTS))) + + // Management API (JWT auth) + mux.Handle("POST /api-keys", jwtAuth(http.HandlerFunc(apiKeysHandler.CreateKey))) + mux.Handle("GET /api-keys", jwtAuth(http.HandlerFunc(apiKeysHandler.ListKeys))) + mux.Handle("DELETE /api-keys/{id}", jwtAuth(http.HandlerFunc(apiKeysHandler.DeleteKey))) + mux.Handle("GET /api-keys/{id}/usage", jwtAuth(http.HandlerFunc(apiKeysHandler.GetUsage))) + mux.Handle("GET /api-keys/{id}/usage/summary", jwtAuth(http.HandlerFunc(apiKeysHandler.GetUsageSummary))) + + // CORS + c := cors.New(cors.Options{ + AllowedOrigins: cfg.CORSOrigins, + AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "X-API-Key"}, + AllowCredentials: true, + }) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: c.Handler(mux), + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + slog.Info("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + server.Shutdown(ctx) + }() + + slog.Info("mana-api-gateway starting", "port", cfg.Port) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } +} diff --git a/services/mana-api-gateway-go/go.mod b/services/mana-api-gateway-go/go.mod new file mode 100644 index 000000000..92a8e8639 --- /dev/null +++ b/services/mana-api-gateway-go/go.mod @@ -0,0 +1,21 @@ +module github.com/manacore/mana-api-gateway + +go 1.25.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.9.1 + github.com/redis/go-redis/v9 v9.18.0 + github.com/rs/cors v1.11.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/services/mana-api-gateway-go/go.sum b/services/mana-api-gateway-go/go.sum new file mode 100644 index 000000000..9614067f1 --- /dev/null +++ b/services/mana-api-gateway-go/go.sum @@ -0,0 +1,46 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/mana-api-gateway-go/internal/config/config.go b/services/mana-api-gateway-go/internal/config/config.go new file mode 100644 index 000000000..c49d91d05 --- /dev/null +++ b/services/mana-api-gateway-go/internal/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +type Config struct { + Port int + + DatabaseURL string + + RedisHost string + RedisPort int + RedisPassword string + RedisPrefix string + + // Backend service URLs + SearchURL string + STTURL string + TTSURL string + + // Auth + AuthURL string + AdminUserIDs []string + + // API Key settings + KeyPrefixLive string + KeyPrefixTest string + + // Defaults + DefaultRateLimit int + DefaultMonthlyCredits int + + CORSOrigins []string +} + +func Load() *Config { + port, _ := strconv.Atoi(getEnv("PORT", "3030")) + redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379")) + defaultRL, _ := strconv.Atoi(getEnv("DEFAULT_RATE_LIMIT", "10")) + defaultCredits, _ := strconv.Atoi(getEnv("DEFAULT_MONTHLY_CREDITS", "100")) + + var adminIDs []string + if ids := os.Getenv("ADMIN_USER_IDS"); ids != "" { + for _, id := range strings.Split(ids, ",") { + id = strings.TrimSpace(id) + if id != "" { + adminIDs = append(adminIDs, id) + } + } + } + + var origins []string + if o := os.Getenv("CORS_ORIGINS"); o != "" { + for _, origin := range strings.Split(o, ",") { + origin = strings.TrimSpace(origin) + if origin != "" { + origins = append(origins, origin) + } + } + } + if len(origins) == 0 { + origins = []string{"http://localhost:3000", "http://localhost:5173"} + } + + return &Config{ + Port: port, + DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"), + RedisHost: getEnv("REDIS_HOST", "localhost"), + RedisPort: redisPort, + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisPrefix: getEnv("REDIS_PREFIX", "api-gateway:"), + SearchURL: getEnv("SEARCH_SERVICE_URL", "http://localhost:3021"), + STTURL: getEnv("STT_SERVICE_URL", "http://localhost:3020"), + TTSURL: getEnv("TTS_SERVICE_URL", "http://localhost:3022"), + AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"), + AdminUserIDs: adminIDs, + KeyPrefixLive: getEnv("API_KEY_PREFIX_LIVE", "sk_live_"), + KeyPrefixTest: getEnv("API_KEY_PREFIX_TEST", "sk_test_"), + DefaultRateLimit: defaultRL, + DefaultMonthlyCredits: defaultCredits, + CORSOrigins: origins, + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/services/mana-api-gateway-go/internal/db/db.go b/services/mana-api-gateway-go/internal/db/db.go new file mode 100644 index 000000000..1aa594571 --- /dev/null +++ b/services/mana-api-gateway-go/internal/db/db.go @@ -0,0 +1,119 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB wraps a pgx connection pool. +type DB struct { + Pool *pgxpool.Pool +} + +// New creates a new database connection pool. +func New(ctx context.Context, databaseURL string) (*DB, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse db config: %w", err) + } + + config.MaxConns = 20 + config.MinConns = 2 + config.MaxConnLifetime = 30 * time.Minute + config.MaxConnIdleTime = 5 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + + slog.Info("database connected") + return &DB{Pool: pool}, nil +} + +// Migrate creates the schema and tables. +func (d *DB) Migrate(ctx context.Context) error { + sql := ` + CREATE SCHEMA IF NOT EXISTS api_gateway; + + CREATE TABLE IF NOT EXISTS api_gateway.api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL UNIQUE, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + user_id TEXT, + organization_id TEXT, + name TEXT NOT NULL, + description TEXT DEFAULT '', + tier TEXT NOT NULL DEFAULT 'free', + rate_limit INT NOT NULL DEFAULT 10, + monthly_credits INT NOT NULL DEFAULT 100, + credits_used INT NOT NULL DEFAULT 0, + credits_reset_at TIMESTAMPTZ, + allowed_endpoints JSONB DEFAULT '["search"]', + allowed_ips JSONB, + active BOOLEAN NOT NULL DEFAULT true, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_gateway.api_keys(key_hash); + CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_gateway.api_keys(user_id); + + CREATE TABLE IF NOT EXISTS api_gateway.api_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_key_id UUID NOT NULL REFERENCES api_gateway.api_keys(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + request_size INT, + response_size INT, + latency_ms INT, + status_code INT, + credits_used INT NOT NULL DEFAULT 0, + credit_reason TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_api_usage_key_id ON api_gateway.api_usage(api_key_id); + CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_gateway.api_usage(created_at); + + CREATE TABLE IF NOT EXISTS api_gateway.api_usage_daily ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_key_id UUID NOT NULL REFERENCES api_gateway.api_keys(id) ON DELETE CASCADE, + date DATE NOT NULL, + endpoint TEXT NOT NULL, + request_count INT NOT NULL DEFAULT 0, + credits_used INT NOT NULL DEFAULT 0, + total_latency_ms INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + UNIQUE(api_key_id, date, endpoint) + ); + + CREATE INDEX IF NOT EXISTS idx_api_usage_daily_date ON api_gateway.api_usage_daily(date); + ` + + _, err := d.Pool.Exec(ctx, sql) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + + slog.Info("database migrated") + return nil +} + +// Close closes the connection pool. +func (d *DB) Close() { + d.Pool.Close() +} diff --git a/services/mana-api-gateway-go/internal/handler/apikeys.go b/services/mana-api-gateway-go/internal/handler/apikeys.go new file mode 100644 index 000000000..afe2cd4c7 --- /dev/null +++ b/services/mana-api-gateway-go/internal/handler/apikeys.go @@ -0,0 +1,132 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/manacore/mana-api-gateway/internal/middleware" + "github.com/manacore/mana-api-gateway/internal/service" +) + +// ApiKeysHandler handles API key management endpoints. +type ApiKeysHandler struct { + apiKeyService *service.ApiKeyService + usageService *service.UsageService +} + +// NewApiKeysHandler creates a new handler. +func NewApiKeysHandler(apiKeySvc *service.ApiKeyService, usageSvc *service.UsageService) *ApiKeysHandler { + return &ApiKeysHandler{apiKeyService: apiKeySvc, usageService: usageSvc} +} + +// CreateKey handles POST /api-keys +func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + if userID == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) + return + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + Tier string `json:"tier"` + IsTest bool `json:"isTest"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if body.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + + if body.Tier == "" { + body.Tier = "free" + } + + rawKey, apiKey, err := h.apiKeyService.Create(r.Context(), userID, body.Name, body.Description, body.Tier, body.IsTest) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"}) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "key": rawKey, + "apiKey": apiKey, + }) +} + +// ListKeys handles GET /api-keys +func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + if userID == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"}) + return + } + + keys, err := h.apiKeyService.ListByUser(r.Context(), userID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"}) + return + } + + if keys == nil { + keys = []service.ApiKey{} + } + + writeJSON(w, http.StatusOK, keys) +} + +// DeleteKey handles DELETE /api-keys/{id} +func (h *ApiKeysHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + keyID := r.PathValue("id") + + if err := h.apiKeyService.Delete(r.Context(), keyID, userID); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "key deleted"}) +} + +// GetUsage handles GET /api-keys/{id}/usage +func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) { + keyID := r.PathValue("id") + + usage, err := h.usageService.GetDailyUsage(r.Context(), keyID, 30) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"}) + return + } + + if usage == nil { + usage = []service.DailyUsage{} + } + + writeJSON(w, http.StatusOK, usage) +} + +// GetUsageSummary handles GET /api-keys/{id}/usage/summary +func (h *ApiKeysHandler) GetUsageSummary(w http.ResponseWriter, r *http.Request) { + keyID := r.PathValue("id") + since := time.Now().AddDate(0, -1, 0) // last 30 days + + summary, err := h.usageService.GetSummary(r.Context(), keyID, since) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"}) + return + } + + writeJSON(w, http.StatusOK, summary) +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/services/mana-api-gateway-go/internal/handler/health.go b/services/mana-api-gateway-go/internal/handler/health.go new file mode 100644 index 000000000..3015b41bc --- /dev/null +++ b/services/mana-api-gateway-go/internal/handler/health.go @@ -0,0 +1,69 @@ +package handler + +import ( + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +// HealthHandler handles health and metrics endpoints. +type HealthHandler struct { + pool *pgxpool.Pool + redis *redis.Client + startTime time.Time +} + +// NewHealthHandler creates a new health handler. +func NewHealthHandler(pool *pgxpool.Pool, rdb *redis.Client) *HealthHandler { + return &HealthHandler{pool: pool, redis: rdb, startTime: time.Now()} +} + +// Health handles GET /health +func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbOK := "ok" + if err := h.pool.Ping(ctx); err != nil { + dbOK = "error" + } + + redisOK := "ok" + if err := h.redis.Ping(ctx).Err(); err != nil { + redisOK = "error" + } + + status := "ok" + if dbOK != "ok" || redisOK != "ok" { + status = "degraded" + } + + writeJSON(w, http.StatusOK, map[string]any{ + "status": status, + "service": "mana-api-gateway", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "uptime": time.Since(h.startTime).Seconds(), + "database": dbOK, + "redis": redisOK, + }) +} + +// Metrics handles GET /metrics (Prometheus format) +func (h *HealthHandler) Metrics(w http.ResponseWriter, r *http.Request) { + uptime := time.Since(h.startTime).Seconds() + + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, "# HELP mana_api_gateway_uptime_seconds Gateway uptime\n") + fmt.Fprintf(w, "# TYPE mana_api_gateway_uptime_seconds gauge\n") + fmt.Fprintf(w, "mana_api_gateway_uptime_seconds %.0f\n", uptime) + + // DB pool stats + stats := h.pool.Stat() + fmt.Fprintf(w, "# HELP mana_api_gateway_db_connections Database connection pool\n") + fmt.Fprintf(w, "# TYPE mana_api_gateway_db_connections gauge\n") + fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"total\"} %d\n", stats.TotalConns()) + fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"idle\"} %d\n", stats.IdleConns()) + fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"acquired\"} %d\n", stats.AcquiredConns()) +} diff --git a/services/mana-api-gateway-go/internal/middleware/apikey.go b/services/mana-api-gateway-go/internal/middleware/apikey.go new file mode 100644 index 000000000..9bbd9f89c --- /dev/null +++ b/services/mana-api-gateway-go/internal/middleware/apikey.go @@ -0,0 +1,106 @@ +package middleware + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "github.com/manacore/mana-api-gateway/internal/service" +) + +type contextKey string + +const ApiKeyContextKey contextKey = "apiKey" + +var endpointRegex = regexp.MustCompile(`/v1/(\w+)`) + +// ApiKeyMiddleware validates X-API-Key header and attaches key data to context. +func ApiKeyMiddleware(apiKeyService *service.ApiKeyService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawKey := r.Header.Get("X-API-Key") + if rawKey == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "API key required. Use X-API-Key header.", + }) + return + } + + keyData, err := apiKeyService.ValidateKey(r.Context(), rawKey) + if err != nil { + slog.Debug("invalid api key", "error", err) + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "Invalid API key", + }) + return + } + + if !keyData.Active { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "API key is disabled", + }) + return + } + + if keyData.ExpiresAt != nil && time.Now().After(*keyData.ExpiresAt) { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "API key has expired", + }) + return + } + + // Check endpoint permission + endpoint := extractEndpoint(r.URL.Path) + if !hasEndpointPermission(keyData, endpoint) { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": "Endpoint '" + endpoint + "' not allowed for this API key. Upgrade your plan.", + }) + return + } + + // Attach key data to context + ctx := context.WithValue(r.Context(), ApiKeyContextKey, keyData) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetApiKey retrieves the API key data from the request context. +func GetApiKey(r *http.Request) *service.ApiKeyData { + data, _ := r.Context().Value(ApiKeyContextKey).(*service.ApiKeyData) + return data +} + +func extractEndpoint(path string) string { + m := endpointRegex.FindStringSubmatch(path) + if len(m) >= 2 { + return m[1] + } + return "unknown" +} + +func hasEndpointPermission(keyData *service.ApiKeyData, endpoint string) bool { + if keyData.AllowedEndpoints == "" { + return true + } + var allowed []string + if err := json.Unmarshal([]byte(keyData.AllowedEndpoints), &allowed); err != nil { + return true + } + for _, e := range allowed { + if e == endpoint || strings.HasPrefix(endpoint, e) { + return true + } + } + return false +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/services/mana-api-gateway-go/internal/middleware/jwt.go b/services/mana-api-gateway-go/internal/middleware/jwt.go new file mode 100644 index 000000000..f3e8175e1 --- /dev/null +++ b/services/mana-api-gateway-go/internal/middleware/jwt.go @@ -0,0 +1,101 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +type jwtContextKey string + +const UserIDContextKey jwtContextKey = "userID" +const UserRoleContextKey jwtContextKey = "userRole" + +// JWTClaims holds the JWT token claims. +type JWTClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// JWTMiddleware validates Bearer JWT tokens for management endpoints. +// Uses JWKS from mana-core-auth (simplified: accepts any valid JWT structure for now). +func JWTMiddleware(authURL string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "Authorization header required. Use Bearer .", + }) + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + // Validate JWT via auth service + userID, role, err := validateJWT(r.Context(), authURL, tokenStr) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "Invalid or expired token", + }) + return + } + + ctx := context.WithValue(r.Context(), UserIDContextKey, userID) + ctx = context.WithValue(ctx, UserRoleContextKey, role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetUserID returns the authenticated user ID from context. +func GetUserID(r *http.Request) string { + id, _ := r.Context().Value(UserIDContextKey).(string) + return id +} + +// GetUserRole returns the user role from context. +func GetUserRole(r *http.Request) string { + role, _ := r.Context().Value(UserRoleContextKey).(string) + return role +} + +// validateJWT calls mana-core-auth /api/v1/auth/validate to verify the token. +func validateJWT(ctx context.Context, authURL, token string) (userID, role string, err error) { + req, err := http.NewRequestWithContext(ctx, "POST", authURL+"/api/v1/auth/validate", strings.NewReader(`{"token":"`+token+`"}`)) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("auth service: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("auth validation failed: %d", resp.StatusCode) + } + + var result struct { + Valid bool `json:"valid"` + UserID string `json:"userId"` + Role string `json:"role"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", "", err + } + + if !result.Valid { + return "", "", fmt.Errorf("token not valid") + } + + return result.UserID, result.Role, nil +} diff --git a/services/mana-api-gateway-go/internal/middleware/ratelimit.go b/services/mana-api-gateway-go/internal/middleware/ratelimit.go new file mode 100644 index 000000000..5563613cd --- /dev/null +++ b/services/mana-api-gateway-go/internal/middleware/ratelimit.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/manacore/mana-api-gateway/internal/service" + "github.com/redis/go-redis/v9" +) + +// RateLimitMiddleware enforces per-key sliding window rate limits using Redis. +func RateLimitMiddleware(rdb *redis.Client, prefix string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + keyData := GetApiKey(r) + if keyData == nil { + next.ServeHTTP(w, r) + return + } + + ctx := r.Context() + key := prefix + "ratelimit:" + keyData.ID + limit := keyData.RateLimit + window := int64(60) // 60 seconds + + now := time.Now().UnixMilli() + windowStart := now - window*1000 + + pipe := rdb.Pipeline() + pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10)) + countCmd := pipe.ZCard(ctx, key) + pipe.Exec(ctx) + + count := countCmd.Val() + + if count >= int64(limit) { + w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit)) + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("Retry-After", "60") + writeJSON(w, http.StatusTooManyRequests, map[string]any{ + "error": "Rate limit exceeded", + "limit": limit, + "remaining": 0, + "retryAfter": 60, + }) + return + } + + // Add current request + rdb.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d", now)}) + rdb.Expire(ctx, key, time.Duration(window)*time.Second) + + remaining := int64(limit) - count - 1 + if remaining < 0 { + remaining = 0 + } + + w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit)) + w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(remaining, 10)) + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(now/1000+window, 10)) + + next.ServeHTTP(w, r) + }) + } +} + +// CreditsMiddleware checks if the API key has enough credits. +func CreditsMiddleware(apiKeyService *service.ApiKeyService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + keyData := GetApiKey(r) + if keyData == nil { + next.ServeHTTP(w, r) + return + } + + endpoint := extractEndpoint(r.URL.Path) + estimatedCredits := service.CreditCosts[endpoint] + if estimatedCredits == 0 { + estimatedCredits = 1 + } + + ok, err := apiKeyService.HasEnoughCredits(r.Context(), keyData.ID, estimatedCredits) + if err != nil || !ok { + writeJSON(w, http.StatusPaymentRequired, map[string]any{ + "error": "Insufficient credits", + "creditsRequired": estimatedCredits, + "creditsUsed": keyData.CreditsUsed, + "monthlyCredits": keyData.MonthlyCredits, + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/services/mana-api-gateway-go/internal/proxy/proxy.go b/services/mana-api-gateway-go/internal/proxy/proxy.go new file mode 100644 index 000000000..928da6de8 --- /dev/null +++ b/services/mana-api-gateway-go/internal/proxy/proxy.go @@ -0,0 +1,157 @@ +package proxy + +import ( + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/manacore/mana-api-gateway/internal/middleware" + "github.com/manacore/mana-api-gateway/internal/service" +) + +// ServiceProxy proxies requests to backend services and tracks usage. +type ServiceProxy struct { + searchProxy *httputil.ReverseProxy + sttProxy *httputil.ReverseProxy + ttsProxy *httputil.ReverseProxy + + apiKeyService *service.ApiKeyService + usageService *service.UsageService +} + +// NewServiceProxy creates a new service proxy. +func NewServiceProxy(searchURL, sttURL, ttsURL string, apiKeySvc *service.ApiKeyService, usageSvc *service.UsageService) *ServiceProxy { + return &ServiceProxy{ + searchProxy: createProxy(searchURL), + sttProxy: createProxy(sttURL), + ttsProxy: createProxy(ttsURL), + apiKeyService: apiKeySvc, + usageService: usageSvc, + } +} + +func createProxy(targetURL string) *httputil.ReverseProxy { + target, err := url.Parse(targetURL) + if err != nil { + slog.Error("invalid proxy target", "url", targetURL, "error", err) + return nil + } + + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.Transport = &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + } + + return proxy +} + +// HandleSearch proxies to the search service. +func (p *ServiceProxy) HandleSearch(w http.ResponseWriter, r *http.Request) { + p.proxyRequest(w, r, p.searchProxy, "search", "/api/v1") +} + +// HandleSTT proxies to the STT service. +func (p *ServiceProxy) HandleSTT(w http.ResponseWriter, r *http.Request) { + p.proxyRequest(w, r, p.sttProxy, "stt", "") +} + +// HandleTTS proxies to the TTS service. +func (p *ServiceProxy) HandleTTS(w http.ResponseWriter, r *http.Request) { + p.proxyRequest(w, r, p.ttsProxy, "tts", "") +} + +func (p *ServiceProxy) proxyRequest(w http.ResponseWriter, r *http.Request, proxy *httputil.ReverseProxy, endpoint, pathPrefix string) { + if proxy == nil { + http.Error(w, `{"error":"service unavailable"}`, http.StatusServiceUnavailable) + return + } + + keyData := middleware.GetApiKey(r) + start := time.Now() + + // Rewrite path: /v1/search -> /api/v1/search (or whatever the backend expects) + originalPath := r.URL.Path + if pathPrefix != "" { + r.URL.Path = strings.Replace(r.URL.Path, "/v1/"+endpoint, pathPrefix+"/"+endpoint, 1) + } + + // Use a response recorder to capture status code + rec := &responseRecorder{ResponseWriter: w, statusCode: 200} + + proxy.ServeHTTP(rec, r) + + // Log usage + latency := time.Since(start).Milliseconds() + credits := service.CreditCosts[endpoint] + if credits == 0 { + credits = 1 + } + + // Deduct credits + if keyData != nil { + p.apiKeyService.IncrementCredits(r.Context(), keyData.ID, credits) + + // Log usage asynchronously + go func() { + p.usageService.LogUsage(r.Context(), service.UsageEntry{ + ApiKeyID: keyData.ID, + Endpoint: endpoint, + Method: r.Method, + Path: originalPath, + RequestSize: int(r.ContentLength), + ResponseSize: rec.size, + LatencyMs: int(latency), + StatusCode: rec.statusCode, + CreditsUsed: credits, + CreditReason: endpoint, + }) + }() + } +} + +// responseRecorder captures the status code and response size. +type responseRecorder struct { + http.ResponseWriter + statusCode int + size int +} + +func (r *responseRecorder) WriteHeader(code int) { + r.statusCode = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *responseRecorder) Write(b []byte) (int, error) { + n, err := r.ResponseWriter.Write(b) + r.size += n + return n, err +} + +// Flush implements http.Flusher for streaming responses. +func (r *responseRecorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap returns the underlying ResponseWriter (for http.ResponseController). +func (r *responseRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + +// ReadFrom implements io.ReaderFrom for efficient copying. +func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) { + if rf, ok := r.ResponseWriter.(io.ReaderFrom); ok { + n, err := rf.ReadFrom(src) + r.size += int(n) + return n, err + } + // Fallback: use io.Copy which will call Write + return io.Copy(r.ResponseWriter, src) +} diff --git a/services/mana-api-gateway-go/internal/service/apikeys.go b/services/mana-api-gateway-go/internal/service/apikeys.go new file mode 100644 index 000000000..b2d0dc066 --- /dev/null +++ b/services/mana-api-gateway-go/internal/service/apikeys.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ApiKey represents a stored API key. +type ApiKey struct { + ID string `json:"id"` + Key string `json:"key"` // masked in responses + KeyHash string `json:"-"` + KeyPrefix string `json:"keyPrefix"` + UserID *string `json:"userId"` + OrganizationID *string `json:"organizationId"` + Name string `json:"name"` + Description string `json:"description"` + Tier string `json:"tier"` + RateLimit int `json:"rateLimit"` + MonthlyCredits int `json:"monthlyCredits"` + CreditsUsed int `json:"creditsUsed"` + CreditsResetAt *time.Time `json:"creditsResetAt"` + AllowedEndpoints string `json:"allowedEndpoints"` // JSON array + AllowedIPs *string `json:"allowedIps"` + Active bool `json:"active"` + ExpiresAt *time.Time `json:"expiresAt"` + LastUsedAt *time.Time `json:"lastUsedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ApiKeyData is the validated key data attached to requests. +type ApiKeyData struct { + ID string + UserID *string + OrganizationID *string + Name string + Tier string + RateLimit int + MonthlyCredits int + CreditsUsed int + AllowedEndpoints string + AllowedIPs *string + Active bool + ExpiresAt *time.Time +} + +// PricingTier defines limits for a tier. +type PricingTier struct { + Name string + RateLimit int + MonthlyCredits int + Endpoints []string + Price int // cents +} + +var Tiers = map[string]PricingTier{ + "free": {Name: "Free", RateLimit: 10, MonthlyCredits: 100, Endpoints: []string{"search"}, Price: 0}, + "pro": {Name: "Pro", RateLimit: 100, MonthlyCredits: 5000, Endpoints: []string{"search", "stt", "tts"}, Price: 1900}, + "enterprise": {Name: "Enterprise", RateLimit: 1000, MonthlyCredits: 50000, Endpoints: []string{"search", "stt", "tts"}, Price: 9900}, +} + +// CreditCosts per endpoint. +var CreditCosts = map[string]int{ + "search": 1, + "extract": 1, + "stt": 10, // per minute + "tts": 1, // per 1000 chars +} + +// ApiKeyService manages API keys in PostgreSQL. +type ApiKeyService struct { + pool *pgxpool.Pool + prefixLive string + prefixTest string +} + +// NewApiKeyService creates a new service. +func NewApiKeyService(pool *pgxpool.Pool, prefixLive, prefixTest string) *ApiKeyService { + return &ApiKeyService{pool: pool, prefixLive: prefixLive, prefixTest: prefixTest} +} + +// GenerateKey creates a new API key string. +func (s *ApiKeyService) GenerateKey(isTest bool) (key, hash, prefix string) { + pfx := s.prefixLive + if isTest { + pfx = s.prefixTest + } + + b := make([]byte, 24) + rand.Read(b) + randomPart := base64.RawURLEncoding.EncodeToString(b) + key = pfx + randomPart + + h := sha256.Sum256([]byte(key)) + hash = fmt.Sprintf("%x", h) + prefix = pfx + return +} + +// MaskKey hides most of the key for display. +func (s *ApiKeyService) MaskKey(key string) string { + if len(key) <= 12 { + return key + } + pfx := s.prefixLive + if len(key) > len(s.prefixTest) && key[:len(s.prefixTest)] == s.prefixTest { + pfx = s.prefixTest + } + return pfx + "..." + key[len(key)-4:] +} + +// Create creates a new API key. +func (s *ApiKeyService) Create(ctx context.Context, userID, name, description, tier string, isTest bool) (string, *ApiKey, error) { + key, hash, prefix := s.GenerateKey(isTest) + + t, ok := Tiers[tier] + if !ok { + t = Tiers["free"] + tier = "free" + } + + endpoints, _ := json.Marshal(t.Endpoints) + resetAt := nextMonthReset() + + row := s.pool.QueryRow(ctx, ` + INSERT INTO api_gateway.api_keys (key, key_hash, key_prefix, user_id, name, description, tier, rate_limit, monthly_credits, credits_used, credits_reset_at, allowed_endpoints, active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, $11, true) + RETURNING id, created_at, updated_at + `, key, hash, prefix, userID, name, description, tier, t.RateLimit, t.MonthlyCredits, resetAt, string(endpoints)) + + var apiKey ApiKey + var id string + var createdAt, updatedAt time.Time + if err := row.Scan(&id, &createdAt, &updatedAt); err != nil { + return "", nil, fmt.Errorf("create key: %w", err) + } + + apiKey = ApiKey{ + ID: id, Key: s.MaskKey(key), KeyPrefix: prefix, + UserID: &userID, Name: name, Description: description, + Tier: tier, RateLimit: t.RateLimit, MonthlyCredits: t.MonthlyCredits, + CreditsUsed: 0, CreditsResetAt: &resetAt, AllowedEndpoints: string(endpoints), + Active: true, CreatedAt: createdAt, UpdatedAt: updatedAt, + } + + return key, &apiKey, nil +} + +// ValidateKey looks up a key by its hash. +func (s *ApiKeyService) ValidateKey(ctx context.Context, rawKey string) (*ApiKeyData, error) { + h := sha256.Sum256([]byte(rawKey)) + hash := fmt.Sprintf("%x", h) + + var data ApiKeyData + err := s.pool.QueryRow(ctx, ` + SELECT id, user_id, organization_id, name, tier, rate_limit, monthly_credits, credits_used, allowed_endpoints, allowed_ips, active, expires_at + FROM api_gateway.api_keys WHERE key_hash = $1 + `, hash).Scan(&data.ID, &data.UserID, &data.OrganizationID, &data.Name, &data.Tier, + &data.RateLimit, &data.MonthlyCredits, &data.CreditsUsed, &data.AllowedEndpoints, + &data.AllowedIPs, &data.Active, &data.ExpiresAt) + + if err != nil { + return nil, err + } + + // Update last_used_at + s.pool.Exec(ctx, `UPDATE api_gateway.api_keys SET last_used_at = NOW() WHERE id = $1`, data.ID) + + return &data, nil +} + +// ListByUser returns all keys for a user (masked). +func (s *ApiKeyService) ListByUser(ctx context.Context, userID string) ([]ApiKey, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, key, key_prefix, user_id, name, description, tier, rate_limit, monthly_credits, credits_used, credits_reset_at, allowed_endpoints, active, expires_at, last_used_at, created_at, updated_at + FROM api_gateway.api_keys WHERE user_id = $1 ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []ApiKey + for rows.Next() { + var k ApiKey + if err := rows.Scan(&k.ID, &k.Key, &k.KeyPrefix, &k.UserID, &k.Name, &k.Description, + &k.Tier, &k.RateLimit, &k.MonthlyCredits, &k.CreditsUsed, &k.CreditsResetAt, + &k.AllowedEndpoints, &k.Active, &k.ExpiresAt, &k.LastUsedAt, &k.CreatedAt, &k.UpdatedAt); err != nil { + return nil, err + } + k.Key = s.MaskKey(k.Key) + keys = append(keys, k) + } + return keys, nil +} + +// Delete removes an API key. +func (s *ApiKeyService) Delete(ctx context.Context, id, userID string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM api_gateway.api_keys WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("key not found") + } + return nil +} + +// IncrementCredits adds used credits to a key. +func (s *ApiKeyService) IncrementCredits(ctx context.Context, keyID string, amount int) error { + _, err := s.pool.Exec(ctx, ` + UPDATE api_gateway.api_keys + SET credits_used = CASE + WHEN credits_reset_at < NOW() THEN $2 + ELSE credits_used + $2 + END, + credits_reset_at = CASE + WHEN credits_reset_at < NOW() THEN $3 + ELSE credits_reset_at + END + WHERE id = $1 + `, keyID, amount, nextMonthReset()) + return err +} + +// HasEnoughCredits checks if a key has sufficient credits. +func (s *ApiKeyService) HasEnoughCredits(ctx context.Context, keyID string, required int) (bool, error) { + var used, total int + var resetAt *time.Time + err := s.pool.QueryRow(ctx, ` + SELECT credits_used, monthly_credits, credits_reset_at FROM api_gateway.api_keys WHERE id = $1 + `, keyID).Scan(&used, &total, &resetAt) + if err != nil { + return false, err + } + + // If past reset date, credits are effectively 0 + if resetAt != nil && time.Now().After(*resetAt) { + return true, nil + } + + return used+required <= total, nil +} + +func nextMonthReset() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) +} diff --git a/services/mana-api-gateway-go/internal/service/apikeys_test.go b/services/mana-api-gateway-go/internal/service/apikeys_test.go new file mode 100644 index 000000000..f3b977fd1 --- /dev/null +++ b/services/mana-api-gateway-go/internal/service/apikeys_test.go @@ -0,0 +1,82 @@ +package service + +import ( + "testing" + "time" +) + +func TestGenerateKey(t *testing.T) { + svc := &ApiKeyService{prefixLive: "sk_live_", prefixTest: "sk_test_"} + + key, hash, prefix := svc.GenerateKey(false) + if prefix != "sk_live_" { + t.Errorf("expected sk_live_ prefix, got %s", prefix) + } + if len(key) < 20 { + t.Errorf("key too short: %s", key) + } + if len(hash) != 64 { // SHA256 hex + t.Errorf("hash wrong length: %d", len(hash)) + } + + // Test key + key2, _, prefix2 := svc.GenerateKey(true) + if prefix2 != "sk_test_" { + t.Errorf("expected sk_test_ prefix, got %s", prefix2) + } + if key == key2 { + t.Error("keys should be unique") + } +} + +func TestMaskKey(t *testing.T) { + svc := &ApiKeyService{prefixLive: "sk_live_", prefixTest: "sk_test_"} + + tests := []struct { + key string + want string + }{ + {"sk_live_abcdefghijklmnop1234", "sk_live_...1234"}, + {"sk_test_xyz9876543210abcdef", "sk_test_...cdef"}, + {"short", "short"}, + } + + for _, tt := range tests { + got := svc.MaskKey(tt.key) + if got != tt.want { + t.Errorf("MaskKey(%q) = %q, want %q", tt.key, got, tt.want) + } + } +} + +func TestNextMonthReset(t *testing.T) { + reset := nextMonthReset() + now := time.Now() + + if reset.Before(now) { + t.Error("reset should be in the future") + } + if reset.Day() != 1 { + t.Errorf("reset should be first of month, got day %d", reset.Day()) + } +} + +func TestPricingTiers(t *testing.T) { + free := Tiers["free"] + if free.RateLimit != 10 { + t.Errorf("free rate limit = %d, want 10", free.RateLimit) + } + if free.MonthlyCredits != 100 { + t.Errorf("free monthly credits = %d, want 100", free.MonthlyCredits) + } + + pro := Tiers["pro"] + if pro.RateLimit != 100 { + t.Errorf("pro rate limit = %d, want 100", pro.RateLimit) + } + + enterprise := Tiers["enterprise"] + if enterprise.RateLimit != 1000 { + t.Errorf("enterprise rate limit = %d, want 1000", enterprise.RateLimit) + } +} diff --git a/services/mana-api-gateway-go/internal/service/usage.go b/services/mana-api-gateway-go/internal/service/usage.go new file mode 100644 index 000000000..dee139b30 --- /dev/null +++ b/services/mana-api-gateway-go/internal/service/usage.go @@ -0,0 +1,114 @@ +package service + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// UsageEntry is a single API usage log entry. +type UsageEntry struct { + ApiKeyID string `json:"apiKeyId"` + Endpoint string `json:"endpoint"` + Method string `json:"method"` + Path string `json:"path"` + RequestSize int `json:"requestSize"` + ResponseSize int `json:"responseSize"` + LatencyMs int `json:"latencyMs"` + StatusCode int `json:"statusCode"` + CreditsUsed int `json:"creditsUsed"` + CreditReason string `json:"creditReason"` +} + +// DailyUsage is an aggregated daily usage entry. +type DailyUsage struct { + Date string `json:"date"` + Endpoint string `json:"endpoint"` + RequestCount int `json:"requestCount"` + CreditsUsed int `json:"creditsUsed"` + ErrorCount int `json:"errorCount"` +} + +// UsageSummary is an overview of usage. +type UsageSummary struct { + TotalRequests int `json:"totalRequests"` + TotalCredits int `json:"totalCredits"` + TotalErrors int `json:"totalErrors"` +} + +// UsageService logs and queries API usage. +type UsageService struct { + pool *pgxpool.Pool +} + +// NewUsageService creates a new usage service. +func NewUsageService(pool *pgxpool.Pool) *UsageService { + return &UsageService{pool: pool} +} + +// LogUsage records a single API request. +func (s *UsageService) LogUsage(ctx context.Context, entry UsageEntry) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO api_gateway.api_usage (api_key_id, endpoint, method, path, request_size, response_size, latency_ms, status_code, credits_used, credit_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, entry.ApiKeyID, entry.Endpoint, entry.Method, entry.Path, + entry.RequestSize, entry.ResponseSize, entry.LatencyMs, entry.StatusCode, + entry.CreditsUsed, entry.CreditReason) + + // Also upsert daily aggregation + isError := 0 + if entry.StatusCode >= 400 { + isError = 1 + } + s.pool.Exec(ctx, ` + INSERT INTO api_gateway.api_usage_daily (api_key_id, date, endpoint, request_count, credits_used, total_latency_ms, error_count) + VALUES ($1, CURRENT_DATE, $2, 1, $3, $4, $5) + ON CONFLICT (api_key_id, date, endpoint) + DO UPDATE SET + request_count = api_gateway.api_usage_daily.request_count + 1, + credits_used = api_gateway.api_usage_daily.credits_used + $3, + total_latency_ms = api_gateway.api_usage_daily.total_latency_ms + $4, + error_count = api_gateway.api_usage_daily.error_count + $5 + `, entry.ApiKeyID, entry.Endpoint, entry.CreditsUsed, entry.LatencyMs, isError) + + return err +} + +// GetDailyUsage returns daily aggregated usage for a key. +func (s *UsageService) GetDailyUsage(ctx context.Context, keyID string, days int) ([]DailyUsage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT date::text, endpoint, request_count, credits_used, error_count + FROM api_gateway.api_usage_daily + WHERE api_key_id = $1 AND date >= CURRENT_DATE - $2::int + ORDER BY date DESC + `, keyID, days) + if err != nil { + return nil, err + } + defer rows.Close() + + var usage []DailyUsage + for rows.Next() { + var u DailyUsage + if err := rows.Scan(&u.Date, &u.Endpoint, &u.RequestCount, &u.CreditsUsed, &u.ErrorCount); err != nil { + return nil, err + } + usage = append(usage, u) + } + return usage, nil +} + +// GetSummary returns a usage summary for a key over a period. +func (s *UsageService) GetSummary(ctx context.Context, keyID string, since time.Time) (*UsageSummary, error) { + var summary UsageSummary + err := s.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(request_count), 0), COALESCE(SUM(credits_used), 0), COALESCE(SUM(error_count), 0) + FROM api_gateway.api_usage_daily + WHERE api_key_id = $1 AND date >= $2 + `, keyID, since).Scan(&summary.TotalRequests, &summary.TotalCredits, &summary.TotalErrors) + if err != nil { + return nil, err + } + return &summary, nil +} diff --git a/services/mana-api-gateway-go/package.json b/services/mana-api-gateway-go/package.json new file mode 100644 index 000000000..9f208e467 --- /dev/null +++ b/services/mana-api-gateway-go/package.json @@ -0,0 +1,11 @@ +{ + "name": "mana-api-gateway-go", + "version": "1.0.0", + "private": true, + "description": "Go API Gateway replacing NestJS mana-api-gateway", + "scripts": { + "build": "go build -ldflags=\"-s -w\" -o dist/mana-api-gateway ./cmd/server", + "dev": "go run ./cmd/server", + "test": "go test ./..." + } +} diff --git a/services/mana-matrix-bot/.gitignore b/services/mana-matrix-bot/.gitignore new file mode 100644 index 000000000..0e46a3f3d --- /dev/null +++ b/services/mana-matrix-bot/.gitignore @@ -0,0 +1,3 @@ +dist/ +data/ +*.json.bak diff --git a/services/mana-matrix-bot/CLAUDE.md b/services/mana-matrix-bot/CLAUDE.md new file mode 100644 index 000000000..87c5bf25e --- /dev/null +++ b/services/mana-matrix-bot/CLAUDE.md @@ -0,0 +1,67 @@ +# mana-matrix-bot + +Consolidated Go Matrix bot replacing 21 separate NestJS bot services. + +## Architecture + +- **Language:** Go 1.23 +- **Matrix SDK:** mautrix-go +- **Port:** 4000 (health/metrics) +- **Pattern:** Plugin architecture with compile-time registration + +## Structure + +``` +cmd/server/main.go # Entry point, imports all plugins +internal/ + config/ # Env-based configuration + runtime/ # Plugin lifecycle, Matrix sync, event routing + matrix/ # Matrix client wrapper, markdown, media + plugin/ # Plugin interface, registry, command routing + session/ # In-memory + Redis session store + services/ # Backend HTTP client, voice (STT/TTS) + plugins/ # One directory per bot plugin + todo/ # @todo-bot + calendar/ # @calendar-bot + gateway/ # @mana-bot (composite: AI + todo + calendar + clock + voice) + ... +``` + +## Adding a New Plugin + +1. Create `internal/plugins/mybot/mybot.go` +2. Implement `plugin.Plugin` interface +3. Register via `func init() { plugin.Register("mybot", func() plugin.Plugin { return &MyBot{} }) }` +4. Import in `cmd/server/main.go`: `_ "github.com/manacore/mana-matrix-bot/internal/plugins/mybot"` +5. Set env: `MATRIX_MYBOT_BOT_TOKEN=syt_xxx` + +## Commands + +```bash +# Build +go build -o dist/mana-matrix-bot ./cmd/server + +# Run +PORT=4000 MATRIX_HOMESERVER_URL=http://localhost:8008 MATRIX_TODO_BOT_TOKEN=xxx ./dist/mana-matrix-bot + +# Test +go test ./... + +# Docker +docker build -t mana-matrix-bot:local -f Dockerfile . +``` + +## Environment Variables + +### Global +- `PORT` — Health server port (default: 4000) +- `MATRIX_HOMESERVER_URL` — Matrix homeserver (default: http://localhost:8008) +- `MATRIX_STORAGE_PATH` — Sync state directory (default: ./data) +- `MANA_CORE_AUTH_URL` — Auth service URL +- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` — Redis for sessions +- `STT_URL`, `TTS_URL` — Voice services + +### Per Plugin (legacy env var names supported) +- `MATRIX_{NAME}_BOT_TOKEN` — Matrix access token +- `MATRIX_{NAME}_BOT_ROOMS` — Comma-separated allowed room IDs +- `{NAME}_BACKEND_URL` — Backend service URL diff --git a/services/mana-matrix-bot/Dockerfile b/services/mana-matrix-bot/Dockerfile new file mode 100644 index 000000000..1015a39c5 --- /dev/null +++ b/services/mana-matrix-bot/Dockerfile @@ -0,0 +1,28 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +# Copy Go module files first for better caching +COPY services/mana-matrix-bot/go.mod services/mana-matrix-bot/go.sum ./ +RUN go mod download + +# Copy source +COPY services/mana-matrix-bot/ . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-matrix-bot ./cmd/server + +# Runtime stage +FROM alpine:3.21 + +RUN apk --no-cache add ca-certificates tzdata + +COPY --from=builder /mana-matrix-bot /usr/local/bin/mana-matrix-bot + +VOLUME /app/data + +EXPOSE 4000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q --spider http://localhost:4000/health || exit 1 + +ENTRYPOINT ["mana-matrix-bot"] diff --git a/services/mana-matrix-bot/cmd/server/main.go b/services/mana-matrix-bot/cmd/server/main.go new file mode 100644 index 000000000..cafea120a --- /dev/null +++ b/services/mana-matrix-bot/cmd/server/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/manacore/mana-matrix-bot/internal/config" + "github.com/manacore/mana-matrix-bot/internal/runtime" + + // Import all plugins to trigger their init() registration. + _ "github.com/manacore/mana-matrix-bot/internal/plugins/calendar" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/chat" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/clock" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/contacts" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/gateway" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/manadeck" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/nutriphi" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/ollama" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/onboarding" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/picture" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/planta" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/presi" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/projectdoc" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/questions" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/skilltree" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/stats" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/storage" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/stt" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/todo" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/tts" + _ "github.com/manacore/mana-matrix-bot/internal/plugins/zitare" +) + +func main() { + // Structured JSON logging + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + cfg := config.Load() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Create and start runtime + rt := runtime.New(cfg) + + // Start health server + health := runtime.NewHealthServer(rt, cfg.Port) + httpServer := health.Start() + + // Start all plugins + if err := rt.Start(ctx); err != nil { + slog.Error("failed to start runtime", "error", err) + os.Exit(1) + } + + slog.Info("mana-matrix-bot running", "port", cfg.Port) + + // Wait for shutdown signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + slog.Info("shutting down...") + cancel() + rt.Stop() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + httpServer.Shutdown(shutdownCtx) + + slog.Info("shutdown complete") +} diff --git a/services/mana-matrix-bot/go.mod b/services/mana-matrix-bot/go.mod new file mode 100644 index 000000000..f6f276797 --- /dev/null +++ b/services/mana-matrix-bot/go.mod @@ -0,0 +1,28 @@ +module github.com/manacore/mana-matrix-bot + +go 1.25.0 + +require ( + github.com/redis/go-redis/v9 v9.18.0 + maunium.net/go/mautrix v0.26.4 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.9.7 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/services/mana-matrix-bot/go.sum b/services/mana-matrix-bot/go.sum new file mode 100644 index 000000000..f0a10f863 --- /dev/null +++ b/services/mana-matrix-bot/go.sum @@ -0,0 +1,66 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= +go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= +maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= diff --git a/services/mana-matrix-bot/internal/config/config.go b/services/mana-matrix-bot/internal/config/config.go new file mode 100644 index 000000000..380cd5a14 --- /dev/null +++ b/services/mana-matrix-bot/internal/config/config.go @@ -0,0 +1,219 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +// Config holds all configuration for the consolidated Matrix bot. +type Config struct { + // Server + Port int + + // Matrix + HomeserverURL string + StoragePath string + + // Auth + AuthURL string + ServiceKey string + + // Redis + RedisHost string + RedisPort int + RedisPassword string + + // Voice services + STTURL string + TTSURL string + + // Plugins (keyed by plugin name) + Plugins map[string]PluginConfig +} + +// PluginConfig holds per-plugin configuration. +type PluginConfig struct { + Enabled bool + AccessToken string + AllowedRooms []string + BackendURL string + Extra map[string]string +} + +// Load reads configuration from environment variables. +func Load() *Config { + port, _ := strconv.Atoi(getEnv("PORT", "4000")) + redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379")) + + cfg := &Config{ + Port: port, + HomeserverURL: getEnv("MATRIX_HOMESERVER_URL", "http://localhost:8008"), + StoragePath: getEnv("MATRIX_STORAGE_PATH", "./data"), + AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"), + ServiceKey: getEnv("MANA_CORE_SERVICE_KEY", ""), + RedisHost: getEnv("REDIS_HOST", "localhost"), + RedisPort: redisPort, + RedisPassword: getEnv("REDIS_PASSWORD", ""), + STTURL: getEnv("STT_URL", "http://localhost:3020"), + TTSURL: getEnv("TTS_URL", "http://localhost:3022"), + Plugins: make(map[string]PluginConfig), + } + + // Load plugin configs from environment variables. + // Pattern: PLUGIN_{NAME}_ENABLED, PLUGIN_{NAME}_ACCESS_TOKEN, etc. + // Also supports legacy patterns: MATRIX_{NAME}_BOT_TOKEN + pluginNames := []string{ + "gateway", "todo", "calendar", "clock", "ollama", "stats", + "contacts", "chat", "manadeck", "nutriphi", "picture", "planta", + "presi", "questions", "skilltree", "storage", "projectdoc", + "stt", "tts", "zitare", "onboarding", + } + + // Map of legacy token env var names + legacyTokenMap := map[string]string{ + "gateway": "MATRIX_MANA_BOT_TOKEN", + "todo": "MATRIX_TODO_BOT_TOKEN", + "calendar": "MATRIX_CALENDAR_BOT_TOKEN", + "clock": "MATRIX_CLOCK_BOT_TOKEN", + "ollama": "MATRIX_OLLAMA_BOT_TOKEN", + "stats": "MATRIX_STATS_BOT_TOKEN", + "contacts": "MATRIX_CONTACTS_BOT_TOKEN", + "chat": "MATRIX_CHAT_BOT_TOKEN", + "manadeck": "MATRIX_MANADECK_BOT_TOKEN", + "nutriphi": "MATRIX_NUTRIPHI_BOT_TOKEN", + "picture": "MATRIX_PICTURE_BOT_TOKEN", + "planta": "MATRIX_PLANTA_BOT_TOKEN", + "presi": "MATRIX_PRESI_BOT_TOKEN", + "questions": "MATRIX_QUESTIONS_BOT_TOKEN", + "skilltree": "MATRIX_SKILLTREE_BOT_TOKEN", + "storage": "MATRIX_STORAGE_BOT_TOKEN", + "projectdoc": "MATRIX_PROJECT_DOC_BOT_TOKEN", + "stt": "MATRIX_STT_BOT_TOKEN", + "tts": "MATRIX_TTS_BOT_TOKEN", + "zitare": "MATRIX_ZITARE_BOT_TOKEN", + "onboarding": "MATRIX_ONBOARDING_BOT_TOKEN", + } + + legacyRoomsMap := map[string]string{ + "gateway": "MATRIX_MANA_BOT_ROOMS", + "todo": "MATRIX_TODO_BOT_ROOMS", + "calendar": "MATRIX_CALENDAR_BOT_ROOMS", + "clock": "MATRIX_CLOCK_BOT_ROOMS", + "ollama": "MATRIX_OLLAMA_BOT_ROOMS", + "stats": "MATRIX_STATS_BOT_ROOMS", + "contacts": "MATRIX_CONTACTS_BOT_ROOMS", + "chat": "MATRIX_CHAT_BOT_ROOMS", + "manadeck": "MATRIX_MANADECK_BOT_ROOMS", + "nutriphi": "MATRIX_NUTRIPHI_BOT_ROOMS", + "picture": "MATRIX_PICTURE_BOT_ROOMS", + "planta": "MATRIX_PLANTA_BOT_ROOMS", + "presi": "MATRIX_PRESI_BOT_ROOMS", + "questions": "MATRIX_QUESTIONS_BOT_ROOMS", + "skilltree": "MATRIX_SKILLTREE_BOT_ROOMS", + "storage": "MATRIX_STORAGE_BOT_ROOMS", + "projectdoc": "MATRIX_PROJECT_DOC_BOT_ROOMS", + "stt": "MATRIX_STT_BOT_ROOMS", + "tts": "MATRIX_TTS_BOT_ROOMS", + "zitare": "MATRIX_ZITARE_BOT_ROOMS", + "onboarding": "MATRIX_ONBOARDING_BOT_ROOMS", + } + + // Backend URL defaults per plugin + backendURLMap := map[string]string{ + "todo": "TODO_BACKEND_URL", + "calendar": "CALENDAR_BACKEND_URL", + "clock": "CLOCK_BACKEND_URL", + "contacts": "CONTACTS_BACKEND_URL", + "chat": "CHAT_BACKEND_URL", + "manadeck": "MANADECK_BACKEND_URL", + "nutriphi": "NUTRIPHI_BACKEND_URL", + "picture": "PICTURE_BACKEND_URL", + "planta": "PLANTA_BACKEND_URL", + "presi": "PRESI_BACKEND_URL", + "questions": "QUESTIONS_BACKEND_URL", + "skilltree": "SKILLTREE_BACKEND_URL", + "storage": "STORAGE_BACKEND_URL", + "projectdoc": "PROJECTDOC_BACKEND_URL", + "zitare": "ZITARE_BACKEND_URL", + } + + for _, name := range pluginNames { + upper := strings.ToUpper(name) + + // Access token: try PLUGIN_*_ACCESS_TOKEN first, then legacy + token := os.Getenv("PLUGIN_" + upper + "_ACCESS_TOKEN") + if token == "" { + if legacyEnv, ok := legacyTokenMap[name]; ok { + token = os.Getenv(legacyEnv) + } + } + + // Enabled: explicit env or auto-detect from token presence + enabledStr := os.Getenv("PLUGIN_" + upper + "_ENABLED") + enabled := token != "" + if enabledStr != "" { + enabled = enabledStr == "true" || enabledStr == "1" + } + + // Allowed rooms + var rooms []string + roomsStr := os.Getenv("PLUGIN_" + upper + "_ALLOWED_ROOMS") + if roomsStr == "" { + if legacyEnv, ok := legacyRoomsMap[name]; ok { + roomsStr = os.Getenv(legacyEnv) + } + } + if roomsStr != "" { + for _, r := range strings.Split(roomsStr, ",") { + r = strings.TrimSpace(r) + if r != "" { + rooms = append(rooms, r) + } + } + } + + // Backend URL + backendURL := "" + if envName, ok := backendURLMap[name]; ok { + backendURL = os.Getenv(envName) + } + + // Extra config (plugin-specific env vars) + extra := make(map[string]string) + // Ollama-specific + if name == "ollama" || name == "gateway" { + extra["ollama_url"] = getEnv("OLLAMA_URL", "http://localhost:11434") + extra["ollama_model"] = getEnv("OLLAMA_MODEL", "gemma3:4b") + } + if name == "stt" || name == "gateway" { + extra["stt_url"] = cfg.STTURL + } + if name == "tts" || name == "gateway" { + extra["tts_url"] = cfg.TTSURL + } + // Gateway needs backend URLs for sub-handlers + if name == "gateway" { + extra["todo_url"] = getEnv("TODO_BACKEND_URL", "") + extra["calendar_url"] = getEnv("CALENDAR_BACKEND_URL", "") + extra["clock_url"] = getEnv("CLOCK_BACKEND_URL", "") + } + + cfg.Plugins[name] = PluginConfig{ + Enabled: enabled, + AccessToken: token, + AllowedRooms: rooms, + BackendURL: backendURL, + Extra: extra, + } + } + + return cfg +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/services/mana-matrix-bot/internal/matrix/client.go b/services/mana-matrix-bot/internal/matrix/client.go new file mode 100644 index 000000000..78c157792 --- /dev/null +++ b/services/mana-matrix-bot/internal/matrix/client.go @@ -0,0 +1,241 @@ +package matrix + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +var mxcRegex = regexp.MustCompile(`^mxc://([^/]+)/(.+)$`) + +// Client wraps mautrix.Client and implements the plugin.MatrixClient interface. +type Client struct { + inner *mautrix.Client + homeserver string + accessToken string + storagePath string + logger *slog.Logger +} + +// ClientConfig holds configuration for creating a Matrix client. +type ClientConfig struct { + HomeserverURL string + AccessToken string + StoragePath string // path for sync state file + PluginName string +} + +// NewClient creates a new Matrix client wrapper. +func NewClient(cfg ClientConfig) (*Client, error) { + userID := id.UserID("") // will be resolved via whoami + + client, err := mautrix.NewClient(cfg.HomeserverURL, userID, cfg.AccessToken) + if err != nil { + return nil, fmt.Errorf("create mautrix client: %w", err) + } + + // Ensure storage directory exists + if cfg.StoragePath != "" { + dir := filepath.Dir(cfg.StoragePath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create storage dir: %w", err) + } + } + + logger := slog.With("plugin", cfg.PluginName) + + return &Client{ + inner: client, + homeserver: cfg.HomeserverURL, + accessToken: cfg.AccessToken, + storagePath: cfg.StoragePath, + logger: logger, + }, nil +} + +// Inner returns the underlying mautrix.Client for advanced operations. +func (c *Client) Inner() *mautrix.Client { + return c.inner +} + +// Login resolves the bot's user ID via /whoami. +func (c *Client) Login(ctx context.Context) (id.UserID, error) { + resp, err := c.inner.Whoami(ctx) + if err != nil { + return "", fmt.Errorf("whoami: %w", err) + } + c.inner.UserID = resp.UserID + c.logger.Info("authenticated", "user_id", resp.UserID) + return resp.UserID, nil +} + +// GetUserID returns the bot's Matrix user ID. +func (c *Client) GetUserID() string { + return c.inner.UserID.String() +} + +// SendMessage sends a text message with markdown formatting to a room. +func (c *Client) SendMessage(ctx context.Context, roomID string, text string) (string, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + Format: event.FormatHTML, + FormattedBody: MarkdownToHTML(text), + } + resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return "", err + } + return resp.EventID.String(), nil +} + +// SendReply sends a reply to a specific event. +func (c *Client) SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + Format: event.FormatHTML, + FormattedBody: MarkdownToHTML(text), + } + content.SetReply(&event.Event{ + RoomID: id.RoomID(roomID), + ID: id.EventID(eventID), + }) + resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return "", err + } + return resp.EventID.String(), nil +} + +// SendNotice sends a notice (non-highlighted message). +func (c *Client) SendNotice(ctx context.Context, roomID string, text string) (string, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: text, + Format: event.FormatHTML, + FormattedBody: MarkdownToHTML(text), + } + resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return "", err + } + return resp.EventID.String(), nil +} + +// EditMessage edits an existing message. +func (c *Client) EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error) { + content := map[string]any{ + "msgtype": "m.text", + "body": "* " + text, + "format": "org.matrix.custom.html", + "formatted_body": "* " + MarkdownToHTML(text), + "m.relates_to": map[string]any{ + "rel_type": "m.replace", + "event_id": eventID, + }, + "m.new_content": map[string]any{ + "msgtype": "m.text", + "body": text, + "format": "org.matrix.custom.html", + "formatted_body": MarkdownToHTML(text), + }, + } + resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return "", err + } + return resp.EventID.String(), nil +} + +// SetTyping sets the typing indicator for the bot in a room. +func (c *Client) SetTyping(ctx context.Context, roomID string, typing bool) error { + timeout := time.Duration(0) + if typing { + timeout = 30 * time.Second + } + _, err := c.inner.UserTyping(ctx, id.RoomID(roomID), typing, timeout) + return err +} + +// DownloadMedia downloads media from a mxc:// URL. +func (c *Client) DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error) { + matches := mxcRegex.FindStringSubmatch(mxcURL) + if len(matches) != 3 { + return nil, fmt.Errorf("invalid mxc URL: %s", mxcURL) + } + + serverName := matches[1] + mediaID := matches[2] + + // Try authenticated media API (Matrix spec v1.11+) + url := fmt.Sprintf("%s/_matrix/client/v1/media/download/%s/%s", c.homeserver, serverName, mediaID) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("download media: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Fallback to legacy API + url = fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", c.homeserver, serverName, mediaID) + req2, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + return nil, fmt.Errorf("download media (legacy): %w", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download media failed: %d", resp2.StatusCode) + } + return io.ReadAll(resp2.Body) + } + + return io.ReadAll(resp.Body) +} + +// UploadMedia uploads media and returns the mxc:// URL. +func (c *Client) UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error) { + resp, err := c.inner.UploadBytes(ctx, data, contentType) + if err != nil { + return "", fmt.Errorf("upload media: %w", err) + } + return resp.ContentURI.String(), nil +} + +// SendAudio sends an audio message to a room. +func (c *Client) SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgAudio, + Body: filename, + URL: id.ContentURIString(mxcURL), + Info: &event.FileInfo{ + MimeType: "audio/mpeg", + Size: size, + }, + } + resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) + if err != nil { + return "", err + } + return resp.EventID.String(), nil +} diff --git a/services/mana-matrix-bot/internal/matrix/markdown.go b/services/mana-matrix-bot/internal/matrix/markdown.go new file mode 100644 index 000000000..de5b06c7c --- /dev/null +++ b/services/mana-matrix-bot/internal/matrix/markdown.go @@ -0,0 +1,63 @@ +package matrix + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + reBold = regexp.MustCompile(`\*\*(.+?)\*\*`) + reItalic = regexp.MustCompile(`\*(.+?)\*`) + reStrikethrough = regexp.MustCompile(`~~(.+?)~~`) + reCode = regexp.MustCompile("`(.+?)`") +) + +// MarkdownToHTML converts simple markdown to HTML for Matrix messages. +// Matches the exact behavior of the TypeScript markdownToHtml function. +func MarkdownToHTML(text string) string { + result := text + result = reBold.ReplaceAllString(result, "$1") + result = reItalic.ReplaceAllString(result, "$1") + result = reStrikethrough.ReplaceAllString(result, "$1") + result = reCode.ReplaceAllString(result, "$1") + result = strings.ReplaceAll(result, "\n", "
") + return result +} + +// EscapeHTML escapes HTML special characters. +func EscapeHTML(text string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(text) +} + +// FormatNumberedList formats items as a numbered markdown list. +func FormatNumberedList[T any](items []T, formatter func(T, int) string) string { + var sb strings.Builder + for i, item := range items { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(fmt.Sprintf("%d. %s", i+1, formatter(item, i))) + } + return sb.String() +} + +// FormatBulletList formats items as a bullet markdown list. +func FormatBulletList[T any](items []T, formatter func(T) string) string { + var sb strings.Builder + for i, item := range items { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString("• ") + sb.WriteString(formatter(item)) + } + return sb.String() +} diff --git a/services/mana-matrix-bot/internal/matrix/markdown_test.go b/services/mana-matrix-bot/internal/matrix/markdown_test.go new file mode 100644 index 000000000..fd4ed647c --- /dev/null +++ b/services/mana-matrix-bot/internal/matrix/markdown_test.go @@ -0,0 +1,64 @@ +package matrix + +import "testing" + +func TestMarkdownToHTML(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"**bold**", "bold"}, + {"*italic*", "italic"}, + {"~~strike~~", "strike"}, + {"`code`", "code"}, + {"line1\nline2", "line1
line2"}, + {"**bold** and *italic*", "bold and italic"}, + {"plain text", "plain text"}, + {"", ""}, + } + + for _, tt := range tests { + got := MarkdownToHTML(tt.input) + if got != tt.want { + t.Errorf("MarkdownToHTML(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestEscapeHTML(t *testing.T) { + tests := []struct { + input string + want string + }{ + {" - -`; - } -} diff --git a/services/matrix-clock-bot/src/widget/widget.module.ts b/services/matrix-clock-bot/src/widget/widget.module.ts deleted file mode 100644 index 69759681f..000000000 --- a/services/matrix-clock-bot/src/widget/widget.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Module } from '@nestjs/common'; -import { WidgetController } from './widget.controller'; -import { ClockModule } from '../clock/clock.module'; -import { SessionModule } from '@manacore/bot-services'; - -/** - * Widget Module - * - * Provides the timer widget for embedding in Matrix clients (Element). - * The widget displays live timer status with controls. - */ -@Module({ - imports: [ClockModule, SessionModule.forRoot({ storageMode: 'redis' })], - controllers: [WidgetController], -}) -export class WidgetModule {} diff --git a/services/matrix-clock-bot/tsconfig.build.json b/services/matrix-clock-bot/tsconfig.build.json deleted file mode 100644 index 4491981e0..000000000 --- a/services/matrix-clock-bot/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/services/matrix-clock-bot/tsconfig.json b/services/matrix-clock-bot/tsconfig.json deleted file mode 100644 index b439390d0..000000000 --- a/services/matrix-clock-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } -} diff --git a/services/matrix-contacts-bot/.env.example b/services/matrix-contacts-bot/.env.example deleted file mode 100644 index 4b5853781..000000000 --- a/services/matrix-contacts-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3320 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#contacts:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Contacts Backend -CONTACTS_BACKEND_URL=http://localhost:3015 -CONTACTS_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-contacts-bot/.gitignore b/services/matrix-contacts-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-contacts-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-contacts-bot/CLAUDE.md b/services/matrix-contacts-bot/CLAUDE.md deleted file mode 100644 index 5cb9f9279..000000000 --- a/services/matrix-contacts-bot/CLAUDE.md +++ /dev/null @@ -1,186 +0,0 @@ -# Matrix Contacts Bot - Claude Code Guidelines - -## Overview - -Matrix Contacts Bot provides contact management via Matrix chat. It integrates with the Contacts backend for full CRUD operations, search, favorites, and archiving. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Contacts API (port 3015) -- **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-contacts-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3320) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── contacts/ -│ │ ├── contacts.module.ts -│ │ └── contacts.service.ts # Contacts Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!kontakte` | contacts, liste | List all contacts | -| `!suche [text]` | search | Search contacts | -| `!favoriten` | favorites | Show favorites | -| `!kontakt [nr]` | contact, details | Show contact details | -| `!neu Vorname [Nachname]` | new, add | Create new contact | -| `!edit [nr] [feld] [wert]` | bearbeiten | Edit contact field | -| `!loeschen [nr]` | delete | Delete contact | -| `!fav [nr]` | favorit | Toggle favorite | -| `!archiv [nr]` | archive | Toggle archive | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -## Editable Fields - -| Field | Aliases | Description | -|-------|---------|-------------| -| `email` | - | Email address | -| `phone` | telefon | Phone number | -| `mobile` | mobil, handy | Mobile number | -| `company` | firma | Company name | -| `job` | jobtitle, beruf | Job title | -| `website` | web | Website URL | -| `street` | strasse | Street address | -| `city` | stadt | City | -| `zip` | plz | Postal code | -| `country` | land | Country | -| `notes` | notizen | Notes | -| `birthday` | geburtstag | Birthday (YYYY-MM-DD) | - -## Example Usage - -``` -# Create a contact -!neu Max Mustermann - -# Add email -!edit 1 email max@example.com - -# Add phone -!edit 1 phone +49 123 456789 - -# Mark as favorite -!fav 1 - -# Search -!suche Muster -``` - -## Environment Variables - -```env -# Server -PORT=3320 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#contacts:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Contacts Backend -CONTACTS_BACKEND_URL=http://localhost:3015 -CONTACTS_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-contacts-bot/Dockerfile -t matrix-contacts-bot services/matrix-contacts-bot - -# Run -docker run -p 3320:3320 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e CONTACTS_BACKEND_URL=http://contacts-backend:3015 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-contacts-bot-data:/app/data \ - matrix-contacts-bot -``` - -## Health Check - -```bash -curl http://localhost:3320/health -``` - -## Getting a Matrix Access Token - -```bash -# Create bot user first, then login -curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ - -H "Content-Type: application/json" \ - -d '{ - "type": "m.login.password", - "user": "contacts-bot", - "password": "your-password" - }' - -# Response contains: {"access_token": "syt_xxx", ...} -``` - -## Contacts Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/v1/contacts` | GET | List contacts with filters | -| `/api/v1/contacts` | POST | Create contact | -| `/api/v1/contacts/:id` | GET | Get contact details | -| `/api/v1/contacts/:id` | PATCH | Update contact | -| `/api/v1/contacts/:id` | DELETE | Delete contact | -| `/api/v1/contacts/:id/favorite` | POST | Toggle favorite | -| `/api/v1/contacts/:id/archive` | POST | Toggle archive | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!kontakte` or `!suche` to get a list -2. Bot stores the list internally for the user -3. User can reference contacts by their list number -4. Numbers are valid until the user runs a new list command - -This allows simple commands like: -- `!kontakt 3` - Show details for contact #3 in the list -- `!edit 1 email new@email.com` - Edit contact #1 -- `!fav 2` - Toggle favorite for contact #2 diff --git a/services/matrix-contacts-bot/Dockerfile b/services/matrix-contacts-bot/Dockerfile deleted file mode 100644 index c4e3a8392..000000000 --- a/services/matrix-contacts-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3320 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3320/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-contacts-bot/nest-cli.json b/services/matrix-contacts-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-contacts-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-contacts-bot/package.json b/services/matrix-contacts-bot/package.json deleted file mode 100644 index 50a716afd..000000000 --- a/services/matrix-contacts-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-contacts-bot", - "version": "1.0.0", - "description": "Matrix bot for contact management via Contacts backend", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-contacts-bot/src/app.module.ts b/services/matrix-contacts-bot/src/app.module.ts deleted file mode 100644 index 9fe527786..000000000 --- a/services/matrix-contacts-bot/src/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import configuration from './config/configuration'; -import { BotModule } from './bot/bot.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-contacts-bot')], -}) -export class AppModule {} diff --git a/services/matrix-contacts-bot/src/bot/bot.module.ts b/services/matrix-contacts-bot/src/bot/bot.module.ts deleted file mode 100644 index b95271d82..000000000 --- a/services/matrix-contacts-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { ContactsModule } from '../contacts/contacts.module'; -import { - SessionModule, - TranscriptionModule, - CreditModule, - I18nModule, -} from '@manacore/bot-services'; - -@Module({ - imports: [ - ContactsModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - I18nModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-contacts-bot/src/bot/matrix.service.ts b/services/matrix-contacts-bot/src/bot/matrix.service.ts deleted file mode 100644 index 3211ee688..000000000 --- a/services/matrix-contacts-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,769 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, - UserListMapper, -} from '@manacore/matrix-bot-common'; -import { ContactsService, Contact } from '../contacts/contacts.service'; -import { - SessionService, - TranscriptionService, - CreditService, - I18nService, - Language, - LANGUAGE_NAMES, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -const CONTACT_CREATE_CREDITS = 0.02; - -// 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' }, -]); - -@Injectable() -export class MatrixService extends BaseMatrixService { - // User list mapper for number-based reference - private contactsMapper = new UserListMapper(); - - constructor( - configService: ConfigService, - private readonly transcriptionService: TranscriptionService, - private contactsService: ContactsService, - private sessionService: SessionService, - private creditService: CreditService, - private i18nService: I18nService - ) { - super(configService); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendReply(roomId, event, '❌ Sprachnachricht konnte nicht erkannt werden.'); - return; - } - - await this.sendMessage(roomId, `🎤 *"${text}"*`); - await this.handleTextMessage(roomId, event, text, sender); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply(roomId, event, '❌ Fehler bei der Spracherkennung.'); - } - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string | null { - return `**Contacts Bot - Kontaktverwaltung** - -Ich helfe dir, deine Kontakte zu verwalten! - -**Schnellstart:** -\`!kontakte\` - Alle Kontakte anzeigen -\`!suche Max\` - Kontakte suchen -\`!neu Vorname Nachname\` - Neuen Kontakt - -Sag "hilfe" fur alle Befehle!`; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - if (message.startsWith('!')) { - await this.handleCommand(roomId, event, sender, message); - return; - } - - const detectedCommand = keywordDetector.detect(message); - if (detectedCommand) { - this.logger.log(`Detected keyword command: ${detectedCommand}`); - await this.handleCommand(roomId, event, sender, `!${detectedCommand}`); - return; - } - - // Fallback: treat any message as a new contact - const args = message.trim().split(/\s+/); - await this.handleCreateContact(roomId, event, sender, args); - } - - private async handleCommand( - roomId: string, - event: MatrixRoomEvent, - 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.sendReply(roomId, event, HELP_MESSAGE); - break; - - case 'kontakte': - case 'contacts': - case 'liste': - case 'list': - await this.handleListContacts(roomId, event, sender); - break; - - case 'suche': - case 'search': - await this.handleSearch(roomId, event, sender, argString); - break; - - case 'favoriten': - case 'favorites': - case 'favs': - await this.handleFavorites(roomId, event, sender); - break; - - case 'kontakt': - case 'contact': - case 'details': - await this.handleContactDetails(roomId, event, sender, args); - break; - - case 'neu': - case 'new': - case 'add': - await this.handleCreateContact(roomId, event, sender, args); - break; - - case 'edit': - case 'bearbeiten': - await this.handleEditContact(roomId, event, sender, args); - break; - - case 'loeschen': - case 'delete': - case 'del': - await this.handleDeleteContact(roomId, event, sender, args); - break; - - case 'fav': - case 'favorit': - await this.handleToggleFavorite(roomId, event, sender, args); - break; - - case 'archiv': - case 'archive': - await this.handleToggleArchive(roomId, event, sender, args); - break; - - case 'status': - await this.handleStatus(roomId, event, sender); - break; - - case 'pin': - await this.pinHelpMessage(roomId, event); - break; - - case 'language': - case 'sprache': - case 'lang': - await this.handleLanguage(roomId, event, sender, argString); - break; - - default: - await this.sendReply( - roomId, - event, - `Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.` - ); - } - } - - private async handleListContacts(roomId: string, event: MatrixRoomEvent, sender: string) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - try { - const result = await this.contactsService.getContacts(token, { limit: 20 }); - const contacts = result.contacts; - - if (contacts.length === 0) { - await this.sendReply( - roomId, - event, - `Du hast noch keine Kontakte.\n\nNutze \`!neu Vorname Nachname\` um einen zu erstellen.` - ); - return; - } - - // Store for reference - 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 favIcon = c.isFavorite ? ' ★' : ''; - const company = c.company ? ` - ${c.company}` : ''; - text += `**${i + 1}.** ${name}${favIcon}${company}\n`; - } - - if (result.total > 20) { - text += `\n_...und ${result.total - 20} weitere_`; - } - - text += `\n\nNutze \`!kontakt [nr]\` fur Details.`; - - await this.sendReply(roomId, event, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleSearch( - roomId: string, - event: MatrixRoomEvent, - sender: string, - searchTerm: string - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - if (!searchTerm.trim()) { - 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 contacts = result.contacts; - - if (contacts.length === 0) { - await this.sendReply(roomId, event, `Keine Kontakte gefunden fur: "${searchTerm}"`); - return; - } - - 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 favIcon = c.isFavorite ? ' ★' : ''; - const email = c.email ? ` (${c.email})` : ''; - text += `**${i + 1}.** ${name}${favIcon}${email}\n`; - } - - await this.sendReply(roomId, event, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleFavorites(roomId: string, event: MatrixRoomEvent, sender: string) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - try { - const result = await this.contactsService.getContacts(token, { isFavorite: true, limit: 20 }); - const contacts = result.contacts; - - if (contacts.length === 0) { - await this.sendReply( - roomId, - event, - `Du hast noch keine Favoriten.\n\nNutze \`!fav [nr]\` um einen Kontakt als Favorit zu markieren.` - ); - return; - } - - 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 phone = c.phone || c.mobile || ''; - text += `**${i + 1}.** ★ ${name}${phone ? ` - ${phone}` : ''}\n`; - } - - await this.sendReply(roomId, event, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleContactDetails( - roomId: string, - event: MatrixRoomEvent, - sender: string, - args: string[] - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendReply( - roomId, - event, - `**Verwendung:** \`!kontakt [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.` - ); - return; - } - - 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; - } - - try { - const details = await this.contactsService.getContact(token, contact.id); - - let text = `**${details.displayName || `${details.firstName || ''} ${details.lastName || ''}`.trim()}**\n\n`; - - if (details.isFavorite) text += `★ Favorit\n\n`; - - if (details.company || details.jobTitle) { - const job = [details.jobTitle, details.company].filter(Boolean).join(' bei '); - text += `**Beruf:** ${job}\n`; - } - - if (details.email) text += `**E-Mail:** ${details.email}\n`; - if (details.phone) text += `**Telefon:** ${details.phone}\n`; - if (details.mobile) text += `**Mobil:** ${details.mobile}\n`; - - if (details.street || details.city) { - 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.notes) text += `\n**Notizen:** ${details.notes}\n`; - - await this.sendReply(roomId, event, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleCreateContact( - roomId: string, - event: MatrixRoomEvent, - sender: string, - args: string[] - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - // Validate credits - const validation = await this.creditService.validateCredits(token, CONTACT_CREATE_CREDITS); - if (!validation.hasCredits) { - const errorMsg = this.creditService.formatInsufficientCreditsError( - CONTACT_CREATE_CREDITS, - validation.availableCredits, - 'Kontakt erstellen' - ); - await this.sendReply(roomId, event, errorMsg.text); - return; - } - - if (args.length < 1) { - await this.sendReply( - roomId, - event, - `**Verwendung:** \`!neu Vorname [Nachname]\`\n\nBeispiel: \`!neu Max Mustermann\`` - ); - return; - } - - const firstName = args[0]; - const lastName = args.slice(1).join(' ') || undefined; - - try { - const contact = await this.contactsService.createContact(token, { - firstName, - lastName, - }); - - const name = contact.displayName || `${firstName} ${lastName || ''}`.trim(); - const balance = await this.creditService.getBalance(token); - await this.sendReply( - roomId, - event, - `Kontakt **${name}** erstellt!\n⚡ -${CONTACT_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)\n\nNutze \`!kontakte\` um die Liste zu sehen oder \`!edit\` um weitere Daten hinzuzufugen.` - ); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleEditContact( - roomId: string, - event: MatrixRoomEvent, - sender: string, - args: string[] - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - if (args.length < 3) { - await this.sendReply( - roomId, - event, - `**Verwendung:** \`!edit [nr] [feld] [wert]\`\n\n**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday\n\n**Beispiel:** \`!edit 1 email max@example.com\`` - ); - return; - } - - const number = parseInt(args[0], 10); - const field = args[1].toLowerCase(); - const value = args.slice(2).join(' '); - - const contact = this.contactsMapper.getByNumber(sender, number); - if (!contact) { - await this.sendReply( - roomId, - event, - `Kontakt ${args[0]} nicht gefunden. Nutze \`!kontakte\` zuerst.` - ); - return; - } - - const fieldMap: Record = { - email: 'email', - phone: 'phone', - telefon: 'phone', - mobile: 'mobile', - mobil: 'mobile', - handy: 'mobile', - company: 'company', - firma: 'company', - job: 'jobTitle', - jobtitle: 'jobTitle', - beruf: 'jobTitle', - website: 'website', - web: 'website', - street: 'street', - strasse: 'street', - city: 'city', - stadt: 'city', - zip: 'postalCode', - plz: 'postalCode', - country: 'country', - land: 'country', - notes: 'notes', - notizen: 'notes', - birthday: 'birthday', - geburtstag: 'birthday', - firstname: 'firstName', - vorname: 'firstName', - lastname: 'lastName', - nachname: 'lastName', - }; - - 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` - ); - return; - } - - try { - const updated = await this.contactsService.updateContact(token, contact.id, { - [mappedField]: 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[] - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendReply( - roomId, - event, - `**Verwendung:** \`!loeschen [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.` - ); - return; - } - - 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 name = - contact.displayName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(); - - try { - await this.contactsService.deleteContact(token, contact.id); - await this.sendReply(roomId, event, `Kontakt **${name}** geloscht.`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleToggleFavorite( - roomId: string, - event: MatrixRoomEvent, - sender: string, - args: string[] - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendReply( - roomId, - event, - `**Verwendung:** \`!fav [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.` - ); - return; - } - - 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; - } - - try { - const updated = await this.contactsService.toggleFavorite(token, contact.id); - const name = - updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); - const status = updated.isFavorite ? 'als Favorit markiert ★' : 'aus Favoriten entfernt'; - await this.sendReply(roomId, event, `**${name}** ${status}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - private async handleToggleArchive( - roomId: string, - event: MatrixRoomEvent, - sender: string, - args: string[] - ) { - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendReply( - roomId, - event, - `**Verwendung:** \`!archiv [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.` - ); - return; - } - - 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; - } - - try { - const updated = await this.contactsService.toggleArchive(token, contact.id); - const name = - updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); - const status = updated.isArchived ? 'archiviert' : 'aus dem Archiv geholt'; - await this.sendReply(roomId, event, `**${name}** ${status}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler: ${errorMsg}`); - } - } - - /** - * Require login - returns token or sends login prompt and returns null - */ - private async requireLogin( - roomId: string, - event: MatrixRoomEvent, - userId: string - ): Promise { - const token = await this.sessionService.getToken(userId); - if (!token) { - await this.sendReply(roomId, event, LOGIN_MESSAGES.contacts); - return null; - } - return token; - } - - private async handleStatus(roomId: string, event: MatrixRoomEvent, sender: string) { - const backendHealthy = await this.contactsService.checkHealth(); - const isLoggedIn = await this.sessionService.isLoggedIn(sender); - const sessionCount = this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusText = `**Contacts Bot Status**\n\n`; - statusText += `**Backend:** ${backendHealthy ? '✅ Online' : '❌ Offline'}\n`; - statusText += `**Aktive Sessions:** ${sessionCount}\n\n`; - - if (isLoggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusText += `👤 Angemeldet als: ${session.email}\n`; - statusText += `⚡ Credits: ${balance.balance.toFixed(2)}\n`; - } else { - statusText += `👤 Nicht angemeldet\n`; - statusText += `💡 Login: \`!login email passwort\``; - } - - await this.sendReply(roomId, event, statusText); - } - - private async pinHelpMessage(roomId: string, event: MatrixRoomEvent) { - try { - const eventId = await this.sendMessage(roomId, HELP_MESSAGE); - - await this.getClient().sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [eventId], - }); - - this.logger.log(`Pinned help message in room ${roomId}`); - } catch (error) { - this.logger.error(`Failed to pin help message:`, error); - await this.sendReply(roomId, event, 'Fehler beim Pinnen der Hilfe.'); - } - } - - private async handleLanguage( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - const lang = args.trim().toLowerCase(); - - if (!lang) { - const currentLang = await this.i18nService.getLanguage(userId); - const langName = LANGUAGE_NAMES[currentLang]; - const available = this.i18nService - .getAvailableLanguages() - .map((l) => `${l} (${LANGUAGE_NAMES[l]})`) - .join(', '); - await this.sendReply( - roomId, - event, - `**Sprache / Language:** ${langName}\n\n**Verfügbar / Available:** ${available}\n\nÄndern / Change: \`!language de\` oder / or \`!language en\`` - ); - return; - } - - if (!this.i18nService.isValidLanguage(lang)) { - const available = this.i18nService.getAvailableLanguages().join(', '); - await this.sendReply( - roomId, - event, - `Unbekannte Sprache / Unknown language: ${lang}\n\nVerfügbar / Available: ${available}` - ); - return; - } - - await this.i18nService.setLanguage(userId, lang as Language); - const langName = LANGUAGE_NAMES[lang as Language]; - - if (lang === 'de') { - await this.sendReply(roomId, event, `Sprache geändert zu: **${langName}**`); - } else { - await this.sendReply(roomId, event, `Language changed to: **${langName}**`); - } - } -} diff --git a/services/matrix-contacts-bot/src/config/configuration.ts b/services/matrix-contacts-bot/src/config/configuration.ts deleted file mode 100644 index 923d1e000..000000000 --- a/services/matrix-contacts-bot/src/config/configuration.ts +++ /dev/null @@ -1,46 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3320', 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', - }, - contacts: { - backendUrl: process.env.CONTACTS_BACKEND_URL || 'http://localhost:3015', - apiPrefix: process.env.CONTACTS_API_PREFIX || '/api/v1', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `**Contacts Bot - Kontaktverwaltung** - -**Kontakte anzeigen:** -- \`!kontakte\` - Alle Kontakte anzeigen -- \`!suche [text]\` - Kontakte suchen -- \`!favoriten\` - Favoriten anzeigen -- \`!kontakt [nr]\` - Kontakt-Details - -**Kontakte verwalten:** -- \`!neu Vorname Nachname\` - Neuen Kontakt erstellen -- \`!edit [nr] [feld] [wert]\` - Kontakt bearbeiten -- \`!loeschen [nr]\` - Kontakt loschen -- \`!fav [nr]\` - Favorit umschalten -- \`!archiv [nr]\` - Archivieren umschalten - -**Felder fur !edit:** -- \`email\`, \`phone\`, \`mobile\` -- \`company\`, \`job\`, \`website\` -- \`street\`, \`city\`, \`zip\`, \`country\` -- \`notes\`, \`birthday\` - -**Beispiele:** -\`!neu Max Mustermann\` -\`!edit 1 email max@example.com\` -\`!edit 1 phone +49 123 456789\` - -**Sonstiges:** -- \`!status\` - Bot-Status -- \`!help\` - Diese Hilfe`; diff --git a/services/matrix-contacts-bot/src/contacts/contacts.module.ts b/services/matrix-contacts-bot/src/contacts/contacts.module.ts deleted file mode 100644 index 999a7a0a4..000000000 --- a/services/matrix-contacts-bot/src/contacts/contacts.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ContactsService } from './contacts.service'; - -@Module({ - providers: [ContactsService], - exports: [ContactsService], -}) -export class ContactsModule {} diff --git a/services/matrix-contacts-bot/src/contacts/contacts.service.ts b/services/matrix-contacts-bot/src/contacts/contacts.service.ts deleted file mode 100644 index 5dd07d5ce..000000000 --- a/services/matrix-contacts-bot/src/contacts/contacts.service.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface Contact { - id: string; - firstName?: string | null; - lastName?: string | null; - displayName?: string | null; - nickname?: string | null; - email?: string | null; - phone?: string | null; - mobile?: string | null; - street?: string | null; - city?: string | null; - postalCode?: string | null; - country?: string | null; - company?: string | null; - jobTitle?: string | null; - department?: string | null; - website?: string | null; - birthday?: string | null; - notes?: string | null; - photoUrl?: string | null; - isFavorite: boolean; - isArchived: boolean; - createdAt: string; - updatedAt: string; -} - -export interface ContactFilters { - search?: string; - isFavorite?: boolean; - isArchived?: boolean; - limit?: number; - offset?: number; -} - -export interface ContactsResult { - contacts: Contact[]; - total: number; -} - -export interface CreateContactDto { - firstName?: string; - lastName?: string; - displayName?: string; - email?: string; - phone?: string; - mobile?: string; - company?: string; - jobTitle?: string; - website?: string; - notes?: string; -} - -@Injectable() -export class ContactsService { - private readonly logger = new Logger(ContactsService.name); - private readonly backendUrl: string; - private readonly apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = - this.configService.get('contacts.backendUrl') || 'http://localhost:3015'; - this.apiPrefix = this.configService.get('contacts.apiPrefix') || '/api/v1'; - } - - private getApiUrl(path: string): string { - return `${this.backendUrl}${this.apiPrefix}${path}`; - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/health`); - return response.ok; - } catch (error) { - this.logger.error('Health check failed:', error); - return false; - } - } - - async getContacts(token: string, filters: ContactFilters = {}): Promise { - try { - const params = new URLSearchParams(); - if (filters.search) params.set('search', filters.search); - if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite)); - if (filters.isArchived !== undefined) params.set('isArchived', String(filters.isArchived)); - if (filters.limit) params.set('limit', String(filters.limit)); - if (filters.offset) params.set('offset', String(filters.offset)); - - const url = `${this.getApiUrl('/contacts')}?${params.toString()}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch contacts: ${response.status}`); - } - - return await response.json(); - } catch (error) { - this.logger.error('Failed to fetch contacts:', error); - throw error; - } - } - - async getContact(token: string, contactId: string): Promise { - try { - const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Kontakt nicht gefunden'); - } - throw new Error(`Failed to fetch contact: ${response.status}`); - } - - const data = await response.json(); - return data.contact; - } catch (error) { - this.logger.error(`Failed to fetch contact ${contactId}:`, error); - throw error; - } - } - - async createContact(token: string, data: CreateContactDto): Promise { - try { - const response = await fetch(this.getApiUrl('/contacts'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to create contact: ${response.status}`); - } - - const result = await response.json(); - return result.contact; - } catch (error) { - this.logger.error('Failed to create contact:', error); - throw error; - } - } - - async updateContact(token: string, contactId: string, data: Partial): Promise { - try { - const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Kontakt nicht gefunden'); - } - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to update contact: ${response.status}`); - } - - const result = await response.json(); - return result.contact; - } catch (error) { - this.logger.error(`Failed to update contact ${contactId}:`, error); - throw error; - } - } - - async deleteContact(token: string, contactId: string): Promise { - try { - const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Kontakt nicht gefunden'); - } - throw new Error(`Failed to delete contact: ${response.status}`); - } - } catch (error) { - this.logger.error(`Failed to delete contact ${contactId}:`, error); - throw error; - } - } - - async toggleFavorite(token: string, contactId: string): Promise { - try { - const response = await fetch(this.getApiUrl(`/contacts/${contactId}/favorite`), { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Kontakt nicht gefunden'); - } - throw new Error(`Failed to toggle favorite: ${response.status}`); - } - - const result = await response.json(); - return result.contact; - } catch (error) { - this.logger.error(`Failed to toggle favorite for ${contactId}:`, error); - throw error; - } - } - - async toggleArchive(token: string, contactId: string): Promise { - try { - const response = await fetch(this.getApiUrl(`/contacts/${contactId}/archive`), { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Kontakt nicht gefunden'); - } - throw new Error(`Failed to toggle archive: ${response.status}`); - } - - const result = await response.json(); - return result.contact; - } catch (error) { - this.logger.error(`Failed to toggle archive for ${contactId}:`, error); - throw error; - } - } -} diff --git a/services/matrix-contacts-bot/src/main.ts b/services/matrix-contacts-bot/src/main.ts deleted file mode 100644 index a19e96313..000000000 --- a/services/matrix-contacts-bot/src/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 || 3320; - await app.listen(port); - - logger.log(`Matrix Contacts Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); diff --git a/services/matrix-contacts-bot/tsconfig.json b/services/matrix-contacts-bot/tsconfig.json deleted file mode 100644 index 38c2b55d7..000000000 --- a/services/matrix-contacts-bot/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 - } -} diff --git a/services/matrix-mana-bot/.env.example b/services/matrix-mana-bot/.env.example deleted file mode 100644 index 89596ec29..000000000 --- a/services/matrix-mana-bot/.env.example +++ /dev/null @@ -1,41 +0,0 @@ -# Server -PORT=3310 -NODE_ENV=development -TZ=Europe/Berlin - -# Matrix Connection -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_your_access_token_here -MATRIX_STORAGE_PATH=./data/mana-bot-storage.json - -# Optional: Restrict to specific rooms (comma-separated) -# MATRIX_ALLOWED_ROOMS=!room1:mana.how,!room2:mana.how - -# AI Service (Ollama) -OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=gemma3:4b -OLLAMA_TIMEOUT=120000 - -# Clock Service (external API) -CLOCK_API_URL=http://localhost:3017/api/v1 - -# Storage paths -TODO_STORAGE_PATH=./data/todos.json -CALENDAR_STORAGE_PATH=./data/calendar.json - -# API Services (for Morning Summary) -TODO_API_URL=http://localhost:3018 -CALENDAR_API_URL=http://localhost:3014 -CONTACTS_API_URL=http://localhost:3015 -PLANTA_API_URL=http://localhost:3022 - -# Weather (Morning Summary) -WEATHER_DEFAULT_LOCATION=Berlin - -# Voice Services -STT_URL=http://localhost:3020 -VOICE_BOT_URL=http://localhost:3050 -DEFAULT_VOICE=de-DE-ConradNeural -DEFAULT_SPEED=1.0 -VOICE_ENABLED=true -VOICE_PREFERENCES_PATH=./data/voice-preferences.json diff --git a/services/matrix-mana-bot/CLAUDE.md b/services/matrix-mana-bot/CLAUDE.md deleted file mode 100644 index e4d9c2988..000000000 --- a/services/matrix-mana-bot/CLAUDE.md +++ /dev/null @@ -1,375 +0,0 @@ -# Matrix Mana Bot (Gateway) - -Unified Matrix bot that combines all features in one. Users can interact with a single bot for AI chat, todos, calendar, timers, and more. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ matrix-mana-bot │ -│ (Gateway) │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Matrix Service │ │ -│ │ • Handles Matrix connection │ │ -│ │ • Receives messages │ │ -│ │ • Sends replies │ │ -│ └─────────────────────────┬────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Command Router │ │ -│ │ • Parses !commands and natural language │ │ -│ │ • Routes to appropriate handler │ │ -│ │ • Falls back to AI chat │ │ -│ └─────────────────────────┬────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────┼──────────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ -│ │ AI Handler │ │Todo Handler│ │Cal Handler │ ... │ -│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ -│ │ │ │ │ -│ └─────────────────┴─────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ @manacore/bot-services │ │ -│ │ (Shared Business Logic - no Matrix code) │ │ -│ │ │ │ -│ │ • TodoService • CalendarService │ │ -│ │ • AiService • ClockService │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Features - -| Category | Commands | Description | -|----------|----------|-------------| -| **AI Chat** | Just type, `!model`, `!models`, `!all`, `!clear` | Local LLM via Ollama | -| **Todos** | `!todo`, `!list`, `!today`, `!done`, `!delete` | Task management | -| **Calendar** | `!cal`, `!week`, `!event`, `!calendars` | Event scheduling | -| **Timers** | `!timer`, `!timers`, `!stop`, `!alarm`, `!alarms` | Time management | -| **Smart** | `!summary`, `!ai-todo` | Cross-feature AI features | -| **Voice** | Send voice note | Speech-to-text via Whisper | -| **Morning** | `!morning`, `!morning-on/off`, `!morning-time` | Daily morning summary | - -## Commands - -### AI & Chat - -``` -# Just type a message - AI responds -Was ist TypeScript? - -# Switch model -!model gemma3:4b - -# List available models -!models - -# Compare all models -!all Erkläre Docker - -# Clear chat history -!clear -``` - -### Todos - -``` -# Create task -!todo Einkaufen gehen - -# With priority (1-4, 1 = highest) -!todo Wichtig !p1 - -# With date -!todo Meeting @morgen -!todo Report @heute - -# With project -!todo Feature implementieren #arbeit - -# List all -!list - -# Today's tasks -!today - -# Complete task -!done 1 - -# Delete task -!delete 1 -``` - -### Calendar - -``` -# Today's events -!cal - -# This week -!week - -# Create event -!event Meeting morgen 14:30 -!event Geburtstag heute ganztägig -``` - -### Timers & Alarms - -``` -# Start timer -!timer 25m Pomodoro -!timer 1h30m Meeting - -# List active timers -!timers - -# Stop timer -!stop - -# Set alarm -!alarm 14:30 Meeting -!alarm 7:00 Aufstehen - -# List alarms -!alarms - -# World clock -!time -!time tokyo -``` - -### Voice Input - -``` -# Send a voice note in Matrix - bot transcribes and responds -🎤 "Was steht heute an?" -→ Bot shows: 🎤 *"Was steht heute an?"* -→ Bot responds with today's events and tasks - -# Voice commands work naturally -🎤 "Neue Aufgabe: Einkaufen gehen" -🎤 "Timer 25 Minuten" -🎤 "Was sind meine Termine diese Woche?" -``` - -### Smart Features (Cross-Feature) - -``` -# AI-powered daily summary -!summary - -# AI extracts todos from text -!ai-todo Im Meeting besprochen: Website redesign, API Docs aktualisieren -``` - -### Morning Summary - -``` -# Get morning summary now -!morning - -# Enable/disable automatic daily delivery -!morning-on -!morning-off - -# Set delivery time (HH:MM) -!morning-time 07:30 - -# Set weather location -!morning-location Berlin - -# Set timezone -!morning-timezone Europe/Berlin - -# Set format (compact/detailed) -!morning-format detailed - -# Show current settings -!morning-settings - -# Show help -!morning-help -``` - -The morning summary includes: -- Weather forecast (Open-Meteo API) -- Today's calendar events -- Today's tasks + overdue tasks -- Birthdays (from Contacts) -- Plants needing water (from Planta) - -## Development - -### Prerequisites - -- Node.js 20+ -- pnpm -- Running Matrix homeserver (Synapse) -- Bot account with access token -- Ollama (for AI features) - -### Setup - -```bash -# Install dependencies -pnpm install - -# Copy environment file -cp .env.example .env -# Edit .env with your settings - -# Start in development mode -pnpm start:dev - -# Or build and run -pnpm build && pnpm start:prod -``` - -### Get Matrix Access Token - -```bash -# Register bot user (if not exists) -docker exec -it synapse register_new_matrix_user \ - -u mana-bot \ - -p your_password \ - -a \ - -c /data/homeserver.yaml \ - http://localhost:8008 - -# Login to get access token -curl -X POST "http://localhost:8008/_matrix/client/r0/login" \ - -H "Content-Type: application/json" \ - -d '{"type": "m.login.password", "user": "mana-bot", "password": "your_password"}' -``` - -### Project Structure - -``` -src/ -├── main.ts # Entry point -├── app.module.ts # Root module -├── config/ -│ └── configuration.ts # Config & help texts -├── health/ -│ └── health.controller.ts # Health endpoint -├── bot/ -│ ├── bot.module.ts -│ ├── matrix.service.ts # Matrix connection -│ └── command-router.service.ts # Command routing -├── voice/ -│ ├── voice.module.ts -│ └── voice.service.ts # STT/TTS integration -├── handlers/ -│ ├── handlers.module.ts -│ ├── ai.handler.ts # AI/Ollama commands -│ ├── todo.handler.ts # Todo commands -│ ├── calendar.handler.ts # Calendar commands -│ ├── clock.handler.ts # Timer/alarm commands -│ ├── help.handler.ts # Help & status -│ ├── voice.handler.ts # Voice commands -│ └── morning.handler.ts # Morning summary commands -├── scheduler/ -│ ├── scheduler.module.ts # @nestjs/schedule integration -│ └── morning-summary.scheduler.ts # Cron job for morning delivery -└── orchestration/ - ├── orchestration.module.ts - └── orchestration.service.ts # Cross-feature logic -``` - -### Adding New Commands - -1. Add route in `command-router.service.ts`: - -```typescript -{ - patterns: ['!mycommand'], - handler: (ctx, args) => this.myHandler.doSomething(ctx, args), - description: 'My new command', -} -``` - -2. Create handler in `handlers/my.handler.ts`: - -```typescript -@Injectable() -export class MyHandler { - constructor(private myService: MyService) {} - - async doSomething(ctx: CommandContext, args: string): Promise { - // Use service from @manacore/bot-services - const result = await this.myService.doThing(ctx.userId, args); - return `Result: ${result}`; - } -} -``` - -3. Register in `handlers.module.ts` - -## Docker - -### Build - -```bash -docker build -t matrix-mana-bot . -``` - -### Run - -```bash -docker run -d \ - --name matrix-mana-bot \ - -p 3310:3310 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e OLLAMA_URL=http://ollama:11434 \ - -v ./data:/app/data \ - matrix-mana-bot -``` - -### Docker Compose - -See `docker-compose.macmini.yml` in the monorepo root. - -## Relationship to Other Bots - -This Gateway bot can run **alongside** the standalone bots: - -| Bot | Purpose | When to Use | -|-----|---------|-------------| -| **matrix-mana-bot** (this) | All features in one | General users | -| **matrix-todo-bot** | Todo only | Dedicated todo room | -| **matrix-ollama-bot** | AI only | Dedicated AI room | -| **matrix-clock-bot** | Timers only | Time tracking room | - -All bots share the same `@manacore/bot-services` package, so data is consistent. - -## Environment Variables - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `PORT` | No | 3310 | HTTP port | -| `MATRIX_HOMESERVER_URL` | Yes | - | Matrix server URL | -| `MATRIX_ACCESS_TOKEN` | Yes | - | Bot access token | -| `MATRIX_STORAGE_PATH` | No | ./data/... | Sync state storage | -| `MATRIX_ALLOWED_ROOMS` | No | - | Restrict to rooms | -| `OLLAMA_URL` | No | localhost:11434 | Ollama API | -| `OLLAMA_MODEL` | No | gemma3:4b | Default LLM | -| `CLOCK_API_URL` | No | localhost:3017 | Clock backend | -| `TODO_STORAGE_PATH` | No | ./data/todos.json | Todo storage | -| `TODO_API_URL` | No | localhost:3018 | Todo API (morning summary) | -| `CALENDAR_STORAGE_PATH` | No | ./data/calendar.json | Calendar storage | -| `CALENDAR_API_URL` | No | localhost:3014 | Calendar API (morning summary) | -| `CONTACTS_API_URL` | No | localhost:3015 | Contacts API (birthdays) | -| `PLANTA_API_URL` | No | localhost:3022 | Planta API (plants) | -| `WEATHER_DEFAULT_LOCATION` | No | Berlin | Default weather location | -| `STT_URL` | No | localhost:3020 | Speech-to-text (Whisper) | -| `VOICE_BOT_URL` | No | localhost:3050 | Voice bot (TTS) | -| `DEFAULT_VOICE` | No | de-DE-ConradNeural | Default TTS voice | -| `VOICE_ENABLED` | No | true | Enable voice processing | diff --git a/services/matrix-mana-bot/Dockerfile b/services/matrix-mana-bot/Dockerfile deleted file mode 100644 index c48b3a391..000000000 --- a/services/matrix-mana-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-mana-bot ./services/matrix-mana-bot - -# Install all dependencies (--ignore-scripts to skip root postinstall hooks) -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter matrix-mana-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-mana-bot/dist ./services/matrix-mana-bot/dist -COPY --from=builder /app/services/matrix-mana-bot/package.json ./services/matrix-mana-bot/ - -# Install production dependencies only (--ignore-scripts to skip root postinstall hooks) -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-mana-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4010/health || exit 1 - -EXPOSE 4010 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-mana-bot/nest-cli.json b/services/matrix-mana-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-mana-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-mana-bot/package.json b/services/matrix-mana-bot/package.json deleted file mode 100644 index f2867c9b8..000000000 --- a/services/matrix-mana-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "matrix-mana-bot", - "version": "1.0.0", - "description": "Unified Matrix Gateway Bot - All features in one", - "private": true, - "main": "dist/main.js", - "scripts": { - "build": "nest build", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@nestjs/common": "^10.0.0", - "@nestjs/config": "^3.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/schedule": "^4.0.0", - "matrix-bot-sdk": "^0.7.1", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@types/node": "^20.0.0", - "typescript": "^5.0.0" - }, - "pnpm": { - "neverBuiltDependencies": [ - "cpu-features", - "ssh2" - ], - "overrides": { - "cpu-features": "npm:empty-npm-package@1.0.0", - "ssh2": "npm:empty-npm-package@1.0.0" - } - } -} diff --git a/services/matrix-mana-bot/src/app.module.ts b/services/matrix-mana-bot/src/app.module.ts deleted file mode 100644 index e1282540c..000000000 --- a/services/matrix-mana-bot/src/app.module.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import configuration from './config/configuration'; -import { BotModule } from './bot/bot.module'; -import { HandlersModule } from './handlers/handlers.module'; -import { OrchestrationModule } from './orchestration/orchestration.module'; -import { SchedulerModule } from './scheduler/scheduler.module'; - -// Import shared services from bot-services package -import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - - // Business Logic Modules from shared package (global: true makes them available everywhere) - { - ...TodoModule.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - storagePath: config.get('services.todo.storagePath'), - }), - inject: [ConfigService], - }), - global: true, - }, - - { - ...CalendarModule.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - storagePath: config.get('services.calendar.storagePath'), - }), - inject: [ConfigService], - }), - global: true, - }, - - { - ...AiModule.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - baseUrl: config.get('services.ai.baseUrl'), - defaultModel: config.get('services.ai.defaultModel'), - timeout: config.get('services.ai.timeout'), - }), - inject: [ConfigService], - }), - global: true, - }, - - { - ...ClockModule.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - apiUrl: config.get('services.clock.apiUrl'), - }), - inject: [ConfigService], - }), - global: true, - }, - - // Gateway-specific modules - BotModule, - HandlersModule, - OrchestrationModule, - SchedulerModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-mana-bot')], -}) -export class AppModule {} diff --git a/services/matrix-mana-bot/src/bot/bot.module.ts b/services/matrix-mana-bot/src/bot/bot.module.ts deleted file mode 100644 index 5e2213bda..000000000 --- a/services/matrix-mana-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { CommandRouterService } from './command-router.service'; -import { HandlersModule } from '../handlers/handlers.module'; -import { OrchestrationModule } from '../orchestration/orchestration.module'; -import { VoiceModule } from '../voice/voice.module'; -import { SessionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - forwardRef(() => HandlersModule), - forwardRef(() => OrchestrationModule), - VoiceModule, - SessionModule.forRoot({ storageMode: 'redis' }), - CreditModule.forRoot(), - ], - providers: [MatrixService, CommandRouterService], - exports: [MatrixService, CommandRouterService], -}) -export class BotModule {} diff --git a/services/matrix-mana-bot/src/bot/command-router.service.ts b/services/matrix-mana-bot/src/bot/command-router.service.ts deleted file mode 100644 index 8d0477393..000000000 --- a/services/matrix-mana-bot/src/bot/command-router.service.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; -import { KeywordCommandDetector, COMMON_KEYWORDS } from '@manacore/matrix-bot-common'; -import { AiHandler } from '../handlers/ai.handler'; -import { TodoHandler } from '../handlers/todo.handler'; -import { CalendarHandler } from '../handlers/calendar.handler'; -import { ClockHandler } from '../handlers/clock.handler'; -import { HelpHandler } from '../handlers/help.handler'; -import { VoiceHandler } from '../handlers/voice.handler'; -import { MorningHandler } from '../handlers/morning.handler'; -import { OrchestrationService } from '../orchestration/orchestration.service'; - -export interface CommandContext { - roomId: string; - userId: string; - message: string; - event: any; - isVoice?: boolean; // True if message came from voice input -} - -interface CommandRoute { - patterns: (string | RegExp)[]; - handler: (ctx: CommandContext, args: string) => Promise; - description: string; -} - -@Injectable() -export class CommandRouterService { - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['modelle', 'models', 'welche modelle', 'ai models'], command: 'models' }, - { - keywords: ['meine aufgaben', 'zeige aufgaben', 'todo liste', 'was muss ich', 'aufgaben'], - command: 'list', - }, - { keywords: ['heute', 'was steht heute an', 'today'], command: 'today' }, - { keywords: ['termine', 'kalender', 'meine termine', 'calendar'], command: 'cal' }, - { keywords: ['timer', 'stoppuhr', 'zeitmesser'], command: 'timers' }, - { - keywords: ['zusammenfassung', 'wie war mein tag', 'tagesrueckblick', 'summary'], - command: 'summary', - }, - { keywords: ['todo', 'aufgabe', 'neue aufgabe', 'task'], command: 'todo' }, - { keywords: ['alarm', 'wecker', 'alarme'], command: 'alarms' }, - { keywords: ['clear', 'loeschen', 'verlauf loeschen', 'reset'], command: 'clear' }, - { - keywords: ['guten morgen', 'morgen zusammenfassung', 'morgenbericht', 'morning'], - command: 'morning', - }, - ]); - private readonly logger = new Logger(CommandRouterService.name); - private routes: CommandRoute[] = []; - - constructor( - @Inject(forwardRef(() => AiHandler)) - private aiHandler: AiHandler, - @Inject(forwardRef(() => TodoHandler)) - private todoHandler: TodoHandler, - @Inject(forwardRef(() => CalendarHandler)) - private calendarHandler: CalendarHandler, - @Inject(forwardRef(() => ClockHandler)) - private clockHandler: ClockHandler, - @Inject(forwardRef(() => HelpHandler)) - private helpHandler: HelpHandler, - @Inject(forwardRef(() => VoiceHandler)) - private voiceHandler: VoiceHandler, - @Inject(forwardRef(() => MorningHandler)) - private morningHandler: MorningHandler, - @Inject(forwardRef(() => OrchestrationService)) - private orchestration: OrchestrationService - ) { - this.initializeRoutes(); - } - - private initializeRoutes() { - this.routes = [ - // Help - { - patterns: ['!help', '!start', '!hilfe'], - handler: (ctx) => this.helpHandler.showHelp(ctx), - description: 'Show help', - }, - - // Auth Commands - { - patterns: ['!login'], - handler: (ctx, args) => this.helpHandler.handleLogin(ctx, args), - description: 'Login with email and password', - }, - { - patterns: ['!logout'], - handler: (ctx) => this.helpHandler.handleLogout(ctx), - description: 'Logout', - }, - - // AI Commands - { - patterns: ['!models', '!modelle'], - handler: (ctx) => this.aiHandler.listModels(ctx), - description: 'List AI models', - }, - { - patterns: ['!model'], - handler: (ctx, args) => this.aiHandler.setModel(ctx, args), - description: 'Switch AI model', - }, - { - patterns: ['!all'], - handler: (ctx, args) => this.aiHandler.compareAll(ctx, args), - description: 'Compare all models', - }, - { - patterns: ['!clear', '!reset'], - handler: (ctx) => this.aiHandler.clearHistory(ctx), - description: 'Clear chat history', - }, - - // Todo Commands - { - patterns: ['!todo', '!add', '!neu'], - handler: (ctx, args) => this.todoHandler.create(ctx, args), - description: 'Create todo', - }, - { - patterns: ['!list', '!liste', '!alle'], - handler: (ctx) => this.todoHandler.list(ctx), - description: 'List todos', - }, - { - patterns: ['!today', '!heute'], - handler: (ctx) => this.todoHandler.today(ctx), - description: "Today's todos", - }, - { - patterns: ['!inbox'], - handler: (ctx) => this.todoHandler.inbox(ctx), - description: 'Inbox todos', - }, - { - patterns: ['!done', '!erledigt', '!fertig'], - handler: (ctx, args) => this.todoHandler.complete(ctx, args), - description: 'Complete todo', - }, - { - patterns: ['!delete', '!löschen'], - handler: (ctx, args) => this.todoHandler.delete(ctx, args), - description: 'Delete todo', - }, - { - patterns: ['!projects', '!projekte'], - handler: (ctx) => this.todoHandler.projects(ctx), - description: 'List projects', - }, - - // Calendar Commands - { - patterns: ['!cal', '!termine'], - handler: (ctx) => this.calendarHandler.today(ctx), - description: "Today's events", - }, - { - patterns: ['!week', '!woche'], - handler: (ctx) => this.calendarHandler.week(ctx), - description: 'Week events', - }, - { - patterns: ['!event', '!termin'], - handler: (ctx, args) => this.calendarHandler.create(ctx, args), - description: 'Create event', - }, - { - patterns: ['!calendars', '!kalender'], - handler: (ctx) => this.calendarHandler.listCalendars(ctx), - description: 'List calendars', - }, - - // Clock Commands - { - patterns: ['!timer'], - handler: (ctx, args) => this.clockHandler.startTimer(ctx, args), - description: 'Start timer', - }, - { - patterns: ['!timers'], - handler: (ctx) => this.clockHandler.listTimers(ctx), - description: 'List timers', - }, - { - patterns: ['!alarm'], - handler: (ctx, args) => this.clockHandler.setAlarm(ctx, args), - description: 'Set alarm', - }, - { - patterns: ['!alarms'], - handler: (ctx) => this.clockHandler.listAlarms(ctx), - description: 'List alarms', - }, - { - patterns: ['!time', '!zeit'], - handler: (ctx, args) => this.clockHandler.worldClock(ctx, args), - description: 'World clock', - }, - { - patterns: ['!stop'], - handler: (ctx, args) => this.clockHandler.stopTimer(ctx, args), - description: 'Stop timer', - }, - - // Cross-Feature (Orchestration) - { - patterns: ['!summary', '!zusammenfassung'], - handler: (ctx) => this.orchestration.dailySummary(ctx), - description: 'Daily summary', - }, - { - patterns: ['!ai-todo'], - handler: (ctx, args) => this.orchestration.aiToTodos(ctx, args), - description: 'AI extracts todos', - }, - - // Status - { - patterns: ['!status'], - handler: (ctx) => this.helpHandler.showStatus(ctx), - description: 'Show status', - }, - - // Voice Commands - { - patterns: ['!voice', '!sprache'], - handler: (ctx, args) => this.voiceHandler.voiceSettings(ctx, args), - description: 'Voice settings', - }, - { - patterns: ['!stimmen', '!voices'], - handler: (ctx) => this.voiceHandler.listVoices(ctx), - description: 'List voices', - }, - { - patterns: ['!stimme'], - handler: (ctx, args) => this.voiceHandler.setVoice(ctx, args), - description: 'Set voice', - }, - { - patterns: ['!speed', '!tempo', '!geschwindigkeit'], - handler: (ctx, args) => this.voiceHandler.setSpeed(ctx, args), - description: 'Set speech speed', - }, - - // Morning Summary Commands - { - patterns: ['!morning', '!morgen'], - handler: (ctx) => this.morningHandler.getSummary(ctx), - description: 'Morning summary', - }, - { - patterns: ['!morning-on'], - handler: (ctx) => this.morningHandler.enable(ctx), - description: 'Enable morning summary', - }, - { - patterns: ['!morning-off'], - handler: (ctx) => this.morningHandler.disable(ctx), - description: 'Disable morning summary', - }, - { - patterns: ['!morning-time'], - handler: (ctx, args) => this.morningHandler.setTime(ctx, args), - description: 'Set morning delivery time', - }, - { - patterns: ['!morning-location'], - handler: (ctx, args) => this.morningHandler.setLocation(ctx, args), - description: 'Set weather location', - }, - { - patterns: ['!morning-timezone'], - handler: (ctx, args) => this.morningHandler.setTimezone(ctx, args), - description: 'Set timezone', - }, - { - patterns: ['!morning-format'], - handler: (ctx, args) => this.morningHandler.setFormat(ctx, args), - description: 'Set summary format', - }, - { - patterns: ['!morning-settings'], - handler: (ctx) => this.morningHandler.showSettings(ctx), - description: 'Show morning settings', - }, - { - patterns: ['!morning-help'], - handler: () => Promise.resolve(this.morningHandler.showHelp()), - description: 'Morning help', - }, - ]; - } - - async route(ctx: CommandContext): Promise { - const message = ctx.message.trim(); - - // Check for natural language keywords first - const keywordCommand = this.detectKeywordCommand(message); - if (keywordCommand) { - return this.routeCommand({ ...ctx, message: keywordCommand }); - } - - // Check for ! commands - if (message.startsWith('!')) { - return this.routeCommand(ctx); - } - - // Default: treat as AI chat - return this.aiHandler.chat(ctx, message); - } - - private async routeCommand(ctx: CommandContext): Promise { - const { command, args } = this.parseCommand(ctx.message); - - for (const route of this.routes) { - if (this.matchesPattern(command, route.patterns)) { - this.logger.debug(`Routing "${command}" to ${route.description}`); - try { - return await route.handler(ctx, args); - } catch (error) { - this.logger.error(`Error in handler for "${command}":`, error); - return `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`; - } - } - } - - // Unknown command - return null; - } - - private detectKeywordCommand(message: string): string | null { - const command = this.keywordDetector.detect(message); - if (command) { - this.logger.debug(`Detected keyword -> "!${command}"`); - return `!${command}`; - } - return null; - } - - private matchesPattern(command: string, patterns: (string | RegExp)[]): boolean { - for (const pattern of patterns) { - if (typeof pattern === 'string') { - if (command === pattern) return true; - } else if (pattern.test(command)) { - return true; - } - } - return false; - } - - private parseCommand(message: string): { command: string; args: string } { - const trimmed = message.trim(); - if (trimmed.startsWith('!')) { - const [cmd, ...rest] = trimmed.split(' '); - return { command: cmd.toLowerCase(), args: rest.join(' ') }; - } - return { command: '', args: trimmed }; - } -} diff --git a/services/matrix-mana-bot/src/bot/matrix.service.ts b/services/matrix-mana-bot/src/bot/matrix.service.ts deleted file mode 100644 index 6292de062..000000000 --- a/services/matrix-mana-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { Injectable, Inject, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common'; -import { CommandRouterService, CommandContext } from './command-router.service'; -import { VoiceService, VoiceServiceError } from '../voice/voice.service'; -import { VoiceFormatterService } from '../voice/voice-formatter.service'; -import { SessionService, CreditService } from '@manacore/bot-services'; -import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - private voiceEnabled: boolean; - - constructor( - configService: ConfigService, - @Inject(forwardRef(() => CommandRouterService)) - private commandRouter: CommandRouterService, - @Inject(forwardRef(() => VoiceService)) - private voiceService: VoiceService, - private voiceFormatter: VoiceFormatterService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - this.voiceEnabled = configService.get('voice.enabled') !== false; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/mana-bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - async onModuleInit() { - await super.onModuleInit(); - - if (!this.client) return; - - // Handle room invites with introduction - this.client.on('room.invite', async (roomId: string) => { - this.logger.log(`Invited to room ${roomId}, joining...`); - await this.client.joinRoom(roomId); - - setTimeout(async () => { - try { - await this.sendBotIntroduction(roomId); - } catch (error) { - this.logger.error(`Failed to send introduction to ${roomId}:`, error); - } - }, 2000); - }); - - // Handle member joins for welcome message - this.client.on('room.event', async (roomId: string, event: any) => { - if (event.type === 'm.room.member' && event.content?.membership === 'join') { - const userId = event.state_key; - if (userId === this.botUserId) return; - if (event.unsigned?.prev_content?.membership !== 'join') { - await this.sendWelcomeMessage(roomId, userId); - } - } - }); - - this.botUserId = await this.client.getUserId(); - this.logger.log(`Mana Gateway Bot connected`); - this.logger.log(`Bot user ID: ${this.botUserId}`); - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - const ctx: CommandContext = { - roomId, - userId: sender, - message, - event, - }; - - try { - // Set typing indicator - await this.client.setTyping(roomId, true, 30000); - - // Route the message - const response = await this.commandRouter.route(ctx); - - // Stop typing - await this.client.setTyping(roomId, false); - - if (response) { - await this.sendReply(roomId, event, response); - } - } catch (error) { - await this.client.setTyping(roomId, false); - this.logger.error(`Error handling message:`, error); - await this.sendReply( - roomId, - event, - '❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.' - ); - } - } - - /** - * Handle voice note messages - transcribe, process, and respond with audio - */ - protected async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - if (!this.voiceEnabled) { - return; - } - - const audioUrl = event.content?.url; - if (!audioUrl) { - this.logger.warn('Audio message without URL'); - return; - } - - try { - // Set typing indicator - await this.client.setTyping(roomId, true, 60000); - - // Download audio from Matrix - this.logger.debug(`Downloading audio from ${audioUrl}`); - const audioBuffer = await this.downloadMedia(audioUrl); - - // Transcribe audio - this.logger.debug(`Transcribing ${audioBuffer.length} bytes`); - const transcription = await this.voiceService.transcribe(audioBuffer); - - if (!transcription.text || transcription.text.trim() === '') { - await this.client.setTyping(roomId, false); - await this.sendReply( - roomId, - event, - '🎤 Ich konnte leider nichts verstehen. Bitte versuche es noch einmal.' - ); - return; - } - - const message = transcription.text.trim(); - this.logger.log(`Transcribed from ${sender}: "${message}"`); - - // Show what was understood - await this.sendReply(roomId, event, `🎤 *"${message}"*`); - - // Create context and route - const ctx: CommandContext = { - roomId, - userId: sender, - message, - event, - isVoice: true, // Flag for voice input - }; - - // Route the transcribed message - const response = await this.commandRouter.route(ctx); - - // Stop typing - await this.client.setTyping(roomId, false); - - if (response) { - // Send text response first - await this.sendReply(roomId, event, response); - - // Then generate and send audio response (non-blocking) - const prefs = this.voiceService.getUserPreferences(sender); - if (prefs.voiceEnabled) { - this.generateAndSendAudioResponse(roomId, response, sender).catch((err) => - this.logger.error(`Failed to send audio response: ${err}`) - ); - } - } - } catch (error) { - await this.client.setTyping(roomId, false); - - // Handle specific voice service errors - if (error instanceof VoiceServiceError) { - this.logger.warn(`Voice service error (${error.code}): ${error.message}`); - - let userMessage: string; - switch (error.code) { - case 'STT_UNAVAILABLE': - userMessage = - '🎤 Spracherkennung momentan nicht verfügbar. Bitte schreibe deine Nachricht.'; - break; - case 'TTS_UNAVAILABLE': - userMessage = '🔊 Sprachausgabe momentan nicht verfügbar.'; - break; - case 'TIMEOUT': - userMessage = - '⏱️ Die Verarbeitung dauert zu lange. Bitte versuche eine kürzere Nachricht.'; - break; - case 'INVALID_AUDIO': - userMessage = `🎤 ${error.message}`; - break; - default: - userMessage = '❌ Spracherkennung fehlgeschlagen. Bitte versuche es erneut.'; - } - - await this.sendReply(roomId, event, userMessage); - return; - } - - this.logger.error(`Error handling voice message:`, error); - await this.sendReply( - roomId, - event, - '❌ Spracherkennung fehlgeschlagen. Bitte versuche es noch einmal.' - ); - } - } - - /** - * Generate TTS audio and send as Matrix audio message - */ - private async generateAndSendAudioResponse( - roomId: string, - text: string, - userId: string - ): Promise { - try { - // Format text for natural German speech - const speechText = this.voiceFormatter.format(text); - - // Skip if text is too short or empty - if (!speechText || speechText.length < 5) { - return; - } - - this.logger.debug(`Formatted for speech: ${speechText.length} chars`); - - // Generate audio - const audioBuffer = await this.voiceService.synthesize(speechText, userId); - - // Upload to Matrix - const mxcUrl = await this.uploadMedia(audioBuffer, 'audio/mpeg', 'response.mp3'); - - // Send audio message - await this.client.sendMessage(roomId, { - msgtype: 'm.audio', - body: 'Sprachantwort', - url: mxcUrl, - info: { - mimetype: 'audio/mpeg', - size: audioBuffer.length, - }, - }); - - this.logger.debug(`Sent audio response (${audioBuffer.length} bytes)`); - } catch (error) { - this.logger.error(`Failed to generate audio response: ${error}`); - // Don't throw - audio is optional - } - } - - private async sendWelcomeMessage(roomId: string, userId: string) { - try { - await this.sendMessage(roomId, WELCOME_TEXT); - this.logger.log(`Sent welcome message to ${userId} in ${roomId}`); - } catch (error) { - this.logger.error(`Failed to send welcome message: ${error}`); - } - } - - private async sendBotIntroduction(roomId: string) { - await this.sendMessage(roomId, BOT_INTRODUCTION); - - // Try to pin the help message - try { - const helpEventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: HELP_TEXT, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtmlPublic(HELP_TEXT), - }); - - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [helpEventId], - }); - this.logger.log(`Pinned help message in ${roomId}`); - } catch (error) { - this.logger.debug(`Could not pin help (might lack permissions): ${error}`); - } - } - - private markdownToHtmlPublic(text: string): string { - return text - .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/\n/g, '
'); - } - - /** - * Send a direct message to a user by finding or creating a DM room - */ - async sendDirectMessage(userId: string, message: string): Promise { - const roomId = await this.getOrCreateDmRoom(userId); - await this.sendMessage(roomId, message); - } - - private async getOrCreateDmRoom(userId: string): Promise { - // Check existing joined rooms for a DM with this user - const joinedRooms = await this.client.getJoinedRooms(); - for (const roomId of joinedRooms) { - try { - const members = await this.client.getJoinedRoomMembers(roomId); - if (members.length === 2 && members.includes(userId)) { - return roomId; - } - } catch { - // Skip rooms we can't inspect - } - } - - // Create a new DM room - const roomId = await this.client.createRoom({ - invite: [userId], - is_direct: true, - preset: 'trusted_private_chat', - }); - return roomId; - } - - getClient() { - return this.client; - } -} diff --git a/services/matrix-mana-bot/src/config/configuration.ts b/services/matrix-mana-bot/src/config/configuration.ts deleted file mode 100644 index 0e5b1a230..000000000 --- a/services/matrix-mana-bot/src/config/configuration.ts +++ /dev/null @@ -1,126 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT, 10) || 3310, - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN || '', - storagePath: process.env.MATRIX_STORAGE_PATH || './data/mana-bot-storage.json', - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS - ? process.env.MATRIX_ALLOWED_ROOMS.split(',').map((r) => r.trim()) - : [], - }, - services: { - ai: { - baseUrl: process.env.OLLAMA_URL || 'http://localhost:11434', - defaultModel: process.env.OLLAMA_MODEL || 'gemma3:4b', - timeout: parseInt(process.env.OLLAMA_TIMEOUT, 10) || 120000, - }, - clock: { - apiUrl: process.env.CLOCK_API_URL || 'http://localhost:3017/api/v1', - }, - todo: { - storagePath: process.env.TODO_STORAGE_PATH || './data/todos.json', - apiUrl: process.env.TODO_API_URL || 'http://localhost:3018', - }, - calendar: { - storagePath: process.env.CALENDAR_STORAGE_PATH || './data/calendar.json', - apiUrl: process.env.CALENDAR_API_URL || 'http://localhost:3014', - }, - contacts: { - apiUrl: process.env.CONTACTS_API_URL || 'http://localhost:3015', - }, - planta: { - apiUrl: process.env.PLANTA_API_URL || 'http://localhost:3022', - }, - }, - weather: { - defaultLocation: process.env.WEATHER_DEFAULT_LOCATION || 'Berlin', - }, - voice: { - sttUrl: process.env.STT_URL || 'http://localhost:3020', - voiceBotUrl: process.env.VOICE_BOT_URL || 'http://localhost:3050', - defaultVoice: process.env.DEFAULT_VOICE || 'de-DE-ConradNeural', - defaultSpeed: parseFloat(process.env.DEFAULT_SPEED) || 1.0, - enabled: process.env.VOICE_ENABLED !== 'false', - preferencesPath: process.env.VOICE_PREFERENCES_PATH || './data/voice-preferences.json', - }, -}); - -// Help text for the unified bot -export const HELP_TEXT = `**🤖 Mana - Dein Assistent** - -**AI & Chat** -Schreib einfach eine Nachricht - ich antworte! -• \`!model [name]\` - KI-Modell wechseln -• \`!models\` - Verfügbare Modelle anzeigen -• \`!all [frage]\` - Alle Modelle vergleichen - -**📋 Todos** -• \`!todo [text]\` - Neue Aufgabe erstellen -• \`!list\` - Alle offenen Aufgaben -• \`!today\` - Heutige Aufgaben -• \`!done [nr]\` - Aufgabe erledigen -• \`!delete [nr]\` - Aufgabe löschen - -**📅 Kalender** -• \`!cal\` - Heutige Termine -• \`!week\` - Wochenübersicht -• \`!event [titel] [zeit]\` - Termin erstellen - -**⏱️ Zeit & Timer** -• \`!timer [dauer]\` - Timer starten (z.B. 25m) -• \`!alarm [zeit]\` - Alarm setzen (z.B. 14:30) -• \`!time [stadt]\` - Weltuhr -• \`!timers\` - Aktive Timer anzeigen - -**🔮 Smart Features** -• \`!summary\` - Tages-Zusammenfassung (AI) -• \`!ai-todo [text]\` - AI extrahiert Todos aus Text - -**☀️ Morgenzusammenfassung** -• \`!morning\` - Zusammenfassung jetzt abrufen -• \`!morning-on\` - Automatisch aktivieren -• \`!morning-off\` - Automatisch deaktivieren -• \`!morning-time HH:MM\` - Sendezeit einstellen -• \`!morning-location [Stadt]\` - Wetter-Ort setzen -• \`!morning-settings\` - Einstellungen anzeigen - -**🎤 Sprache & Voice** -Sende eine Sprachnachricht - ich verstehe dich! -• Natürliche Befehle: "Was steht heute an?" -• \`!voice\` - Voice-Einstellungen anzeigen -• \`!voice an/aus\` - Sprachantworten aktivieren -• \`!stimmen\` - Verfügbare Stimmen -• \`!stimme [name]\` - Stimme wählen -• \`!speed [0.5-2.0]\` - Geschwindigkeit ändern - -**💡 Tipps** -• Natürliche Sprache funktioniert: "Was sind meine Todos?" -• Prioritäten: \`!todo Wichtig !p1\` -• Datum: \`!todo Meeting @morgen\` -• Projekt: \`!todo Task #projekt\` - ---- -*100% DSGVO-konform - alle Daten lokal*`; - -export const WELCOME_TEXT = `👋 **Willkommen bei Mana!** - -Ich bin dein persönlicher Assistent mit vielen Funktionen: -• 🤖 AI Chat (lokales LLM) -• 📋 Todo-Verwaltung -• 📅 Kalender -• ⏱️ Timer & Alarme -• 🎤 Spracherkennung - -Schreib einfach eine Nachricht, sende eine Sprachnachricht, oder sag "hilfe" für alle Befehle!`; - -export const BOT_INTRODUCTION = `🤖 **Hallo! Ich bin Mana, euer All-in-One Assistent.** - -Ich vereinige alle Bot-Funktionen in einem: -• AI Chat & Fragen beantworten -• Aufgaben verwalten -• Termine planen -• Timer & Alarme - -Alle Daten bleiben auf diesem Server - 100% DSGVO-konform! - -Sag einfach "hilfe" oder \`!help\` für alle Befehle.`; diff --git a/services/matrix-mana-bot/src/handlers/ai.handler.ts b/services/matrix-mana-bot/src/handlers/ai.handler.ts deleted file mode 100644 index f7b94c132..000000000 --- a/services/matrix-mana-bot/src/handlers/ai.handler.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { AiService } from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; - -@Injectable() -export class AiHandler { - private readonly logger = new Logger(AiHandler.name); - - constructor(private aiService: AiService) {} - - async chat(ctx: CommandContext, message: string): Promise { - this.logger.debug(`Chat request from ${ctx.userId}: ${message.substring(0, 50)}...`); - - const response = await this.aiService.chatSimple(ctx.userId, message); - return response; - } - - async listModels(ctx: CommandContext): Promise { - const models = await this.aiService.listModels(); - - if (models.length === 0) { - return '❌ Keine Modelle gefunden. Ist Ollama gestartet?'; - } - - const session = this.aiService.getSession(ctx.userId); - const currentModel = session?.model || this.aiService.getDefaultModel(); - - const modelList = models - .map((m) => { - const sizeMB = (m.size / 1024 / 1024).toFixed(0); - const active = m.name === currentModel ? ' ✓' : ''; - return `• \`${m.name}\` (${sizeMB} MB)${active}`; - }) - .join('\n'); - - return `**Verfügbare Modelle:**\n\n${modelList}\n\nWechseln mit: \`!model [name]\``; - } - - async setModel(ctx: CommandContext, modelName: string): Promise { - if (!modelName.trim()) { - const session = this.aiService.getSession(ctx.userId); - const currentModel = session?.model || this.aiService.getDefaultModel(); - return `Aktuelles Modell: \`${currentModel}\`\n\nVerwendung: \`!model gemma3:4b\``; - } - - const models = await this.aiService.listModels(); - const exists = models.some((m) => m.name === modelName); - - if (!exists) { - const available = models.map((m) => m.name).join(', '); - return `❌ Modell "${modelName}" nicht gefunden.\n\nVerfügbar: ${available}`; - } - - this.aiService.setSessionModel(ctx.userId, modelName); - this.logger.log(`User ${ctx.userId} switched to model ${modelName}`); - - return `✅ Modell gewechselt zu: \`${modelName}\``; - } - - async compareAll(ctx: CommandContext, question: string): Promise { - if (!question.trim()) { - return `**Verwendung:** \`!all [Deine Frage]\`\n\nBeispiel: \`!all Was ist 2+2?\``; - } - - const results = await this.aiService.compareModels(question); - - if (results.length === 0) { - return '❌ Keine Modelle gefunden. Ist Ollama gestartet?'; - } - - let resultText = `**📊 Modellvergleich**\n\n**Frage:** "${question}"\n\n---\n\n`; - - for (const result of results) { - const durationSec = (result.duration / 1000).toFixed(1); - if (result.error) { - resultText += `**${result.model}** ⏱️ ${durationSec}s\n❌ Fehler: ${result.error}\n\n---\n\n`; - } else { - const truncated = - result.response.length > 400 - ? result.response.substring(0, 400) + '...' - : result.response; - resultText += `**${result.model}** ⏱️ ${durationSec}s\n${truncated}\n\n---\n\n`; - } - } - - return resultText; - } - - async clearHistory(ctx: CommandContext): Promise { - this.aiService.clearSessionHistory(ctx.userId); - this.logger.log(`User ${ctx.userId} cleared chat history`); - return '✅ Chat-Verlauf gelöscht.'; - } -} diff --git a/services/matrix-mana-bot/src/handlers/calendar.handler.ts b/services/matrix-mana-bot/src/handlers/calendar.handler.ts deleted file mode 100644 index 055167c3a..000000000 --- a/services/matrix-mana-bot/src/handlers/calendar.handler.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CalendarService, CalendarEvent } from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; - -@Injectable() -export class CalendarHandler { - private readonly logger = new Logger(CalendarHandler.name); - - constructor(private calendarService: CalendarService) {} - - async today(ctx: CommandContext): Promise { - const events = await this.calendarService.getTodayEvents(ctx.userId); - - if (events.length === 0) { - return '📅 Keine Termine für heute.\n\nErstelle einen mit `!event [Titel] [Zeit]`'; - } - - return this.formatEventList('📅 **Termine heute:**', events); - } - - async week(ctx: CommandContext): Promise { - const events = await this.calendarService.getWeekEvents(ctx.userId); - - if (events.length === 0) { - return '📅 Keine Termine diese Woche.'; - } - - return this.formatEventList('📅 **Termine diese Woche:**', events); - } - - async create(ctx: CommandContext, input: string): Promise { - if (!input.trim()) { - return `**Verwendung:** \`!event [Titel] [Zeit]\` - -**Beispiele:** -• \`!event Meeting morgen 14:30\` -• \`!event Zahnarzt 15.02. 10:00\` -• \`!event Geburtstag heute ganztägig\``; - } - - const parsed = this.calendarService.parseEventInput(input); - const event = await this.calendarService.createEvent(ctx.userId, parsed); - - const timeStr = event.isAllDay - ? 'Ganztägig' - : this.formatTime(event.startTime); - - const dateStr = this.formatDate(event.startTime); - - this.logger.log(`Created event "${event.title}" for ${ctx.userId}`); - return `✅ Termin erstellt: **${event.title}**\n📅 ${dateStr} ${timeStr}`; - } - - async listCalendars(ctx: CommandContext): Promise { - const calendars = await this.calendarService.getCalendars(ctx.userId); - - if (calendars.length === 0) { - return '📅 Keine Kalender vorhanden.\n\nTermine werden automatisch im Standard-Kalender gespeichert.'; - } - - let response = '📅 **Deine Kalender:**\n\n'; - for (const cal of calendars) { - const color = cal.color || '⬜'; - response += `${color} ${cal.name}\n`; - } - - return response; - } - - private formatEventList(header: string, events: CalendarEvent[]): string { - let response = `${header}\n\n`; - - // Group events by date - const byDate = new Map(); - for (const event of events) { - const dateKey = new Date(event.startTime).toISOString().split('T')[0]; - if (!byDate.has(dateKey)) { - byDate.set(dateKey, []); - } - byDate.get(dateKey)!.push(event); - } - - for (const [dateKey, dayEvents] of byDate) { - const dateLabel = this.formatDate(dateKey); - response += `**${dateLabel}:**\n`; - - for (const event of dayEvents) { - const timeStr = event.isAllDay - ? '🌅 Ganztägig' - : `⏰ ${this.formatTime(event.startTime)}`; - response += `• ${timeStr} - ${event.title}\n`; - } - response += '\n'; - } - - return response; - } - - private formatDate(dateInput: string | Date): string { - const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; - const today = new Date(); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - const dateStr = date.toISOString().split('T')[0]; - const todayStr = today.toISOString().split('T')[0]; - const tomorrowStr = tomorrow.toISOString().split('T')[0]; - - if (dateStr === todayStr) return 'Heute'; - if (dateStr === tomorrowStr) return 'Morgen'; - - return date.toLocaleDateString('de-DE', { - weekday: 'short', - day: '2-digit', - month: '2-digit', - }); - } - - private formatTime(dateInput: string | Date): string { - const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; - return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); - } -} diff --git a/services/matrix-mana-bot/src/handlers/clock.handler.ts b/services/matrix-mana-bot/src/handlers/clock.handler.ts deleted file mode 100644 index 6a55cc982..000000000 --- a/services/matrix-mana-bot/src/handlers/clock.handler.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ClockService } from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; - -@Injectable() -export class ClockHandler { - private readonly logger = new Logger(ClockHandler.name); - - constructor(private clockService: ClockService) {} - - async startTimer(ctx: CommandContext, input: string): Promise { - if (!input.trim()) { - return `**Verwendung:** \`!timer [Dauer] [Name]\` - -**Beispiele:** -• \`!timer 25m Pomodoro\` -• \`!timer 1h30m Meeting\` -• \`!timer 5m Pause\` - -**Dauer-Formate:** 5m, 1h, 1h30m, 90s`; - } - - try { - const result = await this.clockService.startTimerForUser(ctx.userId, input); - this.logger.log(`Started timer for ${ctx.userId}: ${result.name}`); - - const durationStr = this.formatDuration(result.durationSeconds); - return `⏱️ Timer gestartet: **${result.name || 'Timer'}**\nDauer: ${durationStr}\n\nStoppen mit \`!stop\``; - } catch (error) { - return `❌ ${error instanceof Error ? error.message : 'Fehler beim Starten des Timers'}`; - } - } - - async listTimers(ctx: CommandContext): Promise { - try { - const token = this.clockService.getUserToken(ctx.userId); - if (!token) { - return '❌ Nicht authentifiziert. Bitte zuerst anmelden.'; - } - - const timers = await this.clockService.getTimers(token); - - if (timers.length === 0) { - return '⏱️ Keine aktiven Timer.\n\nStarte einen mit `!timer [Dauer]`'; - } - - let response = '⏱️ **Aktive Timer:**\n\n'; - for (const timer of timers) { - const remaining = this.formatDuration(timer.remainingSeconds); - const status = timer.status === 'paused' ? '⏸️' : '▶️'; - response += `${status} **${timer.label || 'Timer'}** - ${remaining} verbleibend\n`; - } - - response += '\n`!stop` zum Beenden'; - return response; - } catch (error) { - return '❌ Fehler beim Abrufen der Timer.'; - } - } - - async stopTimer(ctx: CommandContext, args: string): Promise { - try { - const result = await this.clockService.stopTimerForUser(ctx.userId, args.trim() || undefined); - return `⏹️ Timer gestoppt: **${result.name || 'Timer'}**`; - } catch (error) { - return `❌ ${error instanceof Error ? error.message : 'Kein aktiver Timer gefunden'}`; - } - } - - async setAlarm(ctx: CommandContext, input: string): Promise { - if (!input.trim()) { - return `**Verwendung:** \`!alarm [Zeit] [Name]\` - -**Beispiele:** -• \`!alarm 14:30 Meeting\` -• \`!alarm 7:00 Aufstehen\` -• \`!alarm 18 Uhr Feierabend\``; - } - - try { - const result = await this.clockService.setAlarmForUser(ctx.userId, input); - this.logger.log(`Set alarm for ${ctx.userId}: ${result.name} at ${result.time}`); - - return `⏰ Alarm gesetzt: **${result.name || 'Alarm'}**\nZeit: ${result.time}`; - } catch (error) { - return `❌ ${error instanceof Error ? error.message : 'Fehler beim Setzen des Alarms'}`; - } - } - - async listAlarms(ctx: CommandContext): Promise { - try { - const token = this.clockService.getUserToken(ctx.userId); - if (!token) { - return '❌ Nicht authentifiziert. Bitte zuerst anmelden.'; - } - - const alarms = await this.clockService.getAlarms(token); - - if (alarms.length === 0) { - return '⏰ Keine aktiven Alarme.\n\nSetze einen mit `!alarm [Zeit]`'; - } - - let response = '⏰ **Aktive Alarme:**\n\n'; - for (const alarm of alarms) { - const status = alarm.enabled ? '🔔' : '🔕'; - response += `${status} **${alarm.label || 'Alarm'}** - ${alarm.time}\n`; - } - - return response; - } catch (error) { - return '❌ Fehler beim Abrufen der Alarme.'; - } - } - - async worldClock(ctx: CommandContext, city: string): Promise { - if (!city.trim()) { - // Show common time zones - const zones = [ - { city: 'Berlin', tz: 'Europe/Berlin' }, - { city: 'London', tz: 'Europe/London' }, - { city: 'New York', tz: 'America/New_York' }, - { city: 'Tokyo', tz: 'Asia/Tokyo' }, - { city: 'Sydney', tz: 'Australia/Sydney' }, - ]; - - let response = '🌍 **Weltuhren:**\n\n'; - const now = new Date(); - - for (const { city, tz } of zones) { - const time = now.toLocaleTimeString('de-DE', { - timeZone: tz, - hour: '2-digit', - minute: '2-digit', - }); - response += `• **${city}:** ${time}\n`; - } - - response += '\nZeige andere Stadt: `!time [Stadt]`'; - return response; - } - - try { - const result = await this.clockService.getWorldClockTime(city); - return `🕐 **${result.city}:** ${result.time}\n📅 ${result.date}`; - } catch (error) { - return `❌ Stadt "${city}" nicht gefunden.\n\nVersuche: Berlin, London, New York, Tokyo, Sydney`; - } - } - - private formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - const parts: string[] = []; - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (secs > 0 && hours === 0) parts.push(`${secs}s`); - - return parts.join(' ') || '0s'; - } -} diff --git a/services/matrix-mana-bot/src/handlers/handlers.module.ts b/services/matrix-mana-bot/src/handlers/handlers.module.ts deleted file mode 100644 index 48c682ff6..000000000 --- a/services/matrix-mana-bot/src/handlers/handlers.module.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { AiHandler } from './ai.handler'; -import { TodoHandler } from './todo.handler'; -import { CalendarHandler } from './calendar.handler'; -import { ClockHandler } from './clock.handler'; -import { HelpHandler } from './help.handler'; -import { VoiceHandler } from './voice.handler'; -import { MorningHandler } from './morning.handler'; -import { BotModule } from '../bot/bot.module'; -import { VoiceModule } from '../voice/voice.module'; -import { SessionModule, CreditModule, MorningSummaryModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - forwardRef(() => BotModule), - VoiceModule, - SessionModule.forRoot(), - CreditModule.forRoot(), - MorningSummaryModule.forRoot(), - ], - providers: [ - AiHandler, - TodoHandler, - CalendarHandler, - ClockHandler, - HelpHandler, - VoiceHandler, - MorningHandler, - ], - exports: [ - AiHandler, - TodoHandler, - CalendarHandler, - ClockHandler, - HelpHandler, - VoiceHandler, - MorningHandler, - ], -}) -export class HandlersModule {} diff --git a/services/matrix-mana-bot/src/handlers/help.handler.ts b/services/matrix-mana-bot/src/handlers/help.handler.ts deleted file mode 100644 index 747e34f56..000000000 --- a/services/matrix-mana-bot/src/handlers/help.handler.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AiService, TodoService, SessionService, CreditService } from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; -import { VoiceService } from '../voice/voice.service'; -import { HELP_TEXT } from '../config/configuration'; - -@Injectable() -export class HelpHandler { - constructor( - private aiService: AiService, - private todoService: TodoService, - private voiceService: VoiceService, - private sessionService: SessionService, - private creditService: CreditService - ) {} - - async showHelp(ctx: CommandContext): Promise { - return HELP_TEXT; - } - - async handleLogin(ctx: CommandContext, args: string): Promise { - const parts = args.split(' '); - if (parts.length < 2 || !parts[0] || !parts[1]) { - return 'Verwendung: `!login email passwort`'; - } - const [email, password] = parts; - const result = await this.sessionService.login(ctx.userId, email, password); - - if (result.success) { - const token = await this.sessionService.getToken(ctx.userId); - if (token) { - const balance = await this.creditService.getBalance(token); - return `✅ Erfolgreich angemeldet als **${email}**\n⚡ Credits: ${balance.balance.toFixed(2)}`; - } - return `✅ Erfolgreich angemeldet als **${email}**`; - } - return `❌ Anmeldung fehlgeschlagen: ${result.error}`; - } - - async handleLogout(ctx: CommandContext): Promise { - await this.sessionService.logout(ctx.userId); - return '👋 Erfolgreich abgemeldet.'; - } - - async showStatus(ctx: CommandContext): Promise { - // Auth-Status zuerst - const loggedIn = await this.sessionService.isLoggedIn(ctx.userId); - const session = await this.sessionService.getSession(ctx.userId); - const token = await this.sessionService.getToken(ctx.userId); - - let authSection = ''; - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - authSection = `**👤 Account** -• Angemeldet als: ${session.email} -• Credits: ⚡ ${balance.balance.toFixed(2)} - -`; - } else { - authSection = `**👤 Account** -• Nicht angemeldet -• Nutze \`!login email passwort\` - -`; - } - - // Check services in parallel - const [aiConnected, todoStats, voiceHealth] = await Promise.all([ - this.aiService.checkConnection(), - this.todoService.getStats(ctx.userId), - this.voiceService.checkHealth(), - ]); - - const aiStatus = aiConnected ? '✅ Online' : '❌ Offline'; - const currentModel = - this.aiService.getSession(ctx.userId)?.model || this.aiService.getDefaultModel(); - - const sttStatus = voiceHealth.stt ? '✅ Online' : '❌ Offline'; - const ttsStatus = voiceHealth.tts ? '✅ Online' : '❌ Offline'; - - const voicePrefs = this.voiceService.getUserPreferences(ctx.userId); - const voiceEnabled = voicePrefs.voiceEnabled ? '✅ Aktiviert' : '❌ Deaktiviert'; - - return `**📊 Status** - -${authSection}**AI/Ollama** -• Verbindung: ${aiStatus} -• Modell: \`${currentModel}\` - -**🎤 Voice** -• STT (Whisper): ${sttStatus} -• TTS (Edge): ${ttsStatus} -• Deine Einstellung: ${voiceEnabled} -• Stimme: ${voicePrefs.voice} - -**Todos** -• Offen: ${todoStats.pending} -• Heute fällig: ${todoStats.today} -• Erledigt: ${todoStats.completed} - -**Bot** -• Status: ✅ Online -• DSGVO: ✅ Alle Daten lokal`; - } -} diff --git a/services/matrix-mana-bot/src/handlers/morning.handler.ts b/services/matrix-mana-bot/src/handlers/morning.handler.ts deleted file mode 100644 index 75f2a2ff5..000000000 --- a/services/matrix-mana-bot/src/handlers/morning.handler.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - SessionService, - MorningSummaryService, - MorningPreferencesService, -} from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; - -/** - * Morning Handler - * - * Handles morning summary commands including configuration and instant summaries. - * - * Commands: - * - !morning / !morgen - Get summary now - * - !morning-on - Enable automatic summary - * - !morning-off - Disable automatic summary - * - !morning-time HH:MM - Set delivery time - * - !morning-location [City] - Set weather location - * - !morning-settings - Show current settings - */ -@Injectable() -export class MorningHandler { - private readonly logger = new Logger(MorningHandler.name); - - constructor( - private sessionService: SessionService, - private morningSummaryService: MorningSummaryService, - private preferencesService: MorningPreferencesService - ) {} - - /** - * Get morning summary now - */ - async getSummary(ctx: CommandContext): Promise { - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - try { - const prefs = await this.preferencesService.getPreferences(ctx.userId); - const summary = await this.morningSummaryService.generateSummary(ctx.userId, token); - return this.morningSummaryService.formatSummary(summary, prefs.format); - } catch (error) { - this.logger.error(`Failed to generate summary for ${ctx.userId}:`, error); - return '❌ Fehler beim Erstellen der Zusammenfassung.'; - } - } - - /** - * Enable automatic morning summary - */ - async enable(ctx: CommandContext): Promise { - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - try { - const prefs = await this.preferencesService.setEnabled(ctx.userId, true); - this.logger.log(`Morning summary enabled for ${ctx.userId}`); - - let response = `✅ Morgenzusammenfassung aktiviert!\n\n`; - response += `Du erhaeltst taeglich um **${prefs.deliveryTime}** (${prefs.timezone}) deine Zusammenfassung.`; - - if (!prefs.location) { - response += `\n\n💡 Tipp: Setze deinen Wetter-Ort mit \`!morning-location Berlin\``; - } - - return response; - } catch (error) { - this.logger.error(`Failed to enable morning summary for ${ctx.userId}:`, error); - return '❌ Fehler beim Aktivieren der Morgenzusammenfassung.'; - } - } - - /** - * Disable automatic morning summary - */ - async disable(ctx: CommandContext): Promise { - // Require login for persistent storage - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - try { - await this.preferencesService.setEnabled(ctx.userId, false); - this.logger.log(`Morning summary disabled for ${ctx.userId}`); - return '✅ Morgenzusammenfassung deaktiviert.'; - } catch (error) { - this.logger.error(`Failed to disable morning summary for ${ctx.userId}:`, error); - return '❌ Fehler beim Deaktivieren der Morgenzusammenfassung.'; - } - } - - /** - * Set delivery time - */ - async setTime(ctx: CommandContext, args: string): Promise { - // Require login for persistent storage - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - const time = args.trim(); - - if (!time) { - return '❌ Bitte gib eine Uhrzeit an.\n\nBeispiel: `!morning-time 07:30`'; - } - - try { - const prefs = await this.preferencesService.setDeliveryTime(ctx.userId, time); - this.logger.log(`Morning delivery time set to ${prefs.deliveryTime} for ${ctx.userId}`); - return `✅ Uhrzeit auf **${prefs.deliveryTime}** gesetzt (${prefs.timezone}).`; - } catch (error) { - if (error instanceof Error) { - return `❌ ${error.message}`; - } - return '❌ Fehler beim Setzen der Uhrzeit. Verwende das Format HH:MM (z.B. 07:00).'; - } - } - - /** - * Set weather location - */ - async setLocation(ctx: CommandContext, args: string): Promise { - // Require login for persistent storage - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - const location = args.trim(); - - if (!location) { - // Show current location - const prefs = await this.preferencesService.getPreferences(ctx.userId); - if (prefs.location) { - return `🌍 Aktueller Wetter-Ort: **${prefs.location}**\n\nAendern mit: \`!morning-location [Stadt]\``; - } - return '🌍 Kein Wetter-Ort gesetzt.\n\nSetze mit: `!morning-location Berlin`'; - } - - try { - const prefs = await this.preferencesService.setLocation(ctx.userId, location); - this.logger.log(`Morning location set to ${location} for ${ctx.userId}`); - return `✅ Wetter-Ort auf **${prefs.location}** gesetzt.`; - } catch (error) { - this.logger.error(`Failed to set location for ${ctx.userId}:`, error); - return '❌ Fehler beim Setzen des Wetter-Orts.'; - } - } - - /** - * Set timezone - */ - async setTimezone(ctx: CommandContext, args: string): Promise { - // Require login for persistent storage - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - const timezone = args.trim(); - - if (!timezone) { - const prefs = await this.preferencesService.getPreferences(ctx.userId); - return `🕐 Aktuelle Zeitzone: **${prefs.timezone}**\n\nAendern mit: \`!morning-timezone Europe/Berlin\``; - } - - try { - const prefs = await this.preferencesService.setTimezone(ctx.userId, timezone); - this.logger.log(`Morning timezone set to ${timezone} for ${ctx.userId}`); - return `✅ Zeitzone auf **${prefs.timezone}** gesetzt.`; - } catch (error) { - if (error instanceof Error) { - return `❌ ${error.message}`; - } - return '❌ Ungueltige Zeitzone. Verwende IANA Format (z.B. Europe/Berlin).'; - } - } - - /** - * Set summary format - */ - async setFormat(ctx: CommandContext, args: string): Promise { - // Require login for persistent storage - const token = await this.sessionService.getToken(ctx.userId); - if (!token) { - return this.requireLogin(); - } - - const format = args.trim().toLowerCase(); - - if ( - format !== 'compact' && - format !== 'detailed' && - format !== 'kompakt' && - format !== 'ausfuehrlich' - ) { - const prefs = await this.preferencesService.getPreferences(ctx.userId); - const currentFormat = prefs.format === 'compact' ? 'Kompakt' : 'Ausfuehrlich'; - return `📋 Aktuelles Format: **${currentFormat}**\n\nAendern mit: \`!morning-format kompakt\` oder \`!morning-format ausfuehrlich\``; - } - - try { - const newFormat = format === 'compact' || format === 'kompakt' ? 'compact' : 'detailed'; - const prefs = await this.preferencesService.setFormat(ctx.userId, newFormat); - const formatName = prefs.format === 'compact' ? 'Kompakt' : 'Ausfuehrlich'; - this.logger.log(`Morning format set to ${prefs.format} for ${ctx.userId}`); - return `✅ Format auf **${formatName}** gesetzt.`; - } catch (error) { - this.logger.error(`Failed to set format for ${ctx.userId}:`, error); - return '❌ Fehler beim Setzen des Formats.'; - } - } - - /** - * Show current settings - */ - async showSettings(ctx: CommandContext): Promise { - try { - const prefs = await this.preferencesService.getPreferences(ctx.userId); - return this.preferencesService.formatPreferences(prefs); - } catch (error) { - this.logger.error(`Failed to get settings for ${ctx.userId}:`, error); - return '❌ Fehler beim Laden der Einstellungen.'; - } - } - - /** - * Show help for morning commands - */ - showHelp(): string { - return `**Morgenzusammenfassung Befehle** ☀️ - -\`!morning\` / \`!morgen\` - Zusammenfassung jetzt abrufen -\`!morning-on\` - Automatische Zusammenfassung aktivieren -\`!morning-off\` - Automatische Zusammenfassung deaktivieren -\`!morning-time HH:MM\` - Sendezeit einstellen (z.B. 07:30) -\`!morning-location [Stadt]\` - Wetter-Standort setzen -\`!morning-timezone [Zone]\` - Zeitzone setzen (z.B. Europe/Berlin) -\`!morning-format [kompakt|ausfuehrlich]\` - Format waehlen -\`!morning-settings\` - Aktuelle Einstellungen anzeigen`; - } - - private requireLogin(): string { - return '❌ Du musst angemeldet sein, um die Morgenzusammenfassung zu nutzen.\n\nMelde dich an mit: `!login deine@email.de passwort`'; - } -} diff --git a/services/matrix-mana-bot/src/handlers/todo.handler.ts b/services/matrix-mana-bot/src/handlers/todo.handler.ts deleted file mode 100644 index a30ed4f28..000000000 --- a/services/matrix-mana-bot/src/handlers/todo.handler.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { TodoService, Task } from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; - -@Injectable() -export class TodoHandler { - private readonly logger = new Logger(TodoHandler.name); - - constructor(private todoService: TodoService) {} - - async create(ctx: CommandContext, input: string): Promise { - if (!input.trim()) { - return '❌ Bitte gib eine Aufgabe an.\n\nBeispiel: `!todo Einkaufen gehen`'; - } - - const parsed = this.todoService.parseTaskInput(input); - const task = await this.todoService.createTask(ctx.userId, parsed); - - let response = `✅ Aufgabe erstellt: **${task.title}**`; - - const details: string[] = []; - if (parsed.priority < 4) details.push(`Priorität ${parsed.priority}`); - if (parsed.dueDate) details.push(`Datum: ${this.formatDate(parsed.dueDate)}`); - if (parsed.project) details.push(`Projekt: ${parsed.project}`); - - if (details.length > 0) { - response += `\n📋 ${details.join(' | ')}`; - } - - this.logger.log(`Created task "${task.title}" for ${ctx.userId}`); - return response; - } - - async list(ctx: CommandContext): Promise { - const tasks = await this.todoService.getAllPendingTasks(ctx.userId); - - if (tasks.length === 0) { - return '📭 Keine offenen Aufgaben.\n\nErstelle eine mit `!todo [Aufgabe]`'; - } - - return this.formatTaskList('📋 **Alle offenen Aufgaben:**', tasks); - } - - async today(ctx: CommandContext): Promise { - const tasks = await this.todoService.getTodayTasks(ctx.userId); - - if (tasks.length === 0) { - return '📭 Keine Aufgaben für heute.\n\nErstelle eine mit `!todo Aufgabe @heute`'; - } - - return this.formatTaskList('📅 **Aufgaben für heute:**', tasks); - } - - async inbox(ctx: CommandContext): Promise { - const tasks = await this.todoService.getInboxTasks(ctx.userId); - - if (tasks.length === 0) { - return '📭 Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'; - } - - return this.formatTaskList('📥 **Inbox (ohne Datum):**', tasks); - } - - async complete(ctx: CommandContext, args: string): Promise { - const taskNumber = parseInt(args.trim()); - - if (isNaN(taskNumber) || taskNumber < 1) { - return '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!done 1`'; - } - - const task = await this.todoService.completeTaskByIndex(ctx.userId, taskNumber); - - if (!task) { - return `❌ Aufgabe #${taskNumber} nicht gefunden.`; - } - - this.logger.log(`Completed task "${task.title}" for ${ctx.userId}`); - return `✅ Erledigt: ~~${task.title}~~`; - } - - async delete(ctx: CommandContext, args: string): Promise { - const taskNumber = parseInt(args.trim()); - - if (isNaN(taskNumber) || taskNumber < 1) { - return '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`'; - } - - const task = await this.todoService.deleteTaskByIndex(ctx.userId, taskNumber); - - if (!task) { - return `❌ Aufgabe #${taskNumber} nicht gefunden.`; - } - - this.logger.log(`Deleted task "${task.title}" for ${ctx.userId}`); - return `🗑️ Gelöscht: ${task.title}`; - } - - async projects(ctx: CommandContext): Promise { - const projectList = await this.todoService.getProjects(ctx.userId); - - if (projectList.length === 0) { - return '📭 Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!todo Aufgabe #projektname`'; - } - - let response = '📁 **Deine Projekte:**\n\n'; - for (const project of projectList) { - response += `• ${project.name}\n`; - } - response += '\nZeige Projektaufgaben mit `!project [Name]`'; - - return response; - } - - private formatTaskList(header: string, tasks: Task[]): string { - let response = `${header}\n\n`; - - tasks.forEach((task, index) => { - const num = index + 1; - const priority = task.priority < 4 ? `❗`.repeat(4 - task.priority) : ''; - const date = task.dueDate ? ` 📅 ${this.formatDate(task.dueDate)}` : ''; - const project = task.project ? ` 📁 ${task.project}` : ''; - - response += `**${num}.** ${task.title}${priority}${date}${project}\n`; - }); - - response += `\n✅ Erledigen: \`!done [Nr]\` | 🗑️ Löschen: \`!delete [Nr]\``; - return response; - } - - private formatDate(dateStr: string): string { - const date = new Date(dateStr); - const today = new Date(); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - if (dateStr === today.toISOString().split('T')[0]) { - return 'Heute'; - } else if (dateStr === tomorrow.toISOString().split('T')[0]) { - return 'Morgen'; - } - - return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); - } -} diff --git a/services/matrix-mana-bot/src/handlers/voice.handler.ts b/services/matrix-mana-bot/src/handlers/voice.handler.ts deleted file mode 100644 index 0a639801c..000000000 --- a/services/matrix-mana-bot/src/handlers/voice.handler.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { VoiceService } from '../voice/voice.service'; -import { CommandContext } from '../bot/command-router.service'; - -@Injectable() -export class VoiceHandler { - constructor(private voiceService: VoiceService) {} - - /** - * Show voice settings or toggle voice on/off - */ - async voiceSettings(ctx: CommandContext, args: string): Promise { - const arg = args.trim().toLowerCase(); - const prefs = this.voiceService.getUserPreferences(ctx.userId); - - // Toggle voice on/off - if (arg === 'an' || arg === 'on' || arg === 'ein') { - this.voiceService.setVoiceEnabled(ctx.userId, true); - return '🔊 Sprachantworten aktiviert.'; - } - - if (arg === 'aus' || arg === 'off') { - this.voiceService.setVoiceEnabled(ctx.userId, false); - return '🔇 Sprachantworten deaktiviert.'; - } - - // Toggle auto-reply - if (arg === 'auto an' || arg === 'auto on') { - this.voiceService.setAutoVoiceReply(ctx.userId, true); - return '🔊 Auto-Sprachantwort aktiviert (bei Sprachnachrichten).'; - } - - if (arg === 'auto aus' || arg === 'auto off') { - this.voiceService.setAutoVoiceReply(ctx.userId, false); - return '🔇 Auto-Sprachantwort deaktiviert.'; - } - - // Show current settings - const status = prefs.voiceEnabled ? '✅ Aktiviert' : '❌ Deaktiviert'; - const autoReply = prefs.autoVoiceReply ? '✅ Aktiviert' : '❌ Deaktiviert'; - - return `**🎤 Voice-Einstellungen** - -**Status:** ${status} -**Auto-Antwort:** ${autoReply} -**Stimme:** ${prefs.voice} -**Geschwindigkeit:** ${prefs.speed}x - -**Befehle:** -• \`!voice an\` / \`!voice aus\` - Aktivieren/Deaktivieren -• \`!voice auto an/aus\` - Auto-Antwort bei Sprachnachrichten -• \`!stimme [name]\` - Stimme wählen -• \`!stimmen\` - Verfügbare Stimmen anzeigen -• \`!speed [0.5-2.0]\` - Geschwindigkeit ändern`; - } - - /** - * List available TTS voices - */ - async listVoices(ctx: CommandContext): Promise { - const voices = await this.voiceService.getVoices(); - const prefs = this.voiceService.getUserPreferences(ctx.userId); - - if (Object.keys(voices).length === 0) { - return '❌ Keine Stimmen verfügbar. Voice Service nicht erreichbar.'; - } - - const voiceList = Object.entries(voices) - .map(([id, desc]) => { - const current = id === prefs.voice ? ' ✓' : ''; - return `• **${id}**${current}\n ${desc}`; - }) - .join('\n'); - - return `**🗣️ Verfügbare Stimmen** - -${voiceList} - -*Wähle mit \`!stimme [name]\`*`; - } - - /** - * Set TTS voice - */ - async setVoice(ctx: CommandContext, args: string): Promise { - const voiceName = args.trim(); - - if (!voiceName) { - return '❌ Bitte gib einen Stimmnamen an. Siehe `!stimmen` für verfügbare Stimmen.'; - } - - const voices = await this.voiceService.getVoices(); - - // Check if voice exists - if (!voices[voiceName]) { - // Try partial match - const matches = Object.keys(voices).filter((v) => - v.toLowerCase().includes(voiceName.toLowerCase()) - ); - - if (matches.length === 1) { - this.voiceService.setVoice(ctx.userId, matches[0]); - return `✅ Stimme geändert zu **${matches[0]}**`; - } - - if (matches.length > 1) { - return `❌ Mehrere Treffer: ${matches.join(', ')}\nBitte genauer angeben.`; - } - - return `❌ Stimme "${voiceName}" nicht gefunden. Siehe \`!stimmen\` für verfügbare Stimmen.`; - } - - this.voiceService.setVoice(ctx.userId, voiceName); - return `✅ Stimme geändert zu **${voiceName}**`; - } - - /** - * Set speech speed - */ - async setSpeed(ctx: CommandContext, args: string): Promise { - const speedStr = args.trim(); - - if (!speedStr) { - const prefs = this.voiceService.getUserPreferences(ctx.userId); - return `Aktuelle Geschwindigkeit: **${prefs.speed}x**\n\nNutze \`!speed [0.5-2.0]\` zum Ändern.\n• 0.5 = langsam\n• 1.0 = normal\n• 1.5 = schnell\n• 2.0 = sehr schnell`; - } - - const speed = parseFloat(speedStr); - - if (isNaN(speed)) { - return '❌ Bitte gib eine Zahl zwischen 0.5 und 2.0 an.'; - } - - if (speed < 0.5 || speed > 2.0) { - return '❌ Die Geschwindigkeit muss zwischen 0.5 und 2.0 liegen.'; - } - - this.voiceService.setSpeed(ctx.userId, speed); - return `✅ Geschwindigkeit geändert zu **${speed}x**`; - } - - /** - * Check voice service health - */ - async checkHealth(): Promise<{ stt: boolean; tts: boolean }> { - return this.voiceService.checkHealth(); - } -} diff --git a/services/matrix-mana-bot/src/main.ts b/services/matrix-mana-bot/src/main.ts deleted file mode 100644 index a9cd87374..000000000 --- a/services/matrix-mana-bot/src/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { ConfigService } from '@nestjs/config'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - const configService = app.get(ConfigService); - const port = configService.get('port', 3310); - - await app.listen(port); - logger.log(`Mana Gateway Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); diff --git a/services/matrix-mana-bot/src/orchestration/orchestration.module.ts b/services/matrix-mana-bot/src/orchestration/orchestration.module.ts deleted file mode 100644 index d44cdc4e1..000000000 --- a/services/matrix-mana-bot/src/orchestration/orchestration.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { OrchestrationService } from './orchestration.service'; -import { BotModule } from '../bot/bot.module'; - -@Module({ - imports: [forwardRef(() => BotModule)], - providers: [OrchestrationService], - exports: [OrchestrationService], -}) -export class OrchestrationModule {} diff --git a/services/matrix-mana-bot/src/orchestration/orchestration.service.ts b/services/matrix-mana-bot/src/orchestration/orchestration.service.ts deleted file mode 100644 index 4410c52af..000000000 --- a/services/matrix-mana-bot/src/orchestration/orchestration.service.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { AiService, TodoService, CalendarService } from '@manacore/bot-services'; -import { CommandContext } from '../bot/command-router.service'; - -@Injectable() -export class OrchestrationService { - private readonly logger = new Logger(OrchestrationService.name); - - constructor( - private aiService: AiService, - private todoService: TodoService, - private calendarService: CalendarService - ) {} - - /** - * !summary - AI-powered daily summary combining todos, calendar, etc. - */ - async dailySummary(ctx: CommandContext): Promise { - this.logger.log(`Generating daily summary for ${ctx.userId}`); - - // Gather data from all services in parallel - const [todoStats, todayTodos, todayEvents] = await Promise.all([ - this.todoService.getStats(ctx.userId), - this.todoService.getTodayTasks(ctx.userId), - this.calendarService.getTodayEvents(ctx.userId), - ]); - - // Build context for AI - const todoList = todayTodos.map((t) => t.title).join(', ') || 'keine'; - const eventList = todayEvents.map((e) => e.title).join(', ') || 'keine'; - - const prompt = `Du bist ein freundlicher Assistent. Erstelle eine kurze, motivierende Tages-Zusammenfassung auf Deutsch (max 5 Sätze). - -Daten für heute: -- Offene Todos: ${todoStats.pending} (davon heute fällig: ${todoStats.today}) -- Erledigte Todos: ${todoStats.completed} -- Heutige Todos: ${todoList} -- Heutige Termine: ${eventList} - -Fasse das freundlich und motivierend zusammen. Gib konkrete Tipps falls viele Aufgaben offen sind.`; - - try { - const summary = await this.aiService.chatSimple(ctx.userId, prompt); - - return `**📊 Deine Tages-Zusammenfassung** - -${summary} - ---- -*Generiert mit AI*`; - } catch (error) { - // Fallback without AI - return `**📊 Deine Tages-Übersicht** - -**Todos:** -• Offen: ${todoStats.pending} -• Heute fällig: ${todoStats.today} -• Erledigt: ${todoStats.completed} - -**Termine heute:** ${eventList} - ---- -*AI-Zusammenfassung nicht verfügbar*`; - } - } - - /** - * !ai-todo - AI extracts todos from text (meeting notes, etc.) - */ - async aiToTodos(ctx: CommandContext, text: string): Promise { - if (!text.trim()) { - return `**Verwendung:** \`!ai-todo [Text]\` - -**Beispiel:** -\`!ai-todo Im Meeting haben wir besprochen: Website redesign bis Freitag, API Dokumentation aktualisieren, und Peter soll das Budget prüfen.\` - -Die AI extrahiert automatisch Aufgaben und erstellt Todos.`; - } - - this.logger.log(`Extracting todos from text for ${ctx.userId}`); - - const prompt = `Extrahiere alle Aufgaben aus folgendem Text. -Antworte NUR mit einem JSON-Array im Format: -[{"text": "Aufgabentext", "priority": 1-4}] - -Prioritäten: -1 = Dringend/Wichtig -2 = Wichtig -3 = Normal -4 = Niedrig - -Text: ${text}`; - - try { - const response = await this.aiService.chatSimple(ctx.userId, prompt); - - // Parse JSON from response - const jsonMatch = response.match(/\[[\s\S]*?\]/); - if (!jsonMatch) { - return '❌ Konnte keine Aufgaben extrahieren. Versuche es mit klarerem Text.'; - } - - const todos = JSON.parse(jsonMatch[0]) as { text: string; priority?: number }[]; - - if (todos.length === 0) { - return '❌ Keine Aufgaben im Text gefunden.'; - } - - // Create todos - const created: string[] = []; - for (const todo of todos) { - const task = await this.todoService.createTask(ctx.userId, { - title: todo.text, - priority: todo.priority || 4, - }); - created.push(task.title); - } - - this.logger.log(`Created ${created.length} todos from AI extraction for ${ctx.userId}`); - - const lines = created.map((t, i) => `${i + 1}. ${t}`).join('\n'); - return `✅ **${created.length} Todos erstellt:** - -${lines} - -Zeige alle mit \`!list\``; - } catch (error) { - this.logger.error(`AI todo extraction failed:`, error); - return `❌ Fehler bei der Extraktion: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`; - } - } - - /** - * Create a todo with a calendar reminder - */ - async todoWithReminder(ctx: CommandContext, input: string): Promise { - // Parse: "Aufgabe @morgen 14:00" - const parsed = this.todoService.parseTaskInput(input); - - // Create todo - const task = await this.todoService.createTask(ctx.userId, parsed); - - // If date was specified, create calendar event as reminder - if (parsed.dueDate) { - await this.calendarService.createEvent(ctx.userId, { - title: `📋 Todo: ${task.title}`, - startTime: new Date(parsed.dueDate), - isAllDay: true, - }); - } - - let response = `✅ Todo erstellt: **${task.title}**`; - if (parsed.dueDate) { - response += `\n📅 Erinnerung im Kalender eingetragen`; - } - - return response; - } -} diff --git a/services/matrix-mana-bot/src/scheduler/morning-summary.scheduler.ts b/services/matrix-mana-bot/src/scheduler/morning-summary.scheduler.ts deleted file mode 100644 index 5ea4ce075..000000000 --- a/services/matrix-mana-bot/src/scheduler/morning-summary.scheduler.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { - SessionService, - MorningSummaryService, - MorningPreferencesService, -} from '@manacore/bot-services'; -import { MatrixService } from '../bot/matrix.service'; - -/** - * Morning Summary Scheduler - * - * Runs every minute and checks if any users should receive their morning summary. - * Matches the current time against each user's configured delivery time and timezone. - */ -@Injectable() -export class MorningSummaryScheduler implements OnModuleInit { - private readonly logger = new Logger(MorningSummaryScheduler.name); - - // Track which users have received their summary today to avoid duplicates - private deliveredToday: Map = new Map(); // userId -> date - - constructor( - private sessionService: SessionService, - private morningSummaryService: MorningSummaryService, - private preferencesService: MorningPreferencesService, - private matrixService: MatrixService - ) {} - - onModuleInit() { - this.logger.log('Morning Summary Scheduler initialized'); - } - - /** - * Check delivery times every minute - */ - @Cron(CronExpression.EVERY_MINUTE) - async checkDeliveryTimes() { - const now = new Date(); - const todayStr = now.toISOString().split('T')[0]; - - // Get all active users with sessions - const activeUsers = this.sessionService.getActiveUserIds(); - - for (const userId of activeUsers) { - // Skip if already delivered today - if (this.deliveredToday.get(userId) === todayStr) { - continue; - } - - try { - const prefs = await this.preferencesService.getPreferences(userId); - - // Skip if not enabled - if (!prefs.enabled) { - continue; - } - - // Check if it's time to deliver - if (this.preferencesService.shouldDeliverNow(prefs, now)) { - await this.deliverSummary(userId); - this.deliveredToday.set(userId, todayStr); - } - } catch (error) { - this.logger.error(`Error checking delivery for ${userId}:`, error); - } - } - } - - /** - * Clean up delivered tracking at midnight (UTC) - */ - @Cron('0 0 * * *', { timeZone: 'UTC' }) - cleanupDeliveredTracking() { - this.deliveredToday.clear(); - this.logger.debug('Cleared delivered tracking for new day'); - } - - /** - * Deliver morning summary to a user - */ - private async deliverSummary(matrixUserId: string): Promise { - const token = await this.sessionService.getToken(matrixUserId); - if (!token) { - this.logger.warn(`Cannot deliver summary to ${matrixUserId}: no token`); - return; - } - - try { - const prefs = await this.preferencesService.getPreferences(matrixUserId); - const summary = await this.morningSummaryService.generateSummary(matrixUserId, token); - const formatted = this.morningSummaryService.formatSummary(summary, prefs.format); - - // Send via Matrix - await this.matrixService.sendDirectMessage(matrixUserId, formatted); - - this.logger.log(`Delivered morning summary to ${matrixUserId}`); - } catch (error) { - this.logger.error(`Failed to deliver summary to ${matrixUserId}:`, error); - } - } -} diff --git a/services/matrix-mana-bot/src/scheduler/scheduler.module.ts b/services/matrix-mana-bot/src/scheduler/scheduler.module.ts deleted file mode 100644 index e0cb19650..000000000 --- a/services/matrix-mana-bot/src/scheduler/scheduler.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; -import { MorningSummaryScheduler } from './morning-summary.scheduler'; -import { BotModule } from '../bot/bot.module'; -import { SessionModule } from '@manacore/bot-services'; - -/** - * Scheduler Module - * - * Provides scheduled tasks for the bot including: - * - Morning summary delivery - * - Future: Reminder notifications, recurring tasks, etc. - */ -@Module({ - imports: [ScheduleModule.forRoot(), forwardRef(() => BotModule), SessionModule.forRoot()], - providers: [MorningSummaryScheduler], - exports: [MorningSummaryScheduler], -}) -export class SchedulerModule {} diff --git a/services/matrix-mana-bot/src/voice/voice-formatter.service.ts b/services/matrix-mana-bot/src/voice/voice-formatter.service.ts deleted file mode 100644 index e0eb802a3..000000000 --- a/services/matrix-mana-bot/src/voice/voice-formatter.service.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -/** - * Formats text responses for natural German speech synthesis. - * Converts markdown, numbers, times, lists etc. to spoken language. - */ -@Injectable() -export class VoiceFormatterService { - private readonly MAX_AUDIO_CHARS = 800; - private readonly MAX_LIST_ITEMS = 3; - - /** - * Main entry point - formats text for TTS - */ - format(text: string): string { - if (!text || text.trim().length === 0) { - return ''; - } - - let result = text; - - // Remove code blocks first (they shouldn't be read) - result = this.removeCodeBlocks(result); - - // Handle lists before other formatting - result = this.formatLists(result); - - // Remove markdown formatting - result = this.removeMarkdown(result); - - // Convert task metadata (!p1, @heute, #projekt) - result = this.formatTaskMetadata(result); - - // Convert times to German speech - result = this.formatTimes(result); - - // Convert dates to German speech - result = this.formatDates(result); - - // Convert numbers to words for small numbers - result = this.formatNumbers(result); - - // Remove emojis - result = this.removeEmojis(result); - - // Remove URLs - result = this.removeUrls(result); - - // Clean up whitespace and punctuation - result = this.cleanupText(result); - - // Truncate if too long - result = this.truncateIfNeeded(result); - - return result.trim(); - } - - /** - * Format for confirmations (short, friendly) - */ - formatConfirmation(action: string, item: string): string { - return `Erledigt. ${action} ${item}.`; - } - - /** - * Format for errors (clear, helpful) - */ - formatError(message: string): string { - const cleanMessage = this.removeEmojis(message).trim(); - return `Es gab ein Problem: ${cleanMessage}`; - } - - /** - * Format for list summaries - */ - formatListSummary(items: string[], itemType: string): string { - const count = items.length; - - if (count === 0) { - return `Du hast keine ${itemType}.`; - } - - if (count === 1) { - return `Du hast eine ${itemType.replace(/n$/, '')}: ${items[0]}.`; - } - - if (count <= this.MAX_LIST_ITEMS) { - const lastItem = items[items.length - 1]; - const otherItems = items.slice(0, -1).join(', '); - return `Du hast ${this.numberToWord(count)} ${itemType}: ${otherItems} und ${lastItem}.`; - } - - // Summarize long lists - const topItems = items.slice(0, this.MAX_LIST_ITEMS); - const remaining = count - this.MAX_LIST_ITEMS; - const topItemsText = topItems.join(', '); - return `Du hast ${this.numberToWord(count)} ${itemType}. Die wichtigsten: ${topItemsText}. Und ${this.numberToWord(remaining)} weitere.`; - } - - // --- Private helper methods --- - - private removeCodeBlocks(text: string): string { - // Remove fenced code blocks - let result = text.replace(/```[\s\S]*?```/g, ''); - // Remove inline code - result = result.replace(/`[^`]+`/g, ''); - return result; - } - - private formatLists(text: string): string { - // Find bullet point lists and format them - const bulletListRegex = /(?:^[•\-\*]\s+.+$\n?)+/gm; - let result = text.replace(bulletListRegex, (match) => { - const items = match - .split('\n') - .map((line) => line.replace(/^[•\-\*]\s+/, '').trim()) - .filter((line) => line.length > 0); - - if (items.length <= this.MAX_LIST_ITEMS) { - return items.join('. ') + '. '; - } - - // Summarize long lists - const topItems = items.slice(0, this.MAX_LIST_ITEMS); - const remaining = items.length - this.MAX_LIST_ITEMS; - return `${topItems.join('. ')}. Und ${this.numberToWord(remaining)} weitere. `; - }); - - // Format numbered lists - const numberedListRegex = /(?:^\d+\.\s+.+$\n?)+/gm; - result = result.replace(numberedListRegex, (match) => { - const items = match - .split('\n') - .map((line) => line.replace(/^\d+\.\s+/, '').trim()) - .filter((line) => line.length > 0); - - if (items.length <= this.MAX_LIST_ITEMS) { - return items.map((item, i) => `${this.ordinalWord(i + 1)}, ${item}`).join('. ') + '. '; - } - - const topItems = items.slice(0, this.MAX_LIST_ITEMS); - const remaining = items.length - this.MAX_LIST_ITEMS; - const formattedTop = topItems - .map((item, i) => `${this.ordinalWord(i + 1)}, ${item}`) - .join('. '); - return `${formattedTop}. Und ${this.numberToWord(remaining)} weitere. `; - }); - - return result; - } - - private removeMarkdown(text: string): string { - let result = text; - - // Bold - result = result.replace(/\*\*(.+?)\*\*/g, '$1'); - // Italic - result = result.replace(/\*(.+?)\*/g, '$1'); - // Strikethrough - result = result.replace(/~~(.+?)~~/g, '$1'); - // Headers - result = result.replace(/^#{1,6}\s*/gm, ''); - // Links [text](url) - result = result.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); - // Block quotes - result = result.replace(/^>\s*/gm, ''); - - return result; - } - - private formatTaskMetadata(text: string): string { - let result = text; - - // Priority: !p1, !p2, !p3, !p4 - result = result.replace(/!p1\b/gi, 'mit höchster Priorität'); - result = result.replace(/!p2\b/gi, 'mit hoher Priorität'); - result = result.replace(/!p3\b/gi, 'mit normaler Priorität'); - result = result.replace(/!p4\b/gi, 'mit niedriger Priorität'); - - // Due dates: @heute, @morgen, @übermorgen - result = result.replace(/@heute\b/gi, 'fällig heute'); - result = result.replace(/@morgen\b/gi, 'fällig morgen'); - result = result.replace(/@übermorgen\b/gi, 'fällig übermorgen'); - - // Projects: #projektname -> im Projekt "projektname" - result = result.replace(/#(\w+)/g, 'im Projekt $1'); - - return result; - } - - private formatTimes(text: string): string { - // Convert 24h time format to German speech - return text.replace(/(\d{1,2}):(\d{2})(?:\s*Uhr)?/g, (_, h, m) => { - const hour = parseInt(h); - const min = parseInt(m); - - if (min === 0) { - return `${this.numberToWord(hour)} Uhr`; - } else if (min === 30) { - return `halb ${this.numberToWord(hour + 1)}`; - } else if (min === 15) { - return `viertel nach ${this.numberToWord(hour)}`; - } else if (min === 45) { - return `viertel vor ${this.numberToWord(hour + 1)}`; - } - return `${this.numberToWord(hour)} Uhr ${this.numberToWord(min)}`; - }); - } - - private formatDates(text: string): string { - let result = text; - - // German date format: DD.MM. or DD.MM.YYYY - result = result.replace(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/g, (_, d, m, y) => { - const day = parseInt(d); - const month = parseInt(m); - const monthNames = [ - 'Januar', - 'Februar', - 'März', - 'April', - 'Mai', - 'Juni', - 'Juli', - 'August', - 'September', - 'Oktober', - 'November', - 'Dezember', - ]; - const monthName = monthNames[month - 1] || ''; - - if (y) { - return `${day}. ${monthName} ${y}`; - } - return `${day}. ${monthName}`; - }); - - return result; - } - - private formatNumbers(text: string): string { - // Only convert small standalone numbers (1-12) to words - // Larger numbers are fine as digits for speech synthesis - return text.replace(/\b(\d+)\b/g, (match, numStr) => { - const num = parseInt(numStr); - if (num >= 1 && num <= 12) { - return this.numberToWord(num); - } - return match; - }); - } - - private numberToWord(n: number): string { - const words = [ - 'null', - 'eins', - 'zwei', - 'drei', - 'vier', - 'fünf', - 'sechs', - 'sieben', - 'acht', - 'neun', - 'zehn', - 'elf', - 'zwölf', - 'dreizehn', - 'vierzehn', - 'fünfzehn', - 'sechzehn', - 'siebzehn', - 'achtzehn', - 'neunzehn', - 'zwanzig', - 'einundzwanzig', - 'zweiundzwanzig', - 'dreiundzwanzig', - 'vierundzwanzig', - ]; - - if (n >= 0 && n < words.length) { - return words[n]; - } - return n.toString(); - } - - private ordinalWord(n: number): string { - const ordinals = [ - '', - 'Erstens', - 'Zweitens', - 'Drittens', - 'Viertens', - 'Fünftens', - 'Sechstens', - 'Siebtens', - 'Achtens', - 'Neuntens', - 'Zehntens', - ]; - - if (n >= 1 && n < ordinals.length) { - return ordinals[n]; - } - return `${n}.`; - } - - private removeEmojis(text: string): string { - // Remove common emojis used in bot responses - return text.replace( - /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[✅❌⏱️📋📅🔮💡🎤🔊☀️💪🔔✓]/gu, - '' - ); - } - - private removeUrls(text: string): string { - return text.replace(/https?:\/\/[^\s]+/g, ''); - } - - private cleanupText(text: string): string { - let result = text; - - // Multiple newlines to single period - result = result.replace(/\n{2,}/g, '. '); - // Single newlines to space - result = result.replace(/\n/g, ' '); - // Multiple spaces to single - result = result.replace(/\s{2,}/g, ' '); - // Remove space before punctuation - result = result.replace(/\s+([.,!?;:])/g, '$1'); - // Remove duplicate punctuation - result = result.replace(/([.,!?;:])\s*([.,!?;:])/g, '$1'); - // Ensure space after punctuation - result = result.replace(/([.,!?;:])([A-Za-zÄÖÜäöüß])/g, '$1 $2'); - // Remove trailing/leading punctuation from text - result = result.replace(/^[.,!?;:\s]+/, ''); - result = result.replace(/[.,!?;:\s]+$/, ''); - - return result; - } - - private truncateIfNeeded(text: string): string { - if (text.length <= this.MAX_AUDIO_CHARS) { - return text; - } - - // Try to truncate at sentence boundary - const truncated = text.slice(0, this.MAX_AUDIO_CHARS); - const lastSentenceEnd = Math.max( - truncated.lastIndexOf('. '), - truncated.lastIndexOf('! '), - truncated.lastIndexOf('? ') - ); - - if (lastSentenceEnd > this.MAX_AUDIO_CHARS * 0.5) { - return truncated.slice(0, lastSentenceEnd + 1) + ' Und so weiter.'; - } - - // Fallback: truncate at word boundary - const lastSpace = truncated.lastIndexOf(' '); - if (lastSpace > 0) { - return truncated.slice(0, lastSpace) + '. Und so weiter.'; - } - - return truncated + '. Und so weiter.'; - } -} diff --git a/services/matrix-mana-bot/src/voice/voice-preferences.store.ts b/services/matrix-mana-bot/src/voice/voice-preferences.store.ts deleted file mode 100644 index b83c8b425..000000000 --- a/services/matrix-mana-bot/src/voice/voice-preferences.store.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as fs from 'fs'; -import * as path from 'path'; - -export interface VoicePreferences { - voiceEnabled: boolean; - voice: string; - speed: number; - autoVoiceReply: boolean; // Auto-enable voice replies when user sends voice -} - -interface StoredPreferences { - [userId: string]: VoicePreferences; -} - -@Injectable() -export class VoicePreferencesStore implements OnModuleInit { - private readonly logger = new Logger(VoicePreferencesStore.name); - private readonly storagePath: string; - private readonly defaultVoice: string; - private readonly defaultSpeed: number; - - private preferences: StoredPreferences = {}; - private saveTimeout: NodeJS.Timeout | null = null; - - constructor(private configService: ConfigService) { - this.storagePath = - this.configService.get('voice.preferencesPath') || './data/voice-preferences.json'; - this.defaultVoice = this.configService.get('voice.defaultVoice') || 'de-DE-ConradNeural'; - this.defaultSpeed = this.configService.get('voice.defaultSpeed') || 1.0; - } - - async onModuleInit() { - await this.load(); - } - - /** - * Get preferences for a user - */ - get(userId: string): VoicePreferences { - if (this.preferences[userId]) { - return { ...this.preferences[userId] }; - } - - return this.getDefaults(); - } - - /** - * Get default preferences - */ - getDefaults(): VoicePreferences { - return { - voiceEnabled: true, - voice: this.defaultVoice, - speed: this.defaultSpeed, - autoVoiceReply: true, - }; - } - - /** - * Update preferences for a user - */ - set(userId: string, updates: Partial): VoicePreferences { - const current = this.get(userId); - const updated = { ...current, ...updates }; - - // Validate speed range - if (updated.speed !== undefined) { - updated.speed = Math.max(0.5, Math.min(2.0, updated.speed)); - } - - this.preferences[userId] = updated; - this.scheduleSave(); - - return updated; - } - - /** - * Enable/disable voice responses - */ - setVoiceEnabled(userId: string, enabled: boolean): void { - this.set(userId, { voiceEnabled: enabled }); - } - - /** - * Set preferred voice - */ - setVoice(userId: string, voice: string): void { - this.set(userId, { voice }); - } - - /** - * Set speech speed - */ - setSpeed(userId: string, speed: number): void { - this.set(userId, { speed }); - } - - /** - * Set auto voice reply - */ - setAutoVoiceReply(userId: string, enabled: boolean): void { - this.set(userId, { autoVoiceReply: enabled }); - } - - /** - * Load preferences from disk - */ - private async load(): Promise { - try { - // Ensure directory exists - const dir = path.dirname(this.storagePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - if (fs.existsSync(this.storagePath)) { - const data = fs.readFileSync(this.storagePath, 'utf-8'); - this.preferences = JSON.parse(data); - this.logger.log(`Loaded voice preferences for ${Object.keys(this.preferences).length} users`); - } else { - this.preferences = {}; - this.logger.log('No voice preferences file found, starting fresh'); - } - } catch (error) { - this.logger.error(`Failed to load voice preferences: ${error}`); - this.preferences = {}; - } - } - - /** - * Save preferences to disk (debounced) - */ - private scheduleSave(): void { - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - } - - this.saveTimeout = setTimeout(() => { - this.save(); - }, 1000); - } - - /** - * Save preferences to disk immediately - */ - private save(): void { - try { - const dir = path.dirname(this.storagePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync(this.storagePath, JSON.stringify(this.preferences, null, 2)); - this.logger.debug(`Saved voice preferences for ${Object.keys(this.preferences).length} users`); - } catch (error) { - this.logger.error(`Failed to save voice preferences: ${error}`); - } - } -} diff --git a/services/matrix-mana-bot/src/voice/voice.module.ts b/services/matrix-mana-bot/src/voice/voice.module.ts deleted file mode 100644 index ff0d0ebb8..000000000 --- a/services/matrix-mana-bot/src/voice/voice.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { VoiceService } from './voice.service'; -import { VoiceFormatterService } from './voice-formatter.service'; -import { VoicePreferencesStore } from './voice-preferences.store'; - -@Module({ - providers: [VoicePreferencesStore, VoiceService, VoiceFormatterService], - exports: [VoiceService, VoiceFormatterService, VoicePreferencesStore], -}) -export class VoiceModule {} diff --git a/services/matrix-mana-bot/src/voice/voice.service.ts b/services/matrix-mana-bot/src/voice/voice.service.ts deleted file mode 100644 index 962de28ac..000000000 --- a/services/matrix-mana-bot/src/voice/voice.service.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { VoicePreferencesStore, VoicePreferences } from './voice-preferences.store'; - -export interface TranscriptionResult { - text: string; - language: string; - duration?: number; -} - -export class VoiceServiceError extends Error { - constructor( - message: string, - public readonly code: 'STT_UNAVAILABLE' | 'TTS_UNAVAILABLE' | 'TIMEOUT' | 'INVALID_AUDIO' | 'UNKNOWN' - ) { - super(message); - this.name = 'VoiceServiceError'; - } -} - -// Re-export for convenience -export { VoicePreferences }; - -// Simple LRU cache for TTS responses -interface CacheEntry { - buffer: Buffer; - timestamp: number; -} - -@Injectable() -export class VoiceService { - private readonly logger = new Logger(VoiceService.name); - private readonly sttUrl: string; - private readonly voiceBotUrl: string; - - // Timeouts in milliseconds - private readonly STT_TIMEOUT = 60000; // 60s for transcription (can be slow) - private readonly TTS_TIMEOUT = 30000; // 30s for synthesis - private readonly HEALTH_TIMEOUT = 5000; // 5s for health checks - - // Audio size limits - private readonly MIN_AUDIO_SIZE = 1000; // 1KB minimum - private readonly MAX_AUDIO_SIZE = 25 * 1024 * 1024; // 25MB maximum - - // TTS cache for common short responses - private readonly ttsCache = new Map(); - private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes - private readonly MAX_CACHE_SIZE = 50; - - constructor( - private configService: ConfigService, - private preferencesStore: VoicePreferencesStore - ) { - this.sttUrl = this.configService.get('voice.sttUrl') || 'http://localhost:3020'; - this.voiceBotUrl = this.configService.get('voice.voiceBotUrl') || 'http://localhost:3050'; - - this.logger.log(`Voice Service initialized`); - this.logger.log(`STT URL: ${this.sttUrl}`); - this.logger.log(`Voice Bot URL: ${this.voiceBotUrl}`); - } - - /** - * Transcribe audio to text using mana-stt (Whisper) - */ - async transcribe(audioBuffer: Buffer, language = 'de'): Promise { - const startTime = Date.now(); - - // Validate audio size - if (audioBuffer.length < this.MIN_AUDIO_SIZE) { - throw new VoiceServiceError( - 'Audio zu kurz - bitte länger sprechen.', - 'INVALID_AUDIO' - ); - } - - if (audioBuffer.length > this.MAX_AUDIO_SIZE) { - throw new VoiceServiceError( - 'Audio zu groß (max 25MB). Bitte kürzere Nachricht senden.', - 'INVALID_AUDIO' - ); - } - - try { - const formData = new FormData(); - // Convert Buffer to Uint8Array for Blob compatibility - const uint8Array = new Uint8Array(audioBuffer); - formData.append('file', new Blob([uint8Array]), 'audio.ogg'); - formData.append('language', language); - - const response = await fetch(`${this.sttUrl}/transcribe`, { - method: 'POST', - body: formData, - signal: AbortSignal.timeout(this.STT_TIMEOUT), - }); - - if (!response.ok) { - const error = await response.text(); - throw new VoiceServiceError( - `Spracherkennung fehlgeschlagen: ${response.status}`, - 'STT_UNAVAILABLE' - ); - } - - const result = await response.json(); - const duration = Date.now() - startTime; - - this.logger.debug(`Transcribed in ${duration}ms: "${result.text?.substring(0, 50)}..."`); - - return { - text: result.text || '', - language: result.language || language, - duration, - }; - } catch (error) { - if (error instanceof VoiceServiceError) { - throw error; - } - - if (error.name === 'TimeoutError' || error.name === 'AbortError') { - this.logger.error(`STT timeout after ${this.STT_TIMEOUT}ms`); - throw new VoiceServiceError( - 'Spracherkennung dauert zu lange. Bitte versuche es erneut.', - 'TIMEOUT' - ); - } - - this.logger.error(`Transcription failed: ${error}`); - throw new VoiceServiceError( - 'Spracherkennung nicht erreichbar.', - 'STT_UNAVAILABLE' - ); - } - } - - /** - * Synthesize speech from text using mana-voice-bot (Edge TTS) - * Includes caching for common short responses - */ - async synthesize(text: string, userId?: string): Promise { - const prefs = this.getUserPreferences(userId); - const startTime = Date.now(); - - // Check cache for short texts (< 100 chars) - if (text.length < 100) { - const cacheKey = `${prefs.voice}:${text}`; - const cached = this.getCached(cacheKey); - if (cached) { - this.logger.debug(`TTS cache hit for "${text.substring(0, 30)}..."`); - return cached; - } - } - - try { - const formData = new FormData(); - formData.append('text', text); - formData.append('voice', prefs.voice); - - const response = await fetch(`${this.voiceBotUrl}/tts`, { - method: 'POST', - body: formData, - signal: AbortSignal.timeout(this.TTS_TIMEOUT), - }); - - if (!response.ok) { - const error = await response.text(); - throw new VoiceServiceError( - `Sprachsynthese fehlgeschlagen: ${response.status}`, - 'TTS_UNAVAILABLE' - ); - } - - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - const duration = Date.now() - startTime; - - this.logger.debug(`Synthesized ${buffer.length} bytes in ${duration}ms`); - - // Cache short responses - if (text.length < 100) { - const cacheKey = `${prefs.voice}:${text}`; - this.setCache(cacheKey, buffer); - } - - return buffer; - } catch (error) { - if (error instanceof VoiceServiceError) { - throw error; - } - - if (error.name === 'TimeoutError' || error.name === 'AbortError') { - this.logger.error(`TTS timeout after ${this.TTS_TIMEOUT}ms`); - throw new VoiceServiceError( - 'Sprachsynthese dauert zu lange.', - 'TIMEOUT' - ); - } - - this.logger.error(`Synthesis failed: ${error}`); - throw new VoiceServiceError( - 'Sprachsynthese nicht erreichbar.', - 'TTS_UNAVAILABLE' - ); - } - } - - /** - * Get cached TTS response - */ - private getCached(key: string): Buffer | null { - const entry = this.ttsCache.get(key); - if (!entry) return null; - - // Check if expired - if (Date.now() - entry.timestamp > this.CACHE_TTL) { - this.ttsCache.delete(key); - return null; - } - - return entry.buffer; - } - - /** - * Cache TTS response - */ - private setCache(key: string, buffer: Buffer): void { - // Enforce max cache size - if (this.ttsCache.size >= this.MAX_CACHE_SIZE) { - // Remove oldest entry - const oldestKey = this.ttsCache.keys().next().value; - if (oldestKey) { - this.ttsCache.delete(oldestKey); - } - } - - this.ttsCache.set(key, { - buffer, - timestamp: Date.now(), - }); - } - - /** - * Get available TTS voices - */ - async getVoices(): Promise> { - try { - const response = await fetch(`${this.voiceBotUrl}/voices`, { - signal: AbortSignal.timeout(this.HEALTH_TIMEOUT), - }); - if (!response.ok) { - throw new Error(`Failed to get voices: ${response.status}`); - } - const data = await response.json(); - return data.voices || {}; - } catch (error) { - this.logger.error(`Failed to get voices: ${error}`); - return {}; - } - } - - /** - * Clear the TTS cache - */ - clearCache(): void { - this.ttsCache.clear(); - this.logger.debug('TTS cache cleared'); - } - - /** - * Get cache statistics - */ - getCacheStats(): { size: number; maxSize: number } { - return { - size: this.ttsCache.size, - maxSize: this.MAX_CACHE_SIZE, - }; - } - - /** - * Check if voice services are available - */ - async checkHealth(): Promise<{ stt: boolean; tts: boolean }> { - const results = { stt: false, tts: false }; - - try { - const sttResponse = await fetch(`${this.sttUrl}/health`, { - signal: AbortSignal.timeout(5000), - }); - results.stt = sttResponse.ok; - } catch { - results.stt = false; - } - - try { - const ttsResponse = await fetch(`${this.voiceBotUrl}/health`, { - signal: AbortSignal.timeout(5000), - }); - results.tts = ttsResponse.ok; - } catch { - results.tts = false; - } - - return results; - } - - /** - * Get user voice preferences (persistent) - */ - getUserPreferences(userId?: string): VoicePreferences { - if (!userId) { - return this.preferencesStore.getDefaults(); - } - return this.preferencesStore.get(userId); - } - - /** - * Update user voice preferences (persistent) - */ - setUserPreferences(userId: string, prefs: Partial): VoicePreferences { - return this.preferencesStore.set(userId, prefs); - } - - /** - * Enable/disable voice responses for user - */ - setVoiceEnabled(userId: string, enabled: boolean): void { - this.preferencesStore.setVoiceEnabled(userId, enabled); - } - - /** - * Set user's preferred voice - */ - setVoice(userId: string, voice: string): void { - this.preferencesStore.setVoice(userId, voice); - } - - /** - * Set user's preferred speed - */ - setSpeed(userId: string, speed: number): void { - this.preferencesStore.setSpeed(userId, speed); - } - - /** - * Set auto voice reply setting - */ - setAutoVoiceReply(userId: string, enabled: boolean): void { - this.preferencesStore.setAutoVoiceReply(userId, enabled); - } -} diff --git a/services/matrix-mana-bot/tsconfig.json b/services/matrix-mana-bot/tsconfig.json deleted file mode 100644 index 7e9adda7d..000000000 --- a/services/matrix-mana-bot/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/services/matrix-manadeck-bot/.env.example b/services/matrix-manadeck-bot/.env.example deleted file mode 100644 index f0db1cfcf..000000000 --- a/services/matrix-manadeck-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3321 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#manadeck:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# ManaDeck Backend -MANADECK_BACKEND_URL=http://localhost:3009 -MANADECK_API_PREFIX=/api - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-manadeck-bot/.gitignore b/services/matrix-manadeck-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-manadeck-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-manadeck-bot/CLAUDE.md b/services/matrix-manadeck-bot/CLAUDE.md deleted file mode 100644 index 17c763312..000000000 --- a/services/matrix-manadeck-bot/CLAUDE.md +++ /dev/null @@ -1,225 +0,0 @@ -# Matrix ManaDeck Bot - Claude Code Guidelines - -## Overview - -Matrix ManaDeck Bot provides card/deck management via Matrix chat. It integrates with the ManaDeck backend for full CRUD operations, AI deck generation, study sessions, and spaced repetition progress tracking. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: ManaDeck API (port 3009) -- **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-manadeck-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3321) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── manadeck/ -│ │ ├── manadeck.module.ts -│ │ └── manadeck.service.ts # ManaDeck Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -### Deck Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!decks` | liste | List all decks | -| `!deck [nr]` | details | Show deck details | -| `!neu Titel` | new, create | Create new deck (10 Mana) | -| `!loeschen [nr]` | delete | Delete deck | - -### Card Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!karten [nr]` | cards | List cards in deck | -| `!karte [deck-nr] [card-nr]` | card | Show card details | - -### AI Generation - -| Command | Options | Description | -|---------|---------|-------------| -| `!generate Thema` | generieren, gen | Generate deck with AI (30 Mana) | -| `--count N` | - | Number of cards (1-50) | -| `--type TYPE` | - | flashcard, quiz, text, mixed | -| `--difficulty LEVEL` | - | beginner, intermediate, advanced | - -### Learning & Progress - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!lernen [nr]` | study | Start study session | -| `!faellig` | due | Show due cards | -| `!stats` | statistik | Learning statistics | -| `!mana` | credits, guthaben | Show mana balance | - -### Public Features - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!featured` | empfohlen | Show featured decks | -| `!leaderboard` | rangliste | Show top 10 users | - -## Example Usage - -``` -# Login -!login max@example.com mypassword - -# Create a deck -!neu Spanisch Vokabeln | Grundwortschatz - -# Generate deck with AI -!generate Deutsche Geschichte --count 20 --type flashcard - -# List decks -!decks - -# View cards -!karten 1 - -# Start studying -!lernen 1 - -# Check due cards -!faellig - -# Check mana balance -!mana -``` - -## Card Types - -| Type | Content Structure | -|------|-------------------| -| `text` | `{ text, formatting? }` | -| `flashcard` | `{ front, back, hint? }` | -| `quiz` | `{ question, options[], correctAnswer, explanation? }` | -| `mixed` | `{ sections: Array }` | - -## Credit Costs (Mana) - -| Operation | Cost | -|-----------|------| -| Deck Creation | 10 Mana | -| Card Creation | 2 Mana | -| AI Card Generation | 5 Mana | -| AI Deck Generation | 30 Mana | -| Deck Export | 3 Mana | - -## Environment Variables - -```env -# Server -PORT=3321 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#manadeck:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# ManaDeck Backend -MANADECK_BACKEND_URL=http://localhost:3009 -MANADECK_API_PREFIX=/api - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-manadeck-bot/Dockerfile -t matrix-manadeck-bot services/matrix-manadeck-bot - -# Run -docker run -p 3321:3321 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e MANADECK_BACKEND_URL=http://manadeck-backend:3009 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-manadeck-bot-data:/app/data \ - matrix-manadeck-bot -``` - -## Health Check - -```bash -curl http://localhost:3321/health -``` - -## ManaDeck Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/public/health` | GET | Health check | -| `/public/featured-decks` | GET | Featured decks | -| `/public/leaderboard` | GET | Leaderboard | -| `/api/decks` | GET | List user's decks | -| `/api/decks` | POST | Create deck | -| `/api/decks/:id` | GET | Get deck details | -| `/api/decks/:id` | DELETE | Delete deck | -| `/api/decks/:id/cards` | GET | Get cards in deck | -| `/api/cards/:id` | GET | Get card details | -| `/api/decks/generate` | POST | AI generate deck | -| `/api/study-sessions` | POST | Start study session | -| `/api/progress/due` | GET | Get due cards | -| `/api/stats` | GET | Get user stats | -| `/api/credits/balance` | GET | Get mana balance | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!decks` to get a list of decks -2. Bot stores the list internally for the user -3. User can reference decks by their list number -4. Numbers are valid until the user runs a new list command - -Similarly for cards: -1. User runs `!karten [deck-nr]` to get cards -2. Cards can be referenced by `!karte [deck-nr] [card-nr]` - -This allows simple commands like: -- `!deck 3` - Show details for deck #3 -- `!karten 1` - Show cards in deck #1 -- `!karte 1 5` - Show card #5 in deck #1 -- `!lernen 2` - Start study session for deck #2 diff --git a/services/matrix-manadeck-bot/Dockerfile b/services/matrix-manadeck-bot/Dockerfile deleted file mode 100644 index e3ae29577..000000000 --- a/services/matrix-manadeck-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3321 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3321/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-manadeck-bot/nest-cli.json b/services/matrix-manadeck-bot/nest-cli.json deleted file mode 100644 index 68d1974c4..000000000 --- a/services/matrix-manadeck-bot/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/matrix-manadeck-bot/package.json b/services/matrix-manadeck-bot/package.json deleted file mode 100644 index e5a027418..000000000 --- a/services/matrix-manadeck-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-manadeck-bot", - "version": "1.0.0", - "description": "Matrix bot for ManaDeck card/deck management", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-manadeck-bot/src/app.module.ts b/services/matrix-manadeck-bot/src/app.module.ts deleted file mode 100644 index fa2d7e728..000000000 --- a/services/matrix-manadeck-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { ManadeckModule } from './manadeck/manadeck.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ManadeckModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-manadeck-bot')], -}) -export class AppModule {} diff --git a/services/matrix-manadeck-bot/src/bot/bot.module.ts b/services/matrix-manadeck-bot/src/bot/bot.module.ts deleted file mode 100644 index 6ac8c6e3f..000000000 --- a/services/matrix-manadeck-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { ManadeckModule } from '../manadeck/manadeck.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - ManadeckModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-manadeck-bot/src/bot/matrix.service.ts b/services/matrix-manadeck-bot/src/bot/matrix.service.ts deleted file mode 100644 index 164f8d57a..000000000 --- a/services/matrix-manadeck-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,656 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - UserListMapper, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { ManadeckService, Deck, Card } from '../manadeck/manadeck.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -const DECK_CREATE_CREDITS = 0.1; -const AI_DECK_GENERATE_CREDITS = 20; - -@Injectable() -export class MatrixService extends BaseMatrixService { - // Store last shown decks/cards per user for reference by number - private decksMapper = new UserListMapper(); - private cardsMapper = new UserListMapper(); - private currentDeckId: Map = new Map(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['decks', 'meine decks', 'kartendecks', 'liste'], command: 'decks' }, - { keywords: ['karten', 'cards', 'meine karten'], command: 'karten' }, - { keywords: ['lernen', 'study', 'ueben', 'wiederholen'], command: 'lernen' }, - { keywords: ['faellig', 'due', 'anstehend', 'zu lernen'], command: 'faellig' }, - { keywords: ['mana', 'credits', 'guthaben', 'punkte'], command: 'mana' }, - { keywords: ['stats', 'statistik', 'fortschritt', 'statistiken'], command: 'stats' }, - { keywords: ['generieren', 'generate', 'erstellen', 'ai'], command: 'generate' }, - { keywords: ['featured', 'empfohlen', 'beliebte decks'], command: 'featured' }, - { keywords: ['rangliste', 'leaderboard', 'bestenliste'], command: 'leaderboard' }, - ]); - - constructor( - configService: ConfigService, - private readonly transcriptionService: TranscriptionService, - private manadeckService: ManadeckService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendHtml(roomId, '

❌ Sprachnachricht konnte nicht erkannt werden.

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

🎤 "${text}"

`); - await this.handleTextMessage(roomId, event, text, sender); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendHtml(roomId, '

❌ Fehler bei der Spracherkennung.

'); - } - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - _event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(message); - if (keywordCommand) { - message = `!${keywordCommand}`; - } - - if (!message.startsWith('!')) return; - - const parts = message.slice(1).split(/\s+/); - const command = parts[0].toLowerCase(); - const args = parts.slice(1); - const argString = args.join(' '); - - try { - switch (command) { - case 'help': - case 'hilfe': - await this.sendHtml(roomId, HELP_MESSAGE); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - case 'decks': - case 'liste': - await this.handleListDecks(roomId, sender); - break; - - case 'deck': - case 'details': - await this.handleDeckDetails(roomId, sender, args[0]); - break; - - case 'neu': - case 'new': - case 'create': - await this.handleCreateDeck(roomId, sender, argString); - break; - - case 'loeschen': - case 'delete': - await this.handleDeleteDeck(roomId, sender, args[0]); - break; - - case 'karten': - case 'cards': - await this.handleListCards(roomId, sender, args[0]); - break; - - case 'karte': - case 'card': - await this.handleCardDetails(roomId, sender, args[0], args[1]); - break; - - case 'generate': - case 'gen': - case 'generieren': - await this.handleGenerate(roomId, sender, argString); - break; - - case 'lernen': - case 'study': - await this.handleStartStudy(roomId, sender, args[0]); - break; - - case 'faellig': - case 'due': - await this.handleDueCards(roomId, sender); - break; - - case 'stats': - case 'statistik': - await this.handleStats(roomId, sender); - break; - - case 'mana': - case 'credits': - case 'guthaben': - await this.handleCredits(roomId, sender); - break; - - case 'featured': - case 'empfohlen': - await this.handleFeatured(roomId); - break; - - case 'leaderboard': - case 'rangliste': - await this.handleLeaderboard(roomId); - break; - - default: - await this.sendHtml( - roomId, - `

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

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

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

`); - } - } - - private async sendHtml(roomId: string, html: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: html.replace(/<[^>]*>/g, ''), - format: 'org.matrix.custom.html', - formatted_body: html, - }); - } - - private async requireAuth(sender: string): Promise { - const token = await this.sessionService.getToken(sender); - if (!token) { - throw new Error(LOGIN_MESSAGES.manadeck); - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendOk = await this.manadeckService.checkHealth(); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const sessions = await this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusHtml = `

ManaDeck Bot Status

    `; - statusHtml += `
  • Backend: ${backendOk ? '✅ Online' : '❌ Offline'}
  • `; - statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; - statusHtml += `
  • ⚡ Credits: ${balance.balance.toFixed(2)}
  • `; - } else { - statusHtml += `
  • 👤 Nicht angemeldet
  • `; - statusHtml += `
  • 💡 Login: !login email passwort
  • `; - } - statusHtml += `
`; - - await this.sendHtml(roomId, statusHtml); - } - - // Deck handlers - private async handleListDecks(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.manadeckService.getDecks(token); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine Decks vorhanden. Erstelle eines mit !neu Titel

' - ); - return; - } - - let html = '

Deine Decks

    '; - for (const deck of decks) { - const cardInfo = deck.cardCount !== undefined ? ` (${deck.cardCount} Karten)` : ''; - const tags = deck.tags?.length ? ` [${deck.tags.join(', ')}]` : ''; - html += `
  1. ${deck.title}${cardInfo}${tags}
  2. `; - } - html += '
'; - html += '

Nutze !deck [nr] fuer Details

'; - - await this.sendHtml(roomId, html); - } - - private async handleDeckDetails(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const deck = this.getDeckByNumber(sender, numberStr); - - if (!deck) { - await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !decks

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

Fehler: ${result.error}

`); - return; - } - - const d = result.data!; - let html = `

${d.title}

`; - if (d.description) html += `

${d.description}

`; - html += '
    '; - html += `
  • Karten: ${d.cardCount || 0}
  • `; - html += `
  • Oeffentlich: ${d.isPublic ? 'Ja' : 'Nein'}
  • `; - if (d.tags?.length) html += `
  • Tags: ${d.tags.join(', ')}
  • `; - html += `
  • Erstellt: ${new Date(d.createdAt).toLocaleDateString('de-DE')}
  • `; - html += '
'; - html += `

Nutze !karten ${numberStr} um Karten zu sehen

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

Verwendung: !neu Titel [Beschreibung]

'); - return; - } - - const token = await this.requireAuth(sender); - const parts = title.split('|').map((s) => s.trim()); - const deckTitle = parts[0]; - const description = parts[1]; - - const result = await this.manadeckService.createDeck(token, deckTitle, description); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Deck ${result.data!.title} erstellt! (10 Mana verbraucht)

` - ); - } - - private async handleDeleteDeck(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const deck = this.getDeckByNumber(sender, numberStr); - - if (!deck) { - await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !decks

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

Fehler: ${result.error}

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

Deck ${deck.title} geloescht.

`); - } - - // Card handlers - private async handleListCards(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const deck = this.getDeckByNumber(sender, numberStr); - - if (!deck) { - await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !decks

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

Fehler: ${result.error}

`); - return; - } - - const cards = result.data || []; - this.cardsMapper.setList(sender, cards); - this.currentDeckId.set(sender, deck.id); - - if (cards.length === 0) { - await this.sendHtml(roomId, `

Keine Karten in ${deck.title}.

`); - return; - } - - let html = `

Karten in "${deck.title}"

    `; - for (const card of cards) { - const title = card.title || this.getCardPreview(card); - const fav = card.isFavorite ? ' ⭐' : ''; - html += `
  1. ${card.cardType}: ${title}${fav}
  2. `; - } - html += '
'; - html += `

Nutze !karte ${numberStr} [nr] fuer Details

`; - - await this.sendHtml(roomId, html); - } - - private async handleCardDetails( - roomId: string, - sender: string, - deckNumStr: string, - cardNumStr: string - ) { - const token = await this.requireAuth(sender); - const deck = this.decksMapper.getByNumber(sender, parseInt(deckNumStr, 10)); - - if (!deck) { - await this.sendHtml( - roomId, - '

Ungueltige Deck-Nummer. Nutze zuerst !decks

' - ); - return; - } - - // Refresh cards if needed - const cachedDeckId = this.currentDeckId.get(sender); - if (!cachedDeckId || cachedDeckId !== deck.id || !this.cardsMapper.hasList(sender)) { - const result = await this.manadeckService.getCards(token, deck.id); - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

`); - return; - } - this.cardsMapper.setList(sender, result.data || []); - this.currentDeckId.set(sender, deck.id); - } - - const cardNum = parseInt(cardNumStr, 10); - const card = this.cardsMapper.getByNumber(sender, cardNum); - if (!card) { - await this.sendHtml( - roomId, - `

Ungueltige Kartennummer. Nutze !karten ${deckNumStr}

` - ); - return; - } - let html = `

Karte #${cardNumStr}

`; - html += `

Typ: ${card.cardType}

`; - - switch (card.cardType) { - case 'flashcard': - html += `

Vorderseite: ${card.content.front}

`; - html += `

Rueckseite: ${card.content.back}

`; - if (card.content.hint) html += `

Hinweis: ${card.content.hint}

`; - break; - case 'quiz': - html += `

Frage: ${card.content.question}

`; - html += '

Optionen:

    '; - for (const opt of card.content.options || []) { - html += `
  1. ${opt}
  2. `; - } - html += '
'; - html += `

Richtig: Option ${(card.content.correctAnswer || 0) + 1}

`; - break; - case 'text': - html += `

${card.content.text}

`; - break; - default: - html += `
${JSON.stringify(card.content, null, 2)}
`; - } - - await this.sendHtml(roomId, html); - } - - // AI generation - private async handleGenerate(roomId: string, sender: string, argString: string) { - const token = await this.requireAuth(sender); - - // Parse options from argString - const options: any = {}; - let prompt = argString; - - // Extract --count N - const countMatch = prompt.match(/--count\s+(\d+)/i); - if (countMatch) { - options.cardCount = Math.min(50, Math.max(1, parseInt(countMatch[1], 10))); - prompt = prompt.replace(countMatch[0], '').trim(); - } - - // Extract --type TYPE - const typeMatch = prompt.match(/--type\s+(flashcard|quiz|text|mixed)/i); - if (typeMatch) { - options.cardTypes = [typeMatch[1].toLowerCase()]; - prompt = prompt.replace(typeMatch[0], '').trim(); - } - - // Extract --difficulty LEVEL - const diffMatch = prompt.match(/--difficulty\s+(beginner|intermediate|advanced)/i); - if (diffMatch) { - options.difficulty = diffMatch[1].toLowerCase(); - prompt = prompt.replace(diffMatch[0], '').trim(); - } - - if (!prompt) { - await this.sendHtml( - roomId, - '

Verwendung: !generate Thema [--count 10] [--type flashcard] [--difficulty intermediate]

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

Generiere Deck mit AI... (30 Mana)

'); - - const result = await this.manadeckService.generateDeck(token, prompt, options); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

`); - return; - } - - const { deck, cards } = result.data!; - await this.sendHtml( - roomId, - `

Deck ${deck.title} mit ${cards.length} Karten erstellt!

-

Nutze !decks um deine Decks zu sehen.

` - ); - } - - // Study - private async handleStartStudy(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const deck = this.getDeckByNumber(sender, numberStr); - - if (!deck) { - await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !decks

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

Fehler: ${result.error}

`); - return; - } - - const session = result.data!; - await this.sendHtml( - roomId, - `

Lernsession fuer ${deck.title} gestartet!

-

${session.totalCards} Karten zu lernen.

-

Oeffne die ManaDeck App um mit dem Lernen zu beginnen.

` - ); - } - - private async handleDueCards(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.manadeckService.getDueCards(token); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine faelligen Karten! Gut gemacht!

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

${dueCards.length} Karten sind faellig.

-

Oeffne die ManaDeck App um sie zu wiederholen.

` - ); - } - - // Stats - private async handleStats(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.manadeckService.getStats(token); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

`); - return; - } - - const stats = result.data!; - await this.sendHtml( - roomId, - `

Deine Statistiken

-
    -
  • Decks: ${stats.totalDecks || 0}
  • -
  • Karten: ${stats.totalCards || 0}
  • -
  • Sessions: ${stats.totalSessions || 0}
  • -
  • Streak: ${stats.streakDays || 0} Tage
  • -
  • Genauigkeit: ${stats.averageAccuracy?.toFixed(1) || 0}%
  • -
` - ); - } - - private async handleCredits(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.manadeckService.getCredits(token); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Dein Mana-Guthaben: ${result.data!.balance}

-

Kosten: Deck erstellen (10), AI-Generierung (30)

` - ); - } - - // Public endpoints - private async handleFeatured(roomId: string) { - const result = await this.manadeckService.getFeaturedDecks(); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine empfohlenen Decks verfuegbar.

'); - return; - } - - let html = '

Empfohlene Decks

    '; - for (const deck of decks) { - const cardInfo = deck.cardCount !== undefined ? ` (${deck.cardCount} Karten)` : ''; - html += `
  1. ${deck.title}${cardInfo}
  2. `; - } - html += '
'; - - await this.sendHtml(roomId, html); - } - - private async handleLeaderboard(roomId: string) { - const result = await this.manadeckService.getLeaderboard(10); - - if (result.error) { - await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Noch keine Eintraege in der Rangliste.

'); - return; - } - - let html = '

Rangliste (Top 10)

    '; - for (const user of users) { - html += `
  1. ${user.totalWins || 0} Siege - ${user.streakDays || 0} Tage Streak
  2. `; - } - html += '
'; - - await this.sendHtml(roomId, html); - } - - // Helper methods - private getDeckByNumber(sender: string, numberStr: string): Deck | null { - const num = parseInt(numberStr, 10); - if (isNaN(num)) return null; - return this.decksMapper.getByNumber(sender, num); - } - - private getCardPreview(card: Card): string { - if (card.content.front) return card.content.front.substring(0, 50); - if (card.content.question) return card.content.question.substring(0, 50); - if (card.content.text) return card.content.text.substring(0, 50); - return '(keine Vorschau)'; - } -} diff --git a/services/matrix-manadeck-bot/src/config/configuration.ts b/services/matrix-manadeck-bot/src/config/configuration.ts deleted file mode 100644 index c79d94bb3..000000000 --- a/services/matrix-manadeck-bot/src/config/configuration.ts +++ /dev/null @@ -1,56 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3321', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN, - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - manadeck: { - backendUrl: process.env.MANADECK_BACKEND_URL || 'http://localhost:3009', - apiPrefix: process.env.MANADECK_API_PREFIX || '/api', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `

ManaDeck Bot - Befehle

- -

Decks verwalten

-
    -
  • !decks - Alle Decks auflisten
  • -
  • !deck [nr] - Deck-Details anzeigen
  • -
  • !neu Titel - Neues Deck erstellen (10 Mana)
  • -
  • !loeschen [nr] - Deck loeschen
  • -
- -

Karten

-
    -
  • !karten [nr] - Karten eines Decks anzeigen
  • -
  • !karte [deck-nr] [karten-nr] - Kartendetails
  • -
- -

AI-Generierung

-
    -
  • !generate Thema - Deck mit AI generieren (30 Mana)
  • -
  • !generate Thema --count 10 - Mit Kartenanzahl
  • -
  • !generate Thema --type flashcard - Mit Kartentyp
  • -
- -

Lernen

-
    -
  • !lernen [nr] - Lernsession starten
  • -
  • !faellig - Faellige Karten anzeigen
  • -
  • !stats - Lernstatistiken
  • -
- -

Weiteres

-
    -
  • !mana - Mana-Guthaben anzeigen
  • -
  • !featured - Empfohlene Decks
  • -
  • !leaderboard - Rangliste
  • -
  • !help - Diese Hilfe anzeigen
  • -
- -

Tipp: Nutze Deck-/Kartennummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-manadeck-bot/src/main.ts b/services/matrix-manadeck-bot/src/main.ts deleted file mode 100644 index 782ee2ded..000000000 --- a/services/matrix-manadeck-bot/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3321; - await app.listen(port); - console.log(`Matrix ManaDeck Bot running on port ${port}`); -} -bootstrap(); diff --git a/services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts b/services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts deleted file mode 100644 index c97b32cac..000000000 --- a/services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ManadeckService } from './manadeck.service'; - -@Module({ - providers: [ManadeckService], - exports: [ManadeckService], -}) -export class ManadeckModule {} diff --git a/services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts b/services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts deleted file mode 100644 index 9a17ca86c..000000000 --- a/services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface Deck { - id: string; - title: string; - description?: string; - coverImageUrl?: string; - isPublic: boolean; - isFeatured: boolean; - tags: string[]; - cardCount?: number; - createdAt: string; - updatedAt: string; -} - -export interface Card { - id: string; - deckId: string; - position: number; - title?: string; - content: any; - cardType: 'text' | 'flashcard' | 'quiz' | 'mixed'; - isFavorite: boolean; -} - -export interface StudySession { - id: string; - deckId: string; - mode: string; - totalCards: number; - completedCards: number; - correctCards: number; - startedAt: string; - completedAt?: string; -} - -export interface UserStats { - totalDecks: number; - totalCards: number; - totalSessions: number; - streakDays: number; - averageAccuracy: number; -} - -export interface CardProgress { - cardId: string; - status: string; - nextReview: string; - interval: number; - easeFactor: number; -} - -@Injectable() -export class ManadeckService { - private readonly logger = new Logger(ManadeckService.name); - private backendUrl: string; - private apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = this.configService.get('manadeck.backendUrl') || 'http://localhost:3009'; - this.apiPrefix = this.configService.get('manadeck.apiPrefix') || '/api'; - } - - private async request( - token: string, - endpoint: string, - options: RequestInit = {} - ): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - private async publicRequest(endpoint: string): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}/public${endpoint}`; - const response = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Public request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - // Deck operations - async getDecks(token: string): Promise<{ data?: Deck[]; error?: string }> { - return this.request(token, '/decks'); - } - - async getDeck(token: string, deckId: string): Promise<{ data?: Deck; error?: string }> { - return this.request(token, `/decks/${deckId}`); - } - - async createDeck( - token: string, - title: string, - description?: string - ): Promise<{ data?: Deck; error?: string }> { - return this.request(token, '/decks', { - method: 'POST', - body: JSON.stringify({ title, description }), - }); - } - - async deleteDeck(token: string, deckId: string): Promise<{ error?: string }> { - return this.request(token, `/decks/${deckId}`, { method: 'DELETE' }); - } - - // Card operations - async getCards(token: string, deckId: string): Promise<{ data?: Card[]; error?: string }> { - return this.request(token, `/decks/${deckId}/cards`); - } - - async getCard(token: string, cardId: string): Promise<{ data?: Card; error?: string }> { - return this.request(token, `/cards/${cardId}`); - } - - // AI generation - async generateDeck( - token: string, - prompt: string, - options: { - deckTitle?: string; - cardCount?: number; - cardTypes?: string[]; - difficulty?: string; - } = {} - ): Promise<{ data?: { deck: Deck; cards: Card[] }; error?: string }> { - return this.request<{ deck: Deck; cards: Card[] }>(token, '/decks/generate', { - method: 'POST', - body: JSON.stringify({ - prompt, - deckTitle: options.deckTitle || prompt.substring(0, 50), - cardCount: options.cardCount || 10, - cardTypes: options.cardTypes || ['flashcard'], - difficulty: options.difficulty || 'intermediate', - }), - }); - } - - // Study sessions - async startStudySession( - token: string, - deckId: string, - mode: string = 'all' - ): Promise<{ data?: StudySession; error?: string }> { - return this.request(token, '/study-sessions', { - method: 'POST', - body: JSON.stringify({ deckId, mode }), - }); - } - - async getStudySessions(token: string): Promise<{ data?: StudySession[]; error?: string }> { - return this.request(token, '/study-sessions'); - } - - // Progress - async getDueCards(token: string): Promise<{ data?: CardProgress[]; error?: string }> { - return this.request(token, '/progress/due'); - } - - async getProgressStats(token: string): Promise<{ data?: any; error?: string }> { - return this.request(token, '/progress/stats'); - } - - // User stats - async getStats(token: string): Promise<{ data?: UserStats; error?: string }> { - return this.request(token, '/stats'); - } - - async getCredits(token: string): Promise<{ data?: { balance: number }; error?: string }> { - return this.request<{ balance: number }>(token, '/credits/balance'); - } - - // Public endpoints - async getFeaturedDecks(): Promise<{ data?: Deck[]; error?: string }> { - return this.publicRequest('/featured-decks'); - } - - async getLeaderboard(limit: number = 10): Promise<{ data?: any[]; error?: string }> { - return this.publicRequest(`/leaderboard?limit=${limit}`); - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/public/health`); - return response.ok; - } catch { - return false; - } - } -} diff --git a/services/matrix-manadeck-bot/tsconfig.json b/services/matrix-manadeck-bot/tsconfig.json deleted file mode 100644 index 94f1e9493..000000000 --- a/services/matrix-manadeck-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - } -} diff --git a/services/matrix-nutriphi-bot/.dockerignore b/services/matrix-nutriphi-bot/.dockerignore deleted file mode 100644 index d6a8859ae..000000000 --- a/services/matrix-nutriphi-bot/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -dist -.git -*.log -.env* -data diff --git a/services/matrix-nutriphi-bot/.env.example b/services/matrix-nutriphi-bot/.env.example deleted file mode 100644 index a935c269a..000000000 --- a/services/matrix-nutriphi-bot/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Server -PORT=3316 -NODE_ENV=development - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token -MATRIX_ALLOWED_ROOMS=#nutriphi:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# NutriPhi Backend -NUTRIPHI_BACKEND_URL=http://localhost:3023 -NUTRIPHI_API_PREFIX=/api/v1 - -# Speech-to-Text (mana-stt service) -STT_URL=http://localhost:3020 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 - -# Development bypass (optional) -DEV_BYPASS_AUTH=false -DEV_USER_ID= diff --git a/services/matrix-nutriphi-bot/CLAUDE.md b/services/matrix-nutriphi-bot/CLAUDE.md deleted file mode 100644 index 20cccfd9a..000000000 --- a/services/matrix-nutriphi-bot/CLAUDE.md +++ /dev/null @@ -1,161 +0,0 @@ -# Matrix NutriPhi Bot - Claude Code Guidelines - -## Overview - -Matrix NutriPhi Bot is a Matrix chat bot for AI-powered nutrition tracking. It integrates with the NutriPhi backend to analyze meal photos and text descriptions, track daily nutrition, and provide personalized recommendations. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: NutriPhi API (port 3023) -- **Auth**: Mana Core Auth (JWT) -- **Media Storage**: mana-media (port 3015) - -## 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-nutriphi-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3316) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── nutriphi/ -│ │ ├── nutriphi.module.ts -│ │ └── nutriphi.service.ts # NutriPhi API client -│ └── media/ -│ ├── media.module.ts -│ └── media.service.ts # mana-media client for persistent storage -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe, help | Show help message | -| `!login email pass` | - | Login to NutriPhi | -| `!logout` | - | Logout | -| `!analyze [text]` | - | Analyze photo or text | -| `!today` | heute | Daily summary | -| `!week` | woche | Weekly stats | -| `!goals` | ziele | Show goals | -| `!setgoals cal pro carb fat` | - | Set goals | -| `!favorites` | favoriten | List favorites | -| `!tips` | tipps | AI recommendations | -| `!status` | - | Bot status | - -## Image Analysis Flow - -1. User sends image to room -2. Bot acknowledges: "Bild empfangen! Analysiere..." -3. Bot downloads image, sends to NutriPhi API for analysis -4. Bot displays nutrition results -5. (Background) Image is stored in mana-media for persistent storage with deduplication - -## Environment Variables - -```env -# Server -PORT=3316 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#nutriphi:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# NutriPhi Backend -NUTRIPHI_BACKEND_URL=http://localhost:3023 -NUTRIPHI_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 - -# Mana Media (optional - for persistent image storage) -MANA_MEDIA_URL=http://localhost:3015 - -# Development bypass (optional) -DEV_BYPASS_AUTH=false -DEV_USER_ID= -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-nutriphi-bot/Dockerfile -t matrix-nutriphi-bot services/matrix-nutriphi-bot - -# Run -docker run -p 3315:3315 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -e MANA_MEDIA_URL=http://mana-media:3015 \ - -v matrix-nutriphi-bot-data:/app/data \ - matrix-nutriphi-bot -``` - -## Health Check - -```bash -curl http://localhost:3316/health -``` - -## Getting a Matrix Access Token - -```bash -# Create bot user first, then login -curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ - -H "Content-Type: application/json" \ - -d '{ - "type": "m.login.password", - "user": "nutriphi-bot", - "password": "your-password" - }' - -# Response contains: {"access_token": "syt_xxx", ...} -``` - -## Authentication Flow - -1. User sends `!login email password` -2. Bot calls mana-core-auth `/api/v1/auth/login` -3. JWT token stored in session (in-memory) -4. Token used for all NutriPhi API calls -5. Token expires after 7 days (re-login required) - -## NutriPhi API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/health` | GET | Health check | -| `/api/v1/analysis/photo` | POST | Analyze photo | -| `/api/v1/analysis/text` | POST | Analyze text | -| `/api/v1/meals` | POST | Create meal | -| `/api/v1/goals` | GET/POST | User goals | -| `/api/v1/stats/daily` | GET | Daily summary | -| `/api/v1/stats/weekly` | GET | Weekly stats | -| `/api/v1/favorites` | GET | List favorites | -| `/api/v1/recommendations` | GET | AI tips | diff --git a/services/matrix-nutriphi-bot/Dockerfile b/services/matrix-nutriphi-bot/Dockerfile deleted file mode 100644 index ff5adad6b..000000000 --- a/services/matrix-nutriphi-bot/Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common -COPY services/mana-media/packages/client ./services/mana-media/packages/client - -# Copy this bot -COPY services/matrix-nutriphi-bot ./services/matrix-nutriphi-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-nutriphi-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-nutriphi-bot/dist ./services/matrix-nutriphi-bot/dist -COPY --from=builder /app/services/matrix-nutriphi-bot/package.json ./services/matrix-nutriphi-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-nutriphi-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4016/health || exit 1 - -EXPOSE 4016 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-nutriphi-bot/nest-cli.json b/services/matrix-nutriphi-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-nutriphi-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-nutriphi-bot/package.json b/services/matrix-nutriphi-bot/package.json deleted file mode 100644 index 0eb3772c8..000000000 --- a/services/matrix-nutriphi-bot/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@manacore/matrix-nutriphi-bot", - "version": "1.0.0", - "description": "Matrix bot for NutriPhi - AI-powered nutrition tracking via Matrix", - "private": true, - "license": "MIT", - "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": "tsc -p tsconfig.build.json", - "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@manacore/media-client": "workspace:*", - "@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", - "@nestjs/schematics": "^10.2.3", - "@types/node": "^22.10.5", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-nutriphi-bot/src/app.module.ts b/services/matrix-nutriphi-bot/src/app.module.ts deleted file mode 100644 index 7efab0a62..000000000 --- a/services/matrix-nutriphi-bot/src/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-nutriphi-bot')], -}) -export class AppModule {} diff --git a/services/matrix-nutriphi-bot/src/bot/bot.module.ts b/services/matrix-nutriphi-bot/src/bot/bot.module.ts deleted file mode 100644 index d297dfc8a..000000000 --- a/services/matrix-nutriphi-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { NutriPhiModule } from '../nutriphi/nutriphi.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; -import { MediaModule } from '../media/media.module'; - -@Module({ - imports: [ - NutriPhiModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.forRoot(), - CreditModule.forRoot(), - MediaModule, - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-nutriphi-bot/src/bot/matrix.service.ts b/services/matrix-nutriphi-bot/src/bot/matrix.service.ts deleted file mode 100644 index 8100f6c40..000000000 --- a/services/matrix-nutriphi-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,1159 +0,0 @@ -import { Injectable, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, - handleCreditCommand, - type CreditCommandsHost, -} from '@manacore/matrix-bot-common'; -import { - NutriPhiService, - AIAnalysisResult, - DailySummary, - WeeklyStats, -} from '../nutriphi/nutriphi.service'; -import { - SessionService, - TranscriptionService, - CreditService, - I18nService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { MediaService } from '../media/media.service'; -import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration'; - -const PHOTO_ANALYSIS_CREDITS = 3; - -// 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: ['verbindung'], command: 'status' }, - // Credit commands - { keywords: ['credits', 'guthaben', 'kontostand'], command: 'credits' }, - { keywords: ['packages', 'pakete', 'preise'], command: 'packages' }, - { keywords: ['kaufen', 'buy'], command: 'buy' }, -]); - -@Injectable() -export class MatrixService extends BaseMatrixService implements CreditCommandsHost { - // Expose services for credit commands mixin - public creditService: CreditService; - public i18nService!: I18nService; - public sessionService: SessionService; - - constructor( - configService: ConfigService, - private nutriphiService: NutriPhiService, - sessionService: SessionService, - private transcriptionService: TranscriptionService, - creditService: CreditService, - private mediaService: MediaService, - @Optional() i18nService?: I18nService - ) { - super(configService); - // Assign to public properties for credit commands mixin - this.sessionService = sessionService; - this.creditService = creditService; - if (i18nService) { - this.i18nService = i18nService; - } - } - - // ============================================================================ - // CreditCommandsHost interface implementation - // ============================================================================ - - /** - * Send a credit message (delegates to protected sendMessage) - */ - async sendCreditMessage(roomId: string, message: string): Promise { - await this.sendMessage(roomId, message); - } - - /** - * Send a credit reply (delegates to protected sendReply) - */ - async sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise { - await this.sendReply(roomId, event, message); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string | null { - return `**NutriPhi Bot - KI-Ernahrungsassistent** - -Analysiere deine Mahlzeiten mit KI und tracke deine Ernahrung! - -**Quick Start:** -1. \`!login email passwort\` - Anmelden -2. Sende ein Foto deiner Mahlzeit - wird automatisch analysiert! - -Sag "hilfe" fur alle Befehle!`; - } - - async onModuleInit() { - await super.onModuleInit(); - - if (!this.client) return; - - // Handle image messages - auto-analyze - this.client.on('room.message', async (roomId: string, event: any) => { - if (event.sender === (await this.client.getUserId())) return; - - const content = event.content as { - msgtype?: string; - body?: string; - url?: string; - info?: { mimetype?: string; duration?: number }; - }; - - // Handle image messages - automatically analyze - if (content.msgtype === 'm.image' && content.url) { - this.logger.log(`Image received from ${event.sender}, auto-analyzing...`); - await this.autoAnalyzeImage( - roomId, - event.sender, - content.url, - content.info?.mimetype || 'image/png' - ); - } - }); - } - - private async autoAnalyzeImage(roomId: string, sender: string, mxcUrl: string, mimeType: string) { - let token = await this.requireLogin(roomId, sender); - if (!token) return; - - await this.sendMessage(roomId, 'Bild empfangen! Analysiere...'); - await this.client.setTyping(roomId, true, 60000); - - try { - const imageData = await this.downloadMatrixImage(mxcUrl); - - let result; - try { - result = await this.nutriphiService.analyzePhoto(imageData, mimeType, token); - } catch (error) { - // If token expired, try to refresh and retry once - if (this.isTokenExpiredError(error)) { - token = await this.refreshToken(sender); - if (!token) { - await this.client.setTyping(roomId, false); - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - // Retry with new token - result = await this.nutriphiService.analyzePhoto(imageData, mimeType, token); - } else { - throw error; - } - } - - await this.client.setTyping(roomId, false); - - const response = this.formatAnalysisResult(result); - await this.sendMessage(roomId, response); - - // Store image in mana-media for persistent storage (non-blocking) - // Use Matrix sender ID as user identifier - this.mediaService - .storeFromMatrix(mxcUrl, sender) - .then((mediaResult) => { - if (mediaResult) { - this.logger.log(`Image stored in mana-media: ${mediaResult.id}`); - } - }) - .catch((error) => { - this.logger.warn(`Failed to store image in mana-media: ${error}`); - }); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - this.logger.error('Auto-analyze failed:', error); - await this.sendMessage(roomId, `Fehler bei der Analyse: ${errorMsg}`); - } - } - - protected async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - let token = await this.requireLogin(roomId, sender); - if (!token) return; - - await this.sendMessage(roomId, 'Verarbeite Sprachnotiz...'); - await this.client.setTyping(roomId, true, 60000); - - try { - // Download audio from Matrix using authenticated API - const mxcUrl = event.content.url!; - this.logger.log(`Downloading audio from ${mxcUrl}`); - - const buffer = await this.downloadMedia(mxcUrl); - - // Transcribe audio - const transcription = await this.transcriptionService.transcribe(buffer); - this.logger.log(`Transcription: ${transcription.substring(0, 50)}...`); - - if (!transcription.trim()) { - await this.client.setTyping(roomId, false); - await this.sendMessage(roomId, 'Konnte keine Sprache erkennen. Bitte versuche es erneut.'); - return; - } - - // Analyze the transcribed text as a meal - await this.sendMessage(roomId, `Transkription: "${transcription}"\n\nAnalysiere...`); - - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.analyzeText(transcription, t) - ); - - if ('error' in apiResult) { - await this.client.setTyping(roomId, false); - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - await this.client.setTyping(roomId, false); - - // Format and send result - const formattedResult = this.formatAnalysisResult(apiResult.result); - await this.sendMessage(roomId, formattedResult); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - this.logger.error('Audio processing failed:', error); - await this.sendMessage(roomId, `Fehler bei der Verarbeitung: ${errorMsg}`); - } - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - // Handle commands with ! prefix - if (message.startsWith('!')) { - await this.handleCommand(roomId, event, sender, message); - return; - } - - // Check for natural language keywords - const detectedCommand = keywordDetector.detect(message); - if (detectedCommand) { - this.logger.log(`Detected keyword command: ${detectedCommand}`); - await this.handleCommand(roomId, event, sender, `!${detectedCommand}`); - return; - } - - // Auto-analyze text that looks like a meal description - // (longer than 15 chars and contains food-related content) - if (this.looksLikeMealDescription(message)) { - await this.autoAnalyzeText(roomId, sender, message); - return; - } - - // Don't respond to random messages - only commands - } - - /** - * Check if a message looks like a meal description - * Simple heuristic: longer than 15 chars, contains numbers or food keywords - */ - private looksLikeMealDescription(message: string): boolean { - if (message.length < 15) return false; - - const lowerMessage = message.toLowerCase(); - - // Skip greetings and questions - const skipPatterns = [ - /^(hallo|hi|hey|guten|moin|servus)/, - /^(was|wie|wann|wo|wer|warum|kannst|könntest)/, - /\?$/, - ]; - for (const pattern of skipPatterns) { - if (pattern.test(lowerMessage)) return false; - } - - // Check for food indicators: numbers (portions), units, or food words - const hasNumbers = /\d+/.test(message); - const foodKeywords = [ - 'gramm', - 'g ', - 'ml', - 'liter', - 'stück', - 'portion', - 'tasse', - 'löffel', - 'mit', - 'und', - 'apfel', - 'banane', - 'brot', - 'ei', - 'milch', - 'käse', - 'fleisch', - 'gemüse', - 'obst', - 'salat', - 'reis', - 'nudel', - 'pasta', - 'pizza', - 'suppe', - 'smoothie', - 'müsli', - 'joghurt', - 'haferflocken', - 'nüsse', - 'erdnüsse', - 'mandeln', - 'karotte', - 'tomate', - 'gurke', - 'zwiebel', - 'kartoffel', - 'huhn', - 'hähnchen', - 'rind', - 'schwein', - 'fisch', - 'lachs', - 'thunfisch', - 'schinken', - 'wurst', - 'butter', - 'öl', - 'olive', - 'kokos', - 'dattel', - 'zucker', - 'honig', - 'kaffee', - 'tee', - 'saft', - 'wasser', - 'sahne', - 'quark', - ]; - - const hasFoodKeyword = foodKeywords.some((keyword) => lowerMessage.includes(keyword)); - - return hasNumbers || hasFoodKeyword; - } - - /** - * Auto-analyze a text message as a meal description - */ - private async autoAnalyzeText(roomId: string, sender: string, description: string) { - let token = await this.sessionService.getToken(sender); - - // If not logged in, prompt for login - if (!token) { - await this.sendMessage( - roomId, - `Das sieht nach einer Mahlzeit aus! Melde dich an, um sie zu analysieren:\n\n\`!login email passwort\`` - ); - return; - } - - this.logger.log(`Auto-analyzing text from ${sender}: "${description.substring(0, 50)}..."`); - await this.sendMessage( - roomId, - `Analysiere: "${description.substring(0, 80)}${description.length > 80 ? '...' : ''}"...` - ); - await this.client.setTyping(roomId, true, 60000); - - try { - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.analyzeText(description, t) - ); - - await this.client.setTyping(roomId, false); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - const response = this.formatAnalysisResult(apiResult.result); - await this.sendMessage(roomId, response); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - this.logger.error('Auto-analyze text failed:', error); - await this.sendMessage(roomId, `Fehler bei der Analyse: ${errorMsg}`); - } - } - - private async handleCommand( - roomId: string, - event: MatrixRoomEvent, - sender: string, - body: string - ) { - const [command, ...args] = body.slice(1).split(' '); - const argString = args.join(' '); - - // Handle credit commands first (credits, packages, buy) - if (await handleCreditCommand(this, roomId, event, sender, command.toLowerCase(), argString)) { - return; - } - - switch (command.toLowerCase()) { - case 'help': - case 'start': - await this.sendHelp(roomId); - break; - - case 'analyze': - await this.handleAnalyze(roomId, sender, argString); - break; - - case 'today': - await this.handleToday(roomId, sender); - break; - - case 'week': - await this.handleWeek(roomId, sender); - break; - - case 'goals': - await this.handleGoals(roomId, sender); - break; - - case 'setgoals': - await this.handleSetGoals(roomId, sender, args); - break; - - case 'favorites': - await this.handleFavorites(roomId, sender); - break; - - case 'tips': - await this.handleTips(roomId, sender); - 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 sendHelp(roomId: string) { - await this.sendMessage(roomId, HELP_MESSAGE); - } - - private async handleAnalyze(roomId: string, sender: string, description: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - const pendingImage = await this.sessionService.getSessionData<{ - url: string; - mimeType: string; - }>(sender, 'pendingImage'); - - // If no image and no description, show help - if (!pendingImage && !description.trim()) { - await this.sendMessage( - roomId, - `**Verwendung:**\n- Sende ein Foto, dann \`!analyze\`\n- Oder: \`!analyze Spaghetti mit Tomatensauce\`` - ); - return; - } - - await this.client.setTyping(roomId, true, 60000); - - try { - let apiResult; - - if (pendingImage) { - // Analyze image - await this.sendMessage(roomId, 'Analysiere Bild...'); - const imageData = await this.downloadMatrixImage(pendingImage.url); - apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.analyzePhoto(imageData, pendingImage.mimeType, t) - ); - this.sessionService.setSessionData(sender, 'pendingImage', null); - } else { - // Analyze text - await this.sendMessage(roomId, `Analysiere: "${description}"...`); - apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.analyzeText(description, t) - ); - } - - await this.client.setTyping(roomId, false); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - // Format and send result - const response = this.formatAnalysisResult(apiResult.result); - await this.sendMessage(roomId, response); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler bei der Analyse: ${errorMsg}`); - } - } - - private formatAnalysisResult(result: AIAnalysisResult): string { - const { foods, totalNutrition, confidence, warnings, suggestions } = result; - - let text = `**Mahlzeit analysiert** (Konfidenz: ${Math.round(confidence * 100)}%)\n\n`; - - if (foods.length > 0) { - text += '**Erkannte Lebensmittel:**\n'; - for (const food of foods) { - text += `- ${food.name} (${food.quantity}) - ${food.calories} kcal\n`; - } - text += '\n'; - } - - text += `**Nährwerte:**\n`; - text += `- Kalorien: ${Math.round(totalNutrition.calories)} kcal\n`; - text += `- Protein: ${Math.round(totalNutrition.protein)}g\n`; - text += `- Kohlenhydrate: ${Math.round(totalNutrition.carbohydrates)}g\n`; - text += `- Fett: ${Math.round(totalNutrition.fat)}g\n`; - text += `- Ballaststoffe: ${Math.round(totalNutrition.fiber)}g\n`; - - // Generate smart feedback based on nutrition values - const feedback = this.generateMealFeedback(totalNutrition, foods); - if (feedback.positives.length > 0 || feedback.improvements.length > 0) { - text += '\n---\n'; - - if (feedback.positives.length > 0) { - text += `\n**👍 Positiv:**\n`; - for (const positive of feedback.positives) { - text += `- ${positive}\n`; - } - } - - if (feedback.improvements.length > 0) { - text += `\n**💡 Verbesserungsvorschläge:**\n`; - for (const improvement of feedback.improvements) { - text += `- ${improvement}\n`; - } - } - } - - // Add backend warnings if present - if (warnings && warnings.length > 0) { - text += `\n**⚠️ Hinweise:**\n`; - for (const warning of warnings) { - text += `- ${warning}\n`; - } - } - - // Add backend suggestions if present - if (suggestions && suggestions.length > 0) { - text += `\n**📝 Weitere Tipps:**\n`; - for (const suggestion of suggestions) { - text += `- ${suggestion}\n`; - } - } - - return text; - } - - /** - * Generate smart feedback based on nutritional values - */ - private generateMealFeedback( - nutrition: AIAnalysisResult['totalNutrition'], - foods: AIAnalysisResult['foods'] - ): { positives: string[]; improvements: string[] } { - const positives: string[] = []; - const improvements: string[] = []; - - const { calories, protein, carbohydrates, fat, fiber } = nutrition; - - // Calculate macros as percentage of calories - const proteinCals = protein * 4; - const carbCals = carbohydrates * 4; - const fatCals = fat * 9; - const totalMacroCals = proteinCals + carbCals + fatCals; - - const proteinPct = totalMacroCals > 0 ? (proteinCals / totalMacroCals) * 100 : 0; - const carbPct = totalMacroCals > 0 ? (carbCals / totalMacroCals) * 100 : 0; - const fatPct = totalMacroCals > 0 ? (fatCals / totalMacroCals) * 100 : 0; - - // Check for food variety - const foodNames = foods.map((f) => f.name.toLowerCase()); - const hasNuts = foodNames.some( - (n) => - n.includes('nuss') || n.includes('mandel') || n.includes('cashew') || n.includes('walnuss') - ); - const hasFruit = foodNames.some( - (n) => - n.includes('apfel') || - n.includes('banane') || - n.includes('beere') || - n.includes('orange') || - n.includes('mandarine') - ); - const hasVegetables = foodNames.some( - (n) => - n.includes('salat') || - n.includes('gemüse') || - n.includes('karotte') || - n.includes('tomate') || - n.includes('gurke') || - n.includes('brokkoli') || - n.includes('spinat') || - n.includes('pastinake') - ); - const hasWholeGrains = foodNames.some( - (n) => - n.includes('haferflocken') || - n.includes('vollkorn') || - n.includes('quinoa') || - n.includes('dinkel') - ); - const hasProteinSource = foodNames.some( - (n) => - n.includes('ei') || - n.includes('fleisch') || - n.includes('fisch') || - n.includes('huhn') || - n.includes('lachs') || - n.includes('thunfisch') || - n.includes('tofu') || - n.includes('quark') || - n.includes('joghurt') - ); - - // Positive feedback - if (fiber >= 8) { - positives.push('Sehr guter Ballaststoffgehalt - fördert die Verdauung und hält länger satt'); - } else if (fiber >= 5) { - positives.push('Guter Ballaststoffgehalt'); - } - - if (proteinPct >= 20 && proteinPct <= 35) { - positives.push('Ausgewogener Proteinanteil für Muskelerhalt und Sättigung'); - } else if (protein >= 20) { - positives.push('Proteinreiche Mahlzeit - gut für Muskelaufbau'); - } - - if (hasWholeGrains) { - positives.push('Vollkornprodukte liefern langanhaltende Energie'); - } - - if (hasNuts) { - positives.push('Nüsse liefern gesunde Fette und wichtige Mineralstoffe'); - } - - if (hasFruit && hasVegetables) { - positives.push('Gute Mischung aus Obst und Gemüse für Vitamine'); - } else if (hasFruit) { - positives.push('Obst liefert natürliche Vitamine und Antioxidantien'); - } else if (hasVegetables) { - positives.push('Gemüse liefert wichtige Vitamine und Mineralstoffe'); - } - - // Improvement suggestions - if (calories > 800 && calories <= 1200) { - improvements.push( - 'Diese Mahlzeit ist recht kalorienreich - ideal als Hauptmahlzeit, weniger als Snack' - ); - } else if (calories > 1200) { - improvements.push( - 'Sehr kalorienreiche Mahlzeit - evtl. Portionsgröße reduzieren oder über den Tag verteilen' - ); - } - - if (fatPct > 45) { - improvements.push( - 'Hoher Fettanteil - evtl. Ölmenge reduzieren oder fettärmere Alternativen wählen' - ); - } - - if (fiber < 3 && calories > 300) { - improvements.push('Mehr Ballaststoffe durch Gemüse, Vollkorn oder Hülsenfrüchte ergänzen'); - } - - if (proteinPct < 15 && calories > 400) { - improvements.push( - 'Proteinquelle ergänzen (z.B. Quark, Joghurt, Nüsse oder Samen) für bessere Sättigung' - ); - } - - if (!hasFruit && !hasVegetables && calories > 300) { - improvements.push('Obst oder Gemüse ergänzen für mehr Vitamine und Mineralstoffe'); - } - - if (carbPct > 60 && protein < 15) { - improvements.push( - 'Sehr kohlenhydratreich - Proteinquelle ergänzen für stabileren Blutzucker' - ); - } - - // Specific food-based suggestions - const hasMultipleOils = foodNames.filter((n) => n.includes('öl')).length > 2; - if (hasMultipleOils) { - improvements.push('Mehrere Ölsorten - ein hochwertiges Öl (z.B. Olivenöl) reicht meist aus'); - } - - return { positives, improvements }; - } - - private async handleToday(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - await this.client.setTyping(roomId, true, 10000); - - try { - const today = new Date().toISOString().split('T')[0]; - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.getDailySummary(today, t) - ); - - await this.client.setTyping(roomId, false); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - await this.sendMessage(roomId, this.formatDailySummary(apiResult.result)); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - private formatDailySummary(summary: DailySummary): string { - const dateStr = new Date(summary.date).toLocaleDateString('de-DE', { - weekday: 'long', - day: 'numeric', - month: 'long', - }); - - let text = `**Tages-Zusammenfassung - ${dateStr}**\n\n`; - - const { progress } = summary; - text += `**Kalorien:** ${Math.round(progress.calories.current)} / ${progress.calories.target} kcal (${Math.round(progress.calories.percentage)}%)\n`; - - if (progress.protein) { - text += `**Protein:** ${Math.round(progress.protein.current)}g / ${progress.protein.target}g (${Math.round(progress.protein.percentage)}%)\n`; - } - if (progress.carbs) { - text += `**Kohlenhydrate:** ${Math.round(progress.carbs.current)}g / ${progress.carbs.target}g (${Math.round(progress.carbs.percentage)}%)\n`; - } - if (progress.fat) { - text += `**Fett:** ${Math.round(progress.fat.current)}g / ${progress.fat.target}g (${Math.round(progress.fat.percentage)}%)\n`; - } - - if (summary.meals.length > 0) { - text += `\n**Mahlzeiten (${summary.meals.length}):**\n`; - for (const meal of summary.meals) { - const mealLabel = MEAL_TYPE_LABELS[meal.mealType] || meal.mealType; - const calories = meal.nutrition?.calories - ? ` - ${Math.round(meal.nutrition.calories)} kcal` - : ''; - text += `- ${mealLabel}: ${meal.description}${calories}\n`; - } - } else { - text += `\n_Noch keine Mahlzeiten heute._`; - } - - return text; - } - - private async handleWeek(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - await this.client.setTyping(roomId, true, 10000); - - try { - const today = new Date().toISOString().split('T')[0]; - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.getWeeklyStats(today, t) - ); - - await this.client.setTyping(roomId, false); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - await this.sendMessage(roomId, this.formatWeeklyStats(apiResult.result)); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - private formatWeeklyStats(stats: WeeklyStats): string { - const startStr = new Date(stats.startDate).toLocaleDateString('de-DE', { - day: 'numeric', - month: 'short', - }); - const endStr = new Date(stats.endDate).toLocaleDateString('de-DE', { - day: 'numeric', - month: 'short', - }); - - let text = `**Wochen-Statistik (${startStr} - ${endStr})**\n\n`; - - text += `**Durchschnittswerte:**\n`; - text += `- Kalorien: ${Math.round(stats.averages.calories)} kcal/Tag\n`; - text += `- Protein: ${Math.round(stats.averages.protein)}g/Tag\n`; - text += `- Kohlenhydrate: ${Math.round(stats.averages.carbs)}g/Tag\n`; - text += `- Fett: ${Math.round(stats.averages.fat)}g/Tag\n\n`; - - text += `**Tage:**\n`; - for (const day of stats.days) { - const dayStr = new Date(day.date).toLocaleDateString('de-DE', { - weekday: 'short', - day: 'numeric', - }); - const goalIcon = day.goalsMet ? ' ' : ''; - text += `- ${dayStr}: ${Math.round(day.totalCalories)} kcal, ${day.mealCount} Mahlzeiten${goalIcon}\n`; - } - - return text; - } - - private async handleGoals(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - try { - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.getGoals(t) - ); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - const goals = apiResult.result; - - if (!goals) { - await this.sendMessage( - roomId, - `Du hast noch keine Ziele gesetzt.\n\nNutze \`!setgoals kalorien protein carbs fett\`\nBeispiel: \`!setgoals 2000 80 250 65\`` - ); - return; - } - - let text = `**Deine Tagesziele:**\n\n`; - text += `- Kalorien: ${goals.dailyCalories} kcal\n`; - if (goals.dailyProtein) text += `- Protein: ${goals.dailyProtein}g\n`; - if (goals.dailyCarbs) text += `- Kohlenhydrate: ${goals.dailyCarbs}g\n`; - if (goals.dailyFat) text += `- Fett: ${goals.dailyFat}g\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 handleSetGoals(roomId: string, sender: string, args: string[]) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!setgoals kalorien [protein] [carbs] [fett]\`\n\nBeispiel: \`!setgoals 2000 80 250 65\`` - ); - return; - } - - const calories = parseInt(args[0], 10); - const protein = args[1] ? parseInt(args[1], 10) : undefined; - const carbs = args[2] ? parseInt(args[2], 10) : undefined; - const fat = args[3] ? parseInt(args[3], 10) : undefined; - - if (isNaN(calories) || calories < 500 || calories > 10000) { - await this.sendMessage( - roomId, - `Ungiultige Kalorienzahl. Bitte eine Zahl zwischen 500 und 10000 angeben.` - ); - return; - } - - try { - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.setGoals( - { - dailyCalories: calories, - dailyProtein: protein, - dailyCarbs: carbs, - dailyFat: fat, - }, - t - ) - ); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - let text = `**Ziele gesetzt:**\n`; - text += `- Kalorien: ${calories} kcal\n`; - if (protein) text += `- Protein: ${protein}g\n`; - if (carbs) text += `- Kohlenhydrate: ${carbs}g\n`; - if (fat) text += `- Fett: ${fat}g\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 handleFavorites(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - try { - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.getFavorites(t) - ); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - const favorites = apiResult.result; - - if (favorites.length === 0) { - await this.sendMessage(roomId, `Du hast noch keine Favoriten gespeichert.`); - return; - } - - let text = `**Deine Favoriten (${favorites.length}):**\n\n`; - for (const fav of favorites) { - text += `- **${fav.name}** (${fav.usageCount}x verwendet)\n`; - text += ` ${Math.round(fav.nutrition.calories)} kcal, ${Math.round(fav.nutrition.protein)}g P, ${Math.round(fav.nutrition.carbohydrates)}g KH, ${Math.round(fav.nutrition.fat)}g F\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 handleTips(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - try { - const apiResult = await this.withTokenRefresh(sender, token, (t) => - this.nutriphiService.getRecommendations(t) - ); - - if ('error' in apiResult) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return; - } - - const recommendations = apiResult.result; - - if (recommendations.length === 0) { - await this.sendMessage( - roomId, - `Keine aktuellen Empfehlungen. Tracke mehr Mahlzeiten fur personalisierte Tipps!` - ); - return; - } - - let text = `**KI-Empfehlungen:**\n\n`; - for (const rec of recommendations) { - const icon = rec.type === 'coaching' ? '' : ''; - text += `${icon} ${rec.message}\n\n`; - } - - await this.sendMessage(roomId, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - /** - * Require login - returns token or sends login prompt and returns null - */ - private async requireLogin(roomId: string, userId: string): Promise { - const token = await this.sessionService.getToken(userId); - if (!token) { - await this.sendMessage(roomId, LOGIN_MESSAGES.nutriphi); - return null; - } - return token; - } - - /** - * Check if an error is a token expiration error (JWT exp claim failed) - */ - private isTokenExpiredError(error: unknown): boolean { - if (error instanceof Error) { - const message = error.message.toLowerCase(); - return ( - (message.includes('401') || message.includes('unauthorized')) && - (message.includes('exp') || message.includes('expired') || message.includes('token')) - ); - } - return false; - } - - /** - * Refresh token by clearing session and fetching new one via Matrix-SSO-Link - */ - private async refreshToken(userId: string): Promise { - this.logger.log(`Token expired for ${userId}, attempting refresh via Matrix-SSO-Link...`); - // Clear the expired session - await this.sessionService.logout(userId); - // Try to get a new token via Matrix-SSO-Link - const newToken = await this.sessionService.getToken(userId); - if (newToken) { - this.logger.log(`Token refreshed successfully for ${userId}`); - } else { - this.logger.warn(`Token refresh failed for ${userId} - user needs to re-login`); - } - return newToken; - } - - /** - * Execute an API operation with automatic token refresh on expiration - */ - private async withTokenRefresh( - userId: string, - token: string, - operation: (token: string) => Promise - ): Promise<{ result: T; newToken?: string } | { error: 'token_refresh_failed' }> { - try { - const result = await operation(token); - return { result }; - } catch (error) { - if (this.isTokenExpiredError(error)) { - const newToken = await this.refreshToken(userId); - if (!newToken) { - return { error: 'token_refresh_failed' }; - } - const result = await operation(newToken); - return { result, newToken }; - } - throw error; - } - } - - private async handleStatus(roomId: string, sender: string) { - const backendHealthy = await this.nutriphiService.checkHealth(); - const isLoggedIn = await this.sessionService.isLoggedIn(sender); - const sessionCount = this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusText = `**NutriPhi Bot Status**\n\n`; - statusText += `**Backend:** ${backendHealthy ? '✅ Online' : '❌ Offline'}\n`; - statusText += `**Aktive Sessions:** ${sessionCount}\n\n`; - - if (isLoggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusText += `👤 Angemeldet als: ${session.email}\n`; - statusText += `⚡ Credits: ${balance.balance.toFixed(2)}\n`; - } else { - statusText += `👤 Nicht angemeldet\n`; - statusText += `💡 Login: \`!login email passwort\``; - } - - await this.sendMessage(roomId, statusText); - } - - private async pinHelpMessage(roomId: string) { - try { - const htmlBody = this.markdownToHtmlLocal(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 downloadMatrixImage(mxcUrl: string): Promise { - this.logger.log(`Downloading image from ${mxcUrl}`); - - // Use the authenticated download method from BaseMatrixService - const buffer = await this.downloadMedia(mxcUrl); - return buffer.toString('base64'); - } - - private markdownToHtmlLocal(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-nutriphi-bot/src/config/configuration.ts b/services/matrix-nutriphi-bot/src/config/configuration.ts deleted file mode 100644 index e40b2d2a8..000000000 --- a/services/matrix-nutriphi-bot/src/config/configuration.ts +++ /dev/null @@ -1,54 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3316', 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', - }, - nutriphi: { - backendUrl: process.env.NUTRIPHI_BACKEND_URL || 'http://localhost:3023', - apiPrefix: process.env.NUTRIPHI_API_PREFIX || '/api/v1', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - devBypass: process.env.DEV_BYPASS_AUTH === 'true', - devUserId: process.env.DEV_USER_ID || '', - }, - stt: { - url: process.env.STT_URL || 'http://localhost:3020', - }, - media: { - url: process.env.MANA_MEDIA_URL || 'http://localhost:3015', - }, -}); - -export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent** - -**Befehle:** -- \`!help\` - Diese Hilfe anzeigen -- \`!analyze beschreibung\` - Text analysieren -- \`!today\` / \`heute\` - Tages-Zusammenfassung -- \`!week\` / \`woche\` - Wochen-Statistik -- \`!goals\` / \`ziele\` - Aktuelle Ziele -- \`!setgoals kalorien protein carbs fett\` - Ziele setzen -- \`!favorites\` / \`favoriten\` - Favoriten anzeigen -- \`!tips\` / \`tipps\` - KI-Empfehlungen -- \`!status\` - Bot-Status - -**Mahlzeit erfassen:** -- Foto senden (wird automatisch analysiert!) -- Sprachnotiz senden (wird automatisch transkribiert & analysiert) -- \`!analyze Spaghetti mit Sauce\` (Textbeschreibung) - -**Beispiele:** -- "heute" - Zeigt Tages-Ubersicht -- \`!analyze Apfel und Banane\` - Analysiert Textbeschreibung -- \`!setgoals 2000 80 250 65\` - Setzt Tagesziele`; - -export const MEAL_TYPE_LABELS: Record = { - breakfast: 'Fruhstuck', - lunch: 'Mittagessen', - dinner: 'Abendessen', - snack: 'Snack', -}; diff --git a/services/matrix-nutriphi-bot/src/main.ts b/services/matrix-nutriphi-bot/src/main.ts deleted file mode 100644 index 667aff302..000000000 --- a/services/matrix-nutriphi-bot/src/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - const port = process.env.PORT || 3316; - await app.listen(port); - - logger.log(`Matrix NutriPhi Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} -bootstrap(); diff --git a/services/matrix-nutriphi-bot/src/media/media.module.ts b/services/matrix-nutriphi-bot/src/media/media.module.ts deleted file mode 100644 index cd6208f66..000000000 --- a/services/matrix-nutriphi-bot/src/media/media.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MediaService } from './media.service'; - -@Module({ - providers: [MediaService], - exports: [MediaService], -}) -export class MediaModule {} diff --git a/services/matrix-nutriphi-bot/src/media/media.service.ts b/services/matrix-nutriphi-bot/src/media/media.service.ts deleted file mode 100644 index 32a0819ba..000000000 --- a/services/matrix-nutriphi-bot/src/media/media.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface MediaResult { - id: string; - hash: string; - url: string; -} - -@Injectable() -export class MediaService implements OnModuleInit { - private readonly logger = new Logger(MediaService.name); - private mediaUrl: string | null = null; - - constructor(private configService: ConfigService) {} - - onModuleInit() { - const mediaUrl = this.configService.get('media.url'); - if (mediaUrl) { - this.mediaUrl = mediaUrl; - this.logger.log(`MediaService initialized with URL: ${mediaUrl}`); - } else { - this.logger.warn('MANA_MEDIA_URL not configured, media storage disabled'); - } - } - - /** - * Store an image from a Matrix MXC URL in mana-media. - * Returns the media record if successful, null if disabled or failed. - */ - async storeFromMatrix(mxcUrl: string, userId: string): Promise { - if (!this.mediaUrl) { - this.logger.debug('Media storage disabled, skipping storage'); - return null; - } - - try { - const response = await fetch(`${this.mediaUrl}/api/v1/import/matrix`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mxcUrl, - app: 'nutriphi', - userId, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = (await response.json()) as MediaResult; - this.logger.log(`Stored media from Matrix: ${result.id} (hash: ${result.hash})`); - return result; - } catch (error) { - this.logger.error(`Failed to store media from Matrix: ${error}`); - return null; - } - } - - /** - * Check if media service is available - */ - isEnabled(): boolean { - return this.mediaUrl !== null; - } -} diff --git a/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.module.ts b/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.module.ts deleted file mode 100644 index 77fa15ec8..000000000 --- a/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { NutriPhiService } from './nutriphi.service'; - -@Module({ - providers: [NutriPhiService], - exports: [NutriPhiService], -}) -export class NutriPhiModule {} diff --git a/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts b/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts deleted file mode 100644 index 53222dd71..000000000 --- a/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -// Types from NutriPhi backend -export interface DetectedFood { - name: string; - quantity: string; - calories: number; - confidence: number; -} - -export interface NutritionData { - calories: number; - protein: number; - carbohydrates: number; - fat: number; - fiber: number; - sugar: number; -} - -export interface AIAnalysisResult { - foods: DetectedFood[]; - totalNutrition: NutritionData; - description: string; - confidence: number; - warnings?: string[]; - suggestions?: string[]; -} - -export interface UserGoals { - id: string; - dailyCalories: number; - dailyProtein?: number | null; - dailyCarbs?: number | null; - dailyFat?: number | null; -} - -export interface Meal { - id: string; - date: Date; - mealType: string; - description: string; - confidence: number; -} - -export interface MealWithNutrition extends Meal { - nutrition?: NutritionData; -} - -export interface DailySummary { - date: Date; - meals: MealWithNutrition[]; - totalNutrition: NutritionData; - goals?: UserGoals; - progress: { - calories: { current: number; target: number; percentage: number }; - protein?: { current: number; target: number; percentage: number }; - carbs?: { current: number; target: number; percentage: number }; - fat?: { current: number; target: number; percentage: number }; - }; -} - -export interface WeeklyStats { - startDate: Date; - endDate: Date; - days: { - date: Date; - totalCalories: number; - totalProtein: number; - totalCarbs: number; - totalFat: number; - mealCount: number; - goalsMet: boolean; - }[]; - averages: { - calories: number; - protein: number; - carbs: number; - fat: number; - }; -} - -export interface FavoriteMeal { - id: string; - name: string; - nutrition: NutritionData; - usageCount: number; -} - -export interface Recommendation { - id: string; - type: 'hint' | 'coaching'; - message: string; -} - -@Injectable() -export class NutriPhiService { - private readonly logger = new Logger(NutriPhiService.name); - private readonly backendUrl: string; - private readonly apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = - this.configService.get('nutriphi.backendUrl') || 'http://localhost:3023'; - this.apiPrefix = this.configService.get('nutriphi.apiPrefix') || '/api/v1'; - } - - private getUrl(path: string): string { - return `${this.backendUrl}${this.apiPrefix}${path}`; - } - - private async request( - path: string, - options: RequestInit & { token?: string } = {} - ): Promise { - const { token, ...fetchOptions } = options; - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(this.getUrl(path), { - ...fetchOptions, - headers, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`NutriPhi API error (${response.status}): ${error}`); - } - - return response.json(); - } - - async checkHealth(): Promise { - try { - const response = await fetch(this.getUrl('/health')); - return response.ok; - } catch { - return false; - } - } - - async analyzePhoto( - imageBase64: string, - mimeType: string, - token: string - ): Promise { - return this.request('/analysis/photo', { - method: 'POST', - body: JSON.stringify({ imageBase64, mimeType }), - token, - }); - } - - async analyzeText(description: string, token: string): Promise { - return this.request('/analysis/text', { - method: 'POST', - body: JSON.stringify({ description }), - token, - }); - } - - async createMeal( - data: { - description: string; - mealType: string; - inputType: 'photo' | 'text'; - nutrition: NutritionData; - confidence: number; - }, - token: string - ): Promise { - return this.request('/meals', { - method: 'POST', - body: JSON.stringify(data), - token, - }); - } - - async getDailySummary(date: string, token: string): Promise { - return this.request(`/stats/daily?date=${date}`, { token }); - } - - async getWeeklyStats(date: string, token: string): Promise { - return this.request(`/stats/weekly?date=${date}`, { token }); - } - - async getGoals(token: string): Promise { - try { - return await this.request('/goals', { token }); - } catch { - return null; - } - } - - async setGoals( - goals: { - dailyCalories: number; - dailyProtein?: number; - dailyCarbs?: number; - dailyFat?: number; - }, - token: string - ): Promise { - return this.request('/goals', { - method: 'POST', - body: JSON.stringify(goals), - token, - }); - } - - async getFavorites(token: string): Promise { - return this.request('/favorites', { token }); - } - - async createFavorite( - data: { name: string; nutrition: NutritionData }, - token: string - ): Promise { - return this.request('/favorites', { - method: 'POST', - body: JSON.stringify(data), - token, - }); - } - - async getRecommendations(token: string): Promise { - return this.request('/recommendations', { token }); - } -} diff --git a/services/matrix-nutriphi-bot/tsconfig.build.json b/services/matrix-nutriphi-bot/tsconfig.build.json deleted file mode 100644 index 4491981e0..000000000 --- a/services/matrix-nutriphi-bot/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/services/matrix-nutriphi-bot/tsconfig.json b/services/matrix-nutriphi-bot/tsconfig.json deleted file mode 100644 index b439390d0..000000000 --- a/services/matrix-nutriphi-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } -} diff --git a/services/matrix-ollama-bot/.dockerignore b/services/matrix-ollama-bot/.dockerignore deleted file mode 100644 index d6a8859ae..000000000 --- a/services/matrix-ollama-bot/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -dist -.git -*.log -.env* -data diff --git a/services/matrix-ollama-bot/.env.example b/services/matrix-ollama-bot/.env.example deleted file mode 100644 index d064c4f02..000000000 --- a/services/matrix-ollama-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3311 - -# Matrix Configuration -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_your_access_token_here -# Optional: Restrict to specific rooms (comma-separated) -MATRIX_ALLOWED_ROOMS= -# Path for bot sync storage -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Ollama Configuration -OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=gemma3:4b -OLLAMA_TIMEOUT=120000 diff --git a/services/matrix-ollama-bot/CLAUDE.md b/services/matrix-ollama-bot/CLAUDE.md deleted file mode 100644 index db02699d1..000000000 --- a/services/matrix-ollama-bot/CLAUDE.md +++ /dev/null @@ -1,137 +0,0 @@ -# Matrix Ollama Bot - Claude Code Guidelines - -## Overview - -Matrix Ollama Bot provides a GDPR-compliant chat interface to local LLM inference via Ollama. It uses the Matrix protocol for messaging, which allows self-hosting all data on the Mac Mini server. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **LLM**: mana-llm service (supports Ollama + cloud providers) - -## 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-ollama-bot/ -├── src/ -│ ├── main.ts # Application entry point -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & system prompts -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ └── ollama/ -│ ├── ollama.module.ts -│ └── ollama.service.ts # Ollama API client -├── Dockerfile -└── package.json -``` - -## Matrix Commands - -| Command | Description | -|---------|-------------| -| `!help` | Show help message | -| `!models` | List available Ollama models | -| `!model [name]` | Switch to a different model | -| `!mode [mode]` | Change system prompt mode | -| `!clear` | Clear chat history | -| `!status` | Show Ollama connection status | - -## System Prompt Modes - -| Mode | Description | -|------|-------------| -| `default` | General assistant | -| `classify` | Text classification | -| `summarize` | Text summarization | -| `translate` | Translation | -| `code` | Programming help | - -## Environment Variables - -```env -# Server -PORT=3311 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#ollama-bot:mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# LLM (via mana-llm service) -MANA_LLM_URL=http://localhost:3025 -LLM_MODEL=ollama/gemma3:4b -LLM_TIMEOUT=120000 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-ollama-bot/Dockerfile -t matrix-ollama-bot services/matrix-ollama-bot - -# Run -docker run -p 3311:3311 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e MANA_LLM_URL=http://mana-llm:3025 \ - -v matrix-ollama-bot-data:/app/data \ - matrix-ollama-bot -``` - -## Health Check - -```bash -curl http://localhost:3311/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": "ollama-bot", - "password": "your-password" - }' - -# Response contains: {"access_token": "syt_xxx", ...} -``` - -## Key Differences from Telegram Bot - -| Feature | Telegram | Matrix | -|---------|----------|--------| -| Commands | `/command` | `!command` | -| Message limit | 4096 chars | ~65535 chars | -| Data storage | Telegram servers | Self-hosted | -| E2E encryption | Bot chats unencrypted | Optional (not enabled) | -| Typing indicator | `sendChatAction` | `sendTyping` | - -## GDPR Compliance - -- All message data stored locally on Mac Mini -- No third-party data processing -- Full control over data retention -- Can delete all user data on request diff --git a/services/matrix-ollama-bot/Dockerfile b/services/matrix-ollama-bot/Dockerfile deleted file mode 100644 index dd71b85cf..000000000 --- a/services/matrix-ollama-bot/Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common -COPY packages/shared-llm ./packages/shared-llm - -# Copy this bot -COPY services/matrix-ollama-bot ./services/matrix-ollama-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-ollama-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-ollama-bot/dist ./services/matrix-ollama-bot/dist -COPY --from=builder /app/services/matrix-ollama-bot/package.json ./services/matrix-ollama-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-ollama-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4011/health || exit 1 - -EXPOSE 4011 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-ollama-bot/nest-cli.json b/services/matrix-ollama-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-ollama-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-ollama-bot/package.json b/services/matrix-ollama-bot/package.json deleted file mode 100644 index 3af8c5af9..000000000 --- a/services/matrix-ollama-bot/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@manacore/matrix-ollama-bot", - "version": "1.0.0", - "description": "Matrix bot for local LLM inference via Ollama - GDPR compliant", - "private": true, - "license": "MIT", - "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": "tsc -p tsconfig.build.json", - "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@manacore/shared-llm": "workspace:^", - "@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", - "@nestjs/schematics": "^10.2.3", - "@types/node": "^22.10.5", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-ollama-bot/src/app.module.ts b/services/matrix-ollama-bot/src/app.module.ts deleted file mode 100644 index dcdde94e8..000000000 --- a/services/matrix-ollama-bot/src/app.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { LlmModule } from '@manacore/shared-llm'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - LlmModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - manaLlmUrl: config.get('llm.url') || 'http://localhost:3025', - defaultModel: config.get('llm.model') || 'ollama/gemma3:4b', - timeout: config.get('llm.timeout') || 120000, - }), - inject: [ConfigService], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-ollama-bot')], -}) -export class AppModule {} diff --git a/services/matrix-ollama-bot/src/bot/bot.module.ts b/services/matrix-ollama-bot/src/bot/bot.module.ts deleted file mode 100644 index 90685a1fa..000000000 --- a/services/matrix-ollama-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { OllamaModule } from '../ollama/ollama.module'; -import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - OllamaModule, - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - SessionModule.forRoot({ storageMode: 'redis' }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-ollama-bot/src/bot/matrix.service.ts b/services/matrix-ollama-bot/src/bot/matrix.service.ts deleted file mode 100644 index 66669cef5..000000000 --- a/services/matrix-ollama-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services'; -import { OllamaService } from '../ollama/ollama.service'; -import { SYSTEM_PROMPTS } from '../config/configuration'; - -// Ollama is local, so credits are minimal -const OLLAMA_CHAT_CREDITS = 0.1; - -interface UserSession { - systemPrompt: string; - model: string; - history: { role: 'user' | 'assistant'; content: string }[]; - pendingImage?: { url: string; mimeType: string }; -} - -// Models excluded from !all comparison (specialized, not for general chat) -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 keyword detector -const keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['modelle', 'models', 'welche modelle', 'liste modelle'], command: 'models' }, - { keywords: ['verbindung', 'connection', 'online'], command: 'status' }, - { keywords: ['lösche verlauf', 'clear', 'neustart', 'reset', 'vergiss alles'], command: 'clear' }, -]); - -@Injectable() -export class MatrixService extends BaseMatrixService { - private sessions: Map = new Map(); - - constructor( - configService: ConfigService, - private readonly transcriptionService: TranscriptionService, - private ollamaService: OllamaService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendMessage(roomId, '❌ Sprachnachricht konnte nicht erkannt werden.'); - return; - } - - await this.sendMessage(roomId, `🎤 *"${text}"*`); - await this.handleTextMessage(roomId, event, text, sender); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendMessage(roomId, '❌ Fehler bei der Spracherkennung.'); - } - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string | null { - return `**Hallo! Ich bin Manai, eure lokale KI-Assistentin.** - -Alle Daten bleiben auf diesem Server - 100% DSGVO-konform! - -**Quick Start:** -- Schreibt einfach eine Nachricht -- Sagt "hilfe" für alle Befehle -- Sagt "modelle" um KI-Modelle zu sehen`; - } - - async onModuleInit() { - await super.onModuleInit(); - - if (!this.client) return; - - this.botUserId = await this.client.getUserId(); - this.logger.log(`Bot user ID: ${this.botUserId}`); - - // Setup room join handler for welcome message - this.client.on('room.join', this.handleRoomJoin.bind(this)); - - // Handle image messages - this.client.on('room.message', async (roomId: string, event: any) => { - if (event.sender === this.botUserId) return; - - const content = event.content as { - msgtype?: string; - body?: string; - url?: string; - info?: { mimetype?: string }; - }; - - // Handle image messages - store for later use with !vision - if (content.msgtype === 'm.image' && content.url) { - const session = this.getSession(event.sender); - session.pendingImage = { - url: content.url, - mimeType: content.info?.mimetype || 'image/png', - }; - this.logger.log(`Image received from ${event.sender}, stored for !vision command`); - await this.sendMessage( - roomId, - `Bild empfangen! Nutze jetzt:\n- \`!vision [Frage zum Bild]\` - Bild mit einem Modell analysieren\n- \`!vision:all [Frage]\` - Bild mit allen Vision-Modellen vergleichen` - ); - } - }); - } - - private async handleRoomJoin(roomId: string, event: any) { - // Only send welcome when someone else joins (not the bot itself) - if (event.state_key === this.botUserId) return; - if (!this.isRoomAllowed(roomId)) return; - - this.logger.log(`User ${event.state_key} joined room ${roomId}`); - - // Send welcome message - await this.sendWelcomeMessage(roomId, event.state_key); - } - - private async sendWelcomeMessage(roomId: string, userId: string) { - const welcomeText = `**Willkommen im Mana Chat, ${this.extractUsername(userId)}!** - -Ich bin **Manai**, deine lokale KI-Assistentin (100% DSGVO-konform). - -**So nutzt du mich:** -- Schreib einfach eine Nachricht - ich antworte! -- Sag "hilfe" oder "modelle" für mehr Infos -- Oder nutze Befehle wie \`!help\` - -**Quick Start:** -- "Was ist TypeScript?" -> Ich erkläre es dir -- "modelle" -> Zeigt verfügbare KI-Modelle -- \`!all Erkläre Recursion\` -> Vergleicht alle Modelle - -Viel Spass!`; - - await this.sendMessage(roomId, welcomeText); - } - - private extractUsername(userId: string): string { - // Extract username from @user:server.com format - const match = userId.match(/@([^:]+)/); - return match ? match[1] : userId; - } - - private getSession(senderId: string): UserSession { - if (!this.sessions.has(senderId)) { - this.sessions.set(senderId, { - systemPrompt: SYSTEM_PROMPTS.default, - model: this.ollamaService.getDefaultModel(), - history: [], - }); - } - return this.sessions.get(senderId)!; - } - - protected async handleTextMessage( - roomId: string, - _event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - // Handle commands with ! prefix - if (message.startsWith('!')) { - await this.handleCommand(roomId, sender, message); - return; - } - - // Check for natural language keywords - const detectedCommand = keywordDetector.detect(message); - if (detectedCommand) { - this.logger.log(`Detected keyword command: ${detectedCommand}`); - await this.handleCommand(roomId, sender, `!${detectedCommand}`); - return; - } - - // Regular chat message - await this.handleChat(roomId, sender, message); - } - - 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 'start': - await this.sendHelp(roomId); - break; - - case 'models': - await this.sendModels(roomId, sender); - break; - - case 'model': - await this.setModel(roomId, sender, argString); - break; - - case 'mode': - await this.setMode(roomId, sender, argString); - break; - - case 'clear': - await this.clearHistory(roomId, sender); - break; - - case 'status': - await this.sendStatus(roomId, sender); - break; - - case 'all': - await this.handleAllModels(roomId, sender, argString); - break; - - case 'vision': - await this.handleVision(roomId, sender, argString); - break; - - case 'vision:all': - await this.handleVisionAll(roomId, sender, argString); - break; - - case 'pin': - await this.pinHelpMessage(roomId); - await this.sendMessage(roomId, 'Hilfe wurde angepinnt!'); - break; - - default: - await this.sendMessage( - roomId, - `Unbekannter Befehl: !${command}\n\nVerwende !help für eine Liste der Befehle.` - ); - } - } - - private async sendHelp(roomId: string) { - const helpText = `**Manai - Lokale KI (100% DSGVO-konform)** - -**Einfache Befehle** (sag einfach): -- "hilfe" - Diese Hilfe -- "modelle" - Verfügbare KI-Modelle -- "status" - Verbindungsstatus -- "lösche verlauf" - Chat zurücksetzen - -**Power-User Befehle** (mit !): -- \`!model [name]\` - Modell wechseln -- \`!all [frage]\` - Alle Modelle vergleichen -- \`!mode [modus]\` - Modus ändern (default/code/translate/summarize) - -**Bild-Analyse:** -1. Sende ein Bild -2. Dann: \`!vision [frage]\` oder \`!vision:all [frage]\` - -**Verwendung:** -Schreibe einfach eine Nachricht und ich antworte! - -**Beispiele:** -- "Was ist Kubernetes?" -> Direkte Antwort -- "modelle" -> Zeigt alle Modelle -- \`!all Erkläre Docker\` -> Vergleicht alle Modelle - -**Aktuelles Modell:** \`${this.ollamaService.getDefaultModel()}\``; - - await this.sendMessage(roomId, helpText); - } - - private async sendModels(roomId: string, sender: string) { - const models = await this.ollamaService.listModels(); - if (models.length === 0) { - await this.sendMessage(roomId, 'Keine Modelle gefunden. Ist Ollama gestartet?'); - return; - } - - const session = this.getSession(sender); - const modelList = models - .map((m) => { - const sizeMB = (m.size / 1024 / 1024).toFixed(0); - const active = m.name === session.model ? ' ✓' : ''; - return `- \`${m.name}\` (${sizeMB} MB)${active}`; - }) - .join('\n'); - - await this.sendMessage( - roomId, - `**Verfügbare Modelle:**\n\n${modelList}\n\nWechseln mit: \`!model [name]\`` - ); - } - - private async setModel(roomId: string, sender: string, modelName: string) { - if (!modelName) { - const session = this.getSession(sender); - await this.sendMessage( - roomId, - `Aktuelles Modell: \`${session.model}\`\n\nVerwendung: \`!model gemma3:4b\`` - ); - return; - } - - const models = await this.ollamaService.listModels(); - const exists = models.some((m) => m.name === modelName); - - if (!exists) { - const available = models.map((m) => m.name).join(', '); - await this.sendMessage( - roomId, - `Modell "${modelName}" nicht gefunden.\n\nVerfügbar: ${available}` - ); - return; - } - - const session = this.getSession(sender); - session.model = modelName; - session.history = []; - - this.logger.log(`User ${sender} switched to model ${modelName}`); - await this.sendMessage(roomId, `Modell gewechselt zu: \`${modelName}\``); - } - - private async setMode(roomId: string, sender: string, mode: string) { - const availableModes = Object.keys(SYSTEM_PROMPTS); - - if (!mode) { - const session = this.getSession(sender); - const currentMode = - Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt)?.[0] || - 'custom'; - await this.sendMessage( - roomId, - `Aktueller Modus: \`${currentMode}\`\n\nVerfügbar: ${availableModes.join(', ')}` - ); - return; - } - - const normalizedMode = mode.toLowerCase(); - if (!SYSTEM_PROMPTS[normalizedMode]) { - await this.sendMessage( - roomId, - `Unbekannter Modus: ${mode}\n\nVerfügbar: ${availableModes.join(', ')}` - ); - return; - } - - const session = this.getSession(sender); - session.systemPrompt = SYSTEM_PROMPTS[normalizedMode]; - session.history = []; - - this.logger.log(`User ${sender} switched to mode ${normalizedMode}`); - await this.sendMessage(roomId, `Modus gewechselt zu: \`${normalizedMode}\``); - } - - private async clearHistory(roomId: string, sender: string) { - const session = this.getSession(sender); - session.history = []; - - this.logger.log(`User ${sender} cleared history`); - await this.sendMessage(roomId, 'Chat-Verlauf gelöscht.'); - } - - private async sendStatus(roomId: string, sender: string) { - const connected = await this.ollamaService.checkConnection(); - const models = await this.ollamaService.listModels(); - const session = this.getSession(sender); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const authSession = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusText = `**Ollama Status**\n\n`; - statusText += `**Verbindung:** ${connected ? 'Online' : 'Offline'}\n`; - statusText += `**Modelle:** ${models.length}\n`; - statusText += `**Dein Modell:** \`${session.model}\`\n`; - statusText += `**Chat-Verlauf:** ${session.history.length} Nachrichten\n`; - - if (loggedIn && authSession && token) { - const balance = await this.creditService.getBalance(token); - statusText += `**👤 Angemeldet als:** ${authSession.email}\n`; - statusText += `**⚡ Credits:** ${balance.balance.toFixed(2)}\n`; - } - - statusText += `**DSGVO:** Alle Daten lokal`; - - await this.sendMessage(roomId, statusText); - } - - private async handleAllModels(roomId: string, sender: string, message: string) { - if (!message.trim()) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!all [Deine Frage]\`\n\nBeispiel: \`!all Was ist 2+2?\`\n\nDie Frage wird an alle Chat-Modelle gesendet und du siehst die Antworten zum Vergleich.` - ); - return; - } - - const allModels = await this.ollamaService.listModels(); - // Filter out non-chat models (OCR, specialized models) - const models = allModels.filter((m) => !NON_CHAT_MODELS.includes(m.name)); - - if (models.length === 0) { - await this.sendMessage(roomId, 'Keine Chat-Modelle gefunden. Ist Ollama gestartet?'); - return; - } - - const skipped = allModels.length - models.length; - const skippedNote = skipped > 0 ? ` (${skipped} spezialisierte Modelle übersprungen)` : ''; - - await this.sendMessage( - roomId, - `**Vergleiche ${models.length} Chat-Modelle...**${skippedNote}\n\nFrage: "${message}"` - ); - - // Send typing indicator - await this.client.setTyping(roomId, true, 300000); - - const session = this.getSession(sender); - const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [ - { role: 'system', content: session.systemPrompt }, - { role: 'user', content: message }, - ]; - - const results: { model: string; response: string; duration: number; error?: string }[] = []; - - for (const model of models) { - const startTime = Date.now(); - try { - this.logger.log(`Querying model ${model.name}...`); - const response = await this.ollamaService.chat(messages, model.name); - const duration = Date.now() - startTime; - results.push({ model: model.name, response, duration }); - } catch (error) { - const duration = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - results.push({ model: model.name, response: '', duration, error: errorMessage }); - } - } - - await this.client.setTyping(roomId, false); - - // Format results - let resultText = `**Modellvergleich**\n\n**Frage:** "${message}"\n\n---\n\n`; - - for (const result of results) { - const durationSec = (result.duration / 1000).toFixed(1); - if (result.error) { - resultText += `**${result.model}** ${durationSec}s\nFehler: ${result.error}\n\n---\n\n`; - } else { - // Truncate long responses for readability - const truncatedResponse = - result.response.length > 500 - ? result.response.substring(0, 500) + '...' - : result.response; - resultText += `**${result.model}** ${durationSec}s\n${truncatedResponse}\n\n---\n\n`; - } - } - - await this.sendMessage(roomId, resultText); - } - - private async handleChat(roomId: string, sender: string, message: string) { - const session = this.getSession(sender); - - // Send typing indicator - await this.client.setTyping(roomId, true, 30000); - - try { - // Add user message to history - session.history.push({ role: 'user', content: message }); - - // Keep only last 10 messages - if (session.history.length > 10) { - session.history = session.history.slice(-10); - } - - // Build messages with system prompt - const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [ - { role: 'system', content: session.systemPrompt }, - ...session.history, - ]; - - const response = await this.ollamaService.chat(messages, session.model); - - // Add assistant response to history - session.history.push({ role: 'assistant', content: response }); - - // Stop typing indicator - await this.client.setTyping(roomId, false); - - // Send response (Matrix has higher message limits than Telegram) - await this.sendMessage(roomId, response); - } catch (error) { - await this.client.setTyping(roomId, false); - this.logger.error(`Error processing message:`, error); - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMessage}`); - } - } - - private async handleVision(roomId: string, sender: string, prompt: string) { - const session = this.getSession(sender); - - if (!session.pendingImage) { - await this.sendMessage( - roomId, - `Kein Bild vorhanden!\n\nSende zuerst ein Bild, dann nutze \`!vision [Frage zum Bild]\`` - ); - return; - } - - if (!prompt.trim()) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!vision [Deine Frage zum Bild]\`\n\nBeispiel: \`!vision Was siehst du auf diesem Bild?\`` - ); - return; - } - - // Find available vision models - const allModels = await this.ollamaService.listModels(); - const visionModels = allModels.filter((m) => VISION_MODELS.some((v) => m.name.includes(v))); - - if (visionModels.length === 0) { - await this.sendMessage( - roomId, - `Keine Vision-Modelle gefunden!\n\nInstalliere ein Vision-Modell mit:\n\`ollama pull llava\`` - ); - return; - } - - const model = visionModels[0].name; - await this.sendMessage(roomId, `Analysiere Bild mit \`${model}\`...`); - await this.client.setTyping(roomId, true, 120000); - - try { - // Download image from Matrix - const imageData = await this.downloadMatrixImage(session.pendingImage.url); - - const response = await this.ollamaService.chatWithImage(prompt, imageData, model); - - await this.client.setTyping(roomId, false); - await this.sendMessage(roomId, `**${model}:**\n\n${response}`); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler bei der Bildanalyse: ${errorMsg}`); - } - } - - private async handleVisionAll(roomId: string, sender: string, prompt: string) { - const session = this.getSession(sender); - - if (!session.pendingImage) { - await this.sendMessage( - roomId, - `Kein Bild vorhanden!\n\nSende zuerst ein Bild, dann nutze \`!vision:all [Frage zum Bild]\`` - ); - return; - } - - if (!prompt.trim()) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!vision:all [Deine Frage zum Bild]\`\n\nBeispiel: \`!vision:all Beschreibe was du siehst\`` - ); - return; - } - - // Find available vision models - const allModels = await this.ollamaService.listModels(); - const visionModels = allModels.filter((m) => VISION_MODELS.some((v) => m.name.includes(v))); - - if (visionModels.length === 0) { - await this.sendMessage( - roomId, - `Keine Vision-Modelle gefunden!\n\nInstalliere Vision-Modelle mit:\n\`ollama pull llava\`\n\`ollama pull moondream\`` - ); - return; - } - - await this.sendMessage( - roomId, - `**Vergleiche ${visionModels.length} Vision-Modelle...**\n\nFrage: "${prompt}"` - ); - await this.client.setTyping(roomId, true, 300000); - - try { - // Download image from Matrix once - const imageData = await this.downloadMatrixImage(session.pendingImage.url); - - const results: { model: string; response: string; duration: number; error?: string }[] = []; - - for (const model of visionModels) { - const startTime = Date.now(); - try { - this.logger.log(`Querying vision model ${model.name}...`); - const response = await this.ollamaService.chatWithImage(prompt, imageData, model.name); - const duration = Date.now() - startTime; - results.push({ model: model.name, response, duration }); - } catch (error) { - const duration = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - results.push({ model: model.name, response: '', duration, error: errorMessage }); - } - } - - await this.client.setTyping(roomId, false); - - // Format results - let resultText = `**Vision-Modellvergleich**\n\n**Frage:** "${prompt}"\n\n---\n\n`; - - for (const result of results) { - const durationSec = (result.duration / 1000).toFixed(1); - if (result.error) { - resultText += `**${result.model}** ${durationSec}s\nFehler: ${result.error}\n\n---\n\n`; - } else { - const truncatedResponse = - result.response.length > 500 - ? result.response.substring(0, 500) + '...' - : result.response; - resultText += `**${result.model}** ${durationSec}s\n${truncatedResponse}\n\n---\n\n`; - } - } - - await this.sendMessage(roomId, resultText); - } catch (error) { - await this.client.setTyping(roomId, false); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - private async downloadMatrixImage(mxcUrl: string): Promise { - this.logger.log(`Downloading image from ${mxcUrl}`); - - // Use the authenticated download method from BaseMatrixService - const buffer = await this.downloadMedia(mxcUrl); - return buffer.toString('base64'); - } - - private async pinHelpMessage(roomId: string) { - try { - const helpContent = this.getHelpContent(); - const htmlBody = this.markdownToHtmlLocal(helpContent); - - const eventId = await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: helpContent, - format: 'org.matrix.custom.html', - formatted_body: htmlBody, - }); - - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [eventId], - }); - - this.logger.log(`Pinned help message in room ${roomId}`); - } catch (error) { - this.logger.error(`Failed to pin help message in ${roomId}:`, error); - } - } - - private getHelpContent(): string { - return `**Manai - Befehls-Übersicht** - -**Einfach sagen:** -- "hilfe" - Diese Übersicht -- "modelle" - Verfügbare KI-Modelle -- "status" - Bot-Status -- "lösche verlauf" - Chat zurücksetzen - -**Power-User (mit !):** -- \`!model [name]\` - Modell wechseln -- \`!all [frage]\` - Alle Modelle vergleichen -- \`!vision [frage]\` - Bild analysieren - -**Nutzung:** Einfach schreiben und ich antworte!`; - } - - private markdownToHtmlLocal(markdown: string): string { - return ( - markdown - // Code blocks - .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') - // Inline code - .replace(/`([^`]+)`/g, '$1') - // Bold - .replace(/\*\*([^*]+)\*\*/g, '$1') - // Italic - .replace(/\*([^*]+)\*/g, '$1') - // Line breaks - .replace(/\n/g, '
') - ); - } -} diff --git a/services/matrix-ollama-bot/src/config/configuration.ts b/services/matrix-ollama-bot/src/config/configuration.ts deleted file mode 100644 index 97f9d5d46..000000000 --- a/services/matrix-ollama-bot/src/config/configuration.ts +++ /dev/null @@ -1,22 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3311', 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', - }, - llm: { - url: process.env.MANA_LLM_URL || 'http://localhost:3025', - model: process.env.LLM_MODEL || 'ollama/gemma3:4b', - timeout: parseInt(process.env.LLM_TIMEOUT || '120000', 10), - }, -}); - -export const SYSTEM_PROMPTS: Record = { - default: `Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, wenn der Nutzer Deutsch schreibt. Halte deine Antworten prägnant und hilfreich.`, - classify: `Du bist ein Textklassifizierer. Analysiere den gegebenen Text und ordne ihn einer passenden Kategorie zu. Gib nur die Kategorie und eine kurze Begründung an.`, - summarize: `Du bist ein Zusammenfassungs-Experte. Fasse den gegebenen Text kurz und präzise zusammen. Behalte die wichtigsten Informationen bei.`, - translate: `Du bist ein Übersetzer. Übersetze den Text in die gewünschte Sprache. Wenn keine Zielsprache angegeben ist, übersetze zwischen Deutsch und Englisch.`, - code: `Du bist ein Programmier-Assistent. Hilf bei Code-Fragen, erkläre Konzepte und schreibe sauberen, gut dokumentierten Code. Verwende Markdown Code-Blöcke für Code.`, -}; diff --git a/services/matrix-ollama-bot/src/main.ts b/services/matrix-ollama-bot/src/main.ts deleted file mode 100644 index 0ecb02c07..000000000 --- a/services/matrix-ollama-bot/src/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - const port = process.env.PORT || 3311; - await app.listen(port); - - logger.log(`Matrix Ollama Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} -bootstrap(); diff --git a/services/matrix-ollama-bot/src/ollama/ollama.module.ts b/services/matrix-ollama-bot/src/ollama/ollama.module.ts deleted file mode 100644 index a0ae211c4..000000000 --- a/services/matrix-ollama-bot/src/ollama/ollama.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { OllamaService } from './ollama.service'; - -@Module({ - providers: [OllamaService], - exports: [OllamaService], -}) -export class OllamaModule {} diff --git a/services/matrix-ollama-bot/src/ollama/ollama.service.ts b/services/matrix-ollama-bot/src/ollama/ollama.service.ts deleted file mode 100644 index df6e1524c..000000000 --- a/services/matrix-ollama-bot/src/ollama/ollama.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { LlmClientService } from '@manacore/shared-llm'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class OllamaService implements OnModuleInit { - private readonly logger = new Logger(OllamaService.name); - private readonly defaultModel: string; - - constructor( - private readonly llm: LlmClientService, - private configService: ConfigService - ) { - this.defaultModel = this.configService.get('llm.model') || 'ollama/gemma3:4b'; - } - - async onModuleInit() { - await this.checkConnection(); - } - - async checkConnection(): Promise { - try { - const health = await this.llm.health(); - this.logger.log( - `mana-llm connected: ${health.status}, providers: ${Object.keys(health.providers || {}).join(', ')}` - ); - return health.status === 'healthy' || health.status === 'degraded'; - } catch (error) { - this.logger.error('Failed to connect to mana-llm:', error); - return false; - } - } - - async listModels(): Promise<{ name: string; size: number; modified_at: string }[]> { - try { - const models = await this.llm.listModels(); - return models.map((m) => ({ - name: m.id, - size: 0, - modified_at: new Date().toISOString(), - })); - } catch (error) { - this.logger.error('Failed to list models:', error); - return []; - } - } - - async chat( - messages: { role: 'user' | 'assistant' | 'system'; content: string }[], - model?: string - ): Promise { - const selectedModel = model ? this.normalizeModel(model) : this.defaultModel; - - const result = await this.llm.chatMessages(messages, { model: selectedModel }); - - if (result.usage.completion_tokens) { - this.logger.debug( - `Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})` - ); - } - - return result.content; - } - - getDefaultModel(): string { - return this.defaultModel; - } - - async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise { - const selectedModel = model ? this.normalizeModel(model) : this.defaultModel; - - const result = await this.llm.vision(prompt, imageBase64, 'image/png', { - model: selectedModel, - }); - - if (result.usage.completion_tokens) { - this.logger.debug( - `Vision: Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})` - ); - } - - return result.content; - } - - private normalizeModel(model: string): string { - if (model.includes('/')) { - return model; - } - return `ollama/${model}`; - } -} diff --git a/services/matrix-ollama-bot/tsconfig.build.json b/services/matrix-ollama-bot/tsconfig.build.json deleted file mode 100644 index f2050bc26..000000000 --- a/services/matrix-ollama-bot/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} diff --git a/services/matrix-ollama-bot/tsconfig.json b/services/matrix-ollama-bot/tsconfig.json deleted file mode 100644 index b439390d0..000000000 --- a/services/matrix-ollama-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } -} diff --git a/services/matrix-onboarding-bot/CLAUDE.md b/services/matrix-onboarding-bot/CLAUDE.md deleted file mode 100644 index deb257118..000000000 --- a/services/matrix-onboarding-bot/CLAUDE.md +++ /dev/null @@ -1,193 +0,0 @@ -# Matrix Onboarding Bot - Claude Code Guidelines - -## Overview - -Matrix Onboarding Bot guides new users through a profile setup process. It collects display name, interests, and language preference, storing them in mana-core-auth's globalSettings. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk via @manacore/matrix-bot-common -- **Auth**: mana-core-auth (Settings API) -- **Sessions**: Redis via @manacore/bot-services - -## 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-onboarding-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 4020) -│ ├── app.module.ts # Root module -│ ├── config/ -│ │ └── configuration.ts # Configuration & messages (de/en) -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ └── onboarding/ -│ ├── onboarding.module.ts -│ ├── onboarding.service.ts # API client for mana-core-auth -│ └── state-machine.ts # Onboarding flow state machine -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Description | -|---------|-------------| -| `!start` | Start onboarding (or restart if completed) | -| `!profile` | Show current profile | -| `!edit name Max` | Change display name | -| `!edit interests KI, Musik` | Change interests | -| `!edit language de` | Change language (de/en) | -| `!skip` | Skip current question (if allowed) | -| `!cancel` | Cancel onboarding | -| `!help` | Show help text | - -## Onboarding Flow - -``` -IDLE → NAME → INTERESTS → LANGUAGE → SUMMARY → COMPLETED - ↓ ↓ - SKIP SKIP -``` - -1. **NAME** (required): Ask for display name -2. **INTERESTS** (skippable): Ask for interests (comma-separated) -3. **LANGUAGE** (skippable): Ask for language preference (de/en) -4. **SUMMARY**: Show profile and ask for confirmation -5. **COMPLETED**: Save to mana-core-auth and finish - -## Data Storage - -Profile data is stored in mana-core-auth's `user_settings.globalSettings`: - -```typescript -interface GlobalSettings { - // ... existing fields - displayName?: string; // From onboarding - interests?: string[]; // From onboarding - onboardingCompleted?: boolean; -} -``` - -## Environment Variables - -```env -# Server -PORT=4020 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#onboarding:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# mana-core-auth -MANA_CORE_AUTH_URL=http://localhost:3001 -MANA_CORE_SERVICE_KEY=your-service-key - -# Redis (for session storage) -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD=redis123 -``` - -## API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/settings` | GET | Get user settings | -| `/api/v1/settings/global` | PATCH | Update global settings | - -## Docker - -```bash -# Build -docker build -f services/matrix-onboarding-bot/Dockerfile -t matrix-onboarding-bot . - -# Run -docker run -p 4020:4020 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e MANA_CORE_AUTH_URL=http://mana-auth:3001 \ - -e REDIS_HOST=redis \ - -v matrix-bots-data:/app/data \ - matrix-onboarding-bot -``` - -## Health Check - -```bash -curl http://localhost:4020/health -``` - -## Authentication - -The bot requires users to be logged in via Matrix-SSO-Link (shared Redis session). - -1. User logs in via Matrix SSO link in another bot or web app -2. Session stored in Redis with Matrix user ID as key -3. Onboarding bot retrieves token from Redis -4. Token used to call mana-core-auth Settings API - -## Example Dialog - -``` -Bot: Willkommen beim Onboarding! - - Ich helfe dir, dein Profil einzurichten. - Wie mochtest du genannt werden? - -User: Max - -Bot: Hallo Max! Was sind deine Interessen? - (z.B. Programmierung, Musik - durch Komma getrennt) - Sag `!skip` zum Uberspringen. - -User: KI, Gaming, Musik - -Bot: Welche Sprache bevorzugst du? - Antworte mit `de` fur Deutsch oder `en` fur Englisch. - -User: de - -Bot: **Dein Profil:** - - Name: Max - - Interessen: KI, Gaming, Musik - - Sprache: Deutsch - - Ist das korrekt? (ja/nein) - -User: ja - -Bot: Perfekt! Dein Profil ist eingerichtet. - Du kannst es jederzeit mit `!profile` anzeigen oder mit `!edit` andern. -``` - -## Localization - -The bot supports German (de) and English (en). Messages are defined in `src/config/configuration.ts` under the `MESSAGES` object. - -## State Machine - -The `OnboardingStateMachine` class in `src/onboarding/state-machine.ts` is a pure function that: -- Takes current state + action -- Returns new state + message key -- Has no side effects - -This makes it easy to test and reason about the flow. diff --git a/services/matrix-onboarding-bot/Dockerfile b/services/matrix-onboarding-bot/Dockerfile deleted file mode 100644 index 10a3b4494..000000000 --- a/services/matrix-onboarding-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-onboarding-bot ./services/matrix-onboarding-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-onboarding-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-onboarding-bot/dist ./services/matrix-onboarding-bot/dist -COPY --from=builder /app/services/matrix-onboarding-bot/package.json ./services/matrix-onboarding-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-onboarding-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4020/health || exit 1 - -EXPOSE 4020 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-onboarding-bot/package.json b/services/matrix-onboarding-bot/package.json deleted file mode 100644 index 98f2d2b6e..000000000 --- a/services/matrix-onboarding-bot/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@manacore/matrix-onboarding-bot", - "version": "1.0.0", - "description": "Matrix bot for user onboarding and profile setup", - "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": "tsc -p tsconfig.build.json", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@nestjs/common": "^10.4.17", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.17", - "@nestjs/platform-express": "^10.4.17", - "matrix-bot-sdk": "^0.7.1", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@types/express": "^5.0.6", - "@types/node": "^22.10.7", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-onboarding-bot/scripts/create-bot-user.sh b/services/matrix-onboarding-bot/scripts/create-bot-user.sh deleted file mode 100755 index 0f15e3b0f..000000000 --- a/services/matrix-onboarding-bot/scripts/create-bot-user.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# Create Matrix bot user for onboarding bot -# Run this script on the Mac Mini server - -set -e - -# Configuration -BOT_USERNAME="onboarding-bot" -BOT_PASSWORD="$(openssl rand -base64 32)" -HOMESERVER_URL="${MATRIX_HOMESERVER_URL:-http://localhost:4000}" -REGISTRATION_SECRET="${SYNAPSE_REGISTRATION_SECRET:-}" - -echo "=== Matrix Onboarding Bot User Setup ===" -echo "" - -# Check if registration secret is set -if [ -z "$REGISTRATION_SECRET" ]; then - echo "Error: SYNAPSE_REGISTRATION_SECRET environment variable not set" - echo "Run: export SYNAPSE_REGISTRATION_SECRET=" - exit 1 -fi - -# Generate the HMAC for registration -generate_mac() { - local nonce="$1" - local username="$2" - local password="$3" - local admin="$4" - - echo -n "${nonce}\x00${username}\x00${password}\x00${admin}" | \ - openssl dgst -sha1 -hmac "$REGISTRATION_SECRET" | \ - awk '{print $2}' -} - -echo "1. Getting registration nonce..." -NONCE=$(curl -s "${HOMESERVER_URL}/_synapse/admin/v1/register" | jq -r '.nonce') - -if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then - echo "Error: Could not get registration nonce" - exit 1 -fi - -echo " Nonce: ${NONCE:0:20}..." - -echo "" -echo "2. Registering bot user: @${BOT_USERNAME}:matrix.mana.how" - -MAC=$(generate_mac "$NONCE" "$BOT_USERNAME" "$BOT_PASSWORD" "notadmin") - -REGISTER_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_synapse/admin/v1/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"nonce\": \"$NONCE\", - \"username\": \"$BOT_USERNAME\", - \"password\": \"$BOT_PASSWORD\", - \"admin\": false, - \"mac\": \"$MAC\" - }") - -# Check if user already exists -if echo "$REGISTER_RESPONSE" | grep -q "User ID already taken"; then - echo " User already exists, logging in instead..." - - # Login to get access token - LOGIN_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_matrix/client/v3/login" \ - -H "Content-Type: application/json" \ - -d "{ - \"type\": \"m.login.password\", - \"user\": \"$BOT_USERNAME\", - \"password\": \"$BOT_PASSWORD\" - }") - - if echo "$LOGIN_RESPONSE" | grep -q "Invalid username"; then - echo " Cannot login with generated password." - echo " You need to reset the password or use existing credentials." - echo "" - echo " To reset password, run in Synapse container:" - echo " docker exec -it mana-matrix-synapse /bin/bash" - echo " register_new_matrix_user -c /config/homeserver.yaml -u $BOT_USERNAME -p --no-admin" - exit 1 - fi - - ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token') -else - ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.access_token') -fi - -if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then - echo "Error: Could not get access token" - echo "Response: $REGISTER_RESPONSE" - exit 1 -fi - -echo "" -echo "3. Setting display name..." -curl -s -X PUT "${HOMESERVER_URL}/_matrix/client/v3/profile/@${BOT_USERNAME}:matrix.mana.how/displayname" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"displayname": "Onboarding Bot"}' - -echo "" -echo "=== Setup Complete ===" -echo "" -echo "Add these to your .env file or docker-compose environment:" -echo "" -echo "MATRIX_ONBOARDING_BOT_TOKEN=$ACCESS_TOKEN" -echo "MATRIX_ONBOARDING_BOT_ROOMS=#welcome:matrix.mana.how" -echo "" -echo "Bot User: @${BOT_USERNAME}:matrix.mana.how" -echo "" diff --git a/services/matrix-onboarding-bot/src/app.module.ts b/services/matrix-onboarding-bot/src/app.module.ts deleted file mode 100644 index edd13d285..000000000 --- a/services/matrix-onboarding-bot/src/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-onboarding-bot')], -}) -export class AppModule {} diff --git a/services/matrix-onboarding-bot/src/bot/bot.module.ts b/services/matrix-onboarding-bot/src/bot/bot.module.ts deleted file mode 100644 index d9b38f7f6..000000000 --- a/services/matrix-onboarding-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { OnboardingModule } from '../onboarding/onboarding.module'; -import { SessionModule, I18nModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - OnboardingModule, - SessionModule.forRoot({ storageMode: 'redis' }), - I18nModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-onboarding-bot/src/bot/matrix.service.ts b/services/matrix-onboarding-bot/src/bot/matrix.service.ts deleted file mode 100644 index c7aaf7a43..000000000 --- a/services/matrix-onboarding-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - type MatrixBotConfig, - type MatrixRoomEvent, -} from '@manacore/matrix-bot-common'; -import { SessionService, I18nService, type Language } from '@manacore/bot-services'; -import { OnboardingService } from '../onboarding/onboarding.service'; -import { HELP_TEXT, MESSAGES } from '../config/configuration'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - constructor( - configService: ConfigService, - private readonly sessionService: SessionService, - private readonly i18nService: I18nService, - private readonly onboardingService: OnboardingService - ) { - super(configService); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string | null { - return MESSAGES.de.welcome; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - const lang = await this.getLanguage(sender); - - // Handle commands first - if (message.startsWith('!')) { - const [command, ...args] = message.slice(1).split(' '); - await this.handleCommand(roomId, event, sender, command.toLowerCase(), args.join(' '), lang); - return; - } - - // Check if user is in onboarding flow - if (this.onboardingService.isInOnboarding(sender)) { - await this.handleOnboardingInput(roomId, event, sender, message, lang); - return; - } - - // Natural language hints - const lowerMessage = message.toLowerCase(); - if (lowerMessage.includes('hilfe') || lowerMessage.includes('help')) { - await this.sendReply(roomId, event, HELP_TEXT); - return; - } - - if (lowerMessage.includes('profil') || lowerMessage.includes('profile')) { - await this.handleProfileCommand(roomId, event, sender, lang); - return; - } - - // No action for other messages - } - - private async handleCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - command: string, - args: string, - lang: Language - ): Promise { - const messages = MESSAGES[lang]; - - switch (command) { - case 'start': - await this.handleStartCommand(roomId, event, userId, lang); - break; - - case 'profile': - case 'profil': - await this.handleProfileCommand(roomId, event, userId, lang); - break; - - case 'edit': - case 'bearbeiten': - await this.handleEditCommand(roomId, event, userId, args, lang); - break; - - case 'skip': - case 'ueberspringen': - await this.handleSkipCommand(roomId, event, userId, lang); - break; - - case 'help': - case 'hilfe': - await this.sendReply(roomId, event, HELP_TEXT); - break; - - case 'cancel': - case 'abbrechen': - await this.handleCancelCommand(roomId, event, userId, lang); - break; - - default: - // Unknown command - ignore or show help - break; - } - } - - private async handleStartCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - lang: Language - ): Promise { - const messages = MESSAGES[lang]; - - // Check if user is logged in - const token = await this.getToken(userId); - if (!token) { - await this.sendReply(roomId, event, messages.loginRequired); - return; - } - - // Check if already onboarded - const hasCompleted = await this.onboardingService.hasCompletedOnboarding(token); - if (hasCompleted) { - // Allow restart - this.onboardingService.resetSession(userId); - } - - // Start onboarding - const result = this.onboardingService.processAction(userId, { type: 'START' }, lang); - const message = this.getMessage(result.messageKey, lang, result.messageParams); - await this.sendReply(roomId, event, message); - } - - private async handleProfileCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - lang: Language - ): Promise { - const messages = MESSAGES[lang]; - - const token = await this.getToken(userId); - if (!token) { - await this.sendReply(roomId, event, messages.loginRequired); - return; - } - - const profile = await this.onboardingService.getProfile(token); - if (!profile || !profile.onboardingCompleted) { - await this.sendReply(roomId, event, messages.noProfile); - return; - } - - const message = this.formatMessage(messages.profileDisplay, { - name: profile.displayName || '-', - interests: profile.interests?.length ? profile.interests.join(', ') : '-', - language: profile.locale === 'en' ? 'English' : 'Deutsch', - }); - await this.sendReply(roomId, event, message); - } - - private async handleEditCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string, - lang: Language - ): Promise { - const messages = MESSAGES[lang]; - - const token = await this.getToken(userId); - if (!token) { - await this.sendReply(roomId, event, messages.loginRequired); - return; - } - - const parts = args.split(' '); - if (parts.length < 2) { - await this.sendReply( - roomId, - event, - lang === 'de' - ? 'Verwendung: `!edit [name|interests|language] [Wert]`' - : 'Usage: `!edit [name|interests|language] [value]`' - ); - return; - } - - const field = parts[0].toLowerCase(); - const value = parts.slice(1).join(' '); - - let fieldKey: 'displayName' | 'interests' | 'locale' | null = null; - if (field === 'name' || field === 'namen') { - fieldKey = 'displayName'; - } else if (field === 'interests' || field === 'interessen') { - fieldKey = 'interests'; - } else if (field === 'language' || field === 'sprache' || field === 'lang') { - fieldKey = 'locale'; - } - - if (!fieldKey) { - await this.sendReply( - roomId, - event, - lang === 'de' - ? 'Unbekanntes Feld. Verfugbar: name, interests, language' - : 'Unknown field. Available: name, interests, language' - ); - return; - } - - const success = await this.onboardingService.updateProfileField(token, fieldKey, value); - if (success) { - await this.sendReply(roomId, event, messages.updated); - } else { - await this.sendReply( - roomId, - event, - lang === 'de' ? 'Fehler beim Aktualisieren.' : 'Error updating.' - ); - } - } - - private async handleSkipCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - lang: Language - ): Promise { - const messages = MESSAGES[lang]; - - if (!this.onboardingService.isInOnboarding(userId)) { - return; - } - - if (!this.onboardingService.canSkip(userId)) { - await this.sendReply(roomId, event, messages.skipNotAllowed); - return; - } - - const result = this.onboardingService.processAction(userId, { type: 'SKIP' }, lang); - const message = this.getMessage(result.messageKey, lang, result.messageParams); - await this.sendReply(roomId, event, message); - - // If completed after skip, save the profile - if (result.session.state === 'COMPLETED') { - await this.saveOnboardingData(userId, result.session.data, roomId, event, lang); - } - } - - private async handleCancelCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - lang: Language - ): Promise { - const messages = MESSAGES[lang]; - - if (this.onboardingService.isInOnboarding(userId)) { - this.onboardingService.resetSession(userId); - await this.sendReply(roomId, event, messages.cancelled); - } - } - - private async handleOnboardingInput( - roomId: string, - event: MatrixRoomEvent, - userId: string, - input: string, - lang: Language - ): Promise { - const result = this.onboardingService.processAction( - userId, - { type: 'INPUT', value: input }, - lang - ); - - const message = this.getMessage(result.messageKey, lang, result.messageParams); - await this.sendReply(roomId, event, message); - - // If completed, save the profile - if (result.session.state === 'COMPLETED') { - await this.saveOnboardingData(userId, result.session.data, roomId, event, lang); - } - } - - private async saveOnboardingData( - userId: string, - data: { displayName?: string; interests?: string[]; locale?: 'de' | 'en' }, - roomId: string, - event: MatrixRoomEvent, - lang: Language - ): Promise { - const token = await this.getToken(userId); - if (!token) { - this.logger.error(`No token for user ${userId}, cannot save profile`); - return; - } - - const success = await this.onboardingService.saveProfile(token, data); - if (!success) { - await this.sendReply( - roomId, - event, - lang === 'de' - ? 'Hinweis: Profil konnte nicht gespeichert werden. Versuche es spater erneut.' - : 'Note: Profile could not be saved. Try again later.' - ); - } - - // Also update the i18n language - if (data.locale) { - await this.i18nService.setLanguage(userId, data.locale as Language); - } - - // Clear the session - this.onboardingService.resetSession(userId); - } - - // ============================================================================ - // Helper Methods - // ============================================================================ - - private async getToken(userId: string): Promise { - return this.sessionService.getToken(userId); - } - - private async getLanguage(userId: string): Promise { - return this.i18nService.getLanguage(userId); - } - - private getMessage(key: string, lang: Language, params?: Record): string { - const messages = MESSAGES[lang]; - let message = (messages as Record)[key] || key; - - if (params) { - message = this.formatMessage(message, params); - } - - return message; - } - - private formatMessage(template: string, params: Record): string { - let result = template; - for (const [key, value] of Object.entries(params)) { - result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value); - } - return result; - } -} diff --git a/services/matrix-onboarding-bot/src/config/configuration.ts b/services/matrix-onboarding-bot/src/config/configuration.ts deleted file mode 100644 index 6a9b58beb..000000000 --- a/services/matrix-onboarding-bot/src/config/configuration.ts +++ /dev/null @@ -1,112 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '4020', 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', - }, - manaAuth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_TEXT = `**Onboarding Bot - Profil einrichten** - -**Befehle:** -- \`!start\` - Onboarding starten/neustarten -- \`!profile\` - Dein Profil anzeigen -- \`!edit nickname Max\` - Spitznamen ändern -- \`!edit name Max Mustermann\` - Vollen Namen ändern -- \`!edit birthday 15.03\` - Geburtstag ändern -- \`!edit interests KI, Musik\` - Interessen ändern -- \`!edit goals Produktivität\` - Nutzungsziele ändern -- \`!edit language de\` - Sprache ändern (de/en) -- \`!skip\` - Aktuelle Frage überspringen -- \`!help\` - Diese Hilfe anzeigen - -**Onboarding-Flow:** -1. Sprache wählen (de/en) -2. Spitzname eingeben -3. Vollständigen Namen eingeben -4. Geburtstag angeben -5. Interessen eingeben -6. Nutzungsziele angeben -7. Profil bestätigen - -Alle Fragen sind optional und können übersprungen werden.`; - -export const WELCOME_TEXT = `**Willkommen beim Onboarding!** - -Ich helfe dir, dein Profil einzurichten. Das dauert nur einen Moment. -Alle Fragen sind optional - sag einfach \`!skip\` zum Überspringen. - -Welche Sprache bevorzugst du? (\`de\` oder \`en\`)`; - -export const MESSAGES = { - de: { - welcome: - '**Willkommen beim Onboarding!**\n\nIch helfe dir, dein Profil einzurichten. Das dauert nur einen Moment.\nAlle Fragen sind optional - sag einfach `!skip` zum Überspringen.\n\nWelche Sprache bevorzugst du? (`de` oder `en`)', - askLanguage: - 'Welche Sprache bevorzugst du?\n\nAntworte mit `de` für Deutsch oder `en` für English.\n\n(Optional - `!skip` zum Überspringen)', - askNickname: - 'Wie möchtest du genannt werden? (Spitzname)\n\nz.B. Max, Maxi, M...\n\n(Optional - `!skip` zum Überspringen)', - askFullName: - 'Wie heißt du vollständig? (Vor- und Nachname)\n\n(Optional - `!skip` zum Überspringen)', - askBirthDate: - 'Wann hast du Geburtstag?\n\nFormat: `TT.MM` oder `TT.MM.JJJJ`\nz.B. `15.03` oder `15.03.1990`\n\n(Optional - `!skip` zum Überspringen)', - askInterests: - 'Was sind deine Interessen?\n\n(z.B. Programmierung, Musik, Gaming - durch Komma getrennt)\n\n(Optional - `!skip` zum Überspringen)', - askUsageGoals: - 'Wofür möchtest du Mana nutzen?\n\nz.B. Produktivität, Kreativität, Lernen...\n\n(Optional - `!skip` zum Überspringen)', - summary: - '**Dein Profil:**\n- Spitzname: {nickname}\n- Voller Name: {fullName}\n- Geburtstag: {birthDate}\n- Interessen: {interests}\n- Nutzungsziele: {usageGoals}\n- Sprache: {language}\n\nIst das korrekt? (ja/nein)', - completed: - 'Perfekt! Dein Profil ist eingerichtet.\n\nDu kannst es jederzeit mit `!profile` anzeigen oder mit `!edit` ändern.', - cancelled: 'Onboarding abgebrochen. Starte jederzeit neu mit `!start`.', - profileDisplay: - '**Dein Profil:**\n- Spitzname: {nickname}\n- Voller Name: {fullName}\n- Geburtstag: {birthDate}\n- Interessen: {interests}\n- Nutzungsziele: {usageGoals}\n- Sprache: {language}', - noProfile: 'Du hast noch kein Profil eingerichtet. Starte mit `!start`.', - updated: 'Profil aktualisiert!', - invalidLanguage: 'Bitte wähle `de` oder `en`.', - invalidBirthDate: 'Bitte gib das Datum im Format `TT.MM` oder `TT.MM.JJJJ` ein.', - skipped: 'Übersprungen.', - alreadyOnboarded: - 'Du hast das Onboarding bereits abgeschlossen. Nutze `!profile` zum Anzeigen oder `!edit` zum Ändern.', - restartPrompt: 'Möchtest du das Onboarding neu starten? (ja/nein)', - loginRequired: 'Bitte melde dich zuerst an, um das Onboarding zu starten.', - skipNotAllowed: 'In diesem Schritt ist Überspringen nicht möglich.', - }, - en: { - welcome: - "**Welcome to Onboarding!**\n\nI'll help you set up your profile. This will only take a moment.\nAll questions are optional - just say `!skip` to skip.\n\nWhich language do you prefer? (`de` or `en`)", - askLanguage: - 'Which language do you prefer?\n\nReply with `de` for German or `en` for English.\n\n(Optional - `!skip` to skip)', - askNickname: - 'What would you like to be called? (Nickname)\n\ne.g. Max, Maxi, M...\n\n(Optional - `!skip` to skip)', - askFullName: 'What is your full name? (First and last name)\n\n(Optional - `!skip` to skip)', - askBirthDate: - 'When is your birthday?\n\nFormat: `DD.MM` or `DD.MM.YYYY`\ne.g. `15.03` or `15.03.1990`\n\n(Optional - `!skip` to skip)', - askInterests: - 'What are your interests?\n\n(e.g. Programming, Music, Gaming - separated by commas)\n\n(Optional - `!skip` to skip)', - askUsageGoals: - 'What do you want to use Mana for?\n\ne.g. Productivity, Creativity, Learning...\n\n(Optional - `!skip` to skip)', - summary: - '**Your Profile:**\n- Nickname: {nickname}\n- Full Name: {fullName}\n- Birthday: {birthDate}\n- Interests: {interests}\n- Usage Goals: {usageGoals}\n- Language: {language}\n\nIs this correct? (yes/no)', - completed: - 'Perfect! Your profile is set up.\n\nYou can view it anytime with `!profile` or change it with `!edit`.', - cancelled: 'Onboarding cancelled. Start again anytime with `!start`.', - profileDisplay: - '**Your Profile:**\n- Nickname: {nickname}\n- Full Name: {fullName}\n- Birthday: {birthDate}\n- Interests: {interests}\n- Usage Goals: {usageGoals}\n- Language: {language}', - noProfile: "You haven't set up a profile yet. Start with `!start`.", - updated: 'Profile updated!', - invalidLanguage: 'Please choose `de` or `en`.', - invalidBirthDate: 'Please enter the date in format `DD.MM` or `DD.MM.YYYY`.', - skipped: 'Skipped.', - alreadyOnboarded: - 'You have already completed onboarding. Use `!profile` to view or `!edit` to change.', - restartPrompt: 'Would you like to restart onboarding? (yes/no)', - loginRequired: 'Please log in first to start onboarding.', - skipNotAllowed: 'Skipping is not allowed at this step.', - }, -}; diff --git a/services/matrix-onboarding-bot/src/main.ts b/services/matrix-onboarding-bot/src/main.ts deleted file mode 100644 index 3cbb06b78..000000000 --- a/services/matrix-onboarding-bot/src/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 4020; - - await app.listen(port); - - const logger = new Logger('Bootstrap'); - logger.log(`Matrix Onboarding Bot running on port ${port}`); -} - -bootstrap(); diff --git a/services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts b/services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts deleted file mode 100644 index 14c92b502..000000000 --- a/services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { OnboardingService } from './onboarding.service'; - -@Module({ - providers: [OnboardingService], - exports: [OnboardingService], -}) -export class OnboardingModule {} diff --git a/services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts b/services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts deleted file mode 100644 index 2ea7469ec..000000000 --- a/services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - OnboardingStateMachine, - OnboardingSession, - OnboardingAction, - OnboardingData, - OnboardingState, -} from './state-machine'; - -export interface UserProfile { - displayName?: string; - interests?: string[]; - locale: 'de' | 'en'; - onboardingCompleted: boolean; -} - -interface ManaAuthSettingsResponse { - globalSettings: { - locale: string; - displayName?: string; - interests?: string[]; - onboardingCompleted?: boolean; - nav: unknown; - theme: unknown; - }; -} - -@Injectable() -export class OnboardingService { - private readonly logger = new Logger(OnboardingService.name); - private readonly authUrl: string; - - // In-memory session storage (per Matrix user) - // Key: Matrix user ID (e.g., @user:matrix.org) - private sessions: Map = new Map(); - - constructor(private configService: ConfigService) { - this.authUrl = this.configService.get('manaAuth.url') || 'http://localhost:3001'; - } - - /** - * Get or create onboarding session for a user - */ - getSession(matrixUserId: string): OnboardingSession { - let session = this.sessions.get(matrixUserId); - if (!session) { - session = OnboardingStateMachine.createSession(); - this.sessions.set(matrixUserId, session); - } - return session; - } - - /** - * Process an action and update the session - */ - processAction( - matrixUserId: string, - action: OnboardingAction, - lang: 'de' | 'en' = 'de' - ): { session: OnboardingSession; messageKey: string; messageParams?: Record } { - const session = this.getSession(matrixUserId); - const result = OnboardingStateMachine.transition(session, action, lang); - - // Update session - const updatedSession: OnboardingSession = { - state: result.newState, - data: result.data, - startedAt: session.startedAt, - }; - this.sessions.set(matrixUserId, updatedSession); - - return { - session: updatedSession, - messageKey: result.messageKey, - messageParams: result.messageParams, - }; - } - - /** - * Check if user is in onboarding - */ - isInOnboarding(matrixUserId: string): boolean { - const session = this.sessions.get(matrixUserId); - if (!session) return false; - return OnboardingStateMachine.isInProgress(session.state); - } - - /** - * Get current state - */ - getState(matrixUserId: string): OnboardingState { - const session = this.getSession(matrixUserId); - return session.state; - } - - /** - * Reset session - */ - resetSession(matrixUserId: string): void { - this.sessions.delete(matrixUserId); - } - - /** - * Check if current state can be skipped - */ - canSkip(matrixUserId: string): boolean { - const session = this.getSession(matrixUserId); - return OnboardingStateMachine.canSkip(session.state); - } - - // ============================================================================ - // mana-core-auth API Integration - // ============================================================================ - - /** - * Save onboarding data to mana-core-auth - */ - async saveProfile(token: string, data: OnboardingData): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/settings/global`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - displayName: data.displayName, - interests: data.interests, - locale: data.locale, - onboardingCompleted: true, - }), - }); - - if (!response.ok) { - this.logger.error(`Failed to save profile: ${response.status} ${response.statusText}`); - return false; - } - - this.logger.debug('Profile saved successfully'); - return true; - } catch (error) { - this.logger.error('Failed to save profile', error); - return false; - } - } - - /** - * Get user profile from mana-core-auth - */ - async getProfile(token: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/settings`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - this.logger.error(`Failed to get profile: ${response.status}`); - return null; - } - - const data: ManaAuthSettingsResponse = await response.json(); - const settings = data.globalSettings; - - return { - displayName: settings.displayName, - interests: settings.interests, - locale: (settings.locale as 'de' | 'en') || 'de', - onboardingCompleted: settings.onboardingCompleted || false, - }; - } catch (error) { - this.logger.error('Failed to get profile', error); - return null; - } - } - - /** - * Update a single profile field - */ - async updateProfileField( - token: string, - field: 'displayName' | 'interests' | 'locale', - value: string | string[] - ): Promise { - try { - const body: Record = {}; - - if (field === 'displayName') { - body.displayName = value as string; - } else if (field === 'interests') { - body.interests = Array.isArray(value) - ? value - : (value as string).split(',').map((i) => i.trim()); - } else if (field === 'locale') { - const locale = (value as string).toLowerCase(); - if (locale !== 'de' && locale !== 'en') { - return false; - } - body.locale = locale; - } - - const response = await fetch(`${this.authUrl}/api/v1/settings/global`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - this.logger.error(`Failed to update profile field: ${response.status}`); - return false; - } - - return true; - } catch (error) { - this.logger.error('Failed to update profile field', error); - return false; - } - } - - /** - * Check if user has completed onboarding - */ - async hasCompletedOnboarding(token: string): Promise { - const profile = await this.getProfile(token); - return profile?.onboardingCompleted || false; - } -} diff --git a/services/matrix-onboarding-bot/src/onboarding/state-machine.ts b/services/matrix-onboarding-bot/src/onboarding/state-machine.ts deleted file mode 100644 index dfdb3aac4..000000000 --- a/services/matrix-onboarding-bot/src/onboarding/state-machine.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Onboarding State Machine - * - * States: - * - IDLE: Not in onboarding - * - NAME: Asking for display name - * - INTERESTS: Asking for interests (skippable) - * - LANGUAGE: Asking for language preference - * - SUMMARY: Showing summary and asking for confirmation - * - COMPLETED: Onboarding finished - */ - -export type OnboardingState = 'IDLE' | 'NAME' | 'INTERESTS' | 'LANGUAGE' | 'SUMMARY' | 'COMPLETED'; - -export interface OnboardingData { - displayName?: string; - interests?: string[]; - locale?: 'de' | 'en'; -} - -export interface OnboardingSession { - state: OnboardingState; - data: OnboardingData; - startedAt: number; -} - -export type OnboardingAction = - | { type: 'START' } - | { type: 'INPUT'; value: string } - | { type: 'SKIP' } - | { type: 'CONFIRM' } - | { type: 'REJECT' } - | { type: 'RESET' }; - -export interface StateTransitionResult { - newState: OnboardingState; - data: OnboardingData; - message: string; - messageKey: string; - messageParams?: Record; - error?: string; -} - -/** - * Pure state machine - no side effects - */ -export class OnboardingStateMachine { - /** - * Process an action and return the new state - */ - static transition( - session: OnboardingSession, - action: OnboardingAction, - lang: 'de' | 'en' = 'de' - ): StateTransitionResult { - const { state, data } = session; - - switch (state) { - case 'IDLE': - return this.handleIdle(action, data); - - case 'NAME': - return this.handleName(action, data); - - case 'INTERESTS': - return this.handleInterests(action, data); - - case 'LANGUAGE': - return this.handleLanguage(action, data); - - case 'SUMMARY': - return this.handleSummary(action, data, lang); - - case 'COMPLETED': - return this.handleCompleted(action, data); - - default: - return { - newState: 'IDLE', - data, - message: '', - messageKey: 'error', - }; - } - } - - private static handleIdle(action: OnboardingAction, data: OnboardingData): StateTransitionResult { - if (action.type === 'START') { - return { - newState: 'NAME', - data: {}, - message: '', - messageKey: 'askName', - }; - } - return { - newState: 'IDLE', - data, - message: '', - messageKey: 'idle', - }; - } - - private static handleName(action: OnboardingAction, data: OnboardingData): StateTransitionResult { - if (action.type === 'INPUT' && action.value.trim()) { - const displayName = action.value.trim(); - return { - newState: 'INTERESTS', - data: { ...data, displayName }, - message: '', - messageKey: 'askInterests', - messageParams: { name: displayName }, - }; - } - - if (action.type === 'SKIP') { - return { - newState: 'NAME', - data, - message: '', - messageKey: 'skipNotAllowed', - }; - } - - if (action.type === 'RESET') { - return { - newState: 'IDLE', - data: {}, - message: '', - messageKey: 'cancelled', - }; - } - - return { - newState: 'NAME', - data, - message: '', - messageKey: 'askName', - }; - } - - private static handleInterests( - action: OnboardingAction, - data: OnboardingData - ): StateTransitionResult { - if (action.type === 'INPUT' && action.value.trim()) { - const interests = action.value - .split(',') - .map((i) => i.trim()) - .filter((i) => i.length > 0); - - return { - newState: 'LANGUAGE', - data: { ...data, interests }, - message: '', - messageKey: 'askLanguage', - }; - } - - if (action.type === 'SKIP') { - return { - newState: 'LANGUAGE', - data: { ...data, interests: [] }, - message: '', - messageKey: 'askLanguage', - }; - } - - if (action.type === 'RESET') { - return { - newState: 'IDLE', - data: {}, - message: '', - messageKey: 'cancelled', - }; - } - - return { - newState: 'INTERESTS', - data, - message: '', - messageKey: 'askInterests', - messageParams: { name: data.displayName || '' }, - }; - } - - private static handleLanguage( - action: OnboardingAction, - data: OnboardingData - ): StateTransitionResult { - if (action.type === 'INPUT') { - const input = action.value.trim().toLowerCase(); - if (input === 'de' || input === 'en' || input === 'deutsch' || input === 'english') { - const locale = input === 'de' || input === 'deutsch' ? 'de' : 'en'; - return { - newState: 'SUMMARY', - data: { ...data, locale }, - message: '', - messageKey: 'summary', - messageParams: { - name: data.displayName || '-', - interests: data.interests?.length ? data.interests.join(', ') : '-', - language: locale === 'de' ? 'Deutsch' : 'English', - }, - }; - } - return { - newState: 'LANGUAGE', - data, - message: '', - messageKey: 'invalidLanguage', - }; - } - - if (action.type === 'SKIP') { - // Default to 'de' if skipped - return { - newState: 'SUMMARY', - data: { ...data, locale: 'de' }, - message: '', - messageKey: 'summary', - messageParams: { - name: data.displayName || '-', - interests: data.interests?.length ? data.interests.join(', ') : '-', - language: 'Deutsch', - }, - }; - } - - if (action.type === 'RESET') { - return { - newState: 'IDLE', - data: {}, - message: '', - messageKey: 'cancelled', - }; - } - - return { - newState: 'LANGUAGE', - data, - message: '', - messageKey: 'askLanguage', - }; - } - - private static handleSummary( - action: OnboardingAction, - data: OnboardingData, - _lang: 'de' | 'en' - ): StateTransitionResult { - if (action.type === 'CONFIRM' || action.type === 'INPUT') { - const input = action.type === 'INPUT' ? action.value.trim().toLowerCase() : 'yes'; - const isYes = - input === 'ja' || - input === 'yes' || - input === 'j' || - input === 'y' || - input === 'ok' || - input === 'okay'; - const isNo = input === 'nein' || input === 'no' || input === 'n'; - - if (isYes) { - return { - newState: 'COMPLETED', - data, - message: '', - messageKey: 'completed', - }; - } - - if (isNo) { - return { - newState: 'IDLE', - data: {}, - message: '', - messageKey: 'cancelled', - }; - } - - // Neither yes nor no - repeat the question - return { - newState: 'SUMMARY', - data, - message: '', - messageKey: 'summary', - messageParams: { - name: data.displayName || '-', - interests: data.interests?.length ? data.interests.join(', ') : '-', - language: data.locale === 'en' ? 'English' : 'Deutsch', - }, - }; - } - - if (action.type === 'RESET') { - return { - newState: 'IDLE', - data: {}, - message: '', - messageKey: 'cancelled', - }; - } - - return { - newState: 'SUMMARY', - data, - message: '', - messageKey: 'summary', - messageParams: { - name: data.displayName || '-', - interests: data.interests?.length ? data.interests.join(', ') : '-', - language: data.locale === 'en' ? 'English' : 'Deutsch', - }, - }; - } - - private static handleCompleted( - action: OnboardingAction, - data: OnboardingData - ): StateTransitionResult { - if (action.type === 'START') { - return { - newState: 'NAME', - data: {}, - message: '', - messageKey: 'askName', - }; - } - - return { - newState: 'COMPLETED', - data, - message: '', - messageKey: 'alreadyOnboarded', - }; - } - - /** - * Create initial session - */ - static createSession(): OnboardingSession { - return { - state: 'IDLE', - data: {}, - startedAt: Date.now(), - }; - } - - /** - * Check if a state allows skipping - */ - static canSkip(state: OnboardingState): boolean { - return state === 'INTERESTS' || state === 'LANGUAGE'; - } - - /** - * Check if onboarding is in progress - */ - static isInProgress(state: OnboardingState): boolean { - return state !== 'IDLE' && state !== 'COMPLETED'; - } -} diff --git a/services/matrix-onboarding-bot/tsconfig.build.json b/services/matrix-onboarding-bot/tsconfig.build.json deleted file mode 100644 index 4491981e0..000000000 --- a/services/matrix-onboarding-bot/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/services/matrix-onboarding-bot/tsconfig.json b/services/matrix-onboarding-bot/tsconfig.json deleted file mode 100644 index b439390d0..000000000 --- a/services/matrix-onboarding-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } -} diff --git a/services/matrix-picture-bot/.env.example b/services/matrix-picture-bot/.env.example deleted file mode 100644 index 3b96e8301..000000000 --- a/services/matrix-picture-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3319 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#picture:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Picture Backend -PICTURE_BACKEND_URL=http://localhost:3006 -PICTURE_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-picture-bot/.gitignore b/services/matrix-picture-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-picture-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-picture-bot/CLAUDE.md b/services/matrix-picture-bot/CLAUDE.md deleted file mode 100644 index 27772902e..000000000 --- a/services/matrix-picture-bot/CLAUDE.md +++ /dev/null @@ -1,175 +0,0 @@ -# Matrix Picture Bot - Claude Code Guidelines - -## Overview - -Matrix Picture Bot provides AI image generation via Matrix chat. It integrates with the Picture backend to generate images using various AI models (Replicate). - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Picture API (port 3006) -- **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-picture-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3319) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── picture/ -│ │ ├── picture.module.ts -│ │ └── picture.service.ts # Picture Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!generate [prompt]` | bild, gen | Generate an image | -| `!models` | modelle | List available models | -| `!model [id]` | modell | Switch model | -| `!history` | verlauf | Show recent images | -| `!delete [nr]` | loeschen | Delete an image | -| `!credits` | guthaben | Show credit balance | -| `!login email pass` | - | Login to Picture | -| `!logout` | - | Logout | -| `!cancel` | abbrechen | Cancel active generation | -| `!status` | - | Bot status | - -## Prompt Options - -Options can be added to the generate command: - -``` -!generate A beautiful sunset --width 1280 --height 720 --steps 40 -!bild Ein Hund --negative blurry, low quality --style photorealistic -``` - -| Option | Description | Default | -|--------|-------------|---------| -| `--width N` | Image width | 1024 | -| `--height N` | Image height | 1024 | -| `--steps N` | Generation steps | 25 | -| `--negative [text]` | Negative prompt | - | -| `--style [name]` | Style preset | - | - -## Environment Variables - -```env -# Server -PORT=3319 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#picture:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Picture Backend -PICTURE_BACKEND_URL=http://localhost:3006 -PICTURE_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-picture-bot/Dockerfile -t matrix-picture-bot services/matrix-picture-bot - -# Run -docker run -p 3319:3319 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e PICTURE_BACKEND_URL=http://picture-backend:3006 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-picture-bot-data:/app/data \ - matrix-picture-bot -``` - -## Health Check - -```bash -curl http://localhost:3319/health -``` - -## Getting a Matrix Access Token - -```bash -# Create bot user first, then login -curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ - -H "Content-Type: application/json" \ - -d '{ - "type": "m.login.password", - "user": "picture-bot", - "password": "your-password" - }' - -# Response contains: {"access_token": "syt_xxx", ...} -``` - -## Authentication Flow - -1. User sends `!login email password` -2. Bot calls mana-core-auth `/api/v1/auth/login` -3. JWT token stored in session (in-memory) -4. Token used for all Picture API calls -5. Token expires after 7 days (re-login required) - -## Picture Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/v1/models` | GET | List available models | -| `/api/v1/models/:id` | GET | Get model details | -| `/api/v1/generate` | POST | Generate image | -| `/api/v1/images` | GET | List user's images | -| `/api/v1/images/:id` | DELETE | Delete image | -| `/api/v1/credits/balance` | GET | Get credit balance | - -## Credit System - -- **Cost**: 10 credits per image generation -- **Free tier**: 3 free generations for new users -- **Enforcement**: Only in production environment -- **Development**: Fail-open (no credit enforcement) - -## Image Upload Flow - -1. User sends `!generate [prompt]` -2. Bot calls Picture Backend with `waitForResult: true` -3. Backend generates image via Replicate -4. Bot downloads image from storage URL -5. Bot uploads image to Matrix media server -6. Bot sends image message to room diff --git a/services/matrix-picture-bot/Dockerfile b/services/matrix-picture-bot/Dockerfile deleted file mode 100644 index 4211d1c71..000000000 --- a/services/matrix-picture-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3319 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3319/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-picture-bot/nest-cli.json b/services/matrix-picture-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-picture-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-picture-bot/package.json b/services/matrix-picture-bot/package.json deleted file mode 100644 index a9d0e307c..000000000 --- a/services/matrix-picture-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-picture-bot", - "version": "1.0.0", - "description": "Matrix bot for AI image generation via Picture backend", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-picture-bot/src/app.module.ts b/services/matrix-picture-bot/src/app.module.ts deleted file mode 100644 index c3515cae3..000000000 --- a/services/matrix-picture-bot/src/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import configuration from './config/configuration'; -import { BotModule } from './bot/bot.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-picture-bot')], -}) -export class AppModule {} diff --git a/services/matrix-picture-bot/src/bot/bot.module.ts b/services/matrix-picture-bot/src/bot/bot.module.ts deleted file mode 100644 index eecbc9583..000000000 --- a/services/matrix-picture-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { PictureModule } from '../picture/picture.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - PictureModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-picture-bot/src/bot/matrix.service.ts b/services/matrix-picture-bot/src/bot/matrix.service.ts deleted file mode 100644 index d97670baf..000000000 --- a/services/matrix-picture-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { PictureService } from '../picture/picture.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -// Credit cost for image generation -const IMAGE_GENERATION_CREDITS = 10; - -interface ParsedPrompt { - prompt: string; - negativePrompt?: string; - width?: number; - height?: number; - steps?: number; - style?: string; -} - -@Injectable() -export class MatrixService extends BaseMatrixService { - // Track active generations per user - private activeGenerations: Map = new Map(); - // Track selected model per user - private userModels: Map = new Map(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['modelle', 'models'], command: 'models' }, - { keywords: ['verlauf', 'history', 'bilder'], command: 'history' }, - { keywords: ['credits', 'guthaben'], command: 'credits' }, - ]); - - constructor( - configService: ConfigService, - private readonly transcriptionService: TranscriptionService, - private pictureService: PictureService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendMessage(roomId, '❌ Sprachnachricht konnte nicht erkannt werden.'); - return; - } - - await this.sendMessage(roomId, `🎤 *"${text}"*`); - await this.handleTextMessage(roomId, event, text, sender); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendMessage(roomId, '❌ Fehler bei der Spracherkennung.'); - } - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string | null { - return `**Picture Bot - AI-Bildgenerierung** - -Ich generiere Bilder mit AI fur dich! - -**Schnellstart:** -\`!generate A beautiful landscape\` -\`!bild Ein niedlicher Hund\` - -Sag "hilfe" fur alle Befehle!`; - } - - protected async handleTextMessage( - roomId: string, - _event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - // Handle commands with ! prefix - if (message.startsWith('!')) { - await this.handleCommand(roomId, sender, message); - return; - } - - // Check for natural language keywords - const keywordCommand = this.keywordDetector.detect(message); - if (keywordCommand) { - await this.handleCommand(roomId, sender, `!${keywordCommand}`); - return; - } - - // Don't respond to random messages - } - - 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 'generate': - case 'bild': - case 'gen': - await this.handleGenerate(roomId, sender, argString); - break; - - case 'models': - case 'modelle': - await this.handleModels(roomId); - break; - - case 'model': - case 'modell': - await this.handleSelectModel(roomId, sender, argString); - break; - - case 'history': - case 'verlauf': - await this.handleHistory(roomId, sender); - break; - - case 'delete': - case 'loeschen': - await this.handleDelete(roomId, sender, args); - break; - - case 'credits': - case 'guthaben': - await this.handleCredits(roomId, sender); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - case 'cancel': - case 'abbrechen': - await this.handleCancel(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 parsePrompt(input: string): ParsedPrompt { - const result: ParsedPrompt = { prompt: '' }; - - // Extract options - const widthMatch = input.match(/--width\s+(\d+)/i); - if (widthMatch) { - result.width = parseInt(widthMatch[1], 10); - input = input.replace(widthMatch[0], ''); - } - - const heightMatch = input.match(/--height\s+(\d+)/i); - if (heightMatch) { - result.height = parseInt(heightMatch[1], 10); - input = input.replace(heightMatch[0], ''); - } - - const stepsMatch = input.match(/--steps\s+(\d+)/i); - if (stepsMatch) { - result.steps = parseInt(stepsMatch[1], 10); - input = input.replace(stepsMatch[0], ''); - } - - const negativeMatch = input.match(/--negative\s+(.+?)(?=--|$)/i); - if (negativeMatch) { - result.negativePrompt = negativeMatch[1].trim(); - input = input.replace(negativeMatch[0], ''); - } - - const styleMatch = input.match(/--style\s+(\S+)/i); - if (styleMatch) { - result.style = styleMatch[1]; - input = input.replace(styleMatch[0], ''); - } - - result.prompt = input.trim(); - return result; - } - - private async handleGenerate(roomId: string, sender: string, promptInput: string) { - if (!promptInput.trim()) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!generate [prompt]\`\n\nBeispiel: \`!generate A beautiful sunset over mountains\`` - ); - return; - } - - // Check if user is logged in - const token = await this.sessionService.getToken(sender); - if (!token) { - await this.sendMessage( - roomId, - `Du musst angemeldet sein, um Bilder zu generieren.\n\nNutze \`!login email passwort\` zum Anmelden.` - ); - return; - } - - // Validate credits before generating - const validation = await this.creditService.validateCredits(token, IMAGE_GENERATION_CREDITS); - if (!validation.hasCredits) { - const errorMsg = this.creditService.formatInsufficientCreditsError( - IMAGE_GENERATION_CREDITS, - validation.availableCredits, - 'Bildgenerierung' - ); - await this.sendMessage(roomId, errorMsg.text); - return; - } - - // Check if user already has an active generation - if (this.activeGenerations.has(sender)) { - await this.sendMessage( - roomId, - `Du hast bereits eine laufende Generierung. Warte bis sie fertig ist oder nutze \`!cancel\`.` - ); - return; - } - - // Parse the prompt - const parsed = this.parsePrompt(promptInput); - - await this.sendMessage(roomId, `Generiere Bild...\n\n**Prompt:** "${parsed.prompt}"`); - - try { - // Get user's selected model or use default - const modelId = this.userModels.get(sender); - - // Mark generation as active - this.activeGenerations.set(sender, 'generating'); - - const result = await this.pictureService.generateImage(token, { - prompt: parsed.prompt, - negativePrompt: parsed.negativePrompt, - modelId, - width: parsed.width, - height: parsed.height, - steps: parsed.steps, - style: parsed.style, - }); - - // Clear active generation - this.activeGenerations.delete(sender); - - if (result.status === 'completed' && result.image) { - // Upload image to Matrix - const imageUrl = result.image.publicUrl; - if (imageUrl) { - try { - // Download and upload to Matrix - const response = await fetch(imageUrl); - const buffer = Buffer.from(await response.arrayBuffer()); - const mxcUrl = await this.client.uploadContent(buffer, 'image/png'); - - // Send image message - await this.client.sendMessage(roomId, { - msgtype: 'm.image', - body: parsed.prompt.substring(0, 50), - url: mxcUrl, - info: { - mimetype: 'image/png', - w: result.image.width || 1024, - h: result.image.height || 1024, - }, - }); - - let infoText = `**Bild generiert!**\n\n`; - infoText += `**Prompt:** ${parsed.prompt}\n`; - if (result.creditsUsed) { - infoText += `**Credits verwendet:** ${result.creditsUsed}`; - } - - await this.sendMessage(roomId, infoText); - } catch (uploadError) { - this.logger.error('Failed to upload image to Matrix:', uploadError); - await this.sendMessage(roomId, `Bild generiert! Direkter Link: ${imageUrl}`); - } - } else { - await this.sendMessage(roomId, `Bild generiert, aber keine URL verfugbar.`); - } - } else if (result.status === 'processing') { - await this.sendMessage( - roomId, - `Generierung gestartet (ID: ${result.generationId}). Das Bild wird bald fertig sein.` - ); - } else { - await this.sendMessage(roomId, `Generierung fehlgeschlagen. Bitte versuche es erneut.`); - } - } catch (error) { - this.activeGenerations.delete(sender); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - this.logger.error('Generation error:', error); - await this.sendMessage(roomId, `Fehler bei der Generierung: ${errorMsg}`); - } - } - - private async handleModels(roomId: string) { - try { - const models = await this.pictureService.getModels(); - - if (models.length === 0) { - await this.sendMessage(roomId, 'Keine Modelle verfugbar.'); - return; - } - - let text = `**Verfugbare Modelle:**\n\n`; - for (const model of models) { - const defaultTag = model.isDefault ? ' **(Standard)**' : ''; - text += `**${model.name}**${defaultTag}\n`; - text += `ID: \`${model.id}\`\n`; - if (model.description) { - text += `${model.description}\n`; - } - text += `\n`; - } - - text += `\nNutze \`!model [id]\` um ein Modell auszuwahlen.`; - - await this.sendMessage(roomId, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler beim Laden der Modelle: ${errorMsg}`); - } - } - - private async handleSelectModel(roomId: string, sender: string, modelId: string) { - if (!modelId.trim()) { - const currentModel = this.userModels.get(sender); - if (currentModel) { - await this.sendMessage( - roomId, - `Aktuelles Modell: \`${currentModel}\`\n\nNutze \`!models\` um alle Modelle zu sehen.` - ); - } else { - await this.sendMessage( - roomId, - `Kein Modell ausgewahlt (Standard wird verwendet).\n\nNutze \`!models\` um alle Modelle zu sehen.` - ); - } - return; - } - - try { - const model = await this.pictureService.getModel(modelId.trim()); - this.userModels.set(sender, model.id); - await this.sendMessage(roomId, `Modell gewechselt zu: **${model.name}**`); - } catch (error) { - await this.sendMessage( - roomId, - `Modell "${modelId}" nicht gefunden. Nutze \`!models\` fur verfugbare Modelle.` - ); - } - } - - private async handleHistory(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - try { - const images = await this.pictureService.getImages(token, 10); - - if (images.length === 0) { - await this.sendMessage(roomId, `Du hast noch keine Bilder generiert.`); - return; - } - - let text = `**Deine letzten Bilder (${images.length}):**\n\n`; - - for (let i = 0; i < images.length; i++) { - const img = images[i]; - const promptPreview = img.prompt?.substring(0, 40) || 'Kein Prompt'; - const date = new Date(img.createdAt).toLocaleDateString('de-DE'); - text += `**${i + 1}.** "${promptPreview}${img.prompt && img.prompt.length > 40 ? '...' : ''}"\n`; - text += ` ${date} | ${img.width}x${img.height}\n\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 handleDelete(roomId: string, sender: string, args: string[]) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!delete [bild-nr]\`\n\nNutze \`!history\` um Bildnummern zu sehen.` - ); - return; - } - - const imageIndex = parseInt(args[0], 10); - if (isNaN(imageIndex) || imageIndex < 1) { - await this.sendMessage(roomId, `Ungultige Bildnummer.`); - return; - } - - try { - const images = await this.pictureService.getImages(token, 10); - if (imageIndex > images.length) { - await this.sendMessage(roomId, `Bild ${imageIndex} existiert nicht.`); - return; - } - - const image = images[imageIndex - 1]; - await this.pictureService.deleteImage(token, image.id); - - await this.sendMessage(roomId, `Bild ${imageIndex} geloscht.`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - private async handleCredits(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) return; - - try { - const balance = await this.creditService.getBalance(token); - const creditIcon = balance.hasCredits ? '⚡' : '⚠️'; - let text = `${creditIcon} **Dein Credit-Guthaben:** ${balance.balance.toFixed(2)} Credits\n\n`; - text += `Eine Bildgenerierung kostet **${IMAGE_GENERATION_CREDITS} Credits**.`; - - if (balance.balance < IMAGE_GENERATION_CREDITS) { - text += '\n\n⚠️ Nicht genug Credits fur eine Generierung!'; - text += '\n👉 Credits kaufen: https://mana.how/credits'; - } - - await this.sendMessage(roomId, text); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - private async handleCancel(roomId: string, sender: string) { - if (!this.activeGenerations.has(sender)) { - await this.sendMessage(roomId, `Du hast keine laufende Generierung.`); - return; - } - - this.activeGenerations.delete(sender); - await this.sendMessage(roomId, `Generierung abgebrochen.`); - } - - private async sendHelp(roomId: string) { - await this.sendMessage(roomId, HELP_MESSAGE); - } - - /** - * Require login - returns token or sends login prompt and returns null - */ - private async requireLogin(roomId: string, userId: string): Promise { - const token = await this.sessionService.getToken(userId); - if (!token) { - await this.sendMessage(roomId, LOGIN_MESSAGES.picture); - return null; - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendHealthy = await this.pictureService.checkHealth(); - const isLoggedIn = await this.sessionService.isLoggedIn(sender); - const email = await this.sessionService.getEmail(sender); - const token = await this.sessionService.getToken(sender); - const sessionCount = this.sessionService.getSessionCount(); - const currentModel = this.userModels.get(sender); - const hasActiveGeneration = this.activeGenerations.has(sender); - - // Get credit balance if logged in - let creditInfo = ''; - if (token) { - const balance = await this.creditService.getBalance(token); - const creditIcon = balance.hasCredits ? '⚡' : '⚠️'; - creditInfo = `\n${creditIcon} **Credits:** ${balance.balance.toFixed(2)}`; - if (balance.balance < IMAGE_GENERATION_CREDITS && balance.balance > 0) { - creditInfo += '\n⚠️ Nicht genug Credits fur eine Generierung!'; - } - if (!balance.hasCredits) { - creditInfo += '\n👉 Credits kaufen: https://mana.how/credits'; - } - } - - const statusText = `**Picture Bot Status** - -**Backend:** ${backendHealthy ? 'Online' : 'Offline'} -**Dein Status:** ${isLoggedIn ? `Angemeldet (${email})` : 'Nicht angemeldet'}${creditInfo} -**Ausgewahltes Modell:** ${currentModel || 'Standard'} -**Aktive Generierung:** ${hasActiveGeneration ? 'Ja' : 'Nein'} -**Aktive Sessions:** ${sessionCount} - -${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; - - await this.sendMessage(roomId, statusText); - } - - private async pinHelpMessage(roomId: string) { - try { - const htmlBody = this.markdownToHtmlLocal(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 markdownToHtmlLocal(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-picture-bot/src/config/configuration.ts b/services/matrix-picture-bot/src/config/configuration.ts deleted file mode 100644 index 1bc93e877..000000000 --- a/services/matrix-picture-bot/src/config/configuration.ts +++ /dev/null @@ -1,67 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3319', 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', - }, - picture: { - backendUrl: process.env.PICTURE_BACKEND_URL || 'http://localhost:3006', - apiPrefix: process.env.PICTURE_API_PREFIX || '/api/v1', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `**Picture Bot - AI-Bildgenerierung** - -**Bilder generieren:** -- \`!generate [prompt]\` - Bild generieren -- \`!bild [prompt]\` - Bild generieren (deutsch) -- \`!model [id]\` - Modell wechseln -- \`!models\` - Verfugbare Modelle anzeigen - -**Optionen (im Prompt):** -- \`--width 1024\` - Breite setzen -- \`--height 768\` - Hohe setzen -- \`--steps 30\` - Mehr Schritte = mehr Detail -- \`--negative [text]\` - Negative Prompts - -**Beispiele:** -\`!generate A beautiful sunset over mountains --width 1280 --height 720\` -\`!bild Ein niedlicher Hund im Park --steps 40\` - -**Bilder verwalten:** -- \`!history\` - Letzte Bilder anzeigen -- \`!delete [nr]\` - Bild loschen - -**Sonstiges:** -- \`!status\` - Bot-Status -- \`!credits\` - Credits anzeigen -- \`!help\` - Diese Hilfe`; - -export const STYLES = [ - 'photorealistic', - 'anime', - 'digital-art', - 'oil-painting', - 'watercolor', - 'sketch', - '3d-render', - 'pixel-art', -] as const; - -export type Style = (typeof STYLES)[number]; - -export const STYLE_LABELS: Record = { - photorealistic: 'Fotorealistisch', - anime: 'Anime', - 'digital-art': 'Digital Art', - 'oil-painting': 'Olmalerei', - watercolor: 'Aquarell', - sketch: 'Skizze', - '3d-render': '3D Render', - 'pixel-art': 'Pixel Art', -}; diff --git a/services/matrix-picture-bot/src/main.ts b/services/matrix-picture-bot/src/main.ts deleted file mode 100644 index 4b95c383d..000000000 --- a/services/matrix-picture-bot/src/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 || 3319; - await app.listen(port); - - logger.log(`Matrix Picture Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); diff --git a/services/matrix-picture-bot/src/picture/picture.module.ts b/services/matrix-picture-bot/src/picture/picture.module.ts deleted file mode 100644 index 6b5abb7a9..000000000 --- a/services/matrix-picture-bot/src/picture/picture.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PictureService } from './picture.service'; - -@Module({ - providers: [PictureService], - exports: [PictureService], -}) -export class PictureModule {} diff --git a/services/matrix-picture-bot/src/picture/picture.service.ts b/services/matrix-picture-bot/src/picture/picture.service.ts deleted file mode 100644 index 5a75f6c67..000000000 --- a/services/matrix-picture-bot/src/picture/picture.service.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface Model { - id: string; - name: string; - description?: string; - isDefault?: boolean; - defaultWidth?: number; - defaultHeight?: number; -} - -export interface GenerateOptions { - prompt: string; - negativePrompt?: string; - modelId?: string; - width?: number; - height?: number; - steps?: number; - style?: string; -} - -export interface GenerateResult { - generationId: string; - status: string; - image?: { - id: string; - publicUrl?: string; - width?: number; - height?: number; - }; - creditsUsed?: number; -} - -export interface ImageInfo { - id: string; - prompt?: string; - width: number; - height: number; - publicUrl?: string; - createdAt: string; -} - -@Injectable() -export class PictureService { - private readonly logger = new Logger(PictureService.name); - private readonly backendUrl: string; - private readonly apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = - this.configService.get('picture.backendUrl') || 'http://localhost:3006'; - this.apiPrefix = this.configService.get('picture.apiPrefix') || '/api/v1'; - } - - private getApiUrl(path: string): string { - return `${this.backendUrl}${this.apiPrefix}${path}`; - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/health`); - return response.ok; - } catch (error) { - this.logger.error('Health check failed:', error); - return false; - } - } - - async getModels(): Promise { - try { - const response = await fetch(this.getApiUrl('/models')); - - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status}`); - } - - const data = await response.json(); - return data; - } catch (error) { - this.logger.error('Failed to fetch models:', error); - throw error; - } - } - - async getModel(modelId: string): Promise { - try { - const response = await fetch(this.getApiUrl(`/models/${modelId}`)); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Model not found'); - } - throw new Error(`Failed to fetch model: ${response.status}`); - } - - return await response.json(); - } catch (error) { - this.logger.error(`Failed to fetch model ${modelId}:`, error); - throw error; - } - } - - async generateImage(token: string, options: GenerateOptions): Promise { - try { - // First, get default model if none specified - let modelId = options.modelId; - if (!modelId) { - const models = await this.getModels(); - const defaultModel = models.find((m) => m.isDefault) || models[0]; - if (!defaultModel) { - throw new Error('No models available'); - } - modelId = defaultModel.id; - } - - const response = await fetch(this.getApiUrl('/generate'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - prompt: options.prompt, - negativePrompt: options.negativePrompt, - modelId, - width: options.width, - height: options.height, - steps: options.steps, - style: options.style, - waitForResult: true, // Sync mode for Matrix bot - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Generation failed: ${response.status}`); - } - - return await response.json(); - } catch (error) { - this.logger.error('Generation error:', error); - throw error; - } - } - - async getImages(token: string, limit: number = 10): Promise { - try { - const response = await fetch(this.getApiUrl(`/images?limit=${limit}`), { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch images: ${response.status}`); - } - - return await response.json(); - } catch (error) { - this.logger.error('Failed to fetch images:', error); - throw error; - } - } - - async deleteImage(token: string, imageId: string): Promise { - try { - const response = await fetch(this.getApiUrl(`/images/${imageId}`), { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to delete image: ${response.status}`); - } - } catch (error) { - this.logger.error(`Failed to delete image ${imageId}:`, error); - throw error; - } - } - - async getCredits(token: string): Promise { - try { - // Credits are managed by mana-core, but we can try to get them via the backend - // If the backend doesn't expose this endpoint, return a placeholder - const response = await fetch(this.getApiUrl('/credits/balance'), { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!response.ok) { - // Credits endpoint might not exist, return placeholder - return -1; - } - - const data = await response.json(); - return data.balance ?? data.credits ?? -1; - } catch (error) { - this.logger.warn('Failed to fetch credits:', error); - return -1; - } - } -} diff --git a/services/matrix-picture-bot/tsconfig.json b/services/matrix-picture-bot/tsconfig.json deleted file mode 100644 index 38c2b55d7..000000000 --- a/services/matrix-picture-bot/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 - } -} diff --git a/services/matrix-planta-bot/.env.example b/services/matrix-planta-bot/.env.example deleted file mode 100644 index aefa021b0..000000000 --- a/services/matrix-planta-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3322 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#planta:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Planta Backend -PLANTA_BACKEND_URL=http://localhost:3022 -PLANTA_API_PREFIX=/api - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-planta-bot/.gitignore b/services/matrix-planta-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-planta-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-planta-bot/CLAUDE.md b/services/matrix-planta-bot/CLAUDE.md deleted file mode 100644 index e3e72bd2c..000000000 --- a/services/matrix-planta-bot/CLAUDE.md +++ /dev/null @@ -1,223 +0,0 @@ -# Matrix Planta Bot - Claude Code Guidelines - -## Overview - -Matrix Planta Bot provides plant care management via Matrix chat. It integrates with the Planta backend for plant CRUD operations, watering schedules, watering history, care settings, and **AI-powered plant identification** via image analysis. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Planta API (port 3022) -- **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-planta-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3322) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── planta/ -│ │ ├── planta.module.ts -│ │ └── planta.service.ts # Planta Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -### Plant Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!pflanzen` | plants, liste | List all plants | -| `!pflanze [nr]` | plant, details | Show plant details | -| `!neu Name` | new, add | Add new plant | -| `!loeschen [nr]` | delete, entfernen | Remove plant | -| `!edit [nr] [feld] [wert]` | bearbeiten | Edit plant field | - -### AI Plant Identification - -| Action | Description | -|--------|-------------| -| Send image | Automatically analyzes plant with Gemini Vision AI | - -When you send an image to the bot, it will: -1. Upload the image to the Planta backend -2. Analyze it using Google Gemini Vision -3. Return identification (scientific name, common names, confidence) -4. Show health assessment and care tips - -### Watering - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!giessen [nr]` | water | Mark plant as watered | -| `!giessen [nr] Notiz` | - | Water with note | -| `!faellig` | due, upcoming | Show watering status | -| `!historie [nr]` | history, verlauf | Watering history | -| `!intervall [nr] [tage]` | interval, frequenz | Set watering interval | - -## Editable Fields - -| Field | Aliases | Values | -|-------|---------|--------| -| `name` | - | Any text | -| `art` | wissenschaftlich, scientific | Scientific name | -| `licht` | light | wenig/low, mittel/medium, hell/bright, direkt/direct | -| `wasser` | water | Number of days | -| `feuchtigkeit` | humidity | niedrig/low, mittel/medium, hoch/high | -| `temperatur` | temperature | Any text | -| `erde` | soil | Any text | -| `notizen` | notes | Any text | - -## Example Usage - -``` -# Login -!login max@example.com mypassword - -# Send a plant image -> Bot responds with: -# 🌿 Pflanze erkannt! -# Monstera deliciosa (Fensterblatt) -# ✅ Konfidenz: 92% -# -# Gesundheit: 💚 Gesund -# -# 📋 Pflegetipps: -# • ☀️ Helles Licht - Heller Standort mit indirektem Sonnenlicht -# • 💧 Alle 7 Tage giessen -# • 🌱 Blaetter regelmaessig mit Wasser besprühen - -# Add a new plant -!neu Monstera Deliciosa - -# Edit plant properties -!edit 1 licht hell -!edit 1 wasser 7 -!edit 1 notizen Fensterbank Wohnzimmer - -# Water a plant -!giessen 1 -!giessen 1 Etwas Duenger hinzugefuegt - -# Check watering status -!faellig - -# View watering history -!historie 1 - -# Set watering interval -!intervall 1 5 -``` - -## Plant Health Status - -| Status | Emoji | Description | -|--------|-------|-------------| -| `healthy` | 🌱 | Plant is healthy | -| `needs_attention` | ⚠️ | Plant needs care | -| `sick` | 🥀 | Plant is sick | - -## Environment Variables - -```env -# Server -PORT=3322 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#planta:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Planta Backend -PLANTA_BACKEND_URL=http://localhost:3022 -PLANTA_API_PREFIX=/api - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-planta-bot/Dockerfile -t matrix-planta-bot services/matrix-planta-bot - -# Run -docker run -p 3322:3322 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e PLANTA_BACKEND_URL=http://planta-backend:3022 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-planta-bot-data:/app/data \ - matrix-planta-bot -``` - -## Health Check - -```bash -curl http://localhost:3322/health -``` - -## Planta Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/plants` | GET | List user's plants | -| `/api/plants` | POST | Create plant | -| `/api/plants/:id` | GET | Get plant details | -| `/api/plants/:id` | PUT | Update plant | -| `/api/plants/:id` | DELETE | Delete plant | -| `/api/watering/upcoming` | GET | Get upcoming waterings | -| `/api/watering/:plantId/water` | POST | Log watering | -| `/api/watering/:plantId` | PUT | Update watering schedule | -| `/api/watering/:plantId/history` | GET | Get watering history | -| `/api/photos/upload` | POST | Upload plant photo (multipart) | -| `/api/analysis/identify` | POST | Analyze photo with Gemini Vision AI | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!pflanzen` or `!faellig` to get a list -2. Bot stores the list internally for the user -3. User can reference plants by their list number -4. Numbers are valid until the user runs a new list command - -This allows simple commands like: -- `!pflanze 3` - Show details for plant #3 -- `!giessen 1` - Water plant #1 -- `!edit 2 licht hell` - Set light requirement for plant #2 diff --git a/services/matrix-planta-bot/Dockerfile b/services/matrix-planta-bot/Dockerfile deleted file mode 100644 index f164e39ba..000000000 --- a/services/matrix-planta-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-planta-bot ./services/matrix-planta-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @mana-bots/matrix-planta-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-planta-bot/dist ./services/matrix-planta-bot/dist -COPY --from=builder /app/services/matrix-planta-bot/package.json ./services/matrix-planta-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-planta-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4022/health || exit 1 - -EXPOSE 4022 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-planta-bot/nest-cli.json b/services/matrix-planta-bot/nest-cli.json deleted file mode 100644 index 5c06bb8c3..000000000 --- a/services/matrix-planta-bot/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json-schema.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/matrix-planta-bot/package.json b/services/matrix-planta-bot/package.json deleted file mode 100644 index f0d73904b..000000000 --- a/services/matrix-planta-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-planta-bot", - "version": "1.0.0", - "description": "Matrix bot for plant care management", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-planta-bot/src/app.module.ts b/services/matrix-planta-bot/src/app.module.ts deleted file mode 100644 index 3ab2c287d..000000000 --- a/services/matrix-planta-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { PlantaModule } from './planta/planta.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - PlantaModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-planta-bot')], -}) -export class AppModule {} diff --git a/services/matrix-planta-bot/src/bot/bot.module.ts b/services/matrix-planta-bot/src/bot/bot.module.ts deleted file mode 100644 index 6d0355b2f..000000000 --- a/services/matrix-planta-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { PlantaModule } from '../planta/planta.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - PlantaModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-planta-bot/src/bot/matrix.service.ts b/services/matrix-planta-bot/src/bot/matrix.service.ts deleted file mode 100644 index 01a301e02..000000000 --- a/services/matrix-planta-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,832 +0,0 @@ -import { Injectable, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - UserListMapper, - KeywordCommandDetector, - COMMON_KEYWORDS, - handleCreditCommand, - type CreditCommandsHost, -} from '@manacore/matrix-bot-common'; -import { PlantaService, Plant, PlantAnalysis } from '../planta/planta.service'; -import { - SessionService, - TranscriptionService, - CreditService, - I18nService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -@Injectable() -export class MatrixService extends BaseMatrixService implements CreditCommandsHost { - // Store last shown plants per user for reference by number - private plantsMapper = new UserListMapper(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['pflanzen', 'plants', 'meine pflanzen', 'liste'], command: 'pflanzen' }, - { keywords: ['giessen', 'water', 'bewaessern', 'wasser geben'], command: 'giessen' }, - { keywords: ['faellig', 'due', 'anstehend', 'upcoming'], command: 'faellig' }, - { keywords: ['neu', 'new', 'neue pflanze', 'add'], command: 'neu' }, - { keywords: ['historie', 'history', 'verlauf', 'giess historie'], command: 'historie' }, - { keywords: ['intervall', 'interval', 'frequenz', 'wie oft'], command: 'intervall' }, - { keywords: ['credits', 'guthaben', 'kontostand'], command: 'credits' }, - { keywords: ['packages', 'pakete', 'preise'], command: 'packages' }, - { keywords: ['kaufen', 'buy'], command: 'buy' }, - ]); - - // Field mappings for edit command - private readonly fieldMappings: Record = { - name: 'name', - art: 'scientificName', - wissenschaftlich: 'scientificName', - scientific: 'scientificName', - licht: 'lightRequirements', - light: 'lightRequirements', - wasser: 'wateringFrequencyDays', - water: 'wateringFrequencyDays', - feuchtigkeit: 'humidity', - humidity: 'humidity', - temperatur: 'temperature', - temperature: 'temperature', - erde: 'soilType', - soil: 'soilType', - notizen: 'careNotes', - notes: 'careNotes', - }; - - // Expose services for credit commands mixin (CreditCommandsHost interface) - public sessionService: SessionService; - public creditService: CreditService; - public i18nService!: I18nService; - - constructor( - configService: ConfigService, - private readonly transcriptionService: TranscriptionService, - private plantaService: PlantaService, - sessionService: SessionService, - creditService: CreditService, - @Optional() i18nService?: I18nService - ) { - super(configService); - // Assign to public properties for credit commands mixin - this.sessionService = sessionService; - this.creditService = creditService; - if (i18nService) { - this.i18nService = i18nService; - } - } - - // ============================================================================ - // CreditCommandsHost interface implementation - // ============================================================================ - - /** - * Send a credit message (delegates to protected sendMessage) - */ - async sendCreditMessage(roomId: string, message: string): Promise { - await this.sendMessage(roomId, message); - } - - /** - * Send a credit reply (delegates to protected sendReply) - */ - async sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise { - await this.sendReply(roomId, event, message); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendMessage(roomId, '

❌ Sprachnachricht konnte nicht erkannt werden.

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

🎤 "${text}"

`); - await this.handleTextMessage(roomId, event, text); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendMessage(roomId, '

❌ Fehler bei der Spracherkennung.

'); - } - } - - protected override async handleImageMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - // Check auth - const token = await this.sessionService.getToken(sender); - if (!token) { - await this.sendMessage( - roomId, - '

🌱 Melde dich an, um Pflanzen zu analysieren: !login email passwort

' - ); - return; - } - - // Send processing message - await this.sendMessage(roomId, '

🔍 Analysiere Pflanzenbild...

'); - - // Download image - const imageBuffer = await this.downloadMedia(mxcUrl); - const mimeType = (event.content.info?.mimetype as string) || 'image/jpeg'; - const filename = event.content.body || 'plant.jpg'; - - // Upload and analyze - const result = await this.plantaService.uploadAndAnalyze( - token, - imageBuffer, - mimeType, - filename - ); - - if (result.error || !result.data) { - await this.sendMessage(roomId, `

❌ ${result.error || 'Analyse fehlgeschlagen'}

`); - return; - } - - // Format and send result - const html = this.formatAnalysisResult(result.data); - await this.sendMessage(roomId, html); - } catch (error) { - this.logger.error(`Image analysis error: ${error}`); - await this.sendMessage(roomId, '

❌ Fehler bei der Bildanalyse.

'); - } - } - - private formatAnalysisResult(analysis: PlantAnalysis): string { - const confidence = analysis.confidence || 0; - const confidenceEmoji = confidence >= 80 ? '✅' : confidence >= 50 ? '🤔' : '❓'; - - let html = '

🌿 Pflanze erkannt!

'; - - // Identification - const scientificName = analysis.scientificName || analysis.identifiedSpecies || 'Unbekannt'; - const commonNames = analysis.commonNames?.join(', ') || ''; - - html += `

${scientificName}`; - if (commonNames) { - html += ` (${commonNames})`; - } - html += `
${confidenceEmoji} Konfidenz: ${confidence}%

`; - - // Health - if (analysis.healthAssessment) { - const healthEmoji = this.getHealthStatusEmoji(analysis.healthAssessment); - html += `

Gesundheit: ${healthEmoji} ${this.translateHealthStatus(analysis.healthAssessment)}`; - if (analysis.healthDetails) { - html += `
${analysis.healthDetails}`; - } - html += '

'; - - if (analysis.issues && analysis.issues.length > 0) { - html += '

Probleme:

    '; - for (const issue of analysis.issues) { - html += `
  • ⚠️ ${issue}
  • `; - } - html += '
'; - } - } - - // Care tips - html += '

📋 Pflegetipps:

    '; - if (analysis.lightAdvice) { - html += `
  • ☀️ ${analysis.lightAdvice}
  • `; - } - if (analysis.wateringAdvice) { - html += `
  • 💧 ${analysis.wateringAdvice}
  • `; - } - if (analysis.generalTips && analysis.generalTips.length > 0) { - for (const tip of analysis.generalTips.slice(0, 3)) { - html += `
  • 🌱 ${tip}
  • `; - } - } - html += '
'; - - // Call to action - html += '

Pflanze hinzufuegen mit: !neu Pflanzenname

'; - - return html; - } - - private getHealthStatusEmoji(status: string): string { - const emojiMap: Record = { - healthy: '💚', - minor_issues: '💛', - needs_care: '🧡', - critical: '❤️', - }; - return emojiMap[status] || '💚'; - } - - private translateHealthStatus(status: string): string { - const statusMap: Record = { - healthy: 'Gesund', - minor_issues: 'Kleinere Probleme', - needs_care: 'Braucht Pflege', - critical: 'Kritisch', - }; - return statusMap[status] || status; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - this.logger.debug(`[PLANTA] handleTextMessage called: body="${body}", sender=${event.sender}`); - - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - if (!body.startsWith('!')) { - this.logger.debug(`[PLANTA] Message doesn't start with ! - ignoring`); - return; - } - - const sender = event.sender; - const parts = body.slice(1).split(/\s+/); - const command = parts[0].toLowerCase(); - const args = parts.slice(1); - const argString = args.join(' '); - this.logger.debug(`[PLANTA] Processing command: ${command}`); - - try { - // Handle credit commands first (credits, packages, buy) - if (await handleCreditCommand(this, roomId, event, sender, command, argString)) { - return; - } - - switch (command) { - case 'help': - case 'hilfe': - this.logger.debug(`[PLANTA] Sending help message to room ${roomId}`); - await this.sendMessage(roomId, HELP_MESSAGE); - this.logger.debug(`[PLANTA] Help message sent successfully`); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - case 'pflanzen': - case 'plants': - case 'liste': - await this.handleListPlants(roomId, sender); - break; - - case 'pflanze': - case 'plant': - case 'details': - await this.handlePlantDetails(roomId, sender, args[0]); - break; - - case 'neu': - case 'new': - case 'add': - await this.handleAddPlant(roomId, sender, argString); - break; - - case 'loeschen': - case 'delete': - case 'entfernen': - await this.handleDeletePlant(roomId, sender, args[0]); - break; - - case 'edit': - case 'bearbeiten': - await this.handleEditPlant(roomId, sender, args); - break; - - case 'giessen': - case 'water': - await this.handleWaterPlant(roomId, sender, args[0], args.slice(1).join(' ')); - break; - - case 'faellig': - case 'due': - case 'upcoming': - await this.handleUpcomingWaterings(roomId, sender); - break; - - case 'historie': - case 'history': - case 'verlauf': - await this.handleWateringHistory(roomId, sender, args[0]); - break; - - case 'intervall': - case 'interval': - case 'frequenz': - await this.handleSetInterval(roomId, sender, args[0], args[1]); - break; - - default: - await this.sendMessage( - roomId, - `

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

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

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

`); - } - } - - private async requireAuth(sender: string): Promise { - const token = await this.sessionService.getToken(sender); - if (!token) { - throw new Error(LOGIN_MESSAGES.planta); - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendOk = await this.plantaService.checkHealth(); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const sessions = await this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusHtml = `

Planta Bot Status

    `; - statusHtml += `
  • Backend: ${backendOk ? '✅ Online' : '❌ Offline'}
  • `; - statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; - statusHtml += `
  • ⚡ Credits: ${balance.balance.toFixed(2)}
  • `; - } else { - statusHtml += `
  • 👤 Nicht angemeldet
  • `; - statusHtml += `
  • 💡 Login: !login email passwort
  • `; - } - statusHtml += `
`; - - await this.sendMessage(roomId, statusHtml); - } - - // Plant handlers - private async handleListPlants(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.plantaService.getPlants(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const plants = result.data || []; - this.plantsMapper.setList(sender, plants); - - if (plants.length === 0) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - - let html = '

Deine Pflanzen

    '; - for (const plant of plants) { - const scientific = plant.scientificName ? ` (${plant.scientificName})` : ''; - const health = this.getHealthEmoji(plant.healthStatus); - html += `
  1. ${health} ${plant.name}${scientific}
  2. `; - } - html += '
'; - html += - '

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

'; - - await this.sendMessage(roomId, html); - } - - private async handlePlantDetails(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const plant = this.getPlantByNumber(sender, numberStr); - - if (!plant) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !pflanzen

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

Fehler: ${result.error}

`); - return; - } - - const p = result.data!; - const health = this.getHealthEmoji(p.healthStatus); - let html = `

${health} ${p.name}

`; - - if (p.scientificName) html += `

${p.scientificName}

`; - - html += '
    '; - if (p.lightRequirements) html += `
  • Licht: ${this.translateLight(p.lightRequirements)}
  • `; - if (p.wateringFrequencyDays) html += `
  • Giessen: alle ${p.wateringFrequencyDays} Tage
  • `; - if (p.humidity) html += `
  • Feuchtigkeit: ${this.translateHumidity(p.humidity)}
  • `; - if (p.temperature) html += `
  • Temperatur: ${p.temperature}
  • `; - if (p.soilType) html += `
  • Erde: ${p.soilType}
  • `; - if (p.healthStatus) html += `
  • Gesundheit: ${this.translateHealth(p.healthStatus)}
  • `; - if (p.acquiredAt) - html += `
  • Erworben: ${new Date(p.acquiredAt).toLocaleDateString('de-DE')}
  • `; - html += '
'; - - if (p.careNotes) { - html += `

Notizen: ${p.careNotes}

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

Verwendung: !neu Pflanzenname

'); - return; - } - - const token = await this.requireAuth(sender); - const result = await this.plantaService.createPlant(token, name); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - // Clear cached list - this.plantsMapper.clearList(sender); - await this.sendMessage( - roomId, - `

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

-

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

` - ); - } - - private async handleDeletePlant(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const plant = this.getPlantByNumber(sender, numberStr); - - if (!plant) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !pflanzen

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

Fehler: ${result.error}

`); - return; - } - - // Clear cached list - this.plantsMapper.clearList(sender); - await this.sendMessage(roomId, `

Pflanze ${plant.name} entfernt.

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

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

Felder: name, art, licht, wasser, notizen

' - ); - return; - } - - const token = await this.requireAuth(sender); - const plant = this.getPlantByNumber(sender, args[0]); - - if (!plant) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !pflanzen

' - ); - return; - } - - const fieldInput = args[1].toLowerCase(); - const field = this.fieldMappings[fieldInput]; - const value = args.slice(2).join(' '); - - if (!field) { - await this.sendMessage( - roomId, - `

Unbekanntes Feld: ${fieldInput}

Verfuegbar: name, art, licht, wasser, notizen

` - ); - return; - } - - // Validate and convert values - let updateValue: string | number = value; - if (field === 'wateringFrequencyDays') { - updateValue = parseInt(value, 10); - if (isNaN(updateValue) || updateValue < 1) { - await this.sendMessage(roomId, '

Wasser-Intervall muss eine positive Zahl sein.

'); - return; - } - } else if (field === 'lightRequirements') { - const lightMap: Record = { - wenig: 'low', - low: 'low', - gering: 'low', - mittel: 'medium', - medium: 'medium', - hell: 'bright', - bright: 'bright', - viel: 'bright', - direkt: 'direct', - direct: 'direct', - sonne: 'direct', - }; - updateValue = lightMap[value.toLowerCase()]; - if (!updateValue) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - } else if (field === 'humidity') { - const humidityMap: Record = { - niedrig: 'low', - low: 'low', - gering: 'low', - trocken: 'low', - mittel: 'medium', - medium: 'medium', - normal: 'medium', - hoch: 'high', - high: 'high', - feucht: 'high', - }; - updateValue = humidityMap[value.toLowerCase()]; - if (!updateValue) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - } - - const result = await this.plantaService.updatePlant(token, plant.id, { - [field]: updateValue, - }); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

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

` - ); - } - - // Watering handlers - private async handleWaterPlant( - roomId: string, - sender: string, - numberStr: string, - notes?: string - ) { - const token = await this.requireAuth(sender); - const plant = this.getPlantByNumber(sender, numberStr); - - if (!plant) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !pflanzen

' - ); - return; - } - - const result = await this.plantaService.waterPlant(token, plant.id, notes || undefined); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - let html = `

${plant.name} gegossen!

`; - if (notes) { - html += `

Notiz: ${notes}

`; - } - - await this.sendMessage(roomId, html); - } - - private async handleUpcomingWaterings(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.plantaService.getUpcomingWaterings(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

Keine Pflanzen muessen in den naechsten Tagen gegossen werden.

' - ); - return; - } - - let html = '

Giess-Status

    '; - for (const item of upcoming) { - const status = item.isOverdue - ? `Ueberfaellig (${Math.abs(item.daysUntilWatering)} Tage)` - : item.daysUntilWatering === 0 - ? 'Heute' - : `in ${item.daysUntilWatering} Tag${item.daysUntilWatering > 1 ? 'en' : ''}`; - html += `
  • ${item.plant.name}: ${status}
  • `; - } - html += '
'; - - // Store plants for reference - this.plantsMapper.setList( - sender, - upcoming.map((u) => u.plant) - ); - - await this.sendMessage(roomId, html); - } - - private async handleWateringHistory(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const plant = this.getPlantByNumber(sender, numberStr); - - if (!plant) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !pflanzen

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

Fehler: ${result.error}

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

${plant.name} wurde noch nie gegossen.

` - ); - return; - } - - let html = `

Giess-Historie: ${plant.name}

    `; - for (const log of logs.slice(0, 10)) { - const date = new Date(log.wateredAt).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - const notes = log.notes ? ` - ${log.notes}` : ''; - html += `
  • ${date}${notes}
  • `; - } - html += '
'; - - if (logs.length > 10) { - html += `

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

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

Verwendung: !intervall [nr] [tage]

'); - return; - } - - const token = await this.requireAuth(sender); - const plant = this.getPlantByNumber(sender, numberStr); - - if (!plant) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !pflanzen

' - ); - return; - } - - const days = parseInt(daysStr, 10); - if (isNaN(days) || days < 1) { - await this.sendMessage(roomId, '

Tage muss eine positive Zahl sein.

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

Fehler: ${result.error}

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

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

` - ); - } - - // Helper methods - private getPlantByNumber(sender: string, numberStr: string): Plant | null { - const num = parseInt(numberStr, 10); - if (isNaN(num)) return null; - return this.plantsMapper.getByNumber(sender, num); - } - - private getHealthEmoji(status?: string): string { - switch (status) { - case 'healthy': - return '🌱'; // Seedling - case 'needs_attention': - return '⚠️'; // Warning - case 'sick': - return '🤢'; // Wilted - default: - return '🌱'; - } - } - - private translateLight(light: string): string { - const map: Record = { - low: 'Wenig Licht', - medium: 'Mittleres Licht', - bright: 'Helles Licht', - direct: 'Direktes Sonnenlicht', - }; - return map[light] || light; - } - - private translateHumidity(humidity: string): string { - const map: Record = { - low: 'Niedrig', - medium: 'Mittel', - high: 'Hoch', - }; - return map[humidity] || humidity; - } - - private translateHealth(health: string): string { - const map: Record = { - healthy: 'Gesund', - needs_attention: 'Braucht Aufmerksamkeit', - sick: 'Krank', - }; - return map[health] || health; - } -} diff --git a/services/matrix-planta-bot/src/config/configuration.ts b/services/matrix-planta-bot/src/config/configuration.ts deleted file mode 100644 index b7d7d7388..000000000 --- a/services/matrix-planta-bot/src/config/configuration.ts +++ /dev/null @@ -1,50 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3322', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN, - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - planta: { - backendUrl: process.env.PLANTA_BACKEND_URL || 'http://localhost:3022', - apiPrefix: process.env.PLANTA_API_PREFIX || '/api', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `

Planta Bot - Befehle

- -

Pflanzen verwalten

-
    -
  • !pflanzen - Alle Pflanzen auflisten
  • -
  • !pflanze [nr] - Pflanzendetails anzeigen
  • -
  • !neu Name - Neue Pflanze hinzufuegen
  • -
  • !loeschen [nr] - Pflanze entfernen
  • -
  • !edit [nr] [feld] [wert] - Pflanze bearbeiten
  • -
- -

Giessen

-
    -
  • !giessen [nr] - Pflanze als gegossen markieren
  • -
  • !giessen [nr] Notiz - Mit Notiz giessen
  • -
  • !faellig - Pflanzen die gegossen werden muessen
  • -
  • !historie [nr] - Giess-Historie anzeigen
  • -
- -

Pflege-Einstellungen

-
    -
  • !intervall [nr] [tage] - Giess-Intervall aendern
  • -
- -

Weitere Befehle

-
    -
  • !help - Diese Hilfe anzeigen
  • -
- -

Bearbeitbare Felder

-

name, art (scientificName), licht (low/medium/bright/direct), wasser (Tage), notizen

- -

Tipp: Nutze Pflanzennummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-planta-bot/src/main.ts b/services/matrix-planta-bot/src/main.ts deleted file mode 100644 index 527c77df7..000000000 --- a/services/matrix-planta-bot/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3322; - await app.listen(port); - console.log(`Matrix Planta Bot running on port ${port}`); -} -bootstrap(); diff --git a/services/matrix-planta-bot/src/planta/planta.module.ts b/services/matrix-planta-bot/src/planta/planta.module.ts deleted file mode 100644 index bd941684e..000000000 --- a/services/matrix-planta-bot/src/planta/planta.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PlantaService } from './planta.service'; - -@Module({ - providers: [PlantaService], - exports: [PlantaService], -}) -export class PlantaModule {} diff --git a/services/matrix-planta-bot/src/planta/planta.service.ts b/services/matrix-planta-bot/src/planta/planta.service.ts deleted file mode 100644 index 2a42fc602..000000000 --- a/services/matrix-planta-bot/src/planta/planta.service.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface Plant { - id: string; - name: string; - scientificName?: string; - commonName?: string; - species?: string; - lightRequirements?: 'low' | 'medium' | 'bright' | 'direct'; - wateringFrequencyDays?: number; - humidity?: 'low' | 'medium' | 'high'; - temperature?: string; - soilType?: string; - careNotes?: string; - healthStatus?: 'healthy' | 'needs_attention' | 'sick'; - acquiredAt?: string; - createdAt: string; - updatedAt: string; -} - -export interface WateringSchedule { - id: string; - plantId: string; - frequencyDays: number; - lastWateredAt?: string; - nextWateringAt?: string; - reminderEnabled: boolean; -} - -export interface WateringLog { - id: string; - plantId: string; - wateredAt: string; - notes?: string; -} - -export interface UpcomingWatering { - plant: Plant; - schedule: WateringSchedule; - daysUntilWatering: number; - isOverdue: boolean; -} - -export interface PlantPhoto { - id: string; - plantId?: string; - storagePath: string; - publicUrl?: string; - isPrimary: boolean; - isAnalyzed: boolean; -} - -export interface PlantAnalysis { - id: string; - photoId: string; - identifiedSpecies?: string; - scientificName?: string; - commonNames?: string[]; - confidence?: number; - healthAssessment?: string; - healthDetails?: string; - issues?: string[]; - wateringAdvice?: string; - lightAdvice?: string; - generalTips?: string[]; -} - -@Injectable() -export class PlantaService { - private readonly logger = new Logger(PlantaService.name); - private backendUrl: string; - private apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = - this.configService.get('planta.backendUrl') || 'http://localhost:3022'; - this.apiPrefix = this.configService.get('planta.apiPrefix') || '/api'; - } - - private async request( - token: string, - endpoint: string, - options: RequestInit = {} - ): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - // Plant operations - async getPlants(token: string): Promise<{ data?: Plant[]; error?: string }> { - return this.request(token, '/plants'); - } - - async getPlant(token: string, plantId: string): Promise<{ data?: Plant; error?: string }> { - return this.request(token, `/plants/${plantId}`); - } - - async createPlant( - token: string, - name: string, - options: Partial = {} - ): Promise<{ data?: Plant; error?: string }> { - return this.request(token, '/plants', { - method: 'POST', - body: JSON.stringify({ name, ...options }), - }); - } - - async updatePlant( - token: string, - plantId: string, - updates: Partial - ): Promise<{ data?: Plant; error?: string }> { - return this.request(token, `/plants/${plantId}`, { - method: 'PUT', - body: JSON.stringify(updates), - }); - } - - async deletePlant(token: string, plantId: string): Promise<{ error?: string }> { - return this.request(token, `/plants/${plantId}`, { method: 'DELETE' }); - } - - // Watering operations - async getUpcomingWaterings( - token: string - ): Promise<{ data?: UpcomingWatering[]; error?: string }> { - return this.request(token, '/watering/upcoming'); - } - - async waterPlant( - token: string, - plantId: string, - notes?: string - ): Promise<{ data?: WateringLog; error?: string }> { - return this.request(token, `/watering/${plantId}/water`, { - method: 'POST', - body: JSON.stringify({ notes }), - }); - } - - async updateWateringSchedule( - token: string, - plantId: string, - frequencyDays: number - ): Promise<{ data?: WateringSchedule; error?: string }> { - return this.request(token, `/watering/${plantId}`, { - method: 'PUT', - body: JSON.stringify({ frequencyDays }), - }); - } - - async getWateringHistory( - token: string, - plantId: string - ): Promise<{ data?: WateringLog[]; error?: string }> { - return this.request(token, `/watering/${plantId}/history`); - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/health`); - return response.ok; - } catch { - return false; - } - } - - /** - * Upload a photo for analysis - */ - async uploadPhoto( - token: string, - imageBuffer: Buffer, - mimeType: string, - filename: string, - plantId?: string - ): Promise<{ data?: PlantPhoto; error?: string }> { - try { - const formData = new FormData(); - // Convert Buffer to Blob - use type assertion to bypass strict TypeScript check - const blob = new Blob([imageBuffer as unknown as BlobPart], { type: mimeType }); - formData.append('file', blob, filename); - - let url = `${this.backendUrl}${this.apiPrefix}/photos/upload`; - if (plantId) { - url += `?plantId=${plantId}`; - } - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error('Photo upload failed:', error); - return { error: 'Foto-Upload fehlgeschlagen' }; - } - } - - /** - * Analyze a photo with AI - */ - async analyzePhoto( - token: string, - photoId: string, - plantId?: string - ): Promise<{ data?: PlantAnalysis; error?: string }> { - try { - const body: Record = { photoId }; - if (plantId) body.plantId = plantId; - - const response = await fetch(`${this.backendUrl}${this.apiPrefix}/analysis/identify`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error('Photo analysis failed:', error); - return { error: 'Analyse fehlgeschlagen' }; - } - } - - /** - * Upload and analyze a photo in one step - */ - async uploadAndAnalyze( - token: string, - imageBuffer: Buffer, - mimeType: string, - filename: string, - plantId?: string - ): Promise<{ data?: PlantAnalysis; error?: string }> { - // Step 1: Upload - const uploadResult = await this.uploadPhoto(token, imageBuffer, mimeType, filename, plantId); - if (uploadResult.error || !uploadResult.data) { - return { error: uploadResult.error || 'Upload fehlgeschlagen' }; - } - - // Step 2: Analyze - return this.analyzePhoto(token, uploadResult.data.id, plantId); - } -} diff --git a/services/matrix-planta-bot/tsconfig.json b/services/matrix-planta-bot/tsconfig.json deleted file mode 100644 index 94f1e9493..000000000 --- a/services/matrix-planta-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - } -} diff --git a/services/matrix-presi-bot/.env.example b/services/matrix-presi-bot/.env.example deleted file mode 100644 index e09eaa052..000000000 --- a/services/matrix-presi-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3325 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#presi:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Presi Backend -PRESI_BACKEND_URL=http://localhost:3008 -PRESI_API_PREFIX=/api - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-presi-bot/.gitignore b/services/matrix-presi-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-presi-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-presi-bot/CLAUDE.md b/services/matrix-presi-bot/CLAUDE.md deleted file mode 100644 index f77e718dd..000000000 --- a/services/matrix-presi-bot/CLAUDE.md +++ /dev/null @@ -1,208 +0,0 @@ -# Matrix Presi Bot - Claude Code Guidelines - -## Overview - -Matrix Presi Bot provides presentation management via Matrix chat. It integrates with the Presi backend for deck/slide management, theming, and sharing. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Presi API (port 3008) -- **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-presi-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3325) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── presi/ -│ │ ├── presi.module.ts -│ │ └── presi.service.ts # Presi Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -### Presentation Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!presis` | decks, liste | List all presentations | -| `!presi [nr]` | deck, details | Show presentation with slides | -| `!neu Titel` | new, create | Create presentation | -| `!loeschen [nr]` | delete | Delete presentation | -| `!umbenennen [nr] Titel` | rename | Rename presentation | - -### Slide Management - -| Command | Description | -|---------|-------------| -| `!folie [nr] titel Titel \| Untertitel` | Add title slide | -| `!folie [nr] text Titel \| Inhalt` | Add content slide | -| `!folie [nr] punkte Titel \| P1, P2, P3` | Add bullet slide | -| `!folie [nr] bild Titel \| URL` | Add image slide | -| `!folieloeschen [presi-nr] [folien-nr]` | Delete slide | - -### Themes - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!themes` | designs | List available themes | -| `!theme [presi-nr] [theme-nr]` | design | Apply theme | - -### Sharing - -| Command | Options | Description | -|---------|---------|-------------| -| `!teilen [nr]` | share | Share presentation | -| `--tage N` | - | Expire in N days | -| `!links [nr]` | shares | List share links | - -## Slide Types - -| Type | Content | -|------|---------| -| `title` | Title + optional subtitle | -| `content` | Title + body text | -| `bullets` | Title + bullet points | -| `image` | Title + image URL | - -## Example Usage - -``` -# Login -!login max@example.com mypassword - -# Create presentation -!neu Meine Praesentation | Eine tolle Praesentation - -# List presentations -!presis - -# Add title slide -!folie 1 titel Willkommen | Zur Praesentation - -# Add content slide -!folie 1 text Einfuehrung | Hier ist der Inhalt - -# Add bullet points -!folie 1 punkte Agenda | Punkt 1, Punkt 2, Punkt 3 - -# View presentation -!presi 1 - -# Apply theme -!themes -!theme 1 2 - -# Share presentation -!teilen 1 --tage 7 - -# View share links -!links 1 -``` - -## Environment Variables - -```env -# Server -PORT=3325 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#presi:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Presi Backend -PRESI_BACKEND_URL=http://localhost:3008 -PRESI_API_PREFIX=/api - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-presi-bot/Dockerfile -t matrix-presi-bot services/matrix-presi-bot - -# Run -docker run -p 3325:3325 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e PRESI_BACKEND_URL=http://presi-backend:3008 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-presi-bot-data:/app/data \ - matrix-presi-bot -``` - -## Health Check - -```bash -curl http://localhost:3325/health -``` - -## Presi Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/decks` | GET | List presentations | -| `/api/decks` | POST | Create presentation | -| `/api/decks/:id` | GET | Get presentation with slides | -| `/api/decks/:id` | PUT | Update presentation | -| `/api/decks/:id` | DELETE | Delete presentation | -| `/api/decks/:id/slides` | POST | Add slide | -| `/api/slides/:id` | DELETE | Delete slide | -| `/api/themes` | GET | List themes | -| `/api/share/deck/:id` | POST | Create share link | -| `/api/share/deck/:id/links` | GET | List share links | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!presis` or `!themes` to get a list -2. Bot stores the list internally for the user -3. User can reference items by their list number -4. Numbers are valid until the user runs a new list command - -This allows simple commands like: -- `!presi 2` - Show presentation #2 -- `!folie 1 titel Hallo` - Add slide to presentation #1 -- `!theme 1 3` - Apply theme #3 to presentation #1 diff --git a/services/matrix-presi-bot/Dockerfile b/services/matrix-presi-bot/Dockerfile deleted file mode 100644 index 3b82327df..000000000 --- a/services/matrix-presi-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3325 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3325/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-presi-bot/nest-cli.json b/services/matrix-presi-bot/nest-cli.json deleted file mode 100644 index 5c06bb8c3..000000000 --- a/services/matrix-presi-bot/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json-schema.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/matrix-presi-bot/package.json b/services/matrix-presi-bot/package.json deleted file mode 100644 index 63b5dee51..000000000 --- a/services/matrix-presi-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-presi-bot", - "version": "1.0.0", - "description": "Matrix bot for presentation management", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-presi-bot/src/app.module.ts b/services/matrix-presi-bot/src/app.module.ts deleted file mode 100644 index bad0a2535..000000000 --- a/services/matrix-presi-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { PresiModule } from './presi/presi.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - PresiModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-presi-bot')], -}) -export class AppModule {} diff --git a/services/matrix-presi-bot/src/bot/bot.module.ts b/services/matrix-presi-bot/src/bot/bot.module.ts deleted file mode 100644 index 3a8659241..000000000 --- a/services/matrix-presi-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { PresiModule } from '../presi/presi.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - PresiModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-presi-bot/src/bot/matrix.service.ts b/services/matrix-presi-bot/src/bot/matrix.service.ts deleted file mode 100644 index 1ac9dbf00..000000000 --- a/services/matrix-presi-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,678 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - UserListMapper, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { PresiService, Deck, Theme, SlideContent } from '../presi/presi.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - // User list mappers for number-based reference - private decksMapper = new UserListMapper(); - private themesMapper = new UserListMapper(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['presis', 'decks', 'praesentationen', 'liste'], command: 'presis' }, - { keywords: ['folien', 'slides', 'folie hinzufuegen'], command: 'folie' }, - { keywords: ['themes', 'designs', 'vorlagen', 'stile'], command: 'themes' }, - { keywords: ['teilen', 'share', 'freigeben', 'link'], command: 'teilen' }, - { keywords: ['links', 'shares', 'freigaben', 'geteilte'], command: 'links' }, - { keywords: ['neu', 'new', 'neue praesentation', 'erstellen'], command: 'neu' }, - ]); - - constructor( - configService: ConfigService, - private readonly transcriptionService: TranscriptionService, - private presiService: PresiService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendMessage(roomId, '

❌ Sprachnachricht konnte nicht erkannt werden.

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

🎤 "${text}"

`); - await this.handleTextMessage(roomId, event, text); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendMessage(roomId, '

❌ Fehler bei der Spracherkennung.

'); - } - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - if (!body.startsWith('!')) return; - - const sender = event.sender; - const parts = body.slice(1).split(/\s+/); - const command = parts[0].toLowerCase(); - const args = parts.slice(1); - const argString = args.join(' '); - - try { - switch (command) { - case 'help': - case 'hilfe': - await this.sendMessage(roomId, HELP_MESSAGE); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - // Deck commands - case 'presis': - case 'decks': - case 'liste': - await this.handleListDecks(roomId, sender); - break; - - case 'presi': - case 'deck': - case 'details': - await this.handleDeckDetails(roomId, sender, args[0]); - break; - - case 'neu': - case 'new': - case 'create': - await this.handleCreateDeck(roomId, sender, argString); - break; - - case 'loeschen': - case 'delete': - await this.handleDeleteDeck(roomId, sender, args[0]); - break; - - case 'umbenennen': - case 'rename': - await this.handleRenameDeck(roomId, sender, args[0], args.slice(1).join(' ')); - break; - - // Slide commands - case 'folie': - case 'slide': - await this.handleAddSlide(roomId, sender, args); - break; - - case 'folieloeschen': - case 'deleteslide': - await this.handleDeleteSlide(roomId, sender, args[0], args[1]); - break; - - // Theme commands - case 'themes': - case 'designs': - await this.handleListThemes(roomId, sender); - break; - - case 'theme': - case 'design': - await this.handleApplyTheme(roomId, sender, args[0], args[1]); - break; - - // Share commands - case 'teilen': - case 'share': - await this.handleShareDeck(roomId, sender, argString); - break; - - case 'links': - case 'shares': - await this.handleListShares(roomId, sender, args[0]); - break; - - default: - await this.sendMessage( - roomId, - `

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

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

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

`); - } - } - - private async requireAuth(sender: string): Promise { - const token = await this.sessionService.getToken(sender); - if (!token) { - throw new Error(LOGIN_MESSAGES.presi); - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendOk = await this.presiService.checkHealth(); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const sessions = await this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusHtml = '

Presi Bot Status

    '; - statusHtml += `
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • `; - statusHtml += `
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • `; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; - statusHtml += `
  • ⚡ Credits: ${balance.balance.toFixed(2)}
  • `; - } - - statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; - statusHtml += '
'; - - await this.sendMessage(roomId, statusHtml); - } - - // Deck handlers - private async handleListDecks(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.presiService.getDecks(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const decks = result.data || []; - this.decksMapper.setList(sender, decks); - - if (decks.length === 0) { - await this.sendMessage( - roomId, - '

Keine Praesentationen vorhanden. Erstelle eine mit !neu Titel

' - ); - return; - } - - let html = '

Deine Praesentationen

    '; - for (const deck of decks) { - const theme = deck.theme ? ` [${deck.theme.name}]` : ''; - const pub = deck.isPublic ? ' 🌐' : ''; - html += `
  1. ${deck.title}${theme}${pub}
  2. `; - } - html += '
'; - html += '

Nutze !presi [nr] fuer Details

'; - - await this.sendMessage(roomId, html); - } - - private async handleDeckDetails(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const deck = this.decksMapper.getByNumber(sender, number); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

`); - return; - } - - const d = result.data!; - let html = `

${d.title}

`; - if (d.description) html += `

${d.description}

`; - - html += '
    '; - if (d.theme) html += `
  • Theme: ${d.theme.name}
  • `; - html += `
  • Oeffentlich: ${d.isPublic ? 'Ja' : 'Nein'}
  • `; - html += `
  • Folien: ${d.slides?.length || 0}
  • `; - html += `
  • Erstellt: ${new Date(d.createdAt).toLocaleDateString('de-DE')}
  • `; - html += '
'; - - if (d.slides && d.slides.length > 0) { - html += '

Folien:

    '; - for (const slide of d.slides) { - const title = - slide.content.title || slide.content.body?.substring(0, 30) || `(${slide.content.type})`; - html += `
  1. ${title}
  2. `; - } - html += '
'; - } - - html += `

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

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

Verwendung: !neu Titel | Beschreibung

'); - return; - } - - const token = await this.requireAuth(sender); - const parts = input.split('|').map((s) => s.trim()); - const title = parts[0]; - const description = parts[1]; - - const result = await this.presiService.createDeck(token, title, description); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.decksMapper.clearList(sender); - await this.sendMessage( - roomId, - `

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

-

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

` - ); - } - - private async handleDeleteDeck(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const deck = this.decksMapper.getByNumber(sender, number); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

`); - return; - } - - this.decksMapper.clearList(sender); - await this.sendMessage( - roomId, - `

Praesentation ${deck.title} geloescht.

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

Verwendung: !umbenennen [nr] Neuer Titel

' - ); - return; - } - - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const deck = this.decksMapper.getByNumber(sender, number); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

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

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

` - ); - } - - // Slide handlers - private async handleAddSlide(roomId: string, sender: string, args: string[]) { - if (args.length < 2) { - await this.sendMessage( - roomId, - `

Verwendung:

-
    -
  • !folie [nr] titel Titel | Untertitel
  • -
  • !folie [nr] text Titel | Inhalt
  • -
  • !folie [nr] punkte Titel | Punkt1, Punkt2
  • -
` - ); - return; - } - - const token = await this.requireAuth(sender); - const number = parseInt(args[0], 10); - const deck = this.decksMapper.getByNumber(sender, number); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

'); - return; - } - - const slideType = args[1].toLowerCase(); - const contentStr = args.slice(2).join(' '); - const contentParts = contentStr.split('|').map((s) => s.trim()); - - let content: SlideContent; - - switch (slideType) { - case 'titel': - case 'title': - content = { - type: 'title', - title: contentParts[0] || 'Titel', - subtitle: contentParts[1], - }; - break; - - case 'text': - case 'content': - case 'inhalt': - content = { - type: 'content', - title: contentParts[0] || 'Inhalt', - body: contentParts[1] || '', - }; - break; - - case 'punkte': - case 'bullets': - case 'liste': - const bullets = contentParts[1]?.split(',').map((s) => s.trim()) || []; - content = { - type: 'content', - title: contentParts[0] || 'Punkte', - bulletPoints: bullets, - }; - break; - - case 'bild': - case 'image': - content = { - type: 'image', - title: contentParts[0], - imageUrl: contentParts[1], - }; - break; - - default: - await this.sendMessage( - roomId, - '

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

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

Fehler: ${result.error}

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

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

` - ); - } - - private async handleDeleteSlide( - roomId: string, - sender: string, - deckNumStr: string, - slideNumStr: string - ) { - if (!deckNumStr || !slideNumStr) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - - const token = await this.requireAuth(sender); - const deckNumber = parseInt(deckNumStr, 10); - const deck = this.decksMapper.getByNumber(sender, deckNumber); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Praesentation-Nummer.

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

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

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

Ungueltige Folien-Nummer.

'); - return; - } - - const slide = deckResult.data.slides[slideIndex]; - const result = await this.presiService.deleteSlide(token, slide.id); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

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

` - ); - } - - // Theme handlers - private async handleListThemes(roomId: string, sender: string) { - const result = await this.presiService.getThemes(); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const themes = result.data || []; - this.themesMapper.setList(sender, themes); - - if (themes.length === 0) { - await this.sendMessage(roomId, '

Keine Themes verfuegbar.

'); - return; - } - - let html = '

Verfuegbare Themes

    '; - for (const theme of themes) { - const def = theme.isDefault ? ' (Standard)' : ''; - html += `
  1. ${theme.name}${def}
  2. `; - } - html += '
'; - html += '

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

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

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

' - ); - return; - } - - const token = await this.requireAuth(sender); - 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, '

Ungueltige Praesentation-Nummer.

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

Ungueltige Theme-Nummer. Nutze zuerst !themes

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

Fehler: ${result.error}

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

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

` - ); - } - - // Share handlers - private async handleShareDeck(roomId: string, sender: string, argString: string) { - const args = argString.split(/\s+/); - const numberStr = args[0]; - - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const deck = this.decksMapper.getByNumber(sender, number); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

'); - return; - } - - let expiresAt: string | undefined; - - // Parse --tage N - const daysMatch = argString.match(/--tage\s+(\d+)/i); - if (daysMatch) { - const days = parseInt(daysMatch[1], 10); - const expDate = new Date(); - expDate.setDate(expDate.getDate() + days); - expiresAt = expDate.toISOString(); - } - - const result = await this.presiService.createShareLink(token, deck.id, expiresAt); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const shareUrl = this.presiService.getShareUrl(result.data!.shareCode); - let html = `

${deck.title} wird geteilt:

`; - html += `

${shareUrl}

`; - if (result.data!.expiresAt) { - html += `

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

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

Verwendung: !links [presi-nr]

'); - return; - } - - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const deck = this.decksMapper.getByNumber(sender, number); - - if (!deck) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

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

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

` - ); - return; - } - - let html = `

Share-Links: ${deck.title}

    `; - for (const link of links) { - const expires = link.expiresAt - ? ` (bis ${new Date(link.expiresAt).toLocaleDateString('de-DE')})` - : ' (unbegrenzt)'; - const url = this.presiService.getShareUrl(link.shareCode); - html += `
  1. ${link.shareCode}${expires}
  2. `; - } - html += '
'; - - await this.sendMessage(roomId, html); - } -} diff --git a/services/matrix-presi-bot/src/config/configuration.ts b/services/matrix-presi-bot/src/config/configuration.ts deleted file mode 100644 index c9743f849..000000000 --- a/services/matrix-presi-bot/src/config/configuration.ts +++ /dev/null @@ -1,55 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3325', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN, - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - presi: { - backendUrl: process.env.PRESI_BACKEND_URL || 'http://localhost:3008', - apiPrefix: process.env.PRESI_API_PREFIX || '/api', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `

Presi Bot - Befehle

- -

Praesentationen

-
    -
  • !presis - Alle Praesentationen auflisten
  • -
  • !presi [nr] - Praesentation mit Folien anzeigen
  • -
  • !neu Titel - Neue Praesentation erstellen
  • -
  • !loeschen [nr] - Praesentation loeschen
  • -
  • !umbenennen [nr] Neuer Titel - Umbenennen
  • -
- -

Folien

-
    -
  • !folie [nr] titel Titel | Untertitel - Titel-Folie hinzufuegen
  • -
  • !folie [nr] text Titel | Inhalt - Text-Folie hinzufuegen
  • -
  • !folie [nr] punkte Titel | Punkt1, Punkt2 - Aufzaehlungs-Folie
  • -
  • !folieloeschen [presi-nr] [folien-nr] - Folie loeschen
  • -
- -

Themes

-
    -
  • !themes - Verfuegbare Themes anzeigen
  • -
  • !theme [presi-nr] [theme-nr] - Theme anwenden
  • -
- -

Teilen

-
    -
  • !teilen [nr] - Praesentation teilen
  • -
  • !teilen [nr] --tage 7 - Mit Ablaufdatum
  • -
  • !links [nr] - Share-Links anzeigen
  • -
- -

Weitere Befehle

-
    -
  • !help - Diese Hilfe anzeigen
  • -
- -

Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-presi-bot/src/main.ts b/services/matrix-presi-bot/src/main.ts deleted file mode 100644 index 7430fdc4b..000000000 --- a/services/matrix-presi-bot/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3325; - await app.listen(port); - console.log(`Matrix Presi Bot running on port ${port}`); -} -bootstrap(); diff --git a/services/matrix-presi-bot/src/presi/presi.module.ts b/services/matrix-presi-bot/src/presi/presi.module.ts deleted file mode 100644 index 6c2ee291e..000000000 --- a/services/matrix-presi-bot/src/presi/presi.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PresiService } from './presi.service'; - -@Module({ - providers: [PresiService], - exports: [PresiService], -}) -export class PresiModule {} diff --git a/services/matrix-presi-bot/src/presi/presi.service.ts b/services/matrix-presi-bot/src/presi/presi.service.ts deleted file mode 100644 index daffb0d96..000000000 --- a/services/matrix-presi-bot/src/presi/presi.service.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface SlideContent { - type: 'title' | 'content' | 'image' | 'split'; - title?: string; - subtitle?: string; - body?: string; - imageUrl?: string; - bulletPoints?: string[]; -} - -export interface Slide { - id: string; - deckId: string; - order: number; - content: SlideContent; - createdAt: string; -} - -export interface Theme { - id: string; - name: string; - colors: { - primary: string; - secondary: string; - background: string; - text: string; - accent: string; - }; - fonts: { - heading: string; - body: string; - }; - isDefault: boolean; -} - -export interface Deck { - id: string; - title: string; - description?: string; - themeId?: string; - isPublic: boolean; - theme?: Theme; - slides?: Slide[]; - createdAt: string; - updatedAt: string; -} - -export interface ShareLink { - id: string; - deckId: string; - shareCode: string; - expiresAt?: string; - createdAt: string; -} - -@Injectable() -export class PresiService { - private readonly logger = new Logger(PresiService.name); - private backendUrl: string; - private apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = this.configService.get('presi.backendUrl') || 'http://localhost:3008'; - this.apiPrefix = this.configService.get('presi.apiPrefix') || '/api'; - } - - private async request( - token: string, - endpoint: string, - options: RequestInit = {} - ): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - private async publicRequest(endpoint: string): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Public request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - // Deck operations - async getDecks(token: string): Promise<{ data?: Deck[]; error?: string }> { - return this.request(token, '/decks'); - } - - async getDeck(token: string, deckId: string): Promise<{ data?: Deck; error?: string }> { - return this.request(token, `/decks/${deckId}`); - } - - async createDeck( - token: string, - title: string, - description?: string - ): Promise<{ data?: Deck; error?: string }> { - return this.request(token, '/decks', { - method: 'POST', - body: JSON.stringify({ title, description }), - }); - } - - async updateDeck( - token: string, - deckId: string, - updates: { title?: string; description?: string; themeId?: string; isPublic?: boolean } - ): Promise<{ data?: Deck; error?: string }> { - return this.request(token, `/decks/${deckId}`, { - method: 'PUT', - body: JSON.stringify(updates), - }); - } - - async deleteDeck(token: string, deckId: string): Promise<{ error?: string }> { - return this.request(token, `/decks/${deckId}`, { method: 'DELETE' }); - } - - // Slide operations - async addSlide( - token: string, - deckId: string, - content: SlideContent - ): Promise<{ data?: Slide; error?: string }> { - return this.request(token, `/decks/${deckId}/slides`, { - method: 'POST', - body: JSON.stringify({ content }), - }); - } - - async deleteSlide(token: string, slideId: string): Promise<{ error?: string }> { - return this.request(token, `/slides/${slideId}`, { method: 'DELETE' }); - } - - // Theme operations - async getThemes(): Promise<{ data?: Theme[]; error?: string }> { - return this.publicRequest('/themes'); - } - - async getTheme(themeId: string): Promise<{ data?: Theme; error?: string }> { - return this.publicRequest(`/themes/${themeId}`); - } - - // Share operations - async createShareLink( - token: string, - deckId: string, - expiresAt?: string - ): Promise<{ data?: ShareLink; error?: string }> { - return this.request(token, `/share/deck/${deckId}`, { - method: 'POST', - body: JSON.stringify({ expiresAt }), - }); - } - - async getShareLinks(token: string, deckId: string): Promise<{ data?: ShareLink[]; error?: string }> { - return this.request(token, `/share/deck/${deckId}/links`); - } - - async deleteShareLink(token: string, shareId: string): Promise<{ error?: string }> { - return this.request(token, `/share/${shareId}`, { method: 'DELETE' }); - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/health`); - return response.ok; - } catch { - return false; - } - } - - getShareUrl(shareCode: string): string { - return `${this.backendUrl}/share/${shareCode}`; - } -} diff --git a/services/matrix-presi-bot/tsconfig.json b/services/matrix-presi-bot/tsconfig.json deleted file mode 100644 index 94f1e9493..000000000 --- a/services/matrix-presi-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - } -} diff --git a/services/matrix-project-doc-bot/.env.example b/services/matrix-project-doc-bot/.env.example deleted file mode 100644 index c3e899b2f..000000000 --- a/services/matrix-project-doc-bot/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -PORT=3313 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -# Optional: Restrict to specific users (comma-separated) -MATRIX_ALLOWED_USERS= -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Database -DATABASE_URL=postgresql://postgres:password@localhost:5432/project_doc_bot - -# S3 Storage -S3_ENDPOINT=http://localhost:9000 -S3_REGION=us-east-1 -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin -S3_BUCKET=project-doc-bot - -# Speech-to-Text (mana-stt service) -STT_URL=http://localhost:3020 - -# OpenAI (for blog generation) -OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini diff --git a/services/matrix-project-doc-bot/CLAUDE.md b/services/matrix-project-doc-bot/CLAUDE.md deleted file mode 100644 index 1bbf4401b..000000000 --- a/services/matrix-project-doc-bot/CLAUDE.md +++ /dev/null @@ -1,122 +0,0 @@ -# Matrix Project Doc Bot - Claude Code Guidelines - -## Overview - -Matrix Project Doc Bot collects photos, voice notes, and text for projects and generates blog posts. GDPR-compliant replacement for telegram-project-doc-bot. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Database**: Drizzle ORM + PostgreSQL -- **Storage**: S3 (MinIO) -- **AI**: mana-llm (Ollama/Gemma 3 for generation), mana-stt (Whisper for transcription) — fully self-hosted - -## Commands - -```bash -pnpm install -pnpm start:dev # Development with hot reload -pnpm build # Production build -pnpm type-check # TypeScript check -pnpm db:push # Push schema to database -pnpm db:studio # Open Drizzle Studio -``` - -## Matrix Commands - -| Command | Description | -|---------|-------------| -| `!new [Name]` | Create new project | -| `!projects` | List all projects | -| `!switch [ID]` | Switch to project | -| `!status` | Show project status | -| `!archive` | Archive current project | -| `!generate` | Generate blog post (casual) | -| `!generate [style]` | Generate with specific style | -| `!styles` | Show available styles | -| `!export` | Export last generation | - -## Media Handling - -- **Photos**: Saved to S3, stored in database -- **Voice**: Saved to S3, transcribed via Whisper -- **Text**: Stored directly in database - -## Blog Styles - -| Style | Description | -|-------|-------------| -| `casual` | Friendly, personal blog post | -| `technical` | Detailed technical report | -| `tutorial` | Step-by-step guide | -| `social` | Short social media post | -| `story` | Storytelling format | - -## Environment Variables - -```env -PORT=3313 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_USERS=@user:mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Database -DATABASE_URL=postgresql://postgres:password@localhost:5432/project_doc_bot - -# S3 Storage -S3_ENDPOINT=http://localhost:9000 -S3_REGION=us-east-1 -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin -S3_BUCKET=project-doc-bot - -# OpenAI -OPENAI_API_KEY=sk-xxx -OPENAI_MODEL=gpt-4o-mini -OPENAI_WHISPER_MODEL=whisper-1 -``` - -## Database Schema - -```sql --- projects table -CREATE TABLE projects ( - id UUID PRIMARY KEY, - matrix_user_id TEXT NOT NULL, - name TEXT NOT NULL, - status TEXT DEFAULT 'active', - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- project_items table -CREATE TABLE project_items ( - id UUID PRIMARY KEY, - project_id UUID REFERENCES projects(id), - type TEXT NOT NULL, -- photo, voice, text - content TEXT, - media_url TEXT, - media_mxc_url TEXT, - duration INTEGER, - created_at TIMESTAMP DEFAULT NOW() -); - --- generations table -CREATE TABLE generations ( - id UUID PRIMARY KEY, - project_id UUID REFERENCES projects(id), - style TEXT NOT NULL, - content TEXT NOT NULL, - created_at TIMESTAMP DEFAULT NOW() -); -``` - -## Health Check - -```bash -curl http://localhost:3313/health -``` diff --git a/services/matrix-project-doc-bot/Dockerfile b/services/matrix-project-doc-bot/Dockerfile deleted file mode 100644 index 9f813fb9d..000000000 --- a/services/matrix-project-doc-bot/Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common -COPY packages/shared-llm ./packages/shared-llm - -# Copy this bot -COPY services/matrix-project-doc-bot ./services/matrix-project-doc-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-project-doc-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-project-doc-bot/dist ./services/matrix-project-doc-bot/dist -COPY --from=builder /app/services/matrix-project-doc-bot/package.json ./services/matrix-project-doc-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-project-doc-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4013/health || exit 1 - -EXPOSE 4013 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-project-doc-bot/drizzle.config.ts b/services/matrix-project-doc-bot/drizzle.config.ts deleted file mode 100644 index 695ea628c..000000000 --- a/services/matrix-project-doc-bot/drizzle.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - schema: './src/database/schema.ts', - out: './drizzle', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL || '', - }, -}); diff --git a/services/matrix-project-doc-bot/nest-cli.json b/services/matrix-project-doc-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-project-doc-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-project-doc-bot/package.json b/services/matrix-project-doc-bot/package.json deleted file mode 100644 index 61eefa811..000000000 --- a/services/matrix-project-doc-bot/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@manacore/matrix-project-doc-bot", - "version": "1.0.0", - "description": "Matrix bot for project documentation - collect photos and voice notes, generate blog posts (GDPR compliant)", - "private": true, - "license": "MIT", - "pnpm": { - "neverBuiltDependencies": [ - "@matrix-org/matrix-sdk-crypto-nodejs" - ], - "overrides": { - "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" - } - }, - "scripts": { - "prebuild": "rimraf dist", - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.721.0", - "@aws-sdk/s3-request-presigner": "^3.721.0", - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@manacore/shared-llm": "workspace:^", - "@nestjs/common": "^10.4.15", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.15", - "@nestjs/platform-express": "^10.4.15", - "drizzle-orm": "^0.38.3", - "matrix-bot-sdk": "^0.7.1", - "postgres": "^3.4.5", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@types/node": "^22.10.5", - "drizzle-kit": "^0.30.1", - "rimraf": "^6.0.1", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-project-doc-bot/src/app.module.ts b/services/matrix-project-doc-bot/src/app.module.ts deleted file mode 100644 index f2b97564b..000000000 --- a/services/matrix-project-doc-bot/src/app.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { LlmModule } from '@manacore/shared-llm'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { DatabaseModule } from './database/database.module'; -import { BotModule } from './bot/bot.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - LlmModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - manaLlmUrl: config.get('MANA_LLM_URL') || 'http://localhost:3025', - debug: config.get('NODE_ENV') === 'development', - }), - inject: [ConfigService], - }), - DatabaseModule, - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-project-doc-bot')], -}) -export class AppModule {} diff --git a/services/matrix-project-doc-bot/src/bot/bot.module.ts b/services/matrix-project-doc-bot/src/bot/bot.module.ts deleted file mode 100644 index 5dd8c530b..000000000 --- a/services/matrix-project-doc-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { ProjectModule } from '../project/project.module'; -import { MediaModule } from '../media/media.module'; -import { GenerationModule } from '../generation/generation.module'; -import { SessionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - ProjectModule, - MediaModule, - GenerationModule, - SessionModule.forRoot({ storageMode: 'redis' }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-project-doc-bot/src/bot/matrix.service.ts b/services/matrix-project-doc-bot/src/bot/matrix.service.ts deleted file mode 100644 index f06d38e9f..000000000 --- a/services/matrix-project-doc-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { ProjectService } from '../project/project.service'; -import { MediaService } from '../media/media.service'; -import { GenerationService } from '../generation/generation.service'; -import { SessionService, CreditService } from '@manacore/bot-services'; -import { BLOG_STYLES } from '../config/configuration'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - private readonly allowedUsers: string[]; - - // Active project per user (matrixUserId -> projectId) - private activeProjects: Map = new Map(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['projekte', 'projects', 'meine projekte', 'liste'], command: 'projects' }, - { keywords: ['archiv', 'archive', 'archivieren'], command: 'archive' }, - { keywords: ['generieren', 'generate', 'erstellen', 'blogbeitrag'], command: 'generate' }, - { keywords: ['exportieren', 'export', 'herunterladen', 'download'], command: 'export' }, - { keywords: ['stile', 'styles', 'vorlagen', 'formate'], command: 'styles' }, - { keywords: ['neu', 'new', 'neues projekt', 'projekt starten'], command: 'new' }, - { keywords: ['wechseln', 'switch', 'umschalten'], command: 'switch' }, - ]); - - constructor( - configService: ConfigService, - private projectService: ProjectService, - private mediaService: MediaService, - private generationService: GenerationService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - this.allowedUsers = this.configService.get('matrix.allowedUsers') || []; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: [], // This bot uses allowedUsers instead - }; - } - - private isAllowed(userId: string): boolean { - if (this.allowedUsers.length === 0) return true; - return this.allowedUsers.includes(userId); - } - - /** - * Override onRoomMessage to handle images and audio in addition to text - */ - protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise { - // Ignore own messages - if (event.sender === this.botUserId) return; - - // Check user permissions - if (!this.isAllowed(event.sender)) return; - - const msgtype = event.content?.msgtype; - - try { - if (msgtype === 'm.text') { - const body = event.content.body || ''; - await this.handleTextMessage(roomId, event, body, event.sender); - } else if (msgtype === 'm.image') { - await this.handleImage(roomId, event.sender, { - url: event.content.url || '', - info: event.content.info as { mimetype?: string } | undefined, - body: event.content.body, - }); - } else if (msgtype === 'm.audio') { - await this.handleAudio(roomId, event.sender, { - url: event.content.url || '', - info: event.content.info as { mimetype?: string; duration?: number } | undefined, - }); - } - } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendMessage(roomId, 'Fehler bei der Verarbeitung der Nachricht.'); - } - } - - protected async handleTextMessage( - roomId: string, - _event: MatrixRoomEvent, - body: string, - sender: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - if (body.startsWith('!')) { - await this.handleCommand(roomId, sender, body); - } else { - await this.handleTextNote(roomId, sender, body); - } - } - - 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 'start': - await this.sendHelp(roomId); - break; - case 'auth': - case 'account': - await this.handleAuthStatus(roomId, sender); - break; - case 'new': - await this.createProject(roomId, sender, argString); - break; - case 'projects': - await this.listProjects(roomId, sender); - break; - case 'switch': - await this.switchProject(roomId, sender, argString); - break; - case 'status': - await this.showStatus(roomId, sender); - break; - case 'archive': - await this.archiveProject(roomId, sender); - break; - case 'styles': - await this.showStyles(roomId); - break; - case 'generate': - await this.generateBlogpost(roomId, sender, argString); - break; - case 'export': - await this.exportGeneration(roomId, sender); - break; - default: - await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); - } - } - - private async sendHelp(roomId: string) { - const styles = Object.entries(BLOG_STYLES) - .map(([key, value]) => `- \`${key}\` - ${value.name}`) - .join('\n'); - - const helpText = `**Project Doc Bot (DSGVO-konform)** - -Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge. - -**Account:** -- \`!login email passwort\` - Anmelden -- \`!logout\` - Abmelden -- \`!auth\` - Account Status - -**Projekt-Commands:** -- \`!new [Name]\` - Neues Projekt starten -- \`!projects\` - Alle Projekte anzeigen -- \`!switch [ID]\` - Projekt wechseln -- \`!status\` - Status des aktiven Projekts -- \`!archive\` - Aktives Projekt archivieren - -**Content:** -Foto senden - Wird gespeichert -Sprachnotiz - Wird transkribiert -Text-Nachricht - Als Notiz gespeichert - -**Generierung:** -- \`!generate\` - Blogbeitrag erstellen -- \`!generate [Stil]\` - Mit bestimmtem Stil -- \`!styles\` - Verfügbare Stile anzeigen -- \`!export\` - Letzte Generierung exportieren - -**Verfügbare Stile:** -${styles} - -**Tipp:** Starte mit \`!new Projektname\``; - - await this.sendMessage(roomId, helpText); - } - - private async handleAuthStatus(roomId: string, sender: string) { - const loggedIn = await this.sessionService.isLoggedIn(sender); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let response = '**📋 Account Status**\n\n'; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - response += `👤 Angemeldet als: ${session.email}\n`; - response += `⚡ Credits: ${balance.balance.toFixed(2)}`; - } else { - response += `❌ Nicht angemeldet\n`; - response += `Nutze \`!login email passwort\` zum Anmelden.`; - } - - await this.sendMessage(roomId, response); - } - - private async createProject(roomId: string, sender: string, name: string) { - if (!name) { - await this.sendMessage( - roomId, - 'Verwendung: `!new Projektname`\n\nBeispiel: `!new Gartenhaus-Renovierung`' - ); - return; - } - - try { - const project = await this.projectService.create({ - matrixUserId: sender, - name, - }); - - this.activeProjects.set(sender, project.id); - - await this.sendMessage( - roomId, - `**Projekt erstellt!**\n\n**Name:** ${project.name}\n**ID:** \`${project.id.slice(0, 8)}\`\n\nSende jetzt:\nFotos\nSprachnotizen\nText-Nachrichten\n\nMit \`!generate\` erstellst du den Blogbeitrag.` - ); - } catch (error) { - this.logger.error('Failed to create project:', error); - await this.sendMessage( - roomId, - `Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}` - ); - } - } - - private async listProjects(roomId: string, sender: string) { - const projects = await this.projectService.findByUser(sender); - - if (projects.length === 0) { - await this.sendMessage(roomId, 'Keine Projekte gefunden.\n\nStarte mit: `!new Projektname`'); - return; - } - - const activeId = this.activeProjects.get(sender); - - const projectList = await Promise.all( - projects.map(async (p) => { - const stats = await this.projectService.getStats(p.id); - const active = p.id === activeId ? ' (aktiv)' : ''; - const status = p.status === 'archived' ? ' [archiviert]' : ''; - return `- **${p.name}**${active}${status}\n ID: \`${p.id.slice(0, 8)}\` | ${stats.total} Einträge`; - }) - ); - - await this.sendMessage( - roomId, - `**Deine Projekte:**\n\n${projectList.join('\n\n')}\n\nWechseln mit: \`!switch [ID]\`` - ); - } - - private async switchProject(roomId: string, sender: string, idPrefix: string) { - if (!idPrefix) { - await this.sendMessage( - roomId, - 'Verwendung: `!switch [ID]`\n\nZeige Projekte mit `!projects`' - ); - return; - } - - const projects = await this.projectService.findByUser(sender); - const project = projects.find((p) => p.id.startsWith(idPrefix)); - - if (!project) { - await this.sendMessage(roomId, `Projekt mit ID "${idPrefix}" nicht gefunden.`); - return; - } - - this.activeProjects.set(sender, project.id); - const stats = await this.projectService.getStats(project.id); - - await this.sendMessage( - roomId, - `Gewechselt zu: **${project.name}**\n\n${stats.photos} Fotos\n${stats.voices} Sprachnotizen\n${stats.texts} Textnotizen` - ); - } - - private async showStatus(roomId: string, sender: string) { - // Auth info - const loggedIn = await this.sessionService.isLoggedIn(sender); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let authInfo = ''; - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - authInfo = `**👤 Account**\n${session.email} | ⚡ ${balance.balance.toFixed(2)} Credits\n\n`; - } - - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage( - roomId, - `${authInfo}Kein aktives Projekt.\n\nStarte mit: \`!new Projektname\`` - ); - return; - } - - const project = await this.projectService.findById(projectId); - if (!project) { - this.activeProjects.delete(sender); - await this.sendMessage( - roomId, - `${authInfo}Projekt nicht gefunden. Starte ein neues mit \`!new\`` - ); - return; - } - - const stats = await this.projectService.getStats(projectId); - const latest = await this.generationService.getLatestGeneration(projectId); - - let statusText = `${authInfo}**Projekt-Status**\n\n**Name:** ${project.name}\n**Status:** ${project.status}\n**Erstellt:** ${project.createdAt.toLocaleDateString('de-DE')}\n\n**Inhalte:**\n${stats.photos} Fotos\n${stats.voices} Sprachnotizen\n${stats.texts} Textnotizen\n**Gesamt:** ${stats.total} Einträge`; - - if (latest) { - statusText += `\n\n**Letzte Generierung:**\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`; - } - - await this.sendMessage(roomId, statusText); - } - - private async archiveProject(roomId: string, sender: string) { - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage(roomId, 'Kein aktives Projekt.'); - return; - } - - await this.projectService.update(projectId, { status: 'archived' }); - this.activeProjects.delete(sender); - - await this.sendMessage(roomId, 'Projekt archiviert.\n\nStarte ein neues mit `!new`'); - } - - private async showStyles(roomId: string) { - const styles = Object.entries(BLOG_STYLES) - .map(([key, value]) => `**${key}** - ${value.name}\n_${value.prompt.slice(0, 80)}..._`) - .join('\n\n'); - - await this.sendMessage( - roomId, - `**Verfügbare Blog-Stile:**\n\n${styles}\n\nVerwendung: \`!generate [stil]\`` - ); - } - - private async generateBlogpost(roomId: string, sender: string, style: string) { - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); - return; - } - - const selectedStyle = (style.toLowerCase() || 'casual') as keyof typeof BLOG_STYLES; - const validStyles = Object.keys(BLOG_STYLES); - - if (!validStyles.includes(selectedStyle)) { - await this.sendMessage( - roomId, - `Unbekannter Stil: "${style}"\n\nVerfügbar: ${validStyles.join(', ')}\n\nZeige Details mit \`!styles\`` - ); - return; - } - - await this.sendMessage(roomId, 'Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.'); - await this.client.setTyping(roomId, true, 60000); - - try { - const content = await this.generationService.generateBlogpost(projectId, selectedStyle); - await this.client.setTyping(roomId, false); - - await this.sendMessage(roomId, content); - await this.sendMessage(roomId, 'Blogbeitrag erstellt!\n\nExportieren mit `!export`'); - } catch (error) { - await this.client.setTyping(roomId, false); - this.logger.error('Generation failed:', error); - await this.sendMessage( - roomId, - `Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}` - ); - } - } - - private async exportGeneration(roomId: string, sender: string) { - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage(roomId, 'Kein aktives Projekt.'); - return; - } - - const latest = await this.generationService.getLatestGeneration(projectId); - if (!latest) { - await this.sendMessage( - roomId, - 'Noch kein Blogbeitrag generiert.\n\nErstelle einen mit `!generate`' - ); - return; - } - - const project = await this.projectService.findById(projectId); - const filename = `${project?.name.replace(/[^a-zA-Z0-9]/g, '_') || 'blogpost'}.md`; - - // Upload file to Matrix - const buffer = Buffer.from(latest.content, 'utf-8'); - const mxcUrl = await this.client.uploadContent(buffer, 'text/markdown', filename); - - await this.client.sendMessage(roomId, { - msgtype: 'm.file', - body: filename, - url: mxcUrl, - info: { - mimetype: 'text/markdown', - size: buffer.length, - }, - }); - } - - private async handleTextNote(roomId: string, sender: string, text: string) { - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage(roomId, 'Tipp: Starte ein Projekt mit `!new Projektname`'); - return; - } - - try { - await this.mediaService.addTextNote(projectId, text); - const stats = await this.projectService.getStats(projectId); - await this.sendMessage(roomId, `Notiz gespeichert! (${stats.texts} Notizen gesamt)`); - } catch (error) { - this.logger.error('Failed to add text note:', error); - await this.sendMessage(roomId, 'Fehler beim Speichern der Notiz.'); - } - } - - private async handleImage( - roomId: string, - sender: string, - content: { url: string; info?: { mimetype?: string }; body?: string } - ) { - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); - return; - } - - try { - const mxcUrl = content.url; - const buffer = await this.downloadMedia(mxcUrl); - const contentType = content.info?.mimetype || 'image/jpeg'; - - await this.mediaService.processPhoto(projectId, buffer, contentType, mxcUrl, content.body); - - const stats = await this.projectService.getStats(projectId); - await this.sendMessage(roomId, `Foto gespeichert! (${stats.photos} Fotos gesamt)`); - } catch (error) { - this.logger.error('Failed to process image:', error); - await this.sendMessage(roomId, 'Fehler beim Speichern des Fotos.'); - } - } - - private async handleAudio( - roomId: string, - sender: string, - content: { url: string; info?: { mimetype?: string; duration?: number } } - ) { - const projectId = this.activeProjects.get(sender); - if (!projectId) { - await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); - return; - } - - await this.sendMessage(roomId, 'Verarbeite Sprachnotiz...'); - - try { - const mxcUrl = content.url; - const buffer = await this.downloadMedia(mxcUrl); - const contentType = content.info?.mimetype || 'audio/ogg'; - const duration = Math.round((content.info?.duration || 0) / 1000); - - const item = await this.mediaService.processVoice( - projectId, - buffer, - contentType, - mxcUrl, - duration - ); - - const stats = await this.projectService.getStats(projectId); - let reply = `Sprachnotiz gespeichert! (${stats.voices} gesamt)`; - - if (item.content) { - reply += `\n\nTranskription:\n"${item.content}"`; - } - - await this.sendMessage(roomId, reply); - } catch (error) { - this.logger.error('Failed to process audio:', error); - await this.sendMessage(roomId, 'Fehler beim Verarbeiten der Sprachnotiz.'); - } - } -} diff --git a/services/matrix-project-doc-bot/src/config/configuration.ts b/services/matrix-project-doc-bot/src/config/configuration.ts deleted file mode 100644 index f41be3a35..000000000 --- a/services/matrix-project-doc-bot/src/config/configuration.ts +++ /dev/null @@ -1,49 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3313', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN || '', - allowedUsers: process.env.MATRIX_ALLOWED_USERS?.split(',').filter(Boolean) || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - database: { - url: process.env.DATABASE_URL || '', - }, - s3: { - endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000', - region: process.env.S3_REGION || 'us-east-1', - accessKey: process.env.S3_ACCESS_KEY || 'minioadmin', - secretKey: process.env.S3_SECRET_KEY || 'minioadmin', - bucket: process.env.S3_BUCKET || 'project-doc-bot', - }, - stt: { - url: process.env.STT_URL || 'http://localhost:3020', - }, - openai: { - apiKey: process.env.OPENAI_API_KEY || '', - model: process.env.OPENAI_MODEL || 'gpt-4o-mini', - }, -}); - -export const BLOG_STYLES: Record = { - casual: { - name: 'Casual Blog', - prompt: `Schreibe einen lockeren, persönlichen Blogbeitrag über dieses Projekt. Nutze eine freundliche, nahbare Sprache. Füge passende Überschriften und Absätze ein.`, - }, - technical: { - name: 'Technischer Bericht', - prompt: `Schreibe einen detaillierten technischen Bericht über dieses Projekt. Fokussiere auf Methoden, Materialien und den Prozess. Sei präzise und informativ.`, - }, - tutorial: { - name: 'Schritt-für-Schritt Anleitung', - prompt: `Erstelle eine Schritt-für-Schritt Anleitung basierend auf diesem Projekt. Nummeriere die Schritte und erkläre jeden ausführlich, sodass andere es nachmachen können.`, - }, - social: { - name: 'Social Media Post', - prompt: `Erstelle einen kurzen, ansprechenden Social Media Post über dieses Projekt. Maximal 280 Zeichen für den Haupttext, plus optionale Hashtags.`, - }, - story: { - name: 'Storytelling', - prompt: `Erzähle die Geschichte dieses Projekts. Beginne mit der Motivation, beschreibe Herausforderungen und ende mit dem Ergebnis. Mach es persönlich und fesselnd.`, - }, -}; diff --git a/services/matrix-project-doc-bot/src/database/database.module.ts b/services/matrix-project-doc-bot/src/database/database.module.ts deleted file mode 100644 index 9acacdc60..000000000 --- a/services/matrix-project-doc-bot/src/database/database.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Module, Global, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from './schema'; - -export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: (configService: ConfigService) => { - const logger = new Logger('Database'); - const url = configService.get('database.url'); - - if (!url) { - logger.error('DATABASE_URL is required'); - throw new Error('DATABASE_URL is required'); - } - - const client = postgres(url); - logger.log('Database connected'); - - return drizzle(client, { schema }); - }, - inject: [ConfigService], - }, - ], - exports: [DATABASE_CONNECTION], -}) -export class DatabaseModule {} diff --git a/services/matrix-project-doc-bot/src/database/schema.ts b/services/matrix-project-doc-bot/src/database/schema.ts deleted file mode 100644 index eb8041de7..000000000 --- a/services/matrix-project-doc-bot/src/database/schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { pgTable, text, timestamp, uuid, integer } from 'drizzle-orm/pg-core'; - -export const projects = pgTable('projects', { - id: uuid('id').primaryKey().defaultRandom(), - matrixUserId: text('matrix_user_id').notNull(), - name: text('name').notNull(), - status: text('status').notNull().default('active'), // active, archived - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), -}); - -export const projectItems = pgTable('project_items', { - id: uuid('id').primaryKey().defaultRandom(), - projectId: uuid('project_id') - .notNull() - .references(() => projects.id, { onDelete: 'cascade' }), - type: text('type').notNull(), // photo, voice, text - content: text('content'), // text content or transcription - mediaUrl: text('media_url'), // S3 URL for media - mediaMxcUrl: text('media_mxc_url'), // Matrix MXC URL - duration: integer('duration'), // Voice duration in seconds - createdAt: timestamp('created_at').notNull().defaultNow(), -}); - -export const generations = pgTable('generations', { - id: uuid('id').primaryKey().defaultRandom(), - projectId: uuid('project_id') - .notNull() - .references(() => projects.id, { onDelete: 'cascade' }), - style: text('style').notNull(), - content: text('content').notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), -}); diff --git a/services/matrix-project-doc-bot/src/generation/generation.module.ts b/services/matrix-project-doc-bot/src/generation/generation.module.ts deleted file mode 100644 index fccb8adfd..000000000 --- a/services/matrix-project-doc-bot/src/generation/generation.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GenerationService } from './generation.service'; - -@Module({ - providers: [GenerationService], - exports: [GenerationService], -}) -export class GenerationModule {} diff --git a/services/matrix-project-doc-bot/src/generation/generation.service.ts b/services/matrix-project-doc-bot/src/generation/generation.service.ts deleted file mode 100644 index 535d8d0c4..000000000 --- a/services/matrix-project-doc-bot/src/generation/generation.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable, Inject, Logger } from '@nestjs/common'; -import { eq, desc } from 'drizzle-orm'; -import { LlmClientService } from '@manacore/shared-llm'; -import { DATABASE_CONNECTION } from '../database/database.module'; -import { generations, projectItems, projects } from '../database/schema'; -import { BLOG_STYLES } from '../config/configuration'; -import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import type * as schema from '../database/schema'; - -type Database = PostgresJsDatabase; - -@Injectable() -export class GenerationService { - private readonly logger = new Logger(GenerationService.name); - - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private readonly llm: LlmClientService - ) {} - - async generateBlogpost(projectId: string, style: keyof typeof BLOG_STYLES): Promise { - // Get project info - const [project] = await this.db.select().from(projects).where(eq(projects.id, projectId)); - if (!project) { - throw new Error('Project not found'); - } - - // Get all project items - const items = await this.db - .select() - .from(projectItems) - .where(eq(projectItems.projectId, projectId)) - .orderBy(projectItems.createdAt); - - if (items.length === 0) { - throw new Error( - 'Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.' - ); - } - - // Build content summary - const contentSummary = items - .map((item, index) => { - const timestamp = item.createdAt.toLocaleString('de-DE'); - switch (item.type) { - case 'photo': - return `[Foto ${index + 1}] ${timestamp}${item.content ? `: ${item.content}` : ''}`; - case 'voice': - return `[Sprachnotiz ${index + 1}] ${timestamp}: "${item.content || 'Keine Transkription'}"`; - case 'text': - return `[Notiz ${index + 1}] ${timestamp}: "${item.content}"`; - default: - return ''; - } - }) - .filter(Boolean) - .join('\n\n'); - - const styleConfig = BLOG_STYLES[style]; - - const systemPrompt = `Du bist ein erfahrener Blogger und Content-Creator. ${styleConfig.prompt} - -Projektname: "${project.name}" -Erstellt am: ${project.createdAt.toLocaleDateString('de-DE')} - -Die folgenden Inhalte wurden während des Projekts gesammelt:`; - - const result = await this.llm.chat(contentSummary, { - systemPrompt, - temperature: 0.7, - maxTokens: 2000, - }); - - // Save generation - await this.db.insert(generations).values({ - projectId, - style, - content: result.content, - }); - - this.logger.log(`Generated ${style} blogpost for project ${projectId}`); - return result.content; - } - - async getLatestGeneration(projectId: string) { - const [generation] = await this.db - .select() - .from(generations) - .where(eq(generations.projectId, projectId)) - .orderBy(desc(generations.createdAt)) - .limit(1); - - return generation; - } -} diff --git a/services/matrix-project-doc-bot/src/main.ts b/services/matrix-project-doc-bot/src/main.ts deleted file mode 100644 index 8e0c8c844..000000000 --- a/services/matrix-project-doc-bot/src/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - const port = process.env.PORT || 3313; - await app.listen(port); - - logger.log(`Matrix Project Doc Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} -bootstrap(); diff --git a/services/matrix-project-doc-bot/src/media/media.module.ts b/services/matrix-project-doc-bot/src/media/media.module.ts deleted file mode 100644 index 2acb1d09f..000000000 --- a/services/matrix-project-doc-bot/src/media/media.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MediaService } from './media.service'; -import { StorageService } from './storage.service'; -import { TranscriptionModule } from '@manacore/bot-services'; - -@Module({ - imports: [TranscriptionModule.forRoot()], - providers: [MediaService, StorageService], - exports: [MediaService, StorageService], -}) -export class MediaModule {} diff --git a/services/matrix-project-doc-bot/src/media/media.service.ts b/services/matrix-project-doc-bot/src/media/media.service.ts deleted file mode 100644 index b319be90d..000000000 --- a/services/matrix-project-doc-bot/src/media/media.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Injectable, Inject, Logger } from '@nestjs/common'; -import { DATABASE_CONNECTION } from '../database/database.module'; -import { projectItems } from '../database/schema'; -import { StorageService } from './storage.service'; -import { TranscriptionService } from '@manacore/bot-services'; -import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import type * as schema from '../database/schema'; - -type Database = PostgresJsDatabase; - -@Injectable() -export class MediaService { - private readonly logger = new Logger(MediaService.name); - - constructor( - @Inject(DATABASE_CONNECTION) private db: Database, - private storageService: StorageService, - private transcriptionService: TranscriptionService - ) {} - - async processPhoto( - projectId: string, - buffer: Buffer, - contentType: string, - mxcUrl: string, - caption?: string - ) { - const key = await this.storageService.uploadFile(buffer, contentType, projectId); - - const [item] = await this.db - .insert(projectItems) - .values({ - projectId, - type: 'photo', - content: caption || null, - mediaUrl: key, - mediaMxcUrl: mxcUrl, - }) - .returning(); - - this.logger.log(`Saved photo for project ${projectId}`); - return item; - } - - async processVoice( - projectId: string, - buffer: Buffer, - contentType: string, - mxcUrl: string, - duration: number - ) { - const key = await this.storageService.uploadFile(buffer, contentType, projectId); - - // Transcribe the voice message - let transcription: string | null = null; - try { - transcription = await this.transcriptionService.transcribe(buffer); - this.logger.log(`Transcribed voice message: ${transcription?.substring(0, 50)}...`); - } catch (error) { - this.logger.error('Transcription failed:', error); - } - - const [item] = await this.db - .insert(projectItems) - .values({ - projectId, - type: 'voice', - content: transcription, - mediaUrl: key, - mediaMxcUrl: mxcUrl, - duration, - }) - .returning(); - - this.logger.log(`Saved voice message for project ${projectId}`); - return item; - } - - async addTextNote(projectId: string, content: string) { - const [item] = await this.db - .insert(projectItems) - .values({ - projectId, - type: 'text', - content, - }) - .returning(); - - this.logger.log(`Saved text note for project ${projectId}`); - return item; - } -} diff --git a/services/matrix-project-doc-bot/src/media/storage.service.ts b/services/matrix-project-doc-bot/src/media/storage.service.ts deleted file mode 100644 index f2d14b672..000000000 --- a/services/matrix-project-doc-bot/src/media/storage.service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { randomUUID } from 'crypto'; - -@Injectable() -export class StorageService { - private readonly logger = new Logger(StorageService.name); - private readonly s3Client: S3Client; - private readonly bucket: string; - - constructor(private configService: ConfigService) { - this.s3Client = new S3Client({ - endpoint: this.configService.get('s3.endpoint'), - region: this.configService.get('s3.region'), - credentials: { - accessKeyId: this.configService.get('s3.accessKey') || '', - secretAccessKey: this.configService.get('s3.secretKey') || '', - }, - forcePathStyle: true, - }); - - this.bucket = this.configService.get('s3.bucket') || 'project-doc-bot'; - } - - async uploadFile(buffer: Buffer, contentType: string, projectId: string): Promise { - const extension = this.getExtension(contentType); - const key = `${projectId}/${randomUUID()}${extension}`; - - await this.s3Client.send( - new PutObjectCommand({ - Bucket: this.bucket, - Key: key, - Body: buffer, - ContentType: contentType, - }) - ); - - this.logger.log(`Uploaded file: ${key}`); - return key; - } - - async getSignedUrl(key: string, expiresIn: number = 3600): Promise { - const command = new GetObjectCommand({ - Bucket: this.bucket, - Key: key, - }); - - return getSignedUrl(this.s3Client, command, { expiresIn }); - } - - async downloadFile(key: string): Promise { - const response = await this.s3Client.send( - new GetObjectCommand({ - Bucket: this.bucket, - Key: key, - }) - ); - - const stream = response.Body as NodeJS.ReadableStream; - const chunks: Buffer[] = []; - - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk)); - } - - return Buffer.concat(chunks); - } - - private getExtension(contentType: string): string { - const map: Record = { - 'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'audio/ogg': '.ogg', - 'audio/mpeg': '.mp3', - 'audio/mp4': '.m4a', - }; - return map[contentType] || ''; - } -} diff --git a/services/matrix-project-doc-bot/src/project/project.module.ts b/services/matrix-project-doc-bot/src/project/project.module.ts deleted file mode 100644 index c1b3f70d8..000000000 --- a/services/matrix-project-doc-bot/src/project/project.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ProjectService } from './project.service'; - -@Module({ - providers: [ProjectService], - exports: [ProjectService], -}) -export class ProjectModule {} diff --git a/services/matrix-project-doc-bot/src/project/project.service.ts b/services/matrix-project-doc-bot/src/project/project.service.ts deleted file mode 100644 index 2b251d0bc..000000000 --- a/services/matrix-project-doc-bot/src/project/project.service.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Injectable, Inject, Logger } from '@nestjs/common'; -import { eq, desc } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../database/database.module'; -import { projects, projectItems } from '../database/schema'; -import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import type * as schema from '../database/schema'; - -type Database = PostgresJsDatabase; - -interface CreateProjectInput { - matrixUserId: string; - name: string; -} - -@Injectable() -export class ProjectService { - private readonly logger = new Logger(ProjectService.name); - - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async create(input: CreateProjectInput) { - const [project] = await this.db - .insert(projects) - .values({ - matrixUserId: input.matrixUserId, - name: input.name, - }) - .returning(); - - this.logger.log(`Created project ${project.id} for user ${input.matrixUserId}`); - return project; - } - - async findById(id: string) { - const [project] = await this.db.select().from(projects).where(eq(projects.id, id)); - return project; - } - - async findByUser(matrixUserId: string) { - return this.db - .select() - .from(projects) - .where(eq(projects.matrixUserId, matrixUserId)) - .orderBy(desc(projects.createdAt)); - } - - async update(id: string, data: Partial) { - const [project] = await this.db - .update(projects) - .set({ ...data, updatedAt: new Date() }) - .where(eq(projects.id, id)) - .returning(); - return project; - } - - async getStats(projectId: string) { - const items = await this.db.select().from(projectItems).where(eq(projectItems.projectId, projectId)); - - return { - photos: items.filter((i) => i.type === 'photo').length, - voices: items.filter((i) => i.type === 'voice').length, - texts: items.filter((i) => i.type === 'text').length, - total: items.length, - }; - } - - async getItems(projectId: string) { - return this.db - .select() - .from(projectItems) - .where(eq(projectItems.projectId, projectId)) - .orderBy(projectItems.createdAt); - } -} diff --git a/services/matrix-project-doc-bot/tsconfig.json b/services/matrix-project-doc-bot/tsconfig.json deleted file mode 100644 index f02c2417e..000000000 --- a/services/matrix-project-doc-bot/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/services/matrix-questions-bot/.env.example b/services/matrix-questions-bot/.env.example deleted file mode 100644 index e18a1dbd6..000000000 --- a/services/matrix-questions-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3324 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#questions:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Questions Backend -QUESTIONS_BACKEND_URL=http://localhost:3011 -QUESTIONS_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-questions-bot/.gitignore b/services/matrix-questions-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-questions-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-questions-bot/CLAUDE.md b/services/matrix-questions-bot/CLAUDE.md deleted file mode 100644 index f94adf6ab..000000000 --- a/services/matrix-questions-bot/CLAUDE.md +++ /dev/null @@ -1,234 +0,0 @@ -# Matrix Questions Bot - Claude Code Guidelines - -## Overview - -Matrix Questions Bot provides Q&A research management via Matrix chat. It integrates with the Questions backend for question management, web research via mana-search, answer tracking, and collection organization. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Questions API (port 3011) -- **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-questions-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3324) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── questions/ -│ │ ├── questions.module.ts -│ │ └── questions.service.ts # Questions Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -### Question Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!fragen` | questions, liste | List all questions | -| `!fragen offen` | - | Filter by status | -| `!frage [nr]` | question, details | Show question details | -| `!neu Frage?` | new, ask | Create new question | -| `!loeschen [nr]` | delete | Delete question | -| `!archivieren [nr]` | archive | Archive question | - -### Research - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!recherche [nr]` | research | Start quick research | -| `!recherche [nr] standard` | - | Standard research (15 sources) | -| `!recherche [nr] deep` | - | Deep research (30 sources) | -| `!ergebnis [nr]` | result | Show research result | -| `!quellen [nr]` | sources | Show sources | - -### Answers - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!antwort [nr]` | answer | Show answer | -| `!bewerten [nr] 1-5` | rate | Rate answer | -| `!akzeptieren [nr]` | accept | Accept as solution | - -### Collections - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!sammlungen` | collections | List collections | -| `!sammlung Name` | collection | Create collection | - -### Search - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!suche Begriff` | search | Search questions | - -## Research Depths - -| Depth | Sources | Content Extraction | Categories | -|-------|---------|-------------------|------------| -| `quick` | 5 | No | general | -| `standard` | 15 | Yes | general, news | -| `deep` | 30 | Yes | general, news, science, it | - -## Question Status - -| Status | Emoji | Description | -|--------|-------|-------------| -| `open` | ❓ | New question | -| `researching` | 🔍 | Research in progress | -| `answered` | ✅ | Has answer | -| `archived` | 📦 | Archived | - -## Priority Levels - -| Priority | Indicator | -|----------|-----------| -| `urgent` | 🔴 | -| `high` | 🟠 | -| `normal` | (none) | -| `low` | (none) | - -## Example Usage - -``` -# Login -!login max@example.com mypassword - -# Create a new question -!neu Was ist Quantencomputing? - -# List questions -!fragen - -# Start research -!recherche 1 standard - -# View sources -!quellen 1 - -# View answer -!antwort 1 - -# Rate the answer -!bewerten 1 5 - -# Accept as solution -!akzeptieren 1 - -# Search questions -!suche quantum - -# Create collection -!sammlung Wissenschaft -``` - -## Environment Variables - -```env -# Server -PORT=3324 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#questions:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Questions Backend -QUESTIONS_BACKEND_URL=http://localhost:3011 -QUESTIONS_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-questions-bot/Dockerfile -t matrix-questions-bot services/matrix-questions-bot - -# Run -docker run -p 3324:3324 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e QUESTIONS_BACKEND_URL=http://questions-backend:3011 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-questions-bot-data:/app/data \ - matrix-questions-bot -``` - -## Health Check - -```bash -curl http://localhost:3324/health -``` - -## Questions Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/v1/questions` | GET | List questions | -| `/api/v1/questions` | POST | Create question | -| `/api/v1/questions/:id` | GET | Get question | -| `/api/v1/questions/:id` | DELETE | Delete question | -| `/api/v1/questions/:id/status` | PUT | Update status | -| `/api/v1/research/start` | POST | Start research | -| `/api/v1/research/question/:id` | GET | Get research results | -| `/api/v1/sources/question/:id` | GET | Get sources | -| `/api/v1/answers/question/:id` | GET | Get answers | -| `/api/v1/answers/:id/rate` | POST | Rate answer | -| `/api/v1/answers/:id/accept` | POST | Accept answer | -| `/api/v1/collections` | GET | List collections | -| `/api/v1/collections` | POST | Create collection | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!fragen` to get a list of questions -2. Bot stores the list internally for the user -3. User can reference questions by their list number -4. Numbers are valid until the user runs a new list command - -This allows simple commands like: -- `!frage 3` - Show details for question #3 -- `!recherche 1 deep` - Start deep research for question #1 -- `!antwort 2` - Show answer for question #2 diff --git a/services/matrix-questions-bot/Dockerfile b/services/matrix-questions-bot/Dockerfile deleted file mode 100644 index eaf57520b..000000000 --- a/services/matrix-questions-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3324 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3324/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-questions-bot/nest-cli.json b/services/matrix-questions-bot/nest-cli.json deleted file mode 100644 index 5c06bb8c3..000000000 --- a/services/matrix-questions-bot/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json-schema.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/matrix-questions-bot/package.json b/services/matrix-questions-bot/package.json deleted file mode 100644 index c775af8c5..000000000 --- a/services/matrix-questions-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-questions-bot", - "version": "1.0.0", - "description": "Matrix bot for Q&A research management", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-questions-bot/src/app.module.ts b/services/matrix-questions-bot/src/app.module.ts deleted file mode 100644 index dd9899431..000000000 --- a/services/matrix-questions-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { QuestionsModule } from './questions/questions.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - QuestionsModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-questions-bot')], -}) -export class AppModule {} diff --git a/services/matrix-questions-bot/src/bot/bot.module.ts b/services/matrix-questions-bot/src/bot/bot.module.ts deleted file mode 100644 index 6bfa515de..000000000 --- a/services/matrix-questions-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { QuestionsModule } from '../questions/questions.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - QuestionsModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-questions-bot/src/bot/matrix.service.ts b/services/matrix-questions-bot/src/bot/matrix.service.ts deleted file mode 100644 index 85b2b28be..000000000 --- a/services/matrix-questions-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,788 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - UserListMapper, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { QuestionsService, Question, Collection, Answer } from '../questions/questions.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -const QUESTION_CREATE_CREDITS = 0.02; -const QUICK_RESEARCH_CREDITS = 5; -const STANDARD_RESEARCH_CREDITS = 10; -const DEEP_RESEARCH_CREDITS = 25; - -@Injectable() -export class MatrixService extends BaseMatrixService { - // Store last shown items per user for reference by number - private questionsMapper = new UserListMapper(); - private collectionsMapper = new UserListMapper(); - private answersMapper = new UserListMapper(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['fragen', 'questions', 'meine fragen', 'liste'], command: 'fragen' }, - { keywords: ['recherche', 'research', 'suchen', 'untersuchen'], command: 'recherche' }, - { keywords: ['antwort', 'answer', 'antworten', 'ergebnis'], command: 'antwort' }, - { keywords: ['quellen', 'sources', 'referenzen', 'links'], command: 'quellen' }, - { keywords: ['sammlungen', 'collections', 'ordner', 'kategorien'], command: 'sammlungen' }, - { keywords: ['suche', 'search', 'finde', 'durchsuchen'], command: 'suche' }, - { keywords: ['neu', 'new', 'neue frage', 'frage stellen'], command: 'neu' }, - ]); - - constructor( - configService: ConfigService, - private questionsService: QuestionsService, - private sessionService: SessionService, - private readonly transcriptionService: TranscriptionService, - private creditService: CreditService - ) { - super(configService); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - if (!body.startsWith('!')) return; - - const sender = event.sender; - const parts = body.slice(1).split(/\s+/); - const command = parts[0].toLowerCase(); - const args = parts.slice(1); - const argString = args.join(' '); - - try { - switch (command) { - case 'help': - case 'hilfe': - await this.sendMessage(roomId, HELP_MESSAGE); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - // Question commands - case 'fragen': - case 'questions': - case 'liste': - await this.handleListQuestions(roomId, sender, args[0]); - break; - - case 'frage': - case 'question': - case 'details': - await this.handleQuestionDetails(roomId, sender, args[0]); - break; - - case 'neu': - case 'new': - case 'ask': - await this.handleCreateQuestion(roomId, sender, argString); - break; - - case 'loeschen': - case 'delete': - await this.handleDeleteQuestion(roomId, sender, args[0]); - break; - - case 'archivieren': - case 'archive': - await this.handleArchiveQuestion(roomId, sender, args[0]); - break; - - // Research commands - case 'recherche': - case 'research': - await this.handleStartResearch(roomId, sender, args[0], args[1]); - break; - - case 'ergebnis': - case 'result': - await this.handleResearchResult(roomId, sender, args[0]); - break; - - case 'quellen': - case 'sources': - await this.handleSources(roomId, sender, args[0]); - break; - - // Answer commands - case 'antwort': - case 'answer': - await this.handleAnswer(roomId, sender, args[0]); - break; - - case 'bewerten': - case 'rate': - await this.handleRateAnswer(roomId, sender, args[0], args[1]); - break; - - case 'akzeptieren': - case 'accept': - await this.handleAcceptAnswer(roomId, sender, args[0]); - break; - - // Collection commands - case 'sammlungen': - case 'collections': - await this.handleListCollections(roomId, sender); - break; - - case 'sammlung': - case 'collection': - await this.handleCreateCollection(roomId, sender, argString); - break; - - // Search - case 'suche': - case 'search': - await this.handleSearch(roomId, sender, argString); - break; - - default: - await this.sendMessage( - roomId, - `

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

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

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

`); - } - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - _sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendReply(roomId, event, '

Sprachnachricht konnte nicht erkannt werden.

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

"${text}"

`); - await this.handleTextMessage(roomId, event, text); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply(roomId, event, '

Fehler bei der Spracherkennung.

'); - } - } - - private async requireAuth(sender: string): Promise { - const token = await this.sessionService.getToken(sender); - if (!token) { - throw new Error(LOGIN_MESSAGES.questions); - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendOk = await this.questionsService.checkHealth(); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const sessions = await this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusHtml = `

Questions Bot Status

    `; - statusHtml += `
  • Backend: ${backendOk ? '✅ Online' : '❌ Offline'}
  • `; - statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; - statusHtml += `
  • ⚡ Credits: ${balance.balance.toFixed(2)}
  • `; - } else { - statusHtml += `
  • 👤 Nicht angemeldet
  • `; - statusHtml += `
  • 💡 Login: !login email passwort
  • `; - } - statusHtml += `
`; - - await this.sendMessage(roomId, statusHtml); - } - - // Question handlers - private async handleListQuestions(roomId: string, sender: string, statusFilter?: string) { - const token = await this.requireAuth(sender); - - const options: Record = {}; - if (statusFilter) { - const statusMap: Record = { - offen: 'open', - open: 'open', - recherche: 'researching', - researching: 'researching', - beantwortet: 'answered', - answered: 'answered', - archiviert: 'archived', - archived: 'archived', - }; - options.status = statusMap[statusFilter.toLowerCase()] || statusFilter; - } - - const result = await this.questionsService.getQuestions(token, options); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const questions = result.data || []; - this.questionsMapper.setList(sender, questions); - - if (questions.length === 0) { - await this.sendMessage( - roomId, - '

Keine Fragen vorhanden. Stelle eine mit !neu Frage?

' - ); - return; - } - - let html = '

Deine Fragen

    '; - for (const q of questions) { - const status = this.getStatusEmoji(q.status); - const priority = this.getPriorityIndicator(q.priority); - html += `
  1. ${status} ${priority}${q.title}
  2. `; - } - html += '
'; - html += - '

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

'; - - await this.sendMessage(roomId, html); - } - - private async handleQuestionDetails(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

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

Fehler: ${result.error}

`); - return; - } - - const q = result.data!; - const status = this.getStatusEmoji(q.status); - let html = `

${status} ${q.title}

`; - - if (q.description) html += `

${q.description}

`; - - html += '
    '; - html += `
  • Status: ${this.translateStatus(q.status)}
  • `; - html += `
  • Prioritaet: ${this.translatePriority(q.priority)}
  • `; - html += `
  • Recherche-Tiefe: ${q.researchDepth}
  • `; - if (q.tags?.length) html += `
  • Tags: ${q.tags.join(', ')}
  • `; - if (q.category) html += `
  • Kategorie: ${q.category}
  • `; - html += `
  • Erstellt: ${new Date(q.createdAt).toLocaleDateString('de-DE')}
  • `; - if (q.answeredAt) - html += `
  • Beantwortet: ${new Date(q.answeredAt).toLocaleDateString('de-DE')}
  • `; - html += '
'; - - html += `

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

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

Verwendung: !neu Deine Frage?

'); - return; - } - - const token = await this.requireAuth(sender); - const result = await this.questionsService.createQuestion(token, title); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.questionsMapper.clearList(sender); - await this.sendMessage( - roomId, - `

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

-

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

` - ); - } - - private async handleDeleteQuestion(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

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

Fehler: ${result.error}

`); - return; - } - - this.questionsMapper.clearList(sender); - await this.sendMessage(roomId, `

Frage geloescht: ${question.title}

`); - } - - private async handleArchiveQuestion(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

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

Fehler: ${result.error}

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

Frage archiviert: ${question.title}

`); - } - - // Research handlers - private async handleStartResearch( - roomId: string, - sender: string, - numberStr: string, - depthStr?: string - ) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

'); - return; - } - - const depthMap: Record = { - schnell: 'quick', - quick: 'quick', - standard: 'standard', - normal: 'standard', - tief: 'deep', - deep: 'deep', - }; - const depth = depthMap[depthStr?.toLowerCase() || ''] || 'quick'; - - await this.sendMessage( - roomId, - `

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

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

Fehler: ${result.error}

`); - return; - } - - const research = result.data!; - let html = `

Recherche abgeschlossen

`; - - if (research.summary) { - html += `

Zusammenfassung:

${research.summary}

`; - } - - if (research.keyPoints?.length) { - html += '

Wichtige Punkte:

    '; - for (const point of research.keyPoints.slice(0, 5)) { - html += `
  • ${point}
  • `; - } - html += '
'; - } - - if (research.followUpQuestions?.length) { - html += '

Folge-Fragen:

    '; - for (const fq of research.followUpQuestions.slice(0, 3)) { - html += `
  • ${fq}
  • `; - } - html += '
'; - } - - html += `

Nutze !quellen ${numberStr} fuer die Quellen

`; - - await this.sendMessage(roomId, html); - } - - private async handleResearchResult(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

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

Fehler: ${result.error}

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

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

` - ); - return; - } - - const latest = results[0]; - let html = `

Recherche-Ergebnis

`; - html += `

Tiefe: ${latest.researchDepth}

`; - - if (latest.summary) { - html += `

${latest.summary}

`; - } - - if (latest.keyPoints?.length) { - html += '

Wichtige Punkte:

    '; - for (const point of latest.keyPoints) { - html += `
  • ${point}
  • `; - } - html += '
'; - } - - await this.sendMessage(roomId, html); - } - - private async handleSources(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

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

Fehler: ${result.error}

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

Keine Quellen vorhanden.

'); - return; - } - - let html = `

Quellen fuer: ${question.title}

    `; - for (const source of sources.slice(0, 10)) { - const relevance = source.relevanceScore - ? ` (${Math.round(source.relevanceScore * 100)}%)` - : ''; - html += `
  1. ${source.title}${relevance}
    ${source.domain}
  2. `; - } - html += '
'; - - if (sources.length > 10) { - html += `

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

`; - } - - await this.sendMessage(roomId, html); - } - - // Answer handlers - private async handleAnswer(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const question = this.getQuestionByNumber(sender, numberStr); - - if (!question) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !fragen

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

Fehler: ${result.error}

`); - return; - } - - const answers = result.data || []; - this.answersMapper.setList(sender, answers); - - if (answers.length === 0) { - await this.sendMessage( - roomId, - `

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

` - ); - return; - } - - // Show the first (most recent) answer - const answer = answers[0]; - const accepted = answer.isAccepted ? ' ✅' : ''; - const rating = answer.rating ? ` (${answer.rating}/5 Sterne)` : ''; - const confidence = answer.confidence - ? ` [${Math.round(answer.confidence * 100)}% Konfidenz]` - : ''; - - let html = `

Antwort${accepted}${rating}

`; - html += `

Model: ${answer.modelId}${confidence}

`; - - if (answer.summary) { - html += `

Zusammenfassung: ${answer.summary}

`; - } - - html += `

${answer.contentMarkdown || answer.content}

`; - - if (answer.sourceCount) { - html += `

Basierend auf ${answer.sourceCount} Quellen

`; - } - - html += `

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

`; - - await this.sendMessage(roomId, html); - } - - private async handleRateAnswer( - roomId: string, - sender: string, - numberStr: string, - ratingStr: string - ) { - const token = await this.requireAuth(sender); - - if (!this.answersMapper.hasList(sender)) { - await this.sendMessage( - roomId, - '

Zeige zuerst eine Antwort mit !antwort [nr]

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

Bewertung muss zwischen 1 und 5 sein.

'); - return; - } - - // Get first answer (most recent) - const answer = this.answersMapper.getByNumber(sender, 1); - if (!answer) { - await this.sendMessage(roomId, '

Keine Antwort gefunden.

'); - return; - } - const result = await this.questionsService.rateAnswer(token, answer.id, rating); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

Antwort mit ${rating} Sternen bewertet.

`); - } - - private async handleAcceptAnswer(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - - if (!this.answersMapper.hasList(sender)) { - await this.sendMessage( - roomId, - '

Zeige zuerst eine Antwort mit !antwort [nr]

' - ); - return; - } - - // Get first answer (most recent) - const answer = this.answersMapper.getByNumber(sender, 1); - if (!answer) { - await this.sendMessage(roomId, '

Keine Antwort gefunden.

'); - return; - } - const result = await this.questionsService.acceptAnswer(token, answer.id); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

Antwort als Loesung akzeptiert. ✅

'); - } - - // Collection handlers - private async handleListCollections(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.questionsService.getCollections(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const collections = result.data || []; - this.collectionsMapper.setList(sender, collections); - - if (collections.length === 0) { - await this.sendMessage( - roomId, - '

Keine Sammlungen. Erstelle eine mit !sammlung Name

' - ); - return; - } - - let html = '

Sammlungen

    '; - for (const c of collections) { - const defaultMark = c.isDefault ? ' (Standard)' : ''; - const count = c.questionCount !== undefined ? ` [${c.questionCount} Fragen]` : ''; - html += `
  1. ${c.name}${defaultMark}${count}
  2. `; - } - html += '
'; - - await this.sendMessage(roomId, html); - } - - private async handleCreateCollection(roomId: string, sender: string, name: string) { - if (!name) { - await this.sendMessage(roomId, '

Verwendung: !sammlung Name

'); - return; - } - - const token = await this.requireAuth(sender); - const result = await this.questionsService.createCollection(token, name); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.collectionsMapper.clearList(sender); - await this.sendMessage( - roomId, - `

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

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

Verwendung: !suche Begriff

'); - return; - } - - const token = await this.requireAuth(sender); - const result = await this.questionsService.getQuestions(token, { search: query }); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const questions = result.data || []; - this.questionsMapper.setList(sender, questions); - - if (questions.length === 0) { - await this.sendMessage(roomId, `

Keine Fragen gefunden fuer "${query}"

`); - return; - } - - let html = `

Suchergebnisse: "${query}"

    `; - for (const q of questions) { - const status = this.getStatusEmoji(q.status); - html += `
  1. ${status} ${q.title}
  2. `; - } - html += '
'; - - await this.sendMessage(roomId, html); - } - - // Helper methods - private getQuestionByNumber(sender: string, numberStr: string): Question | null { - const num = parseInt(numberStr, 10); - if (isNaN(num)) return null; - return this.questionsMapper.getByNumber(sender, num); - } - - private getStatusEmoji(status: string): string { - const map: Record = { - open: '❓', // Question mark - researching: '🔍', // Magnifying glass - answered: '✅', // Check mark - archived: '📦', // Package - }; - return map[status] || '❓'; - } - - private translateStatus(status: string): string { - const map: Record = { - open: 'Offen', - researching: 'In Recherche', - answered: 'Beantwortet', - archived: 'Archiviert', - }; - return map[status] || status; - } - - private getPriorityIndicator(priority: string): string { - const map: Record = { - urgent: '🔴 ', // Red circle - high: '🟠 ', // Orange circle - normal: '', - low: '', - }; - return map[priority] || ''; - } - - private translatePriority(priority: string): string { - const map: Record = { - low: 'Niedrig', - normal: 'Normal', - high: 'Hoch', - urgent: 'Dringend', - }; - return map[priority] || priority; - } -} diff --git a/services/matrix-questions-bot/src/config/configuration.ts b/services/matrix-questions-bot/src/config/configuration.ts deleted file mode 100644 index 0d94c5156..000000000 --- a/services/matrix-questions-bot/src/config/configuration.ts +++ /dev/null @@ -1,62 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3324', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN, - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - questions: { - backendUrl: process.env.QUESTIONS_BACKEND_URL || 'http://localhost:3011', - apiPrefix: process.env.QUESTIONS_API_PREFIX || '/api/v1', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `

Questions Bot - Befehle

- -

Fragen

-
    -
  • !fragen - Alle Fragen auflisten
  • -
  • !fragen offen - Offene Fragen
  • -
  • !frage [nr] - Frage-Details anzeigen
  • -
  • !neu Frage? - Neue Frage stellen
  • -
  • !loeschen [nr] - Frage loeschen
  • -
  • !archivieren [nr] - Frage archivieren
  • -
- -

Recherche

-
    -
  • !recherche [nr] - Recherche starten (quick)
  • -
  • !recherche [nr] standard - Standard-Recherche
  • -
  • !recherche [nr] deep - Tiefe Recherche
  • -
  • !ergebnis [nr] - Recherche-Ergebnis anzeigen
  • -
  • !quellen [nr] - Quellen anzeigen
  • -
- -

Antworten

-
    -
  • !antwort [nr] - Antwort zur Frage anzeigen
  • -
  • !bewerten [nr] 1-5 - Antwort bewerten
  • -
  • !akzeptieren [nr] - Antwort akzeptieren
  • -
- -

Sammlungen

-
    -
  • !sammlungen - Alle Sammlungen
  • -
  • !sammlung [name] - Neue Sammlung erstellen
  • -
- -

Suche

-
    -
  • !suche Begriff - Fragen durchsuchen
  • -
- -

Weitere Befehle

-
    -
  • !help - Diese Hilfe anzeigen
  • -
- -

Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-questions-bot/src/main.ts b/services/matrix-questions-bot/src/main.ts deleted file mode 100644 index 5ae0c7777..000000000 --- a/services/matrix-questions-bot/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3324; - await app.listen(port); - console.log(`Matrix Questions Bot running on port ${port}`); -} -bootstrap(); diff --git a/services/matrix-questions-bot/src/questions/questions.module.ts b/services/matrix-questions-bot/src/questions/questions.module.ts deleted file mode 100644 index 0c14b7637..000000000 --- a/services/matrix-questions-bot/src/questions/questions.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { QuestionsService } from './questions.service'; - -@Module({ - providers: [QuestionsService], - exports: [QuestionsService], -}) -export class QuestionsModule {} diff --git a/services/matrix-questions-bot/src/questions/questions.service.ts b/services/matrix-questions-bot/src/questions/questions.service.ts deleted file mode 100644 index 65e0dc526..000000000 --- a/services/matrix-questions-bot/src/questions/questions.service.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface Question { - id: string; - title: string; - description?: string; - status: 'open' | 'researching' | 'answered' | 'archived'; - priority: 'low' | 'normal' | 'high' | 'urgent'; - tags: string[]; - category?: string; - researchDepth: 'quick' | 'standard' | 'deep'; - collectionId?: string; - createdAt: string; - updatedAt: string; - answeredAt?: string; -} - -export interface Collection { - id: string; - name: string; - description?: string; - color: string; - icon: string; - isDefault: boolean; - questionCount?: number; - createdAt: string; -} - -export interface ResearchResult { - id: string; - questionId: string; - researchDepth: string; - summary?: string; - keyPoints?: string[]; - followUpQuestions?: string[]; - createdAt: string; - durationMs?: number; -} - -export interface Source { - id: string; - url: string; - title: string; - snippet?: string; - domain: string; - relevanceScore?: number; - position: number; - engine: string; -} - -export interface Answer { - id: string; - questionId: string; - content: string; - contentMarkdown?: string; - summary?: string; - modelId: string; - provider: string; - confidence?: number; - sourceCount?: number; - rating?: number; - isAccepted: boolean; - createdAt: string; -} - -@Injectable() -export class QuestionsService { - private readonly logger = new Logger(QuestionsService.name); - private backendUrl: string; - private apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = this.configService.get('questions.backendUrl') || 'http://localhost:3011'; - this.apiPrefix = this.configService.get('questions.apiPrefix') || '/api/v1'; - } - - private async request( - token: string, - endpoint: string, - options: RequestInit = {} - ): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - // Question operations - async getQuestions( - token: string, - options: { status?: string; search?: string; collectionId?: string } = {} - ): Promise<{ data?: Question[]; error?: string }> { - const params = new URLSearchParams(); - if (options.status) params.set('status', options.status); - if (options.search) params.set('search', options.search); - if (options.collectionId) params.set('collectionId', options.collectionId); - const query = params.toString() ? `?${params.toString()}` : ''; - return this.request(token, `/questions${query}`); - } - - async getQuestion(token: string, questionId: string): Promise<{ data?: Question; error?: string }> { - return this.request(token, `/questions/${questionId}`); - } - - async createQuestion( - token: string, - title: string, - options: { description?: string; priority?: string; tags?: string[]; collectionId?: string } = {} - ): Promise<{ data?: Question; error?: string }> { - return this.request(token, '/questions', { - method: 'POST', - body: JSON.stringify({ title, ...options }), - }); - } - - async updateQuestionStatus( - token: string, - questionId: string, - status: string - ): Promise<{ data?: Question; error?: string }> { - return this.request(token, `/questions/${questionId}/status`, { - method: 'PUT', - body: JSON.stringify({ status }), - }); - } - - async deleteQuestion(token: string, questionId: string): Promise<{ error?: string }> { - return this.request(token, `/questions/${questionId}`, { method: 'DELETE' }); - } - - // Research operations - async startResearch( - token: string, - questionId: string, - depth: 'quick' | 'standard' | 'deep' = 'quick' - ): Promise<{ data?: ResearchResult; error?: string }> { - return this.request(token, '/research/start', { - method: 'POST', - body: JSON.stringify({ questionId, depth }), - }); - } - - async getResearchResults(token: string, questionId: string): Promise<{ data?: ResearchResult[]; error?: string }> { - return this.request(token, `/research/question/${questionId}`); - } - - async getResearchResult(token: string, researchId: string): Promise<{ data?: ResearchResult; error?: string }> { - return this.request(token, `/research/${researchId}`); - } - - // Source operations - async getSources(token: string, questionId: string): Promise<{ data?: Source[]; error?: string }> { - return this.request(token, `/sources/question/${questionId}`); - } - - // Answer operations - async getAnswers(token: string, questionId: string): Promise<{ data?: Answer[]; error?: string }> { - return this.request(token, `/answers/question/${questionId}`); - } - - async getAcceptedAnswer(token: string, questionId: string): Promise<{ data?: Answer; error?: string }> { - return this.request(token, `/answers/question/${questionId}/accepted`); - } - - async rateAnswer(token: string, answerId: string, rating: number): Promise<{ data?: Answer; error?: string }> { - return this.request(token, `/answers/${answerId}/rate`, { - method: 'POST', - body: JSON.stringify({ rating }), - }); - } - - async acceptAnswer(token: string, answerId: string): Promise<{ data?: Answer; error?: string }> { - return this.request(token, `/answers/${answerId}/accept`, { method: 'POST' }); - } - - // Collection operations - async getCollections(token: string): Promise<{ data?: Collection[]; error?: string }> { - return this.request(token, '/collections'); - } - - async createCollection( - token: string, - name: string, - options: { description?: string; color?: string } = {} - ): Promise<{ data?: Collection; error?: string }> { - return this.request(token, '/collections', { - method: 'POST', - body: JSON.stringify({ name, ...options }), - }); - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/health`); - return response.ok; - } catch { - return false; - } - } -} diff --git a/services/matrix-questions-bot/tsconfig.json b/services/matrix-questions-bot/tsconfig.json deleted file mode 100644 index 94f1e9493..000000000 --- a/services/matrix-questions-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - } -} diff --git a/services/matrix-skilltree-bot/.env.example b/services/matrix-skilltree-bot/.env.example deleted file mode 100644 index a86d0511c..000000000 --- a/services/matrix-skilltree-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3326 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#skilltree:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Skilltree Backend -SKILLTREE_BACKEND_URL=http://localhost:3024 -SKILLTREE_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-skilltree-bot/.gitignore b/services/matrix-skilltree-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-skilltree-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-skilltree-bot/CLAUDE.md b/services/matrix-skilltree-bot/CLAUDE.md deleted file mode 100644 index e5d95b18c..000000000 --- a/services/matrix-skilltree-bot/CLAUDE.md +++ /dev/null @@ -1,207 +0,0 @@ -# Matrix Skilltree Bot - Claude Code Guidelines - -## Overview - -Matrix Skilltree Bot provides skill tree and XP management via Matrix chat. It integrates with the Skilltree backend for skill CRUD, XP tracking, leveling, and activity history. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Skilltree API (port 3024) -- **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-skilltree-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3326) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── skilltree/ -│ │ ├── skilltree.module.ts -│ │ └── skilltree.service.ts # Skilltree Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -### Skill Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!skills` | liste, faehigkeiten | List all skills | -| `!skills koerper` | - | Filter by branch | -| `!skill [nr]` | details | Show skill details | -| `!neu Name \| Branch` | new, create | Create skill | -| `!loeschen [nr]` | delete | Delete skill | - -### XP Tracking - -| Command | Options | Description | -|---------|---------|-------------| -| `!xp [nr] 50 Aktivitaet` | punkte | Add XP to skill | -| `--min N` | - | Optional duration in minutes | - -### Statistics - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!stats` | statistik | Show user statistics | -| `!aktivitaeten` | activities, verlauf | Recent activities | -| `!aktivitaeten [nr]` | - | Activities for skill | - -## Skill Branches - -| Branch | German | Icon | Description | -|--------|--------|------|-------------| -| `intellect` | wissen, gehirn | 🧠 | Knowledge, languages, science | -| `body` | koerper, fitness | 💪 | Fitness, sports, health | -| `creativity` | kreativ, kunst | 🎨 | Art, music, writing | -| `social` | sozial | 👥 | Communication, leadership | -| `practical` | praktisch, handwerk | 🔧 | Crafts, cooking, tech | -| `mindset` | achtsamkeit, mental | 💖 | Meditation, focus | -| `custom` | eigene | ⭐ | User-defined | - -## Level System - -| Level | Name | XP Required | -|-------|------|-------------| -| 0 | Unbekannt | 0 | -| 1 | Anfaenger | 100 | -| 2 | Fortgeschritten | 500 | -| 3 | Kompetent | 1,500 | -| 4 | Experte | 4,000 | -| 5 | Meister | 10,000 | - -## Example Usage - -``` -# Login -!login max@example.com mypassword - -# Create a skill -!neu Spanisch | intellect -!neu Joggen | body | Taegliches Lauftraining - -# List skills -!skills - -# Add XP -!xp 1 100 Vokabeln gelernt -!xp 2 50 30min Joggen --min 30 - -# View skill details -!skill 1 - -# View stats -!stats - -# View activities -!aktivitaeten -!aktivitaeten 1 - -# Delete skill -!loeschen 1 -``` - -## Environment Variables - -```env -# Server -PORT=3326 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#skilltree:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Skilltree Backend -SKILLTREE_BACKEND_URL=http://localhost:3024 -SKILLTREE_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-skilltree-bot/Dockerfile -t matrix-skilltree-bot services/matrix-skilltree-bot - -# Run -docker run -p 3326:3326 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e SKILLTREE_BACKEND_URL=http://skilltree-backend:3024 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-skilltree-bot-data:/app/data \ - matrix-skilltree-bot -``` - -## Health Check - -```bash -curl http://localhost:3326/health -``` - -## Skilltree Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/v1/skills` | GET | List skills | -| `/api/v1/skills` | POST | Create skill | -| `/api/v1/skills/:id` | GET | Get skill details | -| `/api/v1/skills/:id` | DELETE | Delete skill | -| `/api/v1/skills/:id/xp` | POST | Add XP to skill | -| `/api/v1/skills/stats` | GET | Get user statistics | -| `/api/v1/activities` | GET | List activities | -| `/api/v1/activities/recent` | GET | Recent activities | -| `/api/v1/activities/skill/:id` | GET | Skill activities | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!skills` to get a list of skills -2. Bot stores the list internally for the user -3. User can reference skills by their list number -4. Numbers are valid until the user runs a new list command - -This allows simple commands like: -- `!skill 3` - Show details for skill #3 -- `!xp 1 100 Training` - Add 100 XP to skill #1 -- `!aktivitaeten 2` - Show activities for skill #2 diff --git a/services/matrix-skilltree-bot/Dockerfile b/services/matrix-skilltree-bot/Dockerfile deleted file mode 100644 index a5b86c6dc..000000000 --- a/services/matrix-skilltree-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3326 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3326/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-skilltree-bot/nest-cli.json b/services/matrix-skilltree-bot/nest-cli.json deleted file mode 100644 index 5c06bb8c3..000000000 --- a/services/matrix-skilltree-bot/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json-schema.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/matrix-skilltree-bot/package.json b/services/matrix-skilltree-bot/package.json deleted file mode 100644 index 4a44bda13..000000000 --- a/services/matrix-skilltree-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-skilltree-bot", - "version": "1.0.0", - "description": "Matrix bot for skill tree and XP management", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-skilltree-bot/src/app.module.ts b/services/matrix-skilltree-bot/src/app.module.ts deleted file mode 100644 index 5b979fef6..000000000 --- a/services/matrix-skilltree-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { SkilltreeModule } from './skilltree/skilltree.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - SkilltreeModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-skilltree-bot')], -}) -export class AppModule {} diff --git a/services/matrix-skilltree-bot/src/bot/bot.module.ts b/services/matrix-skilltree-bot/src/bot/bot.module.ts deleted file mode 100644 index cea2bb670..000000000 --- a/services/matrix-skilltree-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { SkilltreeModule } from '../skilltree/skilltree.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - SkilltreeModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-skilltree-bot/src/bot/matrix.service.ts b/services/matrix-skilltree-bot/src/bot/matrix.service.ts deleted file mode 100644 index b2bf29023..000000000 --- a/services/matrix-skilltree-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - UserListMapper, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { SkilltreeService, Skill, SkillBranch } from '../skilltree/skilltree.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - // User list mapper for number-based reference - private skillsMapper = new UserListMapper(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['skills', 'faehigkeiten', 'meine skills', 'liste'], command: 'skills' }, - { keywords: ['xp', 'punkte', 'erfahrung', 'erfahrungspunkte'], command: 'xp' }, - { keywords: ['stats', 'statistik', 'statistiken', 'fortschritt'], command: 'stats' }, - { keywords: ['aktivitaeten', 'activities', 'verlauf', 'historie'], command: 'aktivitaeten' }, - { keywords: ['neu', 'new', 'neuer skill', 'skill erstellen'], command: 'neu' }, - ]); - - // Branch name mappings (German/English) - private readonly branchMappings: Record = { - intellect: 'intellect', - wissen: 'intellect', - gehirn: 'intellect', - body: 'body', - koerper: 'body', - fitness: 'body', - sport: 'body', - creativity: 'creativity', - kreativ: 'creativity', - kreativitaet: 'creativity', - kunst: 'creativity', - social: 'social', - sozial: 'social', - practical: 'practical', - praktisch: 'practical', - handwerk: 'practical', - mindset: 'mindset', - achtsamkeit: 'mindset', - mental: 'mindset', - custom: 'custom', - eigene: 'custom', - }; - - constructor( - configService: ConfigService, - private skilltreeService: SkilltreeService, - private sessionService: SessionService, - private readonly transcriptionService: TranscriptionService, - private creditService: CreditService - ) { - super(configService); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - if (!body.startsWith('!')) return; - - const sender = event.sender; - const parts = body.slice(1).split(/\s+/); - const command = parts[0].toLowerCase(); - const args = parts.slice(1); - const argString = args.join(' '); - - try { - switch (command) { - case 'help': - case 'hilfe': - await this.sendMessage(roomId, HELP_MESSAGE); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - // Skill commands - case 'skills': - case 'liste': - case 'faehigkeiten': - await this.handleListSkills(roomId, sender, args[0]); - break; - - case 'skill': - case 'details': - await this.handleSkillDetails(roomId, sender, args[0]); - break; - - case 'neu': - case 'new': - case 'create': - await this.handleCreateSkill(roomId, sender, argString); - break; - - case 'loeschen': - case 'delete': - await this.handleDeleteSkill(roomId, sender, args[0]); - break; - - // XP commands - case 'xp': - case 'punkte': - await this.handleAddXp(roomId, sender, argString); - break; - - // Stats commands - case 'stats': - case 'statistik': - await this.handleStats(roomId, sender); - break; - - // Activity commands - case 'aktivitaeten': - case 'activities': - case 'verlauf': - await this.handleActivities(roomId, sender, args[0]); - break; - - default: - await this.sendMessage( - roomId, - `

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

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

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

`); - } - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - _sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendReply(roomId, event, '

Sprachnachricht konnte nicht erkannt werden.

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

"${text}"

`); - await this.handleTextMessage(roomId, event, text); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply(roomId, event, '

Fehler bei der Spracherkennung.

'); - } - } - - private async requireAuth(sender: string): Promise { - const token = await this.sessionService.getToken(sender); - if (!token) { - throw new Error(LOGIN_MESSAGES.skilltree); - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendOk = await this.skilltreeService.checkHealth(); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const sessions = await this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusHtml = '

Skilltree Bot Status

    '; - statusHtml += `
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • `; - statusHtml += `
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • `; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; - statusHtml += `
  • ⚡ Credits: ${balance.balance.toFixed(2)}
  • `; - } - - statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; - statusHtml += '
'; - - await this.sendMessage(roomId, statusHtml); - } - - // Skill handlers - private async handleListSkills(roomId: string, sender: string, branchFilter?: string) { - const token = await this.requireAuth(sender); - - let branch: string | undefined; - if (branchFilter) { - branch = this.branchMappings[branchFilter.toLowerCase()]; - if (!branch) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - } - - const result = await this.skilltreeService.getSkills(token, branch); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const skills = result.data?.skills || []; - this.skillsMapper.setList(sender, skills); - - if (skills.length === 0) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - - let html = '

Deine Skills

    '; - for (const skill of skills) { - const levelName = this.getLevelName(skill.level); - const branchIcon = this.getBranchIcon(skill.branch); - const progress = this.getProgressBar(skill.totalXp, skill.level); - html += `
  1. ${branchIcon} ${skill.name} - Lvl ${skill.level} (${levelName}) ${progress}
  2. `; - } - html += '
'; - html += - '

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

'; - - await this.sendMessage(roomId, html); - } - - private async handleSkillDetails(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const skill = this.skillsMapper.getByNumber(sender, number); - - if (!skill) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !skills

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

Fehler: ${result.error}

`); - return; - } - - const s = result.data!.skill; - const levelName = this.getLevelName(s.level); - const nextLevelXp = this.getNextLevelXp(s.level); - const branchIcon = this.getBranchIcon(s.branch); - - let html = `

${branchIcon} ${s.name}

`; - if (s.description) html += `

${s.description}

`; - - html += '
    '; - html += `
  • Branch: ${this.translateBranch(s.branch)}
  • `; - html += `
  • Level: ${s.level} (${levelName})
  • `; - html += `
  • XP: ${s.totalXp.toLocaleString('de-DE')}`; - if (nextLevelXp) html += ` / ${nextLevelXp.toLocaleString('de-DE')} (naechstes Level)`; - html += '
  • '; - html += `
  • Erstellt: ${new Date(s.createdAt).toLocaleDateString('de-DE')}
  • `; - html += '
'; - - html += `

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

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

Verwendung: !neu Name | Branch

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

' - ); - return; - } - - const token = await this.requireAuth(sender); - const parts = input.split('|').map((s) => s.trim()); - const name = parts[0]; - const branchInput = parts[1]?.toLowerCase() || 'custom'; - - const branch = this.branchMappings[branchInput]; - if (!branch) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - - const description = parts[2]; - - const result = await this.skilltreeService.createSkill(token, name, branch, description); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.skillsMapper.clearList(sender); - const branchIcon = this.getBranchIcon(branch); - await this.sendMessage( - roomId, - `

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

-

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

` - ); - } - - private async handleDeleteSkill(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const number = parseInt(numberStr, 10); - const skill = this.skillsMapper.getByNumber(sender, number); - - if (!skill) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !skills

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

Fehler: ${result.error}

`); - return; - } - - this.skillsMapper.clearList(sender); - await this.sendMessage(roomId, `

Skill ${skill.name} geloescht.

`); - } - - // XP handler - private async handleAddXp(roomId: string, sender: string, argString: string) { - const args = argString.split(/\s+/); - - if (args.length < 3) { - await this.sendMessage( - roomId, - '

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

Optional: --min 60 fuer Dauer

' - ); - return; - } - - const token = await this.requireAuth(sender); - const number = parseInt(args[0], 10); - const skill = this.skillsMapper.getByNumber(sender, number); - - if (!skill) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !skills

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

XP muss zwischen 1 und 10000 liegen.

'); - return; - } - - // Parse duration (--min N) - let duration: number | undefined; - const minMatch = argString.match(/--min\s+(\d+)/i); - if (minMatch) { - duration = parseInt(minMatch[1], 10); - } - - // Get description (everything after xp number, minus --min part) - let description = args.slice(2).join(' '); - description = description.replace(/--min\s+\d+/i, '').trim(); - - if (!description) { - description = 'Aktivitaet'; - } - - const result = await this.skilltreeService.addXp(token, skill.id, xp, description, duration); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const { leveledUp, newLevel } = result.data!; - let html = `

+${xp} XP fuer ${skill.name}!

`; - html += `

${description}

`; - - if (leveledUp) { - const levelName = this.getLevelName(newLevel); - html += `

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

`; - } - - await this.sendMessage(roomId, html); - } - - // Stats handler - private async handleStats(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.skilltreeService.getStats(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const stats = result.data!.stats; - let html = '

Deine Statistiken

    '; - html += `
  • Gesamt-XP: ${stats.totalXp.toLocaleString('de-DE')}
  • `; - html += `
  • Skills: ${stats.totalSkills}
  • `; - html += `
  • Hoechstes Level: ${stats.highestLevel}
  • `; - html += `
  • Streak: ${stats.streakDays} Tage 🔥
  • `; - if (stats.lastActivityDate) { - html += `
  • Letzte Aktivitaet: ${stats.lastActivityDate}
  • `; - } - html += '
'; - - await this.sendMessage(roomId, html); - } - - // Activities handler - private async handleActivities(roomId: string, sender: string, numberStr?: string) { - const token = await this.requireAuth(sender); - - let result; - let skillName = ''; - - if (numberStr) { - const number = parseInt(numberStr, 10); - const skill = this.skillsMapper.getByNumber(sender, number); - if (!skill) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !skills

' - ); - return; - } - result = await this.skilltreeService.getSkillActivities(token, skill.id); - skillName = skill.name; - } else { - result = await this.skilltreeService.getRecentActivities(token, 10); - } - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

Keine Aktivitaeten vorhanden.

'); - return; - } - - const title = skillName ? `Aktivitaeten: ${skillName}` : 'Letzte Aktivitaeten'; - let html = `

${title}

    `; - - for (const activity of activities) { - const date = new Date(activity.timestamp).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); - const duration = activity.duration ? ` (${activity.duration} min)` : ''; - html += `
  1. +${activity.xpEarned} XP - ${activity.description}${duration}
    ${date}
  2. `; - } - html += '
'; - - await this.sendMessage(roomId, html); - } - - // Helper methods - private getLevelName(level: number): string { - const names: Record = { - 0: 'Unbekannt', - 1: 'Anfaenger', - 2: 'Fortgeschritten', - 3: 'Kompetent', - 4: 'Experte', - 5: 'Meister', - }; - return names[level] || `Level ${level}`; - } - - private getNextLevelXp(level: number): number | null { - const thresholds: Record = { - 0: 100, - 1: 500, - 2: 1500, - 3: 4000, - 4: 10000, - }; - return thresholds[level] || null; - } - - private getBranchIcon(branch: string): string { - const icons: Record = { - intellect: '🧠', // Brain - body: '💪', // Flexed biceps - creativity: '🎨', // Artist palette - social: '👥', // Busts in silhouette - practical: '🔧', // Wrench - mindset: '💖', // Heart - custom: '⭐', // Star - }; - return icons[branch] || '⭐'; - } - - private translateBranch(branch: string): string { - const translations: Record = { - intellect: 'Wissen', - body: 'Koerper', - creativity: 'Kreativitaet', - social: 'Sozial', - practical: 'Praktisch', - mindset: 'Achtsamkeit', - custom: 'Eigene', - }; - return translations[branch] || branch; - } - - private getProgressBar(totalXp: number, level: number): string { - const nextXp = this.getNextLevelXp(level); - if (!nextXp) return ''; - - const prevXp = level > 0 ? this.getNextLevelXp(level - 1) || 0 : 0; - const progress = Math.min(100, Math.round(((totalXp - prevXp) / (nextXp - prevXp)) * 100)); - return `[${progress}%]`; - } -} diff --git a/services/matrix-skilltree-bot/src/config/configuration.ts b/services/matrix-skilltree-bot/src/config/configuration.ts deleted file mode 100644 index 8badcfb4f..000000000 --- a/services/matrix-skilltree-bot/src/config/configuration.ts +++ /dev/null @@ -1,54 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3326', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN, - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - skilltree: { - backendUrl: process.env.SKILLTREE_BACKEND_URL || 'http://localhost:3024', - apiPrefix: process.env.SKILLTREE_API_PREFIX || '/api/v1', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `

Skilltree Bot - Befehle

- -

Skills

-
    -
  • !skills - Alle Skills auflisten
  • -
  • !skills koerper - Nach Branch filtern
  • -
  • !skill [nr] - Skill-Details anzeigen
  • -
  • !neu Name | Branch - Neuen Skill erstellen
  • -
  • !loeschen [nr] - Skill loeschen
  • -
- -

XP sammeln

-
    -
  • !xp [nr] 50 Aktivitaet - XP hinzufuegen
  • -
  • !xp [nr] 100 Training --min 60 - Mit Dauer
  • -
- -

Statistiken

-
    -
  • !stats - Gesamtstatistik anzeigen
  • -
  • !aktivitaeten - Letzte Aktivitaeten
  • -
  • !aktivitaeten [nr] - Aktivitaeten fuer Skill
  • -
- -

Branches

-

intellect (Wissen), body/koerper (Fitness), creativity/kreativ (Kunst), social/sozial (Kommunikation), practical/praktisch (Handwerk), mindset (Achtsamkeit), custom (Eigene)

- -

Level-System

-
    -
  • Level 1: 100 XP (Anfaenger)
  • -
  • Level 2: 500 XP (Fortgeschritten)
  • -
  • Level 3: 1500 XP (Kompetent)
  • -
  • Level 4: 4000 XP (Experte)
  • -
  • Level 5: 10000 XP (Meister)
  • -
- -

Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-skilltree-bot/src/main.ts b/services/matrix-skilltree-bot/src/main.ts deleted file mode 100644 index 9fb8bfb9b..000000000 --- a/services/matrix-skilltree-bot/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3326; - await app.listen(port); - console.log(`Matrix Skilltree Bot running on port ${port}`); -} -bootstrap(); diff --git a/services/matrix-skilltree-bot/src/skilltree/skilltree.module.ts b/services/matrix-skilltree-bot/src/skilltree/skilltree.module.ts deleted file mode 100644 index 19b777784..000000000 --- a/services/matrix-skilltree-bot/src/skilltree/skilltree.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SkilltreeService } from './skilltree.service'; - -@Module({ - providers: [SkilltreeService], - exports: [SkilltreeService], -}) -export class SkilltreeModule {} diff --git a/services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts b/services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts deleted file mode 100644 index 1eea796b3..000000000 --- a/services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export type SkillBranch = 'intellect' | 'body' | 'creativity' | 'social' | 'practical' | 'mindset' | 'custom'; - -export interface Skill { - id: string; - name: string; - description?: string; - branch: SkillBranch; - parentId?: string; - icon: string; - color?: string; - currentXp: number; - totalXp: number; - level: number; - createdAt: string; - updatedAt: string; -} - -export interface Activity { - id: string; - skillId: string; - xpEarned: number; - description: string; - duration?: number; - timestamp: string; -} - -export interface UserStats { - totalXp: number; - totalSkills: number; - highestLevel: number; - streakDays: number; - lastActivityDate?: string; -} - -export interface AddXpResult { - skill: Skill; - leveledUp: boolean; - newLevel: number; -} - -@Injectable() -export class SkilltreeService { - private readonly logger = new Logger(SkilltreeService.name); - private backendUrl: string; - private apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = this.configService.get('skilltree.backendUrl') || 'http://localhost:3024'; - this.apiPrefix = this.configService.get('skilltree.apiPrefix') || '/api/v1'; - } - - private async request( - token: string, - endpoint: string, - options: RequestInit = {} - ): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - // Skill operations - async getSkills(token: string, branch?: string): Promise<{ data?: { skills: Skill[] }; error?: string }> { - const query = branch ? `?branch=${branch}` : ''; - return this.request<{ skills: Skill[] }>(token, `/skills${query}`); - } - - async getSkill(token: string, skillId: string): Promise<{ data?: { skill: Skill }; error?: string }> { - return this.request<{ skill: Skill }>(token, `/skills/${skillId}`); - } - - async createSkill( - token: string, - name: string, - branch: SkillBranch, - description?: string - ): Promise<{ data?: { skill: Skill }; error?: string }> { - return this.request<{ skill: Skill }>(token, '/skills', { - method: 'POST', - body: JSON.stringify({ name, branch, description }), - }); - } - - async deleteSkill(token: string, skillId: string): Promise<{ error?: string }> { - return this.request(token, `/skills/${skillId}`, { method: 'DELETE' }); - } - - async addXp( - token: string, - skillId: string, - xp: number, - description: string, - duration?: number - ): Promise<{ data?: AddXpResult; error?: string }> { - return this.request(token, `/skills/${skillId}/xp`, { - method: 'POST', - body: JSON.stringify({ xp, description, duration }), - }); - } - - // Stats - async getStats(token: string): Promise<{ data?: { stats: UserStats }; error?: string }> { - return this.request<{ stats: UserStats }>(token, '/skills/stats'); - } - - // Activities - async getActivities(token: string, limit?: number): Promise<{ data?: { activities: Activity[] }; error?: string }> { - const query = limit ? `?limit=${limit}` : ''; - return this.request<{ activities: Activity[] }>(token, `/activities${query}`); - } - - async getRecentActivities(token: string, limit?: number): Promise<{ data?: { activities: Activity[] }; error?: string }> { - const query = limit ? `?limit=${limit}` : ''; - return this.request<{ activities: Activity[] }>(token, `/activities/recent${query}`); - } - - async getSkillActivities(token: string, skillId: string): Promise<{ data?: { activities: Activity[] }; error?: string }> { - return this.request<{ activities: Activity[] }>(token, `/activities/skill/${skillId}`); - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}/health`); - return response.ok; - } catch { - return false; - } - } -} diff --git a/services/matrix-skilltree-bot/tsconfig.json b/services/matrix-skilltree-bot/tsconfig.json deleted file mode 100644 index 94f1e9493..000000000 --- a/services/matrix-skilltree-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - } -} diff --git a/services/matrix-stats-bot/.env.example b/services/matrix-stats-bot/.env.example deleted file mode 100644 index 919ef25ee..000000000 --- a/services/matrix-stats-bot/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -PORT=3312 -TZ=Europe/Berlin - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_REPORT_ROOM_ID= -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Umami -UMAMI_API_URL=http://localhost:3000 -UMAMI_USERNAME=admin -UMAMI_PASSWORD= - -# Database (optional, for user counts) -DATABASE_URL= diff --git a/services/matrix-stats-bot/CLAUDE.md b/services/matrix-stats-bot/CLAUDE.md deleted file mode 100644 index 39830873d..000000000 --- a/services/matrix-stats-bot/CLAUDE.md +++ /dev/null @@ -1,110 +0,0 @@ -# Matrix Stats Bot - Claude Code Guidelines - -## Overview - -Matrix Stats Bot delivers analytics from Umami (self-hosted) via Matrix. GDPR-compliant replacement for telegram-stats-bot. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Analytics**: Umami API + Prometheus/VictoriaMetrics -- **Scheduling**: @nestjs/schedule - -## Commands - -```bash -pnpm install -pnpm start:dev # Development with hot reload -pnpm build # Production build -pnpm type-check # TypeScript check -``` - -## Matrix Commands - -### Personal Stats (auto-login via Matrix-SSO-Link) - -| Command | Description | -|---------|-------------| -| `!stats` | Your personal statistics across all ManaCore apps | -| `!status` | Account status and credit balance | - -**Note:** If you logged in via another Matrix bot or via OIDC, you're automatically authenticated. - -### Global Analytics (Umami) - -| Command | Description | -|---------|-------------| -| `!global` | Overview of all apps (30 days) | -| `!today` | Today's statistics | -| `!week` | This week's statistics | -| `!realtime` | Active visitors right now | - -### Infrastructure (Prometheus) - -| Command | Description | -|---------|-------------| -| `!system` | Mac Mini status (CPU, RAM, Disk, Uptime) | -| `!services` | Backend service health (UP/DOWN) | -| `!traffic` | HTTP traffic & latency per service | -| `!db` | PostgreSQL & Redis status | -| `!growth` | User growth statistics | - -### Account - -| Command | Description | -|---------|-------------| -| `!login email password` | Login with ManaCore credentials | -| `!logout` | Logout from current session | -| `!help` | Show available commands | - -## Scheduled Reports - -| Report | Schedule | Timezone | -|--------|----------|----------| -| Daily | 09:00 | Europe/Berlin | -| Weekly | Monday 09:00 | Europe/Berlin | - -## Environment Variables - -```env -PORT=4012 -TZ=Europe/Berlin - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_REPORT_ROOM_ID=!roomid:mana.how - -# Redis (for session storage & Matrix-SSO-Link) -REDIS_HOST=redis -REDIS_PASSWORD=xxx - -# Mana Core Auth (for Matrix-SSO-Link auto-login) -MANA_CORE_AUTH_URL=http://mana-auth:3001 -MANA_CORE_SERVICE_KEY=xxx - -# Umami -UMAMI_API_URL=http://umami:3000 -UMAMI_USERNAME=admin -UMAMI_PASSWORD=xxx - -# Prometheus / VictoriaMetrics -PROMETHEUS_URL=http://victoriametrics:9090 - -# Database (for user counts) -DATABASE_URL=postgresql://... -``` - -## Authentication - -The bot uses **Matrix-SSO-Link** for automatic authentication: -- Sessions are stored in Redis (shared across all bots) -- If a user logged in via another bot or OIDC, they're automatically authenticated -- Manual login via `!login email password` creates a persistent link - -## Health Check - -```bash -curl http://localhost:3312/health -``` diff --git a/services/matrix-stats-bot/Dockerfile b/services/matrix-stats-bot/Dockerfile deleted file mode 100644 index 0f44b313c..000000000 --- a/services/matrix-stats-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-stats-bot ./services/matrix-stats-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-stats-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-stats-bot/dist ./services/matrix-stats-bot/dist -COPY --from=builder /app/services/matrix-stats-bot/package.json ./services/matrix-stats-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-stats-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4012/health || exit 1 - -EXPOSE 4012 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-stats-bot/nest-cli.json b/services/matrix-stats-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-stats-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-stats-bot/package.json b/services/matrix-stats-bot/package.json deleted file mode 100644 index 77c705dc2..000000000 --- a/services/matrix-stats-bot/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@manacore/matrix-stats-bot", - "version": "1.0.0", - "description": "Matrix bot for analytics from Umami - GDPR compliant", - "private": true, - "license": "MIT", - "pnpm": { - "neverBuiltDependencies": [ - "@matrix-org/matrix-sdk-crypto-nodejs" - ], - "overrides": { - "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" - } - }, - "scripts": { - "prebuild": "rimraf dist", - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@nestjs/common": "^10.4.15", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.15", - "@nestjs/platform-express": "^10.4.15", - "@nestjs/schedule": "^4.1.2", - "matrix-bot-sdk": "^0.7.1", - "postgres": "^3.4.5", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@types/node": "^22.10.5", - "rimraf": "^6.0.1", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-stats-bot/src/analytics/analytics.module.ts b/services/matrix-stats-bot/src/analytics/analytics.module.ts deleted file mode 100644 index ab8df2d1d..000000000 --- a/services/matrix-stats-bot/src/analytics/analytics.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AnalyticsService } from './analytics.service'; -import { UmamiModule } from '../umami/umami.module'; - -@Module({ - imports: [UmamiModule], - providers: [AnalyticsService], - exports: [AnalyticsService], -}) -export class AnalyticsModule {} diff --git a/services/matrix-stats-bot/src/analytics/analytics.service.ts b/services/matrix-stats-bot/src/analytics/analytics.service.ts deleted file mode 100644 index 31d0d816e..000000000 --- a/services/matrix-stats-bot/src/analytics/analytics.service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { UmamiService } from '../umami/umami.service'; -import { WEBSITE_IDS, DISPLAY_NAMES } from '../config/configuration'; - -@Injectable() -export class AnalyticsService { - private readonly logger = new Logger(AnalyticsService.name); - - constructor(private readonly umamiService: UmamiService) {} - - async generateStatsOverview(): Promise { - const now = Date.now(); - const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; - - const websites = await this.umamiService.getWebsites(); - if (!websites.length) { - return '❌ Keine Websites in Umami konfiguriert.'; - } - - let report = '**📊 ManaCore Stats (30 Tage)**\n\n'; - - for (const website of websites) { - const stats = await this.umamiService.getStats(website.id, thirtyDaysAgo, now); - if (!stats) continue; - - const displayName = DISPLAY_NAMES[website.name] || website.name; - const changeIcon = (change: number) => (change > 0 ? '📈' : change < 0 ? '📉' : '➡️'); - - report += `**${displayName}**\n`; - report += `👁️ ${stats.pageviews.value.toLocaleString()} Views ${changeIcon(stats.pageviews.change)}\n`; - report += `👥 ${stats.visitors.value.toLocaleString()} Besucher ${changeIcon(stats.visitors.change)}\n\n`; - } - - return report; - } - - async generateDailyReport(): Promise { - const now = Date.now(); - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); - - const websites = await this.umamiService.getWebsites(); - if (!websites.length) { - return '❌ Keine Websites konfiguriert.'; - } - - let report = '**📊 Heute**\n\n'; - let totalViews = 0; - let totalVisitors = 0; - - for (const website of websites) { - const stats = await this.umamiService.getStats(website.id, todayStart.getTime(), now); - if (!stats) continue; - - const displayName = DISPLAY_NAMES[website.name] || website.name; - totalViews += stats.pageviews.value; - totalVisitors += stats.visitors.value; - - if (stats.pageviews.value > 0) { - report += `**${displayName}:** ${stats.pageviews.value} Views, ${stats.visitors.value} Besucher\n`; - } - } - - report += `\n**Gesamt:** ${totalViews} Views, ${totalVisitors} Besucher`; - - return report; - } - - async generateWeeklyReport(): Promise { - const now = Date.now(); - const weekAgo = now - 7 * 24 * 60 * 60 * 1000; - - const websites = await this.umamiService.getWebsites(); - if (!websites.length) { - return '❌ Keine Websites konfiguriert.'; - } - - let report = '**📊 Diese Woche**\n\n'; - let totalViews = 0; - let totalVisitors = 0; - - for (const website of websites) { - const stats = await this.umamiService.getStats(website.id, weekAgo, now); - if (!stats) continue; - - const displayName = DISPLAY_NAMES[website.name] || website.name; - totalViews += stats.pageviews.value; - totalVisitors += stats.visitors.value; - - const changeIcon = (change: number) => (change > 0 ? '📈' : change < 0 ? '📉' : '➡️'); - - report += `**${displayName}**\n`; - report += `👁️ ${stats.pageviews.value.toLocaleString()} Views ${changeIcon(stats.pageviews.change)} (${stats.pageviews.change > 0 ? '+' : ''}${stats.pageviews.change}%)\n`; - report += `👥 ${stats.visitors.value.toLocaleString()} Besucher ${changeIcon(stats.visitors.change)}\n\n`; - } - - report += `**Gesamt:** ${totalViews.toLocaleString()} Views, ${totalVisitors.toLocaleString()} Besucher`; - - return report; - } - - async generateRealtimeReport(): Promise { - const websites = await this.umamiService.getWebsites(); - if (!websites.length) { - return '❌ Keine Websites konfiguriert.'; - } - - let report = '**🔴 Realtime**\n\n'; - let totalActive = 0; - - for (const website of websites) { - const realtime = await this.umamiService.getRealtime(website.id); - if (!realtime || realtime.visitors === 0) continue; - - const displayName = DISPLAY_NAMES[website.name] || website.name; - totalActive += realtime.visitors; - - report += `**${displayName}:** ${realtime.visitors} aktiv\n`; - } - - if (totalActive === 0) { - report += 'Keine aktiven Besucher.'; - } else { - report += `\n**Gesamt:** ${totalActive} aktive Besucher`; - } - - return report; - } -} diff --git a/services/matrix-stats-bot/src/app.module.ts b/services/matrix-stats-bot/src/app.module.ts deleted file mode 100644 index b1fd0ee45..000000000 --- a/services/matrix-stats-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { BotModule } from './bot/bot.module'; -import { SchedulerModule } from './scheduler/scheduler.module'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - SchedulerModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-stats-bot')], -}) -export class AppModule {} diff --git a/services/matrix-stats-bot/src/bot/bot.module.ts b/services/matrix-stats-bot/src/bot/bot.module.ts deleted file mode 100644 index 6d8c7ff4a..000000000 --- a/services/matrix-stats-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { AnalyticsModule } from '../analytics/analytics.module'; -import { UsersModule } from '../users/users.module'; -import { InfrastructureModule } from '../infrastructure/infrastructure.module'; -import { MyDataModule } from '../mydata/mydata.module'; -import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - AnalyticsModule, - UsersModule, - InfrastructureModule, - MyDataModule, - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - SessionModule.forRoot({ storageMode: 'redis' }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-stats-bot/src/bot/matrix.service.ts b/services/matrix-stats-bot/src/bot/matrix.service.ts deleted file mode 100644 index 11d89239b..000000000 --- a/services/matrix-stats-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { AnalyticsService } from '../analytics/analytics.service'; -import { UsersService } from '../users/users.service'; -import { InfrastructureService } from '../infrastructure/infrastructure.service'; -import { MyDataService } from '../mydata/mydata.service'; -import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - private reportRoomId: string = ''; - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['stats', 'statistik', 'statistiken', 'meinestats', 'mydata'], command: 'stats' }, - { keywords: ['global', 'alle', 'gesamt', 'uebersicht', 'overview'], command: 'global' }, - { keywords: ['heute', 'today', 'tagesstatistik'], command: 'today' }, - { keywords: ['woche', 'week', 'wochenstatistik'], command: 'week' }, - { keywords: ['realtime', 'live', 'aktive', 'jetzt'], command: 'realtime' }, - { keywords: ['users', 'benutzer', 'nutzer', 'registrierte'], command: 'users' }, - { keywords: ['system', 'server', 'macmini', 'mac'], command: 'system' }, - { keywords: ['services', 'dienste', 'backends', 'health'], command: 'services' }, - { keywords: ['traffic', 'requests', 'http', 'api'], command: 'traffic' }, - { keywords: ['db', 'database', 'datenbank', 'postgres', 'redis'], command: 'db' }, - { keywords: ['growth', 'wachstum', 'registrierungen'], command: 'growth' }, - ]); - - constructor( - configService: ConfigService, - private analyticsService: AnalyticsService, - private usersService: UsersService, - private infrastructureService: InfrastructureService, - private myDataService: MyDataService, - private readonly transcriptionService: TranscriptionService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - this.reportRoomId = this.configService.get('matrix.reportRoomId') || ''; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: this.configService.get('matrix.homeserverUrl') || '', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: [], // No room restrictions - }; - } - - protected async handleTextMessage( - roomId: string, - _event: MatrixRoomEvent, - message: string, - sender: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(message); - if (keywordCommand) { - message = `!${keywordCommand}`; - } - - if (!message.startsWith('!')) return; - - const [command, ...args] = message.slice(1).split(' '); - await this.handleCommand(roomId, command.toLowerCase(), sender, args.join(' ')); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendReply(roomId, event, 'Sprachnachricht konnte nicht erkannt werden.'); - return; - } - - await this.sendMessage(roomId, `*"${text}"*`); - await this.handleTextMessage(roomId, event, text, sender); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply(roomId, event, 'Fehler bei der Spracherkennung.'); - } - } - - private async handleCommand(roomId: string, command: string, sender: string, args: string) { - switch (command) { - case 'help': - case 'start': - await this.sendHelp(roomId); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - case 'stats': - await this.sendMyStats(roomId, sender); - break; - - case 'global': - await this.sendGlobalStats(roomId); - break; - - case 'today': - await this.sendToday(roomId); - break; - - case 'week': - await this.sendWeek(roomId); - break; - - case 'realtime': - await this.sendRealtime(roomId); - break; - - case 'users': - await this.sendUsers(roomId); - break; - - case 'system': - await this.sendSystem(roomId); - break; - - case 'services': - await this.sendServices(roomId); - break; - - case 'traffic': - await this.sendTraffic(roomId); - break; - - case 'db': - await this.sendDatabase(roomId); - break; - - case 'growth': - await this.sendGrowth(roomId); - break; - - default: - await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); - } - } - - private async sendHelp(roomId: string) { - const helpText = `**📊 ManaCore Stats Bot** - -**Deine Stats:** -- \`!stats\` - Deine persönlichen Statistiken -- \`!status\` - Account Status & Credits - -**Globale Analytics (Umami):** -- \`!global\` - Übersicht aller Apps (30 Tage) -- \`!today\` - Heutige Statistiken -- \`!week\` - Wochenstatistiken -- \`!realtime\` - Aktive Besucher jetzt - -**Infrastruktur (Prometheus):** -- \`!system\` - Mac Mini Status (CPU, RAM, Disk) -- \`!services\` - Backend Service Status -- \`!traffic\` - HTTP Traffic & Latenz -- \`!db\` - Datenbank Status -- \`!growth\` - User Wachstum - -**Account:** -- \`!login email passwort\` - Anmelden -- \`!logout\` - Abmelden -- \`!help\` - Diese Hilfe`; - - await this.sendMessage(roomId, helpText); - } - - private async sendGlobalStats(roomId: string) { - await this.sendMessage(roomId, '📊 Lade globale Statistiken...'); - try { - const report = await this.analyticsService.generateStatsOverview(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate stats overview:', error); - await this.sendMessage( - roomId, - `❌ Fehler beim Laden der Statistiken: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendToday(roomId: string) { - await this.sendMessage(roomId, '📊 Lade heutige Statistiken...'); - try { - const report = await this.analyticsService.generateDailyReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate daily report:', error); - await this.sendMessage( - roomId, - `❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendWeek(roomId: string) { - await this.sendMessage(roomId, '📊 Lade Wochenstatistiken...'); - try { - const report = await this.analyticsService.generateWeeklyReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate weekly report:', error); - await this.sendMessage( - roomId, - `❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendRealtime(roomId: string) { - try { - const report = await this.analyticsService.generateRealtimeReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate realtime report:', error); - await this.sendMessage( - roomId, - `❌ Fehler beim Laden: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendUsers(roomId: string) { - const stats = await this.usersService.getUserStats(); - - if (!stats) { - await this.sendMessage(roomId, '❌ Datenbank nicht verfügbar.'); - return; - } - - const report = `**👥 Benutzer-Statistiken** - -**Gesamt:** ${stats.total} Benutzer -**Verifiziert:** ${stats.verified} (${((stats.verified / stats.total) * 100).toFixed(1)}%) - -**Neue Benutzer:** -- Letzte 7 Tage: ${stats.lastWeek} -- Letzte 30 Tage: ${stats.lastMonth}`; - - await this.sendMessage(roomId, report); - } - - private async sendSystem(roomId: string) { - try { - const report = await this.infrastructureService.generateSystemReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate system report:', error); - await this.sendMessage( - roomId, - `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendServices(roomId: string) { - try { - const report = await this.infrastructureService.generateServicesReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate services report:', error); - await this.sendMessage( - roomId, - `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendTraffic(roomId: string) { - try { - const report = await this.infrastructureService.generateTrafficReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate traffic report:', error); - await this.sendMessage( - roomId, - `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendDatabase(roomId: string) { - try { - const report = await this.infrastructureService.generateDatabaseReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate database report:', error); - await this.sendMessage( - roomId, - `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendGrowth(roomId: string) { - try { - const report = await this.infrastructureService.generateGrowthReport(); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to generate growth report:', error); - await this.sendMessage( - roomId, - `❌ Fehler: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async sendMyStats(roomId: string, sender: string) { - const token = await this.sessionService.getToken(sender); - - if (!token) { - await this.sendMessage(roomId, this.myDataService.formatNotLoggedIn()); - return; - } - - try { - await this.sendMessage(roomId, '📊 Lade deine persönlichen Stats...'); - const userData = await this.myDataService.getUserData(token); - - if (!userData) { - await this.sendMessage(roomId, this.myDataService.formatError()); - return; - } - - const report = this.myDataService.formatUserStats(userData); - await this.sendMessage(roomId, report); - } catch (error) { - this.logger.error('Failed to fetch user stats:', error); - await this.sendMessage(roomId, this.myDataService.formatError()); - } - } - - private async handleStatus(roomId: string, sender: string) { - const loggedIn = await this.sessionService.isLoggedIn(sender); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let response = '**📊 Stats Bot Status**\n\n'; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - response += `👤 Angemeldet als: ${session.email}\n`; - response += `⚡ Credits: ${balance.balance.toFixed(2)}\n`; - } else { - response += `❌ Nicht angemeldet\n`; - response += `Nutze \`!login email passwort\` zum Anmelden.`; - } - - await this.sendMessage(roomId, response); - } - - // Public method for scheduled reports - async sendScheduledReport(report: string) { - if (!this.reportRoomId) { - this.logger.warn('No report room configured'); - return; - } - - await this.sendMessage(this.reportRoomId, report); - } -} diff --git a/services/matrix-stats-bot/src/config/configuration.ts b/services/matrix-stats-bot/src/config/configuration.ts deleted file mode 100644 index 0be0e33fc..000000000 --- a/services/matrix-stats-bot/src/config/configuration.ts +++ /dev/null @@ -1,42 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3312', 10), - timezone: process.env.TZ || 'Europe/Berlin', - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN || '', - reportRoomId: process.env.MATRIX_REPORT_ROOM_ID || '', - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - umami: { - apiUrl: process.env.UMAMI_API_URL || 'http://localhost:3000', - username: process.env.UMAMI_USERNAME || 'admin', - password: process.env.UMAMI_PASSWORD || '', - }, - database: { - url: process.env.DATABASE_URL || '', - }, - prometheus: { - url: process.env.PROMETHEUS_URL || 'http://localhost:9090', - }, -}); - -// Website IDs from Umami - update these with actual UUIDs -export const WEBSITE_IDS: Record = { - 'manacore-webapp': process.env.UMAMI_WEBSITE_MANACORE || '', - 'chat-webapp': process.env.UMAMI_WEBSITE_CHAT || '', - 'todo-webapp': process.env.UMAMI_WEBSITE_TODO || '', - 'calendar-webapp': process.env.UMAMI_WEBSITE_CALENDAR || '', - 'clock-webapp': process.env.UMAMI_WEBSITE_CLOCK || '', - 'contacts-webapp': process.env.UMAMI_WEBSITE_CONTACTS || '', - 'storage-webapp': process.env.UMAMI_WEBSITE_STORAGE || '', -}; - -export const DISPLAY_NAMES: Record = { - 'manacore-webapp': 'Dashboard', - 'chat-webapp': 'Chat', - 'todo-webapp': 'Todo', - 'calendar-webapp': 'Calendar', - 'clock-webapp': 'Clock', - 'contacts-webapp': 'Contacts', - 'storage-webapp': 'Storage', -}; diff --git a/services/matrix-stats-bot/src/infrastructure/infrastructure.module.ts b/services/matrix-stats-bot/src/infrastructure/infrastructure.module.ts deleted file mode 100644 index 9b4e2ef62..000000000 --- a/services/matrix-stats-bot/src/infrastructure/infrastructure.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrometheusModule } from '../prometheus/prometheus.module'; -import { InfrastructureService } from './infrastructure.service'; - -@Module({ - imports: [PrometheusModule], - providers: [InfrastructureService], - exports: [InfrastructureService], -}) -export class InfrastructureModule {} diff --git a/services/matrix-stats-bot/src/infrastructure/infrastructure.service.ts b/services/matrix-stats-bot/src/infrastructure/infrastructure.service.ts deleted file mode 100644 index d0f976b92..000000000 --- a/services/matrix-stats-bot/src/infrastructure/infrastructure.service.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrometheusService } from '../prometheus/prometheus.service'; - -@Injectable() -export class InfrastructureService { - private readonly logger = new Logger(InfrastructureService.name); - - constructor(private readonly prometheus: PrometheusService) {} - - async generateSystemReport(): Promise { - const [cpu, memory, disk, uptime, load] = await Promise.all([ - this.prometheus.getValue('100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'), - this.prometheus.getValue( - '100 * (1 - ((node_memory_free_bytes + node_memory_cached_bytes + node_memory_buffers_bytes) / node_memory_total_bytes))' - ), - this.prometheus.getValue( - '100 - ((node_filesystem_avail_bytes{mountpoint="/",fstype!="rootfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="rootfs"}) * 100)' - ), - this.prometheus.getValue('time() - node_boot_time_seconds'), - this.prometheus.getValue('node_load1'), - ]); - - if (cpu === null && memory === null) { - return '❌ Keine System-Metriken verfügbar. Node Exporter nicht erreichbar.'; - } - - const formatUptime = (seconds: number): string => { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const mins = Math.floor((seconds % 3600) / 60); - if (days > 0) return `${days}d ${hours}h`; - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; - }; - - const getStatusIcon = (value: number, warn: number, crit: number): string => { - if (value >= crit) return '🔴'; - if (value >= warn) return '🟡'; - return '🟢'; - }; - - let report = '**🖥️ Mac Mini System Status**\n\n'; - report += `${getStatusIcon(cpu || 0, 70, 85)} **CPU:** ${cpu?.toFixed(1) || '?'}%\n`; - report += `${getStatusIcon(memory || 0, 70, 85)} **Memory:** ${memory?.toFixed(1) || '?'}%\n`; - report += `${getStatusIcon(disk || 0, 70, 85)} **Disk:** ${disk?.toFixed(1) || '?'}%\n`; - report += `⏱️ **Uptime:** ${uptime ? formatUptime(uptime) : '?'}\n`; - report += `📊 **Load (1m):** ${load?.toFixed(2) || '?'}`; - - return report; - } - - async generateServicesReport(): Promise { - const services = [ - { job: 'mana-core-auth', name: 'Auth' }, - { job: 'chat-backend', name: 'Chat' }, - { job: 'todo-backend', name: 'Todo' }, - { job: 'calendar-backend', name: 'Calendar' }, - { job: 'clock-backend', name: 'Clock' }, - { job: 'contacts-backend', name: 'Contacts' }, - { job: 'zitare-backend', name: 'Zitare' }, - { job: 'picture-backend', name: 'Picture' }, - ]; - - const results = await this.prometheus.query('up'); - const statusMap = new Map(); - for (const result of results) { - statusMap.set(result.metric.job, parseFloat(result.value[1])); - } - - // Also check infrastructure - const pgUp = await this.prometheus.getValue('pg_up'); - const redisUp = await this.prometheus.getValue('redis_up'); - - let report = '**🔧 Service Status**\n\n'; - let allUp = true; - - for (const service of services) { - const status = statusMap.get(service.job); - if (status === 1) { - report += `🟢 ${service.name}\n`; - } else if (status === 0) { - report += `🔴 ${service.name}\n`; - allUp = false; - } else { - report += `⚪ ${service.name} (nicht konfiguriert)\n`; - } - } - - report += '\n**Infrastruktur:**\n'; - report += pgUp === 1 ? '🟢 PostgreSQL\n' : '🔴 PostgreSQL\n'; - report += redisUp === 1 ? '🟢 Redis' : '🔴 Redis'; - - if (allUp && pgUp === 1 && redisUp === 1) { - report = - '**🔧 Service Status**\n\n✅ Alle Services online!\n\n' + - report.split('\n\n').slice(1).join('\n\n'); - } - - return report; - } - - async generateTrafficReport(): Promise { - const [requestRates, errorRates, p95Latency] = await Promise.all([ - this.prometheus.query('sum(rate(http_requests_total[5m])) by (job)'), - this.prometheus.query('sum(rate(http_requests_total{status=~"5.."}[5m])) by (job)'), - this.prometheus.query( - 'histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))' - ), - ]); - - if (requestRates.length === 0) { - return '❌ Keine Traffic-Metriken verfügbar.'; - } - - const rateMap = new Map(); - const errorMap = new Map(); - const latencyMap = new Map(); - - for (const r of requestRates) { - rateMap.set(r.metric.job, parseFloat(r.value[1])); - } - for (const r of errorRates) { - errorMap.set(r.metric.job, parseFloat(r.value[1])); - } - for (const r of p95Latency) { - latencyMap.set(r.metric.job, parseFloat(r.value[1])); - } - - const totalRate = Array.from(rateMap.values()).reduce((a, b) => a + b, 0); - const totalErrors = Array.from(errorMap.values()).reduce((a, b) => a + b, 0); - - let report = '**📈 HTTP Traffic**\n\n'; - report += `**Gesamt:** ${totalRate.toFixed(2)} req/s\n`; - report += `**5xx Errors:** ${totalErrors.toFixed(3)} req/s\n\n`; - - const serviceNames: Record = { - 'mana-core-auth': 'Auth', - 'chat-backend': 'Chat', - 'todo-backend': 'Todo', - 'calendar-backend': 'Calendar', - 'clock-backend': 'Clock', - 'contacts-backend': 'Contacts', - }; - - for (const [job, rate] of rateMap.entries()) { - if (rate < 0.001) continue; - const name = serviceNames[job] || job; - const latency = latencyMap.get(job); - const latencyStr = latency ? `${(latency * 1000).toFixed(0)}ms` : '?'; - report += `**${name}:** ${rate.toFixed(2)} req/s (p95: ${latencyStr})\n`; - } - - return report; - } - - async generateDatabaseReport(): Promise { - const [pgConnections, dbSizes, redisMemory, redisClients] = await Promise.all([ - this.prometheus.getValue('sum(pg_stat_activity_count)'), - this.prometheus.query('pg_database_size_bytes{datname!~"template.*|postgres"}'), - this.prometheus.getValue('redis_memory_used_bytes'), - this.prometheus.getValue('redis_connected_clients'), - ]); - - if (pgConnections === null && redisMemory === null) { - return '❌ Keine Datenbank-Metriken verfügbar.'; - } - - const formatBytes = (bytes: number): string => { - if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${bytes} B`; - }; - - let report = '**🗄️ Datenbank Status**\n\n'; - report += '**PostgreSQL:**\n'; - report += `- Connections: ${pgConnections || '?'}\n`; - - if (dbSizes.length > 0) { - const sortedDbs = dbSizes - .map((r) => ({ name: r.metric.datname, size: parseFloat(r.value[1]) })) - .sort((a, b) => b.size - a.size) - .slice(0, 5); - - for (const db of sortedDbs) { - report += `- ${db.name}: ${formatBytes(db.size)}\n`; - } - } - - report += '\n**Redis:**\n'; - report += `- Memory: ${redisMemory ? formatBytes(redisMemory) : '?'}\n`; - report += `- Clients: ${redisClients || '?'}`; - - return report; - } - - async generateGrowthReport(): Promise { - const [total, verified, today, week, month] = await Promise.all([ - this.prometheus.getValue('auth_users_total'), - this.prometheus.getValue('auth_users_verified'), - this.prometheus.getValue('auth_users_created_today'), - this.prometheus.getValue('auth_users_created_this_week'), - this.prometheus.getValue('auth_users_created_this_month'), - ]); - - if (total === null) { - return '❌ Keine User-Metriken verfügbar. Auth Service nicht erreichbar.'; - } - - const verificationRate = total && verified ? ((verified / total) * 100).toFixed(1) : '?'; - - let report = '**📈 User Growth**\n\n'; - report += `**Gesamt:** ${total?.toLocaleString() || '?'} User\n`; - report += `**Verifiziert:** ${verified?.toLocaleString() || '?'} (${verificationRate}%)\n\n`; - report += '**Neue Registrierungen:**\n'; - report += `- Heute: ${today || 0}\n`; - report += `- Diese Woche: ${week || 0}\n`; - report += `- Dieser Monat: ${month || 0}`; - - return report; - } -} diff --git a/services/matrix-stats-bot/src/main.ts b/services/matrix-stats-bot/src/main.ts deleted file mode 100644 index 4751306f3..000000000 --- a/services/matrix-stats-bot/src/main.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - const port = process.env.PORT || 3312; - await app.listen(port); - - logger.log(`Matrix Stats Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} -bootstrap(); diff --git a/services/matrix-stats-bot/src/mydata/mydata.module.ts b/services/matrix-stats-bot/src/mydata/mydata.module.ts deleted file mode 100644 index d30d60536..000000000 --- a/services/matrix-stats-bot/src/mydata/mydata.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { MyDataService } from './mydata.service'; - -@Module({ - imports: [ConfigModule], - providers: [MyDataService], - exports: [MyDataService], -}) -export class MyDataModule {} diff --git a/services/matrix-stats-bot/src/mydata/mydata.service.ts b/services/matrix-stats-bot/src/mydata/mydata.service.ts deleted file mode 100644 index 065f50f1e..000000000 --- a/services/matrix-stats-bot/src/mydata/mydata.service.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -interface EntityCount { - entity: string; - count: number; - label: string; -} - -interface ProjectDataSummary { - projectId: string; - projectName: string; - icon: string; - available: boolean; - error?: string; - entities: EntityCount[]; - totalCount: number; - lastActivityAt?: string; -} - -interface UserDataSummary { - user: { - id: string; - email: string; - name: string; - role: string; - createdAt: string; - emailVerified: boolean; - }; - auth: { - sessionsCount: number; - accountsCount: number; - has2FA: boolean; - lastLoginAt: string | null; - }; - credits: { - balance: number; - totalEarned: number; - totalSpent: number; - transactionsCount: number; - }; - projects: ProjectDataSummary[]; - totals: { - totalEntities: number; - projectsWithData: number; - }; -} - -@Injectable() -export class MyDataService { - private readonly logger = new Logger(MyDataService.name); - private readonly authUrl: string; - - constructor(private configService: ConfigService) { - this.authUrl = this.configService.get('MANA_CORE_AUTH_URL') || 'http://localhost:3001'; - } - - async getUserData(token: string): Promise { - try { - const response = await fetch(`${this.authUrl}/api/v1/me/data`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - this.logger.error(`Failed to fetch user data: ${response.status}`); - return null; - } - - return (await response.json()) as UserDataSummary; - } catch (error) { - this.logger.error(`Error fetching user data: ${error}`); - return null; - } - } - - formatUserStats(data: UserDataSummary): string { - const formatDate = (dateStr: string): string => { - const date = new Date(dateStr); - return date.toLocaleDateString('de-DE', { - day: 'numeric', - month: 'long', - year: 'numeric', - }); - }; - - const formatNumber = (num: number): string => { - return num.toLocaleString('de-DE'); - }; - - let report = '**📊 Deine ManaCore Stats**\n\n'; - - // Account info - report += '**👤 Account**\n'; - report += `- Email: ${data.user.email}\n`; - report += `- Name: ${data.user.name || 'Nicht angegeben'}\n`; - report += `- Mitglied seit: ${formatDate(data.user.createdAt)}\n`; - report += `- Email verifiziert: ${data.user.emailVerified ? '✅' : '❌'}\n`; - if (data.auth.has2FA) { - report += `- 2FA: ✅ Aktiv\n`; - } - report += '\n'; - - // Credits - report += '**⚡ Credits**\n'; - report += `- Guthaben: ${data.credits.balance.toFixed(2)}\n`; - report += `- Verdient: ${data.credits.totalEarned.toFixed(2)}\n`; - report += `- Ausgegeben: ${data.credits.totalSpent.toFixed(2)}\n`; - report += `- Transaktionen: ${formatNumber(data.credits.transactionsCount)}\n`; - report += '\n'; - - // Projects with data - const projectsWithData = data.projects.filter((p) => p.available && p.totalCount > 0); - - if (projectsWithData.length > 0) { - report += '**📱 Deine Nutzung**\n'; - - for (const project of projectsWithData) { - const entitySummary = project.entities - .filter((e) => e.count > 0) - .map((e) => `${formatNumber(e.count)} ${e.label}`) - .join(', '); - - report += `${project.icon} **${project.projectName}:** ${entitySummary}\n`; - } - report += '\n'; - } - - // Summary - report += '**📈 Gesamt**\n'; - report += `- Datenpunkte: ${formatNumber(data.totals.totalEntities)}\n`; - report += `- Aktive Apps: ${data.totals.projectsWithData}/${data.projects.length}\n`; - - // Last activity - const lastActivities = projectsWithData - .filter((p) => p.lastActivityAt) - .map((p) => ({ name: p.projectName, date: new Date(p.lastActivityAt!) })) - .sort((a, b) => b.date.getTime() - a.date.getTime()); - - if (lastActivities.length > 0) { - const latest = lastActivities[0]; - report += `- Letzte Aktivität: ${latest.name} (${formatDate(latest.date.toISOString())})`; - } - - return report; - } - - formatNotLoggedIn(): string { - return `**❌ Nicht angemeldet** - -Um deine persönlichen Stats zu sehen, melde dich an: - -\`!login deine@email.de deinpasswort\` - -Nach der Anmeldung kannst du mit \`!mystats\` deine Daten abrufen.`; - } - - formatError(): string { - return `**❌ Fehler beim Laden** - -Deine Stats konnten nicht abgerufen werden. Bitte versuche es später erneut. - -Falls das Problem weiterhin besteht, melde dich neu an: -\`!logout\` und dann \`!login\``; - } -} diff --git a/services/matrix-stats-bot/src/prometheus/prometheus.module.ts b/services/matrix-stats-bot/src/prometheus/prometheus.module.ts deleted file mode 100644 index 179374101..000000000 --- a/services/matrix-stats-bot/src/prometheus/prometheus.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrometheusService } from './prometheus.service'; - -@Module({ - providers: [PrometheusService], - exports: [PrometheusService], -}) -export class PrometheusModule {} diff --git a/services/matrix-stats-bot/src/prometheus/prometheus.service.ts b/services/matrix-stats-bot/src/prometheus/prometheus.service.ts deleted file mode 100644 index 0d823054c..000000000 --- a/services/matrix-stats-bot/src/prometheus/prometheus.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -interface PrometheusResult { - metric: Record; - value: [number, string]; -} - -interface PrometheusResponse { - status: string; - data: { - resultType: string; - result: PrometheusResult[]; - }; -} - -@Injectable() -export class PrometheusService { - private readonly logger = new Logger(PrometheusService.name); - private readonly baseUrl: string; - - constructor(private configService: ConfigService) { - this.baseUrl = this.configService.get('prometheus.url') || 'http://localhost:9090'; - } - - async query(promql: string): Promise { - try { - const url = `${this.baseUrl}/api/v1/query?query=${encodeURIComponent(promql)}`; - const response = await fetch(url); - - if (!response.ok) { - this.logger.error(`Prometheus query failed: ${response.status}`); - return []; - } - - const data = (await response.json()) as PrometheusResponse; - if (data.status !== 'success') { - this.logger.error(`Prometheus query error: ${data.status}`); - return []; - } - - return data.data.result; - } catch (error) { - this.logger.error(`Prometheus query error: ${error}`); - return []; - } - } - - async getValue(promql: string): Promise { - const results = await this.query(promql); - if (results.length === 0) return null; - return parseFloat(results[0].value[1]); - } - - async getValues(promql: string): Promise> { - const results = await this.query(promql); - const values = new Map(); - for (const result of results) { - const label = - result.metric.job || result.metric.datname || result.metric.instance || 'unknown'; - values.set(label, parseFloat(result.value[1])); - } - return values; - } -} diff --git a/services/matrix-stats-bot/src/scheduler/report.scheduler.ts b/services/matrix-stats-bot/src/scheduler/report.scheduler.ts deleted file mode 100644 index b193fd0a0..000000000 --- a/services/matrix-stats-bot/src/scheduler/report.scheduler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { MatrixService } from '../bot/matrix.service'; -import { AnalyticsService } from '../analytics/analytics.service'; - -@Injectable() -export class ReportScheduler { - private readonly logger = new Logger(ReportScheduler.name); - - constructor( - private readonly matrixService: MatrixService, - private readonly analyticsService: AnalyticsService - ) {} - - // Daily report at 9:00 AM Berlin time - @Cron('0 9 * * *', { timeZone: 'Europe/Berlin' }) - async sendDailyReport() { - this.logger.log('Sending daily report...'); - const report = await this.analyticsService.generateDailyReport(); - await this.matrixService.sendScheduledReport(`📅 **Täglicher Report**\n\n${report}`); - } - - // Weekly report on Monday at 9:00 AM Berlin time - @Cron('0 9 * * 1', { timeZone: 'Europe/Berlin' }) - async sendWeeklyReport() { - this.logger.log('Sending weekly report...'); - const report = await this.analyticsService.generateWeeklyReport(); - await this.matrixService.sendScheduledReport(`📅 **Wöchentlicher Report**\n\n${report}`); - } -} diff --git a/services/matrix-stats-bot/src/scheduler/scheduler.module.ts b/services/matrix-stats-bot/src/scheduler/scheduler.module.ts deleted file mode 100644 index 51a1ad147..000000000 --- a/services/matrix-stats-bot/src/scheduler/scheduler.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ReportScheduler } from './report.scheduler'; -import { BotModule } from '../bot/bot.module'; -import { AnalyticsModule } from '../analytics/analytics.module'; - -@Module({ - imports: [ScheduleModule.forRoot(), BotModule, AnalyticsModule], - providers: [ReportScheduler], -}) -export class SchedulerModule {} diff --git a/services/matrix-stats-bot/src/umami/umami.module.ts b/services/matrix-stats-bot/src/umami/umami.module.ts deleted file mode 100644 index b9bb7b2bd..000000000 --- a/services/matrix-stats-bot/src/umami/umami.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UmamiService } from './umami.service'; - -@Module({ - providers: [UmamiService], - exports: [UmamiService], -}) -export class UmamiModule {} diff --git a/services/matrix-stats-bot/src/umami/umami.service.ts b/services/matrix-stats-bot/src/umami/umami.service.ts deleted file mode 100644 index 30771c622..000000000 --- a/services/matrix-stats-bot/src/umami/umami.service.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -interface UmamiStats { - pageviews: { value: number; change: number }; - visitors: { value: number; change: number }; - visits: { value: number; change: number }; - bounces: { value: number; change: number }; - totaltime: { value: number; change: number }; -} - -// Raw API response format from Umami -interface UmamiStatsRaw { - pageviews: number; - visitors: number; - visits: number; - bounces: number; - totaltime: number; - comparison: { - pageviews: number; - visitors: number; - visits: number; - bounces: number; - totaltime: number; - }; -} - -interface UmamiRealtime { - pageviews: number; - visitors: number; - countries: { name: string; count: number }[]; -} - -@Injectable() -export class UmamiService implements OnModuleInit { - private readonly logger = new Logger(UmamiService.name); - private readonly apiUrl: string; - private readonly username: string; - private readonly password: string; - private accessToken: string | null = null; - - constructor(private configService: ConfigService) { - this.apiUrl = this.configService.get('umami.apiUrl') || 'http://localhost:3000'; - this.username = this.configService.get('umami.username') || 'admin'; - this.password = this.configService.get('umami.password') || ''; - } - - async onModuleInit() { - try { - await this.authenticate(); - } catch (error) { - this.logger.warn('Initial Umami auth failed, will retry on first request'); - } - } - - private async authenticate(): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${this.apiUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: this.username, - password: this.password, - }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`Umami auth failed: ${response.status}`); - } - - const data = await response.json(); - this.accessToken = data.token; - this.logger.log('Umami authenticated successfully'); - } catch (error) { - this.logger.error('Failed to authenticate with Umami:', error); - this.accessToken = null; - throw error instanceof Error ? error : new Error('Umami authentication failed'); - } - } - - private async request(endpoint: string, retryCount = 0): Promise { - if (!this.accessToken) { - await this.authenticate(); - } - - if (!this.accessToken) { - throw new Error('Umami nicht authentifiziert - prüfe UMAMI_API_URL und Credentials'); - } - - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout - - const response = await fetch(`${this.apiUrl}${endpoint}`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (response.status === 401 && retryCount < 1) { - this.accessToken = null; - await this.authenticate(); - return this.request(endpoint, retryCount + 1); - } - - if (!response.ok) { - throw new Error(`Umami API Fehler: ${response.status}`); - } - - return response.json(); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - this.logger.error(`Umami request timeout: ${endpoint}`); - throw new Error('Umami API Timeout - Server nicht erreichbar?'); - } - this.logger.error(`Umami request failed: ${endpoint}`, error); - throw error; - } - } - - async getWebsites(): Promise<{ id: string; name: string; domain: string }[]> { - const data = await this.request<{ data: { id: string; name: string; domain: string }[] }>( - '/api/websites' - ); - return data?.data || []; - } - - async getStats(websiteId: string, startAt: number, endAt: number): Promise { - const raw = await this.request( - `/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}` - ); - - if (!raw) return null; - - // Transform raw API response to expected format - const calcChange = (current: number, previous: number): number => { - if (previous === 0) return current > 0 ? 100 : 0; - return Math.round(((current - previous) / previous) * 100); - }; - - return { - pageviews: { - value: raw.pageviews, - change: calcChange(raw.pageviews, raw.comparison?.pageviews ?? 0), - }, - visitors: { - value: raw.visitors, - change: calcChange(raw.visitors, raw.comparison?.visitors ?? 0), - }, - visits: { - value: raw.visits, - change: calcChange(raw.visits, raw.comparison?.visits ?? 0), - }, - bounces: { - value: raw.bounces, - change: calcChange(raw.bounces, raw.comparison?.bounces ?? 0), - }, - totaltime: { - value: raw.totaltime, - change: calcChange(raw.totaltime, raw.comparison?.totaltime ?? 0), - }, - }; - } - - async getRealtime(websiteId: string): Promise { - return this.request(`/api/websites/${websiteId}/active`); - } - - async getPageviews( - websiteId: string, - startAt: number, - endAt: number, - unit: 'hour' | 'day' | 'month' = 'day' - ): Promise<{ - pageviews: { x: string; y: number }[]; - sessions: { x: string; y: number }[]; - } | null> { - return this.request( - `/api/websites/${websiteId}/pageviews?startAt=${startAt}&endAt=${endAt}&unit=${unit}` - ); - } -} diff --git a/services/matrix-stats-bot/src/users/users.module.ts b/services/matrix-stats-bot/src/users/users.module.ts deleted file mode 100644 index 00ef465ea..000000000 --- a/services/matrix-stats-bot/src/users/users.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UsersService } from './users.service'; - -@Module({ - providers: [UsersService], - exports: [UsersService], -}) -export class UsersModule {} diff --git a/services/matrix-stats-bot/src/users/users.service.ts b/services/matrix-stats-bot/src/users/users.service.ts deleted file mode 100644 index 5797389af..000000000 --- a/services/matrix-stats-bot/src/users/users.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import postgres from 'postgres'; - -interface UserStats { - total: number; - verified: number; - lastWeek: number; - lastMonth: number; -} - -@Injectable() -export class UsersService implements OnModuleInit { - private readonly logger = new Logger(UsersService.name); - private sql: postgres.Sql | null = null; - - constructor(private configService: ConfigService) {} - - async onModuleInit() { - const databaseUrl = this.configService.get('database.url'); - if (databaseUrl) { - try { - this.sql = postgres(databaseUrl); - this.logger.log('Database connected for user stats'); - } catch (error) { - this.logger.warn('Failed to connect to database:', error); - } - } else { - this.logger.warn('DATABASE_URL not configured - user stats disabled'); - } - } - - async getUserStats(): Promise { - if (!this.sql) { - return null; - } - - try { - const [totalResult] = await this.sql`SELECT COUNT(*) as count FROM "user"`; - const [verifiedResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "emailVerified" = true`; - const [weekResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "createdAt" > NOW() - INTERVAL '7 days'`; - const [monthResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "createdAt" > NOW() - INTERVAL '30 days'`; - - return { - total: parseInt(totalResult.count, 10), - verified: parseInt(verifiedResult.count, 10), - lastWeek: parseInt(weekResult.count, 10), - lastMonth: parseInt(monthResult.count, 10), - }; - } catch (error) { - this.logger.error('Failed to get user stats:', error); - return null; - } - } -} diff --git a/services/matrix-stats-bot/tsconfig.json b/services/matrix-stats-bot/tsconfig.json deleted file mode 100644 index b439390d0..000000000 --- a/services/matrix-stats-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } -} diff --git a/services/matrix-storage-bot/.env.example b/services/matrix-storage-bot/.env.example deleted file mode 100644 index b2be60948..000000000 --- a/services/matrix-storage-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3323 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#storage:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Storage Backend -STORAGE_BACKEND_URL=http://localhost:3016 -STORAGE_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-storage-bot/.gitignore b/services/matrix-storage-bot/.gitignore deleted file mode 100644 index 2d508e5ec..000000000 --- a/services/matrix-storage-bot/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment -.env -.env.local - -# Data -data/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# TypeScript -*.tsbuildinfo diff --git a/services/matrix-storage-bot/CLAUDE.md b/services/matrix-storage-bot/CLAUDE.md deleted file mode 100644 index d387c0818..000000000 --- a/services/matrix-storage-bot/CLAUDE.md +++ /dev/null @@ -1,225 +0,0 @@ -# Matrix Storage Bot - Claude Code Guidelines - -## Overview - -Matrix Storage Bot provides cloud storage management via Matrix chat. It integrates with the Storage backend for file/folder management, sharing, favorites, search, and trash operations. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Storage API (port 3016) -- **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-storage-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3323) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help messages -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & command handlers -│ ├── storage/ -│ │ ├── storage.module.ts -│ │ └── storage.service.ts # Storage Backend API client -│ └── session/ -│ ├── session.module.ts -│ └── session.service.ts # User session & auth management -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!help` | hilfe | Show help message | -| `!login email pass` | - | Login | -| `!logout` | - | Logout | -| `!status` | - | Bot status | - -### File Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!dateien` | files, ls | List files in root | -| `!dateien [ordner-nr]` | - | List files in folder | -| `!datei [nr]` | file, info | Show file details | -| `!download [nr]` | dl | Get download link | -| `!loeschen [nr]` | delete, rm | Move file to trash | -| `!umbenennen [nr] name` | rename, mv | Rename file | -| `!verschieben [nr] [ordner-nr]` | move | Move to folder | - -### Folder Management - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!ordner` | folders, dir | List root folders | -| `!ordner [nr]` | - | List subfolders | -| `!neuordner Name` | mkdir, newfolder | Create folder | -| `!neuordner Name [in-nr]` | - | Create subfolder | -| `!ordnerloeschen [nr]` | rmdir | Delete folder | - -### Sharing - -| Command | Options | Description | -|---------|---------|-------------| -| `!teilen [nr]` | share | Share file (create link) | -| `--tage N` | - | Expire in N days | -| `--passwort abc` | - | Password protect | -| `--downloads N` | - | Limit downloads | -| `!links` | shares | List share links | -| `!linkloeschen [nr]` | unshare | Delete share link | - -### Organization - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!suche Begriff` | search, find | Search files/folders | -| `!favoriten` | favorites, favs | Show favorites | -| `!fav [nr]` | favorit | Toggle favorite | - -### Trash - -| Command | Aliases | Description | -|---------|---------|-------------| -| `!papierkorb` | trash | Show trash | -| `!wiederherstellen [nr]` | restore | Restore from trash | -| `!leeren` | emptytrash | Empty trash | - -## Example Usage - -``` -# Login -!login max@example.com mypassword - -# List files -!dateien - -# Create a folder -!neuordner Dokumente - -# List folders -!ordner - -# Move file to folder -!verschieben 1 1 - -# Share a file with expiration -!teilen 1 --tage 7 --passwort geheim - -# Search for files -!suche bericht - -# View favorites -!favoriten - -# Toggle favorite -!fav 1 - -# View trash -!papierkorb - -# Restore from trash -!wiederherstellen 1 -``` - -## Environment Variables - -```env -# Server -PORT=3323 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#storage:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Storage Backend -STORAGE_BACKEND_URL=http://localhost:3016 -STORAGE_API_PREFIX=/api/v1 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-storage-bot/Dockerfile -t matrix-storage-bot services/matrix-storage-bot - -# Run -docker run -p 3323:3323 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e STORAGE_BACKEND_URL=http://storage-backend:3016 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-storage-bot-data:/app/data \ - matrix-storage-bot -``` - -## Health Check - -```bash -curl http://localhost:3323/health -``` - -## Storage Backend API Endpoints Used - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/health` | GET | Health check | -| `/api/v1/files` | GET | List files | -| `/api/v1/files/:id` | GET | Get file details | -| `/api/v1/files/:id/download` | GET | Get download URL | -| `/api/v1/files/:id` | PATCH | Rename file | -| `/api/v1/files/:id/move` | PATCH | Move file | -| `/api/v1/files/:id` | DELETE | Delete file | -| `/api/v1/files/:id/favorite` | POST | Toggle favorite | -| `/api/v1/folders` | GET | List folders | -| `/api/v1/folders` | POST | Create folder | -| `/api/v1/folders/:id` | DELETE | Delete folder | -| `/api/v1/folders/:id/favorite` | POST | Toggle favorite | -| `/api/v1/shares` | GET | List shares | -| `/api/v1/shares` | POST | Create share | -| `/api/v1/shares/:id` | DELETE | Delete share | -| `/api/v1/search` | GET | Search files/folders | -| `/api/v1/favorites` | GET | Get favorites | -| `/api/v1/trash` | GET | List trash | -| `/api/v1/trash/:id/restore` | POST | Restore from trash | -| `/api/v1/trash` | DELETE | Empty trash | - -## Number-Based Reference System - -The bot uses a number-based reference system for ease of use: -1. User runs `!dateien` or `!ordner` to get a list -2. Bot stores the list internally for the user -3. User can reference items by their list number -4. Numbers are valid until the user runs a new list command - -This allows simple commands like: -- `!datei 3` - Show details for file #3 -- `!download 1` - Get download link for file #1 -- `!dateien 2` - List files in folder #2 -- `!verschieben 1 3` - Move file #1 to folder #3 diff --git a/services/matrix-storage-bot/Dockerfile b/services/matrix-storage-bot/Dockerfile deleted file mode 100644 index 92d866e9b..000000000 --- a/services/matrix-storage-bot/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# 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 3323 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3323/health || exit 1 - -# Start the application -CMD ["node", "dist/main.js"] diff --git a/services/matrix-storage-bot/nest-cli.json b/services/matrix-storage-bot/nest-cli.json deleted file mode 100644 index 5c06bb8c3..000000000 --- a/services/matrix-storage-bot/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json-schema.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/services/matrix-storage-bot/package.json b/services/matrix-storage-bot/package.json deleted file mode 100644 index 29475941d..000000000 --- a/services/matrix-storage-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@mana-bots/matrix-storage-bot", - "version": "1.0.0", - "description": "Matrix bot for cloud storage management", - "private": true, - "main": "dist/main.js", - "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.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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-storage-bot/src/app.module.ts b/services/matrix-storage-bot/src/app.module.ts deleted file mode 100644 index 00ee26893..000000000 --- a/services/matrix-storage-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { StorageModule } from './storage/storage.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - StorageModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-storage-bot')], -}) -export class AppModule {} diff --git a/services/matrix-storage-bot/src/bot/bot.module.ts b/services/matrix-storage-bot/src/bot/bot.module.ts deleted file mode 100644 index 3fe9da9e6..000000000 --- a/services/matrix-storage-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { StorageModule } from '../storage/storage.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - StorageModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - CreditModule.forRoot(), - ], - providers: [MatrixService], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-storage-bot/src/bot/matrix.service.ts b/services/matrix-storage-bot/src/bot/matrix.service.ts deleted file mode 100644 index b76a07e01..000000000 --- a/services/matrix-storage-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,890 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - UserListMapper, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { - StorageService, - StorageFile, - Folder, - ShareLink, - TrashItem, -} from '../storage/storage.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} 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 filesMapper = new UserListMapper(); - private foldersMapper = new UserListMapper(); - private sharesMapper = new UserListMapper(); - private trashMapper = new UserListMapper(); - private currentFolder: Map = new Map(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['dateien', 'files', 'meine dateien', 'liste'], command: 'dateien' }, - { keywords: ['ordner', 'folders', 'verzeichnisse', 'dirs'], command: 'ordner' }, - { keywords: ['teilen', 'share', 'freigeben', 'link erstellen'], command: 'teilen' }, - { keywords: ['suche', 'search', 'finde', 'durchsuchen'], command: 'suche' }, - { keywords: ['favoriten', 'favorites', 'favs', 'gemerkte'], command: 'favoriten' }, - { keywords: ['papierkorb', 'trash', 'geloeschte', 'muell'], command: 'papierkorb' }, - { keywords: ['links', 'shares', 'freigaben', 'geteilte'], command: 'links' }, - ]); - - constructor( - configService: ConfigService, - private storageService: StorageService, - private sessionService: SessionService, - private readonly transcriptionService: TranscriptionService, - private creditService: CreditService - ) { - super(configService); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - if (!body.startsWith('!')) return; - - const sender = event.sender; - const parts = body.slice(1).split(/\s+/); - const command = parts[0].toLowerCase(); - const args = parts.slice(1); - const argString = args.join(' '); - - try { - switch (command) { - case 'help': - case 'hilfe': - await this.sendMessage(roomId, HELP_MESSAGE); - break; - - case 'status': - await this.handleStatus(roomId, sender); - break; - - // File commands - case 'dateien': - case 'files': - case 'ls': - await this.handleListFiles(roomId, sender, args[0]); - break; - - case 'datei': - case 'file': - case 'info': - await this.handleFileDetails(roomId, sender, args[0]); - break; - - case 'download': - case 'dl': - await this.handleDownload(roomId, sender, args[0]); - break; - - case 'loeschen': - case 'delete': - case 'rm': - await this.handleDeleteFile(roomId, sender, args[0]); - break; - - case 'umbenennen': - case 'rename': - case 'mv': - await this.handleRenameFile(roomId, sender, args[0], args.slice(1).join(' ')); - break; - - case 'verschieben': - case 'move': - await this.handleMoveFile(roomId, sender, args[0], args[1]); - break; - - // Folder commands - case 'ordner': - case 'folders': - case 'dir': - await this.handleListFolders(roomId, sender, args[0]); - break; - - case 'neuordner': - case 'mkdir': - case 'newfolder': - await this.handleCreateFolder(roomId, sender, args); - break; - - case 'ordnerloeschen': - case 'rmdir': - await this.handleDeleteFolder(roomId, sender, args[0]); - break; - - // Share commands - case 'teilen': - case 'share': - await this.handleShareFile(roomId, sender, argString); - break; - - case 'links': - case 'shares': - await this.handleListShares(roomId, sender); - break; - - case 'linkloeschen': - case 'unshare': - await this.handleDeleteShare(roomId, sender, args[0]); - break; - - // Organization - case 'suche': - case 'search': - case 'find': - await this.handleSearch(roomId, sender, argString); - break; - - case 'favoriten': - case 'favorites': - case 'favs': - await this.handleFavorites(roomId, sender); - break; - - case 'fav': - case 'favorit': - await this.handleToggleFavorite(roomId, sender, args[0]); - break; - - // Trash - case 'papierkorb': - case 'trash': - await this.handleTrash(roomId, sender); - break; - - case 'wiederherstellen': - case 'restore': - await this.handleRestore(roomId, sender, args[0]); - break; - - case 'leeren': - case 'emptytrash': - await this.handleEmptyTrash(roomId, sender); - break; - - default: - await this.sendMessage( - roomId, - `

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

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

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

`); - } - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - _sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendReply(roomId, event, '

Sprachnachricht konnte nicht erkannt werden.

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

"${text}"

`); - await this.handleTextMessage(roomId, event, text); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply(roomId, event, '

Fehler bei der Spracherkennung.

'); - } - } - - private async requireAuth(sender: string): Promise { - const token = await this.sessionService.getToken(sender); - if (!token) { - throw new Error(LOGIN_MESSAGES.storage); - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendOk = await this.storageService.checkHealth(); - const loggedIn = await this.sessionService.isLoggedIn(sender); - const sessions = await this.sessionService.getSessionCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusHtml = '

Storage Bot Status

    '; - statusHtml += `
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • `; - statusHtml += `
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • `; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; - statusHtml += `
  • ⚡ Credits: ${balance.balance.toFixed(2)}
  • `; - } - - statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; - statusHtml += '
'; - - await this.sendMessage(roomId, statusHtml); - } - - // File handlers - private async handleListFiles(roomId: string, sender: string, folderNumStr?: string) { - const token = await this.requireAuth(sender); - - let parentFolderId: string | undefined; - if (folderNumStr) { - const folder = this.getFolderByNumber(sender, folderNumStr); - if (!folder) { - await this.sendMessage(roomId, '

Ungueltige Ordner-Nummer.

'); - return; - } - parentFolderId = folder.id; - this.currentFolder.set(sender, folder.id); - } else { - this.currentFolder.set(sender, null); - } - - const result = await this.storageService.getFiles(token, parentFolderId); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const files = result.data || []; - this.filesMapper.setList(sender, files); - - if (files.length === 0) { - await this.sendMessage(roomId, '

Keine Dateien vorhanden.

'); - return; - } - - let html = '

Dateien

    '; - for (const file of files) { - const size = this.formatSize(file.size); - const fav = file.isFavorite ? ' ⭐' : ''; - html += `
  1. ${file.name} (${size})${fav}
  2. `; - } - html += '
'; - html += - '

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

'; - - await this.sendMessage(roomId, html); - } - - private async handleFileDetails(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const file = this.getFileByNumber(sender, numberStr); - - if (!file) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !dateien

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

Fehler: ${result.error}

`); - return; - } - - const f = result.data!; - const fav = f.isFavorite ? ' ⭐' : ''; - let html = `

${f.name}${fav}

`; - html += '
    '; - html += `
  • Typ: ${f.mimeType}
  • `; - html += `
  • Groesse: ${this.formatSize(f.size)}
  • `; - html += `
  • Erstellt: ${new Date(f.createdAt).toLocaleDateString('de-DE')}
  • `; - html += `
  • Aktualisiert: ${new Date(f.updatedAt).toLocaleDateString('de-DE')}
  • `; - html += '
'; - html += `

Nutze !download ${numberStr} fuer Download-Link

`; - - await this.sendMessage(roomId, html); - } - - private async handleDownload(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const file = this.getFileByNumber(sender, numberStr); - - if (!file) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !dateien

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

Fehler: ${result.error}

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

${file.name}

Download: ${result.data!.url}

` - ); - } - - private async handleDeleteFile(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const file = this.getFileByNumber(sender, numberStr); - - if (!file) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !dateien

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

Fehler: ${result.error}

`); - return; - } - - this.filesMapper.clearList(sender); - await this.sendMessage( - roomId, - `

${file.name} in Papierkorb verschoben.

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

Verwendung: !umbenennen [nr] neuer name

'); - return; - } - - const token = await this.requireAuth(sender); - const file = this.getFileByNumber(sender, numberStr); - - if (!file) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !dateien

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

Fehler: ${result.error}

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

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

` - ); - } - - private async handleMoveFile( - roomId: string, - sender: string, - fileNumStr: string, - folderNumStr: string - ) { - const token = await this.requireAuth(sender); - const file = this.getFileByNumber(sender, fileNumStr); - - if (!file) { - await this.sendMessage(roomId, '

Ungueltige Datei-Nummer.

'); - return; - } - - let parentFolderId: string | null = null; - let folderName = 'Root'; - - if (folderNumStr && folderNumStr !== '0' && folderNumStr.toLowerCase() !== 'root') { - const folder = this.getFolderByNumber(sender, folderNumStr); - if (!folder) { - await this.sendMessage( - roomId, - '

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

' - ); - return; - } - parentFolderId = folder.id; - folderName = folder.name; - } - - const result = await this.storageService.moveFile(token, file.id, parentFolderId); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

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

` - ); - } - - // Folder handlers - private async handleListFolders(roomId: string, sender: string, folderNumStr?: string) { - const token = await this.requireAuth(sender); - - let parentFolderId: string | undefined; - if (folderNumStr) { - const folder = this.getFolderByNumber(sender, folderNumStr); - if (!folder) { - await this.sendMessage(roomId, '

Ungueltige Ordner-Nummer.

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

Fehler: ${result.error}

`); - return; - } - - const folders = result.data || []; - this.foldersMapper.setList(sender, folders); - - if (folders.length === 0) { - await this.sendMessage(roomId, '

Keine Ordner vorhanden.

'); - return; - } - - let html = '

Ordner

    '; - for (const folder of folders) { - const fav = folder.isFavorite ? ' ⭐' : ''; - const color = folder.color ? ` [${folder.color}]` : ''; - html += `
  1. ${folder.name}${color}${fav}
  2. `; - } - html += '
'; - html += '

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

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

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

' - ); - return; - } - - const token = await this.requireAuth(sender); - - // Check if last arg is a number (parent folder) - let parentFolderId: string | undefined; - let name = args.join(' '); - - const lastArg = args[args.length - 1]; - if (/^\d+$/.test(lastArg) && args.length > 1) { - const folder = this.getFolderByNumber(sender, lastArg); - if (folder) { - parentFolderId = folder.id; - name = args.slice(0, -1).join(' '); - } - } - - const result = await this.storageService.createFolder(token, name, parentFolderId); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.foldersMapper.clearList(sender); - await this.sendMessage(roomId, `

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

`); - } - - private async handleDeleteFolder(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - const folder = this.getFolderByNumber(sender, numberStr); - - if (!folder) { - await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !ordner

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

Fehler: ${result.error}

`); - return; - } - - this.foldersMapper.clearList(sender); - await this.sendMessage( - roomId, - `

Ordner ${folder.name} in Papierkorb verschoben.

` - ); - } - - // Share handlers - private async handleShareFile(roomId: string, sender: string, argString: string) { - const token = await this.requireAuth(sender); - - // Parse arguments - const args = argString.split(/\s+/); - const numberStr = args[0]; - const file = this.getFileByNumber(sender, numberStr); - - if (!file) { - await this.sendMessage( - roomId, - '

Ungueltige Nummer. Nutze zuerst !dateien

' - ); - return; - } - - const options: { expiresInDays?: number; password?: string; maxDownloads?: number } = {}; - - // Parse --tage N - const daysMatch = argString.match(/--tage\s+(\d+)/i); - if (daysMatch) { - options.expiresInDays = parseInt(daysMatch[1], 10); - } - - // Parse --passwort XXX - const passMatch = argString.match(/--passwort\s+(\S+)/i); - if (passMatch) { - options.password = passMatch[1]; - } - - // Parse --downloads N - const dlMatch = argString.match(/--downloads\s+(\d+)/i); - if (dlMatch) { - options.maxDownloads = parseInt(dlMatch[1], 10); - } - - const result = await this.storageService.createShare(token, file.id, options); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const share = result.data!; - const shareUrl = `${this.configService.get('storage.backendUrl')}/public/shares/${share.shareToken}`; - - let html = `

${file.name} wird geteilt:

`; - html += `

${shareUrl}

`; - if (options.expiresInDays) html += `

Gueltig: ${options.expiresInDays} Tage

`; - if (options.password) html += `

Passwort geschuetzt

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

Max Downloads: ${options.maxDownloads}

`; - - await this.sendMessage(roomId, html); - } - - private async handleListShares(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.storageService.getShares(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const shares = result.data || []; - this.sharesMapper.setList(sender, shares); - - if (shares.length === 0) { - await this.sendMessage(roomId, '

Keine Share-Links vorhanden.

'); - return; - } - - let html = '

Share-Links

    '; - for (const share of shares) { - const expires = share.expiresAt - ? ` (bis ${new Date(share.expiresAt).toLocaleDateString('de-DE')})` - : ''; - const downloads = share.maxDownloads - ? ` [${share.downloadCount}/${share.maxDownloads}]` - : ` [${share.downloadCount} DL]`; - html += `
  1. ${share.shareType}${expires}${downloads}
  2. `; - } - html += '
'; - html += '

Nutze !linkloeschen [nr] zum Loeschen

'; - - await this.sendMessage(roomId, html); - } - - private async handleDeleteShare(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - - if (!this.sharesMapper.hasList(sender)) { - await this.sendMessage(roomId, '

Nutze zuerst !links

'); - return; - } - - const num = parseInt(numberStr, 10); - const share = isNaN(num) ? null : this.sharesMapper.getByNumber(sender, num); - if (!share) { - await this.sendMessage(roomId, '

Ungueltige Nummer.

'); - return; - } - const result = await this.storageService.deleteShare(token, share.id); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.sharesMapper.clearList(sender); - await this.sendMessage(roomId, '

Share-Link geloescht.

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

Verwendung: !suche Begriff

'); - return; - } - - const token = await this.requireAuth(sender); - const result = await this.storageService.search(token, query); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const { files, folders } = result.data!; - this.filesMapper.setList(sender, files); - this.foldersMapper.setList(sender, folders); - - if (files.length === 0 && folders.length === 0) { - await this.sendMessage(roomId, `

Keine Ergebnisse fuer "${query}"

`); - return; - } - - let html = `

Suchergebnisse: "${query}"

`; - - if (folders.length > 0) { - html += '

Ordner:

    '; - for (const folder of folders) { - html += `
  1. ${folder.name}
  2. `; - } - html += '
'; - } - - if (files.length > 0) { - html += '

Dateien:

    '; - for (const file of files) { - html += `
  1. ${file.name} (${this.formatSize(file.size)})
  2. `; - } - html += '
'; - } - - await this.sendMessage(roomId, html); - } - - private async handleFavorites(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.storageService.getFavorites(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const { files, folders } = result.data!; - this.filesMapper.setList(sender, files); - this.foldersMapper.setList(sender, folders); - - if (files.length === 0 && folders.length === 0) { - await this.sendMessage(roomId, '

Keine Favoriten vorhanden.

'); - return; - } - - let html = '

Favoriten ⭐

'; - - if (folders.length > 0) { - html += '

Ordner:

    '; - for (const folder of folders) { - html += `
  1. ${folder.name}
  2. `; - } - html += '
'; - } - - if (files.length > 0) { - html += '

Dateien:

    '; - for (const file of files) { - html += `
  1. ${file.name}
  2. `; - } - html += '
'; - } - - await this.sendMessage(roomId, html); - } - - private async handleToggleFavorite(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - - // Try file first - const file = this.getFileByNumber(sender, numberStr); - if (file) { - const result = await this.storageService.toggleFileFavorite(token, file.id); - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

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

`); - return; - } - - // Try folder - const folder = this.getFolderByNumber(sender, numberStr); - if (folder) { - const result = await this.storageService.toggleFolderFavorite(token, folder.id); - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

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

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

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

Ungueltige Nummer.

'); - } - - // Trash handlers - private async handleTrash(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.storageService.getTrash(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - const items = result.data || []; - this.trashMapper.setList(sender, items); - - if (items.length === 0) { - await this.sendMessage(roomId, '

Papierkorb ist leer.

'); - return; - } - - let html = '

Papierkorb

    '; - for (const item of items) { - const type = item.type === 'folder' ? '📁' : '📄'; - const deleted = new Date(item.deletedAt).toLocaleDateString('de-DE'); - html += `
  1. ${type} ${item.name} (geloescht: ${deleted})
  2. `; - } - html += '
'; - html += '

Nutze !wiederherstellen [nr] oder !leeren

'; - - await this.sendMessage(roomId, html); - } - - private async handleRestore(roomId: string, sender: string, numberStr: string) { - const token = await this.requireAuth(sender); - - if (!this.trashMapper.hasList(sender)) { - await this.sendMessage(roomId, '

Nutze zuerst !papierkorb

'); - return; - } - - const num = parseInt(numberStr, 10); - const item = isNaN(num) ? null : this.trashMapper.getByNumber(sender, num); - if (!item) { - await this.sendMessage(roomId, '

Ungueltige Nummer.

'); - return; - } - const result = await this.storageService.restoreFromTrash(token, item.id, item.type); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.trashMapper.clearList(sender); - await this.sendMessage(roomId, `

${item.name} wiederhergestellt.

`); - } - - private async handleEmptyTrash(roomId: string, sender: string) { - const token = await this.requireAuth(sender); - const result = await this.storageService.emptyTrash(token); - - if (result.error) { - await this.sendMessage(roomId, `

Fehler: ${result.error}

`); - return; - } - - this.trashMapper.clearList(sender); - await this.sendMessage(roomId, '

Papierkorb geleert.

'); - } - - // Helper methods - private getFileByNumber(sender: string, numberStr: string): StorageFile | null { - const num = parseInt(numberStr, 10); - if (isNaN(num)) return null; - return this.filesMapper.getByNumber(sender, num); - } - - private getFolderByNumber(sender: string, numberStr: string): Folder | null { - const num = parseInt(numberStr, 10); - if (isNaN(num)) return null; - return this.foldersMapper.getByNumber(sender, num); - } - - private formatSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - } -} diff --git a/services/matrix-storage-bot/src/config/configuration.ts b/services/matrix-storage-bot/src/config/configuration.ts deleted file mode 100644 index c4b797bea..000000000 --- a/services/matrix-storage-bot/src/config/configuration.ts +++ /dev/null @@ -1,64 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3323', 10), - matrix: { - homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', - accessToken: process.env.MATRIX_ACCESS_TOKEN, - allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], - storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', - }, - storage: { - backendUrl: process.env.STORAGE_BACKEND_URL || 'http://localhost:3016', - apiPrefix: process.env.STORAGE_API_PREFIX || '/api/v1', - }, - auth: { - url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', - }, -}); - -export const HELP_MESSAGE = `

Storage Bot - Befehle

- -

Dateien

-
    -
  • !dateien - Dateien im Root auflisten
  • -
  • !dateien [ordner-nr] - Dateien in Ordner
  • -
  • !datei [nr] - Datei-Details anzeigen
  • -
  • !download [nr] - Download-Link erhalten
  • -
  • !loeschen [nr] - Datei in Papierkorb
  • -
  • !umbenennen [nr] neuer name - Datei umbenennen
  • -
  • !verschieben [nr] [ordner-nr] - In Ordner verschieben
  • -
- -

Ordner

-
    -
  • !ordner - Ordner im Root auflisten
  • -
  • !ordner [nr] - Unterordner anzeigen
  • -
  • !neuordner Name - Neuen Ordner erstellen
  • -
  • !neuordner Name [in-ordner-nr] - Unterordner erstellen
  • -
  • !ordnerloeschen [nr] - Ordner loeschen
  • -
- -

Teilen

-
    -
  • !teilen [nr] - Datei teilen (Link erstellen)
  • -
  • !teilen [nr] --tage 7 - Mit Ablaufdatum
  • -
  • !teilen [nr] --passwort abc - Mit Passwort
  • -
  • !links - Alle Share-Links anzeigen
  • -
  • !linkloeschen [nr] - Share-Link loeschen
  • -
- -

Organisation

-
    -
  • !suche Begriff - Dateien/Ordner suchen
  • -
  • !favoriten - Favoriten anzeigen
  • -
  • !fav [nr] - Favorit umschalten
  • -
  • !papierkorb - Papierkorb anzeigen
  • -
  • !wiederherstellen [nr] - Aus Papierkorb holen
  • -
  • !leeren - Papierkorb leeren
  • -
- -

Weitere Befehle

-
    -
  • !help - Diese Hilfe anzeigen
  • -
- -

Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-storage-bot/src/main.ts b/services/matrix-storage-bot/src/main.ts deleted file mode 100644 index 9a6668472..000000000 --- a/services/matrix-storage-bot/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3323; - await app.listen(port); - console.log(`Matrix Storage Bot running on port ${port}`); -} -bootstrap(); diff --git a/services/matrix-storage-bot/src/storage/storage.module.ts b/services/matrix-storage-bot/src/storage/storage.module.ts deleted file mode 100644 index 2e07e3a15..000000000 --- a/services/matrix-storage-bot/src/storage/storage.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { StorageService } from './storage.service'; - -@Module({ - providers: [StorageService], - exports: [StorageService], -}) -export class StorageModule {} diff --git a/services/matrix-storage-bot/src/storage/storage.service.ts b/services/matrix-storage-bot/src/storage/storage.service.ts deleted file mode 100644 index c6b3a1808..000000000 --- a/services/matrix-storage-bot/src/storage/storage.service.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface StorageFile { - id: string; - name: string; - originalName: string; - mimeType: string; - size: number; - parentFolderId?: string; - isFavorite: boolean; - isDeleted: boolean; - createdAt: string; - updatedAt: string; -} - -export interface Folder { - id: string; - name: string; - color?: string; - description?: string; - parentFolderId?: string; - path: string; - depth: number; - isFavorite: boolean; - isDeleted: boolean; - createdAt: string; -} - -export interface ShareLink { - id: string; - fileId?: string; - folderId?: string; - shareType: 'file' | 'folder'; - shareToken: string; - accessLevel: 'view' | 'edit' | 'download'; - password?: string; - maxDownloads?: number; - downloadCount: number; - expiresAt?: string; - isActive: boolean; - createdAt: string; -} - -export interface TrashItem { - id: string; - name: string; - type: 'file' | 'folder'; - deletedAt: string; -} - -@Injectable() -export class StorageService { - private readonly logger = new Logger(StorageService.name); - private backendUrl: string; - private apiPrefix: string; - - constructor(private configService: ConfigService) { - this.backendUrl = this.configService.get('storage.backendUrl') || 'http://localhost:3016'; - this.apiPrefix = this.configService.get('storage.apiPrefix') || '/api/v1'; - } - - private async request( - token: string, - endpoint: string, - options: RequestInit = {} - ): Promise<{ data?: T; error?: string }> { - try { - const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `Fehler: ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - this.logger.error(`Request failed: ${endpoint}`, error); - return { error: 'Verbindung zum Backend fehlgeschlagen' }; - } - } - - // File operations - async getFiles(token: string, parentFolderId?: string): Promise<{ data?: StorageFile[]; error?: string }> { - const query = parentFolderId ? `?parentFolderId=${parentFolderId}` : ''; - return this.request(token, `/files${query}`); - } - - async getFile(token: string, fileId: string): Promise<{ data?: StorageFile; error?: string }> { - return this.request(token, `/files/${fileId}`); - } - - async getDownloadUrl(token: string, fileId: string): Promise<{ data?: { url: string }; error?: string }> { - return this.request<{ url: string }>(token, `/files/${fileId}/download?url=true`); - } - - async deleteFile(token: string, fileId: string): Promise<{ error?: string }> { - return this.request(token, `/files/${fileId}`, { method: 'DELETE' }); - } - - async renameFile(token: string, fileId: string, name: string): Promise<{ data?: StorageFile; error?: string }> { - return this.request(token, `/files/${fileId}`, { - method: 'PATCH', - body: JSON.stringify({ name }), - }); - } - - async moveFile(token: string, fileId: string, parentFolderId: string | null): Promise<{ data?: StorageFile; error?: string }> { - return this.request(token, `/files/${fileId}/move`, { - method: 'PATCH', - body: JSON.stringify({ parentFolderId }), - }); - } - - async toggleFileFavorite(token: string, fileId: string): Promise<{ data?: StorageFile; error?: string }> { - return this.request(token, `/files/${fileId}/favorite`, { method: 'POST' }); - } - - // Folder operations - async getFolders(token: string, parentFolderId?: string): Promise<{ data?: Folder[]; error?: string }> { - const query = parentFolderId ? `?parentFolderId=${parentFolderId}` : ''; - return this.request(token, `/folders${query}`); - } - - async getFolder(token: string, folderId: string): Promise<{ data?: Folder; error?: string }> { - return this.request(token, `/folders/${folderId}`); - } - - async createFolder( - token: string, - name: string, - parentFolderId?: string - ): Promise<{ data?: Folder; error?: string }> { - return this.request(token, '/folders', { - method: 'POST', - body: JSON.stringify({ name, parentFolderId }), - }); - } - - async deleteFolder(token: string, folderId: string): Promise<{ error?: string }> { - return this.request(token, `/folders/${folderId}`, { method: 'DELETE' }); - } - - async toggleFolderFavorite(token: string, folderId: string): Promise<{ data?: Folder; error?: string }> { - return this.request(token, `/folders/${folderId}/favorite`, { method: 'POST' }); - } - - // Share operations - async getShares(token: string): Promise<{ data?: ShareLink[]; error?: string }> { - return this.request(token, '/shares'); - } - - async createShare( - token: string, - fileId: string, - options: { expiresInDays?: number; password?: string; maxDownloads?: number } = {} - ): Promise<{ data?: ShareLink; error?: string }> { - return this.request(token, '/shares', { - method: 'POST', - body: JSON.stringify({ fileId, accessLevel: 'download', ...options }), - }); - } - - async deleteShare(token: string, shareId: string): Promise<{ error?: string }> { - return this.request(token, `/shares/${shareId}`, { method: 'DELETE' }); - } - - // Search - async search(token: string, query: string): Promise<{ data?: { files: StorageFile[]; folders: Folder[] }; error?: string }> { - return this.request<{ files: StorageFile[]; folders: Folder[] }>(token, `/search?q=${encodeURIComponent(query)}`); - } - - // Favorites - async getFavorites(token: string): Promise<{ data?: { files: StorageFile[]; folders: Folder[] }; error?: string }> { - return this.request<{ files: StorageFile[]; folders: Folder[] }>(token, '/favorites'); - } - - // Trash - async getTrash(token: string): Promise<{ data?: TrashItem[]; error?: string }> { - return this.request(token, '/trash'); - } - - async restoreFromTrash(token: string, id: string, type: 'file' | 'folder'): Promise<{ error?: string }> { - return this.request(token, `/trash/${id}/restore?type=${type}`, { method: 'POST' }); - } - - async emptyTrash(token: string): Promise<{ error?: string }> { - return this.request(token, '/trash', { method: 'DELETE' }); - } - - async checkHealth(): Promise { - try { - const response = await fetch(`${this.backendUrl}${this.apiPrefix}/health`); - return response.ok; - } catch { - return false; - } - } -} diff --git a/services/matrix-storage-bot/tsconfig.json b/services/matrix-storage-bot/tsconfig.json deleted file mode 100644 index 94f1e9493..000000000 --- a/services/matrix-storage-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - "esModuleInterop": true - } -} diff --git a/services/matrix-stt-bot/.env.example b/services/matrix-stt-bot/.env.example deleted file mode 100644 index 10634ed43..000000000 --- a/services/matrix-stt-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3024 - -# Matrix Configuration -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS= -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# STT Service (mana-stt) -STT_URL=http://localhost:3020 - -# STT Defaults -DEFAULT_LANGUAGE=de -DEFAULT_MODEL=whisper diff --git a/services/matrix-stt-bot/CLAUDE.md b/services/matrix-stt-bot/CLAUDE.md deleted file mode 100644 index ead1f5fd2..000000000 --- a/services/matrix-stt-bot/CLAUDE.md +++ /dev/null @@ -1,189 +0,0 @@ -# Matrix STT Bot - Claude Code Guidelines - -## Overview - -Matrix STT Bot converts audio/voice messages to text and sends them back as text messages. Uses the mana-stt service (port 3020) for transcription. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **STT Backend**: mana-stt service (Whisper, Voxtral) - -## 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-stt-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3024) -│ ├── app.module.ts # Root module -│ ├── config/ -│ │ └── configuration.ts # Configuration & help text -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & message handler -│ └── stt/ -│ ├── stt.module.ts -│ └── stt.service.ts # mana-stt API client -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Description | -|---------|-------------| -| `!help` / `!hilfe` | Show help text | -| `!language [de\|en\|auto]` | Change transcription language | -| `!model [whisper\|voxtral\|auto]` | Change STT model | -| `!status` | Show current settings | -| (voice message) | Transcribe to text | - -## Message Flow - -1. User sends voice/audio message -2. Bot receives via matrix-bot-sdk -3. Audio downloaded from Matrix -4. STT service transcribes audio -5. Text message sent back to room - -## Environment Variables - -```env -# Server -PORT=3024 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=!roomid:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# STT Service -STT_URL=http://localhost:3020 - -# Defaults -DEFAULT_LANGUAGE=de -DEFAULT_MODEL=whisper -``` - -## STT API Integration - -The bot sends audio to mana-stt for transcription: - -```typescript -// Default Whisper endpoint -POST /transcribe -FormData: file=audio.ogg, language=de - -// Voxtral endpoint (with speaker diarization) -POST /transcribe/voxtral -FormData: file=audio.ogg, language=de - -// Auto-select endpoint -POST /transcribe/auto -FormData: file=audio.ogg, prefer=whisper - -// Response -{ - "text": "Das ist der transkribierte Text...", - "language": "de", - "model": "whisper-large-v3-turbo", - "duration": 3.5 -} -``` - -## Available Models - -| Model | Description | -|-------|-------------| -| `whisper` | Whisper Large V3 (local, fast, 99+ languages) | -| `voxtral` | Voxtral Mini (cloud, speaker diarization) | -| `auto` | Automatic model selection | - -## Supported Languages - -| Code | Language | -|------|----------| -| `de` | German (default) | -| `en` | English | -| `auto` | Automatic detection | - -## Supported Audio Formats - -- OGG, MP3, WAV, M4A, FLAC, WebM, Opus -- Matrix voice messages (typically OGG/Opus) - -## Docker - -```bash -# Build -docker build -f services/matrix-stt-bot/Dockerfile -t matrix-stt-bot . - -# Run -docker run -p 3024:3024 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e STT_URL=http://mana-stt:3020 \ - -v matrix-stt-bot-data:/app/data \ - matrix-stt-bot -``` - -## Health Check - -```bash -curl http://localhost:3024/health -``` - -## Dependencies - -- **mana-stt**: Must be running on port 3020 (or configured via `STT_URL`) -- **Matrix homeserver**: Synapse or compatible homeserver - -## User Settings - -Settings are stored in-memory per Matrix user ID: -- Language selection persists during bot runtime -- Model selection persists during bot runtime -- Settings reset when bot restarts - -## Testing - -```bash -# 1. Ensure mana-stt is running -curl http://localhost:3020/health - -# 2. Start the bot -cd services/matrix-stt-bot -pnpm start:dev - -# 3. Check bot health -curl http://localhost:3024/health - -# 4. In Matrix: -# - Invite bot to a room -# - Send a voice message -# - Receive text transcription -``` - -## Related Services - -| Service | Port | Description | -|---------|------|-------------| -| mana-stt | 3020 | STT backend service | -| matrix-tts-bot | 3023 | Text-to-speech bot (reverse of this) | -| mana-tts | 3022 | TTS backend service | diff --git a/services/matrix-stt-bot/Dockerfile b/services/matrix-stt-bot/Dockerfile deleted file mode 100644 index 1e239a110..000000000 --- a/services/matrix-stt-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-stt-bot ./services/matrix-stt-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-stt-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-stt-bot/dist ./services/matrix-stt-bot/dist -COPY --from=builder /app/services/matrix-stt-bot/package.json ./services/matrix-stt-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-stt-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4021/health || exit 1 - -EXPOSE 4021 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-stt-bot/nest-cli.json b/services/matrix-stt-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-stt-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-stt-bot/package.json b/services/matrix-stt-bot/package.json deleted file mode 100644 index f65c10c64..000000000 --- a/services/matrix-stt-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@manacore/matrix-stt-bot", - "version": "1.0.0", - "description": "Matrix bot for speech-to-text transcription", - "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": "tsc -p tsconfig.build.json", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@nestjs/common": "^10.4.17", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.17", - "@nestjs/platform-express": "^10.4.17", - "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.7", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-stt-bot/src/app.module.ts b/services/matrix-stt-bot/src/app.module.ts deleted file mode 100644 index 141847cb6..000000000 --- a/services/matrix-stt-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { SttModule } from './stt/stt.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - SttModule, - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-stt-bot')], -}) -export class AppModule {} diff --git a/services/matrix-stt-bot/src/bot/bot.module.ts b/services/matrix-stt-bot/src/bot/bot.module.ts deleted file mode 100644 index 6a00c4c2a..000000000 --- a/services/matrix-stt-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { SttModule } from '../stt/stt.module'; - -@Module({ - imports: [SttModule], - providers: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-stt-bot/src/bot/matrix.service.ts b/services/matrix-stt-bot/src/bot/matrix.service.ts deleted file mode 100644 index c293224d5..000000000 --- a/services/matrix-stt-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { SttService, SttLanguage, SttModel } from '../stt/stt.service'; -import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration'; - -interface UserSettings { - language: SttLanguage; - model: SttModel; -} - -@Injectable() -export class MatrixService extends BaseMatrixService { - private readonly defaultLanguage: SttLanguage; - private readonly defaultModel: SttModel; - - // User settings storage (in-memory) - private userSettings: Map = new Map(); - - // Track processed events to prevent duplicates - private processedEvents: Set = new Set(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['language', 'sprache', 'sprache aendern'], command: 'language' }, - { keywords: ['model', 'modell'], command: 'model' }, - ]); - - constructor( - configService: ConfigService, - private sttService: SttService - ) { - super(configService); - this.defaultLanguage = - (this.configService.get('stt.defaultLanguage') as SttLanguage) || 'de'; - this.defaultModel = - (this.configService.get('stt.defaultModel') as SttModel) || 'whisper'; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string { - return WELCOME_TEXT; - } - - protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise { - // Ignore own messages - if (event.sender === this.botUserId) return; - - // Prevent duplicate processing - const eventId = event.event_id; - if (eventId && this.processedEvents.has(eventId)) { - return; - } - if (eventId) { - this.processedEvents.add(eventId); - // Clean up old events (keep last 1000) - if (this.processedEvents.size > 1000) { - const iterator = this.processedEvents.values(); - const firstValue = iterator.next().value; - if (firstValue) { - this.processedEvents.delete(firstValue); - } - } - } - - // Check room allowlist - if (!this.isRoomAllowed(roomId)) { - return; - } - - const msgtype = event.content?.msgtype; - const userId = event.sender; - - // Handle audio messages (main functionality) - if (msgtype === 'm.audio' || msgtype === 'm.file') { - const mimetype = String(event.content?.info?.mimetype || ''); - if (mimetype.startsWith('audio/') || this.isAudioFile(event.content?.body)) { - await this.handleAudioMessage(roomId, event, userId); - return; - } - } - - // Handle text commands - if (msgtype === 'm.text') { - const body = event.content?.body?.trim(); - if (body) { - await this.handleTextMessage(roomId, event, body); - } - } - } - - private isAudioFile(filename?: string): boolean { - if (!filename) return false; - const audioExtensions = ['.ogg', '.mp3', '.wav', '.m4a', '.flac', '.webm', '.opus']; - return audioExtensions.some((ext) => filename.toLowerCase().endsWith(ext)); - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - userId: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) { - this.logger.warn('Audio message without URL'); - return; - } - - // Show typing indicator - await this.client.setTyping(roomId, true, 30000); - - // Download audio - const audioBuffer = await this.downloadMedia(mxcUrl); - - // Get user settings - const settings = this.getUserSettings(userId); - - // Transcribe - const result = await this.sttService.transcribe( - audioBuffer, - settings.language, - settings.model - ); - - // Stop typing indicator - await this.client.setTyping(roomId, false); - - if (!result.text || result.text.trim() === '') { - await this.sendReply(roomId, event, 'Keine Sprache erkannt.'); - return; - } - - // Format response - let response = `**Transkription:**\n\n${result.text}`; - - // Add metadata if available - const metadata: string[] = []; - if (result.language) { - metadata.push(`Sprache: ${result.language}`); - } - if (result.model) { - metadata.push(`Modell: ${result.model}`); - } - if (result.duration) { - metadata.push(`Dauer: ${result.duration.toFixed(1)}s`); - } - - if (metadata.length > 0) { - response += `\n\n*${metadata.join(' | ')}*`; - } - - await this.sendReply(roomId, event, response); - - this.logger.debug(`Transcribed audio for ${userId}: "${result.text.substring(0, 50)}..."`); - } catch (error) { - await this.client.setTyping(roomId, false); - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply( - roomId, - event, - 'Fehler bei der Transkription. Ist der STT-Service erreichbar?' - ); - } - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - const userId = event.sender; - - try { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - // Handle ! commands - if (body.startsWith('!')) { - const [command, ...args] = body.slice(1).split(' '); - await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); - return; - } - - // For regular text messages, just acknowledge - // (This bot is primarily for audio transcription) - } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten.'); - } - } - - private async executeCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - command: string, - args: string - ) { - switch (command) { - case 'help': - case 'hilfe': - await this.sendReply(roomId, event, HELP_TEXT); - break; - - case 'language': - case 'sprache': - await this.handleLanguageCommand(roomId, event, userId, args); - break; - - case 'model': - case 'modell': - await this.handleModelCommand(roomId, event, userId, args); - break; - - case 'status': - await this.handleStatusCommand(roomId, event, userId); - break; - - default: - // Silently ignore unknown commands - break; - } - } - - private async handleLanguageCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - if (!args.trim()) { - await this.sendReply( - roomId, - event, - '**Verwendung:** `!language [de|en|auto]`\n\nBeispiel: `!language de`' - ); - return; - } - - const lang = args.trim().toLowerCase(); - const validLanguages: SttLanguage[] = ['de', 'en', 'auto']; - - if (!validLanguages.includes(lang as SttLanguage)) { - await this.sendReply( - roomId, - event, - `Ungueltige Sprache "${lang}".\n\nVerfuegbar: de, en, auto` - ); - return; - } - - const settings = this.getUserSettings(userId); - settings.language = lang as SttLanguage; - this.userSettings.set(userId, settings); - - await this.sendReply(roomId, event, `Sprache geaendert zu: **${lang}**`); - } - - private async handleModelCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - if (!args.trim()) { - await this.sendReply( - roomId, - event, - '**Verwendung:** `!model [whisper|voxtral|auto]`\n\nBeispiel: `!model whisper`\n\n' + - '**Modelle:**\n' + - '- `whisper` - Whisper Large V3 (lokal, schnell)\n' + - '- `voxtral` - Voxtral Mini (Cloud, Speaker Diarization)\n' + - '- `auto` - Automatische Auswahl' - ); - return; - } - - const model = args.trim().toLowerCase(); - const validModels: SttModel[] = ['whisper', 'voxtral', 'auto']; - - if (!validModels.includes(model as SttModel)) { - await this.sendReply( - roomId, - event, - `Ungueltiges Modell "${model}".\n\nVerfuegbar: whisper, voxtral, auto` - ); - return; - } - - const settings = this.getUserSettings(userId); - settings.model = model as SttModel; - this.userSettings.set(userId, settings); - - await this.sendReply(roomId, event, `Modell geaendert zu: **${model}**`); - } - - private async handleStatusCommand(roomId: string, event: MatrixRoomEvent, userId: string) { - const settings = this.getUserSettings(userId); - const sttHealthy = await this.sttService.isHealthy(); - - let response = '**Aktuelle Einstellungen:**\n\n'; - response += `Sprache: \`${settings.language}\`\n`; - response += `Modell: \`${settings.model}\`\n\n`; - response += `STT-Service: ${sttHealthy ? 'Online' : 'Offline'}`; - - await this.sendReply(roomId, event, response); - } - - private getUserSettings(userId: string): UserSettings { - if (!this.userSettings.has(userId)) { - this.userSettings.set(userId, { - language: this.defaultLanguage, - model: this.defaultModel, - }); - } - return this.userSettings.get(userId)!; - } -} diff --git a/services/matrix-stt-bot/src/config/configuration.ts b/services/matrix-stt-bot/src/config/configuration.ts deleted file mode 100644 index 9d2fcf1bf..000000000 --- a/services/matrix-stt-bot/src/config/configuration.ts +++ /dev/null @@ -1,43 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3024', 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', - }, - stt: { - url: process.env.STT_URL || 'http://localhost:3020', - apiKey: process.env.STT_API_KEY || '', - defaultLanguage: process.env.DEFAULT_LANGUAGE || 'de', - defaultModel: process.env.DEFAULT_MODEL || 'whisper', - }, -}); - -export const HELP_TEXT = `**STT Bot - Hilfe** - -Ich wandle deine Sprachnachrichten in Text um! - -**Befehle:** -- \`!language [de|en|auto]\` - Sprache aendern -- \`!model [whisper|voxtral]\` - Modell waehlen -- \`!status\` - Aktuelle Einstellungen -- \`!help\` - Diese Hilfe - -**Verwendung:** -Sende einfach eine Sprachnachricht und ich schreibe dir den Text zurueck. - -**Modelle:** -- \`whisper\` - Whisper Large V3 (lokal, schnell, Standard) -- \`voxtral\` - Voxtral Mini (Cloud, Speaker Diarization) - -**Sprachen:** -- \`de\` - Deutsch (Standard) -- \`en\` - English -- \`auto\` - Automatische Erkennung`; - -export const WELCOME_TEXT = `**STT Bot** - -Ich wandle Sprachnachrichten in Text um! - -Sende einfach eine Sprachnachricht oder \`!help\` fuer Hilfe.`; diff --git a/services/matrix-stt-bot/src/main.ts b/services/matrix-stt-bot/src/main.ts deleted file mode 100644 index d521f719d..000000000 --- a/services/matrix-stt-bot/src/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3024; - - await app.listen(port); - - const logger = new Logger('Bootstrap'); - logger.log(`Matrix STT Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); diff --git a/services/matrix-stt-bot/src/stt/stt.module.ts b/services/matrix-stt-bot/src/stt/stt.module.ts deleted file mode 100644 index acc7f6132..000000000 --- a/services/matrix-stt-bot/src/stt/stt.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SttService } from './stt.service'; - -@Module({ - providers: [SttService], - exports: [SttService], -}) -export class SttModule {} diff --git a/services/matrix-stt-bot/src/stt/stt.service.ts b/services/matrix-stt-bot/src/stt/stt.service.ts deleted file mode 100644 index 4a3306d4b..000000000 --- a/services/matrix-stt-bot/src/stt/stt.service.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface TranscriptionResult { - text: string; - language?: string; - model?: string; - duration?: number; -} - -export type SttModel = 'whisper' | 'voxtral' | 'auto'; -export type SttLanguage = 'de' | 'en' | 'auto'; - -@Injectable() -export class SttService { - private readonly logger = new Logger(SttService.name); - private readonly sttUrl: string; - private readonly apiKey: string; - - constructor(private configService: ConfigService) { - this.sttUrl = this.configService.get('stt.url', 'http://localhost:3020'); - this.apiKey = this.configService.get('stt.apiKey', ''); - } - - /** - * Transcribe audio to text - */ - async transcribe( - audioBuffer: Buffer, - language: SttLanguage = 'de', - model: SttModel = 'whisper' - ): Promise { - const endpoint = this.getEndpoint(model); - - this.logger.debug( - `Transcribing ${audioBuffer.length} bytes with ${model}, language=${language}` - ); - - const formData = new FormData(); - const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/ogg' }); - formData.append('file', blob, 'audio.ogg'); - - if (language !== 'auto') { - formData.append('language', language); - } - - try { - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(`${this.sttUrl}${endpoint}`, { - method: 'POST', - headers, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`STT failed: ${response.status} - ${errorText}`); - throw new Error(`STT service error: ${response.status}`); - } - - const result = await response.json(); - this.logger.debug(`Transcription completed: "${result.text?.substring(0, 50)}..."`); - - return { - text: result.text || '', - language: result.language, - model: result.model || model, - duration: result.duration, - }; - } catch (error) { - this.logger.error('Transcription failed:', error); - throw error; - } - } - - /** - * Get the appropriate endpoint for the model - */ - private getEndpoint(model: SttModel): string { - switch (model) { - case 'voxtral': - return '/transcribe/voxtral'; - case 'auto': - return '/transcribe/auto'; - case 'whisper': - default: - return '/transcribe'; - } - } - - /** - * Check if STT service is healthy - */ - async isHealthy(): Promise { - try { - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - const response = await fetch(`${this.sttUrl}/models`, { headers }); - return response.ok; - } catch { - return false; - } - } - - /** - * Get available models - */ - async getModels(): Promise { - try { - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - const response = await fetch(`${this.sttUrl}/models`, { headers }); - if (!response.ok) { - return ['whisper', 'voxtral']; - } - const data = await response.json(); - return data.models || ['whisper', 'voxtral']; - } catch { - return ['whisper', 'voxtral']; - } - } -} diff --git a/services/matrix-stt-bot/tsconfig.build.json b/services/matrix-stt-bot/tsconfig.build.json deleted file mode 100644 index 4491981e0..000000000 --- a/services/matrix-stt-bot/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/services/matrix-stt-bot/tsconfig.json b/services/matrix-stt-bot/tsconfig.json deleted file mode 100644 index 0fd6d8ccb..000000000 --- a/services/matrix-stt-bot/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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, - "moduleResolution": "node", - "resolveJsonModule": true - } -} diff --git a/services/matrix-todo-bot/.dockerignore b/services/matrix-todo-bot/.dockerignore deleted file mode 100644 index d6a8859ae..000000000 --- a/services/matrix-todo-bot/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -dist -.git -*.log -.env* -data diff --git a/services/matrix-todo-bot/.env.example b/services/matrix-todo-bot/.env.example deleted file mode 100644 index 3e7c299ad..000000000 --- a/services/matrix-todo-bot/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server -PORT=3314 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token -MATRIX_ALLOWED_ROOMS=#todo:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Todo API (optional, for external todo service) -TODO_API_URL=http://localhost:3010/api/v1 -TODO_SERVICE_KEY= - -# Speech-to-Text (mana-stt service) -STT_URL=http://localhost:3020 diff --git a/services/matrix-todo-bot/CLAUDE.md b/services/matrix-todo-bot/CLAUDE.md deleted file mode 100644 index 06e16416a..000000000 --- a/services/matrix-todo-bot/CLAUDE.md +++ /dev/null @@ -1,158 +0,0 @@ -# Matrix Todo Bot - Claude Code Guidelines - -## Overview - -Matrix Todo Bot provides a task management interface via Matrix chat. It integrates with the Todo backend for full CRUD operations, syncing tasks across Matrix, web, and mobile apps. - -**Login Required**: Users must login (`!login email password`) to use the bot. All tasks are synchronized with the todo-backend. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **Backend**: Todo API (port 3018) -- **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-todo-bot/ -├── src/ -│ ├── main.ts # Application entry point -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help texts -│ └── bot/ -│ ├── bot.module.ts -│ └── matrix.service.ts # Matrix client & command handlers -├── Dockerfile -└── package.json -``` - -## Matrix Commands - -| Command | Description | -|---------|-------------| -| `!help` | Show help message | -| `!login email pass` | Login (required before use) | -| `!logout` | Logout | -| `!add [task]` | Create a new task | -| `!list` | Show all pending tasks | -| `!heute` / `!today` | Show today's tasks | -| `!inbox` | Show tasks without date | -| `!done [nr]` | Mark task as complete | -| `!delete [nr]` | Delete a task | -| `!projects` | List all projects | -| `!project [name]` | Show project tasks | -| `!status` | Show bot status | -| `!pin` | Pin help to room | - -## Natural Language Keywords - -The bot also responds to natural language (German + English): -- "hilfe", "help" → Show help -- "zeige aufgaben", "show tasks" → List tasks -- "heute", "today" → Today's tasks -- "inbox", "eingang" → Inbox tasks -- "projekte", "projects" → List projects - -## Task Input Syntax - -``` -!add Task title !p1 @morgen #projektname - │ │ │ └── Project - │ │ └── Due date (@heute, @morgen, @übermorgen) - │ └── Priority (1-4, 1 highest) - └── Task title -``` - -## Environment Variables - -```env -# Server -PORT=3314 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=#todo-bot:mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# Todo Backend -TODO_BACKEND_URL=http://localhost:3018 - -# Mana Core Auth -MANA_CORE_AUTH_URL=http://localhost:3001 - -# Redis (for session storage) -REDIS_URL=redis://localhost:6379 -``` - -## Docker - -```bash -# Build locally -docker build -f services/matrix-todo-bot/Dockerfile -t matrix-todo-bot services/matrix-todo-bot - -# Run -docker run -p 3314:3314 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e TODO_BACKEND_URL=http://todo-backend:3018 \ - -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ - -v matrix-todo-bot-data:/app/data \ - matrix-todo-bot -``` - -## Health Check - -```bash -curl http://localhost:3314/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": "todo-bot", - "password": "your-password" - }' - -# Response contains: {"access_token": "syt_xxx", ...} -``` - -## Authentication Flow - -1. User sends `!login email password` -2. Bot authenticates via mana-core-auth -3. JWT token stored in Redis session -4. Token used for all Todo API calls -5. Tasks sync with todo-backend (PostgreSQL) - -## Data Synchronization - -All tasks are stored in the Todo backend PostgreSQL database. Changes made via: -- Matrix bot -- Todo web app -- Todo mobile app - -...are all synchronized automatically. diff --git a/services/matrix-todo-bot/Dockerfile b/services/matrix-todo-bot/Dockerfile deleted file mode 100644 index a83436b4d..000000000 --- a/services/matrix-todo-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-todo-bot ./services/matrix-todo-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-todo-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-todo-bot/dist ./services/matrix-todo-bot/dist -COPY --from=builder /app/services/matrix-todo-bot/package.json ./services/matrix-todo-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-todo-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4014/health || exit 1 - -EXPOSE 4014 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-todo-bot/nest-cli.json b/services/matrix-todo-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-todo-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-todo-bot/package.json b/services/matrix-todo-bot/package.json deleted file mode 100644 index 2b65f0a5c..000000000 --- a/services/matrix-todo-bot/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@manacore/matrix-todo-bot", - "version": "1.0.0", - "description": "Matrix bot for task management - GDPR compliant", - "private": true, - "license": "MIT", - "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": "tsc -p tsconfig.build.json", - "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@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", - "@nestjs/schematics": "^10.2.3", - "@types/node": "^22.10.5", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-todo-bot/src/app.module.ts b/services/matrix-todo-bot/src/app.module.ts deleted file mode 100644 index 0b861fd8a..000000000 --- a/services/matrix-todo-bot/src/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import configuration from './config/configuration'; -import { BotModule } from './bot/bot.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-todo-bot')], -}) -export class AppModule {} diff --git a/services/matrix-todo-bot/src/bot/bot.module.ts b/services/matrix-todo-bot/src/bot/bot.module.ts deleted file mode 100644 index dbe4280e0..000000000 --- a/services/matrix-todo-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { MatrixService } from './matrix.service'; -import { - TranscriptionModule, - SessionModule, - CreditModule, - GiftModule, - TodoApiService, - I18nModule, -} from '@manacore/bot-services'; - -// Factory provider for TodoApiService -const todoApiServiceProvider = { - provide: TodoApiService, - useFactory: (configService: ConfigService) => { - const baseUrl = configService.get('TODO_BACKEND_URL', 'http://localhost:3018'); - return new TodoApiService(baseUrl); - }, - inject: [ConfigService], -}; - -@Module({ - imports: [ - ConfigModule, - TranscriptionModule.forRoot(), - SessionModule.forRoot({ storageMode: 'redis' }), - CreditModule.forRoot(), - GiftModule.forRoot(), - I18nModule.forRoot(), - ], - providers: [MatrixService, todoApiServiceProvider], - exports: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-todo-bot/src/bot/matrix.service.ts b/services/matrix-todo-bot/src/bot/matrix.service.ts deleted file mode 100644 index ca477f18a..000000000 --- a/services/matrix-todo-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,888 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, - handleCreditCommand, - handleGiftCommand, - type CreditCommandsHost, - type GiftCommandsHost, -} from '@manacore/matrix-bot-common'; -import { - TranscriptionService, - SessionService, - CreditService, - GiftService, - TodoApiService, - Task as ApiTask, - I18nService, - Language, - LANGUAGE_NAMES, -} from '@manacore/bot-services'; -import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; - -// Credit cost for task creation (micro-credits) -const TASK_CREATE_CREDITS = 0.02; - -// Alias for consistency -type Task = ApiTask; - -@Injectable() -export class MatrixService - extends BaseMatrixService - implements CreditCommandsHost, GiftCommandsHost -{ - // Expose services for credit and gift commands mixins - public creditService: CreditService; - public giftService: GiftService; - public i18nService: I18nService; - public sessionService: SessionService; - private readonly keywordDetector = new KeywordCommandDetector( - [ - ...COMMON_KEYWORDS, - { keywords: ['was kannst du', 'hilfe', 'help'], command: 'help' }, - { - keywords: [ - 'zeige aufgaben', - 'meine aufgaben', - 'was muss ich', - 'show tasks', - 'list', - 'liste', - 'alle', - ], - command: 'list', - }, - { keywords: ['heute', 'today', 'was steht an'], command: 'today' }, - { keywords: ['inbox', 'eingang', 'ohne datum'], command: 'inbox' }, - { keywords: ['projekte', 'projects'], command: 'projects' }, - { keywords: ['verbindung', 'connection', 'status'], command: 'status' }, - { keywords: ['neu', 'neue', 'add'], command: 'add' }, - { keywords: ['erledigt', 'fertig', 'done'], command: 'done' }, - { keywords: ['löschen', 'entfernen', 'delete'], command: 'delete' }, - { keywords: ['projekt', 'project'], command: 'project' }, - { keywords: ['pin'], command: 'pin' }, - { keywords: ['login', 'anmelden'], command: 'login' }, - { keywords: ['logout', 'abmelden'], command: 'logout' }, - { keywords: ['sprache', 'language', 'lang'], command: 'language' }, - { keywords: ['credits', 'guthaben', 'kontostand'], command: 'credits' }, - { keywords: ['packages', 'pakete', 'preise'], command: 'packages' }, - { keywords: ['kaufen', 'buy'], command: 'buy' }, - ], - { partialMatch: true } - ); - - constructor( - configService: ConfigService, - private todoApiService: TodoApiService, - private transcriptionService: TranscriptionService, - sessionService: SessionService, - creditService: CreditService, - giftService: GiftService, - i18nService: I18nService - ) { - super(configService); - // Assign to public properties for credit and gift commands mixins - this.sessionService = sessionService; - this.creditService = creditService; - this.giftService = giftService; - this.i18nService = i18nService; - } - - // ============================================================================ - // CreditCommandsHost interface implementation - // ============================================================================ - - /** - * Send a credit message (delegates to protected sendMessage) - */ - async sendCreditMessage(roomId: string, message: string): Promise { - await this.sendMessage(roomId, message); - } - - /** - * Send a credit reply (delegates to protected sendReply) - */ - async sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise { - await this.sendReply(roomId, event, message); - } - - // ============================================================================ - // GiftCommandsHost interface implementation - // ============================================================================ - - /** - * Send a gift message (delegates to protected sendMessage) - */ - async sendGiftMessage(roomId: string, message: string): Promise { - await this.sendMessage(roomId, message); - } - - /** - * Send a gift reply (delegates to protected sendReply) - */ - async sendGiftReply(roomId: string, event: MatrixRoomEvent, message: string): Promise { - await this.sendReply(roomId, event, message); - } - - // ============================================================================ - // Private helpers - // ============================================================================ - - /** - * Check if user is logged in and has a valid token for API access - */ - private async getToken(userId: string): Promise { - return this.sessionService.getToken(userId); - } - - /** - * Require login - returns token or sends login prompt and returns null - */ - private async requireLogin( - roomId: string, - event: MatrixRoomEvent, - userId: string - ): Promise { - const token = await this.getToken(userId); - if (!token) { - await this.sendReply( - roomId, - event, - '🔐 **Login erforderlich**\n\n' + - 'Um Aufgaben zu verwalten, melde dich bitte an:\n\n' + - '`login deine@email.de deinpasswort`\n\n' + - 'Deine Aufgaben werden dann mit der Todo-App synchronisiert.' - ); - return null; - } - return token; - } - - /** - * Normalize task from API format - */ - private normalizeTask(task: ApiTask): Task { - return { - id: task.id, - title: task.title, - completed: task.completed, - priority: task.priority, - dueDate: task.dueDate, - project: task.project, - labels: task.labels || [], - createdAt: task.createdAt, - completedAt: task.completedAt || null, - userId: task.userId, - }; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string { - return BOT_INTRODUCTION; - } - - // Commands that can be used without ! prefix - private readonly directCommands = [ - 'help', - 'hilfe', - 'add', - 'neu', - 'neue', - 'list', - 'liste', - 'alle', - 'heute', - 'today', - 'inbox', - 'eingang', - 'done', - 'erledigt', - 'fertig', - 'delete', - 'löschen', - 'entfernen', - 'projects', - 'projekte', - 'project', - 'projekt', - 'status', - 'pin', - 'login', - 'logout', - 'language', - 'sprache', - 'lang', - // Credit commands - 'credits', - 'guthaben', - 'packages', - 'pakete', - 'buy', - 'kaufen', - // Gift commands - 'geschenk', - 'gift', - 'einloesen', - 'redeem', - 'meine-geschenke', - 'my-gifts', - ]; - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - const userId = event.sender; - - try { - // Check for ! commands first - if (body.startsWith('!')) { - const [command, ...args] = body.slice(1).split(' '); - await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); - return; - } - - // Check for direct commands (without ! prefix) - const trimmedBody = body.trim(); - const words = trimmedBody.split(/\s+/); - const firstWord = words[0].toLowerCase(); - - if (this.directCommands.includes(firstWord)) { - const args = words.slice(1).join(' '); - await this.executeCommand(roomId, event, userId, firstWord, args); - return; - } - - // Check for natural language keywords - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - // For commands that need args, try to extract from the message - const args = this.extractArgsAfterKeyword(body, keywordCommand); - await this.executeCommand(roomId, event, userId, keywordCommand, args); - return; - } - - // Fallback: treat any message as a task - await this.handleAddTask(roomId, event, userId, body); - } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.'); - } - } - - /** - * Extract arguments after a keyword match - */ - private extractArgsAfterKeyword(body: string, command: string): string { - // Map commands to their trigger keywords - const commandKeywords: Record = { - add: ['neu', 'neue', 'add'], - done: ['erledigt', 'fertig', 'done'], - delete: ['löschen', 'entfernen', 'delete'], - project: ['projekt', 'project'], - login: ['login', 'anmelden'], - language: ['sprache', 'language', 'lang'], - }; - - const keywords = commandKeywords[command]; - if (!keywords) return ''; - - const lowerBody = body.toLowerCase(); - for (const keyword of keywords) { - const index = lowerBody.indexOf(keyword); - if (index !== -1) { - return body.substring(index + keyword.length).trim(); - } - } - return ''; - } - - protected async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - const content = event.content; - if (!content?.url) return; - - try { - // Require login for audio messages - const token = await this.requireLogin(roomId, event, sender); - if (!token) return; - - await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); - - // Download audio from Matrix using authenticated API - const mxcUrl = content.url; - this.logger.log(`Downloading audio from ${mxcUrl}`); - - const buffer = await this.downloadMedia(mxcUrl); - - // Transcribe audio - const transcription = await this.transcriptionService.transcribe(buffer); - this.logger.log(`Transcription: ${transcription.substring(0, 50)}...`); - - if (!transcription.trim()) { - await this.sendReply( - roomId, - event, - 'Konnte keine Sprache erkennen. Bitte versuche es erneut.' - ); - return; - } - - // Check credits - const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS); - if (!validation.hasCredits) { - const errorMsg = this.creditService.formatInsufficientCreditsError( - TASK_CREATE_CREDITS, - validation.availableCredits, - 'Aufgabe erstellen' - ); - await this.sendReply( - roomId, - event, - `Transkription: "${transcription}"\n\n${errorMsg.text}` - ); - return; - } - - // Use API service (syncs with todo-web and mobile) - const { title, priority, dueDate, project } = - this.todoApiService.parseTaskInput(transcription); - const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate }); - if (!apiTask) { - await this.sendReply( - roomId, - event, - `Transkription: "${transcription}"\n\nFehler beim Erstellen der Aufgabe.` - ); - return; - } - const task = this.normalizeTask(apiTask); - task.project = project; - - let responseText = `Aufgabe erstellt: **${task.title}**`; - - const details: string[] = []; - if (task.priority < 4) details.push(`Priorität ${task.priority}`); - if (task.dueDate) details.push(`${this.formatDate(task.dueDate)}`); - if (task.project) details.push(`#${task.project}`); - - if (details.length > 0) { - responseText += ` · ${details.join(' · ')}`; - } - - await this.sendMessage(roomId, responseText); - } catch (error) { - this.logger.error('Audio processing failed:', error); - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendReply(roomId, event, `Fehler bei der Verarbeitung: ${errorMsg}`); - } - } - - private async executeCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - command: string, - args: string - ) { - // Handle credit commands first (credits, packages, buy) - if (await handleCreditCommand(this, roomId, event, userId, command, args)) { - return; - } - - // Handle gift commands (geschenk, einloesen, meine-geschenke) - if (await handleGiftCommand(this, roomId, event, userId, command, args)) { - return; - } - - switch (command) { - case 'help': - case 'hilfe': - await this.sendReply(roomId, event, HELP_TEXT); - break; - - case 'add': - case 'neu': - case 'neue': - await this.handleAddTask(roomId, event, userId, args); - break; - - case 'list': - case 'liste': - case 'alle': - await this.handleListTasks(roomId, event, userId); - break; - - case 'today': - case 'heute': - await this.handleTodayTasks(roomId, event, userId); - break; - - case 'inbox': - case 'eingang': - await this.handleInboxTasks(roomId, event, userId); - break; - - case 'done': - case 'erledigt': - case 'fertig': - await this.handleCompleteTask(roomId, event, userId, args); - break; - - case 'delete': - case 'löschen': - case 'entfernen': - await this.handleDeleteTask(roomId, event, userId, args); - break; - - case 'projects': - case 'projekte': - await this.handleProjects(roomId, event, userId); - break; - - case 'project': - case 'projekt': - await this.handleProjectTasks(roomId, event, userId, args); - break; - - case 'status': - await this.handleStatus(roomId, event, userId); - break; - - case 'pin': - await this.handlePinHelp(roomId, event); - break; - - case 'language': - case 'sprache': - case 'lang': - await this.handleLanguage(roomId, event, userId, args); - break; - - default: - // Unknown command - ignore silently or send help - break; - } - } - - private async handleLanguage( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - const lang = args.trim().toLowerCase(); - - // Show current language if no argument - if (!lang) { - const currentLang = await this.i18nService.getLanguage(userId); - const langName = LANGUAGE_NAMES[currentLang]; - const available = this.i18nService - .getAvailableLanguages() - .map((l) => `${l} (${LANGUAGE_NAMES[l]})`) - .join(', '); - await this.sendReply( - roomId, - event, - `**Sprache / Language:** ${langName}\n\n**Verfügbar / Available:** ${available}\n\nÄndern / Change: \`sprache de\` oder / or \`sprache en\`` - ); - return; - } - - // Validate and set language - if (!this.i18nService.isValidLanguage(lang)) { - const available = this.i18nService.getAvailableLanguages().join(', '); - await this.sendReply( - roomId, - event, - `Unbekannte Sprache / Unknown language: ${lang}\n\nVerfügbar / Available: ${available}` - ); - return; - } - - await this.i18nService.setLanguage(userId, lang as Language); - const langName = LANGUAGE_NAMES[lang as Language]; - - // Respond in the new language - if (lang === 'de') { - await this.sendReply(roomId, event, `Sprache geändert zu: **${langName}**`); - } else { - await this.sendReply(roomId, event, `Language changed to: **${langName}**`); - } - } - - private async handleAddTask( - roomId: string, - event: MatrixRoomEvent, - userId: string, - input: string - ) { - if (!input.trim()) { - await this.sendReply( - roomId, - event, - 'Bitte gib eine Aufgabe an.\n\nBeispiel: `neu Einkaufen gehen`' - ); - return; - } - - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - // Check credits - const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS); - if (!validation.hasCredits) { - const errorMsg = this.creditService.formatInsufficientCreditsError( - TASK_CREATE_CREDITS, - validation.availableCredits, - 'Aufgabe erstellen' - ); - await this.sendReply(roomId, event, errorMsg.text); - return; - } - - // Use API service (syncs with todo-web and mobile) - const { title, priority, dueDate, project } = this.todoApiService.parseTaskInput(input); - const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate }); - if (!apiTask) { - await this.sendReply( - roomId, - event, - 'Fehler beim Erstellen der Aufgabe. Bitte versuche es erneut.' - ); - return; - } - const task = this.normalizeTask(apiTask); - task.project = project; // Note: project handling via API needs project ID lookup - - let response = `Aufgabe erstellt: **${task.title}**`; - - const details: string[] = []; - if (task.priority < 4) details.push(`Priorität ${task.priority}`); - if (task.dueDate) details.push(`${this.formatDate(task.dueDate)}`); - if (task.project) details.push(`#${task.project}`); - - if (details.length > 0) { - response += ` · ${details.join(' · ')}`; - } - - await this.sendMessage(roomId, response); - } - - private async handleListTasks(roomId: string, event: MatrixRoomEvent, userId: string) { - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - const apiTasks = await this.todoApiService.getTasks(token, { completed: false }); - const tasks = apiTasks.map((t) => this.normalizeTask(t)); - - if (tasks.length === 0) { - await this.sendReply( - roomId, - event, - 'Keine offenen Aufgaben.\n\nErstelle eine mit `neu [Aufgabe]`' - ); - return; - } - - const response = this.formatTaskList('**Alle offenen Aufgaben:**', tasks); - await this.sendMessage(roomId, response); - } - - private async handleTodayTasks(roomId: string, event: MatrixRoomEvent, userId: string) { - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - const apiTodayTasks = await this.todoApiService.getTodayTasks(token); - const apiInboxTasks = await this.todoApiService.getInboxTasks(token); - const todayTasks = apiTodayTasks.map((t) => this.normalizeTask(t)); - const inboxTasks = apiInboxTasks.map((t) => this.normalizeTask(t)); - - const hasTodayTasks = todayTasks.length > 0; - const hasInboxTasks = inboxTasks.length > 0; - - if (!hasTodayTasks && !hasInboxTasks) { - await this.sendReply( - roomId, - event, - 'Keine Aufgaben.\n\nErstelle eine mit `neu Aufgabe` oder `neu Aufgabe @heute`' - ); - return; - } - - let response = ''; - - if (hasTodayTasks) { - response += this.formatTaskList('**Aufgaben fuer heute:**', todayTasks); - } - - if (hasInboxTasks) { - if (hasTodayTasks) { - response += '\n\n'; - } - response += this.formatTaskList('**Inbox (ohne Datum):**', inboxTasks); - } - - await this.sendMessage(roomId, response); - } - - private async handleInboxTasks(roomId: string, event: MatrixRoomEvent, userId: string) { - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - const apiTasks = await this.todoApiService.getInboxTasks(token); - const tasks = apiTasks.map((t) => this.normalizeTask(t)); - - if (tasks.length === 0) { - await this.sendReply(roomId, event, 'Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'); - return; - } - - const response = this.formatTaskList('**Inbox (ohne Datum):**', tasks); - await this.sendMessage(roomId, response); - } - - private async handleCompleteTask( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - const taskNumber = parseInt(args.trim()); - - if (isNaN(taskNumber) || taskNumber < 1) { - await this.sendReply( - roomId, - event, - 'Bitte gib eine gueltige Aufgabennummer an.\n\nBeispiel: `erledigt 1`' - ); - return; - } - - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - let task: Task | null = null; - - // Use API service - need to get task list first to find task by index - const apiTasks = await this.todoApiService.getTasks(token, { completed: false }); - if (taskNumber > 0 && taskNumber <= apiTasks.length) { - const targetTask = apiTasks[taskNumber - 1]; - const completedTask = await this.todoApiService.completeTask(token, targetTask.id); - if (completedTask) { - task = this.normalizeTask(completedTask); - } - } - - if (!task) { - await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`); - return; - } - - await this.sendMessage(roomId, `✓ ~~${task.title}~~`); - } - - private async handleDeleteTask( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - const taskNumber = parseInt(args.trim()); - - if (isNaN(taskNumber) || taskNumber < 1) { - await this.sendReply( - roomId, - event, - 'Bitte gib eine gueltige Aufgabennummer an.\n\nBeispiel: `löschen 1`' - ); - return; - } - - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - let task: Task | null = null; - - // Use API service - need to get task list first to find task by index - const apiTasks = await this.todoApiService.getTasks(token, { completed: false }); - if (taskNumber > 0 && taskNumber <= apiTasks.length) { - const targetTask = apiTasks[taskNumber - 1]; - const deleted = await this.todoApiService.deleteTask(token, targetTask.id); - if (deleted) { - task = this.normalizeTask(targetTask); - } - } - - if (!task) { - await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`); - return; - } - - await this.sendMessage(roomId, `🗑️ ${task.title}`); - } - - private async handleProjects(roomId: string, event: MatrixRoomEvent, userId: string) { - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - const projects = await this.todoApiService.getProjects(token); - - if (projects.length === 0) { - await this.sendReply( - roomId, - event, - 'Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `neu Aufgabe #projektname`' - ); - return; - } - - let response = '**Deine Projekte:**\n\n'; - for (const project of projects) { - response += `- ${project.name}\n`; - } - response += '\nZeige Projektaufgaben mit `projekt [Name]`'; - - await this.sendMessage(roomId, response); - } - - private async handleProjectTasks( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - const projectName = args.trim(); - - if (!projectName) { - await this.sendReply( - roomId, - event, - 'Bitte gib einen Projektnamen an.\n\nBeispiel: `projekt Arbeit`' - ); - return; - } - - // Require login - const token = await this.requireLogin(roomId, event, userId); - if (!token) return; - - let tasks: Task[] = []; - - // Use API service - need to find project ID first - const projects = await this.todoApiService.getProjects(token); - const project = projects.find((p) => p.name.toLowerCase() === projectName.toLowerCase()); - if (project) { - const apiTasks = await this.todoApiService.getProjectTasks(token, project.id); - tasks = apiTasks.map((t) => this.normalizeTask(t)); - } - - if (tasks.length === 0) { - await this.sendReply(roomId, event, `Keine Aufgaben im Projekt "${projectName}".`); - return; - } - - const response = this.formatTaskList(`**Projekt: ${projectName}**`, tasks); - await this.sendMessage(roomId, response); - } - - private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) { - const token = await this.getToken(userId); - const isLoggedIn = await this.sessionService.isLoggedIn(userId); - const email = this.sessionService.getEmail(userId); - - if (token) { - const stats = await this.todoApiService.getStats(token); - const response = `**Status** - -👤 ${email} - -- Offen: ${stats.pending} -- Heute: ${stats.today} -- Erledigt: ${stats.completed}`; - - await this.sendMessage(roomId, response); - } else { - const response = `**Status** - -🔐 Nicht angemeldet - -\`login email passwort\``; - - await this.sendMessage(roomId, response); - } - } - - private async handlePinHelp(roomId: string, event: MatrixRoomEvent) { - try { - // Send help message - const helpEventId = await this.sendMessage(roomId, HELP_TEXT); - - // Pin it - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [helpEventId], - }); - - await this.sendReply(roomId, event, 'Hilfe wurde angepinnt!'); - } catch (error) { - this.logger.error('Failed to pin help:', error); - await this.sendReply(roomId, event, 'Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)'); - } - } - - private formatTaskList(header: string, tasks: Task[]): string { - let response = `${header}\n\n`; - - tasks.forEach((task, index) => { - const num = index + 1; - const priority = task.priority < 4 ? `!`.repeat(4 - task.priority) : ''; - const date = task.dueDate ? ` ${this.formatDate(task.dueDate)}` : ''; - const project = task.project ? ` ${task.project}` : ''; - - response += `**${num}.** ${task.title}${priority}${date}${project}\n`; - }); - - response += `\nErledigen: \`erledigt [Nr]\` | Loeschen: \`löschen [Nr]\``; - return response; - } - - private formatDate(dateStr: string): string { - const date = new Date(dateStr); - const today = new Date(); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - if (dateStr === today.toISOString().split('T')[0]) { - return 'Heute'; - } else if (dateStr === tomorrow.toISOString().split('T')[0]) { - return 'Morgen'; - } - - return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); - } -} diff --git a/services/matrix-todo-bot/src/config/configuration.ts b/services/matrix-todo-bot/src/config/configuration.ts deleted file mode 100644 index e3b9a600f..000000000 --- a/services/matrix-todo-bot/src/config/configuration.ts +++ /dev/null @@ -1,63 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3314', 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', - }, - todo: { - apiUrl: process.env.TODO_API_URL || 'http://localhost:3010/api/v1', - serviceKey: process.env.TODO_SERVICE_KEY || '', - }, - stt: { - url: process.env.STT_URL || 'http://localhost:3020', - }, -}); - -export const HELP_TEXT = `🎯 **Todo Bot - Hilfe** - -**Aufgaben verwalten:** -• \`neu [Aufgabe]\` - Neue Aufgabe hinzufügen -• Sprachnotiz senden - Aufgabe per Sprache erstellen -• \`heute\` - Heutige Aufgaben + Inbox anzeigen -• \`liste\` - Alle offenen Aufgaben -• \`inbox\` - Nur Aufgaben ohne Datum -• \`erledigt [Nr]\` - Aufgabe als erledigt markieren -• \`löschen [Nr]\` - Aufgabe löschen - -**Projekte:** -• \`projekte\` - Alle Projekte anzeigen -• \`projekt [Name]\` - Aufgaben eines Projekts anzeigen - -**Prioritäten & Datum:** -• \`neu Wichtige Aufgabe !p1\` - Höchste Priorität (1-4) -• \`neu Morgen machen @morgen\` - Datum setzen -• \`neu Heute erledigen @heute\` - Heute fällig - -**Sonstiges:** -• \`status\` - Verbindungsstatus prüfen -• \`hilfe\` - Diese Hilfe anzeigen - -**Tipp:** Alle Befehle funktionieren auch mit \`!\` davor (z.B. \`!neu\`)`; - -export const WELCOME_TEXT = `👋 **Willkommen beim Todo Bot!** - -Ich helfe dir, deine Aufgaben zu verwalten. Hier sind die wichtigsten Befehle: - -• \`neu [Aufgabe]\` - Neue Aufgabe erstellen -• \`heute\` - Heutige Aufgaben + Inbox anzeigen -• \`erledigt [Nr]\` - Aufgabe abhaken - -Schreibe einfach "hilfe" für alle Befehle.`; - -export const BOT_INTRODUCTION = `🎯 **Hallo! Ich bin der Todo Bot.** - -Ich bin jetzt diesem Raum beigetreten und kann dir bei der Aufgabenverwaltung helfen. - -**Schnellstart:** -• \`neu Einkaufen gehen\` - Aufgabe erstellen -• \`heute\` - Deine Aufgaben sehen -• \`erledigt 1\` - Erste Aufgabe abhaken - -Schreibe "hilfe" für alle Befehle!`; diff --git a/services/matrix-todo-bot/src/main.ts b/services/matrix-todo-bot/src/main.ts deleted file mode 100644 index e5cf550c7..000000000 --- a/services/matrix-todo-bot/src/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { ConfigService } from '@nestjs/config'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - const configService = app.get(ConfigService); - const port = configService.get('port', 3314); - - await app.listen(port); - logger.log(`Todo Bot is running on port ${port}`); -} - -bootstrap(); diff --git a/services/matrix-todo-bot/tsconfig.build.json b/services/matrix-todo-bot/tsconfig.build.json deleted file mode 100644 index 4491981e0..000000000 --- a/services/matrix-todo-bot/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/services/matrix-todo-bot/tsconfig.json b/services/matrix-todo-bot/tsconfig.json deleted file mode 100644 index b439390d0..000000000 --- a/services/matrix-todo-bot/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2022", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true - } -} diff --git a/services/matrix-tts-bot/.env.example b/services/matrix-tts-bot/.env.example deleted file mode 100644 index 1cd9b5a49..000000000 --- a/services/matrix-tts-bot/.env.example +++ /dev/null @@ -1,17 +0,0 @@ -# Server -PORT=3023 - -# Matrix Configuration -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS= -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# TTS Service (mana-tts) -TTS_URL=http://localhost:3022 -TTS_API_KEY=sk-internal-xxx # Internal API key for mana-tts service - -# TTS Defaults -DEFAULT_VOICE=af_heart -DEFAULT_SPEED=1.0 -MAX_TEXT_LENGTH=500 diff --git a/services/matrix-tts-bot/CLAUDE.md b/services/matrix-tts-bot/CLAUDE.md deleted file mode 100644 index 7a54273c0..000000000 --- a/services/matrix-tts-bot/CLAUDE.md +++ /dev/null @@ -1,194 +0,0 @@ -# Matrix TTS Bot - Claude Code Guidelines - -## Overview - -Matrix TTS Bot converts text messages to speech and sends them back as audio messages. Uses the mana-tts service (port 3022) for synthesis. - -## Tech Stack - -- **Framework**: NestJS 10 -- **Matrix**: matrix-bot-sdk -- **TTS Backend**: mana-tts service (Piper for German, Kokoro for English) - -## 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-tts-bot/ -├── src/ -│ ├── main.ts # Application entry point (port 3023) -│ ├── app.module.ts # Root module -│ ├── health.controller.ts # Health check endpoint -│ ├── config/ -│ │ └── configuration.ts # Configuration & help text -│ ├── bot/ -│ │ ├── bot.module.ts -│ │ └── matrix.service.ts # Matrix client & message handler -│ └── tts/ -│ ├── tts.module.ts -│ └── tts.service.ts # mana-tts API client -├── Dockerfile -└── package.json -``` - -## Bot Commands - -| Command | Description | -|---------|-------------| -| `!help` / `!hilfe` | Show help text | -| `!voice [name]` | Change voice (e.g., `!voice bm_daniel`) | -| `!voices` | List available voices | -| `!speed [0.5-2.0]` | Change speech speed | -| `!status` | Show current settings | -| (any text) | Convert to speech | - -## Message Flow - -1. User sends text message -2. Bot receives via matrix-bot-sdk -3. TTS service synthesizes audio -4. Audio uploaded to Matrix -5. Audio message sent back to room - -## Environment Variables - -```env -# Server -PORT=3023 - -# Matrix -MATRIX_HOMESERVER_URL=http://localhost:8008 -MATRIX_ACCESS_TOKEN=syt_xxx -MATRIX_ALLOWED_ROOMS=!roomid:matrix.mana.how -MATRIX_STORAGE_PATH=./data/bot-storage.json - -# TTS Service -TTS_URL=http://localhost:3022 -TTS_API_KEY=sk-internal-xxx - -# Defaults -DEFAULT_VOICE=de_kerstin -DEFAULT_SPEED=1.0 -MAX_TEXT_LENGTH=500 -``` - -## TTS API Integration - -The bot auto-detects language and uses the appropriate endpoint: - -```typescript -// German voices use /synthesize/auto (routes to Piper) -POST /synthesize/auto -{ - "text": "Hallo Welt", - "voice": "de_kerstin", - "speed": 1.0, - "output_format": "wav" -} - -// English voices use /synthesize/kokoro -POST /synthesize/kokoro -{ - "text": "Hello world", - "voice": "af_heart", - "speed": 1.0, - "output_format": "wav" -} - -// Response: audio/wav binary -``` - -**Language Detection**: The bot automatically detects German text (via German characters like äöüß or common German words) and switches to a German voice if needed. - -## Available Voices - -### German (Local - Piper) - -| Voice ID | Description | -|----------|-------------| -| `de_kerstin` | German female (default) | -| `de_thorsten` | German male | - -### German (Cloud - Edge TTS) - -| Voice ID | Description | -|----------|-------------| -| `de_katja` | German female | -| `de_conrad` | German male | -| `de_amala` | German female (young) | -| `de_florian` | German male (young) | - -### English (Kokoro) - -| Voice ID | Description | -|----------|-------------| -| `af_heart` | American female (warm) | -| `af_bella` | American female (expressive) | -| `am_michael` | American male (trustworthy) | -| `bm_daniel` | British male (classic) | -| `bf_emma` | British female (professional) | - -## Docker - -```bash -# Build -docker build -f services/matrix-tts-bot/Dockerfile -t matrix-tts-bot services/matrix-tts-bot - -# Run -docker run -p 3023:3023 \ - -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ - -e MATRIX_ACCESS_TOKEN=syt_xxx \ - -e TTS_URL=http://mana-tts:3022 \ - -v matrix-tts-bot-data:/app/data \ - matrix-tts-bot -``` - -## Health Check - -```bash -curl http://localhost:3023/health -``` - -## Dependencies - -- **mana-tts**: Must be running on port 3022 (or configured via `TTS_URL`) -- **Matrix homeserver**: Synapse or compatible homeserver - -## User Settings - -Settings are stored in-memory per Matrix user ID: -- Voice selection persists during bot runtime -- Speed setting persists during bot runtime -- Settings reset when bot restarts - -## Testing - -```bash -# 1. Ensure mana-tts is running -curl http://localhost:3022/health - -# 2. Start the bot -cd services/matrix-tts-bot -pnpm start:dev - -# 3. Check bot health -curl http://localhost:3023/health - -# 4. In Matrix: -# - Invite bot to a room -# - Send a text message -# - Receive audio response -``` diff --git a/services/matrix-tts-bot/Dockerfile b/services/matrix-tts-bot/Dockerfile deleted file mode 100644 index 75f8b77fa..000000000 --- a/services/matrix-tts-bot/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common - -# Copy this bot -COPY services/matrix-tts-bot ./services/matrix-tts-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-tts-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-tts-bot/dist ./services/matrix-tts-bot/dist -COPY --from=builder /app/services/matrix-tts-bot/package.json ./services/matrix-tts-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-tts-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4019/health || exit 1 - -EXPOSE 4019 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-tts-bot/data/.gitkeep b/services/matrix-tts-bot/data/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/matrix-tts-bot/data/bot-storage.json b/services/matrix-tts-bot/data/bot-storage.json deleted file mode 100644 index 760fb5ba8..000000000 --- a/services/matrix-tts-bot/data/bot-storage.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "syncToken": "s467_20950_20_46_85_1_3_41_0_1_2", - "filter": null, - "appserviceUsers": {}, - "appserviceTransactions": {}, - "kvStore": {} -} \ No newline at end of file diff --git a/services/matrix-tts-bot/nest-cli.json b/services/matrix-tts-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-tts-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/services/matrix-tts-bot/package.json b/services/matrix-tts-bot/package.json deleted file mode 100644 index 5237c9e21..000000000 --- a/services/matrix-tts-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@manacore/matrix-tts-bot", - "version": "1.0.0", - "description": "Matrix bot for text-to-speech conversion", - "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": "tsc -p tsconfig.build.json", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@nestjs/common": "^10.4.17", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.17", - "@nestjs/platform-express": "^10.4.17", - "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.7", - "typescript": "^5.7.3" - } -} diff --git a/services/matrix-tts-bot/src/app.module.ts b/services/matrix-tts-bot/src/app.module.ts deleted file mode 100644 index 6caad7205..000000000 --- a/services/matrix-tts-bot/src/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import { BotModule } from './bot/bot.module'; -import { TtsModule } from './tts/tts.module'; -import configuration from './config/configuration'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - TtsModule, - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-tts-bot')], -}) -export class AppModule {} diff --git a/services/matrix-tts-bot/src/bot/bot.module.ts b/services/matrix-tts-bot/src/bot/bot.module.ts deleted file mode 100644 index d436ac972..000000000 --- a/services/matrix-tts-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { TtsModule } from '../tts/tts.module'; -import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - TtsModule, - TranscriptionModule.register({ - sttUrl: process.env.STT_URL || 'http://localhost:3020', - }), - SessionModule.forRoot({ storageMode: 'redis' }), - CreditModule.forRoot(), - ], - providers: [MatrixService], -}) -export class BotModule {} diff --git a/services/matrix-tts-bot/src/bot/matrix.service.ts b/services/matrix-tts-bot/src/bot/matrix.service.ts deleted file mode 100644 index 065a04bd8..000000000 --- a/services/matrix-tts-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { TtsService } from '../tts/tts.service'; -import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services'; -import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration'; - -interface UserSettings { - voice: string; - speed: number; -} - -@Injectable() -export class MatrixService extends BaseMatrixService { - private readonly defaultVoice: string; - private readonly defaultSpeed: number; - private readonly maxTextLength: number; - - // User settings storage (in-memory) - private userSettings: Map = new Map(); - - // Track processed events to prevent duplicates - private processedEvents: Set = new Set(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { keywords: ['voice', 'stimme', 'stimme aendern'], command: 'voice' }, - { keywords: ['voices', 'stimmen', 'verfuegbare stimmen'], command: 'voices' }, - { keywords: ['speed', 'geschwindigkeit', 'tempo'], command: 'speed' }, - ]); - - constructor( - configService: ConfigService, - private ttsService: TtsService, - private readonly transcriptionService: TranscriptionService, - private sessionService: SessionService, - private creditService: CreditService - ) { - super(configService); - this.defaultVoice = this.configService.get('tts.defaultVoice') || 'af_heart'; - this.defaultSpeed = this.configService.get('tts.defaultSpeed') || 1.0; - this.maxTextLength = this.configService.get('tts.maxTextLength') || 500; - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string { - return WELCOME_TEXT; - } - - protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise { - // Ignore own messages - if (event.sender === this.botUserId) return; - - // Prevent duplicate processing - const eventId = event.event_id; - if (eventId && this.processedEvents.has(eventId)) { - return; - } - if (eventId) { - this.processedEvents.add(eventId); - // Clean up old events (keep last 1000) - if (this.processedEvents.size > 1000) { - const iterator = this.processedEvents.values(); - const firstValue = iterator.next().value; - if (firstValue) { - this.processedEvents.delete(firstValue); - } - } - } - - // Check room allowlist - if (!this.isRoomAllowed(roomId)) { - return; - } - - const msgtype = event.content?.msgtype; - - // Only handle text messages - if (msgtype !== 'm.text') return; - - const body = event.content?.body?.trim(); - if (!body) return; - - await this.handleTextMessage(roomId, event, body); - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - const userId = event.sender; - - try { - // Check for keyword commands first - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - body = `!${keywordCommand}`; - } - - // Handle ! commands - if (body.startsWith('!')) { - const [command, ...args] = body.slice(1).split(' '); - await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); - return; - } - - // Convert text to speech - await this.handleTextToSpeech(roomId, event, userId, body); - } catch (error) { - this.logger.error(`Error handling message: ${error}`); - await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten.'); - } - } - - protected override async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - _sender: string - ): Promise { - try { - const mxcUrl = event.content.url; - if (!mxcUrl) return; - - const audioBuffer = await this.downloadMedia(mxcUrl); - const text = await this.transcriptionService.transcribe(audioBuffer); - if (!text) { - await this.sendReply(roomId, event, 'Sprachnachricht konnte nicht erkannt werden.'); - return; - } - - await this.sendMessage(roomId, `*"${text}"*`); - await this.handleTextMessage(roomId, event, text); - } catch (error) { - this.logger.error(`Audio transcription error: ${error}`); - await this.sendReply(roomId, event, 'Fehler bei der Spracherkennung.'); - } - } - - private async executeCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - command: string, - args: string - ) { - switch (command) { - case 'help': - case 'hilfe': - await this.sendReply(roomId, event, HELP_TEXT); - break; - - case 'voice': - case 'stimme': - await this.handleVoiceCommand(roomId, event, userId, args); - break; - - case 'voices': - case 'stimmen': - await this.handleVoicesCommand(roomId, event); - break; - - case 'speed': - case 'geschwindigkeit': - await this.handleSpeedCommand(roomId, event, userId, args); - break; - - case 'status': - await this.handleStatusCommand(roomId, event, userId); - break; - - default: - // Silently ignore unknown commands - break; - } - } - - private async handleVoiceCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - if (!args.trim()) { - await this.sendReply( - roomId, - event, - '**Verwendung:** `!voice [name]`\n\nBeispiel: `!voice bm_daniel`\n\nZeige alle Stimmen mit `!voices`' - ); - return; - } - - const voiceName = args.trim().toLowerCase(); - - // Check if voice exists - const exists = await this.ttsService.voiceExists(voiceName); - if (!exists) { - await this.sendReply( - roomId, - event, - `Stimme "${voiceName}" nicht gefunden.\n\nZeige alle Stimmen mit \`!voices\`` - ); - return; - } - - // Update user settings - const settings = this.getUserSettings(userId); - settings.voice = voiceName; - this.userSettings.set(userId, settings); - - await this.sendReply(roomId, event, `Stimme geaendert zu: **${voiceName}**`); - } - - private async handleVoicesCommand(roomId: string, event: MatrixRoomEvent) { - try { - const voices = await this.ttsService.getVoices(); - - let response = '**Verfuegbare Stimmen:**\n\n'; - - if (voices.kokoro_voices.length > 0) { - response += '**Kokoro (schnell):**\n'; - const voiceList = voices.kokoro_voices - .slice(0, 15) // Limit to first 15 to avoid message being too long - .map((v) => `- \`${v.id}\``) - .join('\n'); - response += voiceList; - - if (voices.kokoro_voices.length > 15) { - response += `\n... und ${voices.kokoro_voices.length - 15} weitere`; - } - } - - if (voices.custom_voices.length > 0) { - response += '\n\n**Eigene Stimmen:**\n'; - response += voices.custom_voices.map((v) => `- \`${v.id}\` - ${v.name}`).join('\n'); - } - - response += '\n\nWechseln mit: `!voice [name]`'; - - await this.sendReply(roomId, event, response); - } catch (error) { - this.logger.error('Failed to get voices:', error); - await this.sendReply(roomId, event, 'Fehler beim Abrufen der Stimmen.'); - } - } - - private async handleSpeedCommand( - roomId: string, - event: MatrixRoomEvent, - userId: string, - args: string - ) { - if (!args.trim()) { - await this.sendReply( - roomId, - event, - '**Verwendung:** `!speed [0.5-2.0]`\n\nBeispiel: `!speed 1.2` (20% schneller)' - ); - return; - } - - const speed = parseFloat(args.trim()); - if (isNaN(speed) || speed < 0.5 || speed > 2.0) { - await this.sendReply(roomId, event, 'Geschwindigkeit muss zwischen 0.5 und 2.0 liegen.'); - return; - } - - const settings = this.getUserSettings(userId); - settings.speed = speed; - this.userSettings.set(userId, settings); - - await this.sendReply(roomId, event, `Geschwindigkeit geaendert zu: **${speed}x**`); - } - - private async handleStatusCommand(roomId: string, event: MatrixRoomEvent, userId: string) { - const settings = this.getUserSettings(userId); - const ttsHealthy = await this.ttsService.isHealthy(); - const loggedIn = await this.sessionService.isLoggedIn(userId); - const session = await this.sessionService.getSession(userId); - const token = await this.sessionService.getToken(userId); - - let response = '**Aktuelle Einstellungen:**\n\n'; - response += `Stimme: \`${settings.voice}\`\n`; - response += `Geschwindigkeit: ${settings.speed}x\n`; - response += `Max. Textlaenge: ${this.maxTextLength} Zeichen\n\n`; - response += `TTS-Service: ${ttsHealthy ? 'Online' : 'Offline'}\n`; - - if (loggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - response += `\n👤 Angemeldet als: ${session.email}\n`; - response += `⚡ Credits: ${balance.balance.toFixed(2)}`; - } - - await this.sendReply(roomId, event, response); - } - - private async handleTextToSpeech( - roomId: string, - event: MatrixRoomEvent, - userId: string, - text: string - ) { - // Check text length - if (text.length > this.maxTextLength) { - await this.sendReply( - roomId, - event, - `Text zu lang (${text.length}/${this.maxTextLength} Zeichen). Bitte kurze Nachricht senden.` - ); - return; - } - - const settings = this.getUserSettings(userId); - - // Set typing indicator - await this.client.setTyping(roomId, true, 30000); - - try { - // Synthesize speech - const audioBuffer = await this.ttsService.synthesize(text, settings.voice, settings.speed); - - // Stop typing indicator - await this.client.setTyping(roomId, false); - - // Upload audio to Matrix (WAV for better compatibility) - const mxcUrl = await this.client.uploadContent(audioBuffer, 'audio/wav', 'speech.wav'); - - // Calculate approximate duration (rough estimate based on text length and speed) - const estimatedDuration = Math.round(((text.length / 15) * 1000) / settings.speed); - - // Send audio message - await this.client.sendMessage(roomId, { - msgtype: 'm.audio', - body: 'speech.wav', - url: mxcUrl, - info: { - mimetype: 'audio/wav', - size: audioBuffer.length, - duration: estimatedDuration, - }, - }); - - this.logger.debug(`Sent audio message for text: "${text.substring(0, 30)}..."`); - } catch (error) { - await this.client.setTyping(roomId, false); - this.logger.error('TTS processing failed:', error); - await this.sendReply( - roomId, - event, - 'Fehler bei der Sprachsynthese. Ist der TTS-Service erreichbar?' - ); - } - } - - private getUserSettings(userId: string): UserSettings { - if (!this.userSettings.has(userId)) { - this.userSettings.set(userId, { - voice: this.defaultVoice, - speed: this.defaultSpeed, - }); - } - return this.userSettings.get(userId)!; - } -} diff --git a/services/matrix-tts-bot/src/config/configuration.ts b/services/matrix-tts-bot/src/config/configuration.ts deleted file mode 100644 index ceb8a789d..000000000 --- a/services/matrix-tts-bot/src/config/configuration.ts +++ /dev/null @@ -1,49 +0,0 @@ -export default () => ({ - port: parseInt(process.env.PORT || '3023', 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', - }, - tts: { - url: process.env.TTS_URL || 'http://localhost:3022', - apiKey: process.env.TTS_API_KEY || '', - defaultVoice: process.env.DEFAULT_VOICE || 'de_kerstin', - defaultSpeed: parseFloat(process.env.DEFAULT_SPEED || '1.0'), - maxTextLength: parseInt(process.env.MAX_TEXT_LENGTH || '500', 10), - }, -}); - -export const HELP_TEXT = `**TTS Bot - Hilfe** - -Ich wandle deine Textnachrichten in Sprache um! - -**Befehle:** -- \`!voice [name]\` - Stimme wechseln (z.B. \`!voice de_thorsten\`) -- \`!voices\` - Alle verfügbaren Stimmen anzeigen -- \`!speed [0.5-2.0]\` - Geschwindigkeit ändern -- \`!status\` - Aktuelle Einstellungen -- \`!help\` - Diese Hilfe - -**Verwendung:** -Schreibe einfach eine Nachricht und ich sende dir die Sprachausgabe zurück. -Die Sprache wird automatisch erkannt (Deutsch/Englisch). - -**Deutsche Stimmen:** -- \`de_kerstin\` - Deutsch weiblich (lokal, Standard) -- \`de_thorsten\` - Deutsch männlich (lokal) -- \`de_katja\` - Deutsch weiblich (Cloud) -- \`de_conrad\` - Deutsch männlich (Cloud) -- \`de_florian\` - Deutsch männlich jung (Cloud) - -**Englische Stimmen:** -- \`af_heart\` - Amerikanisch weiblich (warm) -- \`bm_daniel\` - Britisch männlich -- \`am_michael\` - Amerikanisch männlich`; - -export const WELCOME_TEXT = `**TTS Bot** - -Ich wandle Textnachrichten in Sprache um! - -Schreibe einfach eine Nachricht oder \`!help\` fur Hilfe.`; diff --git a/services/matrix-tts-bot/src/main.ts b/services/matrix-tts-bot/src/main.ts deleted file mode 100644 index f1a6b6fdc..000000000 --- a/services/matrix-tts-bot/src/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { Logger } from '@nestjs/common'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const port = process.env.PORT || 3023; - - await app.listen(port); - - const logger = new Logger('Bootstrap'); - logger.log(`Matrix TTS Bot running on port ${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); diff --git a/services/matrix-tts-bot/src/tts/tts.module.ts b/services/matrix-tts-bot/src/tts/tts.module.ts deleted file mode 100644 index a1d10edc8..000000000 --- a/services/matrix-tts-bot/src/tts/tts.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TtsService } from './tts.service'; - -@Module({ - providers: [TtsService], - exports: [TtsService], -}) -export class TtsModule {} diff --git a/services/matrix-tts-bot/src/tts/tts.service.ts b/services/matrix-tts-bot/src/tts/tts.service.ts deleted file mode 100644 index 4a10eb0f7..000000000 --- a/services/matrix-tts-bot/src/tts/tts.service.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface VoiceInfo { - id: string; - name: string; - description: string; - type: string; -} - -export interface VoicesResponse { - kokoro_voices: VoiceInfo[]; - custom_voices: VoiceInfo[]; -} - -// German voice mapping -const GERMAN_VOICES: Record = { - de_kerstin: 'de_kerstin', // Local Piper female - de_thorsten: 'de_thorsten', // Local Piper male - de_katja: 'de_katja', // Edge TTS female - de_conrad: 'de_conrad', // Edge TTS male - de_amala: 'de_amala', // Edge TTS female young - de_florian: 'de_florian', // Edge TTS male young -}; - -const DEFAULT_GERMAN_VOICE = 'de_kerstin'; - -// Common German words for language detection -const GERMAN_INDICATORS = [ - 'ich', - 'du', - 'er', - 'sie', - 'wir', - 'ihr', - 'und', - 'oder', - 'aber', - 'wenn', - 'dass', - 'ist', - 'sind', - 'war', - 'haben', - 'werden', - 'kann', - 'muss', - 'soll', - 'will', - 'nicht', - 'auch', - 'noch', - 'schon', - 'sehr', - 'nur', - 'mehr', - 'hier', - 'jetzt', - 'heute', - 'morgen', - 'gestern', - 'bitte', - 'danke', - 'hallo', - 'guten', - 'tag', - 'abend', - 'nacht', - 'wie', - 'was', - 'wer', - 'wo', - 'wann', - 'warum', - 'welche', - 'diese', - 'keine', - 'eine', - 'einen', - 'einem', - 'einer', -]; - -@Injectable() -export class TtsService { - private readonly logger = new Logger(TtsService.name); - private readonly ttsUrl: string; - private readonly apiKey: string; - - constructor(private configService: ConfigService) { - this.ttsUrl = this.configService.get('tts.url', 'http://localhost:3022'); - this.apiKey = this.configService.get('tts.apiKey', ''); - } - - /** - * Detect if text is likely German - */ - private isGerman(text: string): boolean { - const lowerText = text.toLowerCase(); - - // Check for German-specific characters - if (/[äöüß]/.test(lowerText)) { - return true; - } - - // Check for common German words - const words = lowerText.split(/\s+/); - const germanWordCount = words.filter((word) => - GERMAN_INDICATORS.includes(word.replace(/[.,!?;:'"]/g, '')) - ).length; - - // If more than 20% of words are German indicators, consider it German - return germanWordCount / words.length > 0.2; - } - - /** - * Check if voice is a German voice - */ - private isGermanVoice(voice: string): boolean { - return voice.startsWith('de_'); - } - - /** - * Synthesize text to speech - auto-detects language - */ - async synthesize( - text: string, - voice: string = 'de_thorsten', - speed: number = 1.0 - ): Promise { - // Auto-detect language if using English voice but text is German - const textIsGerman = this.isGerman(text); - const voiceIsGerman = this.isGermanVoice(voice); - - if (textIsGerman && !voiceIsGerman) { - this.logger.debug(`German text detected, switching to German voice`); - return this.synthesizeGerman(text, DEFAULT_GERMAN_VOICE, speed); - } - - if (voiceIsGerman) { - return this.synthesizeGerman(text, voice, speed); - } - - return this.synthesizeKokoro(text, voice, speed); - } - - /** - * Synthesize using Kokoro (English voices) - */ - private async synthesizeKokoro( - text: string, - voice: string = 'af_heart', - speed: number = 1.0 - ): Promise { - const url = `${this.ttsUrl}/synthesize/kokoro`; - - this.logger.debug( - `Kokoro synthesizing: "${text.substring(0, 50)}..." with voice=${voice}, speed=${speed}` - ); - - const headers: Record = { 'Content-Type': 'application/json' }; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({ - text, - voice, - speed, - output_format: 'wav', - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`Kokoro TTS failed: ${response.status} - ${errorText}`); - throw new Error(`TTS synthesis failed: ${response.status}`); - } - - const arrayBuffer = await response.arrayBuffer(); - this.logger.debug(`Received audio: ${arrayBuffer.byteLength} bytes`); - - return Buffer.from(arrayBuffer); - } - - /** - * Synthesize using auto endpoint (German voices via Piper/Edge) - */ - private async synthesizeGerman( - text: string, - voice: string = DEFAULT_GERMAN_VOICE, - speed: number = 1.0 - ): Promise { - const url = `${this.ttsUrl}/synthesize/auto`; - - // Map voice to valid German voice - const germanVoice = GERMAN_VOICES[voice] || DEFAULT_GERMAN_VOICE; - - this.logger.debug( - `German synthesizing: "${text.substring(0, 50)}..." with voice=${germanVoice}, speed=${speed}` - ); - - const headers: Record = { 'Content-Type': 'application/json' }; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({ - text, - voice: germanVoice, - speed, - output_format: 'wav', - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`German TTS failed: ${response.status} - ${errorText}`); - throw new Error(`TTS synthesis failed: ${response.status}`); - } - - const arrayBuffer = await response.arrayBuffer(); - this.logger.debug(`Received German audio: ${arrayBuffer.byteLength} bytes`); - - return Buffer.from(arrayBuffer); - } - - /** - * Get list of available voices - */ - async getVoices(): Promise { - const url = `${this.ttsUrl}/voices`; - - const headers: Record = {}; - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - - const response = await fetch(url, { headers }); - - if (!response.ok) { - throw new Error(`Failed to get voices: ${response.status}`); - } - - return response.json(); - } - - /** - * Check if TTS service is healthy - */ - async isHealthy(): Promise { - try { - const response = await fetch(`${this.ttsUrl}/health`); - return response.ok; - } catch { - return false; - } - } - - /** - * Check if a voice exists - */ - async voiceExists(voiceId: string): Promise { - try { - const voices = await this.getVoices(); - const allVoices = [...voices.kokoro_voices, ...voices.custom_voices]; - return allVoices.some((v) => v.id === voiceId); - } catch { - return false; - } - } -} diff --git a/services/matrix-tts-bot/tsconfig.build.json b/services/matrix-tts-bot/tsconfig.build.json deleted file mode 100644 index 045c9529c..000000000 --- a/services/matrix-tts-bot/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] -} diff --git a/services/matrix-tts-bot/tsconfig.json b/services/matrix-tts-bot/tsconfig.json deleted file mode 100644 index 38c2b55d7..000000000 --- a/services/matrix-tts-bot/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 - } -} diff --git a/services/matrix-zitare-bot/.dockerignore b/services/matrix-zitare-bot/.dockerignore deleted file mode 100644 index d6a8859ae..000000000 --- a/services/matrix-zitare-bot/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -dist -.git -*.log -.env* -data diff --git a/services/matrix-zitare-bot/.gitignore b/services/matrix-zitare-bot/.gitignore deleted file mode 100644 index 112abfa38..000000000 --- a/services/matrix-zitare-bot/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 0acc02130..000000000 --- a/services/matrix-zitare-bot/CLAUDE.md +++ /dev/null @@ -1,178 +0,0 @@ -# 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 deleted file mode 100644 index 7f8ccfc84..000000000 --- a/services/matrix-zitare-bot/Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-slim AS builder - -WORKDIR /app - -# Enable pnpm via corepack -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy shared packages that this bot depends on -COPY packages/bot-services ./packages/bot-services -COPY packages/matrix-bot-common ./packages/matrix-bot-common -COPY apps/zitare/packages/content ./apps/zitare/packages/content - -# Copy this bot -COPY services/matrix-zitare-bot ./services/matrix-zitare-bot - -# Install all dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -RUN pnpm --filter @manacore/bot-services build -RUN pnpm --filter @manacore/matrix-bot-common build - -# Build the bot -RUN pnpm --filter @manacore/matrix-zitare-bot build - -# Production stage -FROM node:20-slim AS runner - -WORKDIR /app - -# Install wget for health checks and enable pnpm -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ - && corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy workspace configuration -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Copy built shared packages -COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist -COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ -COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist -COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ - -# Copy built bot -COPY --from=builder /app/services/matrix-zitare-bot/dist ./services/matrix-zitare-bot/dist -COPY --from=builder /app/services/matrix-zitare-bot/package.json ./services/matrix-zitare-bot/ - -# Install production dependencies only -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod --ignore-scripts - -# Create data directory -RUN mkdir -p /app/data - -# Create non-root user -RUN groupadd --system --gid 1001 nodejs && \ - useradd --system --uid 1001 -g nodejs nestjs && \ - chown -R nestjs:nodejs /app/data - -USER nestjs - -WORKDIR /app/services/matrix-zitare-bot - -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:4017/health || exit 1 - -EXPOSE 4017 - -CMD ["node", "dist/main.js"] diff --git a/services/matrix-zitare-bot/nest-cli.json b/services/matrix-zitare-bot/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/services/matrix-zitare-bot/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$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 deleted file mode 100644 index 2f9d415b0..000000000 --- a/services/matrix-zitare-bot/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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": { - "@manacore/bot-services": "workspace:*", - "@manacore/matrix-bot-common": "workspace:*", - "@zitare/content": "workspace:*", - "@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 deleted file mode 100644 index 77e50ae91..000000000 --- a/services/matrix-zitare-bot/src/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; -import configuration from './config/configuration'; -import { BotModule } from './bot/bot.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - BotModule, - ], - controllers: [HealthController], - providers: [createHealthProvider('matrix-zitare-bot')], -}) -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 deleted file mode 100644 index 018465080..000000000 --- a/services/matrix-zitare-bot/src/bot/bot.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Module } from '@nestjs/common'; -import { MatrixService } from './matrix.service'; -import { QuotesModule } from '../quotes/quotes.module'; -import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services'; - -@Module({ - imports: [ - QuotesModule, - SessionModule.forRoot({ storageMode: 'redis' }), - TranscriptionModule.forRoot(), - CreditModule.forRoot(), - ], - 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 deleted file mode 100644 index 0d9e9aa7f..000000000 --- a/services/matrix-zitare-bot/src/bot/matrix.service.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - BaseMatrixService, - MatrixBotConfig, - MatrixRoomEvent, - KeywordCommandDetector, - COMMON_KEYWORDS, -} from '@manacore/matrix-bot-common'; -import { QuotesService } from '../quotes/quotes.service'; -import { ZitareService } from '../quotes/zitare.service'; -import { - SessionService, - TranscriptionService, - CreditService, - LOGIN_MESSAGES, -} from '@manacore/bot-services'; -import { HELP_MESSAGE } from '../config/configuration'; -import type { Category } from '@zitare/content'; - -@Injectable() -export class MatrixService extends BaseMatrixService { - // Track last shown quote per user for favorites - private lastQuotes: Map = new Map(); - - private readonly keywordDetector = new KeywordCommandDetector([ - ...COMMON_KEYWORDS, - { 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' }, - ]); - - constructor( - configService: ConfigService, - private quotesService: QuotesService, - private zitareService: ZitareService, - private sessionService: SessionService, - private transcriptionService: TranscriptionService, - private creditService: CreditService - ) { - super(configService); - } - - protected getConfig(): MatrixBotConfig { - return { - homeserverUrl: - this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', - accessToken: this.configService.get('matrix.accessToken') || '', - storagePath: - this.configService.get('matrix.storagePath') || './data/bot-storage.json', - allowedRooms: this.configService.get('matrix.allowedRooms') || [], - }; - } - - protected getIntroductionMessage(): string { - const dailyQuote = this.quotesService.getDailyQuote(); - - return `**Zitare Bot - Taegliche Inspiration** - -Ich bringe dir jeden Tag neue Inspiration! - -**Zitat des Tages:** -${this.quotesService.formatQuote(dailyQuote)} - -Sag "hilfe" fuer alle Befehle!`; - } - - protected async handleTextMessage( - roomId: string, - event: MatrixRoomEvent, - body: string - ): Promise { - const sender = event.sender; - - this.logger.log(`Message from ${sender} in ${roomId}: ${body.substring(0, 50)}...`); - - // Handle commands with ! prefix - if (body.startsWith('!')) { - await this.handleCommand(roomId, sender, body); - return; - } - - // Check for natural language keywords - const keywordCommand = this.keywordDetector.detect(body); - if (keywordCommand) { - await this.handleCommand(roomId, sender, `!${keywordCommand}`); - return; - } - - // Don't respond to random messages - } - - protected async handleAudioMessage( - roomId: string, - event: MatrixRoomEvent, - sender: string - ): Promise { - const content = event.content; - if (!content?.url) { - this.logger.warn('Audio message without URL'); - return; - } - - this.logger.log(`Processing voice message from ${sender}`); - - try { - // Download audio from Matrix using authenticated API - this.logger.log(`Downloading audio from ${content.url}`); - const audioBuffer = await this.downloadMedia(content.url); - - // 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.keywordDetector.detect(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 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 '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" fuer alle Befehle.` - ); - } - } - - 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 Glueck`' - ); - return; - } - - const results = this.quotesService.searchQuotes(searchText); - - if (results.length === 0) { - await this.sendMessage(roomId, `Keine Zitate gefunden fuer: "${searchText}"`); - return; - } - - let text = `**Suchergebnisse fuer "${searchText}" (${results.length}):**\n\n`; - - const maxResults = Math.min(results.length, 5); - for (let i = 0; i < maxResults; i++) { - const quote = results[i]; - const quoteText = this.quotesService.getQuoteText(quote); - text += `**${i + 1}.** "${quoteText.substring(0, 80)}${quoteText.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\` fuer 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 = `**Verfuegbare 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 handleAddFavorite(roomId: string, sender: string) { - const token = await this.requireLogin(roomId, sender); - if (!token) 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); - const quoteText = quote ? this.quotesService.getQuoteText(quote) : ''; - await this.sendMessage( - roomId, - `Zu Favoriten hinzugefuegt!\n\n"${quoteText.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 = await this.requireLogin(roomId, sender); - if (!token) 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) { - const quoteText = this.quotesService.getQuoteText(quote); - text += `**${i + 1}.** "${quoteText.substring(0, 60)}${quoteText.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 = await this.requireLogin(roomId, sender); - if (!token) 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 = await this.requireLogin(roomId, sender); - if (!token) 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 hinzuzufuegen.` - ); - } 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 = await this.requireLogin(roomId, sender); - if (!token) return; - - if (args.length < 1) { - await this.sendMessage( - roomId, - `**Verwendung:** \`!addliste [listen-nr]\`\n\nFuegt das letzte angezeigte Zitat zur Liste hinzu.` - ); - return; - } - - const listIndex = parseInt(args[0], 10); - if (isNaN(listIndex) || listIndex < 1) { - await this.sendMessage(roomId, `Ungueltige Listennummer.`); - return; - } - - const lastQuoteId = this.lastQuotes.get(sender); - if (!lastQuoteId) { - await this.sendMessage( - roomId, - `Kein Zitat zum Hinzufuegen. 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); - const quoteText = quote ? this.quotesService.getQuoteText(quote) : ''; - await this.sendMessage( - roomId, - `Zitat zu "${list.name}" hinzugefuegt!\n\n"${quoteText.substring(0, 50)}..."` - ); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; - await this.sendMessage(roomId, `Fehler: ${errorMsg}`); - } - } - - /** - * Require login - returns token or sends login prompt and returns null - */ - private async requireLogin(roomId: string, userId: string): Promise { - const token = await this.sessionService.getToken(userId); - if (!token) { - await this.sendMessage(roomId, LOGIN_MESSAGES.zitare); - return null; - } - return token; - } - - private async handleStatus(roomId: string, sender: string) { - const backendHealthy = await this.zitareService.checkHealth(); - const isLoggedIn = await this.sessionService.isLoggedIn(sender); - const sessionCount = this.sessionService.getSessionCount(); - const totalQuotes = this.quotesService.getTotalCount(); - const session = await this.sessionService.getSession(sender); - const token = await this.sessionService.getToken(sender); - - let statusText = `**Zitare Bot Status**\n\n`; - statusText += `**Backend:** ${backendHealthy ? 'Online' : 'Offline'}\n`; - statusText += `**Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'}\n`; - - if (isLoggedIn && session && token) { - const balance = await this.creditService.getBalance(token); - statusText += `**👤 Angemeldet als:** ${session.email}\n`; - statusText += `**⚡ Credits:** ${balance.balance.toFixed(2)}\n`; - } - - statusText += `**Aktive Sessions:** ${sessionCount}\n`; - statusText += `**Verfuegbare Zitate:** ${totalQuotes}\n`; - statusText += `\n${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; - - await this.sendMessage(roomId, statusText); - } - - private async pinHelpMessage(roomId: string) { - try { - const eventId = await this.sendMessage(roomId, HELP_MESSAGE); - - await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { - pinned: [eventId], - }); - - this.logger.log(`Pinned help message in room ${roomId}`); - } catch (error) { - this.logger.error(`Failed to pin help message:`, error); - await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.'); - } - } -} diff --git a/services/matrix-zitare-bot/src/config/configuration.ts b/services/matrix-zitare-bot/src/config/configuration.ts deleted file mode 100644 index a8981439b..000000000 --- a/services/matrix-zitare-bot/src/config/configuration.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 - Taegliche Inspiration** - -**Zitate:** -- \`!zitat\` - Zufaelliges Zitat -- \`!heute\` - Zitat des Tages -- \`!suche [text]\` - Zitate suchen -- \`!kategorie [name]\` - Zitate nach Kategorie -- \`!kategorien\` - Alle Kategorien - -**Favoriten:** -- \`!favorit\` - Letztes Zitat speichern -- \`!favoriten\` - Alle Favoriten anzeigen - -**Listen:** -- \`!listen\` - Alle Listen anzeigen -- \`!liste [name]\` - Neue Liste erstellen -- \`!addliste [nr] [zitat-nr]\` - Zitat zur Liste hinzufuegen - -**Sonstiges:** -- \`!status\` - Bot-Status -- \`!help\` - Diese Hilfe - -**Sprachnotizen:** -Sende eine Sprachnotiz mit Befehlen wie "Zitat", "Motivation" oder einem Suchbegriff. - -**Natuerliche Sprache:** -- "zitat", "inspiration" -> Zufaelliges Zitat -- "motiviere mich" -> Motivation-Zitat -- "guten morgen" -> Morgenzitat`; - -// Re-export types and utilities from @zitare/content -export type { Quote, Category } from '@zitare/content'; -export { CATEGORIES, CATEGORY_LABELS } from '@zitare/content'; diff --git a/services/matrix-zitare-bot/src/main.ts b/services/matrix-zitare-bot/src/main.ts deleted file mode 100644 index ef33adcb7..000000000 --- a/services/matrix-zitare-bot/src/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 31b4696b8..000000000 --- a/services/matrix-zitare-bot/src/quotes/quotes.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index db2715d4b..000000000 --- a/services/matrix-zitare-bot/src/quotes/quotes.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - type Quote, - type Category, - type SupportedLanguage, - QUOTES, - CATEGORIES, - CATEGORY_LABELS, - getRandomQuote, - getDailyQuote, - getQuotesByCategory, - getRandomQuoteByCategory, - searchQuotes, - getQuoteById, - getQuoteByIndex, - getAllCategories, - getCategoryByName, - getQuoteText, - formatQuote, - formatQuoteWithNumber, - getTotalCount, -} from '@zitare/content'; - -@Injectable() -export class QuotesService { - private readonly logger = new Logger(QuotesService.name); - private dailyQuoteCache: { date: string; quote: Quote } | null = null; - - getRandomQuote(): Quote { - return getRandomQuote(); - } - - 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; - } - - const quote = getDailyQuote(); - this.dailyQuoteCache = { date: today, quote }; - const text = getQuoteText(quote, 'de'); - this.logger.log(`Daily quote for ${today}: "${text.substring(0, 30)}..."`); - - return quote; - } - - getQuotesByCategory(category: Category): Quote[] { - return getQuotesByCategory(category); - } - - getRandomQuoteByCategory(category: Category): Quote | null { - return getRandomQuoteByCategory(category); - } - - searchQuotes(searchText: string): Quote[] { - return searchQuotes(searchText); - } - - getQuoteById(id: string): Quote | undefined { - return getQuoteById(id); - } - - getQuoteByIndex(index: number): Quote | null { - return getQuoteByIndex(index); - } - - getAllCategories(): { category: Category; label: string; count: number }[] { - return getAllCategories(); - } - - getCategoryByName(name: string): Category | null { - return getCategoryByName(name); - } - - getTotalCount(): number { - return getTotalCount(); - } - - getQuoteText(quote: Quote, language: SupportedLanguage = 'de'): string { - return getQuoteText(quote, language); - } - - formatQuote(quote: Quote, language: SupportedLanguage = 'de'): string { - return formatQuote(quote, language); - } - - formatQuoteWithNumber(quote: Quote, number: number, language: SupportedLanguage = 'de'): string { - return formatQuoteWithNumber(quote, number, language); - } -} diff --git a/services/matrix-zitare-bot/src/quotes/zitare.service.ts b/services/matrix-zitare-bot/src/quotes/zitare.service.ts deleted file mode 100644 index 9c60f944d..000000000 --- a/services/matrix-zitare-bot/src/quotes/zitare.service.ts +++ /dev/null @@ -1,177 +0,0 @@ -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/tsconfig.json b/services/matrix-zitare-bot/tsconfig.json deleted file mode 100644 index 38c2b55d7..000000000 --- a/services/matrix-zitare-bot/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 - } -}