mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
8da676ff8c
commit
ad7f875c5f
17 changed files with 1461 additions and 0 deletions
15
services/matrix-manadeck-bot/.env.example
Normal file
15
services/matrix-manadeck-bot/.env.example
Normal file
|
|
@ -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
|
||||
29
services/matrix-manadeck-bot/.gitignore
vendored
Normal file
29
services/matrix-manadeck-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
|
||||
225
services/matrix-manadeck-bot/CLAUDE.md
Normal file
225
services/matrix-manadeck-bot/CLAUDE.md
Normal file
|
|
@ -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<TextContent | FlashcardContent | QuizContent> }` |
|
||||
|
||||
## 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
|
||||
41
services/matrix-manadeck-bot/Dockerfile
Normal file
41
services/matrix-manadeck-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 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"]
|
||||
5
services/matrix-manadeck-bot/nest-cli.json
Normal file
5
services/matrix-manadeck-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
27
services/matrix-manadeck-bot/package.json
Normal file
27
services/matrix-manadeck-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
21
services/matrix-manadeck-bot/src/app.module.ts
Normal file
21
services/matrix-manadeck-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 { 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 {}
|
||||
11
services/matrix-manadeck-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-manadeck-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
656
services/matrix-manadeck-bot/src/bot/matrix.service.ts
Normal file
656
services/matrix-manadeck-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string, Deck[]> = new Map();
|
||||
private lastCardsList: Map<string, { deckId: string; cards: Card[] }> = new Map();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private manadeckService: ManadeckService,
|
||||
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 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, '<p>Erfolgreich abgemeldet.</p>');
|
||||
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,
|
||||
`<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.manadeckService.checkHealth();
|
||||
const loggedIn = this.sessionService.isLoggedIn(sender);
|
||||
const sessions = this.sessionService.getSessionCount();
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<h3>ManaDeck Bot Status</h3>
|
||||
<ul>
|
||||
<li>Backend: ${backendOk ? 'Online' : 'Offline'}</li>
|
||||
<li>Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}</li>
|
||||
<li>Aktive Sessions: ${sessions}</li>
|
||||
</ul>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decks = result.data || [];
|
||||
this.lastDecksList.set(sender, decks);
|
||||
|
||||
if (decks.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Keine Decks vorhanden. Erstelle eines mit <code>!neu Titel</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Deine Decks</h3><ol>';
|
||||
for (const deck of decks) {
|
||||
const cardInfo = deck.cardCount !== undefined ? ` (${deck.cardCount} Karten)` : '';
|
||||
const tags = deck.tags?.length ? ` [${deck.tags.join(', ')}]` : '';
|
||||
html += `<li><strong>${deck.title}</strong>${cardInfo}${tags}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
html += '<p><em>Nutze <code>!deck [nr]</code> fuer Details</em></p>';
|
||||
|
||||
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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!decks</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.manadeckService.getDeck(token, deck.id);
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = result.data!;
|
||||
let html = `<h3>${d.title}</h3>`;
|
||||
if (d.description) html += `<p>${d.description}</p>`;
|
||||
html += '<ul>';
|
||||
html += `<li>Karten: ${d.cardCount || 0}</li>`;
|
||||
html += `<li>Oeffentlich: ${d.isPublic ? 'Ja' : 'Nein'}</li>`;
|
||||
if (d.tags?.length) html += `<li>Tags: ${d.tags.join(', ')}</li>`;
|
||||
html += `<li>Erstellt: ${new Date(d.createdAt).toLocaleDateString('de-DE')}</li>`;
|
||||
html += '</ul>';
|
||||
html += `<p><em>Nutze <code>!karten ${numberStr}</code> um Karten zu sehen</em></p>`;
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleCreateDeck(roomId: string, sender: string, title: string) {
|
||||
if (!title) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!neu Titel [Beschreibung]</code></p>');
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Deck <strong>${result.data!.title}</strong> erstellt! (10 Mana verbraucht)</p>`
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!decks</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.manadeckService.deleteDeck(token, deck.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear cached list
|
||||
this.lastDecksList.delete(sender);
|
||||
await this.sendHtml(roomId, `<p>Deck <strong>${deck.title}</strong> geloescht.</p>`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!decks</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.manadeckService.getCards(token, deck.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cards = result.data || [];
|
||||
this.lastCardsList.set(sender, { deckId: deck.id, cards });
|
||||
|
||||
if (cards.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Keine Karten in <strong>${deck.title}</strong>.</p>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<h3>Karten in "${deck.title}"</h3><ol>`;
|
||||
for (const card of cards) {
|
||||
const title = card.title || this.getCardPreview(card);
|
||||
const fav = card.isFavorite ? ' ⭐' : '';
|
||||
html += `<li><strong>${card.cardType}</strong>: ${title}${fav}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
html += `<p><em>Nutze <code>!karte ${numberStr} [nr]</code> fuer Details</em></p>`;
|
||||
|
||||
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,
|
||||
'<p>Ungueltige Deck-Nummer. Nutze zuerst <code>!decks</code></p>'
|
||||
);
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
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,
|
||||
`<p>Ungueltige Kartennummer. Nutze <code>!karten ${deckNumStr}</code></p>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = cachedCards.cards[cardIndex];
|
||||
let html = `<h3>Karte #${cardNumStr}</h3>`;
|
||||
html += `<p><strong>Typ:</strong> ${card.cardType}</p>`;
|
||||
|
||||
switch (card.cardType) {
|
||||
case 'flashcard':
|
||||
html += `<p><strong>Vorderseite:</strong> ${card.content.front}</p>`;
|
||||
html += `<p><strong>Rueckseite:</strong> ${card.content.back}</p>`;
|
||||
if (card.content.hint) html += `<p><strong>Hinweis:</strong> ${card.content.hint}</p>`;
|
||||
break;
|
||||
case 'quiz':
|
||||
html += `<p><strong>Frage:</strong> ${card.content.question}</p>`;
|
||||
html += '<p><strong>Optionen:</strong></p><ol>';
|
||||
for (const opt of card.content.options || []) {
|
||||
html += `<li>${opt}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
html += `<p><strong>Richtig:</strong> Option ${(card.content.correctAnswer || 0) + 1}</p>`;
|
||||
break;
|
||||
case 'text':
|
||||
html += `<p>${card.content.text}</p>`;
|
||||
break;
|
||||
default:
|
||||
html += `<pre>${JSON.stringify(card.content, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
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,
|
||||
'<p>Verwendung: <code>!generate Thema [--count 10] [--type flashcard] [--difficulty intermediate]</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, '<p>Generiere Deck mit AI... (30 Mana)</p>');
|
||||
|
||||
const result = await this.manadeckService.generateDeck(token, prompt, options);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { deck, cards } = result.data!;
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Deck <strong>${deck.title}</strong> mit ${cards.length} Karten erstellt!</p>
|
||||
<p><em>Nutze <code>!decks</code> um deine Decks zu sehen.</em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!decks</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.manadeckService.startStudySession(token, deck.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = result.data!;
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Lernsession fuer <strong>${deck.title}</strong> gestartet!</p>
|
||||
<p>${session.totalCards} Karten zu lernen.</p>
|
||||
<p><em>Oeffne die ManaDeck App um mit dem Lernen zu beginnen.</em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dueCards = result.data || [];
|
||||
|
||||
if (dueCards.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Keine faelligen Karten! Gut gemacht!</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p><strong>${dueCards.length}</strong> Karten sind faellig.</p>
|
||||
<p><em>Oeffne die ManaDeck App um sie zu wiederholen.</em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = result.data!;
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<h3>Deine Statistiken</h3>
|
||||
<ul>
|
||||
<li>Decks: ${stats.totalDecks || 0}</li>
|
||||
<li>Karten: ${stats.totalCards || 0}</li>
|
||||
<li>Sessions: ${stats.totalSessions || 0}</li>
|
||||
<li>Streak: ${stats.streakDays || 0} Tage</li>
|
||||
<li>Genauigkeit: ${stats.averageAccuracy?.toFixed(1) || 0}%</li>
|
||||
</ul>`
|
||||
);
|
||||
}
|
||||
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Dein Mana-Guthaben: <strong>${result.data!.balance}</strong></p>
|
||||
<p><em>Kosten: Deck erstellen (10), AI-Generierung (30)</em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
// Public endpoints
|
||||
private async handleFeatured(roomId: string) {
|
||||
const result = await this.manadeckService.getFeaturedDecks();
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decks = result.data || [];
|
||||
|
||||
if (decks.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Keine empfohlenen Decks verfuegbar.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Empfohlene Decks</h3><ol>';
|
||||
for (const deck of decks) {
|
||||
const cardInfo = deck.cardCount !== undefined ? ` (${deck.cardCount} Karten)` : '';
|
||||
html += `<li><strong>${deck.title}</strong>${cardInfo}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleLeaderboard(roomId: string) {
|
||||
const result = await this.manadeckService.getLeaderboard(10);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const users = result.data || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Noch keine Eintraege in der Rangliste.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Rangliste (Top 10)</h3><ol>';
|
||||
for (const user of users) {
|
||||
html += `<li>${user.totalWins || 0} Siege - ${user.streakDays || 0} Tage Streak</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
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)';
|
||||
}
|
||||
}
|
||||
63
services/matrix-manadeck-bot/src/config/configuration.ts
Normal file
63
services/matrix-manadeck-bot/src/config/configuration.ts
Normal file
|
|
@ -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 = `<h2>ManaDeck 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>Decks verwalten</h3>
|
||||
<ul>
|
||||
<li><code>!decks</code> - Alle Decks auflisten</li>
|
||||
<li><code>!deck [nr]</code> - Deck-Details anzeigen</li>
|
||||
<li><code>!neu Titel</code> - Neues Deck erstellen (10 Mana)</li>
|
||||
<li><code>!loeschen [nr]</code> - Deck loeschen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Karten</h3>
|
||||
<ul>
|
||||
<li><code>!karten [nr]</code> - Karten eines Decks anzeigen</li>
|
||||
<li><code>!karte [deck-nr] [karten-nr]</code> - Kartendetails</li>
|
||||
</ul>
|
||||
|
||||
<h3>AI-Generierung</h3>
|
||||
<ul>
|
||||
<li><code>!generate Thema</code> - Deck mit AI generieren (30 Mana)</li>
|
||||
<li><code>!generate Thema --count 10</code> - Mit Kartenanzahl</li>
|
||||
<li><code>!generate Thema --type flashcard</code> - Mit Kartentyp</li>
|
||||
</ul>
|
||||
|
||||
<h3>Lernen</h3>
|
||||
<ul>
|
||||
<li><code>!lernen [nr]</code> - Lernsession starten</li>
|
||||
<li><code>!faellig</code> - Faellige Karten anzeigen</li>
|
||||
<li><code>!stats</code> - Lernstatistiken</li>
|
||||
</ul>
|
||||
|
||||
<h3>Weiteres</h3>
|
||||
<ul>
|
||||
<li><code>!mana</code> - Mana-Guthaben anzeigen</li>
|
||||
<li><code>!featured</code> - Empfohlene Decks</li>
|
||||
<li><code>!leaderboard</code> - Rangliste</li>
|
||||
<li><code>!help</code> - Diese Hilfe anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<p><em>Tipp: Nutze Deck-/Kartennummern aus der zuletzt angezeigten Liste.</em></p>`;
|
||||
9
services/matrix-manadeck-bot/src/health.controller.ts
Normal file
9
services/matrix-manadeck-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-manadeck-bot' };
|
||||
}
|
||||
}
|
||||
10
services/matrix-manadeck-bot/src/main.ts
Normal file
10
services/matrix-manadeck-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 || 3321;
|
||||
await app.listen(port);
|
||||
console.log(`Matrix ManaDeck Bot running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ManadeckService } from './manadeck.service';
|
||||
|
||||
@Module({
|
||||
providers: [ManadeckService],
|
||||
exports: [ManadeckService],
|
||||
})
|
||||
export class ManadeckModule {}
|
||||
221
services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts
Normal file
221
services/matrix-manadeck-bot/src/manadeck/manadeck.service.ts
Normal file
|
|
@ -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<string>('manadeck.backendUrl') || 'http://localhost:3009';
|
||||
this.apiPrefix = this.configService.get<string>('manadeck.apiPrefix') || '/api';
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
private async publicRequest<T>(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<Deck[]>(token, '/decks');
|
||||
}
|
||||
|
||||
async getDeck(token: string, deckId: string): Promise<{ data?: Deck; error?: string }> {
|
||||
return this.request<Deck>(token, `/decks/${deckId}`);
|
||||
}
|
||||
|
||||
async createDeck(
|
||||
token: string,
|
||||
title: string,
|
||||
description?: string
|
||||
): Promise<{ data?: Deck; error?: string }> {
|
||||
return this.request<Deck>(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<Card[]>(token, `/decks/${deckId}/cards`);
|
||||
}
|
||||
|
||||
async getCard(token: string, cardId: string): Promise<{ data?: Card; error?: string }> {
|
||||
return this.request<Card>(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<StudySession>(token, '/study-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ deckId, mode }),
|
||||
});
|
||||
}
|
||||
|
||||
async getStudySessions(token: string): Promise<{ data?: StudySession[]; error?: string }> {
|
||||
return this.request<StudySession[]>(token, '/study-sessions');
|
||||
}
|
||||
|
||||
// Progress
|
||||
async getDueCards(token: string): Promise<{ data?: CardProgress[]; error?: string }> {
|
||||
return this.request<CardProgress[]>(token, '/progress/due');
|
||||
}
|
||||
|
||||
async getProgressStats(token: string): Promise<{ data?: any; error?: string }> {
|
||||
return this.request<any>(token, '/progress/stats');
|
||||
}
|
||||
|
||||
// User stats
|
||||
async getStats(token: string): Promise<{ data?: UserStats; error?: string }> {
|
||||
return this.request<UserStats>(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<Deck[]>('/featured-decks');
|
||||
}
|
||||
|
||||
async getLeaderboard(limit: number = 10): Promise<{ data?: any[]; error?: string }> {
|
||||
return this.publicRequest<any[]>(`/leaderboard?limit=${limit}`);
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.backendUrl}/public/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
90
services/matrix-manadeck-bot/src/session/session.service.ts
Normal file
90
services/matrix-manadeck-bot/src/session/session.service.ts
Normal file
|
|
@ -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<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' };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
22
services/matrix-manadeck-bot/tsconfig.json
Normal file
22
services/matrix-manadeck-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