From 2d879b327e523dfae7f3f0a03f147e881a39d6a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 00:23:46 +0000 Subject: [PATCH] feat(services): add matrix-mana-bot unified gateway Introduces a unified Matrix bot that combines all features: - AI Chat (Ollama integration) - Todo management - Calendar events - Timers & Alarms - Cross-feature orchestration (!summary, !ai-todo) Architecture: - Uses @manacore/bot-services for shared business logic - Command router with natural language support - Handlers delegate to shared services - Orchestration layer for cross-feature AI features This enables users to interact with a single bot for all features, while standalone bots remain available for dedicated use cases. https://claude.ai/code/session_015bwcqVRiFmSydYTjvDJGTc --- services/matrix-mana-bot/.env.example | 24 ++ services/matrix-mana-bot/CLAUDE.md | 306 ++++++++++++++++++ services/matrix-mana-bot/Dockerfile | 29 ++ services/matrix-mana-bot/nest-cli.json | 8 + services/matrix-mana-bot/package.json | 37 +++ services/matrix-mana-bot/src/app.module.ts | 61 ++++ .../matrix-mana-bot/src/bot/bot.module.ts | 12 + .../src/bot/command-router.service.ts | 272 ++++++++++++++++ .../matrix-mana-bot/src/bot/matrix.service.ts | 215 ++++++++++++ .../src/config/configuration.ts | 89 +++++ .../src/handlers/ai.handler.ts | 109 +++++++ .../src/handlers/calendar.handler.ts | 123 +++++++ .../src/handlers/clock.handler.ts | 152 +++++++++ .../src/handlers/handlers.module.ts | 14 + .../src/handlers/help.handler.ts | 39 +++ .../src/handlers/todo.handler.ts | 144 +++++++++ .../src/health/health.controller.ts | 13 + services/matrix-mana-bot/src/main.ts | 18 ++ .../src/orchestration/orchestration.module.ts | 10 + .../orchestration/orchestration.service.ts | 159 +++++++++ services/matrix-mana-bot/tsconfig.json | 25 ++ 21 files changed, 1859 insertions(+) create mode 100644 services/matrix-mana-bot/.env.example create mode 100644 services/matrix-mana-bot/CLAUDE.md create mode 100644 services/matrix-mana-bot/Dockerfile create mode 100644 services/matrix-mana-bot/nest-cli.json create mode 100644 services/matrix-mana-bot/package.json create mode 100644 services/matrix-mana-bot/src/app.module.ts create mode 100644 services/matrix-mana-bot/src/bot/bot.module.ts create mode 100644 services/matrix-mana-bot/src/bot/command-router.service.ts create mode 100644 services/matrix-mana-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-mana-bot/src/config/configuration.ts create mode 100644 services/matrix-mana-bot/src/handlers/ai.handler.ts create mode 100644 services/matrix-mana-bot/src/handlers/calendar.handler.ts create mode 100644 services/matrix-mana-bot/src/handlers/clock.handler.ts create mode 100644 services/matrix-mana-bot/src/handlers/handlers.module.ts create mode 100644 services/matrix-mana-bot/src/handlers/help.handler.ts create mode 100644 services/matrix-mana-bot/src/handlers/todo.handler.ts create mode 100644 services/matrix-mana-bot/src/health/health.controller.ts create mode 100644 services/matrix-mana-bot/src/main.ts create mode 100644 services/matrix-mana-bot/src/orchestration/orchestration.module.ts create mode 100644 services/matrix-mana-bot/src/orchestration/orchestration.service.ts create mode 100644 services/matrix-mana-bot/tsconfig.json diff --git a/services/matrix-mana-bot/.env.example b/services/matrix-mana-bot/.env.example new file mode 100644 index 000000000..07b91ac37 --- /dev/null +++ b/services/matrix-mana-bot/.env.example @@ -0,0 +1,24 @@ +# Server +PORT=3310 +NODE_ENV=development +TZ=Europe/Berlin + +# Matrix Connection +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_your_access_token_here +MATRIX_STORAGE_PATH=./data/mana-bot-storage.json + +# Optional: Restrict to specific rooms (comma-separated) +# MATRIX_ALLOWED_ROOMS=!room1:mana.how,!room2:mana.how + +# AI Service (Ollama) +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_TIMEOUT=120000 + +# Clock Service (external API) +CLOCK_API_URL=http://localhost:3017/api/v1 + +# Storage paths +TODO_STORAGE_PATH=./data/todos.json +CALENDAR_STORAGE_PATH=./data/calendar.json diff --git a/services/matrix-mana-bot/CLAUDE.md b/services/matrix-mana-bot/CLAUDE.md new file mode 100644 index 000000000..1fe411d1f --- /dev/null +++ b/services/matrix-mana-bot/CLAUDE.md @@ -0,0 +1,306 @@ +# Matrix Mana Bot (Gateway) + +Unified Matrix bot that combines all features in one. Users can interact with a single bot for AI chat, todos, calendar, timers, and more. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ matrix-mana-bot │ +│ (Gateway) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Matrix Service │ │ +│ │ • Handles Matrix connection │ │ +│ │ • Receives messages │ │ +│ │ • Sends replies │ │ +│ └─────────────────────────┬────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Command Router │ │ +│ │ • Parses !commands and natural language │ │ +│ │ • Routes to appropriate handler │ │ +│ │ • Falls back to AI chat │ │ +│ └─────────────────────────┬────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┼──────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ AI Handler │ │Todo Handler│ │Cal Handler │ ... │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +│ │ │ │ │ +│ └─────────────────┴─────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ @manacore/bot-services │ │ +│ │ (Shared Business Logic - no Matrix code) │ │ +│ │ │ │ +│ │ • TodoService • CalendarService │ │ +│ │ • AiService • ClockService │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Features + +| Category | Commands | Description | +|----------|----------|-------------| +| **AI Chat** | Just type, `!model`, `!models`, `!all`, `!clear` | Local LLM via Ollama | +| **Todos** | `!todo`, `!list`, `!today`, `!done`, `!delete` | Task management | +| **Calendar** | `!cal`, `!week`, `!event`, `!calendars` | Event scheduling | +| **Timers** | `!timer`, `!timers`, `!stop`, `!alarm`, `!alarms` | Time management | +| **Smart** | `!summary`, `!ai-todo` | Cross-feature AI features | + +## Commands + +### AI & Chat + +``` +# Just type a message - AI responds +Was ist TypeScript? + +# Switch model +!model gemma3:4b + +# List available models +!models + +# Compare all models +!all Erkläre Docker + +# Clear chat history +!clear +``` + +### Todos + +``` +# Create task +!todo Einkaufen gehen + +# With priority (1-4, 1 = highest) +!todo Wichtig !p1 + +# With date +!todo Meeting @morgen +!todo Report @heute + +# With project +!todo Feature implementieren #arbeit + +# List all +!list + +# Today's tasks +!today + +# Complete task +!done 1 + +# Delete task +!delete 1 +``` + +### Calendar + +``` +# Today's events +!cal + +# This week +!week + +# Create event +!event Meeting morgen 14:30 +!event Geburtstag heute ganztägig +``` + +### Timers & Alarms + +``` +# Start timer +!timer 25m Pomodoro +!timer 1h30m Meeting + +# List active timers +!timers + +# Stop timer +!stop + +# Set alarm +!alarm 14:30 Meeting +!alarm 7:00 Aufstehen + +# List alarms +!alarms + +# World clock +!time +!time tokyo +``` + +### Smart Features (Cross-Feature) + +``` +# AI-powered daily summary +!summary + +# AI extracts todos from text +!ai-todo Im Meeting besprochen: Website redesign, API Docs aktualisieren +``` + +## Development + +### Prerequisites + +- Node.js 20+ +- pnpm +- Running Matrix homeserver (Synapse) +- Bot account with access token +- Ollama (for AI features) + +### Setup + +```bash +# Install dependencies +pnpm install + +# Copy environment file +cp .env.example .env +# Edit .env with your settings + +# Start in development mode +pnpm start:dev + +# Or build and run +pnpm build && pnpm start:prod +``` + +### Get Matrix Access Token + +```bash +# Register bot user (if not exists) +docker exec -it synapse register_new_matrix_user \ + -u mana-bot \ + -p your_password \ + -a \ + -c /data/homeserver.yaml \ + http://localhost:8008 + +# Login to get access token +curl -X POST "http://localhost:8008/_matrix/client/r0/login" \ + -H "Content-Type: application/json" \ + -d '{"type": "m.login.password", "user": "mana-bot", "password": "your_password"}' +``` + +### Project Structure + +``` +src/ +├── main.ts # Entry point +├── app.module.ts # Root module +├── config/ +│ └── configuration.ts # Config & help texts +├── health/ +│ └── health.controller.ts # Health endpoint +├── bot/ +│ ├── bot.module.ts +│ ├── matrix.service.ts # Matrix connection +│ └── command-router.service.ts # Command routing +├── handlers/ +│ ├── handlers.module.ts +│ ├── ai.handler.ts # AI/Ollama commands +│ ├── todo.handler.ts # Todo commands +│ ├── calendar.handler.ts # Calendar commands +│ ├── clock.handler.ts # Timer/alarm commands +│ └── help.handler.ts # Help & status +└── orchestration/ + ├── orchestration.module.ts + └── orchestration.service.ts # Cross-feature logic +``` + +### Adding New Commands + +1. Add route in `command-router.service.ts`: + +```typescript +{ + patterns: ['!mycommand'], + handler: (ctx, args) => this.myHandler.doSomething(ctx, args), + description: 'My new command', +} +``` + +2. Create handler in `handlers/my.handler.ts`: + +```typescript +@Injectable() +export class MyHandler { + constructor(private myService: MyService) {} + + async doSomething(ctx: CommandContext, args: string): Promise { + // Use service from @manacore/bot-services + const result = await this.myService.doThing(ctx.userId, args); + return `Result: ${result}`; + } +} +``` + +3. Register in `handlers.module.ts` + +## Docker + +### Build + +```bash +docker build -t matrix-mana-bot . +``` + +### Run + +```bash +docker run -d \ + --name matrix-mana-bot \ + -p 3310:3310 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e OLLAMA_URL=http://ollama:11434 \ + -v ./data:/app/data \ + matrix-mana-bot +``` + +### Docker Compose + +See `docker-compose.macmini.yml` in the monorepo root. + +## Relationship to Other Bots + +This Gateway bot can run **alongside** the standalone bots: + +| Bot | Purpose | When to Use | +|-----|---------|-------------| +| **matrix-mana-bot** (this) | All features in one | General users | +| **matrix-todo-bot** | Todo only | Dedicated todo room | +| **matrix-ollama-bot** | AI only | Dedicated AI room | +| **matrix-clock-bot** | Timers only | Time tracking room | + +All bots share the same `@manacore/bot-services` package, so data is consistent. + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `PORT` | No | 3310 | HTTP port | +| `MATRIX_HOMESERVER_URL` | Yes | - | Matrix server URL | +| `MATRIX_ACCESS_TOKEN` | Yes | - | Bot access token | +| `MATRIX_STORAGE_PATH` | No | ./data/... | Sync state storage | +| `MATRIX_ALLOWED_ROOMS` | No | - | Restrict to rooms | +| `OLLAMA_URL` | No | localhost:11434 | Ollama API | +| `OLLAMA_MODEL` | No | gemma3:4b | Default LLM | +| `CLOCK_API_URL` | No | localhost:3017 | Clock backend | +| `TODO_STORAGE_PATH` | No | ./data/todos.json | Todo storage | +| `CALENDAR_STORAGE_PATH` | No | ./data/calendar.json | Calendar storage | diff --git a/services/matrix-mana-bot/Dockerfile b/services/matrix-mana-bot/Dockerfile new file mode 100644 index 000000000..d45cf6a43 --- /dev/null +++ b/services/matrix-mana-bot/Dockerfile @@ -0,0 +1,29 @@ +FROM node:20-slim + +WORKDIR /app + +# Install pnpm +RUN npm install -g pnpm@9 + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile --prod + +# Copy source +COPY . . + +# Build +RUN pnpm build + +# Create data directory +RUN mkdir -p /app/data + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3310/health || exit 1 + +EXPOSE 3310 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-mana-bot/nest-cli.json b/services/matrix-mana-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-mana-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-mana-bot/package.json b/services/matrix-mana-bot/package.json new file mode 100644 index 000000000..738e91411 --- /dev/null +++ b/services/matrix-mana-bot/package.json @@ -0,0 +1,37 @@ +{ + "name": "matrix-mana-bot", + "version": "1.0.0", + "description": "Unified Matrix Gateway Bot - All features in one", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@manacore/bot-services": "workspace:*", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "pnpm": { + "neverBuiltDependencies": ["cpu-features", "ssh2"], + "overrides": { + "cpu-features": "npm:empty-npm-package@1.0.0", + "ssh2": "npm:empty-npm-package@1.0.0" + } + } +} diff --git a/services/matrix-mana-bot/src/app.module.ts b/services/matrix-mana-bot/src/app.module.ts new file mode 100644 index 000000000..a80393ad8 --- /dev/null +++ b/services/matrix-mana-bot/src/app.module.ts @@ -0,0 +1,61 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { HandlersModule } from './handlers/handlers.module'; +import { OrchestrationModule } from './orchestration/orchestration.module'; +import { HealthController } from './health/health.controller'; + +// Import shared services from bot-services package +import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot-services'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + + // Business Logic Modules from shared package + TodoModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + storagePath: config.get('services.todo.storagePath'), + }), + inject: [ConfigService], + }), + + CalendarModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + storagePath: config.get('services.calendar.storagePath'), + }), + inject: [ConfigService], + }), + + AiModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + baseUrl: config.get('services.ai.baseUrl'), + defaultModel: config.get('services.ai.defaultModel'), + timeout: config.get('services.ai.timeout'), + }), + inject: [ConfigService], + }), + + ClockModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + apiUrl: config.get('services.clock.apiUrl'), + }), + inject: [ConfigService], + }), + + // Gateway-specific modules + BotModule, + HandlersModule, + OrchestrationModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-mana-bot/src/bot/bot.module.ts b/services/matrix-mana-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..e1a512458 --- /dev/null +++ b/services/matrix-mana-bot/src/bot/bot.module.ts @@ -0,0 +1,12 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { CommandRouterService } from './command-router.service'; +import { HandlersModule } from '../handlers/handlers.module'; +import { OrchestrationModule } from '../orchestration/orchestration.module'; + +@Module({ + imports: [forwardRef(() => HandlersModule), forwardRef(() => OrchestrationModule)], + providers: [MatrixService, CommandRouterService], + exports: [MatrixService, CommandRouterService], +}) +export class BotModule {} diff --git a/services/matrix-mana-bot/src/bot/command-router.service.ts b/services/matrix-mana-bot/src/bot/command-router.service.ts new file mode 100644 index 000000000..416ea90c3 --- /dev/null +++ b/services/matrix-mana-bot/src/bot/command-router.service.ts @@ -0,0 +1,272 @@ +import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; +import { AiHandler } from '../handlers/ai.handler'; +import { TodoHandler } from '../handlers/todo.handler'; +import { CalendarHandler } from '../handlers/calendar.handler'; +import { ClockHandler } from '../handlers/clock.handler'; +import { HelpHandler } from '../handlers/help.handler'; +import { OrchestrationService } from '../orchestration/orchestration.service'; + +export interface CommandContext { + roomId: string; + userId: string; + message: string; + event: any; +} + +interface CommandRoute { + patterns: (string | RegExp)[]; + handler: (ctx: CommandContext, args: string) => Promise; + description: string; +} + +// Natural language keywords (German + English) +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'was kannst du', 'befehle'], command: '!help' }, + { keywords: ['modelle', 'models', 'welche modelle'], command: '!models' }, + { keywords: ['meine aufgaben', 'zeige aufgaben', 'todo liste', 'was muss ich'], command: '!list' }, + { keywords: ['heute', 'was steht heute an'], command: '!today' }, + { keywords: ['termine', 'kalender', 'meine termine'], command: '!cal' }, + { keywords: ['timer', 'stoppuhr'], command: '!timers' }, + { keywords: ['zusammenfassung', 'wie war mein tag', 'tagesrückblick'], command: '!summary' }, +]; + +@Injectable() +export class CommandRouterService { + private readonly logger = new Logger(CommandRouterService.name); + private routes: CommandRoute[] = []; + + constructor( + @Inject(forwardRef(() => AiHandler)) + private aiHandler: AiHandler, + @Inject(forwardRef(() => TodoHandler)) + private todoHandler: TodoHandler, + @Inject(forwardRef(() => CalendarHandler)) + private calendarHandler: CalendarHandler, + @Inject(forwardRef(() => ClockHandler)) + private clockHandler: ClockHandler, + @Inject(forwardRef(() => HelpHandler)) + private helpHandler: HelpHandler, + @Inject(forwardRef(() => OrchestrationService)) + private orchestration: OrchestrationService + ) { + this.initializeRoutes(); + } + + private initializeRoutes() { + this.routes = [ + // Help + { + patterns: ['!help', '!start', '!hilfe'], + handler: (ctx) => this.helpHandler.showHelp(ctx), + description: 'Show help', + }, + + // AI Commands + { + patterns: ['!models', '!modelle'], + handler: (ctx) => this.aiHandler.listModels(ctx), + description: 'List AI models', + }, + { + patterns: ['!model'], + handler: (ctx, args) => this.aiHandler.setModel(ctx, args), + description: 'Switch AI model', + }, + { + patterns: ['!all'], + handler: (ctx, args) => this.aiHandler.compareAll(ctx, args), + description: 'Compare all models', + }, + { + patterns: ['!clear', '!reset'], + handler: (ctx) => this.aiHandler.clearHistory(ctx), + description: 'Clear chat history', + }, + + // Todo Commands + { + patterns: ['!todo', '!add', '!neu'], + handler: (ctx, args) => this.todoHandler.create(ctx, args), + description: 'Create todo', + }, + { + patterns: ['!list', '!liste', '!alle'], + handler: (ctx) => this.todoHandler.list(ctx), + description: 'List todos', + }, + { + patterns: ['!today', '!heute'], + handler: (ctx) => this.todoHandler.today(ctx), + description: 'Today\'s todos', + }, + { + patterns: ['!inbox'], + handler: (ctx) => this.todoHandler.inbox(ctx), + description: 'Inbox todos', + }, + { + patterns: ['!done', '!erledigt', '!fertig'], + handler: (ctx, args) => this.todoHandler.complete(ctx, args), + description: 'Complete todo', + }, + { + patterns: ['!delete', '!löschen'], + handler: (ctx, args) => this.todoHandler.delete(ctx, args), + description: 'Delete todo', + }, + { + patterns: ['!projects', '!projekte'], + handler: (ctx) => this.todoHandler.projects(ctx), + description: 'List projects', + }, + + // Calendar Commands + { + patterns: ['!cal', '!termine'], + handler: (ctx) => this.calendarHandler.today(ctx), + description: 'Today\'s events', + }, + { + patterns: ['!week', '!woche'], + handler: (ctx) => this.calendarHandler.week(ctx), + description: 'Week events', + }, + { + patterns: ['!event', '!termin'], + handler: (ctx, args) => this.calendarHandler.create(ctx, args), + description: 'Create event', + }, + { + patterns: ['!calendars', '!kalender'], + handler: (ctx) => this.calendarHandler.listCalendars(ctx), + description: 'List calendars', + }, + + // Clock Commands + { + patterns: ['!timer'], + handler: (ctx, args) => this.clockHandler.startTimer(ctx, args), + description: 'Start timer', + }, + { + patterns: ['!timers'], + handler: (ctx) => this.clockHandler.listTimers(ctx), + description: 'List timers', + }, + { + patterns: ['!alarm'], + handler: (ctx, args) => this.clockHandler.setAlarm(ctx, args), + description: 'Set alarm', + }, + { + patterns: ['!alarms'], + handler: (ctx) => this.clockHandler.listAlarms(ctx), + description: 'List alarms', + }, + { + patterns: ['!time', '!zeit'], + handler: (ctx, args) => this.clockHandler.worldClock(ctx, args), + description: 'World clock', + }, + { + patterns: ['!stop'], + handler: (ctx, args) => this.clockHandler.stopTimer(ctx, args), + description: 'Stop timer', + }, + + // Cross-Feature (Orchestration) + { + patterns: ['!summary', '!zusammenfassung'], + handler: (ctx) => this.orchestration.dailySummary(ctx), + description: 'Daily summary', + }, + { + patterns: ['!ai-todo'], + handler: (ctx, args) => this.orchestration.aiToTodos(ctx, args), + description: 'AI extracts todos', + }, + + // Status + { + patterns: ['!status'], + handler: (ctx) => this.helpHandler.showStatus(ctx), + description: 'Show status', + }, + ]; + } + + async route(ctx: CommandContext): Promise { + const message = ctx.message.trim(); + + // Check for natural language keywords first + const keywordCommand = this.detectKeywordCommand(message); + if (keywordCommand) { + return this.routeCommand({ ...ctx, message: keywordCommand }); + } + + // Check for ! commands + if (message.startsWith('!')) { + return this.routeCommand(ctx); + } + + // Default: treat as AI chat + return this.aiHandler.chat(ctx, message); + } + + private async routeCommand(ctx: CommandContext): Promise { + const { command, args } = this.parseCommand(ctx.message); + + for (const route of this.routes) { + if (this.matchesPattern(command, route.patterns)) { + this.logger.debug(`Routing "${command}" to ${route.description}`); + try { + return await route.handler(ctx, args); + } catch (error) { + this.logger.error(`Error in handler for "${command}":`, error); + return `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`; + } + } + } + + // Unknown command + return null; + } + + private detectKeywordCommand(message: string): string | null { + const lowerMessage = message.toLowerCase().trim(); + + // Only check short messages + if (lowerMessage.length > 60) return null; + + for (const { keywords, command } of KEYWORD_COMMANDS) { + for (const keyword of keywords) { + if (lowerMessage === keyword || lowerMessage.includes(keyword)) { + this.logger.debug(`Detected keyword "${keyword}" -> "${command}"`); + return command; + } + } + } + + return null; + } + + private matchesPattern(command: string, patterns: (string | RegExp)[]): boolean { + for (const pattern of patterns) { + if (typeof pattern === 'string') { + if (command === pattern) return true; + } else if (pattern.test(command)) { + return true; + } + } + return false; + } + + private parseCommand(message: string): { command: string; args: string } { + const trimmed = message.trim(); + if (trimmed.startsWith('!')) { + const [cmd, ...rest] = trimmed.split(' '); + return { command: cmd.toLowerCase(), args: rest.join(' ') }; + } + return { command: '', args: trimmed }; + } +} diff --git a/services/matrix-mana-bot/src/bot/matrix.service.ts b/services/matrix-mana-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..6a741fa51 --- /dev/null +++ b/services/matrix-mana-bot/src/bot/matrix.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject, forwardRef } 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 { CommandRouterService, CommandContext } from './command-router.service'; +import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client: MatrixClient; + private botUserId: string = ''; + private readonly homeserverUrl: string; + private readonly accessToken: string; + private readonly allowedRooms: string[]; + private readonly storagePath: string; + + constructor( + private configService: ConfigService, + @Inject(forwardRef(() => CommandRouterService)) + private commandRouter: CommandRouterService + ) { + 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/mana-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(); + this.logger.log('Matrix client stopped'); + } + } + + private async initializeClient() { + try { + // Ensure storage directory exists + 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); + + // Auto-join rooms when invited + AutojoinRoomsMixin.setupOnClient(this.client); + + // Handle room invites with introduction + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); + + setTimeout(async () => { + try { + await this.sendBotIntroduction(roomId); + } catch (error) { + this.logger.error(`Failed to send introduction to ${roomId}:`, error); + } + }, 2000); + }); + + // Handle member joins for welcome message + this.client.on('room.event', async (roomId: string, event: any) => { + if (event.type === 'm.room.member' && event.content?.membership === 'join') { + const userId = event.state_key; + if (userId === this.botUserId) return; + if (event.unsigned?.prev_content?.membership !== 'join') { + await this.sendWelcomeMessage(roomId, userId); + } + } + }); + + // Set up message handler + 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(`Mana Gateway Bot connected to ${this.homeserverUrl}`); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + if (this.allowedRooms.length > 0) { + this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`); + } else { + this.logger.log('No room restrictions - bot will respond in all rooms'); + } + } catch (error) { + this.logger.error('Failed to initialize Matrix client:', error); + } + } + + private async handleMessage(roomId: string, event: any) { + // Ignore messages from the bot itself + if (event.sender === this.botUserId) return; + + // Check if room is allowed + if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + return; + } + + const msgtype = event.content?.msgtype; + const body = event.content?.body?.trim(); + + // Only handle text messages for now + if (msgtype !== 'm.text' || !body) return; + + const ctx: CommandContext = { + roomId, + userId: event.sender, + message: body, + event, + }; + + try { + // Set typing indicator + await this.client.setTyping(roomId, true, 30000); + + // Route the message + const response = await this.commandRouter.route(ctx); + + // Stop typing + await this.client.setTyping(roomId, false); + + if (response) { + await this.sendReply(roomId, event, response); + } + } catch (error) { + await this.client.setTyping(roomId, false); + this.logger.error(`Error handling message:`, error); + await this.sendReply( + roomId, + event, + '❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.' + ); + } + } + + 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); + } + + async sendMessage(roomId: string, message: string) { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(message), + }); + } + + private async sendWelcomeMessage(roomId: string, userId: string) { + try { + await this.sendMessage(roomId, WELCOME_TEXT); + this.logger.log(`Sent welcome message to ${userId} in ${roomId}`); + } catch (error) { + this.logger.error(`Failed to send welcome message: ${error}`); + } + } + + private async sendBotIntroduction(roomId: string) { + await this.sendMessage(roomId, BOT_INTRODUCTION); + + // Try to pin the help message + try { + const helpEventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: HELP_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(HELP_TEXT), + }); + + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [helpEventId], + }); + this.logger.log(`Pinned help message in ${roomId}`); + } catch (error) { + this.logger.debug(`Could not pin help (might lack permissions): ${error}`); + } + } + + private markdownToHtml(text: string): string { + return text + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/\n/g, '
'); + } + + getClient(): MatrixClient { + return this.client; + } +} diff --git a/services/matrix-mana-bot/src/config/configuration.ts b/services/matrix-mana-bot/src/config/configuration.ts new file mode 100644 index 000000000..511f75ab4 --- /dev/null +++ b/services/matrix-mana-bot/src/config/configuration.ts @@ -0,0 +1,89 @@ +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3310, + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + storagePath: process.env.MATRIX_STORAGE_PATH || './data/mana-bot-storage.json', + allowedRooms: process.env.MATRIX_ALLOWED_ROOMS + ? process.env.MATRIX_ALLOWED_ROOMS.split(',').map((r) => r.trim()) + : [], + }, + services: { + ai: { + baseUrl: process.env.OLLAMA_URL || 'http://localhost:11434', + defaultModel: process.env.OLLAMA_MODEL || 'gemma3:4b', + timeout: parseInt(process.env.OLLAMA_TIMEOUT, 10) || 120000, + }, + clock: { + apiUrl: process.env.CLOCK_API_URL || 'http://localhost:3017/api/v1', + }, + todo: { + storagePath: process.env.TODO_STORAGE_PATH || './data/todos.json', + }, + calendar: { + storagePath: process.env.CALENDAR_STORAGE_PATH || './data/calendar.json', + }, + }, +}); + +// Help text for the unified bot +export const HELP_TEXT = `**🤖 Mana - Dein Assistent** + +**AI & Chat** +Schreib einfach eine Nachricht - ich antworte! +• \`!model [name]\` - KI-Modell wechseln +• \`!models\` - Verfügbare Modelle anzeigen +• \`!all [frage]\` - Alle Modelle vergleichen + +**📋 Todos** +• \`!todo [text]\` - Neue Aufgabe erstellen +• \`!list\` - Alle offenen Aufgaben +• \`!today\` - Heutige Aufgaben +• \`!done [nr]\` - Aufgabe erledigen +• \`!delete [nr]\` - Aufgabe löschen + +**📅 Kalender** +• \`!cal\` - Heutige Termine +• \`!week\` - Wochenübersicht +• \`!event [titel] [zeit]\` - Termin erstellen + +**⏱️ Zeit & Timer** +• \`!timer [dauer]\` - Timer starten (z.B. 25m) +• \`!alarm [zeit]\` - Alarm setzen (z.B. 14:30) +• \`!time [stadt]\` - Weltuhr +• \`!timers\` - Aktive Timer anzeigen + +**🔮 Smart Features** +• \`!summary\` - Tages-Zusammenfassung (AI) +• \`!ai-todo [text]\` - AI extrahiert Todos aus Text + +**💡 Tipps** +• Natürliche Sprache funktioniert: "Was sind meine Todos?" +• Prioritäten: \`!todo Wichtig !p1\` +• Datum: \`!todo Meeting @morgen\` +• Projekt: \`!todo Task #projekt\` + +--- +*100% DSGVO-konform - alle Daten lokal*`; + +export const WELCOME_TEXT = `👋 **Willkommen bei Mana!** + +Ich bin dein persönlicher Assistent mit vielen Funktionen: +• 🤖 AI Chat (lokales LLM) +• 📋 Todo-Verwaltung +• 📅 Kalender +• ⏱️ Timer & Alarme + +Schreib einfach eine Nachricht oder sag "hilfe" für alle Befehle!`; + +export const BOT_INTRODUCTION = `🤖 **Hallo! Ich bin Mana, euer All-in-One Assistent.** + +Ich vereinige alle Bot-Funktionen in einem: +• AI Chat & Fragen beantworten +• Aufgaben verwalten +• Termine planen +• Timer & Alarme + +Alle Daten bleiben auf diesem Server - 100% DSGVO-konform! + +Sag einfach "hilfe" oder \`!help\` für alle Befehle.`; diff --git a/services/matrix-mana-bot/src/handlers/ai.handler.ts b/services/matrix-mana-bot/src/handlers/ai.handler.ts new file mode 100644 index 000000000..9df100aba --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/ai.handler.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AiService } from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; + +@Injectable() +export class AiHandler { + private readonly logger = new Logger(AiHandler.name); + + constructor(private aiService: AiService) {} + + async chat(ctx: CommandContext, message: string): Promise { + this.logger.debug(`Chat request from ${ctx.userId}: ${message.substring(0, 50)}...`); + + const response = await this.aiService.chatSimple(ctx.userId, message); + return response; + } + + async listModels(ctx: CommandContext): Promise { + const models = await this.aiService.listModels(); + + if (models.length === 0) { + return '❌ Keine Modelle gefunden. Ist Ollama gestartet?'; + } + + const session = this.aiService.getSession(ctx.userId); + const currentModel = session?.model || this.aiService.getDefaultModel(); + + const modelList = models + .map((m) => { + const sizeMB = (m.size / 1024 / 1024).toFixed(0); + const active = m.name === currentModel ? ' ✓' : ''; + return `• \`${m.name}\` (${sizeMB} MB)${active}`; + }) + .join('\n'); + + return `**Verfügbare Modelle:**\n\n${modelList}\n\nWechseln mit: \`!model [name]\``; + } + + async setModel(ctx: CommandContext, modelName: string): Promise { + if (!modelName.trim()) { + const session = this.aiService.getSession(ctx.userId); + const currentModel = session?.model || this.aiService.getDefaultModel(); + return `Aktuelles Modell: \`${currentModel}\`\n\nVerwendung: \`!model gemma3:4b\``; + } + + const models = await this.aiService.listModels(); + const exists = models.some((m) => m.name === modelName); + + if (!exists) { + const available = models.map((m) => m.name).join(', '); + return `❌ Modell "${modelName}" nicht gefunden.\n\nVerfügbar: ${available}`; + } + + this.aiService.setModel(ctx.userId, modelName); + this.logger.log(`User ${ctx.userId} switched to model ${modelName}`); + + return `✅ Modell gewechselt zu: \`${modelName}\``; + } + + async compareAll(ctx: CommandContext, question: string): Promise { + if (!question.trim()) { + return `**Verwendung:** \`!all [Deine Frage]\`\n\nBeispiel: \`!all Was ist 2+2?\``; + } + + const models = await this.aiService.listModels(); + if (models.length === 0) { + return '❌ Keine Modelle gefunden. Ist Ollama gestartet?'; + } + + const results: { model: string; response: string; duration: number; error?: string }[] = []; + + for (const model of models) { + const startTime = Date.now(); + try { + this.logger.debug(`Querying model ${model.name}...`); + const response = await this.aiService.chat(ctx.userId, question, model.name); + const duration = Date.now() - startTime; + results.push({ model: model.name, response, duration }); + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + results.push({ model: model.name, response: '', duration, error: errorMessage }); + } + } + + let resultText = `**📊 Modellvergleich**\n\n**Frage:** "${question}"\n\n---\n\n`; + + for (const result of results) { + const durationSec = (result.duration / 1000).toFixed(1); + if (result.error) { + resultText += `**${result.model}** ⏱️ ${durationSec}s\n❌ Fehler: ${result.error}\n\n---\n\n`; + } else { + const truncated = + result.response.length > 400 + ? result.response.substring(0, 400) + '...' + : result.response; + resultText += `**${result.model}** ⏱️ ${durationSec}s\n${truncated}\n\n---\n\n`; + } + } + + return resultText; + } + + async clearHistory(ctx: CommandContext): Promise { + this.aiService.clearHistory(ctx.userId); + this.logger.log(`User ${ctx.userId} cleared chat history`); + return '✅ Chat-Verlauf gelöscht.'; + } +} diff --git a/services/matrix-mana-bot/src/handlers/calendar.handler.ts b/services/matrix-mana-bot/src/handlers/calendar.handler.ts new file mode 100644 index 000000000..055167c3a --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/calendar.handler.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CalendarService, CalendarEvent } from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; + +@Injectable() +export class CalendarHandler { + private readonly logger = new Logger(CalendarHandler.name); + + constructor(private calendarService: CalendarService) {} + + async today(ctx: CommandContext): Promise { + const events = await this.calendarService.getTodayEvents(ctx.userId); + + if (events.length === 0) { + return '📅 Keine Termine für heute.\n\nErstelle einen mit `!event [Titel] [Zeit]`'; + } + + return this.formatEventList('📅 **Termine heute:**', events); + } + + async week(ctx: CommandContext): Promise { + const events = await this.calendarService.getWeekEvents(ctx.userId); + + if (events.length === 0) { + return '📅 Keine Termine diese Woche.'; + } + + return this.formatEventList('📅 **Termine diese Woche:**', events); + } + + async create(ctx: CommandContext, input: string): Promise { + if (!input.trim()) { + return `**Verwendung:** \`!event [Titel] [Zeit]\` + +**Beispiele:** +• \`!event Meeting morgen 14:30\` +• \`!event Zahnarzt 15.02. 10:00\` +• \`!event Geburtstag heute ganztägig\``; + } + + const parsed = this.calendarService.parseEventInput(input); + const event = await this.calendarService.createEvent(ctx.userId, parsed); + + const timeStr = event.isAllDay + ? 'Ganztägig' + : this.formatTime(event.startTime); + + const dateStr = this.formatDate(event.startTime); + + this.logger.log(`Created event "${event.title}" for ${ctx.userId}`); + return `✅ Termin erstellt: **${event.title}**\n📅 ${dateStr} ${timeStr}`; + } + + async listCalendars(ctx: CommandContext): Promise { + const calendars = await this.calendarService.getCalendars(ctx.userId); + + if (calendars.length === 0) { + return '📅 Keine Kalender vorhanden.\n\nTermine werden automatisch im Standard-Kalender gespeichert.'; + } + + let response = '📅 **Deine Kalender:**\n\n'; + for (const cal of calendars) { + const color = cal.color || '⬜'; + response += `${color} ${cal.name}\n`; + } + + return response; + } + + private formatEventList(header: string, events: CalendarEvent[]): string { + let response = `${header}\n\n`; + + // Group events by date + const byDate = new Map(); + for (const event of events) { + const dateKey = new Date(event.startTime).toISOString().split('T')[0]; + if (!byDate.has(dateKey)) { + byDate.set(dateKey, []); + } + byDate.get(dateKey)!.push(event); + } + + for (const [dateKey, dayEvents] of byDate) { + const dateLabel = this.formatDate(dateKey); + response += `**${dateLabel}:**\n`; + + for (const event of dayEvents) { + const timeStr = event.isAllDay + ? '🌅 Ganztägig' + : `⏰ ${this.formatTime(event.startTime)}`; + response += `• ${timeStr} - ${event.title}\n`; + } + response += '\n'; + } + + return response; + } + + private formatDate(dateInput: string | Date): string { + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const dateStr = date.toISOString().split('T')[0]; + const todayStr = today.toISOString().split('T')[0]; + const tomorrowStr = tomorrow.toISOString().split('T')[0]; + + if (dateStr === todayStr) return 'Heute'; + if (dateStr === tomorrowStr) return 'Morgen'; + + return date.toLocaleDateString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + }); + } + + private formatTime(dateInput: string | Date): string { + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + } +} diff --git a/services/matrix-mana-bot/src/handlers/clock.handler.ts b/services/matrix-mana-bot/src/handlers/clock.handler.ts new file mode 100644 index 000000000..a687400e0 --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/clock.handler.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ClockService } from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; + +@Injectable() +export class ClockHandler { + private readonly logger = new Logger(ClockHandler.name); + + constructor(private clockService: ClockService) {} + + async startTimer(ctx: CommandContext, input: string): Promise { + if (!input.trim()) { + return `**Verwendung:** \`!timer [Dauer] [Name]\` + +**Beispiele:** +• \`!timer 25m Pomodoro\` +• \`!timer 1h30m Meeting\` +• \`!timer 5m Pause\` + +**Dauer-Formate:** 5m, 1h, 1h30m, 90s`; + } + + try { + const result = await this.clockService.startTimer(ctx.userId, input); + this.logger.log(`Started timer for ${ctx.userId}: ${result.name}`); + + const durationStr = this.formatDuration(result.durationSeconds); + return `⏱️ Timer gestartet: **${result.name || 'Timer'}**\nDauer: ${durationStr}\n\nStoppen mit \`!stop\``; + } catch (error) { + return `❌ ${error instanceof Error ? error.message : 'Fehler beim Starten des Timers'}`; + } + } + + async listTimers(ctx: CommandContext): Promise { + try { + const timers = await this.clockService.getTimers(ctx.userId); + + if (timers.length === 0) { + return '⏱️ Keine aktiven Timer.\n\nStarte einen mit `!timer [Dauer]`'; + } + + let response = '⏱️ **Aktive Timer:**\n\n'; + for (const timer of timers) { + const remaining = this.formatDuration(timer.remainingSeconds); + const status = timer.isPaused ? '⏸️' : '▶️'; + response += `${status} **${timer.name || 'Timer'}** - ${remaining} verbleibend\n`; + } + + response += '\n`!stop` zum Beenden'; + return response; + } catch (error) { + return '❌ Fehler beim Abrufen der Timer.'; + } + } + + async stopTimer(ctx: CommandContext, args: string): Promise { + try { + const result = await this.clockService.stopTimer(ctx.userId, args.trim() || undefined); + return `⏹️ Timer gestoppt: **${result.name || 'Timer'}**`; + } catch (error) { + return `❌ ${error instanceof Error ? error.message : 'Kein aktiver Timer gefunden'}`; + } + } + + async setAlarm(ctx: CommandContext, input: string): Promise { + if (!input.trim()) { + return `**Verwendung:** \`!alarm [Zeit] [Name]\` + +**Beispiele:** +• \`!alarm 14:30 Meeting\` +• \`!alarm 7:00 Aufstehen\` +• \`!alarm 18 Uhr Feierabend\``; + } + + try { + const result = await this.clockService.setAlarm(ctx.userId, input); + this.logger.log(`Set alarm for ${ctx.userId}: ${result.name} at ${result.time}`); + + return `⏰ Alarm gesetzt: **${result.name || 'Alarm'}**\nZeit: ${result.time}`; + } catch (error) { + return `❌ ${error instanceof Error ? error.message : 'Fehler beim Setzen des Alarms'}`; + } + } + + async listAlarms(ctx: CommandContext): Promise { + try { + const alarms = await this.clockService.getAlarms(ctx.userId); + + if (alarms.length === 0) { + return '⏰ Keine aktiven Alarme.\n\nSetze einen mit `!alarm [Zeit]`'; + } + + let response = '⏰ **Aktive Alarme:**\n\n'; + for (const alarm of alarms) { + const status = alarm.enabled ? '🔔' : '🔕'; + response += `${status} **${alarm.name || 'Alarm'}** - ${alarm.time}\n`; + } + + return response; + } catch (error) { + return '❌ Fehler beim Abrufen der Alarme.'; + } + } + + async worldClock(ctx: CommandContext, city: string): Promise { + if (!city.trim()) { + // Show common time zones + const zones = [ + { city: 'Berlin', tz: 'Europe/Berlin' }, + { city: 'London', tz: 'Europe/London' }, + { city: 'New York', tz: 'America/New_York' }, + { city: 'Tokyo', tz: 'Asia/Tokyo' }, + { city: 'Sydney', tz: 'Australia/Sydney' }, + ]; + + let response = '🌍 **Weltuhren:**\n\n'; + const now = new Date(); + + for (const { city, tz } of zones) { + const time = now.toLocaleTimeString('de-DE', { + timeZone: tz, + hour: '2-digit', + minute: '2-digit', + }); + response += `• **${city}:** ${time}\n`; + } + + response += '\nZeige andere Stadt: `!time [Stadt]`'; + return response; + } + + try { + const result = await this.clockService.getWorldClock(city); + return `🕐 **${result.city}:** ${result.time}\n📅 ${result.date}`; + } catch (error) { + return `❌ Stadt "${city}" nicht gefunden.\n\nVersuche: Berlin, London, New York, Tokyo, Sydney`; + } + } + + private formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 && hours === 0) parts.push(`${secs}s`); + + return parts.join(' ') || '0s'; + } +} diff --git a/services/matrix-mana-bot/src/handlers/handlers.module.ts b/services/matrix-mana-bot/src/handlers/handlers.module.ts new file mode 100644 index 000000000..a2ac7b430 --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/handlers.module.ts @@ -0,0 +1,14 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AiHandler } from './ai.handler'; +import { TodoHandler } from './todo.handler'; +import { CalendarHandler } from './calendar.handler'; +import { ClockHandler } from './clock.handler'; +import { HelpHandler } from './help.handler'; +import { BotModule } from '../bot/bot.module'; + +@Module({ + imports: [forwardRef(() => BotModule)], + providers: [AiHandler, TodoHandler, CalendarHandler, ClockHandler, HelpHandler], + exports: [AiHandler, TodoHandler, CalendarHandler, ClockHandler, HelpHandler], +}) +export class HandlersModule {} diff --git a/services/matrix-mana-bot/src/handlers/help.handler.ts b/services/matrix-mana-bot/src/handlers/help.handler.ts new file mode 100644 index 000000000..e475b2817 --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/help.handler.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { AiService, TodoService } from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; +import { HELP_TEXT } from '../config/configuration'; + +@Injectable() +export class HelpHandler { + constructor( + private aiService: AiService, + private todoService: TodoService + ) {} + + async showHelp(ctx: CommandContext): Promise { + return HELP_TEXT; + } + + async showStatus(ctx: CommandContext): Promise { + const aiConnected = await this.aiService.checkConnection(); + const todoStats = await this.todoService.getStats(ctx.userId); + + const aiStatus = aiConnected ? '✅ Online' : '❌ Offline'; + const currentModel = this.aiService.getSession(ctx.userId)?.model || this.aiService.getDefaultModel(); + + return `**📊 Status** + +**AI/Ollama** +• Verbindung: ${aiStatus} +• Modell: \`${currentModel}\` + +**Todos** +• Offen: ${todoStats.pending} +• Heute fällig: ${todoStats.today} +• Erledigt: ${todoStats.completed} + +**Bot** +• Status: ✅ Online +• DSGVO: ✅ Alle Daten lokal`; + } +} diff --git a/services/matrix-mana-bot/src/handlers/todo.handler.ts b/services/matrix-mana-bot/src/handlers/todo.handler.ts new file mode 100644 index 000000000..0bafd8fa1 --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/todo.handler.ts @@ -0,0 +1,144 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { TodoService, Task } from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; + +@Injectable() +export class TodoHandler { + private readonly logger = new Logger(TodoHandler.name); + + constructor(private todoService: TodoService) {} + + async create(ctx: CommandContext, input: string): Promise { + if (!input.trim()) { + return '❌ Bitte gib eine Aufgabe an.\n\nBeispiel: `!todo Einkaufen gehen`'; + } + + const parsed = this.todoService.parseTaskInput(input); + const task = await this.todoService.createTask(ctx.userId, parsed); + + let response = `✅ Aufgabe erstellt: **${task.title}**`; + + const details: string[] = []; + if (parsed.priority < 4) details.push(`Priorität ${parsed.priority}`); + if (parsed.dueDate) details.push(`Datum: ${this.formatDate(parsed.dueDate)}`); + if (parsed.project) details.push(`Projekt: ${parsed.project}`); + + if (details.length > 0) { + response += `\n📋 ${details.join(' | ')}`; + } + + this.logger.log(`Created task "${task.title}" for ${ctx.userId}`); + return response; + } + + async list(ctx: CommandContext): Promise { + const tasks = await this.todoService.getAllPendingTasks(ctx.userId); + + if (tasks.length === 0) { + return '📭 Keine offenen Aufgaben.\n\nErstelle eine mit `!todo [Aufgabe]`'; + } + + return this.formatTaskList('📋 **Alle offenen Aufgaben:**', tasks); + } + + async today(ctx: CommandContext): Promise { + const tasks = await this.todoService.getTodayTasks(ctx.userId); + + if (tasks.length === 0) { + return '📭 Keine Aufgaben für heute.\n\nErstelle eine mit `!todo Aufgabe @heute`'; + } + + return this.formatTaskList('📅 **Aufgaben für heute:**', tasks); + } + + async inbox(ctx: CommandContext): Promise { + const tasks = await this.todoService.getInboxTasks(ctx.userId); + + if (tasks.length === 0) { + return '📭 Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'; + } + + return this.formatTaskList('📥 **Inbox (ohne Datum):**', tasks); + } + + async complete(ctx: CommandContext, args: string): Promise { + const taskNumber = parseInt(args.trim()); + + if (isNaN(taskNumber) || taskNumber < 1) { + return '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!done 1`'; + } + + const task = await this.todoService.completeTask(ctx.userId, taskNumber); + + if (!task) { + return `❌ Aufgabe #${taskNumber} nicht gefunden.`; + } + + this.logger.log(`Completed task "${task.title}" for ${ctx.userId}`); + return `✅ Erledigt: ~~${task.title}~~`; + } + + async delete(ctx: CommandContext, args: string): Promise { + const taskNumber = parseInt(args.trim()); + + if (isNaN(taskNumber) || taskNumber < 1) { + return '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`'; + } + + const task = await this.todoService.deleteTask(ctx.userId, taskNumber); + + if (!task) { + return `❌ Aufgabe #${taskNumber} nicht gefunden.`; + } + + this.logger.log(`Deleted task "${task.title}" for ${ctx.userId}`); + return `🗑️ Gelöscht: ${task.title}`; + } + + async projects(ctx: CommandContext): Promise { + const projectList = await this.todoService.getProjects(ctx.userId); + + if (projectList.length === 0) { + return '📭 Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!todo Aufgabe #projektname`'; + } + + let response = '📁 **Deine Projekte:**\n\n'; + for (const project of projectList) { + response += `• ${project.name}\n`; + } + response += '\nZeige Projektaufgaben mit `!project [Name]`'; + + return response; + } + + private formatTaskList(header: string, tasks: Task[]): string { + let response = `${header}\n\n`; + + tasks.forEach((task, index) => { + const num = index + 1; + const priority = task.priority < 4 ? `❗`.repeat(4 - task.priority) : ''; + const date = task.dueDate ? ` 📅 ${this.formatDate(task.dueDate)}` : ''; + const project = task.project ? ` 📁 ${task.project}` : ''; + + response += `**${num}.** ${task.title}${priority}${date}${project}\n`; + }); + + response += `\n✅ Erledigen: \`!done [Nr]\` | 🗑️ Löschen: \`!delete [Nr]\``; + return response; + } + + private formatDate(dateStr: string): string { + const date = new Date(dateStr); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + if (dateStr === today.toISOString().split('T')[0]) { + return 'Heute'; + } else if (dateStr === tomorrow.toISOString().split('T')[0]) { + return 'Morgen'; + } + + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); + } +} diff --git a/services/matrix-mana-bot/src/health/health.controller.ts b/services/matrix-mana-bot/src/health/health.controller.ts new file mode 100644 index 000000000..132c7ef60 --- /dev/null +++ b/services/matrix-mana-bot/src/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-mana-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-mana-bot/src/main.ts b/services/matrix-mana-bot/src/main.ts new file mode 100644 index 000000000..a9cd87374 --- /dev/null +++ b/services/matrix-mana-bot/src/main.ts @@ -0,0 +1,18 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port', 3310); + + await app.listen(port); + logger.log(`Mana Gateway Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/services/matrix-mana-bot/src/orchestration/orchestration.module.ts b/services/matrix-mana-bot/src/orchestration/orchestration.module.ts new file mode 100644 index 000000000..d44cdc4e1 --- /dev/null +++ b/services/matrix-mana-bot/src/orchestration/orchestration.module.ts @@ -0,0 +1,10 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { OrchestrationService } from './orchestration.service'; +import { BotModule } from '../bot/bot.module'; + +@Module({ + imports: [forwardRef(() => BotModule)], + providers: [OrchestrationService], + exports: [OrchestrationService], +}) +export class OrchestrationModule {} diff --git a/services/matrix-mana-bot/src/orchestration/orchestration.service.ts b/services/matrix-mana-bot/src/orchestration/orchestration.service.ts new file mode 100644 index 000000000..4410c52af --- /dev/null +++ b/services/matrix-mana-bot/src/orchestration/orchestration.service.ts @@ -0,0 +1,159 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AiService, TodoService, CalendarService } from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; + +@Injectable() +export class OrchestrationService { + private readonly logger = new Logger(OrchestrationService.name); + + constructor( + private aiService: AiService, + private todoService: TodoService, + private calendarService: CalendarService + ) {} + + /** + * !summary - AI-powered daily summary combining todos, calendar, etc. + */ + async dailySummary(ctx: CommandContext): Promise { + this.logger.log(`Generating daily summary for ${ctx.userId}`); + + // Gather data from all services in parallel + const [todoStats, todayTodos, todayEvents] = await Promise.all([ + this.todoService.getStats(ctx.userId), + this.todoService.getTodayTasks(ctx.userId), + this.calendarService.getTodayEvents(ctx.userId), + ]); + + // Build context for AI + const todoList = todayTodos.map((t) => t.title).join(', ') || 'keine'; + const eventList = todayEvents.map((e) => e.title).join(', ') || 'keine'; + + const prompt = `Du bist ein freundlicher Assistent. Erstelle eine kurze, motivierende Tages-Zusammenfassung auf Deutsch (max 5 Sätze). + +Daten für heute: +- Offene Todos: ${todoStats.pending} (davon heute fällig: ${todoStats.today}) +- Erledigte Todos: ${todoStats.completed} +- Heutige Todos: ${todoList} +- Heutige Termine: ${eventList} + +Fasse das freundlich und motivierend zusammen. Gib konkrete Tipps falls viele Aufgaben offen sind.`; + + try { + const summary = await this.aiService.chatSimple(ctx.userId, prompt); + + return `**📊 Deine Tages-Zusammenfassung** + +${summary} + +--- +*Generiert mit AI*`; + } catch (error) { + // Fallback without AI + return `**📊 Deine Tages-Übersicht** + +**Todos:** +• Offen: ${todoStats.pending} +• Heute fällig: ${todoStats.today} +• Erledigt: ${todoStats.completed} + +**Termine heute:** ${eventList} + +--- +*AI-Zusammenfassung nicht verfügbar*`; + } + } + + /** + * !ai-todo - AI extracts todos from text (meeting notes, etc.) + */ + async aiToTodos(ctx: CommandContext, text: string): Promise { + if (!text.trim()) { + return `**Verwendung:** \`!ai-todo [Text]\` + +**Beispiel:** +\`!ai-todo Im Meeting haben wir besprochen: Website redesign bis Freitag, API Dokumentation aktualisieren, und Peter soll das Budget prüfen.\` + +Die AI extrahiert automatisch Aufgaben und erstellt Todos.`; + } + + this.logger.log(`Extracting todos from text for ${ctx.userId}`); + + const prompt = `Extrahiere alle Aufgaben aus folgendem Text. +Antworte NUR mit einem JSON-Array im Format: +[{"text": "Aufgabentext", "priority": 1-4}] + +Prioritäten: +1 = Dringend/Wichtig +2 = Wichtig +3 = Normal +4 = Niedrig + +Text: ${text}`; + + try { + const response = await this.aiService.chatSimple(ctx.userId, prompt); + + // Parse JSON from response + const jsonMatch = response.match(/\[[\s\S]*?\]/); + if (!jsonMatch) { + return '❌ Konnte keine Aufgaben extrahieren. Versuche es mit klarerem Text.'; + } + + const todos = JSON.parse(jsonMatch[0]) as { text: string; priority?: number }[]; + + if (todos.length === 0) { + return '❌ Keine Aufgaben im Text gefunden.'; + } + + // Create todos + const created: string[] = []; + for (const todo of todos) { + const task = await this.todoService.createTask(ctx.userId, { + title: todo.text, + priority: todo.priority || 4, + }); + created.push(task.title); + } + + this.logger.log(`Created ${created.length} todos from AI extraction for ${ctx.userId}`); + + const lines = created.map((t, i) => `${i + 1}. ${t}`).join('\n'); + return `✅ **${created.length} Todos erstellt:** + +${lines} + +Zeige alle mit \`!list\``; + } catch (error) { + this.logger.error(`AI todo extraction failed:`, error); + return `❌ Fehler bei der Extraktion: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`; + } + } + + /** + * Create a todo with a calendar reminder + */ + async todoWithReminder(ctx: CommandContext, input: string): Promise { + // Parse: "Aufgabe @morgen 14:00" + const parsed = this.todoService.parseTaskInput(input); + + // Create todo + const task = await this.todoService.createTask(ctx.userId, parsed); + + // If date was specified, create calendar event as reminder + if (parsed.dueDate) { + await this.calendarService.createEvent(ctx.userId, { + title: `📋 Todo: ${task.title}`, + startTime: new Date(parsed.dueDate), + isAllDay: true, + }); + } + + let response = `✅ Todo erstellt: **${task.title}**`; + if (parsed.dueDate) { + response += `\n📅 Erinnerung im Kalender eingetragen`; + } + + return response; + } +} diff --git a/services/matrix-mana-bot/tsconfig.json b/services/matrix-mana-bot/tsconfig.json new file mode 100644 index 000000000..7e9adda7d --- /dev/null +++ b/services/matrix-mana-bot/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}