From 89246e7da0d959685cfa6375052f13b76bc78f95 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 21:18:19 -0800 Subject: [PATCH] Recover cleanly from Telegram markdown parse errors --- src/channels/telegram/adapter.test.ts | 35 +++++++++++++++++++++++++++ src/channels/telegram/adapter.ts | 28 +++++++++++++++------ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/channels/telegram/adapter.test.ts b/src/channels/telegram/adapter.test.ts index be15b83..30a5601 100644 --- a/src/channels/telegram/adapter.test.ts +++ b/src/channels/telegram/adapter.test.ts @@ -161,6 +161,41 @@ describe('TelegramAdapter', () => { } }); + it('falls back to plain text when Telegram rejects Markdown entities via description', async () => { + await adapter.connect(); + mockSendMessage + .mockRejectedValueOnce({ + error_code: 400, + description: "Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 43", + }) + .mockResolvedValueOnce(undefined); + + await adapter.send('100', { text: 'Status: **blocked**\nEvidence: `bad markdown' }); + + expect(mockSendMessage).toHaveBeenCalledTimes(2); + expect(mockSendMessage.mock.calls[0]).toEqual([100, 'Status: **blocked**\nEvidence: `bad markdown', { parse_mode: 'Markdown' }]); + expect(mockSendMessage.mock.calls[1]).toEqual([100, 'Status: **blocked**\nEvidence: `bad markdown']); + expect(adapter.status).toBe('connected'); + expect(adapter.lastError).toBeUndefined(); + }); + + it('falls back to plain text when parse-entity failure appears only in error message', async () => { + await adapter.connect(); + mockSendMessage + .mockRejectedValueOnce(new Error( + "Call to 'sendMessage' failed! (400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 43)", + )) + .mockResolvedValueOnce(undefined); + + await adapter.send('100', { text: 'Status: **blocked**\nEvidence: `bad markdown' }); + + expect(mockSendMessage).toHaveBeenCalledTimes(2); + expect(mockSendMessage.mock.calls[0]).toEqual([100, 'Status: **blocked**\nEvidence: `bad markdown', { parse_mode: 'Markdown' }]); + expect(mockSendMessage.mock.calls[1]).toEqual([100, 'Status: **blocked**\nEvidence: `bad markdown']); + expect(adapter.status).toBe('connected'); + expect(adapter.lastError).toBeUndefined(); + }); + // ── onMessage / inbound handling ────────────────────────────── it('onMessage registers a handler that receives text messages', async () => { diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index 5088386..850fbdc 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -544,12 +544,9 @@ export class TelegramAdapter implements ChannelAdapter { `sendMessage(markdown) chat=${chatId}`, ); } catch (error) { - const description = error && typeof error === 'object' && 'description' in error - ? String((error as { description?: unknown }).description) - : ''; - - const isParseError = description.includes("can't parse entities") - || description.includes('message text is empty'); + const parsed = this.parseTelegramError(error); + const isParseError = parsed.parseEntityError + || parsed.message.toLowerCase().includes('message text is empty'); if (!isParseError) { throw error; @@ -677,6 +674,7 @@ export class TelegramAdapter implements ChannelAdapter { retryAfterSec?: number; message: string; transient: boolean; + parseEntityError: boolean; } { const code = typeof error === 'object' && error !== null && 'error_code' in error ? Number((error as { error_code?: unknown }).error_code) @@ -692,6 +690,7 @@ export class TelegramAdapter implements ChannelAdapter { ? error.message : description ?? String(error); const normalized = message.toLowerCase(); + const normalizedDescription = description?.toLowerCase() ?? ''; const transient = code === 429 || (typeof code === 'number' && code >= 500) || normalized.includes('timeout') @@ -700,8 +699,19 @@ export class TelegramAdapter implements ChannelAdapter { || normalized.includes('econnreset') || normalized.includes('enotfound') || normalized.includes('network'); + const parseEntityError = normalized.includes("can't parse entities") + || normalized.includes('cant parse entities') + || normalizedDescription.includes("can't parse entities") + || normalizedDescription.includes('cant parse entities'); - return { code, description, retryAfterSec, message, transient }; + return { + code, + description, + retryAfterSec, + message, + transient, + parseEntityError, + }; } private async sendWithRetry(fn: () => Promise, context: string): Promise { @@ -715,7 +725,9 @@ export class TelegramAdapter implements ChannelAdapter { } catch (error) { const parsed = this.parseTelegramError(error); if (!parsed.transient || attempt >= maxAttempts) { - this.recordAdapterError(`Telegram API failure (${context}): ${parsed.message}`); + if (!parsed.parseEntityError) { + this.recordAdapterError(`Telegram API failure (${context}): ${parsed.message}`); + } throw error; } const baseDelay = parsed.retryAfterSec