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:
Till-JS 2026-02-02 14:51:23 +01:00
parent 7bad849258
commit 2777f604fd
27 changed files with 2997 additions and 838 deletions

View file

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

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

View file

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

View file

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

View file

@ -75,4 +75,5 @@ export interface ParsedEventInput {
startTime: Date | null;
endTime: Date | null;
isAllDay: boolean;
location: string | null;
}

View file

@ -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,

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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