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
This commit is contained in:
Claude 2026-01-29 00:07:32 +00:00
parent bea066c7f8
commit 68a6c7a8d6
No known key found for this signature in database
28 changed files with 2492 additions and 0 deletions

View file

@ -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<TodoData> {
async load(): Promise<TodoData> {
// Load from database
}
async save(data: TodoData): Promise<void> {
// 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
```

View file

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

View file

@ -0,0 +1,45 @@
import { Module, DynamicModule } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiServiceConfig } from './types';
export interface AiModuleOptions extends Partial<AiServiceConfig> {}
@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<AiServiceConfig>) => 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],
};
}
}

View file

@ -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<string, UserAiSession> = new Map();
constructor(config?: Partial<AiServiceConfig>) {
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<boolean> {
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<OllamaModel[]> {
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<OllamaModel[]> {
const models = await this.listModels();
return models.filter((m) => !NON_CHAT_MODELS.includes(m.name));
}
async getVisionModels(): Promise<OllamaModel[]> {
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<ChatResult> {
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<string> {
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<ChatResult> {
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';
}
}

View file

@ -0,0 +1,8 @@
// Module
export { AiModule, AiModuleOptions } from './ai.module';
// Service
export { AiService } from './ai.service';
// Types
export * from './types';

View file

@ -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<string, string> = {
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'];

View file

@ -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<CalendarData>;
}
@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<CalendarData>(storagePath, defaultData),
},
CalendarService,
],
exports: [CalendarService],
};
}
/**
* Register with custom storage provider
*/
static forRoot(storageProvider: StorageProvider<CalendarData>): DynamicModule {
return {
module: CalendarModule,
providers: [
{
provide: CALENDAR_STORAGE_PROVIDER,
useValue: storageProvider,
},
CalendarService,
],
exports: [CalendarService],
};
}
}

View file

@ -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<CalendarData>;
constructor(
@Optional()
@Inject(CALENDAR_STORAGE_PROVIDER)
storage?: StorageProvider<CalendarData>
) {
this.storage =
storage ||
new FileStorageProvider<CalendarData>('./data/calendar-data.json', { events: [], calendars: [] });
}
async onModuleInit() {
await this.loadData();
}
private async loadData(): Promise<void> {
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<void> {
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<CalendarEvent> {
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<CalendarEvent | null> {
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<CalendarEvent | null> {
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<CalendarEvent | null> {
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<CalendarEvent | null> {
return this.data.events.find((e) => e.id === eventId && e.userId === userId) ?? null;
}
async getEventByIndex(userId: string, index: number): Promise<CalendarEvent | null> {
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<CalendarEvent[]> {
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<CalendarEvent[]> {
return this.data.events
.filter((e) => {
if (e.userId !== userId) return false;
const eventStart = new Date(e.startTime);
const eventEnd = new Date(e.endTime);
return eventStart < end && eventEnd > start;
})
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}
async getTodayEvents(userId: string): Promise<CalendarEvent[]> {
const today = startOfDay();
const tomorrow = addDays(today, 1);
return this.getEventsInRange(userId, today, tomorrow);
}
async getTomorrowEvents(userId: string): Promise<CalendarEvent[]> {
const tomorrow = startOfDay(addDays(new Date(), 1));
const dayAfter = addDays(tomorrow, 1);
return this.getEventsInRange(userId, tomorrow, dayAfter);
}
async getWeekEvents(userId: string): Promise<CalendarEvent[]> {
const today = startOfDay();
const weekEnd = addDays(today, 7);
return this.getEventsInRange(userId, today, weekEnd);
}
async getUpcomingEvents(userId: string, days: number = 7): Promise<CalendarEvent[]> {
const now = new Date();
const endDate = addDays(now, days);
return this.getEventsInRange(userId, now, endDate);
}
// ===== Calendars =====
async getCalendars(userId: string): Promise<Calendar[]> {
this.ensureDefaultCalendar(userId);
return this.data.calendars.filter((c) => c.userId === userId);
}
async createCalendar(userId: string, name: string, color?: string): Promise<Calendar> {
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 };
}
}

View file

@ -0,0 +1,8 @@
// Module
export { CalendarModule, CalendarModuleOptions } from './calendar.module';
// Service
export { CalendarService, CALENDAR_STORAGE_PROVIDER } from './calendar.service';
// Types
export * from './types';

View file

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

View file

@ -0,0 +1,45 @@
import { Module, DynamicModule } from '@nestjs/common';
import { ClockService } from './clock.service';
import { ClockServiceConfig } from './types';
export interface ClockModuleOptions extends Partial<ClockServiceConfig> {}
@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<ClockServiceConfig>) => 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],
};
}
}

View file

@ -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<string, string> = new Map();
constructor(config?: Partial<ClockServiceConfig>) {
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<T>(endpoint: string, method: string = 'GET', token?: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
'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<boolean> {
try {
const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`);
return response.ok;
} catch {
return false;
}
}
// ===== Timers =====
async getTimers(token: string): Promise<Timer[]> {
return this.apiCall<Timer[]>('/timers', 'GET', token);
}
async getTimer(id: string, token: string): Promise<Timer> {
return this.apiCall<Timer>(`/timers/${id}`, 'GET', token);
}
async createTimer(input: CreateTimerInput, token: string): Promise<Timer> {
return this.apiCall<Timer>('/timers', 'POST', token, {
durationSeconds: input.durationSeconds,
label: input.label,
});
}
async startTimer(id: string, token: string): Promise<Timer> {
return this.apiCall<Timer>(`/timers/${id}/start`, 'POST', token);
}
async pauseTimer(id: string, token: string): Promise<Timer> {
return this.apiCall<Timer>(`/timers/${id}/pause`, 'POST', token);
}
async resetTimer(id: string, token: string): Promise<Timer> {
return this.apiCall<Timer>(`/timers/${id}/reset`, 'POST', token);
}
async deleteTimer(id: string, token: string): Promise<void> {
await this.apiCall<void>(`/timers/${id}`, 'DELETE', token);
}
async getRunningTimer(token: string): Promise<Timer | null> {
const timers = await this.getTimers(token);
return timers.find((t) => t.status === 'running' || t.status === 'paused') || null;
}
// ===== Alarms =====
async getAlarms(token: string): Promise<Alarm[]> {
return this.apiCall<Alarm[]>('/alarms', 'GET', token);
}
async createAlarm(input: CreateAlarmInput, token: string): Promise<Alarm> {
return this.apiCall<Alarm>('/alarms', 'POST', token, {
time: input.time,
label: input.label,
enabled: true,
repeatDays: input.repeatDays,
});
}
async toggleAlarm(id: string, token: string): Promise<Alarm> {
return this.apiCall<Alarm>(`/alarms/${id}/toggle`, 'PATCH', token);
}
async deleteAlarm(id: string, token: string): Promise<void> {
await this.apiCall<void>(`/alarms/${id}`, 'DELETE', token);
}
// ===== World Clocks =====
async getWorldClocks(token: string): Promise<WorldClock[]> {
return this.apiCall<WorldClock[]>('/world-clocks', 'GET', token);
}
async addWorldClock(input: CreateWorldClockInput, token: string): Promise<WorldClock> {
return this.apiCall<WorldClock>('/world-clocks', 'POST', token, {
timezone: input.timezone,
cityName: input.cityName,
});
}
async deleteWorldClock(id: string, token: string): Promise<void> {
await this.apiCall<void>(`/world-clocks/${id}`, 'DELETE', token);
}
// ===== Timezone Search =====
async searchTimezones(query: string): Promise<TimezoneResult[]> {
return this.apiCall<TimezoneResult[]>(`/timezones/search?q=${encodeURIComponent(query)}`);
}
// ===== Time Tracking Summary =====
async getTodayTracked(token: string): Promise<TimeTrackingSummary> {
// 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(' ');
}
}

View file

@ -0,0 +1,8 @@
// Module
export { ClockModule, ClockModuleOptions } from './clock.module';
// Service
export { ClockService } from './clock.service';
// Types
export * from './types';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
// Shared types
export * from './types';
// Storage providers
export { FileStorageProvider, MemoryStorageProvider } from './storage';
// Utility functions
export * from './utils';

View file

@ -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<T> implements StorageProvider<T> {
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<T> {
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<void> {
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<T> implements StorageProvider<T> {
private data: T;
constructor(defaultData: T) {
this.data = defaultData;
}
async load(): Promise<T> {
return this.data;
}
async save(data: T): Promise<void> {
this.data = data;
}
}

View file

@ -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<T> {
load(): Promise<T>;
save(data: T): Promise<void>;
}
// Service configuration
export interface ServiceConfig {
storagePath?: string;
apiUrl?: string;
timeout?: number;
}
// Result type for operations
export type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E };
// Pagination
export interface PaginationOptions {
limit?: number;
offset?: number;
}
export interface PaginatedResult<T> {
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<Priority, number> = {
urgent: 1,
high: 2,
medium: 3,
low: 4,
};
// Common stats interface
export interface ServiceStats {
total: number;
active: number;
completed?: number;
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
// Module
export { TodoModule, TodoModuleOptions } from './todo.module';
// Service
export { TodoService, TODO_STORAGE_PROVIDER } from './todo.service';
// Types
export * from './types';

View file

@ -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<TodoData>;
}
@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<TodoData>(storagePath, defaultData),
},
TodoService,
],
exports: [TodoService],
};
}
/**
* Register with custom storage provider
*/
static forRoot(storageProvider: StorageProvider<TodoData>): DynamicModule {
return {
module: TodoModule,
providers: [
{
provide: TODO_STORAGE_PROVIDER,
useValue: storageProvider,
},
TodoService,
],
exports: [TodoService],
};
}
}

View file

@ -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<TodoData>;
constructor(
@Optional()
@Inject(TODO_STORAGE_PROVIDER)
storage?: StorageProvider<TodoData>
) {
// Default to file storage if not injected
this.storage =
storage || new FileStorageProvider<TodoData>('./data/todo-data.json', { tasks: [], projects: [] });
}
async onModuleInit() {
await this.loadData();
}
private async loadData(): Promise<void> {
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<void> {
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<Task> {
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<Task | null> {
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<Task | null> {
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<Task | null> {
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<Task | null> {
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<Task | null> {
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<Task | null> {
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<Task | null> {
return this.data.tasks.find((t) => t.id === taskId && t.userId === userId) ?? null;
}
async getTasks(userId: string, filter?: TaskFilter): Promise<Task[]> {
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<Task[]> {
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<Task[]> {
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<Task[]> {
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<Task[]> {
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<Task[]> {
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<Project[]> {
const projectNames = new Set<string>();
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<TodoStats> {
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 };
}
}

View file

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

View file

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