From 42a5868b38e4265973c3aec8e14eb80a7106d5ce Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 25 May 2026 10:47:59 +0800 Subject: [PATCH 1/5] fix(telegram): render markdown replies --- .../telegram-markdown-rendering/plan.md | 32 +++ .../telegram-markdown-rendering/spec.md | 32 +++ .../telegram-markdown-rendering/tasks.md | 8 + package.json | 1 + .../telegram/telegramClient.ts | 25 ++- .../telegram/telegramOutbound.ts | 187 ++++++++++++++++++ .../telegram/telegramPoller.ts | 115 +++++++++-- .../telegramClient.test.ts | 49 +++++ .../telegramOutbound.test.ts | 58 +++++- .../telegramPoller.test.ts | 161 +++++++++++++++ 10 files changed, 650 insertions(+), 18 deletions(-) create mode 100644 docs/features/telegram-markdown-rendering/plan.md create mode 100644 docs/features/telegram-markdown-rendering/spec.md create mode 100644 docs/features/telegram-markdown-rendering/tasks.md diff --git a/docs/features/telegram-markdown-rendering/plan.md b/docs/features/telegram-markdown-rendering/plan.md new file mode 100644 index 000000000..ec80fee18 --- /dev/null +++ b/docs/features/telegram-markdown-rendering/plan.md @@ -0,0 +1,32 @@ +# Telegram Markdown Rendering Plan + +## Approach + +- Add a small Telegram outbound formatter in the main process. +- Use `telegram-markdown-formatter` to convert Markdown to the Telegram HTML subset. +- Preprocess common GFM pipe tables into fenced fixed-width text so they become Telegram `
`
+  blocks after conversion.
+- Use formatted chunks only for assistant answer-style delivery segments and final fallbacks.
+- Keep process/tool/status/control messages plain text.
+
+## Runtime Flow
+
+1. Normalize assistant answer text.
+2. Convert pipe tables to fenced fixed-width text.
+3. Convert Markdown to Telegram HTML.
+4. Split HTML into Telegram-sized chunks.
+5. Send or edit chunks with `parse_mode: "HTML"`.
+6. If Telegram returns a parse-entity error, retry that chunk as plain text.
+
+## Compatibility
+
+- Stored delivery state continues to save original text so existing stream alignment semantics stay
+  stable.
+- Chunk comparison for formatted answer segments uses the rendered Telegram chunks.
+- Existing remote settings, bindings, and IPC surfaces are unchanged.
+
+## Test Strategy
+
+- Unit-test Markdown conversion, table fallback, chunking, and fallback classification.
+- Unit-test Telegram client `parse_mode` payloads.
+- Update poller tests to cover formatted answer delivery and plain process delivery.
diff --git a/docs/features/telegram-markdown-rendering/spec.md b/docs/features/telegram-markdown-rendering/spec.md
new file mode 100644
index 000000000..aade5abf6
--- /dev/null
+++ b/docs/features/telegram-markdown-rendering/spec.md
@@ -0,0 +1,32 @@
+# Telegram Markdown Rendering
+
+## User Story
+
+As a Telegram remote-control user, I want assistant replies to render common Markdown
+formatting in Telegram so that remote answers are easier to read.
+
+## Acceptance Criteria
+
+- Telegram assistant answer and final-answer text renders common Markdown via Telegram
+  `parse_mode: "HTML"`.
+- Bold, italic, inline code, fenced code blocks, links, blockquotes, headings, and lists render
+  as Telegram-supported message formatting or readable text.
+- Common GFM pipe tables are converted to aligned fixed-width text and sent as preformatted
+  Telegram HTML.
+- Process logs, command replies, status messages, pending-interaction prompts, and failure notices
+  keep their existing plain-text behavior.
+- If Telegram rejects formatted text because entities cannot be parsed, DeepChat retries the same
+  chunk as plain text.
+
+## Non-Goals
+
+- Mermaid is not rendered as an image in this increment; it remains readable text/code.
+- Complex HTML is not rendered as browser HTML. Unsupported tags are preserved as safe text or
+  reduced to Telegram-supported formatting.
+- No user-facing setting is added.
+
+## Constraints
+
+- Telegram message text remains limited to 4096 characters after entity parsing.
+- The change must stay in the Telegram remote-control outbound path and avoid renderer Markdown
+  dependencies.
diff --git a/docs/features/telegram-markdown-rendering/tasks.md b/docs/features/telegram-markdown-rendering/tasks.md
new file mode 100644
index 000000000..a0d17a7d3
--- /dev/null
+++ b/docs/features/telegram-markdown-rendering/tasks.md
@@ -0,0 +1,8 @@
+# Telegram Markdown Rendering Tasks
+
+- [x] Create SDD spec, plan, and task documents.
+- [x] Add Telegram Markdown formatting helpers.
+- [x] Add Telegram client parse-mode support.
+- [x] Use formatted delivery for assistant answer/final text with plain-text fallback.
+- [x] Add focused unit coverage.
+- [x] Run format, i18n, lint, typecheck, and focused tests.
diff --git a/package.json b/package.json
index f8bc3ee30..7d64f6a89 100644
--- a/package.json
+++ b/package.json
@@ -119,6 +119,7 @@
     "run-applescript": "^7.1.0",
     "safe-regex2": "^5.1.1",
     "sharp": "^0.34.5",
+    "telegram-markdown-formatter": "^0.1.2",
     "tokenx": "^0.4.1",
     "turndown": "^7.2.4",
     "undici": "^7.25.0",
diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts
index 728d98f5f..42f7b769b 100644
--- a/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts
+++ b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts
@@ -85,11 +85,28 @@ export type TelegramBotCommand = {
   description: string
 }
 
+export type TelegramMessageParseMode = 'HTML'
+
+export type TelegramSendMessageOptions = {
+  replyMarkup?: TelegramInlineKeyboardMarkup
+  parseMode?: TelegramMessageParseMode
+}
+
 const buildReplyMarkup = (
   replyMarkup?: TelegramInlineKeyboardMarkup | null
 ): TelegramInlineKeyboardMarkup | undefined =>
   replyMarkup === null ? { inline_keyboard: [] } : replyMarkup
 
+const normalizeSendMessageOptions = (
+  options?: TelegramInlineKeyboardMarkup | TelegramSendMessageOptions
+): TelegramSendMessageOptions => {
+  if (!options) {
+    return {}
+  }
+
+  return 'inline_keyboard' in options ? { replyMarkup: options } : options
+}
+
 const TELEGRAM_FILE_REQUEST_TIMEOUT_MS = 35_000
 
 const fetchWithTimeout = async (
@@ -157,13 +174,15 @@ export class TelegramClient {
   async sendMessage(
     target: TelegramTransportTarget,
     text: string,
-    replyMarkup?: TelegramInlineKeyboardMarkup
+    options?: TelegramInlineKeyboardMarkup | TelegramSendMessageOptions
   ): Promise {
+    const normalizedOptions = normalizeSendMessageOptions(options)
     const message = await this.request('sendMessage', {
       chat_id: target.chatId,
       message_thread_id: target.messageThreadId || undefined,
       text,
-      reply_markup: buildReplyMarkup(replyMarkup)
+      parse_mode: normalizedOptions.parseMode,
+      reply_markup: buildReplyMarkup(normalizedOptions.replyMarkup)
     })
     return message.message_id
   }
@@ -265,12 +284,14 @@ export class TelegramClient {
     target: TelegramTransportTarget
     messageId: number
     text: string
+    parseMode?: TelegramMessageParseMode
     replyMarkup?: TelegramInlineKeyboardMarkup | null
   }): 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/telegramOutbound.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts
index 1a3613fc9..15b13071c 100644
--- a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts
+++ b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts
@@ -1,7 +1,15 @@
 import type { AssistantMessageBlock } from '@shared/types/agent-interface'
+import { splitHtmlForTelegram, telegramFormat } from 'telegram-markdown-formatter'
 import { TELEGRAM_OUTBOUND_TEXT_LIMIT } from '../types'
 
 const EMPTY_TELEGRAM_TEXT = '(No text output)'
+const TELEGRAM_HTML_PARSE_MODE = 'HTML'
+
+export type TelegramFormattedTextChunk = {
+  text: string
+  parseMode?: typeof TELEGRAM_HTML_PARSE_MODE
+  fallbackText: string
+}
 
 export const createTelegramDraftId = (): number =>
   Math.max(1, Math.trunc(Math.random() * 2_000_000_000))
@@ -109,3 +117,182 @@ export const chunkTelegramText = (
 
   return chunks
 }
+
+const parseMarkdownTableRow = (line: string): string[] | null => {
+  const trimmed = line.trim()
+  if (!trimmed.includes('|')) {
+    return null
+  }
+
+  const withoutOuterPipes =
+    trimmed.startsWith('|') && trimmed.endsWith('|') ? trimmed.slice(1, -1) : trimmed
+  const cells = withoutOuterPipes.split('|').map((cell) => cell.trim())
+
+  return cells.length >= 2 ? cells : null
+}
+
+const isMarkdownTableSeparator = (cells: string[]): boolean =>
+  cells.length >= 2 &&
+  cells.every((cell) => {
+    const normalized = cell.replace(/\s/g, '')
+    return /^:?-{3,}:?$/.test(normalized)
+  })
+
+const getCellWidth = (cell: string): number => Array.from(cell).length
+
+const padCell = (cell: string, width: number): string =>
+  `${cell}${' '.repeat(Math.max(0, width - getCellWidth(cell)))}`
+
+const formatMarkdownTableAsText = (rows: string[][]): string => {
+  const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0)
+  const normalizedRows = rows.map((row) =>
+    Array.from({ length: columnCount }, (_, index) => row[index] ?? '')
+  )
+  const widths = Array.from({ length: columnCount }, (_, index) =>
+    Math.max(2, ...normalizedRows.map((row) => getCellWidth(row[index] ?? '')))
+  )
+
+  const formatRow = (row: string[]): string =>
+    row
+      .map((cell, index) => padCell(cell, widths[index] ?? 2))
+      .join(' | ')
+      .trimEnd()
+  const separator = widths.map((width) => '-'.repeat(width)).join('-|-')
+
+  return [formatRow(normalizedRows[0] ?? []), separator, ...normalizedRows.slice(1).map(formatRow)]
+    .filter(Boolean)
+    .join('\n')
+}
+
+export const convertMarkdownTablesToCodeBlocks = (text: string): string => {
+  const lines = text.replace(/\r\n/g, '\n').split('\n')
+  const output: string[] = []
+  let index = 0
+  let fenceMarker: string | null = null
+
+  while (index < lines.length) {
+    const line = lines[index] ?? ''
+    const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/)
+    if (fenceMatch) {
+      const marker = fenceMatch[1] ?? ''
+      if (!fenceMarker) {
+        fenceMarker = marker
+      } else if (marker[0] === fenceMarker[0] && marker.length >= fenceMarker.length) {
+        fenceMarker = null
+      }
+      output.push(line)
+      index += 1
+      continue
+    }
+
+    if (fenceMarker) {
+      output.push(line)
+      index += 1
+      continue
+    }
+
+    const header = parseMarkdownTableRow(line)
+    const separator = parseMarkdownTableRow(lines[index + 1] ?? '')
+
+    if (header && separator && isMarkdownTableSeparator(separator)) {
+      const rows: string[][] = [header]
+      index += 2
+
+      while (index < lines.length) {
+        const row = parseMarkdownTableRow(lines[index] ?? '')
+        if (!row || isMarkdownTableSeparator(row)) {
+          break
+        }
+        rows.push(row)
+        index += 1
+      }
+
+      output.push('```')
+      output.push(formatMarkdownTableAsText(rows))
+      output.push('```')
+      continue
+    }
+
+    output.push(line)
+    index += 1
+  }
+
+  return output.join('\n')
+}
+
+const decodeHtmlEntities = (text: string): string =>
+  text
+    .replace(/&#x([0-9a-f]+);/gi, (_match, value: string) =>
+      String.fromCodePoint(Number.parseInt(value, 16))
+    )
+    .replace(/&#(\d+);/g, (_match, value: string) =>
+      String.fromCodePoint(Number.parseInt(value, 10))
+    )
+    .replace(/"/g, '"')
+    .replace(/'/g, "'")
+    .replace(/</g, '<')
+    .replace(/>/g, '>')
+    .replace(/&/g, '&')
+
+export const stripTelegramHtmlForFallback = (html: string): string =>
+  decodeHtmlEntities(
+    html
+      .replace(
+        /]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
+        (_match, _quote: string, href: string, label: string) =>
+          label.trim() && label.trim() !== href ? `${label} (${href})` : href
+      )
+      .replace(//gi, '\n')
+      .replace(/<\/(?:pre|blockquote|p|div|h[1-6])>/gi, '\n')
+      .replace(/<[^>]+>/g, '')
+      .replace(/\n{3,}/g, '\n\n')
+  ).trim()
+
+export const chunkTelegramMarkdownText = (
+  text: string,
+  limit: number = TELEGRAM_OUTBOUND_TEXT_LIMIT
+): TelegramFormattedTextChunk[] => {
+  const normalized = text?.trim() || EMPTY_TELEGRAM_TEXT
+
+  if (limit !== TELEGRAM_OUTBOUND_TEXT_LIMIT) {
+    return chunkTelegramText(normalized, limit).map((chunk) => ({
+      text: chunk,
+      fallbackText: chunk
+    }))
+  }
+
+  try {
+    const markdown = convertMarkdownTablesToCodeBlocks(normalized)
+    const formatted = telegramFormat(markdown).trim()
+
+    if (formatted === normalized) {
+      return chunkTelegramText(normalized, limit).map((chunk) => ({
+        text: chunk,
+        fallbackText: chunk
+      }))
+    }
+
+    const formattedChunks = splitHtmlForTelegram(formatted).filter((chunk) => chunk.trim())
+
+    if (
+      formattedChunks.length > 0 &&
+      formattedChunks.every((chunk) => chunk.length <= TELEGRAM_OUTBOUND_TEXT_LIMIT)
+    ) {
+      return formattedChunks.map((chunk) => ({
+        text: chunk,
+        parseMode: TELEGRAM_HTML_PARSE_MODE,
+        fallbackText:
+          formattedChunks.length === 1 && normalized.length <= TELEGRAM_OUTBOUND_TEXT_LIMIT
+            ? normalized
+            : stripTelegramHtmlForFallback(chunk) || normalized
+      }))
+    }
+  } catch {
+    // Fall back to plain text below.
+  }
+
+  return chunkTelegramText(normalized, limit).map((chunk) => ({
+    text: chunk,
+    fallbackText: chunk
+  }))
+}
diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts
index 7a5e0d63b..3f4840d28 100644
--- a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts
+++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts
@@ -19,7 +19,11 @@ import {
   type RemoteCommandRouteResult
 } from '../services/remoteCommandRouter'
 import type { RemoteConversationExecution } from '../services/remoteConversationRunner'
-import { chunkTelegramText } from './telegramOutbound'
+import {
+  chunkTelegramMarkdownText,
+  chunkTelegramText,
+  type TelegramFormattedTextChunk
+} from './telegramOutbound'
 import { buildTelegramPendingInteractionPrompt } from './telegramInteractionPrompt'
 import { TelegramApiRequestError, TelegramClient, type TelegramRawUpdate } from './telegramClient'
 import { TelegramParser } from './telegramParser'
@@ -412,7 +416,7 @@ export class TelegramPoller {
           }
           this.deps.bindingStore.clearRemoteDeliveryState(endpointKey)
         } else if (finalText) {
-          await this.sendChunkedMessage(target, finalText)
+          await this.sendChunkedMarkdownMessage(target, finalText)
         }
         await this.sendGeneratedImages(target, snapshot)
         return
@@ -643,12 +647,12 @@ export class TelegramPoller {
     segment: RemoteDeliverySegment
   ): Promise {
     const normalized = segment.text.trim()
-    const nextChunks = chunkTelegramText(normalized)
+    const nextChunks = this.getDeliveryTextChunks(segment.kind, normalized)
 
     if (!existing) {
       const messageIds: number[] = []
       for (const chunk of nextChunks) {
-        messageIds.push(await this.deps.client.sendMessage(target, chunk))
+        messageIds.push(await this.sendTextChunk(target, chunk))
       }
 
       return {
@@ -659,17 +663,19 @@ export class TelegramPoller {
       }
     }
 
-    const previousChunks = existing.lastText ? chunkTelegramText(existing.lastText) : []
+    const previousChunks = existing.lastText
+      ? this.getDeliveryTextChunks(segment.kind, existing.lastText)
+      : []
     if (
       nextChunks.length < existing.messageIds.length ||
       previousChunks.length < existing.messageIds.length ||
       previousChunks
         .slice(0, Math.max(0, existing.messageIds.length - 1))
-        .some((chunk, index) => chunk !== nextChunks[index])
+        .some((chunk, index) => chunk.text !== nextChunks[index]?.text)
     ) {
       const messageIds: number[] = []
       for (const chunk of nextChunks) {
-        messageIds.push(await this.deps.client.sendMessage(target, chunk))
+        messageIds.push(await this.sendTextChunk(target, chunk))
       }
 
       return {
@@ -685,7 +691,7 @@ export class TelegramPoller {
     const retainedCount = Math.min(messageIds.length, nextChunks.length)
 
     for (let index = editableIndex; index < retainedCount; index += 1) {
-      if (previousChunks[index] === nextChunks[index]) {
+      if (previousChunks[index]?.text === nextChunks[index]?.text) {
         continue
       }
 
@@ -694,16 +700,11 @@ export class TelegramPoller {
         continue
       }
 
-      await this.editMessageText(target, {
-        type: 'editMessageText',
-        messageId,
-        text: nextChunks[index],
-        replyMarkup: null
-      })
+      await this.editTextChunk(target, messageId, nextChunks[index])
     }
 
     for (let index = messageIds.length; index < nextChunks.length; index += 1) {
-      messageIds.push(await this.deps.client.sendMessage(target, nextChunks[index]))
+      messageIds.push(await this.sendTextChunk(target, nextChunks[index]))
     }
 
     return {
@@ -728,6 +729,50 @@ export class TelegramPoller {
     }
   }
 
+  private async sendChunkedMarkdownMessage(
+    target: TelegramTransportTarget,
+    text: string
+  ): Promise {
+    for (const chunk of chunkTelegramMarkdownText(text)) {
+      await this.sendTextChunk(target, chunk)
+    }
+  }
+
+  private getDeliveryTextChunks(
+    kind: RemoteDeliverySegment['kind'],
+    text: string
+  ): TelegramFormattedTextChunk[] {
+    if (kind === 'answer' || kind === 'terminal') {
+      return chunkTelegramMarkdownText(text)
+    }
+
+    return chunkTelegramText(text).map((chunk) => ({
+      text: chunk,
+      fallbackText: chunk
+    }))
+  }
+
+  private async sendTextChunk(
+    target: TelegramTransportTarget,
+    chunk: TelegramFormattedTextChunk
+  ): Promise {
+    if (!chunk.parseMode) {
+      return await this.deps.client.sendMessage(target, chunk.text)
+    }
+
+    try {
+      return await this.deps.client.sendMessage(target, chunk.text, {
+        parseMode: chunk.parseMode
+      })
+    } catch (error) {
+      if (this.isTelegramEntityParseError(error)) {
+        return await this.deps.client.sendMessage(target, chunk.fallbackText)
+      }
+
+      throw error
+    }
+  }
+
   private async sendPendingInteractionPrompt(
     target: TelegramTransportTarget,
     interaction: RemotePendingInteraction
@@ -783,6 +828,36 @@ export class TelegramPoller {
     }
   }
 
+  private async editTextChunk(
+    target: TelegramTransportTarget,
+    messageId: number,
+    chunk: TelegramFormattedTextChunk
+  ): Promise {
+    try {
+      await this.deps.client.editMessageText({
+        target,
+        messageId,
+        text: chunk.text,
+        parseMode: chunk.parseMode
+      })
+    } catch (error) {
+      if (this.isMessageNotModifiedError(error)) {
+        return
+      }
+
+      if (chunk.parseMode && this.isTelegramEntityParseError(error)) {
+        await this.deps.client.editMessageText({
+          target,
+          messageId,
+          text: chunk.fallbackText
+        })
+        return
+      }
+
+      throw error
+    }
+  }
+
   private async setIncomingReaction(chatId: number, messageId: number): Promise {
     try {
       await this.deps.client.setMessageReaction({
@@ -871,6 +946,16 @@ export class TelegramPoller {
     )
   }
 
+  private isTelegramEntityParseError(error: unknown): boolean {
+    return (
+      error instanceof TelegramApiRequestError &&
+      error.code === 400 &&
+      /parse entities|can't parse entities|unsupported start tag|can't find end tag/i.test(
+        error.message
+      )
+    )
+  }
+
   private isFatalPollError(error: unknown): boolean {
     if (error instanceof TelegramApiRequestError) {
       return typeof error.code === 'number' && error.code >= 400 && error.code < 500
diff --git a/test/main/presenter/remoteControlPresenter/telegramClient.test.ts b/test/main/presenter/remoteControlPresenter/telegramClient.test.ts
index 96d5fb2f1..dd897ca64 100644
--- a/test/main/presenter/remoteControlPresenter/telegramClient.test.ts
+++ b/test/main/presenter/remoteControlPresenter/telegramClient.test.ts
@@ -62,6 +62,55 @@ describe('TelegramClient', () => {
     })
   })
 
+  it('sends HTML parse mode payloads with sendMessage', async () => {
+    const client = new TelegramClient('token')
+
+    await client.sendMessage(
+      {
+        chatId: 100,
+        messageThreadId: 0
+      },
+      'Hello',
+      {
+        parseMode: 'HTML'
+      }
+    )
+
+    const fetchCall = vi.mocked(fetch).mock.calls[0]
+    expect(fetchCall[0]).toContain('/sendMessage')
+    expect(JSON.parse(fetchCall[1]!.body as string)).toEqual({
+      chat_id: 100,
+      message_thread_id: undefined,
+      text: 'Hello',
+      parse_mode: 'HTML',
+      reply_markup: undefined
+    })
+  })
+
+  it('sends HTML parse mode payloads with editMessageText', async () => {
+    const client = new TelegramClient('token')
+
+    await client.editMessageText({
+      target: {
+        chatId: 100,
+        messageThreadId: 0
+      },
+      messageId: 30,
+      text: 'Edited',
+      parseMode: 'HTML'
+    })
+
+    const fetchCall = vi.mocked(fetch).mock.calls[0]
+    expect(fetchCall[0]).toContain('/editMessageText')
+    expect(JSON.parse(fetchCall[1]!.body as string)).toEqual({
+      chat_id: 100,
+      message_id: 30,
+      text: 'Edited',
+      parse_mode: 'HTML',
+      reply_markup: undefined
+    })
+  })
+
   it('clears inline keyboards through editMessageReplyMarkup', async () => {
     const client = new TelegramClient('token')
 
diff --git a/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts b/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts
index 6cfb29f75..a7399c6f5 100644
--- a/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts
+++ b/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts
@@ -1,11 +1,15 @@
 import { describe, expect, it } from 'vitest'
 import {
   buildTelegramFinalText,
+  chunkTelegramMarkdownText,
   chunkTelegramText,
+  convertMarkdownTablesToCodeBlocks,
   extractTelegramDraftText,
   extractTelegramStreamText,
-  shouldSendTelegramDraft
+  shouldSendTelegramDraft,
+  stripTelegramHtmlForFallback
 } from '@/presenter/remoteControlPresenter/telegram/telegramOutbound'
+import { TELEGRAM_OUTBOUND_TEXT_LIMIT } from '@/presenter/remoteControlPresenter/types'
 
 describe('telegramOutbound', () => {
   it('extracts streaming text from content blocks', () => {
@@ -82,4 +86,56 @@ describe('telegramOutbound', () => {
     expect(chunks).toHaveLength(3)
     expect(chunks.every((chunk) => chunk.length <= 10)).toBe(true)
   })
+
+  it('formats common markdown as Telegram HTML chunks', () => {
+    const chunks = chunkTelegramMarkdownText(
+      '# Title\n\n**bold** _italic_ `x < y`\n\n```ts\nconst value = 1 < 2\n```'
+    )
+
+    expect(chunks).toHaveLength(1)
+    expect(chunks[0]).toEqual(
+      expect.objectContaining({
+        parseMode: 'HTML'
+      })
+    )
+    expect(chunks[0]?.text).toContain('Title')
+    expect(chunks[0]?.text).toContain('bold')
+    expect(chunks[0]?.text).toContain('x < y')
+    expect(chunks[0]?.text).toContain('
')
+  })
+
+  it('converts GFM pipe tables to preformatted text before Telegram formatting', () => {
+    const markdown = '| Name | Value |\n| --- | ---: |\n| Alpha | 1 |\n| Beta | 22 |'
+
+    expect(convertMarkdownTablesToCodeBlocks(markdown)).toContain('Name  | Value')
+
+    const chunks = chunkTelegramMarkdownText(markdown)
+    expect(chunks[0]?.parseMode).toBe('HTML')
+    expect(chunks[0]?.text).toContain('
')
+    expect(chunks[0]?.text).toContain('Name')
+    expect(chunks[0]?.text).toContain('Alpha')
+  })
+
+  it('keeps complex HTML safe by sending it as escaped text', () => {
+    const chunks = chunkTelegramMarkdownText('
Hi
') + + expect(chunks[0]?.parseMode).toBe('HTML') + expect(chunks[0]?.text).toContain('<div') + expect(chunks[0]?.text).toContain('<script>') + expect(chunks[0]?.text).not.toContain('') - - expect(chunks[0]?.parseMode).toBe('HTML') - expect(chunks[0]?.text).toContain('<div') - expect(chunks[0]?.text).toContain('<script>') - expect(chunks[0]?.text).not.toContain('