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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-03-01 10:26:56 +00:00
parent 0d9d7a2971
commit a2ee6d8cbe
2 changed files with 149 additions and 32 deletions

View file

@ -17,6 +17,18 @@ async function main(): Promise<void> {
const bot = createBot(); const bot = createBot();
registerHandlers(bot); 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 // Graceful shutdown
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
log.info(`Received ${signal}, shutting down...`); log.info(`Received ${signal}, shutting down...`);

View file

@ -16,6 +16,66 @@ function escapeMarkdown(text: string): string {
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&'); 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 // Allowed image extensions for document uploads
const ALLOWED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'svg']); const ALLOWED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'svg']);
const ALLOWED_MIME_PREFIXES = ['image/']; const ALLOWED_MIME_PREFIXES = ['image/'];
@ -62,10 +122,19 @@ async function downloadTelegramFile(
ctx: BotContext, ctx: BotContext,
fileId: string, fileId: string,
): Promise<{ buffer: Buffer; mimeType: string; filePath: 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; const filePath = file.file_path;
if (!filePath) { 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 fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${filePath}`;
const { buffer, mimeType } = await downloadFile(fileUrl); const { buffer, mimeType } = await downloadFile(fileUrl);
@ -127,23 +196,14 @@ async function processUpload(
parse_mode: 'MarkdownV2', parse_mode: 'MarkdownV2',
}); });
} catch (error) { } catch (error) {
const errMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
log.error(`Upload failed for ${filename}`, error); log.error(`Upload failed for ${filename}`, error);
const userMsg = friendlyError(error);
let userMsg: string; await ctx.api.editMessageText(
if (errMsg.includes('429') || errMsg.includes('RATE_LIMITED')) { ctx.chat!.id,
const match = errMsg.match(/"retryAfter":(\d+)/); statusMessageId,
const secs = match ? match[1] : '60'; `\u274c ${escapeMarkdown(userMsg)}`,
userMsg = `\u23f3 Server\\-Rate\\-Limit erreicht\\. Bitte warte ${secs} Sekunden und versuche es dann erneut\\.`; { parse_mode: 'MarkdownV2' },
} 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',
});
} }
} }
@ -161,16 +221,27 @@ async function processAlbum(
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
let lastError = '';
let rateLimited = false;
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
const msg = messages[i]; const msg = messages[i];
const userId = ctx.from?.id; const userId = ctx.from?.id;
if (!userId) continue; if (!userId) continue;
// Skip remaining uploads if rate-limited
if (rateLimited) {
failCount++;
continue;
}
const rateCheck = checkRateLimit(userId); const rateCheck = checkRateLimit(userId);
if (!rateCheck.allowed) { 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}`); log.warn(`Rate limit hit during album upload at image ${i + 1}`);
continue; break;
} }
try { try {
@ -182,7 +253,16 @@ async function processAlbum(
successCount++; successCount++;
} catch (error) { } catch (error) {
failCount++; failCount++;
lastError = friendlyError(error);
log.error(`Album upload failed for ${msg.filename}`, 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 // Progress update every 3 images
@ -199,10 +279,17 @@ async function processAlbum(
} }
} }
const resultText = let resultText: string;
`\u2705 Album hochgeladen\n\n` + if (successCount === total) {
`Erfolgreich: ${successCount}/${total}\n` + resultText = `\u2705 Album hochgeladen (${successCount} Bilder)`;
(failCount > 0 ? `Fehlgeschlagen: ${failCount}` : ''); } 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 { try {
await ctx.api.editMessageText(ctx.chat!.id, statusMsg.message_id, resultText); await ctx.api.editMessageText(ctx.chat!.id, statusMsg.message_id, resultText);
@ -256,7 +343,7 @@ export function registerHandlers(bot: Bot<BotContext>): void {
await ctx.reply('\ud83c\udfe2 Tenant auswählen:', { reply_markup: keyboard }); await ctx.reply('\ud83c\udfe2 Tenant auswählen:', { reply_markup: keyboard });
} catch (error) { } catch (error) {
log.error('Failed to build tenant keyboard', 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<BotContext>): void {
await ctx.reply(text, { parse_mode: 'MarkdownV2' }); await ctx.reply(text, { parse_mode: 'MarkdownV2' });
} catch (error) { } catch (error) {
log.error('Failed to list media', 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 // /status command
bot.command('status', async (ctx) => { 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 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 = const text =
`\ud83d\udcca *Bot Status*\n\n` + `\ud83d\udcca *Bot Status*\n\n` +
`\u23f1 Uptime: ${escapeMarkdown(formatUptime())}\n` + `\u23f1 Uptime: ${escapeMarkdown(formatUptime())}\n` +
`\ud83d\udfe2 API: ${healthy ? 'Erreichbar' : 'Nicht erreichbar'}\n` + `\ud83d\udfe2 API: ${apiStatus}\n` +
`\ud83c\udfe2 Tenant: ${ctx.session.selectedTenantId !== null ? `${escapeMarkdown(ctx.session.selectedTenantName)} \\(ID ${ctx.session.selectedTenantId}\\)` : 'Nicht gewählt'}\n` + `\ud83c\udfe2 Tenant: ${tenantStr}\n` +
`\ud83d\udd11 Token läuft ab: ${escapeMarkdown(expiryStr)}`; `\ud83d\udd11 Token läuft ab: ${escapeMarkdown(expiryStr)}`;
await ctx.reply(text, { parse_mode: 'MarkdownV2' }); await ctx.reply(text, { parse_mode: 'MarkdownV2' });
@ -382,9 +485,11 @@ export function registerHandlers(bot: Bot<BotContext>): void {
// Validate file extension and MIME type // Validate file extension and MIME type
if (!isAllowedFile(filename, mimeType)) { if (!isAllowedFile(filename, mimeType)) {
const allowedList = Array.from(ALLOWED_EXTENSIONS).join(', '); const ext = filename.split('.').pop()?.toLowerCase() ?? '?';
await ctx.reply( 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' }, { parse_mode: 'MarkdownV2' },
); );
return; return;