diff --git a/services/matrix-clock-bot/.dockerignore b/services/matrix-clock-bot/.dockerignore new file mode 100644 index 000000000..d6a8859ae --- /dev/null +++ b/services/matrix-clock-bot/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.log +.env* +data diff --git a/services/matrix-clock-bot/.env.example b/services/matrix-clock-bot/.env.example new file mode 100644 index 000000000..7df69e22a --- /dev/null +++ b/services/matrix-clock-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3317 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token +MATRIX_ALLOWED_ROOMS=#clock:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Clock Backend API +CLOCK_API_URL=http://localhost:3017/api/v1 +CLOCK_API_TOKEN= + +# Speech-to-Text (mana-stt service) +STT_URL=http://localhost:3020 diff --git a/services/matrix-clock-bot/CLAUDE.md b/services/matrix-clock-bot/CLAUDE.md new file mode 100644 index 000000000..3dab63e2c --- /dev/null +++ b/services/matrix-clock-bot/CLAUDE.md @@ -0,0 +1,158 @@ +# Matrix Clock Bot - Claude Code Guidelines + +## Overview + +Matrix Clock Bot provides time tracking functionality via Matrix chat. Users can create timers, set alarms, and manage world clocks through text commands or voice notes. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: Clock API (port 3017) +- **STT**: mana-stt service (port 3020) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-clock-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3317) +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & help messages +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ ├── clock/ +│ │ ├── clock.module.ts +│ │ └── clock.service.ts # Clock API client +│ └── transcription/ +│ ├── transcription.module.ts +│ └── transcription.service.ts # STT service client +├── Dockerfile +└── package.json +``` + +## Bot Commands + +### Timer Commands + +| Command | Description | +|---------|-------------| +| `!timer 25m` | Create & start 25-minute timer | +| `!timer 1h30m` | Create 1.5 hour timer | +| `!timer 25m Pomodoro` | Timer with label | +| `!stop` | Pause running timer | +| `!resume` | Resume paused timer | +| `!reset` | Reset timer to start | +| `!status` | Show current timer status | +| `!timers` | List all timers | + +### Alarm Commands + +| Command | Description | +|---------|-------------| +| `!alarm 07:30` | Set alarm for 7:30 | +| `!alarm 7 Uhr 30` | German time format | +| `!alarm 06:00 Aufstehen!` | Alarm with label | +| `!alarms` | List all alarms | + +### World Clock Commands + +| Command | Description | +|---------|-------------| +| `!zeit` / `!time` | Current time + world clocks | +| `!weltuhr Berlin` | Add world clock | +| `!weltuhren` | List world clocks | + +### Natural Language & Voice + +The bot understands natural language: +- "Timer 25 Minuten" +- "Wecker um 7 Uhr" +- "Stop" +- "Status" + +Voice notes are transcribed and parsed as commands. + +## Environment Variables + +```env +# Server +PORT=3317 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#clock:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Clock Backend API +CLOCK_API_URL=http://localhost:3017/api/v1 +CLOCK_API_TOKEN= + +# Speech-to-Text +STT_URL=http://localhost:3020 +``` + +## Clock API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/timers` | GET | List all timers | +| `/timers` | POST | Create timer | +| `/timers/:id/start` | POST | Start timer | +| `/timers/:id/pause` | POST | Pause timer | +| `/timers/:id/reset` | POST | Reset timer | +| `/alarms` | GET | List alarms | +| `/alarms` | POST | Create alarm | +| `/alarms/:id/toggle` | PATCH | Toggle alarm | +| `/world-clocks` | GET | List world clocks | +| `/world-clocks` | POST | Add world clock | +| `/timezones/search` | GET | Search timezones (public) | + +## Docker + +```bash +# Build +docker build -f services/matrix-clock-bot/Dockerfile -t matrix-clock-bot services/matrix-clock-bot + +# Run +docker run -p 3317:3317 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e CLOCK_API_URL=http://clock-backend:3017/api/v1 \ + -e STT_URL=http://mana-stt:3020 \ + -v matrix-clock-bot-data:/app/data \ + matrix-clock-bot +``` + +## Health Check + +```bash +curl http://localhost:3317/health +``` + +## Authentication + +Currently uses a demo token (`CLOCK_API_TOKEN`) for development. Production should implement proper user authentication flow: + +1. User sends `!login` command +2. Bot initiates OAuth/auth flow with mana-core-auth +3. User token stored per Matrix user ID +4. Token used for all Clock API calls diff --git a/services/matrix-clock-bot/Dockerfile b/services/matrix-clock-bot/Dockerfile new file mode 100644 index 000000000..5bc5b1a33 --- /dev/null +++ b/services/matrix-clock-bot/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install pnpm +RUN npm install -g pnpm + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source +COPY . . + +# Build +RUN pnpm build + +# Create data directory +RUN mkdir -p /app/data + +EXPOSE 3317 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-clock-bot/nest-cli.json b/services/matrix-clock-bot/nest-cli.json new file mode 100644 index 000000000..68d1974c4 --- /dev/null +++ b/services/matrix-clock-bot/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/services/matrix-clock-bot/package.json b/services/matrix-clock-bot/package.json new file mode 100644 index 000000000..3a34e47e0 --- /dev/null +++ b/services/matrix-clock-bot/package.json @@ -0,0 +1,28 @@ +{ + "name": "@manacore/matrix-clock-bot", + "version": "1.0.0", + "description": "Matrix bot for time tracking with Clock app", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.17", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.17", + "@nestjs/platform-express": "^10.4.17", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-clock-bot/src/app.module.ts b/services/matrix-clock-bot/src/app.module.ts new file mode 100644 index 000000000..52d5de4a4 --- /dev/null +++ b/services/matrix-clock-bot/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-clock-bot/src/bot/bot.module.ts b/services/matrix-clock-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..a3d2e67ab --- /dev/null +++ b/services/matrix-clock-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { ClockModule } from '../clock/clock.module'; +import { TranscriptionModule } from '../transcription/transcription.module'; + +@Module({ + imports: [ClockModule, TranscriptionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-clock-bot/src/bot/matrix.service.ts b/services/matrix-clock-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..430be0fd3 --- /dev/null +++ b/services/matrix-clock-bot/src/bot/matrix.service.ts @@ -0,0 +1,725 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichReply, +} from 'matrix-bot-sdk'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ClockService, Timer, Alarm } from '../clock/clock.service'; +import { TranscriptionService } from '../transcription/transcription.service'; +import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration'; + +// Natural language keywords +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'befehle', 'commands'], command: 'help' }, + { keywords: ['status', 'timer status', 'laufend'], command: 'status' }, + { keywords: ['stop', 'stopp', 'pause', 'anhalten'], command: 'stop' }, + { keywords: ['weiter', 'resume', 'fortsetzen'], command: 'resume' }, + { keywords: ['zeit', 'time', 'uhrzeit', 'wie spat'], command: 'time' }, +]; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private readonly homeserverUrl: string; + private readonly accessToken: string; + private readonly allowedRooms: string[]; + private readonly storagePath: string; + private botUserId: string = ''; + + // Demo token for development (TODO: implement proper auth) + private readonly demoToken = process.env.CLOCK_API_TOKEN || ''; + + constructor( + private configService: ConfigService, + private clockService: ClockService, + private transcriptionService: TranscriptionService + ) { + this.homeserverUrl = this.configService.get( + 'matrix.homeserverUrl', + 'http://localhost:8008' + ); + this.accessToken = this.configService.get('matrix.accessToken', ''); + this.allowedRooms = this.configService.get('matrix.allowedRooms', []); + this.storagePath = this.configService.get( + 'matrix.storagePath', + './data/bot-storage.json' + ); + } + + async onModuleInit() { + if (!this.accessToken) { + this.logger.warn('No Matrix access token configured. Bot will not start.'); + return; + } + + await this.initializeClient(); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + } + } + + private async initializeClient() { + try { + const storageDir = path.dirname(this.storagePath); + if (!fs.existsSync(storageDir)) { + fs.mkdirSync(storageDir, { recursive: true }); + } + + const storage = new SimpleFsStorageProvider(this.storagePath); + this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); + + setTimeout(async () => { + await this.sendWelcome(roomId); + }, 2000); + }); + + this.client.on('room.message', async (roomId: string, event: any) => { + await this.handleMessage(roomId, event); + }); + + await this.client.start(); + this.botUserId = await this.client.getUserId(); + this.logger.log(`Matrix Clock Bot connected as ${this.botUserId}`); + } catch (error) { + this.logger.error('Failed to initialize Matrix client:', error); + } + } + + private async handleMessage(roomId: string, event: any) { + if (event.sender === this.botUserId) return; + + if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + return; + } + + const userId = event.sender; + const msgtype = event.content?.msgtype; + + // Handle audio messages + if (msgtype === 'm.audio' && event.content?.url) { + await this.handleAudioMessage(roomId, event, userId); + return; + } + + if (msgtype !== 'm.text') return; + + const body = event.content.body?.trim(); + if (!body) return; + + try { + // Check keywords first + const keywordCommand = this.detectKeywordCommand(body); + if (keywordCommand) { + await this.executeCommand(roomId, event, userId, keywordCommand, ''); + return; + } + + // Handle ! commands + if (body.startsWith('!')) { + const [command, ...args] = body.slice(1).split(' '); + await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); + return; + } + + // Try to parse as natural timer/alarm command + await this.handleNaturalLanguage(roomId, event, userId, body); + } catch (error) { + this.logger.error(`Error handling message: ${error}`); + await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten.'); + } + } + + private detectKeywordCommand(message: string): string | null { + const lowerMessage = message.toLowerCase().trim(); + if (lowerMessage.length > 50) return null; + + for (const { keywords, command } of KEYWORD_COMMANDS) { + for (const keyword of keywords) { + if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) { + return command; + } + } + } + return null; + } + + private async executeCommand( + roomId: string, + event: any, + userId: string, + command: string, + args: string + ) { + switch (command) { + case 'help': + case 'hilfe': + await this.sendReply(roomId, event, HELP_TEXT); + break; + + case 'timer': + await this.handleTimerCommand(roomId, event, userId, args); + break; + + case 'stop': + case 'stopp': + case 'pause': + await this.handleStopCommand(roomId, event, userId); + break; + + case 'resume': + case 'weiter': + await this.handleResumeCommand(roomId, event, userId); + break; + + case 'reset': + await this.handleResetCommand(roomId, event, userId); + break; + + case 'status': + await this.handleStatusCommand(roomId, event, userId); + break; + + case 'timers': + await this.handleTimersCommand(roomId, event, userId); + break; + + case 'alarm': + case 'wecker': + await this.handleAlarmCommand(roomId, event, userId, args); + break; + + case 'alarms': + case 'alarme': + await this.handleAlarmsCommand(roomId, event, userId); + break; + + case 'zeit': + case 'time': + await this.handleTimeCommand(roomId, event, userId); + break; + + case 'weltuhr': + await this.handleWorldClockCommand(roomId, event, userId, args); + break; + + case 'weltuhren': + await this.handleWorldClocksCommand(roomId, event, userId); + break; + + default: + // Silently ignore unknown commands + break; + } + } + + private async handleTimerCommand(roomId: string, event: any, userId: string, args: string) { + if (!args.trim()) { + await this.sendReply( + roomId, + event, + '**Verwendung:** `!timer 25m` oder `!timer 1h30m`\n\nBeispiele:\n- `!timer 25` (25 Minuten)\n- `!timer 1h` (1 Stunde)\n- `!timer 90m Pomodoro` (90 Min mit Label)' + ); + return; + } + + const durationSeconds = this.clockService.parseDuration(args); + if (!durationSeconds) { + await this.sendReply(roomId, event, 'Konnte Zeit nicht verstehen. Beispiel: `!timer 25m`'); + return; + } + + // Extract label if present (everything after the duration) + const label = args.replace(/[\d\s]*[hms]+/gi, '').trim() || null; + + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung. Bitte zuerst `!login`.'); + return; + } + + // Create and start timer + const timer = await this.clockService.createTimer(durationSeconds, label, token); + const startedTimer = await this.clockService.startTimer(timer.id, token); + + const durationStr = this.clockService.formatDuration(durationSeconds); + let response = `**Timer gestartet!**\n\nDauer: ${durationStr}`; + if (label) response += `\nLabel: ${label}`; + response += '\n\n`!stop` zum Pausieren, `!status` fur Status'; + + await this.sendReply(roomId, event, response); + } catch (error) { + this.logger.error('Timer creation failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Erstellen des Timers.'); + } + } + + private async handleStopCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const runningTimer = await this.clockService.getRunningTimer(token); + if (!runningTimer) { + await this.sendReply(roomId, event, 'Kein laufender Timer.'); + return; + } + + if (runningTimer.status === 'paused') { + await this.sendReply( + roomId, + event, + 'Timer ist bereits pausiert. `!resume` zum Fortsetzen.' + ); + return; + } + + const timer = await this.clockService.pauseTimer(runningTimer.id, token); + const remaining = this.clockService.formatDuration(timer.remainingSeconds); + + await this.sendReply( + roomId, + event, + `**Timer pausiert**\n\nVerbleibend: ${remaining}\n\n\`!resume\` zum Fortsetzen, \`!reset\` zum Zurucksetzen` + ); + } catch (error) { + this.logger.error('Stop failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Pausieren.'); + } + } + + private async handleResumeCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const pausedTimer = await this.clockService.getRunningTimer(token); + if (!pausedTimer || pausedTimer.status !== 'paused') { + await this.sendReply(roomId, event, 'Kein pausierter Timer.'); + return; + } + + const timer = await this.clockService.startTimer(pausedTimer.id, token); + const remaining = this.clockService.formatDuration(timer.remainingSeconds); + + await this.sendReply(roomId, event, `**Timer fortgesetzt**\n\nVerbleibend: ${remaining}`); + } catch (error) { + this.logger.error('Resume failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Fortsetzen.'); + } + } + + private async handleResetCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const activeTimer = await this.clockService.getRunningTimer(token); + if (!activeTimer) { + await this.sendReply(roomId, event, 'Kein aktiver Timer.'); + return; + } + + await this.clockService.resetTimer(activeTimer.id, token); + await this.sendReply(roomId, event, 'Timer zuruckgesetzt.'); + } catch (error) { + this.logger.error('Reset failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Zurucksetzen.'); + } + } + + private async handleStatusCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const timer = await this.clockService.getRunningTimer(token); + if (!timer) { + await this.sendReply(roomId, event, 'Kein aktiver Timer.\n\nStarte einen mit `!timer 25m`'); + return; + } + + const remaining = this.clockService.formatDuration(timer.remainingSeconds); + const total = this.clockService.formatDuration(timer.durationSeconds); + const statusIcon = timer.status === 'running' ? '' : ''; + const statusText = timer.status === 'running' ? 'Lauft' : 'Pausiert'; + + let response = `**${statusIcon} Timer ${statusText}**\n\n`; + response += `Verbleibend: ${remaining} / ${total}`; + if (timer.label) response += `\nLabel: ${timer.label}`; + + await this.sendReply(roomId, event, response); + } catch (error) { + this.logger.error('Status failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Abrufen des Status.'); + } + } + + private async handleTimersCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const timers = await this.clockService.getTimers(token); + if (timers.length === 0) { + await this.sendReply(roomId, event, 'Keine Timer.\n\nErstelle einen mit `!timer 25m`'); + return; + } + + let response = '**Deine Timer:**\n\n'; + timers.forEach((t, i) => { + const duration = this.clockService.formatDuration(t.durationSeconds); + const statusIcon = t.status === 'running' ? '' : t.status === 'paused' ? '' : ''; + const label = t.label ? ` - ${t.label}` : ''; + response += `${i + 1}. ${statusIcon} ${duration}${label}\n`; + }); + + await this.sendReply(roomId, event, response); + } catch (error) { + this.logger.error('Timers list failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Abrufen der Timer.'); + } + } + + private async handleAlarmCommand(roomId: string, event: any, userId: string, args: string) { + const parts = args.trim().split(' '); + + // Handle !alarm off/on/delete commands + if (parts[0] === 'off' || parts[0] === 'on' || parts[0] === 'delete') { + // TODO: Implement alarm management + await this.sendReply(roomId, event, 'Alarm-Verwaltung kommt bald!'); + return; + } + + const time = this.clockService.parseAlarmTime(args); + if (!time) { + await this.sendReply( + roomId, + event, + '**Verwendung:** `!alarm 07:30` oder `!alarm 7 Uhr 30`\n\nBeispiel: `!alarm 06:00 Aufstehen!`' + ); + return; + } + + // Extract label (everything after the time) + const label = args.replace(/[\d:]+\s*(uhr\s*\d*)?/gi, '').trim() || null; + + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const alarm = await this.clockService.createAlarm(time, label, token); + let response = `**Alarm gestellt!**\n\nZeit: ${time.substring(0, 5)} Uhr`; + if (label) response += `\nLabel: ${label}`; + + await this.sendReply(roomId, event, response); + } catch (error) { + this.logger.error('Alarm creation failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Erstellen des Alarms.'); + } + } + + private async handleAlarmsCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const alarms = await this.clockService.getAlarms(token); + if (alarms.length === 0) { + await this.sendReply(roomId, event, 'Keine Alarme.\n\nErstelle einen mit `!alarm 07:30`'); + return; + } + + let response = '**Deine Alarme:**\n\n'; + alarms.forEach((a, i) => { + const time = a.time.substring(0, 5); + const enabledIcon = a.enabled ? '' : ''; + const label = a.label ? ` - ${a.label}` : ''; + response += `${i + 1}. ${enabledIcon} ${time} Uhr${label}\n`; + }); + + await this.sendReply(roomId, event, response); + } catch (error) { + this.logger.error('Alarms list failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Abrufen der Alarme.'); + } + } + + private async handleTimeCommand(roomId: string, event: any, userId: string) { + const now = new Date(); + const timeStr = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const dateStr = now.toLocaleDateString('de-DE', { + weekday: 'long', + day: 'numeric', + month: 'long', + }); + + let response = `**${timeStr} Uhr**\n${dateStr}`; + + try { + const token = this.getToken(userId); + if (token) { + const worldClocks = await this.clockService.getWorldClocks(token); + if (worldClocks.length > 0) { + response += '\n\n**Weltuhren:**'; + for (const wc of worldClocks) { + const wcTime = new Date().toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + timeZone: wc.timezone, + }); + response += `\n${wc.cityName}: ${wcTime}`; + } + } + } + } catch { + // Ignore world clock errors + } + + await this.sendReply(roomId, event, response); + } + + private async handleWorldClockCommand(roomId: string, event: any, userId: string, args: string) { + if (!args.trim()) { + await this.sendReply( + roomId, + event, + '**Verwendung:** `!weltuhr Berlin` oder `!weltuhr New York`' + ); + return; + } + + try { + const results = await this.clockService.searchTimezones(args); + if (results.length === 0) { + await this.sendReply(roomId, event, `Keine Zeitzone fur "${args}" gefunden.`); + return; + } + + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const best = results[0]; + await this.clockService.addWorldClock(best.timezone, best.city, token); + await this.sendReply(roomId, event, `**Weltuhr hinzugefugt:** ${best.city}`); + } catch (error) { + this.logger.error('World clock add failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Hinzufugen der Weltuhr.'); + } + } + + private async handleWorldClocksCommand(roomId: string, event: any, userId: string) { + try { + const token = this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, 'Keine Authentifizierung.'); + return; + } + + const clocks = await this.clockService.getWorldClocks(token); + if (clocks.length === 0) { + await this.sendReply( + roomId, + event, + 'Keine Weltuhren.\n\nFuge eine hinzu mit `!weltuhr Berlin`' + ); + return; + } + + let response = '**Deine Weltuhren:**\n\n'; + for (const wc of clocks) { + const time = new Date().toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + timeZone: wc.timezone, + }); + response += `${wc.cityName}: **${time}**\n`; + } + + await this.sendReply(roomId, event, response); + } catch (error) { + this.logger.error('World clocks list failed:', error); + await this.sendReply(roomId, event, 'Fehler beim Abrufen der Weltuhren.'); + } + } + + private async handleNaturalLanguage(roomId: string, event: any, userId: string, text: string) { + const lower = text.toLowerCase(); + + // Try to detect timer intent + if ( + lower.includes('timer') || + lower.includes('stoppuhr') || + lower.match(/start\s*\d+/) || + lower.match(/\d+\s*(min|m|h|stunde)/) + ) { + const duration = this.clockService.parseDuration(text); + if (duration) { + await this.handleTimerCommand(roomId, event, userId, text); + return; + } + } + + // Try to detect alarm intent + if ( + lower.includes('wecker') || + lower.includes('alarm') || + lower.includes('weck mich') || + lower.match(/\d{1,2}:\d{2}/) || + lower.match(/\d{1,2}\s*uhr/) + ) { + const time = this.clockService.parseAlarmTime(text); + if (time) { + await this.handleAlarmCommand(roomId, event, userId, text); + return; + } + } + + // No match - don't respond to random messages + } + + private async handleAudioMessage(roomId: string, event: any, userId: string) { + try { + await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...'); + + const mxcUrl = event.content.url; + const httpUrl = this.client.mxcToHttp(mxcUrl); + + const response = await fetch(httpUrl); + if (!response.ok) { + throw new Error(`Failed to download audio: ${response.status}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const transcription = await this.transcriptionService.transcribe(buffer); + + if (!transcription.trim()) { + await this.sendReply(roomId, event, 'Konnte keine Sprache erkennen.'); + return; + } + + this.logger.log(`Transcription: ${transcription}`); + + // Try to parse as command + const lower = transcription.toLowerCase(); + + // Check for timer + const duration = this.clockService.parseDuration(transcription); + if ( + duration && + (lower.includes('timer') || + lower.includes('minute') || + lower.includes('stunde') || + lower.match(/\d+\s*(m|min|h)/)) + ) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleTimerCommand(roomId, event, userId, transcription); + return; + } + + // Check for alarm + const time = this.clockService.parseAlarmTime(transcription); + if (time && (lower.includes('wecker') || lower.includes('alarm') || lower.includes('uhr'))) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleAlarmCommand(roomId, event, userId, transcription); + return; + } + + // Check for stop/status commands + if (lower.includes('stop') || lower.includes('stopp') || lower.includes('pause')) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleStopCommand(roomId, event, userId); + return; + } + + if (lower.includes('status') || lower.includes('wie viel')) { + await this.sendReply(roomId, event, `"${transcription}"`); + await this.handleStatusCommand(roomId, event, userId); + return; + } + + await this.sendReply( + roomId, + event, + `"${transcription}"\n\nKonnte Befehl nicht verstehen. Versuche "Timer 25 Minuten" oder "Wecker 7 Uhr".` + ); + } catch (error) { + this.logger.error('Audio processing failed:', error); + await this.sendReply(roomId, event, 'Fehler bei der Sprachverarbeitung.'); + } + } + + private getToken(userId: string): string | null { + // First check if user has a stored token + const storedToken = this.clockService.getUserToken(userId); + if (storedToken) return storedToken; + + // Fall back to demo token for development + return this.demoToken || null; + } + + private async sendWelcome(roomId: string) { + try { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: WELCOME_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(WELCOME_TEXT), + }); + } catch (error) { + this.logger.error('Failed to send welcome:', error); + } + } + + private async sendReply(roomId: string, event: any, message: string) { + const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); + reply.msgtype = 'm.text'; + await this.client.sendMessage(roomId, reply); + } + + private markdownToHtml(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-clock-bot/src/clock/clock.module.ts b/services/matrix-clock-bot/src/clock/clock.module.ts new file mode 100644 index 000000000..fe62f671b --- /dev/null +++ b/services/matrix-clock-bot/src/clock/clock.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ClockService } from './clock.service'; + +@Module({ + providers: [ClockService], + exports: [ClockService], +}) +export class ClockModule {} diff --git a/services/matrix-clock-bot/src/clock/clock.service.ts b/services/matrix-clock-bot/src/clock/clock.service.ts new file mode 100644 index 000000000..b278c70ab --- /dev/null +++ b/services/matrix-clock-bot/src/clock/clock.service.ts @@ -0,0 +1,259 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +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; +} + +export interface Alarm { + id: string; + userId: string; + label: string | null; + time: string; + enabled: boolean; + repeatDays: number[]; + snoozeMinutes: number; + sound: string; + vibrate: boolean; + createdAt: string; + updatedAt: string; +} + +export interface WorldClock { + id: string; + userId: string; + timezone: string; + cityName: string; + sortOrder: number; + createdAt: string; +} + +export interface TimezoneResult { + timezone: string; + city: string; +} + +@Injectable() +export class ClockService { + private readonly logger = new Logger(ClockService.name); + private readonly apiUrl: string; + + // In-memory token storage per Matrix user + private userTokens: Map = new Map(); + + constructor(private configService: ConfigService) { + this.apiUrl = this.configService.get('clock.apiUrl') || 'http://localhost:3017/api/v1'; + this.logger.log(`Clock API URL: ${this.apiUrl}`); + } + + setUserToken(matrixUserId: string, token: string) { + this.userTokens.set(matrixUserId, token); + } + + getUserToken(matrixUserId: string): string | undefined { + return this.userTokens.get(matrixUserId); + } + + private async apiCall( + endpoint: string, + method: string = 'GET', + token?: string, + body?: unknown + ): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${this.apiUrl}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Clock API error: ${response.status} - ${errorText}`); + } + + return response.json(); + } + + // Timer operations + async getTimers(token: string): Promise { + return this.apiCall('/timers', 'GET', token); + } + + async getTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}`, 'GET', token); + } + + async createTimer(durationSeconds: number, label: string | null, token: string): Promise { + return this.apiCall('/timers', 'POST', token, { + durationSeconds, + label, + }); + } + + async startTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}/start`, 'POST', token); + } + + async pauseTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}/pause`, 'POST', token); + } + + async resetTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}/reset`, 'POST', token); + } + + async deleteTimer(id: string, token: string): Promise { + await this.apiCall(`/timers/${id}`, 'DELETE', token); + } + + // Alarm operations + async getAlarms(token: string): Promise { + return this.apiCall('/alarms', 'GET', token); + } + + async createAlarm(time: string, label: string | null, token: string): Promise { + return this.apiCall('/alarms', 'POST', token, { + time, + label, + enabled: true, + }); + } + + async toggleAlarm(id: string, token: string): Promise { + return this.apiCall(`/alarms/${id}/toggle`, 'PATCH', token); + } + + async deleteAlarm(id: string, token: string): Promise { + await this.apiCall(`/alarms/${id}`, 'DELETE', token); + } + + // World Clock operations + async getWorldClocks(token: string): Promise { + return this.apiCall('/world-clocks', 'GET', token); + } + + async addWorldClock(timezone: string, cityName: string, token: string): Promise { + return this.apiCall('/world-clocks', 'POST', token, { + timezone, + cityName, + }); + } + + async deleteWorldClock(id: string, token: string): Promise { + await this.apiCall(`/world-clocks/${id}`, 'DELETE', token); + } + + // Timezone search (public, no auth needed) + async searchTimezones(query: string): Promise { + return this.apiCall(`/timezones/search?q=${encodeURIComponent(query)}`); + } + + // Health check + async checkHealth(): Promise { + try { + const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`); + return response.ok; + } catch { + return false; + } + } + + // Utility: Find running timer + async getRunningTimer(token: string): Promise { + const timers = await this.getTimers(token); + return timers.find((t) => t.status === 'running' || t.status === 'paused') || null; + } + + // Utility: Parse duration string to seconds + 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; + } + + // Utility: Parse time string to HH:MM:SS + 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; + } + + // Utility: 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(' '); + } +} diff --git a/services/matrix-clock-bot/src/config/configuration.ts b/services/matrix-clock-bot/src/config/configuration.ts new file mode 100644 index 000000000..c14e0b19a --- /dev/null +++ b/services/matrix-clock-bot/src/config/configuration.ts @@ -0,0 +1,72 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3317', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean), + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + clock: { + apiUrl: process.env.CLOCK_API_URL || 'http://localhost:3017/api/v1', + }, + stt: { + url: process.env.STT_URL || 'http://localhost:3020', + }, +}); + +export const HELP_TEXT = `**Clock Bot - Zeiterfassung per Chat** + +**Timer (Stoppuhr):** +- \`!timer 25m\` oder \`!timer 1h30m\` - Timer erstellen & starten +- \`!stop\` - Laufenden Timer pausieren +- \`!resume\` - Pausierten Timer fortsetzen +- \`!reset\` - Timer zurucksetzen +- \`!status\` - Aktuellen Timer-Status anzeigen +- \`!timers\` - Alle Timer anzeigen + +**Alarme (Wecker):** +- \`!alarm 07:30\` - Alarm fur 7:30 Uhr setzen +- \`!alarm 07:30 Aufwachen!\` - Alarm mit Label +- \`!alarms\` - Alle Alarme anzeigen +- \`!alarm off 1\` - Alarm #1 deaktivieren +- \`!alarm on 1\` - Alarm #1 aktivieren +- \`!alarm delete 1\` - Alarm #1 loschen + +**Weltuhren:** +- \`!zeit\` oder \`!time\` - Aktuelle Zeit + Weltuhren +- \`!weltuhr Berlin\` - Weltuhr hinzufugen +- \`!weltuhren\` - Alle Weltuhren anzeigen + +**Sprachnotizen:** +Sende eine Sprachnotiz wie "Timer 25 Minuten" oder "Wecker um 7 Uhr" + +**Shortcuts:** +- "start 25 min" - Timer starten +- "stop" - Timer stoppen +- "status" - Status anzeigen`; + +export const WELCOME_TEXT = `**Clock Bot - Zeiterfassung** + +Starte mit: +- \`!timer 25m\` - 25-Minuten Timer +- \`!alarm 07:30\` - Wecker stellen +- \`!zeit\` - Aktuelle Zeit + +Oder sende eine Sprachnotiz! + +\`!help\` fur alle Befehle.`; + +// Natural language patterns for time parsing +export const TIME_PATTERNS = { + // Timer duration patterns + duration: [ + /(\d+)\s*h(?:ours?|r)?(?:\s*(\d+)\s*m(?:in(?:utes?)?)?)?/i, // 1h, 1h30m, 1 hour 30 minutes + /(\d+)\s*m(?:in(?:utes?)?)?/i, // 25m, 25 min, 25 minutes + /(\d+)\s*s(?:ec(?:onds?)?)?/i, // 30s, 30 sec + ], + // Alarm time patterns + alarmTime: [ + /(\d{1,2}):(\d{2})(?::(\d{2}))?/, // 07:30, 7:30:00 + /(\d{1,2})\s*uhr(?:\s*(\d{1,2}))?/i, // 7 Uhr, 7 Uhr 30 + ], +}; diff --git a/services/matrix-clock-bot/src/health.controller.ts b/services/matrix-clock-bot/src/health.controller.ts new file mode 100644 index 000000000..d1c4783c6 --- /dev/null +++ b/services/matrix-clock-bot/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok', service: 'matrix-clock-bot' }; + } +} diff --git a/services/matrix-clock-bot/src/main.ts b/services/matrix-clock-bot/src/main.ts new file mode 100644 index 000000000..eb1ce6916 --- /dev/null +++ b/services/matrix-clock-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const port = process.env.PORT || 3317; + + await app.listen(port); + + const logger = new Logger('Bootstrap'); + logger.log(`Matrix Clock Bot running on port ${port}`); +} + +bootstrap(); diff --git a/services/matrix-clock-bot/src/transcription/transcription.module.ts b/services/matrix-clock-bot/src/transcription/transcription.module.ts new file mode 100644 index 000000000..fb5aeeaf1 --- /dev/null +++ b/services/matrix-clock-bot/src/transcription/transcription.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TranscriptionService } from './transcription.service'; + +@Module({ + providers: [TranscriptionService], + exports: [TranscriptionService], +}) +export class TranscriptionModule {} diff --git a/services/matrix-clock-bot/src/transcription/transcription.service.ts b/services/matrix-clock-bot/src/transcription/transcription.service.ts new file mode 100644 index 000000000..85296cf98 --- /dev/null +++ b/services/matrix-clock-bot/src/transcription/transcription.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface SttResponse { + text: string; + language?: string; + model?: string; +} + +@Injectable() +export class TranscriptionService { + private readonly logger = new Logger(TranscriptionService.name); + private readonly sttUrl: string; + + constructor(private configService: ConfigService) { + this.sttUrl = this.configService.get('stt.url') || 'http://localhost:3020'; + this.logger.log(`STT Service URL: ${this.sttUrl}`); + } + + async transcribe(audioBuffer: Buffer, language: string = 'de'): Promise { + const formData = new FormData(); + const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/ogg' }); + formData.append('file', blob, 'audio.ogg'); + formData.append('language', language); + + try { + const response = await fetch(`${this.sttUrl}/transcribe`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`STT service error: ${response.status} - ${errorText}`); + } + + const result: SttResponse = await response.json(); + this.logger.log(`Transcription completed: ${result.text.substring(0, 50)}...`); + return result.text; + } catch (error) { + this.logger.error('Transcription failed:', error); + throw error; + } + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.sttUrl}/health`); + return response.ok; + } catch { + return false; + } + } +} diff --git a/services/matrix-clock-bot/tsconfig.json b/services/matrix-clock-bot/tsconfig.json new file mode 100644 index 000000000..edf10cd0d --- /dev/null +++ b/services/matrix-clock-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +}