🔥 remove: Telegram bots - Matrix-only strategy

Remove all 6 Telegram bot services to focus on Matrix as the sole
messaging platform for full UI/UX control and DSGVO compliance.

Removed services:
- telegram-nutriphi-bot
- telegram-ollama-bot
- telegram-project-doc-bot
- telegram-stats-bot
- telegram-todo-bot
- telegram-zitare-bot

Also:
- Remove Telegram bot scripts from package.json
- Remove telegram-stats-bot from docker-compose.macmini.yml
- Disable Watchtower Telegram notifications
- Remove Telegram devlog
- Add comprehensive MATRIX_BOT_ARCHITECTURE.md documentation

The Matrix-only approach provides:
- Full control over user experience
- Complete DSGVO compliance (all data on own servers)
- No dependency on third-party platforms
- Unified command patterns across all bots
This commit is contained in:
Till-JS 2026-02-01 00:17:14 +01:00
parent d2f00c1d77
commit a341aa1b13
132 changed files with 2133 additions and 9419 deletions

View file

@ -0,0 +1,602 @@
---
title: 'Infrastruktur-Audit & Architektur-Verbesserungen'
description: 'Umfassende Analyse der aktuellen Mac Mini Infrastruktur mit detaillierten Verbesserungsvorschlägen für Port-Verteilung, Volumes, Dependencies und Service-Konsolidierung vor der K8s-Migration.'
date: 2026-01-31
author: 'Till Schneider'
category: 'infrastructure'
status: 'accepted'
tags:
[
'docker',
'infrastructure',
'audit',
'ports',
'volumes',
'dependencies',
'migration',
'mac-mini',
'architecture',
]
featured: true
readTime: 25
relatedBlueprints: ['001-mana-cluster-federation-architecture']
decisionDate: 2026-01-31
---
## Executive Summary
Dieses Dokument analysiert den aktuellen Zustand der ManaCore-Infrastruktur auf dem Mac Mini und identifiziert Verbesserungspotentiale vor der geplanten Migration zu einem selbstheilenden K8s-Cluster. Die Analyse umfasst **52 Docker-Container** plus **3 native macOS-Services**.
## Aktueller Infrastruktur-Zustand
### Service-Kategorien Übersicht
```
┌─────────────────────────────────────────────────────────────────────┐
│ ManaCore Infrastructure │
│ (Mac Mini M4 Pro) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ MinIO │ │
│ │ :5432 │ │ :6379 │ │ :9000/:9001 │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ mana-core-auth (:3001) │ │
│ │ (Central Authentication) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ API Gateway │ │ mana-search │ │ App Suite │ │
│ │ (:3030) │ │ (:3021) │ │ (10 Apps) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Matrix Stack (14 Services) │ │
│ │ Synapse │ Element │ 10 Bots │ Custom Web Client │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Monitoring & Tools (8 Services) │ │
│ │ VictoriaMetrics │ Grafana │ Exporters │ Umami │ n8n │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Vollständige Service-Inventar
#### Tier 0: Infrastruktur (3 Services)
| Service | Container | Port(s) | Volume | Bemerkung |
| ------------- | ----------------- | ---------- | ----------------- | ----------------------------- |
| PostgreSQL 16 | manacore-postgres | 5432 | manacore-postgres | Alle DBs in einer Instanz |
| Redis 7 | manacore-redis | 6379 | manacore-redis | Session-Cache + Pub/Sub |
| MinIO | manacore-minio | 9000, 9001 | manacore-minio | S3-kompatibler Object Storage |
#### Tier 1: Core Auth (1 Service)
| Service | Container | Port | Dependencies | Bemerkung |
| -------------- | -------------- | ---- | --------------- | ----------------------- |
| mana-core-auth | mana-core-auth | 3001 | postgres, redis | Better Auth + EdDSA JWT |
#### Tier 2: Gateway & Suche (3 Services)
| Service | Container | Port | Dependencies | Bemerkung |
| ----------- | ---------------- | ------------- | ----------------------------- | --------------------- |
| API Gateway | mana-api-gateway | 3030 | auth, search, postgres, redis | Monetarisierung |
| SearXNG | mana-searxng | (intern 8080) | - | Meta-Suchmaschine |
| mana-search | mana-search | 3021 | searxng, redis | NestJS Search Service |
#### Tier 3: App-Backends (10 Services)
| App | Container | Port | Datenbank | Spezial |
| --------- | ----------------- | ---- | --------- | ------------------ |
| Chat | chat-backend | 3002 | chat | Ollama-Integration |
| Presi | presi-backend | 3008 | presi | - |
| Contacts | contacts-backend | 3015 | contacts | MinIO (Fotos) |
| Calendar | calendar-backend | 3016 | calendar | - |
| Clock | clock-backend | 3017 | clock | - |
| Todo | todo-backend | 3018 | todo | - |
| Storage | storage-backend | 3019 | storage | MinIO (Dateien) |
| NutriPhi | nutriphi-backend | 3023 | nutriphi | Gemini API |
| SkillTree | skilltree-backend | 3024 | skilltree | - |
#### Tier 4: Web-Frontends (11 Services)
| App | Container | Port | Backend-Port |
| ------------------ | -------------- | ---- | ------------ |
| ManaCore Dashboard | manacore-web | 5173 | - |
| Chat | chat-web | 3000 | 3002 |
| Presi | presi-web | 5178 | 3008 |
| Matrix | matrix-web | 5180 | 8008 |
| Contacts | contacts-web | 5184 | 3015 |
| Storage | storage-web | 5185 | 3019 |
| Calendar | calendar-web | 5186 | 3016 |
| Clock | clock-web | 5187 | 3017 |
| Todo | todo-web | 5188 | 3018 |
| NutriPhi | nutriphi-web | 5189 | 3023 |
| LLM Playground | llm-playground | 5190 | - |
| SkillTree | skilltree-web | 5195 | 3024 |
#### Tier 5: Matrix Stack (14 Services)
| Service | Container | Port | Funktion |
| --------------- | ------------------------------- | ---------- | ----------------------- |
| Synapse | manacore-synapse | 8008, 9002 | Homeserver |
| Element Web | manacore-element | 8087 | Standard Client |
| Matrix Web | manacore-matrix-web | 5180 | Custom SvelteKit Client |
| Mana Bot | manacore-matrix-mana-bot | 3310 | Unified Gateway Bot |
| Ollama Bot | manacore-matrix-ollama-bot | 3311 | AI Chat |
| Stats Bot | manacore-matrix-stats-bot | 3312 | Analytics |
| Project Doc Bot | manacore-matrix-project-doc-bot | 3313 | Dokumentation |
| Todo Bot | manacore-matrix-todo-bot | 3314 | Task Management |
| Calendar Bot | manacore-matrix-calendar-bot | 3315 | Termine |
| NutriPhi Bot | manacore-matrix-nutriphi-bot | 3316 | Nutrition |
| Zitare Bot | manacore-matrix-zitare-bot | 3317 | Quotes |
| Clock Bot | manacore-matrix-clock-bot | 3318 | Time Tracking |
| TTS Bot | manacore-matrix-tts-bot | 3033 | Text-to-Speech |
#### Tier 6: Monitoring & Tools (8 Services)
| Service | Container | Port | Funktion |
| ------------------ | --------------------------- | ---- | ------------------- |
| VictoriaMetrics | manacore-victoriametrics | 8428 | Metriken-DB |
| Grafana | manacore-grafana | 3100 | Dashboards |
| Pushgateway | manacore-pushgateway | 9091 | Batch-Metriken |
| Node Exporter | manacore-node-exporter | 9100 | Host-Metriken |
| cAdvisor | manacore-cadvisor | 8080 | Container-Metriken |
| Postgres Exporter | manacore-postgres-exporter | 9187 | DB-Metriken |
| Redis Exporter | manacore-redis-exporter | 9121 | Cache-Metriken |
| Umami | manacore-umami | 3200 | Web Analytics |
| n8n | manacore-n8n | 5678 | Workflow Automation |
| Telegram Stats Bot | manacore-telegram-stats-bot | 3300 | Telegram Reports |
#### Native macOS Services (3 Services)
| Service | Port | Zugriff | Bemerkung |
| -------------- | ----- | -------------------- | ----------------------- |
| Ollama | 11434 | host.docker.internal | Gemma 3 4B, LLM Runtime |
| mana-image-gen | 3025 | host.docker.internal | Stable Diffusion |
| mana-tts | 3022 | host.docker.internal | Kokoro TTS |
| mana-stt | 3020 | host.docker.internal | Whisper STT |
#### Auto-Update (1 Service)
| Service | Container | Funktion |
| ---------- | ------------------- | ----------------------------- |
| Watchtower | manacore-watchtower | Auto-Update + Telegram Notify |
---
## Identifizierte Probleme
### 1. Port-Chaos
Die aktuelle Port-Verteilung folgt keinem erkennbaren Schema:
```
AKTUELL (Chaotisch):
┌──────────────────────────────────────────────────────────────────┐
│ 3000 chat-web │ Web Frontend im Backend-Bereich! │
│ 3001 mana-core-auth │ OK │
│ 3002 chat-backend │ OK │
│ 3003-3007 LÜCKE │ Verschwendet │
│ 3008 presi-backend │ OK │
│ 3009-3014 LÜCKE │ Verschwendet │
│ 3015-3019 Backends │ OK │
│ 3020-3022 AI Services │ OK (nativ) │
│ 3023-3024 Backends │ OK │
│ 3025 mana-image-gen │ OK (nativ) │
│ 3026-3029 LÜCKE │ │
│ 3030 api-gateway │ OK │
│ 3031-3032 LÜCKE │ │
│ 3033 matrix-tts-bot │ Bot im falschen Bereich! │
│ 3100 grafana │ Monitoring im Backend-Bereich! │
│ 3200 umami │ Warum 3200? │
│ 3300-3301 Telegram │ Telegram im 33xx Bereich │
│ 3310-3318 Matrix Bots │ OK, aber 3033 fehlt! │
├──────────────────────────────────────────────────────────────────┤
│ 5173-5195 Web UIs │ Größtenteils OK, aber Lücken │
│ 5678 n8n │ Warum hier? │
├──────────────────────────────────────────────────────────────────┤
│ 8008 synapse │ Matrix Homeserver │
│ 8080 cadvisor │ Konflikt mit SearXNG intern! │
│ 8087 element-web │ Random? │
│ 8428 victoriametrics │ OK │
├──────────────────────────────────────────────────────────────────┤
│ 9000-9001 minio │ OK │
│ 9002 synapse-metrics │ Ausweichport wegen MinIO │
│ 9091 pushgateway │ OK │
│ 9100 node-exporter │ OK │
│ 9121 redis-exporter │ OK │
│ 9187 postgres-exporter │ OK │
└──────────────────────────────────────────────────────────────────┘
```
**Probleme:**
- `chat-web` auf Port 3000 (sollte bei 51xx sein)
- `grafana` auf 3100 (sollte bei 8xxx oder 9xxx sein)
- `matrix-tts-bot` auf 3033 (sollte bei 331x sein)
- Große Lücken verschwenden Adressraum
- Keine klare Trennung zwischen Service-Typen
### 2. Inkonsistente Container-Benennung
```
AKTUELL:
manacore-postgres ✓ Konsistent
manacore-redis ✓ Konsistent
mana-core-auth ✗ Bindestrich-Variante
mana-api-gateway ✗ Bindestrich-Variante
chat-backend ✗ Kein Präfix
chat-web ✗ Kein Präfix
manacore-matrix-mana-bot ✓ Konsistent
manacore-synapse ✓ Konsistent
```
### 3. Nicht-optimale Dependencies
Einige Services haben übermäßige Abhängigkeiten:
```yaml
# AKTUELL: api-gateway wartet auf 4 Services
api-gateway:
depends_on:
mana-core-auth: # Notwendig
mana-search: # Nur für Search-Features
postgres: # Kann später verbinden
redis: # Kann später verbinden
```
### 4. Duplizierte Bot-Funktionalität
Viele Matrix-Bots teilen ähnlichen Code:
- `matrix-todo-bot` und `matrix-calendar-bot` haben ähnliche Reminder-Logik
- `matrix-nutriphi-bot` und `matrix-zitare-bot` sind Wrapper für Backends
- `matrix-mana-bot` sollte theoretisch alle anderen ersetzen
### 5. Volume-Fragmentierung
```yaml
# 20 separate Volumes, davon 10 nur für Matrix-Bots:
volumes:
matrix_mana_bot_data:
matrix_ollama_bot_data:
matrix_stats_bot_data:
matrix_project_doc_bot_data:
matrix_calendar_bot_data:
matrix_todo_bot_data:
matrix_nutriphi_bot_data:
matrix_zitare_bot_data:
matrix_clock_bot_data:
matrix_tts_bot_data:
```
---
## Verbesserungsvorschläge
### 1. Neues Port-Schema
Ein strukturiertes Port-Schema für bessere Übersicht:
```
NEU (Strukturiert):
┌─────────────────────────────────────────────────────────────────────┐
│ PORT-ALLOCATION-SCHEMA │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 3000-3099: Core Services & Backends │
│ ├── 3001: mana-core-auth (Auth) │
│ ├── 3010: api-gateway (Gateway) │
│ ├── 3020: mana-search (Search) │
│ ├── 3021: mana-stt (Speech-to-Text) │
│ ├── 3022: mana-tts (Text-to-Speech) │
│ ├── 3025: mana-image-gen (Image Generation) │
│ ├── 3030: chat-backend │
│ ├── 3031: todo-backend │
│ ├── 3032: calendar-backend │
│ ├── 3033: clock-backend │
│ ├── 3034: contacts-backend │
│ ├── 3035: storage-backend │
│ ├── 3036: presi-backend │
│ ├── 3037: nutriphi-backend │
│ └── 3038: skilltree-backend │
│ │
│ 4000-4099: Matrix Stack │
│ ├── 4000: synapse (Homeserver) │
│ ├── 4001: synapse-metrics │
│ ├── 4010: matrix-mana-bot (Unified Bot) │
│ ├── 4011: matrix-ollama-bot (AI Chat) │
│ ├── 4012: matrix-stats-bot (Analytics) │
│ ├── 4013: matrix-project-doc-bot (Docs) │
│ ├── 4080: element-web (Standard Client) │
│ └── 4090: matrix-web (Custom Client) │
│ │
│ 5000-5099: Web Frontends │
│ ├── 5000: manacore-web (Dashboard) │
│ ├── 5010: chat-web │
│ ├── 5011: todo-web │
│ ├── 5012: calendar-web │
│ ├── 5013: clock-web │
│ ├── 5014: contacts-web │
│ ├── 5015: storage-web │
│ ├── 5016: presi-web │
│ ├── 5017: nutriphi-web │
│ ├── 5018: skilltree-web │
│ └── 5090: llm-playground │
│ │
│ 6000-6099: Automation & Workflows │
│ ├── 6000: n8n │
│ ├── 6010: telegram-stats-bot │
│ └── 6011: telegram-ollama-bot │
│ │
│ 8000-8099: Monitoring Dashboards │
│ ├── 8000: grafana │
│ ├── 8010: umami │
│ └── 8080: victoriametrics-ui │
│ │
│ 9000-9199: Infrastructure & Exporters │
│ ├── 9000: minio-api │
│ ├── 9001: minio-console │
│ ├── 9090: victoriametrics │
│ ├── 9091: pushgateway │
│ ├── 9100: node-exporter │
│ ├── 9121: redis-exporter │
│ └── 9187: postgres-exporter │
│ │
│ 11000+: Native Services (macOS) │
│ └── 11434: ollama │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 2. Konsistente Benennung
**Container-Namen:**
```
manacore-{category}-{service}
Beispiele:
- manacore-infra-postgres
- manacore-infra-redis
- manacore-core-auth
- manacore-api-gateway
- manacore-app-chat-backend
- manacore-app-chat-web
- manacore-matrix-synapse
- manacore-matrix-bot-mana
- manacore-mon-grafana
- manacore-mon-victoria
```
**Volume-Namen:**
```
manacore-{service}-data
Beispiele:
- manacore-postgres-data
- manacore-redis-data
- manacore-matrix-bots-data (konsolidiert!)
- manacore-grafana-data
```
### 3. Service-Konsolidierung
**Matrix-Bots → Unified Bot Architecture:**
```
AKTUELL: 10 separate Bot-Container
┌────────────────────────────────────────────────────────────┐
│ matrix-mana-bot │ matrix-ollama-bot │ ... │
│ matrix-stats-bot │ matrix-project-doc │ │
│ matrix-todo-bot │ matrix-calendar-bot │ │
│ matrix-nutriphi-bot │ matrix-zitare-bot │ │
│ matrix-clock-bot │ matrix-tts-bot │ │
└────────────────────────────────────────────────────────────┘
NEU: 3 konsolidierte Bots
┌────────────────────────────────────────────────────────────┐
│ │
│ matrix-mana-bot (Unified) │
│ ├── !mana → AI Chat (Ollama) │
│ ├── !todo → Task Management │
│ ├── !cal → Calendar │
│ ├── !clock → Time Tracking │
│ ├── !nutri → Nutrition │
│ ├── !zitat → Quotes │
│ └── !tts → Text-to-Speech │
│ │
│ matrix-stats-bot │
│ └── Scheduled Reports (bleibt separat) │
│ │
│ matrix-project-doc-bot │
│ └── RAG + Embeddings (bleibt separat wegen DB) │
│ │
└────────────────────────────────────────────────────────────┘
```
**Einsparung:** 7 Container, 7 Volumes, ~700MB RAM
### 4. Optimierte Dependencies
```yaml
# NEU: Lazy Dependencies mit Retry-Logic
services:
api-gateway:
depends_on:
mana-core-auth:
condition: service_healthy # Kritisch
environment:
# Andere Services werden lazy verbunden
SEARCH_URL: http://mana-search:3020
DB_RETRY_ATTEMPTS: 5
DB_RETRY_DELAY: 3000
```
### 5. Service-Tiers für Startup-Reihenfolge
```yaml
# docker-compose.yml mit deploy.order
services:
postgres:
deploy:
order: 1
redis:
deploy:
order: 1
minio:
deploy:
order: 1
mana-core-auth:
deploy:
order: 2
api-gateway:
deploy:
order: 3
# ... App-Backends
deploy:
order: 4
# ... Web-Frontends
deploy:
order: 5
```
---
## Migration zu Kubernetes
### Phase 0: Docker Compose Cleanup (vor K8s)
Diese Verbesserungen sollten **vor** der K8s-Migration durchgeführt werden:
| Aufgabe | Priorität | Aufwand | Auswirkung |
| -------------------------------- | --------- | ------- | ------------------- |
| Port-Schema umstellen | Hoch | 2h | Alle Services |
| Container-Namen vereinheitlichen | Mittel | 1h | Alle Services |
| Matrix-Bots konsolidieren | Hoch | 8h | 7 Container weniger |
| Volume-Namen vereinheitlichen | Niedrig | 30min | Cleanup |
| Dependencies optimieren | Mittel | 2h | Schnellerer Start |
### Phase 1: Headscale + K3s (nach Cleanup)
Mit dem neuen Schema ist die Migration zu Kubernetes wesentlich einfacher:
```yaml
# Kubernetes Namespace-Struktur
namespaces:
- manacore-infra # postgres, redis, minio
- manacore-core # auth, gateway, search
- manacore-apps # chat, todo, calendar, etc.
- manacore-matrix # synapse, bots, element
- manacore-monitoring # grafana, victoria, exporters
- manacore-tools # n8n, telegram-bots
```
### Kubernetes Service-Typen
```yaml
# Mapping Docker → Kubernetes
┌────────────────────┬─────────────────────────────────┐
│ Docker Compose │ Kubernetes │
├────────────────────┼─────────────────────────────────┤
│ ports: "3001:3001" │ Service (ClusterIP) │
│ restart: always │ Deployment + ReplicaSet │
│ volumes: │ PersistentVolumeClaim │
│ depends_on: │ initContainers + readinessProbe │
│ healthcheck: │ livenessProbe + readinessProbe │
│ environment: │ ConfigMap + Secret │
└────────────────────┴─────────────────────────────────┘
```
---
## Ressourcen-Analyse
### Aktuelle RAM-Nutzung (geschätzt)
```
┌────────────────────────────────────────────────────────────┐
│ Service-Kategorie │ Container │ RAM (geschätzt) │
├────────────────────────────┼───────────┼──────────────────┤
│ Infrastructure │ 3 │ ~1.5 GB │
│ Core Services │ 4 │ ~800 MB │
│ App Backends │ 10 │ ~2.0 GB │
│ Web Frontends │ 11 │ ~1.1 GB │
│ Matrix Stack │ 14 │ ~2.8 GB │
│ Monitoring │ 8 │ ~1.5 GB │
├────────────────────────────┼───────────┼──────────────────┤
│ GESAMT Docker │ 50 │ ~9.7 GB │
├────────────────────────────┼───────────┼──────────────────┤
│ Native Services (Ollama) │ - │ ~4.0 GB │
│ macOS System │ - │ ~2.0 GB │
├────────────────────────────┼───────────┼──────────────────┤
│ GESAMT │ │ ~15.7 GB │
└────────────────────────────────────────────────────────────┘
```
### Nach Konsolidierung (geschätzt)
```
┌────────────────────────────────────────────────────────────┐
│ Service-Kategorie │ Container │ RAM (geschätzt) │
├────────────────────────────┼───────────┼──────────────────┤
│ Infrastructure │ 3 │ ~1.5 GB │
│ Core Services │ 4 │ ~800 MB │
│ App Backends │ 10 │ ~2.0 GB │
│ Web Frontends │ 11 │ ~1.1 GB │
│ Matrix Stack (konsolidiert)│ 7 │ ~1.4 GB │
│ Monitoring │ 8 │ ~1.5 GB │
├────────────────────────────┼───────────┼──────────────────┤
│ GESAMT Docker │ 43 │ ~8.3 GB │
├────────────────────────────┼───────────┼──────────────────┤
│ EINSPARUNG │ -7 │ ~1.4 GB │
└────────────────────────────────────────────────────────────┘
```
---
## Nächste Schritte
### Sofort (Phase 0)
1. **Port-Mapping-Dokument erstellen** - Diese Datei als Referenz
2. **Cloudflare Tunnel-Konfiguration anpassen** - Neue Port-Mappings
3. **Matrix-Bots konsolidieren** - Code-Merge in matrix-mana-bot
### Kurzfristig (Phase 1)
1. **docker-compose.macmini.yml refactoren** - Neue Ports + Namen
2. **Schrittweise Migration** - Ein Service nach dem anderen
3. **Health-Check-Validierung** - Alle Services testen
### Mittelfristig (Phase 2)
1. **K8s Manifests erstellen** - Basierend auf neuem Schema
2. **Helm Charts entwickeln** - Für B2B-Deployment
3. **CI/CD Pipeline anpassen** - Neue Container-Namen
---
## Referenzen
- [Blueprint 001: Cluster & Federation Architecture](/blueprints/001-mana-cluster-federation-architecture)
- [Docker Compose Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
- [Kubernetes Port Allocation](https://kubernetes.io/docs/concepts/services-networking/service/)
- [Matrix Synapse Deployment](https://matrix-org.github.io/synapse/latest/setup/installation.html)

View file

@ -1,396 +0,0 @@
---
title: 'Bot-Offensive: 3 Telegram Bots + Matrix Self-Hosting für DSGVO-Compliance'
description: 'Telegram Calendar/Contacts/Chat Bots, Matrix Synapse Self-Hosting, Model Comparison Feature, Guest Welcome Modal und Local STT Integration'
date: 2026-01-27
author: 'Till Schneider'
category: 'feature'
tags:
[
'telegram-bot',
'matrix',
'synapse',
'dsgvo',
'gdpr',
'self-hosting',
'chat',
'ai',
'speech-to-text',
'guest-mode',
]
featured: true
commits: 20
readTime: 12
---
Intensiver Tag (und Nacht) mit Fokus auf **Bot-Entwicklung** und **DSGVO-konforme Messaging-Infrastruktur**. Die wichtigsten Errungenschaften:
- **3 neue Telegram Bots** (Calendar, Contacts, Chat)
- **Matrix Self-Hosting** mit Synapse + Element Web
- **4 Matrix Bots** für DSGVO-Compliance
- **Model Comparison** Feature in Chat App
- **Guest Welcome Modal** für alle Apps
- **Local STT Integration** für Project Doc Bot
- **Codebase Cleanup** (Maerchenzauber entfernt)
---
## Telegram Bots
### telegram-calendar-bot
Neuer Bot für Kalender-Integration via Telegram:
```
services/telegram-calendar-bot/
├── src/
│ ├── bot/ # Telegraf Bot Logic
│ ├── calendar/ # Calendar API Client
│ ├── reminder/ # Reminder Scheduler
│ └── user/ # User Linking Service
```
**Commands:**
| Command | Beschreibung |
| ------------------- | ------------------------ |
| `/today` | Heutige Termine |
| `/tomorrow` | Termine für morgen |
| `/week` | Wochenübersicht |
| `/next [n]` | Nächste n Termine |
| `/calendars` | Kalender auflisten |
| `/remind` | Erinnerungseinstellungen |
| `/add` | Termin hinzufügen |
| `/link` / `/unlink` | Account verknüpfen |
**Features:**
- Deutsche Lokalisierung
- Morning Briefing Support
- Reminder Scheduler mit Push-Benachrichtigungen
- Account-Linking mit Calendar Web App
### telegram-contacts-bot
Bot für schnellen Kontakt-Zugriff:
```
services/telegram-contacts-bot/
├── src/
│ ├── bot/ # Telegraf Bot Logic
│ ├── contacts/ # Contacts API Client
│ └── user/ # User Linking Service
```
**Commands:**
| Command | Beschreibung |
| ----------------------- | ---------------------- |
| `/search [name]` | Kontakte suchen |
| `/favorites` | Favoriten anzeigen |
| `/recent` | Zuletzt hinzugefügt |
| `/birthdays` | Anstehende Geburtstage |
| `/tags` / `/tag [name]` | Tag-Verwaltung |
| `/stats` | Kontakt-Statistiken |
| `/add [name]` | Kontakt hinzufügen |
**Features:**
- Geburtstags-Erkennung und -Erinnerungen
- Tag-basierte Filterung
- Schnellsuche mit Formatierung
### telegram-chat-bot
AI Chat Bot mit Multi-Model Support:
```
services/telegram-chat-bot/
├── src/
│ ├── bot/ # Telegraf Bot Logic
│ ├── chat/ # Chat API Client
│ └── user/ # User Service
```
**Commands:**
| Command | Beschreibung |
| --------------- | --------------------------- |
| `/models` | Verfügbare Modelle anzeigen |
| `/model [name]` | Modell wechseln |
| `/new [title]` | Neue Konversation |
| `/convos` | Konversationen auflisten |
| `/history` | Letzte Nachrichten |
| `/clear` | Kontext löschen |
**Features:**
- Unterstützt lokale (Gemma) + Cloud-Modelle (Claude, GPT, etc.)
- Synchronisation mit Chat Web/Mobile App
- Message Splitting für lange Antworten
- Conversation History
---
## Matrix Self-Hosting (DSGVO-Compliance)
Als datenschutzkonforme Alternative zu Telegram wurde eine komplette Matrix-Infrastruktur aufgesetzt.
### Warum Matrix statt Telegram?
| Aspekt | Telegram | Matrix |
| ------------------ | -------------------------------- | --------------------------- |
| **Datenhaltung** | Telegram Server (Russland/Dubai) | Self-Hosted (Deutschland) |
| **DSGVO** | Problematisch | Vollständig konform |
| **Bot-Daten** | Bei Telegram | Bei uns |
| **Data Retention** | Unklar | Konfigurierbar (1-365 Tage) |
### Infrastruktur
```yaml
# docker-compose.macmini.yml
services:
synapse: # Matrix Homeserver (Port 8008)
synapse-db: # PostgreSQL für Synapse
element-web: # Element Web Client (Port 8087)
```
**Konfigurationsdateien:**
- `docker/matrix/homeserver.yaml` - Synapse Konfiguration
- `docker/matrix/log.config.yaml` - Logging mit Rotation
- `docker/matrix/element-config.json` - Element Web Settings
### Matrix Bots
Alle Telegram-Bots wurden als Matrix-Varianten implementiert:
| Bot | Port | Funktion |
| -------------------------- | ---- | ----------------------- |
| **matrix-ollama-bot** | 3311 | Lokale LLM-Inferenz |
| **matrix-stats-bot** | 3312 | Umami Analytics Reports |
| **matrix-project-doc-bot** | 3313 | Projektdokumentation |
#### matrix-ollama-bot
DSGVO-konformer Ersatz für telegram-ollama-bot:
```
services/matrix-ollama-bot/
├── src/
│ ├── bot/ # matrix-bot-sdk
│ ├── ollama/ # Ollama API Client
│ └── main.ts
```
**Commands:** `!help`, `!models`, `!model`, `!mode`, `!clear`, `!status`
**System Prompts:** default, classify, summarize, translate, code
#### matrix-stats-bot
Analytics-Reports im Matrix Room:
- Tägliche/Wöchentliche Reports
- Umami Integration
- Commands: `!stats`, `!today`, `!week`, `!realtime`, `!users`
#### matrix-project-doc-bot
Projektdokumentation mit AI-Unterstützung:
- Voice Transcription via OpenAI Whisper
- Blog-Generierung in 5 Stilen
- PostgreSQL + S3 Storage
- Commands: `!new`, `!projects`, `!switch`, `!status`, `!generate`, `!export`
### Setup Script
```bash
scripts/mac-mini/setup-matrix.sh
```
Automatisiert:
- Synapse-Initialisierung
- Admin-User Erstellung
- Element Web Konfiguration
- Bot-User Registration
---
## Chat App: Model Comparison
Neues Feature zum Vergleichen von AI-Modellen:
```
apps/chat/apps/web/src/
├── lib/
│ ├── components/compare/
│ │ ├── CompareInput.svelte # Prompt Eingabe
│ │ ├── CompareProgress.svelte # Progress Bar
│ │ ├── ModelResponseCard.svelte # Response Karte
│ │ └── ModelResponseGrid.svelte # Grid Layout
│ └── stores/compare.svelte.ts # Svelte 5 Runes Store
└── routes/(protected)/compare/ # Route
```
**Features:**
- Gleichzeitige Anfrage an mehrere Modelle
- Side-by-Side Vergleich
- Response-Metriken (Zeit, Tokens)
- Temperature/Max Tokens Kontrolle
- Cancel-Funktionalität
---
## Guest Welcome Modal
Einheitliches Welcome-Modal für alle Apps im Guest Mode:
```
packages/shared-auth-ui/src/
├── components/GuestWelcomeModal.svelte
└── utils/guestWelcome.ts
```
**Features:**
- App-Icon und -Name aus shared-branding
- Feature-Liste (DE/EN lokalisiert)
- Warnung über lokale Datenspeicherung
- Buttons: Login, Register, Hilfe, "Als Gast fortfahren"
- localStorage für "nicht mehr anzeigen"
**Integriert in:**
- Calendar
- Chat
- Clock
- Contacts
- Todo
---
## Local STT Integration
Integration von lokaler Speech-to-Text in telegram-project-doc-bot:
```typescript
// transcription.service.ts
// STT Provider: local (mana-stt) oder openai
// Fallback wenn lokal nicht verfügbar
```
**Konfiguration:**
```env
STT_PROVIDER=local # local oder openai
STT_LOCAL_URL=http://localhost:8000
STT_LOCAL_MODEL=large-v3-turbo
```
**Grafana Dashboards:**
- `services/mana-stt/grafana-dashboard.json`
- `services/ollama-metrics-proxy/grafana-dashboard.json`
---
## Developer Experience
### Dev-Credentials Pre-Fill
Login-Seite zeigt jetzt Dev-Credentials im Development-Modus:
- Email: `dev@manacore.local`
- Password vorgefüllt
- Seed Script: `pnpm db:seed:dev` in mana-core-auth
### Ollama URL in .env.development
```env
OLLAMA_URL=http://mac-mini.local:11434
```
---
## Codebase Cleanup
### Maerchenzauber entfernt
Komplettes Entfernen der Maerchenzauber-App:
| Entfernt | Dateien |
| --------------- | ---------------------------------------- |
| App Definition | MANA_APPS, APP_URLS, AppId |
| Branding | app-icons.ts, config.ts, StorytellerLogo |
| Theme | maerchenzauber.css |
| Landing Content | maerchenzauber-de.md |
| Env Config | generate-env.mjs |
---
## Bugfixes
### Health Check Pfade
Korrektur des presi-backend Health Endpoints:
```diff
- /api/health
+ /api/v1/health
```
Betroffen:
- `docker-compose.macmini.yml`
- `scripts/mac-mini/health-check.sh`
### Telegram User ID Type
Fix für große Telegram User IDs:
```diff
- telegram_user_id: integer()
+ telegram_user_id: bigint()
```
---
## Zusammenfassung
| Bereich | Commits | Highlights |
| ------------- | ------- | ----------------------------- |
| Telegram Bots | 6 | Calendar, Contacts, Chat Bots |
| Matrix | 5 | Synapse, Element, 3 Bots |
| Chat App | 2 | Model Comparison |
| Auth UI | 2 | Guest Welcome Modal |
| STT | 1 | Local STT Integration |
| Cleanup | 2 | Maerchenzauber entfernt |
| Bugfixes | 2 | Health Checks, User ID Type |
---
## Neue Services
| Service | Port | Typ | Beschreibung |
| ---------------------- | ---- | -------- | -------------------- |
| telegram-calendar-bot | 3303 | Telegram | Kalender-Integration |
| telegram-contacts-bot | 3304 | Telegram | Kontakte-Zugriff |
| telegram-chat-bot | 3305 | Telegram | AI Chat |
| synapse | 8008 | Matrix | Homeserver |
| element-web | 8087 | Matrix | Web Client |
| matrix-ollama-bot | 3311 | Matrix | LLM Chat |
| matrix-stats-bot | 3312 | Matrix | Analytics |
| matrix-project-doc-bot | 3313 | Matrix | Projekt-Docs |
---
## Nächste Schritte
1. **Matrix Bots deployen** auf Mac Mini
2. **Cloudflare Tunnel** für Matrix-Subdomains
3. **Telegram Calendar Bot** mit Reminder-Notifications testen
4. **Model Comparison** mit mehr Metriken erweitern
5. **Mobile Apps** mit neuen Bot-Features integrieren

View file

@ -957,38 +957,6 @@ services:
retries: 3
start_period: 40s
# ============================================
# Telegram Stats Bot
# ============================================
telegram-stats-bot:
image: ghcr.io/memo-2023/telegram-stats-bot:latest
container_name: manacore-telegram-stats-bot
restart: always
depends_on:
postgres:
condition: service_healthy
umami:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3300
TZ: Europe/Berlin
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
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
ports:
- "3300:3300"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3300/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ============================================
# Matrix Synapse (Homeserver) - DSGVO-konform
# ============================================
@ -1420,10 +1388,10 @@ services:
- WATCHTOWER_POLL_INTERVAL=300
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_STOPPED=false
- WATCHTOWER_NOTIFICATIONS=shoutrrr
- WATCHTOWER_NOTIFICATION_URL=telegram://${TELEGRAM_BOT_TOKEN}@telegram?chats=${TELEGRAM_CHAT_ID}
- WATCHTOWER_NOTIFICATION_REPORT=true
- WATCHTOWER_NOTIFICATION_TEMPLATE={{- if .Report -}}{{- with .Report -}}{{- if or .Updated .Failed -}}🐳 ManaCore Watchtower{{- if .Updated }} | ✅ Updated:{{range .Updated}} {{.Name}}{{end}}{{- end -}}{{- if .Failed }} | ❌ Failed:{{range .Failed}} {{.Name}}{{end}}{{- end -}}{{- end -}}{{- end -}}{{- end -}}
# Notifications disabled (Telegram removed)
# Configure Matrix notifications if needed:
# - WATCHTOWER_NOTIFICATIONS=shoutrrr
# - WATCHTOWER_NOTIFICATION_URL=matrix://user:password@matrix.mana.how:8008/!roomid:mana.how
# ============================================
# Volumes

View file

@ -0,0 +1,975 @@
# ManaCore Matrix Bot Architecture
**Status:** Production
**Datum:** 1. Februar 2026
**Autor:** Till Schneider
**Letzte Aktualisierung:** 1. Februar 2026
---
## Executive Summary
ManaCore setzt auf **Matrix** als primäre Messaging-Plattform für Bot-Interaktionen. Mit 19 spezialisierten Matrix-Bots und einem Gateway-Bot bieten wir eine vollständig dezentrale, DSGVO-konforme Alternative zu Cloud-basierten Chat-Diensten.
**Kernprinzipien:**
- **Volle Kontrolle** - Eigene Infrastruktur, eigene Daten
- **DSGVO-Konformität** - Alle Daten auf eigenen Servern
- **Unabhängigkeit** - Keine Abhängigkeit von Drittanbieter-Plattformen
- **Einheitliche UX** - Konsistente Erfahrung über alle Bots
---
## 1. Warum Matrix?
### 1.1 Die Entscheidung gegen Telegram/Discord/Slack
Bei der Wahl der Messaging-Plattform für ManaCore hatten wir mehrere Optionen:
| Plattform | Vorteile | Nachteile |
|-----------|----------|-----------|
| **Telegram** | Große Reichweite, einfache API | Zentral, Daten bei Telegram, keine Kontrolle über UX |
| **Discord** | Gaming-Community, Webhooks | US-basiert, DSGVO-Bedenken, Werbung |
| **Slack** | Business-Standard | Teuer, Vendor Lock-in, keine Self-Hosting Option |
| **Matrix** | Dezentral, Self-Hosted, E2E-Verschlüsselung | Kleinere Community, mehr Setup-Aufwand |
**Unsere Entscheidung:** Matrix bietet die einzige Möglichkeit, eine **vollständig unabhängige** Plattform zu betreiben mit:
- Voller Kontrolle über Nutzerdaten
- Eigener UI/UX (Element, eigene Clients)
- End-to-End-Verschlüsselung
- Federation für Inter-Server-Kommunikation
### 1.2 Matrix Grundkonzepte
```
┌─────────────────────────────────────────────────────────────────┐
│ Matrix Ökosystem │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Homeserver │<───>│ Homeserver │ Federation │
│ │ (mana.how) │ │ (matrix.org) │ │
│ └────────┬────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Räume ││
│ ├─────────────────────────────────────────────────────────────┤│
│ │ !abc:mana.how │ Bot-Interaktion (1:1) ││
│ │ !xyz:mana.how │ Gruppen-Chat (Multi-User) ││
│ │ #public:mana.how │ Öffentlicher Raum ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Clients ││
│ ├─────────────────────────────────────────────────────────────┤│
│ │ Element (Web/Desktop/Mobile) ││
│ │ FluffyChat, Nheko, SchildiChat, ... ││
│ │ ManaCore Bots (matrix-bot-sdk) ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Kernkonzepte:**
- **Homeserver:** Der Server, der Nutzerkonten und Räume hostet (wir nutzen Synapse)
- **Räume:** Container für Nachrichten, Events und State
- **Federation:** Server können miteinander kommunizieren
- **E2E-Verschlüsselung:** Megolm/Olm für sichere Kommunikation
---
## 2. Bot-Architektur Übersicht
### 2.1 Gesamtarchitektur
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ManaCore Bot Ecosystem │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ @manacore/bot-services (Shared Business Logic) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │
│ │ │ TodoSvc │ │ CalSvc │ │ AiSvc │ │ ClockSvc │ │ ... │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Matrix Transport Layer │ │
│ │ (matrix-bot-sdk) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 19 Matrix Bots │ │ Gateway Bot │ │ Shared Services │ │
│ │ (Specialized) │ │ (All-in-One) │ │ (mana-llm, etc) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Backend APIs │ │
│ │ chat │ todo │ contacts │ calendar │ clock │ picture │ ... │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Data Layer │ │
│ │ PostgreSQL │ S3/MinIO │ JSON Files │ Redis │ Ollama │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.2 Bot-Typen
Wir unterscheiden drei Hauptkategorien von Bots:
#### Typ 1: Backend-integrierte Bots
Diese Bots fungieren als Interface zu bestehenden NestJS-Backend-APIs:
```
User → Matrix Bot → REST API → PostgreSQL
```
**Beispiele:**
- `matrix-contacts-bot` → Contacts Backend (Port 3015)
- `matrix-chat-bot` → Chat Backend (Port 3002)
- `matrix-picture-bot` → Picture Backend (Port 3006)
**Vorteile:**
- Konsistente Geschäftslogik (Web + Bot identisch)
- Zentralisierte Datenhaltung
- Einheitliche Auth via JWT
#### Typ 2: DSGVO-konforme Standalone-Bots
Diese Bots speichern Daten lokal ohne externe Services:
```
User → Matrix Bot → JSON File (lokal)
```
**Beispiele:**
- `matrix-todo-bot` → Lokale JSON-Datei
- `matrix-calendar-bot` → Lokale JSON-Datei
- `matrix-ollama-bot` → In-Memory + lokales Ollama
**Vorteile:**
- Keine Daten verlassen den Server
- Volle DSGVO-Konformität
- Offline-fähig
#### Typ 3: Gateway-Bot
Kombiniert alle Features in einem Bot:
```
User → matrix-mana-bot → @manacore/bot-services → Multiple Backends
```
**Features:**
- Einheitlicher Einstiegspunkt (`!mana`)
- Intelligentes Command-Routing
- Cross-Feature-Integration (z.B. "Termin mit Kontakt erstellen")
---
## 3. Shared Package: @manacore/bot-services
### 3.1 Architektur
Das Package `@manacore/bot-services` stellt transport-agnostische Geschäftslogik bereit:
```typescript
// Exportierte Services
export { TodoModule, TodoService } from './todo';
export { CalendarModule, CalendarService } from './calendar';
export { AiModule, AiService } from './ai';
export { ClockModule, ClockService } from './clock';
// Storage Provider (pluggable)
export { FileStorageProvider } from './shared/storage/file-storage.provider';
export { MemoryStorageProvider } from './shared/storage/memory-storage.provider';
// Utilities
export { generateId, getTodayISO, formatDateDE } from './shared/utils';
export { parseGermanDateKeyword } from './shared/date-parser';
```
### 3.2 TodoService
Vollständige Aufgabenverwaltung mit deutscher Sprachunterstützung:
```typescript
interface TodoService {
// CRUD
addTask(userId: string, text: string): Promise<Task>;
listTasks(userId: string, filter?: TaskFilter): Promise<Task[]>;
completeTask(userId: string, taskId: string): Promise<Task>;
deleteTask(userId: string, taskId: string): Promise<void>;
// Projekte
createProject(userId: string, name: string): Promise<Project>;
listProjects(userId: string): Promise<Project[]>;
// Filter
getTasksDueToday(userId: string): Promise<Task[]>;
getTasksByPriority(userId: string, priority: Priority): Promise<Task[]>;
}
// Deutsche Eingabeverarbeitung
"Morgen Arzt anrufen #gesundheit !hoch"
→ { text: "Arzt anrufen", dueDate: tomorrow, project: "gesundheit", priority: "high" }
```
### 3.3 CalendarService
Terminverwaltung mit natürlicher Spracheingabe:
```typescript
interface CalendarService {
// Events
createEvent(userId: string, input: string): Promise<Event>;
getEventsForDate(userId: string, date: Date): Promise<Event[]>;
getEventsInRange(userId: string, start: Date, end: Date): Promise<Event[]>;
// Kalender
createCalendar(userId: string, name: string): Promise<Calendar>;
listCalendars(userId: string): Promise<Calendar[]>;
}
// Natürliche Eingabe
"Meeting morgen um 14 Uhr im Büro"
→ { title: "Meeting", date: tomorrow, time: "14:00", location: "Büro" }
```
### 3.4 AiService
Integration mit lokalem LLM (Ollama) und mana-llm:
```typescript
interface AiService {
chat(userId: string, message: string): Promise<string>;
setModel(userId: string, model: string): Promise<void>;
setSystemPrompt(userId: string, mode: SystemMode): Promise<void>;
clearHistory(userId: string): Promise<void>;
// Vision (für Bildanalyse)
analyzeImage(userId: string, imageUrl: string, prompt: string): Promise<string>;
}
type SystemMode = 'default' | 'classify' | 'summarize' | 'translate' | 'code';
```
### 3.5 Storage Provider Pattern
Pluggable Storage für flexible Datenhaltung:
```typescript
interface StorageProvider<T> {
get(key: string): Promise<T | null>;
set(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
list(prefix?: string): Promise<string[]>;
}
// Implementierungen
class FileStorageProvider<T> implements StorageProvider<T> {
constructor(private basePath: string) {}
// Speichert als JSON-Dateien
}
class MemoryStorageProvider<T> implements StorageProvider<T> {
private store = new Map<string, T>();
// In-Memory für Tests
}
// Zukünftig möglich:
class PostgresStorageProvider<T> implements StorageProvider<T> { }
class RedisStorageProvider<T> implements StorageProvider<T> { }
```
---
## 4. Matrix Bot Implementation
### 4.1 Technologie-Stack
Alle Matrix-Bots nutzen einen einheitlichen Stack:
| Komponente | Technologie | Version |
|------------|-------------|---------|
| **Framework** | NestJS | 10.x |
| **Matrix SDK** | matrix-bot-sdk | 0.7.1 |
| **Language** | TypeScript | 5.x |
| **Runtime** | Node.js | 20.x |
| **Build** | tsc + Docker | - |
### 4.2 Bot-Struktur
```
services/matrix-{name}-bot/
├── src/
│ ├── app.module.ts # NestJS Root Module
│ ├── main.ts # Bootstrap
│ ├── matrix/
│ │ ├── matrix.module.ts # Matrix SDK Integration
│ │ ├── matrix.service.ts # Bot-Logik & Command-Handling
│ │ └── matrix.constants.ts # Konfiguration
│ ├── services/ # Optionale lokale Services
│ └── utils/ # Hilfsfunktionen
├── Dockerfile
├── package.json
└── tsconfig.json
```
### 4.3 Matrix Service Pattern
```typescript
@Injectable()
export class MatrixService implements OnModuleInit, OnModuleDestroy {
private client: MatrixClient;
private storage: SimpleFsStorageProvider;
async onModuleInit() {
// Storage für Sync-State
this.storage = new SimpleFsStorageProvider('./data/matrix-state.json');
// Client initialisieren
this.client = new MatrixClient(
this.configService.get('MATRIX_HOMESERVER_URL'),
this.configService.get('MATRIX_ACCESS_TOKEN'),
this.storage,
);
// Crypto für E2E (optional)
const cryptoStore = new RustSdkCryptoStorageProvider('./data/crypto');
await this.client.crypto.prepare(cryptoStore);
// Event-Handler registrieren
this.client.on('room.message', this.handleMessage.bind(this));
// Sync starten
await this.client.start();
}
private async handleMessage(roomId: string, event: any) {
if (event.sender === this.client.getUserId()) return;
const body = event.content?.body;
if (!body?.startsWith('!')) return;
const [command, ...args] = body.slice(1).split(' ');
switch (command.toLowerCase()) {
case 'help':
case 'hilfe':
await this.sendHelp(roomId);
break;
case 'add':
case 'hinzufuegen':
await this.handleAdd(roomId, event.sender, args.join(' '));
break;
// ... weitere Commands
}
}
private 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),
});
}
}
```
### 4.4 Command-Pattern
Alle Bots nutzen ein einheitliches Command-Schema:
```
!command [args] # Englisch
!befehl [argumente] # Deutsch (Aliase)
```
**Beispiele:**
| Bot | Command | Alias | Beschreibung |
|-----|---------|-------|--------------|
| todo | `!add Task` | `!hinzufuegen` | Aufgabe erstellen |
| todo | `!list` | `!liste` | Aufgaben anzeigen |
| todo | `!done 1` | `!erledigt` | Aufgabe abschließen |
| calendar | `!today` | `!heute` | Termine heute |
| calendar | `!add Meeting morgen 14:00` | `!termin` | Termin erstellen |
| contacts | `!search Max` | `!suche` | Kontakt suchen |
### 4.5 Nummer-basiertes Referenzsystem
Für intuitive Interaktion nutzen Bots ein Listen-Referenz-System:
```
User: !kontakte
Bot: 1. Max Mustermann (max@example.com)
2. Anna Schmidt (anna@example.com)
3. Peter Meyer (peter@example.com)
User: !anrufen 2
Bot: Anruf an Anna Schmidt wird vorbereitet...
Telefon: +49 123 456789
```
**Implementierung:**
```typescript
// Pro User wird die letzte Liste gespeichert
private listCache = new Map<string, Contact[]>();
async handleList(roomId: string, userId: string) {
const contacts = await this.contactsApi.list(userId);
this.listCache.set(userId, contacts);
const message = contacts
.map((c, i) => `${i + 1}. ${c.name} (${c.email})`)
.join('\n');
await this.sendMessage(roomId, message);
}
async handleCall(roomId: string, userId: string, index: number) {
const contacts = this.listCache.get(userId);
if (!contacts || index < 1 || index > contacts.length) {
return this.sendMessage(roomId, 'Ungültige Nummer');
}
const contact = contacts[index - 1];
// ... Anruf-Logik
}
```
---
## 5. Bot-Katalog
### 5.1 Produktivitäts-Bots
| Bot | Port | Storage | Beschreibung |
|-----|------|---------|--------------|
| **matrix-mana-bot** | 3310 | JSON | Gateway - alle Features vereint |
| **matrix-todo-bot** | 3314 | JSON | Aufgabenverwaltung mit Projekten |
| **matrix-calendar-bot** | 3315 | JSON | Terminverwaltung mit Erinnerungen |
| **matrix-clock-bot** | 3318 | API | Timer, Alarme, Weltuhren |
### 5.2 KI & Medien-Bots
| Bot | Port | Backend | Beschreibung |
|-----|------|---------|--------------|
| **matrix-chat-bot** | 3327 | chat:3002 | KI-Konversationen |
| **matrix-ollama-bot** | 3311 | mana-llm:3025 | Lokales LLM (DSGVO) |
| **matrix-picture-bot** | 3319 | picture:3006 | AI-Bildgenerierung |
| **matrix-tts-bot** | 3023 | mana-tts:3022 | Text-to-Speech |
| **matrix-project-doc-bot** | 3313 | PostgreSQL+S3 | Projektdoku → Blog |
### 5.3 App-Integrations-Bots
| Bot | Port | Backend | Beschreibung |
|-----|------|---------|--------------|
| **matrix-contacts-bot** | 3320 | contacts:3015 | Kontaktverwaltung |
| **matrix-storage-bot** | 3323 | storage:3016 | Cloud-Speicher |
| **matrix-nutriphi-bot** | 3316 | nutriphi:3023 | Ernährungstracking |
| **matrix-zitare-bot** | 3321 | zitare:3019 | Tägliche Zitate |
| **matrix-questions-bot** | 3324 | questions:3011 | Q&A mit Web-Recherche |
| **matrix-manadeck-bot** | 3321 | manadeck:3009 | Kartendecks & Lernen |
| **matrix-planta-bot** | 3322 | planta:3022 | Pflanzenpflege |
| **matrix-skilltree-bot** | 3324 | skilltree:3024 | Skill Tree & XP |
| **matrix-presi-bot** | 3308 | presi:3008 | Präsentationen |
| **matrix-stats-bot** | 3312 | Umami | Analytics-Reports |
---
## 6. Authentifizierung
### 6.1 Zwei Auth-Modelle
Wir unterstützen zwei Authentifizierungsmodelle:
#### Modell A: Matrix User ID (DSGVO-optimiert)
Für Standalone-Bots ohne Backend-Anbindung:
```
Matrix User ID → Isolierte Daten pro User
@till:mana.how → /data/till-mana-how/todos.json
```
**Vorteile:**
- Kein Login erforderlich
- Daten strikt isoliert
- Funktioniert offline
**Verwendung:** matrix-todo-bot, matrix-calendar-bot, matrix-ollama-bot
#### Modell B: Mana Core Auth (JWT)
Für Backend-integrierte Bots:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Matrix User │────>│ Matrix Bot │────>│ mana-core-auth │
│ !login x y │ │ │ │ (Port 3001) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ JWT Token │
▼ │
┌─────────────────┐ │
│ In-Memory Map │ │
@user → token │ │
└─────────────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ Backend API │◀──────────┘
│ (JWT Validate) │
└─────────────────┘
```
**Login-Flow:**
```
User: !login till@mana.how geheimespasswort
Bot: Login erfolgreich! Token gültig für 7 Tage.
Nutze !logout zum Abmelden.
User: !kontakte
Bot: [Zeigt Kontakte aus Backend]
```
**Verwendung:** matrix-contacts-bot, matrix-chat-bot, matrix-picture-bot, etc.
### 6.2 Token-Management
```typescript
@Injectable()
export class AuthService {
private tokens = new Map<string, TokenData>();
async login(matrixUserId: string, email: string, password: string): Promise<boolean> {
const response = await fetch(`${this.authUrl}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) return false;
const { accessToken, expiresIn } = await response.json();
this.tokens.set(matrixUserId, {
token: accessToken,
expiresAt: Date.now() + expiresIn * 1000,
});
return true;
}
getToken(matrixUserId: string): string | null {
const data = this.tokens.get(matrixUserId);
if (!data || Date.now() > data.expiresAt) return null;
return data.token;
}
logout(matrixUserId: string): void {
this.tokens.delete(matrixUserId);
}
}
```
---
## 7. Datenbank-Anbindung
### 7.1 Vier Speichermodelle
| Modell | Technologie | Bots | Use Case |
|--------|-------------|------|----------|
| **Stateless** | Keine eigene | contacts, chat, picture | Backend delegiert |
| **JSON Files** | Lokale Dateien | todo, calendar, mana-bot | DSGVO, einfach |
| **PostgreSQL** | Drizzle ORM | project-doc-bot | Komplexe Relationen |
| **S3/MinIO** | AWS SDK | project-doc-bot | Medien-Speicherung |
### 7.2 JSON File Storage (DSGVO)
```typescript
// Struktur
/data/
├── {sanitized-matrix-user-id}/
│ ├── todos.json
│ ├── calendar.json
│ └── settings.json
```
```typescript
// FileStorageProvider
class FileStorageProvider<T> {
constructor(private basePath: string) {}
private getPath(key: string): string {
return path.join(this.basePath, `${key}.json`);
}
async get(key: string): Promise<T | null> {
const filePath = this.getPath(key);
if (!fs.existsSync(filePath)) return null;
const data = await fs.promises.readFile(filePath, 'utf-8');
return JSON.parse(data);
}
async set(key: string, value: T): Promise<void> {
const filePath = this.getPath(key);
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2));
}
}
```
### 7.3 PostgreSQL + Drizzle (Komplexe Bots)
```typescript
// schema.ts (project-doc-bot)
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at').defaultNow(),
});
export const mediaItems = pgTable('media_items', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').references(() => projects.id),
type: varchar('type', { length: 50 }).notNull(), // photo, voice, text
s3Key: varchar('s3_key', { length: 500 }),
transcription: text('transcription'),
createdAt: timestamp('created_at').defaultNow(),
});
```
---
## 8. Matrix-spezifische Features
### 8.1 Rich Media Support
Matrix-Bots können verschiedene Nachrichtentypen senden:
```typescript
// Text mit Markdown/HTML
await client.sendMessage(roomId, {
msgtype: 'm.text',
body: 'Plain text fallback',
format: 'org.matrix.custom.html',
formatted_body: '<b>Bold</b> and <code>code</code>',
});
// Bilder
await client.sendMessage(roomId, {
msgtype: 'm.image',
body: 'Generated image',
url: await client.uploadContent(imageBuffer, 'image/png'),
info: { w: 512, h: 512, mimetype: 'image/png' },
});
// Dateien
await client.sendMessage(roomId, {
msgtype: 'm.file',
body: 'report.pdf',
url: await client.uploadContent(pdfBuffer, 'application/pdf'),
info: { mimetype: 'application/pdf', size: pdfBuffer.length },
});
// Audio (für TTS)
await client.sendMessage(roomId, {
msgtype: 'm.audio',
body: 'Voice message',
url: await client.uploadContent(audioBuffer, 'audio/mp3'),
info: { mimetype: 'audio/mp3', duration: 5000 },
});
```
### 8.2 Reactions
Bots können auf Nachrichten reagieren:
```typescript
// Bestätigung
await client.sendEvent(roomId, 'm.reaction', {
'm.relates_to': {
rel_type: 'm.annotation',
event_id: originalEventId,
key: '✅',
},
});
// Fehler
await client.sendEvent(roomId, 'm.reaction', {
'm.relates_to': {
rel_type: 'm.annotation',
event_id: originalEventId,
key: '❌',
},
});
```
### 8.3 Reply Threading
```typescript
await client.sendMessage(roomId, {
msgtype: 'm.text',
body: '> Original message\n\nMy reply',
format: 'org.matrix.custom.html',
formatted_body: '<mx-reply>...</mx-reply>My reply',
'm.relates_to': {
'm.in_reply_to': {
event_id: originalEventId,
},
},
});
```
### 8.4 End-to-End Encryption
```typescript
// Crypto Storage initialisieren
const cryptoStore = new RustSdkCryptoStorageProvider('./data/crypto');
// Client mit E2E
const client = new MatrixClient(homeserverUrl, accessToken, storage);
await client.crypto.prepare(cryptoStore);
// Verschlüsselten Raum beitreten
await client.joinRoom(encryptedRoomId);
// Nachrichten werden automatisch ver-/entschlüsselt
await client.sendMessage(encryptedRoomId, {
msgtype: 'm.text',
body: 'This will be encrypted',
});
```
---
## 9. Deployment
### 9.1 Docker Configuration
```dockerfile
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# Workspace files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Shared packages
COPY packages/bot-services ./packages/bot-services
# Bot
COPY services/matrix-todo-bot ./services/matrix-todo-bot
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @manacore/bot-services build
RUN pnpm --filter matrix-todo-bot build
# Production
FROM node:20-alpine AS production
WORKDIR /app/services/matrix-todo-bot
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
COPY --from=builder /app/services/matrix-todo-bot/node_modules ./node_modules
COPY --from=builder /app/services/matrix-todo-bot/dist ./dist
COPY --from=builder /app/services/matrix-todo-bot/package.json ./
# Data volume für persistente Speicherung
VOLUME /app/data
ENV NODE_ENV=production
EXPOSE 3314
CMD ["node", "dist/main.js"]
```
### 9.2 Environment Variables
```env
# Matrix Connection
MATRIX_HOMESERVER_URL=https://matrix.mana.how
MATRIX_ACCESS_TOKEN=syt_xxx...
MATRIX_USER_ID=@todo-bot:mana.how
# Auth (für Backend-Integration)
MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Storage
DATA_PATH=/app/data
# Optional: Backend URLs
TODO_BACKEND_URL=http://todo-backend:3018
CONTACTS_BACKEND_URL=http://contacts-backend:3015
# Optional: AI Services
MANA_LLM_URL=http://mana-llm:3025
```
### 9.3 docker-compose.yml
```yaml
version: '3.8'
services:
matrix-todo-bot:
build:
context: .
dockerfile: services/matrix-todo-bot/Dockerfile
environment:
- MATRIX_HOMESERVER_URL=${MATRIX_HOMESERVER_URL}
- MATRIX_ACCESS_TOKEN=${MATRIX_TODO_BOT_TOKEN}
- MATRIX_USER_ID=@todo-bot:mana.how
volumes:
- todo-bot-data:/app/data
networks:
- manacore
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3314/health"]
interval: 30s
timeout: 10s
retries: 3
matrix-calendar-bot:
# ... analog
matrix-mana-bot:
# Gateway mit allen Services
depends_on:
- mana-llm
- todo-backend
- contacts-backend
volumes:
todo-bot-data:
calendar-bot-data:
mana-bot-data:
networks:
manacore:
external: true
```
---
## 10. Port-Allokation
### Matrix Bots (3308-3327)
| Port | Service | Beschreibung |
|------|---------|--------------|
| 3308 | matrix-presi-bot | Präsentationen |
| 3310 | matrix-mana-bot | Gateway (All-in-One) |
| 3311 | matrix-ollama-bot | Lokales LLM |
| 3312 | matrix-stats-bot | Analytics |
| 3313 | matrix-project-doc-bot | Projektdoku |
| 3314 | matrix-todo-bot | Aufgaben |
| 3315 | matrix-calendar-bot | Termine |
| 3316 | matrix-nutriphi-bot | Ernährung |
| 3318 | matrix-clock-bot | Timer/Alarme |
| 3319 | matrix-picture-bot | Bildgenerierung |
| 3320 | matrix-contacts-bot | Kontakte |
| 3321 | matrix-zitare-bot | Zitate |
| 3322 | matrix-planta-bot | Pflanzen |
| 3323 | matrix-storage-bot | Cloud-Speicher |
| 3324 | matrix-questions-bot | Q&A |
| 3327 | matrix-chat-bot | KI-Chat |
### Supporting Services
| Port | Service | Beschreibung |
|------|---------|--------------|
| 3001 | mana-core-auth | Authentifizierung |
| 3020 | mana-stt | Speech-to-Text |
| 3021 | mana-search | Web-Recherche |
| 3022 | mana-tts | Text-to-Speech |
| 3025 | mana-llm | LLM-Abstraction |
---
## 11. Vorteile gegenüber Drittanbieter-Plattformen
### 11.1 Vollständige Kontrolle
| Aspekt | Telegram/Discord | ManaCore Matrix |
|--------|------------------|-----------------|
| **Datenhoheit** | Bei Anbieter | Bei uns |
| **Verfügbarkeit** | Abhängig von Anbieter | Eigene Infrastruktur |
| **API-Änderungen** | Anbieter entscheidet | Wir entscheiden |
| **Preisänderungen** | Anbieter entscheidet | Keine |
| **Zensur/Sperrung** | Möglich | Nicht möglich |
### 11.2 DSGVO-Konformität
```
┌────────────────────────────────────────────────────────────────┐
│ DSGVO-Compliance │
├────────────────────────────────────────────────────────────────┤
│ │
│ ✅ Datenverarbeitung nur auf eigenen Servern │
│ ✅ Keine Weitergabe an Dritte │
│ ✅ Löschung auf Anfrage (Art. 17) │
│ ✅ Auskunft über gespeicherte Daten (Art. 15) │
│ ✅ Datenportabilität (Art. 20) │
│ ✅ Auftragsverarbeitungsvertrag nicht nötig │
│ │
└────────────────────────────────────────────────────────────────┘
```
### 11.3 Einheitliche UX
Da wir beide Seiten kontrollieren (Bot + Client), können wir:
- Konsistente Command-Syntax über alle Bots
- Deutsche Sprachunterstützung überall
- Einheitliches Fehler-Handling
- Nahtlose Cross-Bot-Integration
---
## 12. Zukünftige Entwicklung
### 12.1 Geplante Erweiterungen
- **Widget-Integration:** Interaktive UIs direkt in Element
- **Voice-Bot:** Sprachsteuerung via Matrix Calls
- **Bot-Discovery:** Automatische Bot-Erkennung in Räumen
- **Mehr @manacore/bot-services:** Nutrition, Stats, Docs Services
### 12.2 Konsolidierung
Der Fokus liegt auf der Konsolidierung der Bot-Services in `@manacore/bot-services`:
- Alle wiederkehrende Logik zentral
- Einheitliche Storage-Abstraction
- Transport-agnostische Services
---
## 13. Fazit
ManaCore's Matrix-Bot-Architektur bietet eine **vollständig unabhängige, DSGVO-konforme** Alternative zu Cloud-basierten Chat-Diensten. Mit 19 spezialisierten Bots und einem Gateway-Bot decken wir alle Produktivitäts- und App-Integrationsszenarien ab.
**Kernvorteile:**
1. **Volle Kontrolle** über Daten und Infrastruktur
2. **DSGVO-Konformität** durch lokale Datenhaltung
3. **Einheitliche UX** durch konsistente Command-Patterns
4. **Skalierbarkeit** durch Microservices-Architektur
5. **Erweiterbarkeit** durch @manacore/bot-services
---
*Dokument erstellt am 1. Februar 2026*
*Letzte Aktualisierung: 1. Februar 2026*

View file

@ -0,0 +1,74 @@
# ADR-003: Infrastructure Audit & Port Schema
**Status:** Accepted
**Date:** 2026-01-31
**Author:** Till Schneider
**Category:** Infrastructure
## Context
Die aktuelle Docker-Compose-Konfiguration auf dem Mac Mini hat über die Zeit 52 Container angesammelt mit chaotischer Port-Verteilung, inkonsistenter Benennung und fragmentierten Volumes. Vor der Migration zu K8s ist ein Cleanup notwendig.
## Decision
### 1. Neues Port-Schema
| Range | Kategorie | Beispiele |
|-------|-----------|-----------|
| 3000-3099 | Core Services & Backends | 3001 auth, 3010 gateway, 3030+ backends |
| 4000-4099 | Matrix Stack | 4000 synapse, 401x bots, 4080 element |
| 5000-5099 | Web Frontends | 5000 dashboard, 501x app webs |
| 6000-6099 | Automation | 6000 n8n, 601x telegram |
| 8000-8099 | Monitoring UI | 8000 grafana, 8010 umami |
| 9000-9199 | Infra & Exporters | 9000 minio, 909x metrics |
| 11000+ | Native macOS | 11434 ollama |
### 2. Container-Naming
```
manacore-{category}-{service}
Categories: infra, core, app, matrix, mon, auto
```
### 3. Matrix-Bot Konsolidierung
**Vorher:** 10 separate Bot-Container
**Nachher:** 3 Bots (mana-bot unified, stats-bot, project-doc-bot)
**Einsparung:** 7 Container, ~1.4GB RAM
### 4. Volume-Naming
```
manacore-{service}-data
```
Matrix-Bot-Volumes werden zu einem konsolidiert: `manacore-matrix-bots-data`
## Consequences
### Positive
- Klare Port-Zuordnung erleichtert Debugging
- Konsistente Namen verbessern Übersicht
- Weniger Container = weniger Ressourcenverbrauch
- Vorbereitung für K8s-Migration
### Negative
- Einmaliger Migrationsaufwand
- Cloudflare Tunnel muss angepasst werden
- Matrix-Bot Code-Merge erforderlich
## Migration Steps
1. Port-Mapping dokumentieren (erledigt)
2. Matrix-Bots konsolidieren
3. docker-compose.yml refactoren
4. Cloudflare Tunnel anpassen
5. Services schrittweise migrieren
## Full Documentation
Siehe: `apps/manacore/apps/landing/src/content/blueprints/002-infrastructure-audit-improvements.md`

View file

@ -236,22 +236,6 @@
"dev:skilltree:full": "./scripts/setup-databases.sh skilltree && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:skilltree:backend\" \"pnpm dev:skilltree:web\"",
"skilltree:db:push": "pnpm --filter @skilltree/backend db:push",
"skilltree:db:studio": "pnpm --filter @skilltree/backend db:studio",
"dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev",
"dev:projectdoc:full": "./scripts/setup-databases.sh projectdoc && pnpm dev:projectdoc",
"projectdoc:db:push": "pnpm --filter @manacore/telegram-project-doc-bot db:push",
"projectdoc:db:studio": "pnpm --filter @manacore/telegram-project-doc-bot db:studio",
"dev:zitare-bot": "pnpm --filter @manacore/telegram-zitare-bot start:dev",
"dev:zitare-bot:full": "./scripts/setup-databases.sh zitare_bot && pnpm dev:zitare-bot",
"zitare-bot:db:push": "pnpm --filter @manacore/telegram-zitare-bot db:push",
"zitare-bot:db:studio": "pnpm --filter @manacore/telegram-zitare-bot db:studio",
"dev:todo-bot": "pnpm --filter @manacore/telegram-todo-bot start:dev",
"dev:todo-bot:full": "./scripts/setup-databases.sh todo_bot && ./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,bot -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo-bot\"",
"todo-bot:db:push": "pnpm --filter @manacore/telegram-todo-bot db:push",
"todo-bot:db:studio": "pnpm --filter @manacore/telegram-todo-bot db:studio",
"dev:nutriphi-bot": "pnpm --filter @manacore/telegram-nutriphi-bot start:dev",
"dev:nutriphi-bot:full": "./scripts/setup-databases.sh nutriphi_bot && pnpm dev:nutriphi-bot",
"nutriphi-bot:db:push": "pnpm --filter @manacore/telegram-nutriphi-bot db:push",
"nutriphi-bot:db:studio": "pnpm --filter @manacore/telegram-nutriphi-bot db:studio",
"dev:matrix:mana": "pnpm --filter matrix-mana-bot start:dev",
"dev:matrix:ollama": "pnpm --filter matrix-ollama-bot start:dev",
"dev:matrix:todo": "pnpm --filter matrix-todo-bot start:dev",

1069
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +0,0 @@
# Server
PORT=3303
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token from @BotFather
TELEGRAM_ALLOWED_USERS= # Optional: Comma-separated user IDs
# Database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/nutriphi_bot
# AI
GEMINI_API_KEY=xxx # Google AI Studio API Key

View file

@ -1,294 +0,0 @@
# Telegram NutriPhi Bot
Telegram Bot für NutriPhi - KI-gestützte Ernährungsanalyse per Foto oder Text.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Database**: PostgreSQL + Drizzle ORM
- **AI**: Google Gemini 2.0 Flash
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
# Database
pnpm db:generate # Generate migrations
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Willkommensnachricht |
| `/hilfe` | Hilfe anzeigen |
| `/heute` | Heutige Mahlzeiten & Fortschritt |
| `/woche` | Wochenstatistik |
| `/ziele` | Ziele anzeigen |
| `/ziele [kcal] [P] [K] [F]` | Ziele setzen |
| `/favorit [Name]` | Letzte Mahlzeit speichern |
| `/favoriten` | Gespeicherte Mahlzeiten anzeigen |
| `/essen [Nr]` | Favorit als Mahlzeit eintragen |
| `/delfav [Nr]` | Favorit löschen |
| `/loeschen` | Letzte Mahlzeit löschen |
| **Foto senden** | Automatische Analyse |
| **Text senden** | Automatische Analyse |
## User Flow
```
1. /start → Willkommen
2. 📷 Foto einer Mahlzeit senden → Nährwertanalyse
3. /favorit Morgenmüsli → Als Favorit speichern
4. /heute → Tagesübersicht
5. /ziele 2000 100 200 70 → Ziele setzen
6. /woche → Wochenstatistik
```
## Environment Variables
```env
# Server
PORT=3303
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
TELEGRAM_ALLOWED_USERS= # Optional: Komma-separierte User IDs
# Database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/nutriphi_bot
# AI
GEMINI_API_KEY=xxx # Google AI Studio API Key
```
## Projekt-Struktur
```
services/telegram-nutriphi-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config
│ ├── database/
│ │ ├── database.module.ts # Drizzle connection
│ │ └── schema.ts # DB schema
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── bot.update.ts # Command handlers
│ ├── analysis/
│ │ ├── analysis.module.ts
│ │ └── gemini.service.ts # Gemini AI Integration
│ ├── meals/
│ │ ├── meals.module.ts
│ │ └── meals.service.ts # Mahlzeiten CRUD
│ ├── goals/
│ │ ├── goals.module.ts
│ │ └── goals.service.ts # Nutzerziele
│ └── stats/
│ ├── stats.module.ts
│ └── stats.service.ts # Statistiken
├── drizzle/ # Migrations
├── drizzle.config.ts
├── package.json
└── .env.example
```
## Lokale Entwicklung
### 1. Bot bei Telegram erstellen
1. Öffne @BotFather in Telegram
2. Sende `/newbot`
3. Wähle einen Namen (z.B. "NutriPhi Bot")
4. Wähle einen Username (z.B. "nutriphi_tracker_bot")
5. Kopiere den Token
### 2. Gemini API Key holen
1. Gehe zu https://aistudio.google.com/apikey
2. Erstelle einen API Key
3. Kopiere den Key
### 3. Umgebung vorbereiten
```bash
# Docker Services starten (PostgreSQL)
pnpm docker:up
# Datenbank erstellen
psql -h localhost -U manacore -d postgres -c "CREATE DATABASE nutriphi_bot;"
# In das Verzeichnis wechseln
cd services/telegram-nutriphi-bot
# .env erstellen
cp .env.example .env
# Token und API Key eintragen
# Schema pushen
pnpm db:push
```
### 4. Bot starten
```bash
pnpm start:dev
```
## Features
- **Foto-Analyse**: Mahlzeit fotografieren → Gemini analysiert → Nährwerte
- **Text-Analyse**: Mahlzeit beschreiben → Gemini schätzt → Nährwerte
- **Tages-Tracking**: Alle Mahlzeiten speichern, Tagesübersicht
- **Wochenstatistik**: 7-Tage-Übersicht mit Durchschnittswerten
- **Ziele**: Kalorienziel und Makros setzen
- **Favoriten**: Häufige Mahlzeiten speichern und wiederverwenden
- **Fortschrittsanzeige**: Visuelle Balken für Zielerreichung
## Datenbank-Schema
```
user_goals
├── id (UUID)
├── telegram_user_id (BIGINT, unique)
├── daily_calories (INT, default 2000)
├── daily_protein (INT, default 50)
├── daily_carbs (INT, default 250)
├── daily_fat (INT, default 65)
├── daily_fiber (INT, default 30)
├── created_at, updated_at
meals
├── id (UUID)
├── telegram_user_id (BIGINT)
├── date (DATE)
├── meal_type (TEXT: breakfast/lunch/dinner/snack)
├── input_type (TEXT: photo/text)
├── description (TEXT)
├── calories (INT)
├── protein, carbohydrates, fat, fiber, sugar (REAL)
├── confidence (REAL, 0-1)
├── raw_response (JSONB)
├── created_at
favorite_meals
├── id (UUID)
├── telegram_user_id (BIGINT)
├── name (TEXT)
├── description (TEXT)
├── nutrition (JSONB)
├── usage_count (INT)
├── created_at
```
## Health Check
```bash
curl http://localhost:3303/health
```
## Gemini Integration
Der Bot verwendet Gemini 2.0 Flash für:
1. **Foto-Analyse**
- Erkennt alle sichtbaren Lebensmittel
- Schätzt Portionsgrößen
- Berechnet Nährwerte pro Lebensmittel
- Summiert Gesamtnährwerte
2. **Text-Analyse**
- Interpretiert Mahlzeitbeschreibungen
- Schätzt realistische Portionsgrößen
- Berechnet Nährwerte
**Response-Format:**
```json
{
"foods": [
{"name": "Spaghetti", "quantity": "200g", "calories": 314, "confidence": 0.9},
{"name": "Bolognese-Sauce", "quantity": "150g", "calories": 180, "confidence": 0.85}
],
"totalNutrition": {
"calories": 494,
"protein": 22,
"carbohydrates": 65,
"fat": 15,
"fiber": 4,
"sugar": 8
},
"description": "Spaghetti Bolognese",
"confidence": 0.87
}
```
## Beispiel-Ausgaben
**Foto-Analyse:**
```
🍽️ Spaghetti Bolognese mit Parmesan
Erkannt:
• Spaghetti (200g)
• Bolognese-Sauce (150g)
• Parmesan (20g)
Nährwerte:
Kalorien: 580 kcal
Protein: 28g
Kohlenhydrate: 68g
Fett: 20g
Ballaststoffe: 5g
Zucker: 8g
Genauigkeit: 87%
Als Favorit speichern: /favorit [Name]
```
**Tagesübersicht (/heute):**
```
📊 Heute (28.01.2026)
1. Frühstück (08:15)
Haferflocken mit Banane und Milch
420 kcal
2. Mittagessen (12:30)
Spaghetti Bolognese
580 kcal
─────────────────
Gesamt: 1000 kcal
Fortschritt:
Kalorien: ████████░░ 50%
Protein: ██████░░░░ 60%
Kohlenhydr.: ███████░░░ 70%
Fett: █████░░░░░ 50%
Verbleibend: 1000 kcal
```
## Roadmap
- [ ] Mahlzeit-Typ manuell wählen
- [ ] Foto-Beschreibung als Caption
- [ ] Mehrere Fotos pro Mahlzeit
- [ ] Export als CSV/JSON
- [ ] Erinnerungen für Mahlzeiten
- [ ] Wassertracking

View file

@ -1,9 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'nutriphi_bot',
schemaPath: './src/database/schema.ts',
outDir: './drizzle',
verbose: false,
strict: false,
});

View file

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

View file

@ -1,43 +0,0 @@
{
"name": "@manacore/telegram-nutriphi-bot",
"version": "1.0.0",
"description": "Telegram bot for NutriPhi - AI-powered nutrition tracking",
"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": {
"@google/generative-ai": "^0.21.0",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"drizzle-orm": "^0.38.3",
"nestjs-telegraf": "^2.8.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3"
},
"devDependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@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"
}
}

View file

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

View file

@ -1,175 +0,0 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai';
export interface AnalysisFood {
name: string;
quantity: string;
calories: number;
confidence: number;
}
export interface AnalysisResult {
foods: AnalysisFood[];
totalNutrition: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
};
description: string;
confidence: number;
warnings?: string[];
}
const PHOTO_ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere das Bild dieser Mahlzeit und liefere eine detaillierte Nährwertanalyse.
Aufgaben:
1. Identifiziere alle sichtbaren Lebensmittel
2. Schätze die Portionsgröße (in Gramm) basierend auf visuellen Hinweisen
3. Berechne die Nährwerte für jedes Lebensmittel
4. Summiere die Gesamtnährwerte
Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
{
"foods": [
{
"name": "Lebensmittelname",
"quantity": "geschätzte Menge (z.B. '150g', '1 Tasse')",
"calories": 123,
"confidence": 0.85
}
],
"totalNutrition": {
"calories": 500,
"protein": 25,
"carbohydrates": 60,
"fat": 15,
"fiber": 5,
"sugar": 10
},
"description": "Kurze Beschreibung der Mahlzeit auf Deutsch",
"confidence": 0.8,
"warnings": ["Optional: Warnungen falls etwas unklar ist"]
}
Wichtig:
- Alle Nährwerte als Zahlen (keine Strings)
- Kalorien in kcal
- Protein, Kohlenhydrate, Fett, Ballaststoffe, Zucker in Gramm
- Confidence-Werte zwischen 0 und 1
- Beschreibung auf Deutsch`;
const TEXT_ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die folgende Mahlzeitbeschreibung und liefere eine Nährwertschätzung.
Mahlzeit: {INPUT}
Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
{
"foods": [
{
"name": "Lebensmittelname",
"quantity": "geschätzte Menge",
"calories": 123,
"confidence": 0.85
}
],
"totalNutrition": {
"calories": 500,
"protein": 25,
"carbohydrates": 60,
"fat": 15,
"fiber": 5,
"sugar": 10
},
"description": "Aufbereitete Beschreibung der Mahlzeit",
"confidence": 0.75
}
Wichtig:
- Alle Nährwerte als Zahlen (keine Strings)
- Kalorien in kcal
- Protein, Kohlenhydrate, Fett, Ballaststoffe, Zucker in Gramm
- Confidence-Werte zwischen 0 und 1
- Beschreibung auf Deutsch
- Schätze realistische Portionsgrößen`;
@Injectable()
export class GeminiService implements OnModuleInit {
private readonly logger = new Logger(GeminiService.name);
private model: GenerativeModel | null = null;
constructor(private configService: ConfigService) {}
onModuleInit() {
const apiKey = this.configService.get<string>('gemini.apiKey');
if (apiKey) {
const genAI = new GoogleGenerativeAI(apiKey);
this.model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
this.logger.log('Gemini service initialized');
} else {
this.logger.warn('Gemini API key not configured');
}
}
isAvailable(): boolean {
return this.model !== null;
}
async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise<AnalysisResult> {
if (!this.model) {
throw new Error('Gemini API nicht konfiguriert');
}
this.logger.log('Analyzing image...');
const result = await this.model.generateContent([
PHOTO_ANALYSIS_PROMPT,
{
inlineData: {
mimeType,
data: imageBase64,
},
},
]);
const response = result.response;
const text = response.text();
return this.parseResponse(text);
}
async analyzeText(description: string): Promise<AnalysisResult> {
if (!this.model) {
throw new Error('Gemini API nicht konfiguriert');
}
this.logger.log(`Analyzing text: ${description.substring(0, 50)}...`);
const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description);
const result = await this.model.generateContent(prompt);
const response = result.response;
const text = response.text();
return this.parseResponse(text);
}
private parseResponse(text: string): AnalysisResult {
// Extract JSON from response (handle markdown code blocks)
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
this.logger.error('Failed to parse response:', text);
throw new Error('Konnte Antwort nicht parsen');
}
try {
return JSON.parse(jsonMatch[0]) as AnalysisResult;
} catch (error) {
this.logger.error('JSON parse error:', error);
throw new Error('Ungültiges JSON in Antwort');
}
}
}

View file

@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { DatabaseModule } from './database/database.module';
import { BotModule } from './bot/bot.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.token') || '',
}),
inject: [ConfigService],
}),
DatabaseModule,
BotModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { AnalysisModule } from '../analysis/analysis.module';
import { MealsModule } from '../meals/meals.module';
import { GoalsModule } from '../goals/goals.module';
import { StatsModule } from '../stats/stats.module';
@Module({
imports: [AnalysisModule, MealsModule, GoalsModule, StatsModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -1,513 +0,0 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { ConfigService } from '@nestjs/config';
import { GeminiService } from '../analysis/gemini.service';
import { MealsService } from '../meals/meals.service';
import { GoalsService } from '../goals/goals.service';
import { StatsService } from '../stats/stats.service';
import { MEAL_TYPES, MealType } from '../config/configuration';
import { Meal, NutritionData } from '../database/schema';
interface PhotoSize {
file_id: string;
file_unique_id: string;
width: number;
height: number;
file_size?: number;
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
private readonly telegramApiUrl: string;
// Track last meal for /favorit command
private lastMeal: Map<number, Meal> = new Map();
constructor(
private readonly geminiService: GeminiService,
private readonly mealsService: MealsService,
private readonly goalsService: GoalsService,
private readonly statsService: StatsService,
private configService: ConfigService
) {
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
const token = this.configService.get<string>('telegram.token');
this.telegramApiUrl = `https://api.telegram.org/bot${token}`;
}
private isAllowed(userId: number): boolean {
if (this.allowedUsers.length === 0) return true;
return this.allowedUsers.includes(userId);
}
private formatHelp(): string {
return `<b>🥗 NutriPhi Bot</b>
Dein KI-gestützter Ernährungs-Tracker.
<b>Mahlzeit erfassen:</b>
📷 Foto senden - Automatische Analyse
💬 Text senden - z.B. "Spaghetti Bolognese"
<b>Übersicht:</b>
/heute - Heutige Mahlzeiten & Fortschritt
/woche - Wochenstatistik
<b>Ziele:</b>
/ziele - Aktuelle Ziele anzeigen
/ziele [kcal] [P] [K] [F] - Ziele setzen
Beispiel: /ziele 2000 100 200 70
<b>Favoriten:</b>
/favorit [Name] - Letzte Mahlzeit speichern
/favoriten - Gespeicherte Mahlzeiten anzeigen
/essen [Nr] - Favorit als Mahlzeit eintragen
/delfav [Nr] - Favorit löschen
<b>Sonstiges:</b>
/loeschen - Letzte Mahlzeit löschen
/hilfe - Diese Hilfe anzeigen
<b>Tipp:</b> Starte mit einem Foto deiner Mahlzeit!`;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
// Ensure user has goals
await this.goalsService.ensureGoals(userId);
this.logger.log(`/start from user ${userId}`);
await ctx.replyWithHTML(this.formatHelp());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
await ctx.replyWithHTML(this.formatHelp());
}
@Command('hilfe')
async hilfe(@Ctx() ctx: Context) {
await this.help(ctx);
}
@Command('heute')
async today(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const summary = await this.statsService.getDailySummary(userId);
if (summary.meals.length === 0) {
await ctx.reply(
'📭 Noch keine Mahlzeiten heute.\n\nSende ein Foto oder beschreibe deine Mahlzeit!'
);
return;
}
// Format meals list
const mealsList = summary.meals
.map((m, i) => {
const type = MEAL_TYPES[m.mealType as MealType] || m.mealType;
const time = new Date(m.createdAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
return `${i + 1}. <b>${type}</b> (${time})\n ${m.description}\n ${m.calories} kcal`;
})
.join('\n\n');
// Format totals and progress
let response =
`<b>📊 Heute (${new Date().toLocaleDateString('de-DE')})</b>\n\n` +
`${mealsList}\n\n` +
`<b>─────────────────</b>\n` +
`<b>Gesamt:</b> ${summary.totals.calories} kcal\n\n`;
if (summary.goals) {
response +=
`<b>Fortschritt:</b>\n` +
`Kalorien: ${StatsService.formatProgressBar(summary.progress.calories)}\n` +
`Protein: ${StatsService.formatProgressBar(summary.progress.protein)}\n` +
`Kohlenhydr.: ${StatsService.formatProgressBar(summary.progress.carbohydrates)}\n` +
`Fett: ${StatsService.formatProgressBar(summary.progress.fat)}\n\n` +
`<b>Verbleibend:</b> ${Math.max(0, summary.goals.dailyCalories - summary.totals.calories)} kcal`;
}
await ctx.replyWithHTML(response);
}
@Command('woche')
async week(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const summary = await this.statsService.getWeeklySummary(userId);
if (summary.totalMeals === 0) {
await ctx.reply('📭 Keine Mahlzeiten in den letzten 7 Tagen.');
return;
}
// Format days chart
const maxCal = Math.max(...summary.days.map((d) => d.calories), 1);
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const chart = summary.days
.map((d) => {
const date = new Date(d.date);
const dayName = dayNames[date.getDay()];
const barLen = Math.round((d.calories / maxCal) * 8);
const bar = '█'.repeat(barLen) + '░'.repeat(8 - barLen);
return `${dayName} ${bar} ${d.calories}`;
})
.join('\n');
const response =
`<b>📈 Wochenübersicht</b>\n\n` +
`<code>${chart}</code>\n\n` +
`<b>Durchschnitt:</b>\n` +
`Kalorien: ${summary.averages.calories} kcal\n` +
`Protein: ${summary.averages.protein}g\n` +
`Kohlenhydrate: ${summary.averages.carbohydrates}g\n` +
`Fett: ${summary.averages.fat}g\n\n` +
`<b>Gesamt:</b> ${summary.totalMeals} Mahlzeiten`;
await ctx.replyWithHTML(response);
}
@Command('ziele')
async goals(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const args = text.replace('/ziele', '').trim();
// If no args, show current goals
if (!args) {
const goals = await this.goalsService.ensureGoals(userId);
await ctx.replyWithHTML(
`<b>🎯 Deine Tagesziele</b>\n\n` +
`Kalorien: ${goals.dailyCalories} kcal\n` +
`Protein: ${goals.dailyProtein}g\n` +
`Kohlenhydrate: ${goals.dailyCarbs}g\n` +
`Fett: ${goals.dailyFat}g\n` +
`Ballaststoffe: ${goals.dailyFiber}g\n\n` +
`<b>Ändern:</b>\n/ziele [kcal] [Protein] [Kohlenhydrate] [Fett]\nBeispiel: /ziele 2000 100 200 70`
);
return;
}
// Parse new goals
const parts = args.split(/\s+/).map((n) => parseInt(n, 10));
if (parts.length < 4 || parts.some(isNaN)) {
await ctx.reply(
'Verwendung: /ziele [kcal] [Protein] [Kohlenhydrate] [Fett]\n\n' +
'Beispiel: /ziele 2000 100 200 70'
);
return;
}
const [calories, protein, carbs, fat] = parts;
const fiber = parts[4] || 30; // Optional 5th parameter
await this.goalsService.setGoals(userId, {
dailyCalories: calories,
dailyProtein: protein,
dailyCarbs: carbs,
dailyFat: fat,
dailyFiber: fiber,
});
await ctx.replyWithHTML(
`✅ <b>Ziele aktualisiert!</b>\n\n` +
`Kalorien: ${calories} kcal\n` +
`Protein: ${protein}g\n` +
`Kohlenhydrate: ${carbs}g\n` +
`Fett: ${fat}g\n` +
`Ballaststoffe: ${fiber}g`
);
}
@Command('favorit')
async saveFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const name = text.replace('/favorit', '').trim();
if (!name) {
await ctx.reply('Verwendung: /favorit [Name]\n\nBeispiel: /favorit Morgenmüsli');
return;
}
const lastMeal = this.lastMeal.get(userId);
if (!lastMeal) {
await ctx.reply('Keine aktuelle Mahlzeit zum Speichern.\n\nErfasse erst eine Mahlzeit.');
return;
}
await this.mealsService.saveAsFavorite(userId, lastMeal, name);
await ctx.reply(`⭐ "${name}" als Favorit gespeichert!`);
}
@Command('favoriten')
async listFavorites(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const favorites = await this.mealsService.getFavorites(userId);
if (favorites.length === 0) {
await ctx.reply(
'Keine Favoriten gespeichert.\n\n' + 'Speichere eine Mahlzeit mit /favorit [Name]'
);
return;
}
const list = favorites
.map((f, i) => {
const nutrition = f.nutrition as NutritionData;
return `<b>${i + 1}.</b> ${f.name}\n ${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F`;
})
.join('\n\n');
await ctx.replyWithHTML(
`<b>⭐ Deine Favoriten</b>\n\n${list}\n\n` + `Verwenden: /essen [Nr]\nLöschen: /delfav [Nr]`
);
}
@Command('essen')
async useFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const indexStr = text.replace('/essen', '').trim();
const index = parseInt(indexStr, 10);
if (!indexStr || isNaN(index)) {
await ctx.reply('Verwendung: /essen [Nr]\n\nZeige Favoriten mit /favoriten');
return;
}
const favorite = await this.mealsService.getFavoriteByIndex(userId, index);
if (!favorite) {
await ctx.reply(`Favorit #${index} nicht gefunden.`);
return;
}
const meal = await this.mealsService.createFromFavorite(userId, favorite);
this.lastMeal.set(userId, meal);
const nutrition = favorite.nutrition as NutritionData;
await ctx.replyWithHTML(
`✅ <b>${favorite.name}</b> eingetragen!\n\n` +
`${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F\n\n` +
`Übersicht: /heute`
);
}
@Command('delfav')
async deleteFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const indexStr = text.replace('/delfav', '').trim();
const index = parseInt(indexStr, 10);
if (!indexStr || isNaN(index)) {
await ctx.reply('Verwendung: /delfav [Nr]\n\nZeige Favoriten mit /favoriten');
return;
}
const favorite = await this.mealsService.getFavoriteByIndex(userId, index);
if (!favorite) {
await ctx.reply(`Favorit #${index} nicht gefunden.`);
return;
}
await this.mealsService.deleteFavorite(favorite.id);
await ctx.reply(`✅ "${favorite.name}" gelöscht.`);
}
@Command('loeschen')
async deleteLastMeal(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const deleted = await this.mealsService.deleteLastMeal(userId);
if (deleted) {
this.lastMeal.delete(userId);
await ctx.reply('✅ Letzte Mahlzeit gelöscht.');
} else {
await ctx.reply('Keine Mahlzeit zum Löschen gefunden.');
}
}
@On('photo')
async onPhoto(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
if (!this.geminiService.isAvailable()) {
await ctx.reply('❌ Analyse nicht verfügbar (API nicht konfiguriert).');
return;
}
const message = ctx.message as { photo?: PhotoSize[]; caption?: string };
const photos = message.photo;
if (!photos || photos.length === 0) return;
// Get largest photo
const photo = photos[photos.length - 1];
await ctx.reply('🔍 Analysiere Mahlzeit...');
await ctx.sendChatAction('typing');
try {
// Download photo from Telegram
const imageBase64 = await this.downloadTelegramFile(photo.file_id);
// Analyze with Gemini
const analysis = await this.geminiService.analyzeImage(imageBase64);
// Save meal
const meal = await this.mealsService.createFromAnalysis(userId, 'photo', analysis);
this.lastMeal.set(userId, meal);
// Format response
const foodsList = analysis.foods.map((f) => `${f.name} (${f.quantity})`).join('\n');
const n = analysis.totalNutrition;
const confidence = Math.round(analysis.confidence * 100);
await ctx.replyWithHTML(
`<b>🍽️ ${analysis.description}</b>\n\n` +
`<b>Erkannt:</b>\n${foodsList}\n\n` +
`<b>Nährwerte:</b>\n` +
`Kalorien: ${n.calories} kcal\n` +
`Protein: ${n.protein}g\n` +
`Kohlenhydrate: ${n.carbohydrates}g\n` +
`Fett: ${n.fat}g\n` +
`Ballaststoffe: ${n.fiber}g\n` +
`Zucker: ${n.sugar}g\n\n` +
`<i>Genauigkeit: ${confidence}%</i>\n\n` +
`Als Favorit speichern: /favorit [Name]`
);
} catch (error) {
this.logger.error('Photo analysis failed:', error);
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`❌ Analyse fehlgeschlagen: ${message}`);
}
}
@On('text')
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
// Ignore commands
if (text.startsWith('/')) return;
if (!this.geminiService.isAvailable()) {
await ctx.reply('❌ Analyse nicht verfügbar (API nicht konfiguriert).');
return;
}
// Analyze text as meal description
await ctx.reply('🔍 Analysiere...');
await ctx.sendChatAction('typing');
try {
const analysis = await this.geminiService.analyzeText(text);
// Save meal
const meal = await this.mealsService.createFromAnalysis(userId, 'text', analysis);
this.lastMeal.set(userId, meal);
// Format response
const n = analysis.totalNutrition;
const confidence = Math.round(analysis.confidence * 100);
await ctx.replyWithHTML(
`<b>✅ ${analysis.description}</b>\n\n` +
`<b>Nährwerte:</b>\n` +
`Kalorien: ${n.calories} kcal\n` +
`Protein: ${n.protein}g\n` +
`Kohlenhydrate: ${n.carbohydrates}g\n` +
`Fett: ${n.fat}g\n` +
`Ballaststoffe: ${n.fiber}g\n` +
`Zucker: ${n.sugar}g\n\n` +
`<i>Genauigkeit: ${confidence}%</i>\n\n` +
`Als Favorit speichern: /favorit [Name]`
);
} catch (error) {
this.logger.error('Text analysis failed:', error);
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`❌ Analyse fehlgeschlagen: ${message}`);
}
}
// Download file from Telegram and return Base64
private async downloadTelegramFile(fileId: string): Promise<string> {
// Get file path
const fileResponse = await fetch(`${this.telegramApiUrl}/getFile?file_id=${fileId}`);
const fileData = await fileResponse.json();
if (!fileData.ok) {
throw new Error(`Telegram API error: ${fileData.description}`);
}
// Download file
const token = this.configService.get<string>('telegram.token');
const fileUrl = `https://api.telegram.org/file/bot${token}/${fileData.result.file_path}`;
const response = await fetch(fileUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return buffer.toString('base64');
}
}

View file

@ -1,35 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3303', 10),
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN,
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS
? process.env.TELEGRAM_ALLOWED_USERS.split(',').map((id) => parseInt(id.trim(), 10))
: [],
},
database: {
url:
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/nutriphi_bot',
},
gemini: {
apiKey: process.env.GEMINI_API_KEY,
},
});
// Meal type labels
export const MEAL_TYPES = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
} as const;
export type MealType = keyof typeof MEAL_TYPES;
// Get suggested meal type based on current time
export function suggestMealType(): MealType {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) return 'breakfast';
if (hour >= 11 && hour < 15) return 'lunch';
if (hour >= 17 && hour < 21) return 'dinner';
return 'snack';
}

View file

@ -1,24 +0,0 @@
import { Module, Global } 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 connectionString = configService.get<string>('database.url');
const client = postgres(connectionString!);
return drizzle(client, { schema });
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -1,93 +0,0 @@
import {
pgTable,
uuid,
text,
timestamp,
bigint,
integer,
real,
date,
jsonb,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// User goals - daily nutrition targets
export const userGoals = pgTable('user_goals', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull().unique(),
dailyCalories: integer('daily_calories').default(2000).notNull(),
dailyProtein: integer('daily_protein').default(50).notNull(),
dailyCarbs: integer('daily_carbs').default(250).notNull(),
dailyFat: integer('daily_fat').default(65).notNull(),
dailyFiber: integer('daily_fiber').default(30).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Meals - tracked meals with nutrition data
export const meals = pgTable('meals', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull(),
date: date('date').notNull(),
mealType: text('meal_type').notNull(), // breakfast, lunch, dinner, snack
inputType: text('input_type').notNull(), // photo, text
description: text('description'),
calories: integer('calories').default(0).notNull(),
protein: real('protein').default(0).notNull(),
carbohydrates: real('carbohydrates').default(0).notNull(),
fat: real('fat').default(0).notNull(),
fiber: real('fiber').default(0).notNull(),
sugar: real('sugar').default(0).notNull(),
confidence: real('confidence').default(0).notNull(),
rawResponse: jsonb('raw_response'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Favorite meals - saved for quick re-use
export const favoriteMeals = pgTable('favorite_meals', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull(),
name: text('name').notNull(),
description: text('description'),
nutrition: jsonb('nutrition').notNull(),
usageCount: integer('usage_count').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Relations
export const userGoalsRelations = relations(userGoals, ({ many }) => ({
meals: many(meals),
favorites: many(favoriteMeals),
}));
export const mealsRelations = relations(meals, ({ one }) => ({
userGoals: one(userGoals, {
fields: [meals.telegramUserId],
references: [userGoals.telegramUserId],
}),
}));
export const favoriteMealsRelations = relations(favoriteMeals, ({ one }) => ({
userGoals: one(userGoals, {
fields: [favoriteMeals.telegramUserId],
references: [userGoals.telegramUserId],
}),
}));
// Types
export type UserGoals = typeof userGoals.$inferSelect;
export type NewUserGoals = typeof userGoals.$inferInsert;
export type Meal = typeof meals.$inferSelect;
export type NewMeal = typeof meals.$inferInsert;
export type FavoriteMeal = typeof favoriteMeals.$inferSelect;
export type NewFavoriteMeal = typeof favoriteMeals.$inferInsert;
// Nutrition data structure for favorites
export interface NutritionData {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
}

View file

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

View file

@ -1,56 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { UserGoals, NewUserGoals } from '../database/schema';
@Injectable()
export class GoalsService {
private readonly logger = new Logger(GoalsService.name);
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>
) {}
async getGoals(telegramUserId: number): Promise<UserGoals | null> {
const goals = await this.db.query.userGoals.findFirst({
where: eq(schema.userGoals.telegramUserId, telegramUserId),
});
return goals || null;
}
async ensureGoals(telegramUserId: number): Promise<UserGoals> {
let goals = await this.getGoals(telegramUserId);
if (!goals) {
const [newGoals] = await this.db
.insert(schema.userGoals)
.values({ telegramUserId })
.returning();
goals = newGoals;
this.logger.log(`Created default goals for user ${telegramUserId}`);
}
return goals;
}
async setGoals(
telegramUserId: number,
data: Partial<Omit<NewUserGoals, 'id' | 'telegramUserId' | 'createdAt' | 'updatedAt'>>
): Promise<UserGoals> {
// Ensure user has goals first
await this.ensureGoals(telegramUserId);
const [updated] = await this.db
.update(schema.userGoals)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(schema.userGoals.telegramUserId, telegramUserId))
.returning();
this.logger.log(`Updated goals for user ${telegramUserId}`);
return updated;
}
}

View file

@ -1,13 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'telegram-nutriphi-bot',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -1,18 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port') || 3303;
await app.listen(port);
logger.log(`Telegram NutriPhi Bot running on port ${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
}
bootstrap();

View file

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

View file

@ -1,159 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, sql } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { Meal, NewMeal, FavoriteMeal, NutritionData } from '../database/schema';
import { AnalysisResult } from '../analysis/gemini.service';
import { MealType, suggestMealType } from '../config/configuration';
@Injectable()
export class MealsService {
private readonly logger = new Logger(MealsService.name);
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>
) {}
// Create a meal from analysis result
async createFromAnalysis(
telegramUserId: number,
inputType: 'photo' | 'text',
analysis: AnalysisResult,
mealType?: MealType
): Promise<Meal> {
const today = new Date().toISOString().split('T')[0];
const [meal] = await this.db
.insert(schema.meals)
.values({
telegramUserId,
date: today,
mealType: mealType || suggestMealType(),
inputType,
description: analysis.description,
calories: analysis.totalNutrition.calories,
protein: analysis.totalNutrition.protein,
carbohydrates: analysis.totalNutrition.carbohydrates,
fat: analysis.totalNutrition.fat,
fiber: analysis.totalNutrition.fiber,
sugar: analysis.totalNutrition.sugar,
confidence: analysis.confidence,
rawResponse: analysis,
})
.returning();
this.logger.log(`Created meal for user ${telegramUserId}: ${analysis.description}`);
return meal;
}
// Create a meal from favorite
async createFromFavorite(telegramUserId: number, favorite: FavoriteMeal): Promise<Meal> {
const today = new Date().toISOString().split('T')[0];
const nutrition = favorite.nutrition as NutritionData;
const [meal] = await this.db
.insert(schema.meals)
.values({
telegramUserId,
date: today,
mealType: suggestMealType(),
inputType: 'text',
description: favorite.name,
calories: nutrition.calories,
protein: nutrition.protein,
carbohydrates: nutrition.carbohydrates,
fat: nutrition.fat,
fiber: nutrition.fiber,
sugar: nutrition.sugar,
confidence: 1.0, // From saved data, so high confidence
})
.returning();
// Increment usage count
await this.db
.update(schema.favoriteMeals)
.set({
usageCount: sql`${schema.favoriteMeals.usageCount} + 1`,
})
.where(eq(schema.favoriteMeals.id, favorite.id));
this.logger.log(`Created meal from favorite for user ${telegramUserId}: ${favorite.name}`);
return meal;
}
// Get meals for a specific date
async getMealsByDate(telegramUserId: number, date: string): Promise<Meal[]> {
return this.db.query.meals.findMany({
where: and(eq(schema.meals.telegramUserId, telegramUserId), eq(schema.meals.date, date)),
orderBy: (meals, { asc }) => [asc(meals.createdAt)],
});
}
// Get today's meals
async getTodaysMeals(telegramUserId: number): Promise<Meal[]> {
const today = new Date().toISOString().split('T')[0];
return this.getMealsByDate(telegramUserId, today);
}
// Delete last meal
async deleteLastMeal(telegramUserId: number): Promise<boolean> {
const todaysMeals = await this.getTodaysMeals(telegramUserId);
if (todaysMeals.length === 0) return false;
const lastMeal = todaysMeals[todaysMeals.length - 1];
await this.db.delete(schema.meals).where(eq(schema.meals.id, lastMeal.id));
this.logger.log(`Deleted last meal for user ${telegramUserId}`);
return true;
}
// Save meal as favorite
async saveAsFavorite(telegramUserId: number, meal: Meal, name: string): Promise<FavoriteMeal> {
const nutrition: NutritionData = {
calories: meal.calories,
protein: meal.protein,
carbohydrates: meal.carbohydrates,
fat: meal.fat,
fiber: meal.fiber,
sugar: meal.sugar,
};
const [favorite] = await this.db
.insert(schema.favoriteMeals)
.values({
telegramUserId,
name,
description: meal.description,
nutrition,
})
.returning();
this.logger.log(`Saved favorite for user ${telegramUserId}: ${name}`);
return favorite;
}
// Get all favorites
async getFavorites(telegramUserId: number): Promise<FavoriteMeal[]> {
return this.db.query.favoriteMeals.findMany({
where: eq(schema.favoriteMeals.telegramUserId, telegramUserId),
orderBy: (fav, { desc }) => [desc(fav.usageCount), desc(fav.createdAt)],
});
}
// Get favorite by index (1-based for user display)
async getFavoriteByIndex(telegramUserId: number, index: number): Promise<FavoriteMeal | null> {
const favorites = await this.getFavorites(telegramUserId);
if (index < 1 || index > favorites.length) return null;
return favorites[index - 1];
}
// Delete favorite
async deleteFavorite(favoriteId: string): Promise<boolean> {
const result = await this.db
.delete(schema.favoriteMeals)
.where(eq(schema.favoriteMeals.id, favoriteId));
return (result as unknown as { rowCount: number }).rowCount > 0;
}
}

View file

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

View file

@ -1,194 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, gte, lte, sql } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { Meal, UserGoals } from '../database/schema';
export interface DailySummary {
date: string;
meals: Meal[];
totals: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
};
goals: UserGoals | null;
progress: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
};
}
export interface WeeklySummary {
startDate: string;
endDate: string;
days: {
date: string;
calories: number;
mealsCount: number;
}[];
averages: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
};
totalMeals: number;
}
@Injectable()
export class StatsService {
private readonly logger = new Logger(StatsService.name);
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>
) {}
// Get daily summary for a user
async getDailySummary(telegramUserId: number, date?: string): Promise<DailySummary> {
const targetDate = date || new Date().toISOString().split('T')[0];
// Get meals for the day
const meals = await this.db.query.meals.findMany({
where: and(
eq(schema.meals.telegramUserId, telegramUserId),
eq(schema.meals.date, targetDate)
),
orderBy: (meals, { asc }) => [asc(meals.createdAt)],
});
// Get user goals
const goals = await this.db.query.userGoals.findFirst({
where: eq(schema.userGoals.telegramUserId, telegramUserId),
});
// Calculate totals
const totals = meals.reduce(
(acc, meal) => ({
calories: acc.calories + meal.calories,
protein: acc.protein + meal.protein,
carbohydrates: acc.carbohydrates + meal.carbohydrates,
fat: acc.fat + meal.fat,
fiber: acc.fiber + meal.fiber,
sugar: acc.sugar + meal.sugar,
}),
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
);
// Calculate progress (percentage of goals)
const progress = {
calories: goals ? (totals.calories / goals.dailyCalories) * 100 : 0,
protein: goals ? (totals.protein / goals.dailyProtein) * 100 : 0,
carbohydrates: goals ? (totals.carbohydrates / goals.dailyCarbs) * 100 : 0,
fat: goals ? (totals.fat / goals.dailyFat) * 100 : 0,
fiber: goals ? (totals.fiber / goals.dailyFiber) * 100 : 0,
};
return {
date: targetDate,
meals,
totals,
goals: goals || null,
progress,
};
}
// Get weekly summary
async getWeeklySummary(telegramUserId: number): Promise<WeeklySummary> {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 6);
const startStr = startDate.toISOString().split('T')[0];
const endStr = endDate.toISOString().split('T')[0];
// Get all meals for the week
const meals = await this.db.query.meals.findMany({
where: and(
eq(schema.meals.telegramUserId, telegramUserId),
gte(schema.meals.date, startStr),
lte(schema.meals.date, endStr)
),
});
// Group by date
const byDate = new Map<
string,
{ calories: number; protein: number; carbohydrates: number; fat: number; count: number }
>();
// Initialize all 7 days
for (let i = 0; i < 7; i++) {
const d = new Date(startDate);
d.setDate(d.getDate() + i);
const dateStr = d.toISOString().split('T')[0];
byDate.set(dateStr, { calories: 0, protein: 0, carbohydrates: 0, fat: 0, count: 0 });
}
// Sum up meals
for (const meal of meals) {
const existing = byDate.get(meal.date) || {
calories: 0,
protein: 0,
carbohydrates: 0,
fat: 0,
count: 0,
};
byDate.set(meal.date, {
calories: existing.calories + meal.calories,
protein: existing.protein + meal.protein,
carbohydrates: existing.carbohydrates + meal.carbohydrates,
fat: existing.fat + meal.fat,
count: existing.count + 1,
});
}
// Convert to array
const days = Array.from(byDate.entries()).map(([date, data]) => ({
date,
calories: Math.round(data.calories),
mealsCount: data.count,
}));
// Calculate averages (only for days with meals)
const daysWithMeals = Array.from(byDate.values()).filter((d) => d.count > 0);
const numDays = daysWithMeals.length || 1;
const averages = {
calories: Math.round(daysWithMeals.reduce((sum, d) => sum + d.calories, 0) / numDays),
protein: Math.round(daysWithMeals.reduce((sum, d) => sum + d.protein, 0) / numDays),
carbohydrates: Math.round(
daysWithMeals.reduce((sum, d) => sum + d.carbohydrates, 0) / numDays
),
fat: Math.round(daysWithMeals.reduce((sum, d) => sum + d.fat, 0) / numDays),
};
return {
startDate: startStr,
endDate: endStr,
days,
averages,
totalMeals: meals.length,
};
}
// Get progress bar for display
static formatProgressBar(percentage: number, length = 10): string {
const capped = Math.min(percentage, 100);
const filled = Math.round((capped / 100) * length);
const empty = length - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
// Add indicator if over goal
const indicator = percentage > 100 ? ' ⚠️' : '';
return `${bar} ${Math.round(percentage)}%${indicator}`;
}
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
}
}

View file

@ -1,130 +0,0 @@
# Telegram Ollama Bot
Telegram Bot für lokale LLM-Inferenz via Ollama auf dem Mac Mini Server.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **LLM**: mana-llm service (supports Ollama + cloud providers)
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Hilfe anzeigen |
| `/help` | Hilfe anzeigen |
| `/models` | Verfügbare Modelle auflisten |
| `/model [name]` | Modell wechseln |
| `/mode [modus]` | System-Prompt ändern |
| `/clear` | Chat-Verlauf löschen |
| `/status` | Ollama-Status prüfen |
## Modi
| Modus | Beschreibung |
|-------|--------------|
| `default` | Allgemeiner Assistent |
| `classify` | Text-Klassifizierung |
| `summarize` | Zusammenfassungen |
| `translate` | Übersetzungen |
| `code` | Programmier-Hilfe |
## Environment Variables
```env
# Server
PORT=3301
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
TELEGRAM_ALLOWED_USERS=123,456 # Optional: Nur diese User IDs erlauben
# LLM (via mana-llm service)
MANA_LLM_URL=http://localhost:3025 # mana-llm service URL
LLM_MODEL=ollama/gemma3:4b # Standard-Modell (provider/model format)
LLM_TIMEOUT=120000 # Timeout in ms
```
## Projekt-Struktur
```
services/telegram-ollama-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config & System Prompts
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── bot.update.ts # Command handlers
│ └── ollama/
│ ├── ollama.module.ts
│ └── ollama.service.ts # Ollama API client
└── Dockerfile
```
## Deployment auf Mac Mini
### Option 1: Docker
```yaml
# In docker-compose.macmini.yml
telegram-ollama-bot:
image: ghcr.io/memo-2023/telegram-ollama-bot:latest
container_name: manacore-telegram-ollama-bot
restart: always
environment:
PORT: 3301
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
MANA_LLM_URL: http://mana-llm:3025
LLM_MODEL: ollama/gemma3:4b
ports:
- "3301:3301"
```
### Option 2: Nativ
```bash
# Auf dem Mac Mini
cd ~/projects/manacore-monorepo/services/telegram-ollama-bot
pnpm install
pnpm build
TELEGRAM_BOT_TOKEN=xxx MANA_LLM_URL=http://localhost:3025 pnpm start:prod
```
## Neuen Bot erstellen
1. Öffne @BotFather in Telegram
2. Sende `/newbot`
3. Wähle einen Namen (z.B. "ManaCore Ollama")
4. Wähle einen Username (z.B. "manacore_ollama_bot")
5. Kopiere den Token
## Health Check
```bash
curl http://localhost:3301/health
```
## Features
- **Chat-Verlauf**: Behält die letzten 10 Nachrichten für Kontext
- **Mehrere Modi**: Verschiedene System-Prompts für unterschiedliche Aufgaben
- **Modell-Wechsel**: Dynamisch zwischen installierten Modellen wechseln
- **User-Beschränkung**: Optional nur bestimmte Telegram-User erlauben
- **Lange Antworten**: Automatisches Splitting bei >4000 Zeichen

View file

@ -1,44 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# 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 npm install -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install production dependencies only
RUN pnpm install --prod --frozen-lockfile || pnpm install --prod
# Copy built application
COPY --from=builder /app/dist ./dist
# Set environment
ENV NODE_ENV=production
ENV PORT=3301
EXPOSE 3301
CMD ["node", "dist/main.js"]

View file

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

View file

@ -1,35 +0,0 @@
{
"name": "@manacore/telegram-ollama-bot",
"version": "1.0.0",
"description": "Telegram bot for local LLM inference via Ollama",
"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-telegraf": "^2.8.0",
"telegraf": "^4.16.3",
"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"
}
}

View file

@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { BotModule } from './bot/bot.module';
import { OllamaModule } from './ollama/ollama.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.token') || '',
}),
inject: [ConfigService],
}),
BotModule,
OllamaModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { OllamaModule } from '../ollama/ollama.module';
@Module({
imports: [OllamaModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -1,278 +0,0 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { ConfigService } from '@nestjs/config';
import { OllamaService } from '../ollama/ollama.service';
import { SYSTEM_PROMPTS } from '../config/configuration';
interface UserSession {
systemPrompt: string;
model: string;
history: { role: 'user' | 'assistant'; content: string }[];
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
private sessions: Map<number, UserSession> = new Map();
constructor(
private readonly ollamaService: OllamaService,
private configService: ConfigService
) {
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
}
private isAllowed(userId: number): boolean {
// If no users configured, allow all
if (this.allowedUsers.length === 0) return true;
return this.allowedUsers.includes(userId);
}
private getSession(userId: number): UserSession {
if (!this.sessions.has(userId)) {
this.sessions.set(userId, {
systemPrompt: SYSTEM_PROMPTS.default,
model: this.ollamaService.getDefaultModel(),
history: [],
});
}
return this.sessions.get(userId)!;
}
private formatHelp(): string {
return `<b>Ollama Bot - Lokale KI</b>
<b>Commands:</b>
/start - Diese Hilfe anzeigen
/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
<b>Modi:</b>
<code>default</code> - Allgemeiner Assistent
<code>classify</code> - Text-Klassifizierung
<code>summarize</code> - Zusammenfassungen
<code>translate</code> - Übersetzungen
<code>code</code> - Programmier-Hilfe
<b>Verwendung:</b>
Schreibe einfach eine Nachricht und ich antworte!
<b>Aktuelles Modell:</b> <code>${this.ollamaService.getDefaultModel()}</code>`;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
this.logger.log(`/start from user ${userId}`);
await ctx.replyWithHTML(this.formatHelp());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
await ctx.replyWithHTML(this.formatHelp());
}
@Command('models')
async models(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
this.logger.log(`/models from user ${userId}`);
const models = await this.ollamaService.listModels();
if (models.length === 0) {
await ctx.reply('Keine Modelle gefunden. Ist Ollama gestartet?');
return;
}
const session = this.getSession(userId);
const modelList = models
.map((m) => {
const sizeMB = (m.size / 1024 / 1024).toFixed(0);
const active = m.name === session.model ? ' ✓' : '';
return `• <code>${m.name}</code> (${sizeMB} MB)${active}`;
})
.join('\n');
await ctx.replyWithHTML(
`<b>Verfügbare Modelle:</b>\n\n${modelList}\n\nWechseln mit: /model [name]`
);
}
@Command('model')
async setModel(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const modelName = text.replace('/model', '').trim();
if (!modelName) {
const session = this.getSession(userId);
await ctx.reply(`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) {
await ctx.reply(
`Modell "${modelName}" nicht gefunden. Verfügbar: ${models.map((m) => m.name).join(', ')}`
);
return;
}
const session = this.getSession(userId);
session.model = modelName;
session.history = []; // Clear history on model change
this.logger.log(`User ${userId} switched to model ${modelName}`);
await ctx.reply(`Modell gewechselt zu: ${modelName}`);
}
@Command('mode')
async setMode(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const mode = text.replace('/mode', '').trim().toLowerCase();
const availableModes = Object.keys(SYSTEM_PROMPTS);
if (!mode) {
const session = this.getSession(userId);
const currentMode =
Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt)?.[0] ||
'custom';
await ctx.reply(`Aktueller Modus: ${currentMode}\n\nVerfügbar: ${availableModes.join(', ')}`);
return;
}
if (!SYSTEM_PROMPTS[mode]) {
await ctx.reply(`Unbekannter Modus: ${mode}\n\nVerfügbar: ${availableModes.join(', ')}`);
return;
}
const session = this.getSession(userId);
session.systemPrompt = SYSTEM_PROMPTS[mode];
session.history = []; // Clear history on mode change
this.logger.log(`User ${userId} switched to mode ${mode}`);
await ctx.reply(`Modus gewechselt zu: ${mode}`);
}
@Command('clear')
async clear(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const session = this.getSession(userId);
session.history = [];
this.logger.log(`User ${userId} cleared history`);
await ctx.reply('Chat-Verlauf gelöscht.');
}
@Command('status')
async status(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const connected = await this.ollamaService.checkConnection();
const models = await this.ollamaService.listModels();
const session = this.getSession(userId);
const statusText = `<b>Ollama Status</b>
<b>Verbindung:</b> ${connected ? '✅ Online' : '❌ Offline'}
<b>Modelle:</b> ${models.length}
<b>Dein Modell:</b> <code>${session.model}</code>
<b>Chat-Verlauf:</b> ${session.history.length} Nachrichten`;
await ctx.replyWithHTML(statusText);
}
@On('text')
async onMessage(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
// Ignore commands
if (text.startsWith('/')) return;
this.logger.log(`Message from user ${userId}: ${text.substring(0, 50)}...`);
const session = this.getSession(userId);
// Show typing indicator
await ctx.sendChatAction('typing');
try {
// Add user message to history
session.history.push({ role: 'user', content: text });
// Keep only last 10 messages to avoid context overflow
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 });
// Split long messages (Telegram limit is 4096 chars)
if (response.length > 4000) {
const chunks = response.match(/.{1,4000}/gs) || [];
for (const chunk of chunks) {
await ctx.reply(chunk);
}
} else {
await ctx.reply(response);
}
} catch (error) {
this.logger.error(`Error processing message:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`Fehler: ${errorMessage}`);
}
}
}

View file

@ -1,25 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3301', 10),
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN,
allowedUsers:
process.env.TELEGRAM_ALLOWED_USERS?.split(',').map((id) => parseInt(id, 10)) || [],
},
llm: {
url: process.env.MANA_LLM_URL || 'http://localhost:3025',
model: process.env.LLM_MODEL || 'ollama/gemma3:4b',
timeout: parseInt(process.env.LLM_TIMEOUT || '120000', 10),
},
});
export const SYSTEM_PROMPTS: Record<string, string> = {
default:
'Du bist ein hilfreicher Assistent. Antworte präzise und auf Deutsch, wenn der User Deutsch schreibt.',
classify:
'Du bist ein Klassifikations-Experte. Analysiere den gegebenen Text und ordne ihn einer passenden Kategorie zu. Antworte kurz und präzise.',
summarize:
'Du bist ein Zusammenfassungs-Experte. Fasse den gegebenen Text kurz und prägnant zusammen. Behalte die wichtigsten Informationen bei.',
translate:
'Du bist ein Übersetzer. Übersetze den Text in die gewünschte Sprache. Behalte den Ton und Stil bei.',
code: 'Du bist ein Programmier-Assistent. Hilf bei Code-Fragen, erkläre Konzepte und schlage Verbesserungen vor.',
};

View file

@ -1,21 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { OllamaService } from './ollama/ollama.service';
@Controller()
export class HealthController {
constructor(private readonly ollamaService: OllamaService) {}
@Get('health')
async health() {
const ollamaConnected = await this.ollamaService.checkConnection();
return {
status: ollamaConnected ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
ollama: {
connected: ollamaConnected,
model: this.ollamaService.getDefaultModel(),
},
};
}
}

View file

@ -1,19 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port') || 3301;
await app.listen(port);
logger.log(`Telegram Ollama Bot running on port ${port}`);
logger.log(`Ollama URL: ${configService.get<string>('ollama.url')}`);
logger.log(`Default model: ${configService.get<string>('ollama.model')}`);
}
bootstrap();

View file

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

View file

@ -1,171 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface LlmModel {
id: string;
owned_by: string;
}
interface ChatCompletionResponse {
id: string;
model: string;
choices: {
message: { role: string; content: string };
finish_reason: string;
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
@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>('llm.url') || 'http://localhost:3025';
this.defaultModel = this.configService.get<string>('llm.model') || 'ollama/gemma3:4b';
this.timeout = this.configService.get<number>('llm.timeout') || 120000;
}
async onModuleInit() {
await this.checkConnection();
}
async checkConnection(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(5000),
});
const data = await response.json();
this.logger.log(
`mana-llm connected: ${data.status}, providers: ${Object.keys(data.providers || {}).join(', ')}`
);
return data.status === 'healthy' || data.status === 'degraded';
} catch (error) {
this.logger.error(`Failed to connect to mana-llm at ${this.baseUrl}:`, error);
return false;
}
}
async listModels(): Promise<{ name: string; size: number; modified_at: string }[]> {
try {
const response = await fetch(`${this.baseUrl}/v1/models`);
const data = await response.json();
// Convert OpenAI format to legacy Ollama format for compatibility
return (data.data || []).map((m: LlmModel) => ({
name: m.id,
size: 0,
modified_at: new Date().toISOString(),
}));
} catch (error) {
this.logger.error('Failed to list models:', error);
return [];
}
}
async generate(prompt: string, systemPrompt?: string, model?: string): Promise<string> {
const selectedModel = model ? this.normalizeModel(model) : this.defaultModel;
// Convert generate to chat format
const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
try {
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: selectedModel,
messages,
stream: false,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`mana-llm API error: ${response.status} - ${errorText}`);
}
const data: ChatCompletionResponse = await response.json();
// Log performance metrics
if (data.usage) {
this.logger.debug(
`Generated ${data.usage.completion_tokens} tokens (total: ${data.usage.total_tokens})`
);
}
return data.choices[0]?.message?.content || '';
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error('LLM Timeout - Antwort dauerte zu lange');
}
throw error;
}
}
async chat(
messages: { role: 'user' | 'assistant' | 'system'; content: string }[],
model?: string
): Promise<string> {
const selectedModel = model ? this.normalizeModel(model) : this.defaultModel;
try {
const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: selectedModel,
messages,
stream: false,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`mana-llm API error: ${response.status} - ${errorText}`);
}
const data: ChatCompletionResponse = await response.json();
if (data.usage) {
this.logger.debug(
`Generated ${data.usage.completion_tokens} tokens (total: ${data.usage.total_tokens})`
);
}
return data.choices[0]?.message?.content || '';
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error('LLM Timeout - Antwort dauerte zu lange');
}
throw error;
}
}
getDefaultModel(): string {
return this.defaultModel;
}
/**
* Normalize model name to include provider prefix if missing.
*/
private normalizeModel(model: string): string {
if (model.includes('/')) {
return model;
}
return `ollama/${model}`;
}
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}

View file

@ -1,29 +0,0 @@
# Server
PORT=3302
# Telegram
TELEGRAM_BOT_TOKEN=your-bot-token-from-botfather
TELEGRAM_ALLOWED_USERS= # Optional: comma-separated user IDs
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/projectdoc
# Storage (MinIO)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=projectdoc-storage
# AI - Transcription (STT)
STT_PROVIDER=local # local | openai
STT_LOCAL_URL=http://localhost:3020 # mana-stt service URL
STT_MODEL=whisper # whisper | voxtral
# OpenAI (optional fallback for STT, required if STT_PROVIDER=openai)
OPENAI_API_KEY=sk-your-openai-key
# AI - Generation
LLM_PROVIDER=ollama # ollama | openai
OLLAMA_URL=http://localhost:11435 # Use :11435 for metrics proxy, :11434 for direct
OLLAMA_MODEL=gemma3:4b

View file

@ -1,245 +0,0 @@
# Telegram Project Doc Bot
Telegram Bot zum Sammeln von Projektdokumentation (Fotos, Sprachnotizen, Text) und automatischer Blogbeitrag-Generierung.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Database**: PostgreSQL + Drizzle ORM
- **Storage**: S3 (MinIO lokal, Hetzner in Produktion)
- **AI - Transcription**: OpenAI Whisper
- **AI - Generation**: mana-llm service oder OpenAI GPT
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
# Database
pnpm db:generate # Generate migrations
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Hilfe anzeigen |
| `/help` | Hilfe anzeigen |
| `/new [Name]` | Neues Projekt erstellen |
| `/projects` | Alle Projekte auflisten |
| `/switch [ID]` | Projekt wechseln |
| `/status` | Status des aktiven Projekts |
| `/archive` | Projekt archivieren |
| `/generate` | Blogbeitrag generieren |
| `/generate [Stil]` | Mit bestimmtem Stil generieren |
| `/styles` | Verfügbare Stile anzeigen |
| `/export` | Letzte Generierung als Datei |
## Blog-Stile
| Stil | Beschreibung |
|------|--------------|
| `casual` | Locker & persönlich |
| `formal` | Professionell & sachlich |
| `tutorial` | Anleitung mit Schritten |
| `diary` | Tagebuch-Stil |
## User Flow
```
1. /new Gartenhaus-Renovierung → Projekt erstellen
2. 📷 Foto senden → Wird gespeichert
3. 🎤 Sprachnotiz senden → Transkribiert + gespeichert
4. "Heute das Fundament gegossen" → Text-Notiz
5. /status → Übersicht
6. /generate tutorial → Blogbeitrag erstellen
7. /export → Als .md Datei
```
## Environment Variables
```env
# Server
PORT=3302
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
TELEGRAM_ALLOWED_USERS=123,456 # Optional: Nur diese User IDs erlauben
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/projectdoc
# Storage (MinIO)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=projectdoc-storage
# AI - Transcription (optional, aber empfohlen)
OPENAI_API_KEY=sk-xxx
# AI - Generation
LLM_PROVIDER=mana-llm # mana-llm oder openai
MANA_LLM_URL=http://localhost:3025 # mana-llm service URL
LLM_MODEL=ollama/gemma3:4b # Model with provider prefix
```
## Projekt-Struktur
```
services/telegram-project-doc-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config + Blog-Stile
│ ├── database/
│ │ ├── database.module.ts # Drizzle connection
│ │ └── schema.ts # DB schema
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── bot.update.ts # Command handlers
│ ├── project/
│ │ ├── project.module.ts
│ │ └── project.service.ts # Projekt CRUD
│ ├── media/
│ │ ├── media.module.ts
│ │ ├── media.service.ts # Foto/Voice/Text verarbeiten
│ │ └── storage.service.ts # S3 Upload/Download
│ ├── transcription/
│ │ ├── transcription.module.ts
│ │ └── transcription.service.ts # Whisper API
│ └── generation/
│ ├── generation.module.ts
│ └── generation.service.ts # Blogpost AI
├── drizzle/ # Migrations
├── drizzle.config.ts
├── package.json
└── Dockerfile
```
## Lokale Entwicklung
### 1. Bot bei Telegram erstellen
1. Öffne @BotFather in Telegram
2. Sende `/newbot`
3. Wähle einen Namen (z.B. "Project Doc Bot")
4. Wähle einen Username (z.B. "my_projectdoc_bot")
5. Kopiere den Token
### 2. Umgebung vorbereiten
```bash
# Docker Services starten (PostgreSQL, MinIO, Ollama)
pnpm docker:up
# Datenbank erstellen
psql -h localhost -U postgres -c "CREATE DATABASE projectdoc;"
# Schema pushen
cd services/telegram-project-doc-bot
cp .env.example .env
# Token und Keys eintragen
pnpm db:push
```
### 3. Bot starten
```bash
pnpm start:dev
```
## Features
- **Multi-Projekt**: Mehrere Projekte pro User
- **Foto-Speicherung**: Fotos in S3 mit Metadaten
- **Voice-Transkription**: Automatisch via Whisper
- **Text-Notizen**: Einfache Nachrichten werden gespeichert
- **Chronologisch**: Alle Einträge behalten ihre Reihenfolge
- **Mehrere Stile**: casual, formal, tutorial, diary
- **Export**: Markdown-Datei zum Download
## Datenbank-Schema
```
projects
├── id (UUID)
├── telegram_user_id (INT)
├── name (TEXT)
├── description (TEXT)
├── status (TEXT: active, archived, completed)
├── created_at, updated_at
media_items
├── id (UUID)
├── project_id (UUID FK)
├── type (TEXT: photo, voice, text)
├── storage_key (TEXT)
├── caption (TEXT)
├── transcription (TEXT)
├── ai_description (TEXT)
├── metadata (JSONB)
├── telegram_file_id (TEXT)
├── order_index (INT)
├── created_at
generations
├── id (UUID)
├── project_id (UUID FK)
├── style (TEXT)
├── content (TEXT - Markdown)
├── pdf_key (TEXT)
├── is_latest (BOOL)
├── created_at
```
## Health Check
```bash
curl http://localhost:3302/health
```
## Deployment
### Docker (empfohlen)
```yaml
# docker-compose.yml
telegram-project-doc-bot:
build: ./services/telegram-project-doc-bot
restart: always
environment:
PORT: 3302
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: projectdoc-storage
LLM_PROVIDER: ollama
OLLAMA_URL: http://ollama:11434
ports:
- "3302:3302"
```
## Roadmap
- [ ] Foto-Vision-Analyse (was ist auf dem Bild?)
- [ ] PDF-Export
- [ ] Bilder im Blogpost einbetten
- [ ] Projekt-Templates
- [ ] Web-Dashboard zur Ansicht
- [ ] Telegram Mini App für bessere UX

View file

@ -1,41 +0,0 @@
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
# Copy source
COPY . .
# Build
RUN pnpm build
# Production image
FROM node:20-alpine AS runner
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy package files and install prod dependencies only
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --prod --frozen-lockfile
# Copy built app
COPY --from=builder /app/dist ./dist
# Set environment
ENV NODE_ENV=production
ENV PORT=3302
EXPOSE 3302
CMD ["node", "dist/main.js"]

View file

@ -1,10 +0,0 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/projectdoc',
},
});

View file

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

View file

@ -1,44 +0,0 @@
{
"name": "@manacore/telegram-project-doc-bot",
"version": "1.0.0",
"description": "Telegram bot for project documentation - collect photos and voice notes, generate blog posts",
"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",
"nestjs-telegraf": "^2.8.0",
"openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3"
},
"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"
}
}

View file

@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { DatabaseModule } from './database/database.module';
import { BotModule } from './bot/bot.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.token') || '',
}),
inject: [ConfigService],
}),
DatabaseModule,
BotModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { ProjectModule } from '../project/project.module';
import { MediaModule } from '../media/media.module';
import { GenerationModule } from '../generation/generation.module';
@Module({
imports: [ProjectModule, MediaModule, GenerationModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -1,490 +0,0 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { ConfigService } from '@nestjs/config';
import { ProjectService } from '../project/project.service';
import { MediaService } from '../media/media.service';
import { GenerationService } from '../generation/generation.service';
import { BLOG_STYLES } from '../config/configuration';
interface PhotoSize {
file_id: string;
file_unique_id: string;
width: number;
height: number;
file_size?: number;
}
interface Voice {
file_id: string;
file_unique_id: string;
duration: number;
mime_type?: string;
file_size?: number;
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
// Active project per user (userId -> projectId)
private activeProjects: Map<number, string> = new Map();
constructor(
private readonly projectService: ProjectService,
private readonly mediaService: MediaService,
private readonly generationService: GenerationService,
private configService: ConfigService
) {
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
}
private isAllowed(userId: number): boolean {
if (this.allowedUsers.length === 0) return true;
return this.allowedUsers.includes(userId);
}
private formatHelp(): string {
const styles = Object.entries(BLOG_STYLES)
.map(([key, value]) => `• <code>${key}</code> - ${value.name}`)
.join('\n');
return `<b>📸 Project Doc Bot</b>
Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge.
<b>Projekt-Commands:</b>
/new [Name] - Neues Projekt starten
/projects - Alle Projekte anzeigen
/switch [ID] - Projekt wechseln
/status - Status des aktiven Projekts
/archive - Aktives Projekt archivieren
<b>Content:</b>
📷 Foto senden - Wird gespeichert
🎤 Sprachnotiz - Wird transkribiert
💬 Text-Nachricht - Als Notiz gespeichert
<b>Generierung:</b>
/generate - Blogbeitrag erstellen
/generate [Stil] - Mit bestimmtem Stil
/styles - Verfügbare Stile anzeigen
/export - Letzte Generierung exportieren
<b>Verfügbare Stile:</b>
${styles}
<b>Tipp:</b> Starte mit /new Projektname`;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
this.logger.log(`/start from user ${userId}`);
await ctx.replyWithHTML(this.formatHelp());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
await ctx.replyWithHTML(this.formatHelp());
}
@Command('new')
async newProject(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const name = text.replace('/new', '').trim();
if (!name) {
await ctx.reply('Verwendung: /new Projektname\n\nBeispiel: /new Gartenhaus-Renovierung');
return;
}
try {
this.logger.log(`Creating project "${name}" for user ${userId}`);
const project = await this.projectService.create({
telegramUserId: userId,
name,
});
this.activeProjects.set(userId, project.id);
this.logger.log(`User ${userId} created project "${name}" with id ${project.id}`);
await ctx.replyWithHTML(
`✅ <b>Projekt erstellt!</b>\n\n` +
`<b>Name:</b> ${project.name}\n` +
`<b>ID:</b> <code>${project.id.slice(0, 8)}</code>\n\n` +
`Sende jetzt:\n` +
`📷 Fotos\n` +
`🎤 Sprachnotizen\n` +
`💬 Text-Nachrichten\n\n` +
`Mit /generate erstellst du den Blogbeitrag.`
);
} catch (error) {
this.logger.error('Failed to create project:', error);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`Fehler beim Erstellen des Projekts: ${errorMsg}`);
}
}
@Command('projects')
async listProjects(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projects = await this.projectService.findByUser(userId);
if (projects.length === 0) {
await ctx.reply('Keine Projekte gefunden.\n\nStarte mit: /new Projektname');
return;
}
const activeId = this.activeProjects.get(userId);
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 `• <b>${p.name}</b>${active}${status}\n ID: <code>${p.id.slice(0, 8)}</code> | ${stats.total} Einträge`;
})
);
await ctx.replyWithHTML(
`<b>📂 Deine Projekte:</b>\n\n${projectList.join('\n\n')}\n\n` + `Wechseln mit: /switch [ID]`
);
}
@Command('switch')
async switchProject(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const idPrefix = text.replace('/switch', '').trim();
if (!idPrefix) {
await ctx.reply('Verwendung: /switch [ID]\n\nZeige Projekte mit /projects');
return;
}
// Find project by ID prefix
const projects = await this.projectService.findByUser(userId);
const project = projects.find((p) => p.id.startsWith(idPrefix));
if (!project) {
await ctx.reply(`Projekt mit ID "${idPrefix}" nicht gefunden.`);
return;
}
this.activeProjects.set(userId, project.id);
const stats = await this.projectService.getStats(project.id);
await ctx.replyWithHTML(
`✅ Gewechselt zu: <b>${project.name}</b>\n\n` +
`📷 ${stats.photos} Fotos\n` +
`🎤 ${stats.voices} Sprachnotizen\n` +
`📝 ${stats.texts} Textnotizen`
);
}
@Command('status')
async status(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const project = await this.projectService.findById(projectId);
if (!project) {
this.activeProjects.delete(userId);
await ctx.reply('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 =
`<b>📊 Projekt-Status</b>\n\n` +
`<b>Name:</b> ${project.name}\n` +
`<b>Status:</b> ${project.status}\n` +
`<b>Erstellt:</b> ${project.createdAt.toLocaleDateString('de-DE')}\n\n` +
`<b>Inhalte:</b>\n` +
`📷 ${stats.photos} Fotos\n` +
`🎤 ${stats.voices} Sprachnotizen\n` +
`📝 ${stats.texts} Textnotizen\n` +
`<b>Gesamt:</b> ${stats.total} Einträge`;
if (latest) {
statusText += `\n\n<b>Letzte Generierung:</b>\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`;
}
await ctx.replyWithHTML(statusText);
}
@Command('archive')
async archiveProject(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.');
return;
}
await this.projectService.update(projectId, { status: 'archived' });
this.activeProjects.delete(userId);
await ctx.reply('📦 Projekt archiviert.\n\nStarte ein neues mit /new');
}
@Command('styles')
async showStyles(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const styles = Object.entries(BLOG_STYLES)
.map(
([key, value]) => `<b>${key}</b> - ${value.name}\n<i>${value.prompt.slice(0, 80)}...</i>`
)
.join('\n\n');
await ctx.replyWithHTML(
`<b>📝 Verfügbare Blog-Stile:</b>\n\n${styles}\n\nVerwendung: /generate [stil]`
);
}
@Command('generate')
async generate(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const style = text.replace('/generate', '').trim().toLowerCase() || 'casual';
const validStyles = Object.keys(BLOG_STYLES);
if (!validStyles.includes(style)) {
await ctx.reply(
`Unbekannter Stil: "${style}"\n\nVerfügbar: ${validStyles.join(', ')}\n\nZeige Details mit /styles`
);
return;
}
await ctx.reply('🚀 Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.');
await ctx.sendChatAction('typing');
try {
const content = await this.generationService.generateBlogpost(
projectId,
style as keyof typeof BLOG_STYLES
);
// Split if too long for Telegram
if (content.length <= 4000) {
await ctx.reply(content);
} else {
// Send as document
const buffer = Buffer.from(content, 'utf-8');
await ctx.replyWithDocument(
{
source: buffer,
filename: 'blogpost.md',
},
{
caption: '📄 Blogbeitrag (zu lang für Telegram-Nachricht)',
}
);
// Also send a preview
const preview = content.slice(0, 1000) + '\n\n[...gekürzt, siehe Datei]';
await ctx.reply(preview);
}
await ctx.reply('✅ Blogbeitrag erstellt!\n\nExportieren mit /export');
} catch (error) {
this.logger.error('Generation failed:', error);
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`❌ Fehler: ${message}`);
}
}
@Command('export')
async exportGeneration(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.');
return;
}
const latest = await this.generationService.getLatestGeneration(projectId);
if (!latest) {
await ctx.reply('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`;
const buffer = Buffer.from(latest.content, 'utf-8');
await ctx.replyWithDocument(
{
source: buffer,
filename,
},
{
caption: `📄 ${filename}\nGeneriert: ${latest.createdAt.toLocaleString('de-DE')}`,
}
);
}
@On('photo')
async onPhoto(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const message = ctx.message as { photo?: PhotoSize[]; caption?: string };
const photos = message.photo;
if (!photos || photos.length === 0) return;
// Get largest photo
const photo = photos[photos.length - 1];
const caption = message.caption;
await ctx.sendChatAction('upload_photo');
try {
await this.mediaService.processPhoto(projectId, photo.file_id, caption);
const stats = await this.projectService.getStats(projectId);
await ctx.reply(`📷 Foto gespeichert! (${stats.photos} Fotos gesamt)`);
} catch (error) {
this.logger.error('Failed to process photo:', error);
await ctx.reply('❌ Fehler beim Speichern des Fotos.');
}
}
@On('voice')
async onVoice(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const projectId = this.activeProjects.get(userId);
if (!projectId) {
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
return;
}
const message = ctx.message as { voice?: Voice };
const voice = message.voice;
if (!voice) return;
await ctx.reply('🎤 Verarbeite Sprachnotiz...');
await ctx.sendChatAction('typing');
try {
const item = await this.mediaService.processVoice(projectId, voice.file_id, voice.duration);
const stats = await this.projectService.getStats(projectId);
let reply = `✅ Sprachnotiz gespeichert! (${stats.voices} gesamt)`;
if (item.transcription) {
reply += `\n\n📝 Transkription:\n"${item.transcription}"`;
}
await ctx.reply(reply);
} catch (error) {
this.logger.error('Failed to process voice:', error);
await ctx.reply('❌ Fehler beim Verarbeiten der Sprachnotiz.');
}
}
@On('text')
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
// Ignore commands
if (text.startsWith('/')) return;
const projectId = this.activeProjects.get(userId);
if (!projectId) {
// No active project - show hint
await ctx.reply('💡 Tipp: Starte ein Projekt mit /new Projektname');
return;
}
try {
await this.mediaService.addTextNote(projectId, text);
const stats = await this.projectService.getStats(projectId);
await ctx.reply(`📝 Notiz gespeichert! (${stats.texts} Notizen gesamt)`);
} catch (error) {
this.logger.error('Failed to add text note:', error);
await ctx.reply('❌ Fehler beim Speichern der Notiz.');
}
}
}

View file

@ -1,58 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3302', 10),
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN,
allowedUsers:
process.env.TELEGRAM_ALLOWED_USERS?.split(',')
.map((id) => parseInt(id.trim(), 10))
.filter((id) => !isNaN(id)) || [],
},
database: {
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/projectdoc',
},
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 || 'projectdoc-storage',
},
openai: {
apiKey: process.env.OPENAI_API_KEY,
},
stt: {
provider: process.env.STT_PROVIDER || 'local', // 'local' or 'openai'
localUrl: process.env.STT_LOCAL_URL || 'http://localhost:3020',
model: process.env.STT_MODEL || 'whisper', // 'whisper' or 'voxtral'
},
llm: {
provider: process.env.LLM_PROVIDER || 'mana-llm',
manaLlm: {
url: process.env.MANA_LLM_URL || 'http://localhost:3025',
model: process.env.LLM_MODEL || 'ollama/gemma3:4b',
},
},
});
export const BLOG_STYLES = {
casual: {
name: 'Locker & Persönlich',
prompt:
'Schreibe einen lockeren, persönlichen Blogbeitrag. Verwende "ich" und erzähle die Geschichte authentisch.',
},
formal: {
name: 'Professionell',
prompt:
'Schreibe einen professionellen, sachlichen Blogbeitrag. Verwende eine neutrale Sprache.',
},
tutorial: {
name: 'Anleitung/Tutorial',
prompt:
'Schreibe einen anleitenden Blogbeitrag mit klaren Schritten. Nummeriere die Schritte und gib praktische Tipps.',
},
diary: {
name: 'Tagebuch',
prompt:
'Schreibe einen Tagebuch-Eintrag mit persönlichen Eindrücken und Gefühlen. Sehr authentisch und emotional.',
},
};

View file

@ -1,24 +0,0 @@
import { Module, Global } 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 connectionString = configService.get<string>('database.url');
const client = postgres(connectionString!);
return drizzle(client, { schema });
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -1,94 +0,0 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
bigint,
jsonb,
boolean,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Projects table
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull(),
name: text('name').notNull(),
description: text('description'),
status: text('status').default('active').notNull(), // active, archived, completed
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Media items (photos, voice notes, text)
export const mediaItems = pgTable('media_items', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
type: text('type').notNull(), // photo, voice, text
// Storage
storageKey: text('storage_key'), // S3 key for photo/voice
thumbnailKey: text('thumbnail_key'), // Thumbnail for photos
// Content
caption: text('caption'), // Original caption/text
transcription: text('transcription'), // Voice → Text
aiDescription: text('ai_description'), // Vision → Description
// Metadata
metadata: jsonb('metadata').$type<{
width?: number;
height?: number;
duration?: number;
mimeType?: string;
fileSize?: number;
}>(),
telegramFileId: text('telegram_file_id'),
orderIndex: integer('order_index').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Generated blog posts
export const generations = pgTable('generations', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
style: text('style').default('casual').notNull(),
content: text('content').notNull(), // Generated markdown
pdfKey: text('pdf_key'), // S3 key for PDF export
isLatest: boolean('is_latest').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Relations
export const projectsRelations = relations(projects, ({ many }) => ({
mediaItems: many(mediaItems),
generations: many(generations),
}));
export const mediaItemsRelations = relations(mediaItems, ({ one }) => ({
project: one(projects, {
fields: [mediaItems.projectId],
references: [projects.id],
}),
}));
export const generationsRelations = relations(generations, ({ one }) => ({
project: one(projects, {
fields: [generations.projectId],
references: [projects.id],
}),
}));
// Types
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type MediaItem = typeof mediaItems.$inferSelect;
export type NewMediaItem = typeof mediaItems.$inferInsert;
export type Generation = typeof generations.$inferSelect;
export type NewGeneration = typeof generations.$inferInsert;

View file

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

View file

@ -1,206 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, desc } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import OpenAI from 'openai';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { Generation, Project, MediaItem } from '../database/schema';
import { BLOG_STYLES } from '../config/configuration';
type BlogStyle = keyof typeof BLOG_STYLES;
@Injectable()
export class GenerationService {
private readonly logger = new Logger(GenerationService.name);
private readonly llmProvider: string;
private readonly manaLlmUrl: string;
private readonly manaLlmModel: string;
private readonly openai: OpenAI | null;
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>,
private configService: ConfigService
) {
this.llmProvider = this.configService.get<string>('llm.provider') || 'mana-llm';
this.manaLlmUrl = this.configService.get<string>('llm.manaLlm.url') || 'http://localhost:3025';
this.manaLlmModel =
this.configService.get<string>('llm.manaLlm.model') || 'ollama/gemma3:4b';
const apiKey = this.configService.get<string>('openai.apiKey');
this.openai = apiKey ? new OpenAI({ apiKey }) : null;
this.logger.log(`LLM Provider: ${this.llmProvider}`);
}
async generateBlogpost(projectId: string, style: BlogStyle = 'casual'): Promise<string> {
// 1. Load project
const project = await this.db.query.projects.findFirst({
where: eq(schema.projects.id, projectId),
});
if (!project) {
throw new Error('Projekt nicht gefunden');
}
// 2. Load all media items
const items = await this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
orderBy: [schema.mediaItems.orderIndex, schema.mediaItems.createdAt],
});
if (items.length === 0) {
throw new Error(
'Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.'
);
}
// 3. Build context from media items
const context = this.buildContext(items);
// 4. Build prompt
const styleConfig = BLOG_STYLES[style] || BLOG_STYLES.casual;
const prompt = this.buildPrompt(project, context, styleConfig.prompt);
// 5. Generate with LLM
this.logger.log(`Generating blogpost for "${project.name}" with style "${style}"`);
const content = await this.callLlm(prompt);
// 6. Mark previous generations as not latest
await this.db
.update(schema.generations)
.set({ isLatest: false })
.where(eq(schema.generations.projectId, projectId));
// 7. Save generation
const [generation] = await this.db
.insert(schema.generations)
.values({
projectId,
style,
content,
isLatest: true,
})
.returning();
this.logger.log(`Generated blogpost: ${generation.id} (${content.length} chars)`);
return content;
}
private buildContext(items: MediaItem[]): string {
return items
.map((item, index) => {
const num = index + 1;
const timestamp = item.createdAt.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
if (item.type === 'photo') {
const desc = item.aiDescription || item.caption || 'Keine Beschreibung';
return `[Foto ${num}] (${timestamp})\n${desc}`;
}
if (item.type === 'voice') {
const text = item.transcription || '(Keine Transkription verfügbar)';
return `[Sprachnotiz ${num}] (${timestamp})\n"${text}"`;
}
// text
return `[Notiz ${num}] (${timestamp})\n${item.caption}`;
})
.join('\n\n---\n\n');
}
private buildPrompt(project: Project, context: string, stylePrompt: string): string {
return `Du bist ein erfahrener Blogger und Content Creator.
${stylePrompt}
## Projekt-Informationen
**Name:** ${project.name}
${project.description ? `**Beschreibung:** ${project.description}` : ''}
## Gesammelte Inhalte (chronologisch)
${context}
## Aufgabe
Erstelle einen gut strukturierten Blogbeitrag in Markdown basierend auf den obigen Inhalten.
**Anforderungen:**
- Verwende eine passende, ansprechende Überschrift (# Titel)
- Strukturiere den Beitrag mit Zwischenüberschriften (## Abschnitte)
- Verweise im Text auf die Fotos mit [Foto X], damit sie später eingebettet werden können
- Integriere die Sprachnotizen und Textnotizen natürlich in den Fließtext
- Füge am Ende eine kurze Zusammenfassung oder "Lessons Learned" hinzu
- Schreibe auf Deutsch
- Der Beitrag sollte authentisch und persönlich klingen
Beginne direkt mit dem Blogbeitrag (ohne Einleitung wie "Hier ist der Blogbeitrag"):`;
}
private async callLlm(prompt: string): Promise<string> {
if (this.llmProvider === 'openai' && this.openai) {
return this.callOpenAI(prompt);
}
return this.callManaLlm(prompt);
}
private async callOpenAI(prompt: string): Promise<string> {
if (!this.openai) {
throw new Error('OpenAI not configured');
}
const response = await this.openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 4000,
});
return response.choices[0]?.message?.content || '';
}
private async callManaLlm(prompt: string): Promise<string> {
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.manaLlmModel,
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 4000,
stream: false,
}),
signal: AbortSignal.timeout(180000), // 3 minutes timeout
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`mana-llm API error: ${response.status} - ${errorText}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '';
}
async getLatestGeneration(projectId: string): Promise<Generation | undefined> {
return this.db.query.generations.findFirst({
where: eq(schema.generations.projectId, projectId),
orderBy: [desc(schema.generations.createdAt)],
});
}
getAvailableStyles(): { key: string; name: string }[] {
return Object.entries(BLOG_STYLES).map(([key, value]) => ({
key,
name: value.name,
}));
}
}

View file

@ -1,13 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'telegram-project-doc-bot',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -1,18 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port') || 3302;
await app.listen(port);
logger.log(`Telegram Project Doc Bot running on port ${port}`);
logger.log(`LLM Provider: ${configService.get<string>('llm.provider')}`);
}
bootstrap();

View file

@ -1,11 +0,0 @@
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 {}

View file

@ -1,164 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, asc } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { MediaItem, NewMediaItem } from '../database/schema';
import { StorageService } from './storage.service';
import { TranscriptionService } from '../transcription/transcription.service';
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private readonly telegramApiUrl: string;
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>,
private storageService: StorageService,
private transcriptionService: TranscriptionService,
private configService: ConfigService
) {
const token = this.configService.get<string>('telegram.token');
this.telegramApiUrl = `https://api.telegram.org/bot${token}`;
}
// Get file URL from Telegram
private async getTelegramFileUrl(fileId: string): Promise<string> {
const response = await fetch(`${this.telegramApiUrl}/getFile?file_id=${fileId}`);
const data = await response.json();
if (!data.ok) {
throw new Error(`Telegram API error: ${data.description}`);
}
const token = this.configService.get<string>('telegram.token');
return `https://api.telegram.org/file/bot${token}/${data.result.file_path}`;
}
// Download file from URL
private async downloadFile(url: string): Promise<Buffer> {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
// Process a photo from Telegram
async processPhoto(projectId: string, fileId: string, caption?: string): Promise<MediaItem> {
this.logger.log(`Processing photo for project ${projectId}`);
// 1. Download from Telegram
const fileUrl = await this.getTelegramFileUrl(fileId);
const buffer = await this.downloadFile(fileUrl);
// 2. Generate storage key and upload
const filename = `photo_${Date.now()}.jpg`;
const storageKey = this.storageService.generateKey(projectId, 'photo', filename);
await this.storageService.upload(storageKey, buffer, 'image/jpeg');
// 3. Get next order index
const orderIndex = await this.getNextOrderIndex(projectId);
// 4. Save to database
const [item] = await this.db
.insert(schema.mediaItems)
.values({
projectId,
type: 'photo',
storageKey,
caption,
telegramFileId: fileId,
orderIndex,
metadata: { fileSize: buffer.length },
})
.returning();
this.logger.log(`Photo saved: ${item.id}`);
return item;
}
// Process a voice note from Telegram
async processVoice(projectId: string, fileId: string, duration?: number): Promise<MediaItem> {
this.logger.log(`Processing voice for project ${projectId}`);
// 1. Download from Telegram
const fileUrl = await this.getTelegramFileUrl(fileId);
const buffer = await this.downloadFile(fileUrl);
// 2. Transcribe with Whisper
let transcription: string | undefined;
if (this.transcriptionService.isAvailable()) {
try {
transcription = await this.transcriptionService.transcribe(buffer);
} catch (error) {
this.logger.warn('Transcription failed, saving without:', error);
}
}
// 3. Generate storage key and upload
const filename = `voice_${Date.now()}.ogg`;
const storageKey = this.storageService.generateKey(projectId, 'voice', filename);
await this.storageService.upload(storageKey, buffer, 'audio/ogg');
// 4. Get next order index
const orderIndex = await this.getNextOrderIndex(projectId);
// 5. Save to database
const [item] = await this.db
.insert(schema.mediaItems)
.values({
projectId,
type: 'voice',
storageKey,
transcription,
telegramFileId: fileId,
orderIndex,
metadata: { duration, fileSize: buffer.length },
})
.returning();
this.logger.log(`Voice saved: ${item.id}, transcription: ${transcription ? 'yes' : 'no'}`);
return item;
}
// Add a text note
async addTextNote(projectId: string, text: string): Promise<MediaItem> {
const orderIndex = await this.getNextOrderIndex(projectId);
const [item] = await this.db
.insert(schema.mediaItems)
.values({
projectId,
type: 'text',
caption: text,
orderIndex,
})
.returning();
this.logger.log(`Text note saved: ${item.id}`);
return item;
}
// Get all media items for a project
async getByProject(projectId: string): Promise<MediaItem[]> {
return this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
orderBy: [asc(schema.mediaItems.orderIndex), asc(schema.mediaItems.createdAt)],
});
}
// Get next order index for a project
private async getNextOrderIndex(projectId: string): Promise<number> {
const items = await this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.projectId, projectId),
});
return items.length;
}
// Delete a media item
async delete(id: string): Promise<boolean> {
const result = await this.db.delete(schema.mediaItems).where(eq(schema.mediaItems.id, id));
return (result as unknown as { rowCount: number }).rowCount > 0;
}
}

View file

@ -1,77 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadBucketCommand,
CreateBucketCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@Injectable()
export class StorageService implements OnModuleInit {
private readonly logger = new Logger(StorageService.name);
private readonly s3: S3Client;
private readonly bucket: string;
constructor(private configService: ConfigService) {
this.bucket = this.configService.get<string>('s3.bucket')!;
this.s3 = 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, // Required for MinIO
});
}
async onModuleInit() {
await this.ensureBucket();
}
private async ensureBucket(): Promise<void> {
try {
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Bucket "${this.bucket}" exists`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'name' in error && error.name === 'NotFound') {
this.logger.log(`Creating bucket "${this.bucket}"...`);
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
this.logger.log(`Bucket "${this.bucket}" created`);
} else {
this.logger.warn(`Could not check bucket: ${error}`);
}
}
}
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
this.logger.debug(`Uploaded ${key} (${buffer.length} bytes)`);
return key;
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.s3, command, { expiresIn });
}
generateKey(projectId: string, type: 'photo' | 'voice' | 'pdf', filename: string): string {
return `${projectId}/${type}/${filename}`;
}
}

View file

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

View file

@ -1,90 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { DATABASE_CONNECTION } from '../database/database.module';
import * as schema from '../database/schema';
import { Project, NewProject } from '../database/schema';
@Injectable()
export class ProjectService {
private readonly logger = new Logger(ProjectService.name);
constructor(
@Inject(DATABASE_CONNECTION)
private db: PostgresJsDatabase<typeof schema>
) {}
async create(data: {
telegramUserId: number;
name: string;
description?: string;
}): Promise<Project> {
const [project] = await this.db
.insert(schema.projects)
.values({
telegramUserId: data.telegramUserId,
name: data.name,
description: data.description,
})
.returning();
this.logger.log(`Created project "${project.name}" for user ${data.telegramUserId}`);
return project;
}
async findById(id: string): Promise<Project | undefined> {
return this.db.query.projects.findFirst({
where: eq(schema.projects.id, id),
});
}
async findByUser(telegramUserId: number): Promise<Project[]> {
return this.db.query.projects.findMany({
where: eq(schema.projects.telegramUserId, telegramUserId),
orderBy: [desc(schema.projects.updatedAt)],
});
}
async findActiveByUser(telegramUserId: number): Promise<Project[]> {
return this.db.query.projects.findMany({
where: and(
eq(schema.projects.telegramUserId, telegramUserId),
eq(schema.projects.status, 'active')
),
orderBy: [desc(schema.projects.updatedAt)],
});
}
async update(id: string, data: Partial<NewProject>): Promise<Project | undefined> {
const [project] = await this.db
.update(schema.projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.projects.id, id))
.returning();
return project;
}
async delete(id: string): Promise<boolean> {
const result = await this.db.delete(schema.projects).where(eq(schema.projects.id, id));
return (result as unknown as { rowCount: number }).rowCount > 0;
}
async getStats(projectId: string): Promise<{
photos: number;
voices: number;
texts: number;
total: number;
}> {
const items = await this.db.query.mediaItems.findMany({
where: eq(schema.mediaItems.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,
};
}
}

View file

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

View file

@ -1,116 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
interface LocalSTTResponse {
text: string;
language?: string;
model: string;
}
@Injectable()
export class TranscriptionService {
private readonly logger = new Logger(TranscriptionService.name);
private readonly openai: OpenAI | null;
private readonly provider: 'local' | 'openai';
private readonly localUrl: string;
private readonly sttModel: string;
constructor(private configService: ConfigService) {
this.provider = this.configService.get<string>('stt.provider', 'local') as 'local' | 'openai';
this.localUrl = this.configService.get<string>('stt.localUrl', 'http://localhost:3020');
this.sttModel = this.configService.get<string>('stt.model', 'whisper');
const apiKey = this.configService.get<string>('openai.apiKey');
if (apiKey) {
this.openai = new OpenAI({ apiKey });
this.logger.log('OpenAI Whisper available as fallback');
} else {
this.openai = null;
}
this.logger.log(
`STT Provider: ${this.provider}, URL: ${this.localUrl}, Model: ${this.sttModel}`
);
}
async transcribe(audioBuffer: Buffer, filename = 'audio.ogg'): Promise<string> {
// Try local STT first if configured
if (this.provider === 'local') {
try {
return await this.transcribeLocal(audioBuffer, filename);
} catch (error) {
this.logger.warn(`Local STT failed, trying OpenAI fallback: ${error}`);
if (this.openai) {
return await this.transcribeOpenAI(audioBuffer, filename);
}
throw error;
}
}
// Use OpenAI
if (this.openai) {
return await this.transcribeOpenAI(audioBuffer, filename);
}
throw new Error('No STT provider available');
}
private async transcribeLocal(audioBuffer: Buffer, filename: string): Promise<string> {
const endpoint = this.sttModel === 'voxtral' ? '/transcribe/voxtral' : '/transcribe';
const url = `${this.localUrl}${endpoint}`;
this.logger.debug(`Calling local STT: ${url}`);
const formData = new FormData();
const uint8Array = new Uint8Array(audioBuffer);
const blob = new Blob([uint8Array], { type: 'audio/ogg' });
formData.append('file', blob, filename);
formData.append('language', 'de');
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Local STT error: ${response.status} - ${error}`);
}
const result: LocalSTTResponse = await response.json();
this.logger.debug(`Local STT result: ${result.text.length} chars, model: ${result.model}`);
return result.text;
}
private async transcribeOpenAI(audioBuffer: Buffer, filename: string): Promise<string> {
if (!this.openai) {
throw new Error('OpenAI not configured');
}
try {
const uint8Array = new Uint8Array(audioBuffer);
const file = new File([uint8Array], filename, { type: 'audio/ogg' });
const response = await this.openai.audio.transcriptions.create({
file,
model: 'whisper-1',
language: 'de',
});
this.logger.debug(
`OpenAI transcribed ${audioBuffer.length} bytes -> ${response.text.length} chars`
);
return response.text;
} catch (error) {
this.logger.error('OpenAI transcription failed:', error);
throw new Error('Transkription fehlgeschlagen');
}
}
isAvailable(): boolean {
return this.provider === 'local' || this.openai !== null;
}
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}

