From c7f1e063c86a0a849c6f2f3d68fc200083cd759f Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 25 May 2026 10:37:20 +0800 Subject: [PATCH] fix(telegram): render markdown as html AI replies arrived as Markdown and were sent verbatim, so Telegram clients showed raw `**bold**`, `# heading`, and fenced code blocks. Add a local converter that maps the Markdown subset we emit to Telegram's HTML subset (``, ``, ``, ``, `
`, ``,
`
`), thread `parseMode` through TelegramClient, and route every outbound chunk in telegramPoller through the converter with `parse_mode: 'HTML'`. Dangling fenced blocks at chunk boundaries are auto-closed so 4096-char splits stay parseable. Closes #1665 --- .../telegram-message-markdown-render/plan.md | 19 ++ .../telegram-message-markdown-render/spec.md | 25 +++ .../telegram-message-markdown-render/tasks.md | 9 + .../telegram/telegramClient.ts | 14 +- .../telegram/telegramMarkdown.ts | 210 ++++++++++++++++++ .../telegram/telegramPoller.ts | 32 ++- .../telegramClient.test.ts | 45 ++++ .../telegramMarkdown.test.ts | 68 ++++++ .../telegramPoller.test.ts | 82 +++++-- 9 files changed, 472 insertions(+), 32 deletions(-) create mode 100644 docs/issues/telegram-message-markdown-render/plan.md create mode 100644 docs/issues/telegram-message-markdown-render/spec.md create mode 100644 docs/issues/telegram-message-markdown-render/tasks.md create mode 100644 src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts create mode 100644 test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts diff --git a/docs/issues/telegram-message-markdown-render/plan.md b/docs/issues/telegram-message-markdown-render/plan.md new file mode 100644 index 000000000..72b6a1c83 --- /dev/null +++ b/docs/issues/telegram-message-markdown-render/plan.md @@ -0,0 +1,19 @@ +# Telegram Message Markdown Render Plan + +## Approach + +- Add `src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts` exposing `convertMarkdownToTelegramHtml(text: string): string`, mirroring the Feishu-side `feishuMarkdown.ts` module location and shape. +- The converter: + - Escapes `&`, `<`, `>` first to make raw text safe for `parse_mode: 'HTML'`. + - Handles fenced code blocks (` ``` `) by emitting `
...
` and protecting the body from further Markdown processing. + - Handles inline code (` `…` `), bold (`**`/`__`), italic (`*`/`_`), strikethrough (`~~`), links, headings (`#…######`), unordered/ordered lists, and blockquotes (`>`). + - Auto-closes a dangling fenced block when called on a chunk that ends mid-block, so each chunk produces valid HTML for Telegram. +- Extend `TelegramClient.sendMessage`, `editMessageText`, and `sendPhoto` with an optional `parseMode` ('HTML' | 'MarkdownV2'). Default remains undefined for backward compatibility. +- In `TelegramPoller`: + - Convert chunk text via `convertMarkdownToTelegramHtml` before `sendMessage`/`editMessageText` calls in `syncDeliverySegment`, `sendChunkedMessage`, `dispatchOutboundActions`, and `editMessageText`. Pass `parseMode: 'HTML'`. + - Apply conversion to the interaction prompt text as well so callback prompts render formatting consistently. + +## Validation + +- Run `pnpm test test/main/presenter/remoteControlPresenter/telegramClient.test.ts` (extended) and a new `telegramMarkdown.test.ts` covering core conversion rules and chunk-boundary behavior. +- Run `pnpm run typecheck:node` to confirm no signature break in callers (Poller, Adapter). diff --git a/docs/issues/telegram-message-markdown-render/spec.md b/docs/issues/telegram-message-markdown-render/spec.md new file mode 100644 index 000000000..6a3134103 --- /dev/null +++ b/docs/issues/telegram-message-markdown-render/spec.md @@ -0,0 +1,25 @@ +# Telegram Message Markdown Render + +## User Story + +When DeepChat's Telegram remote control bot delivers AI replies, command output, and other generated text, users should see properly rendered formatting (bold, italic, inline code, fenced code blocks, links, lists, blockquotes) instead of raw Markdown symbols (`**bold**`, `# heading`, ` ``` `). + +## Acceptance Criteria + +- `telegramClient.sendMessage` and `telegramClient.editMessageText` call the Telegram Bot API with `parse_mode: 'HTML'` when the outbound text contains formatted content. +- AI answer / process delivery segments routed through `TelegramPoller.syncDeliverySegment` and outbound actions dispatched via `dispatchOutboundActions` go through a Markdown → Telegram-HTML converter that handles bold, italic, strikethrough, inline code, fenced code blocks, headings, links, ordered/unordered lists, blockquotes, and horizontal rules. +- Plain text (system replies, error messages, command echoes) is HTML-escaped and accepted by Telegram without parse-mode errors. +- Chunked streaming (4096 char limit) keeps each chunk independently renderable — partial Markdown left at a chunk boundary (e.g. an unclosed code fence) renders as text or a safely balanced block instead of breaking the Telegram parse. +- Existing Telegram client tests pass; a new test covers the converter and parse-mode wiring. + +## Constraints + +- Keep behavior parity with the existing Feishu pattern: a dedicated `telegramMarkdown.ts` module living next to `telegramClient.ts`, surfaced through a single conversion entry point. +- No new runtime dependency; the conversion is implemented locally to keep the bundle lean and stay within Telegram's HTML subset. +- Do not change `chunkTelegramText` semantics or the streaming delivery state shape. + +## Non-Goals + +- No switch to Telegram MarkdownV2. +- No changes to attachment handling, photo captions beyond passing `parse_mode` when a caption is sent. +- No richer Telegram-only features (custom emojis, spoilers, MessageEntities). diff --git a/docs/issues/telegram-message-markdown-render/tasks.md b/docs/issues/telegram-message-markdown-render/tasks.md new file mode 100644 index 000000000..93b7e6940 --- /dev/null +++ b/docs/issues/telegram-message-markdown-render/tasks.md @@ -0,0 +1,9 @@ +# Telegram Message Markdown Render Tasks + +- [x] Capture the reproduction from issue #1665 and confirm `sendMessage`/`editMessageText` ship raw Markdown without `parse_mode`. +- [x] Draft SDD spec, plan, tasks documents. +- [ ] Implement `telegram/telegramMarkdown.ts` with `convertMarkdownToTelegramHtml`. +- [ ] Thread an optional `parseMode` through `TelegramClient.sendMessage`, `editMessageText`, and `sendPhoto`. +- [ ] Update `TelegramPoller` to apply the converter and pass `parse_mode: 'HTML'` on all generated text paths. +- [ ] Add focused tests for the converter and parse-mode wiring; keep existing telegram tests green. +- [ ] Run `pnpm run format`, `pnpm run lint`, `pnpm run typecheck:node`, and the focused test suites. diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts index 728d98f5f..4e06c88be 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts @@ -85,6 +85,8 @@ export type TelegramBotCommand = { description: string } +export type TelegramParseMode = 'HTML' | 'MarkdownV2' + const buildReplyMarkup = ( replyMarkup?: TelegramInlineKeyboardMarkup | null ): TelegramInlineKeyboardMarkup | undefined => @@ -157,12 +159,14 @@ export class TelegramClient { async sendMessage( target: TelegramTransportTarget, text: string, - replyMarkup?: TelegramInlineKeyboardMarkup + replyMarkup?: TelegramInlineKeyboardMarkup, + options?: { parseMode?: TelegramParseMode } ): Promise { const message = await this.request('sendMessage', { chat_id: target.chatId, message_thread_id: target.messageThreadId || undefined, text, + parse_mode: options?.parseMode, reply_markup: buildReplyMarkup(replyMarkup) }) return message.message_id @@ -199,7 +203,8 @@ export class TelegramClient { async sendPhoto( target: TelegramTransportTarget, filePath: string, - caption?: string + caption?: string, + options?: { parseMode?: TelegramParseMode } ): Promise { const form = new FormData() form.set('chat_id', String(target.chatId)) @@ -208,6 +213,9 @@ export class TelegramClient { } if (caption?.trim()) { form.set('caption', caption.trim()) + if (options?.parseMode) { + form.set('parse_mode', options.parseMode) + } } const fileBuffer = await fs.readFile(filePath) const fileName = path.basename(filePath) || 'image' @@ -266,11 +274,13 @@ export class TelegramClient { messageId: number text: string replyMarkup?: TelegramInlineKeyboardMarkup | null + parseMode?: TelegramParseMode }): Promise { await this.request('editMessageText', { chat_id: params.target.chatId, message_id: params.messageId, text: params.text, + parse_mode: params.parseMode, reply_markup: buildReplyMarkup(params.replyMarkup) }) } diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts new file mode 100644 index 000000000..b1894a220 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts @@ -0,0 +1,210 @@ +/** + * Markdown -> Telegram HTML conversion for remote-control outbound messages. + * + * Telegram Bot API accepts a small HTML subset (`parse_mode: 'HTML'`). + * AI replies arriving as Markdown were previously sent verbatim, so + * `**bold**`, `# heading`, and fenced code blocks rendered as raw symbols. + * + * Reference: https://core.telegram.org/bots/api#html-style + * + * Supported conversions: + * - Fenced code blocks ``` lang\n...``` -> `
...
` + * - Inline code `code` -> `code` + * - Bold `**text**` / `__text__` -> `text` + * - Italic `*text*` (word-bounded) -> `text` + * - Strikethrough `~~text~~` -> `text` + * - Links `[label](url)` -> `
label` + * - Headings `# … ######` -> `text` + * - Unordered list markers `- / * / +` -> `• ` + * - Blockquote lines `> ` -> grouped into `
...
` + * - Horizontal rules `---` / `***` -> `———` + * + * Chunk-safety: dangling fenced code blocks (when a chunk boundary lands + * inside ``` … ```) are auto-closed so each emitted message still parses. + */ + +const PLACEHOLDER_PREFIX = '⁣CB⁣' +const INLINE_PLACEHOLDER_PREFIX = '⁣CI⁣' +const PLACEHOLDER_SUFFIX = '⁣' + +const HTML_ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>' +} + +const escapeHtml = (value: string): string => + value.replace(/[&<>]/g, (char) => HTML_ESCAPE_MAP[char] ?? char) + +const escapeAttribute = (value: string): string => + escapeHtml(value).replace(/"/g, '"').replace(/\n/g, ' ') + +const sanitizeLanguage = (value: string): string => value.replace(/[^a-zA-Z0-9_+\-.]/g, '') + +const renderCodeBlock = (lang: string, body: string): string => { + const escapedBody = escapeHtml(body.replace(/\n+$/g, '')) + const language = sanitizeLanguage(lang) + if (language) { + return `
${escapedBody}
` + } + return `
${escapedBody}
` +} + +const renderInlineCode = (body: string): string => `${escapeHtml(body)}` + +const extractFencedCodeBlocks = ( + text: string, + store: Array<{ lang: string; body: string }> +): string => { + let result = text.replace( + /(^|\n)```([^\n`]*)\n([\s\S]*?)\n```(?=\n|$)/g, + (_match, prefix: string, lang: string, body: string) => { + const index = store.push({ lang: lang.trim(), body }) - 1 + return `${prefix}${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}` + } + ) + + // Auto-close a dangling fenced block so chunk boundaries stay renderable. + const dangling = result.match(/(^|\n)```([^\n`]*)\n([\s\S]*)$/) + if (dangling) { + const [, prefix = '', lang = '', body = ''] = dangling + const index = store.push({ lang: lang.trim(), body }) - 1 + result = + result.slice(0, dangling.index ?? 0) + + `${prefix}${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}` + } + + return result +} + +const extractInlineCode = (text: string, store: string[]): string => + text.replace(/`([^`\n]+)`/g, (_match, body: string) => { + const index = store.push(body) - 1 + return `${INLINE_PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}` + }) + +const renderLine = (line: string): { content: string; isBlockquote: boolean } => { + let working = line + let isBlockquote = false + + const bqMatch = working.match(/^(\s*)>\s?(.*)$/) + if (bqMatch) { + isBlockquote = true + working = bqMatch[2] + } + + if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(working)) { + return { content: escapeHtml('———'), isBlockquote } + } + + const headingMatch = working.match(/^(\s*)#{1,6}\s+(.+?)\s*#*\s*$/) + if (headingMatch) { + working = `${headingMatch[1]}**${headingMatch[2]}**` + } + + working = working.replace(/^(\s*)[-*+]\s+/, '$1• ') + + let escaped = escapeHtml(working) + + escaped = escaped.replace( + /\[([^\]\n]+)\]\(([^)\s]+?)\)/g, + (_match, label: string, url: string) => { + return `${label}` + } + ) + + escaped = escaped.replace(/\*\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*\*/g, '$1') + escaped = escaped.replace(/__([^\s_][^_\n]*?[^\s_]|[^\s_])__/g, '$1') + + escaped = escaped.replace( + /(^|[\s([{"'>])\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*(?=[\s).,;:!?\]}"'<]|$)/g, + '$1$2' + ) + + escaped = escaped.replace( + /(^|[\s([{"'>])_([^\s_][^_\n]*?[^\s_]|[^\s_])_(?=[\s).,;:!?\]}"'<]|$)/g, + '$1$2' + ) + + escaped = escaped.replace(/~~([^~\n]+)~~/g, '$1') + + return { content: escaped, isBlockquote } +} + +const restoreCodeBlocks = ( + text: string, + blocks: Array<{ lang: string; body: string }>, + inlines: string[] +): string => { + const blockPattern = new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g') + const inlinePattern = new RegExp(`${INLINE_PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g') + + let result = text.replace(blockPattern, (_, indexValue: string) => { + const block = blocks[Number(indexValue)] + if (!block) { + return '' + } + return renderCodeBlock(block.lang, block.body) + }) + + result = result.replace(inlinePattern, (_, indexValue: string) => { + const body = inlines[Number(indexValue)] + if (body === undefined) { + return '' + } + return renderInlineCode(body) + }) + + return result +} + +const collapseExcessNewlines = (text: string): string => text.replace(/\n{3,}/g, '\n\n') + +/** + * Convert Markdown text into the Telegram HTML subset accepted by + * `parse_mode: 'HTML'`. Safe for chunked streaming — partial Markdown + * left at a chunk boundary degrades to escaped text rather than + * breaking Telegram's parser. + */ +export const convertMarkdownToTelegramHtml = (input: string): string => { + if (!input) { + return '' + } + + try { + const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + const codeBlocks: Array<{ lang: string; body: string }> = [] + const codeInlines: string[] = [] + + const withoutFenced = extractFencedCodeBlocks(normalized, codeBlocks) + const withoutInline = extractInlineCode(withoutFenced, codeInlines) + + const lines = withoutInline.split('\n') + const out: string[] = [] + let openBlockquote = false + + for (const rawLine of lines) { + const { content, isBlockquote } = renderLine(rawLine) + + if (isBlockquote && !openBlockquote) { + out.push('
') + openBlockquote = true + } else if (!isBlockquote && openBlockquote) { + out.push('
') + openBlockquote = false + } + + out.push(content) + } + + if (openBlockquote) { + out.push('
') + } + + const joined = collapseExcessNewlines(out.join('\n')) + return restoreCodeBlocks(joined, codeBlocks, codeInlines) + } catch { + return escapeHtml(input) + } +} diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts index 7a5e0d63b..05e540a0b 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts @@ -7,6 +7,7 @@ import { type RemoteDeliverySegment, type RemotePendingInteraction, type TelegramInboundMessage, + type TelegramInlineKeyboardMarkup, type TelegramOutboundAction, type TelegramPollerStatusSnapshot, type TelegramTransportTarget @@ -20,6 +21,7 @@ import { } from '../services/remoteCommandRouter' import type { RemoteConversationExecution } from '../services/remoteConversationRunner' import { chunkTelegramText } from './telegramOutbound' +import { convertMarkdownToTelegramHtml } from './telegramMarkdown' import { buildTelegramPendingInteractionPrompt } from './telegramInteractionPrompt' import { TelegramApiRequestError, TelegramClient, type TelegramRawUpdate } from './telegramClient' import { TelegramParser } from './telegramParser' @@ -648,7 +650,7 @@ export class TelegramPoller { if (!existing) { const messageIds: number[] = [] for (const chunk of nextChunks) { - messageIds.push(await this.deps.client.sendMessage(target, chunk)) + messageIds.push(await this.sendChunk(target, chunk)) } return { @@ -669,7 +671,7 @@ export class TelegramPoller { ) { const messageIds: number[] = [] for (const chunk of nextChunks) { - messageIds.push(await this.deps.client.sendMessage(target, chunk)) + messageIds.push(await this.sendChunk(target, chunk)) } return { @@ -703,7 +705,7 @@ export class TelegramPoller { } for (let index = messageIds.length; index < nextChunks.length; index += 1) { - messageIds.push(await this.deps.client.sendMessage(target, nextChunks[index])) + messageIds.push(await this.sendChunk(target, nextChunks[index])) } return { @@ -724,10 +726,23 @@ export class TelegramPoller { private async sendChunkedMessage(target: TelegramTransportTarget, text: string): Promise { for (const chunk of chunkTelegramText(text)) { - await this.deps.client.sendMessage(target, chunk) + await this.sendChunk(target, chunk) } } + private async sendChunk( + target: TelegramTransportTarget, + text: string, + replyMarkup?: TelegramInlineKeyboardMarkup + ): Promise { + return await this.deps.client.sendMessage( + target, + convertMarkdownToTelegramHtml(text), + replyMarkup, + { parseMode: 'HTML' } + ) + } + private async sendPendingInteractionPrompt( target: TelegramTransportTarget, interaction: RemotePendingInteraction @@ -737,7 +752,7 @@ export class TelegramPoller { const prompt = buildTelegramPendingInteractionPrompt(interaction, token) if (prompt.replyMarkup) { - await this.deps.client.sendMessage(target, prompt.text, prompt.replyMarkup) + await this.sendChunk(target, prompt.text, prompt.replyMarkup) return } @@ -751,7 +766,7 @@ export class TelegramPoller { for (const action of actions) { if (action.type === 'sendMessage') { if (action.replyMarkup) { - await this.deps.client.sendMessage(target, action.text, action.replyMarkup) + await this.sendChunk(target, action.text, action.replyMarkup) continue } @@ -771,8 +786,9 @@ export class TelegramPoller { await this.deps.client.editMessageText({ target, messageId: action.messageId, - text: action.text, - replyMarkup: action.replyMarkup ?? undefined + text: convertMarkdownToTelegramHtml(action.text), + replyMarkup: action.replyMarkup ?? undefined, + parseMode: 'HTML' }) } catch (error) { if (this.isMessageNotModifiedError(error)) { diff --git a/test/main/presenter/remoteControlPresenter/telegramClient.test.ts b/test/main/presenter/remoteControlPresenter/telegramClient.test.ts index 96d5fb2f1..5e838a2d9 100644 --- a/test/main/presenter/remoteControlPresenter/telegramClient.test.ts +++ b/test/main/presenter/remoteControlPresenter/telegramClient.test.ts @@ -49,6 +49,7 @@ describe('TelegramClient', () => { chat_id: 100, message_thread_id: undefined, text: 'Choose a provider', + parse_mode: undefined, reply_markup: { inline_keyboard: [ [ @@ -62,6 +63,50 @@ describe('TelegramClient', () => { }) }) + it('forwards parse_mode option through sendMessage', async () => { + const client = new TelegramClient('token') + + await client.sendMessage( + { + chatId: 100, + messageThreadId: 0 + }, + 'hello', + undefined, + { parseMode: 'HTML' } + ) + + const fetchCall = vi.mocked(fetch).mock.calls[0] + expect(fetchCall[0]).toContain('/sendMessage') + expect(JSON.parse(fetchCall[1]!.body as string)).toMatchObject({ + text: 'hello', + parse_mode: 'HTML' + }) + }) + + it('forwards parse_mode option through editMessageText', async () => { + const client = new TelegramClient('token') + + await client.editMessageText({ + target: { + chatId: 100, + messageThreadId: 0 + }, + messageId: 30, + text: 'hello', + parseMode: 'HTML' + }) + + const fetchCall = vi.mocked(fetch).mock.calls[0] + expect(fetchCall[0]).toContain('/editMessageText') + expect(JSON.parse(fetchCall[1]!.body as string)).toMatchObject({ + chat_id: 100, + message_id: 30, + text: 'hello', + parse_mode: 'HTML' + }) + }) + it('clears inline keyboards through editMessageReplyMarkup', async () => { const client = new TelegramClient('token') diff --git a/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts b/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts new file mode 100644 index 000000000..0eccce973 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { convertMarkdownToTelegramHtml } from '@/presenter/remoteControlPresenter/telegram/telegramMarkdown' + +describe('convertMarkdownToTelegramHtml', () => { + it('returns an empty string for empty input', () => { + expect(convertMarkdownToTelegramHtml('')).toBe('') + }) + + it('escapes HTML-sensitive characters in plain text', () => { + expect(convertMarkdownToTelegramHtml('1 < 2 & 3 > 0')).toBe('1 < 2 & 3 > 0') + }) + + it('converts bold, italic, and strikethrough markers', () => { + expect(convertMarkdownToTelegramHtml('**bold** _italic_ ~~gone~~')).toBe( + 'bold italic gone' + ) + }) + + it('demotes Markdown headings to bold', () => { + expect(convertMarkdownToTelegramHtml('# Title')).toBe('Title') + expect(convertMarkdownToTelegramHtml('### Section')).toBe('Section') + }) + + it('renders inline code with HTML escaping', () => { + expect(convertMarkdownToTelegramHtml('use `
` here')).toBe( + 'use <div> here' + ) + }) + + it('renders fenced code blocks with language class and escapes contents', () => { + const input = '```ts\nconst a = 1 < 2\n```' + expect(convertMarkdownToTelegramHtml(input)).toBe( + '
const a = 1 < 2
' + ) + }) + + it('renders fenced code blocks without a language as plain
', () => {
+    const input = '```\nhello\n```'
+    expect(convertMarkdownToTelegramHtml(input)).toBe('
hello
') + }) + + it('auto-closes a dangling fenced block at a chunk boundary', () => { + const input = '```ts\nconst a = 1' + expect(convertMarkdownToTelegramHtml(input)).toBe( + '
const a = 1
' + ) + }) + + it('rewrites Markdown links into Telegram-safe tags', () => { + expect(convertMarkdownToTelegramHtml('see [docs](https://example.com)')).toBe( + 'see docs' + ) + }) + + it('normalizes unordered list markers to bullet points', () => { + expect(convertMarkdownToTelegramHtml('- one\n* two\n+ three')).toBe('• one\n• two\n• three') + }) + + it('groups consecutive blockquote lines into a single
', () => { + expect(convertMarkdownToTelegramHtml('> first\n> second\nplain')).toBe( + '
\nfirst\nsecond\n
\nplain' + ) + }) + + it('returns escaped text when conversion throws', () => { + expect(convertMarkdownToTelegramHtml('plain ')).toBe('plain <tag>') + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts index d87860628..fdebfd0ef 100644 --- a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts +++ b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts @@ -381,7 +381,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'pong' + 'pong', + undefined, + { parseMode: 'HTML' } ) expect(client.setMessageReaction).toHaveBeenNthCalledWith(2, { chatId: 100, @@ -527,7 +529,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - '💻 shell_command: "git status"' + '💻 shell_command: "git status"', + undefined, + { parseMode: 'HTML' } ) }) @@ -539,7 +543,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Draft answer' + 'Draft answer', + undefined, + { parseMode: 'HTML' } ) expect(bindingStore.rememberRemoteDeliveryState).toHaveBeenCalledWith( 'telegram:100:0', @@ -578,7 +584,8 @@ describe('TelegramPoller', () => { }, messageId: 101, text: 'Final answer', - replyMarkup: undefined + replyMarkup: undefined, + parseMode: 'HTML' }) expect(bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith('telegram:100:0') }) @@ -681,7 +688,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - firstText + firstText, + undefined, + { parseMode: 'HTML' } ) }) @@ -695,14 +704,17 @@ describe('TelegramPoller', () => { }, messageId: 100, text: 'A'.repeat(4_096), - replyMarkup: undefined + replyMarkup: undefined, + parseMode: 'HTML' }) expect(client.sendMessage).toHaveBeenCalledWith( { chatId: 100, messageThreadId: 0 }, - 'A'.repeat(109) + 'A'.repeat(109), + undefined, + { parseMode: 'HTML' } ) }) @@ -924,7 +936,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Partial answer' + 'Partial answer', + undefined, + { parseMode: 'HTML' } ) }) @@ -936,7 +950,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'The conversation ended with an error.' + 'The conversation ended with an error.', + undefined, + { parseMode: 'HTML' } ) expect(client.editMessageText).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -1036,14 +1052,18 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Final answer' + 'Final answer', + undefined, + { parseMode: 'HTML' } ) expect(client.sendMessage).toHaveBeenCalledWith( { chatId: 100, messageThreadId: 0 }, - '💻 shell_command: "git status"' + '💻 shell_command: "git status"', + undefined, + { parseMode: 'HTML' } ) }) @@ -1053,7 +1073,8 @@ describe('TelegramPoller', () => { messageThreadId: 0 }, 'Final answer', - expect.anything() + expect.anything(), + { parseMode: 'HTML' } ) expect( client.sendMessage.mock.calls.filter(([, text]) => text === 'Final answer') @@ -1197,7 +1218,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Let me inspect these files.' + 'Let me inspect these files.', + undefined, + { parseMode: 'HTML' } ) }) @@ -1209,7 +1232,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - '📖 read_file: "/tmp/report.md"' + '📖 read_file: "/tmp/report.md"', + undefined, + { parseMode: 'HTML' } ) }) @@ -1221,7 +1246,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Summary ready.' + 'Summary ready.', + undefined, + { parseMode: 'HTML' } ) expect(client.editMessageText).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -1310,7 +1337,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - '📖 read_file: "/tmp/report.md"' + '📖 read_file: "/tmp/report.md"', + undefined, + { parseMode: 'HTML' } ) expect(bindingStore.clearRemoteDeliveryState).toHaveBeenCalledWith('telegram:100:0') }) @@ -1382,7 +1411,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'running' + 'running', + undefined, + { parseMode: 'HTML' } ) }) @@ -1487,7 +1518,8 @@ describe('TelegramPoller', () => { } ] ] - } + }, + parseMode: 'HTML' }) }) @@ -1776,7 +1808,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Partial answer' + 'Partial answer', + undefined, + { parseMode: 'HTML' } ) expect(client.sendMessage).toHaveBeenNthCalledWith( 2, @@ -1787,7 +1821,8 @@ describe('TelegramPoller', () => { expect.stringContaining('Permission Required'), expect.objectContaining({ inline_keyboard: expect.any(Array) - }) + }), + { parseMode: 'HTML' } ) }) @@ -1878,7 +1913,8 @@ describe('TelegramPoller', () => { }, messageId: 30, text: 'Permission handled.\nApproved. Continuing...', - replyMarkup: undefined + replyMarkup: undefined, + parseMode: 'HTML' }) }) @@ -1904,7 +1940,9 @@ describe('TelegramPoller', () => { chatId: 100, messageThreadId: 0 }, - 'Done' + 'Done', + undefined, + { parseMode: 'HTML' } ) })