From 28637dffc2d8cd8310ed13bfb98f141d96000e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 00:14:54 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20Matrix=20self-?= =?UTF-8?q?hosting=20plan=20for=20GDPR-compliant=20bots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive plan for migrating Telegram bots to self-hosted Matrix: - Synapse homeserver setup on Mac Mini - Element Web client configuration - Bot migration strategy (NestJS with matrix-bot-sdk) - Docker Compose integration - Cloudflare Tunnel configuration https://claude.ai/code/session_01E3r5aFW3YLAhEJfsL2ryhv --- docs/MATRIX_SELF_HOSTING.md | 674 ++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 docs/MATRIX_SELF_HOSTING.md diff --git a/docs/MATRIX_SELF_HOSTING.md b/docs/MATRIX_SELF_HOSTING.md new file mode 100644 index 000000000..8e5969f5e --- /dev/null +++ b/docs/MATRIX_SELF_HOSTING.md @@ -0,0 +1,674 @@ +# Matrix Self-Hosting auf Mac Mini + +Plan für DSGVO-konformes Messaging mit Matrix/Synapse auf dem ManaCore Server. + +## Übersicht + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Internet │ +│ │ │ +│ ▼ │ +│ Cloudflare Tunnel │ +│ │ │ +│ ├─── matrix.mana.how ──────► Synapse (Port 8008) │ +│ ├─── element.mana.how ─────► Element Web (Port 8087) │ +│ └─── (bestehende Services) │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Docker Container │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ +│ │ │ Synapse │ │ Element Web │ │ Matrix Bots │ │ │ +│ │ │ (8008) │ │ (8087) │ │ (NestJS) │ │ │ +│ │ └──────┬───────┘ └──────────────┘ └────────┬─────────┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ PostgreSQL │ │ Ollama │ │ │ +│ │ │ (matrix db) │ │ (11434) │ │ │ +│ │ └──────────────┘ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## DSGVO-Vorteile + +| Aspekt | Telegram | Matrix (Self-Hosted) | +|--------|----------|----------------------| +| Datenstandort | Dubai/Singapur | Mac Mini (Deutschland) | +| AV-Vertrag | Nicht möglich | Nicht nötig (eigene Daten) | +| E2E-Verschlüsselung | Nur Secret Chats | Standard für alle Räume | +| Metadaten | Bei Telegram | Lokal gespeichert | +| Löschung | Abhängig von Telegram | Volle Kontrolle | + +--- + +## Phase 1: Synapse Homeserver + +### 1.1 Datenbank erstellen + +```bash +ssh mana-server + +# Neue Datenbank für Matrix +docker exec manacore-postgres psql -U postgres -c "CREATE DATABASE matrix;" +docker exec manacore-postgres psql -U postgres -c "CREATE USER synapse WITH PASSWORD 'synapse-secure-password';" +docker exec manacore-postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE matrix TO synapse;" +``` + +### 1.2 Synapse Konfiguration erstellen + +```bash +# Verzeichnis erstellen +mkdir -p ~/projects/manacore-monorepo/docker/matrix + +# Synapse Config generieren (einmalig) +docker run -it --rm \ + -v ~/projects/manacore-monorepo/docker/matrix:/data \ + -e SYNAPSE_SERVER_NAME=mana.how \ + -e SYNAPSE_REPORT_STATS=no \ + matrixdotorg/synapse:latest generate +``` + +### 1.3 homeserver.yaml anpassen + +**Datei:** `docker/matrix/homeserver.yaml` + +```yaml +server_name: "mana.how" +pid_file: /data/homeserver.pid + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +database: + name: psycopg2 + args: + user: synapse + password: "synapse-secure-password" + database: matrix + host: postgres + port: 5432 + cp_min: 5 + cp_max: 10 + +# Logging +log_config: "/data/mana.how.log.config" + +# Media Store (lokaler Speicher für Medien) +media_store_path: /data/media_store +max_upload_size: 50M + +# Registrierung +enable_registration: false +enable_registration_without_verification: false + +# Admin-Account beim ersten Start erstellen +# Nach dem Start: docker exec -it synapse register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008 -a + +# Rate Limiting (für Bots erhöhen) +rc_message: + per_second: 5 + burst_count: 20 + +rc_registration: + per_second: 0.5 + burst_count: 5 + +# Für Bot-Integration: Application Services erlauben +app_service_config_files: [] + +# DSGVO: Datenaufbewahrung begrenzen +retention: + enabled: true + default_policy: + min_lifetime: 1d + max_lifetime: 365d + allowed_lifetime_min: 1d + allowed_lifetime_max: 365d + purge_jobs: + - longest_max_lifetime: 3d + interval: 12h + - shortest_max_lifetime: 365d + interval: 1d + +# Telemetrie deaktivieren +report_stats: false + +# Trusted Key Server (Matrix.org) +trusted_key_servers: + - server_name: "matrix.org" + +# Signing Key +signing_key_path: "/data/mana.how.signing.key" +``` + +### 1.4 Docker Compose Ergänzung + +Füge zu `docker-compose.macmini.yml` hinzu: + +```yaml + # ============================================ + # Matrix Synapse (Homeserver) + # ============================================ + + synapse: + image: matrixdotorg/synapse:latest + container_name: manacore-synapse + restart: always + depends_on: + postgres: + condition: service_healthy + environment: + SYNAPSE_CONFIG_PATH: /data/homeserver.yaml + volumes: + - ./docker/matrix:/data + - synapse_media:/data/media_store + ports: + - "8008:8008" + healthcheck: + test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # ============================================ + # Element Web (Matrix Client) + # ============================================ + + element-web: + image: vectorim/element-web:latest + container_name: manacore-element + restart: always + depends_on: + synapse: + condition: service_healthy + volumes: + - ./docker/matrix/element-config.json:/app/config.json:ro + ports: + - "8087:80" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + +# Volumes ergänzen: +volumes: + synapse_media: + name: manacore-synapse-media +``` + +### 1.5 Element Web Konfiguration + +**Datei:** `docker/matrix/element-config.json` + +```json +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://matrix.mana.how", + "server_name": "mana.how" + }, + "m.identity_server": { + "base_url": "" + } + }, + "brand": "ManaCore Chat", + "integrations_ui_url": "", + "integrations_rest_url": "", + "integrations_widgets_urls": [], + "disable_guests": true, + "disable_3pid_login": true, + "default_country_code": "DE", + "show_labs_settings": false, + "features": { + "feature_video_rooms": true, + "feature_group_calls": true + }, + "room_directory": { + "servers": ["mana.how"] + }, + "setting_defaults": { + "breadcrumbs": true + }, + "default_theme": "dark" +} +``` + +### 1.6 Cloudflare Tunnel erweitern + +**Datei:** `~/.cloudflared/config.yml` + +```yaml +# Bestehende Einträge... + + - hostname: matrix.mana.how + service: http://localhost:8008 + + - hostname: element.mana.how + service: http://localhost:8087 +``` + +Nach Änderung: +```bash +launchctl stop com.cloudflare.cloudflared +launchctl start com.cloudflare.cloudflared +``` + +--- + +## Phase 2: Synapse starten & Admin erstellen + +### 2.1 Container starten + +```bash +cd ~/projects/manacore-monorepo + +# Nur Synapse + Element starten +docker compose -f docker-compose.macmini.yml up -d synapse element-web + +# Logs prüfen +docker logs -f manacore-synapse +``` + +### 2.2 Admin-User erstellen + +```bash +# Interaktiv einen Admin erstellen +docker exec -it manacore-synapse register_new_matrix_user \ + -c /data/homeserver.yaml \ + http://localhost:8008 \ + -a + +# Eingeben: +# Username: admin +# Password: (sicheres Passwort) +# Admin: yes +``` + +### 2.3 Testen + +```bash +# Health Check +curl https://matrix.mana.how/health +# Erwartete Antwort: OK + +# Federation Check +curl https://matrix.mana.how/_matrix/federation/v1/version +# Erwartete Antwort: {"server":{"name":"Synapse","version":"..."}} + +# Element Web aufrufen +open https://element.mana.how +``` + +--- + +## Phase 3: Bot-Räume einrichten + +### 3.1 Räume erstellen (via Element) + +1. **Anmelden** bei https://element.mana.how mit Admin-Account +2. **Räume erstellen:** + - `#ollama-bot:mana.how` - AI Chat Bot + - `#stats-bot:mana.how` - Analytics Reports + - `#project-doc-bot:mana.how` - Projektdokumentation + +### 3.2 Bot-User erstellen + +```bash +# Bot-User für jeden Bot erstellen (nicht-Admin) +docker exec -it manacore-synapse register_new_matrix_user \ + -c /data/homeserver.yaml \ + http://localhost:8008 + +# Erstelle: +# - ollama-bot (Password notieren) +# - stats-bot (Password notieren) +# - projectdoc-bot (Password notieren) +``` + +### 3.3 Access Tokens generieren + +```bash +# Für jeden Bot ein Access Token holen +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "ollama-bot", + "password": "bot-password" + }' + +# Response: {"access_token": "syt_xxx", ...} +# Token für .env speichern +``` + +--- + +## Phase 4: Bot-Migration (NestJS) + +### 4.1 Neue Package-Struktur + +``` +services/ +├── telegram-ollama-bot/ # Alt (Telegram) +├── telegram-stats-bot/ # Alt (Telegram) +├── telegram-project-doc-bot/# Alt (Telegram) +│ +├── matrix-ollama-bot/ # NEU (Matrix) +├── matrix-stats-bot/ # NEU (Matrix) +└── matrix-project-doc-bot/ # NEU (Matrix) +``` + +### 4.2 Dependencies + +```bash +cd services/matrix-ollama-bot +pnpm add matrix-bot-sdk +``` + +### 4.3 Bot-Grundstruktur (Beispiel: Ollama Bot) + +**Datei:** `services/matrix-ollama-bot/src/bot/matrix.service.ts` + +```typescript +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, +} from 'matrix-bot-sdk'; +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private client: MatrixClient; + + constructor(private config: ConfigService) {} + + async onModuleInit() { + LogService.setLogger(new RichConsoleLogger()); + + const homeserverUrl = this.config.get('MATRIX_HOMESERVER_URL'); + const accessToken = this.config.get('MATRIX_ACCESS_TOKEN'); + + const storage = new SimpleFsStorageProvider('bot-storage.json'); + + this.client = new MatrixClient(homeserverUrl, accessToken, storage); + + // Auto-join bei Einladungen + AutojoinRoomsMixin.setupOnClient(this.client); + + // Message Handler + this.client.on('room.message', this.handleMessage.bind(this)); + + await this.client.start(); + console.log('Matrix bot started!'); + } + + async onModuleDestroy() { + await this.client.stop(); + } + + private async handleMessage(roomId: string, event: any) { + // Eigene Nachrichten ignorieren + if (event.sender === await this.client.getUserId()) return; + + // Nur Text-Nachrichten + if (event.content?.msgtype !== 'm.text') return; + + const body = event.content.body; + + // Command-Handler + if (body.startsWith('!')) { + await this.handleCommand(roomId, event, body); + } else { + // Normaler Chat → Ollama + await this.handleChat(roomId, event, body); + } + } + + private async handleCommand(roomId: string, event: any, body: string) { + const [command, ...args] = body.slice(1).split(' '); + + switch (command.toLowerCase()) { + case 'help': + await this.sendMessage(roomId, this.getHelpText()); + break; + case 'models': + // Liste verfügbare Modelle + break; + case 'clear': + // Chat-History löschen + break; + // ... weitere Commands + } + } + + private async handleChat(roomId: string, event: any, message: string) { + // Typing-Indikator senden + await this.client.setTyping(roomId, true); + + // Ollama-Anfrage (wie bisher) + const response = await this.ollamaService.chat(message); + + await this.client.setTyping(roomId, false); + await this.sendMessage(roomId, response); + } + + async sendMessage(roomId: string, message: string) { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(message), + }); + } + + private getHelpText(): string { + return `**ManaCore Ollama Bot** + +Befehle: +- \`!help\` - Diese Hilfe +- \`!models\` - Verfügbare Modelle +- \`!model \` - Modell wechseln +- \`!clear\` - Chat-Verlauf löschen + +Einfach eine Nachricht schreiben für AI-Chat.`; + } +} +``` + +### 4.4 Environment Variables + +**Datei:** `services/matrix-ollama-bot/.env` + +```env +# Server +PORT=3311 + +# Matrix +MATRIX_HOMESERVER_URL=https://matrix.mana.how +MATRIX_ACCESS_TOKEN=syt_xxx + +# Optional: Nur bestimmte Räume erlauben +MATRIX_ALLOWED_ROOMS=#ollama-bot:mana.how + +# Ollama +OLLAMA_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_TIMEOUT=120000 +``` + +### 4.5 Docker Compose für Matrix Bots + +```yaml + # ============================================ + # Matrix Ollama Bot + # ============================================ + + matrix-ollama-bot: + image: ghcr.io/memo-2023/matrix-ollama-bot:latest + container_name: manacore-matrix-ollama-bot + restart: always + depends_on: + synapse: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3311 + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} + OLLAMA_URL: http://host.docker.internal:11434 + OLLAMA_MODEL: gemma3:4b + volumes: + - matrix_ollama_bot_data:/app/data + ports: + - "3311:3311" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3311/health"] + interval: 30s + timeout: 10s + retries: 3 + +# Volume ergänzen: +volumes: + matrix_ollama_bot_data: + name: manacore-matrix-ollama-bot +``` + +--- + +## Phase 5: Feature-Mapping Telegram → Matrix + +### Commands + +| Telegram | Matrix | Beschreibung | +|----------|--------|--------------| +| `/start` | `!help` | Hilfe anzeigen | +| `/help` | `!help` | Hilfe anzeigen | +| `/models` | `!models` | Modelle auflisten | +| `/model x` | `!model x` | Modell wechseln | +| `/clear` | `!clear` | Chat löschen | +| `/status` | `!status` | Bot-Status | + +### Media-Handling + +| Feature | Telegram | Matrix | +|---------|----------|--------| +| Foto senden | `ctx.message.photo` | `m.image` msgtype | +| Voice senden | `ctx.message.voice` | `m.audio` msgtype | +| Datei senden | `ctx.message.document` | `m.file` msgtype | +| Foto antworten | `ctx.replyWithPhoto()` | `sendMessage()` mit `m.image` | + +### Beispiel: Media-Download in Matrix + +```typescript +async downloadMedia(event: any): Promise { + const mxcUrl = event.content.url; // mxc://mana.how/abc123 + const httpUrl = this.client.mxcToHttp(mxcUrl); + + const response = await fetch(httpUrl); + return Buffer.from(await response.arrayBuffer()); +} +``` + +--- + +## Phase 6: Health Check & Monitoring + +### Health Checks ergänzen + +**Datei:** `scripts/mac-mini/health-check.sh` + +```bash +# Matrix Synapse +if curl -sf http://localhost:8008/health > /dev/null; then + echo "✅ Synapse: OK" +else + echo "❌ Synapse: FAILED" + FAILED_SERVICES="$FAILED_SERVICES synapse" +fi + +# Element Web +if curl -sf http://localhost:8087/ > /dev/null; then + echo "✅ Element Web: OK" +else + echo "❌ Element Web: FAILED" + FAILED_SERVICES="$FAILED_SERVICES element-web" +fi + +# Matrix Ollama Bot +if curl -sf http://localhost:3311/health > /dev/null; then + echo "✅ Matrix Ollama Bot: OK" +else + echo "❌ Matrix Ollama Bot: FAILED" + FAILED_SERVICES="$FAILED_SERVICES matrix-ollama-bot" +fi +``` + +### Prometheus Metrics (optional) + +Synapse exportiert Metrics auf Port 9000 (kann aktiviert werden): + +```yaml +# In homeserver.yaml ergänzen +enable_metrics: true +metrics_port: 9000 + +# prometheus.yml ergänzen +- job_name: 'synapse' + static_configs: + - targets: ['synapse:9000'] +``` + +--- + +## Zeitplan + +| Phase | Aufgabe | Aufwand | +|-------|---------|---------| +| **1** | Synapse + Element aufsetzen | 1-2h | +| **2** | Admin & Bot-User erstellen | 30min | +| **3** | Bot-Räume einrichten | 30min | +| **4** | Ersten Bot migrieren (Ollama) | 2-4h | +| **5** | Weitere Bots migrieren | je 1-2h | +| **6** | Monitoring & Alerts | 1h | + +**Gesamt:** ~1 Tag für Grundsetup + Bot-Migration + +--- + +## Nächste Schritte + +1. [ ] `docker/matrix/` Verzeichnis erstellen +2. [ ] Synapse Config generieren +3. [ ] Docker Compose erweitern +4. [ ] Cloudflare Tunnel konfigurieren +5. [ ] Synapse starten & testen +6. [ ] Admin-Account erstellen +7. [ ] Bot-User erstellen +8. [ ] `matrix-ollama-bot` Service erstellen +9. [ ] Bot testen +10. [ ] Weitere Bots migrieren +11. [ ] Telegram Bots deaktivieren + +--- + +## Ressourcen + +- [Matrix Spec](https://spec.matrix.org/) +- [Synapse Docs](https://element-hq.github.io/synapse/latest/) +- [matrix-bot-sdk](https://github.com/turt2live/matrix-bot-sdk) +- [Element Web Config](https://github.com/element-hq/element-web/blob/develop/docs/config.md) From 3aa9e8608d91321feba055e06ec8a3abbabec667 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 00:20:12 +0000 Subject: [PATCH 2/4] feat(matrix): add self-hosted Matrix infrastructure for GDPR compliance Add complete Matrix/Synapse setup as Telegram bot alternative: Docker configuration: - Synapse homeserver (port 8008) with PostgreSQL backend - Element Web client (port 8087) with ManaCore branding - DSGVO-compliant data retention policies (1-365 days) - Prometheus metrics endpoint for monitoring Config files: - docker/matrix/homeserver.yaml - Synapse configuration - docker/matrix/log.config.yaml - Logging with rotation - docker/matrix/element-config.json - Element Web settings Scripts & docs: - scripts/mac-mini/setup-matrix.sh - One-time initialization - Updated health-check.sh with Matrix services - Updated MAC_MINI_SERVER.md with Matrix documentation https://claude.ai/code/session_01E3r5aFW3YLAhEJfsL2ryhv --- docker-compose.macmini.yml | 57 +++++++++ docker/matrix/element-config.json | 47 ++++++++ docker/matrix/homeserver.yaml | 190 ++++++++++++++++++++++++++++++ docker/matrix/log.config.yaml | 34 ++++++ docs/MAC_MINI_SERVER.md | 34 ++++++ scripts/mac-mini/health-check.sh | 5 + scripts/mac-mini/setup-matrix.sh | 123 +++++++++++++++++++ 7 files changed, 490 insertions(+) create mode 100644 docker/matrix/element-config.json create mode 100644 docker/matrix/homeserver.yaml create mode 100644 docker/matrix/log.config.yaml create mode 100755 scripts/mac-mini/setup-matrix.sh diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index ad1c3df36..fd5e8af95 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -748,6 +748,61 @@ services: retries: 3 start_period: 40s + # ============================================ + # Matrix Synapse (Homeserver) - DSGVO-konform + # ============================================ + + synapse: + image: matrixdotorg/synapse:latest + container_name: manacore-synapse + restart: always + depends_on: + postgres: + condition: service_healthy + environment: + SYNAPSE_CONFIG_PATH: /data/homeserver.yaml + TZ: Europe/Berlin + # Secrets (override in .env) + SYNAPSE_DB_PASSWORD: ${SYNAPSE_DB_PASSWORD:-synapse-secure-password} + SYNAPSE_PASSWORD_PEPPER: ${SYNAPSE_PASSWORD_PEPPER:-change-me-pepper} + SYNAPSE_FORM_SECRET: ${SYNAPSE_FORM_SECRET:-change-me-form-secret} + SYNAPSE_MACAROON_SECRET: ${SYNAPSE_MACAROON_SECRET:-change-me-macaroon-secret} + SYNAPSE_REGISTRATION_SECRET: ${SYNAPSE_REGISTRATION_SECRET:-change-me-registration-secret} + volumes: + - ./docker/matrix/homeserver.yaml:/data/homeserver.yaml:ro + - ./docker/matrix/log.config.yaml:/data/log.config.yaml:ro + - synapse_data:/data + ports: + - "8008:8008" + - "9000:9000" + healthcheck: + test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # ============================================ + # Element Web (Matrix Client) + # ============================================ + + element-web: + image: vectorim/element-web:latest + container_name: manacore-element + restart: always + depends_on: + synapse: + condition: service_healthy + volumes: + - ./docker/matrix/element-config.json:/app/config.json:ro + ports: + - "8087:80" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -786,3 +841,5 @@ volumes: name: manacore-grafana n8n_data: name: manacore-n8n + synapse_data: + name: manacore-synapse diff --git a/docker/matrix/element-config.json b/docker/matrix/element-config.json new file mode 100644 index 000000000..ac98fd000 --- /dev/null +++ b/docker/matrix/element-config.json @@ -0,0 +1,47 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://matrix.mana.how", + "server_name": "mana.how" + }, + "m.identity_server": { + "base_url": "" + } + }, + "brand": "ManaCore Chat", + "integrations_ui_url": "", + "integrations_rest_url": "", + "integrations_widgets_urls": [], + "disable_guests": true, + "disable_3pid_login": true, + "default_country_code": "DE", + "show_labs_settings": false, + "features": { + "feature_video_rooms": true, + "feature_group_calls": true, + "feature_thread": true + }, + "room_directory": { + "servers": ["mana.how"] + }, + "setting_defaults": { + "breadcrumbs": true, + "custom_themes": [] + }, + "default_theme": "dark", + "permalink_prefix": "https://element.mana.how", + "terms_and_conditions_links": [], + "privacy_policy_url": "https://mana.how/privacy", + "sso_redirect_options": { + "immediate": false + }, + "posthog": { + "disabled": true + }, + "sentry": { + "disabled": true + }, + "bug_report_endpoint_url": "", + "help_url": "https://mana.how/help", + "help_encryption_url": "https://element.io/help#encryption" +} diff --git a/docker/matrix/homeserver.yaml b/docker/matrix/homeserver.yaml new file mode 100644 index 000000000..ffae2de73 --- /dev/null +++ b/docker/matrix/homeserver.yaml @@ -0,0 +1,190 @@ +# ManaCore Matrix Synapse Configuration +# Documentation: https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html + +server_name: "mana.how" +pid_file: /data/homeserver.pid +public_baseurl: https://matrix.mana.how/ + +# ============================================ +# Listeners +# ============================================ + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +# ============================================ +# Database (PostgreSQL) +# ============================================ + +database: + name: psycopg2 + txn_limit: 10000 + args: + user: synapse + password: "${SYNAPSE_DB_PASSWORD:-synapse-secure-password}" + database: matrix + host: postgres + port: 5432 + cp_min: 5 + cp_max: 10 + +# ============================================ +# Logging +# ============================================ + +log_config: "/data/log.config.yaml" + +# ============================================ +# Media Storage +# ============================================ + +media_store_path: /data/media_store +max_upload_size: 50M +url_preview_enabled: true +url_preview_ip_range_blacklist: + - '127.0.0.0/8' + - '10.0.0.0/8' + - '172.16.0.0/12' + - '192.168.0.0/16' + - '100.64.0.0/10' + - '192.0.0.0/24' + - '169.254.0.0/16' + - '198.18.0.0/15' + - '192.0.2.0/24' + - '198.51.100.0/24' + - '203.0.113.0/24' + - '224.0.0.0/4' + - '::1/128' + - 'fe80::/10' + - 'fc00::/7' + - '2001:db8::/32' + - 'ff00::/8' + - 'fec0::/10' + +# ============================================ +# Registration & Authentication +# ============================================ + +enable_registration: false +enable_registration_without_verification: false + +# Password config +password_config: + enabled: true + localdb_enabled: true + pepper: "${SYNAPSE_PASSWORD_PEPPER:-change-me-pepper}" + +# Session lifetime +session_lifetime: 24h +refresh_token_lifetime: 168h + +# ============================================ +# Rate Limiting +# ============================================ + +rc_message: + per_second: 5 + burst_count: 20 + +rc_registration: + per_second: 0.5 + burst_count: 5 + +rc_login: + address: + per_second: 0.5 + burst_count: 5 + account: + per_second: 0.5 + burst_count: 5 + failed_attempts: + per_second: 0.5 + burst_count: 5 + +# ============================================ +# Federation +# ============================================ + +# Allow federation with other Matrix servers +federation_domain_whitelist: [] + +trusted_key_servers: + - server_name: "matrix.org" + +# ============================================ +# DSGVO / Data Retention +# ============================================ + +retention: + enabled: true + default_policy: + min_lifetime: 1d + max_lifetime: 365d + allowed_lifetime_min: 1d + allowed_lifetime_max: 365d + purge_jobs: + - longest_max_lifetime: 3d + interval: 12h + - shortest_max_lifetime: 365d + interval: 1d + +# Forgotten room retention +forgotten_room_retention_period: 7d + +# ============================================ +# Security +# ============================================ + +signing_key_path: "/data/signing.key" + +form_secret: "${SYNAPSE_FORM_SECRET:-change-me-form-secret}" +macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET:-change-me-macaroon-secret}" +registration_shared_secret: "${SYNAPSE_REGISTRATION_SECRET:-change-me-registration-secret}" + +# ============================================ +# Application Services (for Bots) +# ============================================ + +app_service_config_files: [] + +# ============================================ +# Metrics & Telemetry +# ============================================ + +report_stats: false +enable_metrics: true +metrics_port: 9000 + +# ============================================ +# Caching +# ============================================ + +caches: + global_factor: 0.5 + per_cache_factors: {} + expire_caches: true + cache_entry_ttl: 30m + +# ============================================ +# Background Tasks +# ============================================ + +run_background_tasks_on: synapse + +# ============================================ +# Email (optional, for password reset) +# ============================================ + +# email: +# smtp_host: smtp-relay.brevo.com +# smtp_port: 587 +# smtp_user: "${SMTP_USER}" +# smtp_pass: "${SMTP_PASSWORD}" +# require_transport_security: true +# notif_from: "ManaCore Matrix " diff --git a/docker/matrix/log.config.yaml b/docker/matrix/log.config.yaml new file mode 100644 index 000000000..39a2480ba --- /dev/null +++ b/docker/matrix/log.config.yaml @@ -0,0 +1,34 @@ +# Synapse Logging Configuration + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + console: + class: logging.StreamHandler + formatter: precise + stream: 'ext://sys.stdout' + + file: + class: logging.handlers.TimedRotatingFileHandler + formatter: precise + filename: /data/logs/homeserver.log + when: midnight + backupCount: 7 + encoding: utf8 + +loggers: + synapse.storage.SQL: + level: WARNING + + synapse.access.http.8008: + level: WARNING + +root: + level: INFO + handlers: [console, file] + +disable_existing_loggers: false diff --git a/docs/MAC_MINI_SERVER.md b/docs/MAC_MINI_SERVER.md index 097195721..ace021a3c 100644 --- a/docs/MAC_MINI_SERVER.md +++ b/docs/MAC_MINI_SERVER.md @@ -71,6 +71,8 @@ Cloudflare Tunnel (cloudflared) | Todo | https://todo.mana.how | | Calendar | https://calendar.mana.how | | Clock | https://clock.mana.how | +| Matrix (Synapse) | https://matrix.mana.how | +| Element Web | https://element.mana.how | ## SSH-Zugang @@ -260,6 +262,8 @@ curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" | manacore-calendar-web | Calendar Frontend | | manacore-clock-backend | Clock API | | manacore-clock-web | Clock Frontend | +| manacore-synapse | Matrix Homeserver | +| manacore-element | Element Web Client | ### Nützliche Docker-Befehle @@ -597,6 +601,35 @@ launchctl stop com.manacore.telegram-ollama-bot launchctl start com.manacore.telegram-ollama-bot ``` +## Matrix (DSGVO-konformes Messaging) + +Matrix ist eine DSGVO-konforme Alternative zu Telegram für Bot-Kommunikation. + +### Komponenten + +| Service | Port | Beschreibung | +|---------|------|--------------| +| Synapse | 8008 | Matrix Homeserver | +| Element Web | 8087 | Web-Client | + +### Setup + +```bash +# Matrix initialisieren +./scripts/mac-mini/setup-matrix.sh + +# Services starten +docker compose -f docker-compose.macmini.yml up -d synapse element-web + +# Admin-User erstellen +docker exec -it manacore-synapse register_new_matrix_user \ + -c /data/homeserver.yaml http://localhost:8008 -a +``` + +### Dokumentation + +Siehe [MATRIX_SELF_HOSTING.md](./MATRIX_SELF_HOSTING.md) für detaillierte Anleitung. + ## Chronologie der Einrichtung 1. **Docker Setup** - PostgreSQL, Redis, App-Container @@ -608,3 +641,4 @@ launchctl start com.manacore.telegram-ollama-bot 7. **Email Notifications** - Redundante Benachrichtigung 8. **Ollama** - Lokale LLM-Inferenz (Gemma 3 4B) 9. **Telegram Ollama Bot** - Chat-Interface für Ollama +10. **Matrix Synapse** - DSGVO-konformes Messaging diff --git a/scripts/mac-mini/health-check.sh b/scripts/mac-mini/health-check.sh index 65151f2a0..887af6d51 100755 --- a/scripts/mac-mini/health-check.sh +++ b/scripts/mac-mini/health-check.sh @@ -229,6 +229,11 @@ echo "Presi:" check_service "Presi Backend" "http://localhost:3008/api/v1/health" check_service "Presi Web" "http://localhost:5178/health" +echo "" +echo "Matrix (DSGVO-konform):" +check_service "Synapse" "http://localhost:8008/health" +check_service "Element Web" "http://localhost:8087/" + echo "" echo "Cloudflare Tunnel:" if pgrep -x "cloudflared" >/dev/null; then diff --git a/scripts/mac-mini/setup-matrix.sh b/scripts/mac-mini/setup-matrix.sh new file mode 100755 index 000000000..786b75559 --- /dev/null +++ b/scripts/mac-mini/setup-matrix.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Setup Matrix Synapse on Mac Mini +# Run this script once to initialize Matrix + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +MATRIX_DIR="$PROJECT_DIR/docker/matrix" + +echo "============================================" +echo " ManaCore Matrix Setup" +echo "============================================" +echo "" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Check if postgres is running +echo "Checking PostgreSQL..." +if ! docker exec manacore-postgres pg_isready -U postgres > /dev/null 2>&1; then + echo -e "${RED}Error: PostgreSQL is not running.${NC}" + echo "Start it with: docker compose -f docker-compose.macmini.yml up -d postgres" + exit 1 +fi +echo -e "${GREEN}PostgreSQL is running${NC}" + +# Create matrix database +echo "" +echo "Creating Matrix database..." +if docker exec manacore-postgres psql -U postgres -lqt | cut -d \| -f 1 | grep -qw matrix; then + echo -e "${YELLOW}Database 'matrix' already exists${NC}" +else + docker exec manacore-postgres psql -U postgres -c "CREATE DATABASE matrix;" + echo -e "${GREEN}Database 'matrix' created${NC}" +fi + +# Create synapse user +echo "" +echo "Creating Synapse database user..." +if docker exec manacore-postgres psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='synapse'" | grep -q 1; then + echo -e "${YELLOW}User 'synapse' already exists${NC}" +else + # Generate a random password if not set + SYNAPSE_DB_PASSWORD=${SYNAPSE_DB_PASSWORD:-$(openssl rand -base64 24)} + docker exec manacore-postgres psql -U postgres -c "CREATE USER synapse WITH PASSWORD '$SYNAPSE_DB_PASSWORD';" + docker exec manacore-postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE matrix TO synapse;" + docker exec manacore-postgres psql -U postgres -c "ALTER DATABASE matrix OWNER TO synapse;" + echo -e "${GREEN}User 'synapse' created${NC}" + echo "" + echo -e "${YELLOW}IMPORTANT: Add this to your .env file:${NC}" + echo "SYNAPSE_DB_PASSWORD=$SYNAPSE_DB_PASSWORD" +fi + +# Create logs directory in volume +echo "" +echo "Creating logs directory..." +mkdir -p "$MATRIX_DIR/logs" 2>/dev/null || true + +# Generate signing key if not exists +echo "" +echo "Checking signing key..." +if docker volume ls | grep -q manacore-synapse; then + echo -e "${YELLOW}Synapse volume already exists - signing key should be present${NC}" +else + echo "Signing key will be generated on first Synapse start" +fi + +# Generate secrets if not set +echo "" +echo "============================================" +echo " Required Environment Variables" +echo "============================================" +echo "" +echo "Add these to your .env file (generate secure values!):" +echo "" + +# Generate random secrets for display +echo "SYNAPSE_DB_PASSWORD=$(openssl rand -base64 24)" +echo "SYNAPSE_PASSWORD_PEPPER=$(openssl rand -base64 32)" +echo "SYNAPSE_FORM_SECRET=$(openssl rand -base64 32)" +echo "SYNAPSE_MACAROON_SECRET=$(openssl rand -base64 32)" +echo "SYNAPSE_REGISTRATION_SECRET=$(openssl rand -base64 32)" + +echo "" +echo "============================================" +echo " Cloudflare Tunnel Configuration" +echo "============================================" +echo "" +echo "Add these ingress rules to ~/.cloudflared/config.yml:" +echo "" +echo " - hostname: matrix.mana.how" +echo " service: http://localhost:8008" +echo "" +echo " - hostname: element.mana.how" +echo " service: http://localhost:8087" +echo "" + +echo "" +echo "============================================" +echo " Next Steps" +echo "============================================" +echo "" +echo "1. Add environment variables to .env file" +echo "2. Update Cloudflare Tunnel config" +echo "3. Start Matrix services:" +echo " docker compose -f docker-compose.macmini.yml up -d synapse element-web" +echo "" +echo "4. Wait for Synapse to start (check logs):" +echo " docker logs -f manacore-synapse" +echo "" +echo "5. Create admin user:" +echo " docker exec -it manacore-synapse register_new_matrix_user \\" +echo " -c /data/homeserver.yaml http://localhost:8008 -a" +echo "" +echo "6. Test endpoints:" +echo " curl https://matrix.mana.how/health" +echo " open https://element.mana.how" +echo "" +echo -e "${GREEN}Setup complete!${NC}" From aabe328b516d1c4181daa708b64e82c236045479 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 00:35:35 +0000 Subject: [PATCH 3/4] feat(matrix): add Matrix Ollama Bot service GDPR-compliant replacement for telegram-ollama-bot using Matrix protocol: New service: services/matrix-ollama-bot/ - NestJS application with matrix-bot-sdk - Same functionality as telegram-ollama-bot - Commands: !help, !models, !model, !mode, !clear, !status - System prompts: default, classify, summarize, translate, code - Chat history per user (last 10 messages) Changes: - docker-compose.macmini.yml: Added matrix-ollama-bot service - health-check.sh: Added Matrix Ollama Bot health check Environment variables required: - MATRIX_OLLAMA_BOT_TOKEN: Bot access token - MATRIX_OLLAMA_BOT_ROOMS: Optional room restrictions https://claude.ai/code/session_01E3r5aFW3YLAhEJfsL2ryhv --- docker-compose.macmini.yml | 34 ++ scripts/mac-mini/health-check.sh | 1 + services/matrix-ollama-bot/.env.example | 15 + services/matrix-ollama-bot/CLAUDE.md | 137 +++++++ services/matrix-ollama-bot/Dockerfile | 53 +++ services/matrix-ollama-bot/nest-cli.json | 8 + services/matrix-ollama-bot/package.json | 34 ++ services/matrix-ollama-bot/src/app.module.ts | 17 + .../matrix-ollama-bot/src/bot/bot.module.ts | 10 + .../src/bot/matrix.service.ts | 340 ++++++++++++++++++ .../src/config/configuration.ts | 22 ++ .../src/health.controller.ts | 13 + services/matrix-ollama-bot/src/main.ts | 15 + .../src/ollama/ollama.module.ts | 8 + .../src/ollama/ollama.service.ts | 94 +++++ services/matrix-ollama-bot/tsconfig.json | 22 ++ 16 files changed, 823 insertions(+) create mode 100644 services/matrix-ollama-bot/.env.example create mode 100644 services/matrix-ollama-bot/CLAUDE.md create mode 100644 services/matrix-ollama-bot/Dockerfile create mode 100644 services/matrix-ollama-bot/nest-cli.json create mode 100644 services/matrix-ollama-bot/package.json create mode 100644 services/matrix-ollama-bot/src/app.module.ts create mode 100644 services/matrix-ollama-bot/src/bot/bot.module.ts create mode 100644 services/matrix-ollama-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-ollama-bot/src/config/configuration.ts create mode 100644 services/matrix-ollama-bot/src/health.controller.ts create mode 100644 services/matrix-ollama-bot/src/main.ts create mode 100644 services/matrix-ollama-bot/src/ollama/ollama.module.ts create mode 100644 services/matrix-ollama-bot/src/ollama/ollama.service.ts create mode 100644 services/matrix-ollama-bot/tsconfig.json diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index fd5e8af95..3178c2ed9 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -803,6 +803,38 @@ services: timeout: 10s retries: 3 + # ============================================ + # Matrix Ollama Bot (GDPR-compliant AI Chat) + # ============================================ + + matrix-ollama-bot: + image: ghcr.io/memo-2023/matrix-ollama-bot:latest + container_name: manacore-matrix-ollama-bot + restart: always + depends_on: + synapse: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3311 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} + MATRIX_ALLOWED_ROOMS: ${MATRIX_OLLAMA_BOT_ROOMS:-} + OLLAMA_URL: http://host.docker.internal:11434 + OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b} + OLLAMA_TIMEOUT: 120000 + volumes: + - matrix_ollama_bot_data:/app/data + ports: + - "3311:3311" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3311/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -843,3 +875,5 @@ volumes: name: manacore-n8n synapse_data: name: manacore-synapse + matrix_ollama_bot_data: + name: manacore-matrix-ollama-bot diff --git a/scripts/mac-mini/health-check.sh b/scripts/mac-mini/health-check.sh index 887af6d51..f121f150e 100755 --- a/scripts/mac-mini/health-check.sh +++ b/scripts/mac-mini/health-check.sh @@ -233,6 +233,7 @@ echo "" echo "Matrix (DSGVO-konform):" check_service "Synapse" "http://localhost:8008/health" check_service "Element Web" "http://localhost:8087/" +check_service "Matrix Ollama Bot" "http://localhost:3311/health" echo "" echo "Cloudflare Tunnel:" diff --git a/services/matrix-ollama-bot/.env.example b/services/matrix-ollama-bot/.env.example new file mode 100644 index 000000000..d064c4f02 --- /dev/null +++ b/services/matrix-ollama-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3311 + +# Matrix Configuration +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_your_access_token_here +# Optional: Restrict to specific rooms (comma-separated) +MATRIX_ALLOWED_ROOMS= +# Path for bot sync storage +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Ollama Configuration +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_TIMEOUT=120000 diff --git a/services/matrix-ollama-bot/CLAUDE.md b/services/matrix-ollama-bot/CLAUDE.md new file mode 100644 index 000000000..a6c35830c --- /dev/null +++ b/services/matrix-ollama-bot/CLAUDE.md @@ -0,0 +1,137 @@ +# Matrix Ollama Bot - Claude Code Guidelines + +## Overview + +Matrix Ollama Bot provides a GDPR-compliant chat interface to local LLM inference via Ollama. It uses the Matrix protocol for messaging, which allows self-hosting all data on the Mac Mini server. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **LLM**: Ollama (local inference) + +## 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-ollama-bot/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & system prompts +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ └── ollama/ +│ ├── ollama.module.ts +│ └── ollama.service.ts # Ollama API client +├── Dockerfile +└── package.json +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!help` | Show help message | +| `!models` | List available Ollama models | +| `!model [name]` | Switch to a different model | +| `!mode [mode]` | Change system prompt mode | +| `!clear` | Clear chat history | +| `!status` | Show Ollama connection status | + +## System Prompt Modes + +| Mode | Description | +|------|-------------| +| `default` | General assistant | +| `classify` | Text classification | +| `summarize` | Text summarization | +| `translate` | Translation | +| `code` | Programming help | + +## Environment Variables + +```env +# Server +PORT=3311 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#ollama-bot:mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Ollama +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=gemma3:4b +OLLAMA_TIMEOUT=120000 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-ollama-bot/Dockerfile -t matrix-ollama-bot services/matrix-ollama-bot + +# Run +docker run -p 3311:3311 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e OLLAMA_URL=http://host.docker.internal:11434 \ + -v matrix-ollama-bot-data:/app/data \ + matrix-ollama-bot +``` + +## Health Check + +```bash +curl http://localhost:3311/health +``` + +## Getting a Matrix Access Token + +```bash +# Login to get access token +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "ollama-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Key Differences from Telegram Bot + +| Feature | Telegram | Matrix | +|---------|----------|--------| +| Commands | `/command` | `!command` | +| Message limit | 4096 chars | ~65535 chars | +| Data storage | Telegram servers | Self-hosted | +| E2E encryption | Bot chats unencrypted | Optional (not enabled) | +| Typing indicator | `sendChatAction` | `sendTyping` | + +## GDPR Compliance + +- All message data stored locally on Mac Mini +- No third-party data processing +- Full control over data retention +- Can delete all user data on request diff --git a/services/matrix-ollama-bot/Dockerfile b/services/matrix-ollama-bot/Dockerfile new file mode 100644 index 000000000..c9d2c5a0d --- /dev/null +++ b/services/matrix-ollama-bot/Dockerfile @@ -0,0 +1,53 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source +COPY . . + +# Build +RUN pnpm build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Create data directory for bot storage +RUN mkdir -p /app/data + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install production dependencies only +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod + +# Copy built files +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3311/health || exit 1 + +EXPOSE 3311 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-ollama-bot/nest-cli.json b/services/matrix-ollama-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-ollama-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-ollama-bot/package.json b/services/matrix-ollama-bot/package.json new file mode 100644 index 000000000..a1761a1dd --- /dev/null +++ b/services/matrix-ollama-bot/package.json @@ -0,0 +1,34 @@ +{ + "name": "@manacore/matrix-ollama-bot", + "version": "1.0.0", + "description": "Matrix bot for local LLM inference via Ollama - GDPR compliant", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-ollama-bot/src/app.module.ts b/services/matrix-ollama-bot/src/app.module.ts new file mode 100644 index 000000000..52d5de4a4 --- /dev/null +++ b/services/matrix-ollama-bot/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-ollama-bot/src/bot/bot.module.ts b/services/matrix-ollama-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..dbdd32451 --- /dev/null +++ b/services/matrix-ollama-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { OllamaModule } from '../ollama/ollama.module'; + +@Module({ + imports: [OllamaModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-ollama-bot/src/bot/matrix.service.ts b/services/matrix-ollama-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..d1f743f89 --- /dev/null +++ b/services/matrix-ollama-bot/src/bot/matrix.service.ts @@ -0,0 +1,340 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, + MessageEvent, + RoomEvent, +} from 'matrix-bot-sdk'; +import { OllamaService } from '../ollama/ollama.service'; +import { SYSTEM_PROMPTS } from '../config/configuration'; + +interface UserSession { + systemPrompt: string; + model: string; + history: { role: 'user' | 'assistant'; content: string }[]; +} + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private sessions: Map = new Map(); + private readonly allowedRooms: string[]; + private botUserId: string = ''; + + constructor( + private configService: ConfigService, + private ollamaService: OllamaService + ) { + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + // Setup logging + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogService.LogLevel.INFO); + + // Storage for sync token persistence + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + + // Create Matrix client + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + // Auto-join rooms when invited + AutojoinRoomsMixin.setupOnClient(this.client); + + // Get bot's user ID + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + // Setup message handler + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + // Start the client + await this.client.start(); + this.logger.log('Matrix bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix bot stopped'); + } + } + + private isRoomAllowed(roomId: string): boolean { + if (this.allowedRooms.length === 0) return true; + return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); + } + + private getSession(senderId: string): UserSession { + if (!this.sessions.has(senderId)) { + this.sessions.set(senderId, { + systemPrompt: SYSTEM_PROMPTS.default, + model: this.ollamaService.getDefaultModel(), + history: [], + }); + } + return this.sessions.get(senderId)!; + } + + private async handleRoomMessage(roomId: string, event: RoomEvent) { + // Ignore messages from self + if (event.sender === this.botUserId) return; + + // Check if room is allowed + if (!this.isRoomAllowed(roomId)) { + this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + return; + } + + // Only handle text messages + const content = event.content; + if (content.msgtype !== 'm.text') return; + + const body = content.body; + if (!body) return; + + this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); + + // Handle commands + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + return; + } + + // Regular chat message + await this.handleChat(roomId, event.sender, body); + } + + private async handleCommand(roomId: string, sender: string, body: string) { + const [command, ...args] = body.slice(1).split(' '); + const argString = args.join(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + + case 'models': + await this.sendModels(roomId, sender); + break; + + case 'model': + await this.setModel(roomId, sender, argString); + break; + + case 'mode': + await this.setMode(roomId, sender, argString); + break; + + case 'clear': + await this.clearHistory(roomId, sender); + break; + + case 'status': + await this.sendStatus(roomId, sender); + break; + + default: + await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help für eine Liste der Befehle.`); + } + } + + private async sendHelp(roomId: string) { + const helpText = `**Ollama Bot - Lokale KI (DSGVO-konform)** + +**Befehle:** +- \`!help\` - Diese Hilfe anzeigen +- \`!models\` - Verfügbare Modelle anzeigen +- \`!model [name]\` - Modell wechseln +- \`!mode [modus]\` - System-Prompt ändern +- \`!clear\` - Chat-Verlauf löschen +- \`!status\` - Ollama Status prüfen + +**Modi:** +- \`default\` - Allgemeiner Assistent +- \`classify\` - Text-Klassifizierung +- \`summarize\` - Zusammenfassungen +- \`translate\` - Übersetzungen +- \`code\` - Programmier-Hilfe + +**Verwendung:** +Schreibe einfach eine Nachricht und ich antworte! + +**Aktuelles Modell:** \`${this.ollamaService.getDefaultModel()}\``; + + await this.sendMessage(roomId, helpText); + } + + private async sendModels(roomId: string, sender: string) { + const models = await this.ollamaService.listModels(); + if (models.length === 0) { + await this.sendMessage(roomId, 'Keine Modelle gefunden. Ist Ollama gestartet?'); + return; + } + + const session = this.getSession(sender); + const modelList = models + .map((m) => { + const sizeMB = (m.size / 1024 / 1024).toFixed(0); + const active = m.name === session.model ? ' ✓' : ''; + return `- \`${m.name}\` (${sizeMB} MB)${active}`; + }) + .join('\n'); + + await this.sendMessage(roomId, `**Verfügbare Modelle:**\n\n${modelList}\n\nWechseln mit: \`!model [name]\``); + } + + private async setModel(roomId: string, sender: string, modelName: string) { + if (!modelName) { + const session = this.getSession(sender); + await this.sendMessage(roomId, `Aktuelles Modell: \`${session.model}\`\n\nVerwendung: \`!model gemma3:4b\``); + return; + } + + const models = await this.ollamaService.listModels(); + const exists = models.some((m) => m.name === modelName); + + if (!exists) { + const available = models.map((m) => m.name).join(', '); + await this.sendMessage(roomId, `Modell "${modelName}" nicht gefunden.\n\nVerfügbar: ${available}`); + return; + } + + const session = this.getSession(sender); + session.model = modelName; + session.history = []; + + this.logger.log(`User ${sender} switched to model ${modelName}`); + await this.sendMessage(roomId, `Modell gewechselt zu: \`${modelName}\``); + } + + private async setMode(roomId: string, sender: string, mode: string) { + const availableModes = Object.keys(SYSTEM_PROMPTS); + + if (!mode) { + const session = this.getSession(sender); + const currentMode = + Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt)?.[0] || 'custom'; + await this.sendMessage(roomId, `Aktueller Modus: \`${currentMode}\`\n\nVerfügbar: ${availableModes.join(', ')}`); + return; + } + + const normalizedMode = mode.toLowerCase(); + if (!SYSTEM_PROMPTS[normalizedMode]) { + await this.sendMessage(roomId, `Unbekannter Modus: ${mode}\n\nVerfügbar: ${availableModes.join(', ')}`); + return; + } + + const session = this.getSession(sender); + session.systemPrompt = SYSTEM_PROMPTS[normalizedMode]; + session.history = []; + + this.logger.log(`User ${sender} switched to mode ${normalizedMode}`); + await this.sendMessage(roomId, `Modus gewechselt zu: \`${normalizedMode}\``); + } + + private async clearHistory(roomId: string, sender: string) { + const session = this.getSession(sender); + session.history = []; + + this.logger.log(`User ${sender} cleared history`); + await this.sendMessage(roomId, 'Chat-Verlauf gelöscht.'); + } + + private async sendStatus(roomId: string, sender: string) { + const connected = await this.ollamaService.checkConnection(); + const models = await this.ollamaService.listModels(); + const session = this.getSession(sender); + + const statusText = `**Ollama Status** + +**Verbindung:** ${connected ? '✅ Online' : '❌ Offline'} +**Modelle:** ${models.length} +**Dein Modell:** \`${session.model}\` +**Chat-Verlauf:** ${session.history.length} Nachrichten +**DSGVO:** ✅ Alle Daten lokal`; + + await this.sendMessage(roomId, statusText); + } + + private async handleChat(roomId: string, sender: string, message: string) { + const session = this.getSession(sender); + + // Send typing indicator + await this.client.sendTyping(roomId, true, 30000); + + try { + // Add user message to history + session.history.push({ role: 'user', content: message }); + + // Keep only last 10 messages + if (session.history.length > 10) { + session.history = session.history.slice(-10); + } + + // Build messages with system prompt + const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [ + { role: 'system', content: session.systemPrompt }, + ...session.history, + ]; + + const response = await this.ollamaService.chat(messages, session.model); + + // Add assistant response to history + session.history.push({ role: 'assistant', content: response }); + + // Stop typing indicator + await this.client.sendTyping(roomId, false); + + // Send response (Matrix has higher message limits than Telegram) + await this.sendMessage(roomId, response); + } catch (error) { + await this.client.sendTyping(roomId, false); + this.logger.error(`Error processing message:`, error); + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `❌ Fehler: ${errorMessage}`); + } + } + + private async sendMessage(roomId: string, message: string) { + // Convert markdown to basic HTML for Matrix + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return markdown + // Code blocks + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Bold + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic + .replace(/\*([^*]+)\*/g, '$1') + // Line breaks + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-ollama-bot/src/config/configuration.ts b/services/matrix-ollama-bot/src/config/configuration.ts new file mode 100644 index 000000000..72d1af582 --- /dev/null +++ b/services/matrix-ollama-bot/src/config/configuration.ts @@ -0,0 +1,22 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3311', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',').filter(Boolean) || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + ollama: { + url: process.env.OLLAMA_URL || 'http://localhost:11434', + model: process.env.OLLAMA_MODEL || 'gemma3:4b', + timeout: parseInt(process.env.OLLAMA_TIMEOUT || '120000', 10), + }, +}); + +export const SYSTEM_PROMPTS: Record = { + default: `Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, wenn der Nutzer Deutsch schreibt. Halte deine Antworten prägnant und hilfreich.`, + classify: `Du bist ein Textklassifizierer. Analysiere den gegebenen Text und ordne ihn einer passenden Kategorie zu. Gib nur die Kategorie und eine kurze Begründung an.`, + summarize: `Du bist ein Zusammenfassungs-Experte. Fasse den gegebenen Text kurz und präzise zusammen. Behalte die wichtigsten Informationen bei.`, + translate: `Du bist ein Übersetzer. Übersetze den Text in die gewünschte Sprache. Wenn keine Zielsprache angegeben ist, übersetze zwischen Deutsch und Englisch.`, + code: `Du bist ein Programmier-Assistent. Hilf bei Code-Fragen, erkläre Konzepte und schreibe sauberen, gut dokumentierten Code. Verwende Markdown Code-Blöcke für Code.`, +}; diff --git a/services/matrix-ollama-bot/src/health.controller.ts b/services/matrix-ollama-bot/src/health.controller.ts new file mode 100644 index 000000000..85ecda0d3 --- /dev/null +++ b/services/matrix-ollama-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-ollama-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-ollama-bot/src/main.ts b/services/matrix-ollama-bot/src/main.ts new file mode 100644 index 000000000..0ecb02c07 --- /dev/null +++ b/services/matrix-ollama-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3311; + await app.listen(port); + + logger.log(`Matrix Ollama Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-ollama-bot/src/ollama/ollama.module.ts b/services/matrix-ollama-bot/src/ollama/ollama.module.ts new file mode 100644 index 000000000..a0ae211c4 --- /dev/null +++ b/services/matrix-ollama-bot/src/ollama/ollama.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OllamaService } from './ollama.service'; + +@Module({ + providers: [OllamaService], + exports: [OllamaService], +}) +export class OllamaModule {} diff --git a/services/matrix-ollama-bot/src/ollama/ollama.service.ts b/services/matrix-ollama-bot/src/ollama/ollama.service.ts new file mode 100644 index 000000000..7d0dbb29c --- /dev/null +++ b/services/matrix-ollama-bot/src/ollama/ollama.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface OllamaModel { + name: string; + size: number; + modified_at: string; +} + +@Injectable() +export class OllamaService implements OnModuleInit { + private readonly logger = new Logger(OllamaService.name); + private readonly baseUrl: string; + private readonly defaultModel: string; + private readonly timeout: number; + + constructor(private configService: ConfigService) { + this.baseUrl = this.configService.get('ollama.url') || 'http://localhost:11434'; + this.defaultModel = this.configService.get('ollama.model') || 'gemma3:4b'; + this.timeout = this.configService.get('ollama.timeout') || 120000; + } + + async onModuleInit() { + await this.checkConnection(); + } + + async checkConnection(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/version`, { + signal: AbortSignal.timeout(5000), + }); + const data = await response.json(); + this.logger.log(`Ollama connected: v${data.version}`); + return true; + } catch (error) { + this.logger.error(`Failed to connect to Ollama at ${this.baseUrl}:`, error); + return false; + } + } + + async listModels(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/tags`); + const data = await response.json(); + return data.models || []; + } catch (error) { + this.logger.error('Failed to list models:', error); + return []; + } + } + + async chat( + messages: { role: 'user' | 'assistant' | 'system'; content: string }[], + model?: string + ): Promise { + const selectedModel = model || this.defaultModel; + + try { + const response = await fetch(`${this.baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: selectedModel, + messages, + stream: false, + }), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status}`); + } + + const data = await response.json(); + + // Log performance metrics + if (data.eval_count && data.eval_duration) { + const tokensPerSec = (data.eval_count / data.eval_duration) * 1e9; + this.logger.debug(`Generated ${data.eval_count} tokens at ${tokensPerSec.toFixed(1)} t/s`); + } + + return data.message?.content || ''; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error('Ollama Timeout - Antwort dauerte zu lange'); + } + throw error; + } + } + + getDefaultModel(): string { + return this.defaultModel; + } +} diff --git a/services/matrix-ollama-bot/tsconfig.json b/services/matrix-ollama-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-ollama-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +} From 7c5e9e3c49e94bc59cff9a7a7db3b4b0fc3b85fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 00:44:28 +0000 Subject: [PATCH 4/4] feat(matrix): add Stats Bot and Project Doc Bot services Complete GDPR-compliant bot suite for Matrix: matrix-stats-bot (port 3312): - Analytics reports from Umami - Commands: !stats, !today, !week, !realtime, !users - Scheduled daily/weekly reports to Matrix room matrix-project-doc-bot (port 3313): - Project documentation with photos, voice, text - Voice transcription via OpenAI Whisper - Blog generation with 5 styles (casual, technical, tutorial, social, story) - Commands: !new, !projects, !switch, !status, !generate, !export - Uses PostgreSQL + S3 (MinIO) for storage Changes: - docker-compose.macmini.yml: Added both Matrix bots - health-check.sh: Added health checks for both bots Environment variables required: - MATRIX_STATS_BOT_TOKEN, MATRIX_PROJECT_DOC_BOT_TOKEN - OPENAI_API_KEY (for Project Doc Bot) https://claude.ai/code/session_01E3r5aFW3YLAhEJfsL2ryhv --- docker-compose.macmini.yml | 80 ++++ scripts/mac-mini/health-check.sh | 2 + services/matrix-project-doc-bot/.env.example | 23 + services/matrix-project-doc-bot/CLAUDE.md | 122 +++++ services/matrix-project-doc-bot/Dockerfile | 25 + .../matrix-project-doc-bot/drizzle.config.ts | 10 + services/matrix-project-doc-bot/nest-cli.json | 8 + services/matrix-project-doc-bot/package.json | 43 ++ .../matrix-project-doc-bot/src/app.module.ts | 19 + .../src/bot/bot.module.ts | 12 + .../src/bot/matrix.service.ts | 442 ++++++++++++++++++ .../src/config/configuration.ts | 47 ++ .../src/database/database.module.ts | 33 ++ .../src/database/schema.ts | 33 ++ .../src/generation/generation.module.ts | 8 + .../src/generation/generation.service.ts | 112 +++++ .../src/health.controller.ts | 13 + services/matrix-project-doc-bot/src/main.ts | 15 + .../src/media/media.module.ts | 11 + .../src/media/media.service.ts | 92 ++++ .../src/media/storage.service.ts | 83 ++++ .../src/project/project.module.ts | 8 + .../src/project/project.service.ts | 74 +++ .../src/transcription/transcription.module.ts | 8 + .../transcription/transcription.service.ts | 40 ++ services/matrix-project-doc-bot/tsconfig.json | 22 + services/matrix-stats-bot/.env.example | 16 + services/matrix-stats-bot/CLAUDE.md | 65 +++ services/matrix-stats-bot/Dockerfile | 25 + services/matrix-stats-bot/nest-cli.json | 8 + services/matrix-stats-bot/package.json | 36 ++ .../src/analytics/analytics.module.ts | 10 + .../src/analytics/analytics.service.ts | 129 +++++ services/matrix-stats-bot/src/app.module.ts | 19 + .../matrix-stats-bot/src/bot/bot.module.ts | 11 + .../src/bot/matrix.service.ts | 196 ++++++++ .../src/config/configuration.ts | 39 ++ .../matrix-stats-bot/src/health.controller.ts | 13 + services/matrix-stats-bot/src/main.ts | 15 + .../src/scheduler/report.scheduler.ts | 30 ++ .../src/scheduler/scheduler.module.ts | 11 + .../src/umami/umami.module.ts | 8 + .../src/umami/umami.service.ts | 114 +++++ .../src/users/users.module.ts | 8 + .../src/users/users.service.ts | 55 +++ services/matrix-stats-bot/tsconfig.json | 22 + 46 files changed, 2215 insertions(+) create mode 100644 services/matrix-project-doc-bot/.env.example create mode 100644 services/matrix-project-doc-bot/CLAUDE.md create mode 100644 services/matrix-project-doc-bot/Dockerfile create mode 100644 services/matrix-project-doc-bot/drizzle.config.ts create mode 100644 services/matrix-project-doc-bot/nest-cli.json create mode 100644 services/matrix-project-doc-bot/package.json create mode 100644 services/matrix-project-doc-bot/src/app.module.ts create mode 100644 services/matrix-project-doc-bot/src/bot/bot.module.ts create mode 100644 services/matrix-project-doc-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-project-doc-bot/src/config/configuration.ts create mode 100644 services/matrix-project-doc-bot/src/database/database.module.ts create mode 100644 services/matrix-project-doc-bot/src/database/schema.ts create mode 100644 services/matrix-project-doc-bot/src/generation/generation.module.ts create mode 100644 services/matrix-project-doc-bot/src/generation/generation.service.ts create mode 100644 services/matrix-project-doc-bot/src/health.controller.ts create mode 100644 services/matrix-project-doc-bot/src/main.ts create mode 100644 services/matrix-project-doc-bot/src/media/media.module.ts create mode 100644 services/matrix-project-doc-bot/src/media/media.service.ts create mode 100644 services/matrix-project-doc-bot/src/media/storage.service.ts create mode 100644 services/matrix-project-doc-bot/src/project/project.module.ts create mode 100644 services/matrix-project-doc-bot/src/project/project.service.ts create mode 100644 services/matrix-project-doc-bot/src/transcription/transcription.module.ts create mode 100644 services/matrix-project-doc-bot/src/transcription/transcription.service.ts create mode 100644 services/matrix-project-doc-bot/tsconfig.json create mode 100644 services/matrix-stats-bot/.env.example create mode 100644 services/matrix-stats-bot/CLAUDE.md create mode 100644 services/matrix-stats-bot/Dockerfile create mode 100644 services/matrix-stats-bot/nest-cli.json create mode 100644 services/matrix-stats-bot/package.json create mode 100644 services/matrix-stats-bot/src/analytics/analytics.module.ts create mode 100644 services/matrix-stats-bot/src/analytics/analytics.service.ts create mode 100644 services/matrix-stats-bot/src/app.module.ts create mode 100644 services/matrix-stats-bot/src/bot/bot.module.ts create mode 100644 services/matrix-stats-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-stats-bot/src/config/configuration.ts create mode 100644 services/matrix-stats-bot/src/health.controller.ts create mode 100644 services/matrix-stats-bot/src/main.ts create mode 100644 services/matrix-stats-bot/src/scheduler/report.scheduler.ts create mode 100644 services/matrix-stats-bot/src/scheduler/scheduler.module.ts create mode 100644 services/matrix-stats-bot/src/umami/umami.module.ts create mode 100644 services/matrix-stats-bot/src/umami/umami.service.ts create mode 100644 services/matrix-stats-bot/src/users/users.module.ts create mode 100644 services/matrix-stats-bot/src/users/users.service.ts create mode 100644 services/matrix-stats-bot/tsconfig.json diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 3178c2ed9..ee3f1f520 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -835,6 +835,82 @@ services: retries: 3 start_period: 40s + # ============================================ + # Matrix Stats Bot (GDPR-compliant Analytics) + # ============================================ + + matrix-stats-bot: + image: ghcr.io/memo-2023/matrix-stats-bot:latest + container_name: manacore-matrix-stats-bot + restart: always + depends_on: + synapse: + condition: service_healthy + umami: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3312 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_STATS_BOT_TOKEN} + MATRIX_REPORT_ROOM_ID: ${MATRIX_STATS_REPORT_ROOM:-} + UMAMI_API_URL: http://umami:3000 + UMAMI_USERNAME: ${UMAMI_USERNAME:-admin} + UMAMI_PASSWORD: ${UMAMI_PASSWORD} + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/manacore_auth + volumes: + - matrix_stats_bot_data:/app/data + ports: + - "3312:3312" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3312/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # ============================================ + # Matrix Project Doc Bot (GDPR-compliant Documentation) + # ============================================ + + matrix-project-doc-bot: + image: ghcr.io/memo-2023/matrix-project-doc-bot:latest + container_name: manacore-matrix-project-doc-bot + restart: always + depends_on: + synapse: + condition: service_healthy + postgres: + condition: service_healthy + minio: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3313 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_PROJECT_DOC_BOT_TOKEN} + MATRIX_ALLOWED_USERS: ${MATRIX_PROJECT_DOC_ALLOWED_USERS:-} + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/project_doc_bot + S3_ENDPOINT: http://minio:9000 + S3_REGION: us-east-1 + S3_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} + S3_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} + S3_BUCKET: project-doc-bot + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: gpt-4o-mini + volumes: + - matrix_project_doc_bot_data:/app/data + ports: + - "3313:3313" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3313/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -877,3 +953,7 @@ volumes: name: manacore-synapse matrix_ollama_bot_data: name: manacore-matrix-ollama-bot + matrix_stats_bot_data: + name: manacore-matrix-stats-bot + matrix_project_doc_bot_data: + name: manacore-matrix-project-doc-bot diff --git a/scripts/mac-mini/health-check.sh b/scripts/mac-mini/health-check.sh index f121f150e..c8f6566ab 100755 --- a/scripts/mac-mini/health-check.sh +++ b/scripts/mac-mini/health-check.sh @@ -234,6 +234,8 @@ echo "Matrix (DSGVO-konform):" check_service "Synapse" "http://localhost:8008/health" check_service "Element Web" "http://localhost:8087/" check_service "Matrix Ollama Bot" "http://localhost:3311/health" +check_service "Matrix Stats Bot" "http://localhost:3312/health" +check_service "Matrix Project Doc Bot" "http://localhost:3313/health" echo "" echo "Cloudflare Tunnel:" diff --git a/services/matrix-project-doc-bot/.env.example b/services/matrix-project-doc-bot/.env.example new file mode 100644 index 000000000..8045521f6 --- /dev/null +++ b/services/matrix-project-doc-bot/.env.example @@ -0,0 +1,23 @@ +PORT=3313 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +# Optional: Restrict to specific users (comma-separated) +MATRIX_ALLOWED_USERS= +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Database +DATABASE_URL=postgresql://postgres:password@localhost:5432/project_doc_bot + +# S3 Storage +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=project-doc-bot + +# OpenAI +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OPENAI_WHISPER_MODEL=whisper-1 diff --git a/services/matrix-project-doc-bot/CLAUDE.md b/services/matrix-project-doc-bot/CLAUDE.md new file mode 100644 index 000000000..a0d8b0415 --- /dev/null +++ b/services/matrix-project-doc-bot/CLAUDE.md @@ -0,0 +1,122 @@ +# Matrix Project Doc Bot - Claude Code Guidelines + +## Overview + +Matrix Project Doc Bot collects photos, voice notes, and text for projects and generates blog posts. GDPR-compliant replacement for telegram-project-doc-bot. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Database**: Drizzle ORM + PostgreSQL +- **Storage**: S3 (MinIO locally, Hetzner in production) +- **AI**: OpenAI (Whisper for transcription, GPT-4o-mini for generation) + +## Commands + +```bash +pnpm install +pnpm start:dev # Development with hot reload +pnpm build # Production build +pnpm type-check # TypeScript check +pnpm db:push # Push schema to database +pnpm db:studio # Open Drizzle Studio +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!new [Name]` | Create new project | +| `!projects` | List all projects | +| `!switch [ID]` | Switch to project | +| `!status` | Show project status | +| `!archive` | Archive current project | +| `!generate` | Generate blog post (casual) | +| `!generate [style]` | Generate with specific style | +| `!styles` | Show available styles | +| `!export` | Export last generation | + +## Media Handling + +- **Photos**: Saved to S3, stored in database +- **Voice**: Saved to S3, transcribed via Whisper +- **Text**: Stored directly in database + +## Blog Styles + +| Style | Description | +|-------|-------------| +| `casual` | Friendly, personal blog post | +| `technical` | Detailed technical report | +| `tutorial` | Step-by-step guide | +| `social` | Short social media post | +| `story` | Storytelling format | + +## Environment Variables + +```env +PORT=3313 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_USERS=@user:mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Database +DATABASE_URL=postgresql://postgres:password@localhost:5432/project_doc_bot + +# S3 Storage +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=project-doc-bot + +# OpenAI +OPENAI_API_KEY=sk-xxx +OPENAI_MODEL=gpt-4o-mini +OPENAI_WHISPER_MODEL=whisper-1 +``` + +## Database Schema + +```sql +-- projects table +CREATE TABLE projects ( + id UUID PRIMARY KEY, + matrix_user_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- project_items table +CREATE TABLE project_items ( + id UUID PRIMARY KEY, + project_id UUID REFERENCES projects(id), + type TEXT NOT NULL, -- photo, voice, text + content TEXT, + media_url TEXT, + media_mxc_url TEXT, + duration INTEGER, + created_at TIMESTAMP DEFAULT NOW() +); + +-- generations table +CREATE TABLE generations ( + id UUID PRIMARY KEY, + project_id UUID REFERENCES projects(id), + style TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +## Health Check + +```bash +curl http://localhost:3313/health +``` diff --git a/services/matrix-project-doc-bot/Dockerfile b/services/matrix-project-doc-bot/Dockerfile new file mode 100644 index 000000000..0cba86843 --- /dev/null +++ b/services/matrix-project-doc-bot/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install +COPY . . +RUN pnpm build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +RUN mkdir -p /app/data +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod +COPY --from=builder /app/dist ./dist +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3313/health || exit 1 + +EXPOSE 3313 +CMD ["node", "dist/main.js"] diff --git a/services/matrix-project-doc-bot/drizzle.config.ts b/services/matrix-project-doc-bot/drizzle.config.ts new file mode 100644 index 000000000..695ea628c --- /dev/null +++ b/services/matrix-project-doc-bot/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || '', + }, +}); diff --git a/services/matrix-project-doc-bot/nest-cli.json b/services/matrix-project-doc-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-project-doc-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-project-doc-bot/package.json b/services/matrix-project-doc-bot/package.json new file mode 100644 index 000000000..6d10f5630 --- /dev/null +++ b/services/matrix-project-doc-bot/package.json @@ -0,0 +1,43 @@ +{ + "name": "@manacore/matrix-project-doc-bot", + "version": "1.0.0", + "description": "Matrix bot for project documentation - collect photos and voice notes, generate blog posts (GDPR compliant)", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@aws-sdk/client-s3": "^3.721.0", + "@aws-sdk/s3-request-presigner": "^3.721.0", + "drizzle-orm": "^0.38.3", + "matrix-bot-sdk": "^0.7.1", + "openai": "^4.77.0", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-project-doc-bot/src/app.module.ts b/services/matrix-project-doc-bot/src/app.module.ts new file mode 100644 index 000000000..9eabccba9 --- /dev/null +++ b/services/matrix-project-doc-bot/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './database/database.module'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + DatabaseModule, + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-project-doc-bot/src/bot/bot.module.ts b/services/matrix-project-doc-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..506cb7fa3 --- /dev/null +++ b/services/matrix-project-doc-bot/src/bot/bot.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { ProjectModule } from '../project/project.module'; +import { MediaModule } from '../media/media.module'; +import { GenerationModule } from '../generation/generation.module'; + +@Module({ + imports: [ProjectModule, MediaModule, GenerationModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-project-doc-bot/src/bot/matrix.service.ts b/services/matrix-project-doc-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..60e4ec0ac --- /dev/null +++ b/services/matrix-project-doc-bot/src/bot/matrix.service.ts @@ -0,0 +1,442 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, + MessageEvent, + RoomEvent, +} from 'matrix-bot-sdk'; +import { ProjectService } from '../project/project.service'; +import { MediaService } from '../media/media.service'; +import { GenerationService } from '../generation/generation.service'; +import { BLOG_STYLES } from '../config/configuration'; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private botUserId: string = ''; + private readonly allowedUsers: string[]; + + // Active project per user (matrixUserId -> projectId) + private activeProjects: Map = new Map(); + + constructor( + private configService: ConfigService, + private projectService: ProjectService, + private mediaService: MediaService, + private generationService: GenerationService + ) { + this.allowedUsers = this.configService.get('matrix.allowedUsers') || []; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogService.LogLevel.INFO); + + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + await this.client.start(); + this.logger.log('Matrix Project Doc Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + } + } + + private isAllowed(userId: string): boolean { + if (this.allowedUsers.length === 0) return true; + return this.allowedUsers.includes(userId); + } + + private async handleRoomMessage(roomId: string, event: RoomEvent) { + if (event.sender === this.botUserId) return; + if (!this.isAllowed(event.sender)) return; + + const content = event.content; + const msgtype = content.msgtype; + + if (msgtype === 'm.text') { + const body = content.body; + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + } else { + await this.handleTextMessage(roomId, event.sender, body); + } + } else if (msgtype === 'm.image') { + await this.handleImage(roomId, event.sender, content); + } else if (msgtype === 'm.audio') { + await this.handleAudio(roomId, event.sender, content); + } + } + + private async handleCommand(roomId: string, sender: string, body: string) { + const [command, ...args] = body.slice(1).split(' '); + const argString = args.join(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + case 'new': + await this.createProject(roomId, sender, argString); + break; + case 'projects': + await this.listProjects(roomId, sender); + break; + case 'switch': + await this.switchProject(roomId, sender, argString); + break; + case 'status': + await this.showStatus(roomId, sender); + break; + case 'archive': + await this.archiveProject(roomId, sender); + break; + case 'styles': + await this.showStyles(roomId); + break; + case 'generate': + await this.generateBlogpost(roomId, sender, argString); + break; + case 'export': + await this.exportGeneration(roomId, sender); + break; + default: + await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); + } + } + + private async sendHelp(roomId: string) { + const styles = Object.entries(BLOG_STYLES) + .map(([key, value]) => `- \`${key}\` - ${value.name}`) + .join('\n'); + + const helpText = `**📸 Project Doc Bot (DSGVO-konform)** + +Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge. + +**Projekt-Commands:** +- \`!new [Name]\` - Neues Projekt starten +- \`!projects\` - Alle Projekte anzeigen +- \`!switch [ID]\` - Projekt wechseln +- \`!status\` - Status des aktiven Projekts +- \`!archive\` - Aktives Projekt archivieren + +**Content:** +📷 Foto senden - Wird gespeichert +🎤 Sprachnotiz - Wird transkribiert +💬 Text-Nachricht - Als Notiz gespeichert + +**Generierung:** +- \`!generate\` - Blogbeitrag erstellen +- \`!generate [Stil]\` - Mit bestimmtem Stil +- \`!styles\` - Verfügbare Stile anzeigen +- \`!export\` - Letzte Generierung exportieren + +**Verfügbare Stile:** +${styles} + +**Tipp:** Starte mit \`!new Projektname\``; + + await this.sendMessage(roomId, helpText); + } + + private async createProject(roomId: string, sender: string, name: string) { + if (!name) { + await this.sendMessage(roomId, 'Verwendung: `!new Projektname`\n\nBeispiel: `!new Gartenhaus-Renovierung`'); + return; + } + + try { + const project = await this.projectService.create({ + matrixUserId: sender, + name, + }); + + this.activeProjects.set(sender, project.id); + + await this.sendMessage( + roomId, + `✅ **Projekt erstellt!**\n\n**Name:** ${project.name}\n**ID:** \`${project.id.slice(0, 8)}\`\n\nSende jetzt:\n📷 Fotos\n🎤 Sprachnotizen\n💬 Text-Nachrichten\n\nMit \`!generate\` erstellst du den Blogbeitrag.` + ); + } catch (error) { + this.logger.error('Failed to create project:', error); + await this.sendMessage(roomId, `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}`); + } + } + + private async listProjects(roomId: string, sender: string) { + const projects = await this.projectService.findByUser(sender); + + if (projects.length === 0) { + await this.sendMessage(roomId, 'Keine Projekte gefunden.\n\nStarte mit: `!new Projektname`'); + return; + } + + const activeId = this.activeProjects.get(sender); + + const projectList = await Promise.all( + projects.map(async (p) => { + const stats = await this.projectService.getStats(p.id); + const active = p.id === activeId ? ' ✓' : ''; + const status = p.status === 'archived' ? ' 📦' : ''; + return `- **${p.name}**${active}${status}\n ID: \`${p.id.slice(0, 8)}\` | ${stats.total} Einträge`; + }) + ); + + await this.sendMessage(roomId, `**📂 Deine Projekte:**\n\n${projectList.join('\n\n')}\n\nWechseln mit: \`!switch [ID]\``); + } + + private async switchProject(roomId: string, sender: string, idPrefix: string) { + if (!idPrefix) { + await this.sendMessage(roomId, 'Verwendung: `!switch [ID]`\n\nZeige Projekte mit `!projects`'); + return; + } + + const projects = await this.projectService.findByUser(sender); + const project = projects.find((p) => p.id.startsWith(idPrefix)); + + if (!project) { + await this.sendMessage(roomId, `Projekt mit ID "${idPrefix}" nicht gefunden.`); + return; + } + + this.activeProjects.set(sender, project.id); + const stats = await this.projectService.getStats(project.id); + + await this.sendMessage( + roomId, + `✅ Gewechselt zu: **${project.name}**\n\n📷 ${stats.photos} Fotos\n🎤 ${stats.voices} Sprachnotizen\n📝 ${stats.texts} Textnotizen` + ); + } + + private async showStatus(roomId: string, sender: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + const project = await this.projectService.findById(projectId); + if (!project) { + this.activeProjects.delete(sender); + await this.sendMessage(roomId, 'Projekt nicht gefunden. Starte ein neues mit `!new`'); + return; + } + + const stats = await this.projectService.getStats(projectId); + const latest = await this.generationService.getLatestGeneration(projectId); + + let statusText = `**📊 Projekt-Status**\n\n**Name:** ${project.name}\n**Status:** ${project.status}\n**Erstellt:** ${project.createdAt.toLocaleDateString('de-DE')}\n\n**Inhalte:**\n📷 ${stats.photos} Fotos\n🎤 ${stats.voices} Sprachnotizen\n📝 ${stats.texts} Textnotizen\n**Gesamt:** ${stats.total} Einträge`; + + if (latest) { + statusText += `\n\n**Letzte Generierung:**\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`; + } + + await this.sendMessage(roomId, statusText); + } + + private async archiveProject(roomId: string, sender: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.'); + return; + } + + await this.projectService.update(projectId, { status: 'archived' }); + this.activeProjects.delete(sender); + + await this.sendMessage(roomId, '📦 Projekt archiviert.\n\nStarte ein neues mit `!new`'); + } + + private async showStyles(roomId: string) { + const styles = Object.entries(BLOG_STYLES) + .map(([key, value]) => `**${key}** - ${value.name}\n_${value.prompt.slice(0, 80)}..._`) + .join('\n\n'); + + await this.sendMessage(roomId, `**📝 Verfügbare Blog-Stile:**\n\n${styles}\n\nVerwendung: \`!generate [stil]\``); + } + + private async generateBlogpost(roomId: string, sender: string, style: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + const selectedStyle = (style.toLowerCase() || 'casual') as keyof typeof BLOG_STYLES; + const validStyles = Object.keys(BLOG_STYLES); + + if (!validStyles.includes(selectedStyle)) { + await this.sendMessage( + roomId, + `Unbekannter Stil: "${style}"\n\nVerfügbar: ${validStyles.join(', ')}\n\nZeige Details mit \`!styles\`` + ); + return; + } + + await this.sendMessage(roomId, '🚀 Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.'); + await this.client.sendTyping(roomId, true, 60000); + + try { + const content = await this.generationService.generateBlogpost(projectId, selectedStyle); + await this.client.sendTyping(roomId, false); + + await this.sendMessage(roomId, content); + await this.sendMessage(roomId, '✅ Blogbeitrag erstellt!\n\nExportieren mit `!export`'); + } catch (error) { + await this.client.sendTyping(roomId, false); + this.logger.error('Generation failed:', error); + await this.sendMessage(roomId, `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}`); + } + } + + private async exportGeneration(roomId: string, sender: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.'); + return; + } + + const latest = await this.generationService.getLatestGeneration(projectId); + if (!latest) { + await this.sendMessage(roomId, 'Noch kein Blogbeitrag generiert.\n\nErstelle einen mit `!generate`'); + return; + } + + const project = await this.projectService.findById(projectId); + const filename = `${project?.name.replace(/[^a-zA-Z0-9]/g, '_') || 'blogpost'}.md`; + + // Upload file to Matrix + const buffer = Buffer.from(latest.content, 'utf-8'); + const mxcUrl = await this.client.uploadContent(buffer, 'text/markdown', filename); + + await this.client.sendMessage(roomId, { + msgtype: 'm.file', + body: filename, + url: mxcUrl, + info: { + mimetype: 'text/markdown', + size: buffer.length, + }, + }); + } + + private async handleTextMessage(roomId: string, sender: string, text: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, '💡 Tipp: Starte ein Projekt mit `!new Projektname`'); + return; + } + + try { + await this.mediaService.addTextNote(projectId, text); + const stats = await this.projectService.getStats(projectId); + await this.sendMessage(roomId, `📝 Notiz gespeichert! (${stats.texts} Notizen gesamt)`); + } catch (error) { + this.logger.error('Failed to add text note:', error); + await this.sendMessage(roomId, '❌ Fehler beim Speichern der Notiz.'); + } + } + + private async handleImage(roomId: string, sender: string, content: any) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + try { + const mxcUrl = content.url; + const httpUrl = this.client.mxcToHttp(mxcUrl); + const response = await fetch(httpUrl); + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = content.info?.mimetype || 'image/jpeg'; + + await this.mediaService.processPhoto(projectId, buffer, contentType, mxcUrl, content.body); + + const stats = await this.projectService.getStats(projectId); + await this.sendMessage(roomId, `📷 Foto gespeichert! (${stats.photos} Fotos gesamt)`); + } catch (error) { + this.logger.error('Failed to process image:', error); + await this.sendMessage(roomId, '❌ Fehler beim Speichern des Fotos.'); + } + } + + private async handleAudio(roomId: string, sender: string, content: any) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + await this.sendMessage(roomId, '🎤 Verarbeite Sprachnotiz...'); + + try { + const mxcUrl = content.url; + const httpUrl = this.client.mxcToHttp(mxcUrl); + const response = await fetch(httpUrl); + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = content.info?.mimetype || 'audio/ogg'; + const duration = Math.round((content.info?.duration || 0) / 1000); + + const item = await this.mediaService.processVoice(projectId, buffer, contentType, mxcUrl, duration); + + const stats = await this.projectService.getStats(projectId); + let reply = `✅ Sprachnotiz gespeichert! (${stats.voices} gesamt)`; + + if (item.content) { + reply += `\n\n📝 Transkription:\n"${item.content}"`; + } + + await this.sendMessage(roomId, reply); + } catch (error) { + this.logger.error('Failed to process audio:', error); + await this.sendMessage(roomId, '❌ Fehler beim Verarbeiten der Sprachnotiz.'); + } + } + + private async sendMessage(roomId: string, message: string) { + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return markdown + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/_([^_]+)_/g, '$1') + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-project-doc-bot/src/config/configuration.ts b/services/matrix-project-doc-bot/src/config/configuration.ts new file mode 100644 index 000000000..4bc5aa810 --- /dev/null +++ b/services/matrix-project-doc-bot/src/config/configuration.ts @@ -0,0 +1,47 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3313', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedUsers: process.env.MATRIX_ALLOWED_USERS?.split(',').filter(Boolean) || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + database: { + url: process.env.DATABASE_URL || '', + }, + s3: { + endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000', + region: process.env.S3_REGION || 'us-east-1', + accessKey: process.env.S3_ACCESS_KEY || 'minioadmin', + secretKey: process.env.S3_SECRET_KEY || 'minioadmin', + bucket: process.env.S3_BUCKET || 'project-doc-bot', + }, + openai: { + apiKey: process.env.OPENAI_API_KEY || '', + model: process.env.OPENAI_MODEL || 'gpt-4o-mini', + whisperModel: process.env.OPENAI_WHISPER_MODEL || 'whisper-1', + }, +}); + +export const BLOG_STYLES: Record = { + casual: { + name: 'Casual Blog', + prompt: `Schreibe einen lockeren, persönlichen Blogbeitrag über dieses Projekt. Nutze eine freundliche, nahbare Sprache. Füge passende Überschriften und Absätze ein.`, + }, + technical: { + name: 'Technischer Bericht', + prompt: `Schreibe einen detaillierten technischen Bericht über dieses Projekt. Fokussiere auf Methoden, Materialien und den Prozess. Sei präzise und informativ.`, + }, + tutorial: { + name: 'Schritt-für-Schritt Anleitung', + prompt: `Erstelle eine Schritt-für-Schritt Anleitung basierend auf diesem Projekt. Nummeriere die Schritte und erkläre jeden ausführlich, sodass andere es nachmachen können.`, + }, + social: { + name: 'Social Media Post', + prompt: `Erstelle einen kurzen, ansprechenden Social Media Post über dieses Projekt. Maximal 280 Zeichen für den Haupttext, plus optionale Hashtags.`, + }, + story: { + name: 'Storytelling', + prompt: `Erzähle die Geschichte dieses Projekts. Beginne mit der Motivation, beschreibe Herausforderungen und ende mit dem Ergebnis. Mach es persönlich und fesselnd.`, + }, +}; diff --git a/services/matrix-project-doc-bot/src/database/database.module.ts b/services/matrix-project-doc-bot/src/database/database.module.ts new file mode 100644 index 000000000..9acacdc60 --- /dev/null +++ b/services/matrix-project-doc-bot/src/database/database.module.ts @@ -0,0 +1,33 @@ +import { Module, Global, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService) => { + const logger = new Logger('Database'); + const url = configService.get('database.url'); + + if (!url) { + logger.error('DATABASE_URL is required'); + throw new Error('DATABASE_URL is required'); + } + + const client = postgres(url); + logger.log('Database connected'); + + return drizzle(client, { schema }); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/services/matrix-project-doc-bot/src/database/schema.ts b/services/matrix-project-doc-bot/src/database/schema.ts new file mode 100644 index 000000000..eb8041de7 --- /dev/null +++ b/services/matrix-project-doc-bot/src/database/schema.ts @@ -0,0 +1,33 @@ +import { pgTable, text, timestamp, uuid, integer } from 'drizzle-orm/pg-core'; + +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + matrixUserId: text('matrix_user_id').notNull(), + name: text('name').notNull(), + status: text('status').notNull().default('active'), // active, archived + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}); + +export const projectItems = pgTable('project_items', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + type: text('type').notNull(), // photo, voice, text + content: text('content'), // text content or transcription + mediaUrl: text('media_url'), // S3 URL for media + mediaMxcUrl: text('media_mxc_url'), // Matrix MXC URL + duration: integer('duration'), // Voice duration in seconds + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export const generations = pgTable('generations', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + style: text('style').notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); diff --git a/services/matrix-project-doc-bot/src/generation/generation.module.ts b/services/matrix-project-doc-bot/src/generation/generation.module.ts new file mode 100644 index 000000000..fccb8adfd --- /dev/null +++ b/services/matrix-project-doc-bot/src/generation/generation.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { GenerationService } from './generation.service'; + +@Module({ + providers: [GenerationService], + exports: [GenerationService], +}) +export class GenerationModule {} diff --git a/services/matrix-project-doc-bot/src/generation/generation.service.ts b/services/matrix-project-doc-bot/src/generation/generation.service.ts new file mode 100644 index 000000000..9b00d2585 --- /dev/null +++ b/services/matrix-project-doc-bot/src/generation/generation.service.ts @@ -0,0 +1,112 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import OpenAI from 'openai'; +import { eq, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import { generations, projectItems, projects } from '../database/schema'; +import { BLOG_STYLES } from '../config/configuration'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type * as schema from '../database/schema'; + +type Database = PostgresJsDatabase; + +@Injectable() +export class GenerationService { + private readonly logger = new Logger(GenerationService.name); + private readonly openai: OpenAI; + private readonly model: string; + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private configService: ConfigService + ) { + this.openai = new OpenAI({ + apiKey: this.configService.get('openai.apiKey'), + }); + this.model = this.configService.get('openai.model') || 'gpt-4o-mini'; + } + + async generateBlogpost(projectId: string, style: keyof typeof BLOG_STYLES): Promise { + const apiKey = this.configService.get('openai.apiKey'); + if (!apiKey) { + throw new Error('OpenAI API key not configured'); + } + + // Get project info + const [project] = await this.db.select().from(projects).where(eq(projects.id, projectId)); + if (!project) { + throw new Error('Project not found'); + } + + // Get all project items + const items = await this.db + .select() + .from(projectItems) + .where(eq(projectItems.projectId, projectId)) + .orderBy(projectItems.createdAt); + + if (items.length === 0) { + throw new Error('Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.'); + } + + // Build content summary + const contentSummary = items + .map((item, index) => { + const timestamp = item.createdAt.toLocaleString('de-DE'); + switch (item.type) { + case 'photo': + return `[Foto ${index + 1}] ${timestamp}${item.content ? `: ${item.content}` : ''}`; + case 'voice': + return `[Sprachnotiz ${index + 1}] ${timestamp}: "${item.content || 'Keine Transkription'}"`; + case 'text': + return `[Notiz ${index + 1}] ${timestamp}: "${item.content}"`; + default: + return ''; + } + }) + .filter(Boolean) + .join('\n\n'); + + const styleConfig = BLOG_STYLES[style]; + + const systemPrompt = `Du bist ein erfahrener Blogger und Content-Creator. ${styleConfig.prompt} + +Projektname: "${project.name}" +Erstellt am: ${project.createdAt.toLocaleDateString('de-DE')} + +Die folgenden Inhalte wurden während des Projekts gesammelt:`; + + const response = await this.openai.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: contentSummary }, + ], + temperature: 0.7, + max_tokens: 2000, + }); + + const content = response.choices[0]?.message?.content || ''; + + // Save generation + await this.db.insert(generations).values({ + projectId, + style, + content, + }); + + this.logger.log(`Generated ${style} blogpost for project ${projectId}`); + return content; + } + + async getLatestGeneration(projectId: string) { + const [generation] = await this.db + .select() + .from(generations) + .where(eq(generations.projectId, projectId)) + .orderBy(desc(generations.createdAt)) + .limit(1); + + return generation; + } +} diff --git a/services/matrix-project-doc-bot/src/health.controller.ts b/services/matrix-project-doc-bot/src/health.controller.ts new file mode 100644 index 000000000..869b95e8b --- /dev/null +++ b/services/matrix-project-doc-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-project-doc-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-project-doc-bot/src/main.ts b/services/matrix-project-doc-bot/src/main.ts new file mode 100644 index 000000000..8e0c8c844 --- /dev/null +++ b/services/matrix-project-doc-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3313; + await app.listen(port); + + logger.log(`Matrix Project Doc Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-project-doc-bot/src/media/media.module.ts b/services/matrix-project-doc-bot/src/media/media.module.ts new file mode 100644 index 000000000..7d62a4e77 --- /dev/null +++ b/services/matrix-project-doc-bot/src/media/media.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MediaService } from './media.service'; +import { StorageService } from './storage.service'; +import { TranscriptionModule } from '../transcription/transcription.module'; + +@Module({ + imports: [TranscriptionModule], + providers: [MediaService, StorageService], + exports: [MediaService, StorageService], +}) +export class MediaModule {} diff --git a/services/matrix-project-doc-bot/src/media/media.service.ts b/services/matrix-project-doc-bot/src/media/media.service.ts new file mode 100644 index 000000000..d545b2d5c --- /dev/null +++ b/services/matrix-project-doc-bot/src/media/media.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import { projectItems } from '../database/schema'; +import { StorageService } from './storage.service'; +import { TranscriptionService } from '../transcription/transcription.service'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type * as schema from '../database/schema'; + +type Database = PostgresJsDatabase; + +@Injectable() +export class MediaService { + private readonly logger = new Logger(MediaService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private storageService: StorageService, + private transcriptionService: TranscriptionService + ) {} + + async processPhoto( + projectId: string, + buffer: Buffer, + contentType: string, + mxcUrl: string, + caption?: string + ) { + const key = await this.storageService.uploadFile(buffer, contentType, projectId); + + const [item] = await this.db + .insert(projectItems) + .values({ + projectId, + type: 'photo', + content: caption || null, + mediaUrl: key, + mediaMxcUrl: mxcUrl, + }) + .returning(); + + this.logger.log(`Saved photo for project ${projectId}`); + return item; + } + + async processVoice( + projectId: string, + buffer: Buffer, + contentType: string, + mxcUrl: string, + duration: number + ) { + const key = await this.storageService.uploadFile(buffer, contentType, projectId); + + // Transcribe the voice message + let transcription: string | null = null; + try { + transcription = await this.transcriptionService.transcribe(buffer); + this.logger.log(`Transcribed voice message: ${transcription?.substring(0, 50)}...`); + } catch (error) { + this.logger.error('Transcription failed:', error); + } + + const [item] = await this.db + .insert(projectItems) + .values({ + projectId, + type: 'voice', + content: transcription, + mediaUrl: key, + mediaMxcUrl: mxcUrl, + duration, + }) + .returning(); + + this.logger.log(`Saved voice message for project ${projectId}`); + return item; + } + + async addTextNote(projectId: string, content: string) { + const [item] = await this.db + .insert(projectItems) + .values({ + projectId, + type: 'text', + content, + }) + .returning(); + + this.logger.log(`Saved text note for project ${projectId}`); + return item; + } +} diff --git a/services/matrix-project-doc-bot/src/media/storage.service.ts b/services/matrix-project-doc-bot/src/media/storage.service.ts new file mode 100644 index 000000000..f2d14b672 --- /dev/null +++ b/services/matrix-project-doc-bot/src/media/storage.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { randomUUID } from 'crypto'; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly s3Client: S3Client; + private readonly bucket: string; + + constructor(private configService: ConfigService) { + this.s3Client = new S3Client({ + endpoint: this.configService.get('s3.endpoint'), + region: this.configService.get('s3.region'), + credentials: { + accessKeyId: this.configService.get('s3.accessKey') || '', + secretAccessKey: this.configService.get('s3.secretKey') || '', + }, + forcePathStyle: true, + }); + + this.bucket = this.configService.get('s3.bucket') || 'project-doc-bot'; + } + + async uploadFile(buffer: Buffer, contentType: string, projectId: string): Promise { + const extension = this.getExtension(contentType); + const key = `${projectId}/${randomUUID()}${extension}`; + + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: buffer, + ContentType: contentType, + }) + ); + + this.logger.log(`Uploaded file: ${key}`); + return key; + } + + async getSignedUrl(key: string, expiresIn: number = 3600): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + return getSignedUrl(this.s3Client, command, { expiresIn }); + } + + async downloadFile(key: string): Promise { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + ); + + const stream = response.Body as NodeJS.ReadableStream; + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks); + } + + private getExtension(contentType: string): string { + const map: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'audio/ogg': '.ogg', + 'audio/mpeg': '.mp3', + 'audio/mp4': '.m4a', + }; + return map[contentType] || ''; + } +} diff --git a/services/matrix-project-doc-bot/src/project/project.module.ts b/services/matrix-project-doc-bot/src/project/project.module.ts new file mode 100644 index 000000000..c1b3f70d8 --- /dev/null +++ b/services/matrix-project-doc-bot/src/project/project.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ProjectService } from './project.service'; + +@Module({ + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/services/matrix-project-doc-bot/src/project/project.service.ts b/services/matrix-project-doc-bot/src/project/project.service.ts new file mode 100644 index 000000000..2b251d0bc --- /dev/null +++ b/services/matrix-project-doc-bot/src/project/project.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import { projects, projectItems } from '../database/schema'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type * as schema from '../database/schema'; + +type Database = PostgresJsDatabase; + +interface CreateProjectInput { + matrixUserId: string; + name: string; +} + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async create(input: CreateProjectInput) { + const [project] = await this.db + .insert(projects) + .values({ + matrixUserId: input.matrixUserId, + name: input.name, + }) + .returning(); + + this.logger.log(`Created project ${project.id} for user ${input.matrixUserId}`); + return project; + } + + async findById(id: string) { + const [project] = await this.db.select().from(projects).where(eq(projects.id, id)); + return project; + } + + async findByUser(matrixUserId: string) { + return this.db + .select() + .from(projects) + .where(eq(projects.matrixUserId, matrixUserId)) + .orderBy(desc(projects.createdAt)); + } + + async update(id: string, data: Partial) { + const [project] = await this.db + .update(projects) + .set({ ...data, updatedAt: new Date() }) + .where(eq(projects.id, id)) + .returning(); + return project; + } + + async getStats(projectId: string) { + const items = await this.db.select().from(projectItems).where(eq(projectItems.projectId, projectId)); + + return { + photos: items.filter((i) => i.type === 'photo').length, + voices: items.filter((i) => i.type === 'voice').length, + texts: items.filter((i) => i.type === 'text').length, + total: items.length, + }; + } + + async getItems(projectId: string) { + return this.db + .select() + .from(projectItems) + .where(eq(projectItems.projectId, projectId)) + .orderBy(projectItems.createdAt); + } +} diff --git a/services/matrix-project-doc-bot/src/transcription/transcription.module.ts b/services/matrix-project-doc-bot/src/transcription/transcription.module.ts new file mode 100644 index 000000000..fb5aeeaf1 --- /dev/null +++ b/services/matrix-project-doc-bot/src/transcription/transcription.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TranscriptionService } from './transcription.service'; + +@Module({ + providers: [TranscriptionService], + exports: [TranscriptionService], +}) +export class TranscriptionModule {} diff --git a/services/matrix-project-doc-bot/src/transcription/transcription.service.ts b/services/matrix-project-doc-bot/src/transcription/transcription.service.ts new file mode 100644 index 000000000..6eca0b5d6 --- /dev/null +++ b/services/matrix-project-doc-bot/src/transcription/transcription.service.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import OpenAI from 'openai'; +import { Readable } from 'stream'; + +@Injectable() +export class TranscriptionService { + private readonly logger = new Logger(TranscriptionService.name); + private readonly openai: OpenAI; + private readonly model: string; + + constructor(private configService: ConfigService) { + const apiKey = this.configService.get('openai.apiKey'); + + if (!apiKey) { + this.logger.warn('OPENAI_API_KEY not configured - transcription disabled'); + } + + this.openai = new OpenAI({ apiKey }); + this.model = this.configService.get('openai.whisperModel') || 'whisper-1'; + } + + async transcribe(audioBuffer: Buffer): Promise { + const apiKey = this.configService.get('openai.apiKey'); + if (!apiKey) { + throw new Error('OpenAI API key not configured'); + } + + // Create a File-like object for the API + const file = new File([audioBuffer], 'audio.ogg', { type: 'audio/ogg' }); + + const response = await this.openai.audio.transcriptions.create({ + file, + model: this.model, + language: 'de', + }); + + return response.text; + } +} diff --git a/services/matrix-project-doc-bot/tsconfig.json b/services/matrix-project-doc-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-project-doc-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +} diff --git a/services/matrix-stats-bot/.env.example b/services/matrix-stats-bot/.env.example new file mode 100644 index 000000000..919ef25ee --- /dev/null +++ b/services/matrix-stats-bot/.env.example @@ -0,0 +1,16 @@ +PORT=3312 +TZ=Europe/Berlin + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_REPORT_ROOM_ID= +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Umami +UMAMI_API_URL=http://localhost:3000 +UMAMI_USERNAME=admin +UMAMI_PASSWORD= + +# Database (optional, for user counts) +DATABASE_URL= diff --git a/services/matrix-stats-bot/CLAUDE.md b/services/matrix-stats-bot/CLAUDE.md new file mode 100644 index 000000000..402730a38 --- /dev/null +++ b/services/matrix-stats-bot/CLAUDE.md @@ -0,0 +1,65 @@ +# Matrix Stats Bot - Claude Code Guidelines + +## Overview + +Matrix Stats Bot delivers analytics from Umami (self-hosted) via Matrix. GDPR-compliant replacement for telegram-stats-bot. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Analytics**: Umami API +- **Scheduling**: @nestjs/schedule + +## Commands + +```bash +pnpm install +pnpm start:dev # Development with hot reload +pnpm build # Production build +pnpm type-check # TypeScript check +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!stats` | Overview of all apps (30 days) | +| `!today` | Today's statistics | +| `!week` | This week's statistics | +| `!realtime` | Active visitors right now | +| `!users` | Registered user statistics | +| `!help` | Show available commands | + +## Scheduled Reports + +| Report | Schedule | Timezone | +|--------|----------|----------| +| Daily | 09:00 | Europe/Berlin | +| Weekly | Monday 09:00 | Europe/Berlin | + +## Environment Variables + +```env +PORT=3312 +TZ=Europe/Berlin + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_REPORT_ROOM_ID=!roomid:mana.how + +# Umami +UMAMI_API_URL=http://umami:3000 +UMAMI_USERNAME=admin +UMAMI_PASSWORD=xxx + +# Database (for user counts) +DATABASE_URL=postgresql://... +``` + +## Health Check + +```bash +curl http://localhost:3312/health +``` diff --git a/services/matrix-stats-bot/Dockerfile b/services/matrix-stats-bot/Dockerfile new file mode 100644 index 000000000..ce56bc1ba --- /dev/null +++ b/services/matrix-stats-bot/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install +COPY . . +RUN pnpm build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +RUN mkdir -p /app/data +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod +COPY --from=builder /app/dist ./dist +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3312/health || exit 1 + +EXPOSE 3312 +CMD ["node", "dist/main.js"] diff --git a/services/matrix-stats-bot/nest-cli.json b/services/matrix-stats-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-stats-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-stats-bot/package.json b/services/matrix-stats-bot/package.json new file mode 100644 index 000000000..5cdd931dc --- /dev/null +++ b/services/matrix-stats-bot/package.json @@ -0,0 +1,36 @@ +{ + "name": "@manacore/matrix-stats-bot", + "version": "1.0.0", + "description": "Matrix bot for analytics from Umami - GDPR compliant", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@nestjs/schedule": "^4.1.2", + "matrix-bot-sdk": "^0.7.1", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-stats-bot/src/analytics/analytics.module.ts b/services/matrix-stats-bot/src/analytics/analytics.module.ts new file mode 100644 index 000000000..ab8df2d1d --- /dev/null +++ b/services/matrix-stats-bot/src/analytics/analytics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { UmamiModule } from '../umami/umami.module'; + +@Module({ + imports: [UmamiModule], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/services/matrix-stats-bot/src/analytics/analytics.service.ts b/services/matrix-stats-bot/src/analytics/analytics.service.ts new file mode 100644 index 000000000..31d0d816e --- /dev/null +++ b/services/matrix-stats-bot/src/analytics/analytics.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UmamiService } from '../umami/umami.service'; +import { WEBSITE_IDS, DISPLAY_NAMES } from '../config/configuration'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor(private readonly umamiService: UmamiService) {} + + async generateStatsOverview(): Promise { + const now = Date.now(); + const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; + + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites in Umami konfiguriert.'; + } + + let report = '**📊 ManaCore Stats (30 Tage)**\n\n'; + + for (const website of websites) { + const stats = await this.umamiService.getStats(website.id, thirtyDaysAgo, now); + if (!stats) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + const changeIcon = (change: number) => (change > 0 ? '📈' : change < 0 ? '📉' : '➡️'); + + report += `**${displayName}**\n`; + report += `👁️ ${stats.pageviews.value.toLocaleString()} Views ${changeIcon(stats.pageviews.change)}\n`; + report += `👥 ${stats.visitors.value.toLocaleString()} Besucher ${changeIcon(stats.visitors.change)}\n\n`; + } + + return report; + } + + async generateDailyReport(): Promise { + const now = Date.now(); + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites konfiguriert.'; + } + + let report = '**📊 Heute**\n\n'; + let totalViews = 0; + let totalVisitors = 0; + + for (const website of websites) { + const stats = await this.umamiService.getStats(website.id, todayStart.getTime(), now); + if (!stats) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + totalViews += stats.pageviews.value; + totalVisitors += stats.visitors.value; + + if (stats.pageviews.value > 0) { + report += `**${displayName}:** ${stats.pageviews.value} Views, ${stats.visitors.value} Besucher\n`; + } + } + + report += `\n**Gesamt:** ${totalViews} Views, ${totalVisitors} Besucher`; + + return report; + } + + async generateWeeklyReport(): Promise { + const now = Date.now(); + const weekAgo = now - 7 * 24 * 60 * 60 * 1000; + + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites konfiguriert.'; + } + + let report = '**📊 Diese Woche**\n\n'; + let totalViews = 0; + let totalVisitors = 0; + + for (const website of websites) { + const stats = await this.umamiService.getStats(website.id, weekAgo, now); + if (!stats) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + totalViews += stats.pageviews.value; + totalVisitors += stats.visitors.value; + + const changeIcon = (change: number) => (change > 0 ? '📈' : change < 0 ? '📉' : '➡️'); + + report += `**${displayName}**\n`; + report += `👁️ ${stats.pageviews.value.toLocaleString()} Views ${changeIcon(stats.pageviews.change)} (${stats.pageviews.change > 0 ? '+' : ''}${stats.pageviews.change}%)\n`; + report += `👥 ${stats.visitors.value.toLocaleString()} Besucher ${changeIcon(stats.visitors.change)}\n\n`; + } + + report += `**Gesamt:** ${totalViews.toLocaleString()} Views, ${totalVisitors.toLocaleString()} Besucher`; + + return report; + } + + async generateRealtimeReport(): Promise { + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites konfiguriert.'; + } + + let report = '**🔴 Realtime**\n\n'; + let totalActive = 0; + + for (const website of websites) { + const realtime = await this.umamiService.getRealtime(website.id); + if (!realtime || realtime.visitors === 0) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + totalActive += realtime.visitors; + + report += `**${displayName}:** ${realtime.visitors} aktiv\n`; + } + + if (totalActive === 0) { + report += 'Keine aktiven Besucher.'; + } else { + report += `\n**Gesamt:** ${totalActive} aktive Besucher`; + } + + return report; + } +} diff --git a/services/matrix-stats-bot/src/app.module.ts b/services/matrix-stats-bot/src/app.module.ts new file mode 100644 index 000000000..825025743 --- /dev/null +++ b/services/matrix-stats-bot/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BotModule } from './bot/bot.module'; +import { SchedulerModule } from './scheduler/scheduler.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + SchedulerModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-stats-bot/src/bot/bot.module.ts b/services/matrix-stats-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..329152f08 --- /dev/null +++ b/services/matrix-stats-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [AnalyticsModule, UsersModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-stats-bot/src/bot/matrix.service.ts b/services/matrix-stats-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..666a81f72 --- /dev/null +++ b/services/matrix-stats-bot/src/bot/matrix.service.ts @@ -0,0 +1,196 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, + MessageEvent, + RoomEvent, +} from 'matrix-bot-sdk'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { UsersService } from '../users/users.service'; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private botUserId: string = ''; + private reportRoomId: string = ''; + + constructor( + private configService: ConfigService, + private analyticsService: AnalyticsService, + private usersService: UsersService + ) { + this.reportRoomId = this.configService.get('matrix.reportRoomId') || ''; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogService.LogLevel.INFO); + + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + await this.client.start(); + this.logger.log('Matrix Stats Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix Stats Bot stopped'); + } + } + + private async handleRoomMessage(roomId: string, event: RoomEvent) { + if (event.sender === this.botUserId) return; + + const content = event.content; + if (content.msgtype !== 'm.text') return; + + const body = content.body; + if (!body || !body.startsWith('!')) return; + + const [command] = body.slice(1).split(' '); + await this.handleCommand(roomId, command.toLowerCase()); + } + + private async handleCommand(roomId: string, command: string) { + switch (command) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + + case 'stats': + await this.sendStats(roomId); + break; + + case 'today': + await this.sendToday(roomId); + break; + + case 'week': + await this.sendWeek(roomId); + break; + + case 'realtime': + await this.sendRealtime(roomId); + break; + + case 'users': + await this.sendUsers(roomId); + break; + + default: + await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); + } + } + + private async sendHelp(roomId: string) { + const helpText = `**📊 ManaCore Stats Bot (DSGVO-konform)** + +**Befehle:** +- \`!stats\` - Übersicht aller Apps (30 Tage) +- \`!today\` - Heutige Statistiken +- \`!week\` - Wochenstatistiken +- \`!realtime\` - Aktive Besucher jetzt +- \`!users\` - Registrierte Benutzer +- \`!help\` - Diese Hilfe + +Daten von Umami Analytics (self-hosted).`; + + await this.sendMessage(roomId, helpText); + } + + private async sendStats(roomId: string) { + await this.sendMessage(roomId, '📊 Lade Statistiken...'); + const report = await this.analyticsService.generateStatsOverview(); + await this.sendMessage(roomId, report); + } + + private async sendToday(roomId: string) { + await this.sendMessage(roomId, '📊 Lade heutige Statistiken...'); + const report = await this.analyticsService.generateDailyReport(); + await this.sendMessage(roomId, report); + } + + private async sendWeek(roomId: string) { + await this.sendMessage(roomId, '📊 Lade Wochenstatistiken...'); + const report = await this.analyticsService.generateWeeklyReport(); + await this.sendMessage(roomId, report); + } + + private async sendRealtime(roomId: string) { + const report = await this.analyticsService.generateRealtimeReport(); + await this.sendMessage(roomId, report); + } + + private async sendUsers(roomId: string) { + const stats = await this.usersService.getUserStats(); + + if (!stats) { + await this.sendMessage(roomId, '❌ Datenbank nicht verfügbar.'); + return; + } + + const report = `**👥 Benutzer-Statistiken** + +**Gesamt:** ${stats.total} Benutzer +**Verifiziert:** ${stats.verified} (${((stats.verified / stats.total) * 100).toFixed(1)}%) + +**Neue Benutzer:** +- Letzte 7 Tage: ${stats.lastWeek} +- Letzte 30 Tage: ${stats.lastMonth}`; + + await this.sendMessage(roomId, report); + } + + // Public method for scheduled reports + async sendScheduledReport(report: string) { + if (!this.reportRoomId) { + this.logger.warn('No report room configured'); + return; + } + + await this.sendMessage(this.reportRoomId, report); + } + + private async sendMessage(roomId: string, message: string) { + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return markdown + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-stats-bot/src/config/configuration.ts b/services/matrix-stats-bot/src/config/configuration.ts new file mode 100644 index 000000000..90a0aedd6 --- /dev/null +++ b/services/matrix-stats-bot/src/config/configuration.ts @@ -0,0 +1,39 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3312', 10), + timezone: process.env.TZ || 'Europe/Berlin', + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + reportRoomId: process.env.MATRIX_REPORT_ROOM_ID || '', + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + umami: { + apiUrl: process.env.UMAMI_API_URL || 'http://localhost:3000', + username: process.env.UMAMI_USERNAME || 'admin', + password: process.env.UMAMI_PASSWORD || '', + }, + database: { + url: process.env.DATABASE_URL || '', + }, +}); + +// Website IDs from Umami - update these with actual UUIDs +export const WEBSITE_IDS: Record = { + 'manacore-webapp': process.env.UMAMI_WEBSITE_MANACORE || '', + 'chat-webapp': process.env.UMAMI_WEBSITE_CHAT || '', + 'todo-webapp': process.env.UMAMI_WEBSITE_TODO || '', + 'calendar-webapp': process.env.UMAMI_WEBSITE_CALENDAR || '', + 'clock-webapp': process.env.UMAMI_WEBSITE_CLOCK || '', + 'contacts-webapp': process.env.UMAMI_WEBSITE_CONTACTS || '', + 'storage-webapp': process.env.UMAMI_WEBSITE_STORAGE || '', +}; + +export const DISPLAY_NAMES: Record = { + 'manacore-webapp': 'Dashboard', + 'chat-webapp': 'Chat', + 'todo-webapp': 'Todo', + 'calendar-webapp': 'Calendar', + 'clock-webapp': 'Clock', + 'contacts-webapp': 'Contacts', + 'storage-webapp': 'Storage', +}; diff --git a/services/matrix-stats-bot/src/health.controller.ts b/services/matrix-stats-bot/src/health.controller.ts new file mode 100644 index 000000000..44275b13b --- /dev/null +++ b/services/matrix-stats-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-stats-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-stats-bot/src/main.ts b/services/matrix-stats-bot/src/main.ts new file mode 100644 index 000000000..4751306f3 --- /dev/null +++ b/services/matrix-stats-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3312; + await app.listen(port); + + logger.log(`Matrix Stats Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-stats-bot/src/scheduler/report.scheduler.ts b/services/matrix-stats-bot/src/scheduler/report.scheduler.ts new file mode 100644 index 000000000..b193fd0a0 --- /dev/null +++ b/services/matrix-stats-bot/src/scheduler/report.scheduler.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { MatrixService } from '../bot/matrix.service'; +import { AnalyticsService } from '../analytics/analytics.service'; + +@Injectable() +export class ReportScheduler { + private readonly logger = new Logger(ReportScheduler.name); + + constructor( + private readonly matrixService: MatrixService, + private readonly analyticsService: AnalyticsService + ) {} + + // Daily report at 9:00 AM Berlin time + @Cron('0 9 * * *', { timeZone: 'Europe/Berlin' }) + async sendDailyReport() { + this.logger.log('Sending daily report...'); + const report = await this.analyticsService.generateDailyReport(); + await this.matrixService.sendScheduledReport(`📅 **Täglicher Report**\n\n${report}`); + } + + // Weekly report on Monday at 9:00 AM Berlin time + @Cron('0 9 * * 1', { timeZone: 'Europe/Berlin' }) + async sendWeeklyReport() { + this.logger.log('Sending weekly report...'); + const report = await this.analyticsService.generateWeeklyReport(); + await this.matrixService.sendScheduledReport(`📅 **Wöchentlicher Report**\n\n${report}`); + } +} diff --git a/services/matrix-stats-bot/src/scheduler/scheduler.module.ts b/services/matrix-stats-bot/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..51a1ad147 --- /dev/null +++ b/services/matrix-stats-bot/src/scheduler/scheduler.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ReportScheduler } from './report.scheduler'; +import { BotModule } from '../bot/bot.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), BotModule, AnalyticsModule], + providers: [ReportScheduler], +}) +export class SchedulerModule {} diff --git a/services/matrix-stats-bot/src/umami/umami.module.ts b/services/matrix-stats-bot/src/umami/umami.module.ts new file mode 100644 index 000000000..b9bb7b2bd --- /dev/null +++ b/services/matrix-stats-bot/src/umami/umami.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UmamiService } from './umami.service'; + +@Module({ + providers: [UmamiService], + exports: [UmamiService], +}) +export class UmamiModule {} diff --git a/services/matrix-stats-bot/src/umami/umami.service.ts b/services/matrix-stats-bot/src/umami/umami.service.ts new file mode 100644 index 000000000..44b61406e --- /dev/null +++ b/services/matrix-stats-bot/src/umami/umami.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface UmamiStats { + pageviews: { value: number; change: number }; + visitors: { value: number; change: number }; + visits: { value: number; change: number }; + bounces: { value: number; change: number }; + totaltime: { value: number; change: number }; +} + +interface UmamiRealtime { + pageviews: number; + visitors: number; + countries: { name: string; count: number }[]; +} + +@Injectable() +export class UmamiService implements OnModuleInit { + private readonly logger = new Logger(UmamiService.name); + private readonly apiUrl: string; + private readonly username: string; + private readonly password: string; + private accessToken: string | null = null; + + constructor(private configService: ConfigService) { + this.apiUrl = this.configService.get('umami.apiUrl') || 'http://localhost:3000'; + this.username = this.configService.get('umami.username') || 'admin'; + this.password = this.configService.get('umami.password') || ''; + } + + async onModuleInit() { + await this.authenticate(); + } + + private async authenticate(): Promise { + try { + const response = await fetch(`${this.apiUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }); + + if (!response.ok) { + throw new Error(`Auth failed: ${response.status}`); + } + + const data = await response.json(); + this.accessToken = data.token; + this.logger.log('Umami authenticated successfully'); + } catch (error) { + this.logger.error('Failed to authenticate with Umami:', error); + } + } + + private async request(endpoint: string): Promise { + if (!this.accessToken) { + await this.authenticate(); + } + + try { + const response = await fetch(`${this.apiUrl}${endpoint}`, { + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + + if (response.status === 401) { + await this.authenticate(); + return this.request(endpoint); + } + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return response.json(); + } catch (error) { + this.logger.error(`Umami request failed: ${endpoint}`, error); + return null; + } + } + + async getWebsites(): Promise<{ id: string; name: string; domain: string }[]> { + const data = await this.request<{ data: { id: string; name: string; domain: string }[] }>( + '/api/websites' + ); + return data?.data || []; + } + + async getStats(websiteId: string, startAt: number, endAt: number): Promise { + return this.request( + `/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}` + ); + } + + async getRealtime(websiteId: string): Promise { + return this.request(`/api/websites/${websiteId}/active`); + } + + async getPageviews( + websiteId: string, + startAt: number, + endAt: number, + unit: 'hour' | 'day' | 'month' = 'day' + ): Promise<{ pageviews: { x: string; y: number }[]; sessions: { x: string; y: number }[] } | null> { + return this.request( + `/api/websites/${websiteId}/pageviews?startAt=${startAt}&endAt=${endAt}&unit=${unit}` + ); + } +} diff --git a/services/matrix-stats-bot/src/users/users.module.ts b/services/matrix-stats-bot/src/users/users.module.ts new file mode 100644 index 000000000..00ef465ea --- /dev/null +++ b/services/matrix-stats-bot/src/users/users.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/services/matrix-stats-bot/src/users/users.service.ts b/services/matrix-stats-bot/src/users/users.service.ts new file mode 100644 index 000000000..5797389af --- /dev/null +++ b/services/matrix-stats-bot/src/users/users.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import postgres from 'postgres'; + +interface UserStats { + total: number; + verified: number; + lastWeek: number; + lastMonth: number; +} + +@Injectable() +export class UsersService implements OnModuleInit { + private readonly logger = new Logger(UsersService.name); + private sql: postgres.Sql | null = null; + + constructor(private configService: ConfigService) {} + + async onModuleInit() { + const databaseUrl = this.configService.get('database.url'); + if (databaseUrl) { + try { + this.sql = postgres(databaseUrl); + this.logger.log('Database connected for user stats'); + } catch (error) { + this.logger.warn('Failed to connect to database:', error); + } + } else { + this.logger.warn('DATABASE_URL not configured - user stats disabled'); + } + } + + async getUserStats(): Promise { + if (!this.sql) { + return null; + } + + try { + const [totalResult] = await this.sql`SELECT COUNT(*) as count FROM "user"`; + const [verifiedResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "emailVerified" = true`; + const [weekResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "createdAt" > NOW() - INTERVAL '7 days'`; + const [monthResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "createdAt" > NOW() - INTERVAL '30 days'`; + + return { + total: parseInt(totalResult.count, 10), + verified: parseInt(verifiedResult.count, 10), + lastWeek: parseInt(weekResult.count, 10), + lastMonth: parseInt(monthResult.count, 10), + }; + } catch (error) { + this.logger.error('Failed to get user stats:', error); + return null; + } + } +} diff --git a/services/matrix-stats-bot/tsconfig.json b/services/matrix-stats-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-stats-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +}