mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
✨ feat(mana-bot): add daily morning summary feature
Add configurable morning summaries that aggregate data from multiple sources: - Weather forecast via Open-Meteo API (free, no API key needed) - Today's calendar events - Today's tasks + overdue tasks - Birthdays from contacts - Plants needing water from Planta New commands: - !morning / !morgen - Get summary now - !morning-on/off - Enable/disable automatic delivery - !morning-time HH:MM - Set delivery time - !morning-location [city] - Set weather location - !morning-timezone [zone] - Set timezone - !morning-format [kompakt|ausfuehrlich] - Set format - !morning-settings - Show current settings New shared services in @manacore/bot-services: - WeatherService - Open-Meteo integration with geocoding - ContactsApiService - Birthday fetching - PlantaApiService - Watering schedule - MorningSummaryService - Aggregates all sources - MorningPreferencesService - User preferences storage Includes scheduler for automatic daily delivery at user-configured time. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b00303e7b
commit
dcf4438804
28 changed files with 2781 additions and 7 deletions
210
packages/bot-services/src/contacts/contacts-api.service.ts
Normal file
210
packages/bot-services/src/contacts/contacts-api.service.ts
Normal file
|
|
@ -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<ContactBirthday[]> {
|
||||
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<ContactBirthday[]> {
|
||||
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<Contact[]> {
|
||||
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<Contact | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
89
packages/bot-services/src/contacts/contacts.module.ts
Normal file
89
packages/bot-services/src/contacts/contacts.module.ts
Normal file
|
|
@ -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> | 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<string>('contacts.apiUrl') ||
|
||||
config.get<string>('CONTACTS_API_URL') ||
|
||||
'http://localhost:3015',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
}
|
||||
}
|
||||
28
packages/bot-services/src/contacts/index.ts
Normal file
28
packages/bot-services/src/contacts/index.ts
Normal file
|
|
@ -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';
|
||||
54
packages/bot-services/src/contacts/types.ts
Normal file
54
packages/bot-services/src/contacts/types.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
42
packages/bot-services/src/morning-summary/index.ts
Normal file
42
packages/bot-services/src/morning-summary/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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<string>('services.todo.apiUrl') ||
|
||||
config.get<string>('TODO_API_URL') ||
|
||||
'http://localhost:3018',
|
||||
calendarApiUrl:
|
||||
config.get<string>('services.calendar.apiUrl') ||
|
||||
config.get<string>('CALENDAR_API_URL') ||
|
||||
'http://localhost:3014',
|
||||
contactsApiUrl:
|
||||
config.get<string>('services.contacts.apiUrl') ||
|
||||
config.get<string>('CONTACTS_API_URL') ||
|
||||
'http://localhost:3015',
|
||||
plantaApiUrl:
|
||||
config.get<string>('services.planta.apiUrl') ||
|
||||
config.get<string>('PLANTA_API_URL') ||
|
||||
'http://localhost:3022',
|
||||
defaultLocation:
|
||||
config.get<string>('weather.defaultLocation') ||
|
||||
config.get<string>('WEATHER_DEFAULT_LOCATION') ||
|
||||
'Berlin',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
// API Services
|
||||
{
|
||||
provide: CalendarApiService,
|
||||
useFactory: (config: ConfigService) =>
|
||||
new CalendarApiService(
|
||||
config.get<string>('services.calendar.apiUrl') ||
|
||||
config.get<string>('CALENDAR_API_URL') ||
|
||||
'http://localhost:3014'
|
||||
),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: TodoApiService,
|
||||
useFactory: (config: ConfigService) =>
|
||||
new TodoApiService(
|
||||
config.get<string>('services.todo.apiUrl') ||
|
||||
config.get<string>('TODO_API_URL') ||
|
||||
'http://localhost:3018'
|
||||
),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: ContactsApiService,
|
||||
useFactory: (config: ConfigService) => {
|
||||
const apiUrl =
|
||||
config.get<string>('services.contacts.apiUrl') ||
|
||||
config.get<string>('CONTACTS_API_URL') ||
|
||||
'http://localhost:3015';
|
||||
return new ContactsApiService({ apiUrl });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: PlantaApiService,
|
||||
useFactory: (config: ConfigService) => {
|
||||
const apiUrl =
|
||||
config.get<string>('services.planta.apiUrl') ||
|
||||
config.get<string>('PLANTA_API_URL') ||
|
||||
'http://localhost:3022';
|
||||
return new PlantaApiService({ apiUrl });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: WeatherService,
|
||||
useFactory: (config: ConfigService) =>
|
||||
new WeatherService({
|
||||
defaultLocation:
|
||||
config.get<string>('weather.defaultLocation') ||
|
||||
config.get<string>('WEATHER_DEFAULT_LOCATION') ||
|
||||
'Berlin',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
MorningPreferencesService,
|
||||
MorningSummaryService,
|
||||
],
|
||||
exports: [MorningSummaryService, MorningPreferencesService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MorningSummaryData> {
|
||||
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<Task[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
208
packages/bot-services/src/morning-summary/preferences.service.ts
Normal file
208
packages/bot-services/src/morning-summary/preferences.service.ts
Normal file
|
|
@ -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<MorningPreferences> {
|
||||
try {
|
||||
const stored = await this.sessionService.getSessionData<MorningPreferences>(
|
||||
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<MorningPreferences>
|
||||
): Promise<MorningPreferences> {
|
||||
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<MorningPreferences> {
|
||||
return this.savePreferences(matrixUserId, { enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set delivery time (HH:MM format)
|
||||
*/
|
||||
async setDeliveryTime(matrixUserId: string, time: string): Promise<MorningPreferences> {
|
||||
// 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<MorningPreferences> {
|
||||
// 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<MorningPreferences> {
|
||||
return this.savePreferences(matrixUserId, { location });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set summary format
|
||||
*/
|
||||
async setFormat(
|
||||
matrixUserId: string,
|
||||
format: 'compact' | 'detailed'
|
||||
): Promise<MorningPreferences> {
|
||||
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<string[]> {
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
124
packages/bot-services/src/morning-summary/types.ts
Normal file
124
packages/bot-services/src/morning-summary/types.ts
Normal file
|
|
@ -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',
|
||||
];
|
||||
28
packages/bot-services/src/planta/index.ts
Normal file
28
packages/bot-services/src/planta/index.ts
Normal file
|
|
@ -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';
|
||||
155
packages/bot-services/src/planta/planta-api.service.ts
Normal file
155
packages/bot-services/src/planta/planta-api.service.ts
Normal file
|
|
@ -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<PlantWateringStatus[]> {
|
||||
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<PlantWateringStatus[]> {
|
||||
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<Plant[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
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');
|
||||
}
|
||||
}
|
||||
89
packages/bot-services/src/planta/planta.module.ts
Normal file
89
packages/bot-services/src/planta/planta.module.ts
Normal file
|
|
@ -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> | 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<string>('planta.apiUrl') ||
|
||||
config.get<string>('PLANTA_API_URL') ||
|
||||
'http://localhost:3022',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
}
|
||||
}
|
||||
46
packages/bot-services/src/planta/types.ts
Normal file
46
packages/bot-services/src/planta/types.ts
Normal file
|
|
@ -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';
|
||||
32
packages/bot-services/src/weather/index.ts
Normal file
32
packages/bot-services/src/weather/index.ts
Normal file
|
|
@ -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';
|
||||
200
packages/bot-services/src/weather/types.ts
Normal file
200
packages/bot-services/src/weather/types.ts
Normal file
|
|
@ -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<number, string> = {
|
||||
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<number, string> = {
|
||||
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',
|
||||
};
|
||||
115
packages/bot-services/src/weather/weather.module.ts
Normal file
115
packages/bot-services/src/weather/weather.module.ts
Normal file
|
|
@ -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> | 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<string>('weather.defaultLocation') ||
|
||||
config.get<string>('WEATHER_DEFAULT_LOCATION') ||
|
||||
'Berlin',
|
||||
cacheTtlMs:
|
||||
config.get<number>('weather.cacheTtlMs') ||
|
||||
config.get<number>('WEATHER_CACHE_TTL_MS') ||
|
||||
30 * 60 * 1000,
|
||||
language:
|
||||
(config.get<string>('weather.language') as 'de' | 'en') ||
|
||||
(config.get<string>('WEATHER_LANGUAGE') as 'de' | 'en') ||
|
||||
'de',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
});
|
||||
}
|
||||
}
|
||||
274
packages/bot-services/src/weather/weather.service.ts
Normal file
274
packages/bot-services/src/weather/weather.service.ts
Normal file
|
|
@ -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<string, { data: WeatherData; expiresAt: Date }> = new Map();
|
||||
// Geocoding cache: location -> coordinates
|
||||
private geocodeCache: Map<string, GeocodingResult> = 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<WeatherData | null> {
|
||||
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<GeocodingResult | null> {
|
||||
// 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<WeatherData | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
253
services/matrix-mana-bot/src/handlers/morning.handler.ts
Normal file
253
services/matrix-mana-bot/src/handlers/morning.handler.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
// 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<string> {
|
||||
// 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<string> {
|
||||
// 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<string> {
|
||||
// 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<string> {
|
||||
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`';
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
services/matrix-mana-bot/src/scheduler/scheduler.module.ts
Normal file
19
services/matrix-mana-bot/src/scheduler/scheduler.module.ts
Normal file
|
|
@ -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 {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue