From ad7f875c5f09db5bdec34ee1d784a73ab8a3c206 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:29:12 +0100 Subject: [PATCH] feat(matrix-manadeck-bot): add Matrix bot for card/deck management - Full NestJS bot with matrix-bot-sdk integration - Deck CRUD: list, create, view, delete decks - Card management: view cards and card details - AI generation: generate decks with AI (30 Mana) - Study sessions: start learning sessions - Progress tracking: due cards, statistics - Public features: featured decks, leaderboard - Credit system: mana balance display - German/English command aliases - Number-based reference system for decks and cards - JWT auth via mana-core-auth - Runs on port 3321 Co-Authored-By: Claude Opus 4.5 --- services/matrix-manadeck-bot/.env.example | 15 + services/matrix-manadeck-bot/.gitignore | 29 + services/matrix-manadeck-bot/CLAUDE.md | 225 ++++++ services/matrix-manadeck-bot/Dockerfile | 41 ++ services/matrix-manadeck-bot/nest-cli.json | 5 + services/matrix-manadeck-bot/package.json | 27 + .../matrix-manadeck-bot/src/app.module.ts | 21 + .../matrix-manadeck-bot/src/bot/bot.module.ts | 11 + .../src/bot/matrix.service.ts | 656 ++++++++++++++++++ .../src/config/configuration.ts | 63 ++ .../src/health.controller.ts | 9 + services/matrix-manadeck-bot/src/main.ts | 10 + .../src/manadeck/manadeck.module.ts | 8 + .../src/manadeck/manadeck.service.ts | 221 ++++++ .../src/session/session.module.ts | 8 + .../src/session/session.service.ts | 90 +++ services/matrix-manadeck-bot/tsconfig.json | 22 + 17 files changed, 1461 insertions(+) create mode 100644 services/matrix-manadeck-bot/.env.example create mode 100644 services/matrix-manadeck-bot/.gitignore create mode 100644 services/matrix-manadeck-bot/CLAUDE.md create mode 100644 services/matrix-manadeck-bot/Dockerfile create mode 100644 services/matrix-manadeck-bot/nest-cli.json create mode 100644 services/matrix-manadeck-bot/package.json create mode 100644 services/matrix-manadeck-bot/src/app.module.ts create mode 100644 services/matrix-manadeck-bot/src/bot/bot.module.ts create mode 100644 services/matrix-manadeck-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-manadeck-bot/src/config/configuration.ts create mode 100644 services/matrix-manadeck-bot/src/health.controller.ts create mode 100644 services/matrix-manadeck-bot/src/main.ts create mode 100644 services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts create mode 100644 services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts create mode 100644 services/matrix-manadeck-bot/src/session/session.module.ts create mode 100644 services/matrix-manadeck-bot/src/session/session.service.ts create mode 100644 services/matrix-manadeck-bot/tsconfig.json diff --git a/services/matrix-manadeck-bot/.env.example b/services/matrix-manadeck-bot/.env.example new file mode 100644 index 000000000..f0db1cfcf --- /dev/null +++ b/services/matrix-manadeck-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3321 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#manadeck:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# ManaDeck Backend +MANADECK_BACKEND_URL=http://localhost:3009 +MANADECK_API_PREFIX=/api + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-manadeck-bot/.gitignore b/services/matrix-manadeck-bot/.gitignore new file mode 100644 index 000000000..2d508e5ec --- /dev/null +++ b/services/matrix-manadeck-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-manadeck-bot/CLAUDE.md b/services/matrix-manadeck-bot/CLAUDE.md new file mode 100644 index 000000000..17c763312 --- /dev/null +++ b/services/matrix-manadeck-bot/CLAUDE.md @@ -0,0 +1,225 @@ +# Matrix ManaDeck Bot - Claude Code Guidelines + +## Overview + +Matrix ManaDeck Bot provides card/deck management via Matrix chat. It integrates with the ManaDeck backend for full CRUD operations, AI deck generation, study sessions, and spaced repetition progress tracking. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: ManaDeck API (port 3009) +- **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-manadeck-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3321) +│ ├── 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 +│ ├── manadeck/ +│ │ ├── manadeck.module.ts +│ │ └── manadeck.service.ts # ManaDeck 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 | + +### Deck Management + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!decks` | liste | List all decks | +| `!deck [nr]` | details | Show deck details | +| `!neu Titel` | new, create | Create new deck (10 Mana) | +| `!loeschen [nr]` | delete | Delete deck | + +### Card Management + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!karten [nr]` | cards | List cards in deck | +| `!karte [deck-nr] [card-nr]` | card | Show card details | + +### AI Generation + +| Command | Options | Description | +|---------|---------|-------------| +| `!generate Thema` | generieren, gen | Generate deck with AI (30 Mana) | +| `--count N` | - | Number of cards (1-50) | +| `--type TYPE` | - | flashcard, quiz, text, mixed | +| `--difficulty LEVEL` | - | beginner, intermediate, advanced | + +### Learning & Progress + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!lernen [nr]` | study | Start study session | +| `!faellig` | due | Show due cards | +| `!stats` | statistik | Learning statistics | +| `!mana` | credits, guthaben | Show mana balance | + +### Public Features + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!featured` | empfohlen | Show featured decks | +| `!leaderboard` | rangliste | Show top 10 users | + +## Example Usage + +``` +# Login +!login max@example.com mypassword + +# Create a deck +!neu Spanisch Vokabeln | Grundwortschatz + +# Generate deck with AI +!generate Deutsche Geschichte --count 20 --type flashcard + +# List decks +!decks + +# View cards +!karten 1 + +# Start studying +!lernen 1 + +# Check due cards +!faellig + +# Check mana balance +!mana +``` + +## Card Types + +| Type | Content Structure | +|------|-------------------| +| `text` | `{ text, formatting? }` | +| `flashcard` | `{ front, back, hint? }` | +| `quiz` | `{ question, options[], correctAnswer, explanation? }` | +| `mixed` | `{ sections: Array }` | + +## Credit Costs (Mana) + +| Operation | Cost | +|-----------|------| +| Deck Creation | 10 Mana | +| Card Creation | 2 Mana | +| AI Card Generation | 5 Mana | +| AI Deck Generation | 30 Mana | +| Deck Export | 3 Mana | + +## Environment Variables + +```env +# Server +PORT=3321 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#manadeck:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# ManaDeck Backend +MANADECK_BACKEND_URL=http://localhost:3009 +MANADECK_API_PREFIX=/api + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-manadeck-bot/Dockerfile -t matrix-manadeck-bot services/matrix-manadeck-bot + +# Run +docker run -p 3321:3321 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e MANADECK_BACKEND_URL=http://manadeck-backend:3009 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-manadeck-bot-data:/app/data \ + matrix-manadeck-bot +``` + +## Health Check + +```bash +curl http://localhost:3321/health +``` + +## ManaDeck Backend API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/public/health` | GET | Health check | +| `/public/featured-decks` | GET | Featured decks | +| `/public/leaderboard` | GET | Leaderboard | +| `/api/decks` | GET | List user's decks | +| `/api/decks` | POST | Create deck | +| `/api/decks/:id` | GET | Get deck details | +| `/api/decks/:id` | DELETE | Delete deck | +| `/api/decks/:id/cards` | GET | Get cards in deck | +| `/api/cards/:id` | GET | Get card details | +| `/api/decks/generate` | POST | AI generate deck | +| `/api/study-sessions` | POST | Start study session | +| `/api/progress/due` | GET | Get due cards | +| `/api/stats` | GET | Get user stats | +| `/api/credits/balance` | GET | Get mana balance | + +## Number-Based Reference System + +The bot uses a number-based reference system for ease of use: +1. User runs `!decks` to get a list of decks +2. Bot stores the list internally for the user +3. User can reference decks by their list number +4. Numbers are valid until the user runs a new list command + +Similarly for cards: +1. User runs `!karten [deck-nr]` to get cards +2. Cards can be referenced by `!karte [deck-nr] [card-nr]` + +This allows simple commands like: +- `!deck 3` - Show details for deck #3 +- `!karten 1` - Show cards in deck #1 +- `!karte 1 5` - Show card #5 in deck #1 +- `!lernen 2` - Start study session for deck #2 diff --git a/services/matrix-manadeck-bot/Dockerfile b/services/matrix-manadeck-bot/Dockerfile new file mode 100644 index 000000000..e3ae29577 --- /dev/null +++ b/services/matrix-manadeck-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 3321 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3321/health || exit 1 + +# Start the application +CMD ["node", "dist/main.js"] diff --git a/services/matrix-manadeck-bot/nest-cli.json b/services/matrix-manadeck-bot/nest-cli.json new file mode 100644 index 000000000..68d1974c4 --- /dev/null +++ b/services/matrix-manadeck-bot/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/services/matrix-manadeck-bot/package.json b/services/matrix-manadeck-bot/package.json new file mode 100644 index 000000000..de4d2ce3f --- /dev/null +++ b/services/matrix-manadeck-bot/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mana-bots/matrix-manadeck-bot", + "version": "1.0.0", + "description": "Matrix bot for ManaDeck card/deck 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-manadeck-bot/src/app.module.ts b/services/matrix-manadeck-bot/src/app.module.ts new file mode 100644 index 000000000..76f39ffd8 --- /dev/null +++ b/services/matrix-manadeck-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 { ManadeckModule } from './manadeck/manadeck.module'; +import { SessionModule } from './session/session.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ManadeckModule, + SessionModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-manadeck-bot/src/bot/bot.module.ts b/services/matrix-manadeck-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..8878a4a3c --- /dev/null +++ b/services/matrix-manadeck-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { ManadeckModule } from '../manadeck/manadeck.module'; +import { SessionModule } from '../session/session.module'; + +@Module({ + imports: [ManadeckModule, SessionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-manadeck-bot/src/bot/matrix.service.ts b/services/matrix-manadeck-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..fd2d7c19c --- /dev/null +++ b/services/matrix-manadeck-bot/src/bot/matrix.service.ts @@ -0,0 +1,656 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, +} from 'matrix-bot-sdk'; +import { ManadeckService, Deck, Card } from '../manadeck/manadeck.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 decks/cards per user for reference by number + private lastDecksList: Map = new Map(); + private lastCardsList: Map = new Map(); + + constructor( + private configService: ConfigService, + private manadeckService: ManadeckService, + 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 ManaDeck 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; + + // Check allowed rooms + 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; + + case 'decks': + case 'liste': + await this.handleListDecks(roomId, sender); + break; + + 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 'karten': + case 'cards': + await this.handleListCards(roomId, sender, args[0]); + break; + + case 'karte': + case 'card': + await this.handleCardDetails(roomId, sender, args[0], args[1]); + break; + + case 'generate': + case 'gen': + case 'generieren': + await this.handleGenerate(roomId, sender, argString); + break; + + case 'lernen': + case 'study': + await this.handleStartStudy(roomId, sender, args[0]); + break; + + case 'faellig': + case 'due': + await this.handleDueCards(roomId, sender); + break; + + case 'stats': + case 'statistik': + await this.handleStats(roomId, sender); + break; + + case 'mana': + case 'credits': + case 'guthaben': + await this.handleCredits(roomId, sender); + break; + + case 'featured': + case 'empfohlen': + await this.handleFeatured(roomId); + break; + + case 'leaderboard': + case 'rangliste': + await this.handleLeaderboard(roomId); + 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.manadeckService.checkHealth(); + const loggedIn = this.sessionService.isLoggedIn(sender); + const sessions = this.sessionService.getSessionCount(); + + await this.sendHtml( + roomId, + `

ManaDeck 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.manadeckService.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 Decks vorhanden. Erstelle eines mit !neu Titel

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

Deine Decks

    '; + for (const deck of decks) { + const cardInfo = deck.cardCount !== undefined ? ` (${deck.cardCount} Karten)` : ''; + const tags = deck.tags?.length ? ` [${deck.tags.join(', ')}]` : ''; + html += `
  1. ${deck.title}${cardInfo}${tags}
  2. `; + } + html += '
'; + html += '

Nutze !deck [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 !decks

' + ); + return; + } + + const result = await this.manadeckService.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 += '
    '; + html += `
  • Karten: ${d.cardCount || 0}
  • `; + html += `
  • Oeffentlich: ${d.isPublic ? 'Ja' : 'Nein'}
  • `; + if (d.tags?.length) html += `
  • Tags: ${d.tags.join(', ')}
  • `; + html += `
  • Erstellt: ${new Date(d.createdAt).toLocaleDateString('de-DE')}
  • `; + html += '
'; + html += `

Nutze !karten ${numberStr} um Karten zu sehen

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

Verwendung: !neu Titel [Beschreibung]

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

Fehler: ${result.error}

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

Deck ${result.data!.title} erstellt! (10 Mana verbraucht)

` + ); + } + + 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 !decks

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

Fehler: ${result.error}

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

Deck ${deck.title} geloescht.

`); + } + + // Card handlers + private async handleListCards(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 !decks

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

Fehler: ${result.error}

`); + return; + } + + const cards = result.data || []; + this.lastCardsList.set(sender, { deckId: deck.id, cards }); + + if (cards.length === 0) { + await this.sendHtml( + roomId, + `

Keine Karten in ${deck.title}.

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

Karten in "${deck.title}"

    `; + for (const card of cards) { + const title = card.title || this.getCardPreview(card); + const fav = card.isFavorite ? ' ⭐' : ''; + html += `
  1. ${card.cardType}: ${title}${fav}
  2. `; + } + html += '
'; + html += `

Nutze !karte ${numberStr} [nr] fuer Details

`; + + await this.sendHtml(roomId, html); + } + + private async handleCardDetails( + roomId: string, + sender: string, + deckNumStr: string, + cardNumStr: string + ) { + const token = this.requireAuth(sender); + + // Try to get from cache first + let cachedCards = this.lastCardsList.get(sender); + const deck = this.getDeckByNumber(sender, deckNumStr); + + if (!deck) { + await this.sendHtml( + roomId, + '

Ungueltige Deck-Nummer. Nutze zuerst !decks

' + ); + return; + } + + // Refresh cards if needed + if (!cachedCards || cachedCards.deckId !== deck.id) { + const result = await this.manadeckService.getCards(token, deck.id); + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + cachedCards = { deckId: deck.id, cards: result.data || [] }; + this.lastCardsList.set(sender, cachedCards); + } + + const cardIndex = parseInt(cardNumStr, 10) - 1; + if (isNaN(cardIndex) || cardIndex < 0 || cardIndex >= cachedCards.cards.length) { + await this.sendHtml( + roomId, + `

