From a2ee6d8cbeb402f129d5c02de25990ef3b482916 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 1 Mar 2026 10:26:56 +0000 Subject: [PATCH] feat: comprehensive user-friendly German error messages - Add centralized friendlyError() helper mapping technical errors to clear German messages (rate limit, auth, network, timeout, etc.) - Improve /status to handle rate limits gracefully - Improve album upload: abort early on rate limit/auth, show partial results with failure reason - Better file-type rejection: show the rejected extension, suggest sending as photo instead - Friendlier Telegram download errors (file too big, getFile failures) - Add global bot.catch() handler for unhandled Grammy errors - Truncate long unknown errors to 150 chars Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 12 +++ src/telegram/handlers.ts | 169 +++++++++++++++++++++++++++++++-------- 2 files changed, 149 insertions(+), 32 deletions(-) diff --git a/src/index.ts b/src/index.ts index f3aeba6..7dcadd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,18 @@ async function main(): Promise { const bot = createBot(); registerHandlers(bot); + // Global error handler — catch unhandled errors so bot doesn't crash + bot.catch((err) => { + const ctx = err.ctx; + const e = err.error; + log.error(`Unhandled error for update ${ctx.update.update_id}:`, e); + + // Try to notify the user + ctx.reply('\u274c Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.').catch(() => { + // Ignore if we can't even send a message + }); + }); + // Graceful shutdown const shutdown = async (signal: string) => { log.info(`Received ${signal}, shutting down...`); diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index e91b954..959d591 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -16,6 +16,66 @@ function escapeMarkdown(text: string): string { return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&'); } +// Map technical errors to user-friendly German messages +function friendlyError(error: unknown): string { + const msg = error instanceof Error ? error.message : String(error); + + // Rate limit + if (msg.includes('429') || msg.includes('RATE_LIMITED')) { + const match = msg.match(/"retryAfter":(\d+)/); + const secs = match ? match[1] : '60'; + return `Server-Rate-Limit erreicht. Bitte warte ${secs} Sekunden und versuche es dann erneut.`; + } + + // Auth + if (msg.includes('401') || msg.includes('incorrect')) { + return 'Anmeldung am CMS fehlgeschlagen. Bitte den Admin kontaktieren.'; + } + + // Forbidden + if (msg.includes('403') || msg.includes('Berechtigung')) { + return 'Keine Berechtigung für diese Aktion. Bitte den Admin kontaktieren.'; + } + + // Validation (400 from Payload) + if (msg.includes('400') && msg.includes('nicht korrekt')) { + return 'Upload-Validierung fehlgeschlagen. Möglicherweise fehlen Pflichtfelder (Alt-Text, Tenant).'; + } + + // Server error + if (msg.includes('500') || msg.includes('Internal Server')) { + return 'CMS-Serverfehler. Bitte versuche es später erneut.'; + } + + // Network / DNS + if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('fetch failed')) { + return 'CMS-Server nicht erreichbar. Bitte versuche es später erneut.'; + } + + // Timeout + if (msg.includes('Timeout') || msg.includes('AbortError') || msg.includes('abort')) { + return 'Zeitüberschreitung beim Server. Bitte versuche es erneut.'; + } + + // Telegram file errors + if (msg.includes('file_path') || msg.includes('getFile')) { + return 'Telegram konnte die Datei nicht bereitstellen. Bitte sende das Bild erneut.'; + } + + // File too large + if (msg.includes('zu groß') || msg.includes('too large')) { + return 'Datei ist zu groß (max. 20 MB). Bitte eine kleinere Datei senden.'; + } + + // Download error + if (msg.includes('Download failed')) { + return 'Datei konnte nicht heruntergeladen werden. Bitte versuche es erneut.'; + } + + // Fallback: shorten raw errors + return msg.length > 150 ? msg.slice(0, 150) + '...' : msg; +} + // Allowed image extensions for document uploads const ALLOWED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'svg']); const ALLOWED_MIME_PREFIXES = ['image/']; @@ -62,10 +122,19 @@ async function downloadTelegramFile( ctx: BotContext, fileId: string, ): Promise<{ buffer: Buffer; mimeType: string; filePath: string }> { - const file = await ctx.api.getFile(fileId); + let file; + try { + file = await ctx.api.getFile(fileId); + } catch (error) { + const msg = error instanceof Error ? error.message : ''; + if (msg.includes('file is too big')) { + throw new Error('Datei ist zu groß für den Telegram-Download (max. 20 MB). Bitte verkleinere die Datei.'); + } + throw new Error('Telegram konnte die Datei nicht bereitstellen. Bitte sende das Bild erneut.'); + } const filePath = file.file_path; if (!filePath) { - throw new Error('Telegram returned no file_path'); + throw new Error('Telegram konnte die Datei nicht bereitstellen. Bitte sende das Bild erneut.'); } const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${filePath}`; const { buffer, mimeType } = await downloadFile(fileUrl); @@ -127,23 +196,14 @@ async function processUpload( parse_mode: 'MarkdownV2', }); } catch (error) { - const errMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; log.error(`Upload failed for ${filename}`, error); - - let userMsg: string; - if (errMsg.includes('429') || errMsg.includes('RATE_LIMITED')) { - const match = errMsg.match(/"retryAfter":(\d+)/); - const secs = match ? match[1] : '60'; - userMsg = `\u23f3 Server\\-Rate\\-Limit erreicht\\. Bitte warte ${secs} Sekunden und versuche es dann erneut\\.`; - } else if (errMsg.includes('401') || errMsg.includes('incorrect')) { - userMsg = `\u274c Anmeldung am CMS fehlgeschlagen\\. Bitte den Admin kontaktieren\\.`; - } else { - userMsg = `\u274c Upload fehlgeschlagen: ${escapeMarkdown(errMsg)}`; - } - - await ctx.api.editMessageText(ctx.chat!.id, statusMessageId, userMsg, { - parse_mode: 'MarkdownV2', - }); + const userMsg = friendlyError(error); + await ctx.api.editMessageText( + ctx.chat!.id, + statusMessageId, + `\u274c ${escapeMarkdown(userMsg)}`, + { parse_mode: 'MarkdownV2' }, + ); } } @@ -161,16 +221,27 @@ async function processAlbum( let successCount = 0; let failCount = 0; + let lastError = ''; + let rateLimited = false; + for (let i = 0; i < total; i++) { const msg = messages[i]; const userId = ctx.from?.id; if (!userId) continue; + // Skip remaining uploads if rate-limited + if (rateLimited) { + failCount++; + continue; + } + const rateCheck = checkRateLimit(userId); if (!rateCheck.allowed) { - failCount++; + failCount += total - i; + rateLimited = true; + lastError = `Bot-Rate-Limit nach ${i} Bildern erreicht`; log.warn(`Rate limit hit during album upload at image ${i + 1}`); - continue; + break; } try { @@ -182,7 +253,16 @@ async function processAlbum( successCount++; } catch (error) { failCount++; + lastError = friendlyError(error); log.error(`Album upload failed for ${msg.filename}`, error); + + // Abort album on rate limit or auth errors + const errMsg = error instanceof Error ? error.message : ''; + if (errMsg.includes('429') || errMsg.includes('401')) { + rateLimited = true; + failCount += total - i - 1; + break; + } } // Progress update every 3 images @@ -199,10 +279,17 @@ async function processAlbum( } } - const resultText = - `\u2705 Album hochgeladen\n\n` + - `Erfolgreich: ${successCount}/${total}\n` + - (failCount > 0 ? `Fehlgeschlagen: ${failCount}` : ''); + let resultText: string; + if (successCount === total) { + resultText = `\u2705 Album hochgeladen (${successCount} Bilder)`; + } else if (successCount === 0) { + resultText = `\u274c Album-Upload fehlgeschlagen\n\n${lastError}`; + } else { + resultText = + `\u26a0\ufe0f Album teilweise hochgeladen\n\n` + + `Erfolgreich: ${successCount}/${total}\n` + + (lastError ? `Grund: ${lastError}` : ''); + } try { await ctx.api.editMessageText(ctx.chat!.id, statusMsg.message_id, resultText); @@ -256,7 +343,7 @@ export function registerHandlers(bot: Bot): void { 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.'); + await ctx.reply(`\u274c ${friendlyError(error)}`); } }); @@ -291,21 +378,37 @@ export function registerHandlers(bot: Bot): void { 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.'); + await ctx.reply(`\u274c ${friendlyError(error)}`); } }); // /status command bot.command('status', async (ctx) => { - const healthy = await payloadClient.checkHealth(); + let apiStatus: string; + try { + const healthy = await payloadClient.checkHealth(); + apiStatus = healthy ? 'Erreichbar' : 'Nicht erreichbar'; + } catch (error) { + const msg = error instanceof Error ? error.message : ''; + if (msg.includes('429') || msg.includes('RATE_LIMITED')) { + apiStatus = 'Rate\\-Limit \\(bitte warten\\)'; + } else { + apiStatus = 'Nicht erreichbar'; + } + } + const expiry = payloadClient.getTokenExpiry(); - const expiryStr = expiry ? expiry.toLocaleString('de-DE') : 'Unbekannt'; + const expiryStr = expiry ? expiry.toLocaleString('de-DE') : 'Kein Token'; + + const tenantStr = ctx.session.selectedTenantId !== null + ? `${escapeMarkdown(ctx.session.selectedTenantName)} \\(ID ${ctx.session.selectedTenantId}\\)` + : 'Nicht gewählt — nutze /tenant'; const text = `\ud83d\udcca *Bot Status*\n\n` + `\u23f1 Uptime: ${escapeMarkdown(formatUptime())}\n` + - `\ud83d\udfe2 API: ${healthy ? 'Erreichbar' : 'Nicht erreichbar'}\n` + - `\ud83c\udfe2 Tenant: ${ctx.session.selectedTenantId !== null ? `${escapeMarkdown(ctx.session.selectedTenantName)} \\(ID ${ctx.session.selectedTenantId}\\)` : 'Nicht gewählt'}\n` + + `\ud83d\udfe2 API: ${apiStatus}\n` + + `\ud83c\udfe2 Tenant: ${tenantStr}\n` + `\ud83d\udd11 Token läuft ab: ${escapeMarkdown(expiryStr)}`; await ctx.reply(text, { parse_mode: 'MarkdownV2' }); @@ -382,9 +485,11 @@ export function registerHandlers(bot: Bot): void { // Validate file extension and MIME type if (!isAllowedFile(filename, mimeType)) { - const allowedList = Array.from(ALLOWED_EXTENSIONS).join(', '); + const ext = filename.split('.').pop()?.toLowerCase() ?? '?'; await ctx.reply( - `\u274c Nicht unterstütztes Format\\. Erlaubte Formate: ${escapeMarkdown(allowedList)}`, + `\u274c *${escapeMarkdown(ext)}*\\-Dateien werden nicht unterstützt\\.\n\n` + + `Erlaubte Bildformate: jpg, png, webp, avif, gif, svg\n` + + `Tipp: Bilder einfach als Foto \\(nicht Dokument\\) senden\\.`, { parse_mode: 'MarkdownV2' }, ); return;