diff --git a/services/matrix-calendar-bot/CLAUDE.md b/services/matrix-calendar-bot/CLAUDE.md index 6d9f782e3..c82cff269 100644 --- a/services/matrix-calendar-bot/CLAUDE.md +++ b/services/matrix-calendar-bot/CLAUDE.md @@ -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. diff --git a/services/matrix-calendar-bot/src/app.module.ts b/services/matrix-calendar-bot/src/app.module.ts index 8e9ddeeef..644f2c86d 100644 --- a/services/matrix-calendar-bot/src/app.module.ts +++ b/services/matrix-calendar-bot/src/app.module.ts @@ -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')], diff --git a/services/matrix-calendar-bot/src/bot/bot.module.ts b/services/matrix-calendar-bot/src/bot/bot.module.ts index 95db4fedb..bc60b5b71 100644 --- a/services/matrix-calendar-bot/src/bot/bot.module.ts +++ b/services/matrix-calendar-bot/src/bot/bot.module.ts @@ -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', }), diff --git a/services/matrix-calendar-bot/src/bot/matrix.service.ts b/services/matrix-calendar-bot/src/bot/matrix.service.ts index 433380a74..51357db8d 100644 --- a/services/matrix-calendar-bot/src/bot/matrix.service.ts +++ b/services/matrix-calendar-bot/src/bot/matrix.service.ts @@ -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 { + 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 { 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 { diff --git a/services/matrix-calendar-bot/src/calendar/calendar.module.ts b/services/matrix-calendar-bot/src/calendar/calendar.module.ts deleted file mode 100644 index d5859c9b2..000000000 --- a/services/matrix-calendar-bot/src/calendar/calendar.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CalendarService } from './calendar.service'; - -@Module({ - providers: [CalendarService], - exports: [CalendarService], -}) -export class CalendarModule {} diff --git a/services/matrix-calendar-bot/src/calendar/calendar.service.ts b/services/matrix-calendar-bot/src/calendar/calendar.service.ts deleted file mode 100644 index 8dc5bb5f5..000000000 --- a/services/matrix-calendar-bot/src/calendar/calendar.service.ts +++ /dev/null @@ -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( - '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 { - 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 { - 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 - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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}`; - } -} diff --git a/services/matrix-todo-bot/CLAUDE.md b/services/matrix-todo-bot/CLAUDE.md index 1069ea703..06e16416a 100644 --- a/services/matrix-todo-bot/CLAUDE.md +++ b/services/matrix-todo-bot/CLAUDE.md @@ -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. diff --git a/services/matrix-todo-bot/src/app.module.ts b/services/matrix-todo-bot/src/app.module.ts index 49dee3f2f..0b861fd8a 100644 --- a/services/matrix-todo-bot/src/app.module.ts +++ b/services/matrix-todo-bot/src/app.module.ts @@ -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')], diff --git a/services/matrix-todo-bot/src/bot/bot.module.ts b/services/matrix-todo-bot/src/bot/bot.module.ts index 0c1866ebf..45bac5931 100644 --- a/services/matrix-todo-bot/src/bot/bot.module.ts +++ b/services/matrix-todo-bot/src/bot/bot.module.ts @@ -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(), diff --git a/services/matrix-todo-bot/src/bot/matrix.service.ts b/services/matrix-todo-bot/src/bot/matrix.service.ts index 45de59234..5222f6c32 100644 --- a/services/matrix-todo-bot/src/bot/matrix.service.ts +++ b/services/matrix-todo-bot/src/bot/matrix.service.ts @@ -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 { + 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); } diff --git a/services/matrix-todo-bot/src/todo/todo.module.ts b/services/matrix-todo-bot/src/todo/todo.module.ts deleted file mode 100644 index 908900c03..000000000 --- a/services/matrix-todo-bot/src/todo/todo.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TodoService } from './todo.service'; - -@Module({ - providers: [TodoService], - exports: [TodoService], -}) -export class TodoModule {} diff --git a/services/matrix-todo-bot/src/todo/todo.service.ts b/services/matrix-todo-bot/src/todo/todo.service.ts deleted file mode 100644 index 5d2b46d15..000000000 --- a/services/matrix-todo-bot/src/todo/todo.service.ts +++ /dev/null @@ -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( - '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 { - 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 { - 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): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // Get unique projects from tasks - const projectNames = new Set(); - 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 }; - } -}