Ungueltige Kartennummer. Nutze !karten ${deckNumStr}

` + ); + return; + } + + const card = cachedCards.cards[cardIndex]; + let html = `

Karte #${cardNumStr}

`; + html += `

Typ: ${card.cardType}

`; + + switch (card.cardType) { + case 'flashcard': + html += `

Vorderseite: ${card.content.front}

`; + html += `

Rueckseite: ${card.content.back}

`; + if (card.content.hint) html += `

Hinweis: ${card.content.hint}

`; + break; + case 'quiz': + html += `

Frage: ${card.content.question}

`; + html += '

Optionen:

    '; + for (const opt of card.content.options || []) { + html += `
  1. ${opt}
  2. `; + } + html += '
'; + html += `

Richtig: Option ${(card.content.correctAnswer || 0) + 1}

`; + break; + case 'text': + html += `

${card.content.text}

`; + break; + default: + html += `
${JSON.stringify(card.content, null, 2)}
`; + } + + await this.sendHtml(roomId, html); + } + + // AI generation + private async handleGenerate(roomId: string, sender: string, argString: string) { + const token = this.requireAuth(sender); + + // Parse options from argString + const options: any = {}; + let prompt = argString; + + // Extract --count N + const countMatch = prompt.match(/--count\s+(\d+)/i); + if (countMatch) { + options.cardCount = Math.min(50, Math.max(1, parseInt(countMatch[1], 10))); + prompt = prompt.replace(countMatch[0], '').trim(); + } + + // Extract --type TYPE + const typeMatch = prompt.match(/--type\s+(flashcard|quiz|text|mixed)/i); + if (typeMatch) { + options.cardTypes = [typeMatch[1].toLowerCase()]; + prompt = prompt.replace(typeMatch[0], '').trim(); + } + + // Extract --difficulty LEVEL + const diffMatch = prompt.match(/--difficulty\s+(beginner|intermediate|advanced)/i); + if (diffMatch) { + options.difficulty = diffMatch[1].toLowerCase(); + prompt = prompt.replace(diffMatch[0], '').trim(); + } + + if (!prompt) { + await this.sendHtml( + roomId, + '

Verwendung: !generate Thema [--count 10] [--type flashcard] [--difficulty intermediate]

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

Generiere Deck mit AI... (30 Mana)

'); + + const result = await this.manadeckService.generateDeck(token, prompt, options); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const { deck, cards } = result.data!; + await this.sendHtml( + roomId, + `

Deck ${deck.title} mit ${cards.length} Karten erstellt!

+

Nutze !decks um deine Decks zu sehen.

` + ); + } + + // Study + private async handleStartStudy(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 !decks

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

Fehler: ${result.error}

`); + return; + } + + const session = result.data!; + await this.sendHtml( + roomId, + `

Lernsession fuer ${deck.title} gestartet!

+

${session.totalCards} Karten zu lernen.

+

Oeffne die ManaDeck App um mit dem Lernen zu beginnen.

` + ); + } + + private async handleDueCards(roomId: string, sender: string) { + const token = this.requireAuth(sender); + const result = await this.manadeckService.getDueCards(token); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine faelligen Karten! Gut gemacht!

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

${dueCards.length} Karten sind faellig.

+

Oeffne die ManaDeck App um sie zu wiederholen.

` + ); + } + + // Stats + private async handleStats(roomId: string, sender: string) { + const token = this.requireAuth(sender); + const result = await this.manadeckService.getStats(token); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const stats = result.data!; + await this.sendHtml( + roomId, + `

Deine Statistiken

+
    +
  • Decks: ${stats.totalDecks || 0}
  • +
  • Karten: ${stats.totalCards || 0}
  • +
  • Sessions: ${stats.totalSessions || 0}
  • +
  • Streak: ${stats.streakDays || 0} Tage
  • +
  • Genauigkeit: ${stats.averageAccuracy?.toFixed(1) || 0}%
  • +
` + ); + } + + private async handleCredits(roomId: string, sender: string) { + const token = this.requireAuth(sender); + const result = await this.manadeckService.getCredits(token); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Dein Mana-Guthaben: ${result.data!.balance}

+

Kosten: Deck erstellen (10), AI-Generierung (30)

` + ); + } + + // Public endpoints + private async handleFeatured(roomId: string) { + const result = await this.manadeckService.getFeaturedDecks(); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine empfohlenen Decks verfuegbar.

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

Empfohlene Decks

    '; + for (const deck of decks) { + const cardInfo = deck.cardCount !== undefined ? ` (${deck.cardCount} Karten)` : ''; + html += `
  1. ${deck.title}${cardInfo}
  2. `; + } + html += '
'; + + await this.sendHtml(roomId, html); + } + + private async handleLeaderboard(roomId: string) { + const result = await this.manadeckService.getLeaderboard(10); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Noch keine Eintraege in der Rangliste.

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

Rangliste (Top 10)

    '; + for (const user of users) { + html += `
  1. ${user.totalWins || 0} Siege - ${user.streakDays || 0} Tage Streak
  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 getCardPreview(card: Card): string { + if (card.content.front) return card.content.front.substring(0, 50); + if (card.content.question) return card.content.question.substring(0, 50); + if (card.content.text) return card.content.text.substring(0, 50); + return '(keine Vorschau)'; + } +} diff --git a/services/matrix-manadeck-bot/src/config/configuration.ts b/services/matrix-manadeck-bot/src/config/configuration.ts new file mode 100644 index 000000000..c795dd039 --- /dev/null +++ b/services/matrix-manadeck-bot/src/config/configuration.ts @@ -0,0 +1,63 @@ +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3321, + 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', + }, + manadeck: { + backendUrl: process.env.MANADECK_BACKEND_URL || 'http://localhost:3009', + apiPrefix: process.env.MANADECK_API_PREFIX || '/api', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, +}); + +export const HELP_MESSAGE = `

ManaDeck Bot - Befehle

+ +

Authentifizierung

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

Decks verwalten

+
    +
  • !decks - Alle Decks auflisten
  • +
  • !deck [nr] - Deck-Details anzeigen
  • +
  • !neu Titel - Neues Deck erstellen (10 Mana)
  • +
  • !loeschen [nr] - Deck loeschen
  • +
+ +

Karten

+
    +
  • !karten [nr] - Karten eines Decks anzeigen
  • +
  • !karte [deck-nr] [karten-nr] - Kartendetails
  • +
+ +

AI-Generierung

+
    +
  • !generate Thema - Deck mit AI generieren (30 Mana)
  • +
  • !generate Thema --count 10 - Mit Kartenanzahl
  • +
  • !generate Thema --type flashcard - Mit Kartentyp
  • +
+ +

Lernen

+
    +
  • !lernen [nr] - Lernsession starten
  • +
  • !faellig - Faellige Karten anzeigen
  • +
  • !stats - Lernstatistiken
  • +
+ +

Weiteres

+
    +
  • !mana - Mana-Guthaben anzeigen
  • +
  • !featured - Empfohlene Decks
  • +
  • !leaderboard - Rangliste
  • +
  • !help - Diese Hilfe anzeigen
  • +
+ +

Tipp: Nutze Deck-/Kartennummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-manadeck-bot/src/health.controller.ts b/services/matrix-manadeck-bot/src/health.controller.ts new file mode 100644 index 000000000..bbb5af072 --- /dev/null +++ b/services/matrix-manadeck-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-manadeck-bot' }; + } +} diff --git a/services/matrix-manadeck-bot/src/main.ts b/services/matrix-manadeck-bot/src/main.ts new file mode 100644 index 000000000..782ee2ded --- /dev/null +++ b/services/matrix-manadeck-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 || 3321; + await app.listen(port); + console.log(`Matrix ManaDeck Bot running on port ${port}`); +} +bootstrap(); diff --git a/services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts b/services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts new file mode 100644 index 000000000..c97b32cac --- /dev/null +++ b/services/matrix-manadeck-bot/src/manadeck/manadeck.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ManadeckService } from './manadeck.service'; + +@Module({ + providers: [ManadeckService], + exports: [ManadeckService], +}) +export class ManadeckModule {} diff --git a/services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts b/services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts new file mode 100644 index 000000000..9a17ca86c --- /dev/null +++ b/services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts @@ -0,0 +1,221 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface Deck { + id: string; + title: string; + description?: string; + coverImageUrl?: string; + isPublic: boolean; + isFeatured: boolean; + tags: string[]; + cardCount?: number; + createdAt: string; + updatedAt: string; +} + +export interface Card { + id: string; + deckId: string; + position: number; + title?: string; + content: any; + cardType: 'text' | 'flashcard' | 'quiz' | 'mixed'; + isFavorite: boolean; +} + +export interface StudySession { + id: string; + deckId: string; + mode: string; + totalCards: number; + completedCards: number; + correctCards: number; + startedAt: string; + completedAt?: string; +} + +export interface UserStats { + totalDecks: number; + totalCards: number; + totalSessions: number; + streakDays: number; + averageAccuracy: number; +} + +export interface CardProgress { + cardId: string; + status: string; + nextReview: string; + interval: number; + easeFactor: number; +} + +@Injectable() +export class ManadeckService { + private readonly logger = new Logger(ManadeckService.name); + private backendUrl: string; + private apiPrefix: string; + + constructor(private configService: ConfigService) { + this.backendUrl = this.configService.get('manadeck.backendUrl') || 'http://localhost:3009'; + this.apiPrefix = this.configService.get('manadeck.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}/public${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 deleteDeck(token: string, deckId: string): Promise<{ error?: string }> { + return this.request(token, `/decks/${deckId}`, { method: 'DELETE' }); + } + + // Card operations + async getCards(token: string, deckId: string): Promise<{ data?: Card[]; error?: string }> { + return this.request(token, `/decks/${deckId}/cards`); + } + + async getCard(token: string, cardId: string): Promise<{ data?: Card; error?: string }> { + return this.request(token, `/cards/${cardId}`); + } + + // AI generation + async generateDeck( + token: string, + prompt: string, + options: { + deckTitle?: string; + cardCount?: number; + cardTypes?: string[]; + difficulty?: string; + } = {} + ): Promise<{ data?: { deck: Deck; cards: Card[] }; error?: string }> { + return this.request<{ deck: Deck; cards: Card[] }>(token, '/decks/generate', { + method: 'POST', + body: JSON.stringify({ + prompt, + deckTitle: options.deckTitle || prompt.substring(0, 50), + cardCount: options.cardCount || 10, + cardTypes: options.cardTypes || ['flashcard'], + difficulty: options.difficulty || 'intermediate', + }), + }); + } + + // Study sessions + async startStudySession( + token: string, + deckId: string, + mode: string = 'all' + ): Promise<{ data?: StudySession; error?: string }> { + return this.request(token, '/study-sessions', { + method: 'POST', + body: JSON.stringify({ deckId, mode }), + }); + } + + async getStudySessions(token: string): Promise<{ data?: StudySession[]; error?: string }> { + return this.request(token, '/study-sessions'); + } + + // Progress + async getDueCards(token: string): Promise<{ data?: CardProgress[]; error?: string }> { + return this.request(token, '/progress/due'); + } + + async getProgressStats(token: string): Promise<{ data?: any; error?: string }> { + return this.request(token, '/progress/stats'); + } + + // User stats + async getStats(token: string): Promise<{ data?: UserStats; error?: string }> { + return this.request(token, '/stats'); + } + + async getCredits(token: string): Promise<{ data?: { balance: number }; error?: string }> { + return this.request<{ balance: number }>(token, '/credits/balance'); + } + + // Public endpoints + async getFeaturedDecks(): Promise<{ data?: Deck[]; error?: string }> { + return this.publicRequest('/featured-decks'); + } + + async getLeaderboard(limit: number = 10): Promise<{ data?: any[]; error?: string }> { + return this.publicRequest(`/leaderboard?limit=${limit}`); + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.backendUrl}/public/health`); + return response.ok; + } catch { + return false; + } + } +} diff --git a/services/matrix-manadeck-bot/src/session/session.module.ts b/services/matrix-manadeck-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-manadeck-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-manadeck-bot/src/session/session.service.ts b/services/matrix-manadeck-bot/src/session/session.service.ts new file mode 100644 index 000000000..f1bed7852 --- /dev/null +++ b/services/matrix-manadeck-bot/src/session/session.service.ts @@ -0,0 +1,90 @@ +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' }; + } + + // Store session (7 days expiry) + 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-manadeck-bot/tsconfig.json b/services/matrix-manadeck-bot/tsconfig.json new file mode 100644 index 000000000..94f1e9493 --- /dev/null +++ b/services/matrix-manadeck-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 + } +}