diff --git a/docs/TELEGRAM_BOTS.md b/docs/TELEGRAM_BOTS.md index 7bb3c3145..6e8fb7e8b 100644 --- a/docs/TELEGRAM_BOTS.md +++ b/docs/TELEGRAM_BOTS.md @@ -9,7 +9,7 @@ Dokumentation aller Telegram-Bots im ManaCore Monorepo. | [telegram-stats-bot](#telegram-stats-bot) | 3300 | Analytics & Statistiken von Umami | ✅ Aktiv | | [telegram-ollama-bot](#telegram-ollama-bot) | 3301 | Lokale LLM-Inferenz via Ollama | ✅ Aktiv | | [telegram-project-doc-bot](#telegram-project-doc-bot) | 3302 | Projektdokumentation & Blogpost-Generierung | ✅ Aktiv | -| [telegram-calendar-bot](#telegram-calendar-bot) | 3303 | Kalender-Termine & Erinnerungen | 📋 Geplant | +| [telegram-calendar-bot](#telegram-calendar-bot) | 3303 | Kalender-Termine & Erinnerungen | 🚧 In Entwicklung | ## Gemeinsame Architektur diff --git a/services/telegram-calendar-bot/.env.example b/services/telegram-calendar-bot/.env.example new file mode 100644 index 000000000..e56a68dcd --- /dev/null +++ b/services/telegram-calendar-bot/.env.example @@ -0,0 +1,21 @@ +# Server +PORT=3303 +NODE_ENV=development +TZ=Europe/Berlin + +# Telegram +TELEGRAM_BOT_TOKEN=xxx # Bot Token from @BotFather +TELEGRAM_ALLOWED_USERS= # Optional: Comma-separated user IDs + +# Calendar Backend API +CALENDAR_API_URL=http://localhost:3016 +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Database (for telegram user links and reminder settings) +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar_bot + +# Reminder Settings +REMINDER_CHECK_INTERVAL=60000 # Check every minute (ms) +MORNING_BRIEFING_ENABLED=true +MORNING_BRIEFING_TIME=07:00 +MORNING_BRIEFING_TIMEZONE=Europe/Berlin diff --git a/services/telegram-calendar-bot/CLAUDE.md b/services/telegram-calendar-bot/CLAUDE.md new file mode 100644 index 000000000..79c46706b --- /dev/null +++ b/services/telegram-calendar-bot/CLAUDE.md @@ -0,0 +1,290 @@ +# Telegram Calendar Bot + +Telegram Bot für die Calendar-App mit Termin-Abfragen, Quick-Add und Erinnerungen. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Telegram**: nestjs-telegraf + Telegraf +- **Database**: PostgreSQL + Drizzle ORM +- **Scheduling**: @nestjs/schedule +- **Date Handling**: date-fns, date-fns-tz + +## Commands + +```bash +# Development +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types + +# Database +pnpm db:generate # Generate migrations +pnpm db:push # Push schema to database +pnpm db:studio # Open Drizzle Studio +``` + +## Telegram Commands + +| Command | Beschreibung | +|---------|--------------| +| `/start` | Hilfe & Account verknüpfen | +| `/help` | Verfügbare Befehle anzeigen | +| `/today` | Heutige Termine | +| `/tomorrow` | Morgige Termine | +| `/week` | Wochenübersicht | +| `/next [n]` | Nächste n Termine (default: 5) | +| `/add [text]` | Termin hinzufügen | +| `/calendars` | Kalender-Übersicht | +| `/remind` | Erinnerungseinstellungen | +| `/status` | Verbindungsstatus prüfen | +| `/link` | ManaCore Account verknüpfen | +| `/unlink` | Account-Verknüpfung trennen | + +## Environment Variables + +```env +# Server +PORT=3303 +NODE_ENV=development +TZ=Europe/Berlin + +# Telegram +TELEGRAM_BOT_TOKEN=xxx # Bot Token from @BotFather +TELEGRAM_ALLOWED_USERS=123,456 # Optional: Comma-separated user IDs + +# Calendar Backend API +CALENDAR_API_URL=http://localhost:3016 +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Database +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar_bot + +# Reminder Settings +REMINDER_CHECK_INTERVAL=60000 # Check every minute (ms) +MORNING_BRIEFING_ENABLED=true +MORNING_BRIEFING_TIME=07:00 +MORNING_BRIEFING_TIMEZONE=Europe/Berlin +``` + +## Projekt-Struktur + +``` +services/telegram-calendar-bot/ +├── src/ +│ ├── main.ts # Entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health endpoint +│ ├── config/ +│ │ └── configuration.ts # Config & commands +│ ├── database/ +│ │ ├── database.module.ts # Drizzle connection +│ │ └── schema.ts # DB schema +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ ├── bot.update.ts # Command handlers +│ │ └── formatters.ts # Message formatters +│ ├── calendar/ +│ │ ├── calendar.module.ts +│ │ └── calendar.client.ts # Calendar API client +│ ├── user/ +│ │ ├── user.module.ts +│ │ └── user.service.ts # User management +│ └── reminder/ +│ ├── reminder.module.ts +│ ├── reminder.service.ts # Sent reminders tracking +│ └── reminder.scheduler.ts # Cron jobs +├── drizzle/ # Migrations +├── drizzle.config.ts +├── package.json +└── Dockerfile +``` + +## Datenbank-Schema + +### telegram_users + +Verknüpfung zwischen Telegram und ManaCore Accounts. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUID | Primary key | +| `telegram_user_id` | BIGINT | Telegram user ID (unique) | +| `telegram_username` | TEXT | Telegram username | +| `telegram_first_name` | TEXT | Telegram first name | +| `mana_user_id` | TEXT | ManaCore user ID | +| `access_token` | TEXT | JWT access token | +| `refresh_token` | TEXT | JWT refresh token | +| `token_expires_at` | TIMESTAMP | Token expiration | +| `settings` | JSONB | User settings | +| `is_active` | BOOLEAN | Active status | +| `linked_at` | TIMESTAMP | Link timestamp | +| `last_active_at` | TIMESTAMP | Last activity | +| `created_at` | TIMESTAMP | Creation time | +| `updated_at` | TIMESTAMP | Last update | + +### reminder_settings + +User-spezifische Erinnerungseinstellungen. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUID | Primary key | +| `telegram_user_id` | BIGINT | FK to telegram_users | +| `default_reminder_minutes` | INTEGER | Default reminder time (15) | +| `morning_briefing_enabled` | BOOLEAN | Enable daily briefing | +| `morning_briefing_time` | TIME | Briefing time (07:00) | +| `timezone` | TEXT | User timezone | +| `notify_event_reminders` | BOOLEAN | Enable reminders | +| `notify_event_changes` | BOOLEAN | Enable change notifications | +| `notify_shared_calendars` | BOOLEAN | Enable share notifications | +| `created_at` | TIMESTAMP | Creation time | +| `updated_at` | TIMESTAMP | Last update | + +### sent_reminders + +Log of sent reminders to avoid duplicates. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUID | Primary key | +| `telegram_user_id` | BIGINT | FK to telegram_users | +| `event_id` | TEXT | Calendar event ID | +| `event_instance_date` | TIMESTAMP | For recurring events | +| `reminder_type` | TEXT | 'before_event' or 'morning_briefing' | +| `minutes_before` | INTEGER | Minutes before event | +| `sent_at` | TIMESTAMP | Send timestamp | +| `message_id` | INTEGER | Telegram message ID | + +## Lokale Entwicklung + +### 1. Bot bei Telegram erstellen + +1. Öffne @BotFather in Telegram +2. Sende `/newbot` +3. Wähle einen Namen (z.B. "ManaCore Calendar") +4. Wähle einen Username (z.B. "manacore_calendar_bot") +5. Kopiere den Token + +### 2. Umgebung vorbereiten + +```bash +# Docker Services starten (PostgreSQL) +pnpm docker:up + +# Datenbank erstellen +psql -h localhost -U manacore -d postgres -c "CREATE DATABASE calendar_bot;" + +# In das Service-Verzeichnis wechseln +cd services/telegram-calendar-bot + +# .env erstellen +cp .env.example .env +# Token und URLs eintragen + +# Schema pushen +pnpm db:push +``` + +### 3. Bot starten + +```bash +pnpm start:dev +``` + +### 4. Calendar Backend starten + +Der Bot benötigt das Calendar Backend: + +```bash +# In einem anderen Terminal +pnpm dev:calendar:backend +``` + +## Features + +### Termin-Abfragen + +- **Heute/Morgen/Woche**: Schnellübersicht der anstehenden Termine +- **Nächste N**: Flexible Anzahl der nächsten Termine +- **Kalender-Filter**: Termine nach Kalender filtern (TODO) + +### Erinnerungen + +- **Event-Reminder**: X Minuten vor einem Termin +- **Morgen-Briefing**: Tägliche Zusammenfassung am Morgen +- **Konfigurierbar**: Pro User anpassbar + +### Account-Verknüpfung + +- Telegram-User wird mit ManaCore-Account verknüpft +- JWT-Token wird gespeichert für API-Zugriffe +- Automatische Token-Refresh (TODO) + +## Scheduled Jobs + +| Job | Schedule | Beschreibung | +|-----|----------|--------------| +| `checkReminders` | Jede Minute | Prüft anstehende Events und sendet Erinnerungen | +| `sendMorningBriefings` | Jede Stunde | Sendet Morgen-Briefing zur konfigurierten Zeit | +| `cleanupOldReminders` | Täglich 03:00 | Löscht alte sent_reminders Einträge | + +## Health Check + +```bash +curl http://localhost:3303/health +``` + +Antwort: +```json +{ + "status": "ok", + "service": "telegram-calendar-bot", + "timestamp": "2025-01-27T10:00:00.000Z", + "environment": "development" +} +``` + +## Deployment + +### Docker + +```yaml +# docker-compose.yml +telegram-calendar-bot: + build: + context: . + dockerfile: services/telegram-calendar-bot/Dockerfile + restart: always + environment: + PORT: 3303 + TELEGRAM_BOT_TOKEN: ${TELEGRAM_CALENDAR_BOT_TOKEN} + CALENDAR_API_URL: http://calendar-backend:3016 + DATABASE_URL: ${CALENDAR_BOT_DATABASE_URL} + ports: + - "3303:3303" +``` + +### Nativ + +```bash +cd services/telegram-calendar-bot +pnpm install +pnpm build +pnpm start:prod +``` + +## Roadmap + +- [ ] NLP für natürliche Spracheingabe ("/add Meeting morgen 14 Uhr") +- [ ] OAuth-Flow für Account-Verknüpfung +- [ ] Token-Refresh Mechanismus +- [ ] Inline Keyboards für Interaktionen +- [ ] Kalender-Filter bei Abfragen +- [ ] Event-Erstellung mit allen Feldern +- [ ] Recurring Event Support +- [ ] Telegram Mini App Integration diff --git a/services/telegram-calendar-bot/Dockerfile b/services/telegram-calendar-bot/Dockerfile new file mode 100644 index 000000000..53906cbe9 --- /dev/null +++ b/services/telegram-calendar-bot/Dockerfile @@ -0,0 +1,45 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ +COPY services/telegram-calendar-bot/package.json ./services/telegram-calendar-bot/ + +# Install dependencies +RUN pnpm install --frozen-lockfile --filter @manacore/telegram-calendar-bot + +# Copy source +COPY services/telegram-calendar-bot ./services/telegram-calendar-bot + +# Build +WORKDIR /app/services/telegram-calendar-bot +RUN pnpm build + +# Production image +FROM node:20-alpine AS runner + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy built application +COPY --from=builder /app/services/telegram-calendar-bot/dist ./dist +COPY --from=builder /app/services/telegram-calendar-bot/package.json ./ +COPY --from=builder /app/services/telegram-calendar-bot/node_modules ./node_modules + +# Set environment +ENV NODE_ENV=production +ENV PORT=3303 + +EXPOSE 3303 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3303/health || exit 1 + +CMD ["node", "dist/main.js"] diff --git a/services/telegram-calendar-bot/drizzle.config.ts b/services/telegram-calendar-bot/drizzle.config.ts new file mode 100644 index 000000000..1856e4499 --- /dev/null +++ b/services/telegram-calendar-bot/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/calendar_bot', + }, +}); diff --git a/services/telegram-calendar-bot/nest-cli.json b/services/telegram-calendar-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/telegram-calendar-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/telegram-calendar-bot/package.json b/services/telegram-calendar-bot/package.json new file mode 100644 index 000000000..c397b50ea --- /dev/null +++ b/services/telegram-calendar-bot/package.json @@ -0,0 +1,44 @@ +{ + "name": "@manacore/telegram-calendar-bot", + "version": "1.0.0", + "description": "Telegram bot for calendar events, reminders, and quick-add", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^10.0.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@nestjs/schedule": "^4.1.2", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "drizzle-orm": "^0.38.3", + "nestjs-telegraf": "^2.8.0", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "telegraf": "^4.16.3" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/telegram-calendar-bot/src/app.module.ts b/services/telegram-calendar-bot/src/app.module.ts new file mode 100644 index 000000000..24816a5a6 --- /dev/null +++ b/services/telegram-calendar-bot/src/app.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TelegrafModule } from 'nestjs-telegraf'; +import configuration from './config/configuration'; +import { DatabaseModule } from './database/database.module'; +import { BotModule } from './bot/bot.module'; +import { ReminderModule } from './reminder/reminder.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + TelegrafModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + token: configService.get('telegram.token') || '', + }), + inject: [ConfigService], + }), + DatabaseModule, + BotModule, + ReminderModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/telegram-calendar-bot/src/bot/bot.module.ts b/services/telegram-calendar-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..3f709aab2 --- /dev/null +++ b/services/telegram-calendar-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BotUpdate } from './bot.update'; +import { CalendarModule } from '../calendar/calendar.module'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [CalendarModule, UserModule], + providers: [BotUpdate], +}) +export class BotModule {} diff --git a/services/telegram-calendar-bot/src/bot/bot.update.ts b/services/telegram-calendar-bot/src/bot/bot.update.ts new file mode 100644 index 000000000..801dcc4e2 --- /dev/null +++ b/services/telegram-calendar-bot/src/bot/bot.update.ts @@ -0,0 +1,332 @@ +import { Logger } from '@nestjs/common'; +import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf'; +import { Context } from 'telegraf'; +import { ConfigService } from '@nestjs/config'; +import { CalendarClient } from '../calendar/calendar.client'; +import { UserService } from '../user/user.service'; +import { + formatHelpMessage, + formatTodayEvents, + formatTomorrowEvents, + formatWeekEvents, + formatNextEvents, + formatCalendars, + formatStatusMessage, +} from './formatters'; + +@Update() +export class BotUpdate { + private readonly logger = new Logger(BotUpdate.name); + private readonly allowedUsers: number[]; + + constructor( + private readonly configService: ConfigService, + private readonly calendarClient: CalendarClient, + private readonly userService: UserService + ) { + this.allowedUsers = this.configService.get('telegram.allowedUsers') || []; + } + + /** + * Check if user is allowed (if restriction is enabled) + */ + private isAllowed(userId: number): boolean { + if (this.allowedUsers.length === 0) return true; + return this.allowedUsers.includes(userId); + } + + /** + * Get user's access token + */ + private async getAccessToken(telegramUserId: number): Promise { + const user = await this.userService.getUserByTelegramId(telegramUserId); + if (!user || !user.isActive || !user.accessToken) { + return null; + } + return user.accessToken; + } + + @Start() + async start(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/start from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('❌ Du bist nicht berechtigt, diesen Bot zu nutzen.'); + return; + } + + await ctx.replyWithHTML(formatHelpMessage()); + } + + @Help() + async help(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/help from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + await ctx.replyWithHTML(formatHelpMessage()); + } + + @Command('today') + async today(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/today from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + await ctx.reply('📅 Lade heutige Termine...'); + await this.userService.updateLastActive(userId); + + const events = await this.calendarClient.getTodayEvents(accessToken); + await ctx.replyWithHTML(formatTodayEvents(events)); + } + + @Command('tomorrow') + async tomorrow(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/tomorrow from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + await ctx.reply('📅 Lade morgige Termine...'); + await this.userService.updateLastActive(userId); + + const events = await this.calendarClient.getTomorrowEvents(accessToken); + await ctx.replyWithHTML(formatTomorrowEvents(events)); + } + + @Command('week') + async week(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/week from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + await ctx.reply('📅 Lade Wochenübersicht...'); + await this.userService.updateLastActive(userId); + + const events = await this.calendarClient.getWeekEvents(accessToken); + await ctx.replyWithHTML(formatWeekEvents(events)); + } + + @Command('next') + async next(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + this.logger.log(`/next from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + // Parse count from command + const parts = text.split(' '); + const count = parts.length > 1 ? parseInt(parts[1], 10) || 5 : 5; + const limitedCount = Math.min(Math.max(count, 1), 20); + + await ctx.reply(`📅 Lade nächste ${limitedCount} Termine...`); + await this.userService.updateLastActive(userId); + + const events = await this.calendarClient.getNextEvents(accessToken, limitedCount); + await ctx.replyWithHTML(formatNextEvents(events, limitedCount)); + } + + @Command('calendars') + async calendars(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/calendars from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + await this.userService.updateLastActive(userId); + + const calendars = await this.calendarClient.getCalendars(accessToken); + await ctx.replyWithHTML(formatCalendars(calendars)); + } + + @Command('add') + async add(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + this.logger.log(`/add from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + // Remove /add command from text + const input = text.replace(/^\/add\s*/i, '').trim(); + + if (!input) { + await ctx.replyWithHTML(`📝 Termin erstellen + +Beispiele: +• /add Meeting morgen um 14 Uhr +• /add Arzt | 20.01.2025 10:00 | 1h +• /add Geburtstag Lisa | 15.03.2025 | ganztägig + +Format: /add [Titel] | [Datum Zeit] | [Dauer]`); + return; + } + + // TODO: Implement NLP parsing + await ctx.reply( + `⚠️ Natürliche Spracheingabe wird noch implementiert.\n\nEmpfangener Text: "${input}"` + ); + } + + @Command('remind') + async remind(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/remind from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const user = await this.userService.getUserByTelegramId(userId); + if (!user) { + await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link'); + return; + } + + const settings = await this.userService.getReminderSettings(userId); + + await ctx.replyWithHTML(`⚙️ Erinnerungseinstellungen + +📢 Standard-Erinnerung: ${settings?.defaultReminderMinutes || 15} Minuten vorher +🌅 Morgen-Briefing: ${settings?.morningBriefingEnabled ? `Aktiv (${settings.morningBriefingTime})` : 'Deaktiviert'} +🌍 Zeitzone: ${settings?.timezone || 'Europe/Berlin'} + +Einstellungen können in der Web-App geändert werden.`); + } + + @Command('status') + async status(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/status from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const user = await this.userService.getUserByTelegramId(userId); + + await ctx.replyWithHTML( + formatStatusMessage( + !!user?.isActive && !!user?.accessToken, + user?.telegramUsername || user?.telegramFirstName, + user?.lastActiveAt || undefined + ) + ); + } + + @Command('link') + async link(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/link from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + // TODO: Implement proper OAuth flow or token-based linking + await ctx.replyWithHTML(`🔗 Account verknüpfen + +Um deinen ManaCore Account zu verknüpfen: + +1. Öffne die Calendar Web-App +2. Gehe zu Einstellungen → Integrationen → Telegram +3. Klicke auf "Mit Telegram verknüpfen" +4. Gib deine Telegram User-ID ein: ${userId} + +Nach der Verknüpfung kannst du alle Bot-Funktionen nutzen.`); + } + + @Command('unlink') + async unlink(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + this.logger.log(`/unlink from user ${userId}`); + + if (!userId || !this.isAllowed(userId)) { + return; + } + + const success = await this.userService.unlinkUser(userId); + + if (success) { + await ctx.reply('✅ Account-Verknüpfung wurde aufgehoben.'); + } else { + await ctx.reply('❌ Fehler beim Aufheben der Verknüpfung.'); + } + } + + @On('text') + async onText(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + + // Ignore commands + if (text.startsWith('/')) return; + + if (!userId || !this.isAllowed(userId)) { + return; + } + + // Check if user is linked + const accessToken = await this.getAccessToken(userId); + if (!accessToken) { + // Don't respond to random text from unlinked users + return; + } + + // TODO: Implement NLP for natural language event creation + // For now, just acknowledge + this.logger.log(`Text message from ${userId}: ${text.substring(0, 50)}...`); + } +} diff --git a/services/telegram-calendar-bot/src/bot/formatters.ts b/services/telegram-calendar-bot/src/bot/formatters.ts new file mode 100644 index 000000000..d8aec80b3 --- /dev/null +++ b/services/telegram-calendar-bot/src/bot/formatters.ts @@ -0,0 +1,412 @@ +import { format, isToday, isTomorrow, parseISO } from 'date-fns'; +import { de } from 'date-fns/locale'; +import { CalendarEvent, Calendar } from '../calendar/calendar.client'; + +/** + * Format time from ISO string + */ +function formatTime(isoString: string): string { + return format(parseISO(isoString), 'HH:mm'); +} + +/** + * Format date from ISO string + */ +function formatDate(isoString: string): string { + const date = parseISO(isoString); + if (isToday(date)) return 'Heute'; + if (isTomorrow(date)) return 'Morgen'; + return format(date, 'EEE, d. MMM', { locale: de }); +} + +/** + * Format full date with weekday + */ +function formatFullDate(date: Date): string { + return format(date, 'EEEE, d. MMMM yyyy', { locale: de }); +} + +/** + * Get color emoji based on hex color + */ +function getColorEmoji(color?: string): string { + if (!color) return '📅'; + + const colorMap: Record = { + '#3B82F6': '🔵', // blue + '#22C55E': '🟢', // green + '#EF4444': '🔴', // red + '#F59E0B': '🟡', // yellow/amber + '#8B5CF6': '🟣', // purple + '#EC4899': '💗', // pink + '#06B6D4': '🩵', // cyan + '#F97316': '🟠', // orange + }; + + // Try exact match + if (colorMap[color.toUpperCase()]) { + return colorMap[color.toUpperCase()]; + } + + // Default based on first character of hex + const firstChar = color.charAt(1).toLowerCase(); + if (['0', '1', '2', '3'].includes(firstChar)) return '🔵'; + if (['4', '5', '6', '7'].includes(firstChar)) return '🟢'; + if (['8', '9', 'a', 'b'].includes(firstChar)) return '🟡'; + return '🔴'; +} + +/** + * Format a single event + */ +export function formatEvent(event: CalendarEvent, showDate = false): string { + const emoji = getColorEmoji(event.color); + const timeRange = event.isAllDay + ? 'Ganztägig' + : `${formatTime(event.startTime)} - ${formatTime(event.endTime)}`; + + let text = `${emoji} ${timeRange} | ${escapeHtml(event.title)}`; + + if (showDate) { + text = `${emoji} ${formatDate(event.startTime)} ${timeRange}\n ${escapeHtml(event.title)}`; + } + + if (event.location) { + text += `\n 📍 ${escapeHtml(event.location)}`; + } + + if (event.description) { + const desc = + event.description.length > 50 + ? event.description.substring(0, 50) + '...' + : event.description; + text += `\n 📝 ${escapeHtml(desc)}`; + } + + return text; +} + +/** + * Format events list for a day + */ +export function formatDayEvents(events: CalendarEvent[], date: Date): string { + const header = `📅 Termine für ${formatFullDate(date)}\n`; + + if (events.length === 0) { + return header + '\n✨ Keine Termine - genieße deinen freien Tag!'; + } + + // Sort by start time + const sorted = [...events].sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + const eventsList = sorted.map((e) => formatEvent(e)).join('\n\n'); + + return `${header}\n${eventsList}\n\n───────────────\n${events.length} Termin${events.length === 1 ? '' : 'e'}`; +} + +/** + * Format today's events + */ +export function formatTodayEvents(events: CalendarEvent[]): string { + const today = new Date(); + const dayName = format(today, 'EEEE', { locale: de }); + const header = `📅 Deine Termine für heute (${dayName}, ${format(today, 'd. MMMM', { locale: de })})\n`; + + if (events.length === 0) { + return header + '\n✨ Keine Termine heute - genieße deinen freien Tag!'; + } + + const sorted = [...events].sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + const eventsList = sorted.map((e) => formatEvent(e)).join('\n\n'); + + return `${header}\n${eventsList}\n\n───────────────\n${events.length} Termin${events.length === 1 ? '' : 'e'} heute`; +} + +/** + * Format tomorrow's events + */ +export function formatTomorrowEvents(events: CalendarEvent[]): string { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dayName = format(tomorrow, 'EEEE', { locale: de }); + const header = `📅 Deine Termine für morgen (${dayName}, ${format(tomorrow, 'd. MMMM', { locale: de })})\n`; + + if (events.length === 0) { + return header + '\n✨ Keine Termine morgen!'; + } + + const sorted = [...events].sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + const eventsList = sorted.map((e) => formatEvent(e)).join('\n\n'); + + return `${header}\n${eventsList}\n\n───────────────\n${events.length} Termin${events.length === 1 ? '' : 'e'} morgen`; +} + +/** + * Format week overview + */ +export function formatWeekEvents(events: CalendarEvent[]): string { + const today = new Date(); + const weekEnd = new Date(today); + weekEnd.setDate(weekEnd.getDate() + 7); + + const header = `📅 Deine Woche (${format(today, 'd. MMM', { locale: de })} - ${format(weekEnd, 'd. MMM', { locale: de })})\n`; + + if (events.length === 0) { + return header + '\n✨ Keine Termine diese Woche!'; + } + + // Group by day + const byDay = new Map(); + events.forEach((event) => { + const dayKey = format(parseISO(event.startTime), 'yyyy-MM-dd'); + if (!byDay.has(dayKey)) { + byDay.set(dayKey, []); + } + byDay.get(dayKey)!.push(event); + }); + + // Sort days + const sortedDays = Array.from(byDay.keys()).sort(); + + let result = header + '\n'; + + for (const dayKey of sortedDays) { + const dayEvents = byDay.get(dayKey)!; + const dayDate = parseISO(dayKey); + const dayName = isToday(dayDate) + ? '📍 Heute' + : isTomorrow(dayDate) + ? '📍 Morgen' + : format(dayDate, 'EEE, d. MMM', { locale: de }); + + result += `${dayName}\n`; + + const sorted = dayEvents.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + for (const event of sorted) { + const time = event.isAllDay ? '⏰' : formatTime(event.startTime); + result += ` ${getColorEmoji(event.color)} ${time} ${escapeHtml(event.title)}\n`; + } + + result += '\n'; + } + + result += `───────────────\n${events.length} Termin${events.length === 1 ? '' : 'e'} diese Woche`; + + return result; +} + +/** + * Format next N events + */ +export function formatNextEvents(events: CalendarEvent[], count: number): string { + const header = `📅 Deine nächsten ${count} Termine\n`; + + if (events.length === 0) { + return header + '\n✨ Keine anstehenden Termine!'; + } + + const eventsList = events.map((e) => formatEvent(e, true)).join('\n\n'); + + return `${header}\n${eventsList}`; +} + +/** + * Format calendars list + */ +export function formatCalendars(calendars: Calendar[]): string { + if (calendars.length === 0) { + return '📅 Deine Kalender\n\nKeine Kalender gefunden.'; + } + + let result = '📅 Deine Kalender\n\n'; + + for (const cal of calendars) { + const emoji = getColorEmoji(cal.color); + const visibility = cal.isVisible ? '' : ' (versteckt)'; + const isDefault = cal.isDefault ? ' ⭐' : ''; + result += `${emoji} ${escapeHtml(cal.name)}${isDefault}${visibility}\n`; + if (cal.description) { + result += ` ${escapeHtml(cal.description)}\n`; + } + } + + return result; +} + +/** + * Format event created confirmation + */ +export function formatEventCreated(event: CalendarEvent): string { + const emoji = getColorEmoji(event.color); + const date = formatDate(event.startTime); + const time = event.isAllDay + ? 'Ganztägig' + : `${formatTime(event.startTime)} - ${formatTime(event.endTime)}`; + + let result = `✅ Termin erstellt!\n\n`; + result += `${emoji} ${escapeHtml(event.title)}\n`; + result += `📅 ${date}, ${time}\n`; + + if (event.location) { + result += `📍 ${escapeHtml(event.location)}\n`; + } + + return result; +} + +/** + * Format reminder notification + */ +export function formatReminder(event: CalendarEvent, minutesBefore: number): string { + const timeText = + minutesBefore >= 60 + ? `${Math.floor(minutesBefore / 60)} Stunde${minutesBefore >= 120 ? 'n' : ''}` + : `${minutesBefore} Minuten`; + + let result = `⏰ Erinnerung in ${timeText}\n\n`; + result += `📌 ${escapeHtml(event.title)}\n`; + result += `⏱️ ${formatTime(event.startTime)} - ${formatTime(event.endTime)}\n`; + + if (event.location) { + result += `📍 ${escapeHtml(event.location)}\n`; + } + + return result; +} + +/** + * Format morning briefing + */ +export function formatMorningBriefing(events: CalendarEvent[]): string { + const today = new Date(); + const greeting = getGreeting(); + + let result = `${greeting} ☀️\n\n`; + result += `Dein Tag am ${format(today, 'd. MMMM', { locale: de })}\n\n`; + + if (events.length === 0) { + result += '✨ Keine Termine heute - genieße deinen freien Tag!'; + return result; + } + + const sorted = [...events].sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + for (const event of sorted) { + const emoji = getColorEmoji(event.color); + const time = event.isAllDay ? 'Ganztägig' : formatTime(event.startTime); + result += `${emoji} ${time} - ${escapeHtml(event.title)}\n`; + } + + result += `\n───────────────\n${events.length} Termin${events.length === 1 ? '' : 'e'} heute`; + + return result; +} + +/** + * Format help message + */ +export function formatHelpMessage(): string { + return `🗓️ Calendar Bot - Hilfe + +Termine anzeigen: +/today - Heutige Termine +/tomorrow - Morgige Termine +/week - Wochenübersicht +/next [n] - Nächste n Termine + +Termine erstellen: +/add Meeting morgen um 14 Uhr +/add Arzt | 20.01.2025 10:00 | 1h + +Kalender: +/calendars - Kalender-Übersicht + +Einstellungen: +/remind - Erinnerungseinstellungen +/status - Verbindungsstatus + +Account: +/link - ManaCore Account verknüpfen +/unlink - Verknüpfung trennen + +─────────────── +💡 Du kannst auch einfach Text senden, um schnell einen Termin zu erstellen!`; +} + +/** + * Format status message + */ +export function formatStatusMessage( + isLinked: boolean, + username?: string, + lastActive?: Date +): string { + if (!isLinked) { + return `📊 Status + +❌ Nicht mit ManaCore verknüpft + +Nutze /link um deinen Account zu verknüpfen.`; + } + + const lastActiveText = lastActive ? format(lastActive, 'd. MMM HH:mm', { locale: de }) : 'Nie'; + + return `📊 Status + +✅ Verknüpft mit ManaCore +👤 ${username || 'Unbekannt'} +🕐 Letzte Aktivität: ${lastActiveText} + +Nutze /unlink um die Verknüpfung zu trennen.`; +} + +/** + * Format link instructions + */ +export function formatLinkInstructions(linkToken: string): string { + return `🔗 Account verknüpfen + +Um deinen ManaCore Account zu verknüpfen: + +1. Öffne die Calendar Web App +2. Gehe zu Einstellungen → Telegram +3. Gib diesen Code ein: + +${linkToken} + +Der Code ist 10 Minuten gültig.`; +} + +/** + * Get time-appropriate greeting + */ +function getGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Guten Morgen'; + if (hour < 18) return 'Guten Tag'; + return 'Guten Abend'; +} + +/** + * Escape HTML special characters + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/services/telegram-calendar-bot/src/calendar/calendar.client.ts b/services/telegram-calendar-bot/src/calendar/calendar.client.ts new file mode 100644 index 000000000..8a1bc96c8 --- /dev/null +++ b/services/telegram-calendar-bot/src/calendar/calendar.client.ts @@ -0,0 +1,216 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface Calendar { + id: string; + userId: string; + name: string; + description?: string; + color: string; + isDefault: boolean; + isVisible: boolean; + timezone?: string; +} + +export interface CalendarEvent { + id: string; + calendarId: string; + userId: string; + title: string; + description?: string; + location?: string; + startTime: string; + endTime: string; + isAllDay: boolean; + timezone?: string; + recurrenceRule?: string; + color?: string; + status: 'confirmed' | 'tentative' | 'cancelled'; +} + +export interface CreateEventDto { + calendarId: string; + title: string; + description?: string; + location?: string; + startTime: string; + endTime: string; + isAllDay?: boolean; + timezone?: string; +} + +@Injectable() +export class CalendarClient { + private readonly logger = new Logger(CalendarClient.name); + private readonly apiUrl: string; + + constructor(private configService: ConfigService) { + this.apiUrl = this.configService.get('calendar.apiUrl') || 'http://localhost:3016'; + } + + private async request( + endpoint: string, + accessToken: string, + options: RequestInit = {} + ): Promise { + const url = `${this.apiUrl}${endpoint}`; + + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + ...options.headers, + }, + }); + + if (!response.ok) { + this.logger.error(`API request failed: ${response.status} ${response.statusText}`); + return null; + } + + return (await response.json()) as T; + } catch (error) { + this.logger.error(`API request error: ${error}`); + return null; + } + } + + /** + * Get all calendars for the user + */ + async getCalendars(accessToken: string): Promise { + const result = await this.request('/api/v1/calendars', accessToken); + return result || []; + } + + /** + * Get events for a date range + */ + async getEvents( + accessToken: string, + start: Date, + end: Date, + calendarId?: string + ): Promise { + const params = new URLSearchParams({ + start: start.toISOString(), + end: end.toISOString(), + }); + + if (calendarId) { + params.append('calendarId', calendarId); + } + + const result = await this.request( + `/api/v1/events?${params.toString()}`, + accessToken + ); + return result || []; + } + + /** + * Get today's events + */ + async getTodayEvents(accessToken: string, timezone = 'Europe/Berlin'): Promise { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date(start); + end.setDate(end.getDate() + 1); + + return this.getEvents(accessToken, start, end); + } + + /** + * Get tomorrow's events + */ + async getTomorrowEvents( + accessToken: string, + timezone = 'Europe/Berlin' + ): Promise { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + const end = new Date(start); + end.setDate(end.getDate() + 1); + + return this.getEvents(accessToken, start, end); + } + + /** + * Get this week's events + */ + async getWeekEvents(accessToken: string, timezone = 'Europe/Berlin'): Promise { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date(start); + end.setDate(end.getDate() + 7); + + return this.getEvents(accessToken, start, end); + } + + /** + * Get next N events + */ + async getNextEvents(accessToken: string, count = 5): Promise { + const now = new Date(); + const end = new Date(now); + end.setMonth(end.getMonth() + 3); // Look 3 months ahead + + const events = await this.getEvents(accessToken, now, end); + + // Sort by start time and take first N + return events + .filter((e) => new Date(e.startTime) >= now) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + .slice(0, count); + } + + /** + * Get upcoming events for reminders (within next X minutes) + */ + async getUpcomingEventsForReminders( + accessToken: string, + withinMinutes: number + ): Promise { + const now = new Date(); + const end = new Date(now.getTime() + withinMinutes * 60 * 1000); + + return this.getEvents(accessToken, now, end); + } + + /** + * Create a new event + */ + async createEvent(accessToken: string, event: CreateEventDto): Promise { + return this.request('/api/v1/events', accessToken, { + method: 'POST', + body: JSON.stringify(event), + }); + } + + /** + * Get a single event by ID + */ + async getEvent(accessToken: string, eventId: string): Promise { + return this.request(`/api/v1/events/${eventId}`, accessToken); + } + + /** + * Delete an event + */ + async deleteEvent(accessToken: string, eventId: string): Promise { + try { + const response = await fetch(`${this.apiUrl}/api/v1/events/${eventId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return response.ok; + } catch (error) { + this.logger.error(`Delete event error: ${error}`); + return false; + } + } +} diff --git a/services/telegram-calendar-bot/src/calendar/calendar.module.ts b/services/telegram-calendar-bot/src/calendar/calendar.module.ts new file mode 100644 index 000000000..8be5311d5 --- /dev/null +++ b/services/telegram-calendar-bot/src/calendar/calendar.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CalendarClient } from './calendar.client'; + +@Module({ + providers: [CalendarClient], + exports: [CalendarClient], +}) +export class CalendarModule {} diff --git a/services/telegram-calendar-bot/src/config/configuration.ts b/services/telegram-calendar-bot/src/config/configuration.ts new file mode 100644 index 000000000..c93ab778d --- /dev/null +++ b/services/telegram-calendar-bot/src/config/configuration.ts @@ -0,0 +1,48 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3303', 10), + nodeEnv: process.env.NODE_ENV || 'development', + + telegram: { + token: process.env.TELEGRAM_BOT_TOKEN || '', + allowedUsers: process.env.TELEGRAM_ALLOWED_USERS + ? process.env.TELEGRAM_ALLOWED_USERS.split(',').map((id) => parseInt(id.trim(), 10)) + : [], + }, + + calendar: { + apiUrl: process.env.CALENDAR_API_URL || 'http://localhost:3016', + authUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, + + database: { + url: process.env.DATABASE_URL, + }, + + reminder: { + checkInterval: parseInt(process.env.REMINDER_CHECK_INTERVAL || '60000', 10), + morningBriefing: { + enabled: process.env.MORNING_BRIEFING_ENABLED === 'true', + time: process.env.MORNING_BRIEFING_TIME || '07:00', + timezone: process.env.MORNING_BRIEFING_TIMEZONE || 'Europe/Berlin', + }, + }, +}); + +// Command descriptions for BotFather +export const COMMANDS = [ + { command: 'start', description: 'Hilfe & Account verknüpfen' }, + { command: 'help', description: 'Verfügbare Befehle anzeigen' }, + { command: 'today', description: 'Heutige Termine' }, + { command: 'tomorrow', description: 'Morgige Termine' }, + { command: 'week', description: 'Wochenübersicht' }, + { command: 'next', description: 'Nächste Termine (z.B. /next 5)' }, + { command: 'add', description: 'Termin hinzufügen' }, + { command: 'calendars', description: 'Kalender-Übersicht' }, + { command: 'remind', description: 'Erinnerungseinstellungen' }, + { command: 'link', description: 'ManaCore Account verknüpfen' }, + { command: 'unlink', description: 'Account-Verknüpfung trennen' }, + { command: 'status', description: 'Verbindungsstatus prüfen' }, +]; + +// Default reminder times (minutes before event) +export const DEFAULT_REMINDER_OPTIONS = [5, 10, 15, 30, 60, 120, 1440]; // 1440 = 1 day diff --git a/services/telegram-calendar-bot/src/database/database.module.ts b/services/telegram-calendar-bot/src/database/database.module.ts new file mode 100644 index 000000000..abe5e0258 --- /dev/null +++ b/services/telegram-calendar-bot/src/database/database.module.ts @@ -0,0 +1,40 @@ +import { Module, Global, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const logger = new Logger('Database'); + const databaseUrl = configService.get('database.url'); + + if (!databaseUrl) { + logger.warn('DATABASE_URL not configured - database features disabled'); + return null; + } + + try { + const client = postgres(databaseUrl); + const db = drizzle(client, { schema }); + logger.log('Database connection established'); + return db; + } catch (error) { + logger.error('Failed to connect to database:', error); + return null; + } + }, + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} + +export type Database = PostgresJsDatabase; diff --git a/services/telegram-calendar-bot/src/database/schema.ts b/services/telegram-calendar-bot/src/database/schema.ts new file mode 100644 index 000000000..f9bfb66b9 --- /dev/null +++ b/services/telegram-calendar-bot/src/database/schema.ts @@ -0,0 +1,150 @@ +import { + pgTable, + uuid, + text, + timestamp, + bigint, + boolean, + integer, + time, + jsonb, + index, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +/** + * User settings stored in JSONB + */ +export interface UserSettings { + language?: 'de' | 'en'; + defaultCalendarId?: string; + quietHoursStart?: string; // HH:mm + quietHoursEnd?: string; // HH:mm +} + +/** + * Telegram users linked to ManaCore accounts + */ +export const telegramUsers = pgTable( + 'telegram_users', + { + id: uuid('id').primaryKey().defaultRandom(), + telegramUserId: bigint('telegram_user_id', { mode: 'number' }).unique().notNull(), + telegramUsername: text('telegram_username'), + telegramFirstName: text('telegram_first_name'), + + // ManaCore account link + manaUserId: text('mana_user_id').notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }), + + // Settings + settings: jsonb('settings').$type().default({}), + isActive: boolean('is_active').default(true).notNull(), + + // Timestamps + linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow().notNull(), + lastActiveAt: timestamp('last_active_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + telegramUserIdx: index('telegram_users_telegram_id_idx').on(table.telegramUserId), + manaUserIdx: index('telegram_users_mana_id_idx').on(table.manaUserId), + }) +); + +/** + * Reminder settings per user + */ +export const reminderSettings = pgTable( + 'reminder_settings', + { + id: uuid('id').primaryKey().defaultRandom(), + telegramUserId: bigint('telegram_user_id', { mode: 'number' }) + .references(() => telegramUsers.telegramUserId, { onDelete: 'cascade' }) + .notNull(), + + // Default reminder timing + defaultReminderMinutes: integer('default_reminder_minutes').default(15).notNull(), + + // Morning briefing + morningBriefingEnabled: boolean('morning_briefing_enabled').default(false).notNull(), + morningBriefingTime: time('morning_briefing_time').default('07:00').notNull(), + + // Timezone + timezone: text('timezone').default('Europe/Berlin').notNull(), + + // Notification preferences + notifyEventReminders: boolean('notify_event_reminders').default(true).notNull(), + notifyEventChanges: boolean('notify_event_changes').default(true).notNull(), + notifySharedCalendars: boolean('notify_shared_calendars').default(true).notNull(), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('reminder_settings_user_idx').on(table.telegramUserId), + }) +); + +/** + * Sent reminders log (to avoid duplicates) + */ +export const sentReminders = pgTable( + 'sent_reminders', + { + id: uuid('id').primaryKey().defaultRandom(), + telegramUserId: bigint('telegram_user_id', { mode: 'number' }) + .references(() => telegramUsers.telegramUserId, { onDelete: 'cascade' }) + .notNull(), + + // Event reference + eventId: text('event_id').notNull(), + eventInstanceDate: timestamp('event_instance_date', { withTimezone: true }), + + // Reminder details + reminderType: text('reminder_type').notNull(), // 'before_event' | 'morning_briefing' + minutesBefore: integer('minutes_before'), + + // Status + sentAt: timestamp('sent_at', { withTimezone: true }).defaultNow().notNull(), + messageId: integer('message_id'), // Telegram message ID + }, + (table) => ({ + userEventIdx: index('sent_reminders_user_event_idx').on(table.telegramUserId, table.eventId), + sentAtIdx: index('sent_reminders_sent_at_idx').on(table.sentAt), + }) +); + +// Relations +export const telegramUsersRelations = relations(telegramUsers, ({ one, many }) => ({ + reminderSettings: one(reminderSettings, { + fields: [telegramUsers.telegramUserId], + references: [reminderSettings.telegramUserId], + }), + sentReminders: many(sentReminders), +})); + +export const reminderSettingsRelations = relations(reminderSettings, ({ one }) => ({ + user: one(telegramUsers, { + fields: [reminderSettings.telegramUserId], + references: [telegramUsers.telegramUserId], + }), +})); + +export const sentRemindersRelations = relations(sentReminders, ({ one }) => ({ + user: one(telegramUsers, { + fields: [sentReminders.telegramUserId], + references: [telegramUsers.telegramUserId], + }), +})); + +// Types +export type TelegramUser = typeof telegramUsers.$inferSelect; +export type NewTelegramUser = typeof telegramUsers.$inferInsert; +export type ReminderSetting = typeof reminderSettings.$inferSelect; +export type NewReminderSetting = typeof reminderSettings.$inferInsert; +export type SentReminder = typeof sentReminders.$inferSelect; +export type NewSentReminder = typeof sentReminders.$inferInsert; diff --git a/services/telegram-calendar-bot/src/health.controller.ts b/services/telegram-calendar-bot/src/health.controller.ts new file mode 100644 index 000000000..eefcfc168 --- /dev/null +++ b/services/telegram-calendar-bot/src/health.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Controller('health') +export class HealthController { + constructor(private configService: ConfigService) {} + + @Get() + health() { + return { + status: 'ok', + service: 'telegram-calendar-bot', + timestamp: new Date().toISOString(), + environment: this.configService.get('nodeEnv'), + }; + } +} diff --git a/services/telegram-calendar-bot/src/main.ts b/services/telegram-calendar-bot/src/main.ts new file mode 100644 index 000000000..d02fac2fd --- /dev/null +++ b/services/telegram-calendar-bot/src/main.ts @@ -0,0 +1,21 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port') || 3303; + + // Graceful shutdown + app.enableShutdownHooks(); + + await app.listen(port); + logger.log(`Telegram Calendar Bot running on port ${port}`); + logger.log(`Calendar API: ${configService.get('calendar.apiUrl')}`); +} + +bootstrap(); diff --git a/services/telegram-calendar-bot/src/reminder/reminder.module.ts b/services/telegram-calendar-bot/src/reminder/reminder.module.ts new file mode 100644 index 000000000..7534dcb39 --- /dev/null +++ b/services/telegram-calendar-bot/src/reminder/reminder.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ReminderScheduler } from './reminder.scheduler'; +import { ReminderService } from './reminder.service'; +import { CalendarModule } from '../calendar/calendar.module'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), CalendarModule, UserModule], + providers: [ReminderScheduler, ReminderService], + exports: [ReminderService], +}) +export class ReminderModule {} diff --git a/services/telegram-calendar-bot/src/reminder/reminder.scheduler.ts b/services/telegram-calendar-bot/src/reminder/reminder.scheduler.ts new file mode 100644 index 000000000..e37c8867c --- /dev/null +++ b/services/telegram-calendar-bot/src/reminder/reminder.scheduler.ts @@ -0,0 +1,183 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { InjectBot } from 'nestjs-telegraf'; +import { Telegraf, Context } from 'telegraf'; +import { CalendarClient } from '../calendar/calendar.client'; +import { UserService } from '../user/user.service'; +import { ReminderService } from './reminder.service'; +import { formatReminder, formatMorningBriefing } from '../bot/formatters'; + +@Injectable() +export class ReminderScheduler { + private readonly logger = new Logger(ReminderScheduler.name); + + constructor( + @InjectBot() private bot: Telegraf, + private configService: ConfigService, + private calendarClient: CalendarClient, + private userService: UserService, + private reminderService: ReminderService + ) {} + + /** + * Check for upcoming events and send reminders + * Runs every minute + */ + @Cron(CronExpression.EVERY_MINUTE) + async checkReminders() { + this.logger.debug('Checking for event reminders...'); + + try { + const users = await this.userService.getAllActiveUsers(); + + for (const user of users) { + if (!user.accessToken) continue; + + const settings = await this.userService.getReminderSettings(user.telegramUserId); + if (!settings?.notifyEventReminders) continue; + + const reminderMinutes = settings.defaultReminderMinutes || 15; + + // Get events starting in the next reminderMinutes + const events = await this.calendarClient.getUpcomingEventsForReminders( + user.accessToken, + reminderMinutes + 1 // Add 1 minute buffer + ); + + for (const event of events) { + const eventStart = new Date(event.startTime); + const now = new Date(); + const minutesUntilEvent = Math.floor( + (eventStart.getTime() - now.getTime()) / (1000 * 60) + ); + + // Check if this is the right time to send reminder + if (minutesUntilEvent <= reminderMinutes && minutesUntilEvent > reminderMinutes - 1) { + // Check if we already sent this reminder + const alreadySent = await this.reminderService.wasReminderSent( + user.telegramUserId, + event.id, + 'before_event', + reminderMinutes + ); + + if (!alreadySent) { + await this.sendReminder(user.telegramUserId, event, reminderMinutes); + } + } + } + } + } catch (error) { + this.logger.error(`Error checking reminders: ${error}`); + } + } + + /** + * Send morning briefing + * Runs at 7:00 AM (configurable via morning briefing time per user) + */ + @Cron('0 * * * *') // Run every hour, check user-specific times + async sendMorningBriefings() { + this.logger.debug('Checking for morning briefings...'); + + try { + const usersWithBriefing = await this.userService.getUsersWithMorningBriefing(); + const now = new Date(); + const currentHour = now.getHours(); + const currentMinute = now.getMinutes(); + + for (const { user, settings } of usersWithBriefing) { + if (!user.accessToken) continue; + + // Parse briefing time (HH:mm format) + const [briefingHour, briefingMinute] = (settings.morningBriefingTime || '07:00') + .split(':') + .map(Number); + + // Check if it's the right hour (minute check is less precise due to cron) + if (currentHour === briefingHour && currentMinute < 5) { + // Check if we already sent today's briefing + const today = new Date().toISOString().split('T')[0]; + const alreadySent = await this.reminderService.wasReminderSent( + user.telegramUserId, + `briefing-${today}`, + 'morning_briefing' + ); + + if (!alreadySent) { + await this.sendBriefing(user.telegramUserId, user.accessToken); + } + } + } + } catch (error) { + this.logger.error(`Error sending morning briefings: ${error}`); + } + } + + /** + * Cleanup old sent reminders + * Runs daily at 3:00 AM + */ + @Cron('0 3 * * *') + async cleanupOldReminders() { + this.logger.log('Cleaning up old sent reminders...'); + await this.reminderService.cleanupOldReminders(); + } + + /** + * Send a reminder notification + */ + private async sendReminder( + telegramUserId: number, + event: { id: string; title: string; startTime: string; endTime: string; location?: string; color?: string }, + minutesBefore: number + ) { + try { + const message = formatReminder(event as any, minutesBefore); + const sent = await this.bot.telegram.sendMessage(telegramUserId, message, { + parse_mode: 'HTML', + }); + + // Record that we sent this reminder + await this.reminderService.recordSentReminder({ + telegramUserId, + eventId: event.id, + reminderType: 'before_event', + minutesBefore, + messageId: sent.message_id, + }); + + this.logger.log(`Sent reminder to ${telegramUserId} for event ${event.id}`); + } catch (error) { + this.logger.error(`Error sending reminder to ${telegramUserId}: ${error}`); + } + } + + /** + * Send morning briefing + */ + private async sendBriefing(telegramUserId: number, accessToken: string) { + try { + const events = await this.calendarClient.getTodayEvents(accessToken); + const message = formatMorningBriefing(events); + + const sent = await this.bot.telegram.sendMessage(telegramUserId, message, { + parse_mode: 'HTML', + }); + + // Record that we sent today's briefing + const today = new Date().toISOString().split('T')[0]; + await this.reminderService.recordSentReminder({ + telegramUserId, + eventId: `briefing-${today}`, + reminderType: 'morning_briefing', + messageId: sent.message_id, + }); + + this.logger.log(`Sent morning briefing to ${telegramUserId}`); + } catch (error) { + this.logger.error(`Error sending briefing to ${telegramUserId}: ${error}`); + } + } +} diff --git a/services/telegram-calendar-bot/src/reminder/reminder.service.ts b/services/telegram-calendar-bot/src/reminder/reminder.service.ts new file mode 100644 index 000000000..50aa00a37 --- /dev/null +++ b/services/telegram-calendar-bot/src/reminder/reminder.service.ts @@ -0,0 +1,101 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq, and, gte, lte } from 'drizzle-orm'; +import { DATABASE_CONNECTION, Database } from '../database/database.module'; +import { sentReminders, NewSentReminder, SentReminder } from '../database/schema'; + +@Injectable() +export class ReminderService { + private readonly logger = new Logger(ReminderService.name); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database | null) {} + + /** + * Check if a reminder was already sent + */ + async wasReminderSent( + telegramUserId: number, + eventId: string, + reminderType: string, + minutesBefore?: number, + eventInstanceDate?: Date + ): Promise { + if (!this.db) return false; + + try { + // Look for sent reminders in the last 24 hours to avoid duplicates + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const result = await this.db + .select() + .from(sentReminders) + .where( + and( + eq(sentReminders.telegramUserId, telegramUserId), + eq(sentReminders.eventId, eventId), + eq(sentReminders.reminderType, reminderType), + gte(sentReminders.sentAt, oneDayAgo) + ) + ) + .limit(1); + + return result.length > 0; + } catch (error) { + this.logger.error(`Error checking sent reminder: ${error}`); + return false; + } + } + + /** + * Record a sent reminder + */ + async recordSentReminder(data: { + telegramUserId: number; + eventId: string; + reminderType: string; + minutesBefore?: number; + eventInstanceDate?: Date; + messageId?: number; + }): Promise { + if (!this.db) return null; + + try { + const newReminder: NewSentReminder = { + telegramUserId: data.telegramUserId, + eventId: data.eventId, + reminderType: data.reminderType, + minutesBefore: data.minutesBefore, + eventInstanceDate: data.eventInstanceDate, + messageId: data.messageId, + }; + + const result = await this.db.insert(sentReminders).values(newReminder).returning(); + + return result[0] || null; + } catch (error) { + this.logger.error(`Error recording sent reminder: ${error}`); + return null; + } + } + + /** + * Clean up old sent reminders (older than 7 days) + */ + async cleanupOldReminders(): Promise { + if (!this.db) return 0; + + try { + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const result = await this.db + .delete(sentReminders) + .where(lte(sentReminders.sentAt, sevenDaysAgo)); + + // Drizzle doesn't return count directly, so we estimate + this.logger.log('Cleaned up old sent reminders'); + return 0; + } catch (error) { + this.logger.error(`Error cleaning up old reminders: ${error}`); + return 0; + } + } +} diff --git a/services/telegram-calendar-bot/src/user/user.module.ts b/services/telegram-calendar-bot/src/user/user.module.ts new file mode 100644 index 000000000..ab6ead25c --- /dev/null +++ b/services/telegram-calendar-bot/src/user/user.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './user.service'; + +@Module({ + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/services/telegram-calendar-bot/src/user/user.service.ts b/services/telegram-calendar-bot/src/user/user.service.ts new file mode 100644 index 000000000..f298623b8 --- /dev/null +++ b/services/telegram-calendar-bot/src/user/user.service.ts @@ -0,0 +1,240 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DATABASE_CONNECTION, Database } from '../database/database.module'; +import { + telegramUsers, + reminderSettings, + TelegramUser, + NewTelegramUser, + ReminderSetting, +} from '../database/schema'; + +@Injectable() +export class UserService { + private readonly logger = new Logger(UserService.name); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database | null) {} + + /** + * Get user by Telegram ID + */ + async getUserByTelegramId(telegramUserId: number): Promise { + if (!this.db) return null; + + try { + const result = await this.db + .select() + .from(telegramUsers) + .where(eq(telegramUsers.telegramUserId, telegramUserId)) + .limit(1); + + return result[0] || null; + } catch (error) { + this.logger.error(`Error getting user: ${error}`); + return null; + } + } + + /** + * Create or update a linked user + */ + async linkUser(data: { + telegramUserId: number; + telegramUsername?: string; + telegramFirstName?: string; + manaUserId: string; + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: Date; + }): Promise { + if (!this.db) return null; + + try { + const existing = await this.getUserByTelegramId(data.telegramUserId); + + if (existing) { + // Update existing + const result = await this.db + .update(telegramUsers) + .set({ + telegramUsername: data.telegramUsername, + telegramFirstName: data.telegramFirstName, + manaUserId: data.manaUserId, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + tokenExpiresAt: data.tokenExpiresAt, + isActive: true, + updatedAt: new Date(), + }) + .where(eq(telegramUsers.telegramUserId, data.telegramUserId)) + .returning(); + + return result[0] || null; + } else { + // Create new + const newUser: NewTelegramUser = { + telegramUserId: data.telegramUserId, + telegramUsername: data.telegramUsername, + telegramFirstName: data.telegramFirstName, + manaUserId: data.manaUserId, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + tokenExpiresAt: data.tokenExpiresAt, + }; + + const result = await this.db.insert(telegramUsers).values(newUser).returning(); + + // Create default reminder settings + if (result[0]) { + await this.db.insert(reminderSettings).values({ + telegramUserId: data.telegramUserId, + }); + } + + return result[0] || null; + } + } catch (error) { + this.logger.error(`Error linking user: ${error}`); + return null; + } + } + + /** + * Unlink a user (deactivate) + */ + async unlinkUser(telegramUserId: number): Promise { + if (!this.db) return false; + + try { + await this.db + .update(telegramUsers) + .set({ + isActive: false, + accessToken: null, + refreshToken: null, + updatedAt: new Date(), + }) + .where(eq(telegramUsers.telegramUserId, telegramUserId)); + + return true; + } catch (error) { + this.logger.error(`Error unlinking user: ${error}`); + return false; + } + } + + /** + * Update last active timestamp + */ + async updateLastActive(telegramUserId: number): Promise { + if (!this.db) return; + + try { + await this.db + .update(telegramUsers) + .set({ lastActiveAt: new Date() }) + .where(eq(telegramUsers.telegramUserId, telegramUserId)); + } catch (error) { + this.logger.error(`Error updating last active: ${error}`); + } + } + + /** + * Get reminder settings for a user + */ + async getReminderSettings(telegramUserId: number): Promise { + if (!this.db) return null; + + try { + const result = await this.db + .select() + .from(reminderSettings) + .where(eq(reminderSettings.telegramUserId, telegramUserId)) + .limit(1); + + return result[0] || null; + } catch (error) { + this.logger.error(`Error getting reminder settings: ${error}`); + return null; + } + } + + /** + * Update reminder settings + */ + async updateReminderSettings( + telegramUserId: number, + settings: Partial<{ + defaultReminderMinutes: number; + morningBriefingEnabled: boolean; + morningBriefingTime: string; + timezone: string; + notifyEventReminders: boolean; + notifyEventChanges: boolean; + notifySharedCalendars: boolean; + }> + ): Promise { + if (!this.db) return null; + + try { + const result = await this.db + .update(reminderSettings) + .set({ + ...settings, + updatedAt: new Date(), + }) + .where(eq(reminderSettings.telegramUserId, telegramUserId)) + .returning(); + + return result[0] || null; + } catch (error) { + this.logger.error(`Error updating reminder settings: ${error}`); + return null; + } + } + + /** + * Get all active users (for reminder scheduler) + */ + async getAllActiveUsers(): Promise { + if (!this.db) return []; + + try { + return await this.db + .select() + .from(telegramUsers) + .where(eq(telegramUsers.isActive, true)); + } catch (error) { + this.logger.error(`Error getting active users: ${error}`); + return []; + } + } + + /** + * Get users with morning briefing enabled + */ + async getUsersWithMorningBriefing(): Promise< + Array<{ user: TelegramUser; settings: ReminderSetting }> + > { + if (!this.db) return []; + + try { + const result = await this.db + .select({ + user: telegramUsers, + settings: reminderSettings, + }) + .from(telegramUsers) + .innerJoin( + reminderSettings, + eq(telegramUsers.telegramUserId, reminderSettings.telegramUserId) + ) + .where(eq(telegramUsers.isActive, true)); + + return result.filter((r) => r.settings.morningBriefingEnabled); + } catch (error) { + this.logger.error(`Error getting users with morning briefing: ${error}`); + return []; + } + } +} diff --git a/services/telegram-calendar-bot/tsconfig.json b/services/telegram-calendar-bot/tsconfig.json new file mode 100644 index 000000000..94f1e9493 --- /dev/null +++ b/services/telegram-calendar-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true + } +}