mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(bots): enable Redis SSO for todo-bot and calendar-bot
- Activate Redis session storage in both bots for cross-bot SSO - Update SessionHelper to async methods for Redis-backed SessionService - Fix async/await issues in todo-bot and calendar-bot matrix.service.ts - Remove unused imports from calendar-api and todo-api services - Add CALENDAR_BACKEND_URL and MANA_CORE_SERVICE_KEY to .env.development Note: SessionService methods are now async (Redis-backed). Other bots need their matrix.service.ts updated to await these async calls. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7bad849258
commit
2777f604fd
27 changed files with 2997 additions and 838 deletions
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
# Mana Core Auth Service
|
# Mana Core Auth Service
|
||||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||||
|
# Service key for bot-to-auth communication (Matrix-SSO-Link)
|
||||||
|
MANA_CORE_SERVICE_KEY=dev-service-key-for-bot-sso-2024
|
||||||
|
|
||||||
# JWT Keys (shared across apps for token verification)
|
# JWT Keys (shared across apps for token verification)
|
||||||
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGRsOXROB4lprw\n9oXaOIt+cwHe3UxBOoiWiUXcpFuXwb+kBWn/LyjeCIOXtefOwE0S10JEodK+6foe\naqGHanq86qAmmkb4a8sjj5LAxXkHL35sJo8HaYcx5NkJQLxQSRHpTfdfxsKsKwxa\n4R4uqrvToqdo6tl/VMsGDPS8L7KzaiKaSdGugvlVtXWgV1soeXSUPyPwpyAXQg7h\nY4CkTSkJAplrs77RLdj8u6jbHKR3F7QkwiU1JocjhM1GP/suKiqXRu8omLFnu45C\ns09SNSRsOpNY5csrKA4PZ2LCks9VHH7HafFvB+BbRw4+Ssr6myOysAztqi3bZMRW\nLTakWpBbAgMBAAECggEAF5zi0IzaghHxhtkyYfrSRgSynX9+WYBRNu2ch8/SZqAj\neghOXMkZgAPEjtiSMDGqRsr4ReMoYtB2Qea8sOX8kwC1gj4Po1Mhtez0cwexclUf\nebLH3X/y9/1YiZJk5YImOMIuaoC/ELDvFOhIEhJcMbKREbIc+oiMcH6HgN0vViVh\nJptgHTnqnGHNARkEpf+xnxqJJxEgrEMz50b4fApKpoZsWXNnZ3Atc/i2ziGew5z4\npnGJxs9TWSukBZaQvl9iluBBvqmPkCOId+L7CmB44bNURpqQOm8gxEgLcdn06y5j\nIKee3Z4H6OTseFvSIYYqBqCyyyZWHICBZXUCDQKUbQKBgQDnFe+O+pQc5looLFiF\nxuYsfDtJqvoMgQ0BaVAo6wVpPe6w+1NA6ZxghcM0+8zyc70jZvdMXINhdsfWD5Gi\nJ/NEDI8EXJJKMfnFQ7F1Ad5NyTnnn/TsLda4GIGQznPRS6uxUP4ljFtxmU9G8Diz\nUQ47XsLjwzzbTedMTSYoQ46kdwKBgQDbp0dIq047o4A72/BBttKdZbgQmjFmqCXF\n8YRUquIDXh/CJ4OQwOIaOvk2398Rg53c3MsV+XCJaMmWYqnJ4BdITLsqeGKsczoS\nI0DMehDr++aOoX/f29r1c+7J/fV5jtAEUcwIEOR1vyAM+WdiWnnTvdpMPVUDsgaT\ntuH0E8WgPQKBgQCCINci87Z+Q7VXVAmRY7zwJhEY3eArNGzHc6+BKz+D0S1dmll6\nf1LhA9I2PuldSpGiovP1m08cjk/gGipPXyHdGxlaQmravyPA0urWUfQGZ59k8K1y\nZim4x4wGqEuN+4e2tT44lL5VzRhYgSPcznMuOaGTsrjNYiQy0mr/V3O25wKBgHvV\nryaVDaIp553XvXgO7ma2djNF+xv5KHKUWxqwzINBiX4YcOAnHlHTdbUuOcDSByoB\ngK1+16dgYGZccYTSxc2JFOw4usimndKj9WBSYT/p4G4BNuqqNKO1HKbceoxxq20E\nAJd7jpGjkxo9cb/Nammp22yoF0niEDsvG+xTSVOxAoGBAMfxHYCMdPc625upCbqG\nkPSJJGYREKGad80OtXilYXLvBPzV65q32k2YZGjaicPKRAzj72KO4nfIu9SY6bfO\nBvXCtIcvllZQuxyd3Cd8MirujJodKwThLTMd4bAYYMXGz1/W6R6pzunZs5KEpgEr\nczy9Gk9WNp0t8vfzyZZ9aago\n-----END PRIVATE KEY-----"
|
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGRsOXROB4lprw\n9oXaOIt+cwHe3UxBOoiWiUXcpFuXwb+kBWn/LyjeCIOXtefOwE0S10JEodK+6foe\naqGHanq86qAmmkb4a8sjj5LAxXkHL35sJo8HaYcx5NkJQLxQSRHpTfdfxsKsKwxa\n4R4uqrvToqdo6tl/VMsGDPS8L7KzaiKaSdGugvlVtXWgV1soeXSUPyPwpyAXQg7h\nY4CkTSkJAplrs77RLdj8u6jbHKR3F7QkwiU1JocjhM1GP/suKiqXRu8omLFnu45C\ns09SNSRsOpNY5csrKA4PZ2LCks9VHH7HafFvB+BbRw4+Ssr6myOysAztqi3bZMRW\nLTakWpBbAgMBAAECggEAF5zi0IzaghHxhtkyYfrSRgSynX9+WYBRNu2ch8/SZqAj\neghOXMkZgAPEjtiSMDGqRsr4ReMoYtB2Qea8sOX8kwC1gj4Po1Mhtez0cwexclUf\nebLH3X/y9/1YiZJk5YImOMIuaoC/ELDvFOhIEhJcMbKREbIc+oiMcH6HgN0vViVh\nJptgHTnqnGHNARkEpf+xnxqJJxEgrEMz50b4fApKpoZsWXNnZ3Atc/i2ziGew5z4\npnGJxs9TWSukBZaQvl9iluBBvqmPkCOId+L7CmB44bNURpqQOm8gxEgLcdn06y5j\nIKee3Z4H6OTseFvSIYYqBqCyyyZWHICBZXUCDQKUbQKBgQDnFe+O+pQc5looLFiF\nxuYsfDtJqvoMgQ0BaVAo6wVpPe6w+1NA6ZxghcM0+8zyc70jZvdMXINhdsfWD5Gi\nJ/NEDI8EXJJKMfnFQ7F1Ad5NyTnnn/TsLda4GIGQznPRS6uxUP4ljFtxmU9G8Diz\nUQ47XsLjwzzbTedMTSYoQ46kdwKBgQDbp0dIq047o4A72/BBttKdZbgQmjFmqCXF\n8YRUquIDXh/CJ4OQwOIaOvk2398Rg53c3MsV+XCJaMmWYqnJ4BdITLsqeGKsczoS\nI0DMehDr++aOoX/f29r1c+7J/fV5jtAEUcwIEOR1vyAM+WdiWnnTvdpMPVUDsgaT\ntuH0E8WgPQKBgQCCINci87Z+Q7VXVAmRY7zwJhEY3eArNGzHc6+BKz+D0S1dmll6\nf1LhA9I2PuldSpGiovP1m08cjk/gGipPXyHdGxlaQmravyPA0urWUfQGZ59k8K1y\nZim4x4wGqEuN+4e2tT44lL5VzRhYgSPcznMuOaGTsrjNYiQy0mr/V3O25wKBgHvV\nryaVDaIp553XvXgO7ma2djNF+xv5KHKUWxqwzINBiX4YcOAnHlHTdbUuOcDSByoB\ngK1+16dgYGZccYTSxc2JFOw4usimndKj9WBSYT/p4G4BNuqqNKO1HKbceoxxq20E\nAJd7jpGjkxo9cb/Nammp22yoF0niEDsvG+xTSVOxAoGBAMfxHYCMdPc625upCbqG\nkPSJJGYREKGad80OtXilYXLvBPzV65q32k2YZGjaicPKRAzj72KO4nfIu9SY6bfO\nBvXCtIcvllZQuxyd3Cd8MirujJodKwThLTMd4bAYYMXGz1/W6R6pzunZs5KEpgEr\nczy9Gk9WNp0t8vfzyZZ9aago\n-----END PRIVATE KEY-----"
|
||||||
|
|
@ -220,6 +222,7 @@ CONTACTS_GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
CALENDAR_BACKEND_PORT=3014
|
CALENDAR_BACKEND_PORT=3014
|
||||||
|
CALENDAR_BACKEND_URL=http://localhost:3014
|
||||||
CALENDAR_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
|
CALENDAR_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
|
||||||
|
|
||||||
# Speech-to-Text Service (mana-stt)
|
# Speech-to-Text Service (mana-stt)
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,15 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.20",
|
"@nestjs/common": "^11.0.20",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"date-fns": "^4.1.0"
|
"date-fns": "^4.1.0",
|
||||||
|
"ioredis": "^5.4.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
"@nestjs/config": "^3.0.0 || ^4.0.0"
|
"@nestjs/config": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/ioredis": "^5.0.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
300
packages/bot-services/src/calendar/calendar-api.service.ts
Normal file
300
packages/bot-services/src/calendar/calendar-api.service.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CalendarEvent,
|
||||||
|
Calendar,
|
||||||
|
CreateEventInput,
|
||||||
|
UpdateEventInput,
|
||||||
|
ParsedEventInput,
|
||||||
|
} from './types';
|
||||||
|
import { parseGermanDateKeyword, getTodayISO, addDays } from '../shared/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar API Service
|
||||||
|
*
|
||||||
|
* Connects to the calendar-backend API for event management.
|
||||||
|
* This service is used when the user is logged in and has a valid JWT token.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Get events for a user (requires JWT token)
|
||||||
|
* const events = await calendarApiService.getEvents(token, { start: '2024-01-01', end: '2024-01-31' });
|
||||||
|
*
|
||||||
|
* // Create an event
|
||||||
|
* const event = await calendarApiService.createEvent(token, {
|
||||||
|
* title: 'Meeting',
|
||||||
|
* startTime: new Date('2024-01-15T10:00:00'),
|
||||||
|
* endTime: new Date('2024-01-15T11:00:00'),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CalendarApiService {
|
||||||
|
private readonly logger = new Logger(CalendarApiService.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl = 'http://localhost:3014') {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.logger.log(`Calendar API Service initialized with URL: ${baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Event Operations =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events within a date range
|
||||||
|
*/
|
||||||
|
async getEvents(
|
||||||
|
token: string,
|
||||||
|
filter?: { start?: string; end?: string; calendarId?: string }
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter?.start) params.append('start', filter.start);
|
||||||
|
if (filter?.end) params.append('end', filter.end);
|
||||||
|
if (filter?.calendarId) params.append('calendarId', filter.calendarId);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/events?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { events?: unknown[] };
|
||||||
|
return this.mapApiEvents(data.events || []);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get events:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's events
|
||||||
|
*/
|
||||||
|
async getTodayEvents(token: string): Promise<CalendarEvent[]> {
|
||||||
|
const today = getTodayISO();
|
||||||
|
return this.getEvents(token, { start: today, end: today });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming events (next 7 days)
|
||||||
|
*/
|
||||||
|
async getUpcomingEvents(token: string, days = 7): Promise<CalendarEvent[]> {
|
||||||
|
const today = getTodayISO();
|
||||||
|
const end = addDays(new Date(), days).toISOString().split('T')[0];
|
||||||
|
return this.getEvents(token, { start: today, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event
|
||||||
|
*/
|
||||||
|
async createEvent(token: string, input: CreateEventInput): Promise<CalendarEvent | null> {
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title: input.title,
|
||||||
|
startTime:
|
||||||
|
input.startTime instanceof Date ? input.startTime.toISOString() : input.startTime,
|
||||||
|
endTime: input.endTime instanceof Date ? input.endTime.toISOString() : input.endTime,
|
||||||
|
isAllDay: input.isAllDay || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.description) body.description = input.description;
|
||||||
|
if (input.location) body.location = input.location;
|
||||||
|
if (input.calendarId) body.calendarId = input.calendarId;
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { event: unknown };
|
||||||
|
return this.mapApiEvent(data.event);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create event:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an event
|
||||||
|
*/
|
||||||
|
async updateEvent(
|
||||||
|
token: string,
|
||||||
|
eventId: string,
|
||||||
|
input: UpdateEventInput
|
||||||
|
): Promise<CalendarEvent | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/events/${eventId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { event: unknown };
|
||||||
|
return this.mapApiEvent(data.event);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to update event:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an event
|
||||||
|
*/
|
||||||
|
async deleteEvent(token: string, eventId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/events/${eventId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to delete event:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Calendar Operations =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all calendars
|
||||||
|
*/
|
||||||
|
async getCalendars(token: string): Promise<Calendar[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/calendars`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { calendars?: any[] };
|
||||||
|
return (data.calendars || []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
color: c.color,
|
||||||
|
userId: c.userId || '',
|
||||||
|
isDefault: c.isDefault || false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get calendars:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Parsing =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse natural language event input
|
||||||
|
*/
|
||||||
|
parseEventInput(input: string): ParsedEventInput {
|
||||||
|
let title = input;
|
||||||
|
let startTime: Date | null = null;
|
||||||
|
let endTime: Date | null = null;
|
||||||
|
let isAllDay = false;
|
||||||
|
let location: string | null = null;
|
||||||
|
|
||||||
|
// Extract date (@heute, @morgen, etc.)
|
||||||
|
const dateMatch = title.match(/@(\S+)/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const dateStr = dateMatch[1].toLowerCase();
|
||||||
|
const parsedDate = parseGermanDateKeyword(dateStr);
|
||||||
|
|
||||||
|
if (parsedDate) {
|
||||||
|
// Default to 9:00-10:00 for the parsed date
|
||||||
|
startTime = new Date(`${parsedDate}T09:00:00`);
|
||||||
|
endTime = new Date(`${parsedDate}T10:00:00`);
|
||||||
|
}
|
||||||
|
title = title.replace(dateMatch[0], '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract time (um 14 Uhr, 14:00, etc.)
|
||||||
|
const timeMatch = title.match(/(?:um\s+)?(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?/i);
|
||||||
|
if (timeMatch) {
|
||||||
|
const hours = parseInt(timeMatch[1]);
|
||||||
|
const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
|
||||||
|
|
||||||
|
if (startTime) {
|
||||||
|
startTime.setHours(hours, minutes, 0, 0);
|
||||||
|
endTime = new Date(startTime);
|
||||||
|
endTime.setHours(hours + 1); // Default 1 hour duration
|
||||||
|
} else {
|
||||||
|
// If no date specified, assume today
|
||||||
|
startTime = new Date();
|
||||||
|
startTime.setHours(hours, minutes, 0, 0);
|
||||||
|
endTime = new Date(startTime);
|
||||||
|
endTime.setHours(hours + 1);
|
||||||
|
}
|
||||||
|
title = title.replace(timeMatch[0], '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract location (in ...)
|
||||||
|
const locationMatch = title.match(/\bin\s+([^,]+)/i);
|
||||||
|
if (locationMatch) {
|
||||||
|
location = locationMatch[1].trim();
|
||||||
|
title = title.replace(locationMatch[0], '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no time specified, treat as all-day event
|
||||||
|
if (!startTime) {
|
||||||
|
startTime = new Date();
|
||||||
|
startTime.setHours(0, 0, 0, 0);
|
||||||
|
endTime = new Date(startTime);
|
||||||
|
endTime.setHours(23, 59, 59, 999);
|
||||||
|
isAllDay = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title.trim(),
|
||||||
|
startTime: startTime!,
|
||||||
|
endTime: endTime!,
|
||||||
|
isAllDay,
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map API event format to internal CalendarEvent format
|
||||||
|
*/
|
||||||
|
private mapApiEvent(apiEvent: any): CalendarEvent {
|
||||||
|
return {
|
||||||
|
id: apiEvent.id,
|
||||||
|
userId: apiEvent.userId || '',
|
||||||
|
calendarId: apiEvent.calendarId,
|
||||||
|
calendarName: apiEvent.calendar?.name || 'Kalender',
|
||||||
|
title: apiEvent.title,
|
||||||
|
description: apiEvent.description || null,
|
||||||
|
location: apiEvent.location || null,
|
||||||
|
startTime: apiEvent.startTime,
|
||||||
|
endTime: apiEvent.endTime,
|
||||||
|
isAllDay: apiEvent.isAllDay || false,
|
||||||
|
createdAt: apiEvent.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map array of API events
|
||||||
|
*/
|
||||||
|
private mapApiEvents(apiEvents: any[]): CalendarEvent[] {
|
||||||
|
return apiEvents.map((e) => this.mapApiEvent(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -345,6 +345,6 @@ export class CalendarService implements OnModuleInit {
|
||||||
// Clean up title
|
// Clean up title
|
||||||
title = title.replace(/\s+/g, ' ').trim();
|
title = title.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
return { title, startTime, endTime, isAllDay };
|
return { title, startTime, endTime, isAllDay, location: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Module
|
// Module
|
||||||
export { CalendarModule, CalendarModuleOptions } from './calendar.module';
|
export { CalendarModule, CalendarModuleOptions } from './calendar.module';
|
||||||
|
|
||||||
// Service
|
// Services
|
||||||
export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service';
|
export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service';
|
||||||
|
export { CalendarApiService } from './calendar-api.service';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|
|
||||||
|
|
@ -75,4 +75,5 @@ export interface ParsedEventInput {
|
||||||
startTime: Date | null;
|
startTime: Date | null;
|
||||||
endTime: Date | null;
|
endTime: Date | null;
|
||||||
isAllDay: boolean;
|
isAllDay: boolean;
|
||||||
|
location: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,13 @@
|
||||||
// ===== Core Services =====
|
// ===== Core Services =====
|
||||||
|
|
||||||
// Todo
|
// Todo
|
||||||
export { TodoModule, TodoModuleOptions, TodoService, TODO_STORAGE_PROVIDER } from './todo/index.js';
|
export {
|
||||||
|
TodoModule,
|
||||||
|
TodoModuleOptions,
|
||||||
|
TodoService,
|
||||||
|
TODO_STORAGE_PROVIDER,
|
||||||
|
TodoApiService,
|
||||||
|
} from './todo/index.js';
|
||||||
export type {
|
export type {
|
||||||
Task,
|
Task,
|
||||||
Project,
|
Project,
|
||||||
|
|
@ -44,6 +50,7 @@ export {
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
CalendarModuleOptions,
|
CalendarModuleOptions,
|
||||||
CalendarService,
|
CalendarService,
|
||||||
|
CalendarApiService,
|
||||||
CALENDAR_STORAGE_PROVIDER,
|
CALENDAR_STORAGE_PROVIDER,
|
||||||
} from './calendar/index.js';
|
} from './calendar/index.js';
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -88,13 +95,26 @@ export type {
|
||||||
export {
|
export {
|
||||||
SessionModule,
|
SessionModule,
|
||||||
SessionService,
|
SessionService,
|
||||||
|
RedisSessionProvider,
|
||||||
|
REDIS_SESSION_PROVIDER,
|
||||||
|
REDIS_CLIENT,
|
||||||
SESSION_MODULE_OPTIONS,
|
SESSION_MODULE_OPTIONS,
|
||||||
DEFAULT_SESSION_EXPIRY_MS,
|
DEFAULT_SESSION_EXPIRY_MS,
|
||||||
} from './session/index.js';
|
} from './session/index.js';
|
||||||
export type { UserSession, LoginResult, SessionStats, SessionModuleOptions } from './session/index.js';
|
export type {
|
||||||
|
UserSession,
|
||||||
|
LoginResult,
|
||||||
|
SessionStats,
|
||||||
|
SessionModuleOptions,
|
||||||
|
SessionStorageMode,
|
||||||
|
} from './session/index.js';
|
||||||
|
|
||||||
// Transcription (Speech-to-Text via mana-stt)
|
// Transcription (Speech-to-Text via mana-stt)
|
||||||
export { TranscriptionModule, TranscriptionService, STT_MODULE_OPTIONS } from './transcription/index.js';
|
export {
|
||||||
|
TranscriptionModule,
|
||||||
|
TranscriptionService,
|
||||||
|
STT_MODULE_OPTIONS,
|
||||||
|
} from './transcription/index.js';
|
||||||
export type {
|
export type {
|
||||||
SttResponse,
|
SttResponse,
|
||||||
TranscriptionOptions,
|
TranscriptionOptions,
|
||||||
|
|
@ -102,7 +122,12 @@ export type {
|
||||||
} from './transcription/index.js';
|
} from './transcription/index.js';
|
||||||
|
|
||||||
// Credit (Credit balance and formatting for Matrix bots)
|
// Credit (Credit balance and formatting for Matrix bots)
|
||||||
export { CreditModule, CreditService, CREDIT_MODULE_OPTIONS, CreditErrorCode } from './credit/index.js';
|
export {
|
||||||
|
CreditModule,
|
||||||
|
CreditService,
|
||||||
|
CREDIT_MODULE_OPTIONS,
|
||||||
|
CreditErrorCode,
|
||||||
|
} from './credit/index.js';
|
||||||
export type {
|
export type {
|
||||||
CreditBalance,
|
CreditBalance,
|
||||||
CreditValidationResult,
|
CreditValidationResult,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
export { SessionService } from './session.service';
|
export { SessionService, REDIS_SESSION_PROVIDER } from './session.service';
|
||||||
export { SessionModule } from './session.module';
|
export { SessionModule } from './session.module';
|
||||||
export type { UserSession, LoginResult, SessionStats, SessionModuleOptions } from './types';
|
export { RedisSessionProvider, REDIS_CLIENT } from './redis-session.provider';
|
||||||
|
export type {
|
||||||
|
UserSession,
|
||||||
|
LoginResult,
|
||||||
|
SessionStats,
|
||||||
|
SessionModuleOptions,
|
||||||
|
SessionStorageMode,
|
||||||
|
} from './types';
|
||||||
export { SESSION_MODULE_OPTIONS, DEFAULT_SESSION_EXPIRY_MS } from './types';
|
export { SESSION_MODULE_OPTIONS, DEFAULT_SESSION_EXPIRY_MS } from './types';
|
||||||
|
|
|
||||||
245
packages/bot-services/src/session/redis-session.provider.ts
Normal file
245
packages/bot-services/src/session/redis-session.provider.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
Inject,
|
||||||
|
Optional,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import {
|
||||||
|
UserSession,
|
||||||
|
SessionModuleOptions,
|
||||||
|
SESSION_MODULE_OPTIONS,
|
||||||
|
DEFAULT_SESSION_EXPIRY_MS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection token for Redis client
|
||||||
|
*/
|
||||||
|
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key prefix for bot sessions in Redis
|
||||||
|
*/
|
||||||
|
const KEY_PREFIX = 'bot:session:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis-based session provider for cross-bot SSO
|
||||||
|
*
|
||||||
|
* Sessions are stored in Redis with automatic TTL expiration.
|
||||||
|
* All bots using this provider share the same session store.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // User logs in via todo-bot
|
||||||
|
* await sessionProvider.setSession('@user:matrix.mana.how', session);
|
||||||
|
*
|
||||||
|
* // Same user in picture-bot - already logged in!
|
||||||
|
* const session = await sessionProvider.getSession('@user:matrix.mana.how');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RedisSessionProvider implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RedisSessionProvider.name);
|
||||||
|
private client: Redis | null = null;
|
||||||
|
private readonly sessionExpirySeconds: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Optional() private configService: ConfigService,
|
||||||
|
@Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions
|
||||||
|
) {
|
||||||
|
const expiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS;
|
||||||
|
this.sessionExpirySeconds = Math.floor(expiryMs / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const host =
|
||||||
|
this.options?.redisHost || this.configService?.get<string>('REDIS_HOST', 'localhost');
|
||||||
|
const port = this.options?.redisPort || this.configService?.get<number>('REDIS_PORT', 6379);
|
||||||
|
const password =
|
||||||
|
this.options?.redisPassword || this.configService?.get<string>('REDIS_PASSWORD');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.client = new Redis({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password: password || undefined,
|
||||||
|
retryStrategy: (times) => {
|
||||||
|
if (times > 3) {
|
||||||
|
this.logger.warn('Redis connection failed, falling back to in-memory sessions');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(times * 200, 2000);
|
||||||
|
},
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
this.logger.error(`Redis error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
this.logger.log(`Connected to Redis at ${host}:${port} for session storage`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
await this.client.ping();
|
||||||
|
this.logger.log('Redis session provider initialized');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not connect to Redis: ${error}. Falling back to in-memory sessions.`);
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.client) {
|
||||||
|
await this.client.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Redis key for a Matrix user ID
|
||||||
|
*/
|
||||||
|
private buildKey(matrixUserId: string): string {
|
||||||
|
return `${KEY_PREFIX}${matrixUserId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Redis is connected
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.client !== null && this.client.status === 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a session in Redis
|
||||||
|
*/
|
||||||
|
async setSession(matrixUserId: string, session: UserSession): Promise<void> {
|
||||||
|
if (!this.client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
token: session.token,
|
||||||
|
email: session.email,
|
||||||
|
expiresAt: session.expiresAt.toISOString(),
|
||||||
|
data: session.data || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.client.setex(
|
||||||
|
this.buildKey(matrixUserId),
|
||||||
|
this.sessionExpirySeconds,
|
||||||
|
JSON.stringify(data)
|
||||||
|
);
|
||||||
|
this.logger.debug(`Session stored for ${matrixUserId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to store session: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a session from Redis
|
||||||
|
*/
|
||||||
|
async getSession(matrixUserId: string): Promise<UserSession | null> {
|
||||||
|
if (!this.client) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.client.get(this.buildKey(matrixUserId));
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const session: UserSession = {
|
||||||
|
token: parsed.token,
|
||||||
|
email: parsed.email,
|
||||||
|
expiresAt: new Date(parsed.expiresAt),
|
||||||
|
data: parsed.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if expired (should not happen due to TTL, but double-check)
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
await this.deleteSession(matrixUserId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get session: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only the token from a session
|
||||||
|
*/
|
||||||
|
async getToken(matrixUserId: string): Promise<string | null> {
|
||||||
|
const session = await this.getSession(matrixUserId);
|
||||||
|
return session?.token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session from Redis
|
||||||
|
*/
|
||||||
|
async deleteSession(matrixUserId: string): Promise<void> {
|
||||||
|
if (!this.client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.del(this.buildKey(matrixUserId));
|
||||||
|
this.logger.debug(`Session deleted for ${matrixUserId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to delete session: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session data without changing the token
|
||||||
|
*/
|
||||||
|
async updateSessionData(matrixUserId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
const session = await this.getSession(matrixUserId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.data = session.data || {};
|
||||||
|
session.data[key] = value;
|
||||||
|
await this.setSession(matrixUserId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session data
|
||||||
|
*/
|
||||||
|
async getSessionData<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||||
|
const session = await this.getSession(matrixUserId);
|
||||||
|
return (session?.data?.[key] as T) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active session keys (for debugging/stats)
|
||||||
|
*/
|
||||||
|
async getActiveSessionCount(): Promise<number> {
|
||||||
|
if (!this.client) return 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = await this.client.keys(`${KEY_PREFIX}*`);
|
||||||
|
return keys.length;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get session count: ${error}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<{ status: string; latency: number }> {
|
||||||
|
if (!this.client) {
|
||||||
|
return { status: 'disconnected', latency: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await this.client.ping();
|
||||||
|
return { status: 'ok', latency: Date.now() - start };
|
||||||
|
} catch {
|
||||||
|
return { status: 'error', latency: Date.now() - start };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Module, DynamicModule, Global } from '@nestjs/common';
|
import { Module, DynamicModule, Global } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService, REDIS_SESSION_PROVIDER } from './session.service';
|
||||||
|
import { RedisSessionProvider } from './redis-session.provider';
|
||||||
import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types';
|
import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -11,19 +12,31 @@ import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types';
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // With explicit configuration
|
* // Basic usage (in-memory sessions, per bot)
|
||||||
|
* @Module({
|
||||||
|
* imports: [SessionModule.forRoot()]
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // With Redis for cross-bot SSO
|
||||||
* @Module({
|
* @Module({
|
||||||
* imports: [
|
* imports: [
|
||||||
* SessionModule.register({
|
* SessionModule.forRoot({
|
||||||
* authUrl: 'http://mana-core-auth:3001',
|
* storageMode: 'redis',
|
||||||
* sessionExpiryMs: 7 * 24 * 60 * 60 * 1000 // 7 days
|
* redisHost: 'localhost',
|
||||||
|
* redisPort: 6379,
|
||||||
* })
|
* })
|
||||||
* ]
|
* ]
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // With ConfigService (reads from auth.url or MANA_CORE_AUTH_URL)
|
* // With Matrix-SSO-Link (automatic login)
|
||||||
* @Module({
|
* @Module({
|
||||||
* imports: [SessionModule.forRoot()]
|
* imports: [
|
||||||
|
* SessionModule.forRoot({
|
||||||
|
* storageMode: 'redis',
|
||||||
|
* enableMatrixSsoLink: true,
|
||||||
|
* serviceKey: process.env.MANA_CORE_SERVICE_KEY,
|
||||||
|
* })
|
||||||
|
* ]
|
||||||
* })
|
* })
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
@ -34,29 +47,76 @@ export class SessionModule {
|
||||||
* Register module with explicit options
|
* Register module with explicit options
|
||||||
*/
|
*/
|
||||||
static register(options: SessionModuleOptions = {}): DynamicModule {
|
static register(options: SessionModuleOptions = {}): DynamicModule {
|
||||||
|
const providers: any[] = [
|
||||||
|
{
|
||||||
|
provide: SESSION_MODULE_OPTIONS,
|
||||||
|
useValue: options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add Redis provider if storage mode is redis
|
||||||
|
if (options.storageMode === 'redis') {
|
||||||
|
providers.push({
|
||||||
|
provide: REDIS_SESSION_PROVIDER,
|
||||||
|
useClass: RedisSessionProvider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
providers.push(SessionService);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
module: SessionModule,
|
module: SessionModule,
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
providers: [
|
providers,
|
||||||
{
|
|
||||||
provide: SESSION_MODULE_OPTIONS,
|
|
||||||
useValue: options,
|
|
||||||
},
|
|
||||||
SessionService,
|
|
||||||
],
|
|
||||||
exports: [SessionService],
|
exports: [SessionService],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register module with ConfigService (reads auth.url or MANA_CORE_AUTH_URL from config)
|
* Register module with ConfigService
|
||||||
|
*
|
||||||
|
* Reads configuration from environment:
|
||||||
|
* - MANA_CORE_AUTH_URL: Auth service URL
|
||||||
|
* - REDIS_HOST, REDIS_PORT: Redis for cross-bot SSO
|
||||||
|
* - MANA_CORE_SERVICE_KEY: For Matrix-SSO-Link
|
||||||
|
* - SESSION_STORAGE_MODE: 'memory' or 'redis'
|
||||||
*/
|
*/
|
||||||
static forRoot(): DynamicModule {
|
static forRoot(options: SessionModuleOptions = {}): DynamicModule {
|
||||||
|
const providers: any[] = [
|
||||||
|
{
|
||||||
|
provide: SESSION_MODULE_OPTIONS,
|
||||||
|
useValue: options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add Redis provider if storage mode is redis
|
||||||
|
if (options.storageMode === 'redis') {
|
||||||
|
providers.push({
|
||||||
|
provide: REDIS_SESSION_PROVIDER,
|
||||||
|
useClass: RedisSessionProvider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
providers.push(SessionService);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
module: SessionModule,
|
module: SessionModule,
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
providers: [SessionService],
|
providers,
|
||||||
exports: [SessionService],
|
exports: [SessionService],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register module with Redis enabled for cross-bot SSO
|
||||||
|
*
|
||||||
|
* Convenience method that enables Redis storage and Matrix-SSO-Link.
|
||||||
|
*/
|
||||||
|
static forRootWithRedis(options: Omit<SessionModuleOptions, 'storageMode'> = {}): DynamicModule {
|
||||||
|
return this.forRoot({
|
||||||
|
...options,
|
||||||
|
storageMode: 'redis',
|
||||||
|
enableMatrixSsoLink: options.enableMatrixSsoLink ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,31 @@ import {
|
||||||
SESSION_MODULE_OPTIONS,
|
SESSION_MODULE_OPTIONS,
|
||||||
DEFAULT_SESSION_EXPIRY_MS,
|
DEFAULT_SESSION_EXPIRY_MS,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { RedisSessionProvider } from './redis-session.provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection token for Redis session provider
|
||||||
|
*/
|
||||||
|
export const REDIS_SESSION_PROVIDER = 'REDIS_SESSION_PROVIDER';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared session management service for Matrix bots
|
* Shared session management service for Matrix bots
|
||||||
*
|
*
|
||||||
* Manages user authentication sessions linking Matrix user IDs to mana-core-auth JWT tokens.
|
* Manages user authentication sessions linking Matrix user IDs to mana-core-auth JWT tokens.
|
||||||
* Sessions are stored in-memory and automatically expire.
|
*
|
||||||
|
* Features:
|
||||||
|
* - **In-memory mode** (default): Sessions stored per bot instance
|
||||||
|
* - **Redis mode**: Sessions shared across ALL bots (SSO)
|
||||||
|
* - **Matrix-SSO-Link**: Automatic login for users who logged into Matrix via OIDC
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // In NestJS module
|
* // In NestJS module - with Redis for cross-bot SSO
|
||||||
* imports: [SessionModule.register({ authUrl: 'http://mana-core-auth:3001' })]
|
* imports: [SessionModule.forRoot({ storageMode: 'redis' })]
|
||||||
*
|
*
|
||||||
* // In service/controller
|
* // In service/controller
|
||||||
* const result = await sessionService.login(matrixUserId, email, password);
|
* const token = await sessionService.getToken(matrixUserId);
|
||||||
* const token = sessionService.getToken(matrixUserId);
|
* // Token is available across ALL bots!
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -32,10 +42,13 @@ export class SessionService {
|
||||||
private readonly authUrl: string;
|
private readonly authUrl: string;
|
||||||
private readonly sessionExpiryMs: number;
|
private readonly sessionExpiryMs: number;
|
||||||
private readonly loginPath: string;
|
private readonly loginPath: string;
|
||||||
|
private readonly enableMatrixSsoLink: boolean;
|
||||||
|
private readonly serviceKey: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional() private configService: ConfigService,
|
@Optional() private configService: ConfigService,
|
||||||
@Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions
|
@Optional() @Inject(SESSION_MODULE_OPTIONS) private options?: SessionModuleOptions,
|
||||||
|
@Optional() @Inject(REDIS_SESSION_PROVIDER) private redisProvider?: RedisSessionProvider
|
||||||
) {
|
) {
|
||||||
// Priority: module options > config > environment > default
|
// Priority: module options > config > environment > default
|
||||||
this.authUrl =
|
this.authUrl =
|
||||||
|
|
@ -47,7 +60,125 @@ export class SessionService {
|
||||||
this.sessionExpiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS;
|
this.sessionExpiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS;
|
||||||
this.loginPath = options?.loginPath || '/api/v1/auth/login';
|
this.loginPath = options?.loginPath || '/api/v1/auth/login';
|
||||||
|
|
||||||
this.logger.log(`Auth URL: ${this.authUrl}`);
|
// Matrix-SSO-Link settings
|
||||||
|
this.enableMatrixSsoLink = options?.enableMatrixSsoLink ?? options?.storageMode === 'redis';
|
||||||
|
this.serviceKey =
|
||||||
|
options?.serviceKey || this.configService?.get<string>('MANA_CORE_SERVICE_KEY');
|
||||||
|
|
||||||
|
const mode = this.redisProvider?.isConnected() ? 'redis' : 'memory';
|
||||||
|
this.logger.log(
|
||||||
|
`Auth URL: ${this.authUrl}, Storage: ${mode}, Matrix-SSO-Link: ${this.enableMatrixSsoLink}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if using Redis storage
|
||||||
|
*/
|
||||||
|
private useRedis(): boolean {
|
||||||
|
return this.redisProvider?.isConnected() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a session for a Matrix user
|
||||||
|
*
|
||||||
|
* This method tries multiple sources in order:
|
||||||
|
* 1. Redis cache (if enabled)
|
||||||
|
* 2. In-memory cache
|
||||||
|
* 3. Matrix-SSO-Link lookup (automatic login if user logged into Matrix via OIDC)
|
||||||
|
*
|
||||||
|
* @param matrixUserId - Matrix user ID (e.g., "@user:matrix.mana.how")
|
||||||
|
* @returns JWT token or null if not logged in
|
||||||
|
*/
|
||||||
|
async getToken(matrixUserId: string): Promise<string | null> {
|
||||||
|
// 1. Try Redis first
|
||||||
|
if (this.useRedis()) {
|
||||||
|
const token = await this.redisProvider!.getToken(matrixUserId);
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try in-memory cache
|
||||||
|
const session = this.sessions.get(matrixUserId);
|
||||||
|
if (session) {
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
this.sessions.delete(matrixUserId);
|
||||||
|
} else {
|
||||||
|
return session.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try Matrix-SSO-Link (automatic login)
|
||||||
|
if (this.enableMatrixSsoLink) {
|
||||||
|
const token = await this.fetchMatrixLinkedToken(matrixUserId);
|
||||||
|
if (token) {
|
||||||
|
// Cache the token
|
||||||
|
await this.storeSession(matrixUserId, {
|
||||||
|
token,
|
||||||
|
email: '', // Unknown from SSO link
|
||||||
|
expiresAt: new Date(Date.now() + this.sessionExpiryMs),
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch token via Matrix-SSO-Link from mana-core-auth
|
||||||
|
*
|
||||||
|
* If the user logged into Matrix via OIDC (Sign in with Mana Core),
|
||||||
|
* their Matrix user ID is linked to their Mana account.
|
||||||
|
* This method fetches a JWT token for that link.
|
||||||
|
*/
|
||||||
|
private async fetchMatrixLinkedToken(matrixUserId: string): Promise<string | null> {
|
||||||
|
if (!this.serviceKey) {
|
||||||
|
this.logger.debug('Matrix-SSO-Link disabled: no service key configured');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.authUrl}/api/v1/auth/matrix-session/${encodeURIComponent(matrixUserId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Service-Key': this.serviceKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 404 = no link exists, which is normal for users who didn't use OIDC
|
||||||
|
if (response.status !== 404) {
|
||||||
|
this.logger.warn(`Matrix-SSO-Link lookup failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { token?: string };
|
||||||
|
if (data.token) {
|
||||||
|
this.logger.log(`Matrix-SSO-Link: auto-login for ${matrixUserId}`);
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`Matrix-SSO-Link lookup error: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store session in Redis and/or memory
|
||||||
|
*/
|
||||||
|
private async storeSession(matrixUserId: string, session: UserSession): Promise<void> {
|
||||||
|
// Store in Redis if available
|
||||||
|
if (this.useRedis()) {
|
||||||
|
await this.redisProvider!.setSession(matrixUserId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also store in memory as fallback
|
||||||
|
this.sessions.set(matrixUserId, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,12 +212,14 @@ export class SessionService {
|
||||||
return { success: false, error: 'Kein Token erhalten' };
|
return { success: false, error: 'Kein Token erhalten' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store session with expiry
|
// Store session
|
||||||
this.sessions.set(matrixUserId, {
|
const session: UserSession = {
|
||||||
token,
|
token,
|
||||||
email,
|
email,
|
||||||
expiresAt: new Date(Date.now() + this.sessionExpiryMs),
|
expiresAt: new Date(Date.now() + this.sessionExpiryMs),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await this.storeSession(matrixUserId, session);
|
||||||
|
|
||||||
this.logger.log(`User ${matrixUserId} logged in as ${email}`);
|
this.logger.log(`User ${matrixUserId} logged in as ${email}`);
|
||||||
return { success: true, email };
|
return { success: true, email };
|
||||||
|
|
@ -102,56 +235,66 @@ export class SessionService {
|
||||||
/**
|
/**
|
||||||
* Logout a Matrix user
|
* Logout a Matrix user
|
||||||
*/
|
*/
|
||||||
logout(matrixUserId: string): void {
|
async logout(matrixUserId: string): Promise<void> {
|
||||||
|
// Remove from Redis
|
||||||
|
if (this.useRedis()) {
|
||||||
|
await this.redisProvider!.deleteSession(matrixUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from memory
|
||||||
this.sessions.delete(matrixUserId);
|
this.sessions.delete(matrixUserId);
|
||||||
this.logger.log(`User ${matrixUserId} logged out`);
|
this.logger.log(`User ${matrixUserId} logged out`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get JWT token for a Matrix user (null if not logged in or expired)
|
|
||||||
*/
|
|
||||||
getToken(matrixUserId: string): string | null {
|
|
||||||
const session = this.sessions.get(matrixUserId);
|
|
||||||
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
// Check if token expired
|
|
||||||
if (session.expiresAt < new Date()) {
|
|
||||||
this.sessions.delete(matrixUserId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a Matrix user is logged in
|
* Check if a Matrix user is logged in
|
||||||
*/
|
*/
|
||||||
isLoggedIn(matrixUserId: string): boolean {
|
async isLoggedIn(matrixUserId: string): Promise<boolean> {
|
||||||
return this.getToken(matrixUserId) !== null;
|
const token = await this.getToken(matrixUserId);
|
||||||
|
return token !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full session object for a Matrix user
|
* Get the full session object for a Matrix user
|
||||||
*/
|
*/
|
||||||
getSession(matrixUserId: string): UserSession | null {
|
async getSession(matrixUserId: string): Promise<UserSession | null> {
|
||||||
const token = this.getToken(matrixUserId); // This handles expiry check
|
// Try Redis first
|
||||||
if (!token) return null;
|
if (this.useRedis()) {
|
||||||
return this.sessions.get(matrixUserId) || null;
|
const session = await this.redisProvider!.getSession(matrixUserId);
|
||||||
|
if (session) return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try memory
|
||||||
|
const session = this.sessions.get(matrixUserId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
this.sessions.delete(matrixUserId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get email for a logged-in Matrix user
|
* Get email for a logged-in Matrix user
|
||||||
*/
|
*/
|
||||||
getEmail(matrixUserId: string): string | null {
|
async getEmail(matrixUserId: string): Promise<string | null> {
|
||||||
const session = this.getSession(matrixUserId);
|
const session = await this.getSession(matrixUserId);
|
||||||
return session?.email || null;
|
return session?.email || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store custom data in a user's session
|
* Store custom data in a user's session
|
||||||
*/
|
*/
|
||||||
setSessionData(matrixUserId: string, key: string, value: unknown): void {
|
async setSessionData(matrixUserId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
// Update in Redis
|
||||||
|
if (this.useRedis()) {
|
||||||
|
await this.redisProvider!.updateSessionData(matrixUserId, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in memory
|
||||||
const session = this.sessions.get(matrixUserId);
|
const session = this.sessions.get(matrixUserId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.data = session.data || {};
|
session.data = session.data || {};
|
||||||
|
|
@ -162,13 +305,20 @@ export class SessionService {
|
||||||
/**
|
/**
|
||||||
* Get custom data from a user's session
|
* Get custom data from a user's session
|
||||||
*/
|
*/
|
||||||
getSessionData<T = unknown>(matrixUserId: string, key: string): T | null {
|
async getSessionData<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||||
const session = this.getSession(matrixUserId);
|
// Try Redis first
|
||||||
|
if (this.useRedis()) {
|
||||||
|
const data = await this.redisProvider!.getSessionData<T>(matrixUserId, key);
|
||||||
|
if (data !== null) return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try memory
|
||||||
|
const session = await this.getSession(matrixUserId);
|
||||||
return (session?.data?.[key] as T) || null;
|
return (session?.data?.[key] as T) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total session count (including expired)
|
* Get total session count (including expired in memory)
|
||||||
*/
|
*/
|
||||||
getSessionCount(): number {
|
getSessionCount(): number {
|
||||||
return this.sessions.size;
|
return this.sessions.size;
|
||||||
|
|
@ -177,27 +327,40 @@ export class SessionService {
|
||||||
/**
|
/**
|
||||||
* Get count of active (non-expired) sessions
|
* Get count of active (non-expired) sessions
|
||||||
*/
|
*/
|
||||||
getActiveSessionCount(): number {
|
async getActiveSessionCount(): Promise<number> {
|
||||||
const now = new Date();
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const session of this.sessions.values()) {
|
|
||||||
if (session.expiresAt > now) count++;
|
// Count Redis sessions
|
||||||
|
if (this.useRedis()) {
|
||||||
|
count = await this.redisProvider!.getActiveSessionCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not using Redis, count memory sessions
|
||||||
|
if (count === 0) {
|
||||||
|
const now = new Date();
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.expiresAt > now) count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get session statistics
|
* Get session statistics
|
||||||
*/
|
*/
|
||||||
getStats(): SessionStats {
|
async getStats(): Promise<SessionStats> {
|
||||||
|
const active = await this.getActiveSessionCount();
|
||||||
return {
|
return {
|
||||||
total: this.getSessionCount(),
|
total: this.getSessionCount(),
|
||||||
active: this.getActiveSessionCount(),
|
active,
|
||||||
|
storageMode: this.useRedis() ? 'redis' : 'memory',
|
||||||
|
matrixSsoLinkEnabled: this.enableMatrixSsoLink,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up expired sessions (can be called periodically)
|
* Clean up expired sessions (only for in-memory, Redis auto-expires)
|
||||||
*/
|
*/
|
||||||
cleanupExpiredSessions(): number {
|
cleanupExpiredSessions(): number {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -218,7 +381,7 @@ export class SessionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active session user IDs
|
* Get all active session user IDs (memory only)
|
||||||
*/
|
*/
|
||||||
getActiveUserIds(): string[] {
|
getActiveUserIds(): string[] {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -232,4 +395,18 @@ export class SessionService {
|
||||||
|
|
||||||
return userIds;
|
return userIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<{
|
||||||
|
redis: { status: string; latency: number } | null;
|
||||||
|
memory: number;
|
||||||
|
}> {
|
||||||
|
const redisHealth = this.redisProvider ? await this.redisProvider.healthCheck() : null;
|
||||||
|
return {
|
||||||
|
redis: redisHealth,
|
||||||
|
memory: this.sessions.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,17 @@ export interface SessionStats {
|
||||||
total: number;
|
total: number;
|
||||||
/** Active (non-expired) sessions */
|
/** Active (non-expired) sessions */
|
||||||
active: number;
|
active: number;
|
||||||
|
/** Storage mode being used */
|
||||||
|
storageMode?: 'memory' | 'redis';
|
||||||
|
/** Whether Matrix-SSO-Link is enabled */
|
||||||
|
matrixSsoLinkEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session storage mode
|
||||||
|
*/
|
||||||
|
export type SessionStorageMode = 'memory' | 'redis';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session module configuration options
|
* Session module configuration options
|
||||||
*/
|
*/
|
||||||
|
|
@ -45,6 +54,22 @@ export interface SessionModuleOptions {
|
||||||
sessionExpiryMs?: number;
|
sessionExpiryMs?: number;
|
||||||
/** Custom login endpoint path */
|
/** Custom login endpoint path */
|
||||||
loginPath?: string;
|
loginPath?: string;
|
||||||
|
|
||||||
|
// Redis configuration (for cross-bot SSO)
|
||||||
|
/** Storage mode: 'memory' (default) or 'redis' */
|
||||||
|
storageMode?: SessionStorageMode;
|
||||||
|
/** Redis host (default: localhost) */
|
||||||
|
redisHost?: string;
|
||||||
|
/** Redis port (default: 6379) */
|
||||||
|
redisPort?: number;
|
||||||
|
/** Redis password (optional) */
|
||||||
|
redisPassword?: string;
|
||||||
|
|
||||||
|
// Matrix-SSO-Link configuration (automatic login via Matrix OIDC)
|
||||||
|
/** Enable Matrix-SSO-Link lookup (default: true when using Redis) */
|
||||||
|
enableMatrixSsoLink?: boolean;
|
||||||
|
/** Service key for internal API calls to mana-core-auth */
|
||||||
|
serviceKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SESSION_MODULE_OPTIONS = 'SESSION_MODULE_OPTIONS';
|
export const SESSION_MODULE_OPTIONS = 'SESSION_MODULE_OPTIONS';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Module
|
// Module
|
||||||
export { TodoModule, TodoModuleOptions } from './todo.module';
|
export { TodoModule, TodoModuleOptions } from './todo.module';
|
||||||
|
|
||||||
// Service
|
// Services
|
||||||
export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service';
|
export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service';
|
||||||
|
export { TodoApiService } from './todo-api.service';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|
|
||||||
391
packages/bot-services/src/todo/todo-api.service.ts
Normal file
391
packages/bot-services/src/todo/todo-api.service.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Task, Project, CreateTaskInput, TaskFilter, TodoStats, ParsedTaskInput } from './types';
|
||||||
|
import { parseGermanDateKeyword } from '../shared/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Todo API Service
|
||||||
|
*
|
||||||
|
* Connects to the todo-backend API for task management.
|
||||||
|
* This service is used when the user is logged in and has a valid JWT token.
|
||||||
|
* It provides the same interface as TodoService but uses HTTP calls instead of local storage.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Get tasks for a user (requires JWT token)
|
||||||
|
* const tasks = await todoApiService.getTasks(token);
|
||||||
|
*
|
||||||
|
* // Create a task
|
||||||
|
* const task = await todoApiService.createTask(token, { title: 'Buy groceries' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TodoApiService {
|
||||||
|
private readonly logger = new Logger(TodoApiService.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl = 'http://localhost:3018') {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.logger.log(`Todo API Service initialized with URL: ${baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Task Operations =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending tasks for the user
|
||||||
|
*/
|
||||||
|
async getTasks(token: string, filter?: TaskFilter): Promise<Task[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter?.completed !== undefined) params.append('completed', String(filter.completed));
|
||||||
|
if (filter?.project) params.append('projectId', filter.project);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { tasks?: unknown[] };
|
||||||
|
return this.mapApiTasks(data.tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get tasks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's tasks
|
||||||
|
*/
|
||||||
|
async getTodayTasks(token: string): Promise<Task[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks/today`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { tasks?: any[] };
|
||||||
|
return this.mapApiTasks(data.tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get today tasks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inbox tasks (tasks without a project)
|
||||||
|
*/
|
||||||
|
async getInboxTasks(token: string): Promise<Task[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks/inbox`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { tasks?: any[] };
|
||||||
|
return this.mapApiTasks(data.tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get inbox tasks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming tasks
|
||||||
|
*/
|
||||||
|
async getUpcomingTasks(token: string, days = 7): Promise<Task[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks/upcoming?days=${days}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { tasks?: any[] };
|
||||||
|
return this.mapApiTasks(data.tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get upcoming tasks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new task
|
||||||
|
*/
|
||||||
|
async createTask(token: string, input: CreateTaskInput): Promise<Task | null> {
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title: input.title,
|
||||||
|
priority: this.mapPriorityToApi(input.priority),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dueDate) {
|
||||||
|
body.dueDate = input.dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Project handling would need project ID lookup
|
||||||
|
// For now, we skip project assignment via bot
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as Record<string, unknown>;
|
||||||
|
return this.mapApiTask(data.task);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to create task:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a task
|
||||||
|
*/
|
||||||
|
async completeTask(token: string, taskId: string): Promise<Task | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks/${taskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as Record<string, unknown>;
|
||||||
|
return this.mapApiTask(data.task);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to complete task:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a task
|
||||||
|
*/
|
||||||
|
async deleteTask(token: string, taskId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks/${taskId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to delete task:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Project Operations =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all projects
|
||||||
|
*/
|
||||||
|
async getProjects(token: string): Promise<Project[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/projects`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { projects?: any[] };
|
||||||
|
return (data.projects || []).map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
color: p.color,
|
||||||
|
userId: '', // Not needed for bot
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get projects:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tasks for a specific project
|
||||||
|
*/
|
||||||
|
async getProjectTasks(token: string, projectId: string): Promise<Task[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/tasks?projectId=${projectId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { tasks?: any[] };
|
||||||
|
return this.mapApiTasks(data.tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get project tasks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Stats =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get task statistics
|
||||||
|
*/
|
||||||
|
async getStats(token: string): Promise<TodoStats> {
|
||||||
|
try {
|
||||||
|
// Get all tasks and calculate stats
|
||||||
|
const allTasks = await this.getTasks(token);
|
||||||
|
const todayTasks = await this.getTodayTasks(token);
|
||||||
|
|
||||||
|
const pending = allTasks.filter((t) => !t.completed).length;
|
||||||
|
const completed = allTasks.filter((t) => t.completed).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: allTasks.length,
|
||||||
|
pending,
|
||||||
|
completed,
|
||||||
|
today: todayTasks.length,
|
||||||
|
overdue: 0, // Would need to calculate based on due dates
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to get stats:', error);
|
||||||
|
return { total: 0, pending: 0, completed: 0, today: 0, overdue: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Parsing (reused from TodoService) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse natural language task input
|
||||||
|
*/
|
||||||
|
parseTaskInput(input: string): ParsedTaskInput {
|
||||||
|
let title = input;
|
||||||
|
let priority = 4;
|
||||||
|
let dueDate: string | null = null;
|
||||||
|
let project: string | null = null;
|
||||||
|
|
||||||
|
// Extract priority (!p1, !p2, !p3, !p4 or !, !!, !!!)
|
||||||
|
const priorityMatch = title.match(/!p([1-4])\b/i);
|
||||||
|
if (priorityMatch) {
|
||||||
|
priority = parseInt(priorityMatch[1]);
|
||||||
|
title = title.replace(priorityMatch[0], '').trim();
|
||||||
|
} else {
|
||||||
|
const exclamationMatch = title.match(/(!{1,3})(?:\s|$)/);
|
||||||
|
if (exclamationMatch) {
|
||||||
|
priority = Math.max(1, 4 - exclamationMatch[1].length);
|
||||||
|
title = title.replace(exclamationMatch[0], '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract date (@heute, @morgen, @übermorgen, or date)
|
||||||
|
const dateMatch = title.match(/@(\S+)/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const dateStr = dateMatch[1].toLowerCase();
|
||||||
|
const parsedDate = parseGermanDateKeyword(dateStr);
|
||||||
|
|
||||||
|
if (parsedDate) {
|
||||||
|
dueDate = parsedDate.toISOString().split('T')[0];
|
||||||
|
} else {
|
||||||
|
// Try parsing as date (DD.MM or DD.MM.YYYY)
|
||||||
|
const dateRegex = /(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?/;
|
||||||
|
const match = dateStr.match(dateRegex);
|
||||||
|
if (match) {
|
||||||
|
const day = parseInt(match[1]);
|
||||||
|
const month = parseInt(match[2]) - 1;
|
||||||
|
const year = match[3] ? parseInt(match[3]) : new Date().getFullYear();
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
dueDate = date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title = title.replace(dateMatch[0], '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract project (#projectname)
|
||||||
|
const projectMatch = title.match(/#(\S+)/);
|
||||||
|
if (projectMatch) {
|
||||||
|
project = projectMatch[1];
|
||||||
|
title = title.replace(projectMatch[0], '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: title.trim(), priority, dueDate, project };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helpers =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map API task format to internal Task format
|
||||||
|
*/
|
||||||
|
private mapApiTask(apiTask: any): Task {
|
||||||
|
return {
|
||||||
|
id: apiTask.id,
|
||||||
|
userId: apiTask.userId || '',
|
||||||
|
title: apiTask.title,
|
||||||
|
completed: apiTask.isCompleted || false,
|
||||||
|
priority: this.mapApiPriority(apiTask.priority),
|
||||||
|
dueDate: apiTask.dueDate ? apiTask.dueDate.split('T')[0] : null,
|
||||||
|
project: apiTask.project?.name || null,
|
||||||
|
labels: apiTask.labels?.map((l: any) => l.name) || [],
|
||||||
|
createdAt: apiTask.createdAt,
|
||||||
|
completedAt: apiTask.completedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map array of API tasks
|
||||||
|
*/
|
||||||
|
private mapApiTasks(apiTasks: any[]): Task[] {
|
||||||
|
return apiTasks.map((t) => this.mapApiTask(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map internal priority (1-4) to API priority (urgent/high/medium/low)
|
||||||
|
*/
|
||||||
|
private mapPriorityToApi(priority?: number): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 1:
|
||||||
|
return 'urgent';
|
||||||
|
case 2:
|
||||||
|
return 'high';
|
||||||
|
case 3:
|
||||||
|
return 'medium';
|
||||||
|
case 4:
|
||||||
|
default:
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map API priority to internal priority (1-4)
|
||||||
|
*/
|
||||||
|
private mapApiPriority(apiPriority?: string): number {
|
||||||
|
switch (apiPriority) {
|
||||||
|
case 'urgent':
|
||||||
|
return 1;
|
||||||
|
case 'high':
|
||||||
|
return 2;
|
||||||
|
case 'medium':
|
||||||
|
return 3;
|
||||||
|
case 'low':
|
||||||
|
default:
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { SessionService } from '@manacore/bot-services';
|
import { type SessionService } from '@manacore/bot-services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed session helper for bot-specific session data
|
* Typed session helper for bot-specific session data
|
||||||
|
|
@ -14,8 +14,8 @@ import { SessionService } from '@manacore/bot-services';
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* const session = new SessionHelper<ChatSessionData>(sessionService, matrixUserId);
|
* const session = new SessionHelper<ChatSessionData>(sessionService, matrixUserId);
|
||||||
* session.set('currentConversationId', 'abc123');
|
* await session.set('currentConversationId', 'abc123');
|
||||||
* const convId = session.get('currentConversationId'); // string | null
|
* const convId = await session.get('currentConversationId'); // string | null
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class SessionHelper<T extends Record<string, unknown>> {
|
export class SessionHelper<T extends Record<string, unknown>> {
|
||||||
|
|
@ -27,29 +27,29 @@ export class SessionHelper<T extends Record<string, unknown>> {
|
||||||
/**
|
/**
|
||||||
* Set a session value
|
* Set a session value
|
||||||
*/
|
*/
|
||||||
set<K extends keyof T>(key: K, value: T[K]): void {
|
async set<K extends keyof T>(key: K, value: T[K]): Promise<void> {
|
||||||
this.sessionService.setSessionData(this.userId, key as string, value);
|
await this.sessionService.setSessionData(this.userId, key as string, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a session value
|
* Get a session value
|
||||||
*/
|
*/
|
||||||
get<K extends keyof T>(key: K): T[K] | null {
|
async get<K extends keyof T>(key: K): Promise<T[K] | null> {
|
||||||
return this.sessionService.getSessionData<T[K]>(this.userId, key as string);
|
return this.sessionService.getSessionData<T[K]>(this.userId, key as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a session value
|
* Delete a session value
|
||||||
*/
|
*/
|
||||||
delete<K extends keyof T>(key: K): void {
|
async delete<K extends keyof T>(key: K): Promise<void> {
|
||||||
this.sessionService.setSessionData(this.userId, key as string, null);
|
await this.sessionService.setSessionData(this.userId, key as string, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a session value exists
|
* Check if a session value exists
|
||||||
*/
|
*/
|
||||||
has<K extends keyof T>(key: K): boolean {
|
async has<K extends keyof T>(key: K): Promise<boolean> {
|
||||||
return this.get(key) !== null;
|
return (await this.get(key)) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -62,14 +62,14 @@ export class SessionHelper<T extends Record<string, unknown>> {
|
||||||
/**
|
/**
|
||||||
* Check if user is logged in
|
* Check if user is logged in
|
||||||
*/
|
*/
|
||||||
isLoggedIn(): boolean {
|
async isLoggedIn(): Promise<boolean> {
|
||||||
return this.sessionService.isLoggedIn(this.userId);
|
return this.sessionService.isLoggedIn(this.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get JWT token for API calls
|
* Get JWT token for API calls
|
||||||
*/
|
*/
|
||||||
getToken(): string | null {
|
async getToken(): Promise<string | null> {
|
||||||
return this.sessionService.getToken(this.userId);
|
return this.sessionService.getToken(this.userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1104
pnpm-lock.yaml
generated
1104
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,9 @@ import { AuthController } from './auth.controller';
|
||||||
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
|
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
|
||||||
import { OidcController } from './oidc.controller';
|
import { OidcController } from './oidc.controller';
|
||||||
import { OidcLoginController } from './oidc-login.controller';
|
import { OidcLoginController } from './oidc-login.controller';
|
||||||
|
import { MatrixSessionController } from './matrix-session.controller';
|
||||||
import { BetterAuthService } from './services/better-auth.service';
|
import { BetterAuthService } from './services/better-auth.service';
|
||||||
|
import { MatrixSessionService } from './services/matrix-session.service';
|
||||||
import { ReferralsModule } from '../referrals/referrals.module';
|
import { ReferralsModule } from '../referrals/referrals.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -13,8 +15,9 @@ import { ReferralsModule } from '../referrals/referrals.module';
|
||||||
BetterAuthPassthroughController,
|
BetterAuthPassthroughController,
|
||||||
OidcController,
|
OidcController,
|
||||||
OidcLoginController,
|
OidcLoginController,
|
||||||
|
MatrixSessionController,
|
||||||
],
|
],
|
||||||
providers: [BetterAuthService],
|
providers: [BetterAuthService, MatrixSessionService],
|
||||||
exports: [BetterAuthService],
|
exports: [BetterAuthService, MatrixSessionService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|
|
||||||
208
services/mana-core-auth/src/auth/matrix-session.controller.ts
Normal file
208
services/mana-core-auth/src/auth/matrix-session.controller.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Headers,
|
||||||
|
UnauthorizedException,
|
||||||
|
NotFoundException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { MatrixSessionService } from './services/matrix-session.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for linking a Matrix user to a Mana account
|
||||||
|
*/
|
||||||
|
class LinkMatrixUserDto {
|
||||||
|
/** Matrix user ID (e.g., @user:matrix.mana.how) */
|
||||||
|
matrixUserId!: string;
|
||||||
|
/** User's email (optional, for convenience) */
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix Session Controller
|
||||||
|
*
|
||||||
|
* Provides endpoints for Matrix bot authentication via SSO.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - POST /api/v1/auth/matrix-user-links - Link Matrix user to Mana account
|
||||||
|
* - GET /api/v1/auth/matrix-session/:matrixUserId - Get JWT for linked Matrix user
|
||||||
|
* - DELETE /api/v1/auth/matrix-user-links/:matrixUserId - Unlink Matrix user
|
||||||
|
* - GET /api/v1/auth/matrix-user-links/check/:matrixUserId - Check if user is linked
|
||||||
|
*
|
||||||
|
* Authentication:
|
||||||
|
* - POST /link requires Bearer token (user authenticating)
|
||||||
|
* - GET /session requires X-Service-Key (internal bot service)
|
||||||
|
* - DELETE requires Bearer token (user unlinking)
|
||||||
|
* - GET /check requires X-Service-Key (internal bot service)
|
||||||
|
*/
|
||||||
|
@Controller('api/v1/auth')
|
||||||
|
export class MatrixSessionController {
|
||||||
|
constructor(private readonly matrixSessionService: MatrixSessionService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a Matrix user ID to a Mana account
|
||||||
|
*
|
||||||
|
* Called by bots after successful !login command.
|
||||||
|
* Requires the user's JWT token from login.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* POST /api/v1/auth/matrix-user-links
|
||||||
|
* Authorization: Bearer <jwt-token>
|
||||||
|
* Body: { "matrixUserId": "@user:matrix.mana.how", "email": "user@example.com" }
|
||||||
|
*/
|
||||||
|
@Post('matrix-user-links')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async linkMatrixUser(
|
||||||
|
@Body() dto: LinkMatrixUserDto,
|
||||||
|
@Headers('authorization') authHeader?: string,
|
||||||
|
@Headers('x-service-key') serviceKey?: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
// Two auth methods: Bearer token (from user login) or Service key (from bot)
|
||||||
|
let manaUserId: string;
|
||||||
|
|
||||||
|
if (serviceKey && this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||||
|
// Service key auth - must provide userId in body
|
||||||
|
const bodyWithUserId = dto as LinkMatrixUserDto & { userId?: string };
|
||||||
|
if (!bodyWithUserId.userId) {
|
||||||
|
throw new UnauthorizedException('userId required when using service key');
|
||||||
|
}
|
||||||
|
manaUserId = bodyWithUserId.userId;
|
||||||
|
} else if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
// JWT auth - extract user ID from token
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const payload = this.decodeToken(token);
|
||||||
|
if (!payload?.sub) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
manaUserId = payload.sub;
|
||||||
|
} else {
|
||||||
|
throw new UnauthorizedException('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.matrixUserId) {
|
||||||
|
throw new UnauthorizedException('matrixUserId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.matrixSessionService.linkMatrixUser(dto.matrixUserId, manaUserId, dto.email);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Matrix user ${dto.matrixUserId} linked successfully`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a JWT token for a linked Matrix user
|
||||||
|
*
|
||||||
|
* Called by bots to auto-authenticate users.
|
||||||
|
* Requires service key (internal service authentication).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/auth/matrix-session/@user:matrix.mana.how
|
||||||
|
* X-Service-Key: <service-key>
|
||||||
|
*/
|
||||||
|
@Get('matrix-session/:matrixUserId')
|
||||||
|
async getMatrixSession(
|
||||||
|
@Param('matrixUserId') matrixUserId: string,
|
||||||
|
@Headers('x-service-key') serviceKey?: string
|
||||||
|
): Promise<{ token: string; email: string }> {
|
||||||
|
// Require service key for this endpoint
|
||||||
|
if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||||
|
throw new UnauthorizedException('Valid service key required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.matrixSessionService.getSessionForMatrixUser(
|
||||||
|
decodeURIComponent(matrixUserId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException('No link found for this Matrix user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a Matrix user from a Mana account
|
||||||
|
*
|
||||||
|
* Called when user wants to disconnect their Matrix account.
|
||||||
|
* Requires the user's JWT token.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* DELETE /api/v1/auth/matrix-user-links/@user:matrix.mana.how
|
||||||
|
* Authorization: Bearer <jwt-token>
|
||||||
|
*/
|
||||||
|
@Delete('matrix-user-links/:matrixUserId')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async unlinkMatrixUser(
|
||||||
|
@Param('matrixUserId') matrixUserId: string,
|
||||||
|
@Headers('authorization') authHeader?: string,
|
||||||
|
@Headers('x-service-key') serviceKey?: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
// Allow both Bearer token and service key
|
||||||
|
if (
|
||||||
|
!authHeader?.startsWith('Bearer ') &&
|
||||||
|
!this.matrixSessionService.validateServiceKey(serviceKey || '')
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await this.matrixSessionService.unlinkMatrixUser(
|
||||||
|
decodeURIComponent(matrixUserId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new NotFoundException('No link found for this Matrix user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Matrix user ${matrixUserId} unlinked successfully`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a Matrix user is linked
|
||||||
|
*
|
||||||
|
* Requires service key (internal service authentication).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/v1/auth/matrix-user-links/check/@user:matrix.mana.how
|
||||||
|
* X-Service-Key: <service-key>
|
||||||
|
*/
|
||||||
|
@Get('matrix-user-links/check/:matrixUserId')
|
||||||
|
async checkMatrixLink(
|
||||||
|
@Param('matrixUserId') matrixUserId: string,
|
||||||
|
@Headers('x-service-key') serviceKey?: string
|
||||||
|
): Promise<{ linked: boolean }> {
|
||||||
|
// Require service key for this endpoint
|
||||||
|
if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||||
|
throw new UnauthorizedException('Valid service key required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linked = await this.matrixSessionService.isLinked(decodeURIComponent(matrixUserId));
|
||||||
|
|
||||||
|
return { linked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode JWT token to get payload (without verification)
|
||||||
|
* Note: This is used only to extract user ID after the bot has verified the token
|
||||||
|
*/
|
||||||
|
private decodeToken(token: string): { sub?: string } | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8');
|
||||||
|
return JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1346,6 +1346,66 @@ export class BetterAuthService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Matrix Bot SSO Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token for a specific user (used by Matrix bots)
|
||||||
|
*
|
||||||
|
* This method generates a fresh JWT token for an existing user,
|
||||||
|
* without requiring password authentication. It's used by the
|
||||||
|
* Matrix-SSO-Link system to auto-authenticate bot users.
|
||||||
|
*
|
||||||
|
* @param userId - Mana Core Auth user ID
|
||||||
|
* @returns JWT access token or null if user not found
|
||||||
|
*/
|
||||||
|
async generateTokenForUser(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const db = getDb(this.databaseUrl);
|
||||||
|
const { users } = await import('../../db/schema/auth.schema');
|
||||||
|
const { eq } = await import('drizzle-orm');
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
this.logger.warn('generateTokenForUser: User not found', { userId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT using Better Auth's signJWT
|
||||||
|
const api = this.auth.api as any;
|
||||||
|
|
||||||
|
const jwtResult = await api.signJWT({
|
||||||
|
body: {
|
||||||
|
payload: {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role || 'user',
|
||||||
|
sid: `bot-session-${Date.now()}`, // Pseudo session ID for bots
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = jwtResult?.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.error('generateTokenForUser: signJWT returned empty token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug('Generated token for user via Matrix-SSO-Link', { userId });
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
'generateTokenForUser failed',
|
||||||
|
error instanceof Error ? error.stack : undefined
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// SSO Methods
|
// SSO Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Injectable, Logger, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { getDb } from '../../db/connection';
|
||||||
|
import { matrixUserLinks, users } from '../../db/schema/auth.schema';
|
||||||
|
import { BetterAuthService } from './better-auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix Session Service
|
||||||
|
*
|
||||||
|
* Manages the link between Matrix user IDs and Mana Core Auth accounts.
|
||||||
|
* Enables automatic bot authentication for users who have linked their accounts.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. User logs into a Matrix bot via !login email password
|
||||||
|
* 2. Bot calls POST /api/v1/auth/matrix-user-links to store the link
|
||||||
|
* 3. Later, bot can call GET /api/v1/auth/matrix-session/:matrixUserId
|
||||||
|
* 4. If a link exists, a fresh JWT token is returned
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MatrixSessionService {
|
||||||
|
private readonly logger = new Logger(MatrixSessionService.name);
|
||||||
|
private readonly db;
|
||||||
|
private readonly serviceKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly betterAuthService: BetterAuthService
|
||||||
|
) {
|
||||||
|
const databaseUrl = this.configService.get<string>('DATABASE_URL');
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is required');
|
||||||
|
}
|
||||||
|
this.db = getDb(databaseUrl);
|
||||||
|
this.serviceKey = this.configService.get<string>('MANA_CORE_SERVICE_KEY', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate service key from X-Service-Key header
|
||||||
|
*/
|
||||||
|
validateServiceKey(providedKey: string): boolean {
|
||||||
|
if (!this.serviceKey) {
|
||||||
|
this.logger.warn('MANA_CORE_SERVICE_KEY not configured - service key validation disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return providedKey === this.serviceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a link between a Matrix user ID and a Mana user
|
||||||
|
*
|
||||||
|
* @param matrixUserId - Matrix user ID (e.g., @user:matrix.mana.how)
|
||||||
|
* @param manaUserId - Mana Core Auth user ID
|
||||||
|
* @param email - User's email (optional, for convenience)
|
||||||
|
*/
|
||||||
|
async linkMatrixUser(matrixUserId: string, manaUserId: string, email?: string): Promise<void> {
|
||||||
|
// Check if link already exists
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(matrixUserLinks)
|
||||||
|
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing link
|
||||||
|
await this.db
|
||||||
|
.update(matrixUserLinks)
|
||||||
|
.set({
|
||||||
|
userId: manaUserId,
|
||||||
|
email,
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||||
|
|
||||||
|
this.logger.log(`Updated Matrix link: ${matrixUserId} -> ${manaUserId}`);
|
||||||
|
} else {
|
||||||
|
// Create new link
|
||||||
|
await this.db.insert(matrixUserLinks).values({
|
||||||
|
id: nanoid(),
|
||||||
|
matrixUserId,
|
||||||
|
userId: manaUserId,
|
||||||
|
email,
|
||||||
|
linkedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Created Matrix link: ${matrixUserId} -> ${manaUserId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a link for a Matrix user ID
|
||||||
|
*/
|
||||||
|
async unlinkMatrixUser(matrixUserId: string): Promise<boolean> {
|
||||||
|
const result = await this.db
|
||||||
|
.delete(matrixUserLinks)
|
||||||
|
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
this.logger.log(`Removed Matrix link: ${matrixUserId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a fresh JWT token for a linked Matrix user
|
||||||
|
*
|
||||||
|
* @param matrixUserId - Matrix user ID
|
||||||
|
* @returns JWT token or null if no link exists
|
||||||
|
*/
|
||||||
|
async getSessionForMatrixUser(
|
||||||
|
matrixUserId: string
|
||||||
|
): Promise<{ token: string; email: string } | null> {
|
||||||
|
// Find the link
|
||||||
|
const links = await this.db
|
||||||
|
.select({
|
||||||
|
userId: matrixUserLinks.userId,
|
||||||
|
email: matrixUserLinks.email,
|
||||||
|
})
|
||||||
|
.from(matrixUserLinks)
|
||||||
|
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (links.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = links[0];
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
await this.db
|
||||||
|
.update(matrixUserLinks)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||||
|
|
||||||
|
// Get user details if email not stored
|
||||||
|
let email = link.email;
|
||||||
|
if (!email) {
|
||||||
|
const userRecords = await this.db
|
||||||
|
.select({ email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, link.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userRecords.length > 0) {
|
||||||
|
email = userRecords[0].email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a fresh JWT token for this user
|
||||||
|
const token = await this.betterAuthService.generateTokenForUser(link.userId);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.error(`Failed to generate token for user ${link.userId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Generated token for Matrix user ${matrixUserId}`);
|
||||||
|
return { token, email: email || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Matrix links for a Mana user
|
||||||
|
*/
|
||||||
|
async getLinksForUser(manaUserId: string): Promise<{ matrixUserId: string; linkedAt: Date }[]> {
|
||||||
|
const links = await this.db
|
||||||
|
.select({
|
||||||
|
matrixUserId: matrixUserLinks.matrixUserId,
|
||||||
|
linkedAt: matrixUserLinks.linkedAt,
|
||||||
|
})
|
||||||
|
.from(matrixUserLinks)
|
||||||
|
.where(eq(matrixUserLinks.userId, manaUserId));
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a Matrix user is linked
|
||||||
|
*/
|
||||||
|
async isLinked(matrixUserId: string): Promise<boolean> {
|
||||||
|
const links = await this.db
|
||||||
|
.select({ id: matrixUserLinks.id })
|
||||||
|
.from(matrixUserLinks)
|
||||||
|
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return links.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,26 @@ export const oauthConsents = authSchema.table('oauth_consents', {
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Matrix User Links table (for Bot SSO)
|
||||||
|
// Links Matrix user IDs to Mana user accounts for automatic bot authentication
|
||||||
|
export const matrixUserLinks = authSchema.table(
|
||||||
|
'matrix_user_links',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(), // nanoid
|
||||||
|
matrixUserId: text('matrix_user_id').unique().notNull(), // e.g., @user:matrix.mana.how
|
||||||
|
userId: text('user_id')
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||||
|
// Optional: store email for convenience (denormalized from users table)
|
||||||
|
email: text('email'),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index('matrix_user_links_user_id_idx').on(table.userId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// User settings table (synced across all apps)
|
// User settings table (synced across all apps)
|
||||||
export const userSettings = authSchema.table('user_settings', {
|
export const userSettings = authSchema.table('user_settings', {
|
||||||
userId: text('user_id')
|
userId: text('user_id')
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,35 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { MatrixService } from './matrix.service';
|
import { MatrixService } from './matrix.service';
|
||||||
import { CalendarModule } from '../calendar/calendar.module';
|
import { CalendarModule } from '../calendar/calendar.module';
|
||||||
import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services';
|
import {
|
||||||
|
TranscriptionModule,
|
||||||
|
SessionModule,
|
||||||
|
CreditModule,
|
||||||
|
CalendarApiService,
|
||||||
|
} from '@manacore/bot-services';
|
||||||
|
|
||||||
|
// Factory provider for CalendarApiService
|
||||||
|
const calendarApiServiceProvider = {
|
||||||
|
provide: CalendarApiService,
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const baseUrl = configService.get<string>('CALENDAR_BACKEND_URL', 'http://localhost:3014');
|
||||||
|
return new CalendarApiService(baseUrl);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
};
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
TranscriptionModule.register({
|
TranscriptionModule.register({
|
||||||
sttUrl: process.env.STT_URL || 'http://localhost:3020',
|
sttUrl: process.env.STT_URL || 'http://localhost:3020',
|
||||||
}),
|
}),
|
||||||
SessionModule.forRoot(),
|
SessionModule.forRoot({ storageMode: 'redis' }),
|
||||||
CreditModule.forRoot(),
|
CreditModule.forRoot(),
|
||||||
],
|
],
|
||||||
providers: [MatrixService],
|
providers: [MatrixService, calendarApiServiceProvider],
|
||||||
exports: [MatrixService],
|
exports: [MatrixService],
|
||||||
})
|
})
|
||||||
export class BotModule {}
|
export class BotModule {}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ import {
|
||||||
KeywordCommandDetector,
|
KeywordCommandDetector,
|
||||||
COMMON_KEYWORDS,
|
COMMON_KEYWORDS,
|
||||||
} from '@manacore/matrix-bot-common';
|
} from '@manacore/matrix-bot-common';
|
||||||
import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services';
|
import {
|
||||||
|
TranscriptionService,
|
||||||
|
SessionService,
|
||||||
|
CreditService,
|
||||||
|
CalendarApiService,
|
||||||
|
CalendarEvent as ApiCalendarEvent,
|
||||||
|
} from '@manacore/bot-services';
|
||||||
import { CalendarService, CalendarEvent } from '../calendar/calendar.service';
|
import { CalendarService, CalendarEvent } from '../calendar/calendar.service';
|
||||||
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
||||||
|
|
||||||
|
|
@ -19,7 +25,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
[
|
[
|
||||||
...COMMON_KEYWORDS,
|
...COMMON_KEYWORDS,
|
||||||
{ keywords: ['was kannst du'], command: 'help' },
|
{ keywords: ['was kannst du'], command: 'help' },
|
||||||
{ keywords: ['was steht heute an', 'termine heute', 'heute termine', "today's events"], command: 'today' },
|
{
|
||||||
|
keywords: ['was steht heute an', 'termine heute', 'heute termine', "today's events"],
|
||||||
|
command: 'today',
|
||||||
|
},
|
||||||
{ keywords: ['termine morgen', 'morgen termine', 'was ist morgen'], command: 'tomorrow' },
|
{ keywords: ['termine morgen', 'morgen termine', 'was ist morgen'], command: 'tomorrow' },
|
||||||
{ keywords: ['diese woche', 'wochenübersicht', 'week', 'woche'], command: 'week' },
|
{ keywords: ['diese woche', 'wochenübersicht', 'week', 'woche'], command: 'week' },
|
||||||
{ keywords: ['zeige kalender', 'meine kalender', 'calendars'], command: 'calendars' },
|
{ keywords: ['zeige kalender', 'meine kalender', 'calendars'], command: 'calendars' },
|
||||||
|
|
@ -32,12 +41,39 @@ export class MatrixService extends BaseMatrixService {
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private readonly transcriptionService: TranscriptionService,
|
private readonly transcriptionService: TranscriptionService,
|
||||||
private calendarService: CalendarService,
|
private calendarService: CalendarService,
|
||||||
|
private calendarApiService: CalendarApiService,
|
||||||
private sessionService: SessionService,
|
private sessionService: SessionService,
|
||||||
private creditService: CreditService
|
private creditService: CreditService
|
||||||
) {
|
) {
|
||||||
super(configService);
|
super(configService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is logged in and has a valid token for API access
|
||||||
|
*/
|
||||||
|
private async getToken(userId: string): Promise<string | null> {
|
||||||
|
return this.sessionService.getToken(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize event from API or local format to common format
|
||||||
|
*/
|
||||||
|
private normalizeEvent(event: CalendarEvent | ApiCalendarEvent): CalendarEvent {
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description || null,
|
||||||
|
location: event.location || null,
|
||||||
|
startTime: event.startTime,
|
||||||
|
endTime: event.endTime,
|
||||||
|
isAllDay: event.isAllDay,
|
||||||
|
calendarId: event.calendarId || '',
|
||||||
|
calendarName: (event as CalendarEvent).calendarName || 'Kalender',
|
||||||
|
createdAt: event.createdAt || new Date().toISOString(),
|
||||||
|
userId: event.userId || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected override async handleAudioMessage(
|
protected override async handleAudioMessage(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
event: MatrixRoomEvent,
|
event: MatrixRoomEvent,
|
||||||
|
|
@ -64,9 +100,11 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
protected getConfig(): MatrixBotConfig {
|
protected getConfig(): MatrixBotConfig {
|
||||||
return {
|
return {
|
||||||
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
|
homeserverUrl:
|
||||||
|
this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
|
||||||
accessToken: this.configService.get<string>('matrix.accessToken') || '',
|
accessToken: this.configService.get<string>('matrix.accessToken') || '',
|
||||||
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
|
storagePath:
|
||||||
|
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
|
||||||
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
|
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +214,17 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleTodayEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleTodayEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const events = await this.calendarService.getTodayEvents(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let events: CalendarEvent[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -187,12 +235,31 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatEventList('📅 **Termine heute:**', events);
|
let response = this.formatEventList('📅 **Termine heute:**', events);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleTomorrowEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleTomorrowEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const events = await this.calendarService.getTomorrowEvents(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let events: CalendarEvent[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -203,12 +270,25 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatEventList('📅 **Termine morgen:**', events);
|
let response = this.formatEventList('📅 **Termine morgen:**', events);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleWeekEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleWeekEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const events = await this.calendarService.getWeekEvents(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let events: CalendarEvent[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -219,12 +299,25 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatEventList('📅 **Termine diese Woche:**', events);
|
let response = this.formatEventList('📅 **Termine diese Woche:**', events);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleUpcomingEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleUpcomingEvents(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const events = await this.calendarService.getUpcomingEvents(userId, 14);
|
const token = await this.getToken(userId);
|
||||||
|
let events: CalendarEvent[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -235,11 +328,19 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatEventList('📅 **Anstehende Termine:**', events);
|
let response = this.formatEventList('📅 **Anstehende Termine:**', events);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCreateEvent(roomId: string, event: MatrixRoomEvent, userId: string, input: string) {
|
private async handleCreateEvent(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
input: string
|
||||||
|
) {
|
||||||
if (!input.trim()) {
|
if (!input.trim()) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
roomId,
|
roomId,
|
||||||
|
|
@ -249,8 +350,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
const token = await this.getToken(userId);
|
||||||
|
|
||||||
// Validate credits if user is logged in
|
// Validate credits if user is logged in
|
||||||
const token = this.sessionService.getToken(userId);
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const validation = await this.creditService.validateCredits(token, EVENT_CREATE_CREDITS);
|
const validation = await this.creditService.validateCredits(token, EVENT_CREATE_CREDITS);
|
||||||
if (!validation.hasCredits) {
|
if (!validation.hasCredits) {
|
||||||
|
|
@ -264,45 +367,87 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, startTime, endTime, isAllDay } = this.calendarService.parseEventInput(input);
|
let calendarEvent: CalendarEvent;
|
||||||
|
|
||||||
if (!startTime || !endTime) {
|
if (token) {
|
||||||
await this.sendReply(
|
// Use API service
|
||||||
roomId,
|
const { title, startTime, endTime, isAllDay, location } =
|
||||||
event,
|
this.calendarApiService.parseEventInput(input);
|
||||||
'❌ 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) {
|
if (!startTime || !endTime) {
|
||||||
await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.');
|
await this.sendReply(
|
||||||
return;
|
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`'
|
||||||
const calendarEvent = await this.calendarService.createEvent(
|
);
|
||||||
userId,
|
return;
|
||||||
title,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
{
|
|
||||||
isAllDay,
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const timeStr = this.calendarService.formatEventTime(calendarEvent);
|
const timeStr = this.calendarService.formatEventTime(calendarEvent);
|
||||||
let response = `✅ Termin erstellt: **${title}**\n📆 ${timeStr}`;
|
let response = `✅ Termin erstellt: **${calendarEvent.title}**\n📆 ${timeStr}`;
|
||||||
|
|
||||||
// Show credit deduction if logged in
|
// Show credit deduction and sync status if logged in
|
||||||
if (token) {
|
if (token) {
|
||||||
const balance = await this.creditService.getBalance(token);
|
const balance = await this.creditService.getBalance(token);
|
||||||
response += `\n⚡ -${EVENT_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
response += `\n⚡ -${EVENT_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||||
|
response += '\n🔄 Synchronisiert mit calendar-backend';
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEventDetails(roomId: string, event: MatrixRoomEvent, userId: string, args: string) {
|
private async handleEventDetails(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
args: string
|
||||||
|
) {
|
||||||
const eventNumber = parseInt(args.trim());
|
const eventNumber = parseInt(args.trim());
|
||||||
|
|
||||||
if (isNaN(eventNumber) || eventNumber < 1) {
|
if (isNaN(eventNumber) || eventNumber < 1) {
|
||||||
|
|
@ -314,7 +459,19 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarEvent = await this.calendarService.getEventByIndex(userId, eventNumber);
|
const token = await this.getToken(userId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (!calendarEvent) {
|
if (!calendarEvent) {
|
||||||
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
|
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
|
||||||
|
|
@ -334,10 +491,19 @@ export class MatrixService extends BaseMatrixService {
|
||||||
response += `\n📝 ${calendarEvent.description}`;
|
response += `\n📝 ${calendarEvent.description}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDeleteEvent(roomId: string, event: MatrixRoomEvent, userId: string, args: string) {
|
private async handleDeleteEvent(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
args: string
|
||||||
|
) {
|
||||||
const eventNumber = parseInt(args.trim());
|
const eventNumber = parseInt(args.trim());
|
||||||
|
|
||||||
if (isNaN(eventNumber) || eventNumber < 1) {
|
if (isNaN(eventNumber) || eventNumber < 1) {
|
||||||
|
|
@ -349,34 +515,80 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedEvent = await this.calendarService.deleteEvent(userId, eventNumber);
|
const token = await this.getToken(userId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
deletedEvent = await this.calendarService.deleteEvent(userId, eventNumber);
|
||||||
|
}
|
||||||
|
|
||||||
if (!deletedEvent) {
|
if (!deletedEvent) {
|
||||||
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
|
await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, `🗑️ Gelöscht: ${deletedEvent.title}`);
|
let response = `🗑️ Gelöscht: ${deletedEvent.title}`;
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCalendars(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleCalendars(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const calendars = await this.calendarService.getCalendars(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let calendars: { name: string }[];
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Use API service
|
||||||
|
calendars = await this.calendarApiService.getCalendars(token);
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
calendars = await this.calendarService.getCalendars(userId);
|
||||||
|
}
|
||||||
|
|
||||||
let response = '📁 **Deine Kalender:**\n\n';
|
let response = '📁 **Deine Kalender:**\n\n';
|
||||||
for (const calendar of calendars) {
|
for (const calendar of calendars) {
|
||||||
response += `• ${calendar.name}\n`;
|
response += `• ${calendar.name}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
response += '\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const events = await this.calendarService.getUpcomingEvents(userId, 7);
|
const token = await this.getToken(userId);
|
||||||
const todayEvents = await this.calendarService.getTodayEvents(userId);
|
const session = await this.sessionService.getSession(userId);
|
||||||
|
|
||||||
// Check login status and credits
|
let todayEvents: CalendarEvent[];
|
||||||
const token = this.sessionService.getToken(userId);
|
let events: CalendarEvent[];
|
||||||
const session = this.sessionService.getSession(userId);
|
|
||||||
|
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`;
|
let response = `📊 **Status**\n\n`;
|
||||||
response += `• Termine heute: ${todayEvents.length}\n`;
|
response += `• Termine heute: ${todayEvents.length}\n`;
|
||||||
|
|
@ -388,9 +600,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
response += `⚡ Credits: ${balance.balance.toFixed(2)}\n\n`;
|
response += `⚡ Credits: ${balance.balance.toFixed(2)}\n\n`;
|
||||||
} else {
|
} else {
|
||||||
response += `👤 Nicht angemeldet\n`;
|
response += `👤 Nicht angemeldet\n`;
|
||||||
response += `💡 Login: \`!login email passwort\`\n\n`;
|
response += `💡 Login: \`!login email passwort\` für Synchronisation mit calendar-web\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response += `${syncStatus}\n`;
|
||||||
response += `Bot: ✅ Online`;
|
response += `Bot: ✅ Online`;
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
|
|
@ -415,7 +628,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.sessionService.getToken(userId);
|
const token = await this.sessionService.getToken(userId);
|
||||||
if (token) {
|
if (token) {
|
||||||
const balance = await this.creditService.getBalance(token);
|
const balance = await this.creditService.getBalance(token);
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -429,7 +642,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleLogout(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleLogout(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const session = this.sessionService.getSession(userId);
|
const session = await this.sessionService.getSession(userId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
await this.sendReply(roomId, event, '❌ Du bist nicht angemeldet.');
|
await this.sendReply(roomId, event, '❌ Du bist nicht angemeldet.');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,11 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
protected getConfig(): MatrixBotConfig {
|
protected getConfig(): MatrixBotConfig {
|
||||||
return {
|
return {
|
||||||
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
|
homeserverUrl:
|
||||||
|
this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
|
||||||
accessToken: this.configService.get<string>('matrix.accessToken') || '',
|
accessToken: this.configService.get<string>('matrix.accessToken') || '',
|
||||||
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
|
storagePath:
|
||||||
|
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
|
||||||
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
|
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -207,8 +209,8 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireAuth(sender: string): string {
|
private async requireAuth(sender: string): Promise<string> {
|
||||||
const token = this.sessionService.getToken(sender);
|
const token = await this.sessionService.getToken(sender);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Nicht angemeldet. Nutze <code>!login email passwort</code>');
|
throw new Error('Nicht angemeldet. Nutze <code>!login email passwort</code>');
|
||||||
}
|
}
|
||||||
|
|
@ -226,12 +228,18 @@ export class MatrixService extends BaseMatrixService {
|
||||||
const result = await this.sessionService.login(sender, email, password);
|
const result = await this.sessionService.login(sender, email, password);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const token = this.sessionService.getToken(sender);
|
const token = await this.sessionService.getToken(sender);
|
||||||
if (token) {
|
if (token) {
|
||||||
const balance = await this.creditService.getBalance(token);
|
const balance = await this.creditService.getBalance(token);
|
||||||
await this.sendMessage(roomId, `<p>✅ Erfolgreich angemeldet als <strong>${email}</strong><br/>⚡ Credits: ${balance.balance.toFixed(2)}</p>`);
|
await this.sendMessage(
|
||||||
|
roomId,
|
||||||
|
`<p>✅ Erfolgreich angemeldet als <strong>${email}</strong><br/>⚡ Credits: ${balance.balance.toFixed(2)}</p>`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.sendMessage(roomId, `<p>✅ Erfolgreich angemeldet als <strong>${email}</strong></p>`);
|
await this.sendMessage(
|
||||||
|
roomId,
|
||||||
|
`<p>✅ Erfolgreich angemeldet als <strong>${email}</strong></p>`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.sendMessage(roomId, `<p>❌ Login fehlgeschlagen: ${result.error}</p>`);
|
await this.sendMessage(roomId, `<p>❌ Login fehlgeschlagen: ${result.error}</p>`);
|
||||||
|
|
@ -240,10 +248,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
private async handleStatus(roomId: string, sender: string) {
|
private async handleStatus(roomId: string, sender: string) {
|
||||||
const backendOk = await this.questionsService.checkHealth();
|
const backendOk = await this.questionsService.checkHealth();
|
||||||
const loggedIn = this.sessionService.isLoggedIn(sender);
|
const loggedIn = await this.sessionService.isLoggedIn(sender);
|
||||||
const sessions = this.sessionService.getSessionCount();
|
const sessions = await this.sessionService.getSessionCount();
|
||||||
const session = this.sessionService.getSession(sender);
|
const session = await this.sessionService.getSession(sender);
|
||||||
const token = this.sessionService.getToken(sender);
|
const token = await this.sessionService.getToken(sender);
|
||||||
|
|
||||||
let statusHtml = `<h3>Questions Bot Status</h3><ul>`;
|
let statusHtml = `<h3>Questions Bot Status</h3><ul>`;
|
||||||
statusHtml += `<li>Backend: ${backendOk ? '✅ Online' : '❌ Offline'}</li>`;
|
statusHtml += `<li>Backend: ${backendOk ? '✅ Online' : '❌ Offline'}</li>`;
|
||||||
|
|
@ -264,7 +272,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
// Question handlers
|
// Question handlers
|
||||||
private async handleListQuestions(roomId: string, sender: string, statusFilter?: string) {
|
private async handleListQuestions(roomId: string, sender: string, statusFilter?: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
|
|
||||||
const options: Record<string, string> = {};
|
const options: Record<string, string> = {};
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
|
|
@ -306,13 +314,14 @@ export class MatrixService extends BaseMatrixService {
|
||||||
html += `<li>${status} ${priority}<strong>${q.title}</strong></li>`;
|
html += `<li>${status} ${priority}<strong>${q.title}</strong></li>`;
|
||||||
}
|
}
|
||||||
html += '</ol>';
|
html += '</ol>';
|
||||||
html += '<p><em>Nutze <code>!frage [nr]</code> fuer Details oder <code>!recherche [nr]</code></em></p>';
|
html +=
|
||||||
|
'<p><em>Nutze <code>!frage [nr]</code> fuer Details oder <code>!recherche [nr]</code></em></p>';
|
||||||
|
|
||||||
await this.sendMessage(roomId, html);
|
await this.sendMessage(roomId, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleQuestionDetails(roomId: string, sender: string, numberStr: string) {
|
private async handleQuestionDetails(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -339,7 +348,8 @@ export class MatrixService extends BaseMatrixService {
|
||||||
if (q.tags?.length) html += `<li>Tags: ${q.tags.join(', ')}</li>`;
|
if (q.tags?.length) html += `<li>Tags: ${q.tags.join(', ')}</li>`;
|
||||||
if (q.category) html += `<li>Kategorie: ${q.category}</li>`;
|
if (q.category) html += `<li>Kategorie: ${q.category}</li>`;
|
||||||
html += `<li>Erstellt: ${new Date(q.createdAt).toLocaleDateString('de-DE')}</li>`;
|
html += `<li>Erstellt: ${new Date(q.createdAt).toLocaleDateString('de-DE')}</li>`;
|
||||||
if (q.answeredAt) html += `<li>Beantwortet: ${new Date(q.answeredAt).toLocaleDateString('de-DE')}</li>`;
|
if (q.answeredAt)
|
||||||
|
html += `<li>Beantwortet: ${new Date(q.answeredAt).toLocaleDateString('de-DE')}</li>`;
|
||||||
html += '</ul>';
|
html += '</ul>';
|
||||||
|
|
||||||
html += `<p><em>Nutze <code>!recherche ${numberStr}</code> um eine Recherche zu starten</em></p>`;
|
html += `<p><em>Nutze <code>!recherche ${numberStr}</code> um eine Recherche zu starten</em></p>`;
|
||||||
|
|
@ -353,7 +363,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const result = await this.questionsService.createQuestion(token, title);
|
const result = await this.questionsService.createQuestion(token, title);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -370,7 +380,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDeleteQuestion(roomId: string, sender: string, numberStr: string) {
|
private async handleDeleteQuestion(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -390,7 +400,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleArchiveQuestion(roomId: string, sender: string, numberStr: string) {
|
private async handleArchiveQuestion(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -409,8 +419,13 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Research handlers
|
// Research handlers
|
||||||
private async handleStartResearch(roomId: string, sender: string, numberStr: string, depthStr?: string) {
|
private async handleStartResearch(
|
||||||
const token = this.requireAuth(sender);
|
roomId: string,
|
||||||
|
sender: string,
|
||||||
|
numberStr: string,
|
||||||
|
depthStr?: string
|
||||||
|
) {
|
||||||
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -428,7 +443,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
};
|
};
|
||||||
const depth = depthMap[depthStr?.toLowerCase() || ''] || 'quick';
|
const depth = depthMap[depthStr?.toLowerCase() || ''] || 'quick';
|
||||||
|
|
||||||
await this.sendMessage(roomId, `<p>Starte ${depth}-Recherche fuer: <strong>${question.title}</strong>...</p>`);
|
await this.sendMessage(
|
||||||
|
roomId,
|
||||||
|
`<p>Starte ${depth}-Recherche fuer: <strong>${question.title}</strong>...</p>`
|
||||||
|
);
|
||||||
|
|
||||||
const result = await this.questionsService.startResearch(token, question.id, depth);
|
const result = await this.questionsService.startResearch(token, question.id, depth);
|
||||||
|
|
||||||
|
|
@ -466,7 +484,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleResearchResult(roomId: string, sender: string, numberStr: string) {
|
private async handleResearchResult(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -511,7 +529,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSources(roomId: string, sender: string, numberStr: string) {
|
private async handleSources(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -535,7 +553,9 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
let html = `<h3>Quellen fuer: ${question.title}</h3><ol>`;
|
let html = `<h3>Quellen fuer: ${question.title}</h3><ol>`;
|
||||||
for (const source of sources.slice(0, 10)) {
|
for (const source of sources.slice(0, 10)) {
|
||||||
const relevance = source.relevanceScore ? ` (${Math.round(source.relevanceScore * 100)}%)` : '';
|
const relevance = source.relevanceScore
|
||||||
|
? ` (${Math.round(source.relevanceScore * 100)}%)`
|
||||||
|
: '';
|
||||||
html += `<li><a href="${source.url}">${source.title}</a>${relevance}<br/><em>${source.domain}</em></li>`;
|
html += `<li><a href="${source.url}">${source.title}</a>${relevance}<br/><em>${source.domain}</em></li>`;
|
||||||
}
|
}
|
||||||
html += '</ol>';
|
html += '</ol>';
|
||||||
|
|
@ -549,7 +569,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
// Answer handlers
|
// Answer handlers
|
||||||
private async handleAnswer(roomId: string, sender: string, numberStr: string) {
|
private async handleAnswer(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const question = this.getQuestionByNumber(sender, numberStr);
|
const question = this.getQuestionByNumber(sender, numberStr);
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -579,7 +599,9 @@ export class MatrixService extends BaseMatrixService {
|
||||||
const answer = answers[0];
|
const answer = answers[0];
|
||||||
const accepted = answer.isAccepted ? ' ✅' : '';
|
const accepted = answer.isAccepted ? ' ✅' : '';
|
||||||
const rating = answer.rating ? ` (${answer.rating}/5 Sterne)` : '';
|
const rating = answer.rating ? ` (${answer.rating}/5 Sterne)` : '';
|
||||||
const confidence = answer.confidence ? ` [${Math.round(answer.confidence * 100)}% Konfidenz]` : '';
|
const confidence = answer.confidence
|
||||||
|
? ` [${Math.round(answer.confidence * 100)}% Konfidenz]`
|
||||||
|
: '';
|
||||||
|
|
||||||
let html = `<h3>Antwort${accepted}${rating}</h3>`;
|
let html = `<h3>Antwort${accepted}${rating}</h3>`;
|
||||||
html += `<p><em>Model: ${answer.modelId}${confidence}</em></p>`;
|
html += `<p><em>Model: ${answer.modelId}${confidence}</em></p>`;
|
||||||
|
|
@ -599,11 +621,19 @@ export class MatrixService extends BaseMatrixService {
|
||||||
await this.sendMessage(roomId, html);
|
await this.sendMessage(roomId, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRateAnswer(roomId: string, sender: string, numberStr: string, ratingStr: string) {
|
private async handleRateAnswer(
|
||||||
const token = this.requireAuth(sender);
|
roomId: string,
|
||||||
|
sender: string,
|
||||||
|
numberStr: string,
|
||||||
|
ratingStr: string
|
||||||
|
) {
|
||||||
|
const token = await this.requireAuth(sender);
|
||||||
|
|
||||||
if (!this.answersMapper.hasList(sender)) {
|
if (!this.answersMapper.hasList(sender)) {
|
||||||
await this.sendMessage(roomId, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
|
await this.sendMessage(
|
||||||
|
roomId,
|
||||||
|
'<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,10 +660,13 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAcceptAnswer(roomId: string, sender: string, numberStr: string) {
|
private async handleAcceptAnswer(roomId: string, sender: string, numberStr: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
|
|
||||||
if (!this.answersMapper.hasList(sender)) {
|
if (!this.answersMapper.hasList(sender)) {
|
||||||
await this.sendMessage(roomId, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
|
await this.sendMessage(
|
||||||
|
roomId,
|
||||||
|
'<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,7 +688,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
// Collection handlers
|
// Collection handlers
|
||||||
private async handleListCollections(roomId: string, sender: string) {
|
private async handleListCollections(roomId: string, sender: string) {
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const result = await this.questionsService.getCollections(token);
|
const result = await this.questionsService.getCollections(token);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -691,7 +724,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const result = await this.questionsService.createCollection(token, name);
|
const result = await this.questionsService.createCollection(token, name);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -700,7 +733,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.collectionsMapper.clearList(sender);
|
this.collectionsMapper.clearList(sender);
|
||||||
await this.sendMessage(roomId, `<p>Sammlung <strong>${result.data!.name}</strong> erstellt.</p>`);
|
await this.sendMessage(
|
||||||
|
roomId,
|
||||||
|
`<p>Sammlung <strong>${result.data!.name}</strong> erstellt.</p>`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search handler
|
// Search handler
|
||||||
|
|
@ -710,7 +746,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.requireAuth(sender);
|
const token = await this.requireAuth(sender);
|
||||||
const result = await this.questionsService.getQuestions(token, { search: query });
|
const result = await this.questionsService.getQuestions(token, { search: query });
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|
@ -745,10 +781,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
private getStatusEmoji(status: string): string {
|
private getStatusEmoji(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
open: '❓', // Question mark
|
open: '❓', // Question mark
|
||||||
researching: '🔍', // Magnifying glass
|
researching: '🔍', // Magnifying glass
|
||||||
answered: '✅', // Check mark
|
answered: '✅', // Check mark
|
||||||
archived: '📦', // Package
|
archived: '📦', // Package
|
||||||
};
|
};
|
||||||
return map[status] || '❓';
|
return map[status] || '❓';
|
||||||
}
|
}
|
||||||
|
|
@ -765,8 +801,8 @@ export class MatrixService extends BaseMatrixService {
|
||||||
|
|
||||||
private getPriorityIndicator(priority: string): string {
|
private getPriorityIndicator(priority: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
urgent: '🔴 ', // Red circle
|
urgent: '🔴 ', // Red circle
|
||||||
high: '🟠 ', // Orange circle
|
high: '🟠 ', // Orange circle
|
||||||
normal: '',
|
normal: '',
|
||||||
low: '',
|
low: '',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -206,11 +206,13 @@ Daten von Umami Analytics (self-hosted).`;
|
||||||
const result = await this.sessionService.login(sender, email, password);
|
const result = await this.sessionService.login(sender, email, password);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const token = this.sessionService.getToken(sender);
|
const token = await this.sessionService.getToken(sender);
|
||||||
if (token) {
|
if (token) {
|
||||||
const balance = await this.creditService.getBalance(token);
|
const balance = await this.creditService.getBalance(token);
|
||||||
await this.sendMessage(roomId,
|
await this.sendMessage(
|
||||||
`✅ Erfolgreich angemeldet als **${email}**\n⚡ Credits: ${balance.balance.toFixed(2)}`);
|
roomId,
|
||||||
|
`✅ Erfolgreich angemeldet als **${email}**\n⚡ Credits: ${balance.balance.toFixed(2)}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.sendMessage(roomId, `✅ Erfolgreich angemeldet als **${email}**`);
|
await this.sendMessage(roomId, `✅ Erfolgreich angemeldet als **${email}**`);
|
||||||
}
|
}
|
||||||
|
|
@ -220,14 +222,14 @@ Daten von Umami Analytics (self-hosted).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleLogout(roomId: string, sender: string) {
|
private async handleLogout(roomId: string, sender: string) {
|
||||||
this.sessionService.logout(sender);
|
await this.sessionService.logout(sender);
|
||||||
await this.sendMessage(roomId, '👋 Erfolgreich abgemeldet.');
|
await this.sendMessage(roomId, '👋 Erfolgreich abgemeldet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleStatus(roomId: string, sender: string) {
|
private async handleStatus(roomId: string, sender: string) {
|
||||||
const loggedIn = this.sessionService.isLoggedIn(sender);
|
const loggedIn = await this.sessionService.isLoggedIn(sender);
|
||||||
const session = this.sessionService.getSession(sender);
|
const session = await this.sessionService.getSession(sender);
|
||||||
const token = this.sessionService.getToken(sender);
|
const token = await this.sessionService.getToken(sender);
|
||||||
|
|
||||||
let response = '**📊 Stats Bot Status**\n\n';
|
let response = '**📊 Stats Bot Status**\n\n';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { MatrixService } from './matrix.service';
|
import { MatrixService } from './matrix.service';
|
||||||
import { TodoModule } from '../todo/todo.module';
|
import { TodoModule } from '../todo/todo.module';
|
||||||
import { TranscriptionModule, SessionModule, CreditModule } from '@manacore/bot-services';
|
import {
|
||||||
|
TranscriptionModule,
|
||||||
|
SessionModule,
|
||||||
|
CreditModule,
|
||||||
|
TodoApiService,
|
||||||
|
} from '@manacore/bot-services';
|
||||||
|
|
||||||
|
// Factory provider for TodoApiService
|
||||||
|
const todoApiServiceProvider = {
|
||||||
|
provide: TodoApiService,
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const baseUrl = configService.get<string>('TODO_BACKEND_URL', 'http://localhost:3018');
|
||||||
|
return new TodoApiService(baseUrl);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
};
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
TodoModule,
|
TodoModule,
|
||||||
TranscriptionModule.forRoot(),
|
TranscriptionModule.forRoot(),
|
||||||
SessionModule.forRoot(),
|
SessionModule.forRoot({ storageMode: 'redis' }),
|
||||||
CreditModule.forRoot(),
|
CreditModule.forRoot(),
|
||||||
],
|
],
|
||||||
providers: [MatrixService],
|
providers: [MatrixService, todoApiServiceProvider],
|
||||||
exports: [MatrixService],
|
exports: [MatrixService],
|
||||||
})
|
})
|
||||||
export class BotModule {}
|
export class BotModule {}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import {
|
||||||
COMMON_KEYWORDS,
|
COMMON_KEYWORDS,
|
||||||
} from '@manacore/matrix-bot-common';
|
} from '@manacore/matrix-bot-common';
|
||||||
import { TodoService, Task } from '../todo/todo.service';
|
import { TodoService, Task } from '../todo/todo.service';
|
||||||
import { TranscriptionService, SessionService, CreditService } from '@manacore/bot-services';
|
import {
|
||||||
|
TranscriptionService,
|
||||||
|
SessionService,
|
||||||
|
CreditService,
|
||||||
|
TodoApiService,
|
||||||
|
Task as ApiTask,
|
||||||
|
} from '@manacore/bot-services';
|
||||||
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
||||||
|
|
||||||
// Credit cost for task creation (micro-credits)
|
// Credit cost for task creation (micro-credits)
|
||||||
|
|
@ -20,7 +26,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
[
|
[
|
||||||
...COMMON_KEYWORDS,
|
...COMMON_KEYWORDS,
|
||||||
{ keywords: ['was kannst du'], command: 'help' },
|
{ keywords: ['was kannst du'], command: 'help' },
|
||||||
{ keywords: ['zeige aufgaben', 'meine aufgaben', 'was muss ich', 'show tasks', 'list'], command: 'list' },
|
{
|
||||||
|
keywords: ['zeige aufgaben', 'meine aufgaben', 'was muss ich', 'show tasks', 'list'],
|
||||||
|
command: 'list',
|
||||||
|
},
|
||||||
{ keywords: ['heute', 'today', 'was steht an'], command: 'today' },
|
{ keywords: ['heute', 'today', 'was steht an'], command: 'today' },
|
||||||
{ keywords: ['inbox', 'eingang', 'ohne datum'], command: 'inbox' },
|
{ keywords: ['inbox', 'eingang', 'ohne datum'], command: 'inbox' },
|
||||||
{ keywords: ['projekte', 'projects'], command: 'projects' },
|
{ keywords: ['projekte', 'projects'], command: 'projects' },
|
||||||
|
|
@ -32,6 +41,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
constructor(
|
constructor(
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private todoService: TodoService,
|
private todoService: TodoService,
|
||||||
|
private todoApiService: TodoApiService,
|
||||||
private transcriptionService: TranscriptionService,
|
private transcriptionService: TranscriptionService,
|
||||||
private sessionService: SessionService,
|
private sessionService: SessionService,
|
||||||
private creditService: CreditService
|
private creditService: CreditService
|
||||||
|
|
@ -39,11 +49,38 @@ export class MatrixService extends BaseMatrixService {
|
||||||
super(configService);
|
super(configService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is logged in and has a valid token for API access
|
||||||
|
*/
|
||||||
|
private async getToken(userId: string): Promise<string | null> {
|
||||||
|
return this.sessionService.getToken(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize task from API or local format to common format
|
||||||
|
*/
|
||||||
|
private normalizeTask(task: Task | ApiTask): Task {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
completed: task.completed,
|
||||||
|
priority: task.priority,
|
||||||
|
dueDate: task.dueDate,
|
||||||
|
project: task.project,
|
||||||
|
labels: task.labels || [],
|
||||||
|
createdAt: task.createdAt,
|
||||||
|
completedAt: task.completedAt || null,
|
||||||
|
userId: task.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected getConfig(): MatrixBotConfig {
|
protected getConfig(): MatrixBotConfig {
|
||||||
return {
|
return {
|
||||||
homeserverUrl: this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
|
homeserverUrl:
|
||||||
|
this.configService.get<string>('matrix.homeserverUrl') || 'http://localhost:8008',
|
||||||
accessToken: this.configService.get<string>('matrix.accessToken') || '',
|
accessToken: this.configService.get<string>('matrix.accessToken') || '',
|
||||||
storagePath: this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
|
storagePath:
|
||||||
|
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
|
||||||
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
|
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -74,11 +111,7 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error handling message: ${error}`);
|
this.logger.error(`Error handling message: ${error}`);
|
||||||
await this.sendReply(
|
await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.');
|
||||||
roomId,
|
|
||||||
event,
|
|
||||||
'Ein Fehler ist aufgetreten. Bitte versuche es erneut.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,8 +151,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
const token = await this.getToken(sender);
|
||||||
|
|
||||||
// Check credits if user is logged in
|
// Check credits if user is logged in
|
||||||
const token = this.sessionService.getToken(sender);
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
||||||
if (!validation.hasCredits) {
|
if (!validation.hasCredits) {
|
||||||
|
|
@ -128,36 +163,59 @@ export class MatrixService extends BaseMatrixService {
|
||||||
validation.availableCredits,
|
validation.availableCredits,
|
||||||
'Aufgabe erstellen'
|
'Aufgabe erstellen'
|
||||||
);
|
);
|
||||||
await this.sendReply(roomId, event, `Transkription: "${transcription}"\n\n${errorMsg.text}`);
|
await this.sendReply(
|
||||||
|
roomId,
|
||||||
|
event,
|
||||||
|
`Transkription: "${transcription}"\n\n${errorMsg.text}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the transcription as a task input
|
let task: Task;
|
||||||
const { title, priority, dueDate, project } = this.todoService.parseTaskInput(transcription);
|
|
||||||
|
|
||||||
// Create the task
|
if (token) {
|
||||||
const task = await this.todoService.createTask(sender, title, {
|
// Use API service (syncs with todo-web and mobile)
|
||||||
priority,
|
const { title, priority, dueDate, project } =
|
||||||
dueDate,
|
this.todoApiService.parseTaskInput(transcription);
|
||||||
project,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let responseText = `Transkription: "${transcription}"\n\nAufgabe erstellt: **${task.title}**`;
|
let responseText = `Transkription: "${transcription}"\n\nAufgabe erstellt: **${task.title}**`;
|
||||||
|
|
||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
if (priority < 4) details.push(`Prioritat ${priority}`);
|
if (task.priority < 4) details.push(`Prioritat ${task.priority}`);
|
||||||
if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`);
|
if (task.dueDate) details.push(`Datum: ${this.formatDate(task.dueDate)}`);
|
||||||
if (project) details.push(`Projekt: ${project}`);
|
if (task.project) details.push(`Projekt: ${task.project}`);
|
||||||
|
|
||||||
if (details.length > 0) {
|
if (details.length > 0) {
|
||||||
responseText += `\n${details.join(' | ')}`;
|
responseText += `\n${details.join(' | ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show credit deduction if logged in
|
// Show credit deduction and sync status if logged in
|
||||||
if (token) {
|
if (token) {
|
||||||
const balance = await this.creditService.getBalance(token);
|
const balance = await this.creditService.getBalance(token);
|
||||||
responseText += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
responseText += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||||
|
responseText += '\n🔄 Synchronisiert mit todo-backend';
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, responseText);
|
await this.sendReply(roomId, event, responseText);
|
||||||
|
|
@ -247,7 +305,12 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAddTask(roomId: string, event: MatrixRoomEvent, userId: string, input: string) {
|
private async handleAddTask(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
input: string
|
||||||
|
) {
|
||||||
if (!input.trim()) {
|
if (!input.trim()) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
roomId,
|
roomId,
|
||||||
|
|
@ -257,8 +320,10 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
const token = await this.getToken(userId);
|
||||||
|
|
||||||
// Check credits if user is logged in
|
// Check credits if user is logged in
|
||||||
const token = this.sessionService.getToken(userId);
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
const validation = await this.creditService.validateCredits(token, TASK_CREATE_CREDITS);
|
||||||
if (!validation.hasCredits) {
|
if (!validation.hasCredits) {
|
||||||
|
|
@ -272,36 +337,65 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, priority, dueDate, project } = this.todoService.parseTaskInput(input);
|
let task: Task;
|
||||||
|
|
||||||
const task = await this.todoService.createTask(userId, title, {
|
if (token) {
|
||||||
priority,
|
// Use API service (syncs with todo-web and mobile)
|
||||||
dueDate,
|
const { title, priority, dueDate, project } = this.todoApiService.parseTaskInput(input);
|
||||||
project,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let response = `Aufgabe erstellt: **${task.title}**`;
|
let response = `Aufgabe erstellt: **${task.title}**`;
|
||||||
|
|
||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
if (priority < 4) details.push(`Prioritaet ${priority}`);
|
if (task.priority < 4) details.push(`Prioritaet ${task.priority}`);
|
||||||
if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`);
|
if (task.dueDate) details.push(`Datum: ${this.formatDate(task.dueDate)}`);
|
||||||
if (project) details.push(`Projekt: ${project}`);
|
if (task.project) details.push(`Projekt: ${task.project}`);
|
||||||
|
|
||||||
if (details.length > 0) {
|
if (details.length > 0) {
|
||||||
response += `\n${details.join(' | ')}`;
|
response += `\n${details.join(' | ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show credit deduction if logged in
|
// Show credit deduction and sync status if logged in
|
||||||
if (token) {
|
if (token) {
|
||||||
const balance = await this.creditService.getBalance(token);
|
const balance = await this.creditService.getBalance(token);
|
||||||
response += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
response += `\n\n⚡ -${TASK_CREATE_CREDITS} Credits (${balance.balance.toFixed(2)} verbleibend)`;
|
||||||
|
response += '\n🔄 Synchronisiert mit todo-backend';
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleListTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleListTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const tasks = await this.todoService.getAllPendingTasks(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let tasks: Task[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -312,12 +406,25 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatTaskList('**Alle offenen Aufgaben:**', tasks);
|
let response = this.formatTaskList('**Alle offenen Aufgaben:**', tasks);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleTodayTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleTodayTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const tasks = await this.todoService.getTodayTasks(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let tasks: Task[];
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Use API service
|
||||||
|
const apiTasks = await this.todoApiService.getTodayTasks(token);
|
||||||
|
tasks = apiTasks.map((t) => this.normalizeTask(t));
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
tasks = await this.todoService.getTodayTasks(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -328,23 +435,44 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatTaskList('**Aufgaben fuer heute:**', tasks);
|
let response = this.formatTaskList('**Aufgaben fuer heute:**', tasks);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleInboxTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleInboxTasks(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const tasks = await this.todoService.getInboxTasks(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let tasks: Task[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
await this.sendReply(roomId, event, 'Inbox ist leer.\n\nAufgaben ohne Datum landen hier.');
|
await this.sendReply(roomId, event, 'Inbox ist leer.\n\nAufgaben ohne Datum landen hier.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatTaskList('**Inbox (ohne Datum):**', tasks);
|
let response = this.formatTaskList('**Inbox (ohne Datum):**', tasks);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCompleteTask(roomId: string, event: MatrixRoomEvent, userId: string, args: string) {
|
private async handleCompleteTask(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
args: string
|
||||||
|
) {
|
||||||
const taskNumber = parseInt(args.trim());
|
const taskNumber = parseInt(args.trim());
|
||||||
|
|
||||||
if (isNaN(taskNumber) || taskNumber < 1) {
|
if (isNaN(taskNumber) || taskNumber < 1) {
|
||||||
|
|
@ -356,17 +484,42 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await this.todoService.completeTask(userId, taskNumber);
|
const token = await this.getToken(userId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
task = await this.todoService.completeTask(userId, taskNumber);
|
||||||
|
}
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`);
|
await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, `Erledigt: ~~${task.title}~~`);
|
let response = `Erledigt: ~~${task.title}~~`;
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDeleteTask(roomId: string, event: MatrixRoomEvent, userId: string, args: string) {
|
private async handleDeleteTask(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
args: string
|
||||||
|
) {
|
||||||
const taskNumber = parseInt(args.trim());
|
const taskNumber = parseInt(args.trim());
|
||||||
|
|
||||||
if (isNaN(taskNumber) || taskNumber < 1) {
|
if (isNaN(taskNumber) || taskNumber < 1) {
|
||||||
|
|
@ -378,18 +531,48 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await this.todoService.deleteTask(userId, taskNumber);
|
const token = await this.getToken(userId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
task = await this.todoService.deleteTask(userId, taskNumber);
|
||||||
|
}
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`);
|
await this.sendReply(roomId, event, `Aufgabe #${taskNumber} nicht gefunden.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, `Geloescht: ${task.title}`);
|
let response = `Geloescht: ${task.title}`;
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleProjects(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleProjects(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const projects = await this.todoService.getProjects(userId);
|
const token = await this.getToken(userId);
|
||||||
|
let projects: { name: string }[];
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Use API service
|
||||||
|
const apiProjects = await this.todoApiService.getProjects(token);
|
||||||
|
projects = apiProjects;
|
||||||
|
} else {
|
||||||
|
// Use local storage
|
||||||
|
projects = await this.todoService.getProjects(userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
await this.sendReply(
|
await this.sendReply(
|
||||||
|
|
@ -405,11 +588,19 @@ export class MatrixService extends BaseMatrixService {
|
||||||
response += `- ${project.name}\n`;
|
response += `- ${project.name}\n`;
|
||||||
}
|
}
|
||||||
response += '\nZeige Projektaufgaben mit `!project [Name]`';
|
response += '\nZeige Projektaufgaben mit `!project [Name]`';
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleProjectTasks(roomId: string, event: MatrixRoomEvent, userId: string, args: string) {
|
private async handleProjectTasks(
|
||||||
|
roomId: string,
|
||||||
|
event: MatrixRoomEvent,
|
||||||
|
userId: string,
|
||||||
|
args: string
|
||||||
|
) {
|
||||||
const projectName = args.trim();
|
const projectName = args.trim();
|
||||||
|
|
||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
|
|
@ -421,22 +612,50 @@ export class MatrixService extends BaseMatrixService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = await this.todoService.getProjectTasks(userId, projectName);
|
const token = await this.getToken(userId);
|
||||||
|
let tasks: Task[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
await this.sendReply(roomId, event, `Keine Aufgaben im Projekt "${projectName}".`);
|
await this.sendReply(roomId, event, `Keine Aufgaben im Projekt "${projectName}".`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = this.formatTaskList(`**Projekt: ${projectName}**`, tasks);
|
let response = this.formatTaskList(`**Projekt: ${projectName}**`, tasks);
|
||||||
|
if (token) {
|
||||||
|
response += '\n\n🔄 Synchronisiert';
|
||||||
|
}
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) {
|
private async handleStatus(roomId: string, event: MatrixRoomEvent, userId: string) {
|
||||||
const stats = await this.todoService.getStats(userId);
|
const token = await this.getToken(userId);
|
||||||
const isLoggedIn = this.sessionService.isLoggedIn(userId);
|
const isLoggedIn = await this.sessionService.isLoggedIn(userId);
|
||||||
const email = this.sessionService.getEmail(userId);
|
const email = this.sessionService.getEmail(userId);
|
||||||
const token = this.sessionService.getToken(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
|
// Get credit balance if logged in
|
||||||
let creditInfo = '';
|
let creditInfo = '';
|
||||||
|
|
@ -452,6 +671,8 @@ export class MatrixService extends BaseMatrixService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncStatus = token ? '🔄 Synchronisiert mit todo-backend' : '💾 Lokaler Speicher';
|
||||||
|
|
||||||
const response = `**Status**
|
const response = `**Status**
|
||||||
|
|
||||||
👤 Angemeldet: ${isLoggedIn ? `Ja (${email})` : 'Nein'}${creditInfo}
|
👤 Angemeldet: ${isLoggedIn ? `Ja (${email})` : 'Nein'}${creditInfo}
|
||||||
|
|
@ -461,7 +682,8 @@ export class MatrixService extends BaseMatrixService {
|
||||||
- Erledigt: ${stats.completed}
|
- Erledigt: ${stats.completed}
|
||||||
- Gesamt: ${stats.total}
|
- Gesamt: ${stats.total}
|
||||||
|
|
||||||
Bot: Online${!isLoggedIn ? '\n\nTipp: Mit `!login email passwort` anmelden fuer Credit-Tracking' : ''}`;
|
${syncStatus}
|
||||||
|
Bot: Online${!isLoggedIn ? '\n\nTipp: Mit `!login email passwort` anmelden fuer Synchronisation mit todo-web' : ''}`;
|
||||||
|
|
||||||
await this.sendReply(roomId, event, response);
|
await this.sendReply(roomId, event, response);
|
||||||
}
|
}
|
||||||
|
|
@ -501,11 +723,7 @@ Bot: Online${!isLoggedIn ? '\n\nTipp: Mit `!login email passwort` anmelden fuer
|
||||||
await this.sendReply(roomId, event, 'Hilfe wurde angepinnt!');
|
await this.sendReply(roomId, event, 'Hilfe wurde angepinnt!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to pin help:', error);
|
this.logger.error('Failed to pin help:', error);
|
||||||
await this.sendReply(
|
await this.sendReply(roomId, event, 'Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)');
|
||||||
roomId,
|
|
||||||
event,
|
|
||||||
'Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue