mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 02:36:41 +02:00
feat(services): add Telegram bot services for NutriPhi, Todo, and Zitare
Add three new Telegram bot services: - telegram-nutriphi-bot: Nutrition tracking bot with Gemini AI analysis - Photo meal analysis - Daily nutrition goals and tracking - Statistics and reports - telegram-todo-bot: Todo list management bot - Integration with Todo backend API - Reminder scheduling - User preferences per chat - telegram-zitare-bot: Daily inspiration quotes bot - Scheduled daily quotes - Quote database with authors - User subscription management All bots use NestJS with nestjs-telegraf for Telegram integration. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7f3842b63c
commit
8e6adfdb10
66 changed files with 4390 additions and 0 deletions
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class AnalysisModule {}
|
||||
175
services/telegram-nutriphi-bot/src/analysis/gemini.service.ts
Normal file
175
services/telegram-nutriphi-bot/src/analysis/gemini.service.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai';
|
||||
|
||||
export interface AnalysisFood {
|
||||
name: string;
|
||||
quantity: string;
|
||||
calories: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
foods: AnalysisFood[];
|
||||
totalNutrition: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
};
|
||||
description: string;
|
||||
confidence: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
const PHOTO_ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere das Bild dieser Mahlzeit und liefere eine detaillierte Nährwertanalyse.
|
||||
|
||||
Aufgaben:
|
||||
1. Identifiziere alle sichtbaren Lebensmittel
|
||||
2. Schätze die Portionsgröße (in Gramm) basierend auf visuellen Hinweisen
|
||||
3. Berechne die Nährwerte für jedes Lebensmittel
|
||||
4. Summiere die Gesamtnährwerte
|
||||
|
||||
Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
|
||||
{
|
||||
"foods": [
|
||||
{
|
||||
"name": "Lebensmittelname",
|
||||
"quantity": "geschätzte Menge (z.B. '150g', '1 Tasse')",
|
||||
"calories": 123,
|
||||
"confidence": 0.85
|
||||
}
|
||||
],
|
||||
"totalNutrition": {
|
||||
"calories": 500,
|
||||
"protein": 25,
|
||||
"carbohydrates": 60,
|
||||
"fat": 15,
|
||||
"fiber": 5,
|
||||
"sugar": 10
|
||||
},
|
||||
"description": "Kurze Beschreibung der Mahlzeit auf Deutsch",
|
||||
"confidence": 0.8,
|
||||
"warnings": ["Optional: Warnungen falls etwas unklar ist"]
|
||||
}
|
||||
|
||||
Wichtig:
|
||||
- Alle Nährwerte als Zahlen (keine Strings)
|
||||
- Kalorien in kcal
|
||||
- Protein, Kohlenhydrate, Fett, Ballaststoffe, Zucker in Gramm
|
||||
- Confidence-Werte zwischen 0 und 1
|
||||
- Beschreibung auf Deutsch`;
|
||||
|
||||
const TEXT_ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere die folgende Mahlzeitbeschreibung und liefere eine Nährwertschätzung.
|
||||
|
||||
Mahlzeit: {INPUT}
|
||||
|
||||
Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
|
||||
{
|
||||
"foods": [
|
||||
{
|
||||
"name": "Lebensmittelname",
|
||||
"quantity": "geschätzte Menge",
|
||||
"calories": 123,
|
||||
"confidence": 0.85
|
||||
}
|
||||
],
|
||||
"totalNutrition": {
|
||||
"calories": 500,
|
||||
"protein": 25,
|
||||
"carbohydrates": 60,
|
||||
"fat": 15,
|
||||
"fiber": 5,
|
||||
"sugar": 10
|
||||
},
|
||||
"description": "Aufbereitete Beschreibung der Mahlzeit",
|
||||
"confidence": 0.75
|
||||
}
|
||||
|
||||
Wichtig:
|
||||
- Alle Nährwerte als Zahlen (keine Strings)
|
||||
- Kalorien in kcal
|
||||
- Protein, Kohlenhydrate, Fett, Ballaststoffe, Zucker in Gramm
|
||||
- Confidence-Werte zwischen 0 und 1
|
||||
- Beschreibung auf Deutsch
|
||||
- Schätze realistische Portionsgrößen`;
|
||||
|
||||
@Injectable()
|
||||
export class GeminiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private model: GenerativeModel | null = null;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||||
if (apiKey) {
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
|
||||
this.logger.log('Gemini service initialized');
|
||||
} else {
|
||||
this.logger.warn('Gemini API key not configured');
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.model !== null;
|
||||
}
|
||||
|
||||
async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise<AnalysisResult> {
|
||||
if (!this.model) {
|
||||
throw new Error('Gemini API nicht konfiguriert');
|
||||
}
|
||||
|
||||
this.logger.log('Analyzing image...');
|
||||
|
||||
const result = await this.model.generateContent([
|
||||
PHOTO_ANALYSIS_PROMPT,
|
||||
{
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const response = result.response;
|
||||
const text = response.text();
|
||||
|
||||
return this.parseResponse(text);
|
||||
}
|
||||
|
||||
async analyzeText(description: string): Promise<AnalysisResult> {
|
||||
if (!this.model) {
|
||||
throw new Error('Gemini API nicht konfiguriert');
|
||||
}
|
||||
|
||||
this.logger.log(`Analyzing text: ${description.substring(0, 50)}...`);
|
||||
|
||||
const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description);
|
||||
const result = await this.model.generateContent(prompt);
|
||||
|
||||
const response = result.response;
|
||||
const text = response.text();
|
||||
|
||||
return this.parseResponse(text);
|
||||
}
|
||||
|
||||
private parseResponse(text: string): AnalysisResult {
|
||||
// Extract JSON from response (handle markdown code blocks)
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
this.logger.error('Failed to parse response:', text);
|
||||
throw new Error('Konnte Antwort nicht parsen');
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonMatch[0]) as AnalysisResult;
|
||||
} catch (error) {
|
||||
this.logger.error('JSON parse error:', error);
|
||||
throw new Error('Ungültiges JSON in Antwort');
|
||||
}
|
||||
}
|
||||
}
|
||||
27
services/telegram-nutriphi-bot/src/app.module.ts
Normal file
27
services/telegram-nutriphi-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TelegrafModule } from 'nestjs-telegraf';
|
||||
import configuration from './config/configuration';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
TelegrafModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
token: configService.get<string>('telegram.token') || '',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
DatabaseModule,
|
||||
BotModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
12
services/telegram-nutriphi-bot/src/bot/bot.module.ts
Normal file
12
services/telegram-nutriphi-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { BotUpdate } from './bot.update';
|
||||
import { AnalysisModule } from '../analysis/analysis.module';
|
||||
import { MealsModule } from '../meals/meals.module';
|
||||
import { GoalsModule } from '../goals/goals.module';
|
||||
import { StatsModule } from '../stats/stats.module';
|
||||
|
||||
@Module({
|
||||
imports: [AnalysisModule, MealsModule, GoalsModule, StatsModule],
|
||||
providers: [BotUpdate],
|
||||
})
|
||||
export class BotModule {}
|
||||
513
services/telegram-nutriphi-bot/src/bot/bot.update.ts
Normal file
513
services/telegram-nutriphi-bot/src/bot/bot.update.ts
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf';
|
||||
import { Context } from 'telegraf';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GeminiService } from '../analysis/gemini.service';
|
||||
import { MealsService } from '../meals/meals.service';
|
||||
import { GoalsService } from '../goals/goals.service';
|
||||
import { StatsService } from '../stats/stats.service';
|
||||
import { MEAL_TYPES, MealType } from '../config/configuration';
|
||||
import { Meal, NutritionData } from '../database/schema';
|
||||
|
||||
interface PhotoSize {
|
||||
file_id: string;
|
||||
file_unique_id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
@Update()
|
||||
export class BotUpdate {
|
||||
private readonly logger = new Logger(BotUpdate.name);
|
||||
private readonly allowedUsers: number[];
|
||||
private readonly telegramApiUrl: string;
|
||||
|
||||
// Track last meal for /favorit command
|
||||
private lastMeal: Map<number, Meal> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly geminiService: GeminiService,
|
||||
private readonly mealsService: MealsService,
|
||||
private readonly goalsService: GoalsService,
|
||||
private readonly statsService: StatsService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
|
||||
const token = this.configService.get<string>('telegram.token');
|
||||
this.telegramApiUrl = `https://api.telegram.org/bot${token}`;
|
||||
}
|
||||
|
||||
private isAllowed(userId: number): boolean {
|
||||
if (this.allowedUsers.length === 0) return true;
|
||||
return this.allowedUsers.includes(userId);
|
||||
}
|
||||
|
||||
private formatHelp(): string {
|
||||
return `<b>🥗 NutriPhi Bot</b>
|
||||
|
||||
Dein KI-gestützter Ernährungs-Tracker.
|
||||
|
||||
<b>Mahlzeit erfassen:</b>
|
||||
📷 Foto senden - Automatische Analyse
|
||||
💬 Text senden - z.B. "Spaghetti Bolognese"
|
||||
|
||||
<b>Übersicht:</b>
|
||||
/heute - Heutige Mahlzeiten & Fortschritt
|
||||
/woche - Wochenstatistik
|
||||
|
||||
<b>Ziele:</b>
|
||||
/ziele - Aktuelle Ziele anzeigen
|
||||
/ziele [kcal] [P] [K] [F] - Ziele setzen
|
||||
Beispiel: /ziele 2000 100 200 70
|
||||
|
||||
<b>Favoriten:</b>
|
||||
/favorit [Name] - Letzte Mahlzeit speichern
|
||||
/favoriten - Gespeicherte Mahlzeiten anzeigen
|
||||
/essen [Nr] - Favorit als Mahlzeit eintragen
|
||||
/delfav [Nr] - Favorit löschen
|
||||
|
||||
<b>Sonstiges:</b>
|
||||
/loeschen - Letzte Mahlzeit löschen
|
||||
/hilfe - Diese Hilfe anzeigen
|
||||
|
||||
<b>Tipp:</b> Starte mit einem Foto deiner Mahlzeit!`;
|
||||
}
|
||||
|
||||
@Start()
|
||||
async start(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure user has goals
|
||||
await this.goalsService.ensureGoals(userId);
|
||||
|
||||
this.logger.log(`/start from user ${userId}`);
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Help()
|
||||
async help(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Command('hilfe')
|
||||
async hilfe(@Ctx() ctx: Context) {
|
||||
await this.help(ctx);
|
||||
}
|
||||
|
||||
@Command('heute')
|
||||
async today(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await this.statsService.getDailySummary(userId);
|
||||
|
||||
if (summary.meals.length === 0) {
|
||||
await ctx.reply(
|
||||
'📭 Noch keine Mahlzeiten heute.\n\nSende ein Foto oder beschreibe deine Mahlzeit!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format meals list
|
||||
const mealsList = summary.meals
|
||||
.map((m, i) => {
|
||||
const type = MEAL_TYPES[m.mealType as MealType] || m.mealType;
|
||||
const time = new Date(m.createdAt).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${i + 1}. <b>${type}</b> (${time})\n ${m.description}\n ${m.calories} kcal`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
// Format totals and progress
|
||||
let response =
|
||||
`<b>📊 Heute (${new Date().toLocaleDateString('de-DE')})</b>\n\n` +
|
||||
`${mealsList}\n\n` +
|
||||
`<b>─────────────────</b>\n` +
|
||||
`<b>Gesamt:</b> ${summary.totals.calories} kcal\n\n`;
|
||||
|
||||
if (summary.goals) {
|
||||
response +=
|
||||
`<b>Fortschritt:</b>\n` +
|
||||
`Kalorien: ${StatsService.formatProgressBar(summary.progress.calories)}\n` +
|
||||
`Protein: ${StatsService.formatProgressBar(summary.progress.protein)}\n` +
|
||||
`Kohlenhydr.: ${StatsService.formatProgressBar(summary.progress.carbohydrates)}\n` +
|
||||
`Fett: ${StatsService.formatProgressBar(summary.progress.fat)}\n\n` +
|
||||
`<b>Verbleibend:</b> ${Math.max(0, summary.goals.dailyCalories - summary.totals.calories)} kcal`;
|
||||
}
|
||||
|
||||
await ctx.replyWithHTML(response);
|
||||
}
|
||||
|
||||
@Command('woche')
|
||||
async week(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await this.statsService.getWeeklySummary(userId);
|
||||
|
||||
if (summary.totalMeals === 0) {
|
||||
await ctx.reply('📭 Keine Mahlzeiten in den letzten 7 Tagen.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format days chart
|
||||
const maxCal = Math.max(...summary.days.map((d) => d.calories), 1);
|
||||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
const chart = summary.days
|
||||
.map((d) => {
|
||||
const date = new Date(d.date);
|
||||
const dayName = dayNames[date.getDay()];
|
||||
const barLen = Math.round((d.calories / maxCal) * 8);
|
||||
const bar = '█'.repeat(barLen) + '░'.repeat(8 - barLen);
|
||||
return `${dayName} ${bar} ${d.calories}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const response =
|
||||
`<b>📈 Wochenübersicht</b>\n\n` +
|
||||
`<code>${chart}</code>\n\n` +
|
||||
`<b>Durchschnitt:</b>\n` +
|
||||
`Kalorien: ${summary.averages.calories} kcal\n` +
|
||||
`Protein: ${summary.averages.protein}g\n` +
|
||||
`Kohlenhydrate: ${summary.averages.carbohydrates}g\n` +
|
||||
`Fett: ${summary.averages.fat}g\n\n` +
|
||||
`<b>Gesamt:</b> ${summary.totalMeals} Mahlzeiten`;
|
||||
|
||||
await ctx.replyWithHTML(response);
|
||||
}
|
||||
|
||||
@Command('ziele')
|
||||
async goals(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const args = text.replace('/ziele', '').trim();
|
||||
|
||||
// If no args, show current goals
|
||||
if (!args) {
|
||||
const goals = await this.goalsService.ensureGoals(userId);
|
||||
await ctx.replyWithHTML(
|
||||
`<b>🎯 Deine Tagesziele</b>\n\n` +
|
||||
`Kalorien: ${goals.dailyCalories} kcal\n` +
|
||||
`Protein: ${goals.dailyProtein}g\n` +
|
||||
`Kohlenhydrate: ${goals.dailyCarbs}g\n` +
|
||||
`Fett: ${goals.dailyFat}g\n` +
|
||||
`Ballaststoffe: ${goals.dailyFiber}g\n\n` +
|
||||
`<b>Ändern:</b>\n/ziele [kcal] [Protein] [Kohlenhydrate] [Fett]\nBeispiel: /ziele 2000 100 200 70`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse new goals
|
||||
const parts = args.split(/\s+/).map((n) => parseInt(n, 10));
|
||||
if (parts.length < 4 || parts.some(isNaN)) {
|
||||
await ctx.reply(
|
||||
'Verwendung: /ziele [kcal] [Protein] [Kohlenhydrate] [Fett]\n\n' +
|
||||
'Beispiel: /ziele 2000 100 200 70'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [calories, protein, carbs, fat] = parts;
|
||||
const fiber = parts[4] || 30; // Optional 5th parameter
|
||||
|
||||
await this.goalsService.setGoals(userId, {
|
||||
dailyCalories: calories,
|
||||
dailyProtein: protein,
|
||||
dailyCarbs: carbs,
|
||||
dailyFat: fat,
|
||||
dailyFiber: fiber,
|
||||
});
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`✅ <b>Ziele aktualisiert!</b>\n\n` +
|
||||
`Kalorien: ${calories} kcal\n` +
|
||||
`Protein: ${protein}g\n` +
|
||||
`Kohlenhydrate: ${carbs}g\n` +
|
||||
`Fett: ${fat}g\n` +
|
||||
`Ballaststoffe: ${fiber}g`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('favorit')
|
||||
async saveFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = text.replace('/favorit', '').trim();
|
||||
if (!name) {
|
||||
await ctx.reply('Verwendung: /favorit [Name]\n\nBeispiel: /favorit Morgenmüsli');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMeal = this.lastMeal.get(userId);
|
||||
if (!lastMeal) {
|
||||
await ctx.reply('Keine aktuelle Mahlzeit zum Speichern.\n\nErfasse erst eine Mahlzeit.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mealsService.saveAsFavorite(userId, lastMeal, name);
|
||||
await ctx.reply(`⭐ "${name}" als Favorit gespeichert!`);
|
||||
}
|
||||
|
||||
@Command('favoriten')
|
||||
async listFavorites(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const favorites = await this.mealsService.getFavorites(userId);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
await ctx.reply(
|
||||
'Keine Favoriten gespeichert.\n\n' + 'Speichere eine Mahlzeit mit /favorit [Name]'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = favorites
|
||||
.map((f, i) => {
|
||||
const nutrition = f.nutrition as NutritionData;
|
||||
return `<b>${i + 1}.</b> ${f.name}\n ${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>⭐ Deine Favoriten</b>\n\n${list}\n\n` + `Verwenden: /essen [Nr]\nLöschen: /delfav [Nr]`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('essen')
|
||||
async useFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const indexStr = text.replace('/essen', '').trim();
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (!indexStr || isNaN(index)) {
|
||||
await ctx.reply('Verwendung: /essen [Nr]\n\nZeige Favoriten mit /favoriten');
|
||||
return;
|
||||
}
|
||||
|
||||
const favorite = await this.mealsService.getFavoriteByIndex(userId, index);
|
||||
if (!favorite) {
|
||||
await ctx.reply(`Favorit #${index} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const meal = await this.mealsService.createFromFavorite(userId, favorite);
|
||||
this.lastMeal.set(userId, meal);
|
||||
|
||||
const nutrition = favorite.nutrition as NutritionData;
|
||||
await ctx.replyWithHTML(
|
||||
`✅ <b>${favorite.name}</b> eingetragen!\n\n` +
|
||||
`${nutrition.calories} kcal | ${nutrition.protein}g P | ${nutrition.carbohydrates}g K | ${nutrition.fat}g F\n\n` +
|
||||
`Übersicht: /heute`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('delfav')
|
||||
async deleteFavorite(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const indexStr = text.replace('/delfav', '').trim();
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
if (!indexStr || isNaN(index)) {
|
||||
await ctx.reply('Verwendung: /delfav [Nr]\n\nZeige Favoriten mit /favoriten');
|
||||
return;
|
||||
}
|
||||
|
||||
const favorite = await this.mealsService.getFavoriteByIndex(userId, index);
|
||||
if (!favorite) {
|
||||
await ctx.reply(`Favorit #${index} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mealsService.deleteFavorite(favorite.id);
|
||||
await ctx.reply(`✅ "${favorite.name}" gelöscht.`);
|
||||
}
|
||||
|
||||
@Command('loeschen')
|
||||
async deleteLastMeal(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await this.mealsService.deleteLastMeal(userId);
|
||||
if (deleted) {
|
||||
this.lastMeal.delete(userId);
|
||||
await ctx.reply('✅ Letzte Mahlzeit gelöscht.');
|
||||
} else {
|
||||
await ctx.reply('Keine Mahlzeit zum Löschen gefunden.');
|
||||
}
|
||||
}
|
||||
|
||||
@On('photo')
|
||||
async onPhoto(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
await ctx.reply('❌ Analyse nicht verfügbar (API nicht konfiguriert).');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = ctx.message as { photo?: PhotoSize[]; caption?: string };
|
||||
const photos = message.photo;
|
||||
if (!photos || photos.length === 0) return;
|
||||
|
||||
// Get largest photo
|
||||
const photo = photos[photos.length - 1];
|
||||
|
||||
await ctx.reply('🔍 Analysiere Mahlzeit...');
|
||||
await ctx.sendChatAction('typing');
|
||||
|
||||
try {
|
||||
// Download photo from Telegram
|
||||
const imageBase64 = await this.downloadTelegramFile(photo.file_id);
|
||||
|
||||
// Analyze with Gemini
|
||||
const analysis = await this.geminiService.analyzeImage(imageBase64);
|
||||
|
||||
// Save meal
|
||||
const meal = await this.mealsService.createFromAnalysis(userId, 'photo', analysis);
|
||||
this.lastMeal.set(userId, meal);
|
||||
|
||||
// Format response
|
||||
const foodsList = analysis.foods.map((f) => `• ${f.name} (${f.quantity})`).join('\n');
|
||||
|
||||
const n = analysis.totalNutrition;
|
||||
const confidence = Math.round(analysis.confidence * 100);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>🍽️ ${analysis.description}</b>\n\n` +
|
||||
`<b>Erkannt:</b>\n${foodsList}\n\n` +
|
||||
`<b>Nährwerte:</b>\n` +
|
||||
`Kalorien: ${n.calories} kcal\n` +
|
||||
`Protein: ${n.protein}g\n` +
|
||||
`Kohlenhydrate: ${n.carbohydrates}g\n` +
|
||||
`Fett: ${n.fat}g\n` +
|
||||
`Ballaststoffe: ${n.fiber}g\n` +
|
||||
`Zucker: ${n.sugar}g\n\n` +
|
||||
`<i>Genauigkeit: ${confidence}%</i>\n\n` +
|
||||
`Als Favorit speichern: /favorit [Name]`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Photo analysis failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
await ctx.reply(`❌ Analyse fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@On('text')
|
||||
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore commands
|
||||
if (text.startsWith('/')) return;
|
||||
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
await ctx.reply('❌ Analyse nicht verfügbar (API nicht konfiguriert).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Analyze text as meal description
|
||||
await ctx.reply('🔍 Analysiere...');
|
||||
await ctx.sendChatAction('typing');
|
||||
|
||||
try {
|
||||
const analysis = await this.geminiService.analyzeText(text);
|
||||
|
||||
// Save meal
|
||||
const meal = await this.mealsService.createFromAnalysis(userId, 'text', analysis);
|
||||
this.lastMeal.set(userId, meal);
|
||||
|
||||
// Format response
|
||||
const n = analysis.totalNutrition;
|
||||
const confidence = Math.round(analysis.confidence * 100);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>✅ ${analysis.description}</b>\n\n` +
|
||||
`<b>Nährwerte:</b>\n` +
|
||||
`Kalorien: ${n.calories} kcal\n` +
|
||||
`Protein: ${n.protein}g\n` +
|
||||
`Kohlenhydrate: ${n.carbohydrates}g\n` +
|
||||
`Fett: ${n.fat}g\n` +
|
||||
`Ballaststoffe: ${n.fiber}g\n` +
|
||||
`Zucker: ${n.sugar}g\n\n` +
|
||||
`<i>Genauigkeit: ${confidence}%</i>\n\n` +
|
||||
`Als Favorit speichern: /favorit [Name]`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Text analysis failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
await ctx.reply(`❌ Analyse fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Download file from Telegram and return Base64
|
||||
private async downloadTelegramFile(fileId: string): Promise<string> {
|
||||
// Get file path
|
||||
const fileResponse = await fetch(`${this.telegramApiUrl}/getFile?file_id=${fileId}`);
|
||||
const fileData = await fileResponse.json();
|
||||
|
||||
if (!fileData.ok) {
|
||||
throw new Error(`Telegram API error: ${fileData.description}`);
|
||||
}
|
||||
|
||||
// Download file
|
||||
const token = this.configService.get<string>('telegram.token');
|
||||
const fileUrl = `https://api.telegram.org/file/bot${token}/${fileData.result.file_path}`;
|
||||
|
||||
const response = await fetch(fileUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
}
|
||||
35
services/telegram-nutriphi-bot/src/config/configuration.ts
Normal file
35
services/telegram-nutriphi-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3303', 10),
|
||||
telegram: {
|
||||
token: process.env.TELEGRAM_BOT_TOKEN,
|
||||
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS
|
||||
? process.env.TELEGRAM_ALLOWED_USERS.split(',').map((id) => parseInt(id.trim(), 10))
|
||||
: [],
|
||||
},
|
||||
database: {
|
||||
url:
|
||||
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/nutriphi_bot',
|
||||
},
|
||||
gemini: {
|
||||
apiKey: process.env.GEMINI_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
// Meal type labels
|
||||
export const MEAL_TYPES = {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack',
|
||||
} as const;
|
||||
|
||||
export type MealType = keyof typeof MEAL_TYPES;
|
||||
|
||||
// Get suggested meal type based on current time
|
||||
export function suggestMealType(): MealType {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 5 && hour < 11) return 'breakfast';
|
||||
if (hour >= 11 && hour < 15) return 'lunch';
|
||||
if (hour >= 17 && hour < 21) return 'dinner';
|
||||
return 'snack';
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const connectionString = configService.get<string>('database.url');
|
||||
const client = postgres(connectionString!);
|
||||
return drizzle(client, { schema });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
93
services/telegram-nutriphi-bot/src/database/schema.ts
Normal file
93
services/telegram-nutriphi-bot/src/database/schema.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
bigint,
|
||||
integer,
|
||||
real,
|
||||
date,
|
||||
jsonb,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// User goals - daily nutrition targets
|
||||
export const userGoals = pgTable('user_goals', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull().unique(),
|
||||
dailyCalories: integer('daily_calories').default(2000).notNull(),
|
||||
dailyProtein: integer('daily_protein').default(50).notNull(),
|
||||
dailyCarbs: integer('daily_carbs').default(250).notNull(),
|
||||
dailyFat: integer('daily_fat').default(65).notNull(),
|
||||
dailyFiber: integer('daily_fiber').default(30).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Meals - tracked meals with nutrition data
|
||||
export const meals = pgTable('meals', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull(),
|
||||
date: date('date').notNull(),
|
||||
mealType: text('meal_type').notNull(), // breakfast, lunch, dinner, snack
|
||||
inputType: text('input_type').notNull(), // photo, text
|
||||
description: text('description'),
|
||||
calories: integer('calories').default(0).notNull(),
|
||||
protein: real('protein').default(0).notNull(),
|
||||
carbohydrates: real('carbohydrates').default(0).notNull(),
|
||||
fat: real('fat').default(0).notNull(),
|
||||
fiber: real('fiber').default(0).notNull(),
|
||||
sugar: real('sugar').default(0).notNull(),
|
||||
confidence: real('confidence').default(0).notNull(),
|
||||
rawResponse: jsonb('raw_response'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Favorite meals - saved for quick re-use
|
||||
export const favoriteMeals = pgTable('favorite_meals', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
nutrition: jsonb('nutrition').notNull(),
|
||||
usageCount: integer('usage_count').default(0).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const userGoalsRelations = relations(userGoals, ({ many }) => ({
|
||||
meals: many(meals),
|
||||
favorites: many(favoriteMeals),
|
||||
}));
|
||||
|
||||
export const mealsRelations = relations(meals, ({ one }) => ({
|
||||
userGoals: one(userGoals, {
|
||||
fields: [meals.telegramUserId],
|
||||
references: [userGoals.telegramUserId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const favoriteMealsRelations = relations(favoriteMeals, ({ one }) => ({
|
||||
userGoals: one(userGoals, {
|
||||
fields: [favoriteMeals.telegramUserId],
|
||||
references: [userGoals.telegramUserId],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Types
|
||||
export type UserGoals = typeof userGoals.$inferSelect;
|
||||
export type NewUserGoals = typeof userGoals.$inferInsert;
|
||||
export type Meal = typeof meals.$inferSelect;
|
||||
export type NewMeal = typeof meals.$inferInsert;
|
||||
export type FavoriteMeal = typeof favoriteMeals.$inferSelect;
|
||||
export type NewFavoriteMeal = typeof favoriteMeals.$inferInsert;
|
||||
|
||||
// Nutrition data structure for favorites
|
||||
export interface NutritionData {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
}
|
||||
8
services/telegram-nutriphi-bot/src/goals/goals.module.ts
Normal file
8
services/telegram-nutriphi-bot/src/goals/goals.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GoalsService } from './goals.service';
|
||||
|
||||
@Module({
|
||||
providers: [GoalsService],
|
||||
exports: [GoalsService],
|
||||
})
|
||||
export class GoalsModule {}
|
||||
56
services/telegram-nutriphi-bot/src/goals/goals.service.ts
Normal file
56
services/telegram-nutriphi-bot/src/goals/goals.service.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import * as schema from '../database/schema';
|
||||
import { UserGoals, NewUserGoals } from '../database/schema';
|
||||
|
||||
@Injectable()
|
||||
export class GoalsService {
|
||||
private readonly logger = new Logger(GoalsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private db: PostgresJsDatabase<typeof schema>
|
||||
) {}
|
||||
|
||||
async getGoals(telegramUserId: number): Promise<UserGoals | null> {
|
||||
const goals = await this.db.query.userGoals.findFirst({
|
||||
where: eq(schema.userGoals.telegramUserId, telegramUserId),
|
||||
});
|
||||
return goals || null;
|
||||
}
|
||||
|
||||
async ensureGoals(telegramUserId: number): Promise<UserGoals> {
|
||||
let goals = await this.getGoals(telegramUserId);
|
||||
if (!goals) {
|
||||
const [newGoals] = await this.db
|
||||
.insert(schema.userGoals)
|
||||
.values({ telegramUserId })
|
||||
.returning();
|
||||
goals = newGoals;
|
||||
this.logger.log(`Created default goals for user ${telegramUserId}`);
|
||||
}
|
||||
return goals;
|
||||
}
|
||||
|
||||
async setGoals(
|
||||
telegramUserId: number,
|
||||
data: Partial<Omit<NewUserGoals, 'id' | 'telegramUserId' | 'createdAt' | 'updatedAt'>>
|
||||
): Promise<UserGoals> {
|
||||
// Ensure user has goals first
|
||||
await this.ensureGoals(telegramUserId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(schema.userGoals)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.userGoals.telegramUserId, telegramUserId))
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Updated goals for user ${telegramUserId}`);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
13
services/telegram-nutriphi-bot/src/health.controller.ts
Normal file
13
services/telegram-nutriphi-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'telegram-nutriphi-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/telegram-nutriphi-bot/src/main.ts
Normal file
18
services/telegram-nutriphi-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('port') || 3303;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Telegram NutriPhi Bot running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
services/telegram-nutriphi-bot/src/meals/meals.module.ts
Normal file
8
services/telegram-nutriphi-bot/src/meals/meals.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MealsService } from './meals.service';
|
||||
|
||||
@Module({
|
||||
providers: [MealsService],
|
||||
exports: [MealsService],
|
||||
})
|
||||
export class MealsModule {}
|
||||
159
services/telegram-nutriphi-bot/src/meals/meals.service.ts
Normal file
159
services/telegram-nutriphi-bot/src/meals/meals.service.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import * as schema from '../database/schema';
|
||||
import { Meal, NewMeal, FavoriteMeal, NutritionData } from '../database/schema';
|
||||
import { AnalysisResult } from '../analysis/gemini.service';
|
||||
import { MealType, suggestMealType } from '../config/configuration';
|
||||
|
||||
@Injectable()
|
||||
export class MealsService {
|
||||
private readonly logger = new Logger(MealsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private db: PostgresJsDatabase<typeof schema>
|
||||
) {}
|
||||
|
||||
// Create a meal from analysis result
|
||||
async createFromAnalysis(
|
||||
telegramUserId: number,
|
||||
inputType: 'photo' | 'text',
|
||||
analysis: AnalysisResult,
|
||||
mealType?: MealType
|
||||
): Promise<Meal> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const [meal] = await this.db
|
||||
.insert(schema.meals)
|
||||
.values({
|
||||
telegramUserId,
|
||||
date: today,
|
||||
mealType: mealType || suggestMealType(),
|
||||
inputType,
|
||||
description: analysis.description,
|
||||
calories: analysis.totalNutrition.calories,
|
||||
protein: analysis.totalNutrition.protein,
|
||||
carbohydrates: analysis.totalNutrition.carbohydrates,
|
||||
fat: analysis.totalNutrition.fat,
|
||||
fiber: analysis.totalNutrition.fiber,
|
||||
sugar: analysis.totalNutrition.sugar,
|
||||
confidence: analysis.confidence,
|
||||
rawResponse: analysis,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Created meal for user ${telegramUserId}: ${analysis.description}`);
|
||||
return meal;
|
||||
}
|
||||
|
||||
// Create a meal from favorite
|
||||
async createFromFavorite(telegramUserId: number, favorite: FavoriteMeal): Promise<Meal> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const nutrition = favorite.nutrition as NutritionData;
|
||||
|
||||
const [meal] = await this.db
|
||||
.insert(schema.meals)
|
||||
.values({
|
||||
telegramUserId,
|
||||
date: today,
|
||||
mealType: suggestMealType(),
|
||||
inputType: 'text',
|
||||
description: favorite.name,
|
||||
calories: nutrition.calories,
|
||||
protein: nutrition.protein,
|
||||
carbohydrates: nutrition.carbohydrates,
|
||||
fat: nutrition.fat,
|
||||
fiber: nutrition.fiber,
|
||||
sugar: nutrition.sugar,
|
||||
confidence: 1.0, // From saved data, so high confidence
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Increment usage count
|
||||
await this.db
|
||||
.update(schema.favoriteMeals)
|
||||
.set({
|
||||
usageCount: sql`${schema.favoriteMeals.usageCount} + 1`,
|
||||
})
|
||||
.where(eq(schema.favoriteMeals.id, favorite.id));
|
||||
|
||||
this.logger.log(`Created meal from favorite for user ${telegramUserId}: ${favorite.name}`);
|
||||
return meal;
|
||||
}
|
||||
|
||||
// Get meals for a specific date
|
||||
async getMealsByDate(telegramUserId: number, date: string): Promise<Meal[]> {
|
||||
return this.db.query.meals.findMany({
|
||||
where: and(eq(schema.meals.telegramUserId, telegramUserId), eq(schema.meals.date, date)),
|
||||
orderBy: (meals, { asc }) => [asc(meals.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// Get today's meals
|
||||
async getTodaysMeals(telegramUserId: number): Promise<Meal[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return this.getMealsByDate(telegramUserId, today);
|
||||
}
|
||||
|
||||
// Delete last meal
|
||||
async deleteLastMeal(telegramUserId: number): Promise<boolean> {
|
||||
const todaysMeals = await this.getTodaysMeals(telegramUserId);
|
||||
if (todaysMeals.length === 0) return false;
|
||||
|
||||
const lastMeal = todaysMeals[todaysMeals.length - 1];
|
||||
await this.db.delete(schema.meals).where(eq(schema.meals.id, lastMeal.id));
|
||||
|
||||
this.logger.log(`Deleted last meal for user ${telegramUserId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save meal as favorite
|
||||
async saveAsFavorite(telegramUserId: number, meal: Meal, name: string): Promise<FavoriteMeal> {
|
||||
const nutrition: NutritionData = {
|
||||
calories: meal.calories,
|
||||
protein: meal.protein,
|
||||
carbohydrates: meal.carbohydrates,
|
||||
fat: meal.fat,
|
||||
fiber: meal.fiber,
|
||||
sugar: meal.sugar,
|
||||
};
|
||||
|
||||
const [favorite] = await this.db
|
||||
.insert(schema.favoriteMeals)
|
||||
.values({
|
||||
telegramUserId,
|
||||
name,
|
||||
description: meal.description,
|
||||
nutrition,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Saved favorite for user ${telegramUserId}: ${name}`);
|
||||
return favorite;
|
||||
}
|
||||
|
||||
// Get all favorites
|
||||
async getFavorites(telegramUserId: number): Promise<FavoriteMeal[]> {
|
||||
return this.db.query.favoriteMeals.findMany({
|
||||
where: eq(schema.favoriteMeals.telegramUserId, telegramUserId),
|
||||
orderBy: (fav, { desc }) => [desc(fav.usageCount), desc(fav.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// Get favorite by index (1-based for user display)
|
||||
async getFavoriteByIndex(telegramUserId: number, index: number): Promise<FavoriteMeal | null> {
|
||||
const favorites = await this.getFavorites(telegramUserId);
|
||||
if (index < 1 || index > favorites.length) return null;
|
||||
return favorites[index - 1];
|
||||
}
|
||||
|
||||
// Delete favorite
|
||||
async deleteFavorite(favoriteId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(schema.favoriteMeals)
|
||||
.where(eq(schema.favoriteMeals.id, favoriteId));
|
||||
return (result as unknown as { rowCount: number }).rowCount > 0;
|
||||
}
|
||||
}
|
||||
8
services/telegram-nutriphi-bot/src/stats/stats.module.ts
Normal file
8
services/telegram-nutriphi-bot/src/stats/stats.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
|
||||
@Module({
|
||||
providers: [StatsService],
|
||||
exports: [StatsService],
|
||||
})
|
||||
export class StatsModule {}
|
||||
194
services/telegram-nutriphi-bot/src/stats/stats.service.ts
Normal file
194
services/telegram-nutriphi-bot/src/stats/stats.service.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, gte, lte, sql } from 'drizzle-orm';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import * as schema from '../database/schema';
|
||||
import { Meal, UserGoals } from '../database/schema';
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
meals: Meal[];
|
||||
totals: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
};
|
||||
goals: UserGoals | null;
|
||||
progress: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WeeklySummary {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
days: {
|
||||
date: string;
|
||||
calories: number;
|
||||
mealsCount: number;
|
||||
}[];
|
||||
averages: {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
};
|
||||
totalMeals: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StatsService {
|
||||
private readonly logger = new Logger(StatsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private db: PostgresJsDatabase<typeof schema>
|
||||
) {}
|
||||
|
||||
// Get daily summary for a user
|
||||
async getDailySummary(telegramUserId: number, date?: string): Promise<DailySummary> {
|
||||
const targetDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// Get meals for the day
|
||||
const meals = await this.db.query.meals.findMany({
|
||||
where: and(
|
||||
eq(schema.meals.telegramUserId, telegramUserId),
|
||||
eq(schema.meals.date, targetDate)
|
||||
),
|
||||
orderBy: (meals, { asc }) => [asc(meals.createdAt)],
|
||||
});
|
||||
|
||||
// Get user goals
|
||||
const goals = await this.db.query.userGoals.findFirst({
|
||||
where: eq(schema.userGoals.telegramUserId, telegramUserId),
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const totals = meals.reduce(
|
||||
(acc, meal) => ({
|
||||
calories: acc.calories + meal.calories,
|
||||
protein: acc.protein + meal.protein,
|
||||
carbohydrates: acc.carbohydrates + meal.carbohydrates,
|
||||
fat: acc.fat + meal.fat,
|
||||
fiber: acc.fiber + meal.fiber,
|
||||
sugar: acc.sugar + meal.sugar,
|
||||
}),
|
||||
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
|
||||
);
|
||||
|
||||
// Calculate progress (percentage of goals)
|
||||
const progress = {
|
||||
calories: goals ? (totals.calories / goals.dailyCalories) * 100 : 0,
|
||||
protein: goals ? (totals.protein / goals.dailyProtein) * 100 : 0,
|
||||
carbohydrates: goals ? (totals.carbohydrates / goals.dailyCarbs) * 100 : 0,
|
||||
fat: goals ? (totals.fat / goals.dailyFat) * 100 : 0,
|
||||
fiber: goals ? (totals.fiber / goals.dailyFiber) * 100 : 0,
|
||||
};
|
||||
|
||||
return {
|
||||
date: targetDate,
|
||||
meals,
|
||||
totals,
|
||||
goals: goals || null,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
// Get weekly summary
|
||||
async getWeeklySummary(telegramUserId: number): Promise<WeeklySummary> {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 6);
|
||||
|
||||
const startStr = startDate.toISOString().split('T')[0];
|
||||
const endStr = endDate.toISOString().split('T')[0];
|
||||
|
||||
// Get all meals for the week
|
||||
const meals = await this.db.query.meals.findMany({
|
||||
where: and(
|
||||
eq(schema.meals.telegramUserId, telegramUserId),
|
||||
gte(schema.meals.date, startStr),
|
||||
lte(schema.meals.date, endStr)
|
||||
),
|
||||
});
|
||||
|
||||
// Group by date
|
||||
const byDate = new Map<
|
||||
string,
|
||||
{ calories: number; protein: number; carbohydrates: number; fat: number; count: number }
|
||||
>();
|
||||
|
||||
// Initialize all 7 days
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(d.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
byDate.set(dateStr, { calories: 0, protein: 0, carbohydrates: 0, fat: 0, count: 0 });
|
||||
}
|
||||
|
||||
// Sum up meals
|
||||
for (const meal of meals) {
|
||||
const existing = byDate.get(meal.date) || {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbohydrates: 0,
|
||||
fat: 0,
|
||||
count: 0,
|
||||
};
|
||||
byDate.set(meal.date, {
|
||||
calories: existing.calories + meal.calories,
|
||||
protein: existing.protein + meal.protein,
|
||||
carbohydrates: existing.carbohydrates + meal.carbohydrates,
|
||||
fat: existing.fat + meal.fat,
|
||||
count: existing.count + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to array
|
||||
const days = Array.from(byDate.entries()).map(([date, data]) => ({
|
||||
date,
|
||||
calories: Math.round(data.calories),
|
||||
mealsCount: data.count,
|
||||
}));
|
||||
|
||||
// Calculate averages (only for days with meals)
|
||||
const daysWithMeals = Array.from(byDate.values()).filter((d) => d.count > 0);
|
||||
const numDays = daysWithMeals.length || 1;
|
||||
|
||||
const averages = {
|
||||
calories: Math.round(daysWithMeals.reduce((sum, d) => sum + d.calories, 0) / numDays),
|
||||
protein: Math.round(daysWithMeals.reduce((sum, d) => sum + d.protein, 0) / numDays),
|
||||
carbohydrates: Math.round(
|
||||
daysWithMeals.reduce((sum, d) => sum + d.carbohydrates, 0) / numDays
|
||||
),
|
||||
fat: Math.round(daysWithMeals.reduce((sum, d) => sum + d.fat, 0) / numDays),
|
||||
};
|
||||
|
||||
return {
|
||||
startDate: startStr,
|
||||
endDate: endStr,
|
||||
days,
|
||||
averages,
|
||||
totalMeals: meals.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Get progress bar for display
|
||||
static formatProgressBar(percentage: number, length = 10): string {
|
||||
const capped = Math.min(percentage, 100);
|
||||
const filled = Math.round((capped / 100) * length);
|
||||
const empty = length - filled;
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
||||
|
||||
// Add indicator if over goal
|
||||
const indicator = percentage > 100 ? ' ⚠️' : '';
|
||||
return `${bar} ${Math.round(percentage)}%${indicator}`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue