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:
Till-JS 2026-02-17 11:01:47 +01:00
parent 3b00303e7b
commit dcf4438804
28 changed files with 2781 additions and 7 deletions

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

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

View 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';

View 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';

View file

@ -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';

View 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';

View file

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

View file

@ -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));
}
}

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

View 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',
];

View 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';

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

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

View 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';

View 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';

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

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

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

View file

@ -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

View file

@ -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 |

View file

@ -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"

View file

@ -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')],

View file

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

View file

@ -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?"

View file

@ -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 {}

View 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`';
}
}

View file

@ -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);
}
}
}

View 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 {}