Skip to content

Fix Telegram message formatting pipeline#74

Open
punitarani wants to merge 4 commits intomainfrom
feat/telegram-formatting
Open

Fix Telegram message formatting pipeline#74
punitarani wants to merge 4 commits intomainfrom
feat/telegram-formatting

Conversation

@punitarani
Copy link
Copy Markdown
Owner

@punitarani punitarani commented Mar 27, 2026

Summary

  • add shared channel-presentation metadata and channel-aware prompt rules for response formatting
  • render Telegram replies through a markdown-to-HTML formatter and send them with parse_mode=HTML
  • disable Telegram streaming edits for now, update the mock Telegram channel, and add regression coverage

Verification

  • bun test apps/api/src/telegram/formatting.test.ts packages/agent/src/context/builder.test.ts apps/mock/lib/message-store.test.ts
  • bun run format
  • bun run lint:fix
  • bun run typecheck
  • bun run build

Greptile Summary

This PR fixes Telegram message formatting by replacing the @chat-adapter/telegram adapter's legacy Markdown transport with a custom TelegramBotApiClient that converts agent-generated CommonMark into Telegram HTML and sends it with parse_mode=HTML. It also adds a shared ChannelPresentation contract so the agent prompt is channel-aware, disables streaming edits in favor of fully-rendered final messages (with typing indicators retained), and updates the mock Telegram server and UI to handle and render parse_mode.\n\nKey changes:\n- New formatting.ts: walks the markdown AST node-by-node into Telegram HTML with correct escaping, oversized-block fallback to plain text, and 4096-char chunk splitting measured on rendered text length\n- New bot-api.ts: thin TelegramBotApiClient that calls the formatter and sends each chunk sequentially with parse_mode=HTML; replaces all direct adapter.postMessage/editMessage call sites in bot.ts, agent-execution.ts, and telegram/index.ts\n- New channel-presentation.ts + buildConversationPrompt integration: getChannelPresentation produces a typed ChannelPresentation for each platform; Telegram gets transportFormat: \"telegram-html\" and supportsStreaming: false\n- Streaming edits removed from agent-execution.ts, reducing code complexity and eliminating the correctness gap between partial and final markdown rendering\n- Mock Telegram server updated to preserve parse_mode; MessageBubble renders HTML with a DOMParser-based React renderer gated by an isClient hydration flag\n- Regression tests added for formatter output, prompt channel rules, and mock message store updateMessage

Confidence Score: 5/5

Safe to merge — the primary formatting pipeline is correct, well-tested, and all remaining notes are low-risk P2 suggestions.

The core change (markdown → Telegram HTML with parse_mode=HTML) is implemented correctly end-to-end. The formatter handles escaping, chunking, and oversized-block fallback. The streaming removal simplifies the code while retaining typing indicators. All three comments are P2 style suggestions: a URL-scheme guard in renderLink (low risk in Telegram's non-JS context), an optional HTML-length secondary cap (likely unnecessary given the spec language), and a pre-existing inconsistency in the error-reply path in bot.ts. None of these block correct operation for the normal message-delivery path.

apps/api/src/telegram/formatting.ts (renderLink URL sanitization and chunk HTML-length consideration)

Important Files Changed

Filename Overview
apps/api/src/telegram/formatting.ts New markdown-to-Telegram-HTML renderer; covers all common AST node types with proper HTML escaping, graceful oversized-block fallback, and sequential chunk assembly. Minor: no URL-scheme guard in renderLink, and chunk sizing uses plainText length rather than HTML length.
apps/api/src/telegram/bot-api.ts Clean new TelegramBotApiClient that wraps the Bot API, invokes the formatter, and sends chunks sequentially with parse_mode=HTML. API surface and error handling are solid.
apps/api/src/bot.ts Switched sendReply and final reply to the new sender client; error-reply path still routes through the legacy thread.post() from the Chat SDK adapter — minor inconsistency but harmless for plain-text error strings.
apps/api/src/workflows/agent-execution.ts Streaming edits removed cleanly; sendReply and final message now route through the new TelegramBotApiClient. Typing indicators retained as intended.
packages/core/src/domain/channel-presentation.ts New shared ChannelPresentation contract; exhaustive switch over all Platform values with correct defaults for telegram-html transport and streaming flags.
packages/agent/src/context/builder.ts Adds a concurrent DB query for the conversation platform and passes ChannelPresentation to the prompt builder; defaults to 'telegram' if the row is missing.
packages/agent/src/specialists/prompts.ts Channel-specific formatting instructions injected into the system prompt based on ChannelPresentation; clear separation between transport format and streaming capability guards.
apps/api/src/telegram/formatting.test.ts Good regression coverage: bold/list rendering, raw HTML escaping, and chunk-splitting boundary. Test tolerances for split sizes are slightly loose but correct.
apps/mock/components/message-bubble.tsx HTML renderer using DOMParser with React hydration guard (isClient flag); safe against SSR mismatch and handles all Telegram HTML tags cleanly.
apps/mock/lib/message-store.ts Added parseMode field and updateMessage helper with correct immutable-style update; well-tested.

Sequence Diagram

sequenceDiagram
    participant User as Telegram User
    participant TG as Telegram Bot API
    participant Bot as bot.ts / agent-execution.ts
    participant Agent as AgentService
    participant Fmt as formatting.ts
    participant Client as TelegramBotApiClient

    User->>TG: sends message
    TG->>Bot: webhook update
    Bot->>TG: sendChatAction(typing)
    Bot->>Agent: handleMessage(conversationId, text)
    Note over Agent: System prompt includes ChannelPresentation rules
    Agent-->>Bot: sendReply(progressText) [optional]
    Bot->>Fmt: renderTelegramMessageChunks(progressText)
    Fmt-->>Bot: [{html, plainText}, ...]
    Bot->>Client: sendMessage(chatId, progressText)
    Client->>TG: POST sendMessage(parse_mode=HTML)
    Agent-->>Bot: response.userResponse.text
    Bot->>Fmt: renderTelegramMessageChunks(finalText)
    Fmt-->>Bot: [{html, plainText}, ...]
    Bot->>Client: sendMessage(chatId, finalText)
    Client->>TG: POST sendMessage(parse_mode=HTML) [per chunk]
    TG-->>User: formatted reply
Loading

Comments Outside Diff (1)

  1. apps/api/src/bot.ts, line 70-78 (link)

    P2 Error-reply still goes through the old thread.post() path

    Both sendReply and the final response now correctly use sender.sendMessage() (which renders markdown to HTML), but the catchAllCause error handler below still calls thread.post(...) from the Chat SDK adapter:

    yield* Effect.tryPromise(() =>
      thread.post("Sorry, something went wrong. Please try again."),
    )

    For a plain-text error string this makes no practical difference today, but it's an inconsistency — if the error message ever includes markdown (e.g. a formatted contact link), it would bypass the formatting pipeline. Consider routing this through sender.sendMessage(chatId, "Sorry…") for uniformity.

Reviews (1): Last reviewed commit: "Fix Telegram message formatting pipeline" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…eflect changes in analytics setup. Simplify production API logging section by removing unnecessary details.
…erational details. Expand on the personal assistant's functionality, including its cloud-native architecture, interoperability with AI services, and ambient hardware integration. Introduce principles guiding the design and outline a phased roadmap for future development. This update aims to clarify Amby's purpose and operational framework for users and developers alike.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Comment on lines +299 to +306
function renderLink(node: Link): RenderedBlock {
const content = renderInlineNodes(node.children as Content[])
const plainText = content.plainText || node.url
return {
html: `<a href="${escapeTelegramHtmlAttribute(node.url)}">${content.html || escapeTelegramHtml(node.url)}</a>`,
plainText,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No URL-scheme sanitization in renderLink

node.url is HTML-attribute-escaped but not sanitized for dangerous URL schemes such as javascript: or data:. While Telegram's native clients don't execute JavaScript, forwarding javascript: URLs into the Telegram Bot API could confuse some third-party clients or future web-based surfaces.

Consider stripping or escaping non-HTTP(S) schemes before inserting the URL:

Suggested change
function renderLink(node: Link): RenderedBlock {
const content = renderInlineNodes(node.children as Content[])
const plainText = content.plainText || node.url
return {
html: `<a href="${escapeTelegramHtmlAttribute(node.url)}">${content.html || escapeTelegramHtml(node.url)}</a>`,
plainText,
}
}
function renderLink(node: Link): RenderedBlock {
const content = renderInlineNodes(node.children as Content[])
const plainText = content.plainText || node.url
const safeUrl = /^https?:\/\//i.test(node.url) ? node.url : "#"
return {
html: `<a href="${escapeTelegramHtmlAttribute(safeUrl)}">${content.html || escapeTelegramHtml(node.url)}</a>`,
plainText,
}
}

Comment on lines +45 to +85
export function renderTelegramMessageChunks(
markdown: string,
maxTextLength = TELEGRAM_MAX_TEXT_LENGTH,
): TelegramRenderedChunk[] {
const ast = parseMarkdown(markdown)
const blocks = ast.children
.flatMap((node) => renderBlock(node))
.filter((block) => block.plainText.trim().length > 0 || block.html.trim().length > 0)

if (blocks.length === 0) {
return markdown.trim() ? [{ html: escapeTelegramHtml(markdown), plainText: markdown }] : []
}

const chunks: TelegramRenderedChunk[] = []
let current: RenderedBlock[] = []
let currentLength = 0

for (const block of blocks.flatMap((candidate) =>
splitOversizedBlock(candidate, maxTextLength),
)) {
const separatorLength = current.length > 0 ? 2 : 0
if (
current.length > 0 &&
currentLength + separatorLength + block.plainText.length > maxTextLength
) {
chunks.push(joinBlocks(current))
current = [block]
currentLength = block.plainText.length
continue
}

current.push(block)
currentLength += separatorLength + block.plainText.length
}

if (current.length > 0) {
chunks.push(joinBlocks(current))
}

return chunks
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Chunk splitting measures plainText.length, not html.length

The 4096-character ceiling is compared against block.plainText.length (the stripped text). Telegram's Bot API documentation describes the limit as "1-4096 characters after entities parsing," which most interpretations take to mean the rendered text — so this is likely correct.

However, if a chunk is dense with links or other inline formatting, the raw HTML string sent to Telegram could be substantially longer than plainText. If Telegram's server-side limit is applied to the raw text field before parsing, chunks near the boundary could be silently rejected. Adding an html.length guard (e.g., capping at TELEGRAM_MAX_TEXT_LENGTH * 2) would remove any ambiguity and protect against API errors for heavily formatted responses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant