mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
Merge pull request #23 from Memo-2023/claude/gdpr-bot-alternatives-VFgL1
Claude/gdpr bot alternatives v fg l1
This commit is contained in:
commit
13754f2d55
66 changed files with 4202 additions and 0 deletions
|
|
@ -748,6 +748,169 @@ 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
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# 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)
|
||||
# ============================================
|
||||
|
|
@ -786,3 +949,11 @@ volumes:
|
|||
name: manacore-grafana
|
||||
n8n_data:
|
||||
name: manacore-n8n
|
||||
synapse_data:
|
||||
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
|
||||
|
|
|
|||
47
docker/matrix/element-config.json
Normal file
47
docker/matrix/element-config.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
190
docker/matrix/homeserver.yaml
Normal file
190
docker/matrix/homeserver.yaml
Normal file
|
|
@ -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 <noreply@mana.how>"
|
||||
34
docker/matrix/log.config.yaml
Normal file
34
docker/matrix/log.config.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
674
docs/MATRIX_SELF_HOSTING.md
Normal file
674
docs/MATRIX_SELF_HOSTING.md
Normal file
|
|
@ -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 <name>\` - 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<Buffer> {
|
||||
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)
|
||||
|
|
@ -229,6 +229,14 @@ 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/"
|
||||
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:"
|
||||
if pgrep -x "cloudflared" >/dev/null; then
|
||||
|
|
|
|||
123
scripts/mac-mini/setup-matrix.sh
Executable file
123
scripts/mac-mini/setup-matrix.sh
Executable file
|
|
@ -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}"
|
||||
15
services/matrix-ollama-bot/.env.example
Normal file
15
services/matrix-ollama-bot/.env.example
Normal file
|
|
@ -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
|
||||
137
services/matrix-ollama-bot/CLAUDE.md
Normal file
137
services/matrix-ollama-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
53
services/matrix-ollama-bot/Dockerfile
Normal file
53
services/matrix-ollama-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
8
services/matrix-ollama-bot/nest-cli.json
Normal file
8
services/matrix-ollama-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
34
services/matrix-ollama-bot/package.json
Normal file
34
services/matrix-ollama-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
17
services/matrix-ollama-bot/src/app.module.ts
Normal file
17
services/matrix-ollama-bot/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
10
services/matrix-ollama-bot/src/bot/bot.module.ts
Normal file
10
services/matrix-ollama-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
340
services/matrix-ollama-bot/src/bot/matrix.service.ts
Normal file
340
services/matrix-ollama-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string, UserSession> = new Map();
|
||||
private readonly allowedRooms: string[];
|
||||
private botUserId: string = '';
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private ollamaService: OllamaService
|
||||
) {
|
||||
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('matrix.storagePath');
|
||||
|
||||
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<MessageEvent>) {
|
||||
// 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, '<pre><code>$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Bold
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// Italic
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
22
services/matrix-ollama-bot/src/config/configuration.ts
Normal file
22
services/matrix-ollama-bot/src/config/configuration.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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.`,
|
||||
};
|
||||
13
services/matrix-ollama-bot/src/health.controller.ts
Normal file
13
services/matrix-ollama-bot/src/health.controller.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
services/matrix-ollama-bot/src/main.ts
Normal file
15
services/matrix-ollama-bot/src/main.ts
Normal file
|
|
@ -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();
|
||||
8
services/matrix-ollama-bot/src/ollama/ollama.module.ts
Normal file
8
services/matrix-ollama-bot/src/ollama/ollama.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OllamaService } from './ollama.service';
|
||||
|
||||
@Module({
|
||||
providers: [OllamaService],
|
||||
exports: [OllamaService],
|
||||
})
|
||||
export class OllamaModule {}
|
||||
94
services/matrix-ollama-bot/src/ollama/ollama.service.ts
Normal file
94
services/matrix-ollama-bot/src/ollama/ollama.service.ts
Normal file
|
|
@ -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<string>('ollama.url') || 'http://localhost:11434';
|
||||
this.defaultModel = this.configService.get<string>('ollama.model') || 'gemma3:4b';
|
||||
this.timeout = this.configService.get<number>('ollama.timeout') || 120000;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.checkConnection();
|
||||
}
|
||||
|
||||
async checkConnection(): Promise<boolean> {
|
||||
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<OllamaModel[]> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
22
services/matrix-ollama-bot/tsconfig.json
Normal file
22
services/matrix-ollama-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
23
services/matrix-project-doc-bot/.env.example
Normal file
23
services/matrix-project-doc-bot/.env.example
Normal file
|
|
@ -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
|
||||
122
services/matrix-project-doc-bot/CLAUDE.md
Normal file
122
services/matrix-project-doc-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
```
|
||||
25
services/matrix-project-doc-bot/Dockerfile
Normal file
25
services/matrix-project-doc-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
10
services/matrix-project-doc-bot/drizzle.config.ts
Normal file
10
services/matrix-project-doc-bot/drizzle.config.ts
Normal file
|
|
@ -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 || '',
|
||||
},
|
||||
});
|
||||
8
services/matrix-project-doc-bot/nest-cli.json
Normal file
8
services/matrix-project-doc-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
43
services/matrix-project-doc-bot/package.json
Normal file
43
services/matrix-project-doc-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
19
services/matrix-project-doc-bot/src/app.module.ts
Normal file
19
services/matrix-project-doc-bot/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
12
services/matrix-project-doc-bot/src/bot/bot.module.ts
Normal file
12
services/matrix-project-doc-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
442
services/matrix-project-doc-bot/src/bot/matrix.service.ts
Normal file
442
services/matrix-project-doc-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string, string> = new Map();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private projectService: ProjectService,
|
||||
private mediaService: MediaService,
|
||||
private generationService: GenerationService
|
||||
) {
|
||||
this.allowedUsers = this.configService.get<string[]>('matrix.allowedUsers') || [];
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('matrix.storagePath');
|
||||
|
||||
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<MessageEvent>) {
|
||||
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, '<pre><code>$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/_([^_]+)_/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
47
services/matrix-project-doc-bot/src/config/configuration.ts
Normal file
47
services/matrix-project-doc-bot/src/config/configuration.ts
Normal file
|
|
@ -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<string, { name: string; prompt: string }> = {
|
||||
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.`,
|
||||
},
|
||||
};
|
||||
|
|
@ -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<string>('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 {}
|
||||
33
services/matrix-project-doc-bot/src/database/schema.ts
Normal file
33
services/matrix-project-doc-bot/src/database/schema.ts
Normal file
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GenerationService } from './generation.service';
|
||||
|
||||
@Module({
|
||||
providers: [GenerationService],
|
||||
exports: [GenerationService],
|
||||
})
|
||||
export class GenerationModule {}
|
||||
|
|
@ -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<typeof schema>;
|
||||
|
||||
@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<string>('openai.apiKey'),
|
||||
});
|
||||
this.model = this.configService.get<string>('openai.model') || 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
async generateBlogpost(projectId: string, style: keyof typeof BLOG_STYLES): Promise<string> {
|
||||
const apiKey = this.configService.get<string>('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;
|
||||
}
|
||||
}
|
||||
13
services/matrix-project-doc-bot/src/health.controller.ts
Normal file
13
services/matrix-project-doc-bot/src/health.controller.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
services/matrix-project-doc-bot/src/main.ts
Normal file
15
services/matrix-project-doc-bot/src/main.ts
Normal file
|
|
@ -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();
|
||||
11
services/matrix-project-doc-bot/src/media/media.module.ts
Normal file
11
services/matrix-project-doc-bot/src/media/media.module.ts
Normal file
|
|
@ -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 {}
|
||||
92
services/matrix-project-doc-bot/src/media/media.service.ts
Normal file
92
services/matrix-project-doc-bot/src/media/media.service.ts
Normal file
|
|
@ -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<typeof schema>;
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
83
services/matrix-project-doc-bot/src/media/storage.service.ts
Normal file
83
services/matrix-project-doc-bot/src/media/storage.service.ts
Normal file
|
|
@ -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<string>('s3.endpoint'),
|
||||
region: this.configService.get<string>('s3.region'),
|
||||
credentials: {
|
||||
accessKeyId: this.configService.get<string>('s3.accessKey') || '',
|
||||
secretAccessKey: this.configService.get<string>('s3.secretKey') || '',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
this.bucket = this.configService.get<string>('s3.bucket') || 'project-doc-bot';
|
||||
}
|
||||
|
||||
async uploadFile(buffer: Buffer, contentType: string, projectId: string): Promise<string> {
|
||||
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<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async downloadFile(key: string): Promise<Buffer> {
|
||||
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<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/mp4': '.m4a',
|
||||
};
|
||||
return map[contentType] || '';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
@Module({
|
||||
providers: [ProjectService],
|
||||
exports: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
|
|
@ -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<typeof schema>;
|
||||
|
||||
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<typeof projects.$inferInsert>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TranscriptionService } from './transcription.service';
|
||||
|
||||
@Module({
|
||||
providers: [TranscriptionService],
|
||||
exports: [TranscriptionService],
|
||||
})
|
||||
export class TranscriptionModule {}
|
||||
|
|
@ -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<string>('openai.apiKey');
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.warn('OPENAI_API_KEY not configured - transcription disabled');
|
||||
}
|
||||
|
||||
this.openai = new OpenAI({ apiKey });
|
||||
this.model = this.configService.get<string>('openai.whisperModel') || 'whisper-1';
|
||||
}
|
||||
|
||||
async transcribe(audioBuffer: Buffer): Promise<string> {
|
||||
const apiKey = this.configService.get<string>('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;
|
||||
}
|
||||
}
|
||||
22
services/matrix-project-doc-bot/tsconfig.json
Normal file
22
services/matrix-project-doc-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
16
services/matrix-stats-bot/.env.example
Normal file
16
services/matrix-stats-bot/.env.example
Normal file
|
|
@ -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=
|
||||
65
services/matrix-stats-bot/CLAUDE.md
Normal file
65
services/matrix-stats-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
```
|
||||
25
services/matrix-stats-bot/Dockerfile
Normal file
25
services/matrix-stats-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
8
services/matrix-stats-bot/nest-cli.json
Normal file
8
services/matrix-stats-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
36
services/matrix-stats-bot/package.json
Normal file
36
services/matrix-stats-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
10
services/matrix-stats-bot/src/analytics/analytics.module.ts
Normal file
10
services/matrix-stats-bot/src/analytics/analytics.module.ts
Normal file
|
|
@ -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 {}
|
||||
129
services/matrix-stats-bot/src/analytics/analytics.service.ts
Normal file
129
services/matrix-stats-bot/src/analytics/analytics.service.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
19
services/matrix-stats-bot/src/app.module.ts
Normal file
19
services/matrix-stats-bot/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
11
services/matrix-stats-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-stats-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
196
services/matrix-stats-bot/src/bot/matrix.service.ts
Normal file
196
services/matrix-stats-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string>('matrix.reportRoomId') || '';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('matrix.storagePath');
|
||||
|
||||
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<MessageEvent>) {
|
||||
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, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
39
services/matrix-stats-bot/src/config/configuration.ts
Normal file
39
services/matrix-stats-bot/src/config/configuration.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'manacore-webapp': 'Dashboard',
|
||||
'chat-webapp': 'Chat',
|
||||
'todo-webapp': 'Todo',
|
||||
'calendar-webapp': 'Calendar',
|
||||
'clock-webapp': 'Clock',
|
||||
'contacts-webapp': 'Contacts',
|
||||
'storage-webapp': 'Storage',
|
||||
};
|
||||
13
services/matrix-stats-bot/src/health.controller.ts
Normal file
13
services/matrix-stats-bot/src/health.controller.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
services/matrix-stats-bot/src/main.ts
Normal file
15
services/matrix-stats-bot/src/main.ts
Normal file
|
|
@ -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();
|
||||
30
services/matrix-stats-bot/src/scheduler/report.scheduler.ts
Normal file
30
services/matrix-stats-bot/src/scheduler/report.scheduler.ts
Normal file
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
11
services/matrix-stats-bot/src/scheduler/scheduler.module.ts
Normal file
11
services/matrix-stats-bot/src/scheduler/scheduler.module.ts
Normal file
|
|
@ -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 {}
|
||||
8
services/matrix-stats-bot/src/umami/umami.module.ts
Normal file
8
services/matrix-stats-bot/src/umami/umami.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UmamiService } from './umami.service';
|
||||
|
||||
@Module({
|
||||
providers: [UmamiService],
|
||||
exports: [UmamiService],
|
||||
})
|
||||
export class UmamiModule {}
|
||||
114
services/matrix-stats-bot/src/umami/umami.service.ts
Normal file
114
services/matrix-stats-bot/src/umami/umami.service.ts
Normal file
|
|
@ -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<string>('umami.apiUrl') || 'http://localhost:3000';
|
||||
this.username = this.configService.get<string>('umami.username') || 'admin';
|
||||
this.password = this.configService.get<string>('umami.password') || '';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.authenticate();
|
||||
}
|
||||
|
||||
private async authenticate(): Promise<void> {
|
||||
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<T>(endpoint: string): Promise<T | null> {
|
||||
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<UmamiStats | null> {
|
||||
return this.request<UmamiStats>(
|
||||
`/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`
|
||||
);
|
||||
}
|
||||
|
||||
async getRealtime(websiteId: string): Promise<UmamiRealtime | null> {
|
||||
return this.request<UmamiRealtime>(`/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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
8
services/matrix-stats-bot/src/users/users.module.ts
Normal file
8
services/matrix-stats-bot/src/users/users.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
55
services/matrix-stats-bot/src/users/users.service.ts
Normal file
55
services/matrix-stats-bot/src/users/users.service.ts
Normal file
|
|
@ -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<string>('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<UserStats | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/matrix-stats-bot/tsconfig.json
Normal file
22
services/matrix-stats-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue