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:
Claude 2026-01-27 19:30:58 +00:00
parent 48c5ff31dc
commit 0f6faa520b
No known key found for this signature in database
24 changed files with 2269 additions and 1 deletions

View file

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

View 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

View 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

View 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"]

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

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

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

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

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

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

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

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CalendarClient } from './calendar.client';
@Module({
providers: [CalendarClient],
exports: [CalendarClient],
})
export class CalendarModule {}

View 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

View file

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

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

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

View 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();

View file

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

View file

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

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

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
@Module({
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

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

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