From 68a6c7a8d69f5bc7a64eae100e482f4341110355 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 00:07:32 +0000 Subject: [PATCH] feat(packages): add @manacore/bot-services shared package Introduces a new shared package containing transport-agnostic business logic services for Matrix bots and the Gateway. This enables: - Individual bots to import shared services - A unified Gateway bot to combine all features - Code reuse without duplication Services included: - TodoService: Task management with projects, priorities, dates - CalendarService: Events, calendars, reminders - AiService: Ollama LLM integration, chat sessions, vision - ClockService: Timers, alarms, world clocks (API client) - Placeholder modules for Nutrition, Quotes, Stats, Docs Key features: - Pluggable storage providers (file-based, in-memory, custom) - German natural language input parsing - NestJS module system with dependency injection - Fully testable in isolation https://claude.ai/code/session_015bwcqVRiFmSydYTjvDJGTc --- packages/bot-services/CLAUDE.md | 176 ++++++++++ packages/bot-services/package.json | 38 ++ packages/bot-services/src/ai/ai.module.ts | 45 +++ packages/bot-services/src/ai/ai.service.ts | 294 ++++++++++++++++ packages/bot-services/src/ai/index.ts | 8 + packages/bot-services/src/ai/types.ts | 128 +++++++ .../src/calendar/calendar.module.ts | 51 +++ .../src/calendar/calendar.service.ts | 328 ++++++++++++++++++ packages/bot-services/src/calendar/index.ts | 8 + packages/bot-services/src/calendar/types.ts | 78 +++++ .../bot-services/src/clock/clock.module.ts | 45 +++ .../bot-services/src/clock/clock.service.ts | 262 ++++++++++++++ packages/bot-services/src/clock/index.ts | 8 + packages/bot-services/src/clock/types.ts | 97 ++++++ packages/bot-services/src/docs/index.ts | 25 ++ packages/bot-services/src/index.ts | 113 ++++++ packages/bot-services/src/nutrition/index.ts | 25 ++ packages/bot-services/src/quotes/index.ts | 18 + packages/bot-services/src/shared/index.ts | 8 + packages/bot-services/src/shared/storage.ts | 71 ++++ packages/bot-services/src/shared/types.ts | 66 ++++ packages/bot-services/src/shared/utils.ts | 110 ++++++ packages/bot-services/src/stats/index.ts | 20 ++ packages/bot-services/src/todo/index.ts | 8 + packages/bot-services/src/todo/todo.module.ts | 50 +++ .../bot-services/src/todo/todo.service.ts | 294 ++++++++++++++++ packages/bot-services/src/todo/types.ts | 88 +++++ packages/bot-services/tsconfig.json | 30 ++ 28 files changed, 2492 insertions(+) create mode 100644 packages/bot-services/CLAUDE.md create mode 100644 packages/bot-services/package.json create mode 100644 packages/bot-services/src/ai/ai.module.ts create mode 100644 packages/bot-services/src/ai/ai.service.ts create mode 100644 packages/bot-services/src/ai/index.ts create mode 100644 packages/bot-services/src/ai/types.ts create mode 100644 packages/bot-services/src/calendar/calendar.module.ts create mode 100644 packages/bot-services/src/calendar/calendar.service.ts create mode 100644 packages/bot-services/src/calendar/index.ts create mode 100644 packages/bot-services/src/calendar/types.ts create mode 100644 packages/bot-services/src/clock/clock.module.ts create mode 100644 packages/bot-services/src/clock/clock.service.ts create mode 100644 packages/bot-services/src/clock/index.ts create mode 100644 packages/bot-services/src/clock/types.ts create mode 100644 packages/bot-services/src/docs/index.ts create mode 100644 packages/bot-services/src/index.ts create mode 100644 packages/bot-services/src/nutrition/index.ts create mode 100644 packages/bot-services/src/quotes/index.ts create mode 100644 packages/bot-services/src/shared/index.ts create mode 100644 packages/bot-services/src/shared/storage.ts create mode 100644 packages/bot-services/src/shared/types.ts create mode 100644 packages/bot-services/src/shared/utils.ts create mode 100644 packages/bot-services/src/stats/index.ts create mode 100644 packages/bot-services/src/todo/index.ts create mode 100644 packages/bot-services/src/todo/todo.module.ts create mode 100644 packages/bot-services/src/todo/todo.service.ts create mode 100644 packages/bot-services/src/todo/types.ts create mode 100644 packages/bot-services/tsconfig.json diff --git a/packages/bot-services/CLAUDE.md b/packages/bot-services/CLAUDE.md new file mode 100644 index 000000000..365ae6a3b --- /dev/null +++ b/packages/bot-services/CLAUDE.md @@ -0,0 +1,176 @@ +# @manacore/bot-services + +Shared business logic services for Matrix bots and the Gateway. + +## Purpose + +This package provides **transport-agnostic** services that contain all business logic for the Matrix bot ecosystem. Services in this package: + +- Have no Matrix-specific code +- Can be used by individual bots OR the unified Gateway +- Support pluggable storage (file-based, in-memory, database) +- Are fully testable in isolation + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ @manacore/bot-services │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ TodoService │ │ CalendarSvc │ │ AiService │ │ ClockService│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Pure business logic - no Matrix code! │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Gateway │ │ Todo Bot │ │ CLI │ + │ (Matrix) │ │ (Matrix) │ │ Tool │ + └──────────┘ └──────────┘ └──────────┘ +``` + +## Available Services + +| Service | Storage | Description | +|---------|---------|-------------| +| `TodoService` | File (JSON) | Task management with projects, priorities, dates | +| `CalendarService` | File (JSON) | Events, calendars, reminders | +| `AiService` | In-memory | Ollama LLM integration, chat sessions, vision | +| `ClockService` | External API | Timers, alarms, world clocks | +| `NutritionService` | Placeholder | Meal tracking (to be implemented) | +| `QuotesService` | Placeholder | Daily quotes (to be implemented) | +| `StatsService` | Placeholder | Analytics reports (to be implemented) | +| `DocsService` | Placeholder | Documentation generation (to be implemented) | + +## Usage + +### In NestJS (Bot or Gateway) + +```typescript +import { Module } from '@nestjs/common'; +import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot-services'; + +@Module({ + imports: [ + // File-based storage (default) + TodoModule.register({ storagePath: './data/todos.json' }), + CalendarModule.register({ storagePath: './data/calendar.json' }), + + // External services + AiModule.register({ baseUrl: 'http://ollama:11434' }), + ClockModule.register({ apiUrl: 'http://clock-backend:3017/api/v1' }), + ], +}) +export class AppModule {} +``` + +### Direct Service Usage + +```typescript +import { TodoService } from '@manacore/bot-services/todo'; +import { AiService } from '@manacore/bot-services/ai'; + +// Create task +const task = await todoService.createTask('@user:matrix.org', { + title: 'Buy groceries', + priority: 2, + dueDate: '2025-01-30', +}); + +// AI chat +const response = await aiService.chatSimple('@user:matrix.org', 'What is TypeScript?'); +``` + +### Custom Storage Provider + +```typescript +import { TodoModule, StorageProvider, TodoData } from '@manacore/bot-services'; + +// PostgreSQL storage example +class PostgresTodoStorage implements StorageProvider { + async load(): Promise { + // Load from database + } + async save(data: TodoData): Promise { + // Save to database + } +} + +// Use custom storage +TodoModule.forRoot(new PostgresTodoStorage()); +``` + +## Input Parsing + +Services include German-language natural input parsing: + +### Todo + +```typescript +const parsed = todoService.parseTaskInput('Einkaufen !p1 @morgen #haushalt'); +// { title: 'Einkaufen', priority: 1, dueDate: '2025-01-30', project: 'haushalt' } +``` + +### Calendar + +```typescript +const parsed = calendarService.parseEventInput('Meeting morgen um 14:30'); +// { title: 'Meeting', startTime: Date, endTime: Date, isAllDay: false } +``` + +### Clock + +```typescript +const seconds = clockService.parseDuration('1h30m'); // 5400 +const time = clockService.parseAlarmTime('14 Uhr 30'); // '14:30:00' +``` + +## Development + +```bash +# Type check +pnpm --filter @manacore/bot-services type-check + +# Install in a bot +pnpm --filter matrix-todo-bot add @manacore/bot-services +``` + +## Adding New Services + +1. Create directory: `src/{service}/` +2. Add files: + - `types.ts` - Interfaces and types + - `{service}.service.ts` - Business logic + - `{service}.module.ts` - NestJS module + - `index.ts` - Exports +3. Export from `src/index.ts` +4. Update `package.json` exports + +## File Structure + +``` +packages/bot-services/ +├── src/ +│ ├── index.ts # Main exports +│ ├── shared/ +│ │ ├── types.ts # Common types +│ │ ├── storage.ts # Storage providers +│ │ ├── utils.ts # Utility functions +│ │ └── index.ts +│ ├── todo/ +│ │ ├── types.ts +│ │ ├── todo.service.ts +│ │ ├── todo.module.ts +│ │ └── index.ts +│ ├── calendar/ +│ ├── ai/ +│ ├── clock/ +│ └── ... +├── package.json +├── tsconfig.json +└── CLAUDE.md +``` diff --git a/packages/bot-services/package.json b/packages/bot-services/package.json new file mode 100644 index 000000000..018dc0407 --- /dev/null +++ b/packages/bot-services/package.json @@ -0,0 +1,38 @@ +{ + "name": "@manacore/bot-services", + "version": "0.1.0", + "private": true, + "description": "Shared business logic services for Matrix bots and Gateway", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./todo": "./src/todo/index.ts", + "./calendar": "./src/calendar/index.ts", + "./clock": "./src/clock/index.ts", + "./ai": "./src/ai/index.ts", + "./nutrition": "./src/nutrition/index.ts", + "./quotes": "./src/quotes/index.ts", + "./stats": "./src/stats/index.ts", + "./docs": "./src/docs/index.ts", + "./shared": "./src/shared/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit", + "clean": "rm -rf dist", + "lint": "eslint ." + }, + "dependencies": { + "@nestjs/common": "^11.0.20", + "@nestjs/config": "^4.0.2", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/config": "^3.0.0 || ^4.0.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/bot-services/src/ai/ai.module.ts b/packages/bot-services/src/ai/ai.module.ts new file mode 100644 index 000000000..b67d24d4f --- /dev/null +++ b/packages/bot-services/src/ai/ai.module.ts @@ -0,0 +1,45 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { AiService } from './ai.service'; +import { AiServiceConfig } from './types'; + +export interface AiModuleOptions extends Partial {} + +@Module({}) +export class AiModule { + /** + * Register with default configuration (uses environment variables) + */ + static register(options?: AiModuleOptions): DynamicModule { + return { + module: AiModule, + providers: [ + { + provide: 'AI_SERVICE_CONFIG', + useValue: options ?? {}, + }, + { + provide: AiService, + useFactory: (config: Partial) => new AiService(config), + inject: ['AI_SERVICE_CONFIG'], + }, + ], + exports: [AiService], + }; + } + + /** + * Register with explicit configuration + */ + static forRoot(config: AiServiceConfig): DynamicModule { + return { + module: AiModule, + providers: [ + { + provide: AiService, + useFactory: () => new AiService(config), + }, + ], + exports: [AiService], + }; + } +} diff --git a/packages/bot-services/src/ai/ai.service.ts b/packages/bot-services/src/ai/ai.service.ts new file mode 100644 index 000000000..676483a5d --- /dev/null +++ b/packages/bot-services/src/ai/ai.service.ts @@ -0,0 +1,294 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { + OllamaModel, + ChatMessage, + ChatOptions, + ChatResult, + AiServiceConfig, + UserAiSession, + SYSTEM_PROMPTS, + VISION_MODELS, + NON_CHAT_MODELS, +} from './types'; + +@Injectable() +export class AiService implements OnModuleInit { + private readonly logger = new Logger(AiService.name); + private readonly config: AiServiceConfig; + private sessions: Map = new Map(); + + constructor(config?: Partial) { + this.config = { + baseUrl: config?.baseUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434', + defaultModel: config?.defaultModel ?? process.env.OLLAMA_MODEL ?? 'gemma3:4b', + timeout: config?.timeout ?? parseInt(process.env.OLLAMA_TIMEOUT ?? '120000'), + }; + } + + async onModuleInit() { + await this.checkConnection(); + } + + // ===== Connection ===== + + async checkConnection(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/version`, { + signal: AbortSignal.timeout(5000), + }); + const data = await response.json(); + this.logger.log(`Ollama connected: v${data.version}`); + return true; + } catch (error) { + this.logger.error(`Failed to connect to Ollama at ${this.config.baseUrl}:`, error); + return false; + } + } + + // ===== Models ===== + + async listModels(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/tags`); + const data = await response.json(); + return data.models || []; + } catch (error) { + this.logger.error('Failed to list models:', error); + return []; + } + } + + async getChatModels(): Promise { + const models = await this.listModels(); + return models.filter((m) => !NON_CHAT_MODELS.includes(m.name)); + } + + async getVisionModels(): Promise { + const models = await this.listModels(); + return models.filter((m) => VISION_MODELS.some((v) => m.name.includes(v))); + } + + getDefaultModel(): string { + return this.config.defaultModel; + } + + // ===== Chat ===== + + async chat(messages: ChatMessage[], options?: ChatOptions): Promise { + const model = options?.model ?? this.config.defaultModel; + + try { + const response = await fetch(`${this.config.baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages, + stream: false, + options: { + temperature: options?.temperature, + num_predict: options?.maxTokens, + }, + }), + signal: AbortSignal.timeout(this.config.timeout), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status}`); + } + + const data = await response.json(); + + const meta = { + model, + evalCount: data.eval_count, + evalDuration: data.eval_duration, + tokensPerSecond: + data.eval_count && data.eval_duration ? (data.eval_count / data.eval_duration) * 1e9 : undefined, + }; + + if (meta.tokensPerSecond) { + this.logger.debug(`Generated ${meta.evalCount} tokens at ${meta.tokensPerSecond.toFixed(1)} t/s`); + } + + return { + content: data.message?.content || '', + meta, + }; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error('Ollama Timeout - Antwort dauerte zu lange'); + } + throw error; + } + } + + async chatSimple(userId: string, message: string, options?: ChatOptions): Promise { + const session = this.getSession(userId); + + // Add user message to history + session.history.push({ role: 'user', content: message }); + + // Keep only last 10 messages + if (session.history.length > 10) { + session.history = session.history.slice(-10); + } + + // Build messages with system prompt + const messages: ChatMessage[] = [ + { role: 'system', content: options?.systemPrompt ?? session.systemPrompt }, + ...session.history, + ]; + + const result = await this.chat(messages, { ...options, model: options?.model ?? session.model }); + + // Add assistant response to history + session.history.push({ role: 'assistant', content: result.content }); + + return result.content; + } + + // ===== Vision ===== + + async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise { + const selectedModel = model ?? this.config.defaultModel; + + try { + const response = await fetch(`${this.config.baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: selectedModel, + messages: [ + { + role: 'user', + content: prompt, + images: [imageBase64], + }, + ], + stream: false, + }), + signal: AbortSignal.timeout(this.config.timeout), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status}`); + } + + const data = await response.json(); + + const meta = { + model: selectedModel, + evalCount: data.eval_count, + evalDuration: data.eval_duration, + tokensPerSecond: + data.eval_count && data.eval_duration ? (data.eval_count / data.eval_duration) * 1e9 : undefined, + }; + + return { + content: data.message?.content || '', + meta, + }; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error('Ollama Timeout - Bildanalyse dauerte zu lange'); + } + throw error; + } + } + + // ===== Compare Models ===== + + async compareModels( + message: string, + systemPrompt?: string + ): Promise<{ model: string; response: string; duration: number; error?: string }[]> { + const models = await this.getChatModels(); + const results: { model: string; response: string; duration: number; error?: string }[] = []; + + const messages: ChatMessage[] = [ + { role: 'system', content: systemPrompt ?? SYSTEM_PROMPTS.default }, + { role: 'user', content: message }, + ]; + + for (const model of models) { + const startTime = Date.now(); + try { + this.logger.log(`Querying model ${model.name}...`); + const result = await this.chat(messages, { model: model.name }); + const duration = Date.now() - startTime; + results.push({ model: model.name, response: result.content, duration }); + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + results.push({ model: model.name, response: '', duration, error: errorMessage }); + } + } + + return results; + } + + // ===== Session Management ===== + + getSession(userId: string): UserAiSession { + if (!this.sessions.has(userId)) { + this.sessions.set(userId, { + systemPrompt: SYSTEM_PROMPTS.default, + model: this.config.defaultModel, + history: [], + }); + } + return this.sessions.get(userId)!; + } + + setSessionModel(userId: string, model: string): void { + const session = this.getSession(userId); + session.model = model; + session.history = []; // Clear history when switching models + } + + setSessionSystemPrompt(userId: string, prompt: string): void { + const session = this.getSession(userId); + session.systemPrompt = prompt; + session.history = []; + } + + setSessionMode(userId: string, mode: string): boolean { + const prompt = SYSTEM_PROMPTS[mode.toLowerCase()]; + if (!prompt) return false; + + this.setSessionSystemPrompt(userId, prompt); + return true; + } + + clearSessionHistory(userId: string): void { + const session = this.getSession(userId); + session.history = []; + } + + setPendingImage(userId: string, url: string, mimeType: string, base64?: string): void { + const session = this.getSession(userId); + session.pendingImage = { url, mimeType, base64 }; + } + + getPendingImage(userId: string): UserAiSession['pendingImage'] { + return this.getSession(userId).pendingImage; + } + + clearPendingImage(userId: string): void { + const session = this.getSession(userId); + session.pendingImage = undefined; + } + + // ===== Utilities ===== + + getAvailableModes(): string[] { + return Object.keys(SYSTEM_PROMPTS); + } + + getCurrentMode(userId: string): string { + const session = this.getSession(userId); + const entry = Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt); + return entry ? entry[0] : 'custom'; + } +} diff --git a/packages/bot-services/src/ai/index.ts b/packages/bot-services/src/ai/index.ts new file mode 100644 index 000000000..345be5ff5 --- /dev/null +++ b/packages/bot-services/src/ai/index.ts @@ -0,0 +1,8 @@ +// Module +export { AiModule, AiModuleOptions } from './ai.module'; + +// Service +export { AiService } from './ai.service'; + +// Types +export * from './types'; diff --git a/packages/bot-services/src/ai/types.ts b/packages/bot-services/src/ai/types.ts new file mode 100644 index 000000000..67befc0aa --- /dev/null +++ b/packages/bot-services/src/ai/types.ts @@ -0,0 +1,128 @@ +/** + * AI/Ollama service types + */ + +/** + * Ollama model info + */ +export interface OllamaModel { + name: string; + size: number; + modified_at: string; + digest?: string; + details?: { + format: string; + family: string; + parameter_size: string; + quantization_level: string; + }; +} + +/** + * Chat message + */ +export interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + images?: string[]; // Base64 encoded images for vision +} + +/** + * Chat completion options + */ +export interface ChatOptions { + model?: string; + temperature?: number; + maxTokens?: number; + systemPrompt?: string; +} + +/** + * Chat response metadata + */ +export interface ChatResponseMeta { + model: string; + evalCount?: number; + evalDuration?: number; + tokensPerSecond?: number; +} + +/** + * Chat completion result + */ +export interface ChatResult { + content: string; + meta: ChatResponseMeta; +} + +/** + * AI service configuration + */ +export interface AiServiceConfig { + baseUrl: string; + defaultModel: string; + timeout: number; +} + +/** + * User AI session (for conversation history) + */ +export interface UserAiSession { + systemPrompt: string; + model: string; + history: ChatMessage[]; + pendingImage?: { + url: string; + mimeType: string; + base64?: string; + }; +} + +/** + * System prompt presets + */ +export interface SystemPromptPreset { + name: string; + prompt: string; + description: string; +} + +/** + * Default system prompts + */ +export const SYSTEM_PROMPTS: Record = { + default: `Du bist Manai, ein freundlicher und hilfreicher KI-Assistent. +Du antwortest auf Deutsch, es sei denn, der Nutzer schreibt auf Englisch. +Du bist präzise, hilfreich und freundlich. +Halte deine Antworten kompakt, aber informativ.`, + + code: `Du bist ein erfahrener Software-Entwickler und Code-Assistent. +Du hilfst beim Schreiben, Debuggen und Erklären von Code. +Gib klare, gut kommentierte Code-Beispiele. +Erkläre technische Konzepte verständlich.`, + + translate: `Du bist ein professioneller Übersetzer. +Übersetze Texte präzise und natürlich klingend. +Bewahre den Stil und Ton des Originals. +Bei Unklarheiten frage nach der gewünschten Zielsprache.`, + + summarize: `Du bist ein Experte für das Zusammenfassen von Texten. +Erstelle klare, prägnante Zusammenfassungen. +Behalte die wichtigsten Punkte bei. +Strukturiere die Zusammenfassung übersichtlich.`, + + creative: `Du bist ein kreativer Schreibassistent. +Hilf beim Verfassen von Geschichten, Gedichten und kreativen Texten. +Sei fantasievoll und inspirierend. +Passe deinen Stil an die gewünschte Textart an.`, +}; + +/** + * Vision-capable model names + */ +export const VISION_MODELS = ['llava', 'llava:7b', 'llava:13b', 'bakllava', 'moondream']; + +/** + * Models excluded from comparison (specialized, not for general chat) + */ +export const NON_CHAT_MODELS = ['deepseek-r1:1.5b']; diff --git a/packages/bot-services/src/calendar/calendar.module.ts b/packages/bot-services/src/calendar/calendar.module.ts new file mode 100644 index 000000000..a1cf28cdd --- /dev/null +++ b/packages/bot-services/src/calendar/calendar.module.ts @@ -0,0 +1,51 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service'; +import { StorageProvider } from '../shared/types'; +import { FileStorageProvider } from '../shared/storage'; +import { CalendarData } from './types'; + +export interface CalendarModuleOptions { + storagePath?: string; + storageProvider?: StorageProvider; +} + +@Module({}) +export class CalendarModule { + /** + * Register with default file storage + */ + static register(options?: CalendarModuleOptions): DynamicModule { + const storagePath = options?.storagePath ?? './data/calendar-data.json'; + const defaultData: CalendarData = { events: [], calendars: [] }; + + return { + module: CalendarModule, + providers: [ + { + provide: CALENDAR_STORAGE_PROVIDER, + useValue: + options?.storageProvider ?? new FileStorageProvider(storagePath, defaultData), + }, + CalendarService, + ], + exports: [CalendarService], + }; + } + + /** + * Register with custom storage provider + */ + static forRoot(storageProvider: StorageProvider): DynamicModule { + return { + module: CalendarModule, + providers: [ + { + provide: CALENDAR_STORAGE_PROVIDER, + useValue: storageProvider, + }, + CalendarService, + ], + exports: [CalendarService], + }; + } +} diff --git a/packages/bot-services/src/calendar/calendar.service.ts b/packages/bot-services/src/calendar/calendar.service.ts new file mode 100644 index 000000000..318cd7555 --- /dev/null +++ b/packages/bot-services/src/calendar/calendar.service.ts @@ -0,0 +1,328 @@ +import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common'; +import { StorageProvider } from '../shared/types'; +import { FileStorageProvider } from '../shared/storage'; +import { + generateId, + startOfDay, + endOfDay, + addDays, + isToday, + isTomorrow, + formatDateDE, + formatTimeDE, +} from '../shared/utils'; +import { + CalendarEvent, + Calendar, + CalendarData, + CreateEventInput, + UpdateEventInput, + EventFilter, + ParsedEventInput, +} from './types'; + +export const CALENDAR_STORAGE_PROVIDER = 'CALENDAR_STORAGE_PROVIDER'; + +@Injectable() +export class CalendarService implements OnModuleInit { + private readonly logger = new Logger(CalendarService.name); + private data: CalendarData = { events: [], calendars: [] }; + private storage: StorageProvider; + + constructor( + @Optional() + @Inject(CALENDAR_STORAGE_PROVIDER) + storage?: StorageProvider + ) { + this.storage = + storage || + new FileStorageProvider('./data/calendar-data.json', { events: [], calendars: [] }); + } + + async onModuleInit() { + await this.loadData(); + } + + private async loadData(): Promise { + try { + this.data = await this.storage.load(); + this.logger.log(`Loaded ${this.data.events.length} events, ${this.data.calendars.length} calendars`); + } catch (error) { + this.logger.error('Failed to load calendar data:', error); + this.data = { events: [], calendars: [] }; + } + } + + private async saveData(): Promise { + try { + await this.storage.save(this.data); + } catch (error) { + this.logger.error('Failed to save calendar data:', error); + } + } + + private ensureDefaultCalendar(userId: string): Calendar { + let calendar = this.data.calendars.find((c) => c.userId === userId); + if (!calendar) { + calendar = { + id: generateId(), + name: 'Mein Kalender', + color: '#3B82F6', + userId, + }; + this.data.calendars.push(calendar); + this.saveData(); + } + return calendar; + } + + // ===== Event CRUD Operations ===== + + async createEvent(userId: string, input: CreateEventInput): Promise { + const calendar = this.ensureDefaultCalendar(userId); + + const event: CalendarEvent = { + id: generateId(), + userId, + title: input.title, + description: input.description ?? null, + location: input.location ?? null, + startTime: input.startTime.toISOString(), + endTime: input.endTime.toISOString(), + isAllDay: input.isAllDay ?? false, + calendarId: input.calendarId ?? calendar.id, + calendarName: calendar.name, + createdAt: new Date().toISOString(), + }; + + this.data.events.push(event); + await this.saveData(); + this.logger.log(`Created event "${event.title}" for user ${userId}`); + return event; + } + + async updateEvent(userId: string, eventId: string, input: UpdateEventInput): Promise { + const event = this.data.events.find((e) => e.id === eventId && e.userId === userId); + if (!event) return null; + + if (input.title !== undefined) event.title = input.title; + if (input.startTime !== undefined) event.startTime = input.startTime.toISOString(); + if (input.endTime !== undefined) event.endTime = input.endTime.toISOString(); + if (input.description !== undefined) event.description = input.description; + if (input.location !== undefined) event.location = input.location; + if (input.isAllDay !== undefined) event.isAllDay = input.isAllDay; + event.updatedAt = new Date().toISOString(); + + await this.saveData(); + return event; + } + + async deleteEvent(userId: string, eventId: string): Promise { + const eventIndex = this.data.events.findIndex((e) => e.id === eventId && e.userId === userId); + if (eventIndex === -1) return null; + + const [event] = this.data.events.splice(eventIndex, 1); + await this.saveData(); + this.logger.log(`Deleted event "${event.title}" for user ${userId}`); + return event; + } + + async deleteEventByIndex(userId: string, index: number): Promise { + const events = await this.getUpcomingEvents(userId, 30); + if (index < 1 || index > events.length) return null; + + const event = events[index - 1]; + return this.deleteEvent(userId, event.id); + } + + // ===== Event Queries ===== + + async getEvent(userId: string, eventId: string): Promise { + return this.data.events.find((e) => e.id === eventId && e.userId === userId) ?? null; + } + + async getEventByIndex(userId: string, index: number): Promise { + const events = await this.getUpcomingEvents(userId, 30); + if (index < 1 || index > events.length) return null; + return events[index - 1]; + } + + async getEvents(userId: string, filter?: EventFilter): Promise { + let events = this.data.events.filter((e) => e.userId === userId); + + if (filter) { + if (filter.calendarId) { + events = events.filter((e) => e.calendarId === filter.calendarId); + } + if (filter.startAfter) { + events = events.filter((e) => new Date(e.startTime) >= filter.startAfter!); + } + if (filter.startBefore) { + events = events.filter((e) => new Date(e.startTime) <= filter.startBefore!); + } + if (filter.isAllDay !== undefined) { + events = events.filter((e) => e.isAllDay === filter.isAllDay); + } + } + + return events.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + } + + async getEventsInRange(userId: string, start: Date, end: Date): Promise { + return this.data.events + .filter((e) => { + if (e.userId !== userId) return false; + const eventStart = new Date(e.startTime); + const eventEnd = new Date(e.endTime); + return eventStart < end && eventEnd > start; + }) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + } + + async getTodayEvents(userId: string): Promise { + const today = startOfDay(); + const tomorrow = addDays(today, 1); + return this.getEventsInRange(userId, today, tomorrow); + } + + async getTomorrowEvents(userId: string): Promise { + const tomorrow = startOfDay(addDays(new Date(), 1)); + const dayAfter = addDays(tomorrow, 1); + return this.getEventsInRange(userId, tomorrow, dayAfter); + } + + async getWeekEvents(userId: string): Promise { + const today = startOfDay(); + const weekEnd = addDays(today, 7); + return this.getEventsInRange(userId, today, weekEnd); + } + + async getUpcomingEvents(userId: string, days: number = 7): Promise { + const now = new Date(); + const endDate = addDays(now, days); + return this.getEventsInRange(userId, now, endDate); + } + + // ===== Calendars ===== + + async getCalendars(userId: string): Promise { + this.ensureDefaultCalendar(userId); + return this.data.calendars.filter((c) => c.userId === userId); + } + + async createCalendar(userId: string, name: string, color?: string): Promise { + const calendar: Calendar = { + id: generateId(), + name, + color: color ?? '#808080', + userId, + }; + this.data.calendars.push(calendar); + await this.saveData(); + return calendar; + } + + // ===== Formatting ===== + + formatEventTime(event: CalendarEvent): string { + const start = new Date(event.startTime); + + let dateStr: string; + if (isToday(start)) { + dateStr = 'Heute'; + } else if (isTomorrow(start)) { + dateStr = 'Morgen'; + } else { + dateStr = formatDateDE(start, { weekday: 'short', day: '2-digit', month: '2-digit' }); + } + + if (event.isAllDay) { + return `${dateStr} (ganztägig)`; + } + + return `${dateStr}, ${formatTimeDE(start)}`; + } + + // ===== Input Parsing ===== + + /** + * Parse natural language event input + * Supports: "am DD.MM.", "heute/morgen/übermorgen", "um HH:MM", "ganztägig" + */ + parseEventInput(input: string): ParsedEventInput { + let title = input; + let startTime: Date | null = null; + let endTime: Date | null = null; + let isAllDay = false; + + const now = new Date(); + + // Check for "ganztägig" (all-day) + if (/ganztägig/i.test(title)) { + isAllDay = true; + title = title.replace(/ganztägig/gi, '').trim(); + } + + // Parse date patterns + // "am DD.MM." or "am DD.MM.YYYY" + const dateMatch = title.match(/am\s+(\d{1,2})\.(\d{1,2})\.?(\d{4})?/i); + // "heute", "morgen", "übermorgen" + const relativeMatch = title.match(/(heute|morgen|übermorgen)/i); + // Time: "um HH:MM" or "um HH Uhr" + const timeMatch = title.match(/um\s+(\d{1,2})[:.]?(\d{2})?\s*(uhr)?/i); + + if (dateMatch) { + const day = parseInt(dateMatch[1]); + const month = parseInt(dateMatch[2]) - 1; + const year = dateMatch[3] ? parseInt(dateMatch[3]) : now.getFullYear(); + + startTime = new Date(year, month, day); + + // If date is in the past this year, assume next year + if (startTime < now && !dateMatch[3]) { + startTime.setFullYear(startTime.getFullYear() + 1); + } + + title = title.replace(/am\s+\d{1,2}\.\d{1,2}\.?\d{0,4}/i, '').trim(); + } else if (relativeMatch) { + const relative = relativeMatch[1].toLowerCase(); + startTime = startOfDay(); + + if (relative === 'morgen') { + startTime = addDays(startTime, 1); + } else if (relative === 'übermorgen') { + startTime = addDays(startTime, 2); + } + + title = title.replace(/(heute|morgen|übermorgen)/i, '').trim(); + } + + if (timeMatch && startTime) { + const hours = parseInt(timeMatch[1]); + const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0; + + startTime.setHours(hours, minutes, 0, 0); + isAllDay = false; + + title = title.replace(/um\s+\d{1,2}[:.]?\d{0,2}\s*(uhr)?/i, '').trim(); + } else if (startTime && !isAllDay) { + // Default to 9:00 if no time specified + startTime.setHours(9, 0, 0, 0); + } + + // Set end time (1 hour later for timed events, end of day for all-day) + if (startTime) { + endTime = new Date(startTime); + if (isAllDay) { + endTime = endOfDay(startTime); + } else { + endTime.setHours(endTime.getHours() + 1); + } + } + + // Clean up title + title = title.replace(/\s+/g, ' ').trim(); + + return { title, startTime, endTime, isAllDay }; + } +} diff --git a/packages/bot-services/src/calendar/index.ts b/packages/bot-services/src/calendar/index.ts new file mode 100644 index 000000000..4c713ba1f --- /dev/null +++ b/packages/bot-services/src/calendar/index.ts @@ -0,0 +1,8 @@ +// Module +export { CalendarModule, CalendarModuleOptions } from './calendar.module'; + +// Service +export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service'; + +// Types +export * from './types'; diff --git a/packages/bot-services/src/calendar/types.ts b/packages/bot-services/src/calendar/types.ts new file mode 100644 index 000000000..48877c41c --- /dev/null +++ b/packages/bot-services/src/calendar/types.ts @@ -0,0 +1,78 @@ +import { UserEntity } from '../shared/types'; + +/** + * Calendar event entity + */ +export interface CalendarEvent extends UserEntity { + title: string; + description: string | null; + location: string | null; + startTime: string; // ISO datetime + endTime: string; // ISO datetime + isAllDay: boolean; + calendarId: string; + calendarName: string; +} + +/** + * Calendar entity + */ +export interface Calendar { + id: string; + name: string; + color: string; + userId: string; +} + +/** + * Calendar data storage structure + */ +export interface CalendarData { + events: CalendarEvent[]; + calendars: Calendar[]; +} + +/** + * Create event input + */ +export interface CreateEventInput { + title: string; + startTime: Date; + endTime: Date; + description?: string | null; + location?: string | null; + isAllDay?: boolean; + calendarId?: string; +} + +/** + * Update event input + */ +export interface UpdateEventInput { + title?: string; + startTime?: Date; + endTime?: Date; + description?: string | null; + location?: string | null; + isAllDay?: boolean; +} + +/** + * Event filter options + */ +export interface EventFilter { + calendarId?: string; + startAfter?: Date; + startBefore?: Date; + isAllDay?: boolean; +} + +/** + * Parsed event input (from natural language) + */ +export interface ParsedEventInput { + title: string; + startTime: Date | null; + endTime: Date | null; + isAllDay: boolean; +} diff --git a/packages/bot-services/src/clock/clock.module.ts b/packages/bot-services/src/clock/clock.module.ts new file mode 100644 index 000000000..cd10c275d --- /dev/null +++ b/packages/bot-services/src/clock/clock.module.ts @@ -0,0 +1,45 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { ClockService } from './clock.service'; +import { ClockServiceConfig } from './types'; + +export interface ClockModuleOptions extends Partial {} + +@Module({}) +export class ClockModule { + /** + * Register with default configuration (uses environment variables) + */ + static register(options?: ClockModuleOptions): DynamicModule { + return { + module: ClockModule, + providers: [ + { + provide: 'CLOCK_SERVICE_CONFIG', + useValue: options ?? {}, + }, + { + provide: ClockService, + useFactory: (config: Partial) => new ClockService(config), + inject: ['CLOCK_SERVICE_CONFIG'], + }, + ], + exports: [ClockService], + }; + } + + /** + * Register with explicit configuration + */ + static forRoot(config: ClockServiceConfig): DynamicModule { + return { + module: ClockModule, + providers: [ + { + provide: ClockService, + useFactory: () => new ClockService(config), + }, + ], + exports: [ClockService], + }; + } +} diff --git a/packages/bot-services/src/clock/clock.service.ts b/packages/bot-services/src/clock/clock.service.ts new file mode 100644 index 000000000..a52b24a70 --- /dev/null +++ b/packages/bot-services/src/clock/clock.service.ts @@ -0,0 +1,262 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Timer, + Alarm, + WorldClock, + TimezoneResult, + CreateTimerInput, + CreateAlarmInput, + CreateWorldClockInput, + ClockServiceConfig, + TimeTrackingSummary, +} from './types'; + +@Injectable() +export class ClockService { + private readonly logger = new Logger(ClockService.name); + private readonly apiUrl: string; + + // In-memory token storage per user + private userTokens: Map = new Map(); + + constructor(config?: Partial) { + this.apiUrl = config?.apiUrl ?? process.env.CLOCK_API_URL ?? 'http://localhost:3017/api/v1'; + this.logger.log(`Clock API URL: ${this.apiUrl}`); + } + + // ===== Auth Token Management ===== + + setUserToken(userId: string, token: string): void { + this.userTokens.set(userId, token); + } + + getUserToken(userId: string): string | undefined { + return this.userTokens.get(userId); + } + + // ===== API Helper ===== + + private async apiCall(endpoint: string, method: string = 'GET', token?: string, body?: unknown): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${this.apiUrl}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Clock API error: ${response.status} - ${errorText}`); + } + + return response.json(); + } + + // ===== Health ===== + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`); + return response.ok; + } catch { + return false; + } + } + + // ===== Timers ===== + + async getTimers(token: string): Promise { + return this.apiCall('/timers', 'GET', token); + } + + async getTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}`, 'GET', token); + } + + async createTimer(input: CreateTimerInput, token: string): Promise { + return this.apiCall('/timers', 'POST', token, { + durationSeconds: input.durationSeconds, + label: input.label, + }); + } + + async startTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}/start`, 'POST', token); + } + + async pauseTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}/pause`, 'POST', token); + } + + async resetTimer(id: string, token: string): Promise { + return this.apiCall(`/timers/${id}/reset`, 'POST', token); + } + + async deleteTimer(id: string, token: string): Promise { + await this.apiCall(`/timers/${id}`, 'DELETE', token); + } + + async getRunningTimer(token: string): Promise { + const timers = await this.getTimers(token); + return timers.find((t) => t.status === 'running' || t.status === 'paused') || null; + } + + // ===== Alarms ===== + + async getAlarms(token: string): Promise { + return this.apiCall('/alarms', 'GET', token); + } + + async createAlarm(input: CreateAlarmInput, token: string): Promise { + return this.apiCall('/alarms', 'POST', token, { + time: input.time, + label: input.label, + enabled: true, + repeatDays: input.repeatDays, + }); + } + + async toggleAlarm(id: string, token: string): Promise { + return this.apiCall(`/alarms/${id}/toggle`, 'PATCH', token); + } + + async deleteAlarm(id: string, token: string): Promise { + await this.apiCall(`/alarms/${id}`, 'DELETE', token); + } + + // ===== World Clocks ===== + + async getWorldClocks(token: string): Promise { + return this.apiCall('/world-clocks', 'GET', token); + } + + async addWorldClock(input: CreateWorldClockInput, token: string): Promise { + return this.apiCall('/world-clocks', 'POST', token, { + timezone: input.timezone, + cityName: input.cityName, + }); + } + + async deleteWorldClock(id: string, token: string): Promise { + await this.apiCall(`/world-clocks/${id}`, 'DELETE', token); + } + + // ===== Timezone Search ===== + + async searchTimezones(query: string): Promise { + return this.apiCall(`/timezones/search?q=${encodeURIComponent(query)}`); + } + + // ===== Time Tracking Summary ===== + + async getTodayTracked(token: string): Promise { + // This would aggregate timer data for today + // For now, return a placeholder - implement based on actual API + const timers = await this.getTimers(token); + const finishedToday = timers.filter((t) => { + if (t.status !== 'finished') return false; + const finishedAt = new Date(t.updatedAt); + const today = new Date(); + return finishedAt.toDateString() === today.toDateString(); + }); + + const totalMinutes = finishedToday.reduce((sum, t) => sum + Math.floor(t.durationSeconds / 60), 0); + + return { + totalMinutes, + sessions: finishedToday.length, + }; + } + + // ===== Parsing Utilities ===== + + /** + * Parse duration string to seconds + * Supports: "25m", "1h30m", "90s", "25" (assumes minutes) + */ + parseDuration(input: string): number | null { + let totalSeconds = 0; + + // Match hours + const hoursMatch = input.match(/(\d+)\s*h/i); + if (hoursMatch) { + totalSeconds += parseInt(hoursMatch[1], 10) * 3600; + } + + // Match minutes + const minutesMatch = input.match(/(\d+)\s*m(?:in)?/i); + if (minutesMatch) { + totalSeconds += parseInt(minutesMatch[1], 10) * 60; + } + + // Match seconds + const secondsMatch = input.match(/(\d+)\s*s(?:ec)?/i); + if (secondsMatch) { + totalSeconds += parseInt(secondsMatch[1], 10); + } + + // If just a number, assume minutes + if (totalSeconds === 0) { + const justNumber = input.match(/^(\d+)$/); + if (justNumber) { + totalSeconds = parseInt(justNumber[1], 10) * 60; + } + } + + return totalSeconds > 0 ? totalSeconds : null; + } + + /** + * Parse time string to HH:MM:SS + * Supports: "14:30", "9:00", "14 Uhr 30" + */ + parseAlarmTime(input: string): string | null { + // Try HH:MM format + let match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/); + if (match) { + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + const seconds = match[3] ? parseInt(match[3], 10) : 0; + + if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + } + + // Try "X Uhr Y" format (German) + match = input.match(/(\d{1,2})\s*uhr(?:\s*(\d{1,2}))?/i); + if (match) { + const hours = parseInt(match[1], 10); + const minutes = match[2] ? parseInt(match[2], 10) : 0; + + if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; + } + } + + return null; + } + + /** + * Format seconds to human readable + */ + formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(' '); + } +} diff --git a/packages/bot-services/src/clock/index.ts b/packages/bot-services/src/clock/index.ts new file mode 100644 index 000000000..b7d84fc18 --- /dev/null +++ b/packages/bot-services/src/clock/index.ts @@ -0,0 +1,8 @@ +// Module +export { ClockModule, ClockModuleOptions } from './clock.module'; + +// Service +export { ClockService } from './clock.service'; + +// Types +export * from './types'; diff --git a/packages/bot-services/src/clock/types.ts b/packages/bot-services/src/clock/types.ts new file mode 100644 index 000000000..090546698 --- /dev/null +++ b/packages/bot-services/src/clock/types.ts @@ -0,0 +1,97 @@ +/** + * Clock service types + */ + +/** + * Timer entity + */ +export interface Timer { + id: string; + userId: string; + label: string | null; + durationSeconds: number; + remainingSeconds: number; + status: 'idle' | 'running' | 'paused' | 'finished'; + startedAt: string | null; + pausedAt: string | null; + sound: string; + createdAt: string; + updatedAt: string; +} + +/** + * Alarm entity + */ +export interface Alarm { + id: string; + userId: string; + label: string | null; + time: string; // HH:MM:SS + enabled: boolean; + repeatDays: number[]; // 0-6, Sunday = 0 + snoozeMinutes: number; + sound: string; + vibrate: boolean; + createdAt: string; + updatedAt: string; +} + +/** + * World clock entity + */ +export interface WorldClock { + id: string; + userId: string; + timezone: string; + cityName: string; + sortOrder: number; + createdAt: string; +} + +/** + * Timezone search result + */ +export interface TimezoneResult { + timezone: string; + city: string; +} + +/** + * Create timer input + */ +export interface CreateTimerInput { + durationSeconds: number; + label?: string | null; +} + +/** + * Create alarm input + */ +export interface CreateAlarmInput { + time: string; // HH:MM:SS + label?: string | null; + repeatDays?: number[]; +} + +/** + * Create world clock input + */ +export interface CreateWorldClockInput { + timezone: string; + cityName: string; +} + +/** + * Clock service configuration + */ +export interface ClockServiceConfig { + apiUrl: string; +} + +/** + * Time tracking summary + */ +export interface TimeTrackingSummary { + totalMinutes: number; + sessions: number; +} diff --git a/packages/bot-services/src/docs/index.ts b/packages/bot-services/src/docs/index.ts new file mode 100644 index 000000000..fa791eb39 --- /dev/null +++ b/packages/bot-services/src/docs/index.ts @@ -0,0 +1,25 @@ +// Placeholder - to be implemented +// Will integrate with project documentation generation + +export interface DocsServiceConfig { + openaiApiKey?: string; + s3Config?: { + endpoint: string; + bucket: string; + accessKey: string; + secretKey: string; + }; +} + +export interface ProjectDoc { + id: string; + title: string; + content: string; + format: 'blog' | 'summary' | 'technical'; + createdAt: string; +} + +// Export placeholder module +export const DocsModule = { + register: () => ({ module: class {}, providers: [], exports: [] }), +}; diff --git a/packages/bot-services/src/index.ts b/packages/bot-services/src/index.ts new file mode 100644 index 000000000..cbe209317 --- /dev/null +++ b/packages/bot-services/src/index.ts @@ -0,0 +1,113 @@ +/** + * @manacore/bot-services + * + * Shared business logic services for Matrix bots and the Gateway. + * These services are transport-agnostic and can be used by: + * - Individual Matrix bots (standalone) + * - The Gateway bot (all-in-one) + * - REST APIs + * - CLI tools + * + * @example + * ```typescript + * import { TodoModule, TodoService } from '@manacore/bot-services/todo'; + * import { AiModule, AiService } from '@manacore/bot-services/ai'; + * + * // In NestJS module + * @Module({ + * imports: [ + * TodoModule.register({ storagePath: './data/todos.json' }), + * AiModule.register({ baseUrl: 'http://ollama:11434' }), + * ], + * }) + * export class AppModule {} + * ``` + */ + +// ===== Core Services ===== + +// Todo +export { TodoModule, TodoModuleOptions, TodoService, TODO_STORAGE_PROVIDER } from './todo'; +export type { + Task, + Project, + TodoData, + CreateTaskInput, + UpdateTaskInput, + TaskFilter, + TodoStats, + ParsedTaskInput, +} from './todo'; + +// Calendar +export { CalendarModule, CalendarModuleOptions, CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar'; +export type { + CalendarEvent, + Calendar, + CalendarData, + CreateEventInput, + UpdateEventInput, + EventFilter, + ParsedEventInput, +} from './calendar'; + +// AI (Ollama) +export { AiModule, AiModuleOptions, AiService } from './ai'; +export type { + OllamaModel, + ChatMessage, + ChatOptions, + ChatResult, + ChatResponseMeta, + AiServiceConfig, + UserAiSession, + SystemPromptPreset, +} from './ai'; +export { SYSTEM_PROMPTS, VISION_MODELS, NON_CHAT_MODELS } from './ai'; + +// Clock +export { ClockModule, ClockModuleOptions, ClockService } from './clock'; +export type { + Timer, + Alarm, + WorldClock, + TimezoneResult, + CreateTimerInput, + CreateAlarmInput, + CreateWorldClockInput, + ClockServiceConfig, + TimeTrackingSummary, +} from './clock'; + +// ===== Placeholder Services (to be implemented) ===== + +export { NutritionModule } from './nutrition'; +export type { NutritionServiceConfig, Meal, NutritionSummary } from './nutrition'; + +export { QuotesModule } from './quotes'; +export type { QuotesServiceConfig, Quote } from './quotes'; + +export { StatsModule } from './stats'; +export type { StatsServiceConfig, AnalyticsReport } from './stats'; + +export { DocsModule } from './docs'; +export type { DocsServiceConfig, ProjectDoc } from './docs'; + +// ===== Shared Utilities ===== + +export { FileStorageProvider, MemoryStorageProvider } from './shared'; +export type { StorageProvider, BaseEntity, UserEntity, ServiceConfig, Result, PaginationOptions, PaginatedResult, DateRange, Priority, ServiceStats } from './shared'; +export { + generateId, + getTodayISO, + startOfDay, + endOfDay, + addDays, + formatDateDE, + formatTimeDE, + isToday, + isTomorrow, + parseGermanDateKeyword, + getRelativeDateLabel, +} from './shared'; +export { PRIORITY_VALUES } from './shared'; diff --git a/packages/bot-services/src/nutrition/index.ts b/packages/bot-services/src/nutrition/index.ts new file mode 100644 index 000000000..2aa2eee38 --- /dev/null +++ b/packages/bot-services/src/nutrition/index.ts @@ -0,0 +1,25 @@ +// Placeholder - to be implemented +// Will integrate with NutriPhi backend API + +export interface NutritionServiceConfig { + apiUrl: string; +} + +export interface Meal { + id: string; + userId: string; + description: string; + calories: number; + createdAt: string; +} + +export interface NutritionSummary { + totalCalories: number; + mealCount: number; + meals: Meal[]; +} + +// Export placeholder module +export const NutritionModule = { + register: () => ({ module: class {}, providers: [], exports: [] }), +}; diff --git a/packages/bot-services/src/quotes/index.ts b/packages/bot-services/src/quotes/index.ts new file mode 100644 index 000000000..00659db68 --- /dev/null +++ b/packages/bot-services/src/quotes/index.ts @@ -0,0 +1,18 @@ +// Placeholder - to be implemented +// Will integrate with Zitare backend API + +export interface QuotesServiceConfig { + apiUrl: string; +} + +export interface Quote { + id: string; + text: string; + author: string; + category: string; +} + +// Export placeholder module +export const QuotesModule = { + register: () => ({ module: class {}, providers: [], exports: [] }), +}; diff --git a/packages/bot-services/src/shared/index.ts b/packages/bot-services/src/shared/index.ts new file mode 100644 index 000000000..f3d1741d5 --- /dev/null +++ b/packages/bot-services/src/shared/index.ts @@ -0,0 +1,8 @@ +// Shared types +export * from './types'; + +// Storage providers +export { FileStorageProvider, MemoryStorageProvider } from './storage'; + +// Utility functions +export * from './utils'; diff --git a/packages/bot-services/src/shared/storage.ts b/packages/bot-services/src/shared/storage.ts new file mode 100644 index 000000000..ec8a252ce --- /dev/null +++ b/packages/bot-services/src/shared/storage.ts @@ -0,0 +1,71 @@ +import { Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { StorageProvider } from './types'; + +/** + * File-based JSON storage provider + * Used for local GDPR-compliant data storage + */ +export class FileStorageProvider implements StorageProvider { + private readonly logger = new Logger(FileStorageProvider.name); + private readonly filePath: string; + private readonly defaultData: T; + + constructor(filePath: string, defaultData: T) { + this.filePath = filePath; + this.defaultData = defaultData; + } + + async load(): Promise { + try { + const dir = path.dirname(this.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this.filePath)) { + const content = fs.readFileSync(this.filePath, 'utf-8'); + return JSON.parse(content); + } else { + await this.save(this.defaultData); + return this.defaultData; + } + } catch (error) { + this.logger.error(`Failed to load data from ${this.filePath}:`, error); + return this.defaultData; + } + } + + async save(data: T): Promise { + try { + const dir = path.dirname(this.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2)); + } catch (error) { + this.logger.error(`Failed to save data to ${this.filePath}:`, error); + throw error; + } + } +} + +/** + * In-memory storage provider (for testing) + */ +export class MemoryStorageProvider implements StorageProvider { + private data: T; + + constructor(defaultData: T) { + this.data = defaultData; + } + + async load(): Promise { + return this.data; + } + + async save(data: T): Promise { + this.data = data; + } +} diff --git a/packages/bot-services/src/shared/types.ts b/packages/bot-services/src/shared/types.ts new file mode 100644 index 000000000..a956f15df --- /dev/null +++ b/packages/bot-services/src/shared/types.ts @@ -0,0 +1,66 @@ +/** + * Common types used across all bot services + */ + +// Base entity interface +export interface BaseEntity { + id: string; + createdAt: string; + updatedAt?: string; +} + +// User-scoped entity +export interface UserEntity extends BaseEntity { + userId: string; +} + +// Storage provider interface - allows swapping file/db storage +export interface StorageProvider { + load(): Promise; + save(data: T): Promise; +} + +// Service configuration +export interface ServiceConfig { + storagePath?: string; + apiUrl?: string; + timeout?: number; +} + +// Result type for operations +export type Result = { success: true; data: T } | { success: false; error: E }; + +// Pagination +export interface PaginationOptions { + limit?: number; + offset?: number; +} + +export interface PaginatedResult { + items: T[]; + total: number; + limit: number; + offset: number; +} + +// Date range filter +export interface DateRange { + start: Date; + end: Date; +} + +// Priority levels +export type Priority = 'low' | 'medium' | 'high' | 'urgent'; +export const PRIORITY_VALUES: Record = { + urgent: 1, + high: 2, + medium: 3, + low: 4, +}; + +// Common stats interface +export interface ServiceStats { + total: number; + active: number; + completed?: number; +} diff --git a/packages/bot-services/src/shared/utils.ts b/packages/bot-services/src/shared/utils.ts new file mode 100644 index 000000000..bbdb65251 --- /dev/null +++ b/packages/bot-services/src/shared/utils.ts @@ -0,0 +1,110 @@ +/** + * Utility functions for bot services + */ + +/** + * Generate a unique ID + */ +export function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); +} + +/** + * Get ISO date string for today + */ +export function getTodayISO(): string { + return new Date().toISOString().split('T')[0]; +} + +/** + * Get date at start of day + */ +export function startOfDay(date: Date = new Date()): Date { + const result = new Date(date); + result.setHours(0, 0, 0, 0); + return result; +} + +/** + * Get date at end of day + */ +export function endOfDay(date: Date = new Date()): Date { + const result = new Date(date); + result.setHours(23, 59, 59, 999); + return result; +} + +/** + * Add days to a date + */ +export function addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +/** + * Format date for German locale + */ +export function formatDateDE(date: Date, options?: Intl.DateTimeFormatOptions): string { + return date.toLocaleDateString('de-DE', options); +} + +/** + * Format time for German locale + */ +export function formatTimeDE(date: Date): string { + return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +/** + * Check if date is today + */ +export function isToday(date: Date): boolean { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); +} + +/** + * Check if date is tomorrow + */ +export function isTomorrow(date: Date): boolean { + const tomorrow = addDays(new Date(), 1); + return ( + date.getDate() === tomorrow.getDate() && + date.getMonth() === tomorrow.getMonth() && + date.getFullYear() === tomorrow.getFullYear() + ); +} + +/** + * Parse German date keywords + */ +export function parseGermanDateKeyword(keyword: string): Date | null { + const lower = keyword.toLowerCase().trim(); + const today = startOfDay(); + + switch (lower) { + case 'heute': + return today; + case 'morgen': + return addDays(today, 1); + case 'übermorgen': + return addDays(today, 2); + default: + return null; + } +} + +/** + * Get relative date label in German + */ +export function getRelativeDateLabel(date: Date): string { + if (isToday(date)) return 'Heute'; + if (isTomorrow(date)) return 'Morgen'; + return formatDateDE(date, { weekday: 'short', day: '2-digit', month: '2-digit' }); +} diff --git a/packages/bot-services/src/stats/index.ts b/packages/bot-services/src/stats/index.ts new file mode 100644 index 000000000..0970825f1 --- /dev/null +++ b/packages/bot-services/src/stats/index.ts @@ -0,0 +1,20 @@ +// Placeholder - to be implemented +// Will integrate with Umami analytics API + +export interface StatsServiceConfig { + apiUrl: string; + username: string; + password: string; +} + +export interface AnalyticsReport { + pageviews: number; + visitors: number; + bounceRate: number; + avgDuration: number; +} + +// Export placeholder module +export const StatsModule = { + register: () => ({ module: class {}, providers: [], exports: [] }), +}; diff --git a/packages/bot-services/src/todo/index.ts b/packages/bot-services/src/todo/index.ts new file mode 100644 index 000000000..3e65cef27 --- /dev/null +++ b/packages/bot-services/src/todo/index.ts @@ -0,0 +1,8 @@ +// Module +export { TodoModule, TodoModuleOptions } from './todo.module'; + +// Service +export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service'; + +// Types +export * from './types'; diff --git a/packages/bot-services/src/todo/todo.module.ts b/packages/bot-services/src/todo/todo.module.ts new file mode 100644 index 000000000..b65837bd3 --- /dev/null +++ b/packages/bot-services/src/todo/todo.module.ts @@ -0,0 +1,50 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { TodoService, TODO_STORAGE_PROVIDER } from './todo.service'; +import { StorageProvider } from '../shared/types'; +import { FileStorageProvider } from '../shared/storage'; +import { TodoData } from './types'; + +export interface TodoModuleOptions { + storagePath?: string; + storageProvider?: StorageProvider; +} + +@Module({}) +export class TodoModule { + /** + * Register with default file storage + */ + static register(options?: TodoModuleOptions): DynamicModule { + const storagePath = options?.storagePath ?? './data/todo-data.json'; + const defaultData: TodoData = { tasks: [], projects: [] }; + + return { + module: TodoModule, + providers: [ + { + provide: TODO_STORAGE_PROVIDER, + useValue: options?.storageProvider ?? new FileStorageProvider(storagePath, defaultData), + }, + TodoService, + ], + exports: [TodoService], + }; + } + + /** + * Register with custom storage provider + */ + static forRoot(storageProvider: StorageProvider): DynamicModule { + return { + module: TodoModule, + providers: [ + { + provide: TODO_STORAGE_PROVIDER, + useValue: storageProvider, + }, + TodoService, + ], + exports: [TodoService], + }; + } +} diff --git a/packages/bot-services/src/todo/todo.service.ts b/packages/bot-services/src/todo/todo.service.ts new file mode 100644 index 000000000..cd91b7286 --- /dev/null +++ b/packages/bot-services/src/todo/todo.service.ts @@ -0,0 +1,294 @@ +import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common'; +import { StorageProvider } from '../shared/types'; +import { FileStorageProvider } from '../shared/storage'; +import { generateId, getTodayISO, parseGermanDateKeyword, addDays } from '../shared/utils'; +import { + Task, + Project, + TodoData, + CreateTaskInput, + UpdateTaskInput, + TaskFilter, + TodoStats, + ParsedTaskInput, +} from './types'; + +export const TODO_STORAGE_PROVIDER = 'TODO_STORAGE_PROVIDER'; + +@Injectable() +export class TodoService implements OnModuleInit { + private readonly logger = new Logger(TodoService.name); + private data: TodoData = { tasks: [], projects: [] }; + private storage: StorageProvider; + + constructor( + @Optional() + @Inject(TODO_STORAGE_PROVIDER) + storage?: StorageProvider + ) { + // Default to file storage if not injected + this.storage = + storage || new FileStorageProvider('./data/todo-data.json', { tasks: [], projects: [] }); + } + + async onModuleInit() { + await this.loadData(); + } + + private async loadData(): Promise { + try { + this.data = await this.storage.load(); + this.logger.log(`Loaded ${this.data.tasks.length} tasks, ${this.data.projects.length} projects`); + } catch (error) { + this.logger.error('Failed to load todo data:', error); + this.data = { tasks: [], projects: [] }; + } + } + + private async saveData(): Promise { + try { + await this.storage.save(this.data); + } catch (error) { + this.logger.error('Failed to save todo data:', error); + } + } + + // ===== Task CRUD Operations ===== + + async createTask(userId: string, input: CreateTaskInput): Promise { + const task: Task = { + id: generateId(), + userId, + title: input.title, + completed: false, + priority: input.priority ?? 4, + dueDate: input.dueDate ?? null, + project: input.project ?? null, + labels: input.labels ?? [], + createdAt: new Date().toISOString(), + completedAt: null, + }; + + this.data.tasks.push(task); + await this.saveData(); + this.logger.log(`Created task "${task.title}" for user ${userId}`); + return task; + } + + async updateTask(userId: string, taskId: string, input: UpdateTaskInput): Promise { + const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId); + if (!task) return null; + + if (input.title !== undefined) task.title = input.title; + if (input.priority !== undefined) task.priority = input.priority; + if (input.dueDate !== undefined) task.dueDate = input.dueDate; + if (input.project !== undefined) task.project = input.project; + if (input.labels !== undefined) task.labels = input.labels; + task.updatedAt = new Date().toISOString(); + + await this.saveData(); + return task; + } + + async deleteTask(userId: string, taskId: string): Promise { + const taskIndex = this.data.tasks.findIndex((t) => t.id === taskId && t.userId === userId); + if (taskIndex === -1) return null; + + const [task] = this.data.tasks.splice(taskIndex, 1); + await this.saveData(); + this.logger.log(`Deleted task "${task.title}" for user ${userId}`); + return task; + } + + async deleteTaskByIndex(userId: string, index: number): Promise { + const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed); + if (index < 1 || index > userTasks.length) return null; + + const task = userTasks[index - 1]; + return this.deleteTask(userId, task.id); + } + + // ===== Task Completion ===== + + async completeTask(userId: string, taskId: string): Promise { + const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId); + if (!task) return null; + + task.completed = true; + task.completedAt = new Date().toISOString(); + await this.saveData(); + this.logger.log(`Completed task "${task.title}" for user ${userId}`); + return task; + } + + async completeTaskByIndex(userId: string, index: number): Promise { + const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed); + if (index < 1 || index > userTasks.length) return null; + + const task = userTasks[index - 1]; + return this.completeTask(userId, task.id); + } + + async uncompleteTask(userId: string, taskId: string): Promise { + const task = this.data.tasks.find((t) => t.id === taskId && t.userId === userId); + if (!task) return null; + + task.completed = false; + task.completedAt = null; + await this.saveData(); + return task; + } + + // ===== Task Queries ===== + + async getTask(userId: string, taskId: string): Promise { + return this.data.tasks.find((t) => t.id === taskId && t.userId === userId) ?? null; + } + + async getTasks(userId: string, filter?: TaskFilter): Promise { + let tasks = this.data.tasks.filter((t) => t.userId === userId); + + if (filter) { + if (filter.completed !== undefined) { + tasks = tasks.filter((t) => t.completed === filter.completed); + } + if (filter.project) { + tasks = tasks.filter((t) => t.project?.toLowerCase() === filter.project!.toLowerCase()); + } + if (filter.dueDate) { + tasks = tasks.filter((t) => t.dueDate?.startsWith(filter.dueDate!)); + } + if (filter.dueBefore) { + tasks = tasks.filter((t) => t.dueDate && t.dueDate < filter.dueBefore!); + } + if (filter.dueAfter) { + tasks = tasks.filter((t) => t.dueDate && t.dueDate > filter.dueAfter!); + } + if (filter.priority) { + tasks = tasks.filter((t) => t.priority === filter.priority); + } + if (filter.labels && filter.labels.length > 0) { + tasks = tasks.filter((t) => filter.labels!.some((l) => t.labels.includes(l))); + } + } + + return tasks; + } + + async getAllPendingTasks(userId: string): Promise { + return this.data.tasks + .filter((t) => t.userId === userId && !t.completed) + .sort((a, b) => { + // Sort by due date first (nulls last), then by priority + if (a.dueDate && !b.dueDate) return -1; + if (!a.dueDate && b.dueDate) return 1; + if (a.dueDate && b.dueDate) { + const dateCompare = a.dueDate.localeCompare(b.dueDate); + if (dateCompare !== 0) return dateCompare; + } + return a.priority - b.priority; + }); + } + + async getTodayTasks(userId: string): Promise { + const today = getTodayISO(); + return this.data.tasks + .filter((t) => t.userId === userId && !t.completed && t.dueDate?.startsWith(today)) + .sort((a, b) => a.priority - b.priority); + } + + async getOverdueTasks(userId: string): Promise { + const today = getTodayISO(); + return this.data.tasks + .filter((t) => t.userId === userId && !t.completed && t.dueDate && t.dueDate < today) + .sort((a, b) => a.dueDate!.localeCompare(b.dueDate!)); + } + + async getInboxTasks(userId: string): Promise { + return this.data.tasks + .filter((t) => t.userId === userId && !t.completed && !t.dueDate && !t.project) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + async getProjectTasks(userId: string, projectName: string): Promise { + return this.data.tasks + .filter( + (t) => t.userId === userId && !t.completed && t.project?.toLowerCase() === projectName.toLowerCase() + ) + .sort((a, b) => a.priority - b.priority); + } + + // ===== Projects ===== + + async getProjects(userId: string): Promise { + const projectNames = new Set(); + this.data.tasks + .filter((t) => t.userId === userId && t.project) + .forEach((t) => projectNames.add(t.project!)); + + return Array.from(projectNames).map((name) => ({ + id: name.toLowerCase(), + name, + color: '#808080', + userId, + })); + } + + // ===== Statistics ===== + + async getStats(userId: string): Promise { + const userTasks = this.data.tasks.filter((t) => t.userId === userId); + const today = getTodayISO(); + + return { + total: userTasks.length, + completed: userTasks.filter((t) => t.completed).length, + pending: userTasks.filter((t) => !t.completed).length, + today: userTasks.filter((t) => !t.completed && t.dueDate?.startsWith(today)).length, + overdue: userTasks.filter((t) => !t.completed && t.dueDate && t.dueDate < today).length, + }; + } + + // ===== Input Parsing ===== + + /** + * Parse natural language task input + * Supports: !p1-4 (priority), @heute/@morgen/@übermorgen (date), #project + */ + parseTaskInput(input: string): ParsedTaskInput { + let title = input; + let priority = 4; + let dueDate: string | null = null; + let project: string | null = null; + + // Parse priority (!p1, !p2, !p3, !p4) + const priorityMatch = title.match(/!p([1-4])/i); + if (priorityMatch) { + priority = parseInt(priorityMatch[1]); + title = title.replace(/!p[1-4]/i, '').trim(); + } + + // Parse date (@heute, @morgen, @übermorgen) + const dateKeywords = ['heute', 'morgen', 'übermorgen']; + for (const keyword of dateKeywords) { + const regex = new RegExp(`@${keyword}`, 'i'); + if (regex.test(title)) { + const date = parseGermanDateKeyword(keyword); + if (date) { + dueDate = date.toISOString().split('T')[0]; + } + title = title.replace(regex, '').trim(); + break; + } + } + + // Parse project (#projektname) + const projectMatch = title.match(/#(\S+)/); + if (projectMatch) { + project = projectMatch[1]; + title = title.replace(/#\S+/, '').trim(); + } + + return { title, priority, dueDate, project }; + } +} diff --git a/packages/bot-services/src/todo/types.ts b/packages/bot-services/src/todo/types.ts new file mode 100644 index 000000000..d7d5f088d --- /dev/null +++ b/packages/bot-services/src/todo/types.ts @@ -0,0 +1,88 @@ +import { UserEntity, Priority } from '../shared/types'; + +/** + * Task entity + */ +export interface Task extends UserEntity { + title: string; + completed: boolean; + priority: number; // 1-4, 1 is highest (for backward compatibility) + dueDate: string | null; // ISO date string + project: string | null; + labels: string[]; + completedAt: string | null; +} + +/** + * Project entity + */ +export interface Project { + id: string; + name: string; + color: string; + userId: string; +} + +/** + * Todo data storage structure + */ +export interface TodoData { + tasks: Task[]; + projects: Project[]; +} + +/** + * Create task input + */ +export interface CreateTaskInput { + title: string; + priority?: number; + dueDate?: string | null; + project?: string | null; + labels?: string[]; +} + +/** + * Update task input + */ +export interface UpdateTaskInput { + title?: string; + priority?: number; + dueDate?: string | null; + project?: string | null; + labels?: string[]; +} + +/** + * Task filter options + */ +export interface TaskFilter { + completed?: boolean; + project?: string; + dueDate?: string; + dueBefore?: string; + dueAfter?: string; + priority?: number; + labels?: string[]; +} + +/** + * Todo statistics + */ +export interface TodoStats { + total: number; + completed: number; + pending: number; + today: number; + overdue: number; +} + +/** + * Parsed task input (from natural language) + */ +export interface ParsedTaskInput { + title: string; + priority: number; + dueDate: string | null; + project: string | null; +} diff --git a/packages/bot-services/tsconfig.json b/packages/bot-services/tsconfig.json new file mode 100644 index 000000000..7c9335e7b --- /dev/null +++ b/packages/bot-services/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}