mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 14:39:39 +02:00
feat(telegram-calendar-bot): implement new Telegram bot for calendar integration
- Add NestJS service with Telegraf for Telegram bot functionality - Implement commands: /today, /tomorrow, /week, /next, /calendars, /add - Create database schema for user linking and reminder settings - Add Calendar API client for fetching events - Implement reminder scheduler with morning briefing support - Add message formatters for German locale - Include Dockerfile and CLAUDE.md documentation - Update TELEGRAM_BOTS.md with new bot status Commands implemented: - /today, /tomorrow, /week - View events - /next [n] - View next n events - /calendars - List calendars - /remind - Reminder settings - /link, /unlink - Account management - /status - Connection status https://claude.ai/code/session_01LwmhvhKpEsvVtY1ZKhYu6f
This commit is contained in:
parent
48c5ff31dc
commit
0f6faa520b
24 changed files with 2269 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
21
services/telegram-calendar-bot/.env.example
Normal file
21
services/telegram-calendar-bot/.env.example
Normal file
|
|
@ -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
|
||||
290
services/telegram-calendar-bot/CLAUDE.md
Normal file
290
services/telegram-calendar-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
45
services/telegram-calendar-bot/Dockerfile
Normal file
45
services/telegram-calendar-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
10
services/telegram-calendar-bot/drizzle.config.ts
Normal file
10
services/telegram-calendar-bot/drizzle.config.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
8
services/telegram-calendar-bot/nest-cli.json
Normal file
8
services/telegram-calendar-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
44
services/telegram-calendar-bot/package.json
Normal file
44
services/telegram-calendar-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
29
services/telegram-calendar-bot/src/app.module.ts
Normal file
29
services/telegram-calendar-bot/src/app.module.ts
Normal file
|
|
@ -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<string>('telegram.token') || '',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
DatabaseModule,
|
||||
BotModule,
|
||||
ReminderModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
10
services/telegram-calendar-bot/src/bot/bot.module.ts
Normal file
10
services/telegram-calendar-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
332
services/telegram-calendar-bot/src/bot/bot.update.ts
Normal file
332
services/telegram-calendar-bot/src/bot/bot.update.ts
Normal file
|
|
@ -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<number[]>('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<string | null> {
|
||||
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(`📝 <b>Termin erstellen</b>
|
||||
|
||||
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(`⚙️ <b>Erinnerungseinstellungen</b>
|
||||
|
||||
📢 Standard-Erinnerung: ${settings?.defaultReminderMinutes || 15} Minuten vorher
|
||||
🌅 Morgen-Briefing: ${settings?.morningBriefingEnabled ? `Aktiv (${settings.morningBriefingTime})` : 'Deaktiviert'}
|
||||
🌍 Zeitzone: ${settings?.timezone || 'Europe/Berlin'}
|
||||
|
||||
<i>Einstellungen können in der Web-App geändert werden.</i>`);
|
||||
}
|
||||
|
||||
@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(`🔗 <b>Account verknüpfen</b>
|
||||
|
||||
Um deinen ManaCore Account zu verknüpfen:
|
||||
|
||||
1. Öffne die Calendar Web-App
|
||||
2. Gehe zu <b>Einstellungen → Integrationen → Telegram</b>
|
||||
3. Klicke auf "Mit Telegram verknüpfen"
|
||||
4. Gib deine Telegram User-ID ein: <code>${userId}</code>
|
||||
|
||||
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)}...`);
|
||||
}
|
||||
}
|
||||
412
services/telegram-calendar-bot/src/bot/formatters.ts
Normal file
412
services/telegram-calendar-bot/src/bot/formatters.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
'#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} <b>${timeRange}</b> | ${escapeHtml(event.title)}`;
|
||||
|
||||
if (showDate) {
|
||||
text = `${emoji} <b>${formatDate(event.startTime)}</b> ${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 = `📅 <b>Termine für ${formatFullDate(date)}</b>\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 = `📅 <b>Deine Termine für heute</b> (${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 = `📅 <b>Deine Termine für morgen</b> (${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 = `📅 <b>Deine Woche</b> (${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<string, CalendarEvent[]>();
|
||||
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 += `<b>${dayName}</b>\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 = `📅 <b>Deine nächsten ${count} Termine</b>\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 '📅 <b>Deine Kalender</b>\n\nKeine Kalender gefunden.';
|
||||
}
|
||||
|
||||
let result = '📅 <b>Deine Kalender</b>\n\n';
|
||||
|
||||
for (const cal of calendars) {
|
||||
const emoji = getColorEmoji(cal.color);
|
||||
const visibility = cal.isVisible ? '' : ' (versteckt)';
|
||||
const isDefault = cal.isDefault ? ' ⭐' : '';
|
||||
result += `${emoji} <b>${escapeHtml(cal.name)}</b>${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 = `✅ <b>Termin erstellt!</b>\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 = `⏰ <b>Erinnerung in ${timeText}</b>\n\n`;
|
||||
result += `📌 <b>${escapeHtml(event.title)}</b>\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 += `<b>Dein Tag am ${format(today, 'd. MMMM', { locale: de })}</b>\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} <b>${time}</b> - ${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 `🗓️ <b>Calendar Bot - Hilfe</b>
|
||||
|
||||
<b>Termine anzeigen:</b>
|
||||
/today - Heutige Termine
|
||||
/tomorrow - Morgige Termine
|
||||
/week - Wochenübersicht
|
||||
/next [n] - Nächste n Termine
|
||||
|
||||
<b>Termine erstellen:</b>
|
||||
/add Meeting morgen um 14 Uhr
|
||||
/add Arzt | 20.01.2025 10:00 | 1h
|
||||
|
||||
<b>Kalender:</b>
|
||||
/calendars - Kalender-Übersicht
|
||||
|
||||
<b>Einstellungen:</b>
|
||||
/remind - Erinnerungseinstellungen
|
||||
/status - Verbindungsstatus
|
||||
|
||||
<b>Account:</b>
|
||||
/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 `📊 <b>Status</b>
|
||||
|
||||
❌ 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 `📊 <b>Status</b>
|
||||
|
||||
✅ 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 `🔗 <b>Account verknüpfen</b>
|
||||
|
||||
Um deinen ManaCore Account zu verknüpfen:
|
||||
|
||||
1. Öffne die Calendar Web App
|
||||
2. Gehe zu Einstellungen → Telegram
|
||||
3. Gib diesen Code ein:
|
||||
|
||||
<code>${linkToken}</code>
|
||||
|
||||
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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
216
services/telegram-calendar-bot/src/calendar/calendar.client.ts
Normal file
216
services/telegram-calendar-bot/src/calendar/calendar.client.ts
Normal file
|
|
@ -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<string>('calendar.apiUrl') || 'http://localhost:3016';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
accessToken: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T | null> {
|
||||
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<Calendar[]> {
|
||||
const result = await this.request<Calendar[]>('/api/v1/calendars', accessToken);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a date range
|
||||
*/
|
||||
async getEvents(
|
||||
accessToken: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
calendarId?: string
|
||||
): Promise<CalendarEvent[]> {
|
||||
const params = new URLSearchParams({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
});
|
||||
|
||||
if (calendarId) {
|
||||
params.append('calendarId', calendarId);
|
||||
}
|
||||
|
||||
const result = await this.request<CalendarEvent[]>(
|
||||
`/api/v1/events?${params.toString()}`,
|
||||
accessToken
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's events
|
||||
*/
|
||||
async getTodayEvents(accessToken: string, timezone = 'Europe/Berlin'): Promise<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent | null> {
|
||||
return this.request<CalendarEvent>('/api/v1/events', accessToken, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
*/
|
||||
async getEvent(accessToken: string, eventId: string): Promise<CalendarEvent | null> {
|
||||
return this.request<CalendarEvent>(`/api/v1/events/${eventId}`, accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
async deleteEvent(accessToken: string, eventId: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CalendarClient } from './calendar.client';
|
||||
|
||||
@Module({
|
||||
providers: [CalendarClient],
|
||||
exports: [CalendarClient],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
48
services/telegram-calendar-bot/src/config/configuration.ts
Normal file
48
services/telegram-calendar-bot/src/config/configuration.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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<string>('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<typeof schema>;
|
||||
150
services/telegram-calendar-bot/src/database/schema.ts
Normal file
150
services/telegram-calendar-bot/src/database/schema.ts
Normal file
|
|
@ -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<UserSettings>().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;
|
||||
17
services/telegram-calendar-bot/src/health.controller.ts
Normal file
17
services/telegram-calendar-bot/src/health.controller.ts
Normal file
|
|
@ -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<string>('nodeEnv'),
|
||||
};
|
||||
}
|
||||
}
|
||||
21
services/telegram-calendar-bot/src/main.ts
Normal file
21
services/telegram-calendar-bot/src/main.ts
Normal file
|
|
@ -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<number>('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<string>('calendar.apiUrl')}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<Context>,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
services/telegram-calendar-bot/src/reminder/reminder.service.ts
Normal file
101
services/telegram-calendar-bot/src/reminder/reminder.service.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<SentReminder | null> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
services/telegram-calendar-bot/src/user/user.module.ts
Normal file
8
services/telegram-calendar-bot/src/user/user.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
240
services/telegram-calendar-bot/src/user/user.service.ts
Normal file
240
services/telegram-calendar-bot/src/user/user.service.ts
Normal file
|
|
@ -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<TelegramUser | null> {
|
||||
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<TelegramUser | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<ReminderSetting | null> {
|
||||
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<ReminderSetting | null> {
|
||||
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<TelegramUser[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/telegram-calendar-bot/tsconfig.json
Normal file
22
services/telegram-calendar-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue