mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(infra): consolidate 21 Matrix bots into Go binary + add Go API gateway
Replace 21 separate NestJS Matrix bot processes (~2.1 GB RAM, ~4.2 GB Docker images) with a single Go binary using plugin architecture (8.6 MB binary, ~30 MB RAM). New services: - services/mana-matrix-bot/ — Go Matrix bot with 21 plugins (mautrix-go, Redis sessions) - services/mana-api-gateway-go/ — Go API gateway (rate limiting, API keys, credit billing) Deleted: - 21 services/matrix-*-bot/ directories - packages/bot-services/ and packages/matrix-bot-common/ - Legacy deploy scripts and CI build jobs Updated: - docker-compose.macmini.yml: new Go services, legacy bots removed - CI/CD: change detection + build jobs for Go services - Root package.json: new dev:matrix, build:matrix, test:matrix scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce51fd5fe2
commit
819568c3df
503 changed files with 9927 additions and 47044 deletions
|
|
@ -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?
|
||||
10
.github/workflows/cd-macmini.yml
vendored
10
.github/workflows/cd-macmini.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
411
.github/workflows/ci.yml
vendored
411
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -213,7 +213,7 @@ services:
|
|||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: Mana <noreply@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://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
|
||||
|
|
|
|||
13
package.json
13
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"
|
||||
|
|
|
|||
|
|
@ -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<string>('@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<TodoData> {
|
||||
async load(): Promise<TodoData> {
|
||||
// Load from database
|
||||
}
|
||||
async save(data: TodoData): Promise<void> {
|
||||
// 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.**
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AiServiceConfig>;
|
||||
|
||||
export interface AiModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
||||
useFactory: (...args: unknown[]) => Promise<AiModuleOptions> | AiModuleOptions;
|
||||
inject?: (Type<unknown> | 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<AiServiceConfig>) => 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<AiServiceConfig>) => new AiService(config),
|
||||
inject: ['AI_SERVICE_CONFIG'],
|
||||
},
|
||||
],
|
||||
exports: [AiService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, UserAiSession> = new Map();
|
||||
|
||||
constructor(config?: Partial<AiServiceConfig>) {
|
||||
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<boolean> {
|
||||
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<OllamaModel[]> {
|
||||
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<OllamaModel[]> {
|
||||
const models = await this.listModels();
|
||||
return models.filter((m) => !NON_CHAT_MODELS.includes(m.name));
|
||||
}
|
||||
|
||||
async getVisionModels(): Promise<OllamaModel[]> {
|
||||
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<ChatResult> {
|
||||
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<string> {
|
||||
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<ChatResult> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// Module
|
||||
export { AiModule, AiModuleOptions } from './ai.module';
|
||||
|
||||
// Service
|
||||
export { AiService } from './ai.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
|
@ -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<string, string> = {
|
||||
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'];
|
||||
|
|
@ -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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
const today = getTodayISO();
|
||||
return this.getEvents(token, { start: today, end: today });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming events (next 7 days)
|
||||
*/
|
||||
async getUpcomingEvents(token: string, days = 7): Promise<CalendarEvent[]> {
|
||||
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<CalendarEvent | null> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
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<CalendarEvent | null> {
|
||||
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<boolean> {
|
||||
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<Calendar[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CalendarData>;
|
||||
}
|
||||
|
||||
export interface CalendarModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
||||
useFactory: (...args: unknown[]) => Promise<CalendarModuleOptions> | CalendarModuleOptions;
|
||||
inject?: (Type<unknown> | 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<CalendarData>(storagePath, defaultData),
|
||||
},
|
||||
CalendarService,
|
||||
],
|
||||
exports: [CalendarService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with custom storage provider
|
||||
*/
|
||||
static forRoot(storageProvider: StorageProvider<CalendarData>): 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<CalendarData>(storagePath, defaultData)
|
||||
);
|
||||
},
|
||||
inject: options.inject || [],
|
||||
};
|
||||
|
||||
return {
|
||||
module: CalendarModule,
|
||||
imports: options.imports || [],
|
||||
providers: [storageProvider, CalendarService],
|
||||
exports: [CalendarService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CalendarData>;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(CALENDAR_STORAGE_PROVIDER)
|
||||
storage?: StorageProvider<CalendarData>
|
||||
) {
|
||||
this.storage =
|
||||
storage ||
|
||||
new FileStorageProvider<CalendarData>('./data/calendar-data.json', {
|
||||
events: [],
|
||||
calendars: [],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
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<void> {
|
||||
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<CalendarEvent> {
|
||||
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<CalendarEvent | null> {
|
||||
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<CalendarEvent | null> {
|
||||
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<CalendarEvent | null> {
|
||||
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<CalendarEvent | null> {
|
||||
return this.data.events.find((e) => e.id === eventId && e.userId === userId) ?? null;
|
||||
}
|
||||
|
||||
async getEventByIndex(userId: string, index: number): Promise<CalendarEvent | null> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
const today = startOfDay();
|
||||
const tomorrow = addDays(today, 1);
|
||||
return this.getEventsInRange(userId, today, tomorrow);
|
||||
}
|
||||
|
||||
async getTomorrowEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const tomorrow = startOfDay(addDays(new Date(), 1));
|
||||
const dayAfter = addDays(tomorrow, 1);
|
||||
return this.getEventsInRange(userId, tomorrow, dayAfter);
|
||||
}
|
||||
|
||||
async getWeekEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = startOfDay();
|
||||
const weekEnd = addDays(today, 7);
|
||||
return this.getEventsInRange(userId, today, weekEnd);
|
||||
}
|
||||
|
||||
async getUpcomingEvents(userId: string, days = 7): Promise<CalendarEvent[]> {
|
||||
const now = new Date();
|
||||
const endDate = addDays(now, days);
|
||||
return this.getEventsInRange(userId, now, endDate);
|
||||
}
|
||||
|
||||
// ===== Calendars =====
|
||||
|
||||
async getCalendars(userId: string): Promise<Calendar[]> {
|
||||
this.ensureDefaultCalendar(userId);
|
||||
return this.data.calendars.filter((c) => c.userId === userId);
|
||||
}
|
||||
|
||||
async createCalendar(userId: string, name: string, color?: string): Promise<Calendar> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<ClockServiceConfig>;
|
||||
|
||||
export interface ClockModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
||||
useFactory: (...args: unknown[]) => Promise<ClockModuleOptions> | ClockModuleOptions;
|
||||
inject?: (Type<unknown> | 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<ClockServiceConfig>) => 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<ClockServiceConfig>) => new ClockService(config),
|
||||
inject: ['CLOCK_SERVICE_CONFIG'],
|
||||
},
|
||||
],
|
||||
exports: [ClockService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = new Map();
|
||||
|
||||
constructor(config?: Partial<ClockServiceConfig>) {
|
||||
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<T>(
|
||||
endpoint: string,
|
||||
method = 'GET',
|
||||
token?: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<T>;
|
||||
}
|
||||
|
||||
// ===== Health =====
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Timers =====
|
||||
|
||||
async getTimers(token: string): Promise<Timer[]> {
|
||||
return this.apiCall<Timer[]>('/timers', 'GET', token);
|
||||
}
|
||||
|
||||
async getTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}`, 'GET', token);
|
||||
}
|
||||
|
||||
async createTimer(input: CreateTimerInput, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>('/timers', 'POST', token, {
|
||||
durationSeconds: input.durationSeconds,
|
||||
label: input.label,
|
||||
});
|
||||
}
|
||||
|
||||
async startTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/start`, 'POST', token);
|
||||
}
|
||||
|
||||
async pauseTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/pause`, 'POST', token);
|
||||
}
|
||||
|
||||
async resetTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/reset`, 'POST', token);
|
||||
}
|
||||
|
||||
async deleteTimer(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/timers/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
async getRunningTimer(token: string): Promise<Timer | null> {
|
||||
const timers = await this.getTimers(token);
|
||||
return timers.find((t) => t.status === 'running' || t.status === 'paused') || null;
|
||||
}
|
||||
|
||||
// ===== Alarms =====
|
||||
|
||||
async getAlarms(token: string): Promise<Alarm[]> {
|
||||
return this.apiCall<Alarm[]>('/alarms', 'GET', token);
|
||||
}
|
||||
|
||||
async createAlarm(input: CreateAlarmInput, token: string): Promise<Alarm> {
|
||||
return this.apiCall<Alarm>('/alarms', 'POST', token, {
|
||||
time: input.time,
|
||||
label: input.label,
|
||||
enabled: true,
|
||||
repeatDays: input.repeatDays,
|
||||
});
|
||||
}
|
||||
|
||||
async toggleAlarm(id: string, token: string): Promise<Alarm> {
|
||||
return this.apiCall<Alarm>(`/alarms/${id}/toggle`, 'PATCH', token);
|
||||
}
|
||||
|
||||
async deleteAlarm(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/alarms/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// ===== World Clocks =====
|
||||
|
||||
async getWorldClocks(token: string): Promise<WorldClock[]> {
|
||||
return this.apiCall<WorldClock[]>('/world-clocks', 'GET', token);
|
||||
}
|
||||
|
||||
async addWorldClock(input: CreateWorldClockInput, token: string): Promise<WorldClock> {
|
||||
return this.apiCall<WorldClock>('/world-clocks', 'POST', token, {
|
||||
timezone: input.timezone,
|
||||
cityName: input.cityName,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorldClock(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/world-clocks/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// ===== Timezone Search =====
|
||||
|
||||
async searchTimezones(query: string): Promise<TimezoneResult[]> {
|
||||
return this.apiCall<TimezoneResult[]>(`/timezones/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// ===== Time Tracking Summary =====
|
||||
|
||||
async getTodayTracked(token: string): Promise<TimeTrackingSummary> {
|
||||
// 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<Timer & { name?: string }> {
|
||||
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<Timer & { name?: string }> {
|
||||
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<Alarm & { name?: string }> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// Module
|
||||
export { ClockModule, ClockModuleOptions } from './clock.module';
|
||||
|
||||
// Service
|
||||
export { ClockService } from './clock.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<ContactBirthday[]> {
|
||||
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<ContactBirthday[]> {
|
||||
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<Contact[]> {
|
||||
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<Contact | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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> | 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<string>('contacts.apiUrl') ||
|
||||
config.get<string>('CONTACTS_API_URL') ||
|
||||
'http://localhost:3015',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>('auth.url') ||
|
||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
'http://localhost:3001';
|
||||
|
||||
this.serviceKey =
|
||||
options?.serviceKey || this.configService?.get<string>('MANA_CORE_SERVICE_KEY');
|
||||
|
||||
this.appId = options?.appId || this.configService?.get<string>('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<CreditBalance> {
|
||||
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<CreditValidationResult> {
|
||||
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} <b>Credits:</b> ${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<string, string>
|
||||
): CreditStatusMessage {
|
||||
const lines: string[] = [];
|
||||
const htmlLines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push('🤖 Bot Status');
|
||||
htmlLines.push('<b>🤖 Bot Status</b>');
|
||||
|
||||
// User info
|
||||
lines.push(`👤 User: ${email}`);
|
||||
htmlLines.push(`👤 <b>User:</b> ${email}`);
|
||||
|
||||
// Credits
|
||||
const creditIcon = balance.hasCredits ? '⚡' : '⚠️';
|
||||
const creditsFormatted = balance.balance.toFixed(2);
|
||||
lines.push(`${creditIcon} Credits: ${creditsFormatted}`);
|
||||
htmlLines.push(`${creditIcon} <b>Credits:</b> ${creditsFormatted}`);
|
||||
|
||||
// Tier if available
|
||||
if (balance.tier) {
|
||||
lines.push(`📊 Tier: ${balance.tier}`);
|
||||
htmlLines.push(`📊 <b>Tier:</b> ${balance.tier}`);
|
||||
}
|
||||
|
||||
// Additional info
|
||||
if (additionalInfo) {
|
||||
for (const [key, value] of Object.entries(additionalInfo)) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
htmlLines.push(`<b>${key}:</b> ${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('<br>');
|
||||
htmlLines.push('⚠️ <b>Nur noch wenig Credits!</b>');
|
||||
htmlLines.push('👉 <a href="https://mana.how/credits">Credits kaufen</a>');
|
||||
}
|
||||
|
||||
// No credits warning
|
||||
if (!balance.hasCredits) {
|
||||
lines.push('');
|
||||
lines.push('❌ Keine Credits mehr!');
|
||||
lines.push('👉 Credits kaufen: https://mana.how/credits');
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push('❌ <b>Keine Credits mehr!</b>');
|
||||
htmlLines.push('👉 <a href="https://mana.how/credits">Credits kaufen</a>');
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('❌ <b>Nicht genug Credits</b>');
|
||||
|
||||
if (operation) {
|
||||
lines.push(`Operation: ${operation}`);
|
||||
htmlLines.push(`<b>Operation:</b> ${operation}`);
|
||||
}
|
||||
|
||||
lines.push(`Benötigt: ${required.toFixed(2)} Credits`);
|
||||
lines.push(`Vorhanden: ${available.toFixed(2)} Credits`);
|
||||
htmlLines.push(`<b>Benötigt:</b> ${required.toFixed(2)} Credits`);
|
||||
htmlLines.push(`<b>Vorhanden:</b> ${available.toFixed(2)} Credits`);
|
||||
|
||||
lines.push('');
|
||||
lines.push('👉 Credits kaufen: https://mana.how/credits');
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push('👉 <a href="https://mana.how/credits">Credits kaufen</a>');
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}<br>⚡ -${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 =
|
||||
'❌ <b>Nicht genug Credits</b><br>👉 <a href="https://mana.how/credits">Credits kaufen</a>';
|
||||
break;
|
||||
case CreditErrorCode.NOT_LOGGED_IN:
|
||||
text = '❌ Bitte zuerst einloggen: !login email passwort';
|
||||
html = '❌ <b>Bitte zuerst einloggen:</b> <code>!login email passwort</code>';
|
||||
break;
|
||||
case CreditErrorCode.INVALID_OPERATION:
|
||||
text = '❌ Ungültige Operation';
|
||||
html = '❌ <b>Ungültige Operation</b>';
|
||||
break;
|
||||
case CreditErrorCode.SERVICE_UNAVAILABLE:
|
||||
default:
|
||||
text = '❌ Service temporär nicht verfügbar. Bitte später erneut versuchen.';
|
||||
html = '❌ <b>Service temporär nicht verfügbar.</b> 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<CreditPackage[]> {
|
||||
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<PaymentLinkResult | null> {
|
||||
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<PurchaseStatusResult | null> {
|
||||
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('.', ',')} €`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: [] }),
|
||||
};
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>('auth.url') ||
|
||||
this.configService?.get<string>('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<CreateGiftResult | null> {
|
||||
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<GiftCodeInfo | null> {
|
||||
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<RedeemGiftResult> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
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<CreatedGiftItem[]> {
|
||||
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<ReceivedGiftItem[]> {
|
||||
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('🎁 <b>Geschenk erstellt!</b>');
|
||||
|
||||
lines.push('');
|
||||
htmlLines.push('<br>');
|
||||
|
||||
lines.push(`Code: \`${gift.code}\``);
|
||||
htmlLines.push(`Code: <code>${gift.code}</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('<br>');
|
||||
|
||||
lines.push(`Link: ${gift.url}`);
|
||||
htmlLines.push(`Link: <a href="${gift.url}">${gift.url}</a>`);
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('🎁 <b>Geschenk eingelöst!</b>');
|
||||
|
||||
lines.push(`+${credits} Credits`);
|
||||
htmlLines.push(`+${credits} Credits`);
|
||||
|
||||
if (message) {
|
||||
lines.push('');
|
||||
lines.push(`"${message}"`);
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`<i>"${message}"</i>`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`);
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`);
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format gift list message
|
||||
*/
|
||||
formatGiftListMessage(gifts: CreatedGiftItem[]): GiftStatusMessage {
|
||||
const lines: string[] = [];
|
||||
const htmlLines: string[] = [];
|
||||
|
||||
lines.push('🎁 **Deine Geschenke:**');
|
||||
htmlLines.push('🎁 <b>Deine Geschenke:</b>');
|
||||
|
||||
lines.push('');
|
||||
htmlLines.push('<br>');
|
||||
|
||||
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}. <code>${gift.code}</code> ${statusIcon} ${gift.creditsPerPortion} Cr · ${claimed}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('🎁 <b>Geschenk-Info:</b>');
|
||||
|
||||
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('<br>');
|
||||
htmlLines.push(`<i>"${info.message}"</i>`);
|
||||
}
|
||||
|
||||
if (info.hasRiddle) {
|
||||
lines.push('');
|
||||
lines.push(`❓ ${info.riddleQuestion}`);
|
||||
lines.push('Antworte mit: `!einloesen CODE antwort`');
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`❓ ${info.riddleQuestion}`);
|
||||
htmlLines.push('Antworte mit: <code>!einloesen CODE antwort</code>');
|
||||
}
|
||||
|
||||
if (info.creatorName) {
|
||||
lines.push('');
|
||||
lines.push(`Von: ${info.creatorName}`);
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`Von: ${info.creatorName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<I18nOptions>;
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Language, BotTranslations> = { de, en };
|
||||
|
||||
/**
|
||||
* Language display names
|
||||
*/
|
||||
export const LANGUAGE_NAMES: Record<Language, string> = {
|
||||
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<Language>('bot.defaultLanguage') ||
|
||||
'de';
|
||||
|
||||
this.logger.log(`Default language: ${this.defaultLanguage}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language for a user
|
||||
*/
|
||||
async getLanguage(userId: string): Promise<Language> {
|
||||
if (this.sessionService) {
|
||||
const lang = await this.sessionService.getSessionData<Language>(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<void> {
|
||||
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, string | number>) => 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, string | number>) => 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, string | number>) => 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, string | number>) => 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, string | number>) => 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<TodoTranslations> {
|
||||
const lang = await this.getLanguage(userId);
|
||||
return translations[lang].todo;
|
||||
}
|
||||
|
||||
async getCalendarTranslations(userId: string): Promise<CalendarTranslations> {
|
||||
const lang = await this.getLanguage(userId);
|
||||
return translations[lang].calendar;
|
||||
}
|
||||
|
||||
async getContactsTranslations(userId: string): Promise<ContactsTranslations> {
|
||||
const lang = await this.getLanguage(userId);
|
||||
return translations[lang].contacts;
|
||||
}
|
||||
|
||||
async getClockTranslations(userId: string): Promise<ClockTranslations> {
|
||||
const lang = await this.getLanguage(userId);
|
||||
return translations[lang].clock;
|
||||
}
|
||||
|
||||
async getGiftTranslations(userId: string): Promise<GiftTranslations> {
|
||||
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, string | number>): 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<string> {
|
||||
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<string> {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<string>('services.todo.apiUrl') ||
|
||||
config.get<string>('TODO_API_URL') ||
|
||||
'http://localhost:3018',
|
||||
calendarApiUrl:
|
||||
config.get<string>('services.calendar.apiUrl') ||
|
||||
config.get<string>('CALENDAR_API_URL') ||
|
||||
'http://localhost:3014',
|
||||
contactsApiUrl:
|
||||
config.get<string>('services.contacts.apiUrl') ||
|
||||
config.get<string>('CONTACTS_API_URL') ||
|
||||
'http://localhost:3015',
|
||||
plantaApiUrl:
|
||||
config.get<string>('services.planta.apiUrl') ||
|
||||
config.get<string>('PLANTA_API_URL') ||
|
||||
'http://localhost:3022',
|
||||
defaultLocation:
|
||||
config.get<string>('weather.defaultLocation') ||
|
||||
config.get<string>('WEATHER_DEFAULT_LOCATION') ||
|
||||
'Berlin',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
// API Services
|
||||
{
|
||||
provide: CalendarApiService,
|
||||
useFactory: (config: ConfigService) =>
|
||||
new CalendarApiService(
|
||||
config.get<string>('services.calendar.apiUrl') ||
|
||||
config.get<string>('CALENDAR_API_URL') ||
|
||||
'http://localhost:3014'
|
||||
),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: TodoApiService,
|
||||
useFactory: (config: ConfigService) =>
|
||||
new TodoApiService(
|
||||
config.get<string>('services.todo.apiUrl') ||
|
||||
config.get<string>('TODO_API_URL') ||
|
||||
'http://localhost:3018'
|
||||
),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: ContactsApiService,
|
||||
useFactory: (config: ConfigService) => {
|
||||
const apiUrl =
|
||||
config.get<string>('services.contacts.apiUrl') ||
|
||||
config.get<string>('CONTACTS_API_URL') ||
|
||||
'http://localhost:3015';
|
||||
return new ContactsApiService({ apiUrl });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: PlantaApiService,
|
||||
useFactory: (config: ConfigService) => {
|
||||
const apiUrl =
|
||||
config.get<string>('services.planta.apiUrl') ||
|
||||
config.get<string>('PLANTA_API_URL') ||
|
||||
'http://localhost:3022';
|
||||
return new PlantaApiService({ apiUrl });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: WeatherService,
|
||||
useFactory: (config: ConfigService) =>
|
||||
new WeatherService({
|
||||
defaultLocation:
|
||||
config.get<string>('weather.defaultLocation') ||
|
||||
config.get<string>('WEATHER_DEFAULT_LOCATION') ||
|
||||
'Berlin',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
MorningPreferencesService,
|
||||
MorningSummaryService,
|
||||
],
|
||||
exports: [MorningSummaryService, MorningPreferencesService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MorningSummaryData> {
|
||||
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<Task[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MorningPreferences> {
|
||||
try {
|
||||
const stored = await this.sessionService.getSessionData<MorningPreferences>(
|
||||
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<MorningPreferences>
|
||||
): Promise<MorningPreferences> {
|
||||
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<MorningPreferences> {
|
||||
return this.savePreferences(matrixUserId, { enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set delivery time (HH:MM format)
|
||||
*/
|
||||
async setDeliveryTime(matrixUserId: string, time: string): Promise<MorningPreferences> {
|
||||
// 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<MorningPreferences> {
|
||||
// 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<MorningPreferences> {
|
||||
return this.savePreferences(matrixUserId, { location });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set summary format
|
||||
*/
|
||||
async setFormat(
|
||||
matrixUserId: string,
|
||||
format: 'compact' | 'detailed'
|
||||
): Promise<MorningPreferences> {
|
||||
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<string[]> {
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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: [] }),
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<PlantWateringStatus[]> {
|
||||
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<PlantWateringStatus[]> {
|
||||
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<Plant[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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> | 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<string>('planta.apiUrl') ||
|
||||
config.get<string>('PLANTA_API_URL') ||
|
||||
'http://localhost:3022',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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: [] }),
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<string>('REDIS_HOST', 'localhost');
|
||||
const port = this.options?.redisPort || this.configService?.get<number>('REDIS_PORT', 6379);
|
||||
const password =
|
||||
this.options?.redisPassword || this.configService?.get<string>('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<void> {
|
||||
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<UserSession | null> {
|
||||
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<string | null> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return session?.token ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session from Redis
|
||||
*/
|
||||
async deleteSession(matrixUserId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return (session?.data?.[key] as T) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active session keys (for debugging/stats)
|
||||
*/
|
||||
async getActiveSessionCount(): Promise<number> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SessionModuleOptions, 'storageMode'> = {}): DynamicModule {
|
||||
return this.forRoot({
|
||||
...options,
|
||||
storageMode: 'redis',
|
||||
enableMatrixSsoLink: options.enableMatrixSsoLink ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, UserSession> = 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<string>('auth.url') ||
|
||||
this.configService?.get<string>('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<string>('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<string | null> {
|
||||
// 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<string | null> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
// 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<LoginResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<boolean> {
|
||||
const token = await this.getToken(matrixUserId);
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full session object for a Matrix user
|
||||
*/
|
||||
async getSession(matrixUserId: string): Promise<UserSession | null> {
|
||||
// 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<string | null> {
|
||||
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<void> {
|
||||
// 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<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||
// Try Redis first
|
||||
if (this.useRedis()) {
|
||||
const data = await this.redisProvider!.getSessionData<T>(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<number> {
|
||||
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<SessionStats> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// Shared types
|
||||
export * from './types';
|
||||
|
||||
// Storage providers
|
||||
export { FileStorageProvider, MemoryStorageProvider } from './storage';
|
||||
|
||||
// Utility functions
|
||||
export * from './utils';
|
||||
|
|
@ -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<T> implements StorageProvider<T> {
|
||||
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<T> {
|
||||
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<void> {
|
||||
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<T> implements StorageProvider<T> {
|
||||
private data: T;
|
||||
|
||||
constructor(defaultData: T) {
|
||||
this.data = defaultData;
|
||||
}
|
||||
|
||||
async load(): Promise<T> {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
async save(data: T): Promise<void> {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T> {
|
||||
load(): Promise<T>;
|
||||
save(data: T): Promise<void>;
|
||||
}
|
||||
|
||||
// Service configuration
|
||||
export interface ServiceConfig {
|
||||
storagePath?: string;
|
||||
apiUrl?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Result type for operations
|
||||
export type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E };
|
||||
|
||||
// Pagination
|
||||
export interface PaginationOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
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<Priority, number> = {
|
||||
urgent: 1,
|
||||
high: 2,
|
||||
medium: 3,
|
||||
low: 4,
|
||||
};
|
||||
|
||||
// Common stats interface
|
||||
export interface ServiceStats {
|
||||
total: number;
|
||||
active: number;
|
||||
completed?: number;
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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: [] }),
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task | null> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown>;
|
||||
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<Task | null> {
|
||||
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<string, unknown>;
|
||||
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<boolean> {
|
||||
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<Project[]> {
|
||||
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<Task[]> {
|
||||
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<TodoStats> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TodoData>;
|
||||
}
|
||||
|
||||
export interface TodoModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
||||
useFactory: (...args: unknown[]) => Promise<TodoModuleOptions> | TodoModuleOptions;
|
||||
inject?: (Type<unknown> | 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<TodoData>(storagePath, defaultData),
|
||||
},
|
||||
TodoService,
|
||||
],
|
||||
exports: [TodoService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with custom storage provider
|
||||
*/
|
||||
static forRoot(storageProvider: StorageProvider<TodoData>): 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<TodoData>(storagePath, defaultData)
|
||||
);
|
||||
},
|
||||
inject: options.inject || [],
|
||||
};
|
||||
|
||||
return {
|
||||
module: TodoModule,
|
||||
imports: options.imports || [],
|
||||
providers: [storageProvider, TodoService],
|
||||
exports: [TodoService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TodoData>;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(TODO_STORAGE_PROVIDER)
|
||||
storage?: StorageProvider<TodoData>
|
||||
) {
|
||||
// Default to file storage if not injected
|
||||
this.storage =
|
||||
storage || new FileStorageProvider<TodoData>('./data/todo-data.json', { tasks: [], projects: [] });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
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<void> {
|
||||
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<Task> {
|
||||
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<Task | null> {
|
||||
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<Task | null> {
|
||||
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<Task | null> {
|
||||
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<Task | null> {
|
||||
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<Task | null> {
|
||||
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<Task | null> {
|
||||
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<Task | null> {
|
||||
return this.data.tasks.find((t) => t.id === taskId && t.userId === userId) ?? null;
|
||||
}
|
||||
|
||||
async getTasks(userId: string, filter?: TaskFilter): Promise<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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<Project[]> {
|
||||
const projectNames = new Set<string>();
|
||||
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<TodoStats> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>('stt.url') ||
|
||||
this.configService?.get<string>('STT_URL') ||
|
||||
'http://localhost:3020';
|
||||
|
||||
this.defaultLanguage = options?.defaultLanguage || 'de';
|
||||
|
||||
this.apiKey =
|
||||
options?.apiKey ||
|
||||
this.configService?.get<string>('stt.apiKey') ||
|
||||
this.configService?.get<string>('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<string> {
|
||||
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<string, string> = {};
|
||||
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<SttResponse> {
|
||||
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<string, string> = {};
|
||||
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<boolean> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<number, string> = {
|
||||
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<number, string> = {
|
||||
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',
|
||||
};
|
||||
|
|
@ -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> | 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<string>('weather.defaultLocation') ||
|
||||
config.get<string>('WEATHER_DEFAULT_LOCATION') ||
|
||||
'Berlin',
|
||||
cacheTtlMs:
|
||||
config.get<number>('weather.cacheTtlMs') ||
|
||||
config.get<number>('WEATHER_CACHE_TTL_MS') ||
|
||||
30 * 60 * 1000,
|
||||
language:
|
||||
(config.get<string>('weather.language') as 'de' | 'en') ||
|
||||
(config.get<string>('WEATHER_LANGUAGE') as 'de' | 'en') ||
|
||||
'de',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, { data: WeatherData; expiresAt: Date }> = new Map();
|
||||
// Geocoding cache: location -> coordinates
|
||||
private geocodeCache: Map<string, GeocodingResult> = 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<WeatherData | null> {
|
||||
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<GeocodingResult | null> {
|
||||
// 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<WeatherData | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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*');
|
||||
// '<strong>bold</strong> and <em>italic</em>'
|
||||
|
||||
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<MySessionData>(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<Contact>();
|
||||
|
||||
// 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, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// ...
|
||||
}
|
||||
|
||||
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
|
||||
```
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T = unknown>(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<void>;
|
||||
|
||||
/**
|
||||
* Handle an audio message (optional override)
|
||||
*/
|
||||
protected async handleAudioMessage(
|
||||
_roomId: string,
|
||||
_event: MatrixRoomEvent,
|
||||
_sender: string
|
||||
): Promise<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
if (this.client) {
|
||||
await this.client.stop();
|
||||
this.logger.log('Matrix client stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle room join event
|
||||
*/
|
||||
protected async onRoomJoin(roomId: string): Promise<void> {
|
||||
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<void> {
|
||||
// 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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<Buffer> {
|
||||
// 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<string> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<string, unknown>;
|
||||
'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';
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
|
||||
/**
|
||||
* Send a reply to an event (for credit commands)
|
||||
*/
|
||||
sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, CreditPackage[]>();
|
||||
|
||||
// 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<void> {
|
||||
await host.sendCreditMessage(roomId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply to an event
|
||||
*/
|
||||
async function sendReply(
|
||||
host: CreditCommandsHost,
|
||||
roomId: string,
|
||||
event: MatrixRoomEvent,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
await host.sendCreditReply(roomId, event, message);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export {
|
||||
handleCreditCommand,
|
||||
sendPaymentSuccessNotification,
|
||||
isCreditCommand,
|
||||
CREDIT_COMMANDS,
|
||||
type CreditCommand,
|
||||
type CreditCommandsHost,
|
||||
} from './credit-commands.mixin.js';
|
||||
|
|
@ -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<void>;
|
||||
|
||||
/**
|
||||
* Send a reply to an event (for gift commands)
|
||||
*/
|
||||
sendGiftReply(roomId: string, event: MatrixRoomEvent, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, number> = {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
'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<void> {
|
||||
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<void> {
|
||||
await host.sendGiftMessage(roomId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply to an event
|
||||
*/
|
||||
async function sendReply(
|
||||
host: GiftCommandsHost,
|
||||
roomId: string,
|
||||
event: MatrixRoomEvent,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
await host.sendGiftReply(roomId, event, message);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export {
|
||||
handleGiftCommand,
|
||||
isGiftCommand,
|
||||
GIFT_COMMANDS,
|
||||
type GiftCommand,
|
||||
type GiftCommandsHost,
|
||||
} from './gift-commands.mixin.js';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export {
|
||||
HealthController,
|
||||
HEALTH_SERVICE_NAME,
|
||||
createHealthProvider,
|
||||
type HealthResponse,
|
||||
} from './health.controller';
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export {
|
||||
KeywordCommandDetector,
|
||||
COMMON_KEYWORDS,
|
||||
type KeywordCommand,
|
||||
type KeywordDetectorOptions,
|
||||
} from './keyword-detector';
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { UserListMapper, UserIdListMapper } from './list-mapper';
|
||||
|
|
@ -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<Contact>();
|
||||
*
|
||||
* // 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<T> {
|
||||
private lists: Map<string, T[]> = 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<T extends { id: string }> extends UserListMapper<T> {
|
||||
private idMaps: Map<string, Map<number, string>> = new Map();
|
||||
|
||||
override setList(userId: string, items: T[]): void {
|
||||
super.setList(userId, items);
|
||||
|
||||
// Build ID map
|
||||
const idMap = new Map<number, string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export {
|
||||
markdownToHtml,
|
||||
escapeHtml,
|
||||
formatNumberedList,
|
||||
formatBulletList,
|
||||
} from './markdown-formatter';
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* Convert Markdown text to HTML for Matrix messages
|
||||
*
|
||||
* Supports:
|
||||
* - **bold** -> <strong>bold</strong>
|
||||
* - *italic* -> <em>italic</em>
|
||||
* - ~~strikethrough~~ -> <del>strikethrough</del>
|
||||
* - `code` -> <code>code</code>
|
||||
* - Newlines -> <br>
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const html = markdownToHtml('**Hello** *world*');
|
||||
* // Returns: '<strong>Hello</strong> <em>world</em>'
|
||||
* ```
|
||||
*/
|
||||
export function markdownToHtml(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a list of items as numbered markdown list
|
||||
*/
|
||||
export function formatNumberedList<T>(
|
||||
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<T>(items: T[], formatter: (item: T) => string): string {
|
||||
return items.map((item) => `• ${formatter(item)}`).join('\n');
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export {
|
||||
MatrixMessageService,
|
||||
type MatrixMessageContent,
|
||||
type SendMessageOptions,
|
||||
} from './message.service';
|
||||
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<SendMessageOptions, 'msgtype'> = {}
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
await client.sendStateEvent(roomId, 'm.room.topic', '', { topic });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a message in a room
|
||||
*/
|
||||
async pinMessage(client: MatrixClient, roomId: string, eventId: string): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { SessionHelper, createSessionHelper } from './session-helper';
|
||||
|
|
@ -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<ChatSessionData>(sessionService, matrixUserId);
|
||||
* await session.set('currentConversationId', 'abc123');
|
||||
* const convId = await session.get('currentConversationId'); // string | null
|
||||
* ```
|
||||
*/
|
||||
export class SessionHelper<T extends Record<string, unknown>> {
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly userId: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set a session value
|
||||
*/
|
||||
async set<K extends keyof T>(key: K, value: T[K]): Promise<void> {
|
||||
await this.sessionService.setSessionData(this.userId, key as string, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session value
|
||||
*/
|
||||
async get<K extends keyof T>(key: K): Promise<T[K] | null> {
|
||||
return this.sessionService.getSessionData<T[K]>(this.userId, key as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session value
|
||||
*/
|
||||
async delete<K extends keyof T>(key: K): Promise<void> {
|
||||
await this.sessionService.setSessionData(this.userId, key as string, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session value exists
|
||||
*/
|
||||
async has<K extends keyof T>(key: K): Promise<boolean> {
|
||||
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<boolean> {
|
||||
return this.sessionService.isLoggedIn(this.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT token for API calls
|
||||
*/
|
||||
async getToken(): Promise<string | null> {
|
||||
return this.sessionService.getToken(this.userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create session helper
|
||||
*/
|
||||
export function createSessionHelper<T extends Record<string, unknown>>(
|
||||
sessionService: SessionService,
|
||||
userId: string
|
||||
): SessionHelper<T> {
|
||||
return new SessionHelper<T>(sessionService, userId);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue