diff --git a/packages/bot-services/src/contacts/contacts-api.service.ts b/packages/bot-services/src/contacts/contacts-api.service.ts new file mode 100644 index 000000000..fb363e292 --- /dev/null +++ b/packages/bot-services/src/contacts/contacts-api.service.ts @@ -0,0 +1,210 @@ +import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; +import { + Contact, + ContactBirthday, + ContactsModuleOptions, + CONTACTS_MODULE_OPTIONS, + DEFAULT_CONTACTS_API_URL, +} from './types'; + +/** + * Contacts API Service + * + * Connects to the contacts-backend API for contact management. + * Used by the morning summary to show birthdays. + * + * @example + * ```typescript + * // Get today's birthdays (requires JWT token) + * const birthdays = await contactsApiService.getBirthdaysToday(token); + * + * // Get all contacts + * const contacts = await contactsApiService.getContacts(token); + * ``` + */ +@Injectable() +export class ContactsApiService { + private readonly logger = new Logger(ContactsApiService.name); + private readonly baseUrl: string; + + constructor(@Optional() @Inject(CONTACTS_MODULE_OPTIONS) options?: ContactsModuleOptions) { + this.baseUrl = options?.apiUrl || DEFAULT_CONTACTS_API_URL; + this.logger.log(`Contacts API Service initialized with URL: ${this.baseUrl}`); + } + + /** + * Get today's birthdays + * Uses the dedicated /contacts/birthdays endpoint and filters for today + */ + async getBirthdaysToday(token: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/v1/contacts/birthdays`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = (await response.json()) as ContactBirthday[]; + + // Filter for today's birthdays + const today = new Date(); + const todayMonth = today.getMonth() + 1; + const todayDay = today.getDate(); + + return data + .filter((contact) => { + if (!contact.birthday) return false; + const [, month, day] = contact.birthday.split('-').map(Number); + return month === todayMonth && day === todayDay; + }) + .map((contact) => ({ + ...contact, + age: this.calculateAge(contact.birthday), + })); + } catch (error) { + this.logger.error('Failed to get birthdays today:', error); + return []; + } + } + + /** + * Get upcoming birthdays (next 7 days) + */ + async getUpcomingBirthdays(token: string, days = 7): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/v1/contacts/birthdays`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = (await response.json()) as ContactBirthday[]; + + // Filter for upcoming birthdays + const today = new Date(); + today.setHours(0, 0, 0, 0); + const endDate = new Date(today); + endDate.setDate(endDate.getDate() + days); + + return data + .filter((contact) => { + if (!contact.birthday) return false; + + // Create this year's birthday date + const [_year, month, day] = contact.birthday.split('-').map(Number); + const birthdayThisYear = new Date(today.getFullYear(), month - 1, day); + + // If birthday already passed this year, check next year + if (birthdayThisYear < today) { + birthdayThisYear.setFullYear(today.getFullYear() + 1); + } + + return birthdayThisYear >= today && birthdayThisYear <= endDate; + }) + .map((contact) => ({ + ...contact, + age: this.calculateAge(contact.birthday), + })); + } catch (error) { + this.logger.error('Failed to get upcoming birthdays:', error); + return []; + } + } + + /** + * Get all contacts + */ + async getContacts( + token: string, + options?: { limit?: number; search?: string } + ): Promise { + try { + const params = new URLSearchParams(); + if (options?.limit) params.append('limit', String(options.limit)); + if (options?.search) params.append('search', options.search); + + const queryString = params.toString(); + const url = queryString + ? `${this.baseUrl}/api/v1/contacts?${queryString}` + : `${this.baseUrl}/api/v1/contacts`; + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = (await response.json()) as { contacts?: Contact[] }; + return data.contacts || []; + } catch (error) { + this.logger.error('Failed to get contacts:', error); + return []; + } + } + + /** + * Get a single contact by ID + */ + async getContact(token: string, contactId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/v1/contacts/${contactId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return (await response.json()) as Contact; + } catch (error) { + this.logger.error(`Failed to get contact ${contactId}:`, error); + return null; + } + } + + /** + * Format birthdays for display + */ + formatBirthdays(birthdays: ContactBirthday[]): string { + if (birthdays.length === 0) { + return ''; + } + + const lines: string[] = ['**Geburtstage** πŸŽ‚']; + + for (const contact of birthdays) { + const name = contact.displayName || `${contact.firstName} ${contact.lastName}`.trim(); + const ageText = contact.age ? ` wird ${contact.age}` : ''; + lines.push(`β€’ ${name}${ageText}`); + } + + return lines.join('\n'); + } + + /** + * Calculate age from birthday string (YYYY-MM-DD) + */ + private calculateAge(birthday: string): number | undefined { + const [year] = birthday.split('-').map(Number); + if (!year || year < 1900) return undefined; + + const today = new Date(); + const birthDate = new Date(birthday); + let age = today.getFullYear() - birthDate.getFullYear(); + + // Adjust if birthday hasn't occurred this year + const monthDiff = today.getMonth() - birthDate.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + + // Return next age (what they will be turning) + return age + 1; + } +} diff --git a/packages/bot-services/src/contacts/contacts.module.ts b/packages/bot-services/src/contacts/contacts.module.ts new file mode 100644 index 000000000..9e999b5b0 --- /dev/null +++ b/packages/bot-services/src/contacts/contacts.module.ts @@ -0,0 +1,89 @@ +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ContactsApiService } from './contacts-api.service'; +import { ContactsModuleOptions, CONTACTS_MODULE_OPTIONS } from './types'; + +/** + * Contacts Module + * + * Contact management and birthday tracking API client. + * + * @example + * ```typescript + * // Basic usage + * @Module({ + * imports: [ContactsModule.register()] + * }) + * + * // With custom API URL + * @Module({ + * imports: [ + * ContactsModule.register({ + * apiUrl: 'http://contacts-backend:3015', + * }) + * ] + * }) + * ``` + */ +@Global() +@Module({}) +export class ContactsModule { + /** + * Register module with explicit options + */ + static register(options: ContactsModuleOptions = {}): DynamicModule { + return { + module: ContactsModule, + providers: [ + { + provide: CONTACTS_MODULE_OPTIONS, + useValue: options, + }, + ContactsApiService, + ], + exports: [ContactsApiService], + }; + } + + /** + * Register module with async configuration + */ + static registerAsync(options: { + imports?: any[]; + useFactory: (...args: any[]) => Promise | ContactsModuleOptions; + inject?: any[]; + }): DynamicModule { + return { + module: ContactsModule, + imports: [...(options.imports || [])], + providers: [ + { + provide: CONTACTS_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + ContactsApiService, + ], + exports: [ContactsApiService], + }; + } + + /** + * Register with ConfigService reading from environment + * + * Environment variables: + * - CONTACTS_API_URL: Contacts backend URL + */ + static forRoot(): DynamicModule { + return this.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + apiUrl: + config.get('contacts.apiUrl') || + config.get('CONTACTS_API_URL') || + 'http://localhost:3015', + }), + inject: [ConfigService], + }); + } +} diff --git a/packages/bot-services/src/contacts/index.ts b/packages/bot-services/src/contacts/index.ts new file mode 100644 index 000000000..6b5d833a7 --- /dev/null +++ b/packages/bot-services/src/contacts/index.ts @@ -0,0 +1,28 @@ +/** + * Contacts API Service + * + * Contact management and birthday tracking API client. + * + * @example + * ```typescript + * import { ContactsModule, ContactsApiService } from '@manacore/bot-services/contacts'; + * + * // In module + * @Module({ + * imports: [ContactsModule.forRoot()] + * }) + * + * // In service + * const birthdays = await contactsApiService.getBirthdaysToday(token); + * ``` + */ + +export { ContactsModule } from './contacts.module.js'; +export { ContactsApiService } from './contacts-api.service.js'; +export { + ContactsModuleOptions, + Contact, + ContactBirthday, + CONTACTS_MODULE_OPTIONS, + DEFAULT_CONTACTS_API_URL, +} from './types.js'; diff --git a/packages/bot-services/src/contacts/types.ts b/packages/bot-services/src/contacts/types.ts new file mode 100644 index 000000000..b9c217196 --- /dev/null +++ b/packages/bot-services/src/contacts/types.ts @@ -0,0 +1,54 @@ +/** + * Contacts API Service Types + * + * Types for contact management and birthday tracking + */ + +/** + * Contact with basic info + */ +export interface Contact { + id: string; + firstName?: string; + lastName?: string; + displayName?: string; + nickname?: string; + email?: string; + phone?: string; + mobile?: string; + birthday?: string; + photoUrl?: string; + company?: string; + jobTitle?: string; + isFavorite: boolean; +} + +/** + * Contact birthday summary (lightweight) + */ +export interface ContactBirthday { + id: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + birthday: string; + photoUrl: string | null; + age?: number; +} + +/** + * Contacts API module options + */ +export interface ContactsModuleOptions { + apiUrl?: string; +} + +/** + * Injection token for Contacts module options + */ +export const CONTACTS_MODULE_OPTIONS = 'CONTACTS_MODULE_OPTIONS'; + +/** + * Default API URL + */ +export const DEFAULT_CONTACTS_API_URL = 'http://localhost:3015'; diff --git a/packages/bot-services/src/index.ts b/packages/bot-services/src/index.ts index 14f3cc9de..388414567 100644 --- a/packages/bot-services/src/index.ts +++ b/packages/bot-services/src/index.ts @@ -175,6 +175,44 @@ export type { } from './i18n/index.js'; export { de as deTranslations, en as enTranslations } from './i18n/index.js'; +// Weather (Open-Meteo API) +export { WeatherModule, WeatherService } from './weather/index.js'; +export type { WeatherModuleOptions, WeatherData, WeatherCode } from './weather/index.js'; +export { + WEATHER_MODULE_OPTIONS, + WEATHER_DESCRIPTIONS_DE, + WEATHER_DESCRIPTIONS_EN, +} from './weather/index.js'; + +// Contacts API (Birthday tracking) +export { ContactsModule, ContactsApiService } from './contacts/index.js'; +export type { ContactsModuleOptions, Contact, ContactBirthday } from './contacts/index.js'; +export { CONTACTS_MODULE_OPTIONS, DEFAULT_CONTACTS_API_URL } from './contacts/index.js'; + +// Planta API (Plant watering) +export { PlantaModule, PlantaApiService } from './planta/index.js'; +export type { PlantaModuleOptions, Plant, PlantWateringStatus } from './planta/index.js'; +export { PLANTA_MODULE_OPTIONS, DEFAULT_PLANTA_API_URL } from './planta/index.js'; + +// Morning Summary (Daily aggregation) +export { + MorningSummaryModule, + MorningSummaryService, + MorningPreferencesService, +} from './morning-summary/index.js'; +export type { + MorningSummaryModuleOptions, + MorningSummaryData, + MorningPreferences, +} from './morning-summary/index.js'; +export { + MORNING_SUMMARY_MODULE_OPTIONS, + DEFAULT_MORNING_PREFERENCES, + MORNING_PREFS_KEY_PREFIX, + DAY_NAMES_DE, + MONTH_NAMES_DE, +} from './morning-summary/index.js'; + // ===== Placeholder Services (to be implemented) ===== export { NutritionModule } from './nutrition/index.js'; diff --git a/packages/bot-services/src/morning-summary/index.ts b/packages/bot-services/src/morning-summary/index.ts new file mode 100644 index 000000000..cf96b6eaa --- /dev/null +++ b/packages/bot-services/src/morning-summary/index.ts @@ -0,0 +1,42 @@ +/** + * Morning Summary Service + * + * Daily morning summary aggregation with user preferences. + * + * @example + * ```typescript + * import { + * MorningSummaryModule, + * MorningSummaryService, + * MorningPreferencesService, + * } from '@manacore/bot-services/morning-summary'; + * + * // In module + * @Module({ + * imports: [MorningSummaryModule.forRoot()] + * }) + * + * // In service + * const summary = await morningSummaryService.generateSummary(matrixUserId, token); + * const formatted = morningSummaryService.formatSummary(summary, 'detailed'); + * + * // Manage preferences + * await preferencesService.setEnabled(matrixUserId, true); + * await preferencesService.setDeliveryTime(matrixUserId, '07:00'); + * await preferencesService.setLocation(matrixUserId, 'Berlin'); + * ``` + */ + +export { MorningSummaryModule } from './morning-summary.module.js'; +export { MorningSummaryService } from './morning-summary.service.js'; +export { MorningPreferencesService } from './preferences.service.js'; +export { + MorningSummaryModuleOptions, + MorningSummaryData, + MorningPreferences, + DEFAULT_MORNING_PREFERENCES, + MORNING_SUMMARY_MODULE_OPTIONS, + MORNING_PREFS_KEY_PREFIX, + DAY_NAMES_DE, + MONTH_NAMES_DE, +} from './types.js'; diff --git a/packages/bot-services/src/morning-summary/morning-summary.module.ts b/packages/bot-services/src/morning-summary/morning-summary.module.ts new file mode 100644 index 000000000..bfdad1bc8 --- /dev/null +++ b/packages/bot-services/src/morning-summary/morning-summary.module.ts @@ -0,0 +1,196 @@ +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { MorningSummaryService } from './morning-summary.service'; +import { MorningPreferencesService } from './preferences.service'; +import { MorningSummaryModuleOptions, MORNING_SUMMARY_MODULE_OPTIONS } from './types'; + +// Import API services +import { CalendarApiService } from '../calendar/calendar-api.service'; +import { TodoApiService } from '../todo/todo-api.service'; +import { ContactsApiService } from '../contacts/contacts-api.service'; +import { PlantaApiService } from '../planta/planta-api.service'; +import { WeatherService } from '../weather/weather.service'; + +// Note: SessionModule should be imported by the consuming application +// This module assumes SessionService is available globally + +/** + * Morning Summary Module + * + * Provides daily morning summary aggregation service. + * + * @example + * ```typescript + * // Basic usage with all dependencies + * @Module({ + * imports: [ + * MorningSummaryModule.forRoot() + * ] + * }) + * + * // The module requires these services to be available (can be optional): + * // - CalendarApiService (for events) + * // - TodoApiService (for tasks) + * // - ContactsApiService (for birthdays) + * // - PlantaApiService (for plants) + * // - WeatherService (for weather) + * // - SessionService (for preferences storage) + * ``` + */ +@Global() +@Module({}) +export class MorningSummaryModule { + /** + * Register module with explicit options + */ + static register(options: MorningSummaryModuleOptions = {}): DynamicModule { + return { + module: MorningSummaryModule, + providers: [ + { + provide: MORNING_SUMMARY_MODULE_OPTIONS, + useValue: options, + }, + // API Services with configured URLs + { + provide: CalendarApiService, + useFactory: () => new CalendarApiService(options.calendarApiUrl), + }, + { + provide: TodoApiService, + useFactory: () => new TodoApiService(options.todoApiUrl), + }, + { + provide: ContactsApiService, + useFactory: () => { + const service = new ContactsApiService(); + // @ts-expect-error - set apiUrl directly + if (options.contactsApiUrl) service['baseUrl'] = options.contactsApiUrl; + return service; + }, + }, + { + provide: PlantaApiService, + useFactory: () => { + const service = new PlantaApiService(); + // @ts-expect-error - set apiUrl directly + if (options.plantaApiUrl) service['baseUrl'] = options.plantaApiUrl; + return service; + }, + }, + { + provide: WeatherService, + useFactory: () => + new WeatherService({ + defaultLocation: options.defaultLocation || 'Berlin', + }), + }, + MorningPreferencesService, + MorningSummaryService, + ], + exports: [MorningSummaryService, MorningPreferencesService], + }; + } + + /** + * Register with ConfigService reading from environment + * + * Environment variables: + * - TODO_API_URL: Todo backend URL + * - CALENDAR_API_URL: Calendar backend URL + * - CONTACTS_API_URL: Contacts backend URL + * - PLANTA_API_URL: Planta backend URL + * - WEATHER_DEFAULT_LOCATION: Default weather location + */ + static forRoot(): DynamicModule { + return { + module: MorningSummaryModule, + imports: [ConfigModule], + providers: [ + { + provide: MORNING_SUMMARY_MODULE_OPTIONS, + useFactory: (config: ConfigService) => ({ + todoApiUrl: + config.get('services.todo.apiUrl') || + config.get('TODO_API_URL') || + 'http://localhost:3018', + calendarApiUrl: + config.get('services.calendar.apiUrl') || + config.get('CALENDAR_API_URL') || + 'http://localhost:3014', + contactsApiUrl: + config.get('services.contacts.apiUrl') || + config.get('CONTACTS_API_URL') || + 'http://localhost:3015', + plantaApiUrl: + config.get('services.planta.apiUrl') || + config.get('PLANTA_API_URL') || + 'http://localhost:3022', + defaultLocation: + config.get('weather.defaultLocation') || + config.get('WEATHER_DEFAULT_LOCATION') || + 'Berlin', + }), + inject: [ConfigService], + }, + // API Services + { + provide: CalendarApiService, + useFactory: (config: ConfigService) => + new CalendarApiService( + config.get('services.calendar.apiUrl') || + config.get('CALENDAR_API_URL') || + 'http://localhost:3014' + ), + inject: [ConfigService], + }, + { + provide: TodoApiService, + useFactory: (config: ConfigService) => + new TodoApiService( + config.get('services.todo.apiUrl') || + config.get('TODO_API_URL') || + 'http://localhost:3018' + ), + inject: [ConfigService], + }, + { + provide: ContactsApiService, + useFactory: (config: ConfigService) => { + const apiUrl = + config.get('services.contacts.apiUrl') || + config.get('CONTACTS_API_URL') || + 'http://localhost:3015'; + return new ContactsApiService({ apiUrl }); + }, + inject: [ConfigService], + }, + { + provide: PlantaApiService, + useFactory: (config: ConfigService) => { + const apiUrl = + config.get('services.planta.apiUrl') || + config.get('PLANTA_API_URL') || + 'http://localhost:3022'; + return new PlantaApiService({ apiUrl }); + }, + inject: [ConfigService], + }, + { + provide: WeatherService, + useFactory: (config: ConfigService) => + new WeatherService({ + defaultLocation: + config.get('weather.defaultLocation') || + config.get('WEATHER_DEFAULT_LOCATION') || + 'Berlin', + }), + inject: [ConfigService], + }, + MorningPreferencesService, + MorningSummaryService, + ], + exports: [MorningSummaryService, MorningPreferencesService], + }; + } +} diff --git a/packages/bot-services/src/morning-summary/morning-summary.service.ts b/packages/bot-services/src/morning-summary/morning-summary.service.ts new file mode 100644 index 000000000..1dce299da --- /dev/null +++ b/packages/bot-services/src/morning-summary/morning-summary.service.ts @@ -0,0 +1,311 @@ +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { CalendarApiService } from '../calendar/calendar-api.service.js'; +import { TodoApiService } from '../todo/todo-api.service.js'; +import { ContactsApiService } from '../contacts/contacts-api.service.js'; +import { PlantaApiService } from '../planta/planta-api.service.js'; +import { WeatherService } from '../weather/weather.service.js'; +import { MorningPreferencesService } from './preferences.service.js'; +import { MorningSummaryData, DAY_NAMES_DE, MONTH_NAMES_DE } from './types.js'; +import { Task } from '../todo/types.js'; + +/** + * Morning Summary Service + * + * Aggregates data from all sources to generate a comprehensive morning summary. + * + * @example + * ```typescript + * // Generate summary for a user + * const summary = await morningSummaryService.generateSummary(matrixUserId, token); + * const formatted = morningSummaryService.formatSummary(summary, 'detailed'); + * ``` + */ +@Injectable() +export class MorningSummaryService { + private readonly logger = new Logger(MorningSummaryService.name); + + constructor( + @Optional() private calendarService: CalendarApiService, + @Optional() private todoService: TodoApiService, + @Optional() private contactsService: ContactsApiService, + @Optional() private plantaService: PlantaApiService, + @Optional() private weatherService: WeatherService, + private preferencesService: MorningPreferencesService + ) { + this.logger.log('Morning Summary Service initialized'); + } + + /** + * Generate a complete morning summary for a user + */ + async generateSummary(matrixUserId: string, token: string): Promise { + const prefs = await this.preferencesService.getPreferences(matrixUserId); + + // Fetch all data in parallel + const [events, tasks, birthdays, plants, weather] = await Promise.all([ + this.fetchEvents(token), + this.fetchTasks(token), + prefs.includeBirthdays ? this.fetchBirthdays(token) : Promise.resolve([]), + prefs.includePlants ? this.fetchPlants(token) : Promise.resolve([]), + prefs.includeWeather && prefs.location + ? this.fetchWeather(prefs.location) + : Promise.resolve(null), + ]); + + // Separate today's tasks from overdue tasks + const today = new Date().toISOString().split('T')[0]; + const todayTasks = tasks.filter((t) => !t.completed && (t.dueDate === today || !t.dueDate)); + const overdueTasks = tasks.filter((t) => !t.completed && t.dueDate && t.dueDate < today); + + return { + events, + tasks: todayTasks, + overdueTasks, + birthdays, + plants, + weather, + generatedAt: new Date(), + }; + } + + /** + * Format summary for display + */ + formatSummary(data: MorningSummaryData, format: 'compact' | 'detailed' = 'detailed'): string { + const today = new Date(); + const dayName = DAY_NAMES_DE[today.getDay()]; + const day = today.getDate(); + const month = MONTH_NAMES_DE[today.getMonth()]; + const year = today.getFullYear(); + + if (format === 'compact') { + return this.formatCompact(data, dayName, day, month); + } + + return this.formatDetailed(data, dayName, day, month, year); + } + + /** + * Format as compact summary + */ + private formatCompact( + data: MorningSummaryData, + dayName: string, + day: number, + month: string + ): string { + const parts: string[] = [ + `**Guten Morgen!** (${dayName.slice(0, 2)}, ${day}. ${month.slice(0, 3)}.)`, + ]; + + const summaryParts: string[] = []; + + // Weather + if (data.weather) { + summaryParts.push( + `${Math.round(data.weather.temperature)}Β°C ${data.weather.weatherDescription.toLowerCase()}` + ); + } + + // Events + if (data.events.length > 0) { + summaryParts.push(`${data.events.length} Termine`); + } + + // Tasks + if (data.tasks.length > 0) { + summaryParts.push(`${data.tasks.length} Aufgaben`); + } + + // Overdue + if (data.overdueTasks.length > 0) { + summaryParts.push(`${data.overdueTasks.length} ueberfaellig`); + } + + if (summaryParts.length > 0) { + parts.push(summaryParts.join(' | ')); + } + + // Birthdays & Plants + const extraParts: string[] = []; + if (data.birthdays.length > 0) { + const names = data.birthdays.map((b) => { + const name = b.displayName || `${b.firstName || ''} ${b.lastName || ''}`.trim(); + const shortName = + name.split(' ')[0] + (name.split(' ')[1] ? ` ${name.split(' ')[1][0]}.` : ''); + return `${shortName}${b.age ? ` (${b.age})` : ''}`; + }); + extraParts.push(`Geburtstag: ${names.join(', ')}`); + } + + if (data.plants.length > 0) { + extraParts.push(`${data.plants.length} Pflanzen giessen`); + } + + if (extraParts.length > 0) { + parts.push(extraParts.join(' | ')); + } + + return parts.join('\n'); + } + + /** + * Format as detailed summary + */ + private formatDetailed( + data: MorningSummaryData, + dayName: string, + day: number, + month: string, + year: number + ): string { + const sections: string[] = [`**Guten Morgen!** (${dayName}, ${day}. ${month} ${year})`, '']; + + // Weather + if (data.weather && this.weatherService) { + sections.push(this.weatherService.formatWeather(data.weather, 'detailed')); + sections.push(''); + } + + // Events + if (data.events.length > 0) { + sections.push(`**Termine heute (${data.events.length})**`); + for (const event of data.events.slice(0, 5)) { + const time = event.isAllDay + ? 'Ganztaegig' + : new Date(event.startTime).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + }); + sections.push(`β€’ ${time} ${event.title}`); + } + if (data.events.length > 5) { + sections.push(` _... und ${data.events.length - 5} weitere_`); + } + sections.push(''); + } + + // Tasks + if (data.tasks.length > 0) { + sections.push(`**Aufgaben heute (${data.tasks.length})**`); + for (const task of data.tasks.slice(0, 5)) { + const priority = task.priority < 4 ? ' ❗'.repeat(4 - task.priority) : ''; + sections.push(`β€’ ${task.title}${priority}`); + } + if (data.tasks.length > 5) { + sections.push(` _... und ${data.tasks.length - 5} weitere_`); + } + sections.push(''); + } + + // Overdue + if (data.overdueTasks.length > 0) { + sections.push(`**Ueberfaellig (${data.overdueTasks.length})**`); + for (const task of data.overdueTasks.slice(0, 3)) { + const daysOverdue = this.getDaysOverdue(task.dueDate!); + const overdueText = daysOverdue === 1 ? 'seit gestern' : `seit ${daysOverdue} Tagen`; + sections.push(`β€’ ${task.title} (${overdueText})`); + } + if (data.overdueTasks.length > 3) { + sections.push(` _... und ${data.overdueTasks.length - 3} weitere_`); + } + sections.push(''); + } + + // Birthdays + if (data.birthdays.length > 0) { + sections.push('**Geburtstage** πŸŽ‚'); + for (const birthday of data.birthdays) { + const name = + birthday.displayName || `${birthday.firstName || ''} ${birthday.lastName || ''}`.trim(); + const ageText = birthday.age ? ` wird ${birthday.age}` : ''; + sections.push(`β€’ ${name}${ageText}`); + } + sections.push(''); + } + + // Plants + if (data.plants.length > 0) { + sections.push('**Pflanzen giessen** 🌱'); + const overdue = data.plants.filter((p) => p.isOverdue); + const today = data.plants.filter((p) => !p.isOverdue); + + for (const plant of overdue) { + sections.push(`β€’ ${plant.plantName} (ueberfaellig!)`); + } + for (const plant of today) { + sections.push(`β€’ ${plant.plantName}`); + } + sections.push(''); + } + + // Footer + sections.push('---'); + sections.push('Einstellungen: `!morning-settings`'); + + return sections.join('\n'); + } + + // ===== Data Fetching ===== + + private async fetchEvents(token: string) { + if (!this.calendarService) return []; + try { + return await this.calendarService.getTodayEvents(token); + } catch (error) { + this.logger.error('Failed to fetch events:', error); + return []; + } + } + + private async fetchTasks(token: string): Promise { + if (!this.todoService) return []; + try { + // Get all pending tasks + return await this.todoService.getTasks(token, { completed: false }); + } catch (error) { + this.logger.error('Failed to fetch tasks:', error); + return []; + } + } + + private async fetchBirthdays(token: string) { + if (!this.contactsService) return []; + try { + return await this.contactsService.getBirthdaysToday(token); + } catch (error) { + this.logger.error('Failed to fetch birthdays:', error); + return []; + } + } + + private async fetchPlants(token: string) { + if (!this.plantaService) return []; + try { + return await this.plantaService.getPlantsNeedingWater(token); + } catch (error) { + this.logger.error('Failed to fetch plants:', error); + return []; + } + } + + private async fetchWeather(location: string) { + if (!this.weatherService) return null; + try { + return await this.weatherService.getWeather(location); + } catch (error) { + this.logger.error('Failed to fetch weather:', error); + return null; + } + } + + // ===== Helpers ===== + + private getDaysOverdue(dueDate: string): number { + const due = new Date(dueDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + due.setHours(0, 0, 0, 0); + return Math.floor((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24)); + } +} diff --git a/packages/bot-services/src/morning-summary/preferences.service.ts b/packages/bot-services/src/morning-summary/preferences.service.ts new file mode 100644 index 000000000..2d557084b --- /dev/null +++ b/packages/bot-services/src/morning-summary/preferences.service.ts @@ -0,0 +1,208 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SessionService } from '../session/session.service.js'; +import { MorningPreferences, DEFAULT_MORNING_PREFERENCES } from './types.js'; + +/** + * Morning Preferences Service + * + * Manages user preferences for morning summaries. + * Stores preferences in Redis via SessionService for cross-bot persistence. + * + * @example + * ```typescript + * // Get preferences + * const prefs = await preferencesService.getPreferences(matrixUserId); + * + * // Enable morning summary + * await preferencesService.setEnabled(matrixUserId, true); + * + * // Set delivery time + * await preferencesService.setDeliveryTime(matrixUserId, '07:30'); + * + * // Set weather location + * await preferencesService.setLocation(matrixUserId, 'Berlin'); + * ``` + */ +@Injectable() +export class MorningPreferencesService { + private readonly logger = new Logger(MorningPreferencesService.name); + + constructor(private sessionService: SessionService) {} + + /** + * Get user's morning preferences + */ + async getPreferences(matrixUserId: string): Promise { + try { + const stored = await this.sessionService.getSessionData( + matrixUserId, + 'morningPrefs' + ); + + if (stored) { + // Merge with defaults to ensure all fields exist + return { ...DEFAULT_MORNING_PREFERENCES, ...stored }; + } + + return { ...DEFAULT_MORNING_PREFERENCES }; + } catch (error) { + this.logger.error(`Failed to get preferences for ${matrixUserId}:`, error); + return { ...DEFAULT_MORNING_PREFERENCES }; + } + } + + /** + * Save user's morning preferences + */ + async savePreferences( + matrixUserId: string, + prefs: Partial + ): Promise { + try { + const current = await this.getPreferences(matrixUserId); + const updated = { ...current, ...prefs }; + + await this.sessionService.setSessionData(matrixUserId, 'morningPrefs', updated); + + this.logger.debug(`Saved preferences for ${matrixUserId}`); + return updated; + } catch (error) { + this.logger.error(`Failed to save preferences for ${matrixUserId}:`, error); + throw error; + } + } + + /** + * Enable/disable morning summary + */ + async setEnabled(matrixUserId: string, enabled: boolean): Promise { + return this.savePreferences(matrixUserId, { enabled }); + } + + /** + * Set delivery time (HH:MM format) + */ + async setDeliveryTime(matrixUserId: string, time: string): Promise { + // Validate time format + const match = time.match(/^(\d{1,2}):(\d{2})$/); + if (!match) { + throw new Error('Invalid time format. Use HH:MM (e.g., 07:00)'); + } + + const hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + throw new Error('Invalid time. Hours must be 0-23, minutes 0-59'); + } + + const deliveryTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + return this.savePreferences(matrixUserId, { deliveryTime }); + } + + /** + * Set timezone + */ + async setTimezone(matrixUserId: string, timezone: string): Promise { + // Basic validation - check if it's a valid IANA timezone + try { + Intl.DateTimeFormat('en', { timeZone: timezone }); + } catch { + throw new Error(`Invalid timezone: ${timezone}`); + } + + return this.savePreferences(matrixUserId, { timezone }); + } + + /** + * Set weather location + */ + async setLocation(matrixUserId: string, location: string | null): Promise { + return this.savePreferences(matrixUserId, { location }); + } + + /** + * Set summary format + */ + async setFormat( + matrixUserId: string, + format: 'compact' | 'detailed' + ): Promise { + return this.savePreferences(matrixUserId, { format }); + } + + /** + * Get all users with enabled morning summaries + * Note: This requires iterating over all sessions, which is only efficient with Redis + */ + async getEnabledUsers(): Promise { + // This will be implemented via Redis scan in the scheduler + // For now, return from in-memory tracking + const activeUsers = this.sessionService.getActiveUserIds(); + const enabledUsers: string[] = []; + + for (const userId of activeUsers) { + const prefs = await this.getPreferences(userId); + if (prefs.enabled) { + enabledUsers.push(userId); + } + } + + return enabledUsers; + } + + /** + * Check if current time matches a user's delivery time + */ + shouldDeliverNow(prefs: MorningPreferences, currentTime: Date = new Date()): boolean { + if (!prefs.enabled) return false; + + try { + // Get current time in user's timezone + const userTime = currentTime.toLocaleTimeString('en-US', { + timeZone: prefs.timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + // Compare with delivery time (allow 1-minute window) + const [currentHour, currentMinute] = userTime.split(':').map(Number); + const [targetHour, targetMinute] = prefs.deliveryTime.split(':').map(Number); + + return currentHour === targetHour && currentMinute === targetMinute; + } catch (error) { + this.logger.error(`Error checking delivery time:`, error); + return false; + } + } + + /** + * Format preferences for display + */ + formatPreferences(prefs: MorningPreferences): string { + const status = prefs.enabled ? 'βœ… Aktiviert' : '❌ Deaktiviert'; + const lines = [ + '**Morgenzusammenfassung Einstellungen**', + '', + `Status: ${status}`, + `Uhrzeit: ${prefs.deliveryTime}`, + `Zeitzone: ${prefs.timezone}`, + `Format: ${prefs.format === 'compact' ? 'Kompakt' : 'Ausfuehrlich'}`, + ]; + + if (prefs.location) { + lines.push(`Wetter-Ort: ${prefs.location}`); + } else { + lines.push(`Wetter-Ort: Nicht gesetzt`); + } + + lines.push(''); + lines.push('**Optionen:**'); + lines.push(`Wetter: ${prefs.includeWeather ? 'βœ…' : '❌'}`); + lines.push(`Geburtstage: ${prefs.includeBirthdays ? 'βœ…' : '❌'}`); + lines.push(`Pflanzen: ${prefs.includePlants ? 'βœ…' : '❌'}`); + + return lines.join('\n'); + } +} diff --git a/packages/bot-services/src/morning-summary/types.ts b/packages/bot-services/src/morning-summary/types.ts new file mode 100644 index 000000000..2c3fb9f01 --- /dev/null +++ b/packages/bot-services/src/morning-summary/types.ts @@ -0,0 +1,124 @@ +/** + * Morning Summary Service Types + * + * Types for daily morning summary and user preferences + */ + +import { type CalendarEvent } from '../calendar/types.js'; +import { type Task } from '../todo/types.js'; +import { type WeatherData } from '../weather/types.js'; +import { type ContactBirthday } from '../contacts/types.js'; +import { type PlantWateringStatus } from '../planta/types.js'; + +/** + * Morning summary data aggregated from all sources + */ +export interface MorningSummaryData { + /** Today's calendar events */ + events: CalendarEvent[]; + /** Today's tasks */ + tasks: Task[]; + /** Overdue tasks */ + overdueTasks: Task[]; + /** Today's birthdays */ + birthdays: ContactBirthday[]; + /** Plants needing water */ + plants: PlantWateringStatus[]; + /** Weather data (if location set) */ + weather: WeatherData | null; + /** Timestamp when summary was generated */ + generatedAt: Date; +} + +/** + * User morning summary preferences + */ +export interface MorningPreferences { + /** Whether automatic morning summary is enabled (default: false, opt-in) */ + enabled: boolean; + /** Delivery time in HH:MM format (default: '07:00') */ + deliveryTime: string; + /** User's timezone (default: 'Europe/Berlin') */ + timezone: string; + /** Location for weather (optional) */ + location: string | null; + /** Summary format (default: 'detailed') */ + format: 'compact' | 'detailed'; + /** Include weather in summary (default: true) */ + includeWeather: boolean; + /** Include birthdays in summary (default: true) */ + includeBirthdays: boolean; + /** Include plants in summary (default: true) */ + includePlants: boolean; +} + +/** + * Default morning preferences + */ +export const DEFAULT_MORNING_PREFERENCES: MorningPreferences = { + enabled: false, + deliveryTime: '07:00', + timezone: 'Europe/Berlin', + location: null, + format: 'detailed', + includeWeather: true, + includeBirthdays: true, + includePlants: true, +}; + +/** + * Morning summary module options + */ +export interface MorningSummaryModuleOptions { + /** TodoApiService API URL */ + todoApiUrl?: string; + /** CalendarApiService API URL */ + calendarApiUrl?: string; + /** ContactsApiService API URL */ + contactsApiUrl?: string; + /** PlantaApiService API URL */ + plantaApiUrl?: string; + /** Default location for weather */ + defaultLocation?: string; +} + +/** + * Injection token for morning summary module options + */ +export const MORNING_SUMMARY_MODULE_OPTIONS = 'MORNING_SUMMARY_MODULE_OPTIONS'; + +/** + * Redis key prefix for morning preferences + */ +export const MORNING_PREFS_KEY_PREFIX = 'morning:prefs:'; + +/** + * German day names + */ +export const DAY_NAMES_DE = [ + 'Sonntag', + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', +]; + +/** + * German month names + */ +export const MONTH_NAMES_DE = [ + 'Januar', + 'Februar', + 'Maerz', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', +]; diff --git a/packages/bot-services/src/planta/index.ts b/packages/bot-services/src/planta/index.ts new file mode 100644 index 000000000..736214fa2 --- /dev/null +++ b/packages/bot-services/src/planta/index.ts @@ -0,0 +1,28 @@ +/** + * Planta API Service + * + * Plant care and watering management API client. + * + * @example + * ```typescript + * import { PlantaModule, PlantaApiService } from '@manacore/bot-services/planta'; + * + * // In module + * @Module({ + * imports: [PlantaModule.forRoot()] + * }) + * + * // In service + * const plants = await plantaApiService.getPlantsNeedingWater(token); + * ``` + */ + +export { PlantaModule } from './planta.module.js'; +export { PlantaApiService } from './planta-api.service.js'; +export { + PlantaModuleOptions, + Plant, + PlantWateringStatus, + PLANTA_MODULE_OPTIONS, + DEFAULT_PLANTA_API_URL, +} from './types.js'; diff --git a/packages/bot-services/src/planta/planta-api.service.ts b/packages/bot-services/src/planta/planta-api.service.ts new file mode 100644 index 000000000..00c799629 --- /dev/null +++ b/packages/bot-services/src/planta/planta-api.service.ts @@ -0,0 +1,155 @@ +import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; +import { + Plant, + PlantWateringStatus, + PlantaModuleOptions, + PLANTA_MODULE_OPTIONS, + DEFAULT_PLANTA_API_URL, +} from './types'; + +/** + * Planta API Service + * + * Connects to the planta-backend API for plant care management. + * Used by the morning summary to show plants that need watering. + * + * @example + * ```typescript + * // Get plants needing water (requires JWT token) + * const plants = await plantaApiService.getPlantsNeedingWater(token); + * + * // Log watering + * await plantaApiService.logWatering(token, plantId); + * ``` + */ +@Injectable() +export class PlantaApiService { + private readonly logger = new Logger(PlantaApiService.name); + private readonly baseUrl: string; + + constructor(@Optional() @Inject(PLANTA_MODULE_OPTIONS) options?: PlantaModuleOptions) { + this.baseUrl = options?.apiUrl || DEFAULT_PLANTA_API_URL; + this.logger.log(`Planta API Service initialized with URL: ${this.baseUrl}`); + } + + /** + * Get plants that need watering (overdue or due today) + */ + async getPlantsNeedingWater(token: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/v1/watering/upcoming`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = (await response.json()) as PlantWateringStatus[]; + + // Filter to only overdue and today + return data.filter((p) => p.isOverdue || p.daysUntilWatering === 0); + } catch (error) { + this.logger.error('Failed to get plants needing water:', error); + return []; + } + } + + /** + * Get all upcoming watering (next 3 days) + */ + async getUpcomingWatering(token: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/v1/watering/upcoming`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return (await response.json()) as PlantWateringStatus[]; + } catch (error) { + this.logger.error('Failed to get upcoming watering:', error); + return []; + } + } + + /** + * Get all plants + */ + async getPlants(token: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/v1/plants`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = (await response.json()) as { plants?: Plant[] }; + return data.plants || []; + } catch (error) { + this.logger.error('Failed to get plants:', error); + return []; + } + } + + /** + * Log watering for a plant + */ + async logWatering(token: string, plantId: string, notes?: string): Promise { + try { + const body: Record = {}; + if (notes) body.notes = notes; + + const response = await fetch(`${this.baseUrl}/api/v1/watering/${plantId}/water`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + this.logger.log(`Logged watering for plant ${plantId}`); + return true; + } catch (error) { + this.logger.error(`Failed to log watering for plant ${plantId}:`, error); + return false; + } + } + + /** + * Format plants needing water for display + */ + formatPlantsNeedingWater(plants: PlantWateringStatus[]): string { + if (plants.length === 0) { + return ''; + } + + const overdue = plants.filter((p) => p.isOverdue); + const today = plants.filter((p) => !p.isOverdue && p.daysUntilWatering === 0); + + const lines: string[] = ['**Pflanzen giessen** 🌱']; + + if (overdue.length > 0) { + for (const plant of overdue) { + lines.push(`β€’ ${plant.plantName} (ueberfaellig!)`); + } + } + + if (today.length > 0) { + for (const plant of today) { + lines.push(`β€’ ${plant.plantName}`); + } + } + + return lines.join('\n'); + } +} diff --git a/packages/bot-services/src/planta/planta.module.ts b/packages/bot-services/src/planta/planta.module.ts new file mode 100644 index 000000000..ee5a40fa1 --- /dev/null +++ b/packages/bot-services/src/planta/planta.module.ts @@ -0,0 +1,89 @@ +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { PlantaApiService } from './planta-api.service'; +import { PlantaModuleOptions, PLANTA_MODULE_OPTIONS } from './types'; + +/** + * Planta Module + * + * Plant care and watering management API client. + * + * @example + * ```typescript + * // Basic usage + * @Module({ + * imports: [PlantaModule.register()] + * }) + * + * // With custom API URL + * @Module({ + * imports: [ + * PlantaModule.register({ + * apiUrl: 'http://planta-backend:3022', + * }) + * ] + * }) + * ``` + */ +@Global() +@Module({}) +export class PlantaModule { + /** + * Register module with explicit options + */ + static register(options: PlantaModuleOptions = {}): DynamicModule { + return { + module: PlantaModule, + providers: [ + { + provide: PLANTA_MODULE_OPTIONS, + useValue: options, + }, + PlantaApiService, + ], + exports: [PlantaApiService], + }; + } + + /** + * Register module with async configuration + */ + static registerAsync(options: { + imports?: any[]; + useFactory: (...args: any[]) => Promise | PlantaModuleOptions; + inject?: any[]; + }): DynamicModule { + return { + module: PlantaModule, + imports: [...(options.imports || [])], + providers: [ + { + provide: PLANTA_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + PlantaApiService, + ], + exports: [PlantaApiService], + }; + } + + /** + * Register with ConfigService reading from environment + * + * Environment variables: + * - PLANTA_API_URL: Planta backend URL + */ + static forRoot(): DynamicModule { + return this.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + apiUrl: + config.get('planta.apiUrl') || + config.get('PLANTA_API_URL') || + 'http://localhost:3022', + }), + inject: [ConfigService], + }); + } +} diff --git a/packages/bot-services/src/planta/types.ts b/packages/bot-services/src/planta/types.ts new file mode 100644 index 000000000..388eba318 --- /dev/null +++ b/packages/bot-services/src/planta/types.ts @@ -0,0 +1,46 @@ +/** + * Planta API Service Types + * + * Types for plant care and watering management + */ + +/** + * Plant with watering info + */ +export interface Plant { + id: string; + name: string; + scientificName?: string; + healthStatus?: 'healthy' | 'needs_attention' | 'sick'; + photoUrl?: string; +} + +/** + * Plant watering status + */ +export interface PlantWateringStatus { + plantId: string; + plantName: string; + daysUntilWatering: number; + isOverdue: boolean; + lastWateredAt: Date | null; + nextWateringAt: Date | null; + photoUrl?: string; +} + +/** + * Planta API module options + */ +export interface PlantaModuleOptions { + apiUrl?: string; +} + +/** + * Injection token for Planta module options + */ +export const PLANTA_MODULE_OPTIONS = 'PLANTA_MODULE_OPTIONS'; + +/** + * Default API URL + */ +export const DEFAULT_PLANTA_API_URL = 'http://localhost:3022'; diff --git a/packages/bot-services/src/weather/index.ts b/packages/bot-services/src/weather/index.ts new file mode 100644 index 000000000..bea89ce95 --- /dev/null +++ b/packages/bot-services/src/weather/index.ts @@ -0,0 +1,32 @@ +/** + * Weather Service + * + * Open-Meteo weather API integration for morning summaries and weather queries. + * + * @example + * ```typescript + * import { WeatherModule, WeatherService } from '@manacore/bot-services/weather'; + * + * // In module + * @Module({ + * imports: [WeatherModule.register({ defaultLocation: 'Berlin' })] + * }) + * + * // In service + * const weather = await weatherService.getWeather('Berlin'); + * console.log(weatherService.formatWeather(weather)); + * ``` + */ + +export { WeatherModule } from './weather.module.js'; +export { WeatherService } from './weather.service.js'; +export { + WeatherModuleOptions, + WeatherData, + WeatherCode, + GeocodingResult, + WEATHER_MODULE_OPTIONS, + DEFAULT_CACHE_TTL_MS, + WEATHER_DESCRIPTIONS_DE, + WEATHER_DESCRIPTIONS_EN, +} from './types.js'; diff --git a/packages/bot-services/src/weather/types.ts b/packages/bot-services/src/weather/types.ts new file mode 100644 index 000000000..56a2ec11b --- /dev/null +++ b/packages/bot-services/src/weather/types.ts @@ -0,0 +1,200 @@ +/** + * Weather Service Types + * + * Types for Open-Meteo weather API integration + */ + +/** + * Weather condition codes from Open-Meteo + * See: https://open-meteo.com/en/docs#weathervariables + */ +export type WeatherCode = + | 0 // Clear sky + | 1 + | 2 + | 3 // Mainly clear, partly cloudy, overcast + | 45 + | 48 // Fog and depositing rime fog + | 51 + | 53 + | 55 // Drizzle: Light, moderate, dense + | 56 + | 57 // Freezing drizzle: Light, dense + | 61 + | 63 + | 65 // Rain: Slight, moderate, heavy + | 66 + | 67 // Freezing rain: Light, heavy + | 71 + | 73 + | 75 // Snow fall: Slight, moderate, heavy + | 77 // Snow grains + | 80 + | 81 + | 82 // Rain showers: Slight, moderate, violent + | 85 + | 86 // Snow showers: Slight, heavy + | 95 // Thunderstorm: Slight or moderate + | 96 + | 99; // Thunderstorm with slight and heavy hail + +/** + * Weather data structure + */ +export interface WeatherData { + location: string; + temperature: number; + apparentTemperature: number; + humidity: number; + precipitation: number; + precipitationProbability: number; + windSpeed: number; + windDirection: number; + weatherCode: WeatherCode; + weatherDescription: string; + isDay: boolean; + fetchedAt: Date; +} + +/** + * Geocoding result from Open-Meteo + */ +export interface GeocodingResult { + id: number; + name: string; + latitude: number; + longitude: number; + country: string; + countryCode: string; + timezone: string; + admin1?: string; // State/Province +} + +/** + * Open-Meteo geocoding API response + */ +export interface GeocodingApiResponse { + results?: GeocodingResult[]; + generationtime_ms?: number; +} + +/** + * Open-Meteo current weather API response + */ +export interface WeatherApiResponse { + latitude: number; + longitude: number; + timezone: string; + current: { + time: string; + interval: number; + temperature_2m: number; + apparent_temperature: number; + relative_humidity_2m: number; + precipitation: number; + weather_code: WeatherCode; + wind_speed_10m: number; + wind_direction_10m: number; + is_day: number; + }; + hourly?: { + time: string[]; + precipitation_probability: number[]; + }; +} + +/** + * Weather service configuration + */ +export interface WeatherServiceConfig { + defaultLocation?: string; + cacheTtlMs?: number; + language?: 'de' | 'en'; +} + +/** + * Weather module options + */ +export interface WeatherModuleOptions { + defaultLocation?: string; + cacheTtlMs?: number; + language?: 'de' | 'en'; +} + +/** + * Injection token for weather module options + */ +export const WEATHER_MODULE_OPTIONS = 'WEATHER_MODULE_OPTIONS'; + +/** + * Default cache TTL: 30 minutes + */ +export const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000; + +/** + * Weather code to German description mapping + */ +export const WEATHER_DESCRIPTIONS_DE: Record = { + 0: 'Klar', + 1: 'Ueberwiegend klar', + 2: 'Teilweise bewoelkt', + 3: 'Bedeckt', + 45: 'Nebel', + 48: 'Gefrierender Nebel', + 51: 'Leichter Nieselregen', + 53: 'Nieselregen', + 55: 'Starker Nieselregen', + 56: 'Leichter gefrierender Nieselregen', + 57: 'Starker gefrierender Nieselregen', + 61: 'Leichter Regen', + 63: 'Regen', + 65: 'Starker Regen', + 66: 'Leichter gefrierender Regen', + 67: 'Starker gefrierender Regen', + 71: 'Leichter Schneefall', + 73: 'Schneefall', + 75: 'Starker Schneefall', + 77: 'Schneegriesel', + 80: 'Leichte Regenschauer', + 81: 'Regenschauer', + 82: 'Heftige Regenschauer', + 85: 'Leichte Schneeschauer', + 86: 'Starke Schneeschauer', + 95: 'Gewitter', + 96: 'Gewitter mit leichtem Hagel', + 99: 'Gewitter mit starkem Hagel', +}; + +/** + * Weather code to English description mapping + */ +export const WEATHER_DESCRIPTIONS_EN: Record = { + 0: 'Clear sky', + 1: 'Mainly clear', + 2: 'Partly cloudy', + 3: 'Overcast', + 45: 'Fog', + 48: 'Depositing rime fog', + 51: 'Light drizzle', + 53: 'Moderate drizzle', + 55: 'Dense drizzle', + 56: 'Light freezing drizzle', + 57: 'Dense freezing drizzle', + 61: 'Slight rain', + 63: 'Moderate rain', + 65: 'Heavy rain', + 66: 'Light freezing rain', + 67: 'Heavy freezing rain', + 71: 'Slight snow fall', + 73: 'Moderate snow fall', + 75: 'Heavy snow fall', + 77: 'Snow grains', + 80: 'Slight rain showers', + 81: 'Moderate rain showers', + 82: 'Violent rain showers', + 85: 'Slight snow showers', + 86: 'Heavy snow showers', + 95: 'Thunderstorm', + 96: 'Thunderstorm with slight hail', + 99: 'Thunderstorm with heavy hail', +}; diff --git a/packages/bot-services/src/weather/weather.module.ts b/packages/bot-services/src/weather/weather.module.ts new file mode 100644 index 000000000..b1f3cfc72 --- /dev/null +++ b/packages/bot-services/src/weather/weather.module.ts @@ -0,0 +1,115 @@ +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { WeatherService } from './weather.service'; +import { WeatherModuleOptions, WEATHER_MODULE_OPTIONS } from './types'; + +/** + * Weather Module + * + * Provides weather data via Open-Meteo API. + * No API key required - completely free! + * + * @example + * ```typescript + * // Basic usage + * @Module({ + * imports: [WeatherModule.register()] + * }) + * + * // With options + * @Module({ + * imports: [ + * WeatherModule.register({ + * defaultLocation: 'Berlin', + * cacheTtlMs: 15 * 60 * 1000, // 15 minutes + * language: 'de', + * }) + * ] + * }) + * + * // With ConfigService + * @Module({ + * imports: [ + * WeatherModule.registerAsync({ + * imports: [ConfigModule], + * useFactory: (config: ConfigService) => ({ + * defaultLocation: config.get('weather.defaultLocation'), + * }), + * inject: [ConfigService], + * }) + * ] + * }) + * ``` + */ +@Global() +@Module({}) +export class WeatherModule { + /** + * Register module with explicit options + */ + static register(options: WeatherModuleOptions = {}): DynamicModule { + return { + module: WeatherModule, + providers: [ + { + provide: WEATHER_MODULE_OPTIONS, + useValue: options, + }, + WeatherService, + ], + exports: [WeatherService], + }; + } + + /** + * Register module with async configuration + */ + static registerAsync(options: { + imports?: any[]; + useFactory: (...args: any[]) => Promise | WeatherModuleOptions; + inject?: any[]; + }): DynamicModule { + return { + module: WeatherModule, + imports: [...(options.imports || [])], + providers: [ + { + provide: WEATHER_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + WeatherService, + ], + exports: [WeatherService], + }; + } + + /** + * Register with ConfigService reading from environment + * + * Environment variables: + * - WEATHER_DEFAULT_LOCATION: Default city name + * - WEATHER_CACHE_TTL_MS: Cache TTL in milliseconds + * - WEATHER_LANGUAGE: 'de' or 'en' + */ + static forRoot(): DynamicModule { + return this.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + defaultLocation: + config.get('weather.defaultLocation') || + config.get('WEATHER_DEFAULT_LOCATION') || + 'Berlin', + cacheTtlMs: + config.get('weather.cacheTtlMs') || + config.get('WEATHER_CACHE_TTL_MS') || + 30 * 60 * 1000, + language: + (config.get('weather.language') as 'de' | 'en') || + (config.get('WEATHER_LANGUAGE') as 'de' | 'en') || + 'de', + }), + inject: [ConfigService], + }); + } +} diff --git a/packages/bot-services/src/weather/weather.service.ts b/packages/bot-services/src/weather/weather.service.ts new file mode 100644 index 000000000..aa1c0ac8f --- /dev/null +++ b/packages/bot-services/src/weather/weather.service.ts @@ -0,0 +1,274 @@ +import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; +import { + WeatherData, + WeatherCode, + GeocodingResult, + GeocodingApiResponse, + WeatherApiResponse, + WeatherModuleOptions, + WEATHER_MODULE_OPTIONS, + DEFAULT_CACHE_TTL_MS, + WEATHER_DESCRIPTIONS_DE, + WEATHER_DESCRIPTIONS_EN, +} from './types'; + +/** + * Weather Service + * + * Provides weather data via Open-Meteo API (free, no API key required). + * + * Features: + * - Geocoding: City name -> coordinates + * - Current weather with detailed conditions + * - In-memory caching (30 min default) + * - German and English weather descriptions + * + * @example + * ```typescript + * const weather = await weatherService.getWeather('Berlin'); + * console.log(`${weather.temperature}Β°C, ${weather.weatherDescription}`); + * ``` + */ +@Injectable() +export class WeatherService { + private readonly logger = new Logger(WeatherService.name); + private readonly geocodingUrl = 'https://geocoding-api.open-meteo.com/v1/search'; + private readonly weatherUrl = 'https://api.open-meteo.com/v1/forecast'; + + private readonly defaultLocation: string; + private readonly cacheTtlMs: number; + private readonly language: 'de' | 'en'; + + // In-memory cache: location -> { data, expiresAt } + private cache: Map = new Map(); + // Geocoding cache: location -> coordinates + private geocodeCache: Map = new Map(); + + constructor(@Optional() @Inject(WEATHER_MODULE_OPTIONS) options?: WeatherModuleOptions) { + this.defaultLocation = options?.defaultLocation || 'Berlin'; + this.cacheTtlMs = options?.cacheTtlMs || DEFAULT_CACHE_TTL_MS; + this.language = options?.language || 'de'; + + this.logger.log( + `Weather Service initialized (default: ${this.defaultLocation}, cache: ${this.cacheTtlMs / 1000}s)` + ); + } + + /** + * Get weather for a location + * + * @param location - City name (e.g., "Berlin", "New York") + * @returns Weather data or null if location not found + */ + async getWeather(location?: string): Promise { + const loc = (location || this.defaultLocation).toLowerCase().trim(); + + // Check cache first + const cached = this.cache.get(loc); + if (cached && cached.expiresAt > new Date()) { + this.logger.debug(`Cache hit for "${loc}"`); + return cached.data; + } + + // Geocode location + const coordinates = await this.geocode(loc); + if (!coordinates) { + this.logger.warn(`Location not found: "${loc}"`); + return null; + } + + // Fetch weather + const weather = await this.fetchWeather(coordinates); + if (!weather) { + return null; + } + + // Cache result + this.cache.set(loc, { + data: weather, + expiresAt: new Date(Date.now() + this.cacheTtlMs), + }); + + return weather; + } + + /** + * Get weather description for a weather code + */ + getWeatherDescription(code: WeatherCode): string { + const descriptions = this.language === 'de' ? WEATHER_DESCRIPTIONS_DE : WEATHER_DESCRIPTIONS_EN; + return descriptions[code] || 'Unbekannt'; + } + + /** + * Get weather emoji for a weather code + */ + getWeatherEmoji(code: WeatherCode, isDay: boolean): string { + // Clear + if (code === 0) return isDay ? 'β˜€οΈ' : 'πŸŒ™'; + if (code >= 1 && code <= 2) return isDay ? '🌀️' : 'πŸŒ™'; + if (code === 3) return '☁️'; + + // Fog + if (code >= 45 && code <= 48) return '🌫️'; + + // Drizzle + if (code >= 51 && code <= 57) return '🌧️'; + + // Rain + if (code >= 61 && code <= 67) return '🌧️'; + + // Snow + if (code >= 71 && code <= 77) return '❄️'; + + // Showers + if (code >= 80 && code <= 82) return '🌦️'; + if (code >= 85 && code <= 86) return '🌨️'; + + // Thunderstorm + if (code >= 95) return 'β›ˆοΈ'; + + return '🌑️'; + } + + /** + * Format weather for display + * + * @param weather - Weather data + * @param format - 'compact' or 'detailed' + */ + formatWeather(weather: WeatherData, format: 'compact' | 'detailed' = 'detailed'): string { + const emoji = this.getWeatherEmoji(weather.weatherCode, weather.isDay); + + if (format === 'compact') { + return `${Math.round(weather.temperature)}Β°C ${weather.weatherDescription}`; + } + + const lines = [ + `**Wetter in ${weather.location}** ${emoji}`, + `${Math.round(weather.temperature)}Β°C, ${weather.weatherDescription}`, + `Regen: ${weather.precipitationProbability}% | Wind: ${Math.round(weather.windSpeed)} km/h`, + ]; + + if (weather.apparentTemperature !== weather.temperature) { + const diff = Math.round(weather.apparentTemperature - weather.temperature); + if (Math.abs(diff) >= 2) { + lines.push( + `Gefuehlt: ${Math.round(weather.apparentTemperature)}Β°C (${diff > 0 ? '+' : ''}${diff}Β°)` + ); + } + } + + return lines.join('\n'); + } + + /** + * Clear cache (useful for testing) + */ + clearCache(): void { + this.cache.clear(); + this.geocodeCache.clear(); + } + + // ===== Private Methods ===== + + /** + * Geocode a location name to coordinates + */ + private async geocode(location: string): Promise { + // Check geocode cache + const cached = this.geocodeCache.get(location); + if (cached) { + return cached; + } + + try { + const params = new URLSearchParams({ + name: location, + count: '1', + language: this.language, + format: 'json', + }); + + const response = await fetch(`${this.geocodingUrl}?${params}`); + + if (!response.ok) { + this.logger.error(`Geocoding API error: ${response.status}`); + return null; + } + + const data = (await response.json()) as GeocodingApiResponse; + + if (!data.results || data.results.length === 0) { + return null; + } + + const result = data.results[0]; + this.geocodeCache.set(location, result); + return result; + } catch (error) { + this.logger.error(`Geocoding failed for "${location}":`, error); + return null; + } + } + + /** + * Fetch weather data for coordinates + */ + private async fetchWeather(geo: GeocodingResult): Promise { + try { + const params = new URLSearchParams({ + latitude: geo.latitude.toString(), + longitude: geo.longitude.toString(), + current: [ + 'temperature_2m', + 'apparent_temperature', + 'relative_humidity_2m', + 'precipitation', + 'weather_code', + 'wind_speed_10m', + 'wind_direction_10m', + 'is_day', + ].join(','), + hourly: 'precipitation_probability', + forecast_hours: '1', + timezone: 'auto', + }); + + const response = await fetch(`${this.weatherUrl}?${params}`); + + if (!response.ok) { + this.logger.error(`Weather API error: ${response.status}`); + return null; + } + + const data = (await response.json()) as WeatherApiResponse; + + // Get precipitation probability for current hour + const precipProb = data.hourly?.precipitation_probability?.[0] ?? 0; + + const weather: WeatherData = { + location: geo.name, + temperature: data.current.temperature_2m, + apparentTemperature: data.current.apparent_temperature, + humidity: data.current.relative_humidity_2m, + precipitation: data.current.precipitation, + precipitationProbability: precipProb, + windSpeed: data.current.wind_speed_10m, + windDirection: data.current.wind_direction_10m, + weatherCode: data.current.weather_code, + weatherDescription: this.getWeatherDescription(data.current.weather_code), + isDay: data.current.is_day === 1, + fetchedAt: new Date(), + }; + + this.logger.debug( + `Fetched weather for ${geo.name}: ${weather.temperature}Β°C, ${weather.weatherDescription}` + ); + return weather; + } catch (error) { + this.logger.error(`Weather fetch failed for ${geo.name}:`, error); + return null; + } + } +} diff --git a/services/matrix-mana-bot/.env.example b/services/matrix-mana-bot/.env.example index 05a6af6e3..89596ec29 100644 --- a/services/matrix-mana-bot/.env.example +++ b/services/matrix-mana-bot/.env.example @@ -23,6 +23,15 @@ CLOCK_API_URL=http://localhost:3017/api/v1 TODO_STORAGE_PATH=./data/todos.json CALENDAR_STORAGE_PATH=./data/calendar.json +# API Services (for Morning Summary) +TODO_API_URL=http://localhost:3018 +CALENDAR_API_URL=http://localhost:3014 +CONTACTS_API_URL=http://localhost:3015 +PLANTA_API_URL=http://localhost:3022 + +# Weather (Morning Summary) +WEATHER_DEFAULT_LOCATION=Berlin + # Voice Services STT_URL=http://localhost:3020 VOICE_BOT_URL=http://localhost:3050 diff --git a/services/matrix-mana-bot/CLAUDE.md b/services/matrix-mana-bot/CLAUDE.md index 258d04b6a..e4d9c2988 100644 --- a/services/matrix-mana-bot/CLAUDE.md +++ b/services/matrix-mana-bot/CLAUDE.md @@ -54,6 +54,7 @@ Unified Matrix bot that combines all features in one. Users can interact with a | **Timers** | `!timer`, `!timers`, `!stop`, `!alarm`, `!alarms` | Time management | | **Smart** | `!summary`, `!ai-todo` | Cross-feature AI features | | **Voice** | Send voice note | Speech-to-text via Whisper | +| **Morning** | `!morning`, `!morning-on/off`, `!morning-time` | Daily morning summary | ## Commands @@ -168,6 +169,42 @@ Was ist TypeScript? !ai-todo Im Meeting besprochen: Website redesign, API Docs aktualisieren ``` +### Morning Summary + +``` +# Get morning summary now +!morning + +# Enable/disable automatic daily delivery +!morning-on +!morning-off + +# Set delivery time (HH:MM) +!morning-time 07:30 + +# Set weather location +!morning-location Berlin + +# Set timezone +!morning-timezone Europe/Berlin + +# Set format (compact/detailed) +!morning-format detailed + +# Show current settings +!morning-settings + +# Show help +!morning-help +``` + +The morning summary includes: +- Weather forecast (Open-Meteo API) +- Today's calendar events +- Today's tasks + overdue tasks +- Birthdays (from Contacts) +- Plants needing water (from Planta) + ## Development ### Prerequisites @@ -235,7 +272,12 @@ src/ β”‚ β”œβ”€β”€ todo.handler.ts # Todo commands β”‚ β”œβ”€β”€ calendar.handler.ts # Calendar commands β”‚ β”œβ”€β”€ clock.handler.ts # Timer/alarm commands -β”‚ └── help.handler.ts # Help & status +β”‚ β”œβ”€β”€ help.handler.ts # Help & status +β”‚ β”œβ”€β”€ voice.handler.ts # Voice commands +β”‚ └── morning.handler.ts # Morning summary commands +β”œβ”€β”€ scheduler/ +β”‚ β”œβ”€β”€ scheduler.module.ts # @nestjs/schedule integration +β”‚ └── morning-summary.scheduler.ts # Cron job for morning delivery └── orchestration/ β”œβ”€β”€ orchestration.module.ts └── orchestration.service.ts # Cross-feature logic @@ -321,7 +363,12 @@ All bots share the same `@manacore/bot-services` package, so data is consistent. | `OLLAMA_MODEL` | No | gemma3:4b | Default LLM | | `CLOCK_API_URL` | No | localhost:3017 | Clock backend | | `TODO_STORAGE_PATH` | No | ./data/todos.json | Todo storage | +| `TODO_API_URL` | No | localhost:3018 | Todo API (morning summary) | | `CALENDAR_STORAGE_PATH` | No | ./data/calendar.json | Calendar storage | +| `CALENDAR_API_URL` | No | localhost:3014 | Calendar API (morning summary) | +| `CONTACTS_API_URL` | No | localhost:3015 | Contacts API (birthdays) | +| `PLANTA_API_URL` | No | localhost:3022 | Planta API (plants) | +| `WEATHER_DEFAULT_LOCATION` | No | Berlin | Default weather location | | `STT_URL` | No | localhost:3020 | Speech-to-text (Whisper) | | `VOICE_BOT_URL` | No | localhost:3050 | Voice bot (TTS) | | `DEFAULT_VOICE` | No | de-DE-ConradNeural | Default TTS voice | diff --git a/services/matrix-mana-bot/package.json b/services/matrix-mana-bot/package.json index 73ce22d84..f2867c9b8 100644 --- a/services/matrix-mana-bot/package.json +++ b/services/matrix-mana-bot/package.json @@ -19,6 +19,7 @@ "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "matrix-bot-sdk": "^0.7.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" diff --git a/services/matrix-mana-bot/src/app.module.ts b/services/matrix-mana-bot/src/app.module.ts index 3f9f1f632..e1282540c 100644 --- a/services/matrix-mana-bot/src/app.module.ts +++ b/services/matrix-mana-bot/src/app.module.ts @@ -5,6 +5,7 @@ import configuration from './config/configuration'; import { BotModule } from './bot/bot.module'; import { HandlersModule } from './handlers/handlers.module'; import { OrchestrationModule } from './orchestration/orchestration.module'; +import { SchedulerModule } from './scheduler/scheduler.module'; // Import shared services from bot-services package import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot-services'; @@ -67,6 +68,7 @@ import { TodoModule, CalendarModule, AiModule, ClockModule } from '@manacore/bot BotModule, HandlersModule, OrchestrationModule, + SchedulerModule, ], controllers: [HealthController], providers: [createHealthProvider('matrix-mana-bot')], diff --git a/services/matrix-mana-bot/src/bot/command-router.service.ts b/services/matrix-mana-bot/src/bot/command-router.service.ts index d98539c0a..8d0477393 100644 --- a/services/matrix-mana-bot/src/bot/command-router.service.ts +++ b/services/matrix-mana-bot/src/bot/command-router.service.ts @@ -6,6 +6,7 @@ import { CalendarHandler } from '../handlers/calendar.handler'; import { ClockHandler } from '../handlers/clock.handler'; import { HelpHandler } from '../handlers/help.handler'; import { VoiceHandler } from '../handlers/voice.handler'; +import { MorningHandler } from '../handlers/morning.handler'; import { OrchestrationService } from '../orchestration/orchestration.service'; export interface CommandContext { @@ -27,14 +28,24 @@ export class CommandRouterService { private readonly keywordDetector = new KeywordCommandDetector([ ...COMMON_KEYWORDS, { keywords: ['modelle', 'models', 'welche modelle', 'ai models'], command: 'models' }, - { keywords: ['meine aufgaben', 'zeige aufgaben', 'todo liste', 'was muss ich', 'aufgaben'], command: 'list' }, + { + keywords: ['meine aufgaben', 'zeige aufgaben', 'todo liste', 'was muss ich', 'aufgaben'], + command: 'list', + }, { keywords: ['heute', 'was steht heute an', 'today'], command: 'today' }, { keywords: ['termine', 'kalender', 'meine termine', 'calendar'], command: 'cal' }, { keywords: ['timer', 'stoppuhr', 'zeitmesser'], command: 'timers' }, - { keywords: ['zusammenfassung', 'wie war mein tag', 'tagesrueckblick', 'summary'], command: 'summary' }, + { + keywords: ['zusammenfassung', 'wie war mein tag', 'tagesrueckblick', 'summary'], + command: 'summary', + }, { keywords: ['todo', 'aufgabe', 'neue aufgabe', 'task'], command: 'todo' }, { keywords: ['alarm', 'wecker', 'alarme'], command: 'alarms' }, { keywords: ['clear', 'loeschen', 'verlauf loeschen', 'reset'], command: 'clear' }, + { + keywords: ['guten morgen', 'morgen zusammenfassung', 'morgenbericht', 'morning'], + command: 'morning', + }, ]); private readonly logger = new Logger(CommandRouterService.name); private routes: CommandRoute[] = []; @@ -52,6 +63,8 @@ export class CommandRouterService { private helpHandler: HelpHandler, @Inject(forwardRef(() => VoiceHandler)) private voiceHandler: VoiceHandler, + @Inject(forwardRef(() => MorningHandler)) + private morningHandler: MorningHandler, @Inject(forwardRef(() => OrchestrationService)) private orchestration: OrchestrationService ) { @@ -232,6 +245,53 @@ export class CommandRouterService { handler: (ctx, args) => this.voiceHandler.setSpeed(ctx, args), description: 'Set speech speed', }, + + // Morning Summary Commands + { + patterns: ['!morning', '!morgen'], + handler: (ctx) => this.morningHandler.getSummary(ctx), + description: 'Morning summary', + }, + { + patterns: ['!morning-on'], + handler: (ctx) => this.morningHandler.enable(ctx), + description: 'Enable morning summary', + }, + { + patterns: ['!morning-off'], + handler: (ctx) => this.morningHandler.disable(ctx), + description: 'Disable morning summary', + }, + { + patterns: ['!morning-time'], + handler: (ctx, args) => this.morningHandler.setTime(ctx, args), + description: 'Set morning delivery time', + }, + { + patterns: ['!morning-location'], + handler: (ctx, args) => this.morningHandler.setLocation(ctx, args), + description: 'Set weather location', + }, + { + patterns: ['!morning-timezone'], + handler: (ctx, args) => this.morningHandler.setTimezone(ctx, args), + description: 'Set timezone', + }, + { + patterns: ['!morning-format'], + handler: (ctx, args) => this.morningHandler.setFormat(ctx, args), + description: 'Set summary format', + }, + { + patterns: ['!morning-settings'], + handler: (ctx) => this.morningHandler.showSettings(ctx), + description: 'Show morning settings', + }, + { + patterns: ['!morning-help'], + handler: () => Promise.resolve(this.morningHandler.showHelp()), + description: 'Morning help', + }, ]; } diff --git a/services/matrix-mana-bot/src/config/configuration.ts b/services/matrix-mana-bot/src/config/configuration.ts index 8d7dc4686..0e5b1a230 100644 --- a/services/matrix-mana-bot/src/config/configuration.ts +++ b/services/matrix-mana-bot/src/config/configuration.ts @@ -19,10 +19,21 @@ export default () => ({ }, todo: { storagePath: process.env.TODO_STORAGE_PATH || './data/todos.json', + apiUrl: process.env.TODO_API_URL || 'http://localhost:3018', }, calendar: { storagePath: process.env.CALENDAR_STORAGE_PATH || './data/calendar.json', + apiUrl: process.env.CALENDAR_API_URL || 'http://localhost:3014', }, + contacts: { + apiUrl: process.env.CONTACTS_API_URL || 'http://localhost:3015', + }, + planta: { + apiUrl: process.env.PLANTA_API_URL || 'http://localhost:3022', + }, + }, + weather: { + defaultLocation: process.env.WEATHER_DEFAULT_LOCATION || 'Berlin', }, voice: { sttUrl: process.env.STT_URL || 'http://localhost:3020', @@ -65,6 +76,14 @@ Schreib einfach eine Nachricht - ich antworte! β€’ \`!summary\` - Tages-Zusammenfassung (AI) β€’ \`!ai-todo [text]\` - AI extrahiert Todos aus Text +**β˜€οΈ Morgenzusammenfassung** +β€’ \`!morning\` - Zusammenfassung jetzt abrufen +β€’ \`!morning-on\` - Automatisch aktivieren +β€’ \`!morning-off\` - Automatisch deaktivieren +β€’ \`!morning-time HH:MM\` - Sendezeit einstellen +β€’ \`!morning-location [Stadt]\` - Wetter-Ort setzen +β€’ \`!morning-settings\` - Einstellungen anzeigen + **🎀 Sprache & Voice** Sende eine Sprachnachricht - ich verstehe dich! β€’ NatΓΌrliche Befehle: "Was steht heute an?" diff --git a/services/matrix-mana-bot/src/handlers/handlers.module.ts b/services/matrix-mana-bot/src/handlers/handlers.module.ts index fa71f6dcc..48c682ff6 100644 --- a/services/matrix-mana-bot/src/handlers/handlers.module.ts +++ b/services/matrix-mana-bot/src/handlers/handlers.module.ts @@ -5,13 +5,36 @@ import { CalendarHandler } from './calendar.handler'; import { ClockHandler } from './clock.handler'; import { HelpHandler } from './help.handler'; import { VoiceHandler } from './voice.handler'; +import { MorningHandler } from './morning.handler'; import { BotModule } from '../bot/bot.module'; import { VoiceModule } from '../voice/voice.module'; -import { SessionModule, CreditModule } from '@manacore/bot-services'; +import { SessionModule, CreditModule, MorningSummaryModule } from '@manacore/bot-services'; @Module({ - imports: [forwardRef(() => BotModule), VoiceModule, SessionModule.forRoot(), CreditModule.forRoot()], - providers: [AiHandler, TodoHandler, CalendarHandler, ClockHandler, HelpHandler, VoiceHandler], - exports: [AiHandler, TodoHandler, CalendarHandler, ClockHandler, HelpHandler, VoiceHandler], + imports: [ + forwardRef(() => BotModule), + VoiceModule, + SessionModule.forRoot(), + CreditModule.forRoot(), + MorningSummaryModule.forRoot(), + ], + providers: [ + AiHandler, + TodoHandler, + CalendarHandler, + ClockHandler, + HelpHandler, + VoiceHandler, + MorningHandler, + ], + exports: [ + AiHandler, + TodoHandler, + CalendarHandler, + ClockHandler, + HelpHandler, + VoiceHandler, + MorningHandler, + ], }) export class HandlersModule {} diff --git a/services/matrix-mana-bot/src/handlers/morning.handler.ts b/services/matrix-mana-bot/src/handlers/morning.handler.ts new file mode 100644 index 000000000..75f2a2ff5 --- /dev/null +++ b/services/matrix-mana-bot/src/handlers/morning.handler.ts @@ -0,0 +1,253 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + SessionService, + MorningSummaryService, + MorningPreferencesService, +} from '@manacore/bot-services'; +import { CommandContext } from '../bot/command-router.service'; + +/** + * Morning Handler + * + * Handles morning summary commands including configuration and instant summaries. + * + * Commands: + * - !morning / !morgen - Get summary now + * - !morning-on - Enable automatic summary + * - !morning-off - Disable automatic summary + * - !morning-time HH:MM - Set delivery time + * - !morning-location [City] - Set weather location + * - !morning-settings - Show current settings + */ +@Injectable() +export class MorningHandler { + private readonly logger = new Logger(MorningHandler.name); + + constructor( + private sessionService: SessionService, + private morningSummaryService: MorningSummaryService, + private preferencesService: MorningPreferencesService + ) {} + + /** + * Get morning summary now + */ + async getSummary(ctx: CommandContext): Promise { + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + try { + const prefs = await this.preferencesService.getPreferences(ctx.userId); + const summary = await this.morningSummaryService.generateSummary(ctx.userId, token); + return this.morningSummaryService.formatSummary(summary, prefs.format); + } catch (error) { + this.logger.error(`Failed to generate summary for ${ctx.userId}:`, error); + return '❌ Fehler beim Erstellen der Zusammenfassung.'; + } + } + + /** + * Enable automatic morning summary + */ + async enable(ctx: CommandContext): Promise { + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + try { + const prefs = await this.preferencesService.setEnabled(ctx.userId, true); + this.logger.log(`Morning summary enabled for ${ctx.userId}`); + + let response = `βœ… Morgenzusammenfassung aktiviert!\n\n`; + response += `Du erhaeltst taeglich um **${prefs.deliveryTime}** (${prefs.timezone}) deine Zusammenfassung.`; + + if (!prefs.location) { + response += `\n\nπŸ’‘ Tipp: Setze deinen Wetter-Ort mit \`!morning-location Berlin\``; + } + + return response; + } catch (error) { + this.logger.error(`Failed to enable morning summary for ${ctx.userId}:`, error); + return '❌ Fehler beim Aktivieren der Morgenzusammenfassung.'; + } + } + + /** + * Disable automatic morning summary + */ + async disable(ctx: CommandContext): Promise { + // Require login for persistent storage + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + try { + await this.preferencesService.setEnabled(ctx.userId, false); + this.logger.log(`Morning summary disabled for ${ctx.userId}`); + return 'βœ… Morgenzusammenfassung deaktiviert.'; + } catch (error) { + this.logger.error(`Failed to disable morning summary for ${ctx.userId}:`, error); + return '❌ Fehler beim Deaktivieren der Morgenzusammenfassung.'; + } + } + + /** + * Set delivery time + */ + async setTime(ctx: CommandContext, args: string): Promise { + // Require login for persistent storage + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + const time = args.trim(); + + if (!time) { + return '❌ Bitte gib eine Uhrzeit an.\n\nBeispiel: `!morning-time 07:30`'; + } + + try { + const prefs = await this.preferencesService.setDeliveryTime(ctx.userId, time); + this.logger.log(`Morning delivery time set to ${prefs.deliveryTime} for ${ctx.userId}`); + return `βœ… Uhrzeit auf **${prefs.deliveryTime}** gesetzt (${prefs.timezone}).`; + } catch (error) { + if (error instanceof Error) { + return `❌ ${error.message}`; + } + return '❌ Fehler beim Setzen der Uhrzeit. Verwende das Format HH:MM (z.B. 07:00).'; + } + } + + /** + * Set weather location + */ + async setLocation(ctx: CommandContext, args: string): Promise { + // Require login for persistent storage + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + const location = args.trim(); + + if (!location) { + // Show current location + const prefs = await this.preferencesService.getPreferences(ctx.userId); + if (prefs.location) { + return `🌍 Aktueller Wetter-Ort: **${prefs.location}**\n\nAendern mit: \`!morning-location [Stadt]\``; + } + return '🌍 Kein Wetter-Ort gesetzt.\n\nSetze mit: `!morning-location Berlin`'; + } + + try { + const prefs = await this.preferencesService.setLocation(ctx.userId, location); + this.logger.log(`Morning location set to ${location} for ${ctx.userId}`); + return `βœ… Wetter-Ort auf **${prefs.location}** gesetzt.`; + } catch (error) { + this.logger.error(`Failed to set location for ${ctx.userId}:`, error); + return '❌ Fehler beim Setzen des Wetter-Orts.'; + } + } + + /** + * Set timezone + */ + async setTimezone(ctx: CommandContext, args: string): Promise { + // Require login for persistent storage + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + const timezone = args.trim(); + + if (!timezone) { + const prefs = await this.preferencesService.getPreferences(ctx.userId); + return `πŸ• Aktuelle Zeitzone: **${prefs.timezone}**\n\nAendern mit: \`!morning-timezone Europe/Berlin\``; + } + + try { + const prefs = await this.preferencesService.setTimezone(ctx.userId, timezone); + this.logger.log(`Morning timezone set to ${timezone} for ${ctx.userId}`); + return `βœ… Zeitzone auf **${prefs.timezone}** gesetzt.`; + } catch (error) { + if (error instanceof Error) { + return `❌ ${error.message}`; + } + return '❌ Ungueltige Zeitzone. Verwende IANA Format (z.B. Europe/Berlin).'; + } + } + + /** + * Set summary format + */ + async setFormat(ctx: CommandContext, args: string): Promise { + // Require login for persistent storage + const token = await this.sessionService.getToken(ctx.userId); + if (!token) { + return this.requireLogin(); + } + + const format = args.trim().toLowerCase(); + + if ( + format !== 'compact' && + format !== 'detailed' && + format !== 'kompakt' && + format !== 'ausfuehrlich' + ) { + const prefs = await this.preferencesService.getPreferences(ctx.userId); + const currentFormat = prefs.format === 'compact' ? 'Kompakt' : 'Ausfuehrlich'; + return `πŸ“‹ Aktuelles Format: **${currentFormat}**\n\nAendern mit: \`!morning-format kompakt\` oder \`!morning-format ausfuehrlich\``; + } + + try { + const newFormat = format === 'compact' || format === 'kompakt' ? 'compact' : 'detailed'; + const prefs = await this.preferencesService.setFormat(ctx.userId, newFormat); + const formatName = prefs.format === 'compact' ? 'Kompakt' : 'Ausfuehrlich'; + this.logger.log(`Morning format set to ${prefs.format} for ${ctx.userId}`); + return `βœ… Format auf **${formatName}** gesetzt.`; + } catch (error) { + this.logger.error(`Failed to set format for ${ctx.userId}:`, error); + return '❌ Fehler beim Setzen des Formats.'; + } + } + + /** + * Show current settings + */ + async showSettings(ctx: CommandContext): Promise { + try { + const prefs = await this.preferencesService.getPreferences(ctx.userId); + return this.preferencesService.formatPreferences(prefs); + } catch (error) { + this.logger.error(`Failed to get settings for ${ctx.userId}:`, error); + return '❌ Fehler beim Laden der Einstellungen.'; + } + } + + /** + * Show help for morning commands + */ + showHelp(): string { + return `**Morgenzusammenfassung Befehle** β˜€οΈ + +\`!morning\` / \`!morgen\` - Zusammenfassung jetzt abrufen +\`!morning-on\` - Automatische Zusammenfassung aktivieren +\`!morning-off\` - Automatische Zusammenfassung deaktivieren +\`!morning-time HH:MM\` - Sendezeit einstellen (z.B. 07:30) +\`!morning-location [Stadt]\` - Wetter-Standort setzen +\`!morning-timezone [Zone]\` - Zeitzone setzen (z.B. Europe/Berlin) +\`!morning-format [kompakt|ausfuehrlich]\` - Format waehlen +\`!morning-settings\` - Aktuelle Einstellungen anzeigen`; + } + + private requireLogin(): string { + return '❌ Du musst angemeldet sein, um die Morgenzusammenfassung zu nutzen.\n\nMelde dich an mit: `!login deine@email.de passwort`'; + } +} diff --git a/services/matrix-mana-bot/src/scheduler/morning-summary.scheduler.ts b/services/matrix-mana-bot/src/scheduler/morning-summary.scheduler.ts new file mode 100644 index 000000000..5ea4ce075 --- /dev/null +++ b/services/matrix-mana-bot/src/scheduler/morning-summary.scheduler.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { + SessionService, + MorningSummaryService, + MorningPreferencesService, +} from '@manacore/bot-services'; +import { MatrixService } from '../bot/matrix.service'; + +/** + * Morning Summary Scheduler + * + * Runs every minute and checks if any users should receive their morning summary. + * Matches the current time against each user's configured delivery time and timezone. + */ +@Injectable() +export class MorningSummaryScheduler implements OnModuleInit { + private readonly logger = new Logger(MorningSummaryScheduler.name); + + // Track which users have received their summary today to avoid duplicates + private deliveredToday: Map = new Map(); // userId -> date + + constructor( + private sessionService: SessionService, + private morningSummaryService: MorningSummaryService, + private preferencesService: MorningPreferencesService, + private matrixService: MatrixService + ) {} + + onModuleInit() { + this.logger.log('Morning Summary Scheduler initialized'); + } + + /** + * Check delivery times every minute + */ + @Cron(CronExpression.EVERY_MINUTE) + async checkDeliveryTimes() { + const now = new Date(); + const todayStr = now.toISOString().split('T')[0]; + + // Get all active users with sessions + const activeUsers = this.sessionService.getActiveUserIds(); + + for (const userId of activeUsers) { + // Skip if already delivered today + if (this.deliveredToday.get(userId) === todayStr) { + continue; + } + + try { + const prefs = await this.preferencesService.getPreferences(userId); + + // Skip if not enabled + if (!prefs.enabled) { + continue; + } + + // Check if it's time to deliver + if (this.preferencesService.shouldDeliverNow(prefs, now)) { + await this.deliverSummary(userId); + this.deliveredToday.set(userId, todayStr); + } + } catch (error) { + this.logger.error(`Error checking delivery for ${userId}:`, error); + } + } + } + + /** + * Clean up delivered tracking at midnight (UTC) + */ + @Cron('0 0 * * *', { timeZone: 'UTC' }) + cleanupDeliveredTracking() { + this.deliveredToday.clear(); + this.logger.debug('Cleared delivered tracking for new day'); + } + + /** + * Deliver morning summary to a user + */ + private async deliverSummary(matrixUserId: string): Promise { + const token = await this.sessionService.getToken(matrixUserId); + if (!token) { + this.logger.warn(`Cannot deliver summary to ${matrixUserId}: no token`); + return; + } + + try { + const prefs = await this.preferencesService.getPreferences(matrixUserId); + const summary = await this.morningSummaryService.generateSummary(matrixUserId, token); + const formatted = this.morningSummaryService.formatSummary(summary, prefs.format); + + // Send via Matrix + await this.matrixService.sendDirectMessage(matrixUserId, formatted); + + this.logger.log(`Delivered morning summary to ${matrixUserId}`); + } catch (error) { + this.logger.error(`Failed to deliver summary to ${matrixUserId}:`, error); + } + } +} diff --git a/services/matrix-mana-bot/src/scheduler/scheduler.module.ts b/services/matrix-mana-bot/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..e0cb19650 --- /dev/null +++ b/services/matrix-mana-bot/src/scheduler/scheduler.module.ts @@ -0,0 +1,19 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { MorningSummaryScheduler } from './morning-summary.scheduler'; +import { BotModule } from '../bot/bot.module'; +import { SessionModule } from '@manacore/bot-services'; + +/** + * Scheduler Module + * + * Provides scheduled tasks for the bot including: + * - Morning summary delivery + * - Future: Reminder notifications, recurring tasks, etc. + */ +@Module({ + imports: [ScheduleModule.forRoot(), forwardRef(() => BotModule), SessionModule.forRoot()], + providers: [MorningSummaryScheduler], + exports: [MorningSummaryScheduler], +}) +export class SchedulerModule {}