feat(matrix-nutriphi-bot): add Matrix bot for nutrition tracking

- NestJS bot with matrix-bot-sdk integration
- Commands: !help, !login, !analyze, !today, !week, !goals, !favorites, !tips
- Integrates with NutriPhi backend API (port 3023)
- User session management with JWT authentication
- Image analysis via Gemini AI (NutriPhi backend)
- Port 3316

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-28 15:57:49 +01:00
parent 111fc473d9
commit 57b9d4cb37
34 changed files with 3241 additions and 463 deletions

View file

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BotModule } from './bot/bot.module';
import { HealthController } from './health.controller';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BotModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { NutriPhiModule } from '../nutriphi/nutriphi.module';
import { SessionModule } from '../session/session.module';
@Module({
imports: [NutriPhiModule, SessionModule],
providers: [MatrixService],
exports: [MatrixService],
})
export class BotModule {}

View file

@ -0,0 +1,706 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MatrixClient,
SimpleFsStorageProvider,
RichConsoleLogger,
LogService,
LogLevel,
} from 'matrix-bot-sdk';
import {
NutriPhiService,
AIAnalysisResult,
DailySummary,
WeeklyStats,
} from '../nutriphi/nutriphi.service';
import { SessionService } from '../session/session.service';
import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration';
// Natural language keywords that trigger commands (German + English)
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
{ keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' },
{ keywords: ['heute', 'today', 'tages', 'tagesübersicht'], command: 'today' },
{ keywords: ['woche', 'week', 'wochen', 'wochenübersicht'], command: 'week' },
{ keywords: ['ziele', 'goals', 'meine ziele'], command: 'goals' },
{ keywords: ['favoriten', 'favorites', 'lieblings'], command: 'favorites' },
{ keywords: ['tipps', 'tips', 'empfehlungen', 'ratschläge'], command: 'tips' },
{ keywords: ['status', 'verbindung'], command: 'status' },
];
@Injectable()
export class MatrixService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MatrixService.name);
private client!: MatrixClient;
private readonly allowedRooms: string[];
private botUserId: string = '';
constructor(
private configService: ConfigService,
private nutriphiService: NutriPhiService,
private sessionService: SessionService
) {
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
if (!accessToken) {
this.logger.error('MATRIX_ACCESS_TOKEN is required');
return;
}
// Setup logging
LogService.setLogger(new RichConsoleLogger());
LogService.setLevel(LogLevel.INFO);
// Storage for sync token persistence
const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json');
// Create Matrix client
this.client = new MatrixClient(homeserverUrl!, accessToken, storage);
// Auto-join rooms when invited
this.client.on('room.invite', async (roomId: string) => {
this.logger.log(`Invited to room ${roomId}, joining...`);
await this.client.joinRoom(roomId);
setTimeout(async () => {
try {
await this.sendBotIntroduction(roomId);
} catch (error) {
this.logger.error(`Failed to send introduction to ${roomId}:`, error);
}
}, 2000);
});
// Get bot's user ID
this.botUserId = await this.client.getUserId();
this.logger.log(`Bot user ID: ${this.botUserId}`);
// Setup message handler
this.client.on('room.message', this.handleRoomMessage.bind(this));
// Start the client
await this.client.start();
this.logger.log('Matrix NutriPhi Bot started successfully');
}
async onModuleDestroy() {
if (this.client) {
await this.client.stop();
this.logger.log('Matrix bot stopped');
}
}
private async sendBotIntroduction(roomId: string) {
const introText = `**NutriPhi Bot - KI-Ernahrungsassistent**
Analysiere deine Mahlzeiten mit KI und tracke deine Ernahrung!
**Quick Start:**
1. \`!login email passwort\` - Anmelden
2. Sende ein Foto deiner Mahlzeit
3. \`!analyze\` - Nahrwerte erhalten
Sag "hilfe" fur alle Befehle!`;
await this.sendMessage(roomId, introText);
}
private isRoomAllowed(roomId: string): boolean {
if (this.allowedRooms.length === 0) return true;
return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed));
}
private async handleRoomMessage(roomId: string, event: any) {
// Ignore messages from self
if (event.sender === this.botUserId) return;
// Check if room is allowed
if (!this.isRoomAllowed(roomId)) {
this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`);
return;
}
const content = event.content as {
msgtype?: string;
body?: string;
url?: string;
info?: { mimetype?: string };
};
// Handle image messages
if (content.msgtype === 'm.image' && content.url) {
this.sessionService.setPendingImage(
event.sender,
content.url,
content.info?.mimetype || 'image/png'
);
this.logger.log(`Image received from ${event.sender}`);
await this.sendMessage(
roomId,
`Bild empfangen! Nutze jetzt \`!analyze\` um es zu analysieren, oder \`!analyze Beschreibung\` um zusatzlichen Kontext zu geben.`
);
return;
}
// Only handle text messages
if (content.msgtype !== 'm.text') return;
const body = content.body;
if (!body) return;
this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`);
// Handle commands with ! prefix
if (body.startsWith('!')) {
await this.handleCommand(roomId, event.sender, body);
return;
}
// Check for natural language keywords
const keywordCommand = this.detectKeywordCommand(body);
if (keywordCommand) {
await this.handleCommand(roomId, event.sender, `!${keywordCommand}`);
return;
}
// Don't respond to random messages - only commands
}
private detectKeywordCommand(message: string): string | null {
const lowerMessage = message.toLowerCase().trim();
// Only match if the message is short
if (lowerMessage.length > 50) return null;
for (const { keywords, command } of KEYWORD_COMMANDS) {
for (const keyword of keywords) {
if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) {
this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`);
return command;
}
}
}
return null;
}
private async handleCommand(roomId: string, sender: string, body: string) {
const [command, ...args] = body.slice(1).split(' ');
const argString = args.join(' ');
switch (command.toLowerCase()) {
case 'help':
case 'start':
await this.sendHelp(roomId);
break;
case 'login':
await this.handleLogin(roomId, sender, args);
break;
case 'logout':
this.sessionService.logout(sender);
await this.sendMessage(roomId, 'Du wurdest abgemeldet.');
break;
case 'analyze':
await this.handleAnalyze(roomId, sender, argString);
break;
case 'today':
await this.handleToday(roomId, sender);
break;
case 'week':
await this.handleWeek(roomId, sender);
break;
case 'goals':
await this.handleGoals(roomId, sender);
break;
case 'setgoals':
await this.handleSetGoals(roomId, sender, args);
break;
case 'favorites':
await this.handleFavorites(roomId, sender);
break;
case 'tips':
await this.handleTips(roomId, sender);
break;
case 'status':
await this.handleStatus(roomId, sender);
break;
case 'pin':
await this.pinHelpMessage(roomId);
break;
default:
await this.sendMessage(
roomId,
`Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.`
);
}
}
private async sendHelp(roomId: string) {
await this.sendMessage(roomId, HELP_MESSAGE);
}
private async handleLogin(roomId: string, sender: string, args: string[]) {
if (args.length < 2) {
await this.sendMessage(
roomId,
`**Verwendung:** \`!login email passwort\`\n\nBeispiel: \`!login nutzer@example.com meinpasswort\``
);
return;
}
const [email, password] = args;
await this.sendMessage(roomId, 'Anmeldung lauft...');
const result = await this.sessionService.login(sender, email, password);
if (result.success) {
await this.sendMessage(
roomId,
`Erfolgreich angemeldet!\n\nDu kannst jetzt Fotos analysieren und deine Ernahrung tracken.`
);
} else {
await this.sendMessage(roomId, `Anmeldung fehlgeschlagen: ${result.error}`);
}
}
private async handleAnalyze(roomId: string, sender: string, description: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(
roomId,
`Du bist nicht angemeldet. Nutze \`!login email passwort\` um dich anzumelden.`
);
return;
}
const pendingImage = this.sessionService.getPendingImage(sender);
// If no image and no description, show help
if (!pendingImage && !description.trim()) {
await this.sendMessage(
roomId,
`**Verwendung:**\n- Sende ein Foto, dann \`!analyze\`\n- Oder: \`!analyze Spaghetti mit Tomatensauce\``
);
return;
}
await this.client.setTyping(roomId, true, 60000);
try {
let result: AIAnalysisResult;
if (pendingImage) {
// Analyze image
await this.sendMessage(roomId, 'Analysiere Bild...');
const imageData = await this.downloadMatrixImage(pendingImage.url);
result = await this.nutriphiService.analyzePhoto(imageData, pendingImage.mimeType, token);
this.sessionService.clearPendingImage(sender);
} else {
// Analyze text
await this.sendMessage(roomId, `Analysiere: "${description}"...`);
result = await this.nutriphiService.analyzeText(description, token);
}
await this.client.setTyping(roomId, false);
// Format and send result
const response = this.formatAnalysisResult(result);
await this.sendMessage(roomId, response);
} catch (error) {
await this.client.setTyping(roomId, false);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler bei der Analyse: ${errorMsg}`);
}
}
private formatAnalysisResult(result: AIAnalysisResult): string {
const { foods, totalNutrition, confidence, warnings, suggestions } = result;
let text = `**Mahlzeit analysiert** (Konfidenz: ${Math.round(confidence * 100)}%)\n\n`;
if (foods.length > 0) {
text += '**Erkannte Lebensmittel:**\n';
for (const food of foods) {
text += `- ${food.name} (${food.quantity}) - ${food.calories} kcal\n`;
}
text += '\n';
}
text += `**Nahrwerte:**\n`;
text += `- Kalorien: ${Math.round(totalNutrition.calories)} kcal\n`;
text += `- Protein: ${Math.round(totalNutrition.protein)}g\n`;
text += `- Kohlenhydrate: ${Math.round(totalNutrition.carbohydrates)}g\n`;
text += `- Fett: ${Math.round(totalNutrition.fat)}g\n`;
text += `- Ballaststoffe: ${Math.round(totalNutrition.fiber)}g\n`;
if (warnings && warnings.length > 0) {
text += `\n**Hinweise:**\n`;
for (const warning of warnings) {
text += `- ${warning}\n`;
}
}
if (suggestions && suggestions.length > 0) {
text += `\n**Vorschlage:**\n`;
for (const suggestion of suggestions) {
text += `- ${suggestion}\n`;
}
}
return text;
}
private async handleToday(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
await this.client.setTyping(roomId, true, 10000);
try {
const today = new Date().toISOString().split('T')[0];
const summary = await this.nutriphiService.getDailySummary(today, token);
await this.client.setTyping(roomId, false);
await this.sendMessage(roomId, this.formatDailySummary(summary));
} catch (error) {
await this.client.setTyping(roomId, false);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private formatDailySummary(summary: DailySummary): string {
const dateStr = new Date(summary.date).toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
});
let text = `**Tages-Zusammenfassung - ${dateStr}**\n\n`;
const { progress } = summary;
text += `**Kalorien:** ${Math.round(progress.calories.current)} / ${progress.calories.target} kcal (${Math.round(progress.calories.percentage)}%)\n`;
if (progress.protein) {
text += `**Protein:** ${Math.round(progress.protein.current)}g / ${progress.protein.target}g (${Math.round(progress.protein.percentage)}%)\n`;
}
if (progress.carbs) {
text += `**Kohlenhydrate:** ${Math.round(progress.carbs.current)}g / ${progress.carbs.target}g (${Math.round(progress.carbs.percentage)}%)\n`;
}
if (progress.fat) {
text += `**Fett:** ${Math.round(progress.fat.current)}g / ${progress.fat.target}g (${Math.round(progress.fat.percentage)}%)\n`;
}
if (summary.meals.length > 0) {
text += `\n**Mahlzeiten (${summary.meals.length}):**\n`;
for (const meal of summary.meals) {
const mealLabel = MEAL_TYPE_LABELS[meal.mealType] || meal.mealType;
const calories = meal.nutrition?.calories
? ` - ${Math.round(meal.nutrition.calories)} kcal`
: '';
text += `- ${mealLabel}: ${meal.description}${calories}\n`;
}
} else {
text += `\n_Noch keine Mahlzeiten heute._`;
}
return text;
}
private async handleWeek(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
await this.client.setTyping(roomId, true, 10000);
try {
const today = new Date().toISOString().split('T')[0];
const stats = await this.nutriphiService.getWeeklyStats(today, token);
await this.client.setTyping(roomId, false);
await this.sendMessage(roomId, this.formatWeeklyStats(stats));
} catch (error) {
await this.client.setTyping(roomId, false);
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private formatWeeklyStats(stats: WeeklyStats): string {
const startStr = new Date(stats.startDate).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
const endStr = new Date(stats.endDate).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
let text = `**Wochen-Statistik (${startStr} - ${endStr})**\n\n`;
text += `**Durchschnittswerte:**\n`;
text += `- Kalorien: ${Math.round(stats.averages.calories)} kcal/Tag\n`;
text += `- Protein: ${Math.round(stats.averages.protein)}g/Tag\n`;
text += `- Kohlenhydrate: ${Math.round(stats.averages.carbs)}g/Tag\n`;
text += `- Fett: ${Math.round(stats.averages.fat)}g/Tag\n\n`;
text += `**Tage:**\n`;
for (const day of stats.days) {
const dayStr = new Date(day.date).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
});
const goalIcon = day.goalsMet ? ' ' : '';
text += `- ${dayStr}: ${Math.round(day.totalCalories)} kcal, ${day.mealCount} Mahlzeiten${goalIcon}\n`;
}
return text;
}
private async handleGoals(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
try {
const goals = await this.nutriphiService.getGoals(token);
if (!goals) {
await this.sendMessage(
roomId,
`Du hast noch keine Ziele gesetzt.\n\nNutze \`!setgoals kalorien protein carbs fett\`\nBeispiel: \`!setgoals 2000 80 250 65\``
);
return;
}
let text = `**Deine Tagesziele:**\n\n`;
text += `- Kalorien: ${goals.dailyCalories} kcal\n`;
if (goals.dailyProtein) text += `- Protein: ${goals.dailyProtein}g\n`;
if (goals.dailyCarbs) text += `- Kohlenhydrate: ${goals.dailyCarbs}g\n`;
if (goals.dailyFat) text += `- Fett: ${goals.dailyFat}g\n`;
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleSetGoals(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 1) {
await this.sendMessage(
roomId,
`**Verwendung:** \`!setgoals kalorien [protein] [carbs] [fett]\`\n\nBeispiel: \`!setgoals 2000 80 250 65\``
);
return;
}
const calories = parseInt(args[0], 10);
const protein = args[1] ? parseInt(args[1], 10) : undefined;
const carbs = args[2] ? parseInt(args[2], 10) : undefined;
const fat = args[3] ? parseInt(args[3], 10) : undefined;
if (isNaN(calories) || calories < 500 || calories > 10000) {
await this.sendMessage(
roomId,
`Ungiultige Kalorienzahl. Bitte eine Zahl zwischen 500 und 10000 angeben.`
);
return;
}
try {
await this.nutriphiService.setGoals(
{
dailyCalories: calories,
dailyProtein: protein,
dailyCarbs: carbs,
dailyFat: fat,
},
token
);
let text = `**Ziele gesetzt:**\n`;
text += `- Kalorien: ${calories} kcal\n`;
if (protein) text += `- Protein: ${protein}g\n`;
if (carbs) text += `- Kohlenhydrate: ${carbs}g\n`;
if (fat) text += `- Fett: ${fat}g\n`;
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleFavorites(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
try {
const favorites = await this.nutriphiService.getFavorites(token);
if (favorites.length === 0) {
await this.sendMessage(roomId, `Du hast noch keine Favoriten gespeichert.`);
return;
}
let text = `**Deine Favoriten (${favorites.length}):**\n\n`;
for (const fav of favorites) {
text += `- **${fav.name}** (${fav.usageCount}x verwendet)\n`;
text += ` ${Math.round(fav.nutrition.calories)} kcal, ${Math.round(fav.nutrition.protein)}g P, ${Math.round(fav.nutrition.carbohydrates)}g KH, ${Math.round(fav.nutrition.fat)}g F\n`;
}
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleTips(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
try {
const recommendations = await this.nutriphiService.getRecommendations(token);
if (recommendations.length === 0) {
await this.sendMessage(
roomId,
`Keine aktuellen Empfehlungen. Tracke mehr Mahlzeiten fur personalisierte Tipps!`
);
return;
}
let text = `**KI-Empfehlungen:**\n\n`;
for (const rec of recommendations) {
const icon = rec.type === 'coaching' ? '' : '';
text += `${icon} ${rec.message}\n\n`;
}
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleStatus(roomId: string, sender: string) {
const backendHealthy = await this.nutriphiService.checkHealth();
const isLoggedIn = this.sessionService.isLoggedIn(sender);
const sessionCount = this.sessionService.getSessionCount();
const loggedInCount = this.sessionService.getLoggedInCount();
const statusText = `**NutriPhi Bot Status**
**Backend:** ${backendHealthy ? 'Online' : 'Offline'}
**Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'}
**Aktive Sessions:** ${sessionCount} (${loggedInCount} angemeldet)
${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`;
await this.sendMessage(roomId, statusText);
}
private async pinHelpMessage(roomId: string) {
try {
const htmlBody = this.markdownToHtml(HELP_MESSAGE);
const eventId = await this.client.sendMessage(roomId, {
msgtype: 'm.text',
body: HELP_MESSAGE,
format: 'org.matrix.custom.html',
formatted_body: htmlBody,
});
await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', {
pinned: [eventId],
});
this.logger.log(`Pinned help message in room ${roomId}`);
} catch (error) {
this.logger.error(`Failed to pin help message:`, error);
await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.');
}
}
private async downloadMatrixImage(mxcUrl: string): Promise<string> {
const httpUrl = this.client.mxcToHttp(mxcUrl);
this.logger.log(`Downloading image from ${httpUrl}`);
const response = await fetch(httpUrl);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`);
}
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
return base64;
}
private async sendMessage(roomId: string, message: string) {
const htmlBody = this.markdownToHtml(message);
await this.client.sendMessage(roomId, {
msgtype: 'm.text',
body: message,
format: 'org.matrix.custom.html',
formatted_body: htmlBody,
});
}
private markdownToHtml(markdown: string): string {
return (
markdown
// Code blocks
.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Bold
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
// Underscore italic
.replace(/_([^_]+)_/g, '<em>$1</em>')
// Line breaks
.replace(/\n/g, '<br/>')
);
}
}

View file

@ -0,0 +1,48 @@
export default () => ({
port: parseInt(process.env.PORT || '3316', 10),
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
accessToken: process.env.MATRIX_ACCESS_TOKEN || '',
allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',').filter(Boolean) || [],
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
},
nutriphi: {
backendUrl: process.env.NUTRIPHI_BACKEND_URL || 'http://localhost:3023',
apiPrefix: process.env.NUTRIPHI_API_PREFIX || '/api/v1',
},
auth: {
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
devBypass: process.env.DEV_BYPASS_AUTH === 'true',
devUserId: process.env.DEV_USER_ID || '',
},
});
export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent**
**Befehle:**
- \`!help\` - Diese Hilfe anzeigen
- \`!login email passwort\` - Bei NutriPhi anmelden
- \`!analyze [beschreibung]\` - Foto/Text analysieren
- \`!today\` / \`heute\` - Tages-Zusammenfassung
- \`!week\` / \`woche\` - Wochen-Statistik
- \`!goals\` / \`ziele\` - Aktuelle Ziele
- \`!setgoals kalorien protein carbs fett\` - Ziele setzen
- \`!favorites\` / \`favoriten\` - Favoriten anzeigen
- \`!tips\` / \`tipps\` - KI-Empfehlungen
- \`!status\` - Bot-Status
**Bild-Analyse:**
1. Sende ein Foto deiner Mahlzeit
2. Dann: \`!analyze\` oder \`!analyze Spaghetti mit Sauce\`
**Beispiele:**
- "heute" - Zeigt Tages-Ubersicht
- \`!analyze Apfel und Banane\` - Analysiert Textbeschreibung
- \`!setgoals 2000 80 250 65\` - Setzt Tagesziele`;
export const MEAL_TYPE_LABELS: Record<string, string> = {
breakfast: 'Fruhstuck',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
};

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'matrix-nutriphi-bot',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3316;
await app.listen(port);
logger.log(`Matrix 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 { NutriPhiService } from './nutriphi.service';
@Module({
providers: [NutriPhiService],
exports: [NutriPhiService],
})
export class NutriPhiModule {}

View file

@ -0,0 +1,235 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// Types from NutriPhi backend
export interface DetectedFood {
name: string;
quantity: string;
calories: number;
confidence: number;
}
export interface NutritionData {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
}
export interface AIAnalysisResult {
foods: DetectedFood[];
totalNutrition: NutritionData;
description: string;
confidence: number;
warnings?: string[];
suggestions?: string[];
}
export interface UserGoals {
id: string;
dailyCalories: number;
dailyProtein?: number | null;
dailyCarbs?: number | null;
dailyFat?: number | null;
}
export interface Meal {
id: string;
date: Date;
mealType: string;
description: string;
confidence: number;
}
export interface MealWithNutrition extends Meal {
nutrition?: NutritionData;
}
export interface DailySummary {
date: Date;
meals: MealWithNutrition[];
totalNutrition: NutritionData;
goals?: UserGoals;
progress: {
calories: { current: number; target: number; percentage: number };
protein?: { current: number; target: number; percentage: number };
carbs?: { current: number; target: number; percentage: number };
fat?: { current: number; target: number; percentage: number };
};
}
export interface WeeklyStats {
startDate: Date;
endDate: Date;
days: {
date: Date;
totalCalories: number;
totalProtein: number;
totalCarbs: number;
totalFat: number;
mealCount: number;
goalsMet: boolean;
}[];
averages: {
calories: number;
protein: number;
carbs: number;
fat: number;
};
}
export interface FavoriteMeal {
id: string;
name: string;
nutrition: NutritionData;
usageCount: number;
}
export interface Recommendation {
id: string;
type: 'hint' | 'coaching';
message: string;
}
@Injectable()
export class NutriPhiService {
private readonly logger = new Logger(NutriPhiService.name);
private readonly backendUrl: string;
private readonly apiPrefix: string;
constructor(private configService: ConfigService) {
this.backendUrl =
this.configService.get<string>('nutriphi.backendUrl') || 'http://localhost:3023';
this.apiPrefix = this.configService.get<string>('nutriphi.apiPrefix') || '/api/v1';
}
private getUrl(path: string): string {
return `${this.backendUrl}${this.apiPrefix}${path}`;
}
private async request<T>(
path: string,
options: RequestInit & { token?: string } = {}
): Promise<T> {
const { token, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(this.getUrl(path), {
...fetchOptions,
headers,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`NutriPhi API error (${response.status}): ${error}`);
}
return response.json();
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(this.getUrl('/health'));
return response.ok;
} catch {
return false;
}
}
async analyzePhoto(
imageBase64: string,
mimeType: string,
token: string
): Promise<AIAnalysisResult> {
return this.request<AIAnalysisResult>('/analysis/photo', {
method: 'POST',
body: JSON.stringify({ image: imageBase64, mimeType }),
token,
});
}
async analyzeText(description: string, token: string): Promise<AIAnalysisResult> {
return this.request<AIAnalysisResult>('/analysis/text', {
method: 'POST',
body: JSON.stringify({ description }),
token,
});
}
async createMeal(
data: {
description: string;
mealType: string;
inputType: 'photo' | 'text';
nutrition: NutritionData;
confidence: number;
},
token: string
): Promise<Meal> {
return this.request<Meal>('/meals', {
method: 'POST',
body: JSON.stringify(data),
token,
});
}
async getDailySummary(date: string, token: string): Promise<DailySummary> {
return this.request<DailySummary>(`/stats/daily?date=${date}`, { token });
}
async getWeeklyStats(date: string, token: string): Promise<WeeklyStats> {
return this.request<WeeklyStats>(`/stats/weekly?date=${date}`, { token });
}
async getGoals(token: string): Promise<UserGoals | null> {
try {
return await this.request<UserGoals>('/goals', { token });
} catch {
return null;
}
}
async setGoals(
goals: {
dailyCalories: number;
dailyProtein?: number;
dailyCarbs?: number;
dailyFat?: number;
},
token: string
): Promise<UserGoals> {
return this.request<UserGoals>('/goals', {
method: 'POST',
body: JSON.stringify(goals),
token,
});
}
async getFavorites(token: string): Promise<FavoriteMeal[]> {
return this.request<FavoriteMeal[]>('/favorites', { token });
}
async createFavorite(
data: { name: string; nutrition: NutritionData },
token: string
): Promise<FavoriteMeal> {
return this.request<FavoriteMeal>('/favorites', {
method: 'POST',
body: JSON.stringify(data),
token,
});
}
async getRecommendations(token: string): Promise<Recommendation[]> {
return this.request<Recommendation[]>('/recommendations', { token });
}
}

View file

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

View file

@ -0,0 +1,152 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface UserSession {
matrixUserId: string;
jwtToken?: string;
tokenExpiry?: Date;
pendingImage?: { url: string; mimeType: string };
lastActivity: Date;
}
export interface LoginResult {
success: boolean;
token?: string;
error?: string;
}
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
private sessions: Map<string, UserSession> = new Map();
private readonly authUrl: string;
private readonly devBypass: boolean;
private readonly devUserId: string;
constructor(private configService: ConfigService) {
this.authUrl = this.configService.get<string>('auth.url') || 'http://localhost:3001';
this.devBypass = this.configService.get<boolean>('auth.devBypass') || false;
this.devUserId = this.configService.get<string>('auth.devUserId') || '';
}
getSession(matrixUserId: string): UserSession {
if (!this.sessions.has(matrixUserId)) {
this.sessions.set(matrixUserId, {
matrixUserId,
lastActivity: new Date(),
});
}
const session = this.sessions.get(matrixUserId)!;
session.lastActivity = new Date();
return session;
}
isLoggedIn(matrixUserId: string): boolean {
if (this.devBypass && this.devUserId) {
return true;
}
const session = this.sessions.get(matrixUserId);
if (!session?.jwtToken || !session.tokenExpiry) {
return false;
}
// Check if token is expired (with 5 minute buffer)
const now = new Date();
const expiryBuffer = new Date(session.tokenExpiry.getTime() - 5 * 60 * 1000);
return now < expiryBuffer;
}
getToken(matrixUserId: string): string | null {
if (this.devBypass && this.devUserId) {
// In dev mode, return a mock token (the backend should also bypass auth)
return 'dev-bypass-token';
}
const session = this.sessions.get(matrixUserId);
if (!session?.jwtToken || !this.isLoggedIn(matrixUserId)) {
return null;
}
return session.jwtToken;
}
async login(matrixUserId: string, email: string, password: string): Promise<LoginResult> {
try {
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 ${matrixUserId}: ${response.status}`);
return { success: false, error: `Login fehlgeschlagen: ${error}` };
}
const data = await response.json();
const { accessToken, expiresIn } = data;
if (!accessToken) {
return { success: false, error: 'Kein Token erhalten' };
}
// Calculate expiry time (expiresIn is in seconds)
const expiryTime = expiresIn
? new Date(Date.now() + expiresIn * 1000)
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // Default: 7 days
const session = this.getSession(matrixUserId);
session.jwtToken = accessToken;
session.tokenExpiry = expiryTime;
this.logger.log(`User ${matrixUserId} logged in successfully`);
return { success: true, token: accessToken };
} catch (error) {
this.logger.error(`Login error for ${matrixUserId}:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler',
};
}
}
logout(matrixUserId: string): void {
const session = this.sessions.get(matrixUserId);
if (session) {
session.jwtToken = undefined;
session.tokenExpiry = undefined;
}
this.logger.log(`User ${matrixUserId} logged out`);
}
setPendingImage(matrixUserId: string, url: string, mimeType: string): void {
const session = this.getSession(matrixUserId);
session.pendingImage = { url, mimeType };
}
getPendingImage(matrixUserId: string): { url: string; mimeType: string } | undefined {
return this.sessions.get(matrixUserId)?.pendingImage;
}
clearPendingImage(matrixUserId: string): void {
const session = this.sessions.get(matrixUserId);
if (session) {
session.pendingImage = undefined;
}
}
getSessionCount(): number {
return this.sessions.size;
}
getLoggedInCount(): number {
let count = 0;
for (const [userId] of this.sessions) {
if (this.isLoggedIn(userId)) {
count++;
}
}
return count;
}
}