@foxden-app/foxclaw 0.5.33 → 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 CHANGED
@@ -2,6 +2,18 @@
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
+
5
17
  ## 0.5.33 - 2026-06-16
6
18
 
7
19
  ### 中文
@@ -23,6 +23,7 @@ export declare class BridgeMessagingRouter {
23
23
  sendTypingInScope(scopeId: string): Promise<void>;
24
24
  clearInlineKeyboard(scopeId: string, messageId: number): Promise<void>;
25
25
  sendDraft(scopeId: string, draftId: number, text: string): Promise<void>;
26
+ sendRichDraft(scopeId: string, draftId: number, html: string, fallbackText: string): Promise<void>;
26
27
  answerCallback(callbackQueryId: string, text: string): Promise<void>;
27
28
  getFile(fileId: string): Promise<TelegramRemoteFile>;
28
29
  downloadResolvedFile(remoteFilePath: string, destinationPath: string): Promise<number>;
@@ -85,6 +85,12 @@ export class BridgeMessagingRouter {
85
85
  }
86
86
  return this.telegram.sendDraft(scopeId, draftId, text);
87
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
+ }
88
94
  answerCallback(callbackQueryId, text) {
89
95
  return this.telegram.answerCallback(callbackQueryId, text);
90
96
  }
@@ -21,6 +21,7 @@ export declare class TelegramMessagingPort implements ChannelPort {
21
21
  sendTypingInScope(bridgeScopeId: string): Promise<void>;
22
22
  clearInlineKeyboard(bridgeScopeId: string, messageId: number): Promise<void>;
23
23
  sendDraft(bridgeScopeId: string, draftId: number, text: string): Promise<void>;
24
+ sendRichDraft(bridgeScopeId: string, draftId: number, html: string): Promise<void>;
24
25
  answerCallback(callbackQueryId: string, text: string): Promise<void>;
25
26
  getFile(fileId: string): Promise<TelegramRemoteFile>;
26
27
  downloadResolvedFile(remoteFilePath: string, destinationPath: string): Promise<number>;
@@ -48,6 +48,10 @@ export class TelegramMessagingPort {
48
48
  const target = parseTelegramTargetFromBridgeScope(bridgeScopeId);
49
49
  await this.gateway.sendMessageDraft(target.chatId, draftId, text, target.topicId);
50
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
+ }
51
55
  answerCallback(callbackQueryId, text) {
52
56
  return this.gateway.answerCallback(callbackQueryId, text);
53
57
  }
@@ -228,6 +228,7 @@ export declare class BridgeSessionCore {
228
228
  private sendMessage;
229
229
  private sendHtmlMessage;
230
230
  private sendRichHtmlMessage;
231
+ private sendRichMarkdownMessage;
231
232
  private editMessage;
232
233
  private editHtmlMessage;
233
234
  private editRichHtmlMessage;
@@ -439,6 +440,7 @@ export declare class BridgeSessionCore {
439
440
  private forgetPreviewRecord;
440
441
  private clearMessageButtons;
441
442
  private sendDraft;
443
+ private sendRichDraft;
442
444
  private renderActiveStatus;
443
445
  private dismissTurnPreview;
444
446
  private ensureStatusMessage;
@@ -459,5 +461,7 @@ export declare class BridgeSessionCore {
459
461
  private findActiveTurnByThreadId;
460
462
  private findActiveTurnsByThreadId;
461
463
  private syncSegmentTimeline;
464
+ private shouldPromoteSegmentToRich;
465
+ private promoteSegmentMessagesToRich;
462
466
  }
463
467
  export { BridgeSessionCore as BridgeController };
@@ -11,6 +11,7 @@ import { TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, buildAttachmentPrompt, isNativeI
11
11
  import { TELEGRAM_MESSAGE_LIMIT, chunkTelegramMessage, chunkTelegramStreamMessage, clipTelegramDraftMessage, } from '../telegram/text.js';
12
12
  import { escapeTelegramHtml, telegramBold, telegramDetails, telegramExpandableBlockquote, telegramPre, telegramPreCode, } from '../telegram/html.js';
13
13
  import { TELEGRAM_RICH_MESSAGE_TEXT_LIMIT } from '../telegram/rich.js';
14
+ import { renderTelegramMarkdownRichHtml } from '../telegram/rich_markdown.js';
14
15
  import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
15
16
  import { BRIDGE_SCOPE_WEIXIN_PREFIX, parseTelegramTargetFromBridgeScope, parseWeixinBridgeScope } from '../core/bridge_scope.js';
16
17
  import { resolveTelegramRenderRoute } from '../telegram/rendering.js';
@@ -2930,6 +2931,7 @@ export class BridgeSessionCore {
2930
2931
  previewActive: previewMessageId > 0,
2931
2932
  draftId: null,
2932
2933
  draftText: null,
2934
+ richDraftDisabled: false,
2933
2935
  buffer: '',
2934
2936
  finalText: null,
2935
2937
  interruptRequested: false,
@@ -3133,7 +3135,7 @@ export class BridgeSessionCore {
3133
3135
  const fallbackKey = active.interruptRequested ? 'interrupted' : 'completed';
3134
3136
  const finalChunks = chunkTelegramMessage(active.finalText || active.buffer, undefined, t(locale, fallbackKey));
3135
3137
  for (const chunk of finalChunks) {
3136
- await this.sendMessage(active.scopeId, chunk);
3138
+ await this.sendRichMarkdownMessage(active.scopeId, chunk);
3137
3139
  }
3138
3140
  }
3139
3141
  shouldMarkPartialOutput = active.interruptRequested
@@ -3372,6 +3374,9 @@ export class BridgeSessionCore {
3372
3374
  return this.sendHtmlMessage(scopeId, fallbackHtml, inlineKeyboard);
3373
3375
  }
3374
3376
  }
3377
+ async sendRichMarkdownMessage(scopeId, text, inlineKeyboard) {
3378
+ return this.sendRichHtmlMessage(scopeId, renderTelegramMarkdownRichHtml(text), escapeTelegramHtml(text), inlineKeyboard);
3379
+ }
3375
3380
  async editMessage(scopeId, messageId, text, inlineKeyboard) {
3376
3381
  await this.messaging.editPlain(scopeId, messageId, text, inlineKeyboard);
3377
3382
  }
@@ -7619,6 +7624,9 @@ export class BridgeSessionCore {
7619
7624
  async sendDraft(scopeId, draftId, text) {
7620
7625
  await this.messaging.sendDraft(scopeId, draftId, text);
7621
7626
  }
7627
+ async sendRichDraft(scopeId, draftId, html, fallbackText) {
7628
+ await this.messaging.sendRichDraft(scopeId, draftId, html, fallbackText);
7629
+ }
7622
7630
  renderActiveStatus(active) {
7623
7631
  const locale = this.localeForChat(active.scopeId);
7624
7632
  return renderActiveTurnStatus(locale, {
@@ -7864,12 +7872,34 @@ export class BridgeSessionCore {
7864
7872
  active.draftId = crypto.randomInt(1, 2_147_483_647);
7865
7873
  }
7866
7874
  try {
7867
- await this.sendDraft(active.scopeId, active.draftId, draftText);
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
+ }
7868
7881
  active.draftText = draftText;
7869
7882
  }
7870
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
+ }
7871
7901
  this.logger.warn('telegram.draft_send_failed', {
7872
- error: String(error),
7902
+ error: String(finalError),
7873
7903
  turnId: active.turnId,
7874
7904
  draftId: active.draftId,
7875
7905
  });
@@ -7904,7 +7934,7 @@ export class BridgeSessionCore {
7904
7934
  if (!existing) {
7905
7935
  try {
7906
7936
  const messageId = await this.sendMessage(active.scopeId, chunk);
7907
- segment.messages.push({ messageId, text: chunk });
7937
+ segment.messages.push({ messageId, text: chunk, richHtml: null, richFailedForText: null });
7908
7938
  active.statusNeedsRebase = true;
7909
7939
  }
7910
7940
  catch (error) {
@@ -7927,6 +7957,8 @@ export class BridgeSessionCore {
7927
7957
  try {
7928
7958
  await this.editMessage(active.scopeId, existing.messageId, chunk);
7929
7959
  existing.text = chunk;
7960
+ existing.richHtml = null;
7961
+ existing.richFailedForText = null;
7930
7962
  index += 1;
7931
7963
  }
7932
7964
  catch (error) {
@@ -7964,6 +7996,47 @@ export class BridgeSessionCore {
7964
7996
  }
7965
7997
  }
7966
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
+ }
7967
8040
  }
7968
8041
  }
7969
8042
  function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
@@ -0,0 +1,2 @@
1
+ export declare function renderTelegramMarkdownRichHtml(markdown: string): string;
2
+ export declare function renderInlineRichHtml(markdown: string): string;
@@ -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('"', '&quot;');
108
+ }
109
+ function unescapeHtmlAttribute(value) {
110
+ return value
111
+ .replaceAll('&amp;', '&')
112
+ .replaceAll('&lt;', '<')
113
+ .replaceAll('&gt;', '>')
114
+ .replaceAll('&quot;', '"');
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.33",
3
+ "version": "0.5.34",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",