mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
111fc473d9
commit
57b9d4cb37
34 changed files with 3241 additions and 463 deletions
|
|
@ -975,6 +975,35 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Matrix Calendar Bot (GDPR-compliant Calendar)
|
||||
# ============================================
|
||||
|
||||
matrix-calendar-bot:
|
||||
image: matrix-calendar-bot:latest
|
||||
container_name: manacore-matrix-calendar-bot
|
||||
restart: always
|
||||
depends_on:
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3315
|
||||
TZ: Europe/Berlin
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_CALENDAR_BOT_TOKEN}
|
||||
MATRIX_ALLOWED_ROOMS: ${MATRIX_CALENDAR_BOT_ROOMS:-}
|
||||
volumes:
|
||||
- matrix_calendar_bot_data:/app/data
|
||||
ports:
|
||||
- "3315:3315"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3315/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Matrix Todo Bot (GDPR-compliant Task Management)
|
||||
# ============================================
|
||||
|
|
@ -1004,6 +1033,39 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Matrix NutriPhi Bot (GDPR-compliant Nutrition Tracking)
|
||||
# ============================================
|
||||
|
||||
matrix-nutriphi-bot:
|
||||
image: matrix-nutriphi-bot:latest
|
||||
container_name: manacore-matrix-nutriphi-bot
|
||||
restart: always
|
||||
depends_on:
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
nutriphi-backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3316
|
||||
TZ: Europe/Berlin
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN}
|
||||
MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-}
|
||||
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3023
|
||||
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
|
||||
volumes:
|
||||
- matrix_nutriphi_bot_data:/app/data
|
||||
ports:
|
||||
- "3316:3316"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3316/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Auto-Update (Watchtower)
|
||||
# ============================================
|
||||
|
|
@ -1052,5 +1114,9 @@ volumes:
|
|||
name: manacore-matrix-stats-bot
|
||||
matrix_project_doc_bot_data:
|
||||
name: manacore-matrix-project-doc-bot
|
||||
matrix_calendar_bot_data:
|
||||
name: manacore-matrix-calendar-bot
|
||||
matrix_todo_bot_data:
|
||||
name: manacore-matrix-todo-bot
|
||||
matrix_nutriphi_bot_data:
|
||||
name: manacore-matrix-nutriphi-bot
|
||||
|
|
|
|||
815
pnpm-lock.yaml
generated
815
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
6
services/matrix-calendar-bot/.dockerignore
Normal file
6
services/matrix-calendar-bot/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env*
|
||||
data
|
||||
178
services/matrix-calendar-bot/CLAUDE.md
Normal file
178
services/matrix-calendar-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# Matrix Calendar Bot - Claude Code Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Matrix Calendar Bot provides a GDPR-compliant calendar/event management interface via Matrix chat. It uses the Matrix protocol for messaging, allowing self-hosting all data on the Mac Mini server.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Storage**: Local JSON file (per-user events)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm install
|
||||
pnpm start:dev # Start with hot reload
|
||||
|
||||
# Build
|
||||
pnpm build # Production build
|
||||
|
||||
# Type check
|
||||
pnpm type-check # Check TypeScript types
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
services/matrix-calendar-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Application entry point
|
||||
│ ├── app.module.ts # Root module
|
||||
│ ├── health.controller.ts # Health check endpoint
|
||||
│ ├── config/
|
||||
│ │ └── configuration.ts # Configuration & help texts
|
||||
│ ├── bot/
|
||||
│ │ ├── bot.module.ts
|
||||
│ │ └── matrix.service.ts # Matrix client & command handlers
|
||||
│ └── calendar/
|
||||
│ ├── calendar.module.ts
|
||||
│ └── calendar.service.ts # Event storage & management
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Matrix Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!help` | Show help message |
|
||||
| `!heute` / `!today` | Show today's events |
|
||||
| `!morgen` / `!tomorrow` | Show tomorrow's events |
|
||||
| `!woche` / `!week` | Show this week's events |
|
||||
| `!termine` | Show next 14 days |
|
||||
| `!termin [...]` | Create new event |
|
||||
| `!details [nr]` | Show event details |
|
||||
| `!löschen [nr]` | Delete event |
|
||||
| `!kalender` | Show calendars |
|
||||
| `!status` | Bot status |
|
||||
| `!pin` | Pin help to room |
|
||||
|
||||
## Natural Language Keywords
|
||||
|
||||
The bot also responds to natural language (German + English):
|
||||
- "hilfe", "help" → Show help
|
||||
- "was steht heute an", "termine heute" → Today's events
|
||||
- "termine morgen" → Tomorrow's events
|
||||
- "diese woche", "wochenübersicht" → Week events
|
||||
- "zeige kalender" → Show calendars
|
||||
|
||||
## Event Input Syntax
|
||||
|
||||
```
|
||||
!termin Meeting am 15.02. um 14:00
|
||||
│ │ └── Time (optional, defaults to 9:00)
|
||||
│ └── Date (DD.MM. or DD.MM.YYYY)
|
||||
└── Event title
|
||||
|
||||
!termin Zahnarzt morgen um 10:30
|
||||
│ └── Time
|
||||
└── Relative date (heute, morgen, übermorgen)
|
||||
|
||||
!termin Geburtstag am 20.03. ganztägig
|
||||
└── All-day event flag
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3315
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#calendar-bot:mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Calendar API (optional, for future integration)
|
||||
CALENDAR_API_URL=http://localhost:3016/api/v1
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -f services/matrix-calendar-bot/Dockerfile -t matrix-calendar-bot services/matrix-calendar-bot
|
||||
|
||||
# Run
|
||||
docker run -p 3315:3315 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-v matrix-calendar-bot-data:/app/data \
|
||||
matrix-calendar-bot
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3315/health
|
||||
```
|
||||
|
||||
## Getting a Matrix Access Token
|
||||
|
||||
```bash
|
||||
# Login to get access token
|
||||
curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "m.login.password",
|
||||
"user": "calendar-bot",
|
||||
"password": "your-password"
|
||||
}'
|
||||
|
||||
# Response contains: {"access_token": "syt_xxx", ...}
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
|
||||
Events are stored in a local JSON file (`/app/data/calendar-data.json`) with per-user isolation.
|
||||
|
||||
Structure:
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Event title",
|
||||
"description": null,
|
||||
"location": null,
|
||||
"startTime": "2024-02-15T14:00:00.000Z",
|
||||
"endTime": "2024-02-15T15:00:00.000Z",
|
||||
"isAllDay": false,
|
||||
"calendarId": "cal-id",
|
||||
"calendarName": "Mein Kalender",
|
||||
"createdAt": "2024-01-27T10:00:00Z",
|
||||
"userId": "@user:mana.how"
|
||||
}
|
||||
],
|
||||
"calendars": [
|
||||
{
|
||||
"id": "cal-id",
|
||||
"name": "Mein Kalender",
|
||||
"color": "#3B82F6",
|
||||
"userId": "@user:mana.how"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## GDPR Compliance
|
||||
|
||||
- All event data stored locally on Mac Mini
|
||||
- No third-party data processing
|
||||
- Full control over data retention
|
||||
- Per-user data isolation via Matrix user IDs
|
||||
- Can delete all user data on request
|
||||
48
services/matrix-calendar-bot/Dockerfile
Normal file
48
services/matrix-calendar-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies for build)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN rm -rf dist && npx tsc -p tsconfig.build.json
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create data directory for storage
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001 && \
|
||||
chown -R nestjs:nodejs /app
|
||||
|
||||
USER nestjs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3315/health || exit 1
|
||||
|
||||
EXPOSE 3315
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
services/matrix-calendar-bot/nest-cli.json
Normal file
8
services/matrix-calendar-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
44
services/matrix-calendar-bot/package.json
Normal file
44
services/matrix-calendar-bot/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@manacore/matrix-calendar-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Matrix bot for calendar management - GDPR compliant",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs"
|
||||
],
|
||||
"overrides": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "rm -rf dist || true",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"matrix-bot-sdk": "^0.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
19
services/matrix-calendar-bot/src/app.module.ts
Normal file
19
services/matrix-calendar-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import configuration from './config/configuration';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { CalendarModule } from './calendar/calendar.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
BotModule,
|
||||
CalendarModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
10
services/matrix-calendar-bot/src/bot/bot.module.ts
Normal file
10
services/matrix-calendar-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
|
||||
@Module({
|
||||
imports: [CalendarModule],
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class BotModule {}
|
||||
550
services/matrix-calendar-bot/src/bot/matrix.service.ts
Normal file
550
services/matrix-calendar-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
AutojoinRoomsMixin,
|
||||
RichReply,
|
||||
} from 'matrix-bot-sdk';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { CalendarService, CalendarEvent } from '../calendar/calendar.service';
|
||||
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } 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: ['was steht heute an', 'termine heute', 'heute termine', "today's events"],
|
||||
command: 'today',
|
||||
},
|
||||
{ keywords: ['termine morgen', 'morgen termine', 'was ist morgen'], command: 'tomorrow' },
|
||||
{
|
||||
keywords: ['diese woche', 'wochenübersicht', 'week', 'woche'],
|
||||
command: 'week',
|
||||
},
|
||||
{ keywords: ['zeige kalender', 'meine kalender', 'calendars'], command: 'calendars' },
|
||||
{ keywords: ['status', 'verbindung', 'connection'], command: 'status' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MatrixService.name);
|
||||
private client: MatrixClient;
|
||||
private readonly homeserverUrl: string;
|
||||
private readonly accessToken: string;
|
||||
private readonly allowedRooms: string[];
|
||||
private readonly storagePath: string;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private calendarService: CalendarService
|
||||
) {
|
||||
this.homeserverUrl = this.configService.get<string>(
|
||||
'matrix.homeserverUrl',
|
||||
'http://localhost:8008'
|
||||
);
|
||||
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
|
||||
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
|
||||
this.storagePath = this.configService.get<string>(
|
||||
'matrix.storagePath',
|
||||
'./data/bot-storage.json'
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!this.accessToken) {
|
||||
this.logger.warn('No Matrix access token configured. Bot will not start.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initializeClient();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.client) {
|
||||
await this.client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeClient() {
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
const storageDir = path.dirname(this.storagePath);
|
||||
if (!fs.existsSync(storageDir)) {
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = new SimpleFsStorageProvider(this.storagePath);
|
||||
this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage);
|
||||
|
||||
// Auto-join rooms when invited
|
||||
AutojoinRoomsMixin.setupOnClient(this.client);
|
||||
|
||||
// Handle room invites with introduction
|
||||
this.client.on('room.invite', async (roomId: string) => {
|
||||
this.logger.log(`Invited to room ${roomId}, joining...`);
|
||||
await this.client.joinRoom(roomId);
|
||||
|
||||
// Send introduction after a short delay
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.sendBotIntroduction(roomId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send introduction to ${roomId}:`, error);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Handle member joins for welcome message
|
||||
this.client.on('room.event', async (roomId: string, event: any) => {
|
||||
if (event.type === 'm.room.member' && event.content?.membership === 'join') {
|
||||
const odUser = event.state_key;
|
||||
const botUserId = await this.client.getUserId();
|
||||
|
||||
// Don't welcome the bot itself
|
||||
if (odUser === botUserId) return;
|
||||
|
||||
// Check if this is a new join (not just profile update)
|
||||
if (event.unsigned?.prev_content?.membership !== 'join') {
|
||||
await this.sendWelcomeMessage(roomId, odUser);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up message handler
|
||||
this.client.on('room.message', async (roomId: string, event: any) => {
|
||||
await this.handleMessage(roomId, event);
|
||||
});
|
||||
|
||||
await this.client.start();
|
||||
this.logger.log(`Matrix Calendar Bot connected to ${this.homeserverUrl}`);
|
||||
|
||||
const odUser = await this.client.getUserId();
|
||||
this.logger.log(`Bot user ID: ${odUser}`);
|
||||
|
||||
if (this.allowedRooms.length > 0) {
|
||||
this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`);
|
||||
} else {
|
||||
this.logger.log('No room restrictions - bot will respond in all rooms');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Matrix client:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(roomId: string, event: any) {
|
||||
// Ignore messages from the bot itself
|
||||
const botUserId = await this.client.getUserId();
|
||||
if (event.sender === botUserId) return;
|
||||
|
||||
// Check if room is allowed
|
||||
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
|
||||
this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle text messages
|
||||
if (event.content?.msgtype !== 'm.text') return;
|
||||
|
||||
const body = event.content.body?.trim();
|
||||
if (!body) return;
|
||||
|
||||
const odUser = event.sender;
|
||||
|
||||
try {
|
||||
// Check for ! commands first (before keyword detection)
|
||||
if (body.startsWith('!')) {
|
||||
const [command, ...args] = body.slice(1).split(' ');
|
||||
await this.executeCommand(roomId, event, odUser, command.toLowerCase(), args.join(' '));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for natural language keywords
|
||||
const keywordCommand = this.detectKeywordCommand(body);
|
||||
if (keywordCommand) {
|
||||
await this.executeCommand(roomId, event, odUser, keywordCommand, '');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling message: ${error}`);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private detectKeywordCommand(message: string): string | null {
|
||||
const lowerMessage = message.toLowerCase().trim();
|
||||
|
||||
// Only check short messages for keywords
|
||||
if (lowerMessage.length > 60) return null;
|
||||
|
||||
for (const { keywords, command } of KEYWORD_COMMANDS) {
|
||||
for (const keyword of keywords) {
|
||||
if (
|
||||
lowerMessage === keyword ||
|
||||
lowerMessage.startsWith(keyword + ' ') ||
|
||||
lowerMessage.includes(keyword)
|
||||
) {
|
||||
this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async executeCommand(
|
||||
roomId: string,
|
||||
event: any,
|
||||
userId: string,
|
||||
command: string,
|
||||
args: string
|
||||
) {
|
||||
switch (command) {
|
||||
case 'help':
|
||||
case 'hilfe':
|
||||
await this.sendReply(roomId, event, HELP_TEXT);
|
||||
break;
|
||||
|
||||
case 'heute':
|
||||
case 'today':
|
||||
await this.handleTodayEvents(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'morgen':
|
||||
case 'tomorrow':
|
||||
await this.handleTomorrowEvents(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'woche':
|
||||
case 'week':
|
||||
await this.handleWeekEvents(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'termine':
|
||||
case 'events':
|
||||
case 'upcoming':
|
||||
await this.handleUpcomingEvents(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'termin':
|
||||
case 'event':
|
||||
case 'neu':
|
||||
case 'add':
|
||||
await this.handleCreateEvent(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'details':
|
||||
case 'info':
|
||||
await this.handleEventDetails(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'löschen':
|
||||
case 'delete':
|
||||
case 'entfernen':
|
||||
await this.handleDeleteEvent(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'kalender':
|
||||
case 'calendars':
|
||||
await this.handleCalendars(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
await this.handleStatus(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'pin':
|
||||
await this.handlePinHelp(roomId, event);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown command - ignore silently
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTodayEvents(roomId: string, event: any, userId: string) {
|
||||
const events = await this.calendarService.getTodayEvents(userId);
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine Termine für heute.\n\nErstelle einen mit `!termin Titel heute um 14:00`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatEventList('📅 **Termine heute:**', events);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleTomorrowEvents(roomId: string, event: any, userId: string) {
|
||||
const events = await this.calendarService.getTomorrowEvents(userId);
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine Termine für morgen.\n\nErstelle einen mit `!termin Titel morgen um 10:00`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatEventList('📅 **Termine morgen:**', events);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleWeekEvents(roomId: string, event: any, userId: string) {
|
||||
const events = await this.calendarService.getWeekEvents(userId);
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine Termine diese Woche.\n\nErstelle einen mit `!termin Titel am 20.02. um 14:00`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatEventList('📅 **Termine diese Woche:**', events);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleUpcomingEvents(roomId: string, event: any, userId: string) {
|
||||
const events = await this.calendarService.getUpcomingEvents(userId, 14);
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine anstehenden Termine.\n\nErstelle einen mit `!termin Meeting am 15.02. um 14:00`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatEventList('📅 **Anstehende Termine:**', events);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleCreateEvent(roomId: string, event: any, userId: string, input: string) {
|
||||
if (!input.trim()) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib einen Termin an.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Geburtstag am 20.03. ganztägig`\n• `!termin Zahnarzt am 15.02. um 10:30`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, startTime, endTime, isAllDay } = this.calendarService.parseEventInput(input);
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Konnte Datum/Uhrzeit nicht erkennen.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Arzt am 15.02. um 10:00`\n• `!termin Urlaub am 01.03. ganztägig`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.');
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarEvent = await this.calendarService.createEvent(
|
||||
userId,
|
||||
title,
|
||||
startTime,
|
||||
endTime,
|
||||
{
|
||||
isAllDay,
|
||||
}
|
||||
);
|
||||
|
||||
const timeStr = this.calendarService.formatEventTime(calendarEvent);
|
||||
await this.sendReply(roomId, event, `✅ Termin erstellt: **${title}**\n📆 ${timeStr}`);
|
||||
}
|
||||
|
||||
private async handleEventDetails(roomId: string, event: any, userId: string, args: string) {
|
||||
const eventNumber = parseInt(args.trim());
|
||||
|
||||
if (isNaN(eventNumber) || eventNumber < 1) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!details 1`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarEvent = await this.calendarService.getEventByIndex(userId, eventNumber);
|
||||
|
||||
if (!calendarEvent) {
|
||||
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeStr = this.calendarService.formatEventTime(calendarEvent);
|
||||
let response = `📅 **${calendarEvent.title}**\n\n`;
|
||||
response += `🕐 ${timeStr}\n`;
|
||||
response += `📁 Kalender: ${calendarEvent.calendarName}\n`;
|
||||
|
||||
if (calendarEvent.location) {
|
||||
response += `📍 Ort: ${calendarEvent.location}\n`;
|
||||
}
|
||||
|
||||
if (calendarEvent.description) {
|
||||
response += `\n📝 ${calendarEvent.description}`;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleDeleteEvent(roomId: string, event: any, userId: string, args: string) {
|
||||
const eventNumber = parseInt(args.trim());
|
||||
|
||||
if (isNaN(eventNumber) || eventNumber < 1) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!löschen 1`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedEvent = await this.calendarService.deleteEvent(userId, eventNumber);
|
||||
|
||||
if (!deletedEvent) {
|
||||
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, `🗑️ Gelöscht: ${deletedEvent.title}`);
|
||||
}
|
||||
|
||||
private async handleCalendars(roomId: string, event: any, userId: string) {
|
||||
const calendars = await this.calendarService.getCalendars(userId);
|
||||
|
||||
let response = '📁 **Deine Kalender:**\n\n';
|
||||
for (const calendar of calendars) {
|
||||
response += `• ${calendar.name}\n`;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleStatus(roomId: string, event: any, userId: string) {
|
||||
const events = await this.calendarService.getUpcomingEvents(userId, 7);
|
||||
const todayEvents = await this.calendarService.getTodayEvents(userId);
|
||||
|
||||
const response = `📊 **Status**
|
||||
|
||||
• Termine heute: ${todayEvents.length}
|
||||
• Termine nächste 7 Tage: ${events.length}
|
||||
|
||||
Bot: ✅ Online`;
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handlePinHelp(roomId: string, event: any) {
|
||||
try {
|
||||
// Send help message
|
||||
const helpEventId = await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: HELP_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(HELP_TEXT),
|
||||
});
|
||||
|
||||
// Pin it
|
||||
await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', {
|
||||
pinned: [helpEventId],
|
||||
});
|
||||
|
||||
await this.sendReply(roomId, event, '📌 Hilfe wurde angepinnt!');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to pin help:', error);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private formatEventList(header: string, events: CalendarEvent[]): string {
|
||||
let response = `${header}\n\n`;
|
||||
|
||||
events.forEach((event, index) => {
|
||||
const num = index + 1;
|
||||
const timeStr = this.calendarService.formatEventTime(event);
|
||||
response += `**${num}.** ${event.title}\n 🕐 ${timeStr}\n`;
|
||||
});
|
||||
|
||||
response += `\n📋 Details: \`!details [Nr]\` | 🗑️ Löschen: \`!löschen [Nr]\``;
|
||||
return response;
|
||||
}
|
||||
|
||||
private async sendReply(roomId: string, event: any, message: string) {
|
||||
const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message));
|
||||
reply.msgtype = 'm.text';
|
||||
await this.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
private async sendWelcomeMessage(roomId: string, odUser: string) {
|
||||
try {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: WELCOME_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(WELCOME_TEXT),
|
||||
});
|
||||
this.logger.log(`Sent welcome message to ${odUser} in ${roomId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send welcome message: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBotIntroduction(roomId: string) {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: BOT_INTRODUCTION,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(BOT_INTRODUCTION),
|
||||
});
|
||||
|
||||
// Try to pin the help message
|
||||
try {
|
||||
const helpEventId = await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: HELP_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(HELP_TEXT),
|
||||
});
|
||||
|
||||
await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', {
|
||||
pinned: [helpEventId],
|
||||
});
|
||||
this.logger.log(`Pinned help message in ${roomId}`);
|
||||
} catch (error) {
|
||||
this.logger.debug(`Could not pin help (might lack permissions): ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private markdownToHtml(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CalendarService } from './calendar.service';
|
||||
|
||||
@Module({
|
||||
providers: [CalendarService],
|
||||
exports: [CalendarService],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
321
services/matrix-calendar-bot/src/calendar/calendar.service.ts
Normal file
321
services/matrix-calendar-bot/src/calendar/calendar.service.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
startTime: string; // ISO datetime
|
||||
endTime: string; // ISO datetime
|
||||
isAllDay: boolean;
|
||||
calendarId: string;
|
||||
calendarName: string;
|
||||
createdAt: string;
|
||||
userId: string; // Matrix user ID
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface CalendarData {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService implements OnModuleInit {
|
||||
private readonly logger = new Logger(CalendarService.name);
|
||||
private data: CalendarData = { events: [], calendars: [] };
|
||||
private dataPath: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const storagePath = this.configService.get<string>(
|
||||
'matrix.storagePath',
|
||||
'./data/bot-storage.json'
|
||||
);
|
||||
this.dataPath = storagePath.replace('bot-storage.json', 'calendar-data.json');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(this.dataPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this.dataPath)) {
|
||||
const content = fs.readFileSync(this.dataPath, 'utf-8');
|
||||
this.data = JSON.parse(content);
|
||||
this.logger.log(
|
||||
`Loaded ${this.data.events.length} events, ${this.data.calendars.length} calendars`
|
||||
);
|
||||
} else {
|
||||
this.data = { events: [], calendars: [] };
|
||||
await this.saveData();
|
||||
this.logger.log('Created new calendar data file');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load calendar data:', error);
|
||||
this.data = { events: [], calendars: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
fs.writeFileSync(this.dataPath, JSON.stringify(this.data, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save calendar data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
private ensureDefaultCalendar(userId: string): Calendar {
|
||||
let calendar = this.data.calendars.find((c) => c.userId === userId);
|
||||
if (!calendar) {
|
||||
calendar = {
|
||||
id: this.generateId(),
|
||||
name: 'Mein Kalender',
|
||||
color: '#3B82F6',
|
||||
userId,
|
||||
};
|
||||
this.data.calendars.push(calendar);
|
||||
this.saveData();
|
||||
}
|
||||
return calendar;
|
||||
}
|
||||
|
||||
// Event operations
|
||||
|
||||
async createEvent(
|
||||
userId: string,
|
||||
title: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
options?: Partial<CalendarEvent>
|
||||
): Promise<CalendarEvent> {
|
||||
const calendar = this.ensureDefaultCalendar(userId);
|
||||
|
||||
const event: CalendarEvent = {
|
||||
id: this.generateId(),
|
||||
title,
|
||||
description: options?.description || null,
|
||||
location: options?.location || null,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
isAllDay: options?.isAllDay || false,
|
||||
calendarId: calendar.id,
|
||||
calendarName: calendar.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
userId,
|
||||
};
|
||||
|
||||
this.data.events.push(event);
|
||||
await this.saveData();
|
||||
this.logger.log(`Created event "${title}" for user ${userId}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
async getTodayEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
return this.getEventsInRange(userId, today, tomorrow);
|
||||
}
|
||||
|
||||
async getTomorrowEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
const dayAfter = new Date(tomorrow);
|
||||
dayAfter.setDate(dayAfter.getDate() + 1);
|
||||
|
||||
return this.getEventsInRange(userId, tomorrow, dayAfter);
|
||||
}
|
||||
|
||||
async getWeekEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
|
||||
return this.getEventsInRange(userId, today, weekEnd);
|
||||
}
|
||||
|
||||
async getUpcomingEvents(userId: string, days: number = 7): Promise<CalendarEvent[]> {
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
|
||||
return this.getEventsInRange(userId, now, endDate);
|
||||
}
|
||||
|
||||
private getEventsInRange(userId: string, start: Date, end: Date): CalendarEvent[] {
|
||||
return this.data.events
|
||||
.filter((e) => {
|
||||
if (e.userId !== userId) return false;
|
||||
const eventStart = new Date(e.startTime);
|
||||
const eventEnd = new Date(e.endTime);
|
||||
// Event overlaps with range
|
||||
return eventStart < end && eventEnd > start;
|
||||
})
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
}
|
||||
|
||||
async getEventByIndex(userId: string, index: number): Promise<CalendarEvent | null> {
|
||||
const events = await this.getUpcomingEvents(userId, 30);
|
||||
if (index < 1 || index > events.length) {
|
||||
return null;
|
||||
}
|
||||
return events[index - 1];
|
||||
}
|
||||
|
||||
async deleteEvent(userId: string, eventIndex: number): Promise<CalendarEvent | null> {
|
||||
const events = await this.getUpcomingEvents(userId, 30);
|
||||
if (eventIndex < 1 || eventIndex > events.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = events[eventIndex - 1];
|
||||
this.data.events = this.data.events.filter((e) => e.id !== event.id);
|
||||
await this.saveData();
|
||||
this.logger.log(`Deleted event "${event.title}" for user ${userId}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
// Calendar operations
|
||||
|
||||
async getCalendars(userId: string): Promise<Calendar[]> {
|
||||
this.ensureDefaultCalendar(userId);
|
||||
return this.data.calendars.filter((c) => c.userId === userId);
|
||||
}
|
||||
|
||||
// Parse natural language date/time input
|
||||
parseEventInput(input: string): {
|
||||
title: string;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
isAllDay: boolean;
|
||||
} {
|
||||
let title = input;
|
||||
let startTime: Date | null = null;
|
||||
let endTime: Date | null = null;
|
||||
let isAllDay = false;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Check for "ganztägig" (all-day)
|
||||
if (/ganztägig/i.test(title)) {
|
||||
isAllDay = true;
|
||||
title = title.replace(/ganztägig/gi, '').trim();
|
||||
}
|
||||
|
||||
// Parse date patterns
|
||||
// "am DD.MM." or "am DD.MM.YYYY"
|
||||
const dateMatch = title.match(/am\s+(\d{1,2})\.(\d{1,2})\.?(\d{4})?/i);
|
||||
// "heute", "morgen", "übermorgen"
|
||||
const relativeMatch = title.match(/(heute|morgen|übermorgen)/i);
|
||||
// Time: "um HH:MM" or "um HH Uhr"
|
||||
const timeMatch = title.match(/um\s+(\d{1,2})[:.]?(\d{2})?\s*(uhr)?/i);
|
||||
|
||||
if (dateMatch) {
|
||||
const day = parseInt(dateMatch[1]);
|
||||
const month = parseInt(dateMatch[2]) - 1;
|
||||
const year = dateMatch[3] ? parseInt(dateMatch[3]) : now.getFullYear();
|
||||
|
||||
startTime = new Date(year, month, day);
|
||||
|
||||
// If date is in the past this year, assume next year
|
||||
if (startTime < now && !dateMatch[3]) {
|
||||
startTime.setFullYear(startTime.getFullYear() + 1);
|
||||
}
|
||||
|
||||
title = title.replace(/am\s+\d{1,2}\.\d{1,2}\.?\d{0,4}/i, '').trim();
|
||||
} else if (relativeMatch) {
|
||||
const relative = relativeMatch[1].toLowerCase();
|
||||
startTime = new Date();
|
||||
startTime.setHours(0, 0, 0, 0);
|
||||
|
||||
if (relative === 'morgen') {
|
||||
startTime.setDate(startTime.getDate() + 1);
|
||||
} else if (relative === 'übermorgen') {
|
||||
startTime.setDate(startTime.getDate() + 2);
|
||||
}
|
||||
|
||||
title = title.replace(/(heute|morgen|übermorgen)/i, '').trim();
|
||||
}
|
||||
|
||||
if (timeMatch && startTime) {
|
||||
const hours = parseInt(timeMatch[1]);
|
||||
const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
|
||||
|
||||
startTime.setHours(hours, minutes, 0, 0);
|
||||
isAllDay = false;
|
||||
|
||||
title = title.replace(/um\s+\d{1,2}[:.]?\d{0,2}\s*(uhr)?/i, '').trim();
|
||||
} else if (startTime && !isAllDay) {
|
||||
// Default to 9:00 if no time specified
|
||||
startTime.setHours(9, 0, 0, 0);
|
||||
}
|
||||
|
||||
// Set end time (1 hour later for timed events, end of day for all-day)
|
||||
if (startTime) {
|
||||
endTime = new Date(startTime);
|
||||
if (isAllDay) {
|
||||
endTime.setHours(23, 59, 59, 999);
|
||||
} else {
|
||||
endTime.setHours(endTime.getHours() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
title = title.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { title, startTime, endTime, isAllDay };
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.startTime);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const eventDate = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
||||
|
||||
let dateStr: string;
|
||||
if (eventDate.getTime() === today.getTime()) {
|
||||
dateStr = 'Heute';
|
||||
} else if (eventDate.getTime() === tomorrow.getTime()) {
|
||||
dateStr = 'Morgen';
|
||||
} else {
|
||||
dateStr = start.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dateStr} (ganztägig)`;
|
||||
}
|
||||
|
||||
const timeStr = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${dateStr}, ${timeStr}`;
|
||||
}
|
||||
}
|
||||
61
services/matrix-calendar-bot/src/config/configuration.ts
Normal file
61
services/matrix-calendar-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3315', 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',
|
||||
},
|
||||
calendar: {
|
||||
apiUrl: process.env.CALENDAR_API_URL || 'http://localhost:3016/api/v1',
|
||||
},
|
||||
});
|
||||
|
||||
export const HELP_TEXT = `📅 **Calendar Bot - Hilfe**
|
||||
|
||||
**Termine anzeigen:**
|
||||
• \`!heute\` - Termine für heute
|
||||
• \`!morgen\` - Termine für morgen
|
||||
• \`!woche\` - Termine diese Woche
|
||||
• \`!termine\` - Nächste 7 Tage
|
||||
|
||||
**Termine erstellen:**
|
||||
• \`!termin [Titel] am [Datum] um [Uhrzeit]\`
|
||||
• \`!termin Meeting am 15.02. um 14:00\`
|
||||
• \`!termin Zahnarzt morgen um 10:30\`
|
||||
• \`!termin Geburtstag am 20.03. ganztägig\`
|
||||
|
||||
**Termine verwalten:**
|
||||
• \`!details [Nr]\` - Details zu einem Termin
|
||||
• \`!löschen [Nr]\` - Termin löschen
|
||||
|
||||
**Kalender:**
|
||||
• \`!kalender\` - Deine Kalender anzeigen
|
||||
|
||||
**Sonstiges:**
|
||||
• \`!status\` - Verbindungsstatus
|
||||
• \`!help\` oder \`hilfe\` - Diese Hilfe
|
||||
|
||||
**Natürliche Sprache:**
|
||||
Du kannst auch "was steht heute an?", "termine morgen" oder "zeige kalender" schreiben.`;
|
||||
|
||||
export const WELCOME_TEXT = `👋 **Willkommen beim Calendar Bot!**
|
||||
|
||||
Ich helfe dir, deine Termine zu verwalten. Hier sind die wichtigsten Befehle:
|
||||
|
||||
• \`!heute\` - Heutige Termine
|
||||
• \`!termin Meeting morgen um 14:00\` - Termin erstellen
|
||||
• \`!woche\` - Wochenübersicht
|
||||
|
||||
Schreibe \`!help\` oder einfach "hilfe" für alle Befehle.`;
|
||||
|
||||
export const BOT_INTRODUCTION = `📅 **Hallo! Ich bin der Calendar Bot.**
|
||||
|
||||
Ich bin jetzt diesem Raum beigetreten und kann dir bei der Terminverwaltung helfen.
|
||||
|
||||
**Schnellstart:**
|
||||
• \`!heute\` - Was steht heute an?
|
||||
• \`!termin Arzt morgen um 10:00\` - Termin erstellen
|
||||
• \`!woche\` - Wochenübersicht
|
||||
|
||||
Schreibe \`!help\` für alle Befehle!`;
|
||||
13
services/matrix-calendar-bot/src/health.controller.ts
Normal file
13
services/matrix-calendar-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: 'matrix-calendar-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
services/matrix-calendar-bot/src/main.ts
Normal file
17
services/matrix-calendar-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
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', 3315);
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Calendar Bot is running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
4
services/matrix-calendar-bot/tsconfig.build.json
Normal file
4
services/matrix-calendar-bot/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
22
services/matrix-calendar-bot/tsconfig.json
Normal file
22
services/matrix-calendar-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
20
services/matrix-nutriphi-bot/.env.example
Normal file
20
services/matrix-nutriphi-bot/.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Server
|
||||
PORT=3316
|
||||
NODE_ENV=development
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token
|
||||
MATRIX_ALLOWED_ROOMS=#nutriphi:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# NutriPhi Backend
|
||||
NUTRIPHI_BACKEND_URL=http://localhost:3023
|
||||
NUTRIPHI_API_PREFIX=/api/v1
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Development bypass (optional)
|
||||
DEV_BYPASS_AUTH=false
|
||||
DEV_USER_ID=
|
||||
156
services/matrix-nutriphi-bot/CLAUDE.md
Normal file
156
services/matrix-nutriphi-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Matrix NutriPhi Bot - Claude Code Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Matrix NutriPhi Bot is a Matrix chat bot for AI-powered nutrition tracking. It integrates with the NutriPhi backend to analyze meal photos and text descriptions, track daily nutrition, and provide personalized recommendations.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Backend**: NutriPhi API (port 3023)
|
||||
- **Auth**: Mana Core Auth (JWT)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm install
|
||||
pnpm start:dev # Start with hot reload
|
||||
|
||||
# Build
|
||||
pnpm build # Production build
|
||||
|
||||
# Type check
|
||||
pnpm type-check # Check TypeScript types
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
services/matrix-nutriphi-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Application entry point (port 3316)
|
||||
│ ├── app.module.ts # Root module
|
||||
│ ├── health.controller.ts # Health check endpoint
|
||||
│ ├── config/
|
||||
│ │ └── configuration.ts # Configuration & help messages
|
||||
│ ├── bot/
|
||||
│ │ ├── bot.module.ts
|
||||
│ │ └── matrix.service.ts # Matrix client & command handlers
|
||||
│ ├── nutriphi/
|
||||
│ │ ├── nutriphi.module.ts
|
||||
│ │ └── nutriphi.service.ts # NutriPhi API client
|
||||
│ └── session/
|
||||
│ ├── session.module.ts
|
||||
│ └── session.service.ts # User session & auth management
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Bot Commands
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!help` | hilfe, help | Show help message |
|
||||
| `!login email pass` | - | Login to NutriPhi |
|
||||
| `!logout` | - | Logout |
|
||||
| `!analyze [text]` | - | Analyze photo or text |
|
||||
| `!today` | heute | Daily summary |
|
||||
| `!week` | woche | Weekly stats |
|
||||
| `!goals` | ziele | Show goals |
|
||||
| `!setgoals cal pro carb fat` | - | Set goals |
|
||||
| `!favorites` | favoriten | List favorites |
|
||||
| `!tips` | tipps | AI recommendations |
|
||||
| `!status` | - | Bot status |
|
||||
|
||||
## Image Analysis Flow
|
||||
|
||||
1. User sends image to room
|
||||
2. Bot stores image URL: "Bild empfangen!"
|
||||
3. User sends `!analyze` or `!analyze Beschreibung`
|
||||
4. Bot downloads image, sends to NutriPhi API
|
||||
5. Bot displays nutrition results
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3316
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#nutriphi:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# NutriPhi Backend
|
||||
NUTRIPHI_BACKEND_URL=http://localhost:3023
|
||||
NUTRIPHI_API_PREFIX=/api/v1
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Development bypass (optional)
|
||||
DEV_BYPASS_AUTH=false
|
||||
DEV_USER_ID=
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -f services/matrix-nutriphi-bot/Dockerfile -t matrix-nutriphi-bot services/matrix-nutriphi-bot
|
||||
|
||||
# Run
|
||||
docker run -p 3315:3315 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \
|
||||
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
|
||||
-v matrix-nutriphi-bot-data:/app/data \
|
||||
matrix-nutriphi-bot
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3316/health
|
||||
```
|
||||
|
||||
## Getting a Matrix Access Token
|
||||
|
||||
```bash
|
||||
# Create bot user first, then login
|
||||
curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "m.login.password",
|
||||
"user": "nutriphi-bot",
|
||||
"password": "your-password"
|
||||
}'
|
||||
|
||||
# Response contains: {"access_token": "syt_xxx", ...}
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. User sends `!login email password`
|
||||
2. Bot calls mana-core-auth `/api/v1/auth/login`
|
||||
3. JWT token stored in session (in-memory)
|
||||
4. Token used for all NutriPhi API calls
|
||||
5. Token expires after 7 days (re-login required)
|
||||
|
||||
## NutriPhi API Endpoints Used
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/health` | GET | Health check |
|
||||
| `/api/v1/analysis/photo` | POST | Analyze photo |
|
||||
| `/api/v1/analysis/text` | POST | Analyze text |
|
||||
| `/api/v1/meals` | POST | Create meal |
|
||||
| `/api/v1/goals` | GET/POST | User goals |
|
||||
| `/api/v1/stats/daily` | GET | Daily summary |
|
||||
| `/api/v1/stats/weekly` | GET | Weekly stats |
|
||||
| `/api/v1/favorites` | GET | List favorites |
|
||||
| `/api/v1/recommendations` | GET | AI tips |
|
||||
47
services/matrix-nutriphi-bot/Dockerfile
Normal file
47
services/matrix-nutriphi-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files (exclude pnpm-lock.yaml to use npm)
|
||||
COPY package.json ./
|
||||
|
||||
# Install dependencies using npm (more compatible with standard tooling)
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build using TypeScript
|
||||
RUN rm -rf dist && npx tsc -p tsconfig.build.json
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create data directory for bot storage
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nestjs
|
||||
RUN chown -R nestjs:nodejs /app
|
||||
USER nestjs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3316/health || exit 1
|
||||
|
||||
EXPOSE 3316
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
services/matrix-nutriphi-bot/nest-cli.json
Normal file
8
services/matrix-nutriphi-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
44
services/matrix-nutriphi-bot/package.json
Normal file
44
services/matrix-nutriphi-bot/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@manacore/matrix-nutriphi-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Matrix bot for NutriPhi - AI-powered nutrition tracking via Matrix",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs"
|
||||
],
|
||||
"overrides": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "rm -rf dist || true",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"matrix-bot-sdk": "^0.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
17
services/matrix-nutriphi-bot/src/app.module.ts
Normal file
17
services/matrix-nutriphi-bot/src/app.module.ts
Normal 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 {}
|
||||
11
services/matrix-nutriphi-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-nutriphi-bot/src/bot/bot.module.ts
Normal 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 {}
|
||||
706
services/matrix-nutriphi-bot/src/bot/matrix.service.ts
Normal file
706
services/matrix-nutriphi-bot/src/bot/matrix.service.ts
Normal 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/>')
|
||||
);
|
||||
}
|
||||
}
|
||||
48
services/matrix-nutriphi-bot/src/config/configuration.ts
Normal file
48
services/matrix-nutriphi-bot/src/config/configuration.ts
Normal 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',
|
||||
};
|
||||
13
services/matrix-nutriphi-bot/src/health.controller.ts
Normal file
13
services/matrix-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: 'matrix-nutriphi-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
services/matrix-nutriphi-bot/src/main.ts
Normal file
15
services/matrix-nutriphi-bot/src/main.ts
Normal 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();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { NutriPhiService } from './nutriphi.service';
|
||||
|
||||
@Module({
|
||||
providers: [NutriPhiService],
|
||||
exports: [NutriPhiService],
|
||||
})
|
||||
export class NutriPhiModule {}
|
||||
235
services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts
Normal file
235
services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
152
services/matrix-nutriphi-bot/src/session/session.service.ts
Normal file
152
services/matrix-nutriphi-bot/src/session/session.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
services/matrix-nutriphi-bot/tsconfig.build.json
Normal file
4
services/matrix-nutriphi-bot/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
22
services/matrix-nutriphi-bot/tsconfig.json
Normal file
22
services/matrix-nutriphi-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue