From 2c341b53283a14d8f2da0b64c947623cee88d3ed Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:47:33 +0100 Subject: [PATCH] feat(matrix): add Matrix Todo Bot service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GDPR-compliant task management bot for Matrix with: - Task CRUD: !add, !list, !done, !delete - Priority support: !p1 to !p4 - Date shortcuts: @heute, @morgen, @übermorgen - Project tags: #projektname - Natural language keywords: hilfe, zeige aufgaben, heute - Welcome messages and auto-pin help on room join - Per-user task isolation via Matrix user ID - Local JSON storage Co-Authored-By: Claude Opus 4.5 --- docker-compose.macmini.yml | 31 + services/matrix-todo-bot/.dockerignore | 6 + services/matrix-todo-bot/CLAUDE.md | 161 +++++ services/matrix-todo-bot/Dockerfile | 48 ++ services/matrix-todo-bot/nest-cli.json | 8 + services/matrix-todo-bot/package.json | 44 ++ services/matrix-todo-bot/src/app.module.ts | 19 + .../matrix-todo-bot/src/bot/bot.module.ts | 10 + .../matrix-todo-bot/src/bot/matrix.service.ts | 557 ++++++++++++++++++ .../src/config/configuration.ts | 58 ++ .../matrix-todo-bot/src/health.controller.ts | 13 + services/matrix-todo-bot/src/main.ts | 17 + .../matrix-todo-bot/src/todo/todo.module.ts | 8 + .../matrix-todo-bot/src/todo/todo.service.ts | 251 ++++++++ services/matrix-todo-bot/tsconfig.build.json | 4 + services/matrix-todo-bot/tsconfig.json | 22 + 16 files changed, 1257 insertions(+) create mode 100644 services/matrix-todo-bot/.dockerignore create mode 100644 services/matrix-todo-bot/CLAUDE.md create mode 100644 services/matrix-todo-bot/Dockerfile create mode 100644 services/matrix-todo-bot/nest-cli.json create mode 100644 services/matrix-todo-bot/package.json create mode 100644 services/matrix-todo-bot/src/app.module.ts create mode 100644 services/matrix-todo-bot/src/bot/bot.module.ts create mode 100644 services/matrix-todo-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-todo-bot/src/config/configuration.ts create mode 100644 services/matrix-todo-bot/src/health.controller.ts create mode 100644 services/matrix-todo-bot/src/main.ts create mode 100644 services/matrix-todo-bot/src/todo/todo.module.ts create mode 100644 services/matrix-todo-bot/src/todo/todo.service.ts create mode 100644 services/matrix-todo-bot/tsconfig.build.json create mode 100644 services/matrix-todo-bot/tsconfig.json diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 0dc3a0c4e..0800b7150 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -975,6 +975,35 @@ services: retries: 3 start_period: 40s + # ============================================ + # Matrix Todo Bot (GDPR-compliant Task Management) + # ============================================ + + matrix-todo-bot: + image: matrix-todo-bot:latest + container_name: manacore-matrix-todo-bot + restart: always + depends_on: + synapse: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3314 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_TODO_BOT_TOKEN} + MATRIX_ALLOWED_ROOMS: ${MATRIX_TODO_BOT_ROOMS:-} + volumes: + - matrix_todo_bot_data:/app/data + ports: + - "3314:3314" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3314/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -1023,3 +1052,5 @@ volumes: name: manacore-matrix-stats-bot matrix_project_doc_bot_data: name: manacore-matrix-project-doc-bot + matrix_todo_bot_data: + name: manacore-matrix-todo-bot diff --git a/services/matrix-todo-bot/.dockerignore b/services/matrix-todo-bot/.dockerignore new file mode 100644 index 000000000..d6a8859ae --- /dev/null +++ b/services/matrix-todo-bot/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.log +.env* +data diff --git a/services/matrix-todo-bot/CLAUDE.md b/services/matrix-todo-bot/CLAUDE.md new file mode 100644 index 000000000..1069ea703 --- /dev/null +++ b/services/matrix-todo-bot/CLAUDE.md @@ -0,0 +1,161 @@ +# Matrix Todo Bot - Claude Code Guidelines + +## Overview + +Matrix Todo Bot provides a GDPR-compliant task management interface via Matrix chat. It uses the Matrix protocol for messaging, allowing self-hosting all data on the Mac Mini server. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Storage**: Local JSON file (per-user tasks) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-todo-bot/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & help texts +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ └── todo/ +│ ├── todo.module.ts +│ └── todo.service.ts # Task storage & management +├── Dockerfile +└── package.json +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!help` | Show help message | +| `!add [task]` | Create a new task | +| `!list` | Show all pending tasks | +| `!heute` / `!today` | Show today's tasks | +| `!inbox` | Show tasks without date | +| `!done [nr]` | Mark task as complete | +| `!delete [nr]` | Delete a task | +| `!projects` | List all projects | +| `!project [name]` | Show project tasks | +| `!status` | Show bot status | +| `!pin` | Pin help to room | + +## Natural Language Keywords + +The bot also responds to natural language (German + English): +- "hilfe", "help" → Show help +- "zeige aufgaben", "show tasks" → List tasks +- "heute", "today" → Today's tasks +- "inbox", "eingang" → Inbox tasks +- "projekte", "projects" → List projects + +## Task Input Syntax + +``` +!add Task title !p1 @morgen #projektname + │ │ │ └── Project + │ │ └── Due date (@heute, @morgen, @übermorgen) + │ └── Priority (1-4, 1 highest) + └── Task title +``` + +## Environment Variables + +```env +# Server +PORT=3314 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#todo-bot:mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-todo-bot/Dockerfile -t matrix-todo-bot services/matrix-todo-bot + +# Run +docker run -p 3314:3314 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -v matrix-todo-bot-data:/app/data \ + matrix-todo-bot +``` + +## Health Check + +```bash +curl http://localhost:3314/health +``` + +## Getting a Matrix Access Token + +```bash +# Login to get access token +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "todo-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Data Storage + +Tasks are stored in a local JSON file (`/app/data/todo-data.json`) with per-user isolation. + +Structure: +```json +{ + "tasks": [ + { + "id": "unique-id", + "title": "Task title", + "completed": false, + "priority": 4, + "dueDate": "2024-01-28", + "project": "Arbeit", + "labels": [], + "createdAt": "2024-01-27T10:00:00Z", + "completedAt": null, + "userId": "@user:mana.how" + } + ], + "projects": [] +} +``` + +## GDPR Compliance + +- All task data stored locally on Mac Mini +- No third-party data processing +- Full control over data retention +- Per-user data isolation via Matrix user IDs +- Can delete all user data on request diff --git a/services/matrix-todo-bot/Dockerfile b/services/matrix-todo-bot/Dockerfile new file mode 100644 index 000000000..1e79d7d56 --- /dev/null +++ b/services/matrix-todo-bot/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install all dependencies (including devDependencies for build) +RUN npm install + +# Copy source code +COPY . . + +# Build TypeScript +RUN rm -rf dist && npx tsc -p tsconfig.build.json + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Create data directory for storage +RUN mkdir -p /app/data + +# Copy package files +COPY package.json ./ + +# Install production dependencies only +RUN npm install --omit=dev + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 && \ + chown -R nestjs:nodejs /app + +USER nestjs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3314/health || exit 1 + +EXPOSE 3314 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-todo-bot/nest-cli.json b/services/matrix-todo-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-todo-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-todo-bot/package.json b/services/matrix-todo-bot/package.json new file mode 100644 index 000000000..e70f61c05 --- /dev/null +++ b/services/matrix-todo-bot/package.json @@ -0,0 +1,44 @@ +{ + "name": "@manacore/matrix-todo-bot", + "version": "1.0.0", + "description": "Matrix bot for task management - GDPR compliant", + "private": true, + "license": "MIT", + "pnpm": { + "neverBuiltDependencies": [ + "@matrix-org/matrix-sdk-crypto-nodejs" + ], + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + } + }, + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + }, + "scripts": { + "prebuild": "rm -rf dist || true", + "build": "tsc -p tsconfig.build.json", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-todo-bot/src/app.module.ts b/services/matrix-todo-bot/src/app.module.ts new file mode 100644 index 000000000..4c838f17b --- /dev/null +++ b/services/matrix-todo-bot/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { TodoModule } from './todo/todo.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + TodoModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-todo-bot/src/bot/bot.module.ts b/services/matrix-todo-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..82729571d --- /dev/null +++ b/services/matrix-todo-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { TodoModule } from '../todo/todo.module'; + +@Module({ + imports: [TodoModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-todo-bot/src/bot/matrix.service.ts b/services/matrix-todo-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..b5e7da158 --- /dev/null +++ b/services/matrix-todo-bot/src/bot/matrix.service.ts @@ -0,0 +1,557 @@ +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 { TodoService, Task } from '../todo/todo.service'; +import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; + +// Natural language keywords that trigger commands (German + English) +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' }, + { + keywords: ['zeige aufgaben', 'meine aufgaben', 'was muss ich', 'show tasks', 'list'], + command: 'list', + }, + { keywords: ['heute', 'today', 'was steht an'], command: 'today' }, + { keywords: ['inbox', 'eingang', 'ohne datum'], command: 'inbox' }, + { keywords: ['projekte', 'projects'], command: 'projects' }, + { keywords: ['status', 'verbindung', 'connection'], command: 'status' }, +]; + +@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; + + constructor( + private configService: ConfigService, + private todoService: TodoService + ) { + 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 { + // 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); + + // Send introduction after a short delay + 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; + const botUserId = await this.client.getUserId(); + + // Don't welcome the bot itself + if (userId === botUserId) return; + + // Check if this is a new join (not just profile update) + 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.logger.log(`Matrix Todo Bot connected to ${this.homeserverUrl}`); + + const userId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${userId}`); + + 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 + const botUserId = await this.client.getUserId(); + if (event.sender === botUserId) return; + + // Check if room is allowed + if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + return; + } + + // Only handle text messages + if (event.content?.msgtype !== 'm.text') return; + + const body = event.content.body?.trim(); + if (!body) return; + + const userId = event.sender; + + try { + // Check for natural language keywords first + const keywordCommand = this.detectKeywordCommand(body); + if (keywordCommand) { + await this.executeCommand(roomId, event, userId, keywordCommand, ''); + return; + } + + // Check for ! commands + if (body.startsWith('!')) { + const [command, ...args] = body.slice(1).split(' '); + await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' ')); + } + } catch (error) { + this.logger.error(`Error handling message: ${error}`); + await this.sendReply( + roomId, + event, + '❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.' + ); + } + } + + private detectKeywordCommand(message: string): string | null { + const lowerMessage = message.toLowerCase().trim(); + + // Only check short messages for keywords + if (lowerMessage.length > 50) return null; + + for (const { keywords, command } of KEYWORD_COMMANDS) { + for (const keyword of keywords) { + if ( + lowerMessage === keyword || + lowerMessage.startsWith(keyword + ' ') || + lowerMessage.includes(keyword) + ) { + this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`); + 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 'add': + case 'neu': + case 'neue': + await this.handleAddTask(roomId, event, userId, args); + break; + + case 'list': + case 'liste': + case 'alle': + await this.handleListTasks(roomId, event, userId); + break; + + case 'today': + case 'heute': + await this.handleTodayTasks(roomId, event, userId); + break; + + case 'inbox': + case 'eingang': + await this.handleInboxTasks(roomId, event, userId); + break; + + case 'done': + case 'erledigt': + case 'fertig': + await this.handleCompleteTask(roomId, event, userId, args); + break; + + case 'delete': + case 'löschen': + case 'entfernen': + await this.handleDeleteTask(roomId, event, userId, args); + break; + + case 'projects': + case 'projekte': + await this.handleProjects(roomId, event, userId); + break; + + case 'project': + case 'projekt': + await this.handleProjectTasks(roomId, event, userId, args); + break; + + case 'status': + await this.handleStatus(roomId, event, userId); + break; + + case 'pin': + await this.handlePinHelp(roomId, event); + break; + + default: + // Unknown command - ignore silently or send help + break; + } + } + + private async handleAddTask(roomId: string, event: any, userId: string, input: string) { + if (!input.trim()) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib eine Aufgabe an.\n\nBeispiel: `!add Einkaufen gehen`' + ); + return; + } + + const { title, priority, dueDate, project } = this.todoService.parseTaskInput(input); + + const task = await this.todoService.createTask(userId, title, { + priority, + dueDate, + project, + }); + + let response = `✅ Aufgabe erstellt: **${task.title}**`; + + const details: string[] = []; + if (priority < 4) details.push(`Priorität ${priority}`); + if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`); + if (project) details.push(`Projekt: ${project}`); + + if (details.length > 0) { + response += `\n📋 ${details.join(' | ')}`; + } + + await this.sendReply(roomId, event, response); + } + + private async handleListTasks(roomId: string, event: any, userId: string) { + const tasks = await this.todoService.getAllPendingTasks(userId); + + if (tasks.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine offenen Aufgaben.\n\nErstelle eine mit `!add [Aufgabe]`' + ); + return; + } + + const response = this.formatTaskList('📋 **Alle offenen Aufgaben:**', tasks); + await this.sendReply(roomId, event, response); + } + + private async handleTodayTasks(roomId: string, event: any, userId: string) { + const tasks = await this.todoService.getTodayTasks(userId); + + if (tasks.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine Aufgaben für heute.\n\nErstelle eine mit `!add Aufgabe @heute`' + ); + return; + } + + const response = this.formatTaskList('📅 **Aufgaben für heute:**', tasks); + await this.sendReply(roomId, event, response); + } + + private async handleInboxTasks(roomId: string, event: any, userId: string) { + const tasks = await this.todoService.getInboxTasks(userId); + + if (tasks.length === 0) { + await this.sendReply(roomId, event, '📭 Inbox ist leer.\n\nAufgaben ohne Datum landen hier.'); + return; + } + + const response = this.formatTaskList('📥 **Inbox (ohne Datum):**', tasks); + await this.sendReply(roomId, event, response); + } + + private async handleCompleteTask(roomId: string, event: any, userId: string, args: string) { + const taskNumber = parseInt(args.trim()); + + if (isNaN(taskNumber) || taskNumber < 1) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!done 1`' + ); + return; + } + + const task = await this.todoService.completeTask(userId, taskNumber); + + if (!task) { + await this.sendReply(roomId, event, `❌ Aufgabe #${taskNumber} nicht gefunden.`); + return; + } + + await this.sendReply(roomId, event, `✅ Erledigt: ~~${task.title}~~`); + } + + private async handleDeleteTask(roomId: string, event: any, userId: string, args: string) { + const taskNumber = parseInt(args.trim()); + + if (isNaN(taskNumber) || taskNumber < 1) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`' + ); + return; + } + + const task = await this.todoService.deleteTask(userId, taskNumber); + + if (!task) { + await this.sendReply(roomId, event, `❌ Aufgabe #${taskNumber} nicht gefunden.`); + return; + } + + await this.sendReply(roomId, event, `🗑️ Gelöscht: ${task.title}`); + } + + private async handleProjects(roomId: string, event: any, userId: string) { + const projects = await this.todoService.getProjects(userId); + + if (projects.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!add Aufgabe #projektname`' + ); + return; + } + + let response = '📁 **Deine Projekte:**\n\n'; + for (const project of projects) { + response += `• ${project.name}\n`; + } + response += '\nZeige Projektaufgaben mit `!project [Name]`'; + + await this.sendReply(roomId, event, response); + } + + private async handleProjectTasks(roomId: string, event: any, userId: string, args: string) { + const projectName = args.trim(); + + if (!projectName) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib einen Projektnamen an.\n\nBeispiel: `!project Arbeit`' + ); + return; + } + + const tasks = await this.todoService.getProjectTasks(userId, projectName); + + if (tasks.length === 0) { + await this.sendReply(roomId, event, `📭 Keine Aufgaben im Projekt "${projectName}".`); + return; + } + + const response = this.formatTaskList(`📁 **Projekt: ${projectName}**`, tasks); + await this.sendReply(roomId, event, response); + } + + private async handleStatus(roomId: string, event: any, userId: string) { + const stats = await this.todoService.getStats(userId); + + const response = `📊 **Status** + +• Offene Aufgaben: ${stats.pending} +• Heute fällig: ${stats.today} +• Erledigt: ${stats.completed} +• Gesamt: ${stats.total} + +Bot: ✅ Online`; + + await this.sendReply(roomId, event, response); + } + + private async handlePinHelp(roomId: string, event: any) { + try { + // Send help message + const helpEventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: HELP_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(HELP_TEXT), + }); + + // Pin it + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [helpEventId], + }); + + await this.sendReply(roomId, event, '📌 Hilfe wurde angepinnt!'); + } catch (error) { + this.logger.error('Failed to pin help:', error); + await this.sendReply( + roomId, + event, + '❌ Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)' + ); + } + } + + private formatTaskList(header: string, tasks: Task[]): string { + let response = `${header}\n\n`; + + tasks.forEach((task, index) => { + const num = index + 1; + const priority = task.priority < 4 ? `❗`.repeat(4 - task.priority) : ''; + const date = task.dueDate ? ` 📅 ${this.formatDate(task.dueDate)}` : ''; + const project = task.project ? ` 📁 ${task.project}` : ''; + + response += `**${num}.** ${task.title}${priority}${date}${project}\n`; + }); + + response += `\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' }); + } + + 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 async sendWelcomeMessage(roomId: string, userId: string) { + try { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: WELCOME_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(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.client.sendMessage(roomId, { + msgtype: 'm.text', + body: BOT_INTRODUCTION, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(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(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-todo-bot/src/config/configuration.ts b/services/matrix-todo-bot/src/config/configuration.ts new file mode 100644 index 000000000..1c537d575 --- /dev/null +++ b/services/matrix-todo-bot/src/config/configuration.ts @@ -0,0 +1,58 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3314', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean), + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + todo: { + apiUrl: process.env.TODO_API_URL || 'http://localhost:3010/api/v1', + serviceKey: process.env.TODO_SERVICE_KEY || '', + }, +}); + +export const HELP_TEXT = `🎯 **Todo Bot - Hilfe** + +**Aufgaben verwalten:** +• \`!add [Aufgabe]\` - Neue Aufgabe hinzufügen +• \`!list\` oder \`!heute\` - Heutige Aufgaben anzeigen +• \`!inbox\` - Aufgaben ohne Datum anzeigen +• \`!done [Nr]\` - Aufgabe als erledigt markieren +• \`!delete [Nr]\` - Aufgabe löschen + +**Projekte:** +• \`!projects\` - Alle Projekte anzeigen +• \`!project [Name]\` - Aufgaben eines Projekts anzeigen + +**Prioritäten:** +• \`!add Wichtige Aufgabe !p1\` - Höchste Priorität (1-4) +• \`!add Morgen machen @morgen\` - Datum setzen + +**Sonstiges:** +• \`!status\` - Verbindungsstatus prüfen +• \`!help\` oder \`hilfe\` - Diese Hilfe anzeigen + +**Natürliche Sprache:** +Du kannst auch einfach "hilfe", "zeige aufgaben", "was muss ich heute machen?" schreiben.`; + +export const WELCOME_TEXT = `👋 **Willkommen beim Todo Bot!** + +Ich helfe dir, deine Aufgaben zu verwalten. Hier sind die wichtigsten Befehle: + +• \`!add [Aufgabe]\` - Neue Aufgabe erstellen +• \`!list\` - Heutige Aufgaben anzeigen +• \`!done [Nr]\` - Aufgabe abhaken + +Schreibe \`!help\` oder einfach "hilfe" für alle Befehle.`; + +export const BOT_INTRODUCTION = `🎯 **Hallo! Ich bin der Todo Bot.** + +Ich bin jetzt diesem Raum beigetreten und kann dir bei der Aufgabenverwaltung helfen. + +**Schnellstart:** +• \`!add Einkaufen gehen\` - Aufgabe erstellen +• \`!list\` - Deine Aufgaben sehen +• \`!done 1\` - Erste Aufgabe abhaken + +Schreibe \`!help\` für alle Befehle!`; diff --git a/services/matrix-todo-bot/src/health.controller.ts b/services/matrix-todo-bot/src/health.controller.ts new file mode 100644 index 000000000..4787570e7 --- /dev/null +++ b/services/matrix-todo-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-todo-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-todo-bot/src/main.ts b/services/matrix-todo-bot/src/main.ts new file mode 100644 index 000000000..e5cf550c7 --- /dev/null +++ b/services/matrix-todo-bot/src/main.ts @@ -0,0 +1,17 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port', 3314); + + await app.listen(port); + logger.log(`Todo Bot is running on port ${port}`); +} + +bootstrap(); diff --git a/services/matrix-todo-bot/src/todo/todo.module.ts b/services/matrix-todo-bot/src/todo/todo.module.ts new file mode 100644 index 000000000..908900c03 --- /dev/null +++ b/services/matrix-todo-bot/src/todo/todo.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TodoService } from './todo.service'; + +@Module({ + providers: [TodoService], + exports: [TodoService], +}) +export class TodoModule {} diff --git a/services/matrix-todo-bot/src/todo/todo.service.ts b/services/matrix-todo-bot/src/todo/todo.service.ts new file mode 100644 index 000000000..5d2b46d15 --- /dev/null +++ b/services/matrix-todo-bot/src/todo/todo.service.ts @@ -0,0 +1,251 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface Task { + id: string; + title: string; + completed: boolean; + priority: number; // 1-4, 1 is highest + dueDate: string | null; // ISO date string + project: string | null; + labels: string[]; + createdAt: string; + completedAt: string | null; + userId: string; // Matrix user ID +} + +export interface Project { + id: string; + name: string; + color: string; + userId: string; +} + +interface TodoData { + tasks: Task[]; + projects: Project[]; +} + +@Injectable() +export class TodoService implements OnModuleInit { + private readonly logger = new Logger(TodoService.name); + private data: TodoData = { tasks: [], projects: [] }; + private dataPath: string; + + constructor(private configService: ConfigService) { + const storagePath = this.configService.get( + 'matrix.storagePath', + './data/bot-storage.json' + ); + this.dataPath = storagePath.replace('bot-storage.json', 'todo-data.json'); + } + + async onModuleInit() { + await this.loadData(); + } + + private async loadData(): Promise { + try { + const dir = path.dirname(this.dataPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this.dataPath)) { + const content = fs.readFileSync(this.dataPath, 'utf-8'); + this.data = JSON.parse(content); + this.logger.log( + `Loaded ${this.data.tasks.length} tasks, ${this.data.projects.length} projects` + ); + } else { + this.data = { tasks: [], projects: [] }; + await this.saveData(); + this.logger.log('Created new todo data file'); + } + } catch (error) { + this.logger.error('Failed to load todo data:', error); + this.data = { tasks: [], projects: [] }; + } + } + + private async saveData(): Promise { + try { + fs.writeFileSync(this.dataPath, JSON.stringify(this.data, null, 2)); + } catch (error) { + this.logger.error('Failed to save todo data:', error); + } + } + + private generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + // Task operations + + async createTask(userId: string, title: string, options?: Partial): Promise { + const task: Task = { + id: this.generateId(), + title, + completed: false, + priority: options?.priority || 4, + dueDate: options?.dueDate || null, + project: options?.project || null, + labels: options?.labels || [], + createdAt: new Date().toISOString(), + completedAt: null, + userId, + }; + + this.data.tasks.push(task); + await this.saveData(); + this.logger.log(`Created task "${title}" for user ${userId}`); + return task; + } + + async getTodayTasks(userId: string): Promise { + const today = new Date().toISOString().split('T')[0]; + return this.data.tasks + .filter( + (t) => t.userId === userId && !t.completed && t.dueDate && t.dueDate.startsWith(today) + ) + .sort((a, b) => a.priority - b.priority); + } + + async getInboxTasks(userId: string): Promise { + return this.data.tasks + .filter((t) => t.userId === userId && !t.completed && !t.dueDate && !t.project) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + async getAllPendingTasks(userId: string): Promise { + return this.data.tasks + .filter((t) => t.userId === userId && !t.completed) + .sort((a, b) => { + // Sort by due date first (nulls last), then by priority + if (a.dueDate && !b.dueDate) return -1; + if (!a.dueDate && b.dueDate) return 1; + if (a.dueDate && b.dueDate) { + const dateCompare = a.dueDate.localeCompare(b.dueDate); + if (dateCompare !== 0) return dateCompare; + } + return a.priority - b.priority; + }); + } + + async getProjectTasks(userId: string, projectName: string): Promise { + return this.data.tasks + .filter( + (t) => + t.userId === userId && + !t.completed && + t.project?.toLowerCase() === projectName.toLowerCase() + ) + .sort((a, b) => a.priority - b.priority); + } + + async completeTask(userId: string, taskIndex: number): Promise { + const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed); + if (taskIndex < 1 || taskIndex > userTasks.length) { + return null; + } + + const task = userTasks[taskIndex - 1]; + task.completed = true; + task.completedAt = new Date().toISOString(); + await this.saveData(); + this.logger.log(`Completed task "${task.title}" for user ${userId}`); + return task; + } + + async deleteTask(userId: string, taskIndex: number): Promise { + const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed); + if (taskIndex < 1 || taskIndex > userTasks.length) { + return null; + } + + const task = userTasks[taskIndex - 1]; + this.data.tasks = this.data.tasks.filter((t) => t.id !== task.id); + await this.saveData(); + this.logger.log(`Deleted task "${task.title}" for user ${userId}`); + return task; + } + + // Project operations + + async getProjects(userId: string): Promise { + // Get unique projects from tasks + const projectNames = new Set(); + this.data.tasks + .filter((t) => t.userId === userId && t.project) + .forEach((t) => projectNames.add(t.project!)); + + return Array.from(projectNames).map((name) => ({ + id: name.toLowerCase(), + name, + color: '#808080', + userId, + })); + } + + // Statistics + + async getStats( + userId: string + ): Promise<{ total: number; completed: number; pending: number; today: number }> { + const userTasks = this.data.tasks.filter((t) => t.userId === userId); + const today = new Date().toISOString().split('T')[0]; + + return { + total: userTasks.length, + completed: userTasks.filter((t) => t.completed).length, + pending: userTasks.filter((t) => !t.completed).length, + today: userTasks.filter((t) => !t.completed && t.dueDate?.startsWith(today)).length, + }; + } + + // Parse task input for priority and date + parseTaskInput(input: string): { + title: string; + priority: number; + dueDate: string | null; + project: string | null; + } { + let title = input; + let priority = 4; + let dueDate: string | null = null; + let project: string | null = null; + + // Parse priority (!p1, !p2, !p3, !p4) + const priorityMatch = title.match(/!p([1-4])/i); + if (priorityMatch) { + priority = parseInt(priorityMatch[1]); + title = title.replace(/!p[1-4]/i, '').trim(); + } + + // Parse date (@heute, @morgen, @übermorgen) + const today = new Date(); + if (/@heute/i.test(title)) { + dueDate = today.toISOString().split('T')[0]; + title = title.replace(/@heute/i, '').trim(); + } else if (/@morgen/i.test(title)) { + today.setDate(today.getDate() + 1); + dueDate = today.toISOString().split('T')[0]; + title = title.replace(/@morgen/i, '').trim(); + } else if (/@übermorgen/i.test(title)) { + today.setDate(today.getDate() + 2); + dueDate = today.toISOString().split('T')[0]; + title = title.replace(/@übermorgen/i, '').trim(); + } + + // Parse project (#projektname) + const projectMatch = title.match(/#(\S+)/); + if (projectMatch) { + project = projectMatch[1]; + title = title.replace(/#\S+/, '').trim(); + } + + return { title, priority, dueDate, project }; + } +} diff --git a/services/matrix-todo-bot/tsconfig.build.json b/services/matrix-todo-bot/tsconfig.build.json new file mode 100644 index 000000000..4491981e0 --- /dev/null +++ b/services/matrix-todo-bot/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/services/matrix-todo-bot/tsconfig.json b/services/matrix-todo-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-todo-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +}