feat(services): add matrix-mana-bot unified gateway

Introduces a unified Matrix bot that combines all features:
- AI Chat (Ollama integration)
- Todo management
- Calendar events
- Timers & Alarms
- Cross-feature orchestration (!summary, !ai-todo)

Architecture:
- Uses @manacore/bot-services for shared business logic
- Command router with natural language support
- Handlers delegate to shared services
- Orchestration layer for cross-feature AI features

This enables users to interact with a single bot for all features,
while standalone bots remain available for dedicated use cases.

https://claude.ai/code/session_015bwcqVRiFmSydYTjvDJGTc
This commit is contained in:
Claude 2026-01-29 00:23:46 +00:00
parent 68a6c7a8d6
commit 2d879b327e
No known key found for this signature in database
21 changed files with 1859 additions and 0 deletions

View 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

View 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 |

View 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"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View 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"
}
}
}

View 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 {}

View 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 {}

View 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 };
}
}

View 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;
}
}

View 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.`;

View file

@ -0,0 +1,109 @@
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.setModel(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 models = await this.aiService.listModels();
if (models.length === 0) {
return '❌ Keine Modelle gefunden. Ist Ollama gestartet?';
}
const results: { model: string; response: string; duration: number; error?: string }[] = [];
for (const model of models) {
const startTime = Date.now();
try {
this.logger.debug(`Querying model ${model.name}...`);
const response = await this.aiService.chat(ctx.userId, question, model.name);
const duration = Date.now() - startTime;
results.push({ model: model.name, response, 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 });
}
}
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.clearHistory(ctx.userId);
this.logger.log(`User ${ctx.userId} cleared chat history`);
return '✅ Chat-Verlauf gelöscht.';
}
}

View 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' });
}
}

View 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';
}
}

View 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 {}

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

View 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.completeTask(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.deleteTask(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' });
}
}

View 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(),
};
}
}

View 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();

View file

@ -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 {}

View file

@ -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;
}
}

View 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"]
}