Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/issues/telegram-message-markdown-render/plan.md
Original file line number Diff line number Diff line change
@@ -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 `<pre><code class="language-...">...</code></pre>` 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).
25 changes: 25 additions & 0 deletions docs/issues/telegram-message-markdown-render/spec.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 9 additions & 0 deletions docs/issues/telegram-message-markdown-render/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export type TelegramBotCommand = {
description: string
}

export type TelegramParseMode = 'HTML' | 'MarkdownV2'

const buildReplyMarkup = (
replyMarkup?: TelegramInlineKeyboardMarkup | null
): TelegramInlineKeyboardMarkup | undefined =>
Expand Down Expand Up @@ -157,12 +159,14 @@ export class TelegramClient {
async sendMessage(
target: TelegramTransportTarget,
text: string,
replyMarkup?: TelegramInlineKeyboardMarkup
replyMarkup?: TelegramInlineKeyboardMarkup,
options?: { parseMode?: TelegramParseMode }
): Promise<number> {
const message = await this.request<TelegramSentMessage>('sendMessage', {
chat_id: target.chatId,
message_thread_id: target.messageThreadId || undefined,
text,
parse_mode: options?.parseMode,
reply_markup: buildReplyMarkup(replyMarkup)
})
return message.message_id
Expand Down Expand Up @@ -199,7 +203,8 @@ export class TelegramClient {
async sendPhoto(
target: TelegramTransportTarget,
filePath: string,
caption?: string
caption?: string,
options?: { parseMode?: TelegramParseMode }
): Promise<number> {
const form = new FormData()
form.set('chat_id', String(target.chatId))
Expand All @@ -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'
Expand Down Expand Up @@ -266,11 +274,13 @@ export class TelegramClient {
messageId: number
text: string
replyMarkup?: TelegramInlineKeyboardMarkup | null
parseMode?: TelegramParseMode
}): Promise<void> {
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)
})
}
Expand Down
210 changes: 210 additions & 0 deletions src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts
Original file line number Diff line number Diff line change
@@ -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...``` -> `<pre><code class="language-...">...</code></pre>`
* - Inline code `code` -> `<code>code</code>`
* - Bold `**text**` / `__text__` -> `<b>text</b>`
* - Italic `*text*` (word-bounded) -> `<i>text</i>`
* - Strikethrough `~~text~~` -> `<s>text</s>`
* - Links `[label](url)` -> `<a href="url">label</a>`
* - Headings `# … ######` -> `<b>text</b>`
* - Unordered list markers `- / * / +` -> `• `
* - Blockquote lines `> ` -> grouped into `<blockquote>...</blockquote>`
* - 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<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;'
}

const escapeHtml = (value: string): string =>
value.replace(/[&<>]/g, (char) => HTML_ESCAPE_MAP[char] ?? char)

const escapeAttribute = (value: string): string =>
escapeHtml(value).replace(/"/g, '&quot;').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 `<pre><code class="language-${language}">${escapedBody}</code></pre>`
}
return `<pre>${escapedBody}</pre>`
}

const renderInlineCode = (body: string): string => `<code>${escapeHtml(body)}</code>`

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 `<a href="${escapeAttribute(url)}">${label}</a>`
}
)

escaped = escaped.replace(/\*\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*\*/g, '<b>$1</b>')
escaped = escaped.replace(/__([^\s_][^_\n]*?[^\s_]|[^\s_])__/g, '<b>$1</b>')

escaped = escaped.replace(
/(^|[\s([{"'>])\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*(?=[\s).,;:!?\]}"'<]|$)/g,
'$1<i>$2</i>'
)

escaped = escaped.replace(
/(^|[\s([{"'>])_([^\s_][^_\n]*?[^\s_]|[^\s_])_(?=[\s).,;:!?\]}"'<]|$)/g,
'$1<i>$2</i>'
)

escaped = escaped.replace(/~~([^~\n]+)~~/g, '<s>$1</s>')

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('<blockquote>')
openBlockquote = true
} else if (!isBlockquote && openBlockquote) {
out.push('</blockquote>')
openBlockquote = false
}

out.push(content)
}

if (openBlockquote) {
out.push('</blockquote>')
}

const joined = collapseExcessNewlines(out.join('\n'))
return restoreCodeBlocks(joined, codeBlocks, codeInlines)
} catch {
return escapeHtml(input)
}
}
Loading