mirror of
https://github.com/complexcaresolutions/telegram-media-bot.git
synced 2026-03-17 16:13:42 +00:00
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:
parent
0d9d7a2971
commit
a2ee6d8cbe
2 changed files with 149 additions and 32 deletions
12
src/index.ts
12
src/index.ts
|
|
@ -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...`);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue