mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 11:26:42 +02:00
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:
parent
bea066c7f8
commit
68a6c7a8d6
28 changed files with 2492 additions and 0 deletions
45
packages/bot-services/src/clock/clock.module.ts
Normal file
45
packages/bot-services/src/clock/clock.module.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
262
packages/bot-services/src/clock/clock.service.ts
Normal file
262
packages/bot-services/src/clock/clock.service.ts
Normal 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(' ');
|
||||
}
|
||||
}
|
||||
8
packages/bot-services/src/clock/index.ts
Normal file
8
packages/bot-services/src/clock/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Module
|
||||
export { ClockModule, ClockModuleOptions } from './clock.module';
|
||||
|
||||
// Service
|
||||
export { ClockService } from './clock.service';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
97
packages/bot-services/src/clock/types.ts
Normal file
97
packages/bot-services/src/clock/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue