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:
Till-JS 2026-01-28 12:52:01 +01:00
parent 7f3842b63c
commit 8e6adfdb10
66 changed files with 4390 additions and 0 deletions

View 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

View 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

View 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',
},
});

View file

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

View 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"
}
}

View file

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

View 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');
}
}
}

View 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 {}

View 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 {}

View 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');
}
}

View 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';
}

View 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 {}

View 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;
}

View file

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

View 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;
}
}

View 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(),
};
}
}

View 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();

View file

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

View 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;
}
}

View file

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

View 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}`;
}
}

View 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
}
}

View 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

View 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)

View 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',
},
});

View file

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

View 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"
}
}

View 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 {}

View 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 {}

View 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;
}
}

View 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',
},
});

View 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 {}

View 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;

View 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(),
};
}
}

View 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();

View 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;
}
}

View 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 {}

View file

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

View file

@ -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;
}
}
}

View 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[];
}

View file

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

View 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,
};
}
}

View 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
}
}

View 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

View 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
```

View 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',
},
});

View 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
}
}

View 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"
}
}

View 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 {}

View 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 {}

View 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.');
}
}
}

View 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',
},
});

View 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 {}

View 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;

View 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(),
};
}
}

View 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();

View 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" }
]

View 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" }
]

View file

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

View 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}`;
}
}

View 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;
}

View file

@ -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);
}
}
}

View file

@ -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 {}

View file

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

View 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,
};
}
}

View 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
}
}