@foxden-app/foxclaw 0.5.33 → 0.5.35
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 +22 -0
- package/dist/channels/bridge_messaging_router.d.ts +1 -0
- package/dist/channels/bridge_messaging_router.js +6 -0
- package/dist/channels/telegram/telegram_messaging_port.d.ts +1 -0
- package/dist/channels/telegram/telegram_messaging_port.js +4 -0
- package/dist/controller/controller.d.ts +5 -0
- package/dist/controller/controller.js +154 -9
- package/dist/telegram/rich_markdown.d.ts +2 -0
- package/dist/telegram/rich_markdown.js +115 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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.35 - 2026-06-17
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- `/status`、`/auth`、`/auth sync status/events/trace` 在 Telegram 上优先使用 RichMessage 展示,内部状态会自动整理成标题、表格、列表和可展开原文。
|
|
9
|
+
- 保留微信和 RichMessage 失败 fallback:微信继续走原文本通道,Telegram rich 发送失败时退回原 HTML/plain 内容,不影响按钮和回调。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- `/status`, `/auth`, and `/auth sync status/events/trace` now prefer RichMessage on Telegram, rendering internal diagnostics as headings, tables, lists, and expandable plain-text details.
|
|
13
|
+
- Weixin and RichMessage failure fallbacks are preserved: Weixin keeps the previous text path, and Telegram falls back without breaking buttons or callbacks.
|
|
14
|
+
|
|
15
|
+
## 0.5.34 - 2026-06-17
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 普通 Codex 输出接入 Telegram RichMessage:流式阶段继续稳定发送纯文本,segment 完成后自动把已发消息编辑为富文本,支持标题、列表、引用、代码块、行内 code、粗体和安全链接。
|
|
19
|
+
- draft 流式路径优先使用官方 `sendRichMessageDraft`,发送失败时对当前 turn 自动熔断回原纯文本 draft,避免影响实时输出。
|
|
20
|
+
- 新增 Markdown 到 RichMessage HTML 的安全转换与回归测试,保留 Telegram/微信 fallback:RichMessage 失败时不会破坏原纯文本输出,微信继续使用原 HTML/plain 降级通道。
|
|
21
|
+
|
|
22
|
+
### English
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
- Added a safe Markdown-to-RichMessage HTML renderer with regression coverage while preserving Telegram/Weixin fallbacks so RichMessage failures do not break existing output.
|
|
26
|
+
|
|
5
27
|
## 0.5.33 - 2026-06-16
|
|
6
28
|
|
|
7
29
|
### 中文
|
|
@@ -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,8 @@ export declare class BridgeSessionCore {
|
|
|
228
228
|
private sendMessage;
|
|
229
229
|
private sendHtmlMessage;
|
|
230
230
|
private sendRichHtmlMessage;
|
|
231
|
+
private sendRichMarkdownMessage;
|
|
232
|
+
private sendRichInternalMessage;
|
|
231
233
|
private editMessage;
|
|
232
234
|
private editHtmlMessage;
|
|
233
235
|
private editRichHtmlMessage;
|
|
@@ -439,6 +441,7 @@ export declare class BridgeSessionCore {
|
|
|
439
441
|
private forgetPreviewRecord;
|
|
440
442
|
private clearMessageButtons;
|
|
441
443
|
private sendDraft;
|
|
444
|
+
private sendRichDraft;
|
|
442
445
|
private renderActiveStatus;
|
|
443
446
|
private dismissTurnPreview;
|
|
444
447
|
private ensureStatusMessage;
|
|
@@ -459,5 +462,7 @@ export declare class BridgeSessionCore {
|
|
|
459
462
|
private findActiveTurnByThreadId;
|
|
460
463
|
private findActiveTurnsByThreadId;
|
|
461
464
|
private syncSegmentTimeline;
|
|
465
|
+
private shouldPromoteSegmentToRich;
|
|
466
|
+
private promoteSegmentMessagesToRich;
|
|
462
467
|
}
|
|
463
468
|
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';
|
|
@@ -507,7 +508,7 @@ export class BridgeSessionCore {
|
|
|
507
508
|
}
|
|
508
509
|
lines.push(...codexUsageLines);
|
|
509
510
|
lines.push(...codexLocalUsageLines);
|
|
510
|
-
await this.
|
|
511
|
+
await this.sendRichInternalMessage(scopeId, '/status', lines.join('\n'));
|
|
511
512
|
return;
|
|
512
513
|
}
|
|
513
514
|
case 'account': {
|
|
@@ -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.
|
|
3138
|
+
await this.sendRichMarkdownMessage(active.scopeId, chunk);
|
|
3137
3139
|
}
|
|
3138
3140
|
}
|
|
3139
3141
|
shouldMarkPartialOutput = active.interruptRequested
|
|
@@ -3372,6 +3374,15 @@ 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
|
+
}
|
|
3380
|
+
async sendRichInternalMessage(scopeId, title, text, inlineKeyboard) {
|
|
3381
|
+
if (scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)) {
|
|
3382
|
+
return this.sendMessage(scopeId, text, inlineKeyboard);
|
|
3383
|
+
}
|
|
3384
|
+
return this.sendRichHtmlMessage(scopeId, formatRichInternalMessage(title, text), escapeTelegramHtml(text), inlineKeyboard);
|
|
3385
|
+
}
|
|
3375
3386
|
async editMessage(scopeId, messageId, text, inlineKeyboard) {
|
|
3376
3387
|
await this.messaging.editPlain(scopeId, messageId, text, inlineKeyboard);
|
|
3377
3388
|
}
|
|
@@ -4567,17 +4578,20 @@ export class BridgeSessionCore {
|
|
|
4567
4578
|
await this.refreshCurrentCodexAuthQuota(state);
|
|
4568
4579
|
const record = createPendingAuthChoiceList(scopeId, state.candidates, listRequest);
|
|
4569
4580
|
this.pendingAuthChoiceLists.set(record.localId, record);
|
|
4570
|
-
const
|
|
4581
|
+
const authListMessage = renderAuthListMessage(locale, state, this.authDisplayBotLabel(), parseWeixinBridgeScope(scopeId) !== null, record);
|
|
4582
|
+
const messageId = await this.sendRichInternalMessage(scopeId, '/auth', authListMessage, authChoiceKeyboard(locale, record));
|
|
4571
4583
|
record.messageId = messageId;
|
|
4572
4584
|
}
|
|
4573
4585
|
async handleAuthSyncCommand(scopeId, locale, args) {
|
|
4574
4586
|
const action = args[0]?.toLowerCase() ?? 'status';
|
|
4575
4587
|
if (action === 'status') {
|
|
4576
|
-
|
|
4588
|
+
const message = formatAuthSyncStatus(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, this.getRuntimeStatus().authProactiveRefresh ?? null);
|
|
4589
|
+
await this.sendRichInternalMessage(scopeId, '/auth sync status', message);
|
|
4577
4590
|
return;
|
|
4578
4591
|
}
|
|
4579
4592
|
if (action === 'events') {
|
|
4580
|
-
|
|
4593
|
+
const message = formatAuthSyncEvents(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, args.slice(1).join(' ').trim() || null);
|
|
4594
|
+
await this.sendRichInternalMessage(scopeId, '/auth sync events', message);
|
|
4581
4595
|
return;
|
|
4582
4596
|
}
|
|
4583
4597
|
if (action === 'trace') {
|
|
@@ -4586,7 +4600,7 @@ export class BridgeSessionCore {
|
|
|
4586
4600
|
await this.sendMessage(scopeId, t(locale, 'auth_sync_trace_missing'));
|
|
4587
4601
|
return;
|
|
4588
4602
|
}
|
|
4589
|
-
await this.
|
|
4603
|
+
await this.sendRichInternalMessage(scopeId, '/auth sync trace', formatAuthSyncTrace(locale, this.coordinator?.getAuthSyncStatus?.() ?? null, requestId));
|
|
4590
4604
|
return;
|
|
4591
4605
|
}
|
|
4592
4606
|
if (action === 'test') {
|
|
@@ -7619,6 +7633,9 @@ export class BridgeSessionCore {
|
|
|
7619
7633
|
async sendDraft(scopeId, draftId, text) {
|
|
7620
7634
|
await this.messaging.sendDraft(scopeId, draftId, text);
|
|
7621
7635
|
}
|
|
7636
|
+
async sendRichDraft(scopeId, draftId, html, fallbackText) {
|
|
7637
|
+
await this.messaging.sendRichDraft(scopeId, draftId, html, fallbackText);
|
|
7638
|
+
}
|
|
7622
7639
|
renderActiveStatus(active) {
|
|
7623
7640
|
const locale = this.localeForChat(active.scopeId);
|
|
7624
7641
|
return renderActiveTurnStatus(locale, {
|
|
@@ -7864,12 +7881,34 @@ export class BridgeSessionCore {
|
|
|
7864
7881
|
active.draftId = crypto.randomInt(1, 2_147_483_647);
|
|
7865
7882
|
}
|
|
7866
7883
|
try {
|
|
7867
|
-
|
|
7884
|
+
if (active.richDraftDisabled) {
|
|
7885
|
+
await this.sendDraft(active.scopeId, active.draftId, draftText);
|
|
7886
|
+
}
|
|
7887
|
+
else {
|
|
7888
|
+
await this.sendRichDraft(active.scopeId, active.draftId, renderTelegramMarkdownRichHtml(draftText), draftText);
|
|
7889
|
+
}
|
|
7868
7890
|
active.draftText = draftText;
|
|
7869
7891
|
}
|
|
7870
7892
|
catch (error) {
|
|
7893
|
+
let finalError = error;
|
|
7894
|
+
if (!active.richDraftDisabled) {
|
|
7895
|
+
active.richDraftDisabled = true;
|
|
7896
|
+
this.logger.warn('telegram.rich_draft_send_failed', {
|
|
7897
|
+
error: String(finalError),
|
|
7898
|
+
turnId: active.turnId,
|
|
7899
|
+
draftId: active.draftId,
|
|
7900
|
+
});
|
|
7901
|
+
try {
|
|
7902
|
+
await this.sendDraft(active.scopeId, active.draftId, draftText);
|
|
7903
|
+
active.draftText = draftText;
|
|
7904
|
+
return;
|
|
7905
|
+
}
|
|
7906
|
+
catch (fallbackError) {
|
|
7907
|
+
finalError = fallbackError;
|
|
7908
|
+
}
|
|
7909
|
+
}
|
|
7871
7910
|
this.logger.warn('telegram.draft_send_failed', {
|
|
7872
|
-
error: String(
|
|
7911
|
+
error: String(finalError),
|
|
7873
7912
|
turnId: active.turnId,
|
|
7874
7913
|
draftId: active.draftId,
|
|
7875
7914
|
});
|
|
@@ -7904,7 +7943,7 @@ export class BridgeSessionCore {
|
|
|
7904
7943
|
if (!existing) {
|
|
7905
7944
|
try {
|
|
7906
7945
|
const messageId = await this.sendMessage(active.scopeId, chunk);
|
|
7907
|
-
segment.messages.push({ messageId, text: chunk });
|
|
7946
|
+
segment.messages.push({ messageId, text: chunk, richHtml: null, richFailedForText: null });
|
|
7908
7947
|
active.statusNeedsRebase = true;
|
|
7909
7948
|
}
|
|
7910
7949
|
catch (error) {
|
|
@@ -7927,6 +7966,8 @@ export class BridgeSessionCore {
|
|
|
7927
7966
|
try {
|
|
7928
7967
|
await this.editMessage(active.scopeId, existing.messageId, chunk);
|
|
7929
7968
|
existing.text = chunk;
|
|
7969
|
+
existing.richHtml = null;
|
|
7970
|
+
existing.richFailedForText = null;
|
|
7930
7971
|
index += 1;
|
|
7931
7972
|
}
|
|
7932
7973
|
catch (error) {
|
|
@@ -7964,6 +8005,47 @@ export class BridgeSessionCore {
|
|
|
7964
8005
|
}
|
|
7965
8006
|
}
|
|
7966
8007
|
}
|
|
8008
|
+
if (this.shouldPromoteSegmentToRich(active, segment)) {
|
|
8009
|
+
await this.promoteSegmentMessagesToRich(active, segment, chunks);
|
|
8010
|
+
}
|
|
8011
|
+
}
|
|
8012
|
+
shouldPromoteSegmentToRich(active, segment) {
|
|
8013
|
+
return !active.scopeId.startsWith(BRIDGE_SCOPE_WEIXIN_PREFIX)
|
|
8014
|
+
&& segment.completed
|
|
8015
|
+
&& segment.outputKind !== 'error'
|
|
8016
|
+
&& Boolean(segment.text.trim());
|
|
8017
|
+
}
|
|
8018
|
+
async promoteSegmentMessagesToRich(active, segment, chunks) {
|
|
8019
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
8020
|
+
const chunk = chunks[index];
|
|
8021
|
+
const existing = segment.messages[index];
|
|
8022
|
+
if (!existing || !chunk.trim()) {
|
|
8023
|
+
continue;
|
|
8024
|
+
}
|
|
8025
|
+
const richHtml = renderTelegramMarkdownRichHtml(chunk);
|
|
8026
|
+
if (existing.richHtml === richHtml || existing.richFailedForText === chunk) {
|
|
8027
|
+
continue;
|
|
8028
|
+
}
|
|
8029
|
+
try {
|
|
8030
|
+
await this.messaging.editRichHtml(active.scopeId, existing.messageId, richHtml, escapeTelegramHtml(chunk));
|
|
8031
|
+
existing.richHtml = richHtml;
|
|
8032
|
+
existing.richFailedForText = null;
|
|
8033
|
+
}
|
|
8034
|
+
catch (error) {
|
|
8035
|
+
if (isTelegramMessageGone(error)) {
|
|
8036
|
+
segment.messages.splice(index);
|
|
8037
|
+
return;
|
|
8038
|
+
}
|
|
8039
|
+
existing.richFailedForText = chunk;
|
|
8040
|
+
this.logger.warn('telegram.stream_rich_edit_failed', {
|
|
8041
|
+
error: String(error),
|
|
8042
|
+
turnId: active.turnId,
|
|
8043
|
+
itemId: segment.itemId,
|
|
8044
|
+
messageId: existing.messageId,
|
|
8045
|
+
chunkIndex: index,
|
|
8046
|
+
});
|
|
8047
|
+
}
|
|
8048
|
+
}
|
|
7967
8049
|
}
|
|
7968
8050
|
}
|
|
7969
8051
|
function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
|
|
@@ -8367,6 +8449,69 @@ function formatRichDemoFallbackMessage(locale) {
|
|
|
8367
8449
|
telegramPreCode('sendRichMessage({ rich_message: { html } })', 'typescript'),
|
|
8368
8450
|
].join('\n');
|
|
8369
8451
|
}
|
|
8452
|
+
function formatRichInternalMessage(title, text) {
|
|
8453
|
+
const sections = splitRichInternalSections(text);
|
|
8454
|
+
const body = sections.map(formatRichInternalSection).filter(Boolean).join('\n');
|
|
8455
|
+
return [
|
|
8456
|
+
`<h3>${escapeTelegramHtml(title)}</h3>`,
|
|
8457
|
+
body || `<p>${escapeTelegramHtml(text)}</p>`,
|
|
8458
|
+
telegramDetails('Plain text', telegramPreCode(text, 'text')),
|
|
8459
|
+
'<footer>FoxClaw · RichMessage</footer>',
|
|
8460
|
+
].join('\n');
|
|
8461
|
+
}
|
|
8462
|
+
function splitRichInternalSections(text) {
|
|
8463
|
+
const sections = [];
|
|
8464
|
+
let current = [];
|
|
8465
|
+
for (const rawLine of text.split('\n')) {
|
|
8466
|
+
const line = rawLine.trimEnd();
|
|
8467
|
+
if (!line.trim()) {
|
|
8468
|
+
if (current.length > 0) {
|
|
8469
|
+
sections.push(current);
|
|
8470
|
+
current = [];
|
|
8471
|
+
}
|
|
8472
|
+
continue;
|
|
8473
|
+
}
|
|
8474
|
+
current.push(line);
|
|
8475
|
+
}
|
|
8476
|
+
if (current.length > 0) {
|
|
8477
|
+
sections.push(current);
|
|
8478
|
+
}
|
|
8479
|
+
return sections;
|
|
8480
|
+
}
|
|
8481
|
+
function formatRichInternalSection(lines) {
|
|
8482
|
+
const rows = lines
|
|
8483
|
+
.map(parseRichInternalKeyValue)
|
|
8484
|
+
.filter((row) => row !== null);
|
|
8485
|
+
if (rows.length >= Math.max(2, Math.floor(lines.length * 0.6))) {
|
|
8486
|
+
return [
|
|
8487
|
+
'<table bordered striped>',
|
|
8488
|
+
...rows.map(([key, value]) => (`<tr><td>${escapeTelegramHtml(key)}</td><td>${formatRichInternalValue(value)}</td></tr>`)),
|
|
8489
|
+
'</table>',
|
|
8490
|
+
].join('\n');
|
|
8491
|
+
}
|
|
8492
|
+
return [
|
|
8493
|
+
'<ul>',
|
|
8494
|
+
...lines.map(line => `<li>${formatRichInternalValue(line.replace(/^\s*[-*]\s+/, '').replace(/^\s*\d+[.)]\s+/, ''))}</li>`),
|
|
8495
|
+
'</ul>',
|
|
8496
|
+
].join('\n');
|
|
8497
|
+
}
|
|
8498
|
+
function parseRichInternalKeyValue(line) {
|
|
8499
|
+
const separator = line.includes(':') ? ':' : ':';
|
|
8500
|
+
const index = line.indexOf(separator);
|
|
8501
|
+
if (index <= 0) {
|
|
8502
|
+
return null;
|
|
8503
|
+
}
|
|
8504
|
+
const key = line.slice(0, index).trim();
|
|
8505
|
+
const value = line.slice(index + separator.length).trim();
|
|
8506
|
+
if (!key || !value || key.length > 80) {
|
|
8507
|
+
return null;
|
|
8508
|
+
}
|
|
8509
|
+
return [key, value];
|
|
8510
|
+
}
|
|
8511
|
+
function formatRichInternalValue(value) {
|
|
8512
|
+
const escaped = escapeTelegramHtml(value);
|
|
8513
|
+
return escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
8514
|
+
}
|
|
8370
8515
|
function clipRichMessageText(value, limit) {
|
|
8371
8516
|
if (value.length <= limit) {
|
|
8372
8517
|
return value;
|
|
@@ -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
|
+
}
|