View file

@ -1,15 +0,0 @@
# Server
PORT=3300
TZ=Europe/Berlin
# Telegram Bot
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
TELEGRAM_CHAT_ID=your-telegram-chat-id
# Umami Analytics
UMAMI_API_URL=http://localhost:3200
UMAMI_USERNAME=admin
UMAMI_PASSWORD=your-umami-password
# Database (for user counts - optional)
DATABASE_URL=postgresql://postgres:password@localhost:5432/manacore_auth

View file

@ -1,150 +0,0 @@
# Telegram Stats Bot - Claude Code Guidelines
## Overview
Telegram Stats Bot delivers analytics and statistics from Umami (stats.mana.how) via Telegram. It provides both automated scheduled reports and on-demand commands.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Scheduling**: @nestjs/schedule
- **Analytics**: Umami API
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/telegram-stats-bot/
├── src/
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health check endpoint
│ ├── config/
│ │ └── configuration.ts # Configuration & website IDs
│ ├── bot/
│ │ ├── bot.module.ts
│ │ ├── bot.service.ts # Send messages to Telegram
│ │ └── bot.update.ts # Command handlers
│ ├── umami/
│ │ ├── umami.module.ts
│ │ └── umami.service.ts # Umami API client
│ ├── analytics/
│ │ ├── analytics.module.ts
│ │ ├── analytics.service.ts # Data aggregation
│ │ └── formatters.ts # Message formatters
│ ├── users/
│ │ ├── users.module.ts
│ │ └── users.service.ts # User count from auth DB
│ └── scheduler/
│ ├── scheduler.module.ts
│ └── report.scheduler.ts # Cron jobs
└── Dockerfile
```
## Telegram Commands
| Command | Description |
|---------|-------------|
| `/start` | Show help |
| `/stats` | Overview of all apps (last 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
# Server
PORT=3300
TZ=Europe/Berlin
# Telegram
TELEGRAM_BOT_TOKEN=xxx
TELEGRAM_CHAT_ID=xxx
# Umami
UMAMI_API_URL=http://umami:3000
UMAMI_USERNAME=admin
UMAMI_PASSWORD=xxx
# Database (optional, for user counts)
DATABASE_URL=postgresql://...
```
## Adding New Website IDs
Edit `src/config/configuration.ts`:
```typescript
export const WEBSITE_IDS: Record<string, string> = {
'new-app-webapp': 'uuid-from-umami',
};
export const DISPLAY_NAMES: Record<string, string> = {
'new-app-webapp': 'New App',
};
```
## Docker
```bash
# Build locally
docker build -f services/telegram-stats-bot/Dockerfile -t telegram-stats-bot .
# Run
docker run -p 3300:3300 \
-e TELEGRAM_BOT_TOKEN=xxx \
-e TELEGRAM_CHAT_ID=xxx \
-e UMAMI_API_URL=http://umami:3000 \
-e UMAMI_USERNAME=admin \
-e UMAMI_PASSWORD=xxx \
telegram-stats-bot
```
## Health Check
```bash
curl http://localhost:3300/health
```
## Testing Bot Commands
In Telegram, send commands to your bot:
```
/start # Shows help message
/today # Gets today's stats
/week # Gets weekly stats
/realtime # Shows active visitors
```
## Key Files
| File | Purpose |
|------|---------|
| `src/config/configuration.ts` | All Umami website IDs |
| `src/analytics/formatters.ts` | Report formatting |
| `src/scheduler/report.scheduler.ts` | Cron job definitions |
| `src/umami/umami.service.ts` | Umami API authentication |

View file

@ -1,58 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN npm install -g pnpm@9.15.0
WORKDIR /app
# Copy package files for telegram-stats-bot only (standalone build)
COPY services/telegram-stats-bot/package.json ./
# Install all dependencies (including devDependencies for build)
RUN pnpm install
# Copy source code
COPY services/telegram-stats-bot/src ./src
COPY services/telegram-stats-bot/tsconfig*.json ./
COPY services/telegram-stats-bot/nest-cli.json ./
# Build the application
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm
RUN npm install -g pnpm@9.15.0
WORKDIR /app
# Copy package files
COPY --from=builder /app/package.json ./
# Install production dependencies only
RUN pnpm install --prod
# Copy built application
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Change ownership
RUN chown -R nestjs:nodejs /app
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3300
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3300/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "dist/main"]

View file

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

View file

@ -1,38 +0,0 @@
{
"name": "@manacore/telegram-stats-bot",
"version": "1.0.0",
"description": "Telegram bot for ManaCore analytics and statistics from Umami",
"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",
"nestjs-telegraf": "^2.8.0",
"telegraf": "^4.16.3",
"drizzle-orm": "^0.38.3",
"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"
}
}

View file

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { UmamiModule } from '../umami/umami.module';
import { UsersModule } from '../users/users.module';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [UmamiModule, UsersModule],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View file

@ -1,135 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { UmamiService, UmamiStats } from '../umami/umami.service';
import { UsersService, UserStats } from '../users/users.service';
import {
formatDailyReport,
formatWeeklyReport,
formatRealtimeReport,
formatStatsOverview,
formatUsersReportCompact,
} from './formatters';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(
private readonly umamiService: UmamiService,
private readonly usersService: UsersService
) {}
private getStartOfDay(date: Date = new Date()): Date {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
return start;
}
private getEndOfDay(date: Date = new Date()): Date {
const end = new Date(date);
end.setHours(23, 59, 59, 999);
return end;
}
private getStartOfWeek(date: Date = new Date()): Date {
const start = new Date(date);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Monday start
start.setDate(diff);
start.setHours(0, 0, 0, 0);
return start;
}
private getEndOfWeek(date: Date = new Date()): Date {
const end = this.getStartOfWeek(date);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return end;
}
async getTodayStats(): Promise<Map<string, UmamiStats>> {
const startAt = this.getStartOfDay().getTime();
const endAt = this.getEndOfDay().getTime();
return this.umamiService.getAllWebsiteStats(startAt, endAt);
}
async getYesterdayStats(): Promise<Map<string, UmamiStats>> {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const startAt = this.getStartOfDay(yesterday).getTime();
const endAt = this.getEndOfDay(yesterday).getTime();
return this.umamiService.getAllWebsiteStats(startAt, endAt);
}
async getWeekStats(): Promise<Map<string, UmamiStats>> {
const startAt = this.getStartOfWeek().getTime();
const endAt = this.getEndOfWeek().getTime();
return this.umamiService.getAllWebsiteStats(startAt, endAt);
}
async getPreviousWeekStats(): Promise<Map<string, UmamiStats>> {
const prevWeekStart = this.getStartOfWeek();
prevWeekStart.setDate(prevWeekStart.getDate() - 7);
const prevWeekEnd = this.getEndOfWeek(prevWeekStart);
return this.umamiService.getAllWebsiteStats(prevWeekStart.getTime(), prevWeekEnd.getTime());
}
async getRealtimeStats(): Promise<Map<string, number>> {
return this.umamiService.getAllActiveVisitors();
}
async generateDailyReport(): Promise<string> {
try {
const stats = await this.getTodayStats();
let report = formatDailyReport(stats, new Date());
// Add user stats to daily report
const userStats = await this.usersService.getUserStats();
if (userStats) {
report += formatUsersReportCompact(userStats);
}
return report;
} catch (error) {
this.logger.error('Failed to generate daily report:', error);
return '❌ Fehler beim Erstellen des Daily Reports';
}
}
async generateWeeklyReport(): Promise<string> {
try {
const stats = await this.getWeekStats();
const prevStats = await this.getPreviousWeekStats();
const weekStart = this.getStartOfWeek();
const weekEnd = this.getEndOfWeek();
return formatWeeklyReport(stats, weekStart, weekEnd, prevStats);
} catch (error) {
this.logger.error('Failed to generate weekly report:', error);
return '❌ Fehler beim Erstellen des Weekly Reports';
}
}
async generateRealtimeReport(): Promise<string> {
try {
const activeVisitors = await this.getRealtimeStats();
return formatRealtimeReport(activeVisitors);
} catch (error) {
this.logger.error('Failed to generate realtime report:', error);
return '❌ Fehler beim Abrufen der Realtime-Daten';
}
}
async generateStatsOverview(): Promise<string> {
try {
// Get last 30 days stats for overview
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const startAt = this.getStartOfDay(thirtyDaysAgo).getTime();
const endAt = this.getEndOfDay().getTime();
const stats = await this.umamiService.getAllWebsiteStats(startAt, endAt);
return formatStatsOverview(stats);
} catch (error) {
this.logger.error('Failed to generate stats overview:', error);
return '❌ Fehler beim Abrufen der Statistiken';
}
}
}

View file

@ -1,305 +0,0 @@
import { DISPLAY_NAMES } from '../config/configuration';
import { UmamiStats } from '../umami/umami.service';
export function formatNumber(num: number): string {
return num.toLocaleString('de-DE');
}
export function formatChange(change: number): string {
if (change === 0) return '→';
const sign = change > 0 ? '+' : '';
return `${sign}${Math.round(change)}%`;
}
export function formatChangeEmoji(change: number): string {
if (change > 10) return '📈';
if (change > 0) return '↗';
if (change < -10) return '📉';
if (change < 0) return '↘';
return '→';
}
export function getDisplayName(websiteKey: string): string {
return DISPLAY_NAMES[websiteKey] || websiteKey;
}
export function formatDate(date: Date, format: 'short' | 'long' = 'short'): string {
const options: Intl.DateTimeFormatOptions =
format === 'short'
? { day: 'numeric', month: 'numeric', year: 'numeric' }
: { day: 'numeric', month: 'long', year: 'numeric' };
return date.toLocaleDateString('de-DE', options);
}
export function formatWeekNumber(date: Date): string {
const startOfYear = new Date(date.getFullYear(), 0, 1);
const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7);
return `KW ${weekNumber}`;
}
export function formatDailyReport(stats: Map<string, UmamiStats>, date: Date): string {
const lines: string[] = [
'📊 <b>ManaCore Daily Report</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
`📅 ${formatDate(date, 'long')}`,
'',
'<b>📈 Besucher heute:</b>',
];
// Sort by visitors (descending)
const sortedStats = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
let totalVisitors = 0;
let totalPageviews = 0;
for (const [key, stat] of sortedStats) {
const name = getDisplayName(key).padEnd(12);
const visitors = stat.visitors.value;
const change = formatChange(stat.visitors.change);
const emoji = formatChangeEmoji(stat.visitors.change);
totalVisitors += visitors;
totalPageviews += stat.pageviews.value;
lines.push(` ${name}: ${formatNumber(visitors)} (${change}) ${emoji}`);
}
lines.push('');
lines.push(`📄 <b>Pageviews:</b> ${formatNumber(totalPageviews)}`);
lines.push(`👥 <b>Besucher gesamt:</b> ${formatNumber(totalVisitors)}`);
return lines.join('\n');
}
export function formatWeeklyReport(
stats: Map<string, UmamiStats>,
weekStart: Date,
weekEnd: Date,
prevStats?: Map<string, UmamiStats>
): string {
const lines: string[] = [
'📊 <b>ManaCore Weekly Report</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
`📅 ${formatWeekNumber(weekStart)} (${formatDate(weekStart)} - ${formatDate(weekEnd)})`,
'',
' Besucher Pageviews',
];
// Sort by visitors (descending)
const sortedStats = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
let totalVisitors = 0;
let totalPageviews = 0;
for (const [key, stat] of sortedStats) {
const name = getDisplayName(key).padEnd(12);
const visitors = formatNumber(stat.visitors.value).padStart(6);
const pageviews = formatNumber(stat.pageviews.value).padStart(9);
totalVisitors += stat.visitors.value;
totalPageviews += stat.pageviews.value;
lines.push(`${name}: ${visitors} ${pageviews}`);
}
lines.push('────────────────────────────');
lines.push(
`<b>Total:</b> ${formatNumber(totalVisitors).padStart(6)} ${formatNumber(totalPageviews).padStart(9)}`
);
// Calculate week-over-week change if previous stats available
if (prevStats) {
let prevTotal = 0;
for (const [key, stat] of prevStats.entries()) {
if (key.endsWith('-webapp')) {
prevTotal += stat.visitors.value;
}
}
if (prevTotal > 0) {
const change = ((totalVisitors - prevTotal) / prevTotal) * 100;
lines.push('');
lines.push(`📊 <b>vs. Vorwoche:</b> ${formatChange(change)} ${formatChangeEmoji(change)}`);
}
}
return lines.join('\n');
}
export function formatRealtimeReport(activeVisitors: Map<string, number>): string {
const lines: string[] = ['🔴 <b>Realtime - Aktive Besucher</b>', '━━━━━━━━━━━━━━━━━━━━', ''];
// Sort by active visitors (descending)
const sortedVisitors = Array.from(activeVisitors.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1] - a[1]);
let total = 0;
for (const [key, count] of sortedVisitors) {
const name = getDisplayName(key).padEnd(12);
total += count;
const indicator = count > 0 ? '🟢' : '⚪';
lines.push(`${indicator} ${name}: ${count}`);
}
lines.push('');
lines.push(`👥 <b>Gesamt aktiv:</b> ${total}`);
return lines.join('\n');
}
export function formatStatsOverview(stats: Map<string, UmamiStats>): string {
const lines: string[] = ['📊 <b>ManaCore Stats Übersicht</b>', '━━━━━━━━━━━━━━━━━━━━', ''];
// Group by type
const webapps = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-webapp'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
const landings = Array.from(stats.entries())
.filter(([key]) => key.endsWith('-landing'))
.sort((a, b) => b[1].visitors.value - a[1].visitors.value);
lines.push('<b>🌐 Web Apps:</b>');
for (const [key, stat] of webapps) {
const name = getDisplayName(key).padEnd(12);
lines.push(` ${name}: ${formatNumber(stat.visitors.value)} visitors`);
}
if (landings.length > 0) {
lines.push('');
lines.push('<b>🏠 Landing Pages:</b>');
for (const [key, stat] of landings) {
const name = getDisplayName(key).padEnd(12);
lines.push(` ${name}: ${formatNumber(stat.visitors.value)} visitors`);
}
}
return lines.join('\n');
}
export function formatHelpMessage(): string {
return `🤖 <b>ManaCore Stats Bot</b>
Verfügbare Befehle:
/stats - Übersicht aller Apps
/today - Heutige Statistiken
/week - Wochenstatistiken
/realtime - Aktive Besucher jetzt
/users - Registrierte User
/help - Diese Hilfe anzeigen
📅 Automatische Reports:
Daily: Jeden Tag um 9:00
Weekly: Jeden Montag um 9:00`;
}
export interface DailyRegistration {
date: string;
count: number;
}
export interface UserStats {
totalUsers: number;
verifiedUsers: number;
todayNewUsers: number;
yesterdayNewUsers: number;
weekNewUsers: number;
lastWeekNewUsers: number;
monthNewUsers: number;
dailyRegistrations: DailyRegistration[];
}
function createMiniBarChart(dailyRegistrations: DailyRegistration[]): string[] {
if (dailyRegistrations.length === 0) return [];
const maxCount = Math.max(...dailyRegistrations.map((d) => d.count), 1);
const barChars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
// Fill in missing days and sort
const last7Days: DailyRegistration[] = [];
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const found = dailyRegistrations.find((d) => d.date === dateStr);
last7Days.push({ date: dateStr, count: found?.count || 0 });
}
const bars = last7Days.map((d) => {
const index = Math.floor((d.count / maxCount) * (barChars.length - 1));
return barChars[Math.max(0, index)];
});
const dayLabels = last7Days.map((d) => {
const date = new Date(d.date);
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][date.getDay()];
});
return [`<code>${bars.join('')}</code>`, `<code>${dayLabels.join('')}</code>`];
}
export function formatUsersReport(stats: UserStats): string {
const verificationRate =
stats.totalUsers > 0 ? Math.round((stats.verifiedUsers / stats.totalUsers) * 100) : 0;
// Calculate trends
const dailyTrend =
stats.yesterdayNewUsers > 0
? ((stats.todayNewUsers - stats.yesterdayNewUsers) / stats.yesterdayNewUsers) * 100
: stats.todayNewUsers > 0
? 100
: 0;
const weeklyTrend =
stats.lastWeekNewUsers > 0
? ((stats.weekNewUsers - stats.lastWeekNewUsers) / stats.lastWeekNewUsers) * 100
: stats.weekNewUsers > 0
? 100
: 0;
const lines: string[] = [
'👥 <b>ManaCore User Statistics</b>',
'━━━━━━━━━━━━━━━━━━━━',
'',
'<b>📊 Übersicht</b>',
` 👤 Gesamt: <b>${formatNumber(stats.totalUsers)}</b>`,
` ✅ Verifiziert: ${formatNumber(stats.verifiedUsers)} (${verificationRate}%)`,
'',
'<b>📈 Neue Registrierungen</b>',
` Heute: <b>+${formatNumber(stats.todayNewUsers)}</b> ${formatChangeEmoji(dailyTrend)}`,
` Gestern: +${formatNumber(stats.yesterdayNewUsers)}`,
` Diese Woche: +${formatNumber(stats.weekNewUsers)} ${formatChange(weeklyTrend)} ${formatChangeEmoji(weeklyTrend)}`,
` Dieser Monat: +${formatNumber(stats.monthNewUsers)}`,
];
// Add mini bar chart for last 7 days
if (stats.dailyRegistrations.length > 0) {
lines.push('');
lines.push('<b>📅 Letzte 7 Tage</b>');
lines.push(...createMiniBarChart(stats.dailyRegistrations));
}
return lines.join('\n');
}
export function formatUsersReportCompact(stats: UserStats): string {
const verificationRate =
stats.totalUsers > 0 ? Math.round((stats.verifiedUsers / stats.totalUsers) * 100) : 0;
return [
'',
'<b>👥 Registrierte User</b>',
` Gesamt: <b>${formatNumber(stats.totalUsers)}</b> (${verificationRate}% verifiziert)`,
` Heute: +${formatNumber(stats.todayNewUsers)} | Woche: +${formatNumber(stats.weekNewUsers)} | Monat: +${formatNumber(stats.monthNewUsers)}`,
].join('\n');
}

View file

@ -1,36 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { BotModule } from './bot/bot.module';
import { UmamiModule } from './umami/umami.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
ScheduleModule.forRoot(),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.botToken') || '',
launchOptions: {
dropPendingUpdates: true,
},
}),
inject: [ConfigService],
}),
BotModule,
UmamiModule,
AnalyticsModule,
SchedulerModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { UsersModule } from '../users/users.module';
import { BotService } from './bot.service';
import { BotUpdate } from './bot.update';
@Module({
imports: [AnalyticsModule, UsersModule],
providers: [BotService, BotUpdate],
exports: [BotService],
})
export class BotModule {}

View file

@ -1,40 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectBot } from 'nestjs-telegraf';
import { Telegraf, Context } from 'telegraf';
@Injectable()
export class BotService {
private readonly logger = new Logger(BotService.name);
private readonly chatId: string;
constructor(
@InjectBot() private readonly bot: Telegraf<Context>,
private readonly configService: ConfigService
) {
this.chatId = this.configService.get<string>('telegram.chatId') || '';
}
async sendMessage(message: string, chatId?: string): Promise<void> {
const targetChatId = chatId || this.chatId;
if (!targetChatId) {
this.logger.warn('No chat ID configured, skipping message');
return;
}
try {
await this.bot.telegram.sendMessage(targetChatId, message, {
parse_mode: 'HTML',
});
this.logger.log(`Message sent to chat ${targetChatId}`);
} catch (error) {
this.logger.error(`Failed to send message: ${error}`);
throw error;
}
}
async sendReport(report: string): Promise<void> {
return this.sendMessage(report);
}
}

View file

@ -1,87 +0,0 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { AnalyticsService } from '../analytics/analytics.service';
import { UsersService } from '../users/users.service';
import { formatHelpMessage, formatUsersReport } from '../analytics/formatters';
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
constructor(
private readonly analyticsService: AnalyticsService,
private readonly usersService: UsersService
) {}
@Start()
async start(@Ctx() ctx: Context) {
this.logger.log(`/start command from ${ctx.from?.id}`);
await ctx.replyWithHTML(formatHelpMessage());
}
@Help()
async help(@Ctx() ctx: Context) {
this.logger.log(`/help command from ${ctx.from?.id}`);
await ctx.replyWithHTML(formatHelpMessage());
}
@Command('stats')
async stats(@Ctx() ctx: Context) {
this.logger.log(`/stats command from ${ctx.from?.id}`);
await ctx.reply('📊 Lade Statistiken...');
const report = await this.analyticsService.generateStatsOverview();
await ctx.replyWithHTML(report);
}
@Command('today')
async today(@Ctx() ctx: Context) {
this.logger.log(`/today command from ${ctx.from?.id}`);
await ctx.reply('📊 Lade heutige Statistiken...');
const report = await this.analyticsService.generateDailyReport();
await ctx.replyWithHTML(report);
}
@Command('week')
async week(@Ctx() ctx: Context) {
this.logger.log(`/week command from ${ctx.from?.id}`);
await ctx.reply('📊 Lade Wochenstatistiken...');
const report = await this.analyticsService.generateWeeklyReport();
await ctx.replyWithHTML(report);
}
@Command('realtime')
async realtime(@Ctx() ctx: Context) {
this.logger.log(`/realtime command from ${ctx.from?.id}`);
await ctx.reply('🔴 Lade Realtime-Daten...');
const report = await this.analyticsService.generateRealtimeReport();
await ctx.replyWithHTML(report);
}
@Command('users')
async users(@Ctx() ctx: Context) {
this.logger.log(`/users command from ${ctx.from?.id}`);
await ctx.reply('👥 Lade User-Statistiken...');
try {
const stats = await this.usersService.getUserStats();
if (!stats) {
this.logger.warn('User stats returned null - database may not be configured');
await ctx.reply('❌ Datenbank nicht verfügbar. Prüfe DATABASE_URL Konfiguration.');
return;
}
const report = formatUsersReport(stats);
await ctx.replyWithHTML(report);
} catch (error) {
this.logger.error('Failed to get user stats:', error);
await ctx.reply(
`❌ Fehler beim Laden der User-Statistiken: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`
);
}
}
}

View file

@ -1,71 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3300', 10),
timezone: process.env.TZ || 'Europe/Berlin',
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN,
chatId: process.env.TELEGRAM_CHAT_ID,
},
umami: {
apiUrl: process.env.UMAMI_API_URL || 'http://localhost:3200',
username: process.env.UMAMI_USERNAME || 'admin',
password: process.env.UMAMI_PASSWORD,
},
database: {
url: process.env.DATABASE_URL,
},
});
export const WEBSITE_IDS: Record<string, string> = {
// Landing Pages
'chat-landing': 'a264b165-80d2-47ab-91f4-2efc01de0b66',
'manacore-landing': 'cef3798d-85ae-47df-a44a-e9bee09dbcf9',
'clock-landing': '0332b471-a022-46af-a726-0f45932bfd58',
// Web Apps
'chat-webapp': '5cf9d569-3266-4a57-80dd-3a652dc32786',
'manacore-webapp': '4a14016d-394a-44e0-8ecc-67271f63ffb0',
'todo-webapp': 'ac021d98-778e-46cf-b6b2-2f650ea78f07',
'calendar-webapp': '884fc0a8-3b67-43bd-903b-2be531c66792',
'clock-webapp': '1e7b5006-87a5-4547-8a3d-ab30eac15dd4',
'contacts-webapp': 'ab89a839-be15-4949-99b4-e72492cee4ff',
'picture-webapp': 'bc552bd2-667d-44b4-a717-0dce6a8db98f',
'manadeck-webapp': '314fc57a-c63d-4008-b19e-5e272c0329d6',
'planta-webapp': '876f30bd-43e3-405a-9697-6157db67ca6b',
'zitare-landing': '17e7f92d-8f85-4e78-a4f5-10f0b47e8fb8',
'zitare-webapp': '8ad3c21f-6e9b-4d1e-b3a2-5c8f7d6e9a4b',
};
// Grouped websites for reporting
export const WEBSITE_GROUPS = {
landings: ['chat-landing', 'manacore-landing', 'clock-landing', 'zitare-landing'],
webapps: [
'manacore-webapp',
'chat-webapp',
'todo-webapp',
'calendar-webapp',
'clock-webapp',
'contacts-webapp',
'picture-webapp',
'manadeck-webapp',
'planta-webapp',
'zitare-webapp',
],
};
// Display names for reports
export const DISPLAY_NAMES: Record<string, string> = {
'chat-landing': 'Chat Landing',
'chat-webapp': 'Chat',
'manacore-landing': 'ManaCore Landing',
'manacore-webapp': 'ManaCore',
'todo-webapp': 'Todo',
'calendar-webapp': 'Calendar',
'clock-landing': 'Clock Landing',
'clock-webapp': 'Clock',
'contacts-webapp': 'Contacts',
'picture-webapp': 'Picture',
'manadeck-webapp': 'ManaDeck',
'planta-webapp': 'Planta',
'zitare-landing': 'Zitare Landing',
'zitare-webapp': 'Zitare',
};

View file

@ -1,13 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class HealthController {
@Get('health')
health() {
return {
status: 'ok',
service: 'telegram-stats-bot',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -1,18 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port') || 3300;
await app.listen(port);
logger.log(`Telegram Stats Bot running on port ${port}`);
logger.log(`Timezone: ${configService.get<string>('timezone')}`);
}
bootstrap();

View file

@ -1,66 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { AnalyticsService } from '../analytics/analytics.service';
import { BotService } from '../bot/bot.service';
@Injectable()
export class ReportScheduler {
private readonly logger = new Logger(ReportScheduler.name);
constructor(
private readonly analyticsService: AnalyticsService,
private readonly botService: BotService
) {}
/**
* Daily Report - Every day at 9:00 AM Europe/Berlin
* Cron: minute hour day month weekday
*/
@Cron('0 9 * * *', {
name: 'daily-report',
timeZone: 'Europe/Berlin',
})
async sendDailyReport(): Promise<void> {
this.logger.log('Starting daily report...');
try {
const report = await this.analyticsService.generateDailyReport();
await this.botService.sendReport(report);
this.logger.log('Daily report sent successfully');
} catch (error) {
this.logger.error('Failed to send daily report:', error);
}
}
/**
* Weekly Report - Every Monday at 9:00 AM Europe/Berlin
* Cron: minute hour day month weekday (1 = Monday)
*/
@Cron('0 9 * * 1', {
name: 'weekly-report',
timeZone: 'Europe/Berlin',
})
async sendWeeklyReport(): Promise<void> {
this.logger.log('Starting weekly report...');
try {
const report = await this.analyticsService.generateWeeklyReport();
await this.botService.sendReport(report);
this.logger.log('Weekly report sent successfully');
} catch (error) {
this.logger.error('Failed to send weekly report:', error);
}
}
/**
* Health check log - Every hour
* Useful for debugging and ensuring the scheduler is running
*/
@Cron('0 * * * *', {
name: 'scheduler-health',
timeZone: 'Europe/Berlin',
})
healthCheck(): void {
this.logger.debug('Scheduler health check - running');
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AnalyticsModule } from '../analytics/analytics.module';
import { BotModule } from '../bot/bot.module';
import { ReportScheduler } from './report.scheduler';
@Module({
imports: [AnalyticsModule, BotModule],
providers: [ReportScheduler],
})
export class SchedulerModule {}

View file

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

View file

@ -1,135 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WEBSITE_IDS } from '../config/configuration';
export 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 };
}
export interface ActiveVisitors {
websiteId: string;
visitors: number;
}
@Injectable()
export class UmamiService implements OnModuleInit {
private readonly logger = new Logger(UmamiService.name);
private apiUrl: string;
private username: string;
private password: string;
private authToken: string | null = null;
private tokenExpiry: Date | null = null;
constructor(private configService: ConfigService) {
this.apiUrl = this.configService.get<string>('umami.apiUrl') || 'http://localhost:3200';
this.username = this.configService.get<string>('umami.username') || 'admin';
this.password = this.configService.get<string>('umami.password') || '';
}
async onModuleInit() {
try {
await this.authenticate();
this.logger.log('Successfully authenticated with Umami');
} catch (error) {
this.logger.warn(
'Failed to authenticate with Umami on startup. Will retry on first request.'
);
}
}
private async authenticate(): Promise<void> {
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(`Umami auth failed: ${response.status}`);
}
const data = await response.json();
this.authToken = data.token;
// Token is valid for 24 hours, refresh after 23 hours
this.tokenExpiry = new Date(Date.now() + 23 * 60 * 60 * 1000);
}
private async getAuthToken(): Promise<string> {
if (!this.authToken || !this.tokenExpiry || this.tokenExpiry < new Date()) {
await this.authenticate();
}
return this.authToken!;
}
private async apiRequest<T>(endpoint: string): Promise<T> {
const token = await this.getAuthToken();
const response = await fetch(`${this.apiUrl}${endpoint}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Umami API error: ${response.status} ${await response.text()}`);
}
return response.json();
}
async getWebsiteStats(websiteId: string, startAt: number, endAt: number): Promise<UmamiStats> {
return this.apiRequest<UmamiStats>(
`/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`
);
}
async getActiveVisitors(websiteId: string): Promise<number> {
try {
const result = await this.apiRequest<ActiveVisitors[]>(`/api/websites/${websiteId}/active`);
return result?.[0]?.visitors || 0;
} catch {
return 0;
}
}
async getAllWebsiteStats(startAt: number, endAt: number): Promise<Map<string, UmamiStats>> {
const results = new Map<string, UmamiStats>();
for (const [name, id] of Object.entries(WEBSITE_IDS)) {
try {
const stats = await this.getWebsiteStats(id, startAt, endAt);
results.set(name, stats);
} catch (error) {
this.logger.warn(`Failed to get stats for ${name}: ${error}`);
}
}
return results;
}
async getAllActiveVisitors(): Promise<Map<string, number>> {
const results = new Map<string, number>();
for (const [name, id] of Object.entries(WEBSITE_IDS)) {
try {
const visitors = await this.getActiveVisitors(id);
results.set(name, visitors);
} catch (error) {
this.logger.warn(`Failed to get active visitors for ${name}: ${error}`);
}
}
return results;
}
getWebsiteId(name: string): string | undefined {
return WEBSITE_IDS[name];
}
}

View file

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

View file

@ -1,120 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import postgres from 'postgres';
export interface UserStats {
totalUsers: number;
verifiedUsers: number;
todayNewUsers: number;
weekNewUsers: number;
monthNewUsers: number;
yesterdayNewUsers: number;
lastWeekNewUsers: number;
dailyRegistrations: DailyRegistration[];
}
export interface DailyRegistration {
date: string;
count: number;
}
@Injectable()
export class UsersService implements OnModuleInit {
private readonly logger = new Logger(UsersService.name);
private sql: postgres.Sql | null = null;
private databaseUrl: string | undefined;
constructor(private configService: ConfigService) {
this.databaseUrl = this.configService.get<string>('database.url');
}
async onModuleInit() {
if (this.databaseUrl) {
try {
// Mask password in logs
const maskedUrl = this.databaseUrl.replace(/:([^@]+)@/, ':****@');
this.logger.log(`Connecting to database: ${maskedUrl}`);
this.sql = postgres(this.databaseUrl);
// Test connection
await this.sql`SELECT 1`;
this.logger.log('Database connection initialized and tested successfully');
} catch (error) {
this.logger.error('Failed to initialize database connection:', error);
this.sql = null;
}
} else {
this.logger.warn('DATABASE_URL not configured, user stats will be unavailable');
}
}
async getUserStats(): Promise<UserStats | null> {
if (!this.sql) {
return null;
}
try {
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfYesterday = new Date(startOfToday);
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
const startOfWeek = new Date(now);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const startOfLastWeek = new Date(startOfWeek);
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Main stats query
const [result] = await this.sql`
SELECT
COUNT(*) as total_users,
COUNT(*) FILTER (WHERE email_verified = true) as verified_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfToday.toISOString()}) as today_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfYesterday.toISOString()} AND created_at < ${startOfToday.toISOString()}) as yesterday_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfWeek.toISOString()}) as week_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfLastWeek.toISOString()} AND created_at < ${startOfWeek.toISOString()}) as last_week_new_users,
COUNT(*) FILTER (WHERE created_at >= ${startOfMonth.toISOString()}) as month_new_users
FROM auth.users
WHERE deleted_at IS NULL
`;
// Get daily registrations for last 7 days
const dailyStats = await this.sql`
SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM auth.users
WHERE deleted_at IS NULL
AND created_at >= ${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()}
GROUP BY DATE(created_at)
ORDER BY date DESC
`;
const dailyRegistrations: DailyRegistration[] = dailyStats.map((row) => ({
date: new Date(row.date).toISOString().split('T')[0],
count: Number(row.count),
}));
return {
totalUsers: Number(result.total_users),
verifiedUsers: Number(result.verified_users),
todayNewUsers: Number(result.today_new_users),
yesterdayNewUsers: Number(result.yesterday_new_users),
weekNewUsers: Number(result.week_new_users),
lastWeekNewUsers: Number(result.last_week_new_users),
monthNewUsers: Number(result.month_new_users),
dailyRegistrations,
};
} catch (error) {
this.logger.error('Failed to fetch user stats:', error);
return null;
}
}
}

View file

@ -1,28 +0,0 @@
{
"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,
"resolveJsonModule": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,14 +0,0 @@
# Server
PORT=3304
# Telegram
TELEGRAM_BOT_TOKEN=xxx
# Database (Bot's own database for user mappings)
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo_bot
# Todo Backend API
TODO_API_URL=http://localhost:3018
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001

View file

@ -1,209 +0,0 @@
# Telegram Todo Bot
Telegram Bot fuer Todo - Aufgabenverwaltung via Telegram.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Database**: PostgreSQL + Drizzle ORM
- **Scheduler**: @nestjs/schedule
- **API Client**: Calls Todo Backend (localhost:3018)
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
# Database
pnpm db:generate # Generate migrations
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Willkommensnachricht |
| `/help` | Hilfe anzeigen |
| `/login` | Account verknuepfen |
| `/logout` | Account trennen |
| `/add [Text]` | Neue Aufgabe erstellen |
| `/inbox` | Inbox-Aufgaben anzeigen |
| `/today` | Heutige Aufgaben |
| `/list` | Alle offenen Aufgaben |
| `/done [Nr]` | Aufgabe als erledigt markieren |
| `/projects` | Projekte anzeigen |
| `/remind` | Taegliche Erinnerung an/aus |
## User Flow
```
1. /start → Willkommen
2. /login → Email eingeben
3. [Email eingeben] → Passwort eingeben
4. [Passwort eingeben] → Account verknuepft
5. /today → Heutige Aufgaben
6. /add Einkaufen → Aufgabe erstellt
7. /done 1 → Aufgabe erledigt
8. /remind → Taegliche Erinnerung aktivieren
```
## Architecture
Der Bot verwendet einen **API-Client Ansatz**:
- Bot hat eigene DB fuer Telegram User ↔ Todo User Mapping
- Ruft Todo Backend REST API auf fuer Task-Operationen
- Kein direkter DB-Zugriff auf Todo-Datenbank
```
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Telegram │────>│ Todo Bot │────>│ Todo Backend │
│ User │ │ (port 3304) │ │ (port 3018) │
└─────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ Bot DB (user mapping) │ Todo DB (tasks)
▼ ▼
┌─────────────┐ ┌─────────────┐
│ todo_bot │ │ todo │
│ (PG DB) │ │ (PG DB) │
└─────────────┘ └─────────────┘
```
## Environment Variables
```env
# Server
PORT=3304
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
# Database (Bot's own database)
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo_bot
# Todo Backend API
TODO_API_URL=http://localhost:3018
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
```
## Projekt-Struktur
```
services/telegram-todo-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config
│ ├── database/
│ │ ├── database.module.ts # Drizzle connection
│ │ └── schema.ts # DB schema (user mapping)
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── bot.update.ts # Command handlers
│ ├── todo-client/
│ │ ├── todo-client.module.ts
│ │ ├── todo-client.service.ts # Todo API wrapper
│ │ └── types.ts # TypeScript interfaces
│ ├── user/
│ │ ├── user.module.ts
│ │ └── user.service.ts # Account linking, settings
│ └── scheduler/
│ ├── scheduler.module.ts
│ └── reminder.scheduler.ts # Cron fuer 08:00 Uhr
├── drizzle/ # Migrations
├── drizzle.config.ts
├── package.json
└── .env.example
```
## Lokale Entwicklung
### 1. Bot bei Telegram erstellen
1. Oeffne @BotFather in Telegram
2. Sende `/newbot`
3. Waehle einen Namen (z.B. "Todo Bot")
4. Waehle einen Username (z.B. "mana_todo_bot")
5. Kopiere den Token
### 2. Umgebung vorbereiten
```bash
# Docker Services starten (PostgreSQL)
pnpm docker:up
# Datenbank erstellen und Schema pushen
pnpm dev:todo-bot:full
```
### 3. Bot starten
```bash
# Nur Bot starten (DB muss existieren)
pnpm dev:todo-bot
```
## Features
- **Account-Verknuepfung**: Login via Email/Passwort
- **Aufgaben erstellen**: Schnell neue Aufgaben anlegen
- **Aufgaben anzeigen**: Inbox, Today, alle offenen
- **Aufgaben erledigen**: Per Nummer abhaken
- **Projekte**: Projektliste anzeigen
- **Taegliche Erinnerung**: Automatisch um 08:00 Uhr
## Datenbank-Schema
```
telegram_users
├── id (UUID)
├── telegram_user_id (BIGINT, unique)
├── telegram_username (TEXT)
├── mana_user_id (TEXT) # Verknuepfter Todo-User
├── access_token (TEXT) # JWT fuer API-Calls
├── refresh_token (TEXT)
├── token_expires_at (TIMESTAMP)
├── daily_reminder_enabled (BOOLEAN)
├── daily_reminder_time (TEXT, default '08:00')
├── timezone (TEXT, default 'Europe/Berlin')
├── created_at, updated_at
```
## Health Check
```bash
curl http://localhost:3304/health
```
## MVP Features (Phase 1)
- `/start`, `/help`
- `/login`, `/logout` - Account-Verknuepfung
- `/add [text]` - Aufgabe in Inbox erstellen
- `/today` - Heutige Aufgaben
- `/inbox` - Inbox-Aufgaben
- `/list` - Alle offenen Aufgaben
- `/done [Nr]` - Abhaken
- `/projects` - Projektliste
- `/remind` - Taegliche Erinnerung
## Spaetere Features (Phase 2)
- `/add @projekt [text]` - Aufgabe in Projekt
- `/due [Nr] [Datum]` - Faelligkeitsdatum setzen
- `/priority [Nr] [hoch/mittel/niedrig]`
- Inline-Buttons fuer schnelle Aktionen
- OAuth-basiertes Login (statt Email/Passwort)

View file

@ -1,9 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'todo_bot',
schemaPath: './src/database/schema.ts',
outDir: './drizzle',
verbose: false,
strict: false,
});

View file

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

View file

@ -1,43 +0,0 @@
{
"name": "@manacore/telegram-todo-bot",
"version": "1.0.0",
"description": "Telegram bot for Todo - Task management via Telegram",
"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",
"@nestjs/schedule": "^4.1.2",
"drizzle-orm": "^0.38.3",
"nestjs-telegraf": "^2.8.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3"
},
"devDependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@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"
}
}

View file

@ -1,29 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { DatabaseModule } from './database/database.module';
import { BotModule } from './bot/bot.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.token') || '',
}),
inject: [ConfigService],
}),
DatabaseModule,
BotModule,
SchedulerModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { TodoClientModule } from '../todo-client/todo-client.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [TodoClientModule, UserModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -1,460 +0,0 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command, Message, On } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { TodoClientService } from '../todo-client/todo-client.service';
import { UserService } from '../user/user.service';
import { Task } from '../todo-client/types';
// State for users currently in the login flow
interface LoginState {
step: 'email' | 'password';
email?: string;
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
// Track last shown tasks per user for /done command
private lastTaskList: Map<number, Task[]> = new Map();
// Track users in login flow
private loginFlow: Map<number, LoginState> = new Map();
constructor(
private readonly todoClient: TodoClientService,
private readonly userService: UserService
) {}
private formatHelp(): string {
return `<b>Todo Bot</b>
Verwalte deine Aufgaben direkt in Telegram.
<b>Aufgaben:</b>
/add [Text] - Neue Aufgabe erstellen
/inbox - Inbox-Aufgaben anzeigen
/today - Heutige Aufgaben
/list - Alle offenen Aufgaben
/done [Nr] - Aufgabe als erledigt markieren
<b>Projekte:</b>
/projects - Projekte anzeigen
<b>Einstellungen:</b>
/remind - Taegliche Erinnerung an/aus
/login - Account verknuepfen
/logout - Account trennen
<b>Tipp:</b> Starte mit /today fuer deine heutigen Aufgaben!`;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
const username = ctx.from?.username;
if (!userId) return;
// Ensure user exists in database
await this.userService.ensureUser(userId, username);
const linkedUser = await this.userService.getLinkedUser(userId);
this.logger.log(`/start from user ${userId} (@${username})`);
if (linkedUser) {
await ctx.replyWithHTML(
`<b>Willkommen zurueck!</b>\n\n` +
`Dein Account ist verknuepft. Du kannst sofort loslegen.\n\n` +
this.formatHelp()
);
} else {
await ctx.replyWithHTML(
`<b>Willkommen beim Todo Bot!</b>\n\n` +
`Um Aufgaben zu verwalten, verknuepfe deinen Account:\n` +
`/login - Mit Email/Passwort anmelden\n\n` +
`Oder sieh dir die Hilfe an:\n` +
`/help - Alle Befehle anzeigen`
);
}
}
@Help()
async help(@Ctx() ctx: Context) {
await ctx.replyWithHTML(this.formatHelp());
}
@Command('login')
async login(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
await this.userService.ensureUser(userId, ctx.from?.username);
// Check if already linked
const linkedUser = await this.userService.getLinkedUser(userId);
if (linkedUser) {
await ctx.reply(
'Dein Account ist bereits verknuepft.\n\n' +
'Mit /logout kannst du die Verknuepfung aufheben.'
);
return;
}
// Start login flow
this.loginFlow.set(userId, { step: 'email' });
await ctx.reply('Bitte gib deine E-Mail-Adresse ein:');
}
@Command('logout')
async logout(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
const linkedUser = await this.userService.getLinkedUser(userId);
if (!linkedUser) {
await ctx.reply('Kein Account verknuepft.');
return;
}
await this.userService.unlinkAccount(userId);
await ctx.reply(
'Account-Verknuepfung wurde aufgehoben.\n\nMit /login kannst du dich erneut anmelden.'
);
}
@On('text')
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId) return;
// Check if user is in login flow
const loginState = this.loginFlow.get(userId);
if (!loginState) return; // Not in login flow, ignore
// Ignore commands
if (text.startsWith('/')) return;
if (loginState.step === 'email') {
// Validate email format
if (!text.includes('@')) {
await ctx.reply('Bitte gib eine gueltige E-Mail-Adresse ein:');
return;
}
this.loginFlow.set(userId, { step: 'password', email: text.trim() });
await ctx.reply('Bitte gib dein Passwort ein:');
} else if (loginState.step === 'password') {
const email = loginState.email!;
const password = text.trim();
// Clear login flow
this.loginFlow.delete(userId);
// Attempt login
const result = await this.userService.linkAccount(userId, email, password);
if (result.success) {
await ctx.replyWithHTML(
'<b>Account erfolgreich verknuepft!</b>\n\n' +
'Du kannst jetzt Aufgaben verwalten.\n\n' +
'Probiere /today fuer deine heutigen Aufgaben.'
);
} else {
await ctx.reply(result.error || 'Anmeldung fehlgeschlagen.');
}
}
}
@Command('add')
async addTask(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId) return;
const user = await this.userService.getLinkedUser(userId);
if (!user) {
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
return;
}
const title = text.replace('/add', '').trim();
if (!title) {
await ctx.reply('Verwendung: /add Aufgabentext\n\nBeispiel: /add Einkaufen gehen');
return;
}
try {
const task = await this.todoClient.createTask(user.accessToken!, title);
await ctx.reply(`Aufgabe erstellt: "${task.title}"`);
} catch (error) {
this.logger.error(`Failed to create task: ${error}`);
await ctx.reply('Fehler beim Erstellen der Aufgabe. Bitte versuche es erneut.');
}
}
@Command('inbox')
async inboxTasks(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
const user = await this.userService.getLinkedUser(userId);
if (!user) {
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
return;
}
try {
const tasks = await this.todoClient.getInboxTasks(user.accessToken!);
this.lastTaskList.set(userId, tasks);
if (tasks.length === 0) {
await ctx.reply('Keine Aufgaben in der Inbox.\n\nErstelle eine mit /add [Text]');
return;
}
let response = `<b>Inbox (${tasks.length}):</b>\n\n`;
tasks.slice(0, 20).forEach((task, i) => {
const status = task.isCompleted ? '' : '';
const priority = this.formatPriority(task.priority);
response += `${i + 1}. ${status} ${task.title}${priority}\n`;
});
if (tasks.length > 20) {
response += `\n... und ${tasks.length - 20} weitere`;
}
response += '\n\nAbhaken mit /done [Nr]';
await ctx.replyWithHTML(response);
} catch (error) {
this.logger.error(`Failed to get inbox: ${error}`);
await ctx.reply('Fehler beim Laden der Inbox.');
}
}
@Command('today')
async todayTasks(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
const user = await this.userService.getLinkedUser(userId);
if (!user) {
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
return;
}
try {
const tasks = await this.todoClient.getTodayTasks(user.accessToken!);
this.lastTaskList.set(userId, tasks);
if (tasks.length === 0) {
await ctx.reply('Keine Aufgaben fuer heute!\n\nErstelle eine mit /add [Text]');
return;
}
let response = `<b>Heute (${tasks.length}):</b>\n\n`;
tasks.slice(0, 20).forEach((task, i) => {
const status = task.isCompleted ? '' : '';
const priority = this.formatPriority(task.priority);
const overdue = this.isOverdue(task.dueDate) ? ' (ueberfaellig)' : '';
response += `${i + 1}. ${status} ${task.title}${priority}${overdue}\n`;
});
if (tasks.length > 20) {
response += `\n... und ${tasks.length - 20} weitere`;
}
response += '\n\nAbhaken mit /done [Nr]';
await ctx.replyWithHTML(response);
} catch (error) {
this.logger.error(`Failed to get today tasks: ${error}`);
await ctx.reply('Fehler beim Laden der heutigen Aufgaben.');
}
}
@Command('list')
async listTasks(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
const user = await this.userService.getLinkedUser(userId);
if (!user) {
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
return;
}
try {
const tasks = await this.todoClient.getAllTasks(user.accessToken!, false);
this.lastTaskList.set(userId, tasks);
if (tasks.length === 0) {
await ctx.reply('Keine offenen Aufgaben.\n\nErstelle eine mit /add [Text]');
return;
}
let response = `<b>Alle Aufgaben (${tasks.length}):</b>\n\n`;
tasks.slice(0, 20).forEach((task, i) => {
const priority = this.formatPriority(task.priority);
const dueInfo = this.formatDueDate(task.dueDate);
response += `${i + 1}. ${task.title}${priority}${dueInfo}\n`;
});
if (tasks.length > 20) {
response += `\n... und ${tasks.length - 20} weitere`;
}
response += '\n\nAbhaken mit /done [Nr]';
await ctx.replyWithHTML(response);
} catch (error) {
this.logger.error(`Failed to get tasks: ${error}`);
await ctx.reply('Fehler beim Laden der Aufgaben.');
}
}
@Command('done')
async completeTask(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId) return;
const user = await this.userService.getLinkedUser(userId);
if (!user) {
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
return;
}
const nrStr = text.replace('/done', '').trim();
const nr = parseInt(nrStr, 10);
if (!nrStr || isNaN(nr) || nr < 1) {
await ctx.reply(
'Verwendung: /done [Nr]\n\n' +
'Zeige erst deine Aufgaben mit /today, /inbox oder /list um die Nummer zu sehen.'
);
return;
}
const tasks = this.lastTaskList.get(userId);
if (!tasks || tasks.length === 0) {
await ctx.reply(
'Keine Aufgabenliste im Cache. Bitte erst /today, /inbox oder /list ausfuehren.'
);
return;
}
if (nr > tasks.length) {
await ctx.reply(`Ungueltige Nummer. Du hast ${tasks.length} Aufgaben in der Liste.`);
return;
}
const task = tasks[nr - 1];
try {
await this.todoClient.completeTask(user.accessToken!, task.id);
await ctx.reply(`"${task.title}" erledigt!`);
// Remove from cache
tasks.splice(nr - 1, 1);
this.lastTaskList.set(userId, tasks);
} catch (error) {
this.logger.error(`Failed to complete task: ${error}`);
await ctx.reply('Fehler beim Abschliessen der Aufgabe.');
}
}
@Command('projects')
async showProjects(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
const user = await this.userService.getLinkedUser(userId);
if (!user) {
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
return;
}
try {
const projects = await this.todoClient.getProjects(user.accessToken!);
if (projects.length === 0) {
await ctx.reply('Keine Projekte vorhanden.');
return;
}
let response = `<b>Projekte (${projects.length}):</b>\n\n`;
projects.forEach((project, i) => {
const icon = project.icon || '';
const archived = project.isArchived ? ' (archiviert)' : '';
const isDefault = project.isDefault ? ' (Inbox)' : '';
response += `${i + 1}. ${icon} ${project.name}${isDefault}${archived}\n`;
});
await ctx.replyWithHTML(response);
} catch (error) {
this.logger.error(`Failed to get projects: ${error}`);
await ctx.reply('Fehler beim Laden der Projekte.');
}
}
@Command('remind')
async toggleReminder(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId) return;
await this.userService.ensureUser(userId, ctx.from?.username);
const newState = await this.userService.toggleDailyReminder(userId);
const settings = await this.userService.getDailyReminderSettings(userId);
if (newState) {
await ctx.replyWithHTML(
`<b>Taegliche Erinnerung aktiviert!</b>\n\n` +
`Du erhaeltst jeden Tag um ${settings?.time || '08:00'} Uhr eine Uebersicht deiner Aufgaben.\n\n` +
`Mit /remind wieder deaktivieren.`
);
} else {
await ctx.reply('Taegliche Erinnerung deaktiviert.');
}
}
private formatPriority(priority: string): string {
switch (priority) {
case 'urgent':
return ' !!!';
case 'high':
return ' !!';
case 'low':
return '';
default:
return '';
}
}
private formatDueDate(dueDate: string | null): string {
if (!dueDate) return '';
const date = new Date(dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date < today) {
return ' (ueberfaellig)';
} else if (date < tomorrow) {
return ' (heute)';
} else {
const options: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit' };
return ` (${date.toLocaleDateString('de-DE', options)})`;
}
}
private isOverdue(dueDate: string | null): boolean {
if (!dueDate) return false;
const date = new Date(dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
}
}

View file

@ -1,15 +0,0 @@
export default () => ({
port: parseInt(process.env.PORT || '3304', 10),
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN,
},
database: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/todo_bot',
},
todoApi: {
url: process.env.TODO_API_URL || 'http://localhost:3018',
},
manaCore: {
authUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
});

View file

@ -1,24 +0,0 @@
import { Module, Global } 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 connectionString = configService.get<string>('database.url');
const client = postgres(connectionString!);
return drizzle(client, { schema });
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -1,23 +0,0 @@
import { pgTable, uuid, text, timestamp, bigint, boolean } from 'drizzle-orm/pg-core';
// Telegram users - Mapping Telegram User <-> Todo User
export const telegramUsers = pgTable('telegram_users', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull().unique(),
telegramUsername: text('telegram_username'),
// Linking with mana-core-auth
manaUserId: text('mana_user_id'),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
tokenExpiresAt: timestamp('token_expires_at'),
// Settings
dailyReminderEnabled: boolean('daily_reminder_enabled').default(false).notNull(),
dailyReminderTime: text('daily_reminder_time').default('08:00').notNull(),
timezone: text('timezone').default('Europe/Berlin').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Types
export type TelegramUser = typeof telegramUsers.$inferSelect;
export type NewTelegramUser = typeof telegramUsers.$inferInsert;

View file

@ -1,13 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'telegram-todo-bot',
timestamp: new Date().toISOString(),
};
}
}

Some files were not shown because too many files have changed in this diff Show more