From e3cfafe5943ef36d5166a43fd3587704d4486659 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:54:07 +0100 Subject: [PATCH] feat(matrix-presi-bot): add Matrix bot for presentation management - Full NestJS bot with matrix-bot-sdk integration - Deck management: create, list, view, delete, rename - Slide management: add title/text/bullet/image slides, delete - Multiple slide types: title, content, bullets, image - Theme support: list themes, apply to presentation - Sharing: create links with optional expiration - German/English command aliases - Number-based reference system for decks and themes - JWT auth via mana-core-auth - Runs on port 3325 Co-Authored-By: Claude Opus 4.5 --- services/matrix-presi-bot/.env.example | 15 + services/matrix-presi-bot/.gitignore | 29 + services/matrix-presi-bot/CLAUDE.md | 208 ++++++ services/matrix-presi-bot/Dockerfile | 41 ++ services/matrix-presi-bot/nest-cli.json | 5 + services/matrix-presi-bot/package.json | 27 + services/matrix-presi-bot/src/app.module.ts | 21 + .../matrix-presi-bot/src/bot/bot.module.ts | 11 + .../src/bot/matrix.service.ts | 651 ++++++++++++++++++ .../src/config/configuration.ts | 62 ++ .../matrix-presi-bot/src/health.controller.ts | 9 + services/matrix-presi-bot/src/main.ts | 10 + .../src/presi/presi.module.ts | 8 + .../src/presi/presi.service.ts | 210 ++++++ .../src/session/session.module.ts | 8 + .../src/session/session.service.ts | 86 +++ services/matrix-presi-bot/tsconfig.json | 22 + 17 files changed, 1423 insertions(+) create mode 100644 services/matrix-presi-bot/.env.example create mode 100644 services/matrix-presi-bot/.gitignore create mode 100644 services/matrix-presi-bot/CLAUDE.md create mode 100644 services/matrix-presi-bot/Dockerfile create mode 100644 services/matrix-presi-bot/nest-cli.json create mode 100644 services/matrix-presi-bot/package.json create mode 100644 services/matrix-presi-bot/src/app.module.ts create mode 100644 services/matrix-presi-bot/src/bot/bot.module.ts create mode 100644 services/matrix-presi-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-presi-bot/src/config/configuration.ts create mode 100644 services/matrix-presi-bot/src/health.controller.ts create mode 100644 services/matrix-presi-bot/src/main.ts create mode 100644 services/matrix-presi-bot/src/presi/presi.module.ts create mode 100644 services/matrix-presi-bot/src/presi/presi.service.ts create mode 100644 services/matrix-presi-bot/src/session/session.module.ts create mode 100644 services/matrix-presi-bot/src/session/session.service.ts create mode 100644 services/matrix-presi-bot/tsconfig.json diff --git a/services/matrix-presi-bot/.env.example b/services/matrix-presi-bot/.env.example new file mode 100644 index 000000000..e09eaa052 --- /dev/null +++ b/services/matrix-presi-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3325 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#presi:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Presi Backend +PRESI_BACKEND_URL=http://localhost:3008 +PRESI_API_PREFIX=/api + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-presi-bot/.gitignore b/services/matrix-presi-bot/.gitignore new file mode 100644 index 000000000..2d508e5ec --- /dev/null +++ b/services/matrix-presi-bot/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local + +# Data +data/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# TypeScript +*.tsbuildinfo diff --git a/services/matrix-presi-bot/CLAUDE.md b/services/matrix-presi-bot/CLAUDE.md new file mode 100644 index 000000000..f77e718dd --- /dev/null +++ b/services/matrix-presi-bot/CLAUDE.md @@ -0,0 +1,208 @@ +# Matrix Presi Bot - Claude Code Guidelines + +## Overview + +Matrix Presi Bot provides presentation management via Matrix chat. It integrates with the Presi backend for deck/slide management, theming, and sharing. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: Presi API (port 3008) +- **Auth**: Mana Core Auth (JWT) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-presi-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3325) +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & help messages +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ ├── presi/ +│ │ ├── presi.module.ts +│ │ └── presi.service.ts # Presi Backend API client +│ └── session/ +│ ├── session.module.ts +│ └── session.service.ts # User session & auth management +├── Dockerfile +└── package.json +``` + +## Bot Commands + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!help` | hilfe | Show help message | +| `!login email pass` | - | Login | +| `!logout` | - | Logout | +| `!status` | - | Bot status | + +### Presentation Management + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!presis` | decks, liste | List all presentations | +| `!presi [nr]` | deck, details | Show presentation with slides | +| `!neu Titel` | new, create | Create presentation | +| `!loeschen [nr]` | delete | Delete presentation | +| `!umbenennen [nr] Titel` | rename | Rename presentation | + +### Slide Management + +| Command | Description | +|---------|-------------| +| `!folie [nr] titel Titel \| Untertitel` | Add title slide | +| `!folie [nr] text Titel \| Inhalt` | Add content slide | +| `!folie [nr] punkte Titel \| P1, P2, P3` | Add bullet slide | +| `!folie [nr] bild Titel \| URL` | Add image slide | +| `!folieloeschen [presi-nr] [folien-nr]` | Delete slide | + +### Themes + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!themes` | designs | List available themes | +| `!theme [presi-nr] [theme-nr]` | design | Apply theme | + +### Sharing + +| Command | Options | Description | +|---------|---------|-------------| +| `!teilen [nr]` | share | Share presentation | +| `--tage N` | - | Expire in N days | +| `!links [nr]` | shares | List share links | + +## Slide Types + +| Type | Content | +|------|---------| +| `title` | Title + optional subtitle | +| `content` | Title + body text | +| `bullets` | Title + bullet points | +| `image` | Title + image URL | + +## Example Usage + +``` +# Login +!login max@example.com mypassword + +# Create presentation +!neu Meine Praesentation | Eine tolle Praesentation + +# List presentations +!presis + +# Add title slide +!folie 1 titel Willkommen | Zur Praesentation + +# Add content slide +!folie 1 text Einfuehrung | Hier ist der Inhalt + +# Add bullet points +!folie 1 punkte Agenda | Punkt 1, Punkt 2, Punkt 3 + +# View presentation +!presi 1 + +# Apply theme +!themes +!theme 1 2 + +# Share presentation +!teilen 1 --tage 7 + +# View share links +!links 1 +``` + +## Environment Variables + +```env +# Server +PORT=3325 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#presi:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Presi Backend +PRESI_BACKEND_URL=http://localhost:3008 +PRESI_API_PREFIX=/api + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-presi-bot/Dockerfile -t matrix-presi-bot services/matrix-presi-bot + +# Run +docker run -p 3325:3325 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e PRESI_BACKEND_URL=http://presi-backend:3008 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-presi-bot-data:/app/data \ + matrix-presi-bot +``` + +## Health Check + +```bash +curl http://localhost:3325/health +``` + +## Presi Backend API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/api/decks` | GET | List presentations | +| `/api/decks` | POST | Create presentation | +| `/api/decks/:id` | GET | Get presentation with slides | +| `/api/decks/:id` | PUT | Update presentation | +| `/api/decks/:id` | DELETE | Delete presentation | +| `/api/decks/:id/slides` | POST | Add slide | +| `/api/slides/:id` | DELETE | Delete slide | +| `/api/themes` | GET | List themes | +| `/api/share/deck/:id` | POST | Create share link | +| `/api/share/deck/:id/links` | GET | List share links | + +## Number-Based Reference System + +The bot uses a number-based reference system for ease of use: +1. User runs `!presis` or `!themes` to get a list +2. Bot stores the list internally for the user +3. User can reference items by their list number +4. Numbers are valid until the user runs a new list command + +This allows simple commands like: +- `!presi 2` - Show presentation #2 +- `!folie 1 titel Hallo` - Add slide to presentation #1 +- `!theme 1 3` - Apply theme #3 to presentation #1 diff --git a/services/matrix-presi-bot/Dockerfile b/services/matrix-presi-bot/Dockerfile new file mode 100644 index 000000000..3b82327df --- /dev/null +++ b/services/matrix-presi-bot/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files and install production dependencies only +COPY package.json ./ +RUN npm install --omit=dev + +# Copy built application from builder +COPY --from=builder /app/dist ./dist + +# Create data directory +RUN mkdir -p /app/data + +# Expose port +EXPOSE 3325 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3325/health || exit 1 + +# Start the application +CMD ["node", "dist/main.js"] diff --git a/services/matrix-presi-bot/nest-cli.json b/services/matrix-presi-bot/nest-cli.json new file mode 100644 index 000000000..5c06bb8c3 --- /dev/null +++ b/services/matrix-presi-bot/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json-schema.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/services/matrix-presi-bot/package.json b/services/matrix-presi-bot/package.json new file mode 100644 index 000000000..9b40d3569 --- /dev/null +++ b/services/matrix-presi-bot/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mana-bots/matrix-presi-bot", + "version": "1.0.0", + "description": "Matrix bot for presentation management", + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@types/node": "^20.10.0", + "typescript": "^5.3.0" + } +} diff --git a/services/matrix-presi-bot/src/app.module.ts b/services/matrix-presi-bot/src/app.module.ts new file mode 100644 index 000000000..7babeca70 --- /dev/null +++ b/services/matrix-presi-bot/src/app.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { HealthController } from './health.controller'; +import { BotModule } from './bot/bot.module'; +import { PresiModule } from './presi/presi.module'; +import { SessionModule } from './session/session.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + PresiModule, + SessionModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-presi-bot/src/bot/bot.module.ts b/services/matrix-presi-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..0c3b493aa --- /dev/null +++ b/services/matrix-presi-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { PresiModule } from '../presi/presi.module'; +import { SessionModule } from '../session/session.module'; + +@Module({ + imports: [PresiModule, SessionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-presi-bot/src/bot/matrix.service.ts b/services/matrix-presi-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..563e29e26 --- /dev/null +++ b/services/matrix-presi-bot/src/bot/matrix.service.ts @@ -0,0 +1,651 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, +} from 'matrix-bot-sdk'; +import { PresiService, Deck, Theme, SlideContent } from '../presi/presi.service'; +import { SessionService } from '../session/session.service'; +import { HELP_MESSAGE } from '../config/configuration'; + +@Injectable() +export class MatrixService implements OnModuleInit { + private readonly logger = new Logger(MatrixService.name); + private client: MatrixClient; + private allowedRooms: string[]; + + // Store last shown items per user for reference by number + private lastDecksList: Map = new Map(); + private lastThemesList: Map = new Map(); + + constructor( + private configService: ConfigService, + private presiService: PresiService, + private sessionService: SessionService + ) {} + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + + if (!accessToken) { + this.logger.warn('No Matrix access token configured, bot disabled'); + return; + } + + const storage = new SimpleFsStorageProvider(storagePath); + this.client = new MatrixClient(homeserverUrl, accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + this.client.on('room.message', this.handleMessage.bind(this)); + + await this.client.start(); + this.logger.log('Matrix Presi Bot started'); + } + + private async handleMessage(roomId: string, event: any) { + if (event.sender === (await this.client.getUserId())) return; + if (event.content?.msgtype !== 'm.text') return; + + const body = event.content.body?.trim(); + if (!body?.startsWith('!')) return; + + if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + return; + } + + const sender = event.sender; + const parts = body.slice(1).split(/\s+/); + const command = parts[0].toLowerCase(); + const args = parts.slice(1); + const argString = args.join(' '); + + try { + switch (command) { + case 'help': + case 'hilfe': + await this.sendHtml(roomId, HELP_MESSAGE); + break; + + case 'login': + await this.handleLogin(roomId, sender, args); + break; + + case 'logout': + this.sessionService.logout(sender); + await this.sendHtml(roomId, '

Erfolgreich abgemeldet.

'); + break; + + case 'status': + await this.handleStatus(roomId, sender); + break; + + // Deck commands + case 'presis': + case 'decks': + case 'liste': + await this.handleListDecks(roomId, sender); + break; + + case 'presi': + case 'deck': + case 'details': + await this.handleDeckDetails(roomId, sender, args[0]); + break; + + case 'neu': + case 'new': + case 'create': + await this.handleCreateDeck(roomId, sender, argString); + break; + + case 'loeschen': + case 'delete': + await this.handleDeleteDeck(roomId, sender, args[0]); + break; + + case 'umbenennen': + case 'rename': + await this.handleRenameDeck(roomId, sender, args[0], args.slice(1).join(' ')); + break; + + // Slide commands + case 'folie': + case 'slide': + await this.handleAddSlide(roomId, sender, args); + break; + + case 'folieloeschen': + case 'deleteslide': + await this.handleDeleteSlide(roomId, sender, args[0], args[1]); + break; + + // Theme commands + case 'themes': + case 'designs': + await this.handleListThemes(roomId, sender); + break; + + case 'theme': + case 'design': + await this.handleApplyTheme(roomId, sender, args[0], args[1]); + break; + + // Share commands + case 'teilen': + case 'share': + await this.handleShareDeck(roomId, sender, argString); + break; + + case 'links': + case 'shares': + await this.handleListShares(roomId, sender, args[0]); + break; + + default: + await this.sendHtml( + roomId, + `

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

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

Fehler: ${error.message}

`); + } + } + + private async sendHtml(roomId: string, html: string) { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: html.replace(/<[^>]*>/g, ''), + format: 'org.matrix.custom.html', + formatted_body: html, + }); + } + + private requireAuth(sender: string): string { + const token = this.sessionService.getToken(sender); + if (!token) { + throw new Error('Nicht angemeldet. Nutze !login email passwort'); + } + return token; + } + + // Auth handlers + private async handleLogin(roomId: string, sender: string, args: string[]) { + if (args.length < 2) { + await this.sendHtml(roomId, '

Verwendung: !login email passwort

'); + return; + } + + const [email, password] = args; + const result = await this.sessionService.login(sender, email, password); + + if (result.success) { + await this.sendHtml(roomId, `

Erfolgreich angemeldet als ${email}

`); + } else { + await this.sendHtml(roomId, `

Login fehlgeschlagen: ${result.error}

`); + } + } + + private async handleStatus(roomId: string, sender: string) { + const backendOk = await this.presiService.checkHealth(); + const loggedIn = this.sessionService.isLoggedIn(sender); + const sessions = this.sessionService.getSessionCount(); + + await this.sendHtml( + roomId, + `

Presi Bot Status

+
    +
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • +
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • +
  • Aktive Sessions: ${sessions}
  • +
` + ); + } + + // Deck handlers + private async handleListDecks(roomId: string, sender: string) { + const token = this.requireAuth(sender); + const result = await this.presiService.getDecks(token); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine Praesentationen vorhanden. Erstelle eine mit !neu Titel

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

Deine Praesentationen

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

Nutze !presi [nr] fuer Details

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

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

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

${d.title}

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

${d.description}

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

Folien:

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

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

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

Verwendung: !neu Titel | Beschreibung

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

Fehler: ${result.error}

`); + return; + } + + this.lastDecksList.delete(sender); + await this.sendHtml( + roomId, + `

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

+

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

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

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

`); + return; + } + + this.lastDecksList.delete(sender); + await this.sendHtml(roomId, `

Praesentation ${deck.title} geloescht.

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

Verwendung: !umbenennen [nr] Neuer Titel

'); + return; + } + + const token = this.requireAuth(sender); + const deck = this.getDeckByNumber(sender, numberStr); + + if (!deck) { + await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

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

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

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

Verwendung:

+
    +
  • !folie [nr] titel Titel | Untertitel
  • +
  • !folie [nr] text Titel | Inhalt
  • +
  • !folie [nr] punkte Titel | Punkt1, Punkt2
  • +
` + ); + return; + } + + const token = this.requireAuth(sender); + const deck = this.getDeckByNumber(sender, args[0]); + + if (!deck) { + await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

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

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

Fehler: ${result.error}

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

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

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

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

'); + return; + } + + const token = this.requireAuth(sender); + const deck = this.getDeckByNumber(sender, deckNumStr); + + if (!deck) { + await this.sendHtml(roomId, '

Ungueltige Praesentation-Nummer.

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

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

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

Ungueltige Folien-Nummer.

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

Fehler: ${result.error}

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

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

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

Fehler: ${result.error}

`); + return; + } + + const themes = result.data || []; + this.lastThemesList.set(sender, themes); + + if (themes.length === 0) { + await this.sendHtml(roomId, '

Keine Themes verfuegbar.

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

Verfuegbare Themes

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

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

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

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

'); + return; + } + + const token = this.requireAuth(sender); + const deck = this.getDeckByNumber(sender, deckNumStr); + const theme = this.getThemeByNumber(sender, themeNumStr); + + if (!deck) { + await this.sendHtml(roomId, '

Ungueltige Praesentation-Nummer.

'); + return; + } + + if (!theme) { + await this.sendHtml(roomId, '

Ungueltige Theme-Nummer. Nutze zuerst !themes

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

Fehler: ${result.error}

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

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

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

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

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

${deck.title} wird geteilt:

`; + html += `

${shareUrl}

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

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

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

Verwendung: !links [presi-nr]

'); + return; + } + + const token = this.requireAuth(sender); + const deck = this.getDeckByNumber(sender, numberStr); + + if (!deck) { + await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !presis

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

Fehler: ${result.error}

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

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

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

Share-Links: ${deck.title}

    `; + for (const link of links) { + const expires = link.expiresAt + ? ` (bis ${new Date(link.expiresAt).toLocaleDateString('de-DE')})` + : ' (unbegrenzt)'; + const url = this.presiService.getShareUrl(link.shareCode); + html += `
  1. ${link.shareCode}${expires}
  2. `; + } + html += '
'; + + await this.sendHtml(roomId, html); + } + + // Helper methods + private getDeckByNumber(sender: string, numberStr: string): Deck | null { + const decks = this.lastDecksList.get(sender); + if (!decks) return null; + + const index = parseInt(numberStr, 10) - 1; + if (isNaN(index) || index < 0 || index >= decks.length) return null; + + return decks[index]; + } + + private getThemeByNumber(sender: string, numberStr: string): Theme | null { + const themes = this.lastThemesList.get(sender); + if (!themes) return null; + + const index = parseInt(numberStr, 10) - 1; + if (isNaN(index) || index < 0 || index >= themes.length) return null; + + return themes[index]; + } +} diff --git a/services/matrix-presi-bot/src/config/configuration.ts b/services/matrix-presi-bot/src/config/configuration.ts new file mode 100644 index 000000000..fec533243 --- /dev/null +++ b/services/matrix-presi-bot/src/config/configuration.ts @@ -0,0 +1,62 @@ +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3325, + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN, + allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + presi: { + backendUrl: process.env.PRESI_BACKEND_URL || 'http://localhost:3008', + apiPrefix: process.env.PRESI_API_PREFIX || '/api', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, +}); + +export const HELP_MESSAGE = `

Presi Bot - Befehle

+ +

Authentifizierung

+
    +
  • !login email passwort - Anmelden
  • +
  • !logout - Abmelden
  • +
  • !status - Bot-Status anzeigen
  • +
+ +

Praesentationen

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

Folien

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

Themes

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

Teilen

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

Weitere Befehle

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

Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-presi-bot/src/health.controller.ts b/services/matrix-presi-bot/src/health.controller.ts new file mode 100644 index 000000000..35f3c8e73 --- /dev/null +++ b/services/matrix-presi-bot/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok', service: 'matrix-presi-bot' }; + } +} diff --git a/services/matrix-presi-bot/src/main.ts b/services/matrix-presi-bot/src/main.ts new file mode 100644 index 000000000..7430fdc4b --- /dev/null +++ b/services/matrix-presi-bot/src/main.ts @@ -0,0 +1,10 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const port = process.env.PORT || 3325; + await app.listen(port); + console.log(`Matrix Presi Bot running on port ${port}`); +} +bootstrap(); diff --git a/services/matrix-presi-bot/src/presi/presi.module.ts b/services/matrix-presi-bot/src/presi/presi.module.ts new file mode 100644 index 000000000..6c2ee291e --- /dev/null +++ b/services/matrix-presi-bot/src/presi/presi.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PresiService } from './presi.service'; + +@Module({ + providers: [PresiService], + exports: [PresiService], +}) +export class PresiModule {} diff --git a/services/matrix-presi-bot/src/presi/presi.service.ts b/services/matrix-presi-bot/src/presi/presi.service.ts new file mode 100644 index 000000000..daffb0d96 --- /dev/null +++ b/services/matrix-presi-bot/src/presi/presi.service.ts @@ -0,0 +1,210 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface SlideContent { + type: 'title' | 'content' | 'image' | 'split'; + title?: string; + subtitle?: string; + body?: string; + imageUrl?: string; + bulletPoints?: string[]; +} + +export interface Slide { + id: string; + deckId: string; + order: number; + content: SlideContent; + createdAt: string; +} + +export interface Theme { + id: string; + name: string; + colors: { + primary: string; + secondary: string; + background: string; + text: string; + accent: string; + }; + fonts: { + heading: string; + body: string; + }; + isDefault: boolean; +} + +export interface Deck { + id: string; + title: string; + description?: string; + themeId?: string; + isPublic: boolean; + theme?: Theme; + slides?: Slide[]; + createdAt: string; + updatedAt: string; +} + +export interface ShareLink { + id: string; + deckId: string; + shareCode: string; + expiresAt?: string; + createdAt: string; +} + +@Injectable() +export class PresiService { + private readonly logger = new Logger(PresiService.name); + private backendUrl: string; + private apiPrefix: string; + + constructor(private configService: ConfigService) { + this.backendUrl = this.configService.get('presi.backendUrl') || 'http://localhost:3008'; + this.apiPrefix = this.configService.get('presi.apiPrefix') || '/api'; + } + + private async request( + token: string, + endpoint: string, + options: RequestInit = {} + ): Promise<{ data?: T; error?: string }> { + try { + const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { error: errorData.message || `Fehler: ${response.status}` }; + } + + const data = await response.json(); + return { data }; + } catch (error) { + this.logger.error(`Request failed: ${endpoint}`, error); + return { error: 'Verbindung zum Backend fehlgeschlagen' }; + } + } + + private async publicRequest(endpoint: string): Promise<{ data?: T; error?: string }> { + try { + const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { error: errorData.message || `Fehler: ${response.status}` }; + } + + const data = await response.json(); + return { data }; + } catch (error) { + this.logger.error(`Public request failed: ${endpoint}`, error); + return { error: 'Verbindung zum Backend fehlgeschlagen' }; + } + } + + // Deck operations + async getDecks(token: string): Promise<{ data?: Deck[]; error?: string }> { + return this.request(token, '/decks'); + } + + async getDeck(token: string, deckId: string): Promise<{ data?: Deck; error?: string }> { + return this.request(token, `/decks/${deckId}`); + } + + async createDeck( + token: string, + title: string, + description?: string + ): Promise<{ data?: Deck; error?: string }> { + return this.request(token, '/decks', { + method: 'POST', + body: JSON.stringify({ title, description }), + }); + } + + async updateDeck( + token: string, + deckId: string, + updates: { title?: string; description?: string; themeId?: string; isPublic?: boolean } + ): Promise<{ data?: Deck; error?: string }> { + return this.request(token, `/decks/${deckId}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } + + async deleteDeck(token: string, deckId: string): Promise<{ error?: string }> { + return this.request(token, `/decks/${deckId}`, { method: 'DELETE' }); + } + + // Slide operations + async addSlide( + token: string, + deckId: string, + content: SlideContent + ): Promise<{ data?: Slide; error?: string }> { + return this.request(token, `/decks/${deckId}/slides`, { + method: 'POST', + body: JSON.stringify({ content }), + }); + } + + async deleteSlide(token: string, slideId: string): Promise<{ error?: string }> { + return this.request(token, `/slides/${slideId}`, { method: 'DELETE' }); + } + + // Theme operations + async getThemes(): Promise<{ data?: Theme[]; error?: string }> { + return this.publicRequest('/themes'); + } + + async getTheme(themeId: string): Promise<{ data?: Theme; error?: string }> { + return this.publicRequest(`/themes/${themeId}`); + } + + // Share operations + async createShareLink( + token: string, + deckId: string, + expiresAt?: string + ): Promise<{ data?: ShareLink; error?: string }> { + return this.request(token, `/share/deck/${deckId}`, { + method: 'POST', + body: JSON.stringify({ expiresAt }), + }); + } + + async getShareLinks(token: string, deckId: string): Promise<{ data?: ShareLink[]; error?: string }> { + return this.request(token, `/share/deck/${deckId}/links`); + } + + async deleteShareLink(token: string, shareId: string): Promise<{ error?: string }> { + return this.request(token, `/share/${shareId}`, { method: 'DELETE' }); + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.backendUrl}/health`); + return response.ok; + } catch { + return false; + } + } + + getShareUrl(shareCode: string): string { + return `${this.backendUrl}/share/${shareCode}`; + } +} diff --git a/services/matrix-presi-bot/src/session/session.module.ts b/services/matrix-presi-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-presi-bot/src/session/session.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SessionService } from './session.service'; + +@Module({ + providers: [SessionService], + exports: [SessionService], +}) +export class SessionModule {} diff --git a/services/matrix-presi-bot/src/session/session.service.ts b/services/matrix-presi-bot/src/session/session.service.ts new file mode 100644 index 000000000..504b9951d --- /dev/null +++ b/services/matrix-presi-bot/src/session/session.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface UserSession { + token: string; + email: string; + expiresAt: Date; +} + +@Injectable() +export class SessionService { + private readonly logger = new Logger(SessionService.name); + private sessions: Map = new Map(); + private authUrl: string; + + constructor(private configService: ConfigService) { + this.authUrl = this.configService.get('auth.url') || 'http://localhost:3001'; + } + + async login( + matrixUserId: string, + email: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch(`${this.authUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.message || 'Authentifizierung fehlgeschlagen', + }; + } + + const data = await response.json(); + const token = data.accessToken || data.token; + + if (!token) { + return { success: false, error: 'Kein Token erhalten' }; + } + + this.sessions.set(matrixUserId, { + token, + email, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }); + + this.logger.log(`User ${matrixUserId} logged in as ${email}`); + return { success: true }; + } catch (error) { + this.logger.error(`Login failed for ${matrixUserId}:`, error); + return { + success: false, + error: 'Verbindung zum Auth-Server fehlgeschlagen', + }; + } + } + + logout(matrixUserId: string): void { + this.sessions.delete(matrixUserId); + this.logger.log(`User ${matrixUserId} logged out`); + } + + getToken(matrixUserId: string): string | null { + const session = this.sessions.get(matrixUserId); + if (!session) return null; + if (session.expiresAt < new Date()) { + this.sessions.delete(matrixUserId); + return null; + } + return session.token; + } + + isLoggedIn(matrixUserId: string): boolean { + return this.getToken(matrixUserId) !== null; + } + + getSessionCount(): number { + return this.sessions.size; + } +} diff --git a/services/matrix-presi-bot/tsconfig.json b/services/matrix-presi-bot/tsconfig.json new file mode 100644 index 000000000..94f1e9493 --- /dev/null +++ b/services/matrix-presi-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true + } +}