From 3ed1453ff4d70ce0b484d9c60d55b2859e9e04e6 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:00:01 +0100 Subject: [PATCH] feat(matrix-skilltree-bot): add Matrix bot for skill tree and XP management - Skill management: create, list, view details, delete - XP tracking with level-up notifications - Branch filtering (intellect, body, creativity, social, practical, mindset, custom) - Activity history per skill or global - User statistics (total XP, skill count, highest level, streak) - German/English command aliases - Number-based reference system for ease of use - JWT auth via mana-core-auth - Health check endpoint on port 3326 Co-Authored-By: Claude Opus 4.5 --- services/matrix-skilltree-bot/.env.example | 15 + services/matrix-skilltree-bot/.gitignore | 29 + services/matrix-skilltree-bot/CLAUDE.md | 207 +++++++ services/matrix-skilltree-bot/Dockerfile | 41 ++ services/matrix-skilltree-bot/nest-cli.json | 5 + services/matrix-skilltree-bot/package.json | 27 + .../matrix-skilltree-bot/src/app.module.ts | 21 + .../src/bot/bot.module.ts | 11 + .../src/bot/matrix.service.ts | 561 ++++++++++++++++++ .../src/config/configuration.ts | 61 ++ .../src/health.controller.ts | 9 + services/matrix-skilltree-bot/src/main.ts | 10 + .../src/session/session.module.ts | 8 + .../src/session/session.service.ts | 86 +++ .../src/skilltree/skilltree.module.ts | 8 + .../src/skilltree/skilltree.service.ts | 151 +++++ services/matrix-skilltree-bot/tsconfig.json | 22 + 17 files changed, 1272 insertions(+) create mode 100644 services/matrix-skilltree-bot/.env.example create mode 100644 services/matrix-skilltree-bot/.gitignore create mode 100644 services/matrix-skilltree-bot/CLAUDE.md create mode 100644 services/matrix-skilltree-bot/Dockerfile create mode 100644 services/matrix-skilltree-bot/nest-cli.json create mode 100644 services/matrix-skilltree-bot/package.json create mode 100644 services/matrix-skilltree-bot/src/app.module.ts create mode 100644 services/matrix-skilltree-bot/src/bot/bot.module.ts create mode 100644 services/matrix-skilltree-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-skilltree-bot/src/config/configuration.ts create mode 100644 services/matrix-skilltree-bot/src/health.controller.ts create mode 100644 services/matrix-skilltree-bot/src/main.ts create mode 100644 services/matrix-skilltree-bot/src/session/session.module.ts create mode 100644 services/matrix-skilltree-bot/src/session/session.service.ts create mode 100644 services/matrix-skilltree-bot/src/skilltree/skilltree.module.ts create mode 100644 services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts create mode 100644 services/matrix-skilltree-bot/tsconfig.json diff --git a/services/matrix-skilltree-bot/.env.example b/services/matrix-skilltree-bot/.env.example new file mode 100644 index 000000000..a86d0511c --- /dev/null +++ b/services/matrix-skilltree-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3326 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#skilltree:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Skilltree Backend +SKILLTREE_BACKEND_URL=http://localhost:3024 +SKILLTREE_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-skilltree-bot/.gitignore b/services/matrix-skilltree-bot/.gitignore new file mode 100644 index 000000000..2d508e5ec --- /dev/null +++ b/services/matrix-skilltree-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-skilltree-bot/CLAUDE.md b/services/matrix-skilltree-bot/CLAUDE.md new file mode 100644 index 000000000..e5d95b18c --- /dev/null +++ b/services/matrix-skilltree-bot/CLAUDE.md @@ -0,0 +1,207 @@ +# Matrix Skilltree Bot - Claude Code Guidelines + +## Overview + +Matrix Skilltree Bot provides skill tree and XP management via Matrix chat. It integrates with the Skilltree backend for skill CRUD, XP tracking, leveling, and activity history. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: Skilltree API (port 3024) +- **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-skilltree-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3326) +│ ├── 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 +│ ├── skilltree/ +│ │ ├── skilltree.module.ts +│ │ └── skilltree.service.ts # Skilltree 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 | + +### Skill Management + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!skills` | liste, faehigkeiten | List all skills | +| `!skills koerper` | - | Filter by branch | +| `!skill [nr]` | details | Show skill details | +| `!neu Name \| Branch` | new, create | Create skill | +| `!loeschen [nr]` | delete | Delete skill | + +### XP Tracking + +| Command | Options | Description | +|---------|---------|-------------| +| `!xp [nr] 50 Aktivitaet` | punkte | Add XP to skill | +| `--min N` | - | Optional duration in minutes | + +### Statistics + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!stats` | statistik | Show user statistics | +| `!aktivitaeten` | activities, verlauf | Recent activities | +| `!aktivitaeten [nr]` | - | Activities for skill | + +## Skill Branches + +| Branch | German | Icon | Description | +|--------|--------|------|-------------| +| `intellect` | wissen, gehirn | 🧠 | Knowledge, languages, science | +| `body` | koerper, fitness | 💪 | Fitness, sports, health | +| `creativity` | kreativ, kunst | 🎨 | Art, music, writing | +| `social` | sozial | 👥 | Communication, leadership | +| `practical` | praktisch, handwerk | 🔧 | Crafts, cooking, tech | +| `mindset` | achtsamkeit, mental | 💖 | Meditation, focus | +| `custom` | eigene | ⭐ | User-defined | + +## Level System + +| Level | Name | XP Required | +|-------|------|-------------| +| 0 | Unbekannt | 0 | +| 1 | Anfaenger | 100 | +| 2 | Fortgeschritten | 500 | +| 3 | Kompetent | 1,500 | +| 4 | Experte | 4,000 | +| 5 | Meister | 10,000 | + +## Example Usage + +``` +# Login +!login max@example.com mypassword + +# Create a skill +!neu Spanisch | intellect +!neu Joggen | body | Taegliches Lauftraining + +# List skills +!skills + +# Add XP +!xp 1 100 Vokabeln gelernt +!xp 2 50 30min Joggen --min 30 + +# View skill details +!skill 1 + +# View stats +!stats + +# View activities +!aktivitaeten +!aktivitaeten 1 + +# Delete skill +!loeschen 1 +``` + +## Environment Variables + +```env +# Server +PORT=3326 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#skilltree:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Skilltree Backend +SKILLTREE_BACKEND_URL=http://localhost:3024 +SKILLTREE_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-skilltree-bot/Dockerfile -t matrix-skilltree-bot services/matrix-skilltree-bot + +# Run +docker run -p 3326:3326 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e SKILLTREE_BACKEND_URL=http://skilltree-backend:3024 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-skilltree-bot-data:/app/data \ + matrix-skilltree-bot +``` + +## Health Check + +```bash +curl http://localhost:3326/health +``` + +## Skilltree Backend API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/api/v1/skills` | GET | List skills | +| `/api/v1/skills` | POST | Create skill | +| `/api/v1/skills/:id` | GET | Get skill details | +| `/api/v1/skills/:id` | DELETE | Delete skill | +| `/api/v1/skills/:id/xp` | POST | Add XP to skill | +| `/api/v1/skills/stats` | GET | Get user statistics | +| `/api/v1/activities` | GET | List activities | +| `/api/v1/activities/recent` | GET | Recent activities | +| `/api/v1/activities/skill/:id` | GET | Skill activities | + +## Number-Based Reference System + +The bot uses a number-based reference system for ease of use: +1. User runs `!skills` to get a list of skills +2. Bot stores the list internally for the user +3. User can reference skills by their list number +4. Numbers are valid until the user runs a new list command + +This allows simple commands like: +- `!skill 3` - Show details for skill #3 +- `!xp 1 100 Training` - Add 100 XP to skill #1 +- `!aktivitaeten 2` - Show activities for skill #2 diff --git a/services/matrix-skilltree-bot/Dockerfile b/services/matrix-skilltree-bot/Dockerfile new file mode 100644 index 000000000..a5b86c6dc --- /dev/null +++ b/services/matrix-skilltree-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 3326 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3326/health || exit 1 + +# Start the application +CMD ["node", "dist/main.js"] diff --git a/services/matrix-skilltree-bot/nest-cli.json b/services/matrix-skilltree-bot/nest-cli.json new file mode 100644 index 000000000..5c06bb8c3 --- /dev/null +++ b/services/matrix-skilltree-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-skilltree-bot/package.json b/services/matrix-skilltree-bot/package.json new file mode 100644 index 000000000..0938f308d --- /dev/null +++ b/services/matrix-skilltree-bot/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mana-bots/matrix-skilltree-bot", + "version": "1.0.0", + "description": "Matrix bot for skill tree and XP 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-skilltree-bot/src/app.module.ts b/services/matrix-skilltree-bot/src/app.module.ts new file mode 100644 index 000000000..f76ada916 --- /dev/null +++ b/services/matrix-skilltree-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 { SkilltreeModule } from './skilltree/skilltree.module'; +import { SessionModule } from './session/session.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + SkilltreeModule, + SessionModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-skilltree-bot/src/bot/bot.module.ts b/services/matrix-skilltree-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..bdf211146 --- /dev/null +++ b/services/matrix-skilltree-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { SkilltreeModule } from '../skilltree/skilltree.module'; +import { SessionModule } from '../session/session.module'; + +@Module({ + imports: [SkilltreeModule, SessionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-skilltree-bot/src/bot/matrix.service.ts b/services/matrix-skilltree-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..66123d2c4 --- /dev/null +++ b/services/matrix-skilltree-bot/src/bot/matrix.service.ts @@ -0,0 +1,561 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, +} from 'matrix-bot-sdk'; +import { SkilltreeService, Skill, SkillBranch } from '../skilltree/skilltree.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 skills per user for reference by number + private lastSkillsList: Map = new Map(); + + // Branch name mappings (German/English) + private readonly branchMappings: Record = { + intellect: 'intellect', + wissen: 'intellect', + gehirn: 'intellect', + body: 'body', + koerper: 'body', + fitness: 'body', + sport: 'body', + creativity: 'creativity', + kreativ: 'creativity', + kreativitaet: 'creativity', + kunst: 'creativity', + social: 'social', + sozial: 'social', + practical: 'practical', + praktisch: 'practical', + handwerk: 'practical', + mindset: 'mindset', + achtsamkeit: 'mindset', + mental: 'mindset', + custom: 'custom', + eigene: 'custom', + }; + + constructor( + private configService: ConfigService, + private skilltreeService: SkilltreeService, + 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 Skilltree 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; + + // Skill commands + case 'skills': + case 'liste': + case 'faehigkeiten': + await this.handleListSkills(roomId, sender, args[0]); + break; + + case 'skill': + case 'details': + await this.handleSkillDetails(roomId, sender, args[0]); + break; + + case 'neu': + case 'new': + case 'create': + await this.handleCreateSkill(roomId, sender, argString); + break; + + case 'loeschen': + case 'delete': + await this.handleDeleteSkill(roomId, sender, args[0]); + break; + + // XP commands + case 'xp': + case 'punkte': + await this.handleAddXp(roomId, sender, argString); + break; + + // Stats commands + case 'stats': + case 'statistik': + await this.handleStats(roomId, sender); + break; + + // Activity commands + case 'aktivitaeten': + case 'activities': + case 'verlauf': + await this.handleActivities(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.skilltreeService.checkHealth(); + const loggedIn = this.sessionService.isLoggedIn(sender); + const sessions = this.sessionService.getSessionCount(); + + await this.sendHtml( + roomId, + `

Skilltree Bot Status

+
    +
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • +
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • +
  • Aktive Sessions: ${sessions}
  • +
` + ); + } + + // Skill handlers + private async handleListSkills(roomId: string, sender: string, branchFilter?: string) { + const token = this.requireAuth(sender); + + let branch: string | undefined; + if (branchFilter) { + branch = this.branchMappings[branchFilter.toLowerCase()]; + if (!branch) { + await this.sendHtml( + roomId, + '

Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom

' + ); + return; + } + } + + const result = await this.skilltreeService.getSkills(token, branch); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine Skills vorhanden. Erstelle einen mit !neu Name | Branch

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

Deine Skills

    '; + for (const skill of skills) { + const levelName = this.getLevelName(skill.level); + const branchIcon = this.getBranchIcon(skill.branch); + const progress = this.getProgressBar(skill.totalXp, skill.level); + html += `
  1. ${branchIcon} ${skill.name} - Lvl ${skill.level} (${levelName}) ${progress}
  2. `; + } + html += '
'; + html += '

Nutze !skill [nr] fuer Details oder !xp [nr] 50 Aktivitaet

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

Ungueltige Nummer. Nutze zuerst !skills

'); + return; + } + + const result = await this.skilltreeService.getSkill(token, skill.id); + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const s = result.data!.skill; + const levelName = this.getLevelName(s.level); + const nextLevelXp = this.getNextLevelXp(s.level); + const branchIcon = this.getBranchIcon(s.branch); + + let html = `

${branchIcon} ${s.name}

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

${s.description}

`; + + html += '
    '; + html += `
  • Branch: ${this.translateBranch(s.branch)}
  • `; + html += `
  • Level: ${s.level} (${levelName})
  • `; + html += `
  • XP: ${s.totalXp.toLocaleString('de-DE')}`; + if (nextLevelXp) html += ` / ${nextLevelXp.toLocaleString('de-DE')} (naechstes Level)`; + html += '
  • '; + html += `
  • Erstellt: ${new Date(s.createdAt).toLocaleDateString('de-DE')}
  • `; + html += '
'; + + html += `

Nutze !xp ${numberStr} [xp] [aktivitaet] um XP hinzuzufuegen

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

Verwendung: !neu Name | Branch

Branches: intellect, body, creativity, social, practical, mindset, custom

' + ); + return; + } + + const token = this.requireAuth(sender); + const parts = input.split('|').map((s) => s.trim()); + const name = parts[0]; + const branchInput = parts[1]?.toLowerCase() || 'custom'; + + const branch = this.branchMappings[branchInput]; + if (!branch) { + await this.sendHtml( + roomId, + '

Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom

' + ); + return; + } + + const description = parts[2]; + + const result = await this.skilltreeService.createSkill(token, name, branch, description); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + this.lastSkillsList.delete(sender); + const branchIcon = this.getBranchIcon(branch); + await this.sendHtml( + roomId, + `

${branchIcon} Skill ${result.data!.skill.name} erstellt!

+

Nutze !skills und dann !xp [nr] [xp] [aktivitaet]

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

Ungueltige Nummer. Nutze zuerst !skills

'); + return; + } + + const result = await this.skilltreeService.deleteSkill(token, skill.id); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Skill ${skill.name} geloescht.

`); + } + + // XP handler + private async handleAddXp(roomId: string, sender: string, argString: string) { + const args = argString.split(/\s+/); + + if (args.length < 3) { + await this.sendHtml( + roomId, + '

Verwendung: !xp [nr] [xp] [aktivitaet]

Optional: --min 60 fuer Dauer

' + ); + return; + } + + const token = this.requireAuth(sender); + const skill = this.getSkillByNumber(sender, args[0]); + + if (!skill) { + await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !skills

'); + return; + } + + const xp = parseInt(args[1], 10); + if (isNaN(xp) || xp < 1 || xp > 10000) { + await this.sendHtml(roomId, '

XP muss zwischen 1 und 10000 liegen.

'); + return; + } + + // Parse duration (--min N) + let duration: number | undefined; + const minMatch = argString.match(/--min\s+(\d+)/i); + if (minMatch) { + duration = parseInt(minMatch[1], 10); + } + + // Get description (everything after xp number, minus --min part) + let description = args.slice(2).join(' '); + description = description.replace(/--min\s+\d+/i, '').trim(); + + if (!description) { + description = 'Aktivitaet'; + } + + const result = await this.skilltreeService.addXp(token, skill.id, xp, description, duration); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const { leveledUp, newLevel } = result.data!; + let html = `

+${xp} XP fuer ${skill.name}!

`; + html += `

${description}

`; + + if (leveledUp) { + const levelName = this.getLevelName(newLevel); + html += `

🎉 LEVEL UP! Du bist jetzt Level ${newLevel} (${levelName})!

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

Fehler: ${result.error}

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

Deine Statistiken

    '; + html += `
  • Gesamt-XP: ${stats.totalXp.toLocaleString('de-DE')}
  • `; + html += `
  • Skills: ${stats.totalSkills}
  • `; + html += `
  • Hoechstes Level: ${stats.highestLevel}
  • `; + html += `
  • Streak: ${stats.streakDays} Tage 🔥
  • `; + if (stats.lastActivityDate) { + html += `
  • Letzte Aktivitaet: ${stats.lastActivityDate}
  • `; + } + html += '
'; + + await this.sendHtml(roomId, html); + } + + // Activities handler + private async handleActivities(roomId: string, sender: string, numberStr?: string) { + const token = this.requireAuth(sender); + + let result; + let skillName = ''; + + if (numberStr) { + const skill = this.getSkillByNumber(sender, numberStr); + if (!skill) { + await this.sendHtml(roomId, '

Ungueltige Nummer. Nutze zuerst !skills

'); + return; + } + result = await this.skilltreeService.getSkillActivities(token, skill.id); + skillName = skill.name; + } else { + result = await this.skilltreeService.getRecentActivities(token, 10); + } + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

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

Keine Aktivitaeten vorhanden.

'); + return; + } + + const title = skillName ? `Aktivitaeten: ${skillName}` : 'Letzte Aktivitaeten'; + let html = `

${title}

    `; + + for (const activity of activities) { + const date = new Date(activity.timestamp).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const duration = activity.duration ? ` (${activity.duration} min)` : ''; + html += `
  1. +${activity.xpEarned} XP - ${activity.description}${duration}
    ${date}
  2. `; + } + html += '
'; + + await this.sendHtml(roomId, html); + } + + // Helper methods + private getSkillByNumber(sender: string, numberStr: string): Skill | null { + const skills = this.lastSkillsList.get(sender); + if (!skills) return null; + + const index = parseInt(numberStr, 10) - 1; + if (isNaN(index) || index < 0 || index >= skills.length) return null; + + return skills[index]; + } + + private getLevelName(level: number): string { + const names: Record = { + 0: 'Unbekannt', + 1: 'Anfaenger', + 2: 'Fortgeschritten', + 3: 'Kompetent', + 4: 'Experte', + 5: 'Meister', + }; + return names[level] || `Level ${level}`; + } + + private getNextLevelXp(level: number): number | null { + const thresholds: Record = { + 0: 100, + 1: 500, + 2: 1500, + 3: 4000, + 4: 10000, + }; + return thresholds[level] || null; + } + + private getBranchIcon(branch: string): string { + const icons: Record = { + intellect: '🧠', // Brain + body: '💪', // Flexed biceps + creativity: '🎨', // Artist palette + social: '👥', // Busts in silhouette + practical: '🔧', // Wrench + mindset: '💖', // Heart + custom: '⭐', // Star + }; + return icons[branch] || '⭐'; + } + + private translateBranch(branch: string): string { + const translations: Record = { + intellect: 'Wissen', + body: 'Koerper', + creativity: 'Kreativitaet', + social: 'Sozial', + practical: 'Praktisch', + mindset: 'Achtsamkeit', + custom: 'Eigene', + }; + return translations[branch] || branch; + } + + private getProgressBar(totalXp: number, level: number): string { + const nextXp = this.getNextLevelXp(level); + if (!nextXp) return ''; + + const prevXp = level > 0 ? this.getNextLevelXp(level - 1) || 0 : 0; + const progress = Math.min(100, Math.round(((totalXp - prevXp) / (nextXp - prevXp)) * 100)); + return `[${progress}%]`; + } +} diff --git a/services/matrix-skilltree-bot/src/config/configuration.ts b/services/matrix-skilltree-bot/src/config/configuration.ts new file mode 100644 index 000000000..7919d07bf --- /dev/null +++ b/services/matrix-skilltree-bot/src/config/configuration.ts @@ -0,0 +1,61 @@ +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3326, + 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', + }, + skilltree: { + backendUrl: process.env.SKILLTREE_BACKEND_URL || 'http://localhost:3024', + apiPrefix: process.env.SKILLTREE_API_PREFIX || '/api/v1', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, +}); + +export const HELP_MESSAGE = `

Skilltree Bot - Befehle

+ +

Authentifizierung

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

Skills

+
    +
  • !skills - Alle Skills auflisten
  • +
  • !skills koerper - Nach Branch filtern
  • +
  • !skill [nr] - Skill-Details anzeigen
  • +
  • !neu Name | Branch - Neuen Skill erstellen
  • +
  • !loeschen [nr] - Skill loeschen
  • +
+ +

XP sammeln

+
    +
  • !xp [nr] 50 Aktivitaet - XP hinzufuegen
  • +
  • !xp [nr] 100 Training --min 60 - Mit Dauer
  • +
+ +

Statistiken

+
    +
  • !stats - Gesamtstatistik anzeigen
  • +
  • !aktivitaeten - Letzte Aktivitaeten
  • +
  • !aktivitaeten [nr] - Aktivitaeten fuer Skill
  • +
+ +

Branches

+

intellect (Wissen), body/koerper (Fitness), creativity/kreativ (Kunst), social/sozial (Kommunikation), practical/praktisch (Handwerk), mindset (Achtsamkeit), custom (Eigene)

+ +

Level-System

+
    +
  • Level 1: 100 XP (Anfaenger)
  • +
  • Level 2: 500 XP (Fortgeschritten)
  • +
  • Level 3: 1500 XP (Kompetent)
  • +
  • Level 4: 4000 XP (Experte)
  • +
  • Level 5: 10000 XP (Meister)
  • +
+ +

Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-skilltree-bot/src/health.controller.ts b/services/matrix-skilltree-bot/src/health.controller.ts new file mode 100644 index 000000000..6251c68f4 --- /dev/null +++ b/services/matrix-skilltree-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-skilltree-bot' }; + } +} diff --git a/services/matrix-skilltree-bot/src/main.ts b/services/matrix-skilltree-bot/src/main.ts new file mode 100644 index 000000000..9fb8bfb9b --- /dev/null +++ b/services/matrix-skilltree-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 || 3326; + await app.listen(port); + console.log(`Matrix Skilltree Bot running on port ${port}`); +} +bootstrap(); diff --git a/services/matrix-skilltree-bot/src/session/session.module.ts b/services/matrix-skilltree-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-skilltree-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-skilltree-bot/src/session/session.service.ts b/services/matrix-skilltree-bot/src/session/session.service.ts new file mode 100644 index 000000000..504b9951d --- /dev/null +++ b/services/matrix-skilltree-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-skilltree-bot/src/skilltree/skilltree.module.ts b/services/matrix-skilltree-bot/src/skilltree/skilltree.module.ts new file mode 100644 index 000000000..19b777784 --- /dev/null +++ b/services/matrix-skilltree-bot/src/skilltree/skilltree.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SkilltreeService } from './skilltree.service'; + +@Module({ + providers: [SkilltreeService], + exports: [SkilltreeService], +}) +export class SkilltreeModule {} diff --git a/services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts b/services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts new file mode 100644 index 000000000..1eea796b3 --- /dev/null +++ b/services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type SkillBranch = 'intellect' | 'body' | 'creativity' | 'social' | 'practical' | 'mindset' | 'custom'; + +export interface Skill { + id: string; + name: string; + description?: string; + branch: SkillBranch; + parentId?: string; + icon: string; + color?: string; + currentXp: number; + totalXp: number; + level: number; + createdAt: string; + updatedAt: string; +} + +export interface Activity { + id: string; + skillId: string; + xpEarned: number; + description: string; + duration?: number; + timestamp: string; +} + +export interface UserStats { + totalXp: number; + totalSkills: number; + highestLevel: number; + streakDays: number; + lastActivityDate?: string; +} + +export interface AddXpResult { + skill: Skill; + leveledUp: boolean; + newLevel: number; +} + +@Injectable() +export class SkilltreeService { + private readonly logger = new Logger(SkilltreeService.name); + private backendUrl: string; + private apiPrefix: string; + + constructor(private configService: ConfigService) { + this.backendUrl = this.configService.get('skilltree.backendUrl') || 'http://localhost:3024'; + this.apiPrefix = this.configService.get('skilltree.apiPrefix') || '/api/v1'; + } + + 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' }; + } + } + + // Skill operations + async getSkills(token: string, branch?: string): Promise<{ data?: { skills: Skill[] }; error?: string }> { + const query = branch ? `?branch=${branch}` : ''; + return this.request<{ skills: Skill[] }>(token, `/skills${query}`); + } + + async getSkill(token: string, skillId: string): Promise<{ data?: { skill: Skill }; error?: string }> { + return this.request<{ skill: Skill }>(token, `/skills/${skillId}`); + } + + async createSkill( + token: string, + name: string, + branch: SkillBranch, + description?: string + ): Promise<{ data?: { skill: Skill }; error?: string }> { + return this.request<{ skill: Skill }>(token, '/skills', { + method: 'POST', + body: JSON.stringify({ name, branch, description }), + }); + } + + async deleteSkill(token: string, skillId: string): Promise<{ error?: string }> { + return this.request(token, `/skills/${skillId}`, { method: 'DELETE' }); + } + + async addXp( + token: string, + skillId: string, + xp: number, + description: string, + duration?: number + ): Promise<{ data?: AddXpResult; error?: string }> { + return this.request(token, `/skills/${skillId}/xp`, { + method: 'POST', + body: JSON.stringify({ xp, description, duration }), + }); + } + + // Stats + async getStats(token: string): Promise<{ data?: { stats: UserStats }; error?: string }> { + return this.request<{ stats: UserStats }>(token, '/skills/stats'); + } + + // Activities + async getActivities(token: string, limit?: number): Promise<{ data?: { activities: Activity[] }; error?: string }> { + const query = limit ? `?limit=${limit}` : ''; + return this.request<{ activities: Activity[] }>(token, `/activities${query}`); + } + + async getRecentActivities(token: string, limit?: number): Promise<{ data?: { activities: Activity[] }; error?: string }> { + const query = limit ? `?limit=${limit}` : ''; + return this.request<{ activities: Activity[] }>(token, `/activities/recent${query}`); + } + + async getSkillActivities(token: string, skillId: string): Promise<{ data?: { activities: Activity[] }; error?: string }> { + return this.request<{ activities: Activity[] }>(token, `/activities/skill/${skillId}`); + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.backendUrl}/health`); + return response.ok; + } catch { + return false; + } + } +} diff --git a/services/matrix-skilltree-bot/tsconfig.json b/services/matrix-skilltree-bot/tsconfig.json new file mode 100644 index 000000000..94f1e9493 --- /dev/null +++ b/services/matrix-skilltree-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 + } +}