@foxden-app/foxclaw 0.5.32 → 0.5.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/channels/bridge_messaging_router.d.ts +3 -0
- package/dist/channels/bridge_messaging_router.js +18 -0
- package/dist/channels/telegram/telegram_messaging_port.d.ts +3 -0
- package/dist/channels/telegram/telegram_messaging_port.js +13 -0
- package/dist/controller/controller.d.ts +7 -0
- package/dist/controller/controller.js +201 -17
- package/dist/controller/presentation.js +1 -6
- package/dist/i18n.d.ts +2 -0
- package/dist/i18n.js +3 -0
- package/dist/telegram/gateway.d.ts +10 -0
- package/dist/telegram/gateway.js +34 -0
- package/dist/telegram/html.d.ts +8 -0
- package/dist/telegram/html.js +31 -0
- package/dist/telegram/rich.d.ts +13 -0
- package/dist/telegram/rich.js +12 -0
- package/dist/telegram/rich_markdown.d.ts +2 -0
- package/dist/telegram/rich_markdown.js +115 -0
- package/docs/telegram-rich-messages.md +99 -0
- package/docs/user-manual.md +4 -0
- package/docs/zh/telegram-rich-messages.md +125 -0
- package/docs/zh/user-manual.md +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.34 - 2026-06-17
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 普通 Codex 输出接入 Telegram RichMessage:流式阶段继续稳定发送纯文本,segment 完成后自动把已发消息编辑为富文本,支持标题、列表、引用、代码块、行内 code、粗体和安全链接。
|
|
9
|
+
- draft 流式路径优先使用官方 `sendRichMessageDraft`,发送失败时对当前 turn 自动熔断回原纯文本 draft,避免影响实时输出。
|
|
10
|
+
- 新增 Markdown 到 RichMessage HTML 的安全转换与回归测试,保留 Telegram/微信 fallback:RichMessage 失败时不会破坏原纯文本输出,微信继续使用原 HTML/plain 降级通道。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- Wired normal Codex output into Telegram RichMessage: live streaming still sends stable plain text, then completed segments are edited into rich text with headings, lists, quotes, fenced code, inline code, bold text, and safe links.
|
|
14
|
+
- Draft streaming now prefers the official `sendRichMessageDraft` API and automatically disables rich drafts for the current turn if it fails, preserving the previous plain draft behavior.
|
|
15
|
+
- Added a safe Markdown-to-RichMessage HTML renderer with regression coverage while preserving Telegram/Weixin fallbacks so RichMessage failures do not break existing output.
|
|
16
|
+
|
|
17
|
+
## 0.5.33 - 2026-06-16
|
|
18
|
+
|
|
19
|
+
### 中文
|
|
20
|
+
- 新增 Telegram Rich Message 适配专项盘点,明确现有 HTML 通道、Bot API 10.1 rich message 能力、FoxClaw 可用功能面和分阶段接入路线。
|
|
21
|
+
- 接入 `sendRichMessage` / rich HTML 发送链路,新增 `/rich` 诊断命令用于在 Telegram 客户端直接查看 heading、table、details、pre/code、list 的 RichMessage 渲染效果。
|
|
22
|
+
- 集中 Telegram HTML 转义与常用标签 helper,并把 `/diff` 改为优先使用 RichMessage details + diff code block;发送失败时回退到原 Telegram HTML 折叠展示。
|
|
23
|
+
|
|
24
|
+
### English
|
|
25
|
+
- Added a Telegram Rich Message adaptation check covering the current HTML path, Bot API 10.1 rich message capabilities, FoxClaw candidate surfaces, and a phased rollout plan.
|
|
26
|
+
- Wired the `sendRichMessage` / rich HTML send path and added `/rich` as a diagnostic command for checking heading, table, details, pre/code, and list rendering in Telegram clients.
|
|
27
|
+
- Centralized Telegram HTML escaping/tag helpers and changed `/diff` to prefer RichMessage details plus a diff code block, falling back to the previous Telegram HTML collapsible rendering if rich sending fails.
|
|
28
|
+
|
|
5
29
|
## 0.5.32 - 2026-06-11
|
|
6
30
|
|
|
7
31
|
### 中文
|
|
@@ -15,12 +15,15 @@ export declare class BridgeMessagingRouter {
|
|
|
15
15
|
private requireWeixinTransport;
|
|
16
16
|
sendPlain(scopeId: string, text: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
17
17
|
sendHtml(scopeId: string, text: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
18
|
+
sendRichHtml(scopeId: string, html: string, fallbackHtml: string, keyboard?: InlineKeyboard): Promise<number>;
|
|
18
19
|
editPlain(scopeId: string, messageId: number, text: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
19
20
|
editHtml(scopeId: string, messageId: number, text: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
21
|
+
editRichHtml(scopeId: string, messageId: number, html: string, fallbackHtml: string, keyboard?: InlineKeyboard): Promise<void>;
|
|
20
22
|
deleteMessage(scopeId: string, messageId: number): Promise<void>;
|
|
21
23
|
sendTypingInScope(scopeId: string): Promise<void>;
|
|
22
24
|
clearInlineKeyboard(scopeId: string, messageId: number): Promise<void>;
|
|
23
25
|
sendDraft(scopeId: string, draftId: number, text: string): Promise<void>;
|
|
26
|
+
sendRichDraft(scopeId: string, draftId: number, html: string, fallbackText: string): Promise<void>;
|
|
24
27
|
answerCallback(callbackQueryId: string, text: string): Promise<void>;
|
|
25
28
|
getFile(fileId: string): Promise<TelegramRemoteFile>;
|
|
26
29
|
downloadResolvedFile(remoteFilePath: string, destinationPath: string): Promise<number>;
|
|
@@ -37,6 +37,12 @@ export class BridgeMessagingRouter {
|
|
|
37
37
|
}
|
|
38
38
|
return this.telegram.sendHtml(scopeId, text, keyboard);
|
|
39
39
|
}
|
|
40
|
+
sendRichHtml(scopeId, html, fallbackHtml, keyboard) {
|
|
41
|
+
if (this.isWeixinScope(scopeId)) {
|
|
42
|
+
return this.requireWeixinTransport(scopeId).sendHtml(scopeId, fallbackHtml, keyboard);
|
|
43
|
+
}
|
|
44
|
+
return this.telegram.sendRichHtml(scopeId, html, keyboard);
|
|
45
|
+
}
|
|
40
46
|
editPlain(scopeId, messageId, text, keyboard) {
|
|
41
47
|
if (this.isWeixinScope(scopeId)) {
|
|
42
48
|
return this.requireWeixinTransport(scopeId).editPlain(scopeId, messageId, text, keyboard);
|
|
@@ -49,6 +55,12 @@ export class BridgeMessagingRouter {
|
|
|
49
55
|
}
|
|
50
56
|
return this.telegram.editHtml(scopeId, messageId, text, keyboard);
|
|
51
57
|
}
|
|
58
|
+
editRichHtml(scopeId, messageId, html, fallbackHtml, keyboard) {
|
|
59
|
+
if (this.isWeixinScope(scopeId)) {
|
|
60
|
+
return this.requireWeixinTransport(scopeId).editHtml(scopeId, messageId, fallbackHtml, keyboard);
|
|
61
|
+
}
|
|
62
|
+
return this.telegram.editRichHtml(scopeId, messageId, html, keyboard);
|
|
63
|
+
}
|
|
52
64
|
deleteMessage(scopeId, messageId) {
|
|
53
65
|
if (this.isWeixinScope(scopeId)) {
|
|
54
66
|
return this.requireWeixinTransport(scopeId).deleteMessage(scopeId, messageId);
|
|
@@ -73,6 +85,12 @@ export class BridgeMessagingRouter {
|
|
|
73
85
|
}
|
|
74
86
|
return this.telegram.sendDraft(scopeId, draftId, text);
|
|
75
87
|
}
|
|
88
|
+
sendRichDraft(scopeId, draftId, html, fallbackText) {
|
|
89
|
+
if (this.isWeixinScope(scopeId)) {
|
|
90
|
+
return this.requireWeixinTransport(scopeId).sendDraft(scopeId, draftId, fallbackText);
|
|
91
|
+
}
|
|
92
|
+
return this.telegram.sendRichDraft(scopeId, draftId, html);
|
|
93
|
+
}
|
|
76
94
|
answerCallback(callbackQueryId, text) {
|
|
77
95
|
return this.telegram.answerCallback(callbackQueryId, text);
|
|
78
96
|
}
|
|
@@ -13,12 +13,15 @@ export declare class TelegramMessagingPort implements ChannelPort {
|
|
|
13
13
|
constructor(gateway: TelegramGateway);
|
|
14
14
|
sendPlain(bridgeScopeId: string, text: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
15
15
|
sendHtml(bridgeScopeId: string, text: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
16
|
+
sendRichHtml(bridgeScopeId: string, html: string, inlineKeyboard?: InlineKeyboard): Promise<number>;
|
|
16
17
|
editPlain(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
17
18
|
editHtml(bridgeScopeId: string, messageId: number, text: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
19
|
+
editRichHtml(bridgeScopeId: string, messageId: number, html: string, inlineKeyboard?: InlineKeyboard): Promise<void>;
|
|
18
20
|
deleteMessage(bridgeScopeId: string, messageId: number): Promise<void>;
|
|
19
21
|
sendTypingInScope(bridgeScopeId: string): Promise<void>;
|
|
20
22
|
clearInlineKeyboard(bridgeScopeId: string, messageId: number): Promise<void>;
|
|
21
23
|
sendDraft(bridgeScopeId: string, draftId: number, text: string): Promise<void>;
|
|
24
|
+
sendRichDraft(bridgeScopeId: string, draftId: number, html: string): Promise<void>;
|
|
22
25
|
answerCallback(callbackQueryId: string, text: string): Promise<void>;
|
|
23
26
|
getFile(fileId: string): Promise<TelegramRemoteFile>;
|
|
24
27
|
downloadResolvedFile(remoteFilePath: string, destinationPath: string): Promise<number>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseTelegramTargetFromBridgeScope } from '../../core/bridge_scope.js';
|
|
2
|
+
import { telegramRichHtml } from '../../telegram/rich.js';
|
|
2
3
|
/**
|
|
3
4
|
* Telegram outbound operations addressed by bridge scope id (`telegram:…`).
|
|
4
5
|
*/
|
|
@@ -15,6 +16,10 @@ export class TelegramMessagingPort {
|
|
|
15
16
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
16
17
|
return this.gateway.sendHtmlMessage(target.chatId, text, inlineKeyboard, target.topicId);
|
|
17
18
|
}
|
|
19
|
+
async sendRichHtml(bridgeScopeId, html, inlineKeyboard) {
|
|
20
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
21
|
+
return this.gateway.sendRichMessage(target.chatId, telegramRichHtml(html, { skipEntityDetection: true }), inlineKeyboard, target.topicId);
|
|
22
|
+
}
|
|
18
23
|
async editPlain(bridgeScopeId, messageId, text, inlineKeyboard) {
|
|
19
24
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
20
25
|
await this.gateway.editMessage(target.chatId, messageId, text, inlineKeyboard);
|
|
@@ -23,6 +28,10 @@ export class TelegramMessagingPort {
|
|
|
23
28
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
24
29
|
await this.gateway.editHtmlMessage(target.chatId, messageId, text, inlineKeyboard);
|
|
25
30
|
}
|
|
31
|
+
async editRichHtml(bridgeScopeId, messageId, html, inlineKeyboard) {
|
|
32
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
33
|
+
await this.gateway.editRichMessage(target.chatId, messageId, telegramRichHtml(html, { skipEntityDetection: true }), inlineKeyboard);
|
|
34
|
+
}
|
|
26
35
|
async deleteMessage(bridgeScopeId, messageId) {
|
|
27
36
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
28
37
|
await this.gateway.deleteMessage(target.chatId, messageId);
|
|
@@ -39,6 +48,10 @@ export class TelegramMessagingPort {
|
|
|
39
48
|
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
40
49
|
await this.gateway.sendMessageDraft(target.chatId, draftId, text, target.topicId);
|
|
41
50
|
}
|
|
51
|
+
async sendRichDraft(bridgeScopeId, draftId, html) {
|
|
52
|
+
const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
|
|
53
|
+
await this.gateway.sendRichMessageDraft(target.chatId, draftId, telegramRichHtml(html, { skipEntityDetection: true }), target.topicId);
|
|
54
|
+
}
|
|
42
55
|
answerCallback(callbackQueryId, text) {
|
|
43
56
|
return this.gateway.answerCallback(callbackQueryId, text);
|
|
44
57
|
}
|
|
@@ -227,8 +227,11 @@ export declare class BridgeSessionCore {
|
|
|
227
227
|
private updateStatus;
|
|
228
228
|
private sendMessage;
|
|
229
229
|
private sendHtmlMessage;
|
|
230
|
+
private sendRichHtmlMessage;
|
|
231
|
+
private sendRichMarkdownMessage;
|
|
230
232
|
private editMessage;
|
|
231
233
|
private editHtmlMessage;
|
|
234
|
+
private editRichHtmlMessage;
|
|
232
235
|
private deleteMessage;
|
|
233
236
|
private sendTyping;
|
|
234
237
|
private sendObservedCliUserMessage;
|
|
@@ -318,6 +321,7 @@ export declare class BridgeSessionCore {
|
|
|
318
321
|
private handleArchiveCommand;
|
|
319
322
|
private handleUnarchiveCommand;
|
|
320
323
|
private handleReviewCommand;
|
|
324
|
+
private handleRichCommand;
|
|
321
325
|
private handleDiffCommand;
|
|
322
326
|
private handleLoadedCommand;
|
|
323
327
|
private handleSkillsCommand;
|
|
@@ -436,6 +440,7 @@ export declare class BridgeSessionCore {
|
|
|
436
440
|
private forgetPreviewRecord;
|
|
437
441
|
private clearMessageButtons;
|
|
438
442
|
private sendDraft;
|
|
443
|
+
private sendRichDraft;
|
|
439
444
|
private renderActiveStatus;
|
|
440
445
|
private dismissTurnPreview;
|
|
441
446
|
private ensureStatusMessage;
|
|
@@ -456,5 +461,7 @@ export declare class BridgeSessionCore {
|
|
|
456
461
|
private findActiveTurnByThreadId;
|
|
457
462
|
private findActiveTurnsByThreadId;
|
|
458
463
|
private syncSegmentTimeline;
|
|
464
|
+
private shouldPromoteSegmentToRich;
|
|
465
|
+
private promoteSegmentMessagesToRich;
|
|
459
466
|
}
|
|
460
467
|
export { BridgeSessionCore as BridgeController };
|
|
@@ -9,6 +9,9 @@ import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPane
|
|
|
9
9
|
import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
|
|
10
10
|
import { TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, buildAttachmentPrompt, isNativeImageAttachment, planAttachmentStoragePath, summarizeTelegramInput, } from '../telegram/media.js';
|
|
11
11
|
import { TELEGRAM_MESSAGE_LIMIT, chunkTelegramMessage, chunkTelegramStreamMessage, clipTelegramDraftMessage, } from '../telegram/text.js';
|
|
12
|
+
import { escapeTelegramHtml, telegramBold, telegramDetails, telegramExpandableBlockquote, telegramPre, telegramPreCode, } from '../telegram/html.js';
|
|
13
|
+
import { TELEGRAM_RICH_MESSAGE_TEXT_LIMIT } from '../telegram/rich.js';
|
|
14
|
+
import { renderTelegramMarkdownRichHtml } from '../telegram/rich_markdown.js';
|
|
12
15
|
import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
|
|
13
16
|
import { BRIDGE_SCOPE_WEIXIN_PREFIX, parseTelegramTargetFromBridgeScope, parseWeixinBridgeScope } from '../core/bridge_scope.js';
|
|
14
17
|
import { resolveTelegramRenderRoute } from '../telegram/rendering.js';
|
|
@@ -50,6 +53,7 @@ const PINNED_HELP_COMMANDS = [
|
|
|
50
53
|
const DYNAMIC_HELP_COMMANDS = [
|
|
51
54
|
{ key: 'fast', line: '/fast <on|off|toggle>' },
|
|
52
55
|
{ key: 'active', line: '/active <steer|queue>' },
|
|
56
|
+
{ key: 'rich', line: '/rich' },
|
|
53
57
|
{ key: 'account', line: '/account' },
|
|
54
58
|
{ key: 'quota', line: '/quota' },
|
|
55
59
|
{ key: 'update', line: '/update' },
|
|
@@ -717,6 +721,10 @@ export class BridgeSessionCore {
|
|
|
717
721
|
await this.handleReviewCommand(event, locale, args);
|
|
718
722
|
return;
|
|
719
723
|
}
|
|
724
|
+
case 'rich': {
|
|
725
|
+
await this.handleRichCommand(scopeId, locale);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
720
728
|
case 'diff': {
|
|
721
729
|
await this.handleDiffCommand(scopeId, locale);
|
|
722
730
|
return;
|
|
@@ -2923,6 +2931,7 @@ export class BridgeSessionCore {
|
|
|
2923
2931
|
previewActive: previewMessageId > 0,
|
|
2924
2932
|
draftId: null,
|
|
2925
2933
|
draftText: null,
|
|
2934
|
+
richDraftDisabled: false,
|
|
2926
2935
|
buffer: '',
|
|
2927
2936
|
finalText: null,
|
|
2928
2937
|
interruptRequested: false,
|
|
@@ -3126,7 +3135,7 @@ export class BridgeSessionCore {
|
|
|
3126
3135
|
const fallbackKey = active.interruptRequested ? 'interrupted' : 'completed';
|
|
3127
3136
|
const finalChunks = chunkTelegramMessage(active.finalText || active.buffer, undefined, t(locale, fallbackKey));
|
|
3128
3137
|
for (const chunk of finalChunks) {
|
|
3129
|
-
await this.
|
|
3138
|
+
await this.sendRichMarkdownMessage(active.scopeId, chunk);
|
|
3130
3139
|
}
|
|
3131
3140
|
}
|
|
3132
3141
|
shouldMarkPartialOutput = active.interruptRequested
|
|
@@ -3356,12 +3365,33 @@ export class BridgeSessionCore {
|
|
|
3356
3365
|
async sendHtmlMessage(scopeId, text, inlineKeyboard) {
|
|
3357
3366
|
return this.messaging.sendHtml(scopeId, text, inlineKeyboard);
|
|
3358
3367
|
}
|
|
3368
|
+
async sendRichHtmlMessage(scopeId, html, fallbackHtml, inlineKeyboard) {
|
|
3369
|
+
try {
|
|
3370
|
+
return await this.messaging.sendRichHtml(scopeId, html, fallbackHtml, inlineKeyboard);
|
|
3371
|
+
}
|
|
3372
|
+
catch (error) {
|
|
3373
|
+
this.logger.warn('telegram.rich_message_send_failed', { scopeId, error: toErrorMeta(error) });
|
|
3374
|
+
return this.sendHtmlMessage(scopeId, fallbackHtml, inlineKeyboard);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
async sendRichMarkdownMessage(scopeId, text, inlineKeyboard) {
|
|
3378
|
+
return this.sendRichHtmlMessage(scopeId, renderTelegramMarkdownRichHtml(text), escapeTelegramHtml(text), inlineKeyboard);
|
|
3379
|
+
}
|
|
3359
3380
|
async editMessage(scopeId, messageId, text, inlineKeyboard) {
|
|
3360
3381
|
await this.messaging.editPlain(scopeId, messageId, text, inlineKeyboard);
|
|
3361
3382
|
}
|
|
3362
3383
|
async editHtmlMessage(scopeId, messageId, text, inlineKeyboard) {
|
|
3363
3384
|
await this.messaging.editHtml(scopeId, messageId, text, inlineKeyboard);
|
|
3364
3385
|
}
|
|
3386
|
+
async editRichHtmlMessage(scopeId, messageId, html, fallbackHtml, inlineKeyboard) {
|
|
3387
|
+
try {
|
|
3388
|
+
await this.messaging.editRichHtml(scopeId, messageId, html, fallbackHtml, inlineKeyboard);
|
|
3389
|
+
}
|
|
3390
|
+
catch (error) {
|
|
3391
|
+
this.logger.warn('telegram.rich_message_edit_failed', { scopeId, messageId, error: toErrorMeta(error) });
|
|
3392
|
+
await this.editHtmlMessage(scopeId, messageId, fallbackHtml, inlineKeyboard);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3365
3395
|
async deleteMessage(scopeId, messageId) {
|
|
3366
3396
|
await this.messaging.deleteMessage(scopeId, messageId);
|
|
3367
3397
|
}
|
|
@@ -3373,8 +3403,8 @@ export class BridgeSessionCore {
|
|
|
3373
3403
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
3374
3404
|
const chunk = chunks[index];
|
|
3375
3405
|
const body = index === 0
|
|
3376
|
-
?
|
|
3377
|
-
:
|
|
3406
|
+
? `${telegramBold(OBSERVED_CLI_USER_LABEL)}\n${telegramPre(chunk)}`
|
|
3407
|
+
: telegramPre(chunk);
|
|
3378
3408
|
await this.sendHtmlMessage(scopeId, body);
|
|
3379
3409
|
}
|
|
3380
3410
|
}
|
|
@@ -5052,13 +5082,16 @@ export class BridgeSessionCore {
|
|
|
5052
5082
|
await this.registerActiveTurn(event.scopeId, event.chatId, event.chatType, event.topicId, result.reviewThreadId, result.turnId, 0);
|
|
5053
5083
|
}
|
|
5054
5084
|
}
|
|
5085
|
+
async handleRichCommand(scopeId, locale) {
|
|
5086
|
+
await this.sendRichHtmlMessage(scopeId, formatRichDemoMessage(locale), formatRichDemoFallbackMessage(locale));
|
|
5087
|
+
}
|
|
5055
5088
|
async handleDiffCommand(scopeId, locale) {
|
|
5056
5089
|
const diff = this.latestTurnDiffs.get(scopeId);
|
|
5057
5090
|
if (!diff?.diff.trim()) {
|
|
5058
5091
|
await this.sendMessage(scopeId, t(locale, 'diff_unavailable'));
|
|
5059
5092
|
return;
|
|
5060
5093
|
}
|
|
5061
|
-
await this.
|
|
5094
|
+
await this.sendRichHtmlMessage(scopeId, formatRichDiffMessage(locale, diff.diff), formatDiffMessage(locale, diff.diff));
|
|
5062
5095
|
}
|
|
5063
5096
|
async handleLoadedCommand(scopeId, locale) {
|
|
5064
5097
|
const threadIds = await this.app.listLoadedThreads();
|
|
@@ -7591,6 +7624,9 @@ export class BridgeSessionCore {
|
|
|
7591
7624
|
async sendDraft(scopeId, draftId, text) {
|
|
7592
7625
|
await this.messaging.sendDraft(scopeId, draftId, text);
|
|
7593
7626
|
}
|
|
7627
|
+
async sendRichDraft(scopeId, draftId, html, fallbackText) {
|
|
7628
|
+
await this.messaging.sendRichDraft(scopeId, draftId, html, fallbackText);
|
|
7629
|
+
}
|
|
7594
7630
|
renderActiveStatus(active) {
|
|
7595
7631
|
const locale = this.localeForChat(active.scopeId);
|
|
7596
7632
|
return renderActiveTurnStatus(locale, {
|
|
@@ -7836,12 +7872,34 @@ export class BridgeSessionCore {
|
|
|
7836
7872
|
active.draftId = crypto.randomInt(1, 2_147_483_647);
|
|
7837
7873
|
}
|
|
7838
7874
|
try {
|
|
7839
|
-
|
|
7875
|
+
if (active.richDraftDisabled) {
|
|
7876
|
+
await this.sendDraft(active.scopeId, active.draftId, draftText);
|
|
7877
|
+
}
|
|
7878
|
+
else {
|
|
7879
|
+
await this.sendRichDraft(active.scopeId, active.draftId, renderTelegramMarkdownRichHtml(draftText), draftText);
|
|
7880
|
+
}
|
|
7840
7881
|
active.draftText = draftText;
|
|
7841
7882
|
}
|
|
7842
7883
|
catch (error) {
|
|
7884
|
+
let finalError = error;
|
|
7885
|
+
if (!active.richDraftDisabled) {
|
|
7886
|
+
active.richDraftDisabled = true;
|
|
7887
|
+
this.logger.warn('telegram.rich_draft_send_failed', {
|
|
7888
|
+
error: String(finalError),
|
|
7889
|
+
turnId: active.turnId,
|
|
7890
|
+
draftId: active.draftId,
|
|
7891
|
+
});
|
|
7892
|
+
try {
|
|
7893
|
+
await this.sendDraft(active.scopeId, active.draftId, draftText);
|
|
7894
|
+
active.draftText = draftText;
|
|
7895
|
+
return;
|
|
7896
|
+
}
|
|
7897
|
+
catch (fallbackError) {
|
|
7898
|
+
finalError = fallbackError;
|
|
7899
|
+
}
|
|
7900
|
+
}
|
|
7843
7901
|
this.logger.warn('telegram.draft_send_failed', {
|
|
7844
|
-
error: String(
|
|
7902
|
+
error: String(finalError),
|
|
7845
7903
|
turnId: active.turnId,
|
|
7846
7904
|
draftId: active.draftId,
|
|
7847
7905
|
});
|
|
@@ -7876,7 +7934,7 @@ export class BridgeSessionCore {
|
|
|
7876
7934
|
if (!existing) {
|
|
7877
7935
|
try {
|
|
7878
7936
|
const messageId = await this.sendMessage(active.scopeId, chunk);
|
|
7879
|
-
segment.messages.push({ messageId, text: chunk });
|
|
7937
|
+
segment.messages.push({ messageId, text: chunk, richHtml: null, richFailedForText: null });
|
|
7880
7938
|
active.statusNeedsRebase = true;
|
|
7881
7939
|
}
|
|
7882
7940
|
catch (error) {
|
|
@@ -7899,6 +7957,8 @@ export class BridgeSessionCore {
|
|
|
7899
7957
|
try {
|
|
7900
7958
|
await this.editMessage(active.scopeId, existing.messageId, chunk);
|
|
7901
7959
|
existing.text = chunk;
|
|
7960
|
+
existing.richHtml = null;
|
|
7961
|
+
existing.richFailedForText = null;
|
|
7902
7962
|
index += 1;
|
|
7903
7963
|
}
|
|
7904
7964
|
catch (error) {
|
|
@@ -7936,6 +7996,47 @@ export class BridgeSessionCore {
|
|
|
7936
7996
|
}
|
|
7937
7997
|
}
|
|
7938
7998
|
}
|
|
7999
|
+
if (this.shouldPromoteSegmentToRich(active, segment)) {
|
|
8000
|
+
await this.promoteSegmentMessagesToRich(active, segment, chunks);
|
|
8001
|
+
}
|
|
8002
|
+
}
|
|
8003
|
+
shouldPromoteSegmentToRich(active, segment) {
|
|
8004
|
+
return !active.scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)
|
|
8005
|
+
&& segment.completed
|
|
8006
|
+
&& segment.outputKind !== 'error'
|
|
8007
|
+
&& Boolean(segment.text.trim());
|
|
8008
|
+
}
|
|
8009
|
+
async promoteSegmentMessagesToRich(active, segment, chunks) {
|
|
8010
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
8011
|
+
const chunk = chunks[index];
|
|
8012
|
+
const existing = segment.messages[index];
|
|
8013
|
+
if (!existing || !chunk.trim()) {
|
|
8014
|
+
continue;
|
|
8015
|
+
}
|
|
8016
|
+
const richHtml = renderTelegramMarkdownRichHtml(chunk);
|
|
8017
|
+
if (existing.richHtml === richHtml || existing.richFailedForText === chunk) {
|
|
8018
|
+
continue;
|
|
8019
|
+
}
|
|
8020
|
+
try {
|
|
8021
|
+
await this.messaging.editRichHtml(active.scopeId, existing.messageId, richHtml, escapeTelegramHtml(chunk));
|
|
8022
|
+
existing.richHtml = richHtml;
|
|
8023
|
+
existing.richFailedForText = null;
|
|
8024
|
+
}
|
|
8025
|
+
catch (error) {
|
|
8026
|
+
if (isTelegramMessageGone(error)) {
|
|
8027
|
+
segment.messages.splice(index);
|
|
8028
|
+
return;
|
|
8029
|
+
}
|
|
8030
|
+
existing.richFailedForText = chunk;
|
|
8031
|
+
this.logger.warn('telegram.stream_rich_edit_failed', {
|
|
8032
|
+
error: String(error),
|
|
8033
|
+
turnId: active.turnId,
|
|
8034
|
+
itemId: segment.itemId,
|
|
8035
|
+
messageId: existing.messageId,
|
|
8036
|
+
chunkIndex: index,
|
|
8037
|
+
});
|
|
8038
|
+
}
|
|
8039
|
+
}
|
|
7939
8040
|
}
|
|
7940
8041
|
}
|
|
7941
8042
|
function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
|
|
@@ -7990,10 +8091,9 @@ function renderArchivedToolBatchStatus(locale, counts, actionLines) {
|
|
|
7990
8091
|
return { text, html: null };
|
|
7991
8092
|
}
|
|
7992
8093
|
const heading = formatToolBatchHeading(locale, counts, false);
|
|
7993
|
-
const detailLines = actionLines.slice(0, 12).map(line => escapeTelegramHtml(line));
|
|
7994
8094
|
const html = [
|
|
7995
|
-
|
|
7996
|
-
|
|
8095
|
+
telegramBold(heading),
|
|
8096
|
+
telegramExpandableBlockquote(actionLines.slice(0, 12).join('\n')),
|
|
7997
8097
|
].join('\n');
|
|
7998
8098
|
return { text, html };
|
|
7999
8099
|
}
|
|
@@ -8142,12 +8242,6 @@ function truncateInline(value, limit) {
|
|
|
8142
8242
|
}
|
|
8143
8243
|
return `${value.slice(0, Math.max(0, limit - 1))}…`;
|
|
8144
8244
|
}
|
|
8145
|
-
function escapeTelegramHtml(value) {
|
|
8146
|
-
return value
|
|
8147
|
-
.replaceAll('&', '&')
|
|
8148
|
-
.replaceAll('<', '<')
|
|
8149
|
-
.replaceAll('>', '>');
|
|
8150
|
-
}
|
|
8151
8245
|
function parseReviewTarget(args) {
|
|
8152
8246
|
if (args.length === 0) {
|
|
8153
8247
|
return { type: 'uncommittedChanges' };
|
|
@@ -8260,7 +8354,97 @@ function formatMcpResourceMessage(locale, server, uri, contents) {
|
|
|
8260
8354
|
}
|
|
8261
8355
|
function formatDiffMessage(locale, diff) {
|
|
8262
8356
|
const clipped = diff.length > 3500 ? `${diff.slice(0, 3500)}\n...` : diff;
|
|
8263
|
-
return
|
|
8357
|
+
return [
|
|
8358
|
+
telegramBold(t(locale, 'diff_title')),
|
|
8359
|
+
telegramExpandableBlockquote(clipped),
|
|
8360
|
+
].join('\n');
|
|
8361
|
+
}
|
|
8362
|
+
function formatRichDiffMessage(locale, diff) {
|
|
8363
|
+
const clipped = clipRichMessageText(diff, Math.min(24_000, TELEGRAM_RICH_MESSAGE_TEXT_LIMIT - 1024));
|
|
8364
|
+
const summary = locale === 'zh' ? '展开 diff' : 'Expand diff';
|
|
8365
|
+
const footer = locale === 'zh'
|
|
8366
|
+
? 'FoxClaw · sendRichMessage · details/pre/code'
|
|
8367
|
+
: 'FoxClaw · sendRichMessage · details/pre/code';
|
|
8368
|
+
return [
|
|
8369
|
+
`<h3>${escapeTelegramHtml(t(locale, 'diff_title'))}</h3>`,
|
|
8370
|
+
telegramDetails(summary, telegramPreCode(clipped, 'diff')),
|
|
8371
|
+
`<footer>${escapeTelegramHtml(footer)}</footer>`,
|
|
8372
|
+
].join('\n');
|
|
8373
|
+
}
|
|
8374
|
+
function formatRichDemoMessage(locale) {
|
|
8375
|
+
const title = 'FoxClaw RichMessage';
|
|
8376
|
+
const intro = locale === 'zh'
|
|
8377
|
+
? '这条消息通过 Telegram Bot API sendRichMessage 发送,用来验证 Rich Message 在真实客户端里的渲染。'
|
|
8378
|
+
: 'This message is sent through Telegram Bot API sendRichMessage to verify Rich Message rendering in a real client.';
|
|
8379
|
+
const detailsSummary = locale === 'zh' ? '展开 details + pre 示例' : 'Open details + pre sample';
|
|
8380
|
+
const diffSample = [
|
|
8381
|
+
'diff --git a/src/telegram/rich.ts b/src/telegram/rich.ts',
|
|
8382
|
+
'+ sendRichMessage({ rich_message: { html } })',
|
|
8383
|
+
'+ <details><summary>Expandable</summary>...</details>',
|
|
8384
|
+
'+ <table bordered striped>...</table>',
|
|
8385
|
+
].join('\n');
|
|
8386
|
+
const tableCaption = locale === 'zh' ? 'FoxClaw 可用 rich 面' : 'FoxClaw rich surfaces';
|
|
8387
|
+
const surfaceHeader = locale === 'zh' ? '功能面' : 'Surface';
|
|
8388
|
+
const richHeader = locale === 'zh' ? 'Rich 用法' : 'Rich usage';
|
|
8389
|
+
const tableRows = locale === 'zh'
|
|
8390
|
+
? [
|
|
8391
|
+
['`/diff`', 'details + pre/code'],
|
|
8392
|
+
['工具状态', 'details/list'],
|
|
8393
|
+
['`/status`', 'table'],
|
|
8394
|
+
]
|
|
8395
|
+
: [
|
|
8396
|
+
['`/diff`', 'details + pre/code'],
|
|
8397
|
+
['Tool status', 'details/list'],
|
|
8398
|
+
['`/status`', 'table'],
|
|
8399
|
+
];
|
|
8400
|
+
const listItems = locale === 'zh'
|
|
8401
|
+
? ['Gateway 已接入 sendRichMessage', '/diff 已优先使用 RichMessage', '失败会回退到 Telegram HTML']
|
|
8402
|
+
: ['Gateway now supports sendRichMessage', '/diff prefers RichMessage', 'Failures fall back to Telegram HTML'];
|
|
8403
|
+
const detailBody = [
|
|
8404
|
+
`<p>${escapeTelegramHtml(locale === 'zh' ? '下面是 rich pre/code block,客户端支持时会按代码块渲染。' : 'This is a rich pre/code block rendered as code by supported clients.')}</p>`,
|
|
8405
|
+
telegramPreCode(diffSample, 'diff'),
|
|
8406
|
+
].join('\n');
|
|
8407
|
+
return [
|
|
8408
|
+
`<h2>${escapeTelegramHtml(title)}</h2>`,
|
|
8409
|
+
`<p><b>Bot API 10.1</b> ${escapeTelegramHtml(intro)}</p>`,
|
|
8410
|
+
'<hr/>',
|
|
8411
|
+
'<table bordered striped>',
|
|
8412
|
+
`<caption>${escapeTelegramHtml(tableCaption)}</caption>`,
|
|
8413
|
+
`<tr><th>${escapeTelegramHtml(surfaceHeader)}</th><th>${escapeTelegramHtml(richHeader)}</th></tr>`,
|
|
8414
|
+
...tableRows.map(([surface, usage]) => `<tr><td>${escapeTelegramHtml(surface)}</td><td>${escapeTelegramHtml(usage)}</td></tr>`),
|
|
8415
|
+
'</table>',
|
|
8416
|
+
telegramDetails(detailsSummary, detailBody, true),
|
|
8417
|
+
'<ul>',
|
|
8418
|
+
...listItems.map(item => `<li>${escapeTelegramHtml(item)}</li>`),
|
|
8419
|
+
'</ul>',
|
|
8420
|
+
'<footer>FoxClaw · sendRichMessage</footer>',
|
|
8421
|
+
].join('\n');
|
|
8422
|
+
}
|
|
8423
|
+
function formatRichDemoFallbackMessage(locale) {
|
|
8424
|
+
const lines = locale === 'zh'
|
|
8425
|
+
? [
|
|
8426
|
+
'RichMessage fallback 预览',
|
|
8427
|
+
'Gateway 已接入 sendRichMessage',
|
|
8428
|
+
'/diff 已优先使用 RichMessage',
|
|
8429
|
+
'如果你看到这条 HTML fallback,说明 Telegram rich API 返回了失败,正常功能仍可用。',
|
|
8430
|
+
]
|
|
8431
|
+
: [
|
|
8432
|
+
'RichMessage fallback preview',
|
|
8433
|
+
'Gateway now supports sendRichMessage',
|
|
8434
|
+
'/diff prefers RichMessage',
|
|
8435
|
+
'If you see this HTML fallback, Telegram rich API failed but normal messaging still works.',
|
|
8436
|
+
];
|
|
8437
|
+
return [
|
|
8438
|
+
telegramBold('FoxClaw RichMessage'),
|
|
8439
|
+
telegramExpandableBlockquote(lines.join('\n')),
|
|
8440
|
+
telegramPreCode('sendRichMessage({ rich_message: { html } })', 'typescript'),
|
|
8441
|
+
].join('\n');
|
|
8442
|
+
}
|
|
8443
|
+
function clipRichMessageText(value, limit) {
|
|
8444
|
+
if (value.length <= limit) {
|
|
8445
|
+
return value;
|
|
8446
|
+
}
|
|
8447
|
+
return `${value.slice(0, Math.max(0, limit - 4))}\n...`;
|
|
8264
8448
|
}
|
|
8265
8449
|
function formatLoadedThreadsMessage(locale, threadIds) {
|
|
8266
8450
|
const lines = [t(locale, 'loaded_title')];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { t } from '../i18n.js';
|
|
3
|
+
import { escapeTelegramHtml } from '../telegram/html.js';
|
|
3
4
|
import { resolveFastTierForModel } from './service_tier.js';
|
|
4
5
|
export function formatThreadsMessage(locale, threads, currentThreadId, searchTerm, listState) {
|
|
5
6
|
if (threads.length === 0) {
|
|
@@ -642,12 +643,6 @@ function formatIsoTime(locale, unixSeconds) {
|
|
|
642
643
|
return t(locale, 'unknown');
|
|
643
644
|
return new Date(unixSeconds * 1000).toISOString();
|
|
644
645
|
}
|
|
645
|
-
function escapeTelegramHtml(value) {
|
|
646
|
-
return value
|
|
647
|
-
.replaceAll('&', '&')
|
|
648
|
-
.replaceAll('<', '<')
|
|
649
|
-
.replaceAll('>', '>');
|
|
650
|
-
}
|
|
651
646
|
function chunkButtons(buttons, width) {
|
|
652
647
|
const rows = [];
|
|
653
648
|
for (let index = 0; index < buttons.length; index += width) {
|
package/dist/i18n.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ declare const MESSAGES: {
|
|
|
45
45
|
readonly cmd_desc_setup: "Unified preference panel";
|
|
46
46
|
readonly cmd_desc_fast: "Toggle Fast mode";
|
|
47
47
|
readonly cmd_desc_active: "Active-turn message behavior";
|
|
48
|
+
readonly cmd_desc_rich: "Telegram RichMessage demo";
|
|
48
49
|
readonly cmd_desc_status: "Bridge status";
|
|
49
50
|
readonly cmd_desc_update: "Update and restart FoxClaw";
|
|
50
51
|
readonly cmd_desc_account: "Codex account";
|
|
@@ -742,6 +743,7 @@ declare const MESSAGES: {
|
|
|
742
743
|
readonly cmd_desc_setup: "统一偏好面板";
|
|
743
744
|
readonly cmd_desc_fast: "切换 Fast 模式";
|
|
744
745
|
readonly cmd_desc_active: "运行中新消息处理方式";
|
|
746
|
+
readonly cmd_desc_rich: "Telegram RichMessage 演示";
|
|
745
747
|
readonly cmd_desc_status: "查看桥接状态";
|
|
746
748
|
readonly cmd_desc_update: "升级并重启 FoxClaw";
|
|
747
749
|
readonly cmd_desc_account: "Codex 账号";
|
package/dist/i18n.js
CHANGED
|
@@ -43,6 +43,7 @@ const MESSAGES = {
|
|
|
43
43
|
cmd_desc_setup: 'Unified preference panel',
|
|
44
44
|
cmd_desc_fast: 'Toggle Fast mode',
|
|
45
45
|
cmd_desc_active: 'Active-turn message behavior',
|
|
46
|
+
cmd_desc_rich: 'Telegram RichMessage demo',
|
|
46
47
|
cmd_desc_status: 'Bridge status',
|
|
47
48
|
cmd_desc_update: 'Update and restart FoxClaw',
|
|
48
49
|
cmd_desc_account: 'Codex account',
|
|
@@ -740,6 +741,7 @@ const MESSAGES = {
|
|
|
740
741
|
cmd_desc_setup: '统一偏好面板',
|
|
741
742
|
cmd_desc_fast: '切换 Fast 模式',
|
|
742
743
|
cmd_desc_active: '运行中新消息处理方式',
|
|
744
|
+
cmd_desc_rich: 'Telegram RichMessage 演示',
|
|
743
745
|
cmd_desc_status: '查看桥接状态',
|
|
744
746
|
cmd_desc_update: '升级并重启 FoxClaw',
|
|
745
747
|
cmd_desc_account: 'Codex 账号',
|
|
@@ -1412,6 +1414,7 @@ export function getTelegramCommands(locale) {
|
|
|
1412
1414
|
{ command: 'auth', description: t(locale, 'cmd_desc_auth') },
|
|
1413
1415
|
{ command: 'fast', description: t(locale, 'cmd_desc_fast') },
|
|
1414
1416
|
{ command: 'active', description: t(locale, 'cmd_desc_active') },
|
|
1417
|
+
{ command: 'rich', description: t(locale, 'cmd_desc_rich') },
|
|
1415
1418
|
{ command: 'account', description: t(locale, 'cmd_desc_account') },
|
|
1416
1419
|
{ command: 'quota', description: t(locale, 'cmd_desc_quota') },
|
|
1417
1420
|
{ command: 'login_device', description: t(locale, 'cmd_desc_login_device') },
|
|
@@ -4,6 +4,7 @@ import type { BridgeStore } from '../store/database.js';
|
|
|
4
4
|
import type { Logger } from '../logger.js';
|
|
5
5
|
import type { TelegramMessageEntity } from './addressing.js';
|
|
6
6
|
import type { TelegramInboundAttachment } from './media.js';
|
|
7
|
+
import type { TelegramInputRichMessage } from './rich.js';
|
|
7
8
|
export interface TelegramTextEvent {
|
|
8
9
|
chatId: string;
|
|
9
10
|
topicId: number | null;
|
|
@@ -62,8 +63,13 @@ export declare class TelegramGateway extends EventEmitter {
|
|
|
62
63
|
text: string;
|
|
63
64
|
callback_data: string;
|
|
64
65
|
}>>, messageThreadId?: number | null): Promise<number>;
|
|
66
|
+
sendRichMessage(chatId: string, richMessage: TelegramInputRichMessage, inlineKeyboard?: Array<Array<{
|
|
67
|
+
text: string;
|
|
68
|
+
callback_data: string;
|
|
69
|
+
}>>, messageThreadId?: number | null): Promise<number>;
|
|
65
70
|
sendDocument(chatId: string, filename: string, contents: Buffer, caption?: string): Promise<number>;
|
|
66
71
|
sendMessageDraft(chatId: string, draftId: number, text: string, messageThreadId?: number | null): Promise<void>;
|
|
72
|
+
sendRichMessageDraft(chatId: string, draftId: number, richMessage: TelegramInputRichMessage, messageThreadId?: number | null): Promise<void>;
|
|
67
73
|
editMessage(chatId: string, messageId: number, text: string, inlineKeyboard?: Array<Array<{
|
|
68
74
|
text: string;
|
|
69
75
|
callback_data: string;
|
|
@@ -72,6 +78,10 @@ export declare class TelegramGateway extends EventEmitter {
|
|
|
72
78
|
text: string;
|
|
73
79
|
callback_data: string;
|
|
74
80
|
}>>): Promise<void>;
|
|
81
|
+
editRichMessage(chatId: string, messageId: number, richMessage: TelegramInputRichMessage, inlineKeyboard?: Array<Array<{
|
|
82
|
+
text: string;
|
|
83
|
+
callback_data: string;
|
|
84
|
+
}>>): Promise<void>;
|
|
75
85
|
clearMessageInlineKeyboard(chatId: string, messageId: number): Promise<void>;
|
|
76
86
|
private sendMessageWithOptions;
|
|
77
87
|
private editMessageWithOptions;
|
package/dist/telegram/gateway.js
CHANGED
|
@@ -56,6 +56,18 @@ export class TelegramGateway extends EventEmitter {
|
|
|
56
56
|
async sendHtmlMessage(chatId, text, inlineKeyboard, messageThreadId) {
|
|
57
57
|
return this.sendMessageWithOptions(chatId, text, inlineKeyboard, 'HTML', messageThreadId);
|
|
58
58
|
}
|
|
59
|
+
async sendRichMessage(chatId, richMessage, inlineKeyboard, messageThreadId) {
|
|
60
|
+
const result = await callTelegramApi(this.botToken, 'sendRichMessage', {
|
|
61
|
+
chat_id: chatId,
|
|
62
|
+
rich_message: richMessage,
|
|
63
|
+
...(messageThreadId !== null && messageThreadId !== undefined ? { message_thread_id: messageThreadId } : {}),
|
|
64
|
+
...(inlineKeyboard ? { reply_markup: { inline_keyboard: inlineKeyboard } } : {}),
|
|
65
|
+
});
|
|
66
|
+
if (!result.ok || !result.result) {
|
|
67
|
+
throw new Error(result.description || 'Failed to send Telegram rich message');
|
|
68
|
+
}
|
|
69
|
+
return result.result.message_id;
|
|
70
|
+
}
|
|
59
71
|
async sendDocument(chatId, filename, contents, caption) {
|
|
60
72
|
const result = await callTelegramMultipartApi(this.botToken, 'sendDocument', {
|
|
61
73
|
chat_id: chatId,
|
|
@@ -83,12 +95,34 @@ export class TelegramGateway extends EventEmitter {
|
|
|
83
95
|
throw new Error(result.description || 'Failed to send Telegram draft message');
|
|
84
96
|
}
|
|
85
97
|
}
|
|
98
|
+
async sendRichMessageDraft(chatId, draftId, richMessage, messageThreadId) {
|
|
99
|
+
const result = await callTelegramApi(this.botToken, 'sendRichMessageDraft', {
|
|
100
|
+
chat_id: chatId,
|
|
101
|
+
draft_id: draftId,
|
|
102
|
+
rich_message: richMessage,
|
|
103
|
+
...(messageThreadId !== null && messageThreadId !== undefined ? { message_thread_id: messageThreadId } : {}),
|
|
104
|
+
});
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
throw new Error(result.description || 'Failed to send Telegram rich draft message');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
86
109
|
async editMessage(chatId, messageId, text, inlineKeyboard) {
|
|
87
110
|
return this.editMessageWithOptions(chatId, messageId, text, inlineKeyboard);
|
|
88
111
|
}
|
|
89
112
|
async editHtmlMessage(chatId, messageId, text, inlineKeyboard) {
|
|
90
113
|
return this.editMessageWithOptions(chatId, messageId, text, inlineKeyboard, 'HTML');
|
|
91
114
|
}
|
|
115
|
+
async editRichMessage(chatId, messageId, richMessage, inlineKeyboard) {
|
|
116
|
+
const result = await callTelegramApi(this.botToken, 'editMessageText', {
|
|
117
|
+
chat_id: chatId,
|
|
118
|
+
message_id: messageId,
|
|
119
|
+
rich_message: richMessage,
|
|
120
|
+
...(inlineKeyboard ? { reply_markup: { inline_keyboard: inlineKeyboard } } : {}),
|
|
121
|
+
});
|
|
122
|
+
if (!result.ok && !String(result.description || '').includes('message is not modified')) {
|
|
123
|
+
throw new Error(result.description || 'Failed to edit Telegram rich message');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
92
126
|
async clearMessageInlineKeyboard(chatId, messageId) {
|
|
93
127
|
const result = await callTelegramApi(this.botToken, 'editMessageReplyMarkup', {
|
|
94
128
|
chat_id: chatId,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function escapeTelegramHtml(value: string): string;
|
|
2
|
+
export declare function telegramBold(value: string): string;
|
|
3
|
+
export declare function telegramCode(value: string): string;
|
|
4
|
+
export declare function telegramPre(value: string): string;
|
|
5
|
+
export declare function telegramPreCode(value: string, language?: string): string;
|
|
6
|
+
export declare function telegramExpandableBlockquote(value: string): string;
|
|
7
|
+
export declare function telegramSpoiler(value: string): string;
|
|
8
|
+
export declare function telegramDetails(summary: string, bodyHtml: string, open?: boolean): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function escapeTelegramHtml(value) {
|
|
2
|
+
return value
|
|
3
|
+
.replaceAll('&', '&')
|
|
4
|
+
.replaceAll('<', '<')
|
|
5
|
+
.replaceAll('>', '>');
|
|
6
|
+
}
|
|
7
|
+
function escapeTelegramHtmlAttribute(value) {
|
|
8
|
+
return escapeTelegramHtml(value).replaceAll('"', '"');
|
|
9
|
+
}
|
|
10
|
+
export function telegramBold(value) {
|
|
11
|
+
return `<b>${escapeTelegramHtml(value)}</b>`;
|
|
12
|
+
}
|
|
13
|
+
export function telegramCode(value) {
|
|
14
|
+
return `<code>${escapeTelegramHtml(value)}</code>`;
|
|
15
|
+
}
|
|
16
|
+
export function telegramPre(value) {
|
|
17
|
+
return `<pre>${escapeTelegramHtml(value)}</pre>`;
|
|
18
|
+
}
|
|
19
|
+
export function telegramPreCode(value, language) {
|
|
20
|
+
const classAttr = language ? ` class="language-${escapeTelegramHtmlAttribute(language)}"` : '';
|
|
21
|
+
return `<pre><code${classAttr}>${escapeTelegramHtml(value)}</code></pre>`;
|
|
22
|
+
}
|
|
23
|
+
export function telegramExpandableBlockquote(value) {
|
|
24
|
+
return `<blockquote expandable>${escapeTelegramHtml(value)}</blockquote>`;
|
|
25
|
+
}
|
|
26
|
+
export function telegramSpoiler(value) {
|
|
27
|
+
return `<tg-spoiler>${escapeTelegramHtml(value)}</tg-spoiler>`;
|
|
28
|
+
}
|
|
29
|
+
export function telegramDetails(summary, bodyHtml, open = false) {
|
|
30
|
+
return `<details${open ? ' open' : ''}><summary>${escapeTelegramHtml(summary)}</summary>${bodyHtml}</details>`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const TELEGRAM_RICH_MESSAGE_TEXT_LIMIT = 32768;
|
|
2
|
+
export declare const TELEGRAM_RICH_MESSAGE_BLOCK_LIMIT = 500;
|
|
3
|
+
export interface TelegramInputRichMessage {
|
|
4
|
+
html?: string;
|
|
5
|
+
markdown?: string;
|
|
6
|
+
is_rtl?: true;
|
|
7
|
+
skip_entity_detection?: true;
|
|
8
|
+
}
|
|
9
|
+
export interface TelegramRichMessageOptions {
|
|
10
|
+
isRtl?: boolean;
|
|
11
|
+
skipEntityDetection?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function telegramRichHtml(html: string, options?: TelegramRichMessageOptions): TelegramInputRichMessage;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const TELEGRAM_RICH_MESSAGE_TEXT_LIMIT = 32_768;
|
|
2
|
+
export const TELEGRAM_RICH_MESSAGE_BLOCK_LIMIT = 500;
|
|
3
|
+
export function telegramRichHtml(html, options = {}) {
|
|
4
|
+
const message = { html };
|
|
5
|
+
if (options.isRtl) {
|
|
6
|
+
message.is_rtl = true;
|
|
7
|
+
}
|
|
8
|
+
if (options.skipEntityDetection) {
|
|
9
|
+
message.skip_entity_detection = true;
|
|
10
|
+
}
|
|
11
|
+
return message;
|
|
12
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { escapeTelegramHtml, telegramPreCode } from './html.js';
|
|
2
|
+
const SAFE_LINK_SCHEMES = /^(https?:|mailto:|tg:)/i;
|
|
3
|
+
export function renderTelegramMarkdownRichHtml(markdown) {
|
|
4
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
5
|
+
const blocks = [];
|
|
6
|
+
let index = 0;
|
|
7
|
+
while (index < lines.length) {
|
|
8
|
+
const line = lines[index];
|
|
9
|
+
if (!line.trim()) {
|
|
10
|
+
index += 1;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const fence = line.match(/^```([A-Za-z0-9_+-]*)\s*$/);
|
|
14
|
+
if (fence) {
|
|
15
|
+
const codeLines = [];
|
|
16
|
+
index += 1;
|
|
17
|
+
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
|
|
18
|
+
codeLines.push(lines[index]);
|
|
19
|
+
index += 1;
|
|
20
|
+
}
|
|
21
|
+
if (index < lines.length) {
|
|
22
|
+
index += 1;
|
|
23
|
+
}
|
|
24
|
+
blocks.push(telegramPreCode(codeLines.join('\n'), sanitizeCodeLanguage(fence[1] ?? '')));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const heading = line.match(/^(#{1,4})\s+(.+?)\s*#*\s*$/);
|
|
28
|
+
if (heading) {
|
|
29
|
+
const level = Math.min(4, Math.max(2, heading[1].length + 1));
|
|
30
|
+
blocks.push(`<h${level}>${renderInlineRichHtml(heading[2])}</h${level}>`);
|
|
31
|
+
index += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
35
|
+
const items = [];
|
|
36
|
+
while (index < lines.length && /^\s*[-*+]\s+/.test(lines[index])) {
|
|
37
|
+
items.push(stripListMarker(lines[index], false));
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
blocks.push(`<ul>${items.map(item => `<li>${renderInlineRichHtml(item)}</li>`).join('')}</ul>`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (/^\s*\d+[.)]\s+/.test(line)) {
|
|
44
|
+
const items = [];
|
|
45
|
+
while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) {
|
|
46
|
+
items.push(stripListMarker(lines[index], true));
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
blocks.push(`<ol>${items.map(item => `<li>${renderInlineRichHtml(item)}</li>`).join('')}</ol>`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (/^\s*>\s?/.test(line)) {
|
|
53
|
+
const quoteLines = [];
|
|
54
|
+
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
|
55
|
+
quoteLines.push(lines[index].replace(/^\s*>\s?/, ''));
|
|
56
|
+
index += 1;
|
|
57
|
+
}
|
|
58
|
+
blocks.push(`<blockquote>${quoteLines.map(renderInlineRichHtml).join('<br>')}</blockquote>`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const paragraphLines = [line.trimEnd()];
|
|
62
|
+
index += 1;
|
|
63
|
+
while (index < lines.length
|
|
64
|
+
&& lines[index].trim()
|
|
65
|
+
&& !/^```/.test(lines[index])
|
|
66
|
+
&& !/^(#{1,4})\s+/.test(lines[index])
|
|
67
|
+
&& !/^\s*[-*+]\s+/.test(lines[index])
|
|
68
|
+
&& !/^\s*\d+[.)]\s+/.test(lines[index])
|
|
69
|
+
&& !/^\s*>\s?/.test(lines[index])) {
|
|
70
|
+
paragraphLines.push(lines[index].trimEnd());
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
blocks.push(`<p>${renderInlineRichHtml(paragraphLines.join('\n'))}</p>`);
|
|
74
|
+
}
|
|
75
|
+
return blocks.join('\n') || '<p></p>';
|
|
76
|
+
}
|
|
77
|
+
export function renderInlineRichHtml(markdown) {
|
|
78
|
+
const tokens = [];
|
|
79
|
+
const protectedText = markdown.replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
80
|
+
const token = `{{FOXCLAW_RICH_CODE_${tokens.length}}}`;
|
|
81
|
+
tokens.push(`<code>${escapeTelegramHtml(code)}</code>`);
|
|
82
|
+
return token;
|
|
83
|
+
});
|
|
84
|
+
let html = escapeTelegramHtml(protectedText);
|
|
85
|
+
html = html.replace(/\[([^\]\n]+)\]\(((?:[^()\s]+|\([^)\s]*\))+)\)/g, (_match, label, url) => {
|
|
86
|
+
const normalizedUrl = unescapeHtmlAttribute(url);
|
|
87
|
+
if (!SAFE_LINK_SCHEMES.test(normalizedUrl)) {
|
|
88
|
+
return label;
|
|
89
|
+
}
|
|
90
|
+
return `<a href="${escapeTelegramHtmlAttribute(normalizedUrl)}">${label}</a>`;
|
|
91
|
+
});
|
|
92
|
+
html = html.replace(/\*\*([^*\n][\s\S]*?[^*\n])\*\*/g, '<b>$1</b>');
|
|
93
|
+
html = html.replace(/__([^_\n][\s\S]*?[^_\n])__/g, '<b>$1</b>');
|
|
94
|
+
html = html.replace(/\{\{FOXCLAW_RICH_CODE_(\d+)}}/g, (_match, tokenIndex) => tokens[Number(tokenIndex)] ?? '');
|
|
95
|
+
return html.replaceAll('\n', '<br>');
|
|
96
|
+
}
|
|
97
|
+
function stripListMarker(line, ordered) {
|
|
98
|
+
return ordered
|
|
99
|
+
? line.replace(/^\s*\d+[.)]\s+/, '')
|
|
100
|
+
: line.replace(/^\s*[-*+]\s+/, '');
|
|
101
|
+
}
|
|
102
|
+
function sanitizeCodeLanguage(language) {
|
|
103
|
+
const normalized = language.trim().replace(/[^A-Za-z0-9_+-]/g, '');
|
|
104
|
+
return normalized || undefined;
|
|
105
|
+
}
|
|
106
|
+
function escapeTelegramHtmlAttribute(value) {
|
|
107
|
+
return escapeTelegramHtml(value).replaceAll('"', '"');
|
|
108
|
+
}
|
|
109
|
+
function unescapeHtmlAttribute(value) {
|
|
110
|
+
return value
|
|
111
|
+
.replaceAll('&', '&')
|
|
112
|
+
.replaceAll('<', '<')
|
|
113
|
+
.replaceAll('>', '>')
|
|
114
|
+
.replaceAll('"', '"');
|
|
115
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Telegram Rich Message Adaptation Check
|
|
2
|
+
|
|
3
|
+
Checked on 2026-06-16 against Telegram Bot API Rich Messages, especially `RichMessage`, `sendRichMessage`, `sendRichMessageDraft`, and Rich Message Formatting Options.
|
|
4
|
+
|
|
5
|
+
Official references:
|
|
6
|
+
|
|
7
|
+
- https://core.telegram.org/bots/api#rich-message-formatting-options
|
|
8
|
+
- https://core.telegram.org/bots/api#sendrichmessage
|
|
9
|
+
- https://core.telegram.org/bots/api#sendrichmessagedraft
|
|
10
|
+
|
|
11
|
+
## Conclusion
|
|
12
|
+
|
|
13
|
+
FoxClaw can benefit from the new rich message surface, and RichMessage is now wired into Telegram surfaces that are easy to inspect and safe to fall back.
|
|
14
|
+
|
|
15
|
+
The current strategy is to try `sendRichMessage` first on Telegram, then fall back to the existing Telegram HTML path on failure. Weixin continues to use HTML/plain fallback. Bot API 10.1 Rich Messages start with diagnostics and structured long text, then can expand to status, auth/quota, and AI draft streaming.
|
|
16
|
+
|
|
17
|
+
Already landed in this pass:
|
|
18
|
+
|
|
19
|
+
- Added `src/telegram/html.ts` for centralized Telegram HTML escaping and tag helpers.
|
|
20
|
+
- Added `src/telegram/rich.ts` and wired `sendRichMessage` / rich HTML through `TelegramGateway`, `TelegramMessagingPort`, and `BridgeMessagingRouter`.
|
|
21
|
+
- Added `/rich` as a diagnostic command for checking heading, table, details, pre/code, and list rendering in a real Telegram client.
|
|
22
|
+
- Changed `/diff` to prefer RichMessage with a heading plus details/pre/code diff block, falling back to HTML bold title plus expandable quote body.
|
|
23
|
+
- Moved existing CLI observation and archived tool-batch HTML generation onto the shared helper.
|
|
24
|
+
|
|
25
|
+
## Official Capability
|
|
26
|
+
|
|
27
|
+
Bot API 10.1 adds Rich Messages:
|
|
28
|
+
|
|
29
|
+
- `RichText*`: bold, italic, underline, strikethrough, spoiler, code, marked, math, URL, email, phone, mention, hashtag, bot command, anchors, and references.
|
|
30
|
+
- `RichBlock*`: paragraph, heading, preformatted, footer, divider, math block, anchor, list, block quote, pull quote, collage, slideshow, table, details, map, media blocks, and thinking.
|
|
31
|
+
- `sendRichMessage`: sends a complete rich message.
|
|
32
|
+
- `sendRichMessageDraft`: streams an ephemeral partial rich message in private chat; the final answer must still be persisted with `sendRichMessage`.
|
|
33
|
+
- `editMessageText` accepts `rich_message` for editing rich messages.
|
|
34
|
+
|
|
35
|
+
Rich Message HTML also supports tags such as `<details>`, `<table>`, `<pre><code class="language-...">`, `<ul>/<ol>`, `<hr/>`, `<tg-math-block>`, and `<tg-thinking>`. `RichBlockThinking` is draft-only.
|
|
36
|
+
|
|
37
|
+
## FoxClaw Inventory
|
|
38
|
+
|
|
39
|
+
Current Telegram send layer:
|
|
40
|
+
|
|
41
|
+
- `src/telegram/gateway.ts`: plain/html/rich send and edit are wired; regular HTML uses `parse_mode=HTML`, while rich messages use `rich_message.html`.
|
|
42
|
+
- `src/channels/telegram/telegram_messaging_port.ts`: controller-facing plain/html/rich-html send/edit and text draft operations.
|
|
43
|
+
- `src/telegram/rendering.ts`: `segmented_stream` is the default; `draft_stream` still uses the old text draft path.
|
|
44
|
+
- `src/controller/controller.ts`: central dispatcher for status cards, approvals, tool batches, diffs, auth, MCP, plugins, files, and runtime summaries.
|
|
45
|
+
- `src/controller/presentation.ts`: `/threads`, `/setup`, model, and access panels already use Telegram HTML.
|
|
46
|
+
|
|
47
|
+
Useful mapping:
|
|
48
|
+
|
|
49
|
+
| Surface | Current state | Useful rich capabilities | Recommendation |
|
|
50
|
+
| --- | --- | --- | --- |
|
|
51
|
+
| Active turn status | Short plain text, frequent edits | heading, list, thinking draft | Keep stable; use `RichBlockThinking` later only for private draft streaming |
|
|
52
|
+
| Codex streaming replies | Segmented plain text | rich draft, paragraph, pre, details | Feature flag only; needs fallback |
|
|
53
|
+
| Archived tool batches | Expandable HTML quote | details, pre, list | Keep HTML now; later use rich details |
|
|
54
|
+
| `/diff` | RichMessage first, HTML fallback | pre language, details | Landed |
|
|
55
|
+
| Approvals | Plain text plus inline keyboard | code, pre, spoiler, details | Command/path/patch fit code/pre/details; sensitive params should be hidden |
|
|
56
|
+
| `/status` and runtime summaries | Plain text lists | table, heading, footer | Good candidate for rich tables |
|
|
57
|
+
| `/auth` and `/quota` | Compact text plus buttons | table, marked, spoiler | Quota windows fit tables; abnormal candidates fit marked text |
|
|
58
|
+
| `/threads` and `/setup` | HTML panels | heading, list, anchor | Current HTML is enough; medium priority |
|
|
59
|
+
| MCP resources and plugin skills | Long plain text | details, pre, anchor/reference | Good candidate for collapsible schema/resource blocks |
|
|
60
|
+
| Help and setup text | Plain text | heading, list, code | Low priority |
|
|
61
|
+
| Media attachment feedback | Plain summary | collage/slideshow/media captions | Use only if FoxClaw starts returning media previews |
|
|
62
|
+
|
|
63
|
+
## Rollout Plan
|
|
64
|
+
|
|
65
|
+
Phase 1: HTML-compatible enhancement.
|
|
66
|
+
|
|
67
|
+
- Centralize Telegram HTML helpers.
|
|
68
|
+
- Collapse long content by default: diffs, tool logs, MCP resources, plugin skill contents.
|
|
69
|
+
- Render commands, paths, models, and candidate names as code.
|
|
70
|
+
- Use spoilers or omission for secret/token-like diagnostics.
|
|
71
|
+
|
|
72
|
+
Phase 2: Rich Message builder.
|
|
73
|
+
|
|
74
|
+
Done:
|
|
75
|
+
|
|
76
|
+
- Added a minimal typed `src/telegram/rich.ts`.
|
|
77
|
+
- Added `sendRichMessage`, `editRichMessage`, and `sendRichMessageDraft` to `TelegramGateway`.
|
|
78
|
+
- Added rich HTML send/edit to `TelegramMessagingPort` and `BridgeMessagingRouter`; Weixin scopes use fallback HTML.
|
|
79
|
+
- `/rich` and `/diff` use rich sending first and fall back to HTML.
|
|
80
|
+
|
|
81
|
+
Next:
|
|
82
|
+
|
|
83
|
+
- Move `/status`, `/auth`, `/quota`, and MCP resources to rich table/details.
|
|
84
|
+
- Decide whether a global config flag is needed after real Telegram client checks.
|
|
85
|
+
|
|
86
|
+
Phase 3: Rich draft streaming.
|
|
87
|
+
|
|
88
|
+
- Enable only in Telegram private chats first; keep group/topic rendering on the current segmented stream.
|
|
89
|
+
- Use `RichBlockThinking` while generating and paragraph/pre/details for partial output.
|
|
90
|
+
- Persist the final answer with `sendRichMessage`.
|
|
91
|
+
- Keep the old text draft and segmented stream as fallback paths.
|
|
92
|
+
|
|
93
|
+
## Risks
|
|
94
|
+
|
|
95
|
+
- Rich Messages landed in Bot API 10.1 on 2026-06-11, so client compatibility should be checked with `/rich` and `/diff`.
|
|
96
|
+
- `sendRichMessageDraft` targets private users; group, topic, and multi-bot paths must keep existing rendering.
|
|
97
|
+
- Rich media blocks add bot permission and media URL/upload constraints; they are not a Phase 1 target.
|
|
98
|
+
- Automatic entity detection can mis-detect paths, emails, URLs, and commands; rich builders should choose `skip_entity_detection` per message type.
|
|
99
|
+
- All Codex, shell, and file output must pass through centralized escaping before entering HTML/rich markup.
|
package/docs/user-manual.md
CHANGED
|
@@ -265,6 +265,10 @@ It controls:
|
|
|
265
265
|
|
|
266
266
|
Telegram renders the HTML and buttons. This text block approximates the real panel:
|
|
267
267
|
|
|
268
|
+
For the Telegram Rich Message inventory and rollout plan, see [Telegram Rich Message Adaptation Check](./telegram-rich-messages.md). The default path still favors Telegram HTML for compatibility.
|
|
269
|
+
|
|
270
|
+
Send `/rich` to view a RichMessage demo in the current Telegram client. `/diff` also prefers RichMessage details plus a diff code block, with automatic Telegram HTML fallback.
|
|
271
|
+
|
|
268
272
|
```text
|
|
269
273
|
Session preferences
|
|
270
274
|
Current: gpt-5.5 · high · fast=off · default · Agent · Steer current turn
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Telegram Rich Message 适配专项检查
|
|
2
|
+
|
|
3
|
+
检查日期:2026-06-16。依据:Telegram Bot API Rich Messages 文档,尤其是 `RichMessage`、`sendRichMessage`、`sendRichMessageDraft` 与 Rich Message Formatting Options。
|
|
4
|
+
|
|
5
|
+
官方入口:
|
|
6
|
+
|
|
7
|
+
- https://core.telegram.org/bots/api#rich-message-formatting-options
|
|
8
|
+
- https://core.telegram.org/bots/api#sendrichmessage
|
|
9
|
+
- https://core.telegram.org/bots/api#sendrichmessagedraft
|
|
10
|
+
|
|
11
|
+
## 结论
|
|
12
|
+
|
|
13
|
+
FoxClaw 有明确收益,并已开始把 RichMessage 用在适合看效果、可安全回退的 Telegram 功能面。
|
|
14
|
+
|
|
15
|
+
现在的策略是:Telegram 优先尝试 `sendRichMessage`,失败时回退到既有 Telegram HTML;微信继续使用 HTML/plain 回退。Bot API 10.1 的 Rich Message 先用于可观察的诊断和结构化长文本,再逐步扩展到状态、auth/quota 和 AI draft streaming。
|
|
16
|
+
|
|
17
|
+
本次已先落地低风险增强:
|
|
18
|
+
|
|
19
|
+
- 新增 `src/telegram/html.ts`,统一 Telegram HTML 转义和常用标签生成。
|
|
20
|
+
- 新增 `src/telegram/rich.ts`,并在 `TelegramGateway` / `TelegramMessagingPort` / `BridgeMessagingRouter` 接入 `sendRichMessage` / rich HTML 发送链路。
|
|
21
|
+
- 新增 `/rich` 诊断命令,用来在真实 Telegram 客户端查看 heading、table、details、pre/code、list 的 RichMessage 效果。
|
|
22
|
+
- `/diff` 改为优先发送 RichMessage:标题用 heading,diff 内容放入 details + pre/code;rich 发送失败时回退到 HTML 加粗标题和可展开引用块。
|
|
23
|
+
- 现有 CLI 观察消息和归档工具批次状态复用同一套 HTML helper。
|
|
24
|
+
|
|
25
|
+
## 官方能力摘录
|
|
26
|
+
|
|
27
|
+
Bot API 10.1 新增 Rich Messages:
|
|
28
|
+
|
|
29
|
+
- `RichText*`:bold、italic、underline、strikethrough、spoiler、code、marked、math、url、email、phone、mention、hashtag、bot command、anchor/reference 等。
|
|
30
|
+
- `RichBlock*`:paragraph、heading、pre、footer、divider、math block、anchor、list、blockquote、pullquote、collage、slideshow、table、details、map、photo/video/audio/animation/voice、thinking。
|
|
31
|
+
- `sendRichMessage`:发送完整 rich message。
|
|
32
|
+
- `sendRichMessageDraft`:在私聊里流式发送临时 rich draft;draft 是短暂预览,最终仍要用 `sendRichMessage` 发送完整消息。
|
|
33
|
+
- `editMessageText` 新增 `rich_message` 参数,可编辑 rich message。
|
|
34
|
+
|
|
35
|
+
Rich Message HTML 还支持 `<details>`、`<table>`、`<pre><code class="language-...">`、`<ul>/<ol>`、`<hr/>`、`<tg-math-block>`、`<tg-thinking>` 等标签。`RichBlockThinking` 只能用于 `sendRichMessageDraft`。
|
|
36
|
+
|
|
37
|
+
## FoxClaw 现状
|
|
38
|
+
|
|
39
|
+
当前 Telegram 发送层:
|
|
40
|
+
|
|
41
|
+
- `src/telegram/gateway.ts`:`sendMessage`、`sendHtmlMessage`、`sendRichMessage`、`editMessage`、`editHtmlMessage`、`editRichMessage` 已接入;HTML 普通消息使用 `parse_mode=HTML`,rich 消息使用 `rich_message.html`。
|
|
42
|
+
- `src/channels/telegram/telegram_messaging_port.ts`:对 controller 暴露 plain/html/rich-html send/edit 和 `sendDraft`。
|
|
43
|
+
- `src/telegram/rendering.ts`:默认 `segmented_stream`;`draft_stream` 仍是旧 `sendMessageDraft` 文本 draft。
|
|
44
|
+
- `src/controller/controller.ts`:状态卡、审批、工具批次、diff、auth、MCP、插件、文件等功能面都在这里汇总发送。
|
|
45
|
+
- `src/controller/presentation.ts`:`/threads`、`/setup`、模型/权限面板已经使用 Telegram HTML。
|
|
46
|
+
|
|
47
|
+
已经使用的格式能力:
|
|
48
|
+
|
|
49
|
+
- `/threads`、`/setup` 等面板:加粗、code、HTML escape。
|
|
50
|
+
- CLI 观察消息:`<pre>`。
|
|
51
|
+
- 归档工具批次状态:`<blockquote expandable>`。
|
|
52
|
+
- 本次增强后的 `/diff`:加粗标题和可展开引用块。
|
|
53
|
+
|
|
54
|
+
已经接入:
|
|
55
|
+
|
|
56
|
+
- `sendRichMessage`:Telegram rich HTML 发送链路。
|
|
57
|
+
- `/rich`:RichMessage demo。
|
|
58
|
+
- `/diff`:RichMessage details + diff pre/code,失败回退 HTML。
|
|
59
|
+
|
|
60
|
+
尚未接入:
|
|
61
|
+
|
|
62
|
+
- Rich table/status 面板、auth/quota 表格、MCP resource details、rich draft streaming。
|
|
63
|
+
- `sendRichMessageDraft` 的 thinking block 和最终 rich message 持久化。
|
|
64
|
+
|
|
65
|
+
## 功能面盘点
|
|
66
|
+
|
|
67
|
+
| 功能面 | 现状 | 可用 rich 能力 | 建议 |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| 活动 turn 状态卡 | 普通短文本,频繁编辑 | heading、list、thinking draft | 保持普通状态卡稳定;私聊 draft streaming 后续用 `RichBlockThinking` |
|
|
70
|
+
| Codex streaming 回复 | 分段纯文本为主 | rich draft、paragraph、pre、details | 先不默认切;需要 feature flag 和失败回退 |
|
|
71
|
+
| 归档工具批次 | 已用 expandable blockquote | details、pre、list | 短期维持 HTML;后续 rich details 展开命令、文件、搜索结果 |
|
|
72
|
+
| `/diff` | 已优先 RichMessage,失败回退 HTML | pre language、details | 已落地 |
|
|
73
|
+
| 审批请求 | 多行纯文本 + inline keyboard | code、pre、spoiler、details | 命令、路径、patch 适合 code/pre/details;敏感参数可 spoiler |
|
|
74
|
+
| `/status` / runtime 摘要 | 纯文本列表 | table、heading、footer | 多 bot、多 auth、多 quota 适合 rich table |
|
|
75
|
+
| `/auth` / `/quota` | 紧凑纯文本 + 按钮 | table、marked、spoiler | quota 窗口适合 table;异常候选用 marked;隐藏敏感候选信息需谨慎 |
|
|
76
|
+
| `/threads` / `/setup` | 已用 HTML 面板 | heading、list、anchor | 现状够用;rich message 价值中等 |
|
|
77
|
+
| MCP resource / plugin skill | 纯文本长内容 | details、pre、anchor/reference | schema、resource 文本可放 details/pre,引用可用 anchor/reference |
|
|
78
|
+
| 说明类消息 / help | 纯文本 | heading、list、code | 可转 rich list,但优先级低 |
|
|
79
|
+
| 媒体附件反馈 | 纯文本摘要 | collage/slideshow/photo/video caption | 只在需要回显媒体结果时考虑,当前不是主路径 |
|
|
80
|
+
|
|
81
|
+
## 落地路线
|
|
82
|
+
|
|
83
|
+
### Phase 1:HTML 兼容增强
|
|
84
|
+
|
|
85
|
+
目标:不改变 Bot API 主方法,先改善现有客户端体验。
|
|
86
|
+
|
|
87
|
+
- 统一 Telegram HTML helper,禁止散落手写转义。
|
|
88
|
+
- 长内容默认折叠:diff、工具日志、MCP resource、插件 skill 内容。
|
|
89
|
+
- 命令、路径、模型、候选名使用 `<code>`。
|
|
90
|
+
- 对可能包含 secret/token 的诊断内容使用 `<tg-spoiler>` 或直接不展示。
|
|
91
|
+
|
|
92
|
+
已完成:HTML helper、`/diff` 折叠、现有 CLI/工具归档复用 helper。
|
|
93
|
+
|
|
94
|
+
### Phase 2:Rich Message builder
|
|
95
|
+
|
|
96
|
+
目标:让 rich message 作为可回退能力存在,而不是替换全部消息。
|
|
97
|
+
|
|
98
|
+
已完成:
|
|
99
|
+
|
|
100
|
+
- 新增 `src/telegram/rich.ts`,定义 `InputRichMessage` 的最小 HTML 输入。
|
|
101
|
+
- `TelegramGateway` 增加 `sendRichMessage`、`editRichMessage`、`sendRichMessageDraft`。
|
|
102
|
+
- `TelegramMessagingPort` 和 `BridgeMessagingRouter` 增加 rich HTML send/edit;微信 scope 自动用 fallback HTML。
|
|
103
|
+
- `/rich` 和 `/diff` 先使用 rich 发送,失败回退到 HTML。
|
|
104
|
+
|
|
105
|
+
下一步:
|
|
106
|
+
|
|
107
|
+
- 把 `/status`、`/auth`、`/quota`、MCP resource 等结构消息迁移到 rich table/details。
|
|
108
|
+
- 按真实客户端表现决定是否加入全局配置开关。
|
|
109
|
+
|
|
110
|
+
### Phase 3:Rich draft streaming
|
|
111
|
+
|
|
112
|
+
目标:私聊里的 AI 生成过程更自然。
|
|
113
|
+
|
|
114
|
+
- 仅对 Telegram 私聊启用;群组、topic 默认继续走现有 segmented stream。
|
|
115
|
+
- draft 中使用 `RichBlockThinking` 表示思考中,已生成文本用 paragraph/pre/details。
|
|
116
|
+
- 生成完成后调用 `sendRichMessage` 发送完整消息,不能只依赖 ephemeral draft。
|
|
117
|
+
- 保留旧 `sendMessageDraft` 和 plain segmented stream 回退。
|
|
118
|
+
|
|
119
|
+
## 风险和注意点
|
|
120
|
+
|
|
121
|
+
- Rich Messages 是 2026-06-11 Bot API 10.1 新能力,客户端兼容性需要通过 `/rich` 和 `/diff` 实测。
|
|
122
|
+
- `sendRichMessageDraft` 只面向用户私聊;FoxClaw 的群组、topic、多 bot 场景必须保留旧路径。
|
|
123
|
+
- Rich media block 需要 bot 具备对应发送权限,且媒体 URL/上传处理比文本复杂,暂不作为第一阶段目标。
|
|
124
|
+
- 自动实体识别可能把路径、邮箱、URL、命令误识别;rich builder 应按消息类型决定是否设置 `skip_entity_detection`。
|
|
125
|
+
- HTML/rich 格式必须集中 escape,不能让 Codex 输出或 shell 输出直接拼进标签。
|
package/docs/zh/user-manual.md
CHANGED
|
@@ -265,6 +265,10 @@ TG_ALLOWED_TOPIC_ID=42
|
|
|
265
265
|
|
|
266
266
|
Telegram 会把 HTML 和按钮渲染出来。这里用等宽框模拟实际面板:
|
|
267
267
|
|
|
268
|
+
Telegram Rich Message 能力的专项盘点和后续接入路线见 [Telegram Rich Message 适配专项检查](./telegram-rich-messages.md)。当前默认通道仍优先使用兼容性更稳的 Telegram HTML。
|
|
269
|
+
|
|
270
|
+
可以发送 `/rich` 在当前 Telegram 客户端查看 RichMessage demo。`/diff` 也会优先用 RichMessage 展示 details 和 diff code block,失败时自动回退到 Telegram HTML。
|
|
271
|
+
|
|
268
272
|
```text
|
|
269
273
|
会话偏好
|
|
270
274
|
当前:gpt-5.5 · high · fast=off · default · Agent · Steer current turn
|