mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 07:59:39 +02:00
Merge pull request #26 from Memo-2023/claude/matrix-setup-documentation-Ketxw
feat: Add Matrix Mana Gateway Bot with unified bot architecture
This commit is contained in:
commit
8692690f51
54 changed files with 4946 additions and 0 deletions
|
|
@ -0,0 +1,268 @@
|
|||
---
|
||||
title: 'Matrix Mana Gateway Bot: Unified Bot Architecture'
|
||||
description: 'Einführung des matrix-mana-bot als zentraler Gateway mit Shared Business Logic Package für alle Matrix Bots'
|
||||
date: 2026-01-29
|
||||
author: 'Till Schneider'
|
||||
category: 'architecture'
|
||||
tags:
|
||||
[
|
||||
'matrix',
|
||||
'bot',
|
||||
'gateway',
|
||||
'architecture',
|
||||
'nestjs',
|
||||
'monorepo',
|
||||
'shared-packages',
|
||||
]
|
||||
featured: true
|
||||
commits: 3
|
||||
readTime: 8
|
||||
---
|
||||
|
||||
Einführung einer neuen Bot-Architektur mit dem **Matrix Mana Gateway Bot** - ein zentraler Bot, der alle Features vereint, während die Einzelbots weiterhin verfügbar bleiben.
|
||||
|
||||
---
|
||||
|
||||
## Das Problem
|
||||
|
||||
Bisher hatten wir **8 separate Matrix Bots**, jeder für eine spezifische Funktion:
|
||||
|
||||
- matrix-ollama-bot (AI Chat)
|
||||
- matrix-todo-bot (Aufgaben)
|
||||
- matrix-calendar-bot (Termine)
|
||||
- matrix-clock-bot (Timer/Alarme)
|
||||
- matrix-nutriphi-bot (Ernährung)
|
||||
- matrix-zitare-bot (Zitate)
|
||||
- matrix-stats-bot (Analytics)
|
||||
- matrix-project-doc-bot (Dokumentation)
|
||||
|
||||
**Nachteile:**
|
||||
- User müssen 8 verschiedene Bots einladen
|
||||
- Kein Cross-Feature-Support ("Erstelle Todo aus Kalender-Event")
|
||||
- Code-Duplikation zwischen Bots
|
||||
- 8 Matrix-Verbindungen zu Synapse
|
||||
- Hoher Ressourcenverbrauch
|
||||
|
||||
---
|
||||
|
||||
## Die Lösung: Hybrid-Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ User Wahl │
|
||||
├──────────────────────────────┬──────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ @mana:mana.how │ @todo:mana.how │
|
||||
│ (Gateway - alles) │ (Nur Todos) │
|
||||
│ │ │ │ │
|
||||
│ ▼ │ ▼ │
|
||||
│ ┌─────────────────┐ │ ┌─────────────────┐ │
|
||||
│ │ matrix-mana-bot │ │ │ matrix-todo-bot │ │
|
||||
│ └────────┬────────┘ │ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ @manacore/bot-services │ │
|
||||
│ │ (Shared Business Logic) │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User können wählen:**
|
||||
- **@mana** für alle Features in einem Bot
|
||||
- **@todo/@calendar/etc.** für dedizierte Nutzung
|
||||
|
||||
---
|
||||
|
||||
## Neues Package: @manacore/bot-services
|
||||
|
||||
Ein **Shared Package** mit transport-agnostischer Business Logic:
|
||||
|
||||
```
|
||||
packages/bot-services/
|
||||
├── src/
|
||||
│ ├── todo/
|
||||
│ │ ├── todo.service.ts # CRUD, Parsing, Stats
|
||||
│ │ ├── todo.module.ts # NestJS Module
|
||||
│ │ └── types.ts
|
||||
│ ├── calendar/
|
||||
│ │ ├── calendar.service.ts
|
||||
│ │ └── ...
|
||||
│ ├── ai/
|
||||
│ │ ├── ai.service.ts # Ollama Integration
|
||||
│ │ └── ...
|
||||
│ ├── clock/
|
||||
│ │ ├── clock.service.ts # Timer, Alarm, WorldClock
|
||||
│ │ └── ...
|
||||
│ └── shared/
|
||||
│ ├── storage.ts # File/Memory Provider
|
||||
│ └── utils.ts
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Kein Matrix-Code in Services
|
||||
- Testbar ohne Matrix
|
||||
- Wiederverwendbar in Gateway und Einzelbots
|
||||
- Pluggable Storage (File, Memory, Database)
|
||||
|
||||
### Beispiel: TodoService
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class TodoService {
|
||||
// Pure business logic - kein Matrix!
|
||||
|
||||
async createTask(userId: string, input: CreateTaskInput): Promise<Task> {
|
||||
const parsed = this.parseTaskInput(input.text);
|
||||
return this.storage.create({ userId, ...parsed });
|
||||
}
|
||||
|
||||
parseTaskInput(text: string): ParsedTask {
|
||||
// "Einkaufen !p1 @morgen #haushalt"
|
||||
// → { title: "Einkaufen", priority: 1, dueDate: "...", project: "haushalt" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gateway Bot: matrix-mana-bot
|
||||
|
||||
Der neue **Unified Gateway** kombiniert alle Features:
|
||||
|
||||
```
|
||||
services/matrix-mana-bot/
|
||||
├── src/
|
||||
│ ├── bot/
|
||||
│ │ ├── matrix.service.ts # Matrix-Verbindung
|
||||
│ │ └── command-router.service.ts # Routing
|
||||
│ ├── handlers/
|
||||
│ │ ├── ai.handler.ts # !model, !all, chat
|
||||
│ │ ├── todo.handler.ts # !todo, !list, !done
|
||||
│ │ ├── calendar.handler.ts # !cal, !event
|
||||
│ │ ├── clock.handler.ts # !timer, !alarm
|
||||
│ │ └── help.handler.ts
|
||||
│ └── orchestration/
|
||||
│ └── orchestration.service.ts # Cross-Feature AI
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
| Kategorie | Commands |
|
||||
|-----------|----------|
|
||||
| **AI Chat** | Einfach tippen, `!model`, `!models`, `!all` |
|
||||
| **Todos** | `!todo`, `!list`, `!today`, `!done`, `!delete` |
|
||||
| **Kalender** | `!cal`, `!week`, `!event` |
|
||||
| **Timer** | `!timer`, `!alarm`, `!time`, `!timers` |
|
||||
| **Smart** | `!summary`, `!ai-todo` |
|
||||
|
||||
### Cross-Feature Orchestration
|
||||
|
||||
Der große Vorteil des Gateways - Features die mehrere Services kombinieren:
|
||||
|
||||
```typescript
|
||||
// !summary - AI-generierte Tages-Zusammenfassung
|
||||
async dailySummary(ctx: CommandContext): Promise<string> {
|
||||
const [todoStats, todayTodos, todayEvents] = await Promise.all([
|
||||
this.todoService.getStats(ctx.userId),
|
||||
this.todoService.getTodayTasks(ctx.userId),
|
||||
this.calendarService.getTodayEvents(ctx.userId),
|
||||
]);
|
||||
|
||||
const prompt = `Erstelle eine motivierende Tages-Zusammenfassung:
|
||||
Todos: ${todoStats.pending} offen, ${todoStats.completed} erledigt
|
||||
Termine: ${todayEvents.map(e => e.title).join(', ')}`;
|
||||
|
||||
return this.aiService.chat(ctx.userId, prompt);
|
||||
}
|
||||
|
||||
// !ai-todo - AI extrahiert Todos aus Text
|
||||
async aiToTodos(ctx: CommandContext, text: string): Promise<string> {
|
||||
const extracted = await this.aiService.extract(text);
|
||||
for (const todo of extracted) {
|
||||
await this.todoService.createTask(ctx.userId, todo);
|
||||
}
|
||||
return `✅ ${extracted.length} Todos erstellt`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup & Deployment
|
||||
|
||||
### Bot registrieren
|
||||
|
||||
```bash
|
||||
./scripts/mac-mini/setup-mana-bot.sh
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
matrix-mana-bot:
|
||||
image: matrix-mana-bot:latest
|
||||
environment:
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_MANA_BOT_TOKEN}
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
CLOCK_API_URL: http://matrix-clock-bot:3318/api/v1
|
||||
volumes:
|
||||
- matrix_mana_bot_data:/app/data
|
||||
ports:
|
||||
- "3310:3310"
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
pnpm dev:matrix:mana # Gateway starten
|
||||
pnpm dev:matrix:todo # Todo-Bot starten
|
||||
pnpm build:matrix:all # Alle Bots bauen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Entscheidungen
|
||||
|
||||
### Warum Hybrid statt nur Gateway?
|
||||
|
||||
| Aspekt | Nur Gateway | Nur Einzelbots | Hybrid ✓ |
|
||||
|--------|-------------|----------------|----------|
|
||||
| User Experience | ⭐⭐⭐ Einfach | ⭐ Komplex | ⭐⭐⭐ Flexibel |
|
||||
| Cross-Features | ✅ Ja | ❌ Nein | ✅ Ja |
|
||||
| Fehler-Isolation | ❌ | ✅ | ✅ |
|
||||
| Power-User | ❌ | ✅ | ✅ |
|
||||
| Ressourcen | ⭐⭐⭐ | ⭐ | ⭐⭐ |
|
||||
|
||||
### Warum Shared Package?
|
||||
|
||||
- **Kein Code-Duplikation** - Services einmal geschrieben
|
||||
- **Testbarkeit** - Services ohne Matrix testbar
|
||||
- **Flexibilität** - Neue Clients (CLI, Web) nutzen gleiche Logic
|
||||
- **Konsistenz** - Gleiche Daten in Gateway und Einzelbots
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. **Bestehende Bots refactoren** um `@manacore/bot-services` zu nutzen
|
||||
2. **Weitere Services implementieren** (Nutrition, Quotes, Stats, Docs)
|
||||
3. **E2EE Support** für verschlüsselte Räume
|
||||
4. **Reactions** für Feedback (`✅` = verstanden)
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Mit dem **matrix-mana-bot** haben wir jetzt:
|
||||
|
||||
- ✅ Einen zentralen Bot für alle Features
|
||||
- ✅ Shared Business Logic Package
|
||||
- ✅ Cross-Feature AI-Orchestration
|
||||
- ✅ Weiterhin Einzelbots für Power-User
|
||||
- ✅ DSGVO-konform (Self-Hosted)
|
||||
- ✅ Natürliche Sprache + Commands
|
||||
|
|
@ -869,6 +869,42 @@ services:
|
|||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ============================================
|
||||
# Matrix Mana Bot (Unified Gateway - All Features)
|
||||
# ============================================
|
||||
|
||||
matrix-mana-bot:
|
||||
image: matrix-mana-bot:latest
|
||||
container_name: manacore-matrix-mana-bot
|
||||
restart: always
|
||||
depends_on:
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3310
|
||||
TZ: Europe/Berlin
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_MANA_BOT_TOKEN}
|
||||
MATRIX_ALLOWED_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-}
|
||||
MATRIX_STORAGE_PATH: /app/data/mana-bot-storage.json
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b}
|
||||
OLLAMA_TIMEOUT: 120000
|
||||
CLOCK_API_URL: http://matrix-clock-bot:3318/api/v1
|
||||
TODO_STORAGE_PATH: /app/data/todos.json
|
||||
CALENDAR_STORAGE_PATH: /app/data/calendar.json
|
||||
volumes:
|
||||
- matrix_mana_bot_data:/app/data
|
||||
ports:
|
||||
- "3310:3310"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3310/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Matrix Ollama Bot (GDPR-compliant AI Chat)
|
||||
# ============================================
|
||||
|
|
@ -1172,6 +1208,8 @@ volumes:
|
|||
name: manacore-n8n
|
||||
synapse_data:
|
||||
name: manacore-synapse
|
||||
matrix_mana_bot_data:
|
||||
name: manacore-matrix-mana-bot
|
||||
matrix_ollama_bot_data:
|
||||
name: manacore-matrix-ollama-bot
|
||||
matrix_stats_bot_data:
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -228,6 +228,16 @@
|
|||
"dev:nutriphi-bot:full": "./scripts/setup-databases.sh nutriphi_bot && pnpm dev:nutriphi-bot",
|
||||
"nutriphi-bot:db:push": "pnpm --filter @manacore/telegram-nutriphi-bot db:push",
|
||||
"nutriphi-bot:db:studio": "pnpm --filter @manacore/telegram-nutriphi-bot db:studio",
|
||||
"dev:matrix:mana": "pnpm --filter matrix-mana-bot start:dev",
|
||||
"dev:matrix:ollama": "pnpm --filter matrix-ollama-bot start:dev",
|
||||
"dev:matrix:todo": "pnpm --filter matrix-todo-bot start:dev",
|
||||
"dev:matrix:calendar": "pnpm --filter matrix-calendar-bot start:dev",
|
||||
"dev:matrix:clock": "pnpm --filter matrix-clock-bot start:dev",
|
||||
"dev:matrix:stats": "pnpm --filter matrix-stats-bot start:dev",
|
||||
"dev:matrix:zitare": "pnpm --filter matrix-zitare-bot start:dev",
|
||||
"dev:matrix:nutriphi": "pnpm --filter matrix-nutriphi-bot start:dev",
|
||||
"build:matrix:mana": "pnpm --filter matrix-mana-bot build",
|
||||
"build:matrix:all": "pnpm --filter 'matrix-*-bot' build",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
176
packages/bot-services/CLAUDE.md
Normal file
176
packages/bot-services/CLAUDE.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# @manacore/bot-services
|
||||
|
||||
Shared business logic services for Matrix bots and the Gateway.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package provides **transport-agnostic** services that contain all business logic for the Matrix bot ecosystem. Services in this package:
|
||||
|
||||
- Have no Matrix-specific code
|
||||
- Can be used by individual bots OR the unified Gateway
|
||||
- Support pluggable storage (file-based, in-memory, database)
|
||||
- Are fully testable in isolation
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ @manacore/bot-services │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ TodoService │ │ CalendarSvc │ │ AiService │ │ ClockService│ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ Pure business logic - no Matrix code! │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Gateway │ │ Todo Bot │ │ CLI │
|
||||
│ (Matrix) │ │ (Matrix) │ │ Tool │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Available Services
|
||||
|
||||
| Service | Storage | Description |
|
||||
|---------|---------|-------------|
|
||||
| `TodoService` | File (JSON) | Task management with projects, priorities, dates |
|
||||
| `CalendarService` | File (JSON) | Events, calendars, reminders |
|
||||
| `AiService` | In-memory | Ollama LLM integration, chat sessions, vision |
|
||||
| `ClockService` | External API | Timers, alarms, world clocks |
|
||||
| `NutritionService` | Placeholder | Meal tracking (to be implemented) |
|
||||
| `QuotesService` | Placeholder | Daily quotes (to be implemented) |
|
||||
| `StatsService` | Placeholder | Analytics reports (to be implemented) |
|
||||
| `DocsService` | Placeholder | Documentation generation (to be implemented) |
|
||||
|
||||
## Usage
|
||||
|
||||
### In NestJS (Bot or Gateway)
|
||||
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot-services';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// File-based storage (default)
|
||||
TodoModule.register({ storagePath: './data/todos.json' }),
|
||||
CalendarModule.register({ storagePath: './data/calendar.json' }),
|
||||
|
||||
// External services
|
||||
AiModule.register({ baseUrl: 'http://ollama:11434' }),
|
||||
ClockModule.register({ apiUrl: 'http://clock-backend:3017/api/v1' }),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
### Direct Service Usage
|
||||
|
||||
```typescript
|
||||
import { TodoService } from '@manacore/bot-services/todo';
|
||||
import { AiService } from '@manacore/bot-services/ai';
|
||||
|
||||
// Create task
|
||||
const task = await todoService.createTask('@user:matrix.org', {
|
||||
title: 'Buy groceries',
|
||||
priority: 2,
|
||||
dueDate: '2025-01-30',
|
||||
});
|
||||
|
||||
// AI chat
|
||||
const response = await aiService.chatSimple('@user:matrix.org', 'What is TypeScript?');
|
||||
```
|
||||
|
||||
### Custom Storage Provider
|
||||
|
||||
```typescript
|
||||
import { TodoModule, StorageProvider, TodoData } from '@manacore/bot-services';
|
||||
|
||||
// PostgreSQL storage example
|
||||
class PostgresTodoStorage implements StorageProvider<TodoData> {
|
||||
async load(): Promise<TodoData> {
|
||||
// Load from database
|
||||
}
|
||||
async save(data: TodoData): Promise<void> {
|
||||
// Save to database
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom storage
|
||||
TodoModule.forRoot(new PostgresTodoStorage());
|
||||
```
|
||||
|
||||
## Input Parsing
|
||||
|
||||
Services include German-language natural input parsing:
|
||||
|
||||
### Todo
|
||||
|
||||
```typescript
|
||||
const parsed = todoService.parseTaskInput('Einkaufen !p1 @morgen #haushalt');
|
||||
// { title: 'Einkaufen', priority: 1, dueDate: '2025-01-30', project: 'haushalt' }
|
||||
```
|
||||
|
||||
### Calendar
|
||||
|
||||
```typescript
|
||||
const parsed = calendarService.parseEventInput('Meeting morgen um 14:30');
|
||||
// { title: 'Meeting', startTime: Date, endTime: Date, isAllDay: false }
|
||||
```
|
||||
|
||||
### Clock
|
||||
|
||||
```typescript
|
||||
const seconds = clockService.parseDuration('1h30m'); // 5400
|
||||
const time = clockService.parseAlarmTime('14 Uhr 30'); // '14:30:00'
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Type check
|
||||
pnpm --filter @manacore/bot-services type-check
|
||||
|
||||
# Install in a bot
|
||||
pnpm --filter matrix-todo-bot add @manacore/bot-services
|
||||
```
|
||||
|
||||
## Adding New Services
|
||||
|
||||
1. Create directory: `src/{service}/`
|
||||
2. Add files:
|
||||
- `types.ts` - Interfaces and types
|
||||
- `{service}.service.ts` - Business logic
|
||||
- `{service}.module.ts` - NestJS module
|
||||
- `index.ts` - Exports
|
||||
3. Export from `src/index.ts`
|
||||
4. Update `package.json` exports
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/bot-services/
|
||||
├── src/
|
||||
│ ├── index.ts # Main exports
|
||||
│ ├── shared/
|
||||
│ │ ├── types.ts # Common types
|
||||
│ │ ├── storage.ts # Storage providers
|
||||
│ │ ├── utils.ts # Utility functions
|
||||
│ │ └── index.ts
|
||||
│ ├── todo/
|
||||
│ │ ├── types.ts
|
||||
│ │ ├── todo.service.ts
|
||||
│ │ ├── todo.module.ts
|
||||
│ │ └── index.ts
|
||||
│ ├── calendar/
|
||||
│ ├── ai/
|
||||
│ ├── clock/
|
||||
│ └── ...
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
38
packages/bot-services/package.json
Normal file
38
packages/bot-services/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@manacore/bot-services",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Shared business logic services for Matrix bots and Gateway",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./todo": "./src/todo/index.ts",
|
||||
"./calendar": "./src/calendar/index.ts",
|
||||
"./clock": "./src/clock/index.ts",
|
||||
"./ai": "./src/ai/index.ts",
|
||||
"./nutrition": "./src/nutrition/index.ts",
|
||||
"./quotes": "./src/quotes/index.ts",
|
||||
"./stats": "./src/stats/index.ts",
|
||||
"./docs": "./src/docs/index.ts",
|
||||
"./shared": "./src/shared/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.20",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/config": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
45
packages/bot-services/src/ai/ai.module.ts
Normal file
45
packages/bot-services/src/ai/ai.module.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Module, DynamicModule } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiServiceConfig } from './types';
|
||||
|
||||
export interface AiModuleOptions extends Partial<AiServiceConfig> {}
|
||||
|
||||
@Module({})
|
||||
export class AiModule {
|
||||
/**
|
||||
* Register with default configuration (uses environment variables)
|
||||
*/
|
||||
static register(options?: AiModuleOptions): DynamicModule {
|
||||
return {
|
||||
module: AiModule,
|
||||
providers: [
|
||||
{
|
||||
provide: 'AI_SERVICE_CONFIG',
|
||||
useValue: options ?? {},
|
||||
},
|
||||
{
|
||||
provide: AiService,
|
||||
useFactory: (config: Partial<AiServiceConfig>) => new AiService(config),
|
||||
inject: ['AI_SERVICE_CONFIG'],
|
||||
},
|
||||
],
|
||||
exports: [AiService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with explicit configuration
|
||||
*/
|
||||
static forRoot(config: AiServiceConfig): DynamicModule {
|
||||
return {
|
||||
module: AiModule,
|
||||
providers: [
|
||||
{
|
||||
provide: AiService,
|
||||
useFactory: () => new AiService(config),
|
||||
},
|
||||
],
|
||||
exports: [AiService],
|
||||
};
|
||||
}
|
||||
}
|
||||
294
packages/bot-services/src/ai/ai.service.ts
Normal file
294
packages/bot-services/src/ai/ai.service.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import {
|
||||
OllamaModel,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
ChatResult,
|
||||
AiServiceConfig,
|
||||
UserAiSession,
|
||||
SYSTEM_PROMPTS,
|
||||
VISION_MODELS,
|
||||
NON_CHAT_MODELS,
|
||||
} from './types';
|
||||
|
||||
@Injectable()
|
||||
export class AiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private readonly config: AiServiceConfig;
|
||||
private sessions: Map<string, UserAiSession> = new Map();
|
||||
|
||||
constructor(config?: Partial<AiServiceConfig>) {
|
||||
this.config = {
|
||||
baseUrl: config?.baseUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434',
|
||||
defaultModel: config?.defaultModel ?? process.env.OLLAMA_MODEL ?? 'gemma3:4b',
|
||||
timeout: config?.timeout ?? parseInt(process.env.OLLAMA_TIMEOUT ?? '120000'),
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.checkConnection();
|
||||
}
|
||||
|
||||
// ===== Connection =====
|
||||
|
||||
async checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/version`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const data = await response.json();
|
||||
this.logger.log(`Ollama connected: v${data.version}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to connect to Ollama at ${this.config.baseUrl}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Models =====
|
||||
|
||||
async listModels(): Promise<OllamaModel[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/tags`);
|
||||
const data = await response.json();
|
||||
return data.models || [];
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to list models:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getChatModels(): Promise<OllamaModel[]> {
|
||||
const models = await this.listModels();
|
||||
return models.filter((m) => !NON_CHAT_MODELS.includes(m.name));
|
||||
}
|
||||
|
||||
async getVisionModels(): Promise<OllamaModel[]> {
|
||||
const models = await this.listModels();
|
||||
return models.filter((m) => VISION_MODELS.some((v) => m.name.includes(v)));
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return this.config.defaultModel;
|
||||
}
|
||||
|
||||
// ===== Chat =====
|
||||
|
||||
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
||||
const model = options?.model ?? this.config.defaultModel;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: options?.temperature,
|
||||
num_predict: options?.maxTokens,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.config.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const meta = {
|
||||
model,
|
||||
evalCount: data.eval_count,
|
||||
evalDuration: data.eval_duration,
|
||||
tokensPerSecond:
|
||||
data.eval_count && data.eval_duration ? (data.eval_count / data.eval_duration) * 1e9 : undefined,
|
||||
};
|
||||
|
||||
if (meta.tokensPerSecond) {
|
||||
this.logger.debug(`Generated ${meta.evalCount} tokens at ${meta.tokensPerSecond.toFixed(1)} t/s`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: data.message?.content || '',
|
||||
meta,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error('Ollama Timeout - Antwort dauerte zu lange');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async chatSimple(userId: string, message: string, options?: ChatOptions): Promise<string> {
|
||||
const session = this.getSession(userId);
|
||||
|
||||
// Add user message to history
|
||||
session.history.push({ role: 'user', content: message });
|
||||
|
||||
// Keep only last 10 messages
|
||||
if (session.history.length > 10) {
|
||||
session.history = session.history.slice(-10);
|
||||
}
|
||||
|
||||
// Build messages with system prompt
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: options?.systemPrompt ?? session.systemPrompt },
|
||||
...session.history,
|
||||
];
|
||||
|
||||
const result = await this.chat(messages, { ...options, model: options?.model ?? session.model });
|
||||
|
||||
// Add assistant response to history
|
||||
session.history.push({ role: 'assistant', content: result.content });
|
||||
|
||||
return result.content;
|
||||
}
|
||||
|
||||
// ===== Vision =====
|
||||
|
||||
async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise<ChatResult> {
|
||||
const selectedModel = model ?? this.config.defaultModel;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
images: [imageBase64],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.config.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const meta = {
|
||||
model: selectedModel,
|
||||
evalCount: data.eval_count,
|
||||
evalDuration: data.eval_duration,
|
||||
tokensPerSecond:
|
||||
data.eval_count && data.eval_duration ? (data.eval_count / data.eval_duration) * 1e9 : undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
content: data.message?.content || '',
|
||||
meta,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error('Ollama Timeout - Bildanalyse dauerte zu lange');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Compare Models =====
|
||||
|
||||
async compareModels(
|
||||
message: string,
|
||||
systemPrompt?: string
|
||||
): Promise<{ model: string; response: string; duration: number; error?: string }[]> {
|
||||
const models = await this.getChatModels();
|
||||
const results: { model: string; response: string; duration: number; error?: string }[] = [];
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: systemPrompt ?? SYSTEM_PROMPTS.default },
|
||||
{ role: 'user', content: message },
|
||||
];
|
||||
|
||||
for (const model of models) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
this.logger.log(`Querying model ${model.name}...`);
|
||||
const result = await this.chat(messages, { model: model.name });
|
||||
const duration = Date.now() - startTime;
|
||||
results.push({ model: model.name, response: result.content, duration });
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
results.push({ model: model.name, response: '', duration, error: errorMessage });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ===== Session Management =====
|
||||
|
||||
getSession(userId: string): UserAiSession {
|
||||
if (!this.sessions.has(userId)) {
|
||||
this.sessions.set(userId, {
|
||||
systemPrompt: SYSTEM_PROMPTS.default,
|
||||
model: this.config.defaultModel,
|
||||
history: [],
|
||||
});
|
||||
}
|
||||
return this.sessions.get(userId)!;
|
||||
}
|
||||
|
||||
setSessionModel(userId: string, model: string): void {
|
||||
const session = this.getSession(userId);
|
||||
session.model = model;
|
||||
session.history = []; // Clear history when switching models
|
||||
}
|
||||
|
||||
setSessionSystemPrompt(userId: string, prompt: string): void {
|
||||
const session = this.getSession(userId);
|
||||
session.systemPrompt = prompt;
|
||||
session.history = [];
|
||||
}
|
||||
|
||||
setSessionMode(userId: string, mode: string): boolean {
|
||||
const prompt = SYSTEM_PROMPTS[mode.toLowerCase()];
|
||||
if (!prompt) return false;
|
||||
|
||||
this.setSessionSystemPrompt(userId, prompt);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearSessionHistory(userId: string): void {
|
||||
const session = this.getSession(userId);
|
||||
session.history = [];
|
||||
}
|
||||
|
||||
setPendingImage(userId: string, url: string, mimeType: string, base64?: string): void {
|
||||
const session = this.getSession(userId);
|
||||
session.pendingImage = { url, mimeType, base64 };
|
||||
}
|
||||
|
||||
getPendingImage(userId: string): UserAiSession['pendingImage'] {
|
||||
return this.getSession(userId).pendingImage;
|
||||
}
|
||||
|
||||
clearPendingImage(userId: string): void {
|
||||
const session = this.getSession(userId);
|
||||
session.pendingImage = undefined;
|
||||
}
|
||||
|
||||
// ===== Utilities =====
|
||||
|
||||
getAvailableModes(): string[] {
|
||||
return Object.keys(SYSTEM_PROMPTS);
|
||||
}
|
||||
|
||||
getCurrentMode(userId: string): string {
|
||||
const session = this.getSession(userId);
|
||||
const entry = Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt);
|
||||
return entry ? entry[0] : 'custom';
|
||||
}
|
||||
}
|
||||
8
packages/bot-services/src/ai/index.ts
Normal file
8
packages/bot-services/src/ai/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Module
|
||||
export { AiModule, AiModuleOptions } from './ai.module';
|
||||
|
||||
// Service
|
||||
export { AiService } from './ai.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
128
packages/bot-services/src/ai/types.ts
Normal file
128
packages/bot-services/src/ai/types.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* AI/Ollama service types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ollama model info
|
||||
*/
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
size: number;
|
||||
modified_at: string;
|
||||
digest?: string;
|
||||
details?: {
|
||||
format: string;
|
||||
family: string;
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
images?: string[]; // Base64 encoded images for vision
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion options
|
||||
*/
|
||||
export interface ChatOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat response metadata
|
||||
*/
|
||||
export interface ChatResponseMeta {
|
||||
model: string;
|
||||
evalCount?: number;
|
||||
evalDuration?: number;
|
||||
tokensPerSecond?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion result
|
||||
*/
|
||||
export interface ChatResult {
|
||||
content: string;
|
||||
meta: ChatResponseMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI service configuration
|
||||
*/
|
||||
export interface AiServiceConfig {
|
||||
baseUrl: string;
|
||||
defaultModel: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User AI session (for conversation history)
|
||||
*/
|
||||
export interface UserAiSession {
|
||||
systemPrompt: string;
|
||||
model: string;
|
||||
history: ChatMessage[];
|
||||
pendingImage?: {
|
||||
url: string;
|
||||
mimeType: string;
|
||||
base64?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt presets
|
||||
*/
|
||||
export interface SystemPromptPreset {
|
||||
name: string;
|
||||
prompt: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default system prompts
|
||||
*/
|
||||
export const SYSTEM_PROMPTS: Record<string, string> = {
|
||||
default: `Du bist Manai, ein freundlicher und hilfreicher KI-Assistent.
|
||||
Du antwortest auf Deutsch, es sei denn, der Nutzer schreibt auf Englisch.
|
||||
Du bist präzise, hilfreich und freundlich.
|
||||
Halte deine Antworten kompakt, aber informativ.`,
|
||||
|
||||
code: `Du bist ein erfahrener Software-Entwickler und Code-Assistent.
|
||||
Du hilfst beim Schreiben, Debuggen und Erklären von Code.
|
||||
Gib klare, gut kommentierte Code-Beispiele.
|
||||
Erkläre technische Konzepte verständlich.`,
|
||||
|
||||
translate: `Du bist ein professioneller Übersetzer.
|
||||
Übersetze Texte präzise und natürlich klingend.
|
||||
Bewahre den Stil und Ton des Originals.
|
||||
Bei Unklarheiten frage nach der gewünschten Zielsprache.`,
|
||||
|
||||
summarize: `Du bist ein Experte für das Zusammenfassen von Texten.
|
||||
Erstelle klare, prägnante Zusammenfassungen.
|
||||
Behalte die wichtigsten Punkte bei.
|
||||
Strukturiere die Zusammenfassung übersichtlich.`,
|
||||
|
||||
creative: `Du bist ein kreativer Schreibassistent.
|
||||
Hilf beim Verfassen von Geschichten, Gedichten und kreativen Texten.
|
||||
Sei fantasievoll und inspirierend.
|
||||
Passe deinen Stil an die gewünschte Textart an.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Vision-capable model names
|
||||
*/
|
||||
export const VISION_MODELS = ['llava', 'llava:7b', 'llava:13b', 'bakllava', 'moondream'];
|
||||
|
||||
/**
|
||||
* Models excluded from comparison (specialized, not for general chat)
|
||||
*/
|
||||
export const NON_CHAT_MODELS = ['deepseek-r1:1.5b'];
|
||||
51
packages/bot-services/src/calendar/calendar.module.ts
Normal file
51
packages/bot-services/src/calendar/calendar.module.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Module, DynamicModule } from '@nestjs/common';
|
||||
import { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service';
|
||||
import { StorageProvider } from '../shared/types';
|
||||
import { FileStorageProvider } from '../shared/storage';
|
||||
import { CalendarData } from './types';
|
||||
|
||||
export interface CalendarModuleOptions {
|
||||
storagePath?: string;
|
||||
storageProvider?: StorageProvider<CalendarData>;
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class CalendarModule {
|
||||
/**
|
||||
* Register with default file storage
|
||||
*/
|
||||
static register(options?: CalendarModuleOptions): DynamicModule {
|
||||
const storagePath = options?.storagePath ?? './data/calendar-data.json';
|
||||
const defaultData: CalendarData = { events: [], calendars: [] };
|
||||
|
||||
return {
|
||||
module: CalendarModule,
|
||||
providers: [
|
||||
{
|
||||
provide: CALENDAR_STORAGE_PROVIDER,
|
||||
useValue:
|
||||
options?.storageProvider ?? new FileStorageProvider<CalendarData>(storagePath, defaultData),
|
||||
},
|
||||
CalendarService,
|
||||
],
|
||||
exports: [CalendarService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with custom storage provider
|
||||
*/
|
||||
static forRoot(storageProvider: StorageProvider<CalendarData>): DynamicModule {
|
||||
return {
|
||||
module: CalendarModule,
|
||||
providers: [
|
||||
{
|
||||
provide: CALENDAR_STORAGE_PROVIDER,
|
||||
useValue: storageProvider,
|
||||
},
|
||||
CalendarService,
|
||||
],
|
||||
exports: [CalendarService],
|
||||
};
|
||||
}
|
||||
}
|
||||
328
packages/bot-services/src/calendar/calendar.service.ts
Normal file
328
packages/bot-services/src/calendar/calendar.service.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common';
|
||||
import { StorageProvider } from '../shared/types';
|
||||
import { FileStorageProvider } from '../shared/storage';
|
||||
import {
|
||||
generateId,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
addDays,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
formatDateDE,
|
||||
formatTimeDE,
|
||||
} from '../shared/utils';
|
||||
import {
|
||||
CalendarEvent,
|
||||
Calendar,
|
||||
CalendarData,
|
||||
CreateEventInput,
|
||||
UpdateEventInput,
|
||||
EventFilter,
|
||||
ParsedEventInput,
|
||||
} from './types';
|
||||
|
||||
export const CALENDAR_STORAGE_PROVIDER = 'CALENDAR_STORAGE_PROVIDER';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService implements OnModuleInit {
|
||||
private readonly logger = new Logger(CalendarService.name);
|
||||
private data: CalendarData = { events: [], calendars: [] };
|
||||
private storage: StorageProvider<CalendarData>;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(CALENDAR_STORAGE_PROVIDER)
|
||||
storage?: StorageProvider<CalendarData>
|
||||
) {
|
||||
this.storage =
|
||||
storage ||
|
||||
new FileStorageProvider<CalendarData>('./data/calendar-data.json', { events: [], calendars: [] });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
this.data = await this.storage.load();
|
||||
this.logger.log(`Loaded ${this.data.events.length} events, ${this.data.calendars.length} calendars`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load calendar data:', error);
|
||||
this.data = { events: [], calendars: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
await this.storage.save(this.data);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save calendar data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDefaultCalendar(userId: string): Calendar {
|
||||
let calendar = this.data.calendars.find((c) => c.userId === userId);
|
||||
if (!calendar) {
|
||||
calendar = {
|
||||
id: generateId(),
|
||||
name: 'Mein Kalender',
|
||||
color: '#3B82F6',
|
||||
userId,
|
||||
};
|
||||
this.data.calendars.push(calendar);
|
||||
this.saveData();
|
||||
}
|
||||
return calendar;
|
||||
}
|
||||
|
||||
// ===== Event CRUD Operations =====
|
||||
|
||||
async createEvent(userId: string, input: CreateEventInput): Promise<CalendarEvent> {
|
||||
const calendar = this.ensureDefaultCalendar(userId);
|
||||
|
||||
const event: CalendarEvent = {
|
||||
id: generateId(),
|
||||
userId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
location: input.location ?? null,
|
||||
startTime: input.startTime.toISOString(),
|
||||
endTime: input.endTime.toISOString(),
|
||||
isAllDay: input.isAllDay ?? false,
|
||||
calendarId: input.calendarId ?? calendar.id,
|
||||
calendarName: calendar.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.data.events.push(event);
|
||||
await this.saveData();
|
||||
this.logger.log(`Created event "${event.title}" for user ${userId}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
async updateEvent(userId: string, eventId: string, input: UpdateEventInput): Promise<CalendarEvent | null> {
|
||||
const event = this.data.events.find((e) => e.id === eventId && e.userId === userId);
|
||||
if (!event) return null;
|
||||
|
||||
if (input.title !== undefined) event.title = input.title;
|
||||
if (input.startTime !== undefined) event.startTime = input.startTime.toISOString();
|
||||
if (input.endTime !== undefined) event.endTime = input.endTime.toISOString();
|
||||
if (input.description !== undefined) event.description = input.description;
|
||||
if (input.location !== undefined) event.location = input.location;
|
||||
if (input.isAllDay !== undefined) event.isAllDay = input.isAllDay;
|
||||
event.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.saveData();
|
||||
return event;
|
||||
}
|
||||
|
||||
async deleteEvent(userId: string, eventId: string): Promise<CalendarEvent | null> {
|
||||
const eventIndex = this.data.events.findIndex((e) => e.id === eventId && e.userId === userId);
|
||||
if (eventIndex === -1) return null;
|
||||
|
||||
const [event] = this.data.events.splice(eventIndex, 1);
|
||||
await this.saveData();
|
||||
this.logger.log(`Deleted event "${event.title}" for user ${userId}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
async deleteEventByIndex(userId: string, index: number): Promise<CalendarEvent | null> {
|
||||
const events = await this.getUpcomingEvents(userId, 30);
|
||||
if (index < 1 || index > events.length) return null;
|
||||
|
||||
const event = events[index - 1];
|
||||
return this.deleteEvent(userId, event.id);
|
||||
}
|
||||
|
||||
// ===== Event Queries =====
|
||||
|
||||
async getEvent(userId: string, eventId: string): Promise<CalendarEvent | null> {
|
||||
return this.data.events.find((e) => e.id === eventId && e.userId === userId) ?? null;
|
||||
}
|
||||
|
||||
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 getEvents(userId: string, filter?: EventFilter): Promise<CalendarEvent[]> {
|
||||
let events = this.data.events.filter((e) => e.userId === userId);
|
||||
|
||||
if (filter) {
|
||||
if (filter.calendarId) {
|
||||
events = events.filter((e) => e.calendarId === filter.calendarId);
|
||||
}
|
||||
if (filter.startAfter) {
|
||||
events = events.filter((e) => new Date(e.startTime) >= filter.startAfter!);
|
||||
}
|
||||
if (filter.startBefore) {
|
||||
events = events.filter((e) => new Date(e.startTime) <= filter.startBefore!);
|
||||
}
|
||||
if (filter.isAllDay !== undefined) {
|
||||
events = events.filter((e) => e.isAllDay === filter.isAllDay);
|
||||
}
|
||||
}
|
||||
|
||||
return events.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
}
|
||||
|
||||
async getEventsInRange(userId: string, start: Date, end: Date): Promise<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);
|
||||
return eventStart < end && eventEnd > start;
|
||||
})
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
}
|
||||
|
||||
async getTodayEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = startOfDay();
|
||||
const tomorrow = addDays(today, 1);
|
||||
return this.getEventsInRange(userId, today, tomorrow);
|
||||
}
|
||||
|
||||
async getTomorrowEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const tomorrow = startOfDay(addDays(new Date(), 1));
|
||||
const dayAfter = addDays(tomorrow, 1);
|
||||
return this.getEventsInRange(userId, tomorrow, dayAfter);
|
||||
}
|
||||
|
||||
async getWeekEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = startOfDay();
|
||||
const weekEnd = addDays(today, 7);
|
||||
return this.getEventsInRange(userId, today, weekEnd);
|
||||
}
|
||||
|
||||
async getUpcomingEvents(userId: string, days: number = 7): Promise<CalendarEvent[]> {
|
||||
const now = new Date();
|
||||
const endDate = addDays(now, days);
|
||||
return this.getEventsInRange(userId, now, endDate);
|
||||
}
|
||||
|
||||
// ===== Calendars =====
|
||||
|
||||
async getCalendars(userId: string): Promise<Calendar[]> {
|
||||
this.ensureDefaultCalendar(userId);
|
||||
return this.data.calendars.filter((c) => c.userId === userId);
|
||||
}
|
||||
|
||||
async createCalendar(userId: string, name: string, color?: string): Promise<Calendar> {
|
||||
const calendar: Calendar = {
|
||||
id: generateId(),
|
||||
name,
|
||||
color: color ?? '#808080',
|
||||
userId,
|
||||
};
|
||||
this.data.calendars.push(calendar);
|
||||
await this.saveData();
|
||||
return calendar;
|
||||
}
|
||||
|
||||
// ===== Formatting =====
|
||||
|
||||
formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.startTime);
|
||||
|
||||
let dateStr: string;
|
||||
if (isToday(start)) {
|
||||
dateStr = 'Heute';
|
||||
} else if (isTomorrow(start)) {
|
||||
dateStr = 'Morgen';
|
||||
} else {
|
||||
dateStr = formatDateDE(start, { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dateStr} (ganztägig)`;
|
||||
}
|
||||
|
||||
return `${dateStr}, ${formatTimeDE(start)}`;
|
||||
}
|
||||
|
||||
// ===== Input Parsing =====
|
||||
|
||||
/**
|
||||
* Parse natural language event input
|
||||
* Supports: "am DD.MM.", "heute/morgen/übermorgen", "um HH:MM", "ganztägig"
|
||||
*/
|
||||
parseEventInput(input: string): ParsedEventInput {
|
||||
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 = startOfDay();
|
||||
|
||||
if (relative === 'morgen') {
|
||||
startTime = addDays(startTime, 1);
|
||||
} else if (relative === 'übermorgen') {
|
||||
startTime = addDays(startTime, 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 = endOfDay(startTime);
|
||||
} else {
|
||||
endTime.setHours(endTime.getHours() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
title = title.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { title, startTime, endTime, isAllDay };
|
||||
}
|
||||
}
|
||||
8
packages/bot-services/src/calendar/index.ts
Normal file
8
packages/bot-services/src/calendar/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Module
|
||||
export { CalendarModule, CalendarModuleOptions } from './calendar.module';
|
||||
|
||||
// Service
|
||||
export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
78
packages/bot-services/src/calendar/types.ts
Normal file
78
packages/bot-services/src/calendar/types.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { UserEntity } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Calendar event entity
|
||||
*/
|
||||
export interface CalendarEvent extends UserEntity {
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
startTime: string; // ISO datetime
|
||||
endTime: string; // ISO datetime
|
||||
isAllDay: boolean;
|
||||
calendarId: string;
|
||||
calendarName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar entity
|
||||
*/
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar data storage structure
|
||||
*/
|
||||
export interface CalendarData {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create event input
|
||||
*/
|
||||
export interface CreateEventInput {
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
isAllDay?: boolean;
|
||||
calendarId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update event input
|
||||
*/
|
||||
export interface UpdateEventInput {
|
||||
title?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
isAllDay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event filter options
|
||||
*/
|
||||
export interface EventFilter {
|
||||
calendarId?: string;
|
||||
startAfter?: Date;
|
||||
startBefore?: Date;
|
||||
isAllDay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed event input (from natural language)
|
||||
*/
|
||||
export interface ParsedEventInput {
|
||||
title: string;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
isAllDay: boolean;
|
||||
}
|
||||
45
packages/bot-services/src/clock/clock.module.ts
Normal file
45
packages/bot-services/src/clock/clock.module.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Module, DynamicModule } from '@nestjs/common';
|
||||
import { ClockService } from './clock.service';
|
||||
import { ClockServiceConfig } from './types';
|
||||
|
||||
export interface ClockModuleOptions extends Partial<ClockServiceConfig> {}
|
||||
|
||||
@Module({})
|
||||
export class ClockModule {
|
||||
/**
|
||||
* Register with default configuration (uses environment variables)
|
||||
*/
|
||||
static register(options?: ClockModuleOptions): DynamicModule {
|
||||
return {
|
||||
module: ClockModule,
|
||||
providers: [
|
||||
{
|
||||
provide: 'CLOCK_SERVICE_CONFIG',
|
||||
useValue: options ?? {},
|
||||
},
|
||||
{
|
||||
provide: ClockService,
|
||||
useFactory: (config: Partial<ClockServiceConfig>) => new ClockService(config),
|
||||
inject: ['CLOCK_SERVICE_CONFIG'],
|
||||
},
|
||||
],
|
||||
exports: [ClockService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with explicit configuration
|
||||
*/
|
||||
static forRoot(config: ClockServiceConfig): DynamicModule {
|
||||
return {
|
||||
module: ClockModule,
|
||||
providers: [
|
||||
{
|
||||
provide: ClockService,
|
||||
useFactory: () => new ClockService(config),
|
||||
},
|
||||
],
|
||||
exports: [ClockService],
|
||||
};
|
||||
}
|
||||
}
|
||||
262
packages/bot-services/src/clock/clock.service.ts
Normal file
262
packages/bot-services/src/clock/clock.service.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Timer,
|
||||
Alarm,
|
||||
WorldClock,
|
||||
TimezoneResult,
|
||||
CreateTimerInput,
|
||||
CreateAlarmInput,
|
||||
CreateWorldClockInput,
|
||||
ClockServiceConfig,
|
||||
TimeTrackingSummary,
|
||||
} from './types';
|
||||
|
||||
@Injectable()
|
||||
export class ClockService {
|
||||
private readonly logger = new Logger(ClockService.name);
|
||||
private readonly apiUrl: string;
|
||||
|
||||
// In-memory token storage per user
|
||||
private userTokens: Map<string, string> = new Map();
|
||||
|
||||
constructor(config?: Partial<ClockServiceConfig>) {
|
||||
this.apiUrl = config?.apiUrl ?? process.env.CLOCK_API_URL ?? 'http://localhost:3017/api/v1';
|
||||
this.logger.log(`Clock API URL: ${this.apiUrl}`);
|
||||
}
|
||||
|
||||
// ===== Auth Token Management =====
|
||||
|
||||
setUserToken(userId: string, token: string): void {
|
||||
this.userTokens.set(userId, token);
|
||||
}
|
||||
|
||||
getUserToken(userId: string): string | undefined {
|
||||
return this.userTokens.get(userId);
|
||||
}
|
||||
|
||||
// ===== API Helper =====
|
||||
|
||||
private async apiCall<T>(endpoint: string, method: string = 'GET', token?: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiUrl}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Clock API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ===== Health =====
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Timers =====
|
||||
|
||||
async getTimers(token: string): Promise<Timer[]> {
|
||||
return this.apiCall<Timer[]>('/timers', 'GET', token);
|
||||
}
|
||||
|
||||
async getTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}`, 'GET', token);
|
||||
}
|
||||
|
||||
async createTimer(input: CreateTimerInput, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>('/timers', 'POST', token, {
|
||||
durationSeconds: input.durationSeconds,
|
||||
label: input.label,
|
||||
});
|
||||
}
|
||||
|
||||
async startTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/start`, 'POST', token);
|
||||
}
|
||||
|
||||
async pauseTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/pause`, 'POST', token);
|
||||
}
|
||||
|
||||
async resetTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/reset`, 'POST', token);
|
||||
}
|
||||
|
||||
async deleteTimer(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/timers/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
async getRunningTimer(token: string): Promise<Timer | null> {
|
||||
const timers = await this.getTimers(token);
|
||||
return timers.find((t) => t.status === 'running' || t.status === 'paused') || null;
|
||||
}
|
||||
|
||||
// ===== Alarms =====
|
||||
|
||||
async getAlarms(token: string): Promise<Alarm[]> {
|
||||
return this.apiCall<Alarm[]>('/alarms', 'GET', token);
|
||||
}
|
||||
|
||||
async createAlarm(input: CreateAlarmInput, token: string): Promise<Alarm> {
|
||||
return this.apiCall<Alarm>('/alarms', 'POST', token, {
|
||||
time: input.time,
|
||||
label: input.label,
|
||||
enabled: true,
|
||||
repeatDays: input.repeatDays,
|
||||
});
|
||||
}
|
||||
|
||||
async toggleAlarm(id: string, token: string): Promise<Alarm> {
|
||||
return this.apiCall<Alarm>(`/alarms/${id}/toggle`, 'PATCH', token);
|
||||
}
|
||||
|
||||
async deleteAlarm(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/alarms/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// ===== World Clocks =====
|
||||
|
||||
async getWorldClocks(token: string): Promise<WorldClock[]> {
|
||||
return this.apiCall<WorldClock[]>('/world-clocks', 'GET', token);
|
||||
}
|
||||
|
||||
async addWorldClock(input: CreateWorldClockInput, token: string): Promise<WorldClock> {
|
||||
return this.apiCall<WorldClock>('/world-clocks', 'POST', token, {
|
||||
timezone: input.timezone,
|
||||
cityName: input.cityName,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorldClock(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/world-clocks/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// ===== Timezone Search =====
|
||||
|
||||
async searchTimezones(query: string): Promise<TimezoneResult[]> {
|
||||
return this.apiCall<TimezoneResult[]>(`/timezones/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// ===== Time Tracking Summary =====
|
||||
|
||||
async getTodayTracked(token: string): Promise<TimeTrackingSummary> {
|
||||
// This would aggregate timer data for today
|
||||
// For now, return a placeholder - implement based on actual API
|
||||
const timers = await this.getTimers(token);
|
||||
const finishedToday = timers.filter((t) => {
|
||||
if (t.status !== 'finished') return false;
|
||||
const finishedAt = new Date(t.updatedAt);
|
||||
const today = new Date();
|
||||
return finishedAt.toDateString() === today.toDateString();
|
||||
});
|
||||
|
||||
const totalMinutes = finishedToday.reduce((sum, t) => sum + Math.floor(t.durationSeconds / 60), 0);
|
||||
|
||||
return {
|
||||
totalMinutes,
|
||||
sessions: finishedToday.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Parsing Utilities =====
|
||||
|
||||
/**
|
||||
* Parse duration string to seconds
|
||||
* Supports: "25m", "1h30m", "90s", "25" (assumes minutes)
|
||||
*/
|
||||
parseDuration(input: string): number | null {
|
||||
let totalSeconds = 0;
|
||||
|
||||
// Match hours
|
||||
const hoursMatch = input.match(/(\d+)\s*h/i);
|
||||
if (hoursMatch) {
|
||||
totalSeconds += parseInt(hoursMatch[1], 10) * 3600;
|
||||
}
|
||||
|
||||
// Match minutes
|
||||
const minutesMatch = input.match(/(\d+)\s*m(?:in)?/i);
|
||||
if (minutesMatch) {
|
||||
totalSeconds += parseInt(minutesMatch[1], 10) * 60;
|
||||
}
|
||||
|
||||
// Match seconds
|
||||
const secondsMatch = input.match(/(\d+)\s*s(?:ec)?/i);
|
||||
if (secondsMatch) {
|
||||
totalSeconds += parseInt(secondsMatch[1], 10);
|
||||
}
|
||||
|
||||
// If just a number, assume minutes
|
||||
if (totalSeconds === 0) {
|
||||
const justNumber = input.match(/^(\d+)$/);
|
||||
if (justNumber) {
|
||||
totalSeconds = parseInt(justNumber[1], 10) * 60;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSeconds > 0 ? totalSeconds : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time string to HH:MM:SS
|
||||
* Supports: "14:30", "9:00", "14 Uhr 30"
|
||||
*/
|
||||
parseAlarmTime(input: string): string | null {
|
||||
// Try HH:MM format
|
||||
let match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = parseInt(match[2], 10);
|
||||
const seconds = match[3] ? parseInt(match[3], 10) : 0;
|
||||
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Try "X Uhr Y" format (German)
|
||||
match = input.match(/(\d{1,2})\s*uhr(?:\s*(\d{1,2}))?/i);
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds to human readable
|
||||
*/
|
||||
formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
8
packages/bot-services/src/clock/index.ts
Normal file
8
packages/bot-services/src/clock/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Module
|
||||
export { ClockModule, ClockModuleOptions } from './clock.module';
|
||||
|
||||
// Service
|
||||
export { ClockService } from './clock.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
97
packages/bot-services/src/clock/types.ts
Normal file
97
packages/bot-services/src/clock/types.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Clock service types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Timer entity
|
||||
*/
|
||||
export interface Timer {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
durationSeconds: number;
|
||||
remainingSeconds: number;
|
||||
status: 'idle' | 'running' | 'paused' | 'finished';
|
||||
startedAt: string | null;
|
||||
pausedAt: string | null;
|
||||
sound: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm entity
|
||||
*/
|
||||
export interface Alarm {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
time: string; // HH:MM:SS
|
||||
enabled: boolean;
|
||||
repeatDays: number[]; // 0-6, Sunday = 0
|
||||
snoozeMinutes: number;
|
||||
sound: string;
|
||||
vibrate: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* World clock entity
|
||||
*/
|
||||
export interface WorldClock {
|
||||
id: string;
|
||||
userId: string;
|
||||
timezone: string;
|
||||
cityName: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timezone search result
|
||||
*/
|
||||
export interface TimezoneResult {
|
||||
timezone: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timer input
|
||||
*/
|
||||
export interface CreateTimerInput {
|
||||
durationSeconds: number;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create alarm input
|
||||
*/
|
||||
export interface CreateAlarmInput {
|
||||
time: string; // HH:MM:SS
|
||||
label?: string | null;
|
||||
repeatDays?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create world clock input
|
||||
*/
|
||||
export interface CreateWorldClockInput {
|
||||
timezone: string;
|
||||
cityName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clock service configuration
|
||||
*/
|
||||
export interface ClockServiceConfig {
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time tracking summary
|
||||
*/
|
||||
export interface TimeTrackingSummary {
|
||||
totalMinutes: number;
|
||||
sessions: number;
|
||||
}
|
||||
25
packages/bot-services/src/docs/index.ts
Normal file
25
packages/bot-services/src/docs/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Placeholder - to be implemented
|
||||
// Will integrate with project documentation generation
|
||||
|
||||
export interface DocsServiceConfig {
|
||||
openaiApiKey?: string;
|
||||
s3Config?: {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectDoc {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
format: 'blog' | 'summary' | 'technical';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Export placeholder module
|
||||
export const DocsModule = {
|
||||
register: () => ({ module: class {}, providers: [], exports: [] }),
|
||||
};
|
||||
113
packages/bot-services/src/index.ts
Normal file
113
packages/bot-services/src/index.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* @manacore/bot-services
|
||||
*
|
||||
* Shared business logic services for Matrix bots and the Gateway.
|
||||
* These services are transport-agnostic and can be used by:
|
||||
* - Individual Matrix bots (standalone)
|
||||
* - The Gateway bot (all-in-one)
|
||||
* - REST APIs
|
||||
* - CLI tools
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { TodoModule, TodoService } from '@manacore/bot-services/todo';
|
||||
* import { AiModule, AiService } from '@manacore/bot-services/ai';
|
||||
*
|
||||
* // In NestJS module
|
||||
* @Module({
|
||||
* imports: [
|
||||
* TodoModule.register({ storagePath: './data/todos.json' }),
|
||||
* AiModule.register({ baseUrl: 'http://ollama:11434' }),
|
||||
* ],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ===== Core Services =====
|
||||
|
||||
// Todo
|
||||
export { TodoModule, TodoModuleOptions, TodoService, TODO_STORAGE_PROVIDER } from './todo';
|
||||
export type {
|
||||
Task,
|
||||
Project,
|
||||
TodoData,
|
||||
CreateTaskInput,
|
||||
UpdateTaskInput,
|
||||
TaskFilter,
|
||||
TodoStats,
|
||||
ParsedTaskInput,
|
||||
} from './todo';
|
||||
|
||||
// Calendar
|
||||
export { CalendarModule, CalendarModuleOptions, CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar';
|
||||
export type {
|
||||
CalendarEvent,
|
||||
Calendar,
|
||||
CalendarData,
|
||||
CreateEventInput,
|
||||
UpdateEventInput,
|
||||
EventFilter,
|
||||
ParsedEventInput,
|
||||
} from './calendar';
|
||||
|
||||
// AI (Ollama)
|
||||
export { AiModule, AiModuleOptions, AiService } from './ai';
|
||||
export type {
|
||||
OllamaModel,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
ChatResult,
|
||||
ChatResponseMeta,
|
||||
AiServiceConfig,
|
||||
UserAiSession,
|
||||
SystemPromptPreset,
|
||||
} from './ai';
|
||||
export { SYSTEM_PROMPTS, VISION_MODELS, NON_CHAT_MODELS } from './ai';
|
||||
|
||||
// Clock
|
||||
export { ClockModule, ClockModuleOptions, ClockService } from './clock';
|
||||
export type {
|
||||
Timer,
|
||||
Alarm,
|
||||
WorldClock,
|
||||
TimezoneResult,
|
||||
CreateTimerInput,
|
||||
CreateAlarmInput,
|
||||
CreateWorldClockInput,
|
||||
ClockServiceConfig,
|
||||
TimeTrackingSummary,
|
||||
} from './clock';
|
||||
|
||||
// ===== Placeholder Services (to be implemented) =====
|
||||
|
||||
export { NutritionModule } from './nutrition';
|
||||
export type { NutritionServiceConfig, Meal, NutritionSummary } from './nutrition';
|
||||
|
||||
export { QuotesModule } from './quotes';
|
||||
export type { QuotesServiceConfig, Quote } from './quotes';
|
||||
|
||||
export { StatsModule } from './stats';
|
||||
export type { StatsServiceConfig, AnalyticsReport } from './stats';
|
||||
|
||||
export { DocsModule } from './docs';
|
||||
export type { DocsServiceConfig, ProjectDoc } from './docs';
|
||||
|
||||
// ===== Shared Utilities =====
|
||||
|
||||
export { FileStorageProvider, MemoryStorageProvider } from './shared';
|
||||
export type { StorageProvider, BaseEntity, UserEntity, ServiceConfig, Result, PaginationOptions, PaginatedResult, DateRange, Priority, ServiceStats } from './shared';
|
||||
export {
|
||||
generateId,
|
||||
getTodayISO,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
addDays,
|
||||
formatDateDE,
|
||||
formatTimeDE,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
parseGermanDateKeyword,
|
||||
getRelativeDateLabel,
|
||||
} from './shared';
|
||||
export { PRIORITY_VALUES } from './shared';
|
||||
25
packages/bot-services/src/nutrition/index.ts
Normal file
25
packages/bot-services/src/nutrition/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Placeholder - to be implemented
|
||||
// Will integrate with NutriPhi backend API
|
||||
|
||||
export interface NutritionServiceConfig {
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export interface Meal {
|
||||
id: string;
|
||||
userId: string;
|
||||
description: string;
|
||||
calories: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NutritionSummary {
|
||||
totalCalories: number;
|
||||
mealCount: number;
|
||||
meals: Meal[];
|
||||
}
|
||||
|
||||
// Export placeholder module
|
||||
export const NutritionModule = {
|
||||
register: () => ({ module: class {}, providers: [], exports: [] }),
|
||||
};
|
||||
18
packages/bot-services/src/quotes/index.ts
Normal file
18
packages/bot-services/src/quotes/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Placeholder - to be implemented
|
||||
// Will integrate with Zitare backend API
|
||||
|
||||
export interface QuotesServiceConfig {
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
// Export placeholder module
|
||||
export const QuotesModule = {
|
||||
register: () => ({ module: class {}, providers: [], exports: [] }),
|
||||
};
|
||||
8
packages/bot-services/src/shared/index.ts
Normal file
8
packages/bot-services/src/shared/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Shared types
|
||||
export * from './types';
|
||||
|
||||
// Storage providers
|
||||
export { FileStorageProvider, MemoryStorageProvider } from './storage';
|
||||
|
||||
// Utility functions
|
||||
export * from './utils';
|
||||
71
packages/bot-services/src/shared/storage.ts
Normal file
71
packages/bot-services/src/shared/storage.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { StorageProvider } from './types';
|
||||
|
||||
/**
|
||||
* File-based JSON storage provider
|
||||
* Used for local GDPR-compliant data storage
|
||||
*/
|
||||
export class FileStorageProvider<T> implements StorageProvider<T> {
|
||||
private readonly logger = new Logger(FileStorageProvider.name);
|
||||
private readonly filePath: string;
|
||||
private readonly defaultData: T;
|
||||
|
||||
constructor(filePath: string, defaultData: T) {
|
||||
this.filePath = filePath;
|
||||
this.defaultData = defaultData;
|
||||
}
|
||||
|
||||
async load(): Promise<T> {
|
||||
try {
|
||||
const dir = path.dirname(this.filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
const content = fs.readFileSync(this.filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} else {
|
||||
await this.save(this.defaultData);
|
||||
return this.defaultData;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load data from ${this.filePath}:`, error);
|
||||
return this.defaultData;
|
||||
}
|
||||
}
|
||||
|
||||
async save(data: T): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(this.filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to save data to ${this.filePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory storage provider (for testing)
|
||||
*/
|
||||
export class MemoryStorageProvider<T> implements StorageProvider<T> {
|
||||
private data: T;
|
||||
|
||||
constructor(defaultData: T) {
|
||||
this.data = defaultData;
|
||||
}
|
||||
|
||||
async load(): Promise<T> {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
async save(data: T): Promise<void> {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
66
packages/bot-services/src/shared/types.ts
Normal file
66
packages/bot-services/src/shared/types.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Common types used across all bot services
|
||||
*/
|
||||
|
||||
// Base entity interface
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// User-scoped entity
|
||||
export interface UserEntity extends BaseEntity {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// Storage provider interface - allows swapping file/db storage
|
||||
export interface StorageProvider<T> {
|
||||
load(): Promise<T>;
|
||||
save(data: T): Promise<void>;
|
||||
}
|
||||
|
||||
// Service configuration
|
||||
export interface ServiceConfig {
|
||||
storagePath?: string;
|
||||
apiUrl?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Result type for operations
|
||||
export type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E };
|
||||
|
||||
// Pagination
|
||||
export interface PaginationOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
// Priority levels
|
||||
export type Priority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export const PRIORITY_VALUES: Record<Priority, number> = {
|
||||
urgent: 1,
|
||||
high: 2,
|
||||
medium: 3,
|
||||
low: 4,
|
||||
};
|
||||
|
||||
// Common stats interface
|
||||
export interface ServiceStats {
|
||||
total: number;
|
||||
active: number;
|
||||
completed?: number;
|
||||
}
|
||||
110
packages/bot-services/src/shared/utils.ts
Normal file
110
packages/bot-services/src/shared/utils.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Utility functions for bot services
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a unique ID
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO date string for today
|
||||
*/
|
||||
export function getTodayISO(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date at start of day
|
||||
*/
|
||||
export function startOfDay(date: Date = new Date()): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date at end of day
|
||||
*/
|
||||
export function endOfDay(date: Date = new Date()): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add days to a date
|
||||
*/
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for German locale
|
||||
*/
|
||||
export function formatDateDE(date: Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleDateString('de-DE', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for German locale
|
||||
*/
|
||||
export function formatTimeDE(date: Date): string {
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if date is today
|
||||
*/
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if date is tomorrow
|
||||
*/
|
||||
export function isTomorrow(date: Date): boolean {
|
||||
const tomorrow = addDays(new Date(), 1);
|
||||
return (
|
||||
date.getDate() === tomorrow.getDate() &&
|
||||
date.getMonth() === tomorrow.getMonth() &&
|
||||
date.getFullYear() === tomorrow.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse German date keywords
|
||||
*/
|
||||
export function parseGermanDateKeyword(keyword: string): Date | null {
|
||||
const lower = keyword.toLowerCase().trim();
|
||||
const today = startOfDay();
|
||||
|
||||
switch (lower) {
|
||||
case 'heute':
|
||||
return today;
|
||||
case 'morgen':
|
||||
return addDays(today, 1);
|
||||
case 'übermorgen':
|
||||
return addDays(today, 2);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative date label in German
|
||||
*/
|
||||
export function getRelativeDateLabel(date: Date): string {
|
||||
if (isToday(date)) return 'Heute';
|
||||
if (isTomorrow(date)) return 'Morgen';
|
||||
return formatDateDE(date, { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
20
packages/bot-services/src/stats/index.ts
Normal file
20
packages/bot-services/src/stats/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Placeholder - to be implemented
|
||||
// Will integrate with Umami analytics API
|
||||
|
||||
export interface StatsServiceConfig {
|
||||
apiUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsReport {
|
||||
pageviews: number;
|
||||
visitors: number;
|
||||
bounceRate: number;
|
||||
avgDuration: number;
|
||||
}
|
||||
|
||||
// Export placeholder module
|
||||
export const StatsModule = {
|
||||
register: () => ({ module: class {}, providers: [], exports: [] }),
|
||||
};
|
||||
8
packages/bot-services/src/todo/index.ts
Normal file
8
packages/bot-services/src/todo/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Module
|
||||
export { TodoModule, TodoModuleOptions } from './todo.module';
|
||||
|
||||
// Service
|
||||
export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
50
packages/bot-services/src/todo/todo.module.ts
Normal file
50
packages/bot-services/src/todo/todo.module.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Module, DynamicModule } from '@nestjs/common';
|
||||
import { TodoService, TODO_STORAGE_PROVIDER } from './todo.service';
|
||||
import { StorageProvider } from '../shared/types';
|
||||
import { FileStorageProvider } from '../shared/storage';
|
||||
import { TodoData } from './types';
|
||||
|
||||
export interface TodoModuleOptions {
|
||||
storagePath?: string;
|
||||
storageProvider?: StorageProvider<TodoData>;
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class TodoModule {
|
||||
/**
|
||||
* Register with default file storage
|
||||
*/
|
||||
static register(options?: TodoModuleOptions): DynamicModule {
|
||||
const storagePath = options?.storagePath ?? './data/todo-data.json';
|
||||
const defaultData: TodoData = { tasks: [], projects: [] };
|
||||
|
||||
return {
|
||||
module: TodoModule,
|
||||
providers: [
|
||||
{
|
||||
provide: TODO_STORAGE_PROVIDER,
|
||||
useValue: options?.storageProvider ?? new FileStorageProvider<TodoData>(storagePath, defaultData),
|
||||
},
|
||||
TodoService,
|
||||
],
|
||||
exports: [TodoService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with custom storage provider
|
||||
*/
|
||||
static forRoot(storageProvider: StorageProvider<TodoData>): DynamicModule {
|
||||
return {
|
||||
module: TodoModule,
|
||||
providers: [
|
||||
{
|
||||
provide: TODO_STORAGE_PROVIDER,
|
||||
useValue: storageProvider,
|
||||
},
|
||||
TodoService,
|
||||
],
|
||||
exports: [TodoService],
|
||||
};
|
||||
}
|
||||
}
|
||||
294
packages/bot-services/src/todo/todo.service.ts
Normal file
294
packages/bot-services/src/todo/todo.service.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common';
|
||||
import { StorageProvider } from '../shared/types';
|
||||
import { FileStorageProvider } from '../shared/storage';
|
||||
import { generateId, getTodayISO, parseGermanDateKeyword, addDays } from '../shared/utils';
|
||||
import {
|
||||
Task,
|
||||
Project,
|
||||
TodoData,
|
||||
CreateTaskInput,
|
||||
UpdateTaskInput,
|
||||
TaskFilter,
|
||||
TodoStats,
|
||||
ParsedTaskInput,
|
||||
} from './types';
|
||||
|
||||
export const TODO_STORAGE_PROVIDER = 'TODO_STORAGE_PROVIDER';
|
||||
|
||||
@Injectable()
|
||||
export class TodoService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TodoService.name);
|
||||
private data: TodoData = { tasks: [], projects: [] };
|
||||
private storage: StorageProvider<TodoData>;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(TODO_STORAGE_PROVIDER)
|
||||
storage?: StorageProvider<TodoData>
|
||||
) {
|
||||
// Default to file storage if not injected
|
||||
this.storage =
|
||||
storage || new FileStorageProvider<TodoData>('./data/todo-data.json', { tasks: [], projects: [] });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
this.data = await this.storage.load();
|
||||
this.logger.log(`Loaded ${this.data.tasks.length} tasks, ${this.data.projects.length} projects`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load todo data:', error);
|
||||
this.data = { tasks: [], projects: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
await this.storage.save(this.data);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save todo data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Task CRUD Operations =====
|
||||
|
||||
async createTask(userId: string, input: CreateTaskInput): Promise<Task> {
|
||||
const task: Task = {
|
||||
id: generateId(),
|
||||
userId,
|
||||
title: input.title,
|
||||
completed: false,
|
||||
priority: input.priority ?? 4,
|
||||
dueDate: input.dueDate ?? null,
|
||||
project: input.project ?? null,
|
||||
labels: input.labels ?? [],
|
||||
createdAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
this.data.tasks.push(task);
|
||||
await this.saveData();
|
||||
this.logger.log(`Created task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async updateTask(userId: string, taskId: string, input: UpdateTaskInput): Promise<Task | null> {
|
||||
const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId);
|
||||
if (!task) return null;
|
||||
|
||||
if (input.title !== undefined) task.title = input.title;
|
||||
if (input.priority !== undefined) task.priority = input.priority;
|
||||
if (input.dueDate !== undefined) task.dueDate = input.dueDate;
|
||||
if (input.project !== undefined) task.project = input.project;
|
||||
if (input.labels !== undefined) task.labels = input.labels;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.saveData();
|
||||
return task;
|
||||
}
|
||||
|
||||
async deleteTask(userId: string, taskId: string): Promise<Task | null> {
|
||||
const taskIndex = this.data.tasks.findIndex((t) => t.id === taskId && t.userId === userId);
|
||||
if (taskIndex === -1) return null;
|
||||
|
||||
const [task] = this.data.tasks.splice(taskIndex, 1);
|
||||
await this.saveData();
|
||||
this.logger.log(`Deleted task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async deleteTaskByIndex(userId: string, index: number): Promise<Task | null> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed);
|
||||
if (index < 1 || index > userTasks.length) return null;
|
||||
|
||||
const task = userTasks[index - 1];
|
||||
return this.deleteTask(userId, task.id);
|
||||
}
|
||||
|
||||
// ===== Task Completion =====
|
||||
|
||||
async completeTask(userId: string, taskId: string): Promise<Task | null> {
|
||||
const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId);
|
||||
if (!task) return null;
|
||||
|
||||
task.completed = true;
|
||||
task.completedAt = new Date().toISOString();
|
||||
await this.saveData();
|
||||
this.logger.log(`Completed task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async completeTaskByIndex(userId: string, index: number): Promise<Task | null> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed);
|
||||
if (index < 1 || index > userTasks.length) return null;
|
||||
|
||||
const task = userTasks[index - 1];
|
||||
return this.completeTask(userId, task.id);
|
||||
}
|
||||
|
||||
async uncompleteTask(userId: string, taskId: string): Promise<Task | null> {
|
||||
const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId);
|
||||
if (!task) return null;
|
||||
|
||||
task.completed = false;
|
||||
task.completedAt = null;
|
||||
await this.saveData();
|
||||
return task;
|
||||
}
|
||||
|
||||
// ===== Task Queries =====
|
||||
|
||||
async getTask(userId: string, taskId: string): Promise<Task | null> {
|
||||
return this.data.tasks.find((t) => t.id === taskId && t.userId === userId) ?? null;
|
||||
}
|
||||
|
||||
async getTasks(userId: string, filter?: TaskFilter): Promise<Task[]> {
|
||||
let tasks = this.data.tasks.filter((t) => t.userId === userId);
|
||||
|
||||
if (filter) {
|
||||
if (filter.completed !== undefined) {
|
||||
tasks = tasks.filter((t) => t.completed === filter.completed);
|
||||
}
|
||||
if (filter.project) {
|
||||
tasks = tasks.filter((t) => t.project?.toLowerCase() === filter.project!.toLowerCase());
|
||||
}
|
||||
if (filter.dueDate) {
|
||||
tasks = tasks.filter((t) => t.dueDate?.startsWith(filter.dueDate!));
|
||||
}
|
||||
if (filter.dueBefore) {
|
||||
tasks = tasks.filter((t) => t.dueDate && t.dueDate < filter.dueBefore!);
|
||||
}
|
||||
if (filter.dueAfter) {
|
||||
tasks = tasks.filter((t) => t.dueDate && t.dueDate > filter.dueAfter!);
|
||||
}
|
||||
if (filter.priority) {
|
||||
tasks = tasks.filter((t) => t.priority === filter.priority);
|
||||
}
|
||||
if (filter.labels && filter.labels.length > 0) {
|
||||
tasks = tasks.filter((t) => filter.labels!.some((l) => t.labels.includes(l)));
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async getAllPendingTasks(userId: string): Promise<Task[]> {
|
||||
return this.data.tasks
|
||||
.filter((t) => t.userId === userId && !t.completed)
|
||||
.sort((a, b) => {
|
||||
// Sort by due date first (nulls last), then by priority
|
||||
if (a.dueDate && !b.dueDate) return -1;
|
||||
if (!a.dueDate && b.dueDate) return 1;
|
||||
if (a.dueDate && b.dueDate) {
|
||||
const dateCompare = a.dueDate.localeCompare(b.dueDate);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
}
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<Task[]> {
|
||||
const today = getTodayISO();
|
||||
return this.data.tasks
|
||||
.filter((t) => t.userId === userId && !t.completed && t.dueDate?.startsWith(today))
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
async getOverdueTasks(userId: string): Promise<Task[]> {
|
||||
const today = getTodayISO();
|
||||
return this.data.tasks
|
||||
.filter((t) => t.userId === userId && !t.completed && t.dueDate && t.dueDate < today)
|
||||
.sort((a, b) => a.dueDate!.localeCompare(b.dueDate!));
|
||||
}
|
||||
|
||||
async getInboxTasks(userId: string): Promise<Task[]> {
|
||||
return this.data.tasks
|
||||
.filter((t) => t.userId === userId && !t.completed && !t.dueDate && !t.project)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
async getProjectTasks(userId: string, projectName: string): Promise<Task[]> {
|
||||
return this.data.tasks
|
||||
.filter(
|
||||
(t) => t.userId === userId && !t.completed && t.project?.toLowerCase() === projectName.toLowerCase()
|
||||
)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
// ===== Projects =====
|
||||
|
||||
async getProjects(userId: string): Promise<Project[]> {
|
||||
const projectNames = new Set<string>();
|
||||
this.data.tasks
|
||||
.filter((t) => t.userId === userId && t.project)
|
||||
.forEach((t) => projectNames.add(t.project!));
|
||||
|
||||
return Array.from(projectNames).map((name) => ({
|
||||
id: name.toLowerCase(),
|
||||
name,
|
||||
color: '#808080',
|
||||
userId,
|
||||
}));
|
||||
}
|
||||
|
||||
// ===== Statistics =====
|
||||
|
||||
async getStats(userId: string): Promise<TodoStats> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId);
|
||||
const today = getTodayISO();
|
||||
|
||||
return {
|
||||
total: userTasks.length,
|
||||
completed: userTasks.filter((t) => t.completed).length,
|
||||
pending: userTasks.filter((t) => !t.completed).length,
|
||||
today: userTasks.filter((t) => !t.completed && t.dueDate?.startsWith(today)).length,
|
||||
overdue: userTasks.filter((t) => !t.completed && t.dueDate && t.dueDate < today).length,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Input Parsing =====
|
||||
|
||||
/**
|
||||
* Parse natural language task input
|
||||
* Supports: !p1-4 (priority), @heute/@morgen/@übermorgen (date), #project
|
||||
*/
|
||||
parseTaskInput(input: string): ParsedTaskInput {
|
||||
let title = input;
|
||||
let priority = 4;
|
||||
let dueDate: string | null = null;
|
||||
let project: string | null = null;
|
||||
|
||||
// Parse priority (!p1, !p2, !p3, !p4)
|
||||
const priorityMatch = title.match(/!p([1-4])/i);
|
||||
if (priorityMatch) {
|
||||
priority = parseInt(priorityMatch[1]);
|
||||
title = title.replace(/!p[1-4]/i, '').trim();
|
||||
}
|
||||
|
||||
// Parse date (@heute, @morgen, @übermorgen)
|
||||
const dateKeywords = ['heute', 'morgen', 'übermorgen'];
|
||||
for (const keyword of dateKeywords) {
|
||||
const regex = new RegExp(`@${keyword}`, 'i');
|
||||
if (regex.test(title)) {
|
||||
const date = parseGermanDateKeyword(keyword);
|
||||
if (date) {
|
||||
dueDate = date.toISOString().split('T')[0];
|
||||
}
|
||||
title = title.replace(regex, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse project (#projektname)
|
||||
const projectMatch = title.match(/#(\S+)/);
|
||||
if (projectMatch) {
|
||||
project = projectMatch[1];
|
||||
title = title.replace(/#\S+/, '').trim();
|
||||
}
|
||||
|
||||
return { title, priority, dueDate, project };
|
||||
}
|
||||
}
|
||||
88
packages/bot-services/src/todo/types.ts
Normal file
88
packages/bot-services/src/todo/types.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { UserEntity, Priority } from '../shared/types';
|
||||
|
||||
/**
|
||||
* Task entity
|
||||
*/
|
||||
export interface Task extends UserEntity {
|
||||
title: string;
|
||||
completed: boolean;
|
||||
priority: number; // 1-4, 1 is highest (for backward compatibility)
|
||||
dueDate: string | null; // ISO date string
|
||||
project: string | null;
|
||||
labels: string[];
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project entity
|
||||
*/
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo data storage structure
|
||||
*/
|
||||
export interface TodoData {
|
||||
tasks: Task[];
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task input
|
||||
*/
|
||||
export interface CreateTaskInput {
|
||||
title: string;
|
||||
priority?: number;
|
||||
dueDate?: string | null;
|
||||
project?: string | null;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task input
|
||||
*/
|
||||
export interface UpdateTaskInput {
|
||||
title?: string;
|
||||
priority?: number;
|
||||
dueDate?: string | null;
|
||||
project?: string | null;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task filter options
|
||||
*/
|
||||
export interface TaskFilter {
|
||||
completed?: boolean;
|
||||
project?: string;
|
||||
dueDate?: string;
|
||||
dueBefore?: string;
|
||||
dueAfter?: string;
|
||||
priority?: number;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo statistics
|
||||
*/
|
||||
export interface TodoStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
today: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed task input (from natural language)
|
||||
*/
|
||||
export interface ParsedTaskInput {
|
||||
title: string;
|
||||
priority: number;
|
||||
dueDate: string | null;
|
||||
project: string | null;
|
||||
}
|
||||
30
packages/bot-services/tsconfig.json
Normal file
30
packages/bot-services/tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
134
scripts/mac-mini/deploy-mana-bot.sh
Executable file
134
scripts/mac-mini/deploy-mana-bot.sh
Executable file
|
|
@ -0,0 +1,134 @@
|
|||
#!/bin/bash
|
||||
# Deploy Matrix Mana Bot (Gateway) to Mac Mini
|
||||
# This script handles the complete deployment process
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "============================================"
|
||||
echo " Matrix Mana Bot - Full Deployment"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if .env exists and has the token
|
||||
if ! grep -q "MATRIX_MANA_BOT_TOKEN" .env 2>/dev/null; then
|
||||
echo -e "${YELLOW}Warning: MATRIX_MANA_BOT_TOKEN not found in .env${NC}"
|
||||
echo "Run ./scripts/mac-mini/setup-mana-bot.sh first to register the bot."
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 1: Pull latest code
|
||||
echo -e "${CYAN}Step 1: Pulling latest code...${NC}"
|
||||
git pull --ff-only || {
|
||||
echo -e "${YELLOW}Git pull failed. You may have local changes.${NC}"
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Step 2: Build shared package
|
||||
echo ""
|
||||
echo -e "${CYAN}Step 2: Building @manacore/bot-services...${NC}"
|
||||
cd "$PROJECT_DIR/packages/bot-services"
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
pnpm build || {
|
||||
echo -e "${RED}Failed to build bot-services package${NC}"
|
||||
exit 1
|
||||
}
|
||||
echo -e "${GREEN}bot-services built successfully${NC}"
|
||||
|
||||
# Step 3: Build gateway bot
|
||||
echo ""
|
||||
echo -e "${CYAN}Step 3: Building matrix-mana-bot...${NC}"
|
||||
cd "$PROJECT_DIR/services/matrix-mana-bot"
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
pnpm build || {
|
||||
echo -e "${RED}Failed to build matrix-mana-bot${NC}"
|
||||
exit 1
|
||||
}
|
||||
echo -e "${GREEN}matrix-mana-bot built successfully${NC}"
|
||||
|
||||
# Step 4: Build Docker image
|
||||
echo ""
|
||||
echo -e "${CYAN}Step 4: Building Docker image...${NC}"
|
||||
cd "$PROJECT_DIR"
|
||||
docker build -t matrix-mana-bot:latest ./services/matrix-mana-bot || {
|
||||
echo -e "${RED}Failed to build Docker image${NC}"
|
||||
exit 1
|
||||
}
|
||||
echo -e "${GREEN}Docker image built successfully${NC}"
|
||||
|
||||
# Step 5: Stop existing container if running
|
||||
echo ""
|
||||
echo -e "${CYAN}Step 5: Stopping existing container...${NC}"
|
||||
docker compose -f docker-compose.macmini.yml stop matrix-mana-bot 2>/dev/null || true
|
||||
docker compose -f docker-compose.macmini.yml rm -f matrix-mana-bot 2>/dev/null || true
|
||||
|
||||
# Step 6: Start new container
|
||||
echo ""
|
||||
echo -e "${CYAN}Step 6: Starting matrix-mana-bot...${NC}"
|
||||
docker compose -f docker-compose.macmini.yml up -d matrix-mana-bot || {
|
||||
echo -e "${RED}Failed to start container${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 7: Wait for health check
|
||||
echo ""
|
||||
echo -e "${CYAN}Step 7: Waiting for health check...${NC}"
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:3310/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}Health check passed!${NC}"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo -e "${RED}Health check failed after 30 seconds${NC}"
|
||||
echo "Check logs with: docker logs manacore-matrix-mana-bot"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Step 8: Show status
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e "${GREEN} Deployment Complete!${NC}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Container Status:"
|
||||
docker ps --filter "name=manacore-matrix-mana-bot" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
echo ""
|
||||
echo "Health Check:"
|
||||
curl -s http://localhost:3310/health | jq . 2>/dev/null || curl -s http://localhost:3310/health
|
||||
echo ""
|
||||
echo ""
|
||||
echo "Next Steps:"
|
||||
echo "1. Invite the bot to a Matrix room:"
|
||||
echo " /invite @mana:mana.how"
|
||||
echo ""
|
||||
echo "2. Test with:"
|
||||
echo " hilfe"
|
||||
echo " !todo Test aufgabe"
|
||||
echo " !list"
|
||||
echo ""
|
||||
echo "3. View logs with:"
|
||||
echo " docker logs -f manacore-matrix-mana-bot"
|
||||
echo ""
|
||||
160
scripts/mac-mini/setup-mana-bot.sh
Executable file
160
scripts/mac-mini/setup-mana-bot.sh
Executable file
|
|
@ -0,0 +1,160 @@
|
|||
#!/bin/bash
|
||||
# Register and setup Matrix Mana Bot (Gateway)
|
||||
# Run this after Matrix Synapse is running
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "============================================"
|
||||
echo " Matrix Mana Bot Setup"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Default values
|
||||
HOMESERVER_URL="${MATRIX_HOMESERVER_URL:-http://localhost:8008}"
|
||||
BOT_USERNAME="mana"
|
||||
BOT_DISPLAY_NAME="Mana"
|
||||
|
||||
# Check if Synapse is running
|
||||
echo "Checking Synapse..."
|
||||
if ! curl -s "${HOMESERVER_URL}/health" > /dev/null 2>&1; then
|
||||
echo -e "${RED}Error: Synapse is not reachable at ${HOMESERVER_URL}${NC}"
|
||||
echo "Start it with: docker compose -f docker-compose.macmini.yml up -d synapse"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Synapse is running${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if registration secret is available
|
||||
if [ -z "$SYNAPSE_REGISTRATION_SECRET" ]; then
|
||||
echo -e "${YELLOW}SYNAPSE_REGISTRATION_SECRET not set.${NC}"
|
||||
echo "Please provide the registration secret from your .env file:"
|
||||
read -sp "Registration Secret: " SYNAPSE_REGISTRATION_SECRET
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Generate bot password
|
||||
BOT_PASSWORD=$(openssl rand -base64 24)
|
||||
|
||||
echo "Registering bot user @${BOT_USERNAME}..."
|
||||
|
||||
# Generate HMAC for registration
|
||||
generate_mac() {
|
||||
local nonce=$1
|
||||
local user=$2
|
||||
local password=$3
|
||||
local user_type=$4
|
||||
local admin=$5
|
||||
|
||||
local mac_input="${nonce}\x00${user}\x00${password}\x00${user_type}\x00${admin}"
|
||||
echo -n "$mac_input" | openssl dgst -sha1 -hmac "$SYNAPSE_REGISTRATION_SECRET" | cut -d' ' -f2
|
||||
}
|
||||
|
||||
# Get nonce
|
||||
NONCE=$(curl -s "${HOMESERVER_URL}/_synapse/admin/v1/register" | jq -r '.nonce')
|
||||
|
||||
if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then
|
||||
echo -e "${RED}Failed to get registration nonce. Is admin registration enabled?${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate MAC
|
||||
MAC=$(generate_mac "$NONCE" "$BOT_USERNAME" "$BOT_PASSWORD" "bot" "false")
|
||||
|
||||
# Register user
|
||||
REGISTER_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_synapse/admin/v1/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"nonce\": \"${NONCE}\",
|
||||
\"username\": \"${BOT_USERNAME}\",
|
||||
\"password\": \"${BOT_PASSWORD}\",
|
||||
\"displayname\": \"${BOT_DISPLAY_NAME}\",
|
||||
\"user_type\": \"bot\",
|
||||
\"admin\": false,
|
||||
\"mac\": \"${MAC}\"
|
||||
}")
|
||||
|
||||
# Check if registration was successful
|
||||
if echo "$REGISTER_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then
|
||||
ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.access_token')
|
||||
USER_ID=$(echo "$REGISTER_RESPONSE" | jq -r '.user_id')
|
||||
|
||||
echo -e "${GREEN}Bot registered successfully!${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}User ID:${NC} ${USER_ID}"
|
||||
echo ""
|
||||
else
|
||||
ERROR=$(echo "$REGISTER_RESPONSE" | jq -r '.error // .errcode // "Unknown error"')
|
||||
|
||||
# Check if user already exists
|
||||
if echo "$ERROR" | grep -qi "user.*exists\|already.*registered\|M_USER_IN_USE"; then
|
||||
echo -e "${YELLOW}User @${BOT_USERNAME} already exists. Getting access token via login...${NC}"
|
||||
|
||||
echo "Please enter the existing bot password:"
|
||||
read -sp "Password: " EXISTING_PASSWORD
|
||||
echo ""
|
||||
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_matrix/client/r0/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"m.login.password\",
|
||||
\"user\": \"${BOT_USERNAME}\",
|
||||
\"password\": \"${EXISTING_PASSWORD}\"
|
||||
}")
|
||||
|
||||
if echo "$LOGIN_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then
|
||||
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token')
|
||||
USER_ID=$(echo "$LOGIN_RESPONSE" | jq -r '.user_id')
|
||||
echo -e "${GREEN}Login successful!${NC}"
|
||||
else
|
||||
echo -e "${RED}Login failed. Please check the password.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}Registration failed: ${ERROR}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Add to .env file"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo -e "${CYAN}# Matrix Mana Bot (Gateway)${NC}"
|
||||
echo "MATRIX_MANA_BOT_TOKEN=${ACCESS_TOKEN}"
|
||||
echo ""
|
||||
|
||||
# Optional: Set display name and avatar
|
||||
echo "Setting display name..."
|
||||
curl -s -X PUT "${HOMESERVER_URL}/_matrix/client/r0/profile/${USER_ID}/displayname" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"displayname\": \"🤖 ${BOT_DISPLAY_NAME}\"}" > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Next Steps"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "1. Add the MATRIX_MANA_BOT_TOKEN to your .env file"
|
||||
echo ""
|
||||
echo "2. Build the bot image:"
|
||||
echo " docker build -t matrix-mana-bot ./services/matrix-mana-bot"
|
||||
echo ""
|
||||
echo "3. Start the bot:"
|
||||
echo " docker compose -f docker-compose.macmini.yml up -d matrix-mana-bot"
|
||||
echo ""
|
||||
echo "4. Invite the bot to a room in Element:"
|
||||
echo " /invite @mana:mana.how"
|
||||
echo ""
|
||||
echo -e "${GREEN}Setup complete!${NC}"
|
||||
24
services/matrix-mana-bot/.env.example
Normal file
24
services/matrix-mana-bot/.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Server
|
||||
PORT=3310
|
||||
NODE_ENV=development
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# Matrix Connection
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_your_access_token_here
|
||||
MATRIX_STORAGE_PATH=./data/mana-bot-storage.json
|
||||
|
||||
# Optional: Restrict to specific rooms (comma-separated)
|
||||
# MATRIX_ALLOWED_ROOMS=!room1:mana.how,!room2:mana.how
|
||||
|
||||
# AI Service (Ollama)
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:4b
|
||||
OLLAMA_TIMEOUT=120000
|
||||
|
||||
# Clock Service (external API)
|
||||
CLOCK_API_URL=http://localhost:3017/api/v1
|
||||
|
||||
# Storage paths
|
||||
TODO_STORAGE_PATH=./data/todos.json
|
||||
CALENDAR_STORAGE_PATH=./data/calendar.json
|
||||
306
services/matrix-mana-bot/CLAUDE.md
Normal file
306
services/matrix-mana-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# Matrix Mana Bot (Gateway)
|
||||
|
||||
Unified Matrix bot that combines all features in one. Users can interact with a single bot for AI chat, todos, calendar, timers, and more.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ matrix-mana-bot │
|
||||
│ (Gateway) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Matrix Service │ │
|
||||
│ │ • Handles Matrix connection │ │
|
||||
│ │ • Receives messages │ │
|
||||
│ │ • Sends replies │ │
|
||||
│ └─────────────────────────┬────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Command Router │ │
|
||||
│ │ • Parses !commands and natural language │ │
|
||||
│ │ • Routes to appropriate handler │ │
|
||||
│ │ • Falls back to AI chat │ │
|
||||
│ └─────────────────────────┬────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┼──────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ AI Handler │ │Todo Handler│ │Cal Handler │ ... │
|
||||
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┴─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ @manacore/bot-services │ │
|
||||
│ │ (Shared Business Logic - no Matrix code) │ │
|
||||
│ │ │ │
|
||||
│ │ • TodoService • CalendarService │ │
|
||||
│ │ • AiService • ClockService │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
| Category | Commands | Description |
|
||||
|----------|----------|-------------|
|
||||
| **AI Chat** | Just type, `!model`, `!models`, `!all`, `!clear` | Local LLM via Ollama |
|
||||
| **Todos** | `!todo`, `!list`, `!today`, `!done`, `!delete` | Task management |
|
||||
| **Calendar** | `!cal`, `!week`, `!event`, `!calendars` | Event scheduling |
|
||||
| **Timers** | `!timer`, `!timers`, `!stop`, `!alarm`, `!alarms` | Time management |
|
||||
| **Smart** | `!summary`, `!ai-todo` | Cross-feature AI features |
|
||||
|
||||
## Commands
|
||||
|
||||
### AI & Chat
|
||||
|
||||
```
|
||||
# Just type a message - AI responds
|
||||
Was ist TypeScript?
|
||||
|
||||
# Switch model
|
||||
!model gemma3:4b
|
||||
|
||||
# List available models
|
||||
!models
|
||||
|
||||
# Compare all models
|
||||
!all Erkläre Docker
|
||||
|
||||
# Clear chat history
|
||||
!clear
|
||||
```
|
||||
|
||||
### Todos
|
||||
|
||||
```
|
||||
# Create task
|
||||
!todo Einkaufen gehen
|
||||
|
||||
# With priority (1-4, 1 = highest)
|
||||
!todo Wichtig !p1
|
||||
|
||||
# With date
|
||||
!todo Meeting @morgen
|
||||
!todo Report @heute
|
||||
|
||||
# With project
|
||||
!todo Feature implementieren #arbeit
|
||||
|
||||
# List all
|
||||
!list
|
||||
|
||||
# Today's tasks
|
||||
!today
|
||||
|
||||
# Complete task
|
||||
!done 1
|
||||
|
||||
# Delete task
|
||||
!delete 1
|
||||
```
|
||||
|
||||
### Calendar
|
||||
|
||||
```
|
||||
# Today's events
|
||||
!cal
|
||||
|
||||
# This week
|
||||
!week
|
||||
|
||||
# Create event
|
||||
!event Meeting morgen 14:30
|
||||
!event Geburtstag heute ganztägig
|
||||
```
|
||||
|
||||
### Timers & Alarms
|
||||
|
||||
```
|
||||
# Start timer
|
||||
!timer 25m Pomodoro
|
||||
!timer 1h30m Meeting
|
||||
|
||||
# List active timers
|
||||
!timers
|
||||
|
||||
# Stop timer
|
||||
!stop
|
||||
|
||||
# Set alarm
|
||||
!alarm 14:30 Meeting
|
||||
!alarm 7:00 Aufstehen
|
||||
|
||||
# List alarms
|
||||
!alarms
|
||||
|
||||
# World clock
|
||||
!time
|
||||
!time tokyo
|
||||
```
|
||||
|
||||
### Smart Features (Cross-Feature)
|
||||
|
||||
```
|
||||
# AI-powered daily summary
|
||||
!summary
|
||||
|
||||
# AI extracts todos from text
|
||||
!ai-todo Im Meeting besprochen: Website redesign, API Docs aktualisieren
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm
|
||||
- Running Matrix homeserver (Synapse)
|
||||
- Bot account with access token
|
||||
- Ollama (for AI features)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Start in development mode
|
||||
pnpm start:dev
|
||||
|
||||
# Or build and run
|
||||
pnpm build && pnpm start:prod
|
||||
```
|
||||
|
||||
### Get Matrix Access Token
|
||||
|
||||
```bash
|
||||
# Register bot user (if not exists)
|
||||
docker exec -it synapse register_new_matrix_user \
|
||||
-u mana-bot \
|
||||
-p your_password \
|
||||
-a \
|
||||
-c /data/homeserver.yaml \
|
||||
http://localhost:8008
|
||||
|
||||
# Login to get access token
|
||||
curl -X POST "http://localhost:8008/_matrix/client/r0/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type": "m.login.password", "user": "mana-bot", "password": "your_password"}'
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Entry point
|
||||
├── app.module.ts # Root module
|
||||
├── config/
|
||||
│ └── configuration.ts # Config & help texts
|
||||
├── health/
|
||||
│ └── health.controller.ts # Health endpoint
|
||||
├── bot/
|
||||
│ ├── bot.module.ts
|
||||
│ ├── matrix.service.ts # Matrix connection
|
||||
│ └── command-router.service.ts # Command routing
|
||||
├── handlers/
|
||||
│ ├── handlers.module.ts
|
||||
│ ├── ai.handler.ts # AI/Ollama commands
|
||||
│ ├── todo.handler.ts # Todo commands
|
||||
│ ├── calendar.handler.ts # Calendar commands
|
||||
│ ├── clock.handler.ts # Timer/alarm commands
|
||||
│ └── help.handler.ts # Help & status
|
||||
└── orchestration/
|
||||
├── orchestration.module.ts
|
||||
└── orchestration.service.ts # Cross-feature logic
|
||||
```
|
||||
|
||||
### Adding New Commands
|
||||
|
||||
1. Add route in `command-router.service.ts`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
patterns: ['!mycommand'],
|
||||
handler: (ctx, args) => this.myHandler.doSomething(ctx, args),
|
||||
description: 'My new command',
|
||||
}
|
||||
```
|
||||
|
||||
2. Create handler in `handlers/my.handler.ts`:
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class MyHandler {
|
||||
constructor(private myService: MyService) {}
|
||||
|
||||
async doSomething(ctx: CommandContext, args: string): Promise<string> {
|
||||
// Use service from @manacore/bot-services
|
||||
const result = await this.myService.doThing(ctx.userId, args);
|
||||
return `Result: ${result}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Register in `handlers.module.ts`
|
||||
|
||||
## Docker
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
docker build -t matrix-mana-bot .
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name matrix-mana-bot \
|
||||
-p 3310:3310 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e OLLAMA_URL=http://ollama:11434 \
|
||||
-v ./data:/app/data \
|
||||
matrix-mana-bot
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
See `docker-compose.macmini.yml` in the monorepo root.
|
||||
|
||||
## Relationship to Other Bots
|
||||
|
||||
This Gateway bot can run **alongside** the standalone bots:
|
||||
|
||||
| Bot | Purpose | When to Use |
|
||||
|-----|---------|-------------|
|
||||
| **matrix-mana-bot** (this) | All features in one | General users |
|
||||
| **matrix-todo-bot** | Todo only | Dedicated todo room |
|
||||
| **matrix-ollama-bot** | AI only | Dedicated AI room |
|
||||
| **matrix-clock-bot** | Timers only | Time tracking room |
|
||||
|
||||
All bots share the same `@manacore/bot-services` package, so data is consistent.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `PORT` | No | 3310 | HTTP port |
|
||||
| `MATRIX_HOMESERVER_URL` | Yes | - | Matrix server URL |
|
||||
| `MATRIX_ACCESS_TOKEN` | Yes | - | Bot access token |
|
||||
| `MATRIX_STORAGE_PATH` | No | ./data/... | Sync state storage |
|
||||
| `MATRIX_ALLOWED_ROOMS` | No | - | Restrict to rooms |
|
||||
| `OLLAMA_URL` | No | localhost:11434 | Ollama API |
|
||||
| `OLLAMA_MODEL` | No | gemma3:4b | Default LLM |
|
||||
| `CLOCK_API_URL` | No | localhost:3017 | Clock backend |
|
||||
| `TODO_STORAGE_PATH` | No | ./data/todos.json | Todo storage |
|
||||
| `CALENDAR_STORAGE_PATH` | No | ./data/calendar.json | Calendar storage |
|
||||
29
services/matrix-mana-bot/Dockerfile
Normal file
29
services/matrix-mana-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN pnpm build
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3310/health || exit 1
|
||||
|
||||
EXPOSE 3310
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
services/matrix-mana-bot/nest-cli.json
Normal file
8
services/matrix-mana-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
|
||||
}
|
||||
}
|
||||
37
services/matrix-mana-bot/package.json
Normal file
37
services/matrix-mana-bot/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "matrix-mana-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Unified Matrix Gateway Bot - All features in one",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/bot-services": "workspace:*",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"matrix-bot-sdk": "^0.7.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": ["cpu-features", "ssh2"],
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0",
|
||||
"ssh2": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
services/matrix-mana-bot/src/app.module.ts
Normal file
61
services/matrix-mana-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import configuration from './config/configuration';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { HandlersModule } from './handlers/handlers.module';
|
||||
import { OrchestrationModule } from './orchestration/orchestration.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
|
||||
// Import shared services from bot-services package
|
||||
import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot-services';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
|
||||
// Business Logic Modules from shared package
|
||||
TodoModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
storagePath: config.get('services.todo.storagePath'),
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
CalendarModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
storagePath: config.get('services.calendar.storagePath'),
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
AiModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
baseUrl: config.get('services.ai.baseUrl'),
|
||||
defaultModel: config.get('services.ai.defaultModel'),
|
||||
timeout: config.get('services.ai.timeout'),
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
ClockModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
apiUrl: config.get('services.clock.apiUrl'),
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Gateway-specific modules
|
||||
BotModule,
|
||||
HandlersModule,
|
||||
OrchestrationModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
12
services/matrix-mana-bot/src/bot/bot.module.ts
Normal file
12
services/matrix-mana-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { CommandRouterService } from './command-router.service';
|
||||
import { HandlersModule } from '../handlers/handlers.module';
|
||||
import { OrchestrationModule } from '../orchestration/orchestration.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => HandlersModule), forwardRef(() => OrchestrationModule)],
|
||||
providers: [MatrixService, CommandRouterService],
|
||||
exports: [MatrixService, CommandRouterService],
|
||||
})
|
||||
export class BotModule {}
|
||||
272
services/matrix-mana-bot/src/bot/command-router.service.ts
Normal file
272
services/matrix-mana-bot/src/bot/command-router.service.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { AiHandler } from '../handlers/ai.handler';
|
||||
import { TodoHandler } from '../handlers/todo.handler';
|
||||
import { CalendarHandler } from '../handlers/calendar.handler';
|
||||
import { ClockHandler } from '../handlers/clock.handler';
|
||||
import { HelpHandler } from '../handlers/help.handler';
|
||||
import { OrchestrationService } from '../orchestration/orchestration.service';
|
||||
|
||||
export interface CommandContext {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
message: string;
|
||||
event: any;
|
||||
}
|
||||
|
||||
interface CommandRoute {
|
||||
patterns: (string | RegExp)[];
|
||||
handler: (ctx: CommandContext, args: string) => Promise<string>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Natural language keywords (German + English)
|
||||
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
|
||||
{ keywords: ['hilfe', 'help', 'was kannst du', 'befehle'], command: '!help' },
|
||||
{ keywords: ['modelle', 'models', 'welche modelle'], command: '!models' },
|
||||
{ keywords: ['meine aufgaben', 'zeige aufgaben', 'todo liste', 'was muss ich'], command: '!list' },
|
||||
{ keywords: ['heute', 'was steht heute an'], command: '!today' },
|
||||
{ keywords: ['termine', 'kalender', 'meine termine'], command: '!cal' },
|
||||
{ keywords: ['timer', 'stoppuhr'], command: '!timers' },
|
||||
{ keywords: ['zusammenfassung', 'wie war mein tag', 'tagesrückblick'], command: '!summary' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CommandRouterService {
|
||||
private readonly logger = new Logger(CommandRouterService.name);
|
||||
private routes: CommandRoute[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => AiHandler))
|
||||
private aiHandler: AiHandler,
|
||||
@Inject(forwardRef(() => TodoHandler))
|
||||
private todoHandler: TodoHandler,
|
||||
@Inject(forwardRef(() => CalendarHandler))
|
||||
private calendarHandler: CalendarHandler,
|
||||
@Inject(forwardRef(() => ClockHandler))
|
||||
private clockHandler: ClockHandler,
|
||||
@Inject(forwardRef(() => HelpHandler))
|
||||
private helpHandler: HelpHandler,
|
||||
@Inject(forwardRef(() => OrchestrationService))
|
||||
private orchestration: OrchestrationService
|
||||
) {
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes() {
|
||||
this.routes = [
|
||||
// Help
|
||||
{
|
||||
patterns: ['!help', '!start', '!hilfe'],
|
||||
handler: (ctx) => this.helpHandler.showHelp(ctx),
|
||||
description: 'Show help',
|
||||
},
|
||||
|
||||
// AI Commands
|
||||
{
|
||||
patterns: ['!models', '!modelle'],
|
||||
handler: (ctx) => this.aiHandler.listModels(ctx),
|
||||
description: 'List AI models',
|
||||
},
|
||||
{
|
||||
patterns: ['!model'],
|
||||
handler: (ctx, args) => this.aiHandler.setModel(ctx, args),
|
||||
description: 'Switch AI model',
|
||||
},
|
||||
{
|
||||
patterns: ['!all'],
|
||||
handler: (ctx, args) => this.aiHandler.compareAll(ctx, args),
|
||||
description: 'Compare all models',
|
||||
},
|
||||
{
|
||||
patterns: ['!clear', '!reset'],
|
||||
handler: (ctx) => this.aiHandler.clearHistory(ctx),
|
||||
description: 'Clear chat history',
|
||||
},
|
||||
|
||||
// Todo Commands
|
||||
{
|
||||
patterns: ['!todo', '!add', '!neu'],
|
||||
handler: (ctx, args) => this.todoHandler.create(ctx, args),
|
||||
description: 'Create todo',
|
||||
},
|
||||
{
|
||||
patterns: ['!list', '!liste', '!alle'],
|
||||
handler: (ctx) => this.todoHandler.list(ctx),
|
||||
description: 'List todos',
|
||||
},
|
||||
{
|
||||
patterns: ['!today', '!heute'],
|
||||
handler: (ctx) => this.todoHandler.today(ctx),
|
||||
description: 'Today\'s todos',
|
||||
},
|
||||
{
|
||||
patterns: ['!inbox'],
|
||||
handler: (ctx) => this.todoHandler.inbox(ctx),
|
||||
description: 'Inbox todos',
|
||||
},
|
||||
{
|
||||
patterns: ['!done', '!erledigt', '!fertig'],
|
||||
handler: (ctx, args) => this.todoHandler.complete(ctx, args),
|
||||
description: 'Complete todo',
|
||||
},
|
||||
{
|
||||
patterns: ['!delete', '!löschen'],
|
||||
handler: (ctx, args) => this.todoHandler.delete(ctx, args),
|
||||
description: 'Delete todo',
|
||||
},
|
||||
{
|
||||
patterns: ['!projects', '!projekte'],
|
||||
handler: (ctx) => this.todoHandler.projects(ctx),
|
||||
description: 'List projects',
|
||||
},
|
||||
|
||||
// Calendar Commands
|
||||
{
|
||||
patterns: ['!cal', '!termine'],
|
||||
handler: (ctx) => this.calendarHandler.today(ctx),
|
||||
description: 'Today\'s events',
|
||||
},
|
||||
{
|
||||
patterns: ['!week', '!woche'],
|
||||
handler: (ctx) => this.calendarHandler.week(ctx),
|
||||
description: 'Week events',
|
||||
},
|
||||
{
|
||||
patterns: ['!event', '!termin'],
|
||||
handler: (ctx, args) => this.calendarHandler.create(ctx, args),
|
||||
description: 'Create event',
|
||||
},
|
||||
{
|
||||
patterns: ['!calendars', '!kalender'],
|
||||
handler: (ctx) => this.calendarHandler.listCalendars(ctx),
|
||||
description: 'List calendars',
|
||||
},
|
||||
|
||||
// Clock Commands
|
||||
{
|
||||
patterns: ['!timer'],
|
||||
handler: (ctx, args) => this.clockHandler.startTimer(ctx, args),
|
||||
description: 'Start timer',
|
||||
},
|
||||
{
|
||||
patterns: ['!timers'],
|
||||
handler: (ctx) => this.clockHandler.listTimers(ctx),
|
||||
description: 'List timers',
|
||||
},
|
||||
{
|
||||
patterns: ['!alarm'],
|
||||
handler: (ctx, args) => this.clockHandler.setAlarm(ctx, args),
|
||||
description: 'Set alarm',
|
||||
},
|
||||
{
|
||||
patterns: ['!alarms'],
|
||||
handler: (ctx) => this.clockHandler.listAlarms(ctx),
|
||||
description: 'List alarms',
|
||||
},
|
||||
{
|
||||
patterns: ['!time', '!zeit'],
|
||||
handler: (ctx, args) => this.clockHandler.worldClock(ctx, args),
|
||||
description: 'World clock',
|
||||
},
|
||||
{
|
||||
patterns: ['!stop'],
|
||||
handler: (ctx, args) => this.clockHandler.stopTimer(ctx, args),
|
||||
description: 'Stop timer',
|
||||
},
|
||||
|
||||
// Cross-Feature (Orchestration)
|
||||
{
|
||||
patterns: ['!summary', '!zusammenfassung'],
|
||||
handler: (ctx) => this.orchestration.dailySummary(ctx),
|
||||
description: 'Daily summary',
|
||||
},
|
||||
{
|
||||
patterns: ['!ai-todo'],
|
||||
handler: (ctx, args) => this.orchestration.aiToTodos(ctx, args),
|
||||
description: 'AI extracts todos',
|
||||
},
|
||||
|
||||
// Status
|
||||
{
|
||||
patterns: ['!status'],
|
||||
handler: (ctx) => this.helpHandler.showStatus(ctx),
|
||||
description: 'Show status',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async route(ctx: CommandContext): Promise<string | null> {
|
||||
const message = ctx.message.trim();
|
||||
|
||||
// Check for natural language keywords first
|
||||
const keywordCommand = this.detectKeywordCommand(message);
|
||||
if (keywordCommand) {
|
||||
return this.routeCommand({ ...ctx, message: keywordCommand });
|
||||
}
|
||||
|
||||
// Check for ! commands
|
||||
if (message.startsWith('!')) {
|
||||
return this.routeCommand(ctx);
|
||||
}
|
||||
|
||||
// Default: treat as AI chat
|
||||
return this.aiHandler.chat(ctx, message);
|
||||
}
|
||||
|
||||
private async routeCommand(ctx: CommandContext): Promise<string | null> {
|
||||
const { command, args } = this.parseCommand(ctx.message);
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (this.matchesPattern(command, route.patterns)) {
|
||||
this.logger.debug(`Routing "${command}" to ${route.description}`);
|
||||
try {
|
||||
return await route.handler(ctx, args);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in handler for "${command}":`, error);
|
||||
return `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown command
|
||||
return null;
|
||||
}
|
||||
|
||||
private detectKeywordCommand(message: string): string | null {
|
||||
const lowerMessage = message.toLowerCase().trim();
|
||||
|
||||
// Only check short messages
|
||||
if (lowerMessage.length > 60) return null;
|
||||
|
||||
for (const { keywords, command } of KEYWORD_COMMANDS) {
|
||||
for (const keyword of keywords) {
|
||||
if (lowerMessage === keyword || lowerMessage.includes(keyword)) {
|
||||
this.logger.debug(`Detected keyword "${keyword}" -> "${command}"`);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private matchesPattern(command: string, patterns: (string | RegExp)[]): boolean {
|
||||
for (const pattern of patterns) {
|
||||
if (typeof pattern === 'string') {
|
||||
if (command === pattern) return true;
|
||||
} else if (pattern.test(command)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseCommand(message: string): { command: string; args: string } {
|
||||
const trimmed = message.trim();
|
||||
if (trimmed.startsWith('!')) {
|
||||
const [cmd, ...rest] = trimmed.split(' ');
|
||||
return { command: cmd.toLowerCase(), args: rest.join(' ') };
|
||||
}
|
||||
return { command: '', args: trimmed };
|
||||
}
|
||||
}
|
||||
215
services/matrix-mana-bot/src/bot/matrix.service.ts
Normal file
215
services/matrix-mana-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject, forwardRef } 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 { CommandRouterService, CommandContext } from './command-router.service';
|
||||
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MatrixService.name);
|
||||
private client: MatrixClient;
|
||||
private botUserId: string = '';
|
||||
private readonly homeserverUrl: string;
|
||||
private readonly accessToken: string;
|
||||
private readonly allowedRooms: string[];
|
||||
private readonly storagePath: string;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(forwardRef(() => CommandRouterService))
|
||||
private commandRouter: CommandRouterService
|
||||
) {
|
||||
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/mana-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();
|
||||
this.logger.log('Matrix client stopped');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 userId = event.state_key;
|
||||
if (userId === this.botUserId) return;
|
||||
if (event.unsigned?.prev_content?.membership !== 'join') {
|
||||
await this.sendWelcomeMessage(roomId, userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up message handler
|
||||
this.client.on('room.message', async (roomId: string, event: any) => {
|
||||
await this.handleMessage(roomId, event);
|
||||
});
|
||||
|
||||
await this.client.start();
|
||||
this.botUserId = await this.client.getUserId();
|
||||
|
||||
this.logger.log(`Mana Gateway Bot connected to ${this.homeserverUrl}`);
|
||||
this.logger.log(`Bot user ID: ${this.botUserId}`);
|
||||
|
||||
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
|
||||
if (event.sender === this.botUserId) return;
|
||||
|
||||
// Check if room is allowed
|
||||
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msgtype = event.content?.msgtype;
|
||||
const body = event.content?.body?.trim();
|
||||
|
||||
// Only handle text messages for now
|
||||
if (msgtype !== 'm.text' || !body) return;
|
||||
|
||||
const ctx: CommandContext = {
|
||||
roomId,
|
||||
userId: event.sender,
|
||||
message: body,
|
||||
event,
|
||||
};
|
||||
|
||||
try {
|
||||
// Set typing indicator
|
||||
await this.client.setTyping(roomId, true, 30000);
|
||||
|
||||
// Route the message
|
||||
const response = await this.commandRouter.route(ctx);
|
||||
|
||||
// Stop typing
|
||||
await this.client.setTyping(roomId, false);
|
||||
|
||||
if (response) {
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.client.setTyping(roomId, false);
|
||||
this.logger.error(`Error handling message:`, error);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async sendMessage(roomId: string, message: string) {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: message,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(message),
|
||||
});
|
||||
}
|
||||
|
||||
private async sendWelcomeMessage(roomId: string, userId: string) {
|
||||
try {
|
||||
await this.sendMessage(roomId, WELCOME_TEXT);
|
||||
this.logger.log(`Sent welcome message to ${userId} in ${roomId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send welcome message: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBotIntroduction(roomId: string) {
|
||||
await this.sendMessage(roomId, 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(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
getClient(): MatrixClient {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
89
services/matrix-mana-bot/src/config/configuration.ts
Normal file
89
services/matrix-mana-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT, 10) || 3310,
|
||||
matrix: {
|
||||
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
|
||||
accessToken: process.env.MATRIX_ACCESS_TOKEN || '',
|
||||
storagePath: process.env.MATRIX_STORAGE_PATH || './data/mana-bot-storage.json',
|
||||
allowedRooms: process.env.MATRIX_ALLOWED_ROOMS
|
||||
? process.env.MATRIX_ALLOWED_ROOMS.split(',').map((r) => r.trim())
|
||||
: [],
|
||||
},
|
||||
services: {
|
||||
ai: {
|
||||
baseUrl: process.env.OLLAMA_URL || 'http://localhost:11434',
|
||||
defaultModel: process.env.OLLAMA_MODEL || 'gemma3:4b',
|
||||
timeout: parseInt(process.env.OLLAMA_TIMEOUT, 10) || 120000,
|
||||
},
|
||||
clock: {
|
||||
apiUrl: process.env.CLOCK_API_URL || 'http://localhost:3017/api/v1',
|
||||
},
|
||||
todo: {
|
||||
storagePath: process.env.TODO_STORAGE_PATH || './data/todos.json',
|
||||
},
|
||||
calendar: {
|
||||
storagePath: process.env.CALENDAR_STORAGE_PATH || './data/calendar.json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Help text for the unified bot
|
||||
export const HELP_TEXT = `**🤖 Mana - Dein Assistent**
|
||||
|
||||
**AI & Chat**
|
||||
Schreib einfach eine Nachricht - ich antworte!
|
||||
• \`!model [name]\` - KI-Modell wechseln
|
||||
• \`!models\` - Verfügbare Modelle anzeigen
|
||||
• \`!all [frage]\` - Alle Modelle vergleichen
|
||||
|
||||
**📋 Todos**
|
||||
• \`!todo [text]\` - Neue Aufgabe erstellen
|
||||
• \`!list\` - Alle offenen Aufgaben
|
||||
• \`!today\` - Heutige Aufgaben
|
||||
• \`!done [nr]\` - Aufgabe erledigen
|
||||
• \`!delete [nr]\` - Aufgabe löschen
|
||||
|
||||
**📅 Kalender**
|
||||
• \`!cal\` - Heutige Termine
|
||||
• \`!week\` - Wochenübersicht
|
||||
• \`!event [titel] [zeit]\` - Termin erstellen
|
||||
|
||||
**⏱️ Zeit & Timer**
|
||||
• \`!timer [dauer]\` - Timer starten (z.B. 25m)
|
||||
• \`!alarm [zeit]\` - Alarm setzen (z.B. 14:30)
|
||||
• \`!time [stadt]\` - Weltuhr
|
||||
• \`!timers\` - Aktive Timer anzeigen
|
||||
|
||||
**🔮 Smart Features**
|
||||
• \`!summary\` - Tages-Zusammenfassung (AI)
|
||||
• \`!ai-todo [text]\` - AI extrahiert Todos aus Text
|
||||
|
||||
**💡 Tipps**
|
||||
• Natürliche Sprache funktioniert: "Was sind meine Todos?"
|
||||
• Prioritäten: \`!todo Wichtig !p1\`
|
||||
• Datum: \`!todo Meeting @morgen\`
|
||||
• Projekt: \`!todo Task #projekt\`
|
||||
|
||||
---
|
||||
*100% DSGVO-konform - alle Daten lokal*`;
|
||||
|
||||
export const WELCOME_TEXT = `👋 **Willkommen bei Mana!**
|
||||
|
||||
Ich bin dein persönlicher Assistent mit vielen Funktionen:
|
||||
• 🤖 AI Chat (lokales LLM)
|
||||
• 📋 Todo-Verwaltung
|
||||
• 📅 Kalender
|
||||
• ⏱️ Timer & Alarme
|
||||
|
||||
Schreib einfach eine Nachricht oder sag "hilfe" für alle Befehle!`;
|
||||
|
||||
export const BOT_INTRODUCTION = `🤖 **Hallo! Ich bin Mana, euer All-in-One Assistent.**
|
||||
|
||||
Ich vereinige alle Bot-Funktionen in einem:
|
||||
• AI Chat & Fragen beantworten
|
||||
• Aufgaben verwalten
|
||||
• Termine planen
|
||||
• Timer & Alarme
|
||||
|
||||
Alle Daten bleiben auf diesem Server - 100% DSGVO-konform!
|
||||
|
||||
Sag einfach "hilfe" oder \`!help\` für alle Befehle.`;
|
||||
94
services/matrix-mana-bot/src/handlers/ai.handler.ts
Normal file
94
services/matrix-mana-bot/src/handlers/ai.handler.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AiService } from '@manacore/bot-services';
|
||||
import { CommandContext } from '../bot/command-router.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiHandler {
|
||||
private readonly logger = new Logger(AiHandler.name);
|
||||
|
||||
constructor(private aiService: AiService) {}
|
||||
|
||||
async chat(ctx: CommandContext, message: string): Promise<string> {
|
||||
this.logger.debug(`Chat request from ${ctx.userId}: ${message.substring(0, 50)}...`);
|
||||
|
||||
const response = await this.aiService.chatSimple(ctx.userId, message);
|
||||
return response;
|
||||
}
|
||||
|
||||
async listModels(ctx: CommandContext): Promise<string> {
|
||||
const models = await this.aiService.listModels();
|
||||
|
||||
if (models.length === 0) {
|
||||
return '❌ Keine Modelle gefunden. Ist Ollama gestartet?';
|
||||
}
|
||||
|
||||
const session = this.aiService.getSession(ctx.userId);
|
||||
const currentModel = session?.model || this.aiService.getDefaultModel();
|
||||
|
||||
const modelList = models
|
||||
.map((m) => {
|
||||
const sizeMB = (m.size / 1024 / 1024).toFixed(0);
|
||||
const active = m.name === currentModel ? ' ✓' : '';
|
||||
return `• \`${m.name}\` (${sizeMB} MB)${active}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `**Verfügbare Modelle:**\n\n${modelList}\n\nWechseln mit: \`!model [name]\``;
|
||||
}
|
||||
|
||||
async setModel(ctx: CommandContext, modelName: string): Promise<string> {
|
||||
if (!modelName.trim()) {
|
||||
const session = this.aiService.getSession(ctx.userId);
|
||||
const currentModel = session?.model || this.aiService.getDefaultModel();
|
||||
return `Aktuelles Modell: \`${currentModel}\`\n\nVerwendung: \`!model gemma3:4b\``;
|
||||
}
|
||||
|
||||
const models = await this.aiService.listModels();
|
||||
const exists = models.some((m) => m.name === modelName);
|
||||
|
||||
if (!exists) {
|
||||
const available = models.map((m) => m.name).join(', ');
|
||||
return `❌ Modell "${modelName}" nicht gefunden.\n\nVerfügbar: ${available}`;
|
||||
}
|
||||
|
||||
this.aiService.setSessionModel(ctx.userId, modelName);
|
||||
this.logger.log(`User ${ctx.userId} switched to model ${modelName}`);
|
||||
|
||||
return `✅ Modell gewechselt zu: \`${modelName}\``;
|
||||
}
|
||||
|
||||
async compareAll(ctx: CommandContext, question: string): Promise<string> {
|
||||
if (!question.trim()) {
|
||||
return `**Verwendung:** \`!all [Deine Frage]\`\n\nBeispiel: \`!all Was ist 2+2?\``;
|
||||
}
|
||||
|
||||
const results = await this.aiService.compareModels(question);
|
||||
|
||||
if (results.length === 0) {
|
||||
return '❌ Keine Modelle gefunden. Ist Ollama gestartet?';
|
||||
}
|
||||
|
||||
let resultText = `**📊 Modellvergleich**\n\n**Frage:** "${question}"\n\n---\n\n`;
|
||||
|
||||
for (const result of results) {
|
||||
const durationSec = (result.duration / 1000).toFixed(1);
|
||||
if (result.error) {
|
||||
resultText += `**${result.model}** ⏱️ ${durationSec}s\n❌ Fehler: ${result.error}\n\n---\n\n`;
|
||||
} else {
|
||||
const truncated =
|
||||
result.response.length > 400
|
||||
? result.response.substring(0, 400) + '...'
|
||||
: result.response;
|
||||
resultText += `**${result.model}** ⏱️ ${durationSec}s\n${truncated}\n\n---\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return resultText;
|
||||
}
|
||||
|
||||
async clearHistory(ctx: CommandContext): Promise<string> {
|
||||
this.aiService.clearSessionHistory(ctx.userId);
|
||||
this.logger.log(`User ${ctx.userId} cleared chat history`);
|
||||
return '✅ Chat-Verlauf gelöscht.';
|
||||
}
|
||||
}
|
||||
123
services/matrix-mana-bot/src/handlers/calendar.handler.ts
Normal file
123
services/matrix-mana-bot/src/handlers/calendar.handler.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CalendarService, CalendarEvent } from '@manacore/bot-services';
|
||||
import { CommandContext } from '../bot/command-router.service';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarHandler {
|
||||
private readonly logger = new Logger(CalendarHandler.name);
|
||||
|
||||
constructor(private calendarService: CalendarService) {}
|
||||
|
||||
async today(ctx: CommandContext): Promise<string> {
|
||||
const events = await this.calendarService.getTodayEvents(ctx.userId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return '📅 Keine Termine für heute.\n\nErstelle einen mit `!event [Titel] [Zeit]`';
|
||||
}
|
||||
|
||||
return this.formatEventList('📅 **Termine heute:**', events);
|
||||
}
|
||||
|
||||
async week(ctx: CommandContext): Promise<string> {
|
||||
const events = await this.calendarService.getWeekEvents(ctx.userId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return '📅 Keine Termine diese Woche.';
|
||||
}
|
||||
|
||||
return this.formatEventList('📅 **Termine diese Woche:**', events);
|
||||
}
|
||||
|
||||
async create(ctx: CommandContext, input: string): Promise<string> {
|
||||
if (!input.trim()) {
|
||||
return `**Verwendung:** \`!event [Titel] [Zeit]\`
|
||||
|
||||
**Beispiele:**
|
||||
• \`!event Meeting morgen 14:30\`
|
||||
• \`!event Zahnarzt 15.02. 10:00\`
|
||||
• \`!event Geburtstag heute ganztägig\``;
|
||||
}
|
||||
|
||||
const parsed = this.calendarService.parseEventInput(input);
|
||||
const event = await this.calendarService.createEvent(ctx.userId, parsed);
|
||||
|
||||
const timeStr = event.isAllDay
|
||||
? 'Ganztägig'
|
||||
: this.formatTime(event.startTime);
|
||||
|
||||
const dateStr = this.formatDate(event.startTime);
|
||||
|
||||
this.logger.log(`Created event "${event.title}" for ${ctx.userId}`);
|
||||
return `✅ Termin erstellt: **${event.title}**\n📅 ${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
async listCalendars(ctx: CommandContext): Promise<string> {
|
||||
const calendars = await this.calendarService.getCalendars(ctx.userId);
|
||||
|
||||
if (calendars.length === 0) {
|
||||
return '📅 Keine Kalender vorhanden.\n\nTermine werden automatisch im Standard-Kalender gespeichert.';
|
||||
}
|
||||
|
||||
let response = '📅 **Deine Kalender:**\n\n';
|
||||
for (const cal of calendars) {
|
||||
const color = cal.color || '⬜';
|
||||
response += `${color} ${cal.name}\n`;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private formatEventList(header: string, events: CalendarEvent[]): string {
|
||||
let response = `${header}\n\n`;
|
||||
|
||||
// Group events by date
|
||||
const byDate = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const dateKey = new Date(event.startTime).toISOString().split('T')[0];
|
||||
if (!byDate.has(dateKey)) {
|
||||
byDate.set(dateKey, []);
|
||||
}
|
||||
byDate.get(dateKey)!.push(event);
|
||||
}
|
||||
|
||||
for (const [dateKey, dayEvents] of byDate) {
|
||||
const dateLabel = this.formatDate(dateKey);
|
||||
response += `**${dateLabel}:**\n`;
|
||||
|
||||
for (const event of dayEvents) {
|
||||
const timeStr = event.isAllDay
|
||||
? '🌅 Ganztägig'
|
||||
: `⏰ ${this.formatTime(event.startTime)}`;
|
||||
response += `• ${timeStr} - ${event.title}\n`;
|
||||
}
|
||||
response += '\n';
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private formatDate(dateInput: string | Date): string {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
if (dateStr === todayStr) return 'Heute';
|
||||
if (dateStr === tomorrowStr) return 'Morgen';
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
private formatTime(dateInput: string | Date): string {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
152
services/matrix-mana-bot/src/handlers/clock.handler.ts
Normal file
152
services/matrix-mana-bot/src/handlers/clock.handler.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ClockService } from '@manacore/bot-services';
|
||||
import { CommandContext } from '../bot/command-router.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClockHandler {
|
||||
private readonly logger = new Logger(ClockHandler.name);
|
||||
|
||||
constructor(private clockService: ClockService) {}
|
||||
|
||||
async startTimer(ctx: CommandContext, input: string): Promise<string> {
|
||||
if (!input.trim()) {
|
||||
return `**Verwendung:** \`!timer [Dauer] [Name]\`
|
||||
|
||||
**Beispiele:**
|
||||
• \`!timer 25m Pomodoro\`
|
||||
• \`!timer 1h30m Meeting\`
|
||||
• \`!timer 5m Pause\`
|
||||
|
||||
**Dauer-Formate:** 5m, 1h, 1h30m, 90s`;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.clockService.startTimer(ctx.userId, input);
|
||||
this.logger.log(`Started timer for ${ctx.userId}: ${result.name}`);
|
||||
|
||||
const durationStr = this.formatDuration(result.durationSeconds);
|
||||
return `⏱️ Timer gestartet: **${result.name || 'Timer'}**\nDauer: ${durationStr}\n\nStoppen mit \`!stop\``;
|
||||
} catch (error) {
|
||||
return `❌ ${error instanceof Error ? error.message : 'Fehler beim Starten des Timers'}`;
|
||||
}
|
||||
}
|
||||
|
||||
async listTimers(ctx: CommandContext): Promise<string> {
|
||||
try {
|
||||
const timers = await this.clockService.getTimers(ctx.userId);
|
||||
|
||||
if (timers.length === 0) {
|
||||
return '⏱️ Keine aktiven Timer.\n\nStarte einen mit `!timer [Dauer]`';
|
||||
}
|
||||
|
||||
let response = '⏱️ **Aktive Timer:**\n\n';
|
||||
for (const timer of timers) {
|
||||
const remaining = this.formatDuration(timer.remainingSeconds);
|
||||
const status = timer.isPaused ? '⏸️' : '▶️';
|
||||
response += `${status} **${timer.name || 'Timer'}** - ${remaining} verbleibend\n`;
|
||||
}
|
||||
|
||||
response += '\n`!stop` zum Beenden';
|
||||
return response;
|
||||
} catch (error) {
|
||||
return '❌ Fehler beim Abrufen der Timer.';
|
||||
}
|
||||
}
|
||||
|
||||
async stopTimer(ctx: CommandContext, args: string): Promise<string> {
|
||||
try {
|
||||
const result = await this.clockService.stopTimer(ctx.userId, args.trim() || undefined);
|
||||
return `⏹️ Timer gestoppt: **${result.name || 'Timer'}**`;
|
||||
} catch (error) {
|
||||
return `❌ ${error instanceof Error ? error.message : 'Kein aktiver Timer gefunden'}`;
|
||||
}
|
||||
}
|
||||
|
||||
async setAlarm(ctx: CommandContext, input: string): Promise<string> {
|
||||
if (!input.trim()) {
|
||||
return `**Verwendung:** \`!alarm [Zeit] [Name]\`
|
||||
|
||||
**Beispiele:**
|
||||
• \`!alarm 14:30 Meeting\`
|
||||
• \`!alarm 7:00 Aufstehen\`
|
||||
• \`!alarm 18 Uhr Feierabend\``;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.clockService.setAlarm(ctx.userId, input);
|
||||
this.logger.log(`Set alarm for ${ctx.userId}: ${result.name} at ${result.time}`);
|
||||
|
||||
return `⏰ Alarm gesetzt: **${result.name || 'Alarm'}**\nZeit: ${result.time}`;
|
||||
} catch (error) {
|
||||
return `❌ ${error instanceof Error ? error.message : 'Fehler beim Setzen des Alarms'}`;
|
||||
}
|
||||
}
|
||||
|
||||
async listAlarms(ctx: CommandContext): Promise<string> {
|
||||
try {
|
||||
const alarms = await this.clockService.getAlarms(ctx.userId);
|
||||
|
||||
if (alarms.length === 0) {
|
||||
return '⏰ Keine aktiven Alarme.\n\nSetze einen mit `!alarm [Zeit]`';
|
||||
}
|
||||
|
||||
let response = '⏰ **Aktive Alarme:**\n\n';
|
||||
for (const alarm of alarms) {
|
||||
const status = alarm.enabled ? '🔔' : '🔕';
|
||||
response += `${status} **${alarm.name || 'Alarm'}** - ${alarm.time}\n`;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
return '❌ Fehler beim Abrufen der Alarme.';
|
||||
}
|
||||
}
|
||||
|
||||
async worldClock(ctx: CommandContext, city: string): Promise<string> {
|
||||
if (!city.trim()) {
|
||||
// Show common time zones
|
||||
const zones = [
|
||||
{ city: 'Berlin', tz: 'Europe/Berlin' },
|
||||
{ city: 'London', tz: 'Europe/London' },
|
||||
{ city: 'New York', tz: 'America/New_York' },
|
||||
{ city: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
{ city: 'Sydney', tz: 'Australia/Sydney' },
|
||||
];
|
||||
|
||||
let response = '🌍 **Weltuhren:**\n\n';
|
||||
const now = new Date();
|
||||
|
||||
for (const { city, tz } of zones) {
|
||||
const time = now.toLocaleTimeString('de-DE', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
response += `• **${city}:** ${time}\n`;
|
||||
}
|
||||
|
||||
response += '\nZeige andere Stadt: `!time [Stadt]`';
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.clockService.getWorldClock(city);
|
||||
return `🕐 **${result.city}:** ${result.time}\n📅 ${result.date}`;
|
||||
} catch (error) {
|
||||
return `❌ Stadt "${city}" nicht gefunden.\n\nVersuche: Berlin, London, New York, Tokyo, Sydney`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (secs > 0 && hours === 0) parts.push(`${secs}s`);
|
||||
|
||||
return parts.join(' ') || '0s';
|
||||
}
|
||||
}
|
||||
14
services/matrix-mana-bot/src/handlers/handlers.module.ts
Normal file
14
services/matrix-mana-bot/src/handlers/handlers.module.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { AiHandler } from './ai.handler';
|
||||
import { TodoHandler } from './todo.handler';
|
||||
import { CalendarHandler } from './calendar.handler';
|
||||
import { ClockHandler } from './clock.handler';
|
||||
import { HelpHandler } from './help.handler';
|
||||
import { BotModule } from '../bot/bot.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => BotModule)],
|
||||
providers: [AiHandler, TodoHandler, CalendarHandler, ClockHandler, HelpHandler],
|
||||
exports: [AiHandler, TodoHandler, CalendarHandler, ClockHandler, HelpHandler],
|
||||
})
|
||||
export class HandlersModule {}
|
||||
39
services/matrix-mana-bot/src/handlers/help.handler.ts
Normal file
39
services/matrix-mana-bot/src/handlers/help.handler.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AiService, TodoService } from '@manacore/bot-services';
|
||||
import { CommandContext } from '../bot/command-router.service';
|
||||
import { HELP_TEXT } from '../config/configuration';
|
||||
|
||||
@Injectable()
|
||||
export class HelpHandler {
|
||||
constructor(
|
||||
private aiService: AiService,
|
||||
private todoService: TodoService
|
||||
) {}
|
||||
|
||||
async showHelp(ctx: CommandContext): Promise<string> {
|
||||
return HELP_TEXT;
|
||||
}
|
||||
|
||||
async showStatus(ctx: CommandContext): Promise<string> {
|
||||
const aiConnected = await this.aiService.checkConnection();
|
||||
const todoStats = await this.todoService.getStats(ctx.userId);
|
||||
|
||||
const aiStatus = aiConnected ? '✅ Online' : '❌ Offline';
|
||||
const currentModel = this.aiService.getSession(ctx.userId)?.model || this.aiService.getDefaultModel();
|
||||
|
||||
return `**📊 Status**
|
||||
|
||||
**AI/Ollama**
|
||||
• Verbindung: ${aiStatus}
|
||||
• Modell: \`${currentModel}\`
|
||||
|
||||
**Todos**
|
||||
• Offen: ${todoStats.pending}
|
||||
• Heute fällig: ${todoStats.today}
|
||||
• Erledigt: ${todoStats.completed}
|
||||
|
||||
**Bot**
|
||||
• Status: ✅ Online
|
||||
• DSGVO: ✅ Alle Daten lokal`;
|
||||
}
|
||||
}
|
||||
144
services/matrix-mana-bot/src/handlers/todo.handler.ts
Normal file
144
services/matrix-mana-bot/src/handlers/todo.handler.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TodoService, Task } from '@manacore/bot-services';
|
||||
import { CommandContext } from '../bot/command-router.service';
|
||||
|
||||
@Injectable()
|
||||
export class TodoHandler {
|
||||
private readonly logger = new Logger(TodoHandler.name);
|
||||
|
||||
constructor(private todoService: TodoService) {}
|
||||
|
||||
async create(ctx: CommandContext, input: string): Promise<string> {
|
||||
if (!input.trim()) {
|
||||
return '❌ Bitte gib eine Aufgabe an.\n\nBeispiel: `!todo Einkaufen gehen`';
|
||||
}
|
||||
|
||||
const parsed = this.todoService.parseTaskInput(input);
|
||||
const task = await this.todoService.createTask(ctx.userId, parsed);
|
||||
|
||||
let response = `✅ Aufgabe erstellt: **${task.title}**`;
|
||||
|
||||
const details: string[] = [];
|
||||
if (parsed.priority < 4) details.push(`Priorität ${parsed.priority}`);
|
||||
if (parsed.dueDate) details.push(`Datum: ${this.formatDate(parsed.dueDate)}`);
|
||||
if (parsed.project) details.push(`Projekt: ${parsed.project}`);
|
||||
|
||||
if (details.length > 0) {
|
||||
response += `\n📋 ${details.join(' | ')}`;
|
||||
}
|
||||
|
||||
this.logger.log(`Created task "${task.title}" for ${ctx.userId}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async list(ctx: CommandContext): Promise<string> {
|
||||
const tasks = await this.todoService.getAllPendingTasks(ctx.userId);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return '📭 Keine offenen Aufgaben.\n\nErstelle eine mit `!todo [Aufgabe]`';
|
||||
}
|
||||
|
||||
return this.formatTaskList('📋 **Alle offenen Aufgaben:**', tasks);
|
||||
}
|
||||
|
||||
async today(ctx: CommandContext): Promise<string> {
|
||||
const tasks = await this.todoService.getTodayTasks(ctx.userId);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return '📭 Keine Aufgaben für heute.\n\nErstelle eine mit `!todo Aufgabe @heute`';
|
||||
}
|
||||
|
||||
return this.formatTaskList('📅 **Aufgaben für heute:**', tasks);
|
||||
}
|
||||
|
||||
async inbox(ctx: CommandContext): Promise<string> {
|
||||
const tasks = await this.todoService.getInboxTasks(ctx.userId);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return '📭 Inbox ist leer.\n\nAufgaben ohne Datum landen hier.';
|
||||
}
|
||||
|
||||
return this.formatTaskList('📥 **Inbox (ohne Datum):**', tasks);
|
||||
}
|
||||
|
||||
async complete(ctx: CommandContext, args: string): Promise<string> {
|
||||
const taskNumber = parseInt(args.trim());
|
||||
|
||||
if (isNaN(taskNumber) || taskNumber < 1) {
|
||||
return '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!done 1`';
|
||||
}
|
||||
|
||||
const task = await this.todoService.completeTaskByIndex(ctx.userId, taskNumber);
|
||||
|
||||
if (!task) {
|
||||
return `❌ Aufgabe #${taskNumber} nicht gefunden.`;
|
||||
}
|
||||
|
||||
this.logger.log(`Completed task "${task.title}" for ${ctx.userId}`);
|
||||
return `✅ Erledigt: ~~${task.title}~~`;
|
||||
}
|
||||
|
||||
async delete(ctx: CommandContext, args: string): Promise<string> {
|
||||
const taskNumber = parseInt(args.trim());
|
||||
|
||||
if (isNaN(taskNumber) || taskNumber < 1) {
|
||||
return '❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`';
|
||||
}
|
||||
|
||||
const task = await this.todoService.deleteTaskByIndex(ctx.userId, taskNumber);
|
||||
|
||||
if (!task) {
|
||||
return `❌ Aufgabe #${taskNumber} nicht gefunden.`;
|
||||
}
|
||||
|
||||
this.logger.log(`Deleted task "${task.title}" for ${ctx.userId}`);
|
||||
return `🗑️ Gelöscht: ${task.title}`;
|
||||
}
|
||||
|
||||
async projects(ctx: CommandContext): Promise<string> {
|
||||
const projectList = await this.todoService.getProjects(ctx.userId);
|
||||
|
||||
if (projectList.length === 0) {
|
||||
return '📭 Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!todo Aufgabe #projektname`';
|
||||
}
|
||||
|
||||
let response = '📁 **Deine Projekte:**\n\n';
|
||||
for (const project of projectList) {
|
||||
response += `• ${project.name}\n`;
|
||||
}
|
||||
response += '\nZeige Projektaufgaben mit `!project [Name]`';
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private formatTaskList(header: string, tasks: Task[]): string {
|
||||
let response = `${header}\n\n`;
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
const num = index + 1;
|
||||
const priority = task.priority < 4 ? `❗`.repeat(4 - task.priority) : '';
|
||||
const date = task.dueDate ? ` 📅 ${this.formatDate(task.dueDate)}` : '';
|
||||
const project = task.project ? ` 📁 ${task.project}` : '';
|
||||
|
||||
response += `**${num}.** ${task.title}${priority}${date}${project}\n`;
|
||||
});
|
||||
|
||||
response += `\n✅ Erledigen: \`!done [Nr]\` | 🗑️ Löschen: \`!delete [Nr]\``;
|
||||
return response;
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (dateStr === today.toISOString().split('T')[0]) {
|
||||
return 'Heute';
|
||||
} else if (dateStr === tomorrow.toISOString().split('T')[0]) {
|
||||
return 'Morgen';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
}
|
||||
13
services/matrix-mana-bot/src/health/health.controller.ts
Normal file
13
services/matrix-mana-bot/src/health/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-mana-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/matrix-mana-bot/src/main.ts
Normal file
18
services/matrix-mana-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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', 3310);
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Mana Gateway Bot running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { OrchestrationService } from './orchestration.service';
|
||||
import { BotModule } from '../bot/bot.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => BotModule)],
|
||||
providers: [OrchestrationService],
|
||||
exports: [OrchestrationService],
|
||||
})
|
||||
export class OrchestrationModule {}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AiService, TodoService, CalendarService } from '@manacore/bot-services';
|
||||
import { CommandContext } from '../bot/command-router.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrchestrationService {
|
||||
private readonly logger = new Logger(OrchestrationService.name);
|
||||
|
||||
constructor(
|
||||
private aiService: AiService,
|
||||
private todoService: TodoService,
|
||||
private calendarService: CalendarService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* !summary - AI-powered daily summary combining todos, calendar, etc.
|
||||
*/
|
||||
async dailySummary(ctx: CommandContext): Promise<string> {
|
||||
this.logger.log(`Generating daily summary for ${ctx.userId}`);
|
||||
|
||||
// Gather data from all services in parallel
|
||||
const [todoStats, todayTodos, todayEvents] = await Promise.all([
|
||||
this.todoService.getStats(ctx.userId),
|
||||
this.todoService.getTodayTasks(ctx.userId),
|
||||
this.calendarService.getTodayEvents(ctx.userId),
|
||||
]);
|
||||
|
||||
// Build context for AI
|
||||
const todoList = todayTodos.map((t) => t.title).join(', ') || 'keine';
|
||||
const eventList = todayEvents.map((e) => e.title).join(', ') || 'keine';
|
||||
|
||||
const prompt = `Du bist ein freundlicher Assistent. Erstelle eine kurze, motivierende Tages-Zusammenfassung auf Deutsch (max 5 Sätze).
|
||||
|
||||
Daten für heute:
|
||||
- Offene Todos: ${todoStats.pending} (davon heute fällig: ${todoStats.today})
|
||||
- Erledigte Todos: ${todoStats.completed}
|
||||
- Heutige Todos: ${todoList}
|
||||
- Heutige Termine: ${eventList}
|
||||
|
||||
Fasse das freundlich und motivierend zusammen. Gib konkrete Tipps falls viele Aufgaben offen sind.`;
|
||||
|
||||
try {
|
||||
const summary = await this.aiService.chatSimple(ctx.userId, prompt);
|
||||
|
||||
return `**📊 Deine Tages-Zusammenfassung**
|
||||
|
||||
${summary}
|
||||
|
||||
---
|
||||
*Generiert mit AI*`;
|
||||
} catch (error) {
|
||||
// Fallback without AI
|
||||
return `**📊 Deine Tages-Übersicht**
|
||||
|
||||
**Todos:**
|
||||
• Offen: ${todoStats.pending}
|
||||
• Heute fällig: ${todoStats.today}
|
||||
• Erledigt: ${todoStats.completed}
|
||||
|
||||
**Termine heute:** ${eventList}
|
||||
|
||||
---
|
||||
*AI-Zusammenfassung nicht verfügbar*`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* !ai-todo - AI extracts todos from text (meeting notes, etc.)
|
||||
*/
|
||||
async aiToTodos(ctx: CommandContext, text: string): Promise<string> {
|
||||
if (!text.trim()) {
|
||||
return `**Verwendung:** \`!ai-todo [Text]\`
|
||||
|
||||
**Beispiel:**
|
||||
\`!ai-todo Im Meeting haben wir besprochen: Website redesign bis Freitag, API Dokumentation aktualisieren, und Peter soll das Budget prüfen.\`
|
||||
|
||||
Die AI extrahiert automatisch Aufgaben und erstellt Todos.`;
|
||||
}
|
||||
|
||||
this.logger.log(`Extracting todos from text for ${ctx.userId}`);
|
||||
|
||||
const prompt = `Extrahiere alle Aufgaben aus folgendem Text.
|
||||
Antworte NUR mit einem JSON-Array im Format:
|
||||
[{"text": "Aufgabentext", "priority": 1-4}]
|
||||
|
||||
Prioritäten:
|
||||
1 = Dringend/Wichtig
|
||||
2 = Wichtig
|
||||
3 = Normal
|
||||
4 = Niedrig
|
||||
|
||||
Text: ${text}`;
|
||||
|
||||
try {
|
||||
const response = await this.aiService.chatSimple(ctx.userId, prompt);
|
||||
|
||||
// Parse JSON from response
|
||||
const jsonMatch = response.match(/\[[\s\S]*?\]/);
|
||||
if (!jsonMatch) {
|
||||
return '❌ Konnte keine Aufgaben extrahieren. Versuche es mit klarerem Text.';
|
||||
}
|
||||
|
||||
const todos = JSON.parse(jsonMatch[0]) as { text: string; priority?: number }[];
|
||||
|
||||
if (todos.length === 0) {
|
||||
return '❌ Keine Aufgaben im Text gefunden.';
|
||||
}
|
||||
|
||||
// Create todos
|
||||
const created: string[] = [];
|
||||
for (const todo of todos) {
|
||||
const task = await this.todoService.createTask(ctx.userId, {
|
||||
title: todo.text,
|
||||
priority: todo.priority || 4,
|
||||
});
|
||||
created.push(task.title);
|
||||
}
|
||||
|
||||
this.logger.log(`Created ${created.length} todos from AI extraction for ${ctx.userId}`);
|
||||
|
||||
const lines = created.map((t, i) => `${i + 1}. ${t}`).join('\n');
|
||||
return `✅ **${created.length} Todos erstellt:**
|
||||
|
||||
${lines}
|
||||
|
||||
Zeige alle mit \`!list\``;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI todo extraction failed:`, error);
|
||||
return `❌ Fehler bei der Extraktion: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a todo with a calendar reminder
|
||||
*/
|
||||
async todoWithReminder(ctx: CommandContext, input: string): Promise<string> {
|
||||
// Parse: "Aufgabe @morgen 14:00"
|
||||
const parsed = this.todoService.parseTaskInput(input);
|
||||
|
||||
// Create todo
|
||||
const task = await this.todoService.createTask(ctx.userId, parsed);
|
||||
|
||||
// If date was specified, create calendar event as reminder
|
||||
if (parsed.dueDate) {
|
||||
await this.calendarService.createEvent(ctx.userId, {
|
||||
title: `📋 Todo: ${task.title}`,
|
||||
startTime: new Date(parsed.dueDate),
|
||||
isAllDay: true,
|
||||
});
|
||||
}
|
||||
|
||||
let response = `✅ Todo erstellt: **${task.title}**`;
|
||||
if (parsed.dueDate) {
|
||||
response += `\n📅 Erinnerung im Kalender eingetragen`;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
25
services/matrix-mana-bot/tsconfig.json
Normal file
25
services/matrix-mana-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue