mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
🔥 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:
parent
d2f00c1d77
commit
a341aa1b13
132 changed files with 2133 additions and 9419 deletions
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
975
docs/MATRIX_BOT_ARCHITECTURE.md
Normal file
975
docs/MATRIX_BOT_ARCHITECTURE.md
Normal 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*
|
||||
74
docs/decisions/003-infrastructure-audit-improvements.md
Normal file
74
docs/decisions/003-infrastructure-audit-improvements.md
Normal 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`
|
||||
16
package.json
16
package.json
|
|
@ -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
1069
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class AnalysisModule {}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GoalsService } from './goals.service';
|
||||
|
||||
@Module({
|
||||
providers: [GoalsService],
|
||||
exports: [GoalsService],
|
||||
})
|
||||
export class GoalsModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MealsService } from './meals.service';
|
||||
|
||||
@Module({
|
||||
providers: [MealsService],
|
||||
exports: [MealsService],
|
||||
})
|
||||
export class MealsModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
|
||||
@Module({
|
||||
providers: [StatsService],
|
||||
exports: [StatsService],
|
||||
})
|
||||
export class StatsModule {}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
};
|
||||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OllamaService } from './ollama.service';
|
||||
|
||||
@Module({
|
||||
providers: [OllamaService],
|
||||
exports: [OllamaService],
|
||||
})
|
||||
export class OllamaModule {}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GenerationService } from './generation.service';
|
||||
|
||||
@Module({
|
||||
providers: [GenerationService],
|
||||
exports: [GenerationService],
|
||||
})
|
||||
export class GenerationModule {}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
@Module({
|
||||
providers: [ProjectService],
|
||||
exports: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TranscriptionService } from './transcription.service';
|
||||
|
||||
@Module({
|
||||
providers: [TranscriptionService],
|
||||
exports: [TranscriptionService],
|
||||
})
|
||||
export class TranscriptionModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UmamiService } from './umami.service';
|
||||
|
||||
@Module({
|
||||
providers: [UmamiService],
|
||||
exports: [UmamiService],
|
||||
})
|
||||
export class UmamiModule {}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue