mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
7f3842b63c
commit
8e6adfdb10
66 changed files with 4390 additions and 0 deletions
12
services/telegram-nutriphi-bot/.env.example
Normal file
12
services/telegram-nutriphi-bot/.env.example
Normal file
|
|
@ -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
|
||||
294
services/telegram-nutriphi-bot/CLAUDE.md
Normal file
294
services/telegram-nutriphi-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
11
services/telegram-nutriphi-bot/drizzle.config.ts
Normal file
11
services/telegram-nutriphi-bot/drizzle.config.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
8
services/telegram-nutriphi-bot/nest-cli.json
Normal file
8
services/telegram-nutriphi-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
42
services/telegram-nutriphi-bot/package.json
Normal file
42
services/telegram-nutriphi-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class AnalysisModule {}
|
||||
175
services/telegram-nutriphi-bot/src/analysis/gemini.service.ts
Normal file
175
services/telegram-nutriphi-bot/src/analysis/gemini.service.ts
Normal file
|
|
@ -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<string>('gemini.apiKey');
|
||||
if (apiKey) {
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
|
||||
this.logger.log('Gemini service initialized');
|
||||
} else {
|
||||
this.logger.warn('Gemini API key not configured');
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.model !== null;
|
||||
}
|
||||
|
||||
async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise<AnalysisResult> {
|
||||
if (!this.model) {
|
||||
throw new Error('Gemini API nicht konfiguriert');
|
||||
}
|
||||
|
||||
this.logger.log('Analyzing image...');
|
||||
|
||||
const result = await this.model.generateContent([
|
||||
PHOTO_ANALYSIS_PROMPT,
|
||||
{
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const response = result.response;
|
||||
const text = response.text();
|
||||
|
||||
return this.parseResponse(text);
|
||||
}
|
||||
|
||||
async analyzeText(description: string): Promise<AnalysisResult> {
|
||||
if (!this.model) {
|
||||
throw new Error('Gemini API nicht konfiguriert');
|
||||
}
|
||||
|
||||
this.logger.log(`Analyzing text: ${description.substring(0, 50)}...`);
|
||||
|
||||
const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description);
|
||||
const result = await this.model.generateContent(prompt);
|
||||
|
||||
const response = result.response;
|
||||
const text = response.text();
|
||||
|
||||
return this.parseResponse(text);
|
||||
}
|
||||
|
||||
private parseResponse(text: string): AnalysisResult {
|
||||
// Extract JSON from response (handle markdown code blocks)
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
this.logger.error('Failed to parse response:', text);
|
||||
throw new Error('Konnte Antwort nicht parsen');
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0]) as AnalysisResult;
|
||||
} catch (error) {
|
||||
this.logger.error('JSON parse error:', error);
|
||||
throw new Error('Ungültiges JSON in Antwort');
|
||||
}
|
||||
}
|
||||
}
|
||||
27
services/telegram-nutriphi-bot/src/app.module.ts
Normal file
27
services/telegram-nutriphi-bot/src/app.module.ts
Normal file
|
|
@ -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<string>('telegram.token') || '',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
DatabaseModule,
|
||||
BotModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
12
services/telegram-nutriphi-bot/src/bot/bot.module.ts
Normal file
12
services/telegram-nutriphi-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
513
services/telegram-nutriphi-bot/src/bot/bot.update.ts
Normal file
513
services/telegram-nutriphi-bot/src/bot/bot.update.ts
Normal file
|
|
@ -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<number, Meal> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly geminiService: GeminiService,
|
||||
private readonly mealsService: MealsService,
|
||||
private readonly goalsService: GoalsService,
|
||||
private readonly statsService: StatsService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
|
||||
const token = this.configService.get<string>('telegram.token');
|
||||
this.telegramApiUrl = `https://api.telegram.org/bot${token}`;
|
||||
}
|
||||
|
||||
private isAllowed(userId: number): boolean {
|
||||
if (this.allowedUsers.length === 0) return true;
|
||||
return this.allowedUsers.includes(userId);
|
||||
}
|
||||
|
||||
private formatHelp(): string {
|
||||
return `<b>🥗 NutriPhi Bot</b>
|
||||
|
||||
Dein KI-gestützter Ernährungs-Tracker.
|
||||
|
||||
<b>Mahlzeit erfassen:</b>
|
||||
📷 Foto senden - Automatische Analyse
|
||||
💬 Text senden - z.B. "Spaghetti Bolognese"
|
||||
|
||||
<b>Übersicht:</b>
|
||||
/heute - Heutige Mahlzeiten & Fortschritt
|
||||
/woche - Wochenstatistik
|
||||
|
||||
<b>Ziele:</b>
|
||||
/ziele - Aktuelle Ziele anzeigen
|
||||
/ziele [kcal] [P] [K] [F] - Ziele setzen
|
||||
Beispiel: /ziele 2000 100 200 70
|
||||
|
||||
<b>Favoriten:</b>
|
||||
/favorit [Name] - Letzte Mahlzeit speichern
|
||||
/favoriten - Gespeicherte Mahlzeiten anzeigen
|
||||
/essen [Nr] - Favorit als Mahlzeit eintragen
|
||||
/delfav [Nr] - Favorit löschen
|
||||
|
||||
<b>Sonstiges:</b>
|
||||
/loeschen - Letzte Mahlzeit löschen
|
||||
/hilfe - Diese Hilfe anzeigen
|
||||
|
||||
<b>Tipp:</b> Starte mit einem Foto deiner Mahlzeit!`;
|
||||
}
|
||||
|
||||
@Start()
|
||||
async start(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure user has goals
|
||||
await this.goalsService.ensureGoals(userId);
|
||||
|
||||
this.logger.log(`/start from user ${userId}`);
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Help()
|
||||
async help(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Command('hilfe')
|
||||
async hilfe(@Ctx() ctx: Context) {
|
||||
await this.help(ctx);
|
||||
}
|
||||
|
||||
@Command('heute')
|
||||
async today(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await this.statsService.getDailySummary(userId);
|
||||
|
||||
if (summary.meals.length === 0) {
|
||||
await ctx.reply(
|
||||
'📭 Noch keine Mahlzeiten heute.\n\nSende ein Foto oder beschreibe deine Mahlzeit!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format meals list
|
||||
const mealsList = summary.meals
|
||||
.map((m, i) => {
|
||||
const type = MEAL_TYPES[m.mealType as MealType] || m.mealType;
|
||||
const time = new Date(m.createdAt).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${i + 1}. <b>${type}</b> (${time})\n ${m.description}\n ${m.calories} kcal`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
// Format totals and progress
|
||||
let response =
|
||||
`<b>📊 Heute (${new Date().toLocaleDateString('de-DE')})</b>\n\n` +
|
||||
`${mealsList}\n\n` +
|
||||
`<b>─────────────────</b>\n` +
|
||||
`<b>Gesamt:</b> ${summary.totals.calories} kcal\n\n`;
|
||||
|
||||
if (summary.goals) {
|
||||
response +=
|
||||
`<b>Fortschritt:</b>\n` +
|
||||
`Kalorien: ${StatsService.formatProgressBar(summary.progress.calories)}\n` +
|
||||
`Protein: ${StatsService.formatProgressBar(summary.progress.protein)}\n` +
|
||||
`Kohlenhydr.: ${StatsService.formatProgressBar(summary.progress.carbohydrates)}\n` +
|
||||
`Fett: ${StatsService.formatProgressBar(summary.progress.fat)}\n\n` +
|
||||
`<b>Verbleibend:</b> ${Math.max(0, summary.goals.dailyCalories - summary.totals.calories)} kcal`;
|
||||
}
|
||||
|
||||
await ctx.replyWithHTML(response);
|
||||
}
|
||||
|
||||
@Command('woche')
|
||||
async week(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await this.statsService.getWeeklySummary(userId);
|
||||
|
||||
if (summary.totalMeals === 0) {
|
||||
await ctx.reply('📭 Keine Mahlzeiten in den letzten 7 Tagen.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format days chart
|
||||
const maxCal = Math.max(...summary.days.map((d) => d.calories), 1);
|
||||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
const chart = summary.days
|
||||
.map((d) => {
|
||||
const date = new Date(d.date);
|
||||
const dayName = dayNames[date.getDay()];
|
||||
const barLen = Math.round((d.calories / maxCal) * 8);
|
||||
const bar = '█'.repeat(barLen) + '░'.repeat(8 - barLen);
|
||||
return `${dayName} ${bar} ${d.calories}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const response =
|
||||
`<b>📈 Wochenübersicht</b>\n\n` +
|
||||
`<code>${chart}</code>\n\n` +
|
||||
`<b>Durchschnitt:</b>\n` +
|
||||
`Kalorien: ${summary.averages.calories} kcal\n` +
|
||||
`Protein: ${summary.averages.protein}g\n` +
|
||||
`Kohlenhydrate: ${summary.averages.carbohydrates}g\n` +
|
||||
`Fett: ${summary.averages.fat}g\n\n` +
|
||||
`<b>Gesamt:</b> ${summary.totalMeals} Mahlzeiten`;
|
||||
|
||||
await ctx.replyWithHTML(response);
|
||||
}
|
||||
|
||||
@Command('ziele')
|
||||
async goals(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const args = text.replace('/ziele', '').trim();
|
||||
|
||||
// If no args, show current goals
|
||||
if (!args) {
|
||||
const goals = await this.goalsService.ensureGoals(userId);
|
||||
await ctx.replyWithHTML(
|
||||
`<b>🎯 Deine Tagesziele</b>\n\n` +
|
||||
`Kalorien: ${goals.dailyCalories} kcal\n` +
|
||||
`Protein: ${goals.dailyProtein}g\n` +
|
||||
`Kohlenhydrate: ${goals.dailyCarbs}g\n` +
|
||||
`Fett: ${goals.dailyFat}g\n` +
|
||||
`Ballaststoffe: ${goals.dailyFiber}g\n\n` +
|
||||
`<b>Ändern:</b>\n/ziele [kcal] [Protein] [Kohlenhydrate] [Fett]\nBeispiel: /ziele 2000 100 200 70`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse new goals
|
||||
const parts = args.split(/\s+/).map((n) => parseInt(n, 10));
|
||||
if (parts.length < 4 || parts.some(isNaN)) {
|
||||
await ctx.reply(
|
||||
'Verwendung: /ziele [kcal] [Protein] [Kohlenhydrate] [Fett]\n\n' +
|
||||
'Beispiel: /ziele 2000 100 200 70'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [calories, protein, carbs, fat] = parts;
|
||||
const fiber = parts[4] || 30; // Optional 5th parameter
|
||||
|
||||
await this.goalsService.setGoals(userId, {
|
||||
dailyCalories: calories,
|
||||
dailyProtein: protein,
|
||||
dailyCarbs: carbs,
|
||||
dailyFat: fat,
|
||||
dailyFiber: fiber,
|
||||
});
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`✅ <b>Ziele aktualisiert!</b>\n\n` +
|
||||
`Kalorien: ${calories} kcal\n` +
|
||||
`Protein: ${protein}g\n` +
|
||||
`Kohlenhydrate: ${carbs}g\n` +
|
||||
`Fett: ${fat}g\n` +
|
||||
`Ballaststoffe: ${fiber}g`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('favorit')
|
||||
async saveFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = text.replace('/favorit', '').trim();
|
||||
if (!name) {
|
||||
await ctx.reply('Verwendung: /favorit [Name]\n\nBeispiel: /favorit Morgenmüsli');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMeal = this.lastMeal.get(userId);
|
||||
if (!lastMeal) {
|
||||
await ctx.reply('Keine aktuelle Mahlzeit zum Speichern.\n\nErfasse erst eine Mahlzeit.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mealsService.saveAsFavorite(userId, lastMeal, name);
|
||||
await ctx.reply(`⭐ "${name}" als Favorit gespeichert!`);
|
||||
}
|
||||
|
||||
@Command('favoriten')
|
||||
async listFavorites(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const favorites = await this.mealsService.getFavorites(userId);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
await ctx.reply(
|
||||
'Keine Favoriten gespeichert.\n\n' + 'Speichere eine Mahlzeit mit /favorit [Name]'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = favorites
|
||||
.map((f, i) => {
|
||||
const nutrition = f.nutrition as NutritionData;
|
||||
return `<b>${i + 1}.</b> ${f.name}\n ${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>⭐ Deine Favoriten</b>\n\n${list}\n\n` + `Verwenden: /essen [Nr]\nLöschen: /delfav [Nr]`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('essen')
|
||||
async useFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const indexStr = text.replace('/essen', '').trim();
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (!indexStr || isNaN(index)) {
|
||||
await ctx.reply('Verwendung: /essen [Nr]\n\nZeige Favoriten mit /favoriten');
|
||||
return;
|
||||
}
|
||||
|
||||
const favorite = await this.mealsService.getFavoriteByIndex(userId, index);
|
||||
if (!favorite) {
|
||||
await ctx.reply(`Favorit #${index} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const meal = await this.mealsService.createFromFavorite(userId, favorite);
|
||||
this.lastMeal.set(userId, meal);
|
||||
|
||||
const nutrition = favorite.nutrition as NutritionData;
|
||||
await ctx.replyWithHTML(
|
||||
`✅ <b>${favorite.name}</b> eingetragen!\n\n` +
|
||||
`${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F\n\n` +
|
||||
`Übersicht: /heute`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('delfav')
|
||||
async deleteFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const indexStr = text.replace('/delfav', '').trim();
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (!indexStr || isNaN(index)) {
|
||||
await ctx.reply('Verwendung: /delfav [Nr]\n\nZeige Favoriten mit /favoriten');
|
||||
return;
|
||||
}
|
||||
|
||||
const favorite = await this.mealsService.getFavoriteByIndex(userId, index);
|
||||
if (!favorite) {
|
||||
await ctx.reply(`Favorit #${index} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mealsService.deleteFavorite(favorite.id);
|
||||
await ctx.reply(`✅ "${favorite.name}" gelöscht.`);
|
||||
}
|
||||
|
||||
@Command('loeschen')
|
||||
async deleteLastMeal(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await this.mealsService.deleteLastMeal(userId);
|
||||
if (deleted) {
|
||||
this.lastMeal.delete(userId);
|
||||
await ctx.reply('✅ Letzte Mahlzeit gelöscht.');
|
||||
} else {
|
||||
await ctx.reply('Keine Mahlzeit zum Löschen gefunden.');
|
||||
}
|
||||
}
|
||||
|
||||
@On('photo')
|
||||
async onPhoto(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
await ctx.reply('❌ Analyse nicht verfügbar (API nicht konfiguriert).');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = ctx.message as { photo?: PhotoSize[]; caption?: string };
|
||||
const photos = message.photo;
|
||||
if (!photos || photos.length === 0) return;
|
||||
|
||||
// Get largest photo
|
||||
const photo = photos[photos.length - 1];
|
||||
|
||||
await ctx.reply('🔍 Analysiere Mahlzeit...');
|
||||
await ctx.sendChatAction('typing');
|
||||
|
||||
try {
|
||||
// Download photo from Telegram
|
||||
const imageBase64 = await this.downloadTelegramFile(photo.file_id);
|
||||
|
||||
// Analyze with Gemini
|
||||
const analysis = await this.geminiService.analyzeImage(imageBase64);
|
||||
|
||||
// Save meal
|
||||
const meal = await this.mealsService.createFromAnalysis(userId, 'photo', analysis);
|
||||
this.lastMeal.set(userId, meal);
|
||||
|
||||
// Format response
|
||||
const foodsList = analysis.foods.map((f) => `• ${f.name} (${f.quantity})`).join('\n');
|
||||
|
||||
const n = analysis.totalNutrition;
|
||||
const confidence = Math.round(analysis.confidence * 100);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>🍽️ ${analysis.description}</b>\n\n` +
|
||||
`<b>Erkannt:</b>\n${foodsList}\n\n` +
|
||||
`<b>Nährwerte:</b>\n` +
|
||||
`Kalorien: ${n.calories} kcal\n` +
|
||||
`Protein: ${n.protein}g\n` +
|
||||
`Kohlenhydrate: ${n.carbohydrates}g\n` +
|
||||
`Fett: ${n.fat}g\n` +
|
||||
`Ballaststoffe: ${n.fiber}g\n` +
|
||||
`Zucker: ${n.sugar}g\n\n` +
|
||||
`<i>Genauigkeit: ${confidence}%</i>\n\n` +
|
||||
`Als Favorit speichern: /favorit [Name]`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Photo analysis failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
await ctx.reply(`❌ Analyse fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@On('text')
|
||||
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore commands
|
||||
if (text.startsWith('/')) return;
|
||||
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
await ctx.reply('❌ Analyse nicht verfügbar (API nicht konfiguriert).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Analyze text as meal description
|
||||
await ctx.reply('🔍 Analysiere...');
|
||||
await ctx.sendChatAction('typing');
|
||||
|
||||
try {
|
||||
const analysis = await this.geminiService.analyzeText(text);
|
||||
|
||||
// Save meal
|
||||
const meal = await this.mealsService.createFromAnalysis(userId, 'text', analysis);
|
||||
this.lastMeal.set(userId, meal);
|
||||
|
||||
// Format response
|
||||
const n = analysis.totalNutrition;
|
||||
const confidence = Math.round(analysis.confidence * 100);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>✅ ${analysis.description}</b>\n\n` +
|
||||
`<b>Nährwerte:</b>\n` +
|
||||
`Kalorien: ${n.calories} kcal\n` +
|
||||
`Protein: ${n.protein}g\n` +
|
||||
`Kohlenhydrate: ${n.carbohydrates}g\n` +
|
||||
`Fett: ${n.fat}g\n` +
|
||||
`Ballaststoffe: ${n.fiber}g\n` +
|
||||
`Zucker: ${n.sugar}g\n\n` +
|
||||
`<i>Genauigkeit: ${confidence}%</i>\n\n` +
|
||||
`Als Favorit speichern: /favorit [Name]`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Text analysis failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
await ctx.reply(`❌ Analyse fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Download file from Telegram and return Base64
|
||||
private async downloadTelegramFile(fileId: string): Promise<string> {
|
||||
// Get file path
|
||||
const fileResponse = await fetch(`${this.telegramApiUrl}/getFile?file_id=${fileId}`);
|
||||
const fileData = await fileResponse.json();
|
||||
|
||||
if (!fileData.ok) {
|
||||
throw new Error(`Telegram API error: ${fileData.description}`);
|
||||
}
|
||||
|
||||
// Download file
|
||||
const token = this.configService.get<string>('telegram.token');
|
||||
const fileUrl = `https://api.telegram.org/file/bot${token}/${fileData.result.file_path}`;
|
||||
|
||||
const response = await fetch(fileUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
}
|
||||
35
services/telegram-nutriphi-bot/src/config/configuration.ts
Normal file
35
services/telegram-nutriphi-bot/src/config/configuration.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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<string>('database.url');
|
||||
const client = postgres(connectionString!);
|
||||
return drizzle(client, { schema });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
93
services/telegram-nutriphi-bot/src/database/schema.ts
Normal file
93
services/telegram-nutriphi-bot/src/database/schema.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
8
services/telegram-nutriphi-bot/src/goals/goals.module.ts
Normal file
8
services/telegram-nutriphi-bot/src/goals/goals.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GoalsService } from './goals.service';
|
||||
|
||||
@Module({
|
||||
providers: [GoalsService],
|
||||
exports: [GoalsService],
|
||||
})
|
||||
export class GoalsModule {}
|
||||
56
services/telegram-nutriphi-bot/src/goals/goals.service.ts
Normal file
56
services/telegram-nutriphi-bot/src/goals/goals.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
async getGoals(telegramUserId: number): Promise<UserGoals | null> {
|
||||
const goals = await this.db.query.userGoals.findFirst({
|
||||
where: eq(schema.userGoals.telegramUserId, telegramUserId),
|
||||
});
|
||||
return goals || null;
|
||||
}
|
||||
|
||||
async ensureGoals(telegramUserId: number): Promise<UserGoals> {
|
||||
let goals = await this.getGoals(telegramUserId);
|
||||
if (!goals) {
|
||||
const [newGoals] = await this.db
|
||||
.insert(schema.userGoals)
|
||||
.values({ telegramUserId })
|
||||
.returning();
|
||||
goals = newGoals;
|
||||
this.logger.log(`Created default goals for user ${telegramUserId}`);
|
||||
}
|
||||
return goals;
|
||||
}
|
||||
|
||||
async setGoals(
|
||||
telegramUserId: number,
|
||||
data: Partial<Omit<NewUserGoals, 'id' | 'telegramUserId' | 'createdAt' | 'updatedAt'>>
|
||||
): Promise<UserGoals> {
|
||||
// Ensure user has goals first
|
||||
await this.ensureGoals(telegramUserId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(schema.userGoals)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userGoals.telegramUserId, telegramUserId))
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Updated goals for user ${telegramUserId}`);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
13
services/telegram-nutriphi-bot/src/health.controller.ts
Normal file
13
services/telegram-nutriphi-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'telegram-nutriphi-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/telegram-nutriphi-bot/src/main.ts
Normal file
18
services/telegram-nutriphi-bot/src/main.ts
Normal file
|
|
@ -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<number>('port') || 3303;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Telegram NutriPhi Bot running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
services/telegram-nutriphi-bot/src/meals/meals.module.ts
Normal file
8
services/telegram-nutriphi-bot/src/meals/meals.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MealsService } from './meals.service';
|
||||
|
||||
@Module({
|
||||
providers: [MealsService],
|
||||
exports: [MealsService],
|
||||
})
|
||||
export class MealsModule {}
|
||||
159
services/telegram-nutriphi-bot/src/meals/meals.service.ts
Normal file
159
services/telegram-nutriphi-bot/src/meals/meals.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
// Create a meal from analysis result
|
||||
async createFromAnalysis(
|
||||
telegramUserId: number,
|
||||
inputType: 'photo' | 'text',
|
||||
analysis: AnalysisResult,
|
||||
mealType?: MealType
|
||||
): Promise<Meal> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [meal] = await this.db
|
||||
.insert(schema.meals)
|
||||
.values({
|
||||
telegramUserId,
|
||||
date: today,
|
||||
mealType: mealType || suggestMealType(),
|
||||
inputType,
|
||||
description: analysis.description,
|
||||
calories: analysis.totalNutrition.calories,
|
||||
protein: analysis.totalNutrition.protein,
|
||||
carbohydrates: analysis.totalNutrition.carbohydrates,
|
||||
fat: analysis.totalNutrition.fat,
|
||||
fiber: analysis.totalNutrition.fiber,
|
||||
sugar: analysis.totalNutrition.sugar,
|
||||
confidence: analysis.confidence,
|
||||
rawResponse: analysis,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Created meal for user ${telegramUserId}: ${analysis.description}`);
|
||||
return meal;
|
||||
}
|
||||
|
||||
// Create a meal from favorite
|
||||
async createFromFavorite(telegramUserId: number, favorite: FavoriteMeal): Promise<Meal> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const nutrition = favorite.nutrition as NutritionData;
|
||||
|
||||
const [meal] = await this.db
|
||||
.insert(schema.meals)
|
||||
.values({
|
||||
telegramUserId,
|
||||
date: today,
|
||||
mealType: suggestMealType(),
|
||||
inputType: 'text',
|
||||
description: favorite.name,
|
||||
calories: nutrition.calories,
|
||||
protein: nutrition.protein,
|
||||
carbohydrates: nutrition.carbohydrates,
|
||||
fat: nutrition.fat,
|
||||
fiber: nutrition.fiber,
|
||||
sugar: nutrition.sugar,
|
||||
confidence: 1.0, // From saved data, so high confidence
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Increment usage count
|
||||
await this.db
|
||||
.update(schema.favoriteMeals)
|
||||
.set({
|
||||
usageCount: sql`${schema.favoriteMeals.usageCount} + 1`,
|
||||
})
|
||||
.where(eq(schema.favoriteMeals.id, favorite.id));
|
||||
|
||||
this.logger.log(`Created meal from favorite for user ${telegramUserId}: ${favorite.name}`);
|
||||
return meal;
|
||||
}
|
||||
|
||||
// Get meals for a specific date
|
||||
async getMealsByDate(telegramUserId: number, date: string): Promise<Meal[]> {
|
||||
return this.db.query.meals.findMany({
|
||||
where: and(eq(schema.meals.telegramUserId, telegramUserId), eq(schema.meals.date, date)),
|
||||
orderBy: (meals, { asc }) => [asc(meals.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// Get today's meals
|
||||
async getTodaysMeals(telegramUserId: number): Promise<Meal[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return this.getMealsByDate(telegramUserId, today);
|
||||
}
|
||||
|
||||
// Delete last meal
|
||||
async deleteLastMeal(telegramUserId: number): Promise<boolean> {
|
||||
const todaysMeals = await this.getTodaysMeals(telegramUserId);
|
||||
if (todaysMeals.length === 0) return false;
|
||||
|
||||
const lastMeal = todaysMeals[todaysMeals.length - 1];
|
||||
await this.db.delete(schema.meals).where(eq(schema.meals.id, lastMeal.id));
|
||||
|
||||
this.logger.log(`Deleted last meal for user ${telegramUserId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save meal as favorite
|
||||
async saveAsFavorite(telegramUserId: number, meal: Meal, name: string): Promise<FavoriteMeal> {
|
||||
const nutrition: NutritionData = {
|
||||
calories: meal.calories,
|
||||
protein: meal.protein,
|
||||
carbohydrates: meal.carbohydrates,
|
||||
fat: meal.fat,
|
||||
fiber: meal.fiber,
|
||||
sugar: meal.sugar,
|
||||
};
|
||||
|
||||
const [favorite] = await this.db
|
||||
.insert(schema.favoriteMeals)
|
||||
.values({
|
||||
telegramUserId,
|
||||
name,
|
||||
description: meal.description,
|
||||
nutrition,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Saved favorite for user ${telegramUserId}: ${name}`);
|
||||
return favorite;
|
||||
}
|
||||
|
||||
// Get all favorites
|
||||
async getFavorites(telegramUserId: number): Promise<FavoriteMeal[]> {
|
||||
return this.db.query.favoriteMeals.findMany({
|
||||
where: eq(schema.favoriteMeals.telegramUserId, telegramUserId),
|
||||
orderBy: (fav, { desc }) => [desc(fav.usageCount), desc(fav.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// Get favorite by index (1-based for user display)
|
||||
async getFavoriteByIndex(telegramUserId: number, index: number): Promise<FavoriteMeal | null> {
|
||||
const favorites = await this.getFavorites(telegramUserId);
|
||||
if (index < 1 || index > favorites.length) return null;
|
||||
return favorites[index - 1];
|
||||
}
|
||||
|
||||
// Delete favorite
|
||||
async deleteFavorite(favoriteId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(schema.favoriteMeals)
|
||||
.where(eq(schema.favoriteMeals.id, favoriteId));
|
||||
return (result as unknown as { rowCount: number }).rowCount > 0;
|
||||
}
|
||||
}
|
||||
8
services/telegram-nutriphi-bot/src/stats/stats.module.ts
Normal file
8
services/telegram-nutriphi-bot/src/stats/stats.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
|
||||
@Module({
|
||||
providers: [StatsService],
|
||||
exports: [StatsService],
|
||||
})
|
||||
export class StatsModule {}
|
||||
194
services/telegram-nutriphi-bot/src/stats/stats.service.ts
Normal file
194
services/telegram-nutriphi-bot/src/stats/stats.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
// Get daily summary for a user
|
||||
async getDailySummary(telegramUserId: number, date?: string): Promise<DailySummary> {
|
||||
const targetDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// Get meals for the day
|
||||
const meals = await this.db.query.meals.findMany({
|
||||
where: and(
|
||||
eq(schema.meals.telegramUserId, telegramUserId),
|
||||
eq(schema.meals.date, targetDate)
|
||||
),
|
||||
orderBy: (meals, { asc }) => [asc(meals.createdAt)],
|
||||
});
|
||||
|
||||
// Get user goals
|
||||
const goals = await this.db.query.userGoals.findFirst({
|
||||
where: eq(schema.userGoals.telegramUserId, telegramUserId),
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const totals = meals.reduce(
|
||||
(acc, meal) => ({
|
||||
calories: acc.calories + meal.calories,
|
||||
protein: acc.protein + meal.protein,
|
||||
carbohydrates: acc.carbohydrates + meal.carbohydrates,
|
||||
fat: acc.fat + meal.fat,
|
||||
fiber: acc.fiber + meal.fiber,
|
||||
sugar: acc.sugar + meal.sugar,
|
||||
}),
|
||||
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
|
||||
);
|
||||
|
||||
// Calculate progress (percentage of goals)
|
||||
const progress = {
|
||||
calories: goals ? (totals.calories / goals.dailyCalories) * 100 : 0,
|
||||
protein: goals ? (totals.protein / goals.dailyProtein) * 100 : 0,
|
||||
carbohydrates: goals ? (totals.carbohydrates / goals.dailyCarbs) * 100 : 0,
|
||||
fat: goals ? (totals.fat / goals.dailyFat) * 100 : 0,
|
||||
fiber: goals ? (totals.fiber / goals.dailyFiber) * 100 : 0,
|
||||
};
|
||||
|
||||
return {
|
||||
date: targetDate,
|
||||
meals,
|
||||
totals,
|
||||
goals: goals || null,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
// Get weekly summary
|
||||
async getWeeklySummary(telegramUserId: number): Promise<WeeklySummary> {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 6);
|
||||
|
||||
const startStr = startDate.toISOString().split('T')[0];
|
||||
const endStr = endDate.toISOString().split('T')[0];
|
||||
|
||||
// Get all meals for the week
|
||||
const meals = await this.db.query.meals.findMany({
|
||||
where: and(
|
||||
eq(schema.meals.telegramUserId, telegramUserId),
|
||||
gte(schema.meals.date, startStr),
|
||||
lte(schema.meals.date, endStr)
|
||||
),
|
||||
});
|
||||
|
||||
// Group by date
|
||||
const byDate = new Map<
|
||||
string,
|
||||
{ calories: number; protein: number; carbohydrates: number; fat: number; count: number }
|
||||
>();
|
||||
|
||||
// Initialize all 7 days
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(d.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
byDate.set(dateStr, { calories: 0, protein: 0, carbohydrates: 0, fat: 0, count: 0 });
|
||||
}
|
||||
|
||||
// Sum up meals
|
||||
for (const meal of meals) {
|
||||
const existing = byDate.get(meal.date) || {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbohydrates: 0,
|
||||
fat: 0,
|
||||
count: 0,
|
||||
};
|
||||
byDate.set(meal.date, {
|
||||
calories: existing.calories + meal.calories,
|
||||
protein: existing.protein + meal.protein,
|
||||
carbohydrates: existing.carbohydrates + meal.carbohydrates,
|
||||
fat: existing.fat + meal.fat,
|
||||
count: existing.count + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to array
|
||||
const days = Array.from(byDate.entries()).map(([date, data]) => ({
|
||||
date,
|
||||
calories: Math.round(data.calories),
|
||||
mealsCount: data.count,
|
||||
}));
|
||||
|
||||
// Calculate averages (only for days with meals)
|
||||
const daysWithMeals = Array.from(byDate.values()).filter((d) => d.count > 0);
|
||||
const numDays = daysWithMeals.length || 1;
|
||||
|
||||
const averages = {
|
||||
calories: Math.round(daysWithMeals.reduce((sum, d) => sum + d.calories, 0) / numDays),
|
||||
protein: Math.round(daysWithMeals.reduce((sum, d) => sum + d.protein, 0) / numDays),
|
||||
carbohydrates: Math.round(
|
||||
daysWithMeals.reduce((sum, d) => sum + d.carbohydrates, 0) / numDays
|
||||
),
|
||||
fat: Math.round(daysWithMeals.reduce((sum, d) => sum + d.fat, 0) / numDays),
|
||||
};
|
||||
|
||||
return {
|
||||
startDate: startStr,
|
||||
endDate: endStr,
|
||||
days,
|
||||
averages,
|
||||
totalMeals: meals.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Get progress bar for display
|
||||
static formatProgressBar(percentage: number, length = 10): string {
|
||||
const capped = Math.min(percentage, 100);
|
||||
const filled = Math.round((capped / 100) * length);
|
||||
const empty = length - filled;
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
||||
|
||||
// Add indicator if over goal
|
||||
const indicator = percentage > 100 ? ' ⚠️' : '';
|
||||
return `${bar} ${Math.round(percentage)}%${indicator}`;
|
||||
}
|
||||
}
|
||||
22
services/telegram-nutriphi-bot/tsconfig.json
Normal file
22
services/telegram-nutriphi-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
14
services/telegram-todo-bot/.env.example
Normal file
14
services/telegram-todo-bot/.env.example
Normal file
|
|
@ -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
|
||||
209
services/telegram-todo-bot/CLAUDE.md
Normal file
209
services/telegram-todo-bot/CLAUDE.md
Normal file
|
|
@ -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)
|
||||
10
services/telegram-todo-bot/drizzle.config.ts
Normal file
10
services/telegram-todo-bot/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/database/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/todo_bot',
|
||||
},
|
||||
});
|
||||
8
services/telegram-todo-bot/nest-cli.json
Normal file
8
services/telegram-todo-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
42
services/telegram-todo-bot/package.json
Normal file
42
services/telegram-todo-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
29
services/telegram-todo-bot/src/app.module.ts
Normal file
29
services/telegram-todo-bot/src/app.module.ts
Normal file
|
|
@ -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<string>('telegram.token') || '',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
DatabaseModule,
|
||||
BotModule,
|
||||
SchedulerModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
10
services/telegram-todo-bot/src/bot/bot.module.ts
Normal file
10
services/telegram-todo-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
460
services/telegram-todo-bot/src/bot/bot.update.ts
Normal file
460
services/telegram-todo-bot/src/bot/bot.update.ts
Normal file
|
|
@ -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<number, Task[]> = new Map();
|
||||
|
||||
// Track users in login flow
|
||||
private loginFlow: Map<number, LoginState> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly todoClient: TodoClientService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
private formatHelp(): string {
|
||||
return `<b>Todo Bot</b>
|
||||
|
||||
Verwalte deine Aufgaben direkt in Telegram.
|
||||
|
||||
<b>Aufgaben:</b>
|
||||
/add [Text] - Neue Aufgabe erstellen
|
||||
/inbox - Inbox-Aufgaben anzeigen
|
||||
/today - Heutige Aufgaben
|
||||
/list - Alle offenen Aufgaben
|
||||
/done [Nr] - Aufgabe als erledigt markieren
|
||||
|
||||
<b>Projekte:</b>
|
||||
/projects - Projekte anzeigen
|
||||
|
||||
<b>Einstellungen:</b>
|
||||
/remind - Taegliche Erinnerung an/aus
|
||||
/login - Account verknuepfen
|
||||
/logout - Account trennen
|
||||
|
||||
<b>Tipp:</b> Starte mit /today fuer deine heutigen Aufgaben!`;
|
||||
}
|
||||
|
||||
@Start()
|
||||
async start(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
const username = ctx.from?.username;
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
// Ensure user exists in database
|
||||
await this.userService.ensureUser(userId, username);
|
||||
const linkedUser = await this.userService.getLinkedUser(userId);
|
||||
|
||||
this.logger.log(`/start from user ${userId} (@${username})`);
|
||||
|
||||
if (linkedUser) {
|
||||
await ctx.replyWithHTML(
|
||||
`<b>Willkommen zurueck!</b>\n\n` +
|
||||
`Dein Account ist verknuepft. Du kannst sofort loslegen.\n\n` +
|
||||
this.formatHelp()
|
||||
);
|
||||
} else {
|
||||
await ctx.replyWithHTML(
|
||||
`<b>Willkommen beim Todo Bot!</b>\n\n` +
|
||||
`Um Aufgaben zu verwalten, verknuepfe deinen Account:\n` +
|
||||
`/login - Mit Email/Passwort anmelden\n\n` +
|
||||
`Oder sieh dir die Hilfe an:\n` +
|
||||
`/help - Alle Befehle anzeigen`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Help()
|
||||
async help(@Ctx() ctx: Context) {
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Command('login')
|
||||
async login(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
await this.userService.ensureUser(userId, ctx.from?.username);
|
||||
|
||||
// Check if already linked
|
||||
const linkedUser = await this.userService.getLinkedUser(userId);
|
||||
if (linkedUser) {
|
||||
await ctx.reply(
|
||||
'Dein Account ist bereits verknuepft.\n\n' +
|
||||
'Mit /logout kannst du die Verknuepfung aufheben.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start login flow
|
||||
this.loginFlow.set(userId, { step: 'email' });
|
||||
await ctx.reply('Bitte gib deine E-Mail-Adresse ein:');
|
||||
}
|
||||
|
||||
@Command('logout')
|
||||
async logout(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const linkedUser = await this.userService.getLinkedUser(userId);
|
||||
if (!linkedUser) {
|
||||
await ctx.reply('Kein Account verknuepft.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.userService.unlinkAccount(userId);
|
||||
await ctx.reply(
|
||||
'Account-Verknuepfung wurde aufgehoben.\n\nMit /login kannst du dich erneut anmelden.'
|
||||
);
|
||||
}
|
||||
|
||||
@On('text')
|
||||
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
// Check if user is in login flow
|
||||
const loginState = this.loginFlow.get(userId);
|
||||
if (!loginState) return; // Not in login flow, ignore
|
||||
|
||||
// Ignore commands
|
||||
if (text.startsWith('/')) return;
|
||||
|
||||
if (loginState.step === 'email') {
|
||||
// Validate email format
|
||||
if (!text.includes('@')) {
|
||||
await ctx.reply('Bitte gib eine gueltige E-Mail-Adresse ein:');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginFlow.set(userId, { step: 'password', email: text.trim() });
|
||||
await ctx.reply('Bitte gib dein Passwort ein:');
|
||||
} else if (loginState.step === 'password') {
|
||||
const email = loginState.email!;
|
||||
const password = text.trim();
|
||||
|
||||
// Clear login flow
|
||||
this.loginFlow.delete(userId);
|
||||
|
||||
// Attempt login
|
||||
const result = await this.userService.linkAccount(userId, email, password);
|
||||
|
||||
if (result.success) {
|
||||
await ctx.replyWithHTML(
|
||||
'<b>Account erfolgreich verknuepft!</b>\n\n' +
|
||||
'Du kannst jetzt Aufgaben verwalten.\n\n' +
|
||||
'Probiere /today fuer deine heutigen Aufgaben.'
|
||||
);
|
||||
} else {
|
||||
await ctx.reply(result.error || 'Anmeldung fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Command('add')
|
||||
async addTask(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = await this.userService.getLinkedUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = text.replace('/add', '').trim();
|
||||
if (!title) {
|
||||
await ctx.reply('Verwendung: /add Aufgabentext\n\nBeispiel: /add Einkaufen gehen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await this.todoClient.createTask(user.accessToken!, title);
|
||||
await ctx.reply(`Aufgabe erstellt: "${task.title}"`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create task: ${error}`);
|
||||
await ctx.reply('Fehler beim Erstellen der Aufgabe. Bitte versuche es erneut.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('inbox')
|
||||
async inboxTasks(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = await this.userService.getLinkedUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await this.todoClient.getInboxTasks(user.accessToken!);
|
||||
this.lastTaskList.set(userId, tasks);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await ctx.reply('Keine Aufgaben in der Inbox.\n\nErstelle eine mit /add [Text]');
|
||||
return;
|
||||
}
|
||||
|
||||
let response = `<b>Inbox (${tasks.length}):</b>\n\n`;
|
||||
tasks.slice(0, 20).forEach((task, i) => {
|
||||
const status = task.isCompleted ? '' : '';
|
||||
const priority = this.formatPriority(task.priority);
|
||||
response += `${i + 1}. ${status} ${task.title}${priority}\n`;
|
||||
});
|
||||
|
||||
if (tasks.length > 20) {
|
||||
response += `\n... und ${tasks.length - 20} weitere`;
|
||||
}
|
||||
|
||||
response += '\n\nAbhaken mit /done [Nr]';
|
||||
await ctx.replyWithHTML(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get inbox: ${error}`);
|
||||
await ctx.reply('Fehler beim Laden der Inbox.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('today')
|
||||
async todayTasks(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = await this.userService.getLinkedUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await this.todoClient.getTodayTasks(user.accessToken!);
|
||||
this.lastTaskList.set(userId, tasks);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await ctx.reply('Keine Aufgaben fuer heute!\n\nErstelle eine mit /add [Text]');
|
||||
return;
|
||||
}
|
||||
|
||||
let response = `<b>Heute (${tasks.length}):</b>\n\n`;
|
||||
tasks.slice(0, 20).forEach((task, i) => {
|
||||
const status = task.isCompleted ? '' : '';
|
||||
const priority = this.formatPriority(task.priority);
|
||||
const overdue = this.isOverdue(task.dueDate) ? ' (ueberfaellig)' : '';
|
||||
response += `${i + 1}. ${status} ${task.title}${priority}${overdue}\n`;
|
||||
});
|
||||
|
||||
if (tasks.length > 20) {
|
||||
response += `\n... und ${tasks.length - 20} weitere`;
|
||||
}
|
||||
|
||||
response += '\n\nAbhaken mit /done [Nr]';
|
||||
await ctx.replyWithHTML(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get today tasks: ${error}`);
|
||||
await ctx.reply('Fehler beim Laden der heutigen Aufgaben.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('list')
|
||||
async listTasks(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = await this.userService.getLinkedUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await this.todoClient.getAllTasks(user.accessToken!, false);
|
||||
this.lastTaskList.set(userId, tasks);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await ctx.reply('Keine offenen Aufgaben.\n\nErstelle eine mit /add [Text]');
|
||||
return;
|
||||
}
|
||||
|
||||
let response = `<b>Alle Aufgaben (${tasks.length}):</b>\n\n`;
|
||||
tasks.slice(0, 20).forEach((task, i) => {
|
||||
const priority = this.formatPriority(task.priority);
|
||||
const dueInfo = this.formatDueDate(task.dueDate);
|
||||
response += `${i + 1}. ${task.title}${priority}${dueInfo}\n`;
|
||||
});
|
||||
|
||||
if (tasks.length > 20) {
|
||||
response += `\n... und ${tasks.length - 20} weitere`;
|
||||
}
|
||||
|
||||
response += '\n\nAbhaken mit /done [Nr]';
|
||||
await ctx.replyWithHTML(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get tasks: ${error}`);
|
||||
await ctx.reply('Fehler beim Laden der Aufgaben.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('done')
|
||||
async completeTask(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = await this.userService.getLinkedUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
|
||||
return;
|
||||
}
|
||||
|
||||
const nrStr = text.replace('/done', '').trim();
|
||||
const nr = parseInt(nrStr, 10);
|
||||
|
||||
if (!nrStr || isNaN(nr) || nr < 1) {
|
||||
await ctx.reply(
|
||||
'Verwendung: /done [Nr]\n\n' +
|
||||
'Zeige erst deine Aufgaben mit /today, /inbox oder /list um die Nummer zu sehen.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = this.lastTaskList.get(userId);
|
||||
if (!tasks || tasks.length === 0) {
|
||||
await ctx.reply(
|
||||
'Keine Aufgabenliste im Cache. Bitte erst /today, /inbox oder /list ausfuehren.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nr > tasks.length) {
|
||||
await ctx.reply(`Ungueltige Nummer. Du hast ${tasks.length} Aufgaben in der Liste.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = tasks[nr - 1];
|
||||
|
||||
try {
|
||||
await this.todoClient.completeTask(user.accessToken!, task.id);
|
||||
await ctx.reply(`"${task.title}" erledigt!`);
|
||||
|
||||
// Remove from cache
|
||||
tasks.splice(nr - 1, 1);
|
||||
this.lastTaskList.set(userId, tasks);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to complete task: ${error}`);
|
||||
await ctx.reply('Fehler beim Abschliessen der Aufgabe.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('projects')
|
||||
async showProjects(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const user = await this.userService.getLinkedUser(userId);
|
||||
if (!user) {
|
||||
await ctx.reply('Bitte verknuepfe erst deinen Account mit /login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await this.todoClient.getProjects(user.accessToken!);
|
||||
|
||||
if (projects.length === 0) {
|
||||
await ctx.reply('Keine Projekte vorhanden.');
|
||||
return;
|
||||
}
|
||||
|
||||
let response = `<b>Projekte (${projects.length}):</b>\n\n`;
|
||||
projects.forEach((project, i) => {
|
||||
const icon = project.icon || '';
|
||||
const archived = project.isArchived ? ' (archiviert)' : '';
|
||||
const isDefault = project.isDefault ? ' (Inbox)' : '';
|
||||
response += `${i + 1}. ${icon} ${project.name}${isDefault}${archived}\n`;
|
||||
});
|
||||
|
||||
await ctx.replyWithHTML(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get projects: ${error}`);
|
||||
await ctx.reply('Fehler beim Laden der Projekte.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('remind')
|
||||
async toggleReminder(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId) return;
|
||||
|
||||
await this.userService.ensureUser(userId, ctx.from?.username);
|
||||
|
||||
const newState = await this.userService.toggleDailyReminder(userId);
|
||||
const settings = await this.userService.getDailyReminderSettings(userId);
|
||||
|
||||
if (newState) {
|
||||
await ctx.replyWithHTML(
|
||||
`<b>Taegliche Erinnerung aktiviert!</b>\n\n` +
|
||||
`Du erhaeltst jeden Tag um ${settings?.time || '08:00'} Uhr eine Uebersicht deiner Aufgaben.\n\n` +
|
||||
`Mit /remind wieder deaktivieren.`
|
||||
);
|
||||
} else {
|
||||
await ctx.reply('Taegliche Erinnerung deaktiviert.');
|
||||
}
|
||||
}
|
||||
|
||||
private formatPriority(priority: string): string {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return ' !!!';
|
||||
case 'high':
|
||||
return ' !!';
|
||||
case 'low':
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private formatDueDate(dueDate: string | null): string {
|
||||
if (!dueDate) return '';
|
||||
|
||||
const date = new Date(dueDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (date < today) {
|
||||
return ' (ueberfaellig)';
|
||||
} else if (date < tomorrow) {
|
||||
return ' (heute)';
|
||||
} else {
|
||||
const options: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit' };
|
||||
return ` (${date.toLocaleDateString('de-DE', options)})`;
|
||||
}
|
||||
}
|
||||
|
||||
private isOverdue(dueDate: string | null): boolean {
|
||||
if (!dueDate) return false;
|
||||
|
||||
const date = new Date(dueDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return date < today;
|
||||
}
|
||||
}
|
||||
15
services/telegram-todo-bot/src/config/configuration.ts
Normal file
15
services/telegram-todo-bot/src/config/configuration.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
24
services/telegram-todo-bot/src/database/database.module.ts
Normal file
24
services/telegram-todo-bot/src/database/database.module.ts
Normal file
|
|
@ -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<string>('database.url');
|
||||
const client = postgres(connectionString!);
|
||||
return drizzle(client, { schema });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
23
services/telegram-todo-bot/src/database/schema.ts
Normal file
23
services/telegram-todo-bot/src/database/schema.ts
Normal file
|
|
@ -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;
|
||||
13
services/telegram-todo-bot/src/health.controller.ts
Normal file
13
services/telegram-todo-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'telegram-todo-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/telegram-todo-bot/src/main.ts
Normal file
18
services/telegram-todo-bot/src/main.ts
Normal file
|
|
@ -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<number>('port') || 3304;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Telegram Todo Bot running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
108
services/telegram-todo-bot/src/scheduler/reminder.scheduler.ts
Normal file
108
services/telegram-todo-bot/src/scheduler/reminder.scheduler.ts
Normal file
|
|
@ -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<Context>,
|
||||
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 = `<b>Guten Morgen!</b>\n\nDu hast keine Aufgaben fuer heute. Genieße den Tag!\n\nMit /add kannst du neue Aufgaben erstellen.`;
|
||||
} else {
|
||||
message = `<b>Guten Morgen!</b>\n\n<b>Deine Aufgaben fuer heute (${tasks.length}):</b>\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;
|
||||
}
|
||||
}
|
||||
11
services/telegram-todo-bot/src/scheduler/scheduler.module.ts
Normal file
11
services/telegram-todo-bot/src/scheduler/scheduler.module.ts
Normal file
|
|
@ -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 {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TodoClientService } from './todo-client.service';
|
||||
|
||||
@Module({
|
||||
providers: [TodoClientService],
|
||||
exports: [TodoClientService],
|
||||
})
|
||||
export class TodoClientModule {}
|
||||
|
|
@ -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<string>('todoApi.url') || 'http://localhost:3018';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
token: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
// Task Operations
|
||||
|
||||
async createTask(token: string, title: string, projectId?: string): Promise<Task> {
|
||||
const dto: CreateTaskDto = { title };
|
||||
if (projectId) {
|
||||
dto.projectId = projectId;
|
||||
}
|
||||
|
||||
const response = await this.request<TaskResponse>(token, 'POST', '/tasks', dto);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async getInboxTasks(token: string): Promise<Task[]> {
|
||||
const response = await this.request<TasksResponse>(token, 'GET', '/tasks/inbox');
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
async getTodayTasks(token: string): Promise<Task[]> {
|
||||
const response = await this.request<TasksResponse>(token, 'GET', '/tasks/today');
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
async getAllTasks(token: string, isCompleted = false): Promise<Task[]> {
|
||||
const response = await this.request<TasksResponse>(
|
||||
token,
|
||||
'GET',
|
||||
`/tasks?isCompleted=${isCompleted}`
|
||||
);
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
async getUpcomingTasks(token: string, days = 7): Promise<Task[]> {
|
||||
const response = await this.request<TasksResponse>(
|
||||
token,
|
||||
'GET',
|
||||
`/tasks/upcoming?days=${days}`
|
||||
);
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
async completeTask(token: string, taskId: string): Promise<Task> {
|
||||
const response = await this.request<TaskResponse>(token, 'POST', `/tasks/${taskId}/complete`);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async uncompleteTask(token: string, taskId: string): Promise<Task> {
|
||||
const response = await this.request<TaskResponse>(token, 'POST', `/tasks/${taskId}/uncomplete`);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
async deleteTask(token: string, taskId: string): Promise<void> {
|
||||
await this.request<{ success: boolean }>(token, 'DELETE', `/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
// Project Operations
|
||||
|
||||
async getProjects(token: string): Promise<Project[]> {
|
||||
const response = await this.request<ProjectsResponse>(token, 'GET', '/projects');
|
||||
return response.projects;
|
||||
}
|
||||
|
||||
async getProjectById(token: string, projectId: string): Promise<Project | null> {
|
||||
try {
|
||||
const response = await this.request<{ project: Project }>(
|
||||
token,
|
||||
'GET',
|
||||
`/projects/${projectId}`
|
||||
);
|
||||
return response.project;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
services/telegram-todo-bot/src/todo-client/types.ts
Normal file
60
services/telegram-todo-bot/src/todo-client/types.ts
Normal file
|
|
@ -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[];
|
||||
}
|
||||
8
services/telegram-todo-bot/src/user/user.module.ts
Normal file
8
services/telegram-todo-bot/src/user/user.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
226
services/telegram-todo-bot/src/user/user.service.ts
Normal file
226
services/telegram-todo-bot/src/user/user.service.ts
Normal file
|
|
@ -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<typeof schema>,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.authUrl = this.configService.get<string>('manaCore.authUrl') || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
async ensureUser(telegramUserId: number, username?: string): Promise<TelegramUser> {
|
||||
// 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<TelegramUser | null> {
|
||||
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<void> {
|
||||
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<TelegramUser | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.db
|
||||
.update(schema.telegramUsers)
|
||||
.set({ dailyReminderTime: time, updatedAt: new Date() })
|
||||
.where(eq(schema.telegramUsers.telegramUserId, telegramUserId));
|
||||
}
|
||||
|
||||
async getUsersWithDailyReminderEnabled(): Promise<TelegramUser[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
services/telegram-todo-bot/tsconfig.json
Normal file
23
services/telegram-todo-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
8
services/telegram-zitare-bot/.env.example
Normal file
8
services/telegram-zitare-bot/.env.example
Normal file
|
|
@ -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
|
||||
161
services/telegram-zitare-bot/CLAUDE.md
Normal file
161
services/telegram-zitare-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
```
|
||||
10
services/telegram-zitare-bot/drizzle.config.ts
Normal file
10
services/telegram-zitare-bot/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/database/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/zitare_bot',
|
||||
},
|
||||
});
|
||||
10
services/telegram-zitare-bot/nest-cli.json
Normal file
10
services/telegram-zitare-bot/nest-cli.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
42
services/telegram-zitare-bot/package.json
Normal file
42
services/telegram-zitare-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
29
services/telegram-zitare-bot/src/app.module.ts
Normal file
29
services/telegram-zitare-bot/src/app.module.ts
Normal file
|
|
@ -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<string>('telegram.token') || '',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
DatabaseModule,
|
||||
BotModule,
|
||||
SchedulerModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
10
services/telegram-zitare-bot/src/bot/bot.module.ts
Normal file
10
services/telegram-zitare-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
242
services/telegram-zitare-bot/src/bot/bot.update.ts
Normal file
242
services/telegram-zitare-bot/src/bot/bot.update.ts
Normal file
|
|
@ -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<number, string> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly quotesService: QuotesService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
private formatHelp(): string {
|
||||
return `<b>✨ Zitare Bot</b>
|
||||
|
||||
Deine tägliche Dosis Inspiration mit deutschen Zitaten.
|
||||
|
||||
<b>Zitate:</b>
|
||||
/quote oder /zitat - Zufälliges Zitat
|
||||
/search [Begriff] - Zitate suchen
|
||||
/author [Name] - Zitate eines Autors
|
||||
|
||||
<b>Favoriten:</b>
|
||||
/favorite - Aktuelles Zitat speichern
|
||||
/favorites - Deine Favoriten anzeigen
|
||||
/removefav [Nr] - Favorit entfernen
|
||||
|
||||
<b>Täglich:</b>
|
||||
/daily - Tägliches Zitat an/aus
|
||||
|
||||
<b>Tipp:</b> 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 = `<b>🔍 Suchergebnisse für "${term}":</b>\n\n`;
|
||||
results.forEach((quote, index) => {
|
||||
response += `<b>${index + 1}.</b> „${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 = `<b>📚 Zitate von ${results[0].author.name}:</b>\n\n`;
|
||||
results.forEach((quote, index) => {
|
||||
response += `<b>${index + 1}.</b> „${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 = `<b>⭐ Deine Favoriten (${quotes.length}):</b>\n\n`;
|
||||
quotes.forEach((quote, index) => {
|
||||
response += `<b>${index + 1}.</b> „${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(
|
||||
`✅ <b>Tägliches Zitat aktiviert!</b>\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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
9
services/telegram-zitare-bot/src/config/configuration.ts
Normal file
9
services/telegram-zitare-bot/src/config/configuration.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
24
services/telegram-zitare-bot/src/database/database.module.ts
Normal file
24
services/telegram-zitare-bot/src/database/database.module.ts
Normal file
|
|
@ -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<string>('database.url');
|
||||
const client = postgres(connectionString!);
|
||||
return drizzle(client, { schema });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
44
services/telegram-zitare-bot/src/database/schema.ts
Normal file
44
services/telegram-zitare-bot/src/database/schema.ts
Normal file
|
|
@ -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;
|
||||
13
services/telegram-zitare-bot/src/health.controller.ts
Normal file
13
services/telegram-zitare-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'telegram-zitare-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/telegram-zitare-bot/src/main.ts
Normal file
18
services/telegram-zitare-bot/src/main.ts
Normal file
|
|
@ -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<number>('port') || 3303;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Telegram Zitare Bot running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
44
services/telegram-zitare-bot/src/quotes/data/authors.json
Normal file
44
services/telegram-zitare-bot/src/quotes/data/authors.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
166
services/telegram-zitare-bot/src/quotes/data/quotes.json
Normal file
166
services/telegram-zitare-bot/src/quotes/data/quotes.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
8
services/telegram-zitare-bot/src/quotes/quotes.module.ts
Normal file
8
services/telegram-zitare-bot/src/quotes/quotes.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { QuotesService } from './quotes.service';
|
||||
|
||||
@Module({
|
||||
providers: [QuotesService],
|
||||
exports: [QuotesService],
|
||||
})
|
||||
export class QuotesModule {}
|
||||
106
services/telegram-zitare-bot/src/quotes/quotes.service.ts
Normal file
106
services/telegram-zitare-bot/src/quotes/quotes.service.ts
Normal file
|
|
@ -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<string, Author>;
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
17
services/telegram-zitare-bot/src/quotes/types.ts
Normal file
17
services/telegram-zitare-bot/src/quotes/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Context>,
|
||||
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 =
|
||||
`☀️ <b>Dein tägliches Zitat:</b>\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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
8
services/telegram-zitare-bot/src/user/user.module.ts
Normal file
8
services/telegram-zitare-bot/src/user/user.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
146
services/telegram-zitare-bot/src/user/user.service.ts
Normal file
146
services/telegram-zitare-bot/src/user/user.service.ts
Normal file
|
|
@ -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<typeof schema>
|
||||
) {}
|
||||
|
||||
async ensureUser(telegramUserId: number, username?: string): Promise<TelegramUser> {
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
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<UserFavorite[]> {
|
||||
return this.db.query.userFavorites.findMany({
|
||||
where: eq(schema.userFavorites.telegramUserId, telegramUserId),
|
||||
orderBy: (favorites, { desc }) => [desc(favorites.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async getFavoriteQuoteIds(telegramUserId: number): Promise<string[]> {
|
||||
const favorites = await this.getFavorites(telegramUserId);
|
||||
return favorites.map((f) => f.quoteId);
|
||||
}
|
||||
|
||||
async isFavorite(telegramUserId: number, quoteId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.db
|
||||
.update(schema.telegramUsers)
|
||||
.set({ dailyTime: time, updatedAt: new Date() })
|
||||
.where(eq(schema.telegramUsers.telegramUserId, telegramUserId));
|
||||
}
|
||||
|
||||
async getUsersWithDailyEnabled(): Promise<TelegramUser[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
services/telegram-zitare-bot/tsconfig.json
Normal file
23
services/telegram-zitare-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue