Skip to content

Commit 38e7c96

Browse files
7418claude
andcommitted
feat: Telegram Bridge 出站消息 Markdown 渲染
新增三层 Markdown → Telegram HTML 渲染管线: markdown/ir.ts — markdown-it 解析 Markdown 为中间表示 (MarkdownIR) - 支持 bold/italic/strikethrough/code/code_block/blockquote/links/lists/headings/tables/hr - 表格使用 code-block 模式渲染为 ASCII 表格(<pre> 保留对齐) - HTML 内联 <br> 标签转换为换行符 markdown/render.ts — 通用标记渲染器 - boundary tracking + LIFO stack 处理嵌套样式 markdown/telegram.ts — Telegram 专用渲染器 - 样式映射到 Telegram HTML 标签 (b/i/s/code/pre+code/blockquote/a) - 文件引用保护:README.md/main.go 等不被误识别为 URL - render-first 分片:按渲染后 HTML 长度分片,超限时按比例重分割 delivery-layer.ts — 新增 deliverRendered() 接收预渲染 chunks - HTML + plain text 双通道,解析失败自动降级纯文本 - sendWithRetry 支持 plainFallback 参数 bridge-manager.ts — Claude 回复改用 markdownToTelegramChunks + deliverRendered - 命令响应和错误消息保持 escapeHtml + deliver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8526b58 commit 38e7c96

File tree

8 files changed

+1569
-14
lines changed

8 files changed

+1569
-14
lines changed

docs/handover/bridge-system.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ src/lib/bridge/
1515
├── permission-broker.ts # 权限请求转发到 IM 内联按钮,处理回调审批
1616
├── delivery-layer.ts # 出站消息分片、限流、重试退避、HTML 降级
1717
├── bridge-manager.ts # 生命周期编排,adapter 事件循环,/stop abort,命令路由
18+
├── markdown/
19+
│ ├── ir.ts # Markdown → IR 中间表示解析器(基于 markdown-it)
20+
│ ├── render.ts # IR → 格式化输出的通用标记渲染器
21+
│ └── telegram.ts # Telegram HTML 渲染 + 文件引用保护 + render-first 分片
1822
├── adapters/
1923
│ ├── index.ts # Adapter 目录文件(side-effect import 自注册所有 adapter)
2024
│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
@@ -43,7 +47,8 @@ Telegram 消息 → TelegramAdapter.pollLoop()
4347
→ text/tool_use/tool_result → 累积内容块
4448
→ result → 捕获 tokenUsage + sdkSessionId
4549
→ addMessage() 保存到 DB
46-
→ DeliveryLayer.deliver() → 分片 + 限流 + 发送到 Telegram
50+
→ markdownToTelegramChunks() → Markdown→IR→HTML render-first 分片
51+
→ DeliveryLayer.deliverRendered() → 限流 + HTML/plain 双通道发送到 Telegram
4752
→ finally: adapter.acknowledgeUpdate(updateId) → 推进 committedOffset 并持久化
4853
```
4954

@@ -93,6 +98,15 @@ PermissionBroker 在处理 IM 内联按钮回调时,验证 callbackData 中的
9398
**10. 图片消息 DB 格式统一**
9499
Bridge 和桌面端使用相同的消息存储格式:图片写入 `.codepilot-uploads/`,消息 content 以 `<!--files:[{id,name,type,size,filePath}]-->text` 格式保存。桌面 UI 的 `MessageItem.parseMessageFiles()` 解析后通过 `FileAttachmentDisplay` + `/api/uploads?path=` 渲染缩略图。`conversation-engine.ts``getSession()` 提前到文件持久化之前调用,确保 workingDirectory 可用。
95100

101+
**11. Telegram 出站 Markdown 渲染**
102+
Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i/s/code/pre+code/blockquote/a)。采用三层架构将 Markdown 转换为 Telegram HTML:
103+
104+
- **IR 层**`markdown/ir.ts`):使用 markdown-it 将 Markdown 解析为中间表示 `MarkdownIR = { text, styles[], links[] }`。text 是纯文本,styles 是 `{ start, end, style }` 区间标记。支持 bold/italic/strikethrough/code/code_block/blockquote/links/lists/headings/tables/hr。表格使用 code-block 模式渲染为 ASCII 表格(包裹在 `<pre><code>` 中保留对齐)。HTML 内联标签中的 `<br>` 被转换为换行符。
105+
- **渲染层**`markdown/render.ts`):通用标记渲染器 `renderMarkdownWithMarkers(ir, options)`,接受样式→标签映射表 + escapeText + buildLink 回调,输出格式化文本。使用 boundary tracking + LIFO stack 处理嵌套。
106+
- **Telegram 层**`markdown/telegram.ts`):组合 IR+渲染器,映射样式到 Telegram HTML 标签。`wrapFileReferencesInHtml()` 防止 `README.md``main.go` 等文件名被 Telegram linkify 误识别为 URL(用 `<code>` 包裹)。`markdownToTelegramChunks(text, limit)` 实现 render-first 分片:先按 IR text 长度分块,再渲染每块为 HTML,若 HTML 超出 4096 限制则按比例重新分割。
107+
108+
`bridge-manager.ts` 对 Claude 回复调用 `markdownToTelegramChunks()`,通过 `deliverRendered()` 发送预渲染 chunks(每个 chunk 包含 html + text 双通道,HTML 解析失败自动降级纯文本)。命令响应和错误消息仍使用 `escapeHtml()` + `deliver()`
109+
96110
## 设置项(settings 表)
97111

98112
| Key | 说明 |

package-lock.json

Lines changed: 88 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.21.2",
3+
"version": "0.22.0",
44
"private": true,
55
"author": {
66
"name": "op7418",
@@ -42,6 +42,7 @@
4242
"cmdk": "^1.1.1",
4343
"electron-updater": "^6.8.3",
4444
"lucide-react": "^0.563.0",
45+
"markdown-it": "^14.1.1",
4546
"motion": "^12.33.0",
4647
"nanoid": "^5.1.6",
4748
"next": "16.1.6",
@@ -64,6 +65,7 @@
6465
"@playwright/test": "^1.58.1",
6566
"@tailwindcss/postcss": "^4",
6667
"@types/better-sqlite3": "^7.6.13",
68+
"@types/markdown-it": "^14.1.2",
6769
"@types/node": "^20",
6870
"@types/react": "^19",
6971
"@types/react-dom": "^19",

src/lib/bridge/bridge-manager.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import './adapters';
1515
import * as router from './channel-router';
1616
import * as engine from './conversation-engine';
1717
import * as broker from './permission-broker';
18-
import { deliver } from './delivery-layer';
18+
import { deliver, deliverRendered } from './delivery-layer';
19+
import { markdownToTelegramChunks } from './markdown/telegram';
1920
import { getSetting, insertAuditLog, updateChannelBinding } from '../db';
2021
import { setBridgeModeActive } from '../telegram-bot';
2122
import { escapeHtml } from './adapters/telegram-utils';
@@ -382,16 +383,14 @@ async function handleMessage(
382383
);
383384
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined);
384385

385-
// Send response text
386+
// Send response text — render Markdown to Telegram HTML
386387
if (result.responseText) {
387-
const response: OutboundMessage = {
388-
address: msg.address,
389-
text: escapeHtml(result.responseText),
390-
parseMode: 'HTML',
391-
};
392-
await deliver(adapter, response, {
393-
sessionId: binding.codepilotSessionId,
394-
});
388+
const chunks = markdownToTelegramChunks(result.responseText, 4096);
389+
if (chunks.length > 0) {
390+
await deliverRendered(adapter, msg.address, chunks, {
391+
sessionId: binding.codepilotSessionId,
392+
});
393+
}
395394
} else if (result.hasError) {
396395
const errorResponse: OutboundMessage = {
397396
address: msg.address,

src/lib/bridge/delivery-layer.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import type {
77
ChannelType,
8+
ChannelAddress,
89
OutboundMessage,
910
SendResult,
1011
PLATFORM_LIMITS,
1112
} from './types';
13+
import type { TelegramChunk } from './markdown/telegram';
1214
import { PLATFORM_LIMITS as limits } from './types';
1315
import type { BaseChannelAdapter } from './channel-adapter';
1416
import {
@@ -222,6 +224,7 @@ export async function deliver(
222224
async function sendWithRetry(
223225
adapter: BaseChannelAdapter,
224226
message: OutboundMessage,
227+
plainFallback?: string,
225228
): Promise<SendResult> {
226229
let lastError: string | undefined;
227230

@@ -234,8 +237,10 @@ async function sendWithRetry(
234237

235238
// HTML parse error: immediately fallback to plain text (no retry needed)
236239
if (category === 'parse_error' && message.parseMode === 'HTML') {
240+
const fallbackText = plainFallback || message.text;
237241
const plainResult = await adapter.send({
238242
...message,
243+
text: fallbackText,
239244
parseMode: 'plain',
240245
});
241246
if (plainResult.ok) return plainResult;
@@ -260,3 +265,73 @@ async function sendWithRetry(
260265

261266
return { ok: false, error: lastError || 'Max retries exceeded' };
262267
}
268+
269+
/**
270+
* Deliver pre-rendered chunks (from Markdown renderer).
271+
* Each chunk already has HTML and plain text fallback.
272+
*/
273+
export async function deliverRendered(
274+
adapter: BaseChannelAdapter,
275+
address: ChannelAddress,
276+
chunks: TelegramChunk[],
277+
opts?: { sessionId?: string; dedupKey?: string },
278+
): Promise<SendResult> {
279+
// Dedup check
280+
if (opts?.dedupKey) {
281+
if (checkDedup(opts.dedupKey)) {
282+
return { ok: true, messageId: undefined };
283+
}
284+
}
285+
if (Math.random() < 0.01) {
286+
try { cleanupExpiredDedup(); } catch { /* best effort */ }
287+
}
288+
289+
let lastMessageId: string | undefined;
290+
291+
for (let i = 0; i < chunks.length; i++) {
292+
await rateLimiter.acquire(address.chatId);
293+
if (i > 0) {
294+
await new Promise(r => setTimeout(r, INTER_CHUNK_DELAY_MS));
295+
}
296+
297+
const chunk = chunks[i];
298+
const htmlMessage: OutboundMessage = {
299+
address,
300+
text: chunk.html,
301+
parseMode: 'HTML',
302+
};
303+
304+
// Try HTML first, fall back to plain text on parse error
305+
const result = await sendWithRetry(adapter, htmlMessage, chunk.text);
306+
if (!result.ok) return result;
307+
lastMessageId = result.messageId;
308+
309+
if (result.messageId && opts?.sessionId) {
310+
try {
311+
insertOutboundRef({
312+
channelType: adapter.channelType,
313+
chatId: address.chatId,
314+
codepilotSessionId: opts.sessionId,
315+
platformMessageId: result.messageId,
316+
purpose: 'response',
317+
});
318+
} catch { /* best effort */ }
319+
}
320+
}
321+
322+
if (opts?.dedupKey) {
323+
try { insertDedup(opts.dedupKey); } catch { /* best effort */ }
324+
}
325+
326+
try {
327+
insertAuditLog({
328+
channelType: adapter.channelType,
329+
chatId: address.chatId,
330+
direction: 'outbound',
331+
messageId: lastMessageId || '',
332+
summary: chunks.map(c => c.text).join('').slice(0, 200),
333+
});
334+
} catch { /* best effort */ }
335+
336+
return { ok: true, messageId: lastMessageId };
337+
}

0 commit comments

Comments
 (0)