mirror of
https://github.com/complexcaresolutions/telegram-media-bot.git
synced 2026-03-17 16:13:42 +00:00
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:
parent
829d785c4a
commit
6a6710c666
2 changed files with 463 additions and 0 deletions
402
src/telegram/handlers.ts
Normal file
402
src/telegram/handlers.ts
Normal 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
61
src/telegram/keyboards.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue