mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
♻️ refactor(matrix-bots): remove offline mode, require login for all operations
- Remove local JSON storage from matrix-todo-bot and matrix-calendar-bot - Delete TodoService, CalendarService and their modules - Add requireLogin() helper that prompts users to authenticate - All bot commands now require login before any operation - Data is always synced with respective backends (todo-backend, calendar-backend) - Update CLAUDE.md documentation for both bots BREAKING CHANGE: Bots no longer work without authentication Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
435d06a756
commit
b9f0d841df
12 changed files with 442 additions and 1116 deletions
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
## Overview
|
||||
|
||||
Matrix Calendar Bot provides a GDPR-compliant calendar/event management interface via Matrix chat. It uses the Matrix protocol for messaging, allowing self-hosting all data on the Mac Mini server.
|
||||
Matrix Calendar Bot provides calendar/event management via Matrix chat. It integrates with the Calendar backend for full CRUD operations, syncing events across Matrix, web, and mobile apps.
|
||||
|
||||
**Login Required**: Users must login (`!login email password`) to use the bot. All events are synchronized with the calendar-backend.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Storage**: Local JSON file (per-user events)
|
||||
- **Backend**: Calendar API (port 3014)
|
||||
- **Auth**: Mana Core Auth (JWT)
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -34,12 +37,9 @@ services/matrix-calendar-bot/
|
|||
│ ├── health.controller.ts # Health check endpoint
|
||||
│ ├── config/
|
||||
│ │ └── configuration.ts # Configuration & help texts
|
||||
│ ├── bot/
|
||||
│ │ ├── bot.module.ts
|
||||
│ │ └── matrix.service.ts # Matrix client & command handlers
|
||||
│ └── calendar/
|
||||
│ ├── calendar.module.ts
|
||||
│ └── calendar.service.ts # Event storage & management
|
||||
│ └── bot/
|
||||
│ ├── bot.module.ts
|
||||
│ └── matrix.service.ts # Matrix client & command handlers
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
|
@ -49,6 +49,8 @@ services/matrix-calendar-bot/
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!help` | Show help message |
|
||||
| `!login email pass` | Login (required before use) |
|
||||
| `!logout` | Logout |
|
||||
| `!heute` / `!today` | Show today's events |
|
||||
| `!morgen` / `!tomorrow` | Show tomorrow's events |
|
||||
| `!woche` / `!week` | Show this week's events |
|
||||
|
|
@ -97,8 +99,17 @@ MATRIX_ACCESS_TOKEN=syt_xxx
|
|||
MATRIX_ALLOWED_ROOMS=#calendar-bot:mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Calendar API (optional, for future integration)
|
||||
CALENDAR_API_URL=http://localhost:3016/api/v1
|
||||
# Calendar Backend
|
||||
CALENDAR_BACKEND_URL=http://localhost:3014
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Redis (for session storage)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Speech-to-Text (optional)
|
||||
STT_URL=http://localhost:3020
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
|
@ -111,6 +122,8 @@ docker build -f services/matrix-calendar-bot/Dockerfile -t matrix-calendar-bot s
|
|||
docker run -p 3315:3315 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e CALENDAR_BACKEND_URL=http://calendar-backend:3014 \
|
||||
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
|
||||
-v matrix-calendar-bot-data:/app/data \
|
||||
matrix-calendar-bot
|
||||
```
|
||||
|
|
@ -136,43 +149,19 @@ curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
|
|||
# Response contains: {"access_token": "syt_xxx", ...}
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
## Authentication Flow
|
||||
|
||||
Events are stored in a local JSON file (`/app/data/calendar-data.json`) with per-user isolation.
|
||||
1. User sends `!login email password`
|
||||
2. Bot authenticates via mana-core-auth
|
||||
3. JWT token stored in Redis session
|
||||
4. Token used for all Calendar API calls
|
||||
5. Events sync with calendar-backend (PostgreSQL)
|
||||
|
||||
Structure:
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Event title",
|
||||
"description": null,
|
||||
"location": null,
|
||||
"startTime": "2024-02-15T14:00:00.000Z",
|
||||
"endTime": "2024-02-15T15:00:00.000Z",
|
||||
"isAllDay": false,
|
||||
"calendarId": "cal-id",
|
||||
"calendarName": "Mein Kalender",
|
||||
"createdAt": "2024-01-27T10:00:00Z",
|
||||
"userId": "@user:mana.how"
|
||||
}
|
||||
],
|
||||
"calendars": [
|
||||
{
|
||||
"id": "cal-id",
|
||||
"name": "Mein Kalender",
|
||||
"color": "#3B82F6",
|
||||
"userId": "@user:mana.how"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
## Data Synchronization
|
||||
|
||||
## GDPR Compliance
|
||||
All events are stored in the Calendar backend PostgreSQL database. Changes made via:
|
||||
- Matrix bot
|
||||
- Calendar web app
|
||||
- Calendar mobile app
|
||||
|
||||
- All event data stored locally on Mac Mini
|
||||
- No third-party data processing
|
||||
- Full control over data retention
|
||||
- Per-user data isolation via Matrix user IDs
|
||||
- Can delete all user data on request
|
||||
...are all synchronized automatically.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config';
|
|||
import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common';
|
||||
import configuration from './config/configuration';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { CalendarModule } from './calendar/calendar.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -12,7 +11,6 @@ import { CalendarModule } from './calendar/calendar.module';
|
|||
load: [configuration],
|
||||
}),
|
||||
BotModule,
|
||||
CalendarModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [createHealthProvider('matrix-calendar-bot')],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
import {
|
||||
TranscriptionModule,
|
||||
SessionModule,
|
||||
|
|
@ -23,7 +22,6 @@ const calendarApiServiceProvider = {
|
|||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
CalendarModule,
|
||||
TranscriptionModule.register({
|
||||
sttUrl: process.env.STT_URL || 'http://localhost:3020',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ import {
|
|||
Language,
|
||||
LANGUAGE_NAMES,
|
||||
} from '@manacore/bot-services';
|
||||
import { CalendarService, CalendarEvent } from '../calendar/calendar.service';
|
||||
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
||||
|
||||
const EVENT_CREATE_CREDITS = 0.02;
|
||||
|
||||
// Alias for consistency
|
||||
type CalendarEvent = ApiCalendarEvent;
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService extends BaseMatrixService {
|
||||
private readonly keywordDetector = new KeywordCommandDetector(
|
||||
|
|
@ -43,7 +45,6 @@ export class MatrixService extends BaseMatrixService {
|
|||
constructor(
|
||||
configService: ConfigService,
|
||||
private readonly transcriptionService: TranscriptionService,
|
||||
private calendarService: CalendarService,
|
||||
private calendarApiService: CalendarApiService,
|
||||
private sessionService: SessionService,
|
||||
private creditService: CreditService,
|
||||
|
|
@ -60,9 +61,32 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Normalize event from API or local format to common format
|
||||
* Require login - returns token or sends login prompt and returns null
|
||||
*/
|
||||
private normalizeEvent(event: CalendarEvent | ApiCalendarEvent): CalendarEvent {
|
||||
private async requireLogin(
|
||||
roomId: string,
|
||||
event: MatrixRoomEvent,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const token = await this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'🔐 **Login erforderlich**\n\n' +
|
||||
'Um Termine zu verwalten, melde dich bitte an:\n\n' +
|
||||
'`!login deine@email.de deinpasswort`\n\n' +
|
||||
'Deine Termine werden dann mit der Kalender-App synchronisiert.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize event from API format
|
||||
*/
|
||||
private normalizeEvent(event: ApiCalendarEvent): CalendarEvent {
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
|
|
@ -72,7 +96,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay,
|
||||
calendarId: event.calendarId || '',
|
||||
calendarName: (event as CalendarEvent).calendarName || 'Kalender',
|
||||
calendarName: 'Kalender',
|
||||
createdAt: event.createdAt || new Date().toISOString(),
|
||||
userId: event.userId || '',
|
||||
};
|
||||
|
|
@ -84,6 +108,10 @@ export class MatrixService extends BaseMatrixService {
|
|||
sender: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Require login for audio messages
|
||||
const token = await this.requireLogin(roomId, event, sender);
|
||||
if (!token) return;
|
||||
|
||||
const mxcUrl = event.content.url;
|
||||
if (!mxcUrl) return;
|
||||
|
||||
|
|
@ -227,17 +255,12 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
private async handleTodayEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let events: CalendarEvent[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiEvents = await this.calendarApiService.getTodayEvents(token);
|
||||
events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
} else {
|
||||
// Use local storage
|
||||
events = await this.calendarService.getTodayEvents(userId);
|
||||
}
|
||||
const apiEvents = await this.calendarApiService.getTodayEvents(token);
|
||||
const events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
|
|
@ -249,30 +272,24 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatEventList('📅 **Termine heute:**', events);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleTomorrowEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let events: CalendarEvent[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service - get events for tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
||||
const apiEvents = await this.calendarApiService.getEvents(token, {
|
||||
start: tomorrowStr,
|
||||
end: tomorrowStr,
|
||||
});
|
||||
events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
} else {
|
||||
// Use local storage
|
||||
events = await this.calendarService.getTomorrowEvents(userId);
|
||||
}
|
||||
// Get events for tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
||||
const apiEvents = await this.calendarApiService.getEvents(token, {
|
||||
start: tomorrowStr,
|
||||
end: tomorrowStr,
|
||||
});
|
||||
const events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
|
|
@ -284,24 +301,17 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatEventList('📅 **Termine morgen:**', events);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleWeekEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let events: CalendarEvent[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 7);
|
||||
events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
} else {
|
||||
// Use local storage
|
||||
events = await this.calendarService.getWeekEvents(userId);
|
||||
}
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 7);
|
||||
const events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
|
|
@ -313,24 +323,17 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatEventList('📅 **Termine diese Woche:**', events);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleUpcomingEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let events: CalendarEvent[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 14);
|
||||
events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
} else {
|
||||
// Use local storage
|
||||
events = await this.calendarService.getUpcomingEvents(userId, 14);
|
||||
}
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 14);
|
||||
const events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
|
||||
if (events.length === 0) {
|
||||
await this.sendReply(
|
||||
|
|
@ -342,9 +345,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatEventList('📅 **Anstehende Termine:**', events);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
|
|
@ -363,94 +364,64 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const token = await this.getToken(userId);
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
// Validate credits if user is logged in
|
||||
if (token) {
|
||||
const validation = await this.creditService.validateCredits(token, EVENT_CREATE_CREDITS);
|
||||
if (!validation.hasCredits) {
|
||||
const errorMsg = this.creditService.formatInsufficientCreditsError(
|
||||
EVENT_CREATE_CREDITS,
|
||||
validation.availableCredits,
|
||||
'Termin erstellen'
|
||||
);
|
||||
await this.sendReply(roomId, event, errorMsg.text);
|
||||
return;
|
||||
}
|
||||
// Validate credits
|
||||
const validation = await this.creditService.validateCredits(token, EVENT_CREATE_CREDITS);
|
||||
if (!validation.hasCredits) {
|
||||
const errorMsg = this.creditService.formatInsufficientCreditsError(
|
||||
EVENT_CREATE_CREDITS,
|
||||
validation.availableCredits,
|
||||
'Termin erstellen'
|
||||
);
|
||||
await this.sendReply(roomId, event, errorMsg.text);
|
||||
return;
|
||||
}
|
||||
|
||||
let calendarEvent: CalendarEvent;
|
||||
// Use API service
|
||||
const { title, startTime, endTime, isAllDay, location } =
|
||||
this.calendarApiService.parseEventInput(input);
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const { title, startTime, endTime, isAllDay, location } =
|
||||
this.calendarApiService.parseEventInput(input);
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Konnte Datum/Uhrzeit nicht erkennen.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Arzt am 15.02. um 10:00`\n• `!termin Urlaub am 01.03. ganztägig`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.');
|
||||
return;
|
||||
}
|
||||
|
||||
const apiEvent = await this.calendarApiService.createEvent(token, {
|
||||
title,
|
||||
startTime,
|
||||
endTime,
|
||||
isAllDay,
|
||||
location: location || undefined,
|
||||
});
|
||||
|
||||
if (!apiEvent) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Fehler beim Erstellen des Termins. Bitte versuche es erneut.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
calendarEvent = this.normalizeEvent(apiEvent);
|
||||
} else {
|
||||
// Use local storage
|
||||
const { title, startTime, endTime, isAllDay } = this.calendarService.parseEventInput(input);
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Konnte Datum/Uhrzeit nicht erkennen.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Arzt am 15.02. um 10:00`\n• `!termin Urlaub am 01.03. ganztägig`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.');
|
||||
return;
|
||||
}
|
||||
|
||||
calendarEvent = await this.calendarService.createEvent(userId, title, startTime, endTime, {
|
||||
isAllDay,
|
||||
});
|
||||
if (!startTime || !endTime) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Konnte Datum/Uhrzeit nicht erkennen.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Arzt am 15.02. um 10:00`\n• `!termin Urlaub am 01.03. ganztägig`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeStr = this.calendarService.formatEventTime(calendarEvent);
|
||||
if (!title) {
|
||||
await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.');
|
||||
return;
|
||||
}
|
||||
|
||||
const apiEvent = await this.calendarApiService.createEvent(token, {
|
||||
title,
|
||||
startTime,
|
||||
endTime,
|
||||
isAllDay,
|
||||
location: location || undefined,
|
||||
});
|
||||
|
||||
if (!apiEvent) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Fehler beim Erstellen des Termins. Bitte versuche es erneut.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarEvent = this.normalizeEvent(apiEvent);
|
||||
const timeStr = this.formatEventTime(calendarEvent);
|
||||
let response = `✅ Termin erstellt: **${calendarEvent.title}**\n📆 ${timeStr}`;
|
||||
|
||||
// Show credit deduction and sync status if logged in
|
||||
if (token) {
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
response += `\n⚡ -${EVENT_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||
response += '\n🔄 Synchronisiert mit calendar-backend';
|
||||
}
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
response += `\n⚡ -${EVENT_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||
response += '\n🔄 Synchronisiert';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
|
@ -472,18 +443,16 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
const token = await this.getToken(userId);
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
let calendarEvent: CalendarEvent | null = null;
|
||||
|
||||
if (token) {
|
||||
// Use API service - get event list first
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 30);
|
||||
if (eventNumber > 0 && eventNumber <= apiEvents.length) {
|
||||
calendarEvent = this.normalizeEvent(apiEvents[eventNumber - 1]);
|
||||
}
|
||||
} else {
|
||||
// Use local storage
|
||||
calendarEvent = await this.calendarService.getEventByIndex(userId, eventNumber);
|
||||
// Use API service - get event list first
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 30);
|
||||
if (eventNumber > 0 && eventNumber <= apiEvents.length) {
|
||||
calendarEvent = this.normalizeEvent(apiEvents[eventNumber - 1]);
|
||||
}
|
||||
|
||||
if (!calendarEvent) {
|
||||
|
|
@ -491,7 +460,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
const timeStr = this.calendarService.formatEventTime(calendarEvent);
|
||||
const timeStr = this.formatEventTime(calendarEvent);
|
||||
let response = `📅 **${calendarEvent.title}**\n\n`;
|
||||
response += `🕐 ${timeStr}\n`;
|
||||
response += `📁 Kalender: ${calendarEvent.calendarName}\n`;
|
||||
|
|
@ -504,9 +473,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
response += `\n📝 ${calendarEvent.description}`;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
|
@ -528,22 +495,20 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
const token = await this.getToken(userId);
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
let deletedEvent: CalendarEvent | null = null;
|
||||
|
||||
if (token) {
|
||||
// Use API service - get event list first to find event by index
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 30);
|
||||
if (eventNumber > 0 && eventNumber <= apiEvents.length) {
|
||||
const targetEvent = apiEvents[eventNumber - 1];
|
||||
const success = await this.calendarApiService.deleteEvent(token, targetEvent.id);
|
||||
if (success) {
|
||||
deletedEvent = this.normalizeEvent(targetEvent);
|
||||
}
|
||||
// Use API service - get event list first to find event by index
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 30);
|
||||
if (eventNumber > 0 && eventNumber <= apiEvents.length) {
|
||||
const targetEvent = apiEvents[eventNumber - 1];
|
||||
const success = await this.calendarApiService.deleteEvent(token, targetEvent.id);
|
||||
if (success) {
|
||||
deletedEvent = this.normalizeEvent(targetEvent);
|
||||
}
|
||||
} else {
|
||||
// Use local storage
|
||||
deletedEvent = await this.calendarService.deleteEvent(userId, eventNumber);
|
||||
}
|
||||
|
||||
if (!deletedEvent) {
|
||||
|
|
@ -551,33 +516,23 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
let response = `🗑️ Gelöscht: ${deletedEvent.title}`;
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
const response = `🗑️ Gelöscht: ${deletedEvent.title}\n\n🔄 Synchronisiert`;
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleCalendars(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let calendars: { name: string }[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
calendars = await this.calendarApiService.getCalendars(token);
|
||||
} else {
|
||||
// Use local storage
|
||||
calendars = await this.calendarService.getCalendars(userId);
|
||||
}
|
||||
const calendars = await this.calendarApiService.getCalendars(token);
|
||||
|
||||
let response = '📁 **Deine Kalender:**\n\n';
|
||||
for (const calendar of calendars) {
|
||||
response += `• ${calendar.name}\n`;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
response += '\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n🔄 Synchronisiert';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
|
@ -586,39 +541,32 @@ export class MatrixService extends BaseMatrixService {
|
|||
const token = await this.getToken(userId);
|
||||
const session = await this.sessionService.getSession(userId);
|
||||
|
||||
let todayEvents: CalendarEvent[];
|
||||
let events: CalendarEvent[];
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiTodayEvents = await this.calendarApiService.getTodayEvents(token);
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 7);
|
||||
todayEvents = apiTodayEvents.map((e) => this.normalizeEvent(e));
|
||||
events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
} else {
|
||||
// Use local storage
|
||||
todayEvents = await this.calendarService.getTodayEvents(userId);
|
||||
events = await this.calendarService.getUpcomingEvents(userId, 7);
|
||||
}
|
||||
|
||||
const syncStatus = token ? '🔄 Synchronisiert mit calendar-backend' : '💾 Lokaler Speicher';
|
||||
|
||||
let response = `📊 **Status**\n\n`;
|
||||
response += `• Termine heute: ${todayEvents.length}\n`;
|
||||
response += `• Termine nächste 7 Tage: ${events.length}\n\n`;
|
||||
|
||||
if (token && session) {
|
||||
// Get stats from API
|
||||
const apiTodayEvents = await this.calendarApiService.getTodayEvents(token);
|
||||
const apiEvents = await this.calendarApiService.getUpcomingEvents(token, 7);
|
||||
const todayEvents = apiTodayEvents.map((e) => this.normalizeEvent(e));
|
||||
const events = apiEvents.map((e) => this.normalizeEvent(e));
|
||||
|
||||
response += `• Termine heute: ${todayEvents.length}\n`;
|
||||
response += `• Termine nächste 7 Tage: ${events.length}\n\n`;
|
||||
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
response += `👤 Angemeldet als: ${session.email}\n`;
|
||||
response += `⚡ Credits: ${balance.balance.toFixed(2)}\n\n`;
|
||||
response += `🔄 Synchronisiert mit calendar-backend\n`;
|
||||
response += `Bot: ✅ Online`;
|
||||
} else {
|
||||
response += `👤 Nicht angemeldet\n`;
|
||||
response += `💡 Login: \`!login email passwort\` für Synchronisation mit calendar-web\n\n`;
|
||||
response += `👤 Nicht angemeldet\n\n`;
|
||||
response += `🔐 **Login erforderlich**\n\n`;
|
||||
response += `Um Termine zu verwalten, melde dich an:\n`;
|
||||
response += `\`!login deine@email.de deinpasswort\`\n\n`;
|
||||
response += `Deine Termine werden dann mit der Kalender-App synchronisiert.\n\n`;
|
||||
response += `Bot: ✅ Online`;
|
||||
}
|
||||
|
||||
response += `${syncStatus}\n`;
|
||||
response += `Bot: ✅ Online`;
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
|
|
@ -691,7 +639,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
|
||||
events.forEach((event, index) => {
|
||||
const num = index + 1;
|
||||
const timeStr = this.calendarService.formatEventTime(event);
|
||||
const timeStr = this.formatEventTime(event);
|
||||
response += `**${num}.** ${event.title}\n 🕐 ${timeStr}\n`;
|
||||
});
|
||||
|
||||
|
|
@ -699,6 +647,41 @@ export class MatrixService extends BaseMatrixService {
|
|||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event time for display
|
||||
*/
|
||||
private formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.startTime);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Check if date is today or tomorrow
|
||||
let dateStr: string;
|
||||
if (start.toDateString() === today.toDateString()) {
|
||||
dateStr = 'Heute';
|
||||
} else if (start.toDateString() === tomorrow.toDateString()) {
|
||||
dateStr = 'Morgen';
|
||||
} else {
|
||||
dateStr = start.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dateStr} (ganztägig)`;
|
||||
}
|
||||
|
||||
const timeStr = start.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return `${dateStr}, ${timeStr}`;
|
||||
}
|
||||
|
||||
// Public method to send welcome message to new users
|
||||
async sendWelcomeMessage(roomId: string, userId: string) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CalendarService } from './calendar.service';
|
||||
|
||||
@Module({
|
||||
providers: [CalendarService],
|
||||
exports: [CalendarService],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
startTime: string; // ISO datetime
|
||||
endTime: string; // ISO datetime
|
||||
isAllDay: boolean;
|
||||
calendarId: string;
|
||||
calendarName: string;
|
||||
createdAt: string;
|
||||
userId: string; // Matrix user ID
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface CalendarData {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService implements OnModuleInit {
|
||||
private readonly logger = new Logger(CalendarService.name);
|
||||
private data: CalendarData = { events: [], calendars: [] };
|
||||
private dataPath: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const storagePath = this.configService.get<string>(
|
||||
'matrix.storagePath',
|
||||
'./data/bot-storage.json'
|
||||
);
|
||||
this.dataPath = storagePath.replace('bot-storage.json', 'calendar-data.json');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(this.dataPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this.dataPath)) {
|
||||
const content = fs.readFileSync(this.dataPath, 'utf-8');
|
||||
this.data = JSON.parse(content);
|
||||
this.logger.log(
|
||||
`Loaded ${this.data.events.length} events, ${this.data.calendars.length} calendars`
|
||||
);
|
||||
} else {
|
||||
this.data = { events: [], calendars: [] };
|
||||
await this.saveData();
|
||||
this.logger.log('Created new calendar data file');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load calendar data:', error);
|
||||
this.data = { events: [], calendars: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
fs.writeFileSync(this.dataPath, JSON.stringify(this.data, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save calendar data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
private ensureDefaultCalendar(userId: string): Calendar {
|
||||
let calendar = this.data.calendars.find((c) => c.userId === userId);
|
||||
if (!calendar) {
|
||||
calendar = {
|
||||
id: this.generateId(),
|
||||
name: 'Mein Kalender',
|
||||
color: '#3B82F6',
|
||||
userId,
|
||||
};
|
||||
this.data.calendars.push(calendar);
|
||||
this.saveData();
|
||||
}
|
||||
return calendar;
|
||||
}
|
||||
|
||||
// Event operations
|
||||
|
||||
async createEvent(
|
||||
userId: string,
|
||||
title: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
options?: Partial<CalendarEvent>
|
||||
): Promise<CalendarEvent> {
|
||||
const calendar = this.ensureDefaultCalendar(userId);
|
||||
|
||||
const event: CalendarEvent = {
|
||||
id: this.generateId(),
|
||||
title,
|
||||
description: options?.description || null,
|
||||
location: options?.location || null,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
isAllDay: options?.isAllDay || false,
|
||||
calendarId: calendar.id,
|
||||
calendarName: calendar.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
userId,
|
||||
};
|
||||
|
||||
this.data.events.push(event);
|
||||
await this.saveData();
|
||||
this.logger.log(`Created event "${title}" for user ${userId}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
async getTodayEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
return this.getEventsInRange(userId, today, tomorrow);
|
||||
}
|
||||
|
||||
async getTomorrowEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
const dayAfter = new Date(tomorrow);
|
||||
dayAfter.setDate(dayAfter.getDate() + 1);
|
||||
|
||||
return this.getEventsInRange(userId, tomorrow, dayAfter);
|
||||
}
|
||||
|
||||
async getWeekEvents(userId: string): Promise<CalendarEvent[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
|
||||
return this.getEventsInRange(userId, today, weekEnd);
|
||||
}
|
||||
|
||||
async getUpcomingEvents(userId: string, days: number = 7): Promise<CalendarEvent[]> {
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
|
||||
return this.getEventsInRange(userId, now, endDate);
|
||||
}
|
||||
|
||||
private getEventsInRange(userId: string, start: Date, end: Date): CalendarEvent[] {
|
||||
return this.data.events
|
||||
.filter((e) => {
|
||||
if (e.userId !== userId) return false;
|
||||
const eventStart = new Date(e.startTime);
|
||||
const eventEnd = new Date(e.endTime);
|
||||
// Event overlaps with range
|
||||
return eventStart < end && eventEnd > start;
|
||||
})
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
}
|
||||
|
||||
async getEventByIndex(userId: string, index: number): Promise<CalendarEvent | null> {
|
||||
const events = await this.getUpcomingEvents(userId, 30);
|
||||
if (index < 1 || index > events.length) {
|
||||
return null;
|
||||
}
|
||||
return events[index - 1];
|
||||
}
|
||||
|
||||
async deleteEvent(userId: string, eventIndex: number): Promise<CalendarEvent | null> {
|
||||
const events = await this.getUpcomingEvents(userId, 30);
|
||||
if (eventIndex < 1 || eventIndex > events.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = events[eventIndex - 1];
|
||||
this.data.events = this.data.events.filter((e) => e.id !== event.id);
|
||||
await this.saveData();
|
||||
this.logger.log(`Deleted event "${event.title}" for user ${userId}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
// Calendar operations
|
||||
|
||||
async getCalendars(userId: string): Promise<Calendar[]> {
|
||||
this.ensureDefaultCalendar(userId);
|
||||
return this.data.calendars.filter((c) => c.userId === userId);
|
||||
}
|
||||
|
||||
// Parse natural language date/time input
|
||||
parseEventInput(input: string): {
|
||||
title: string;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
isAllDay: boolean;
|
||||
} {
|
||||
let title = input;
|
||||
let startTime: Date | null = null;
|
||||
let endTime: Date | null = null;
|
||||
let isAllDay = false;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Check for "ganztägig" (all-day)
|
||||
if (/ganztägig/i.test(title)) {
|
||||
isAllDay = true;
|
||||
title = title.replace(/ganztägig/gi, '').trim();
|
||||
}
|
||||
|
||||
// Parse date patterns
|
||||
// "am DD.MM." or "am DD.MM.YYYY"
|
||||
const dateMatch = title.match(/am\s+(\d{1,2})\.(\d{1,2})\.?(\d{4})?/i);
|
||||
// "heute", "morgen", "übermorgen"
|
||||
const relativeMatch = title.match(/(heute|morgen|übermorgen)/i);
|
||||
// Time: "um HH:MM" or "um HH Uhr"
|
||||
const timeMatch = title.match(/um\s+(\d{1,2})[:.]?(\d{2})?\s*(uhr)?/i);
|
||||
|
||||
if (dateMatch) {
|
||||
const day = parseInt(dateMatch[1]);
|
||||
const month = parseInt(dateMatch[2]) - 1;
|
||||
const year = dateMatch[3] ? parseInt(dateMatch[3]) : now.getFullYear();
|
||||
|
||||
startTime = new Date(year, month, day);
|
||||
|
||||
// If date is in the past this year, assume next year
|
||||
if (startTime < now && !dateMatch[3]) {
|
||||
startTime.setFullYear(startTime.getFullYear() + 1);
|
||||
}
|
||||
|
||||
title = title.replace(/am\s+\d{1,2}\.\d{1,2}\.?\d{0,4}/i, '').trim();
|
||||
} else if (relativeMatch) {
|
||||
const relative = relativeMatch[1].toLowerCase();
|
||||
startTime = new Date();
|
||||
startTime.setHours(0, 0, 0, 0);
|
||||
|
||||
if (relative === 'morgen') {
|
||||
startTime.setDate(startTime.getDate() + 1);
|
||||
} else if (relative === 'übermorgen') {
|
||||
startTime.setDate(startTime.getDate() + 2);
|
||||
}
|
||||
|
||||
title = title.replace(/(heute|morgen|übermorgen)/i, '').trim();
|
||||
}
|
||||
|
||||
if (timeMatch && startTime) {
|
||||
const hours = parseInt(timeMatch[1]);
|
||||
const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
|
||||
|
||||
startTime.setHours(hours, minutes, 0, 0);
|
||||
isAllDay = false;
|
||||
|
||||
title = title.replace(/um\s+\d{1,2}[:.]?\d{0,2}\s*(uhr)?/i, '').trim();
|
||||
} else if (startTime && !isAllDay) {
|
||||
// Default to 9:00 if no time specified
|
||||
startTime.setHours(9, 0, 0, 0);
|
||||
}
|
||||
|
||||
// Set end time (1 hour later for timed events, end of day for all-day)
|
||||
if (startTime) {
|
||||
endTime = new Date(startTime);
|
||||
if (isAllDay) {
|
||||
endTime.setHours(23, 59, 59, 999);
|
||||
} else {
|
||||
endTime.setHours(endTime.getHours() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
title = title.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { title, startTime, endTime, isAllDay };
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
formatEventTime(event: CalendarEvent): string {
|
||||
const start = new Date(event.startTime);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const eventDate = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
||||
|
||||
let dateStr: string;
|
||||
if (eventDate.getTime() === today.getTime()) {
|
||||
dateStr = 'Heute';
|
||||
} else if (eventDate.getTime() === tomorrow.getTime()) {
|
||||
dateStr = 'Morgen';
|
||||
} else {
|
||||
dateStr = start.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dateStr} (ganztägig)`;
|
||||
}
|
||||
|
||||
const timeStr = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${dateStr}, ${timeStr}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
## Overview
|
||||
|
||||
Matrix Todo Bot provides a GDPR-compliant task management interface via Matrix chat. It uses the Matrix protocol for messaging, allowing self-hosting all data on the Mac Mini server.
|
||||
Matrix Todo Bot provides a task management interface via Matrix chat. It integrates with the Todo backend for full CRUD operations, syncing tasks across Matrix, web, and mobile apps.
|
||||
|
||||
**Login Required**: Users must login (`!login email password`) to use the bot. All tasks are synchronized with the todo-backend.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Storage**: Local JSON file (per-user tasks)
|
||||
- **Backend**: Todo API (port 3018)
|
||||
- **Auth**: Mana Core Auth (JWT)
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -34,12 +37,9 @@ services/matrix-todo-bot/
|
|||
│ ├── health.controller.ts # Health check endpoint
|
||||
│ ├── config/
|
||||
│ │ └── configuration.ts # Configuration & help texts
|
||||
│ ├── bot/
|
||||
│ │ ├── bot.module.ts
|
||||
│ │ └── matrix.service.ts # Matrix client & command handlers
|
||||
│ └── todo/
|
||||
│ ├── todo.module.ts
|
||||
│ └── todo.service.ts # Task storage & management
|
||||
│ └── bot/
|
||||
│ ├── bot.module.ts
|
||||
│ └── matrix.service.ts # Matrix client & command handlers
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
|
@ -49,6 +49,8 @@ services/matrix-todo-bot/
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!help` | Show help message |
|
||||
| `!login email pass` | Login (required before use) |
|
||||
| `!logout` | Logout |
|
||||
| `!add [task]` | Create a new task |
|
||||
| `!list` | Show all pending tasks |
|
||||
| `!heute` / `!today` | Show today's tasks |
|
||||
|
|
@ -90,6 +92,15 @@ MATRIX_HOMESERVER_URL=http://localhost:8008
|
|||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#todo-bot:mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Todo Backend
|
||||
TODO_BACKEND_URL=http://localhost:3018
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Redis (for session storage)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
|
@ -102,6 +113,8 @@ docker build -f services/matrix-todo-bot/Dockerfile -t matrix-todo-bot services/
|
|||
docker run -p 3314:3314 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e TODO_BACKEND_URL=http://todo-backend:3018 \
|
||||
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
|
||||
-v matrix-todo-bot-data:/app/data \
|
||||
matrix-todo-bot
|
||||
```
|
||||
|
|
@ -127,35 +140,19 @@ curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
|
|||
# Response contains: {"access_token": "syt_xxx", ...}
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
## Authentication Flow
|
||||
|
||||
Tasks are stored in a local JSON file (`/app/data/todo-data.json`) with per-user isolation.
|
||||
1. User sends `!login email password`
|
||||
2. Bot authenticates via mana-core-auth
|
||||
3. JWT token stored in Redis session
|
||||
4. Token used for all Todo API calls
|
||||
5. Tasks sync with todo-backend (PostgreSQL)
|
||||
|
||||
Structure:
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Task title",
|
||||
"completed": false,
|
||||
"priority": 4,
|
||||
"dueDate": "2024-01-28",
|
||||
"project": "Arbeit",
|
||||
"labels": [],
|
||||
"createdAt": "2024-01-27T10:00:00Z",
|
||||
"completedAt": null,
|
||||
"userId": "@user:mana.how"
|
||||
}
|
||||
],
|
||||
"projects": []
|
||||
}
|
||||
```
|
||||
## Data Synchronization
|
||||
|
||||
## GDPR Compliance
|
||||
All tasks are stored in the Todo backend PostgreSQL database. Changes made via:
|
||||
- Matrix bot
|
||||
- Todo web app
|
||||
- Todo mobile app
|
||||
|
||||
- All task data stored locally on Mac Mini
|
||||
- No third-party data processing
|
||||
- Full control over data retention
|
||||
- Per-user data isolation via Matrix user IDs
|
||||
- Can delete all user data on request
|
||||
...are all synchronized automatically.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config';
|
|||
import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common';
|
||||
import configuration from './config/configuration';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { TodoModule } from './todo/todo.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -12,7 +11,6 @@ import { TodoModule } from './todo/todo.module';
|
|||
load: [configuration],
|
||||
}),
|
||||
BotModule,
|
||||
TodoModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [createHealthProvider('matrix-todo-bot')],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { TodoModule } from '../todo/todo.module';
|
||||
import {
|
||||
TranscriptionModule,
|
||||
SessionModule,
|
||||
|
|
@ -23,7 +22,6 @@ const todoApiServiceProvider = {
|
|||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TodoModule,
|
||||
TranscriptionModule.forRoot(),
|
||||
SessionModule.forRoot({ storageMode: 'redis' }),
|
||||
CreditModule.forRoot(),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
KeywordCommandDetector,
|
||||
COMMON_KEYWORDS,
|
||||
} from '@manacore/matrix-bot-common';
|
||||
import { TodoService, Task } from '../todo/todo.service';
|
||||
import {
|
||||
TranscriptionService,
|
||||
SessionService,
|
||||
|
|
@ -23,6 +22,9 @@ import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configurati
|
|||
// Credit cost for task creation (micro-credits)
|
||||
const TASK_CREATE_CREDITS = 0.02;
|
||||
|
||||
// Alias for consistency
|
||||
type Task = ApiTask;
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService extends BaseMatrixService {
|
||||
private readonly keywordDetector = new KeywordCommandDetector(
|
||||
|
|
@ -59,7 +61,6 @@ export class MatrixService extends BaseMatrixService {
|
|||
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private todoService: TodoService,
|
||||
private todoApiService: TodoApiService,
|
||||
private transcriptionService: TranscriptionService,
|
||||
private sessionService: SessionService,
|
||||
|
|
@ -77,9 +78,32 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Normalize task from API or local format to common format
|
||||
* Require login - returns token or sends login prompt and returns null
|
||||
*/
|
||||
private normalizeTask(task: Task | ApiTask): Task {
|
||||
private async requireLogin(
|
||||
roomId: string,
|
||||
event: MatrixRoomEvent,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const token = await this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'🔐 **Login erforderlich**\n\n' +
|
||||
'Um Aufgaben zu verwalten, melde dich bitte an:\n\n' +
|
||||
'`login deine@email.de deinpasswort`\n\n' +
|
||||
'Deine Aufgaben werden dann mit der Todo-App synchronisiert.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize task from API format
|
||||
*/
|
||||
private normalizeTask(task: ApiTask): Task {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
|
|
@ -221,6 +245,10 @@ export class MatrixService extends BaseMatrixService {
|
|||
if (!content?.url) return;
|
||||
|
||||
try {
|
||||
// Require login for audio messages
|
||||
const token = await this.requireLogin(roomId, event, sender);
|
||||
if (!token) return;
|
||||
|
||||
await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...');
|
||||
|
||||
// Download audio from Matrix
|
||||
|
|
@ -248,54 +276,36 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const token = await this.getToken(sender);
|
||||
|
||||
// Check credits if user is logged in
|
||||
if (token) {
|
||||
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
||||
if (!validation.hasCredits) {
|
||||
const errorMsg = this.creditService.formatInsufficientCreditsError(
|
||||
TASK_CREATE_CREDITS,
|
||||
validation.availableCredits,
|
||||
'Aufgabe erstellen'
|
||||
);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
`Transkription: "${transcription}"\n\n${errorMsg.text}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Check credits
|
||||
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
||||
if (!validation.hasCredits) {
|
||||
const errorMsg = this.creditService.formatInsufficientCreditsError(
|
||||
TASK_CREATE_CREDITS,
|
||||
validation.availableCredits,
|
||||
'Aufgabe erstellen'
|
||||
);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
`Transkription: "${transcription}"\n\n${errorMsg.text}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let task: Task;
|
||||
|
||||
if (token) {
|
||||
// Use API service (syncs with todo-web and mobile)
|
||||
const { title, priority, dueDate, project } =
|
||||
this.todoApiService.parseTaskInput(transcription);
|
||||
const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate });
|
||||
if (!apiTask) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
`Transkription: "${transcription}"\n\nFehler beim Erstellen der Aufgabe.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
task = this.normalizeTask(apiTask);
|
||||
task.project = project;
|
||||
} else {
|
||||
// Use local storage (offline mode)
|
||||
const { title, priority, dueDate, project } =
|
||||
this.todoService.parseTaskInput(transcription);
|
||||
task = await this.todoService.createTask(sender, title, {
|
||||
priority,
|
||||
dueDate,
|
||||
project,
|
||||
});
|
||||
// Use API service (syncs with todo-web and mobile)
|
||||
const { title, priority, dueDate, project } =
|
||||
this.todoApiService.parseTaskInput(transcription);
|
||||
const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate });
|
||||
if (!apiTask) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
`Transkription: "${transcription}"\n\nFehler beim Erstellen der Aufgabe.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const task = this.normalizeTask(apiTask);
|
||||
task.project = project;
|
||||
|
||||
let responseText = `Transkription: "${transcription}"\n\nAufgabe erstellt: **${task.title}**`;
|
||||
|
||||
|
|
@ -308,12 +318,9 @@ export class MatrixService extends BaseMatrixService {
|
|||
responseText += `\n${details.join(' | ')}`;
|
||||
}
|
||||
|
||||
// Show credit deduction and sync status if logged in
|
||||
if (token) {
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
responseText += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||
responseText += '\n🔄 Synchronisiert mit todo-backend';
|
||||
}
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
responseText += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||
responseText += '\n🔄 Synchronisiert';
|
||||
|
||||
await this.sendReply(roomId, event, responseText);
|
||||
} catch (error) {
|
||||
|
|
@ -469,48 +476,35 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const token = await this.getToken(userId);
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
// Check credits if user is logged in
|
||||
if (token) {
|
||||
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
||||
if (!validation.hasCredits) {
|
||||
const errorMsg = this.creditService.formatInsufficientCreditsError(
|
||||
TASK_CREATE_CREDITS,
|
||||
validation.availableCredits,
|
||||
'Aufgabe erstellen'
|
||||
);
|
||||
await this.sendReply(roomId, event, errorMsg.text);
|
||||
return;
|
||||
}
|
||||
// Check credits
|
||||
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
||||
if (!validation.hasCredits) {
|
||||
const errorMsg = this.creditService.formatInsufficientCreditsError(
|
||||
TASK_CREATE_CREDITS,
|
||||
validation.availableCredits,
|
||||
'Aufgabe erstellen'
|
||||
);
|
||||
await this.sendReply(roomId, event, errorMsg.text);
|
||||
return;
|
||||
}
|
||||
|
||||
let task: Task;
|
||||
|
||||
if (token) {
|
||||
// Use API service (syncs with todo-web and mobile)
|
||||
const { title, priority, dueDate, project } = this.todoApiService.parseTaskInput(input);
|
||||
const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate });
|
||||
if (!apiTask) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'Fehler beim Erstellen der Aufgabe. Bitte versuche es erneut.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
task = this.normalizeTask(apiTask);
|
||||
task.project = project; // Note: project handling via API needs project ID lookup
|
||||
} else {
|
||||
// Use local storage (offline mode)
|
||||
const { title, priority, dueDate, project } = this.todoService.parseTaskInput(input);
|
||||
task = await this.todoService.createTask(userId, title, {
|
||||
priority,
|
||||
dueDate,
|
||||
project,
|
||||
});
|
||||
// Use API service (syncs with todo-web and mobile)
|
||||
const { title, priority, dueDate, project } = this.todoApiService.parseTaskInput(input);
|
||||
const apiTask = await this.todoApiService.createTask(token, { title, priority, dueDate });
|
||||
if (!apiTask) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'Fehler beim Erstellen der Aufgabe. Bitte versuche es erneut.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const task = this.normalizeTask(apiTask);
|
||||
task.project = project; // Note: project handling via API needs project ID lookup
|
||||
|
||||
let response = `Aufgabe erstellt: **${task.title}**`;
|
||||
|
||||
|
|
@ -523,28 +517,20 @@ export class MatrixService extends BaseMatrixService {
|
|||
response += `\n${details.join(' | ')}`;
|
||||
}
|
||||
|
||||
// Show credit deduction and sync status if logged in
|
||||
if (token) {
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
response += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||
response += '\n🔄 Synchronisiert mit todo-backend';
|
||||
}
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
response += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||
response += '\n🔄 Synchronisiert';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleListTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let tasks: Task[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiTasks = await this.todoApiService.getTasks(token, { completed: false });
|
||||
tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||
} else {
|
||||
// Use local storage
|
||||
tasks = await this.todoService.getAllPendingTasks(userId);
|
||||
}
|
||||
const apiTasks = await this.todoApiService.getTasks(token, { completed: false });
|
||||
const tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await this.sendReply(
|
||||
|
|
@ -556,28 +542,19 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatTaskList('**Alle offenen Aufgaben:**', tasks);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleTodayTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let todayTasks: Task[];
|
||||
let inboxTasks: Task[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiTodayTasks = await this.todoApiService.getTodayTasks(token);
|
||||
const apiInboxTasks = await this.todoApiService.getInboxTasks(token);
|
||||
todayTasks = apiTodayTasks.map((t) => this.normalizeTask(t));
|
||||
inboxTasks = apiInboxTasks.map((t) => this.normalizeTask(t));
|
||||
} else {
|
||||
// Use local storage
|
||||
todayTasks = await this.todoService.getTodayTasks(userId);
|
||||
inboxTasks = await this.todoService.getInboxTasks(userId);
|
||||
}
|
||||
const apiTodayTasks = await this.todoApiService.getTodayTasks(token);
|
||||
const apiInboxTasks = await this.todoApiService.getInboxTasks(token);
|
||||
const todayTasks = apiTodayTasks.map((t) => this.normalizeTask(t));
|
||||
const inboxTasks = apiInboxTasks.map((t) => this.normalizeTask(t));
|
||||
|
||||
const hasTodayTasks = todayTasks.length > 0;
|
||||
const hasInboxTasks = inboxTasks.length > 0;
|
||||
|
|
@ -604,24 +581,17 @@ export class MatrixService extends BaseMatrixService {
|
|||
response += this.formatTaskList('**Inbox (ohne Datum):**', inboxTasks);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleInboxTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let tasks: Task[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiTasks = await this.todoApiService.getInboxTasks(token);
|
||||
tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||
} else {
|
||||
// Use local storage
|
||||
tasks = await this.todoService.getInboxTasks(userId);
|
||||
}
|
||||
const apiTasks = await this.todoApiService.getInboxTasks(token);
|
||||
const tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await this.sendReply(roomId, event, 'Inbox ist leer.\n\nAufgaben ohne Datum landen hier.');
|
||||
|
|
@ -629,9 +599,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatTaskList('**Inbox (ohne Datum):**', tasks);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
|
|
@ -652,22 +620,20 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
const token = await this.getToken(userId);
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
let task: Task | null = null;
|
||||
|
||||
if (token) {
|
||||
// Use API service - need to get task list first to find task by index
|
||||
const apiTasks = await this.todoApiService.getTasks(token, { completed: false });
|
||||
if (taskNumber > 0 && taskNumber <= apiTasks.length) {
|
||||
const targetTask = apiTasks[taskNumber - 1];
|
||||
const completedTask = await this.todoApiService.completeTask(token, targetTask.id);
|
||||
if (completedTask) {
|
||||
task = this.normalizeTask(completedTask);
|
||||
}
|
||||
// Use API service - need to get task list first to find task by index
|
||||
const apiTasks = await this.todoApiService.getTasks(token, { completed: false });
|
||||
if (taskNumber > 0 && taskNumber <= apiTasks.length) {
|
||||
const targetTask = apiTasks[taskNumber - 1];
|
||||
const completedTask = await this.todoApiService.completeTask(token, targetTask.id);
|
||||
if (completedTask) {
|
||||
task = this.normalizeTask(completedTask);
|
||||
}
|
||||
} else {
|
||||
// Use local storage
|
||||
task = await this.todoService.completeTask(userId, taskNumber);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
|
|
@ -675,10 +641,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
let response = `Erledigt: ~~${task.title}~~`;
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
const response = `Erledigt: ~~${task.title}~~\n\n🔄 Synchronisiert`;
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
|
|
@ -699,22 +662,20 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
const token = await this.getToken(userId);
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
let task: Task | null = null;
|
||||
|
||||
if (token) {
|
||||
// Use API service - need to get task list first to find task by index
|
||||
const apiTasks = await this.todoApiService.getTasks(token, { completed: false });
|
||||
if (taskNumber > 0 && taskNumber <= apiTasks.length) {
|
||||
const targetTask = apiTasks[taskNumber - 1];
|
||||
const deleted = await this.todoApiService.deleteTask(token, targetTask.id);
|
||||
if (deleted) {
|
||||
task = this.normalizeTask(targetTask);
|
||||
}
|
||||
// Use API service - need to get task list first to find task by index
|
||||
const apiTasks = await this.todoApiService.getTasks(token, { completed: false });
|
||||
if (taskNumber > 0 && taskNumber <= apiTasks.length) {
|
||||
const targetTask = apiTasks[taskNumber - 1];
|
||||
const deleted = await this.todoApiService.deleteTask(token, targetTask.id);
|
||||
if (deleted) {
|
||||
task = this.normalizeTask(targetTask);
|
||||
}
|
||||
} else {
|
||||
// Use local storage
|
||||
task = await this.todoService.deleteTask(userId, taskNumber);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
|
|
@ -722,25 +683,16 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
let response = `Geloescht: ${task.title}`;
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
const response = `Geloescht: ${task.title}\n\n🔄 Synchronisiert`;
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleProjects(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||
const token = await this.getToken(userId);
|
||||
let projects: { name: string }[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
const apiProjects = await this.todoApiService.getProjects(token);
|
||||
projects = apiProjects;
|
||||
} else {
|
||||
// Use local storage
|
||||
projects = await this.todoService.getProjects(userId);
|
||||
}
|
||||
const projects = await this.todoApiService.getProjects(token);
|
||||
|
||||
if (projects.length === 0) {
|
||||
await this.sendReply(
|
||||
|
|
@ -756,9 +708,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
response += `- ${project.name}\n`;
|
||||
}
|
||||
response += '\nZeige Projektaufgaben mit `projekt [Name]`';
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
|
@ -780,22 +730,18 @@ export class MatrixService extends BaseMatrixService {
|
|||
return;
|
||||
}
|
||||
|
||||
const token = await this.getToken(userId);
|
||||
let tasks: Task[];
|
||||
// Require login
|
||||
const token = await this.requireLogin(roomId, event, userId);
|
||||
if (!token) return;
|
||||
|
||||
if (token) {
|
||||
// Use API service - need to find project ID first
|
||||
const projects = await this.todoApiService.getProjects(token);
|
||||
const project = projects.find((p) => p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (project) {
|
||||
const apiTasks = await this.todoApiService.getProjectTasks(token, project.id);
|
||||
tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||
} else {
|
||||
tasks = [];
|
||||
}
|
||||
} else {
|
||||
// Use local storage
|
||||
tasks = await this.todoService.getProjectTasks(userId, projectName);
|
||||
let tasks: Task[] = [];
|
||||
|
||||
// Use API service - need to find project ID first
|
||||
const projects = await this.todoApiService.getProjects(token);
|
||||
const project = projects.find((p) => p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (project) {
|
||||
const apiTasks = await this.todoApiService.getProjectTasks(token, project.id);
|
||||
tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
|
|
@ -804,9 +750,7 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
|
||||
let response = this.formatTaskList(`**Projekt: ${projectName}**`, tasks);
|
||||
if (token) {
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
}
|
||||
response += '\n\n🔄 Synchronisiert';
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
|
|
@ -815,19 +759,19 @@ export class MatrixService extends BaseMatrixService {
|
|||
const isLoggedIn = await this.sessionService.isLoggedIn(userId);
|
||||
const email = this.sessionService.getEmail(userId);
|
||||
|
||||
let stats: { total: number; completed: number; pending: number; today: number };
|
||||
|
||||
if (token) {
|
||||
// Use API service
|
||||
stats = await this.todoApiService.getStats(token);
|
||||
} else {
|
||||
// Use local storage
|
||||
stats = await this.todoService.getStats(userId);
|
||||
}
|
||||
|
||||
// Get credit balance if logged in
|
||||
let statsInfo = '';
|
||||
let creditInfo = '';
|
||||
|
||||
if (token) {
|
||||
// Get stats from API
|
||||
const stats = await this.todoApiService.getStats(token);
|
||||
statsInfo = `
|
||||
- Offene Aufgaben: ${stats.pending}
|
||||
- Heute faellig: ${stats.today}
|
||||
- Erledigt: ${stats.completed}
|
||||
- Gesamt: ${stats.total}`;
|
||||
|
||||
// Get credit balance
|
||||
const balance = await this.creditService.getBalance(token);
|
||||
const creditIcon = balance.hasCredits ? '⚡' : '⚠️';
|
||||
creditInfo = `\n${creditIcon} Credits: ${balance.balance.toFixed(2)}`;
|
||||
|
|
@ -839,19 +783,28 @@ export class MatrixService extends BaseMatrixService {
|
|||
}
|
||||
}
|
||||
|
||||
const syncStatus = token ? '🔄 Synchronisiert mit todo-backend' : '💾 Lokaler Speicher';
|
||||
let response = `**Status**
|
||||
|
||||
const response = `**Status**
|
||||
👤 Angemeldet: ${isLoggedIn ? `Ja (${email})` : 'Nein'}${creditInfo}`;
|
||||
|
||||
👤 Angemeldet: ${isLoggedIn ? `Ja (${email})` : 'Nein'}${creditInfo}
|
||||
if (token) {
|
||||
response += `
|
||||
${statsInfo}
|
||||
|
||||
- Offene Aufgaben: ${stats.pending}
|
||||
- Heute faellig: ${stats.today}
|
||||
- Erledigt: ${stats.completed}
|
||||
- Gesamt: ${stats.total}
|
||||
🔄 Synchronisiert mit todo-backend
|
||||
Bot: Online`;
|
||||
} else {
|
||||
response += `
|
||||
|
||||
${syncStatus}
|
||||
Bot: Online${!isLoggedIn ? '\n\nTipp: Mit `login email passwort` anmelden fuer Synchronisation mit todo-web' : ''}`;
|
||||
🔐 **Login erforderlich**
|
||||
|
||||
Um Aufgaben zu verwalten, melde dich an:
|
||||
\`login deine@email.de deinpasswort\`
|
||||
|
||||
Deine Aufgaben werden dann mit der Todo-App synchronisiert.
|
||||
|
||||
Bot: Online`;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TodoService } from './todo.service';
|
||||
|
||||
@Module({
|
||||
providers: [TodoService],
|
||||
exports: [TodoService],
|
||||
})
|
||||
export class TodoModule {}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
priority: number; // 1-4, 1 is highest
|
||||
dueDate: string | null; // ISO date string
|
||||
project: string | null;
|
||||
labels: string[];
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
userId: string; // Matrix user ID
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface TodoData {
|
||||
tasks: Task[];
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TodoService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TodoService.name);
|
||||
private data: TodoData = { tasks: [], projects: [] };
|
||||
private dataPath: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const storagePath = this.configService.get<string>(
|
||||
'matrix.storagePath',
|
||||
'./data/bot-storage.json'
|
||||
);
|
||||
this.dataPath = storagePath.replace('bot-storage.json', 'todo-data.json');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(this.dataPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this.dataPath)) {
|
||||
const content = fs.readFileSync(this.dataPath, 'utf-8');
|
||||
this.data = JSON.parse(content);
|
||||
this.logger.log(
|
||||
`Loaded ${this.data.tasks.length} tasks, ${this.data.projects.length} projects`
|
||||
);
|
||||
} else {
|
||||
this.data = { tasks: [], projects: [] };
|
||||
await this.saveData();
|
||||
this.logger.log('Created new todo data file');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load todo data:', error);
|
||||
this.data = { tasks: [], projects: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
fs.writeFileSync(this.dataPath, JSON.stringify(this.data, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save todo data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
// Task operations
|
||||
|
||||
async createTask(userId: string, title: string, options?: Partial<Task>): Promise<Task> {
|
||||
const task: Task = {
|
||||
id: this.generateId(),
|
||||
title,
|
||||
completed: false,
|
||||
priority: options?.priority || 4,
|
||||
dueDate: options?.dueDate || null,
|
||||
project: options?.project || null,
|
||||
labels: options?.labels || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
userId,
|
||||
};
|
||||
|
||||
this.data.tasks.push(task);
|
||||
await this.saveData();
|
||||
this.logger.log(`Created task "${title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<Task[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return this.data.tasks
|
||||
.filter(
|
||||
(t) => t.userId === userId && !t.completed && t.dueDate && t.dueDate.startsWith(today)
|
||||
)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
async completeTask(userId: string, taskIndex: number): Promise<Task | null> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed);
|
||||
if (taskIndex < 1 || taskIndex > userTasks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = userTasks[taskIndex - 1];
|
||||
task.completed = true;
|
||||
task.completedAt = new Date().toISOString();
|
||||
await this.saveData();
|
||||
this.logger.log(`Completed task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async deleteTask(userId: string, taskIndex: number): Promise<Task | null> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed);
|
||||
if (taskIndex < 1 || taskIndex > userTasks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = userTasks[taskIndex - 1];
|
||||
this.data.tasks = this.data.tasks.filter((t) => t.id !== task.id);
|
||||
await this.saveData();
|
||||
this.logger.log(`Deleted task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
// Project operations
|
||||
|
||||
async getProjects(userId: string): Promise<Project[]> {
|
||||
// Get unique projects from tasks
|
||||
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<{ total: number; completed: number; pending: number; today: number }> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse task input for priority and date
|
||||
parseTaskInput(input: string): {
|
||||
title: string;
|
||||
priority: number;
|
||||
dueDate: string | null;
|
||||
project: string | null;
|
||||
} {
|
||||
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 today = new Date();
|
||||
if (/@heute/i.test(title)) {
|
||||
dueDate = today.toISOString().split('T')[0];
|
||||
title = title.replace(/@heute/i, '').trim();
|
||||
} else if (/@morgen/i.test(title)) {
|
||||
today.setDate(today.getDate() + 1);
|
||||
dueDate = today.toISOString().split('T')[0];
|
||||
title = title.replace(/@morgen/i, '').trim();
|
||||
} else if (/@übermorgen/i.test(title)) {
|
||||
today.setDate(today.getDate() + 2);
|
||||
dueDate = today.toISOString().split('T')[0];
|
||||
title = title.replace(/@übermorgen/i, '').trim();
|
||||
}
|
||||
|
||||
// Parse project (#projektname)
|
||||
const projectMatch = title.match(/#(\S+)/);
|
||||
if (projectMatch) {
|
||||
project = projectMatch[1];
|
||||
title = title.replace(/#\S+/, '').trim();
|
||||
}
|
||||
|
||||
return { title, priority, dueDate, project };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue