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