feat: add command handlers, photo/document/album upload, tenant keyboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-03-01 09:24:35 +00:00
parent 829d785c4a
commit 6a6710c666
2 changed files with 463 additions and 0 deletions

402
src/telegram/handlers.ts Normal file
View file

@ -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<typeof setTimeout>;
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<void> {
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<void> {
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<BotContext>): 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,
});
}
}

61
src/telegram/keyboards.ts Normal file
View file

@ -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<TenantDoc[]> {
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<InlineKeyboard> {
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<string> {
try {
const tenants = await loadTenants();
const tenant = tenants.find((t) => t.id === tenantId);
return tenant?.name ?? `Tenant ${tenantId}`;
} catch {
return `Tenant ${tenantId}`;
}
}