mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +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
|
|
@ -69,13 +69,15 @@
|
|||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.20",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"date-fns": "^4.1.0"
|
||||
"date-fns": "^4.1.0",
|
||||
"ioredis": "^5.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/config": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ioredis": "^5.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"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
|
||||
title = title.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { title, startTime, endTime, isAllDay };
|
||||
return { title, startTime, endTime, isAllDay, location: null };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Module
|
||||
export { CalendarModule, CalendarModuleOptions } from './calendar.module';
|
||||
|
||||
// Service
|
||||
// Services
|
||||
export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service';
|
||||
export { CalendarApiService } from './calendar-api.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
|
|
|||
|
|
@ -75,4 +75,5 @@ export interface ParsedEventInput {
|
|||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
isAllDay: boolean;
|
||||
location: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@
|
|||
// ===== Core Services =====
|
||||
|
||||
// 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 {
|
||||
Task,
|
||||
Project,
|
||||
|
|
@ -44,6 +50,7 @@ export {
|
|||
CalendarModule,
|
||||
CalendarModuleOptions,
|
||||
CalendarService,
|
||||
CalendarApiService,
|
||||
CALENDAR_STORAGE_PROVIDER,
|
||||
} from './calendar/index.js';
|
||||
export type {
|
||||
|
|
@ -88,13 +95,26 @@ export type {
|
|||
export {
|
||||
SessionModule,
|
||||
SessionService,
|
||||
RedisSessionProvider,
|
||||
REDIS_SESSION_PROVIDER,
|
||||
REDIS_CLIENT,
|
||||
SESSION_MODULE_OPTIONS,
|
||||
DEFAULT_SESSION_EXPIRY_MS,
|
||||
} 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)
|
||||
export { TranscriptionModule, TranscriptionService, STT_MODULE_OPTIONS } from './transcription/index.js';
|
||||
export {
|
||||
TranscriptionModule,
|
||||
TranscriptionService,
|
||||
STT_MODULE_OPTIONS,
|
||||
} from './transcription/index.js';
|
||||
export type {
|
||||
SttResponse,
|
||||
TranscriptionOptions,
|
||||
|
|
@ -102,7 +122,12 @@ export type {
|
|||
} from './transcription/index.js';
|
||||
|
||||
// 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 {
|
||||
CreditBalance,
|
||||
CreditValidationResult,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
export { SessionService } from './session.service';
|
||||
export { SessionService, REDIS_SESSION_PROVIDER } from './session.service';
|
||||
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';
|
||||
|
|
|
|||
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 { 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';
|
||||
|
||||
/**
|
||||
|
|
@ -11,19 +12,31 @@ import { SessionModuleOptions, SESSION_MODULE_OPTIONS } from './types';
|
|||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With explicit configuration
|
||||
* // Basic usage (in-memory sessions, per bot)
|
||||
* @Module({
|
||||
* imports: [SessionModule.forRoot()]
|
||||
* })
|
||||
*
|
||||
* // With Redis for cross-bot SSO
|
||||
* @Module({
|
||||
* imports: [
|
||||
* SessionModule.register({
|
||||
* authUrl: 'http://mana-core-auth:3001',
|
||||
* sessionExpiryMs: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
* SessionModule.forRoot({
|
||||
* storageMode: 'redis',
|
||||
* redisHost: 'localhost',
|
||||
* redisPort: 6379,
|
||||
* })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
* // With ConfigService (reads from auth.url or MANA_CORE_AUTH_URL)
|
||||
* // With Matrix-SSO-Link (automatic login)
|
||||
* @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
|
||||
*/
|
||||
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 {
|
||||
module: SessionModule,
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
{
|
||||
provide: SESSION_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
},
|
||||
SessionService,
|
||||
],
|
||||
providers,
|
||||
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 {
|
||||
module: SessionModule,
|
||||
imports: [ConfigModule],
|
||||
providers: [SessionService],
|
||||
providers,
|
||||
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,
|
||||
DEFAULT_SESSION_EXPIRY_MS,
|
||||
} 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
|
||||
*
|
||||
* 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
|
||||
* ```typescript
|
||||
* // In NestJS module
|
||||
* imports: [SessionModule.register({ authUrl: 'http://mana-core-auth:3001' })]
|
||||
* // In NestJS module - with Redis for cross-bot SSO
|
||||
* imports: [SessionModule.forRoot({ storageMode: 'redis' })]
|
||||
*
|
||||
* // In service/controller
|
||||
* const result = await sessionService.login(matrixUserId, email, password);
|
||||
* const token = sessionService.getToken(matrixUserId);
|
||||
* const token = await sessionService.getToken(matrixUserId);
|
||||
* // Token is available across ALL bots!
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
|
|
@ -32,10 +42,13 @@ export class SessionService {
|
|||
private readonly authUrl: string;
|
||||
private readonly sessionExpiryMs: number;
|
||||
private readonly loginPath: string;
|
||||
private readonly enableMatrixSsoLink: boolean;
|
||||
private readonly serviceKey: string | undefined;
|
||||
|
||||
constructor(
|
||||
@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
|
||||
this.authUrl =
|
||||
|
|
@ -47,7 +60,125 @@ export class SessionService {
|
|||
this.sessionExpiryMs = options?.sessionExpiryMs || DEFAULT_SESSION_EXPIRY_MS;
|
||||
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' };
|
||||
}
|
||||
|
||||
// Store session with expiry
|
||||
this.sessions.set(matrixUserId, {
|
||||
// Store session
|
||||
const session: UserSession = {
|
||||
token,
|
||||
email,
|
||||
expiresAt: new Date(Date.now() + this.sessionExpiryMs),
|
||||
});
|
||||
};
|
||||
|
||||
await this.storeSession(matrixUserId, session);
|
||||
|
||||
this.logger.log(`User ${matrixUserId} logged in as ${email}`);
|
||||
return { success: true, email };
|
||||
|
|
@ -102,56 +235,66 @@ export class SessionService {
|
|||
/**
|
||||
* 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.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
|
||||
*/
|
||||
isLoggedIn(matrixUserId: string): boolean {
|
||||
return this.getToken(matrixUserId) !== null;
|
||||
async isLoggedIn(matrixUserId: string): Promise<boolean> {
|
||||
const token = await this.getToken(matrixUserId);
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full session object for a Matrix user
|
||||
*/
|
||||
getSession(matrixUserId: string): UserSession | null {
|
||||
const token = this.getToken(matrixUserId); // This handles expiry check
|
||||
if (!token) return null;
|
||||
return this.sessions.get(matrixUserId) || null;
|
||||
async getSession(matrixUserId: string): Promise<UserSession | null> {
|
||||
// Try Redis first
|
||||
if (this.useRedis()) {
|
||||
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
|
||||
*/
|
||||
getEmail(matrixUserId: string): string | null {
|
||||
const session = this.getSession(matrixUserId);
|
||||
async getEmail(matrixUserId: string): Promise<string | null> {
|
||||
const session = await this.getSession(matrixUserId);
|
||||
return session?.email || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (session) {
|
||||
session.data = session.data || {};
|
||||
|
|
@ -162,13 +305,20 @@ export class SessionService {
|
|||
/**
|
||||
* Get custom data from a user's session
|
||||
*/
|
||||
getSessionData<T = unknown>(matrixUserId: string, key: string): T | null {
|
||||
const session = this.getSession(matrixUserId);
|
||||
async getSessionData<T = unknown>(matrixUserId: string, key: string): Promise<T | null> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total session count (including expired)
|
||||
* Get total session count (including expired in memory)
|
||||
*/
|
||||
getSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
|
|
@ -177,27 +327,40 @@ export class SessionService {
|
|||
/**
|
||||
* Get count of active (non-expired) sessions
|
||||
*/
|
||||
getActiveSessionCount(): number {
|
||||
const now = new Date();
|
||||
async getActiveSessionCount(): Promise<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics
|
||||
*/
|
||||
getStats(): SessionStats {
|
||||
async getStats(): Promise<SessionStats> {
|
||||
const active = await this.getActiveSessionCount();
|
||||
return {
|
||||
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 {
|
||||
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[] {
|
||||
const now = new Date();
|
||||
|
|
@ -232,4 +395,18 @@ export class SessionService {
|
|||
|
||||
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;
|
||||
/** Active (non-expired) sessions */
|
||||
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
|
||||
*/
|
||||
|
|
@ -45,6 +54,22 @@ export interface SessionModuleOptions {
|
|||
sessionExpiryMs?: number;
|
||||
/** Custom login endpoint path */
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Module
|
||||
export { TodoModule, TodoModuleOptions } from './todo.module';
|
||||
|
||||
// Service
|
||||
// Services
|
||||
export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service';
|
||||
export { TodoApiService } from './todo-api.service';
|
||||
|
||||
// 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
|
||||
|
|
@ -14,8 +14,8 @@ import { SessionService } from '@manacore/bot-services';
|
|||
* }
|
||||
*
|
||||
* const session = new SessionHelper<ChatSessionData>(sessionService, matrixUserId);
|
||||
* session.set('currentConversationId', 'abc123');
|
||||
* const convId = session.get('currentConversationId'); // string | null
|
||||
* await session.set('currentConversationId', 'abc123');
|
||||
* const convId = await session.get('currentConversationId'); // string | null
|
||||
* ```
|
||||
*/
|
||||
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<K extends keyof T>(key: K, value: T[K]): void {
|
||||
this.sessionService.setSessionData(this.userId, key as string, value);
|
||||
async set<K extends keyof T>(key: K, value: T[K]): Promise<void> {
|
||||
await this.sessionService.setSessionData(this.userId, key as string, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session value
|
||||
*/
|
||||
delete<K extends keyof T>(key: K): void {
|
||||
this.sessionService.setSessionData(this.userId, key as string, null);
|
||||
async delete<K extends keyof T>(key: K): Promise<void> {
|
||||
await this.sessionService.setSessionData(this.userId, key as string, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session value exists
|
||||
*/
|
||||
has<K extends keyof T>(key: K): boolean {
|
||||
return this.get(key) !== null;
|
||||
async has<K extends keyof T>(key: K): Promise<boolean> {
|
||||
return (await this.get(key)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,14 +62,14 @@ export class SessionHelper<T extends Record<string, unknown>> {
|
|||
/**
|
||||
* Check if user is logged in
|
||||
*/
|
||||
isLoggedIn(): boolean {
|
||||
async isLoggedIn(): Promise<boolean> {
|
||||
return this.sessionService.isLoggedIn(this.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT token for API calls
|
||||
*/
|
||||
getToken(): string | null {
|
||||
async getToken(): Promise<string | null> {
|
||||
return this.sessionService.getToken(this.userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue