feat(matrix-presi-bot): add Matrix bot for presentation management

- Full NestJS bot with matrix-bot-sdk integration
- Deck management: create, list, view, delete, rename
- Slide management: add title/text/bullet/image slides, delete
- Multiple slide types: title, content, bullets, image
- Theme support: list themes, apply to presentation
- Sharing: create links with optional expiration
- German/English command aliases
- Number-based reference system for decks and themes
- JWT auth via mana-core-auth
- Runs on port 3325

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-30 16:54:07 +01:00
parent c5476447ec
commit e3cfafe594
17 changed files with 1423 additions and 0 deletions

View file

@ -0,0 +1,15 @@
# Server
PORT=3325
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#presi:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Presi Backend
PRESI_BACKEND_URL=http://localhost:3008
PRESI_API_PREFIX=/api
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001

29
services/matrix-presi-bot/.gitignore vendored Normal file
View 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

View file

@ -0,0 +1,208 @@
# Matrix Presi Bot - Claude Code Guidelines
## Overview
Matrix Presi Bot provides presentation management via Matrix chat. It integrates with the Presi backend for deck/slide management, theming, and sharing.
## Tech Stack
- **Framework**: NestJS 10
- **Matrix**: matrix-bot-sdk
- **Backend**: Presi API (port 3008)
- **Auth**: Mana Core Auth (JWT)
## Commands
```bash
# Development
pnpm install
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/matrix-presi-bot/
├── src/
│ ├── main.ts # Application entry point (port 3325)
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health check endpoint
│ ├── config/
│ │ └── configuration.ts # Configuration & help messages
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── matrix.service.ts # Matrix client & command handlers
│ ├── presi/
│ │ ├── presi.module.ts
│ │ └── presi.service.ts # Presi Backend API client
│ └── session/
│ ├── session.module.ts
│ └── session.service.ts # User session & auth management
├── Dockerfile
└── package.json
```
## Bot Commands
| Command | Aliases | Description |
|---------|---------|-------------|
| `!help` | hilfe | Show help message |
| `!login email pass` | - | Login |
| `!logout` | - | Logout |
| `!status` | - | Bot status |
### Presentation Management
| Command | Aliases | Description |
|---------|---------|-------------|
| `!presis` | decks, liste | List all presentations |
| `!presi [nr]` | deck, details | Show presentation with slides |
| `!neu Titel` | new, create | Create presentation |
| `!loeschen [nr]` | delete | Delete presentation |
| `!umbenennen [nr] Titel` | rename | Rename presentation |
### Slide Management
| Command | Description |
|---------|-------------|
| `!folie [nr] titel Titel \| Untertitel` | Add title slide |
| `!folie [nr] text Titel \| Inhalt` | Add content slide |
| `!folie [nr] punkte Titel \| P1, P2, P3` | Add bullet slide |
| `!folie [nr] bild Titel \| URL` | Add image slide |
| `!folieloeschen [presi-nr] [folien-nr]` | Delete slide |
### Themes
| Command | Aliases | Description |
|---------|---------|-------------|
| `!themes` | designs | List available themes |
| `!theme [presi-nr] [theme-nr]` | design | Apply theme |
### Sharing
| Command | Options | Description |
|---------|---------|-------------|
| `!teilen [nr]` | share | Share presentation |
| `--tage N` | - | Expire in N days |
| `!links [nr]` | shares | List share links |
## Slide Types
| Type | Content |
|------|---------|
| `title` | Title + optional subtitle |
| `content` | Title + body text |
| `bullets` | Title + bullet points |
| `image` | Title + image URL |
## Example Usage
```
# Login
!login max@example.com mypassword
# Create presentation
!neu Meine Praesentation | Eine tolle Praesentation
# List presentations
!presis
# Add title slide
!folie 1 titel Willkommen | Zur Praesentation
# Add content slide
!folie 1 text Einfuehrung | Hier ist der Inhalt
# Add bullet points
!folie 1 punkte Agenda | Punkt 1, Punkt 2, Punkt 3
# View presentation
!presi 1
# Apply theme
!themes
!theme 1 2
# Share presentation
!teilen 1 --tage 7
# View share links
!links 1
```
## Environment Variables
```env
# Server
PORT=3325
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#presi:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Presi Backend
PRESI_BACKEND_URL=http://localhost:3008
PRESI_API_PREFIX=/api
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
```
## Docker
```bash
# Build locally
docker build -f services/matrix-presi-bot/Dockerfile -t matrix-presi-bot services/matrix-presi-bot
# Run
docker run -p 3325:3325 \
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \
-e PRESI_BACKEND_URL=http://presi-backend:3008 \
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
-v matrix-presi-bot-data:/app/data \
matrix-presi-bot
```
## Health Check
```bash
curl http://localhost:3325/health
```
## Presi Backend API Endpoints Used
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/api/decks` | GET | List presentations |
| `/api/decks` | POST | Create presentation |
| `/api/decks/:id` | GET | Get presentation with slides |
| `/api/decks/:id` | PUT | Update presentation |
| `/api/decks/:id` | DELETE | Delete presentation |
| `/api/decks/:id/slides` | POST | Add slide |
| `/api/slides/:id` | DELETE | Delete slide |
| `/api/themes` | GET | List themes |
| `/api/share/deck/:id` | POST | Create share link |
| `/api/share/deck/:id/links` | GET | List share links |
## Number-Based Reference System
The bot uses a number-based reference system for ease of use:
1. User runs `!presis` or `!themes` to get a list
2. Bot stores the list internally for the user
3. User can reference items by their list number
4. Numbers are valid until the user runs a new list command
This allows simple commands like:
- `!presi 2` - Show presentation #2
- `!folie 1 titel Hallo` - Add slide to presentation #1
- `!theme 1 3` - Apply theme #3 to presentation #1

View 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 3325
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3325/health || exit 1
# Start the application
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,5 @@
{
"$schema": "https://json-schema.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View file

@ -0,0 +1,27 @@
{
"name": "@mana-bots/matrix-presi-bot",
"version": "1.0.0",
"description": "Matrix bot for presentation management",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"matrix-bot-sdk": "^0.7.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}

View 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 { PresiModule } from './presi/presi.module';
import { SessionModule } from './session/session.module';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BotModule,
PresiModule,
SessionModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { PresiModule } from '../presi/presi.module';
import { SessionModule } from '../session/session.module';
@Module({
imports: [PresiModule, SessionModule],
providers: [MatrixService],
exports: [MatrixService],
})
export class BotModule {}

View file

@ -0,0 +1,651 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MatrixClient,
SimpleFsStorageProvider,
AutojoinRoomsMixin,
} from 'matrix-bot-sdk';
import { PresiService, Deck, Theme, SlideContent } from '../presi/presi.service';
import { SessionService } from '../session/session.service';
import { HELP_MESSAGE } from '../config/configuration';
@Injectable()
export class MatrixService implements OnModuleInit {
private readonly logger = new Logger(MatrixService.name);
private client: MatrixClient;
private allowedRooms: string[];
// Store last shown items per user for reference by number
private lastDecksList: Map<string, Deck[]> = new Map();
private lastThemesList: Map<string, Theme[]> = new Map();
constructor(
private configService: ConfigService,
private presiService: PresiService,
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 Presi Bot started');
}
private async handleMessage(roomId: string, event: any) {
if (event.sender === (await this.client.getUserId())) return;
if (event.content?.msgtype !== 'm.text') return;
const body = event.content.body?.trim();
if (!body?.startsWith('!')) return;
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
return;
}
const sender = event.sender;
const parts = body.slice(1).split(/\s+/);
const command = parts[0].toLowerCase();
const args = parts.slice(1);
const argString = args.join(' ');
try {
switch (command) {
case 'help':
case 'hilfe':
await this.sendHtml(roomId, HELP_MESSAGE);
break;
case 'login':
await this.handleLogin(roomId, sender, args);
break;
case 'logout':
this.sessionService.logout(sender);
await this.sendHtml(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
await this.handleStatus(roomId, sender);
break;
// Deck commands
case 'presis':
case 'decks':
case 'liste':
await this.handleListDecks(roomId, sender);
break;
case 'presi':
case 'deck':
case 'details':
await this.handleDeckDetails(roomId, sender, args[0]);
break;
case 'neu':
case 'new':
case 'create':
await this.handleCreateDeck(roomId, sender, argString);
break;
case 'loeschen':
case 'delete':
await this.handleDeleteDeck(roomId, sender, args[0]);
break;
case 'umbenennen':
case 'rename':
await this.handleRenameDeck(roomId, sender, args[0], args.slice(1).join(' '));
break;
// Slide commands
case 'folie':
case 'slide':
await this.handleAddSlide(roomId, sender, args);
break;
case 'folieloeschen':
case 'deleteslide':
await this.handleDeleteSlide(roomId, sender, args[0], args[1]);
break;
// Theme commands
case 'themes':
case 'designs':
await this.handleListThemes(roomId, sender);
break;
case 'theme':
case 'design':
await this.handleApplyTheme(roomId, sender, args[0], args[1]);
break;
// Share commands
case 'teilen':
case 'share':
await this.handleShareDeck(roomId, sender, argString);
break;
case 'links':
case 'shares':
await this.handleListShares(roomId, sender, args[0]);
break;
default:
await this.sendHtml(
roomId,
`<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.presiService.checkHealth();
const loggedIn = this.sessionService.isLoggedIn(sender);
const sessions = this.sessionService.getSessionCount();
await this.sendHtml(
roomId,
`<h3>Presi 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.presiService.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 Praesentationen vorhanden. Erstelle eine mit <code>!neu Titel</code></p>'
);
return;
}
let html = '<h3>Deine Praesentationen</h3><ol>';
for (const deck of decks) {
const theme = deck.theme ? ` [${deck.theme.name}]` : '';
const pub = deck.isPublic ? ' &#127760;' : '';
html += `<li><strong>${deck.title}</strong>${theme}${pub}</li>`;
}
html += '</ol>';
html += '<p><em>Nutze <code>!presi [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>!presis</code></p>');
return;
}
const result = await this.presiService.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><em>${d.description}</em></p>`;
html += '<ul>';
if (d.theme) html += `<li>Theme: ${d.theme.name}</li>`;
html += `<li>Oeffentlich: ${d.isPublic ? 'Ja' : 'Nein'}</li>`;
html += `<li>Folien: ${d.slides?.length || 0}</li>`;
html += `<li>Erstellt: ${new Date(d.createdAt).toLocaleDateString('de-DE')}</li>`;
html += '</ul>';
if (d.slides && d.slides.length > 0) {
html += '<p><strong>Folien:</strong></p><ol>';
for (const slide of d.slides) {
const title = slide.content.title || slide.content.body?.substring(0, 30) || `(${slide.content.type})`;
html += `<li>${title}</li>`;
}
html += '</ol>';
}
html += `<p><em>Nutze <code>!folie ${numberStr} typ Inhalt</code> um Folien hinzuzufuegen</em></p>`;
await this.sendHtml(roomId, html);
}
private async handleCreateDeck(roomId: string, sender: string, input: string) {
if (!input) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!neu Titel | Beschreibung</code></p>');
return;
}
const token = this.requireAuth(sender);
const parts = input.split('|').map((s) => s.trim());
const title = parts[0];
const description = parts[1];
const result = await this.presiService.createDeck(token, title, description);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastDecksList.delete(sender);
await this.sendHtml(
roomId,
`<p>Praesentation <strong>${result.data!.title}</strong> erstellt!</p>
<p><em>Nutze <code>!presis</code> und dann <code>!folie [nr] typ Inhalt</code></em></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>!presis</code></p>');
return;
}
const result = await this.presiService.deleteDeck(token, deck.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastDecksList.delete(sender);
await this.sendHtml(roomId, `<p>Praesentation <strong>${deck.title}</strong> geloescht.</p>`);
}
private async handleRenameDeck(roomId: string, sender: string, numberStr: string, newTitle: string) {
if (!newTitle) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!umbenennen [nr] Neuer Titel</code></p>');
return;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const result = await this.presiService.updateDeck(token, deck.id, { title: newTitle });
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
roomId,
`<p><strong>${deck.title}</strong> umbenannt zu <strong>${newTitle}</strong></p>`
);
}
// Slide handlers
private async handleAddSlide(roomId: string, sender: string, args: string[]) {
if (args.length < 2) {
await this.sendHtml(
roomId,
`<p>Verwendung:</p>
<ul>
<li><code>!folie [nr] titel Titel | Untertitel</code></li>
<li><code>!folie [nr] text Titel | Inhalt</code></li>
<li><code>!folie [nr] punkte Titel | Punkt1, Punkt2</code></li>
</ul>`
);
return;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, args[0]);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const slideType = args[1].toLowerCase();
const contentStr = args.slice(2).join(' ');
const contentParts = contentStr.split('|').map((s) => s.trim());
let content: SlideContent;
switch (slideType) {
case 'titel':
case 'title':
content = {
type: 'title',
title: contentParts[0] || 'Titel',
subtitle: contentParts[1],
};
break;
case 'text':
case 'content':
case 'inhalt':
content = {
type: 'content',
title: contentParts[0] || 'Inhalt',
body: contentParts[1] || '',
};
break;
case 'punkte':
case 'bullets':
case 'liste':
const bullets = contentParts[1]?.split(',').map((s) => s.trim()) || [];
content = {
type: 'content',
title: contentParts[0] || 'Punkte',
bulletPoints: bullets,
};
break;
case 'bild':
case 'image':
content = {
type: 'image',
title: contentParts[0],
imageUrl: contentParts[1],
};
break;
default:
await this.sendHtml(
roomId,
'<p>Unbekannter Folien-Typ. Verfuegbar: titel, text, punkte, bild</p>'
);
return;
}
const result = await this.presiService.addSlide(token, deck.id, content);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
roomId,
`<p>Folie zu <strong>${deck.title}</strong> hinzugefuegt (Position ${result.data!.order + 1})</p>`
);
}
private async handleDeleteSlide(roomId: string, sender: string, deckNumStr: string, slideNumStr: string) {
if (!deckNumStr || !slideNumStr) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!folieloeschen [presi-nr] [folien-nr]</code></p>');
return;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, deckNumStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
return;
}
// Get deck with slides
const deckResult = await this.presiService.getDeck(token, deck.id);
if (deckResult.error || !deckResult.data?.slides) {
await this.sendHtml(roomId, `<p>Fehler: ${deckResult.error || 'Keine Folien'}</p>`);
return;
}
const slideIndex = parseInt(slideNumStr, 10) - 1;
if (isNaN(slideIndex) || slideIndex < 0 || slideIndex >= deckResult.data.slides.length) {
await this.sendHtml(roomId, '<p>Ungueltige Folien-Nummer.</p>');
return;
}
const slide = deckResult.data.slides[slideIndex];
const result = await this.presiService.deleteSlide(token, slide.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(roomId, `<p>Folie ${slideNumStr} aus <strong>${deck.title}</strong> geloescht.</p>`);
}
// Theme handlers
private async handleListThemes(roomId: string, sender: string) {
const result = await this.presiService.getThemes();
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const themes = result.data || [];
this.lastThemesList.set(sender, themes);
if (themes.length === 0) {
await this.sendHtml(roomId, '<p>Keine Themes verfuegbar.</p>');
return;
}
let html = '<h3>Verfuegbare Themes</h3><ol>';
for (const theme of themes) {
const def = theme.isDefault ? ' (Standard)' : '';
html += `<li><strong>${theme.name}</strong>${def}</li>`;
}
html += '</ol>';
html += '<p><em>Nutze <code>!theme [presi-nr] [theme-nr]</code></em></p>';
await this.sendHtml(roomId, html);
}
private async handleApplyTheme(roomId: string, sender: string, deckNumStr: string, themeNumStr: string) {
if (!deckNumStr || !themeNumStr) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!theme [presi-nr] [theme-nr]</code></p>');
return;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, deckNumStr);
const theme = this.getThemeByNumber(sender, themeNumStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Praesentation-Nummer.</p>');
return;
}
if (!theme) {
await this.sendHtml(roomId, '<p>Ungueltige Theme-Nummer. Nutze zuerst <code>!themes</code></p>');
return;
}
const result = await this.presiService.updateDeck(token, deck.id, { themeId: theme.id });
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
await this.sendHtml(
roomId,
`<p>Theme <strong>${theme.name}</strong> auf <strong>${deck.title}</strong> angewendet.</p>`
);
}
// Share handlers
private async handleShareDeck(roomId: string, sender: string, argString: string) {
const args = argString.split(/\s+/);
const numberStr = args[0];
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
let expiresAt: string | undefined;
// Parse --tage N
const daysMatch = argString.match(/--tage\s+(\d+)/i);
if (daysMatch) {
const days = parseInt(daysMatch[1], 10);
const expDate = new Date();
expDate.setDate(expDate.getDate() + days);
expiresAt = expDate.toISOString();
}
const result = await this.presiService.createShareLink(token, deck.id, expiresAt);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const shareUrl = this.presiService.getShareUrl(result.data!.shareCode);
let html = `<p><strong>${deck.title}</strong> wird geteilt:</p>`;
html += `<p><a href="${shareUrl}">${shareUrl}</a></p>`;
if (result.data!.expiresAt) {
html += `<p><em>Gueltig bis: ${new Date(result.data!.expiresAt).toLocaleDateString('de-DE')}</em></p>`;
}
await this.sendHtml(roomId, html);
}
private async handleListShares(roomId: string, sender: string, numberStr: string) {
if (!numberStr) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!links [presi-nr]</code></p>');
return;
}
const token = this.requireAuth(sender);
const deck = this.getDeckByNumber(sender, numberStr);
if (!deck) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!presis</code></p>');
return;
}
const result = await this.presiService.getShareLinks(token, deck.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const links = result.data || [];
if (links.length === 0) {
await this.sendHtml(
roomId,
`<p>Keine Share-Links fuer <strong>${deck.title}</strong>. Nutze <code>!teilen ${numberStr}</code></p>`
);
return;
}
let html = `<h3>Share-Links: ${deck.title}</h3><ol>`;
for (const link of links) {
const expires = link.expiresAt
? ` (bis ${new Date(link.expiresAt).toLocaleDateString('de-DE')})`
: ' (unbegrenzt)';
const url = this.presiService.getShareUrl(link.shareCode);
html += `<li><a href="${url}">${link.shareCode}</a>${expires}</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 getThemeByNumber(sender: string, numberStr: string): Theme | null {
const themes = this.lastThemesList.get(sender);
if (!themes) return null;
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= themes.length) return null;
return themes[index];
}
}

View file

@ -0,0 +1,62 @@
export default () => ({
port: parseInt(process.env.PORT, 10) || 3325,
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
accessToken: process.env.MATRIX_ACCESS_TOKEN,
allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [],
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
},
presi: {
backendUrl: process.env.PRESI_BACKEND_URL || 'http://localhost:3008',
apiPrefix: process.env.PRESI_API_PREFIX || '/api',
},
auth: {
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
});
export const HELP_MESSAGE = `<h2>Presi 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>Praesentationen</h3>
<ul>
<li><code>!presis</code> - Alle Praesentationen auflisten</li>
<li><code>!presi [nr]</code> - Praesentation mit Folien anzeigen</li>
<li><code>!neu Titel</code> - Neue Praesentation erstellen</li>
<li><code>!loeschen [nr]</code> - Praesentation loeschen</li>
<li><code>!umbenennen [nr] Neuer Titel</code> - Umbenennen</li>
</ul>
<h3>Folien</h3>
<ul>
<li><code>!folie [nr] titel Titel | Untertitel</code> - Titel-Folie hinzufuegen</li>
<li><code>!folie [nr] text Titel | Inhalt</code> - Text-Folie hinzufuegen</li>
<li><code>!folie [nr] punkte Titel | Punkt1, Punkt2</code> - Aufzaehlungs-Folie</li>
<li><code>!folieloeschen [presi-nr] [folien-nr]</code> - Folie loeschen</li>
</ul>
<h3>Themes</h3>
<ul>
<li><code>!themes</code> - Verfuegbare Themes anzeigen</li>
<li><code>!theme [presi-nr] [theme-nr]</code> - Theme anwenden</li>
</ul>
<h3>Teilen</h3>
<ul>
<li><code>!teilen [nr]</code> - Praesentation teilen</li>
<li><code>!teilen [nr] --tage 7</code> - Mit Ablaufdatum</li>
<li><code>!links [nr]</code> - Share-Links anzeigen</li>
</ul>
<h3>Weitere Befehle</h3>
<ul>
<li><code>!help</code> - Diese Hilfe anzeigen</li>
</ul>
<p><em>Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.</em></p>`;

View file

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'matrix-presi-bot' };
}
}

View 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 || 3325;
await app.listen(port);
console.log(`Matrix Presi Bot running on port ${port}`);
}
bootstrap();

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PresiService } from './presi.service';
@Module({
providers: [PresiService],
exports: [PresiService],
})
export class PresiModule {}

View file

@ -0,0 +1,210 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface SlideContent {
type: 'title' | 'content' | 'image' | 'split';
title?: string;
subtitle?: string;
body?: string;
imageUrl?: string;
bulletPoints?: string[];
}
export interface Slide {
id: string;
deckId: string;
order: number;
content: SlideContent;
createdAt: string;
}
export interface Theme {
id: string;
name: string;
colors: {
primary: string;
secondary: string;
background: string;
text: string;
accent: string;
};
fonts: {
heading: string;
body: string;
};
isDefault: boolean;
}
export interface Deck {
id: string;
title: string;
description?: string;
themeId?: string;
isPublic: boolean;
theme?: Theme;
slides?: Slide[];
createdAt: string;
updatedAt: string;
}
export interface ShareLink {
id: string;
deckId: string;
shareCode: string;
expiresAt?: string;
createdAt: string;
}
@Injectable()
export class PresiService {
private readonly logger = new Logger(PresiService.name);
private backendUrl: string;
private apiPrefix: string;
constructor(private configService: ConfigService) {
this.backendUrl = this.configService.get<string>('presi.backendUrl') || 'http://localhost:3008';
this.apiPrefix = this.configService.get<string>('presi.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}${this.apiPrefix}${endpoint}`;
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.message || `Fehler: ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
this.logger.error(`Public request failed: ${endpoint}`, error);
return { error: 'Verbindung zum Backend fehlgeschlagen' };
}
}
// Deck operations
async getDecks(token: string): Promise<{ data?: Deck[]; error?: string }> {
return this.request<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 updateDeck(
token: string,
deckId: string,
updates: { title?: string; description?: string; themeId?: string; isPublic?: boolean }
): Promise<{ data?: Deck; error?: string }> {
return this.request<Deck>(token, `/decks/${deckId}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async deleteDeck(token: string, deckId: string): Promise<{ error?: string }> {
return this.request(token, `/decks/${deckId}`, { method: 'DELETE' });
}
// Slide operations
async addSlide(
token: string,
deckId: string,
content: SlideContent
): Promise<{ data?: Slide; error?: string }> {
return this.request<Slide>(token, `/decks/${deckId}/slides`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
async deleteSlide(token: string, slideId: string): Promise<{ error?: string }> {
return this.request(token, `/slides/${slideId}`, { method: 'DELETE' });
}
// Theme operations
async getThemes(): Promise<{ data?: Theme[]; error?: string }> {
return this.publicRequest<Theme[]>('/themes');
}
async getTheme(themeId: string): Promise<{ data?: Theme; error?: string }> {
return this.publicRequest<Theme>(`/themes/${themeId}`);
}
// Share operations
async createShareLink(
token: string,
deckId: string,
expiresAt?: string
): Promise<{ data?: ShareLink; error?: string }> {
return this.request<ShareLink>(token, `/share/deck/${deckId}`, {
method: 'POST',
body: JSON.stringify({ expiresAt }),
});
}
async getShareLinks(token: string, deckId: string): Promise<{ data?: ShareLink[]; error?: string }> {
return this.request<ShareLink[]>(token, `/share/deck/${deckId}/links`);
}
async deleteShareLink(token: string, shareId: string): Promise<{ error?: string }> {
return this.request(token, `/share/${shareId}`, { method: 'DELETE' });
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.backendUrl}/health`);
return response.ok;
} catch {
return false;
}
}
getShareUrl(shareCode: string): string {
return `${this.backendUrl}/share/${shareCode}`;
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SessionService } from './session.service';
@Module({
providers: [SessionService],
exports: [SessionService],
})
export class SessionModule {}

View 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;
}
}

View 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
}
}