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