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