From 8e6adfdb10e2dcda7d1a306b2746e25c495b5f01 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:52:01 +0100 Subject: [PATCH] feat(services): add Telegram bot services for NutriPhi, Todo, and Zitare Add three new Telegram bot services: - telegram-nutriphi-bot: Nutrition tracking bot with Gemini AI analysis - Photo meal analysis - Daily nutrition goals and tracking - Statistics and reports - telegram-todo-bot: Todo list management bot - Integration with Todo backend API - Reminder scheduling - User preferences per chat - telegram-zitare-bot: Daily inspiration quotes bot - Scheduled daily quotes - Quote database with authors - User subscription management All bots use NestJS with nestjs-telegraf for Telegram integration. Co-Authored-By: Claude Opus 4.5 --- services/telegram-nutriphi-bot/.env.example | 12 + services/telegram-nutriphi-bot/CLAUDE.md | 294 ++++++++++ .../telegram-nutriphi-bot/drizzle.config.ts | 11 + services/telegram-nutriphi-bot/nest-cli.json | 8 + services/telegram-nutriphi-bot/package.json | 42 ++ .../src/analysis/analysis.module.ts | 8 + .../src/analysis/gemini.service.ts | 175 ++++++ .../telegram-nutriphi-bot/src/app.module.ts | 27 + .../src/bot/bot.module.ts | 12 + .../src/bot/bot.update.ts | 513 ++++++++++++++++++ .../src/config/configuration.ts | 35 ++ .../src/database/database.module.ts | 24 + .../src/database/schema.ts | 93 ++++ .../src/goals/goals.module.ts | 8 + .../src/goals/goals.service.ts | 56 ++ .../src/health.controller.ts | 13 + services/telegram-nutriphi-bot/src/main.ts | 18 + .../src/meals/meals.module.ts | 8 + .../src/meals/meals.service.ts | 159 ++++++ .../src/stats/stats.module.ts | 8 + .../src/stats/stats.service.ts | 194 +++++++ services/telegram-nutriphi-bot/tsconfig.json | 22 + services/telegram-todo-bot/.env.example | 14 + services/telegram-todo-bot/CLAUDE.md | 209 +++++++ services/telegram-todo-bot/drizzle.config.ts | 10 + services/telegram-todo-bot/nest-cli.json | 8 + services/telegram-todo-bot/package.json | 42 ++ services/telegram-todo-bot/src/app.module.ts | 29 + .../telegram-todo-bot/src/bot/bot.module.ts | 10 + .../telegram-todo-bot/src/bot/bot.update.ts | 460 ++++++++++++++++ .../src/config/configuration.ts | 15 + .../src/database/database.module.ts | 24 + .../telegram-todo-bot/src/database/schema.ts | 23 + .../src/health.controller.ts | 13 + services/telegram-todo-bot/src/main.ts | 18 + .../src/scheduler/reminder.scheduler.ts | 108 ++++ .../src/scheduler/scheduler.module.ts | 11 + .../src/todo-client/todo-client.module.ts | 8 + .../src/todo-client/todo-client.service.ts | 121 +++++ .../src/todo-client/types.ts | 60 ++ .../telegram-todo-bot/src/user/user.module.ts | 8 + .../src/user/user.service.ts | 226 ++++++++ services/telegram-todo-bot/tsconfig.json | 23 + services/telegram-zitare-bot/.env.example | 8 + services/telegram-zitare-bot/CLAUDE.md | 161 ++++++ .../telegram-zitare-bot/drizzle.config.ts | 10 + services/telegram-zitare-bot/nest-cli.json | 10 + services/telegram-zitare-bot/package.json | 42 ++ .../telegram-zitare-bot/src/app.module.ts | 29 + .../telegram-zitare-bot/src/bot/bot.module.ts | 10 + .../telegram-zitare-bot/src/bot/bot.update.ts | 242 +++++++++ .../src/config/configuration.ts | 9 + .../src/database/database.module.ts | 24 + .../src/database/schema.ts | 44 ++ .../src/health.controller.ts | 13 + services/telegram-zitare-bot/src/main.ts | 18 + .../src/quotes/data/authors.json | 44 ++ .../src/quotes/data/quotes.json | 166 ++++++ .../src/quotes/quotes.module.ts | 8 + .../src/quotes/quotes.service.ts | 106 ++++ .../telegram-zitare-bot/src/quotes/types.ts | 17 + .../src/scheduler/daily.scheduler.ts | 61 +++ .../src/scheduler/scheduler.module.ts | 11 + .../src/user/user.module.ts | 8 + .../src/user/user.service.ts | 146 +++++ services/telegram-zitare-bot/tsconfig.json | 23 + 66 files changed, 4390 insertions(+) create mode 100644 services/telegram-nutriphi-bot/.env.example create mode 100644 services/telegram-nutriphi-bot/CLAUDE.md create mode 100644 services/telegram-nutriphi-bot/drizzle.config.ts create mode 100644 services/telegram-nutriphi-bot/nest-cli.json create mode 100644 services/telegram-nutriphi-bot/package.json create mode 100644 services/telegram-nutriphi-bot/src/analysis/analysis.module.ts create mode 100644 services/telegram-nutriphi-bot/src/analysis/gemini.service.ts create mode 100644 services/telegram-nutriphi-bot/src/app.module.ts create mode 100644 services/telegram-nutriphi-bot/src/bot/bot.module.ts create mode 100644 services/telegram-nutriphi-bot/src/bot/bot.update.ts create mode 100644 services/telegram-nutriphi-bot/src/config/configuration.ts create mode 100644 services/telegram-nutriphi-bot/src/database/database.module.ts create mode 100644 services/telegram-nutriphi-bot/src/database/schema.ts create mode 100644 services/telegram-nutriphi-bot/src/goals/goals.module.ts create mode 100644 services/telegram-nutriphi-bot/src/goals/goals.service.ts create mode 100644 services/telegram-nutriphi-bot/src/health.controller.ts create mode 100644 services/telegram-nutriphi-bot/src/main.ts create mode 100644 services/telegram-nutriphi-bot/src/meals/meals.module.ts create mode 100644 services/telegram-nutriphi-bot/src/meals/meals.service.ts create mode 100644 services/telegram-nutriphi-bot/src/stats/stats.module.ts create mode 100644 services/telegram-nutriphi-bot/src/stats/stats.service.ts create mode 100644 services/telegram-nutriphi-bot/tsconfig.json create mode 100644 services/telegram-todo-bot/.env.example create mode 100644 services/telegram-todo-bot/CLAUDE.md create mode 100644 services/telegram-todo-bot/drizzle.config.ts create mode 100644 services/telegram-todo-bot/nest-cli.json create mode 100644 services/telegram-todo-bot/package.json create mode 100644 services/telegram-todo-bot/src/app.module.ts create mode 100644 services/telegram-todo-bot/src/bot/bot.module.ts create mode 100644 services/telegram-todo-bot/src/bot/bot.update.ts create mode 100644 services/telegram-todo-bot/src/config/configuration.ts create mode 100644 services/telegram-todo-bot/src/database/database.module.ts create mode 100644 services/telegram-todo-bot/src/database/schema.ts create mode 100644 services/telegram-todo-bot/src/health.controller.ts create mode 100644 services/telegram-todo-bot/src/main.ts create mode 100644 services/telegram-todo-bot/src/scheduler/reminder.scheduler.ts create mode 100644 services/telegram-todo-bot/src/scheduler/scheduler.module.ts create mode 100644 services/telegram-todo-bot/src/todo-client/todo-client.module.ts create mode 100644 services/telegram-todo-bot/src/todo-client/todo-client.service.ts create mode 100644 services/telegram-todo-bot/src/todo-client/types.ts create mode 100644 services/telegram-todo-bot/src/user/user.module.ts create mode 100644 services/telegram-todo-bot/src/user/user.service.ts create mode 100644 services/telegram-todo-bot/tsconfig.json create mode 100644 services/telegram-zitare-bot/.env.example create mode 100644 services/telegram-zitare-bot/CLAUDE.md create mode 100644 services/telegram-zitare-bot/drizzle.config.ts create mode 100644 services/telegram-zitare-bot/nest-cli.json create mode 100644 services/telegram-zitare-bot/package.json create mode 100644 services/telegram-zitare-bot/src/app.module.ts create mode 100644 services/telegram-zitare-bot/src/bot/bot.module.ts create mode 100644 services/telegram-zitare-bot/src/bot/bot.update.ts create mode 100644 services/telegram-zitare-bot/src/config/configuration.ts create mode 100644 services/telegram-zitare-bot/src/database/database.module.ts create mode 100644 services/telegram-zitare-bot/src/database/schema.ts create mode 100644 services/telegram-zitare-bot/src/health.controller.ts create mode 100644 services/telegram-zitare-bot/src/main.ts create mode 100644 services/telegram-zitare-bot/src/quotes/data/authors.json create mode 100644 services/telegram-zitare-bot/src/quotes/data/quotes.json create mode 100644 services/telegram-zitare-bot/src/quotes/quotes.module.ts create mode 100644 services/telegram-zitare-bot/src/quotes/quotes.service.ts create mode 100644 services/telegram-zitare-bot/src/quotes/types.ts create mode 100644 services/telegram-zitare-bot/src/scheduler/daily.scheduler.ts create mode 100644 services/telegram-zitare-bot/src/scheduler/scheduler.module.ts create mode 100644 services/telegram-zitare-bot/src/user/user.module.ts create mode 100644 services/telegram-zitare-bot/src/user/user.service.ts create mode 100644 services/telegram-zitare-bot/tsconfig.json diff --git a/services/telegram-nutriphi-bot/.env.example b/services/telegram-nutriphi-bot/.env.example new file mode 100644 index 000000000..a187689e8 --- /dev/null +++ b/services/telegram-nutriphi-bot/.env.example @@ -0,0 +1,12 @@ +# 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 diff --git a/services/telegram-nutriphi-bot/CLAUDE.md b/services/telegram-nutriphi-bot/CLAUDE.md new file mode 100644 index 000000000..3a94d3f45 --- /dev/null +++ b/services/telegram-nutriphi-bot/CLAUDE.md @@ -0,0 +1,294 @@ +# 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 diff --git a/services/telegram-nutriphi-bot/drizzle.config.ts b/services/telegram-nutriphi-bot/drizzle.config.ts new file mode 100644 index 000000000..c983a4ec2 --- /dev/null +++ b/services/telegram-nutriphi-bot/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: + process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/nutriphi_bot', + }, +}); diff --git a/services/telegram-nutriphi-bot/nest-cli.json b/services/telegram-nutriphi-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/telegram-nutriphi-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/telegram-nutriphi-bot/package.json b/services/telegram-nutriphi-bot/package.json new file mode 100644 index 000000000..921febaa9 --- /dev/null +++ b/services/telegram-nutriphi-bot/package.json @@ -0,0 +1,42 @@ +{ + "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": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/telegram-nutriphi-bot/src/analysis/analysis.module.ts b/services/telegram-nutriphi-bot/src/analysis/analysis.module.ts new file mode 100644 index 000000000..748ea23ee --- /dev/null +++ b/services/telegram-nutriphi-bot/src/analysis/analysis.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { GeminiService } from './gemini.service'; + +@Module({ + providers: [GeminiService], + exports: [GeminiService], +}) +export class AnalysisModule {} diff --git a/services/telegram-nutriphi-bot/src/analysis/gemini.service.ts b/services/telegram-nutriphi-bot/src/analysis/gemini.service.ts new file mode 100644 index 000000000..c97ce8cb1 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/analysis/gemini.service.ts @@ -0,0 +1,175 @@ +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('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 { + 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 { + 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'); + } + } +} diff --git a/services/telegram-nutriphi-bot/src/app.module.ts b/services/telegram-nutriphi-bot/src/app.module.ts new file mode 100644 index 000000000..58c1dc80e --- /dev/null +++ b/services/telegram-nutriphi-bot/src/app.module.ts @@ -0,0 +1,27 @@ +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('telegram.token') || '', + }), + inject: [ConfigService], + }), + DatabaseModule, + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/telegram-nutriphi-bot/src/bot/bot.module.ts b/services/telegram-nutriphi-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..6bff5098d --- /dev/null +++ b/services/telegram-nutriphi-bot/src/bot/bot.module.ts @@ -0,0 +1,12 @@ +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 {} diff --git a/services/telegram-nutriphi-bot/src/bot/bot.update.ts b/services/telegram-nutriphi-bot/src/bot/bot.update.ts new file mode 100644 index 000000000..3179823ba --- /dev/null +++ b/services/telegram-nutriphi-bot/src/bot/bot.update.ts @@ -0,0 +1,513 @@ +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 = 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('telegram.allowedUsers') || []; + const token = this.configService.get('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 `🥗 NutriPhi Bot + +Dein KI-gestützter Ernährungs-Tracker. + +Mahlzeit erfassen: +📷 Foto senden - Automatische Analyse +💬 Text senden - z.B. "Spaghetti Bolognese" + +Übersicht: +/heute - Heutige Mahlzeiten & Fortschritt +/woche - Wochenstatistik + +Ziele: +/ziele - Aktuelle Ziele anzeigen +/ziele [kcal] [P] [K] [F] - Ziele setzen + Beispiel: /ziele 2000 100 200 70 + +Favoriten: +/favorit [Name] - Letzte Mahlzeit speichern +/favoriten - Gespeicherte Mahlzeiten anzeigen +/essen [Nr] - Favorit als Mahlzeit eintragen +/delfav [Nr] - Favorit löschen + +Sonstiges: +/loeschen - Letzte Mahlzeit löschen +/hilfe - Diese Hilfe anzeigen + +Tipp: 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}. ${type} (${time})\n ${m.description}\n ${m.calories} kcal`; + }) + .join('\n\n'); + + // Format totals and progress + let response = + `📊 Heute (${new Date().toLocaleDateString('de-DE')})\n\n` + + `${mealsList}\n\n` + + `─────────────────\n` + + `Gesamt: ${summary.totals.calories} kcal\n\n`; + + if (summary.goals) { + response += + `Fortschritt:\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` + + `Verbleibend: ${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 = + `📈 Wochenübersicht\n\n` + + `${chart}\n\n` + + `Durchschnitt:\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` + + `Gesamt: ${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( + `🎯 Deine Tagesziele\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` + + `Ändern:\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( + `✅ Ziele aktualisiert!\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 `${i + 1}. ${f.name}\n ${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F`; + }) + .join('\n\n'); + + await ctx.replyWithHTML( + `⭐ Deine Favoriten\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( + `✅ ${favorite.name} 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( + `🍽️ ${analysis.description}\n\n` + + `Erkannt:\n${foodsList}\n\n` + + `Nährwerte:\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` + + `Genauigkeit: ${confidence}%\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( + `✅ ${analysis.description}\n\n` + + `Nährwerte:\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` + + `Genauigkeit: ${confidence}%\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 { + // 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('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'); + } +} diff --git a/services/telegram-nutriphi-bot/src/config/configuration.ts b/services/telegram-nutriphi-bot/src/config/configuration.ts new file mode 100644 index 000000000..8706a6ad8 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/config/configuration.ts @@ -0,0 +1,35 @@ +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'; +} diff --git a/services/telegram-nutriphi-bot/src/database/database.module.ts b/services/telegram-nutriphi-bot/src/database/database.module.ts new file mode 100644 index 000000000..905674bf6 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/database/database.module.ts @@ -0,0 +1,24 @@ +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('database.url'); + const client = postgres(connectionString!); + return drizzle(client, { schema }); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/services/telegram-nutriphi-bot/src/database/schema.ts b/services/telegram-nutriphi-bot/src/database/schema.ts new file mode 100644 index 000000000..53a64c2c8 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/database/schema.ts @@ -0,0 +1,93 @@ +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; +} diff --git a/services/telegram-nutriphi-bot/src/goals/goals.module.ts b/services/telegram-nutriphi-bot/src/goals/goals.module.ts new file mode 100644 index 000000000..f9c681e2e --- /dev/null +++ b/services/telegram-nutriphi-bot/src/goals/goals.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { GoalsService } from './goals.service'; + +@Module({ + providers: [GoalsService], + exports: [GoalsService], +}) +export class GoalsModule {} diff --git a/services/telegram-nutriphi-bot/src/goals/goals.service.ts b/services/telegram-nutriphi-bot/src/goals/goals.service.ts new file mode 100644 index 000000000..895862ea8 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/goals/goals.service.ts @@ -0,0 +1,56 @@ +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 + ) {} + + async getGoals(telegramUserId: number): Promise { + const goals = await this.db.query.userGoals.findFirst({ + where: eq(schema.userGoals.telegramUserId, telegramUserId), + }); + return goals || null; + } + + async ensureGoals(telegramUserId: number): Promise { + 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> + ): Promise { + // 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; + } +} diff --git a/services/telegram-nutriphi-bot/src/health.controller.ts b/services/telegram-nutriphi-bot/src/health.controller.ts new file mode 100644 index 000000000..ce9e400bc --- /dev/null +++ b/services/telegram-nutriphi-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'telegram-nutriphi-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/telegram-nutriphi-bot/src/main.ts b/services/telegram-nutriphi-bot/src/main.ts new file mode 100644 index 000000000..cbc00255d --- /dev/null +++ b/services/telegram-nutriphi-bot/src/main.ts @@ -0,0 +1,18 @@ +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('port') || 3303; + + await app.listen(port); + logger.log(`Telegram NutriPhi Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/services/telegram-nutriphi-bot/src/meals/meals.module.ts b/services/telegram-nutriphi-bot/src/meals/meals.module.ts new file mode 100644 index 000000000..38c743520 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/meals/meals.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MealsService } from './meals.service'; + +@Module({ + providers: [MealsService], + exports: [MealsService], +}) +export class MealsModule {} diff --git a/services/telegram-nutriphi-bot/src/meals/meals.service.ts b/services/telegram-nutriphi-bot/src/meals/meals.service.ts new file mode 100644 index 000000000..4fa0dea2b --- /dev/null +++ b/services/telegram-nutriphi-bot/src/meals/meals.service.ts @@ -0,0 +1,159 @@ +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 + ) {} + + // Create a meal from analysis result + async createFromAnalysis( + telegramUserId: number, + inputType: 'photo' | 'text', + analysis: AnalysisResult, + mealType?: MealType + ): Promise { + 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 { + 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 { + 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 { + const today = new Date().toISOString().split('T')[0]; + return this.getMealsByDate(telegramUserId, today); + } + + // Delete last meal + async deleteLastMeal(telegramUserId: number): Promise { + 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 { + 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 { + 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 { + 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 { + const result = await this.db + .delete(schema.favoriteMeals) + .where(eq(schema.favoriteMeals.id, favoriteId)); + return (result as unknown as { rowCount: number }).rowCount > 0; + } +} diff --git a/services/telegram-nutriphi-bot/src/stats/stats.module.ts b/services/telegram-nutriphi-bot/src/stats/stats.module.ts new file mode 100644 index 000000000..a4e4375c0 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/stats/stats.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { StatsService } from './stats.service'; + +@Module({ + providers: [StatsService], + exports: [StatsService], +}) +export class StatsModule {} diff --git a/services/telegram-nutriphi-bot/src/stats/stats.service.ts b/services/telegram-nutriphi-bot/src/stats/stats.service.ts new file mode 100644 index 000000000..f6ee5f6b5 --- /dev/null +++ b/services/telegram-nutriphi-bot/src/stats/stats.service.ts @@ -0,0 +1,194 @@ +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 + ) {} + + // Get daily summary for a user + async getDailySummary(telegramUserId: number, date?: string): Promise { + 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 { + 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}`; + } +} diff --git a/services/telegram-nutriphi-bot/tsconfig.json b/services/telegram-nutriphi-bot/tsconfig.json new file mode 100644 index 000000000..edf10cd0d --- /dev/null +++ b/services/telegram-nutriphi-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +} diff --git a/services/telegram-todo-bot/.env.example b/services/telegram-todo-bot/.env.example new file mode 100644 index 000000000..7f791224c --- /dev/null +++ b/services/telegram-todo-bot/.env.example @@ -0,0 +1,14 @@ +# 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 diff --git a/services/telegram-todo-bot/CLAUDE.md b/services/telegram-todo-bot/CLAUDE.md new file mode 100644 index 000000000..2e9a51f32 --- /dev/null +++ b/services/telegram-todo-bot/CLAUDE.md @@ -0,0 +1,209 @@ +# 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) diff --git a/services/telegram-todo-bot/drizzle.config.ts b/services/telegram-todo-bot/drizzle.config.ts new file mode 100644 index 000000000..b6007cb04 --- /dev/null +++ b/services/telegram-todo-bot/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/todo_bot', + }, +}); diff --git a/services/telegram-todo-bot/nest-cli.json b/services/telegram-todo-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/telegram-todo-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/telegram-todo-bot/package.json b/services/telegram-todo-bot/package.json new file mode 100644 index 000000000..78ab4f5a0 --- /dev/null +++ b/services/telegram-todo-bot/package.json @@ -0,0 +1,42 @@ +{ + "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": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/telegram-todo-bot/src/app.module.ts b/services/telegram-todo-bot/src/app.module.ts new file mode 100644 index 000000000..c85d9f80f --- /dev/null +++ b/services/telegram-todo-bot/src/app.module.ts @@ -0,0 +1,29 @@ +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('telegram.token') || '', + }), + inject: [ConfigService], + }), + DatabaseModule, + BotModule, + SchedulerModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/telegram-todo-bot/src/bot/bot.module.ts b/services/telegram-todo-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..f7e0b4ce7 --- /dev/null +++ b/services/telegram-todo-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +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 {} diff --git a/services/telegram-todo-bot/src/bot/bot.update.ts b/services/telegram-todo-bot/src/bot/bot.update.ts new file mode 100644 index 000000000..dad03215f --- /dev/null +++ b/services/telegram-todo-bot/src/bot/bot.update.ts @@ -0,0 +1,460 @@ +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 = new Map(); + + // Track users in login flow + private loginFlow: Map = new Map(); + + constructor( + private readonly todoClient: TodoClientService, + private readonly userService: UserService + ) {} + + private formatHelp(): string { + return `Todo Bot + +Verwalte deine Aufgaben direkt in Telegram. + +Aufgaben: +/add [Text] - Neue Aufgabe erstellen +/inbox - Inbox-Aufgaben anzeigen +/today - Heutige Aufgaben +/list - Alle offenen Aufgaben +/done [Nr] - Aufgabe als erledigt markieren + +Projekte: +/projects - Projekte anzeigen + +Einstellungen: +/remind - Taegliche Erinnerung an/aus +/login - Account verknuepfen +/logout - Account trennen + +Tipp: 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( + `Willkommen zurueck!\n\n` + + `Dein Account ist verknuepft. Du kannst sofort loslegen.\n\n` + + this.formatHelp() + ); + } else { + await ctx.replyWithHTML( + `Willkommen beim Todo Bot!\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( + 'Account erfolgreich verknuepft!\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 = `Inbox (${tasks.length}):\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 = `Heute (${tasks.length}):\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 = `Alle Aufgaben (${tasks.length}):\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 = `Projekte (${projects.length}):\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( + `Taegliche Erinnerung aktiviert!\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; + } +} diff --git a/services/telegram-todo-bot/src/config/configuration.ts b/services/telegram-todo-bot/src/config/configuration.ts new file mode 100644 index 000000000..8c0a58297 --- /dev/null +++ b/services/telegram-todo-bot/src/config/configuration.ts @@ -0,0 +1,15 @@ +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', + }, +}); diff --git a/services/telegram-todo-bot/src/database/database.module.ts b/services/telegram-todo-bot/src/database/database.module.ts new file mode 100644 index 000000000..905674bf6 --- /dev/null +++ b/services/telegram-todo-bot/src/database/database.module.ts @@ -0,0 +1,24 @@ +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('database.url'); + const client = postgres(connectionString!); + return drizzle(client, { schema }); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/services/telegram-todo-bot/src/database/schema.ts b/services/telegram-todo-bot/src/database/schema.ts new file mode 100644 index 000000000..a3b7ec26f --- /dev/null +++ b/services/telegram-todo-bot/src/database/schema.ts @@ -0,0 +1,23 @@ +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; diff --git a/services/telegram-todo-bot/src/health.controller.ts b/services/telegram-todo-bot/src/health.controller.ts new file mode 100644 index 000000000..ea78b59a6 --- /dev/null +++ b/services/telegram-todo-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'telegram-todo-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/telegram-todo-bot/src/main.ts b/services/telegram-todo-bot/src/main.ts new file mode 100644 index 000000000..69c14555d --- /dev/null +++ b/services/telegram-todo-bot/src/main.ts @@ -0,0 +1,18 @@ +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('port') || 3304; + + await app.listen(port); + logger.log(`Telegram Todo Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/services/telegram-todo-bot/src/scheduler/reminder.scheduler.ts b/services/telegram-todo-bot/src/scheduler/reminder.scheduler.ts new file mode 100644 index 000000000..a684aafd8 --- /dev/null +++ b/services/telegram-todo-bot/src/scheduler/reminder.scheduler.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectBot } from 'nestjs-telegraf'; +import { Telegraf, Context } from 'telegraf'; +import { TodoClientService } from '../todo-client/todo-client.service'; +import { UserService } from '../user/user.service'; + +@Injectable() +export class ReminderScheduler { + private readonly logger = new Logger(ReminderScheduler.name); + + constructor( + @InjectBot() private readonly bot: Telegraf, + private readonly todoClient: TodoClientService, + private readonly userService: UserService + ) {} + + // Run every day at 8:00 AM Europe/Berlin + @Cron('0 8 * * *', { + timeZone: 'Europe/Berlin', + }) + async sendDailyReminders() { + this.logger.log('Starting daily reminder distribution...'); + + try { + const users = await this.userService.getUsersWithDailyReminderEnabled(); + this.logger.log(`Found ${users.length} users with daily reminder enabled`); + + let sent = 0; + let failed = 0; + + for (const user of users) { + // Skip users without linked account + if (!user.accessToken) { + this.logger.debug(`Skipping user ${user.telegramUserId}: no linked account`); + continue; + } + + try { + // Get today's tasks + const tasks = await this.todoClient.getTodayTasks(user.accessToken); + + let message: string; + if (tasks.length === 0) { + message = `Guten Morgen!\n\nDu hast keine Aufgaben fuer heute. Genieße den Tag!\n\nMit /add kannst du neue Aufgaben erstellen.`; + } else { + message = `Guten Morgen!\n\nDeine Aufgaben fuer heute (${tasks.length}):\n\n`; + + tasks.slice(0, 10).forEach((task, i) => { + const priority = this.formatPriority(task.priority); + const overdue = this.isOverdue(task.dueDate) ? ' (ueberfaellig)' : ''; + message += `${i + 1}. ${task.title}${priority}${overdue}\n`; + }); + + if (tasks.length > 10) { + message += `\n... und ${tasks.length - 10} weitere\n`; + } + + message += '\nAbhaken mit /done [Nr]'; + } + + await this.bot.telegram.sendMessage(user.telegramUserId, message, { + parse_mode: 'HTML', + }); + + sent++; + this.logger.debug(`Sent daily reminder to user ${user.telegramUserId}`); + } catch (error) { + failed++; + this.logger.warn( + `Failed to send daily reminder to user ${user.telegramUserId}: ${error}` + ); + + // If user blocked the bot, disable reminder + if ((error as { response?: { error_code?: number } }).response?.error_code === 403) { + this.logger.log(`User ${user.telegramUserId} blocked bot, disabling daily reminder`); + await this.userService.toggleDailyReminder(user.telegramUserId); + } + } + } + + this.logger.log(`Daily reminder distribution complete: ${sent} sent, ${failed} failed`); + } catch (error) { + this.logger.error('Daily reminder distribution failed:', error); + } + } + + private formatPriority(priority: string): string { + switch (priority) { + case 'urgent': + return ' !!!'; + case 'high': + return ' !!'; + default: + return ''; + } + } + + 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; + } +} diff --git a/services/telegram-todo-bot/src/scheduler/scheduler.module.ts b/services/telegram-todo-bot/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..78d8cd23e --- /dev/null +++ b/services/telegram-todo-bot/src/scheduler/scheduler.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ReminderScheduler } from './reminder.scheduler'; +import { TodoClientModule } from '../todo-client/todo-client.module'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), TodoClientModule, UserModule], + providers: [ReminderScheduler], +}) +export class SchedulerModule {} diff --git a/services/telegram-todo-bot/src/todo-client/todo-client.module.ts b/services/telegram-todo-bot/src/todo-client/todo-client.module.ts new file mode 100644 index 000000000..fa5ad0446 --- /dev/null +++ b/services/telegram-todo-bot/src/todo-client/todo-client.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TodoClientService } from './todo-client.service'; + +@Module({ + providers: [TodoClientService], + exports: [TodoClientService], +}) +export class TodoClientModule {} diff --git a/services/telegram-todo-bot/src/todo-client/todo-client.service.ts b/services/telegram-todo-bot/src/todo-client/todo-client.service.ts new file mode 100644 index 000000000..6d1d3f22d --- /dev/null +++ b/services/telegram-todo-bot/src/todo-client/todo-client.service.ts @@ -0,0 +1,121 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + Task, + Project, + CreateTaskDto, + TasksResponse, + TaskResponse, + ProjectsResponse, +} from './types'; + +@Injectable() +export class TodoClientService { + private readonly logger = new Logger(TodoClientService.name); + private readonly baseUrl: string; + + constructor(private configService: ConfigService) { + this.baseUrl = this.configService.get('todoApi.url') || 'http://localhost:3018'; + } + + private async request( + token: string, + method: string, + path: string, + body?: unknown + ): Promise { + const url = `${this.baseUrl}${path}`; + this.logger.debug(`${method} ${url}`); + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + this.logger.error(`API Error: ${response.status} - ${errorText}`); + throw new Error(`Todo API error: ${response.status}`); + } + + return response.json() as Promise; + } + + // Task Operations + + async createTask(token: string, title: string, projectId?: string): Promise { + const dto: CreateTaskDto = { title }; + if (projectId) { + dto.projectId = projectId; + } + + const response = await this.request(token, 'POST', '/tasks', dto); + return response.task; + } + + async getInboxTasks(token: string): Promise { + const response = await this.request(token, 'GET', '/tasks/inbox'); + return response.tasks; + } + + async getTodayTasks(token: string): Promise { + const response = await this.request(token, 'GET', '/tasks/today'); + return response.tasks; + } + + async getAllTasks(token: string, isCompleted = false): Promise { + const response = await this.request( + token, + 'GET', + `/tasks?isCompleted=${isCompleted}` + ); + return response.tasks; + } + + async getUpcomingTasks(token: string, days = 7): Promise { + const response = await this.request( + token, + 'GET', + `/tasks/upcoming?days=${days}` + ); + return response.tasks; + } + + async completeTask(token: string, taskId: string): Promise { + const response = await this.request(token, 'POST', `/tasks/${taskId}/complete`); + return response.task; + } + + async uncompleteTask(token: string, taskId: string): Promise { + const response = await this.request(token, 'POST', `/tasks/${taskId}/uncomplete`); + return response.task; + } + + async deleteTask(token: string, taskId: string): Promise { + await this.request<{ success: boolean }>(token, 'DELETE', `/tasks/${taskId}`); + } + + // Project Operations + + async getProjects(token: string): Promise { + const response = await this.request(token, 'GET', '/projects'); + return response.projects; + } + + async getProjectById(token: string, projectId: string): Promise { + try { + const response = await this.request<{ project: Project }>( + token, + 'GET', + `/projects/${projectId}` + ); + return response.project; + } catch { + return null; + } + } +} diff --git a/services/telegram-todo-bot/src/todo-client/types.ts b/services/telegram-todo-bot/src/todo-client/types.ts new file mode 100644 index 000000000..8ff94c325 --- /dev/null +++ b/services/telegram-todo-bot/src/todo-client/types.ts @@ -0,0 +1,60 @@ +export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface Subtask { + id: string; + title: string; + isCompleted: boolean; + order: number; +} + +export interface Task { + id: string; + projectId: string | null; + userId: string; + title: string; + description: string | null; + dueDate: string | null; + dueTime: string | null; + priority: TaskPriority; + status: TaskStatus; + isCompleted: boolean; + completedAt: string | null; + order: number; + subtasks: Subtask[] | null; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: string; + userId: string; + name: string; + color: string | null; + icon: string | null; + order: number; + isArchived: boolean; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateTaskDto { + title: string; + description?: string; + projectId?: string; + dueDate?: string; + priority?: TaskPriority; +} + +export interface TasksResponse { + tasks: Task[]; +} + +export interface TaskResponse { + task: Task; +} + +export interface ProjectsResponse { + projects: Project[]; +} diff --git a/services/telegram-todo-bot/src/user/user.module.ts b/services/telegram-todo-bot/src/user/user.module.ts new file mode 100644 index 000000000..ab6ead25c --- /dev/null +++ b/services/telegram-todo-bot/src/user/user.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './user.service'; + +@Module({ + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/services/telegram-todo-bot/src/user/user.service.ts b/services/telegram-todo-bot/src/user/user.service.ts new file mode 100644 index 000000000..d3f82f5ee --- /dev/null +++ b/services/telegram-todo-bot/src/user/user.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +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 { TelegramUser } from '../database/schema'; + +interface AuthResponse { + accessToken: string; + refreshToken: string; + user: { + id: string; + email: string; + }; +} + +@Injectable() +export class UserService { + private readonly logger = new Logger(UserService.name); + private readonly authUrl: string; + + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + private readonly configService: ConfigService + ) { + this.authUrl = this.configService.get('manaCore.authUrl') || 'http://localhost:3001'; + } + + async ensureUser(telegramUserId: number, username?: string): Promise { + // Try to find existing user + const existing = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (existing) { + // Update username if changed + if (username && existing.telegramUsername !== username) { + await this.db + .update(schema.telegramUsers) + .set({ telegramUsername: username, updatedAt: new Date() }) + .where(eq(schema.telegramUsers.id, existing.id)); + } + return existing; + } + + // Create new user + const [newUser] = await this.db + .insert(schema.telegramUsers) + .values({ + telegramUserId, + telegramUsername: username, + }) + .returning(); + + this.logger.log(`Created new user: ${telegramUserId} (@${username})`); + return newUser; + } + + async getLinkedUser(telegramUserId: number): Promise { + const user = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (!user || !user.accessToken) { + return null; + } + + // Check if token is expired + if (user.tokenExpiresAt && user.tokenExpiresAt < new Date()) { + // Try to refresh the token + if (user.refreshToken) { + const refreshed = await this.refreshAccessToken(user); + if (refreshed) { + return refreshed; + } + } + return null; + } + + return user; + } + + async linkAccount( + telegramUserId: number, + email: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + try { + // Authenticate with mana-core-auth + const response = await fetch(`${this.authUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const error = await response.text(); + this.logger.warn(`Login failed for telegram user ${telegramUserId}: ${error}`); + return { success: false, error: 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.' }; + } + + const data = (await response.json()) as AuthResponse; + + // Calculate token expiry (15 minutes from now, or parse from JWT) + const tokenExpiresAt = new Date(); + tokenExpiresAt.setMinutes(tokenExpiresAt.getMinutes() + 14); // 14 min to be safe + + // Update the user with tokens + await this.db + .update(schema.telegramUsers) + .set({ + manaUserId: data.user.id, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + tokenExpiresAt, + updatedAt: new Date(), + }) + .where(eq(schema.telegramUsers.telegramUserId, telegramUserId)); + + this.logger.log(`Linked telegram user ${telegramUserId} to mana user ${data.user.id}`); + return { success: true }; + } catch (error) { + this.logger.error(`Failed to link account: ${error}`); + return { success: false, error: 'Verbindungsfehler. Bitte spaeter erneut versuchen.' }; + } + } + + async unlinkAccount(telegramUserId: number): Promise { + await this.db + .update(schema.telegramUsers) + .set({ + manaUserId: null, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + updatedAt: new Date(), + }) + .where(eq(schema.telegramUsers.telegramUserId, telegramUserId)); + } + + private async refreshAccessToken(user: TelegramUser): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: user.refreshToken }), + }); + + if (!response.ok) { + this.logger.warn(`Token refresh failed for user ${user.telegramUserId}`); + return null; + } + + const data = (await response.json()) as AuthResponse; + + const tokenExpiresAt = new Date(); + tokenExpiresAt.setMinutes(tokenExpiresAt.getMinutes() + 14); + + const [updated] = await this.db + .update(schema.telegramUsers) + .set({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + tokenExpiresAt, + updatedAt: new Date(), + }) + .where(eq(schema.telegramUsers.id, user.id)) + .returning(); + + return updated; + } catch (error) { + this.logger.error(`Token refresh error: ${error}`); + return null; + } + } + + async toggleDailyReminder(telegramUserId: number): Promise { + const user = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (!user) { + return false; + } + + const newValue = !user.dailyReminderEnabled; + await this.db + .update(schema.telegramUsers) + .set({ dailyReminderEnabled: newValue, updatedAt: new Date() }) + .where(eq(schema.telegramUsers.id, user.id)); + + return newValue; + } + + async setDailyReminderTime(telegramUserId: number, time: string): Promise { + await this.db + .update(schema.telegramUsers) + .set({ dailyReminderTime: time, updatedAt: new Date() }) + .where(eq(schema.telegramUsers.telegramUserId, telegramUserId)); + } + + async getUsersWithDailyReminderEnabled(): Promise { + return this.db.query.telegramUsers.findMany({ + where: eq(schema.telegramUsers.dailyReminderEnabled, true), + }); + } + + async getDailyReminderSettings( + telegramUserId: number + ): Promise<{ enabled: boolean; time: string } | null> { + const user = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (!user) { + return null; + } + + return { + enabled: user.dailyReminderEnabled, + time: user.dailyReminderTime, + }; + } +} diff --git a/services/telegram-todo-bot/tsconfig.json b/services/telegram-todo-bot/tsconfig.json new file mode 100644 index 000000000..c705ffcb3 --- /dev/null +++ b/services/telegram-todo-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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, + "resolveJsonModule": true + } +} diff --git a/services/telegram-zitare-bot/.env.example b/services/telegram-zitare-bot/.env.example new file mode 100644 index 000000000..db1d222a5 --- /dev/null +++ b/services/telegram-zitare-bot/.env.example @@ -0,0 +1,8 @@ +# Server +PORT=3303 + +# Telegram +TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather + +# Database +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/zitare_bot diff --git a/services/telegram-zitare-bot/CLAUDE.md b/services/telegram-zitare-bot/CLAUDE.md new file mode 100644 index 000000000..08caefdcd --- /dev/null +++ b/services/telegram-zitare-bot/CLAUDE.md @@ -0,0 +1,161 @@ +# Telegram Zitare Bot + +Telegram Bot fuer Zitare - deutsche Inspirationszitate. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Telegram**: nestjs-telegraf + Telegraf +- **Database**: PostgreSQL + Drizzle ORM +- **Scheduler**: @nestjs/schedule + +## 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 | +| `/quote` | Zufaelliges Zitat | +| `/zitat` | Alias fuer /quote | +| `/search [Begriff]` | Zitate suchen | +| `/author [Name]` | Zitate eines Autors | +| `/favorite` | Aktuelles Zitat speichern | +| `/favorites` | Favoriten anzeigen | +| `/removefav [Nr]` | Favorit entfernen | +| `/daily` | Taegliches Zitat an/aus | + +## User Flow + +``` +1. /start → Willkommen +2. /quote → Zufaelliges Zitat +3. /favorite → Zitat zu Favoriten +4. /favorites → Liste der Favoriten +5. /daily → Taegliches Zitat aktivieren +``` + +## Environment Variables + +```env +# Server +PORT=3303 + +# Telegram +TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather + +# Database +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/zitare_bot +``` + +## Projekt-Struktur + +``` +services/telegram-zitare-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 +│ ├── quotes/ +│ │ ├── quotes.module.ts +│ │ ├── quotes.service.ts # Zitat-Logik +│ │ ├── types.ts # TypeScript Interfaces +│ │ └── data/ +│ │ ├── quotes.json # Deutsche Zitate +│ │ └── authors.json # Autoren +│ ├── user/ +│ │ ├── user.module.ts +│ │ └── user.service.ts # Favoriten, Daily +│ └── scheduler/ +│ ├── scheduler.module.ts +│ └── daily.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. "Zitare Bot") +4. Waehle einen Username (z.B. "zitare_inspiration_bot") +5. Kopiere den Token + +### 2. Umgebung vorbereiten + +```bash +# Docker Services starten (PostgreSQL) +pnpm docker:up + +# Datenbank erstellen und Schema pushen +pnpm dev:zitare-bot:full +``` + +### 3. Bot starten + +```bash +# Nur Bot starten (DB muss existieren) +pnpm dev:zitare-bot +``` + +## Features + +- **Zitat-Suche**: Nach Begriff oder Autor suchen +- **Favoriten**: Lieblingszitate speichern +- **Taegliches Zitat**: Automatisch um 08:00 Uhr +- **40+ deutsche Zitate**: Von Einstein bis Goethe + +## Datenbank-Schema + +``` +telegram_users +├── id (UUID) +├── telegram_user_id (BIGINT, unique) +├── telegram_username (TEXT) +├── daily_enabled (BOOLEAN) +├── daily_time (TEXT, default '08:00') +├── timezone (TEXT, default 'Europe/Berlin') +├── created_at, updated_at + +user_favorites +├── id (UUID) +├── telegram_user_id (BIGINT) +├── quote_id (TEXT) +├── created_at +├── UNIQUE(telegram_user_id, quote_id) +``` + +## Health Check + +```bash +curl http://localhost:3303/health +``` diff --git a/services/telegram-zitare-bot/drizzle.config.ts b/services/telegram-zitare-bot/drizzle.config.ts new file mode 100644 index 000000000..f184039fa --- /dev/null +++ b/services/telegram-zitare-bot/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/zitare_bot', + }, +}); diff --git a/services/telegram-zitare-bot/nest-cli.json b/services/telegram-zitare-bot/nest-cli.json new file mode 100644 index 000000000..07c816dc4 --- /dev/null +++ b/services/telegram-zitare-bot/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "assets": ["quotes/data/**/*.json"], + "watchAssets": true + } +} diff --git a/services/telegram-zitare-bot/package.json b/services/telegram-zitare-bot/package.json new file mode 100644 index 000000000..959f45ae3 --- /dev/null +++ b/services/telegram-zitare-bot/package.json @@ -0,0 +1,42 @@ +{ + "name": "@manacore/telegram-zitare-bot", + "version": "1.0.0", + "description": "Telegram bot for Zitare - German inspiration quotes", + "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": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/telegram-zitare-bot/src/app.module.ts b/services/telegram-zitare-bot/src/app.module.ts new file mode 100644 index 000000000..c85d9f80f --- /dev/null +++ b/services/telegram-zitare-bot/src/app.module.ts @@ -0,0 +1,29 @@ +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('telegram.token') || '', + }), + inject: [ConfigService], + }), + DatabaseModule, + BotModule, + SchedulerModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/telegram-zitare-bot/src/bot/bot.module.ts b/services/telegram-zitare-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..bc407adee --- /dev/null +++ b/services/telegram-zitare-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BotUpdate } from './bot.update'; +import { QuotesModule } from '../quotes/quotes.module'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [QuotesModule, UserModule], + providers: [BotUpdate], +}) +export class BotModule {} diff --git a/services/telegram-zitare-bot/src/bot/bot.update.ts b/services/telegram-zitare-bot/src/bot/bot.update.ts new file mode 100644 index 000000000..ad9a46059 --- /dev/null +++ b/services/telegram-zitare-bot/src/bot/bot.update.ts @@ -0,0 +1,242 @@ +import { Logger } from '@nestjs/common'; +import { Update, Ctx, Start, Help, Command, Message } from 'nestjs-telegraf'; +import { Context } from 'telegraf'; +import { QuotesService } from '../quotes/quotes.service'; +import { UserService } from '../user/user.service'; + +@Update() +export class BotUpdate { + private readonly logger = new Logger(BotUpdate.name); + + // Track last shown quote per user for /favorite command + private lastQuote: Map = new Map(); + + constructor( + private readonly quotesService: QuotesService, + private readonly userService: UserService + ) {} + + private formatHelp(): string { + return `✨ Zitare Bot + +Deine tägliche Dosis Inspiration mit deutschen Zitaten. + +Zitate: +/quote oder /zitat - Zufälliges Zitat +/search [Begriff] - Zitate suchen +/author [Name] - Zitate eines Autors + +Favoriten: +/favorite - Aktuelles Zitat speichern +/favorites - Deine Favoriten anzeigen +/removefav [Nr] - Favorit entfernen + +Täglich: +/daily - Tägliches Zitat an/aus + +Tipp: Starte mit /quote für ein erstes Zitat!`; + } + + @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); + + this.logger.log(`/start from user ${userId} (@${username})`); + await ctx.replyWithHTML(this.formatHelp()); + } + + @Help() + async help(@Ctx() ctx: Context) { + await ctx.replyWithHTML(this.formatHelp()); + } + + @Command('quote') + async quote(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId) return; + + await this.userService.ensureUser(userId, ctx.from?.username); + + const quote = this.quotesService.getRandomQuote(); + this.lastQuote.set(userId, quote.id); + + const formatted = this.quotesService.formatQuote(quote); + await ctx.reply(formatted); + } + + @Command('zitat') + async zitat(@Ctx() ctx: Context) { + await this.quote(ctx); + } + + @Command('search') + async search(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + if (!userId) return; + + const term = text.replace('/search', '').trim(); + if (!term) { + await ctx.reply('Verwendung: /search [Begriff]\n\nBeispiel: /search Leben'); + return; + } + + const results = this.quotesService.search(term); + + if (results.length === 0) { + await ctx.reply(`Keine Zitate gefunden für "${term}".`); + return; + } + + let response = `🔍 Suchergebnisse für "${term}":\n\n`; + results.forEach((quote, index) => { + response += `${index + 1}. „${quote.text}"\n— ${quote.author.name}\n\n`; + }); + + // Store last quote for /favorite + if (results.length > 0) { + this.lastQuote.set(userId, results[0].id); + } + + await ctx.replyWithHTML(response); + } + + @Command('author') + async author(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + if (!userId) return; + + const authorName = text.replace('/author', '').trim(); + if (!authorName) { + await ctx.reply('Verwendung: /author [Name]\n\nBeispiel: /author Einstein'); + return; + } + + const results = this.quotesService.getByAuthor(authorName); + + if (results.length === 0) { + await ctx.reply(`Keine Zitate gefunden von "${authorName}".`); + return; + } + + let response = `📚 Zitate von ${results[0].author.name}:\n\n`; + results.forEach((quote, index) => { + response += `${index + 1}. „${quote.text}"\n\n`; + }); + + // Store last quote for /favorite + if (results.length > 0) { + this.lastQuote.set(userId, results[0].id); + } + + await ctx.replyWithHTML(response); + } + + @Command('favorite') + async favorite(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId) return; + + await this.userService.ensureUser(userId, ctx.from?.username); + + const lastQuoteId = this.lastQuote.get(userId); + if (!lastQuoteId) { + await ctx.reply('Kein aktuelles Zitat zum Speichern.\n\nHole dir erst ein Zitat mit /quote'); + return; + } + + const added = await this.userService.addFavorite(userId, lastQuoteId); + + if (added) { + await ctx.reply('⭐ Zitat zu Favoriten hinzugefügt!'); + } else { + await ctx.reply('ℹ️ Dieses Zitat ist bereits in deinen Favoriten.'); + } + } + + @Command('favorites') + async favorites(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId) return; + + await this.userService.ensureUser(userId, ctx.from?.username); + + const favoriteIds = await this.userService.getFavoriteQuoteIds(userId); + + if (favoriteIds.length === 0) { + await ctx.reply( + 'Du hast noch keine Favoriten.\n\nSpeichere Zitate mit /favorite nach /quote' + ); + return; + } + + const quotes = this.quotesService.getQuotesByIds(favoriteIds); + + let response = `⭐ Deine Favoriten (${quotes.length}):\n\n`; + quotes.forEach((quote, index) => { + response += `${index + 1}. „${quote.text}"\n— ${quote.author.name}\n\n`; + }); + + response += `\nEntfernen mit /removefav [Nr]`; + + await ctx.replyWithHTML(response); + } + + @Command('removefav') + async removeFavorite(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + if (!userId) return; + + const indexStr = text.replace('/removefav', '').trim(); + const index = parseInt(indexStr, 10); + + if (!indexStr || isNaN(index) || index < 1) { + await ctx.reply( + 'Verwendung: /removefav [Nr]\n\nZeige deine Favoriten mit /favorites um die Nummer zu sehen.' + ); + return; + } + + const favoriteIds = await this.userService.getFavoriteQuoteIds(userId); + + if (index > favoriteIds.length) { + await ctx.reply(`Ungültige Nummer. Du hast ${favoriteIds.length} Favoriten.`); + return; + } + + const quoteId = favoriteIds[index - 1]; + const removed = await this.userService.removeFavorite(userId, quoteId); + + if (removed) { + await ctx.reply(`✅ Favorit #${index} entfernt.`); + } else { + await ctx.reply('Fehler beim Entfernen.'); + } + } + + @Command('daily') + async daily(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId) return; + + await this.userService.ensureUser(userId, ctx.from?.username); + + const newState = await this.userService.toggleDaily(userId); + const settings = await this.userService.getDailySettings(userId); + + if (newState) { + await ctx.replyWithHTML( + `✅ Tägliches Zitat aktiviert!\n\n` + + `Du erhältst jeden Tag um ${settings?.time || '08:00'} Uhr ein inspirierendes Zitat.\n\n` + + `Mit /daily wieder deaktivieren.` + ); + } else { + await ctx.reply('❌ Tägliches Zitat deaktiviert.'); + } + } +} diff --git a/services/telegram-zitare-bot/src/config/configuration.ts b/services/telegram-zitare-bot/src/config/configuration.ts new file mode 100644 index 000000000..15b3918b3 --- /dev/null +++ b/services/telegram-zitare-bot/src/config/configuration.ts @@ -0,0 +1,9 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3303', 10), + telegram: { + token: process.env.TELEGRAM_BOT_TOKEN, + }, + database: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/zitare_bot', + }, +}); diff --git a/services/telegram-zitare-bot/src/database/database.module.ts b/services/telegram-zitare-bot/src/database/database.module.ts new file mode 100644 index 000000000..905674bf6 --- /dev/null +++ b/services/telegram-zitare-bot/src/database/database.module.ts @@ -0,0 +1,24 @@ +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('database.url'); + const client = postgres(connectionString!); + return drizzle(client, { schema }); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/services/telegram-zitare-bot/src/database/schema.ts b/services/telegram-zitare-bot/src/database/schema.ts new file mode 100644 index 000000000..afdef2a54 --- /dev/null +++ b/services/telegram-zitare-bot/src/database/schema.ts @@ -0,0 +1,44 @@ +import { pgTable, uuid, text, timestamp, bigint, boolean, unique } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// Telegram users +export const telegramUsers = pgTable('telegram_users', { + id: uuid('id').primaryKey().defaultRandom(), + telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull().unique(), + telegramUsername: text('telegram_username'), + dailyEnabled: boolean('daily_enabled').default(false).notNull(), + dailyTime: text('daily_time').default('08:00').notNull(), + timezone: text('timezone').default('Europe/Berlin').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// User favorites +export const userFavorites = pgTable( + 'user_favorites', + { + id: uuid('id').primaryKey().defaultRandom(), + telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull(), + quoteId: text('quote_id').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [unique().on(table.telegramUserId, table.quoteId)] +); + +// Relations +export const telegramUsersRelations = relations(telegramUsers, ({ many }) => ({ + favorites: many(userFavorites), +})); + +export const userFavoritesRelations = relations(userFavorites, ({ one }) => ({ + user: one(telegramUsers, { + fields: [userFavorites.telegramUserId], + references: [telegramUsers.telegramUserId], + }), +})); + +// Types +export type TelegramUser = typeof telegramUsers.$inferSelect; +export type NewTelegramUser = typeof telegramUsers.$inferInsert; +export type UserFavorite = typeof userFavorites.$inferSelect; +export type NewUserFavorite = typeof userFavorites.$inferInsert; diff --git a/services/telegram-zitare-bot/src/health.controller.ts b/services/telegram-zitare-bot/src/health.controller.ts new file mode 100644 index 000000000..74499387e --- /dev/null +++ b/services/telegram-zitare-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'telegram-zitare-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/telegram-zitare-bot/src/main.ts b/services/telegram-zitare-bot/src/main.ts new file mode 100644 index 000000000..d40da01a8 --- /dev/null +++ b/services/telegram-zitare-bot/src/main.ts @@ -0,0 +1,18 @@ +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('port') || 3303; + + await app.listen(port); + logger.log(`Telegram Zitare Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/services/telegram-zitare-bot/src/quotes/data/authors.json b/services/telegram-zitare-bot/src/quotes/data/authors.json new file mode 100644 index 000000000..e7bcc762b --- /dev/null +++ b/services/telegram-zitare-bot/src/quotes/data/authors.json @@ -0,0 +1,44 @@ +[ + { "id": "a001", "name": "John Lennon", "profession": ["Musiker", "Sänger"] }, + { "id": "a002", "name": "Steve Jobs", "profession": ["Unternehmer", "Visionär"] }, + { "id": "a003", "name": "Albert Einstein", "profession": ["Physiker", "Wissenschaftler"] }, + { "id": "a004", "name": "Mahatma Gandhi", "profession": ["Politiker", "Friedensaktivist"] }, + { + "id": "a005", + "name": "Johann Wolfgang von Goethe", + "profession": ["Dichter", "Schriftsteller"] + }, + { "id": "a006", "name": "Eleanor Roosevelt", "profession": ["Diplomatin", "Aktivistin"] }, + { "id": "a007", "name": "Seneca", "profession": ["Philosoph", "Schriftsteller"] }, + { "id": "a008", "name": "Bertolt Brecht", "profession": ["Dramatiker", "Dichter"] }, + { "id": "a009", "name": "Marc Aurel", "profession": ["Kaiser", "Philosoph"] }, + { "id": "a010", "name": "Pablo Picasso", "profession": ["Maler", "Künstler"] }, + { + "id": "a011", + "name": "Dalai Lama", + "profession": ["Geistlicher Führer", "Friedensnobelpreisträger"] + }, + { "id": "a012", "name": "Konfuzius", "profession": ["Philosoph", "Lehrer"] }, + { "id": "a013", "name": "Winston Churchill", "profession": ["Politiker", "Staatsmann"] }, + { "id": "a014", "name": "Chinesisches Sprichwort" }, + { "id": "a015", "name": "Hermann Hesse", "profession": ["Schriftsteller", "Dichter"] }, + { "id": "a016", "name": "Nelson Mandela", "profession": ["Politiker", "Aktivist"] }, + { "id": "a017", "name": "Henry Ford", "profession": ["Unternehmer", "Industrieller"] }, + { "id": "a018", "name": "Blaise Pascal", "profession": ["Mathematiker", "Philosoph"] }, + { "id": "a019", "name": "Aristoteles", "profession": ["Philosoph"] }, + { "id": "a020", "name": "Muhammad Ali", "profession": ["Boxer", "Aktivist"] }, + { "id": "a021", "name": "T.S. Eliot", "profession": ["Dichter", "Schriftsteller"] }, + { "id": "a022", "name": "André Gide", "profession": ["Schriftsteller"] }, + { "id": "a023", "name": "David Ben-Gurion", "profession": ["Politiker", "Staatsmann"] }, + { "id": "a024", "name": "Abraham Lincoln", "profession": ["Politiker", "Präsident"] }, + { "id": "a025", "name": "Arthur Schopenhauer", "profession": ["Philosoph"] }, + { "id": "a026", "name": "Laozi", "profession": ["Philosoph"] }, + { "id": "a027", "name": "Unbekannt" }, + { "id": "a028", "name": "Oscar Wilde", "profession": ["Schriftsteller", "Dichter"] }, + { "id": "a029", "name": "E. Joseph Cossman", "profession": ["Unternehmer"] }, + { "id": "a030", "name": "Ingmar Bergman", "profession": ["Regisseur", "Autor"] }, + { "id": "a031", "name": "Demokrit", "profession": ["Philosoph"] }, + { "id": "a032", "name": "Indisches Sprichwort" }, + { "id": "a033", "name": "Mark Twain", "profession": ["Schriftsteller"] }, + { "id": "a034", "name": "Deutsches Sprichwort" } +] diff --git a/services/telegram-zitare-bot/src/quotes/data/quotes.json b/services/telegram-zitare-bot/src/quotes/data/quotes.json new file mode 100644 index 000000000..7db065ea3 --- /dev/null +++ b/services/telegram-zitare-bot/src/quotes/data/quotes.json @@ -0,0 +1,166 @@ +[ + { + "id": "q001", + "text": "Das Leben ist das, was passiert, während du andere Pläne machst.", + "authorId": "a001" + }, + { + "id": "q002", + "text": "Der einzige Weg, großartige Arbeit zu leisten, ist zu lieben, was man tut.", + "authorId": "a002" + }, + { + "id": "q003", + "text": "In der Mitte der Schwierigkeit liegt die Möglichkeit.", + "authorId": "a003" + }, + { + "id": "q004", + "text": "Sei du selbst die Veränderung, die du dir wünschst für diese Welt.", + "authorId": "a004" + }, + { + "id": "q005", + "text": "Es ist nicht genug zu wissen – man muss auch anwenden. Es ist nicht genug zu wollen – man muss auch tun.", + "authorId": "a005" + }, + { + "id": "q006", + "text": "Die Zukunft gehört denen, die an die Schönheit ihrer Träume glauben.", + "authorId": "a006" + }, + { + "id": "q007", + "text": "Nicht weil es schwer ist, wagen wir es nicht, sondern weil wir es nicht wagen, ist es schwer.", + "authorId": "a007" + }, + { + "id": "q008", + "text": "Wer kämpft, kann verlieren. Wer nicht kämpft, hat schon verloren.", + "authorId": "a008" + }, + { + "id": "q009", + "text": "Das Glück deines Lebens hängt von der Beschaffenheit deiner Gedanken ab.", + "authorId": "a009" + }, + { "id": "q010", "text": "Alles, was du dir vorstellen kannst, ist real.", "authorId": "a010" }, + { + "id": "q011", + "text": "Es gibt nur zwei Tage im Jahr, an denen man nichts tun kann. Der eine ist Gestern, der andere Morgen.", + "authorId": "a011" + }, + { "id": "q012", "text": "Der Weg ist das Ziel.", "authorId": "a012" }, + { + "id": "q013", + "text": "Lerne aus der Vergangenheit, lebe in der Gegenwart, hoffe für die Zukunft.", + "authorId": "a003" + }, + { + "id": "q014", + "text": "Erfolg ist nicht endgültig, Misserfolg ist nicht fatal: Es ist der Mut weiterzumachen, der zählt.", + "authorId": "a013" + }, + { + "id": "q015", + "text": "Die beste Zeit, einen Baum zu pflanzen, war vor zwanzig Jahren. Die zweitbeste Zeit ist jetzt.", + "authorId": "a014" + }, + { + "id": "q016", + "text": "Hab keine Angst, langsam zu gehen. Hab nur Angst, stehen zu bleiben.", + "authorId": "a014" + }, + { + "id": "q017", + "text": "Man muss das Unmögliche versuchen, um das Mögliche zu erreichen.", + "authorId": "a015" + }, + { + "id": "q018", + "text": "Die größte Ehre im Leben liegt nicht darin, niemals zu fallen, sondern jedes Mal wieder aufzustehen.", + "authorId": "a016" + }, + { + "id": "q019", + "text": "Wer immer tut, was er schon kann, bleibt immer das, was er schon ist.", + "authorId": "a017" + }, + { + "id": "q020", + "text": "Phantasie ist wichtiger als Wissen, denn Wissen ist begrenzt.", + "authorId": "a003" + }, + { + "id": "q021", + "text": "Es ist nicht wichtig, wie langsam du gehst, solange du nicht stehen bleibst.", + "authorId": "a012" + }, + { + "id": "q022", + "text": "Der Mensch, der den Berg versetzte, war derselbe, der anfing, kleine Steine wegzutragen.", + "authorId": "a012" + }, + { + "id": "q023", + "text": "Wenn der Wind der Veränderung weht, bauen die einen Mauern und die anderen Windmühlen.", + "authorId": "a014" + }, + { + "id": "q024", + "text": "Ein Tropfen Liebe ist mehr als ein Ozean Verstand.", + "authorId": "a018" + }, + { "id": "q025", "text": "Glück ist kein Zufall, sondern eine Entscheidung.", "authorId": "a019" }, + { + "id": "q026", + "text": "Die Kraft liegt nicht im Körper, sondern im Willen.", + "authorId": "a020" + }, + { "id": "q027", "text": "Jeder Tag ist ein neuer Anfang.", "authorId": "a021" }, + { + "id": "q028", + "text": "Das Geheimnis des Glücks liegt nicht im Besitz, sondern im Geben.", + "authorId": "a022" + }, + { "id": "q029", "text": "Wer nicht an Wunder glaubt, ist kein Realist.", "authorId": "a023" }, + { + "id": "q030", + "text": "Es sind nicht die Jahre in deinem Leben, die zählen. Es ist das Leben in deinen Jahren.", + "authorId": "a024" + }, + { + "id": "q031", + "text": "Das Schicksal mischt die Karten, aber wir spielen.", + "authorId": "a025" + }, + { "id": "q032", "text": "Nur wer sein Ziel kennt, findet den Weg.", "authorId": "a026" }, + { "id": "q033", "text": "Das Leben ist zu kurz für später.", "authorId": "a027" }, + { + "id": "q034", + "text": "Wer nach den Sternen greift, wird nicht im Schlamm stecken bleiben.", + "authorId": "a028" + }, + { + "id": "q035", + "text": "Die beste Brücke zwischen Verzweiflung und Hoffnung ist eine gute Nachtruhe.", + "authorId": "a029" + }, + { + "id": "q036", + "text": "Es gibt keine Grenzen. Weder für Gedanken noch für Gefühle. Es ist die Angst, die immer Grenzen setzt.", + "authorId": "a030" + }, + { "id": "q037", "text": "Mut steht am Anfang des Handelns, Glück am Ende.", "authorId": "a031" }, + { + "id": "q038", + "text": "Das Lächeln, das du aussendest, kehrt zu dir zurück.", + "authorId": "a032" + }, + { + "id": "q039", + "text": "Gib jedem Tag die Chance, der schönste deines Lebens zu werden.", + "authorId": "a033" + }, + { "id": "q040", "text": "Wer wagt, gewinnt.", "authorId": "a034" } +] diff --git a/services/telegram-zitare-bot/src/quotes/quotes.module.ts b/services/telegram-zitare-bot/src/quotes/quotes.module.ts new file mode 100644 index 000000000..8174627bd --- /dev/null +++ b/services/telegram-zitare-bot/src/quotes/quotes.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { QuotesService } from './quotes.service'; + +@Module({ + providers: [QuotesService], + exports: [QuotesService], +}) +export class QuotesModule {} diff --git a/services/telegram-zitare-bot/src/quotes/quotes.service.ts b/services/telegram-zitare-bot/src/quotes/quotes.service.ts new file mode 100644 index 000000000..f6663f62d --- /dev/null +++ b/services/telegram-zitare-bot/src/quotes/quotes.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Quote, Author, QuoteWithAuthor } from './types'; +import quotesJson from './data/quotes.json'; +import authorsJson from './data/authors.json'; + +@Injectable() +export class QuotesService { + private readonly logger = new Logger(QuotesService.name); + private readonly quotes: Quote[]; + private readonly authors: Map; + + constructor() { + this.quotes = quotesJson as Quote[]; + this.authors = new Map((authorsJson as Author[]).map((a) => [a.id, a])); + this.logger.log(`Loaded ${this.quotes.length} quotes and ${this.authors.size} authors`); + } + + private getAuthor(authorId: string): Author { + return this.authors.get(authorId) || { id: authorId, name: 'Unbekannt' }; + } + + private toQuoteWithAuthor(quote: Quote): QuoteWithAuthor { + return { + id: quote.id, + text: quote.text, + author: this.getAuthor(quote.authorId), + }; + } + + getRandomQuote(): QuoteWithAuthor { + const index = Math.floor(Math.random() * this.quotes.length); + return this.toQuoteWithAuthor(this.quotes[index]); + } + + getQuoteById(id: string): QuoteWithAuthor | null { + const quote = this.quotes.find((q) => q.id === id); + return quote ? this.toQuoteWithAuthor(quote) : null; + } + + getQuotesByIds(ids: string[]): QuoteWithAuthor[] { + return ids.map((id) => this.getQuoteById(id)).filter((q): q is QuoteWithAuthor => q !== null); + } + + search(term: string, limit = 5): QuoteWithAuthor[] { + const lowerTerm = term.toLowerCase(); + const results: QuoteWithAuthor[] = []; + + for (const quote of this.quotes) { + if (results.length >= limit) break; + + const author = this.getAuthor(quote.authorId); + if ( + quote.text.toLowerCase().includes(lowerTerm) || + author.name.toLowerCase().includes(lowerTerm) + ) { + results.push(this.toQuoteWithAuthor(quote)); + } + } + + return results; + } + + getByAuthor(authorName: string, limit = 5): QuoteWithAuthor[] { + const lowerName = authorName.toLowerCase(); + const results: QuoteWithAuthor[] = []; + + // Find matching author(s) + const matchingAuthorIds: string[] = []; + for (const author of this.authors.values()) { + if (author.name.toLowerCase().includes(lowerName)) { + matchingAuthorIds.push(author.id); + } + } + + if (matchingAuthorIds.length === 0) { + return []; + } + + // Get quotes from matching authors + for (const quote of this.quotes) { + if (results.length >= limit) break; + + if (matchingAuthorIds.includes(quote.authorId)) { + results.push(this.toQuoteWithAuthor(quote)); + } + } + + return results; + } + + getAllAuthors(): Author[] { + return Array.from(this.authors.values()); + } + + getTotalCount(): number { + return this.quotes.length; + } + + formatQuote(quote: QuoteWithAuthor): string { + const profession = + quote.author.profession && quote.author.profession.length > 0 + ? ` (${quote.author.profession.join(', ')})` + : ''; + return `„${quote.text}"\n\n— ${quote.author.name}${profession}`; + } +} diff --git a/services/telegram-zitare-bot/src/quotes/types.ts b/services/telegram-zitare-bot/src/quotes/types.ts new file mode 100644 index 000000000..fbdb1a455 --- /dev/null +++ b/services/telegram-zitare-bot/src/quotes/types.ts @@ -0,0 +1,17 @@ +export interface Quote { + id: string; + text: string; + authorId: string; +} + +export interface Author { + id: string; + name: string; + profession?: string[]; +} + +export interface QuoteWithAuthor { + id: string; + text: string; + author: Author; +} diff --git a/services/telegram-zitare-bot/src/scheduler/daily.scheduler.ts b/services/telegram-zitare-bot/src/scheduler/daily.scheduler.ts new file mode 100644 index 000000000..5a6a0439a --- /dev/null +++ b/services/telegram-zitare-bot/src/scheduler/daily.scheduler.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectBot } from 'nestjs-telegraf'; +import { Telegraf, Context } from 'telegraf'; +import { QuotesService } from '../quotes/quotes.service'; +import { UserService } from '../user/user.service'; + +@Injectable() +export class DailyScheduler { + private readonly logger = new Logger(DailyScheduler.name); + + constructor( + @InjectBot() private readonly bot: Telegraf, + private readonly quotesService: QuotesService, + private readonly userService: UserService + ) {} + + // Run every day at 8:00 AM Europe/Berlin + @Cron('0 8 * * *', { + timeZone: 'Europe/Berlin', + }) + async sendDailyQuotes() { + this.logger.log('Starting daily quote distribution...'); + + try { + const users = await this.userService.getUsersWithDailyEnabled(); + this.logger.log(`Found ${users.length} users with daily enabled`); + + let sent = 0; + let failed = 0; + + for (const user of users) { + try { + const quote = this.quotesService.getRandomQuote(); + const message = + `☀️ Dein tägliches Zitat:\n\n` + this.quotesService.formatQuote(quote); + + await this.bot.telegram.sendMessage(user.telegramUserId, message, { + parse_mode: 'HTML', + }); + + sent++; + this.logger.debug(`Sent daily quote to user ${user.telegramUserId}`); + } catch (error) { + failed++; + this.logger.warn(`Failed to send daily quote to user ${user.telegramUserId}: ${error}`); + + // If user blocked the bot, disable daily + if ((error as { response?: { error_code?: number } }).response?.error_code === 403) { + this.logger.log(`User ${user.telegramUserId} blocked bot, disabling daily`); + await this.userService.toggleDaily(user.telegramUserId); + } + } + } + + this.logger.log(`Daily quote distribution complete: ${sent} sent, ${failed} failed`); + } catch (error) { + this.logger.error('Daily quote distribution failed:', error); + } + } +} diff --git a/services/telegram-zitare-bot/src/scheduler/scheduler.module.ts b/services/telegram-zitare-bot/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..28241407b --- /dev/null +++ b/services/telegram-zitare-bot/src/scheduler/scheduler.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { DailyScheduler } from './daily.scheduler'; +import { QuotesModule } from '../quotes/quotes.module'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), QuotesModule, UserModule], + providers: [DailyScheduler], +}) +export class SchedulerModule {} diff --git a/services/telegram-zitare-bot/src/user/user.module.ts b/services/telegram-zitare-bot/src/user/user.module.ts new file mode 100644 index 000000000..ab6ead25c --- /dev/null +++ b/services/telegram-zitare-bot/src/user/user.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './user.service'; + +@Module({ + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/services/telegram-zitare-bot/src/user/user.service.ts b/services/telegram-zitare-bot/src/user/user.service.ts new file mode 100644 index 000000000..29efe8157 --- /dev/null +++ b/services/telegram-zitare-bot/src/user/user.service.ts @@ -0,0 +1,146 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import * as schema from '../database/schema'; +import { TelegramUser, UserFavorite } from '../database/schema'; + +@Injectable() +export class UserService { + private readonly logger = new Logger(UserService.name); + + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase + ) {} + + async ensureUser(telegramUserId: number, username?: string): Promise { + // Try to find existing user + const existing = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (existing) { + // Update username if changed + if (username && existing.telegramUsername !== username) { + await this.db + .update(schema.telegramUsers) + .set({ telegramUsername: username, updatedAt: new Date() }) + .where(eq(schema.telegramUsers.id, existing.id)); + } + return existing; + } + + // Create new user + const [newUser] = await this.db + .insert(schema.telegramUsers) + .values({ + telegramUserId, + telegramUsername: username, + }) + .returning(); + + this.logger.log(`Created new user: ${telegramUserId} (@${username})`); + return newUser; + } + + async addFavorite(telegramUserId: number, quoteId: string): Promise { + try { + await this.db.insert(schema.userFavorites).values({ + telegramUserId, + quoteId, + }); + return true; + } catch (error) { + // Unique constraint violation = already favorited + if ((error as { code?: string }).code === '23505') { + return false; + } + throw error; + } + } + + async removeFavorite(telegramUserId: number, quoteId: string): Promise { + const result = await this.db + .delete(schema.userFavorites) + .where( + and( + eq(schema.userFavorites.telegramUserId, telegramUserId), + eq(schema.userFavorites.quoteId, quoteId) + ) + ) + .returning(); + + return result.length > 0; + } + + async getFavorites(telegramUserId: number): Promise { + return this.db.query.userFavorites.findMany({ + where: eq(schema.userFavorites.telegramUserId, telegramUserId), + orderBy: (favorites, { desc }) => [desc(favorites.createdAt)], + }); + } + + async getFavoriteQuoteIds(telegramUserId: number): Promise { + const favorites = await this.getFavorites(telegramUserId); + return favorites.map((f) => f.quoteId); + } + + async isFavorite(telegramUserId: number, quoteId: string): Promise { + const favorite = await this.db.query.userFavorites.findFirst({ + where: and( + eq(schema.userFavorites.telegramUserId, telegramUserId), + eq(schema.userFavorites.quoteId, quoteId) + ), + }); + return !!favorite; + } + + async toggleDaily(telegramUserId: number): Promise { + const user = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (!user) { + return false; + } + + const newValue = !user.dailyEnabled; + await this.db + .update(schema.telegramUsers) + .set({ dailyEnabled: newValue, updatedAt: new Date() }) + .where(eq(schema.telegramUsers.id, user.id)); + + return newValue; + } + + async setDailyTime(telegramUserId: number, time: string): Promise { + await this.db + .update(schema.telegramUsers) + .set({ dailyTime: time, updatedAt: new Date() }) + .where(eq(schema.telegramUsers.telegramUserId, telegramUserId)); + } + + async getUsersWithDailyEnabled(): Promise { + return this.db.query.telegramUsers.findMany({ + where: eq(schema.telegramUsers.dailyEnabled, true), + }); + } + + async getDailySettings( + telegramUserId: number + ): Promise<{ enabled: boolean; time: string } | null> { + const user = await this.db.query.telegramUsers.findFirst({ + where: eq(schema.telegramUsers.telegramUserId, telegramUserId), + }); + + if (!user) { + return null; + } + + return { + enabled: user.dailyEnabled, + time: user.dailyTime, + }; + } +} diff --git a/services/telegram-zitare-bot/tsconfig.json b/services/telegram-zitare-bot/tsconfig.json new file mode 100644 index 000000000..c705ffcb3 --- /dev/null +++ b/services/telegram-zitare-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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, + "resolveJsonModule": true + } +}