switchroom 0.16.23 → 0.16.27
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/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -1,649 +1,467 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram-
|
|
2
|
+
* Telegram rich-message formatting helpers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Since the Bot API 10.1 migration (#2669) every outbound message goes
|
|
5
|
+
* through `sendRichMessage` / `editMessageText({ markdown })` with raw GFM
|
|
6
|
+
* markdown. The old markdown→HTML engine, the HTML sanitizer, and the
|
|
7
|
+
* MarkdownV2 escaper are all gone — there is exactly ONE rendering path.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* What lives here now:
|
|
10
|
+
* - repairEscapedWhitespace: format-agnostic repair of LLM-side JSON
|
|
11
|
+
* escape bungles (literal `\n` etc.). Applied to raw text BEFORE it is
|
|
12
|
+
* handed to the rich-message path.
|
|
13
|
+
* - escapeMarkdown: escape GFM-special characters in DYNAMIC content
|
|
14
|
+
* (filenames, user input, ids) so they render literally inside a
|
|
15
|
+
* hand-built markdown card instead of being interpreted as formatting.
|
|
16
|
+
* - splitMarkdownChunks: split a long markdown body into <=maxLen chunks
|
|
17
|
+
* at safe boundaries (never mid code-fence, never mid table row),
|
|
18
|
+
* defaulting maxLen to the rich-message cap of 32768.
|
|
19
|
+
* - normalizeParagraphBreaks: promote a LONE prose `\n` into a GFM hard
|
|
20
|
+
* break (` \n`) so paragraph separation survives the rich GFM path,
|
|
21
|
+
* while leaving lists / tables / code / `\n\n` untouched.
|
|
22
|
+
* - RICH_MESSAGE_MAX_CHARS: the rich-text wire cap (32768).
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
26
|
+
* Rich-message wire cap. Bot API 10.1 rich messages allow up to 32768
|
|
27
|
+
* UTF-8 characters (JSDoc-only in @grammyjs/types; no exported constant,
|
|
28
|
+
* so we hard-code it). The legacy plain-text `sendMessage`/`editMessageText`
|
|
29
|
+
* cap of 4096 does NOT apply on the rich path.
|
|
22
30
|
*/
|
|
23
|
-
export const
|
|
24
|
-
'b', 'strong',
|
|
25
|
-
'i', 'em',
|
|
26
|
-
'u', 'ins',
|
|
27
|
-
's', 'strike', 'del',
|
|
28
|
-
'span', // requires class="tg-spoiler"
|
|
29
|
-
'tg-spoiler',
|
|
30
|
-
'a',
|
|
31
|
-
'tg-emoji',
|
|
32
|
-
'code',
|
|
33
|
-
'pre',
|
|
34
|
-
'blockquote',
|
|
35
|
-
])
|
|
31
|
+
export const RICH_MESSAGE_MAX_CHARS = 32768
|
|
36
32
|
|
|
37
33
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
34
|
+
* Escape the GFM-markdown special characters so a dynamic value
|
|
35
|
+
* (a filename, an id, arbitrary user text) renders LITERALLY inside a
|
|
36
|
+
* hand-built markdown card instead of being parsed as formatting.
|
|
40
37
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* we treat it as markdown and escape it. If the model wrote `<b>foo</b>`,
|
|
46
|
-
* we trust it.
|
|
38
|
+
* Used wherever the old HTML cards called `escapeHtml(value)` while
|
|
39
|
+
* interpolating into `<b>…</b>` / `<code>…</code>`. The markdown
|
|
40
|
+
* equivalent is `**${escapeMarkdown(value)}**` / `` `${value}` `` (code
|
|
41
|
+
* spans don't need escaping — backtick content is literal).
|
|
47
42
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
43
|
+
* Escapes only the characters that trigger INLINE formatting in
|
|
44
|
+
* rich-markdown — backslash, `` ` ``, `*`, `_`, `~`, `=`, `[`, `]`, `|`.
|
|
45
|
+
* Deliberately does NOT escape `.` `-` `+` `#` `(` `)` `{` `}` `!` `>`:
|
|
46
|
+
* those are only meaningful at line-start (headings, lists, quotes) or in
|
|
47
|
+
* link/structure context, and escaping them mid-word (filenames like
|
|
48
|
+
* `foo.ts`, versions like `v1.2-rc`, URLs) would litter the output with
|
|
49
|
+
* visible backslashes. The backslash is escaped first so we never
|
|
50
|
+
* double-escape.
|
|
53
51
|
*/
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
let scanText = text.replace(/```[\s\S]*?```/g, '')
|
|
57
|
-
// Then strip inline code spans (single backticks, no newlines)
|
|
58
|
-
scanText = scanText.replace(/`[^`\n]+`/g, '')
|
|
59
|
-
|
|
60
|
-
// If the stripped text contains markdown-only syntax (**bold**,
|
|
61
|
-
// [text](url), or markdown headings), the caller is writing markdown
|
|
62
|
-
// even if they ALSO sprinkled some <b> tags in. Treat as markdown.
|
|
63
|
-
if (/\*\*[^\n*]+\*\*/.test(scanText)) return false
|
|
64
|
-
if (/\[[^\]]+\]\([^)]+\)/.test(scanText)) return false
|
|
65
|
-
if (/^#{1,6}\s+/m.test(scanText)) return false
|
|
66
|
-
|
|
67
|
-
// Now count remaining HTML tags
|
|
68
|
-
const tagMatches = scanText.matchAll(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi)
|
|
69
|
-
let count = 0
|
|
70
|
-
for (const m of tagMatches) {
|
|
71
|
-
const tag = m[1].toLowerCase()
|
|
72
|
-
if (!TELEGRAM_HTML_TAGS.has(tag)) {
|
|
73
|
-
// Found an unsupported tag — caller didn't intend Telegram HTML
|
|
74
|
-
return false
|
|
75
|
-
}
|
|
76
|
-
count++
|
|
77
|
-
}
|
|
78
|
-
return count > 0
|
|
52
|
+
export function escapeMarkdown(text: string): string {
|
|
53
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
79
54
|
}
|
|
80
55
|
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// Markdown table → Telegram HTML
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
56
|
/**
|
|
86
|
-
*
|
|
57
|
+
* Repair LLM-side JSON escape bungles.
|
|
87
58
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
59
|
+
* Some MCP clients (and some LLM tool-call generators) occasionally emit a
|
|
60
|
+
* tool-argument string whose whitespace has been double-escaped — real
|
|
61
|
+
* newlines become the two-character sequence `\n`, tabs become `\t`, etc.
|
|
62
|
+
* The message then ships to Telegram intact and the user sees literal
|
|
63
|
+
* `\n\n` in the chat instead of paragraph breaks.
|
|
92
64
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
65
|
+
* Unescape literal `\n`, `\r`, `\t`, and `\"` sequences everywhere EXCEPT
|
|
66
|
+
* inside code spans (inline backtick spans and fenced ``` blocks). Those
|
|
67
|
+
* regions are masked with placeholders before the unescape pass so that a
|
|
68
|
+
* literal `\n` a user typed inside a shell snippet or regex is preserved
|
|
69
|
+
* verbatim. The genuine escaped-backslash sequence `\\n` (which the user
|
|
70
|
+
* intended as a literal backslash + n, not a newline) is handled by
|
|
71
|
+
* protecting `\\` before touching `\n`.
|
|
96
72
|
*
|
|
97
|
-
*
|
|
73
|
+
* This deliberately fires even when the message contains real newlines —
|
|
74
|
+
* the old whole-message heuristic ("bail if any real newline exists") was
|
|
75
|
+
* too broad and prevented repair of mixed messages that had both real
|
|
76
|
+
* newlines and stray literal `\n` escape sequences outside code spans.
|
|
98
77
|
*/
|
|
99
|
-
function
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (!sepRe.test(lines[1].trim())) return null
|
|
109
|
-
// Double-check: the header row must also look like a table row
|
|
110
|
-
if (!rowRe.test(lines[0])) return null
|
|
111
|
-
// Must have at least one data row
|
|
112
|
-
if (lines.length < 3 || !rowRe.test(lines[2])) return null
|
|
113
|
-
|
|
114
|
-
const splitRow = (line: string): string[] =>
|
|
115
|
-
line
|
|
116
|
-
.replace(/^\|/, '')
|
|
117
|
-
.replace(/\|$/, '')
|
|
118
|
-
.split('|')
|
|
119
|
-
.map(c => c.trim())
|
|
120
|
-
|
|
121
|
-
const headers = splitRow(lines[0])
|
|
122
|
-
const rows: string[][] = []
|
|
123
|
-
for (let i = 2; i < lines.length; i++) {
|
|
124
|
-
if (!rowRe.test(lines[i])) break
|
|
125
|
-
rows.push(splitRow(lines[i]))
|
|
126
|
-
}
|
|
78
|
+
export function repairEscapedWhitespace(text: string): string {
|
|
79
|
+
if (!/\\[nrt"\\]/.test(text)) return text
|
|
80
|
+
|
|
81
|
+
// Per-call random nonce prevents sentinel collision: if user text happens to
|
|
82
|
+
// contain our placeholder bytes, the restore step would look up an out-of-range
|
|
83
|
+
// index and produce "undefined" in the Telegram output. A nonce that is unique
|
|
84
|
+
// per invocation makes the sentinel statistically impossible to collide with.
|
|
85
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
86
|
+
const BACKSLASH_PH = `\x00BK${nonce}_`
|
|
127
87
|
|
|
128
|
-
|
|
129
|
-
|
|
88
|
+
// Mask fenced code blocks and inline code spans so the unescape pass never
|
|
89
|
+
// touches their content (shared masker — see maskCodeRegions for the exact
|
|
90
|
+
// closed-fence / inline-span definitions).
|
|
91
|
+
const { masked, restore } = maskCodeRegions(text, nonce)
|
|
92
|
+
|
|
93
|
+
// Order matters: protect existing `\\` first so `\\n` stays as a literal
|
|
94
|
+
// backslash + n and doesn't become a newline.
|
|
95
|
+
const unescaped = masked
|
|
96
|
+
.replace(/\\\\/g, BACKSLASH_PH)
|
|
97
|
+
.replace(/\\n/g, '\n')
|
|
98
|
+
.replace(/\\r/g, '\r')
|
|
99
|
+
.replace(/\\t/g, '\t')
|
|
100
|
+
.replace(/\\"/g, '"')
|
|
101
|
+
.replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
|
|
102
|
+
|
|
103
|
+
// Restore masked code spans verbatim.
|
|
104
|
+
return restore(unescaped)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Shared code-region masking (used by repairEscapedWhitespace AND
|
|
109
|
+
// normalizeParagraphBreaks). Closed fenced blocks (``` … ```) and inline
|
|
110
|
+
// code spans (` … `) are replaced with unique placeholders so neither pass
|
|
111
|
+
// ever rewrites their interior; `restore` puts them back verbatim.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
interface MaskedCode {
|
|
115
|
+
masked: string
|
|
116
|
+
restore: (s: string) => string
|
|
117
|
+
/** The placeholder prefix injected for each masked region (fence or span). */
|
|
118
|
+
placeholder: string
|
|
130
119
|
}
|
|
131
120
|
|
|
132
121
|
/**
|
|
133
|
-
*
|
|
122
|
+
* Mask fenced code blocks and inline code spans with collision-resistant
|
|
123
|
+
* placeholders. `nonce` is a per-call random string the caller already holds
|
|
124
|
+
* (so two maskers in one function share one nonce namespace cleanly).
|
|
134
125
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* appended as " — value".
|
|
139
|
-
* - otherwise → <pre> block with padded columns.
|
|
126
|
+
* Fenced blocks are extracted FIRST and only when CLOSED (matching ```), so an
|
|
127
|
+
* unclosed fence is left intact rather than misparsed by the inline pass. Inline
|
|
128
|
+
* spans use `[^\`\n]+` — the same definition the chunker treats as code.
|
|
140
129
|
*/
|
|
141
|
-
function
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.join('')
|
|
156
|
-
return `• <b>${key}</b>${rest}`
|
|
130
|
+
function maskCodeRegions(text: string, nonce: string): MaskedCode {
|
|
131
|
+
const CODE_MASK_PH = `\x00RM${nonce}_`
|
|
132
|
+
const codeMasks: string[] = []
|
|
133
|
+
|
|
134
|
+
const masked = text
|
|
135
|
+
.replace(/```[\s\S]*?```/g, (m) => {
|
|
136
|
+
const idx = codeMasks.length
|
|
137
|
+
codeMasks.push(m)
|
|
138
|
+
return `${CODE_MASK_PH}${idx}\x00`
|
|
139
|
+
})
|
|
140
|
+
.replace(/`[^`\n]+`/g, (m) => {
|
|
141
|
+
const idx = codeMasks.length
|
|
142
|
+
codeMasks.push(m)
|
|
143
|
+
return `${CODE_MASK_PH}${idx}\x00`
|
|
157
144
|
})
|
|
158
|
-
// Prepend header names as a label line when there are 2+ columns
|
|
159
|
-
const headerLine =
|
|
160
|
-
colCount >= 2
|
|
161
|
-
? `<b>${headers.map(h => escapeHtml(h)).join(' / ')}</b>\n`
|
|
162
|
-
: ''
|
|
163
|
-
return headerLine + bullets.join('\n')
|
|
164
|
-
}
|
|
165
145
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const widths = headers.map((_, ci) =>
|
|
170
|
-
Math.max(...allRows.map(r => (r[ci] ?? '').length))
|
|
146
|
+
const restoreRe = new RegExp(
|
|
147
|
+
`${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`,
|
|
148
|
+
'g',
|
|
171
149
|
)
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const formatRow = (r: string[]) =>
|
|
175
|
-
headers.map((_, ci) => pad(r[ci] ?? '', widths[ci])).join(' ')
|
|
150
|
+
const restore = (s: string): string =>
|
|
151
|
+
s.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
|
|
176
152
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const lines = [
|
|
180
|
-
formatRow(headers),
|
|
181
|
-
sepLine,
|
|
182
|
-
...rows.map(r => formatRow(r)),
|
|
183
|
-
]
|
|
184
|
-
return `<pre>${escapeHtml(lines.join('\n'))}</pre>`
|
|
153
|
+
return { masked, restore, placeholder: CODE_MASK_PH }
|
|
185
154
|
}
|
|
186
155
|
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Paragraph-break normalizer — make lone prose newlines survive the GFM path
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
187
160
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
161
|
+
* GFM (the rich-message render path) treats a LONE `\n` as a *soft* break: the
|
|
162
|
+
* two lines collapse onto the same visual line, so a model that separates its
|
|
163
|
+
* paragraphs with a single newline produces a cramped wall of text. The old
|
|
164
|
+
* markdown→HTML path rendered every `\n` as a hard break, which masked the
|
|
165
|
+
* habit; the rich path no longer does.
|
|
166
|
+
*
|
|
167
|
+
* This normalizer fixes that DETERMINISTICALLY without breaking GFM block
|
|
168
|
+
* syntax. It does exactly two things, on code-masked text:
|
|
191
169
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
170
|
+
* 1. Collapse runs of 3+ newlines down to exactly `\n\n` (never collapse a
|
|
171
|
+
* genuine `\n\n` paragraph gap).
|
|
172
|
+
* 2. Promote a LONE `\n` (one not adjacent to another `\n`) into a GFM hard
|
|
173
|
+
* break (` \n`, two trailing spaces) — but ONLY when it is a genuine
|
|
174
|
+
* prose paragraph break.
|
|
175
|
+
*
|
|
176
|
+
* The promotion heuristic is deliberately CONSERVATIVE — it prefers a false
|
|
177
|
+
* negative (leaving a break un-promoted, so two prose lines stay cramped) over
|
|
178
|
+
* a false positive (double-spacing a tight list or table). A break is promoted
|
|
179
|
+
* only when ALL of these hold:
|
|
180
|
+
*
|
|
181
|
+
* - The preceding line ends in sentence-terminal punctuation: `.`, `!`, `?`,
|
|
182
|
+
* `:`, or a closing `)` / `"` / `'` / `’` / `”` that itself follows such a
|
|
183
|
+
* terminator (e.g. `...done.")`).
|
|
184
|
+
* - The preceding line is NOT itself a marker line (list / table / quote /
|
|
185
|
+
* heading).
|
|
186
|
+
* - The NEXT line starts with a non-marker character: not a list bullet
|
|
187
|
+
* (`-`/`*`/`+`/`\d+.`/`\d+)`, incl. indented), not a table row (`|` or
|
|
188
|
+
* ` | `), not a blockquote (`>`), not a heading (`#`), not blank.
|
|
189
|
+
*
|
|
190
|
+
* Code fences and inline code are masked out before any of this runs, so their
|
|
191
|
+
* interior `\n`s are never touched.
|
|
196
192
|
*/
|
|
197
|
-
function
|
|
198
|
-
text
|
|
199
|
-
store: string[],
|
|
200
|
-
placeholderPrefix: string,
|
|
201
|
-
): string {
|
|
202
|
-
const inputLines = text.split('\n')
|
|
203
|
-
const outputLines: string[] = []
|
|
204
|
-
let i = 0
|
|
205
|
-
|
|
206
|
-
while (i < inputLines.length) {
|
|
207
|
-
const line = inputLines[i]
|
|
208
|
-
if (!line.includes('|')) {
|
|
209
|
-
outputLines.push(line)
|
|
210
|
-
i++
|
|
211
|
-
continue
|
|
212
|
-
}
|
|
193
|
+
export function normalizeParagraphBreaks(text: string): string {
|
|
194
|
+
if (!text.includes('\n')) return text
|
|
213
195
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
196
|
+
const nonce = Math.random().toString(36).slice(2)
|
|
197
|
+
const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
|
|
198
|
+
|
|
199
|
+
// Step 1: collapse 3+ newlines to exactly two. This also normalizes runs that
|
|
200
|
+
// contain interleaved spaces only between the newlines is NOT done here —
|
|
201
|
+
// we only touch pure newline runs so we never eat meaningful whitespace.
|
|
202
|
+
let out = masked.replace(/\n{3,}/g, '\n\n')
|
|
203
|
+
|
|
204
|
+
// Step 2: walk lines and promote lone prose breaks. We rebuild the string by
|
|
205
|
+
// joining lines with the right separator. A separator is "hard" (` \n`) only
|
|
206
|
+
// when the break between this line and the next is a genuine prose paragraph
|
|
207
|
+
// break per the heuristic; otherwise it stays a plain `\n`. Blank lines (the
|
|
208
|
+
// `\n\n` gaps) are preserved as empty entries in the split, so we never
|
|
209
|
+
// promote a break that is adjacent to a blank line.
|
|
210
|
+
const lines = out.split('\n')
|
|
211
|
+
const pieces: string[] = []
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
let line = lines[i]
|
|
214
|
+
const isLast = i === lines.length - 1
|
|
215
|
+
const next = isLast ? '' : lines[i + 1]
|
|
216
|
+
// A blank current or next line means this is part of a `\n\n` gap — leave
|
|
217
|
+
// the separator as a plain newline (the blank entry reconstructs the gap).
|
|
218
|
+
const promote =
|
|
219
|
+
!isLast &&
|
|
220
|
+
line.trim() !== '' &&
|
|
221
|
+
next.trim() !== '' &&
|
|
222
|
+
shouldPromoteBreak(line, next, placeholder)
|
|
223
|
+
if (promote) {
|
|
224
|
+
// Strip any trailing whitespace the line already carried so we emit
|
|
225
|
+
// exactly one ` \n` hard break (never accumulate spaces on a re-run).
|
|
226
|
+
// Include `\r` so a CRLF source ("Alpha.\r\nBravo.") doesn't strand a
|
|
227
|
+
// lone carriage return before the injected ` \n`.
|
|
228
|
+
line = line.replace(/[ \t\r]+$/, '')
|
|
233
229
|
}
|
|
230
|
+
pieces.push(line)
|
|
231
|
+
if (isLast) break
|
|
232
|
+
pieces.push(promote ? ' \n' : '\n')
|
|
234
233
|
}
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
out = pieces.join('')
|
|
235
|
+
|
|
236
|
+
// Step 3: guarantee a blank line (`\n\n`) at BLOCK BOUNDARIES. The
|
|
237
|
+
// prose-promotion above keeps lists/tables tight by leaving their single
|
|
238
|
+
// `\n` separators alone — but GFM's rich renderer needs a blank line to
|
|
239
|
+
// START a new block, so a block that is glued to the previous line by a
|
|
240
|
+
// single `\n` fails to render (a table prints as literal pipe text, prose
|
|
241
|
+
// after a list is absorbed as a lazy list continuation). This pass inserts
|
|
242
|
+
// the missing blank line at those transitions only, on the same masked text,
|
|
243
|
+
// never touching code interiors, never collapsing/expanding existing `\n\n`,
|
|
244
|
+
// and never splitting a table's header/delimiter/body rows apart.
|
|
245
|
+
out = ensureBlockBoundaries(out, placeholder)
|
|
246
|
+
|
|
247
|
+
return restore(out)
|
|
237
248
|
}
|
|
238
249
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
* Escapes HTML entities in plain text. Wraps file references in <code>.
|
|
243
|
-
* Preserves embedded whitelisted Telegram HTML tags so the model can
|
|
244
|
-
* mix markdown and raw HTML in the same message.
|
|
245
|
-
*/
|
|
246
|
-
export function markdownToHtml(text: string): string {
|
|
247
|
-
// Smart pass-through: if the input is already valid Telegram HTML
|
|
248
|
-
// (every tag is in the supported list), trust the caller and return
|
|
249
|
-
// it unchanged.
|
|
250
|
-
if (isLikelyTelegramHtml(text)) {
|
|
251
|
-
return text
|
|
252
|
-
}
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Tables are extracted after code blocks so that table-looking rows inside
|
|
260
|
-
// fenced code blocks are already parked in codeBlocks placeholders and
|
|
261
|
-
// won't be touched. Rendered table HTML is stored alongside codeBlocks and
|
|
262
|
-
// uses the same placeholder so restoration happens in a single pass.
|
|
263
|
-
const TABLE_PH = '\x00TABLEBLOCK'
|
|
264
|
-
|
|
265
|
-
// Code blocks: ```lang\ncode\n```
|
|
266
|
-
let result = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => {
|
|
267
|
-
const escaped = escapeHtml(code.replace(/\n$/, ''))
|
|
268
|
-
const cls = lang ? ` class="language-${lang}"` : ''
|
|
269
|
-
const idx = codeBlocks.length
|
|
270
|
-
codeBlocks.push(`<pre><code${cls}>${escaped}</code></pre>`)
|
|
271
|
-
return `${BLOCK_PH}${idx}\x00`
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
// Extract markdown tables after fenced code blocks are parked. Rendered
|
|
275
|
-
// HTML is stored in codeBlocks (shared store); TABLE_PH is a distinct
|
|
276
|
-
// prefix so the two restore regexes below can target each independently.
|
|
277
|
-
result = extractMarkdownTables(result, codeBlocks, TABLE_PH)
|
|
278
|
-
|
|
279
|
-
// Convert markdown headings (# / ## / ### ...) to bold lines on their
|
|
280
|
-
// own. Telegram has no <h1> tag, and rendering ## as plain text leaves
|
|
281
|
-
// ugly hash marks in the message.
|
|
282
|
-
result = result.replace(/^(#{1,6})\s+(.+?)\s*$/gm, (_m, _hashes, title: string) => {
|
|
283
|
-
return `**${title}**`
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// Inline code: `code`
|
|
287
|
-
const inlineCodes: string[] = []
|
|
288
|
-
result = result.replace(/`([^`\n]+)`/g, (_m, code: string) => {
|
|
289
|
-
const idx = inlineCodes.length
|
|
290
|
-
inlineCodes.push(`<code>${escapeHtml(code)}</code>`)
|
|
291
|
-
return `${INLINE_PH}${idx}\x00`
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
// Telegram HTML tag pass-through. Extract any opening/closing tag
|
|
295
|
-
// whose name is in the whitelist into placeholders. The TEXT BETWEEN
|
|
296
|
-
// tags still flows through escapeHtml and the markdown conversions
|
|
297
|
-
// below, so `<b>**bold**</b>` and `<b>plain</b>` both work. Tags are
|
|
298
|
-
// restored verbatim at the very end.
|
|
299
|
-
const htmlTags: string[] = []
|
|
300
|
-
const HTMLTAG_PH = '\x00HTMLTAG'
|
|
301
|
-
const tagNamePattern = Array.from(TELEGRAM_HTML_TAGS).join('|')
|
|
302
|
-
const htmlTagRe = new RegExp(`</?(?:${tagNamePattern})\\b[^>]*>`, 'gi')
|
|
303
|
-
result = result.replace(htmlTagRe, (match: string) => {
|
|
304
|
-
const idx = htmlTags.length
|
|
305
|
-
htmlTags.push(match)
|
|
306
|
-
return `${HTMLTAG_PH}${idx}\x00`
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
// Escape HTML entities in remaining plain text
|
|
310
|
-
result = escapeHtml(result)
|
|
311
|
-
|
|
312
|
-
// Bold: **text** (must come before italic)
|
|
313
|
-
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|
314
|
-
|
|
315
|
-
// Italic: *text* (single asterisk, not preceded by another *)
|
|
316
|
-
result = result.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<i>$1</i>')
|
|
317
|
-
|
|
318
|
-
// Italic: _text_ (underscore form). Lookarounds guard snake_case,
|
|
319
|
-
// __double__, and word-internal underscores. Emoji codepoints are not
|
|
320
|
-
// \w, so emoji-leading/trailing italics like `_📥 queued_` work correctly.
|
|
321
|
-
result = result.replace(/(?<![\w_])_(?!_)([^_\n]+?)_(?![\w_])/g, '<i>$1</i>')
|
|
322
|
-
|
|
323
|
-
// Strikethrough: ~~text~~
|
|
324
|
-
result = result.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
|
325
|
-
|
|
326
|
-
// Restore inline-code, code-block, and table-block placeholders ONLY
|
|
327
|
-
// AFTER bold/italic/strike have run. If the inline-code placeholder
|
|
328
|
-
// is restored before italic, an inline-code span containing asterisks
|
|
329
|
-
// (e.g. `\`size_t *p\``) gets matched by the italic regex on the
|
|
330
|
-
// restored `<code>...*p</code>` buffer and produces invalid HTML
|
|
331
|
-
// that Telegram rejects with 400 Bad Request — sending the caller
|
|
332
|
-
// into a `format: text` fallback for the rest of the chunk. Same
|
|
333
|
-
// fault class for code blocks containing `**` literals. See #415.
|
|
334
|
-
result = result.replace(new RegExp(`${escapeHtml(BLOCK_PH)}(\\d+)${escapeHtml('\x00')}`, 'g'), (_m, idx) => codeBlocks[Number(idx)])
|
|
335
|
-
result = result.replace(new RegExp(`${escapeHtml(TABLE_PH)}(\\d+)${escapeHtml('\x00')}`, 'g'), (_m, idx) => codeBlocks[Number(idx)])
|
|
336
|
-
result = result.replace(new RegExp(`${escapeHtml(INLINE_PH)}(\\d+)${escapeHtml('\x00')}`, 'g'), (_m, idx) => inlineCodes[Number(idx)])
|
|
337
|
-
|
|
338
|
-
// Links: [text](url). Two safety requirements here:
|
|
339
|
-
//
|
|
340
|
-
// 1. URL scheme allowlist. Unrestricted href accepts `javascript:` and
|
|
341
|
-
// `data:` URIs; Telegram historically renders tg:// links directly
|
|
342
|
-
// (opening another bot) which is a phishing primitive. Anything not
|
|
343
|
-
// in the allowlist falls back to `#`.
|
|
344
|
-
//
|
|
345
|
-
// 2. Escape the URL before interpolating into the attribute. The HTML
|
|
346
|
-
// tag extraction above parks whitelisted tags in \x00HTMLTAG<n>\x00
|
|
347
|
-
// placeholders that get restored AFTER this replace. Without escaping
|
|
348
|
-
// the href value, an adversarial `[text](x"></a><a href="evil">)` in
|
|
349
|
-
// model output produces two <a> tags after placeholder restoration —
|
|
350
|
-
// the second hijacks the visible link target. escapeAttr covers both
|
|
351
|
-
// the placeholder-restoration attack and plain `"` breakout.
|
|
352
|
-
const ALLOWED_LINK_SCHEMES = /^(?:https?|mailto|tel|tg):/i
|
|
353
|
-
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText: string, url: string) => {
|
|
354
|
-
const safe = ALLOWED_LINK_SCHEMES.test(url.trim()) ? url.trim() : '#'
|
|
355
|
-
return `<a href="${escapeHtml(safe)}">${linkText}</a>`
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
// File references: wrap filename.ext patterns in <code> tags.
|
|
359
|
-
// Lookbehind excludes `>` so we don't double-wrap filenames that are
|
|
360
|
-
// already inside a restored inline-code placeholder like
|
|
361
|
-
// `<code>settings.json</code>`. Without this, the regex matched the
|
|
362
|
-
// filename character immediately after the `>` of the opening <code>
|
|
363
|
-
// tag and re-wrapped it, producing `<code><code>settings.json</code></code>`.
|
|
364
|
-
result = result.replace(/(?<![<\/\w>])(\b[\w][\w.-]*\.(?:ts|js|py|rs|go|json|yaml|yml|toml|md|txt|sh|bash|zsh|css|html|xml|sql|env|cfg|conf|ini|log|csv|tsx|jsx|vue|svelte|rb|java|kt|swift|c|cpp|h|hpp|zig|asm|wasm|lock|mod|sum)\b)(?![^<]*>)/g, '<code>$1</code>')
|
|
365
|
-
|
|
366
|
-
// Restore preserved Telegram HTML tags (must run last so the file-ref
|
|
367
|
-
// regex above doesn't accidentally match characters inside our placeholders).
|
|
368
|
-
result = result.replace(new RegExp(`${escapeHtml(HTMLTAG_PH)}(\\d+)${escapeHtml('\x00')}`, 'g'), (_m, idx) => htmlTags[Number(idx)])
|
|
369
|
-
|
|
370
|
-
return result
|
|
254
|
+
/** A line that begins a GFM list item (bullet or ordered), incl. leading indent. */
|
|
255
|
+
function isListItemLine(line: string): boolean {
|
|
256
|
+
const t = line.trimStart()
|
|
257
|
+
return /^[-*+]\s/.test(t) || /^\d+[.)]\s/.test(t)
|
|
371
258
|
}
|
|
372
259
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return text
|
|
377
|
-
.replace(/&/g, '&')
|
|
378
|
-
.replace(/</g, '<')
|
|
379
|
-
.replace(/>/g, '>')
|
|
380
|
-
.replace(/"/g, '"')
|
|
260
|
+
/** A GFM table body/header row: a line whose first non-space char is `|`. */
|
|
261
|
+
function isTableRowLine(line: string): boolean {
|
|
262
|
+
return /^\s*\|/.test(line)
|
|
381
263
|
}
|
|
382
264
|
|
|
383
265
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* "unsupported start tag" — i.e. our HTML prevention (markdownToHtml +
|
|
388
|
-
* sanitizeForTelegram + splitHtmlChunks) let something through anyway.
|
|
389
|
-
*
|
|
390
|
-
* The caller resends the result with `parse_mode` UNSET, so the output
|
|
391
|
-
* is literal text — we intentionally do NOT re-escape `< > &`. The goal
|
|
392
|
-
* is "the agent's answer lands unformatted" instead of "the answer
|
|
393
|
-
* silently vanishes" (visibility + always-on).
|
|
394
|
-
*
|
|
395
|
-
* Transforms, in order:
|
|
396
|
-
* 1. `<a href="u">label</a>` → `label (u)` (or just `u` when label is
|
|
397
|
-
* empty or equals the href). href/label are themselves stripped +
|
|
398
|
-
* entity-decoded so we never emit nested markup.
|
|
399
|
-
* 2. Block / break boundaries → newline: `<br>`, `</p>`, `</div>`,
|
|
400
|
-
* `</li>`, `</blockquote>`, `</pre>`. (These aren't Telegram-
|
|
401
|
-
* supported tags, but a markdown→HTML slip that emits one is a
|
|
402
|
-
* prime cause of the parse reject we're recovering from.)
|
|
403
|
-
* 3. Strip every remaining tag.
|
|
404
|
-
* 4. Decode the standard HTML entities Telegram uses.
|
|
405
|
-
* 5. Collapse 3+ blank lines to 2; trim trailing per-line whitespace.
|
|
266
|
+
* A GFM table delimiter row: optional leading pipe, then one or more
|
|
267
|
+
* `:?-{1,}:?` cells separated by pipes (e.g. `|---|---|`, `---|:--:`,
|
|
268
|
+
* `| :-- | --: |`). This is what turns the line ABOVE it into a table header.
|
|
406
269
|
*/
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
.replace(/&/g, '&')
|
|
411
|
-
.replace(/</g, '<')
|
|
412
|
-
.replace(/>/g, '>')
|
|
413
|
-
.replace(/"/g, '"')
|
|
414
|
-
.replace(/�*39;|�*27;|'/gi, "'")
|
|
415
|
-
.replace(/ /g, ' ')
|
|
416
|
-
.replace(/&#(\d+);/g, (_m, d: string) => {
|
|
417
|
-
const cp = Number(d)
|
|
418
|
-
return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff
|
|
419
|
-
? String.fromCodePoint(cp)
|
|
420
|
-
: _m
|
|
421
|
-
})
|
|
422
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h: string) => {
|
|
423
|
-
const cp = parseInt(h, 16)
|
|
424
|
-
return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff
|
|
425
|
-
? String.fromCodePoint(cp)
|
|
426
|
-
: _m
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
const stripTags = (s: string): string =>
|
|
430
|
-
decodeEntities(
|
|
431
|
-
s
|
|
432
|
-
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
|
433
|
-
.replace(/<\/\s*(?:p|div|li|blockquote|pre|h[1-6])\s*>/gi, '\n')
|
|
434
|
-
.replace(/<[^>]*>/g, ''),
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
// 1. Anchors → "label (href)". Handle double/single/unquoted href.
|
|
438
|
-
const withPlainLinks = html.replace(
|
|
439
|
-
/<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi,
|
|
440
|
-
(_m, dq: string | undefined, sq: string | undefined, uq: string | undefined, label: string) => {
|
|
441
|
-
const href = decodeEntities((dq ?? sq ?? uq ?? '').trim())
|
|
442
|
-
const text = stripTags(label).trim()
|
|
443
|
-
if (!href) return text
|
|
444
|
-
return !text || text === href ? href : `${text} (${href})`
|
|
445
|
-
},
|
|
446
|
-
)
|
|
270
|
+
function isTableDelimiterLine(line: string): boolean {
|
|
271
|
+
return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line)
|
|
272
|
+
}
|
|
447
273
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
274
|
+
/** A fenced-code OPEN line — either a literal ``` fence or a masked block. */
|
|
275
|
+
function isFenceOpenLine(line: string, placeholder?: string): boolean {
|
|
276
|
+
const t = line.trimStart()
|
|
277
|
+
if (placeholder != null && placeholder.length > 0 && t.startsWith(placeholder)) return true
|
|
278
|
+
return t.startsWith('```')
|
|
452
279
|
}
|
|
453
280
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
281
|
+
/** A blockquote line. */
|
|
282
|
+
function isBlockquoteLine(line: string): boolean {
|
|
283
|
+
return line.trimStart().startsWith('>')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** An ATX heading line. */
|
|
287
|
+
function isHeadingLine(line: string): boolean {
|
|
288
|
+
return /^#{1,6}\s/.test(line.trimStart())
|
|
289
|
+
}
|
|
457
290
|
|
|
458
291
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* Runs AFTER markdownToHtml, just before the text is sent to the Bot API.
|
|
462
|
-
* Conservative by design: only rewrites things that are universally wrong;
|
|
463
|
-
* leaves semantic decisions (where to bold, link choice, list-vs-prose) to
|
|
464
|
-
* the agent.
|
|
292
|
+
* Insert a blank line at block boundaries that are currently separated by
|
|
293
|
+
* exactly one `\n`. Operates line-by-line on already-code-masked text.
|
|
465
294
|
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
295
|
+
* A blank line is guaranteed:
|
|
296
|
+
* - BEFORE the first row of a GFM table (a `|`-leading line that is itself a
|
|
297
|
+
* delimiter row, OR a `|`-containing header line immediately followed by a
|
|
298
|
+
* delimiter row) when the previous emitted line is non-blank and not part
|
|
299
|
+
* of a table — never between a table's own header/delimiter/body rows.
|
|
300
|
+
* - BEFORE a fenced-code open, a blockquote, or an ATX heading when the
|
|
301
|
+
* previous line is non-blank and of a DIFFERENT block type.
|
|
302
|
+
* - AFTER a list block: when a list-item line is followed by a non-blank
|
|
303
|
+
* line that is NOT itself a list item and NOT an indented continuation of
|
|
304
|
+
* the item (4+ leading spaces / a tab), so the prose breaks out of the list.
|
|
476
305
|
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
306
|
+
* Conservative: prefers a false negative (leave glued) over corrupting a valid
|
|
307
|
+
* block. Existing blank lines (empty entries from a `\n\n` gap) are preserved
|
|
308
|
+
* and short-circuit every rule — we never double up a gap.
|
|
479
309
|
*/
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
310
|
+
function ensureBlockBoundaries(text: string, placeholder?: string): string {
|
|
311
|
+
if (!text.includes('\n')) return text
|
|
312
|
+
const lines = text.split('\n')
|
|
313
|
+
const result: string[] = []
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i]
|
|
317
|
+
const prev = result.length > 0 ? result[result.length - 1] : null
|
|
318
|
+
const prevNonBlank = prev != null && prev.trim() !== ''
|
|
319
|
+
const curBlank = line.trim() === ''
|
|
320
|
+
|
|
321
|
+
// ---- Rule A: blank line BEFORE a block that needs one to start ----
|
|
322
|
+
if (prevNonBlank && !curBlank) {
|
|
323
|
+
const next = i + 1 < lines.length ? lines[i + 1] : ''
|
|
324
|
+
|
|
325
|
+
// Table first row: either THIS line is a delimiter row (header was the
|
|
326
|
+
// prev line — but only treat as a table start when prev itself isn't
|
|
327
|
+
// already a table row), or THIS line is a `|`-bearing header whose NEXT
|
|
328
|
+
// line is a delimiter. We anchor the blank-line insertion on the HEADER
|
|
329
|
+
// line so header+delimiter+body stay contiguous.
|
|
330
|
+
const prevIsTable = isTableRowLine(prev)
|
|
331
|
+
const startsTableHere =
|
|
332
|
+
!prevIsTable &&
|
|
333
|
+
((line.includes('|') && isTableDelimiterLine(next)) ||
|
|
334
|
+
(isTableRowLine(line) && isTableDelimiterLine(next)))
|
|
335
|
+
|
|
336
|
+
const startsFence = isFenceOpenLine(line, placeholder) && !isFenceOpenLine(prev, placeholder)
|
|
337
|
+
const startsQuote = isBlockquoteLine(line) && !isBlockquoteLine(prev)
|
|
338
|
+
const startsHeading = isHeadingLine(line) && !isHeadingLine(prev)
|
|
339
|
+
|
|
340
|
+
if (startsTableHere || startsFence || startsQuote || startsHeading) {
|
|
341
|
+
result.push('')
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- Rule B: blank line AFTER a list block, before breakout prose ----
|
|
346
|
+
if (prevNonBlank && !curBlank && isListItemLine(prev) && !isListItemLine(line)) {
|
|
347
|
+
// A 4+ space (or tab) indent means `line` is a lazy continuation of the
|
|
348
|
+
// list item's paragraph, NOT breakout prose — leave it glued.
|
|
349
|
+
const isIndentedContinuation = /^(\t| {4,})\S/.test(line)
|
|
350
|
+
// A table/fence/quote/heading start is already handled by Rule A above
|
|
351
|
+
// (its blank line was just inserted); avoid inserting a second one.
|
|
352
|
+
const alreadySeparated = result.length > 0 && result[result.length - 1].trim() === ''
|
|
353
|
+
if (!isIndentedContinuation && !alreadySeparated) {
|
|
354
|
+
result.push('')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
result.push(line)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return result.join('\n')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Lines that introduce GFM block structure — never reflow around these. */
|
|
365
|
+
function isMarkerLine(line: string, placeholder?: string): boolean {
|
|
366
|
+
// CommonMark indented code block: 4+ leading spaces then a non-space char.
|
|
367
|
+
// Checked on the RAW (pre-trim) line — trimming would erase the very indent
|
|
368
|
+
// that makes it a code block, so we must look before `trimStart()`.
|
|
369
|
+
if (/^ {4,}\S/.test(line)) return true
|
|
370
|
+
const t = line.trimStart()
|
|
371
|
+
// A line that begins with the code-mask placeholder is a standalone masked
|
|
372
|
+
// fenced block — treat it as a block marker so we never inject a hard break
|
|
373
|
+
// immediately before/after a code block. (An INLINE code span sits mid-line,
|
|
374
|
+
// so the line won't START with the placeholder and ordinary prose rules apply.)
|
|
375
|
+
if (placeholder != null && placeholder.length > 0 && t.startsWith(placeholder)) return true
|
|
376
|
+
return (
|
|
377
|
+
// Unordered list bullet: -, *, + followed by a space.
|
|
378
|
+
/^[-*+]\s/.test(t) ||
|
|
379
|
+
// Ordered list: `1.` or `1)` followed by a space.
|
|
380
|
+
/^\d+[.)]\s/.test(t) ||
|
|
381
|
+
// Blockquote / pull-quote.
|
|
382
|
+
t.startsWith('>') ||
|
|
383
|
+
// ATX heading.
|
|
384
|
+
/^#{1,6}\s/.test(t) ||
|
|
385
|
+
// Table row (leading pipe) or table-ish line (interior ` | `).
|
|
386
|
+
t.startsWith('|') ||
|
|
387
|
+
line.includes(' | ') ||
|
|
388
|
+
// Fenced code delimiter (defensive — fences are masked, but a lone/odd
|
|
389
|
+
// fence line can survive masking).
|
|
390
|
+
t.startsWith('```') ||
|
|
391
|
+
// Thematic break / divider.
|
|
392
|
+
/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)
|
|
393
|
+
)
|
|
535
394
|
}
|
|
536
395
|
|
|
537
396
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
* Strategy: we walk the string and escape `&` only when it is not the start
|
|
543
|
-
* of a valid entity (`&name;` or `&#digits;` or `&#xhex;`). We always escape
|
|
544
|
-
* bare `<` and `>` because they cannot appear literally inside code content
|
|
545
|
-
* that is correct Telegram HTML.
|
|
397
|
+
* Decide whether the lone `\n` between `prev` and `next` is a genuine prose
|
|
398
|
+
* paragraph break worth promoting to a GFM hard break. Conservative by design
|
|
399
|
+
* (see normalizeParagraphBreaks doc) — returns false on any doubt.
|
|
546
400
|
*/
|
|
547
|
-
function
|
|
548
|
-
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
401
|
+
function shouldPromoteBreak(prev: string, next: string, placeholder?: string): boolean {
|
|
402
|
+
if (isMarkerLine(prev, placeholder) || isMarkerLine(next, placeholder)) return false
|
|
403
|
+
// Next line must begin with ordinary prose, not a structural marker char.
|
|
404
|
+
const nextTrimmed = next.trimStart()
|
|
405
|
+
if (nextTrimmed.length === 0) return false
|
|
406
|
+
// The preceding line must read as a finished sentence/clause: it ends in a
|
|
407
|
+
// sentence-terminal punctuation mark, optionally wrapped by a closing quote
|
|
408
|
+
// or paren that itself follows such a terminator.
|
|
409
|
+
const prevTrimmed = prev.trimEnd()
|
|
410
|
+
// Strip up to one trailing closing-bracket/quote run to look at the real
|
|
411
|
+
// terminator (e.g. `He said "go."` or `(done.)`).
|
|
412
|
+
const unwrapped = prevTrimmed.replace(/[)"'’”\]]+$/, '')
|
|
413
|
+
const terminator = unwrapped.slice(-1)
|
|
414
|
+
return terminator === '.' || terminator === '!' || terminator === '?' || terminator === ':'
|
|
555
415
|
}
|
|
556
416
|
|
|
557
417
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* `\n\n` in the chat instead of paragraph breaks.
|
|
565
|
-
*
|
|
566
|
-
* Unescape literal `\n`, `\r`, `\t`, and `\"` sequences everywhere EXCEPT
|
|
567
|
-
* inside code spans (inline backtick spans and fenced ``` blocks). Those
|
|
568
|
-
* regions are masked with placeholders before the unescape pass so that a
|
|
569
|
-
* literal `\n` a user typed inside a shell snippet or regex is preserved
|
|
570
|
-
* verbatim. The genuine escaped-backslash sequence `\\n` (which the user
|
|
571
|
-
* intended as a literal backslash + n, not a newline) is handled by
|
|
572
|
-
* protecting `\\` before touching `\n`.
|
|
418
|
+
* Last-resort hard slicer for a body that `splitMarkdownChunks` could not break
|
|
419
|
+
* (a single indivisible region larger than the cap — e.g. a giant fenced block
|
|
420
|
+
* with no interior boundary). Cuts on raw character count so every emitted
|
|
421
|
+
* piece is guaranteed `<= cap`, accepting that a cut MAY land inside a fence
|
|
422
|
+
* (which Telegram renders imperfectly) — a degraded-but-delivered message beats
|
|
423
|
+
* a hard `RICH_MESSAGE_TEXT_TOO_LONG` reject that drops the answer entirely.
|
|
573
424
|
*
|
|
574
|
-
*
|
|
575
|
-
* the old whole-message heuristic ("bail if any real newline exists") was
|
|
576
|
-
* too broad and prevented repair of mixed messages that had both real
|
|
577
|
-
* newlines and stray literal `\n` escape sequences outside code spans.
|
|
425
|
+
* Returns the input as a single-element array when it already fits.
|
|
578
426
|
*/
|
|
579
|
-
export function
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const CODE_MASK_PH = `\x00RM${nonce}_`
|
|
588
|
-
const BACKSLASH_PH = `\x00BK${nonce}_`
|
|
589
|
-
|
|
590
|
-
// Mask fenced code blocks (``` ... ```) and inline code spans (` ... `)
|
|
591
|
-
// so the unescape pass never touches their content.
|
|
592
|
-
//
|
|
593
|
-
// Fenced blocks are extracted first. Only CLOSED fenced blocks (with a
|
|
594
|
-
// matching closing ```) are masked — an unclosed fence is left as-is so the
|
|
595
|
-
// inline-code pass below won't misparse the two leading backticks as an empty
|
|
596
|
-
// inline span and expose the block's interior.
|
|
597
|
-
//
|
|
598
|
-
// Inline code uses `[^\`\n]+` (one or more non-backtick, non-newline chars)
|
|
599
|
-
// matching the same definition that markdownToHtml uses, so the masked regions
|
|
600
|
-
// are consistent with what the downstream pipeline treats as code.
|
|
601
|
-
const codeMasks: string[] = []
|
|
602
|
-
|
|
603
|
-
const masked = text
|
|
604
|
-
// Closed fenced code blocks only (``` ... ``` with a matching closer).
|
|
605
|
-
.replace(/```[\s\S]*?```/g, (m) => {
|
|
606
|
-
const idx = codeMasks.length
|
|
607
|
-
codeMasks.push(m)
|
|
608
|
-
return `${CODE_MASK_PH}${idx}\x00`
|
|
609
|
-
})
|
|
610
|
-
// Inline code spans: at least one character between backticks, no embedded
|
|
611
|
-
// backtick or newline (matches markdownToHtml's /`([^`\n]+)`/ definition).
|
|
612
|
-
.replace(/`[^`\n]+`/g, (m) => {
|
|
613
|
-
const idx = codeMasks.length
|
|
614
|
-
codeMasks.push(m)
|
|
615
|
-
return `${CODE_MASK_PH}${idx}\x00`
|
|
616
|
-
})
|
|
617
|
-
|
|
618
|
-
// Order matters: protect existing `\\` first so `\\n` stays as a literal
|
|
619
|
-
// backslash + n and doesn't become a newline.
|
|
620
|
-
const unescaped = masked
|
|
621
|
-
.replace(/\\\\/g, BACKSLASH_PH)
|
|
622
|
-
.replace(/\\n/g, '\n')
|
|
623
|
-
.replace(/\\r/g, '\r')
|
|
624
|
-
.replace(/\\t/g, '\t')
|
|
625
|
-
.replace(/\\"/g, '"')
|
|
626
|
-
.replace(new RegExp(BACKSLASH_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '\\')
|
|
627
|
-
|
|
628
|
-
// Restore masked code spans verbatim.
|
|
629
|
-
const restoreRe = new RegExp(`${CODE_MASK_PH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)\x00`, 'g')
|
|
630
|
-
return unescaped.replace(restoreRe, (_m, idx) => codeMasks[Number(idx)] ?? _m)
|
|
427
|
+
export function hardSliceToCap(text: string, cap = RICH_MESSAGE_MAX_CHARS): string[] {
|
|
428
|
+
if (cap <= 0) return [text]
|
|
429
|
+
if (text.length <= cap) return [text]
|
|
430
|
+
const out: string[] = []
|
|
431
|
+
for (let i = 0; i < text.length; i += cap) {
|
|
432
|
+
out.push(text.slice(i, i + cap))
|
|
433
|
+
}
|
|
434
|
+
return out
|
|
631
435
|
}
|
|
632
436
|
|
|
633
437
|
// ---------------------------------------------------------------------------
|
|
634
|
-
//
|
|
438
|
+
// Markdown-aware chunking — never bisects a code fence or a table row
|
|
635
439
|
// ---------------------------------------------------------------------------
|
|
636
440
|
|
|
637
441
|
/**
|
|
638
|
-
* Split
|
|
639
|
-
*
|
|
640
|
-
*
|
|
442
|
+
* Split a markdown body into chunks that each fit within `maxLen`.
|
|
443
|
+
*
|
|
444
|
+
* The rich-message path is raw GFM markdown, so chunk boundaries must not
|
|
445
|
+
* land inside a fenced code block (``` … ```) or in the middle of a table
|
|
446
|
+
* row — either produces a chunk Telegram renders wrong (an unterminated
|
|
447
|
+
* fence swallows the next chunk's text, a half table row drops cells).
|
|
448
|
+
*
|
|
449
|
+
* Strategy:
|
|
450
|
+
* 1. If the whole body fits, return it as one chunk.
|
|
451
|
+
* 2. Otherwise pick the largest safe cut <= maxLen, preferring a blank
|
|
452
|
+
* line, then a single newline, then a space. The cut is then nudged
|
|
453
|
+
* so it never falls inside an open fenced block or inside a line that
|
|
454
|
+
* is part of a table (a line containing `|`).
|
|
455
|
+
* 3. An unsplittable region (a single code fence longer than maxLen) is
|
|
456
|
+
* emitted whole rather than spun on forever — Telegram will reject an
|
|
457
|
+
* oversized message, which is a louder, debuggable failure than an
|
|
458
|
+
* infinite loop.
|
|
641
459
|
*/
|
|
642
|
-
export function
|
|
643
|
-
if (
|
|
460
|
+
export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHARS): string[] {
|
|
461
|
+
if (text.length <= maxLen) return [text]
|
|
644
462
|
|
|
645
463
|
const chunks: string[] = []
|
|
646
|
-
let rest =
|
|
464
|
+
let rest = text
|
|
647
465
|
|
|
648
466
|
while (rest.length > 0) {
|
|
649
467
|
if (rest.length <= maxLen) {
|
|
@@ -651,7 +469,6 @@ export function splitHtmlChunks(html: string, maxLen = 4000): string[] {
|
|
|
651
469
|
break
|
|
652
470
|
}
|
|
653
471
|
|
|
654
|
-
// Find a good split point
|
|
655
472
|
let cut = maxLen
|
|
656
473
|
const paraIdx = rest.lastIndexOf('\n\n', maxLen)
|
|
657
474
|
const lineIdx = rest.lastIndexOf('\n', maxLen)
|
|
@@ -665,129 +482,59 @@ export function splitHtmlChunks(html: string, maxLen = 4000): string[] {
|
|
|
665
482
|
cut = spaceIdx
|
|
666
483
|
}
|
|
667
484
|
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
//
|
|
671
|
-
cut =
|
|
672
|
-
|
|
673
|
-
// between `<` and its closing `>`), back up to before the `<`.
|
|
674
|
-
// Otherwise we'd emit a chunk ending in `<a` or `<a href="..` which
|
|
675
|
-
// Telegram rejects outright.
|
|
676
|
-
cut = backOffOpenTag(rest, cut)
|
|
677
|
-
// Pathological: the tag-back-off retreated to 0 because `rest`
|
|
678
|
-
// begins with a tag and the nearest space we picked landed inside
|
|
679
|
-
// that tag. Fall back to the hard maxLen cut — that position lives
|
|
680
|
-
// in content past the opening tag (since the tag itself is at the
|
|
681
|
-
// start) so it won't bisect anything, and we make forward progress.
|
|
485
|
+
// Back off so the cut doesn't fall inside an open ``` fence.
|
|
486
|
+
cut = backOffOpenFence(rest, cut)
|
|
487
|
+
// Back off so the cut doesn't bisect a table row (a line with `|`).
|
|
488
|
+
cut = backOffTableRow(rest, cut)
|
|
489
|
+
|
|
682
490
|
if (cut <= 0) {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// fires on genuinely malformed input.
|
|
689
|
-
if (cut <= 0) cut = rest.length
|
|
491
|
+
// Could not find a safe boundary below maxLen — the region is one
|
|
492
|
+
// indivisible block (e.g. a single huge fenced block). Emit the
|
|
493
|
+
// whole remainder rather than loop forever.
|
|
494
|
+
chunks.push(rest)
|
|
495
|
+
break
|
|
690
496
|
}
|
|
691
497
|
|
|
692
|
-
|
|
498
|
+
chunks.push(rest.slice(0, cut))
|
|
693
499
|
rest = rest.slice(cut).replace(/^\n+/, '')
|
|
694
|
-
|
|
695
|
-
// Track open tags in this segment — we keep the FULL opening tag
|
|
696
|
-
// string (including attributes) so we can reopen `<a href="...">`
|
|
697
|
-
// in the next chunk without dropping the href.
|
|
698
|
-
const openTags = getOpenTags(segment)
|
|
699
|
-
|
|
700
|
-
// Close any open tags at the end of this chunk (by tag name)
|
|
701
|
-
for (let i = openTags.length - 1; i >= 0; i--) {
|
|
702
|
-
segment += `</${openTags[i].name}>`
|
|
703
|
-
}
|
|
704
|
-
chunks.push(segment)
|
|
705
|
-
|
|
706
|
-
// Reopen tags at the start of the next chunk, preserving attrs
|
|
707
|
-
if (rest.length > 0 && openTags.length > 0) {
|
|
708
|
-
const reopenPrefix = openTags.map(t => t.openTag).join('')
|
|
709
|
-
rest = reopenPrefix + rest
|
|
710
|
-
}
|
|
711
500
|
}
|
|
712
501
|
|
|
713
502
|
return chunks
|
|
714
503
|
}
|
|
715
504
|
|
|
716
505
|
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
506
|
+
* Count fenced-code delimiters (``` at line start) up to `cut`. If the
|
|
507
|
+
* count is odd, the cut lands inside an open fence — retreat to just before
|
|
508
|
+
* the opening fence so the boundary sits between complete blocks.
|
|
719
509
|
*/
|
|
720
|
-
function
|
|
510
|
+
function backOffOpenFence(text: string, cut: number): number {
|
|
721
511
|
if (cut <= 0 || cut >= text.length) return cut
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
return i
|
|
732
|
-
}
|
|
733
|
-
return cut
|
|
734
|
-
}
|
|
512
|
+
const before = text.slice(0, cut)
|
|
513
|
+
const fences = before.match(/^```/gm)
|
|
514
|
+
if (fences == null || fences.length % 2 === 0) return cut
|
|
515
|
+
// Odd number of fences → inside an open block. Find the opening fence and
|
|
516
|
+
// cut just before it (so the whole fenced block goes to the next chunk).
|
|
517
|
+
const lastFence = before.lastIndexOf('\n```')
|
|
518
|
+
if (lastFence <= 0) {
|
|
519
|
+
// Fence opens at the very start of `before` — can't retreat past it.
|
|
520
|
+
return 0
|
|
735
521
|
}
|
|
736
|
-
return
|
|
522
|
+
return lastFence
|
|
737
523
|
}
|
|
738
524
|
|
|
739
525
|
/**
|
|
740
|
-
* If
|
|
741
|
-
*
|
|
742
|
-
* `<` without a matching `>` (e.g. chunk ending `<a href="..`).
|
|
526
|
+
* If the cut lands on a line that contains a `|` (a markdown table row),
|
|
527
|
+
* retreat to the start of that line so we never emit a half table row.
|
|
743
528
|
*/
|
|
744
|
-
function
|
|
529
|
+
function backOffTableRow(text: string, cut: number): number {
|
|
745
530
|
if (cut <= 0 || cut >= text.length) return cut
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const closeIdx = text.indexOf('>', i)
|
|
754
|
-
if (closeIdx >= cut) return i
|
|
755
|
-
return cut
|
|
756
|
-
}
|
|
531
|
+
const lineStart = text.lastIndexOf('\n', cut - 1) + 1
|
|
532
|
+
const nextNl = text.indexOf('\n', cut)
|
|
533
|
+
const lineEnd = nextNl === -1 ? text.length : nextNl
|
|
534
|
+
const line = text.slice(lineStart, lineEnd)
|
|
535
|
+
if (line.includes('|')) {
|
|
536
|
+
// Cut at the line start so the whole row moves to the next chunk.
|
|
537
|
+
return lineStart > 0 ? lineStart - 1 : 0
|
|
757
538
|
}
|
|
758
539
|
return cut
|
|
759
540
|
}
|
|
760
|
-
|
|
761
|
-
/** A tag still open at the end of a fragment. */
|
|
762
|
-
interface OpenTag {
|
|
763
|
-
name: string // lowercase tag name, e.g. "a", "tg-spoiler"
|
|
764
|
-
openTag: string // full opening string with attrs, e.g. `<a href="...">`
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/** Parse an HTML fragment and return the list of tags still open at the end. */
|
|
768
|
-
function getOpenTags(html: string): OpenTag[] {
|
|
769
|
-
const tagStack: OpenTag[] = []
|
|
770
|
-
// Allow hyphens in tag names so `tg-spoiler` and `tg-emoji` parse as a
|
|
771
|
-
// single tag rather than `tg` plus stray text.
|
|
772
|
-
const tagRe = /<(\/?)([a-z][a-z0-9-]*)\b[^>]*>/gi
|
|
773
|
-
let m: RegExpExecArray | null
|
|
774
|
-
while ((m = tagRe.exec(html)) !== null) {
|
|
775
|
-
const full = m[0]
|
|
776
|
-
const isClosing = m[1] === '/'
|
|
777
|
-
const tagName = m[2].toLowerCase()
|
|
778
|
-
if (isClosing) {
|
|
779
|
-
// Closing tag — pop the most recent matching entry off the stack
|
|
780
|
-
for (let i = tagStack.length - 1; i >= 0; i--) {
|
|
781
|
-
if (tagStack[i].name === tagName) {
|
|
782
|
-
tagStack.splice(i, 1)
|
|
783
|
-
break
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
} else if (!full.endsWith('/>')) {
|
|
787
|
-
// Opening tag (not self-closing) — remember the full open string
|
|
788
|
-
// so reopen in the next chunk preserves attributes.
|
|
789
|
-
tagStack.push({ name: tagName, openTag: full })
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
return tagStack
|
|
793
|
-
}
|