diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts new file mode 100644 index 0000000..58aaae8 --- /dev/null +++ b/src/telegram/handlers.ts @@ -0,0 +1,402 @@ +import type { Bot } from 'grammy'; +import type { BotContext } from '../bot.js'; +import { checkRateLimit } from '../bot.js'; +import { config } from '../config.js'; +import { payloadClient } from '../payload/client.js'; +import { downloadFile } from '../utils/download.js'; +import { createLogger } from '../utils/logger.js'; +import { buildTenantKeyboard, getTenantName } from './keyboards.js'; + +const log = createLogger('Handlers'); + +const startTime = Date.now(); + +// MarkdownV2 requires escaping these characters +function escapeMarkdown(text: string): string { + return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&'); +} + +// Allowed image extensions for document uploads +const ALLOWED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'svg']); +const ALLOWED_MIME_PREFIXES = ['image/']; + +function isAllowedFile(filename: string, mimeType?: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + const extOk = ALLOWED_EXTENSIONS.has(ext); + const mimeOk = !mimeType || ALLOWED_MIME_PREFIXES.some((p) => mimeType.startsWith(p)); + return extOk && mimeOk; +} + +// Album collection: group messages by media_group_id with 500ms debounce +const albumCollector = new Map< + string, + { + chatId: number; + messages: Array<{ fileId: string; filename: string; mimeType: string }>; + timer: ReturnType; + ctx: BotContext; + } +>(); + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatUptime(): string { + const ms = Date.now() - startTime; + const seconds = Math.floor(ms / 1000) % 60; + const minutes = Math.floor(ms / 60000) % 60; + const hours = Math.floor(ms / 3600000) % 24; + const days = Math.floor(ms / 86400000); + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + return parts.join(' '); +} + +async function downloadTelegramFile( + ctx: BotContext, + fileId: string, +): Promise<{ buffer: Buffer; mimeType: string; filePath: string }> { + const file = await ctx.api.getFile(fileId); + const filePath = file.file_path; + if (!filePath) { + throw new Error('Telegram returned no file_path'); + } + const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${filePath}`; + const { buffer, mimeType } = await downloadFile(fileUrl); + return { buffer, mimeType, filePath }; +} + +async function processUpload( + ctx: BotContext, + fileId: string, + filename: string, + statusMessageId: number, +): Promise { + const userId = ctx.from?.id; + if (!userId) return; + + // Rate limit check + const rateCheck = checkRateLimit(userId); + if (!rateCheck.allowed) { + await ctx.api.editMessageText( + ctx.chat!.id, + statusMessageId, + `\u26a0\ufe0f Rate Limit erreicht\\. Versuche es in ${rateCheck.retryAfter} Sekunden erneut\\.`, + { parse_mode: 'MarkdownV2' }, + ); + return; + } + + try { + const { buffer, mimeType } = await downloadTelegramFile(ctx, fileId); + + const doc = await payloadClient.uploadMedia(buffer, filename, mimeType, { + alt: filename.replace(/\.[^.]+$/, ''), + tenantId: ctx.session.selectedTenantId, + }); + + const sizeStr = formatBytes(doc.filesize); + const dimensions = doc.width && doc.height ? ` \\| ${doc.width}x${doc.height}` : ''; + const successText = + `\u2705 *Hochgeladen*\n\n` + + `\ud83d\udcce ${escapeMarkdown(doc.filename)}\n` + + `\ud83d\udcc0 ${escapeMarkdown(sizeStr)}${dimensions}\n` + + `\ud83c\udfaf Tenant: ${escapeMarkdown(ctx.session.selectedTenantName)}\n` + + `\ud83c\udd94 ID: \`${doc.id}\``; + + await ctx.api.editMessageText(ctx.chat!.id, statusMessageId, successText, { + parse_mode: 'MarkdownV2', + }); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + log.error(`Upload failed for ${filename}`, error); + await ctx.api.editMessageText( + ctx.chat!.id, + statusMessageId, + `\u274c Upload fehlgeschlagen: ${escapeMarkdown(errMsg)}`, + { parse_mode: 'MarkdownV2' }, + ); + } +} + +async function processAlbum( + ctx: BotContext, + messages: Array<{ fileId: string; filename: string; mimeType: string }>, +): Promise { + const total = Math.min(messages.length, 10); + const statusMsg = await ctx.reply(`\u23f3 Album wird hochgeladen (0/${total})...`); + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < total; i++) { + const msg = messages[i]; + const userId = ctx.from?.id; + if (!userId) continue; + + const rateCheck = checkRateLimit(userId); + if (!rateCheck.allowed) { + failCount++; + log.warn(`Rate limit hit during album upload at image ${i + 1}`); + continue; + } + + try { + const { buffer, mimeType } = await downloadTelegramFile(ctx, msg.fileId); + await payloadClient.uploadMedia(buffer, msg.filename, mimeType, { + alt: msg.filename.replace(/\.[^.]+$/, ''), + tenantId: ctx.session.selectedTenantId, + }); + successCount++; + } catch (error) { + failCount++; + log.error(`Album upload failed for ${msg.filename}`, error); + } + + // Progress update every 3 images + if ((i + 1) % 3 === 0 && i + 1 < total) { + try { + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `\u23f3 Album wird hochgeladen (${i + 1}/${total})...`, + ); + } catch { + // Ignore edit errors (e.g. message not modified) + } + } + } + + const resultText = + `\u2705 Album hochgeladen\n\n` + + `Erfolgreich: ${successCount}/${total}\n` + + (failCount > 0 ? `Fehlgeschlagen: ${failCount}` : ''); + + try { + await ctx.api.editMessageText(ctx.chat!.id, statusMsg.message_id, resultText); + } catch { + await ctx.reply(resultText); + } +} + +export function registerHandlers(bot: Bot): void { + // /start command + bot.command('start', async (ctx) => { + const tenantName = await getTenantName(ctx.session.selectedTenantId); + ctx.session.selectedTenantName = tenantName; + + const text = + `\ud83d\udcf7 *Payload Media Bot*\n\n` + + `Willkommen\\! Aktueller Tenant: *${escapeMarkdown(tenantName)}*\n\n` + + `*Befehle:*\n` + + `/tenant \\- Tenant wechseln\n` + + `/list \\- Letzte 5 Medien anzeigen\n` + + `/status \\- Bot\\-Status\n` + + `/help \\- Hilfe\n\n` + + `Sende einfach ein Bild oder Dokument zum Hochladen\\.`; + + await ctx.reply(text, { parse_mode: 'MarkdownV2' }); + }); + + // /tenant command — show tenant selection keyboard + bot.command('tenant', async (ctx) => { + try { + const keyboard = await buildTenantKeyboard(); + await ctx.reply('\ud83c\udfe2 Tenant auswählen:', { reply_markup: keyboard }); + } catch (error) { + log.error('Failed to build tenant keyboard', error); + await ctx.reply('\u274c Fehler beim Laden der Tenants. Versuche es später erneut.'); + } + }); + + // /list command — show last 5 media items + bot.command('list', async (ctx) => { + try { + const result = await payloadClient.listMedia(ctx.session.selectedTenantId, 5); + + if (result.docs.length === 0) { + await ctx.reply( + `Keine Medien für *${escapeMarkdown(ctx.session.selectedTenantName)}* gefunden\\.`, + { parse_mode: 'MarkdownV2' }, + ); + return; + } + + let text = `\ud83d\uddbc *Letzte ${result.docs.length} Medien* \\(${escapeMarkdown(ctx.session.selectedTenantName)}\\):\n\n`; + + for (const doc of result.docs) { + const size = formatBytes(doc.filesize); + const date = new Date(doc.createdAt).toLocaleDateString('de-DE'); + text += `\u2022 \`${doc.id}\` ${escapeMarkdown(doc.filename)} \\(${escapeMarkdown(size)}, ${escapeMarkdown(date)}\\)\n`; + } + + text += `\nGesamt: ${escapeMarkdown(String(result.totalDocs))} Medien`; + + await ctx.reply(text, { parse_mode: 'MarkdownV2' }); + } catch (error) { + log.error('Failed to list media', error); + await ctx.reply('\u274c Fehler beim Laden der Medien.'); + } + }); + + // /status command + bot.command('status', async (ctx) => { + const healthy = await payloadClient.checkHealth(); + const expiry = payloadClient.getTokenExpiry(); + const expiryStr = expiry ? expiry.toLocaleString('de-DE') : 'Unbekannt'; + + const text = + `\ud83d\udcca *Bot Status*\n\n` + + `\u23f1 Uptime: ${escapeMarkdown(formatUptime())}\n` + + `\ud83d\udfe2 API: ${healthy ? 'Erreichbar' : 'Nicht erreichbar'}\n` + + `\ud83c\udfe2 Tenant: ${escapeMarkdown(ctx.session.selectedTenantName)} \\(ID ${ctx.session.selectedTenantId}\\)\n` + + `\ud83d\udd11 Token läuft ab: ${escapeMarkdown(expiryStr)}`; + + await ctx.reply(text, { parse_mode: 'MarkdownV2' }); + }); + + // /help command + bot.command('help', async (ctx) => { + const text = + `\u2753 *Hilfe*\n\n` + + `Dieser Bot lädt Bilder in die Payload CMS Media\\-Kollektion hoch\\.\n\n` + + `*So geht's:*\n` + + `1\\. Wähle einen Tenant mit /tenant\n` + + `2\\. Sende ein Bild oder mehrere als Album\n` + + `3\\. Der Bot lädt sie automatisch hoch\n\n` + + `*Unterstützte Formate:*\n` + + `jpg, jpeg, png, webp, avif, gif, svg\n\n` + + `\ud83d\udca1 *Tipp:* Sende Bilder als *Dokument* \\(Büroklammer\\-Symbol\\), ` + + `um die Originalqualität beizubehalten\\. Telegram komprimiert Fotos sonst automatisch\\.`; + + await ctx.reply(text, { parse_mode: 'MarkdownV2' }); + }); + + // Callback query handler for tenant selection + bot.callbackQuery(/^tenant:(\d+)$/, async (ctx) => { + const match = ctx.callbackQuery.data.match(/^tenant:(\d+)$/); + if (!match) return; + + const tenantId = Number(match[1]); + const tenantName = await getTenantName(tenantId); + + ctx.session.selectedTenantId = tenantId; + ctx.session.selectedTenantName = tenantName; + + await ctx.answerCallbackQuery(`Tenant gewechselt: ${tenantName}`); + + try { + await ctx.editMessageText(`\u2705 Tenant gewechselt: *${escapeMarkdown(tenantName)}*`, { + parse_mode: 'MarkdownV2', + }); + } catch { + // Message might not be editable + await ctx.reply(`\u2705 Tenant gewechselt: *${escapeMarkdown(tenantName)}*`, { + parse_mode: 'MarkdownV2', + }); + } + + log.info(`User ${ctx.from?.id} switched to tenant ${tenantId} (${tenantName})`); + }); + + // Photo handler + bot.on('message:photo', async (ctx) => { + const photo = ctx.message.photo; + if (!photo || photo.length === 0) return; + + // Check for album + const mediaGroupId = ctx.message.media_group_id; + if (mediaGroupId) { + // Highest resolution is last in array + const largest = photo[photo.length - 1]; + const filename = `photo_${Date.now()}_${largest.file_unique_id}.jpg`; + + handleAlbumMessage(ctx, mediaGroupId, { + fileId: largest.file_id, + filename, + mimeType: 'image/jpeg', + }); + return; + } + + // Single photo: highest resolution is last in array + const largest = photo[photo.length - 1]; + const filename = `photo_${Date.now()}_${largest.file_unique_id}.jpg`; + + const statusMsg = await ctx.reply('\u23f3 Bild wird hochgeladen...'); + await processUpload(ctx, largest.file_id, filename, statusMsg.message_id); + }); + + // Document handler + bot.on('message:document', async (ctx) => { + const doc = ctx.message.document; + if (!doc) return; + + const filename = doc.file_name ?? `document_${Date.now()}`; + const mimeType = doc.mime_type ?? 'application/octet-stream'; + + // Validate file extension and MIME type + if (!isAllowedFile(filename, mimeType)) { + const allowedList = Array.from(ALLOWED_EXTENSIONS).join(', '); + await ctx.reply( + `\u274c Nicht unterstütztes Format\\. Erlaubte Formate: ${escapeMarkdown(allowedList)}`, + { parse_mode: 'MarkdownV2' }, + ); + return; + } + + // Check for album + const mediaGroupId = ctx.message.media_group_id; + if (mediaGroupId) { + handleAlbumMessage(ctx, mediaGroupId, { + fileId: doc.file_id, + filename, + mimeType, + }); + return; + } + + const statusMsg = await ctx.reply('\u23f3 Dokument wird hochgeladen...'); + await processUpload(ctx, doc.file_id, filename, statusMsg.message_id); + }); + + log.info('All handlers registered'); +} + +function handleAlbumMessage( + ctx: BotContext, + mediaGroupId: string, + file: { fileId: string; filename: string; mimeType: string }, +): void { + const existing = albumCollector.get(mediaGroupId); + + if (existing) { + existing.messages.push(file); + // Reset the debounce timer + clearTimeout(existing.timer); + existing.timer = setTimeout(() => { + albumCollector.delete(mediaGroupId); + void processAlbum(existing.ctx, existing.messages); + }, 500); + } else { + const timer = setTimeout(() => { + const entry = albumCollector.get(mediaGroupId); + if (entry) { + albumCollector.delete(mediaGroupId); + void processAlbum(entry.ctx, entry.messages); + } + }, 500); + + albumCollector.set(mediaGroupId, { + chatId: ctx.chat!.id, + messages: [file], + timer, + ctx, + }); + } +} diff --git a/src/telegram/keyboards.ts b/src/telegram/keyboards.ts new file mode 100644 index 0000000..fd584d4 --- /dev/null +++ b/src/telegram/keyboards.ts @@ -0,0 +1,61 @@ +import { InlineKeyboard } from 'grammy'; +import { payloadClient, type TenantDoc } from '../payload/client.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('Keyboards'); + +// Tenant cache with 5-minute TTL +let tenantCache: TenantDoc[] | null = null; +let cacheExpiresAt = 0; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +async function loadTenants(): Promise { + const now = Date.now(); + + // Return valid cache + if (tenantCache && cacheExpiresAt > now) { + return tenantCache; + } + + try { + const tenants = await payloadClient.listTenants(); + tenantCache = tenants; + cacheExpiresAt = now + CACHE_TTL; + log.info(`Loaded ${tenants.length} tenants from API`); + return tenants; + } catch (error) { + // Stale cache fallback: if API fails but we have old data, return it + if (tenantCache) { + log.warn('API failed, returning stale tenant cache', error); + return tenantCache; + } + log.error('Failed to load tenants and no cache available', error); + throw error; + } +} + +export async function buildTenantKeyboard(): Promise { + const tenants = await loadTenants(); + const keyboard = new InlineKeyboard(); + + for (let i = 0; i < tenants.length; i++) { + const tenant = tenants[i]; + keyboard.text(tenant.name, `tenant:${tenant.id}`); + // 2 buttons per row + if (i % 2 === 1 && i < tenants.length - 1) { + keyboard.row(); + } + } + + return keyboard; +} + +export async function getTenantName(tenantId: number): Promise { + try { + const tenants = await loadTenants(); + const tenant = tenants.find((t) => t.id === tenantId); + return tenant?.name ?? `Tenant ${tenantId}`; + } catch { + return `Tenant ${tenantId}`; + } +}