mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
edbe7502d3
commit
3ed1453ff4
17 changed files with 1272 additions and 0 deletions
15
services/matrix-skilltree-bot/.env.example
Normal file
15
services/matrix-skilltree-bot/.env.example
Normal file
|
|
@ -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
|
||||
29
services/matrix-skilltree-bot/.gitignore
vendored
Normal file
29
services/matrix-skilltree-bot/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
207
services/matrix-skilltree-bot/CLAUDE.md
Normal file
207
services/matrix-skilltree-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
41
services/matrix-skilltree-bot/Dockerfile
Normal file
41
services/matrix-skilltree-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
5
services/matrix-skilltree-bot/nest-cli.json
Normal file
5
services/matrix-skilltree-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
27
services/matrix-skilltree-bot/package.json
Normal file
27
services/matrix-skilltree-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
21
services/matrix-skilltree-bot/src/app.module.ts
Normal file
21
services/matrix-skilltree-bot/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
11
services/matrix-skilltree-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-skilltree-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
561
services/matrix-skilltree-bot/src/bot/matrix.service.ts
Normal file
561
services/matrix-skilltree-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string, Skill[]> = new Map();
|
||||
|
||||
// Branch name mappings (German/English)
|
||||
private readonly branchMappings: Record<string, SkillBranch> = {
|
||||
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<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('matrix.storagePath');
|
||||
this.allowedRooms = this.configService.get<string[]>('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, '<p>Erfolgreich abgemeldet.</p>');
|
||||
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,
|
||||
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling command ${command}:`, error);
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
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 <code>!login email passwort</code>');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Auth handlers
|
||||
private async handleLogin(roomId: string, sender: string, args: string[]) {
|
||||
if (args.length < 2) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const [email, password] = args;
|
||||
const result = await this.sessionService.login(sender, email, password);
|
||||
|
||||
if (result.success) {
|
||||
await this.sendHtml(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
|
||||
} else {
|
||||
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
`<h3>Skilltree Bot Status</h3>
|
||||
<ul>
|
||||
<li>Backend: ${backendOk ? 'Online' : 'Offline'}</li>
|
||||
<li>Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}</li>
|
||||
<li>Aktive Sessions: ${sessions}</li>
|
||||
</ul>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
'<p>Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.skilltreeService.getSkills(token, branch);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const skills = result.data?.skills || [];
|
||||
this.lastSkillsList.set(sender, skills);
|
||||
|
||||
if (skills.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Keine Skills vorhanden. Erstelle einen mit <code>!neu Name | Branch</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Deine Skills</h3><ol>';
|
||||
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 += `<li>${branchIcon} <strong>${skill.name}</strong> - Lvl ${skill.level} (${levelName}) ${progress}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
html += '<p><em>Nutze <code>!skill [nr]</code> fuer Details oder <code>!xp [nr] 50 Aktivitaet</code></em></p>';
|
||||
|
||||
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.skilltreeService.getSkill(token, skill.id);
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
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 = `<h3>${branchIcon} ${s.name}</h3>`;
|
||||
if (s.description) html += `<p><em>${s.description}</em></p>`;
|
||||
|
||||
html += '<ul>';
|
||||
html += `<li>Branch: ${this.translateBranch(s.branch)}</li>`;
|
||||
html += `<li>Level: ${s.level} (${levelName})</li>`;
|
||||
html += `<li>XP: ${s.totalXp.toLocaleString('de-DE')}`;
|
||||
if (nextLevelXp) html += ` / ${nextLevelXp.toLocaleString('de-DE')} (naechstes Level)`;
|
||||
html += '</li>';
|
||||
html += `<li>Erstellt: ${new Date(s.createdAt).toLocaleDateString('de-DE')}</li>`;
|
||||
html += '</ul>';
|
||||
|
||||
html += `<p><em>Nutze <code>!xp ${numberStr} [xp] [aktivitaet]</code> um XP hinzuzufuegen</em></p>`;
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleCreateSkill(roomId: string, sender: string, input: string) {
|
||||
if (!input) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Verwendung: <code>!neu Name | Branch</code></p><p>Branches: intellect, body, creativity, social, practical, mindset, custom</p>'
|
||||
);
|
||||
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,
|
||||
'<p>Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const description = parts[2];
|
||||
|
||||
const result = await this.skilltreeService.createSkill(token, name, branch, description);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSkillsList.delete(sender);
|
||||
const branchIcon = this.getBranchIcon(branch);
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>${branchIcon} Skill <strong>${result.data!.skill.name}</strong> erstellt!</p>
|
||||
<p><em>Nutze <code>!skills</code> und dann <code>!xp [nr] [xp] [aktivitaet]</code></em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.skilltreeService.deleteSkill(token, skill.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSkillsList.delete(sender);
|
||||
await this.sendHtml(roomId, `<p>Skill <strong>${skill.name}</strong> geloescht.</p>`);
|
||||
}
|
||||
|
||||
// XP handler
|
||||
private async handleAddXp(roomId: string, sender: string, argString: string) {
|
||||
const args = argString.split(/\s+/);
|
||||
|
||||
if (args.length < 3) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Verwendung: <code>!xp [nr] [xp] [aktivitaet]</code></p><p>Optional: <code>--min 60</code> fuer Dauer</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const skill = this.getSkillByNumber(sender, args[0]);
|
||||
|
||||
if (!skill) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const xp = parseInt(args[1], 10);
|
||||
if (isNaN(xp) || xp < 1 || xp > 10000) {
|
||||
await this.sendHtml(roomId, '<p>XP muss zwischen 1 und 10000 liegen.</p>');
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { leveledUp, newLevel } = result.data!;
|
||||
let html = `<p><strong>+${xp} XP</strong> fuer <strong>${skill.name}</strong>!</p>`;
|
||||
html += `<p><em>${description}</em></p>`;
|
||||
|
||||
if (leveledUp) {
|
||||
const levelName = this.getLevelName(newLevel);
|
||||
html += `<p>🎉 <strong>LEVEL UP!</strong> Du bist jetzt Level ${newLevel} (${levelName})!</p>`;
|
||||
}
|
||||
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = result.data!.stats;
|
||||
let html = '<h3>Deine Statistiken</h3><ul>';
|
||||
html += `<li>Gesamt-XP: ${stats.totalXp.toLocaleString('de-DE')}</li>`;
|
||||
html += `<li>Skills: ${stats.totalSkills}</li>`;
|
||||
html += `<li>Hoechstes Level: ${stats.highestLevel}</li>`;
|
||||
html += `<li>Streak: ${stats.streakDays} Tage 🔥</li>`;
|
||||
if (stats.lastActivityDate) {
|
||||
html += `<li>Letzte Aktivitaet: ${stats.lastActivityDate}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
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, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activities = result.data?.activities || [];
|
||||
|
||||
if (activities.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Keine Aktivitaeten vorhanden.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = skillName ? `Aktivitaeten: ${skillName}` : 'Letzte Aktivitaeten';
|
||||
let html = `<h3>${title}</h3><ol>`;
|
||||
|
||||
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 += `<li><strong>+${activity.xpEarned} XP</strong> - ${activity.description}${duration}<br/><em>${date}</em></li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
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<number, string> = {
|
||||
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<number, number> = {
|
||||
0: 100,
|
||||
1: 500,
|
||||
2: 1500,
|
||||
3: 4000,
|
||||
4: 10000,
|
||||
};
|
||||
return thresholds[level] || null;
|
||||
}
|
||||
|
||||
private getBranchIcon(branch: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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}%]`;
|
||||
}
|
||||
}
|
||||
61
services/matrix-skilltree-bot/src/config/configuration.ts
Normal file
61
services/matrix-skilltree-bot/src/config/configuration.ts
Normal file
|
|
@ -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 = `<h2>Skilltree Bot - Befehle</h2>
|
||||
|
||||
<h3>Authentifizierung</h3>
|
||||
<ul>
|
||||
<li><code>!login email passwort</code> - Anmelden</li>
|
||||
<li><code>!logout</code> - Abmelden</li>
|
||||
<li><code>!status</code> - Bot-Status anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Skills</h3>
|
||||
<ul>
|
||||
<li><code>!skills</code> - Alle Skills auflisten</li>
|
||||
<li><code>!skills koerper</code> - Nach Branch filtern</li>
|
||||
<li><code>!skill [nr]</code> - Skill-Details anzeigen</li>
|
||||
<li><code>!neu Name | Branch</code> - Neuen Skill erstellen</li>
|
||||
<li><code>!loeschen [nr]</code> - Skill loeschen</li>
|
||||
</ul>
|
||||
|
||||
<h3>XP sammeln</h3>
|
||||
<ul>
|
||||
<li><code>!xp [nr] 50 Aktivitaet</code> - XP hinzufuegen</li>
|
||||
<li><code>!xp [nr] 100 Training --min 60</code> - Mit Dauer</li>
|
||||
</ul>
|
||||
|
||||
<h3>Statistiken</h3>
|
||||
<ul>
|
||||
<li><code>!stats</code> - Gesamtstatistik anzeigen</li>
|
||||
<li><code>!aktivitaeten</code> - Letzte Aktivitaeten</li>
|
||||
<li><code>!aktivitaeten [nr]</code> - Aktivitaeten fuer Skill</li>
|
||||
</ul>
|
||||
|
||||
<h3>Branches</h3>
|
||||
<p><code>intellect</code> (Wissen), <code>body</code>/<code>koerper</code> (Fitness), <code>creativity</code>/<code>kreativ</code> (Kunst), <code>social</code>/<code>sozial</code> (Kommunikation), <code>practical</code>/<code>praktisch</code> (Handwerk), <code>mindset</code> (Achtsamkeit), <code>custom</code> (Eigene)</p>
|
||||
|
||||
<h3>Level-System</h3>
|
||||
<ul>
|
||||
<li>Level 1: 100 XP (Anfaenger)</li>
|
||||
<li>Level 2: 500 XP (Fortgeschritten)</li>
|
||||
<li>Level 3: 1500 XP (Kompetent)</li>
|
||||
<li>Level 4: 4000 XP (Experte)</li>
|
||||
<li>Level 5: 10000 XP (Meister)</li>
|
||||
</ul>
|
||||
|
||||
<p><em>Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.</em></p>`;
|
||||
9
services/matrix-skilltree-bot/src/health.controller.ts
Normal file
9
services/matrix-skilltree-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'matrix-skilltree-bot' };
|
||||
}
|
||||
}
|
||||
10
services/matrix-skilltree-bot/src/main.ts
Normal file
10
services/matrix-skilltree-bot/src/main.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
86
services/matrix-skilltree-bot/src/session/session.service.ts
Normal file
86
services/matrix-skilltree-bot/src/session/session.service.ts
Normal file
|
|
@ -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<string, UserSession> = new Map();
|
||||
private authUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.authUrl = this.configService.get<string>('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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SkilltreeService } from './skilltree.service';
|
||||
|
||||
@Module({
|
||||
providers: [SkilltreeService],
|
||||
exports: [SkilltreeService],
|
||||
})
|
||||
export class SkilltreeModule {}
|
||||
151
services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts
Normal file
151
services/matrix-skilltree-bot/src/skilltree/skilltree.service.ts
Normal file
|
|
@ -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<string>('skilltree.backendUrl') || 'http://localhost:3024';
|
||||
this.apiPrefix = this.configService.get<string>('skilltree.apiPrefix') || '/api/v1';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
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<AddXpResult>(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<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.backendUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/matrix-skilltree-bot/tsconfig.json
Normal file
22
services/matrix-skilltree-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue