switchroom 0.16.22 → 0.16.24
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/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2865 -48
- package/dist/cli/switchroom.js +338 -92
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- 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/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 +751 -624
- 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 +28 -0
- 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/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 +36 -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 +44 -2
- package/telegram-plugin/retry-api-call.ts +15 -7
- package/telegram-plugin/rich-send.ts +57 -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 +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- 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/issues-card.test.ts +15 -12
- 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/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -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,557 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
* unrecognized (Telegram strips it) or actively dangerous (the API
|
|
21
|
-
* rejects the message). Source: https://core.telegram.org/bots/api#html-style
|
|
22
|
-
*/
|
|
23
|
-
export const TELEGRAM_HTML_TAGS = new Set([
|
|
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
|
-
])
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Heuristic: does this look like already-rendered Telegram HTML rather
|
|
39
|
-
* than markdown waiting to be converted?
|
|
40
|
-
*
|
|
41
|
-
* Returns true when ALL the tags we find are recognized Telegram HTML
|
|
42
|
-
* tags AND there's at least one of them AND the text doesn't also have
|
|
43
|
-
* markdown-only syntax (** for bold, [text](url) for links). This is
|
|
44
|
-
* conservative: if the model wrote `<div>foo</div>` (not Telegram HTML),
|
|
45
|
-
* we treat it as markdown and escape it. If the model wrote `<b>foo</b>`,
|
|
46
|
-
* we trust it.
|
|
47
|
-
*
|
|
48
|
-
* Critical: we strip markdown code spans and fenced code blocks BEFORE
|
|
49
|
-
* scanning for tags, because the model frequently writes things like
|
|
50
|
-
* `\`<b>tag</b>\`` (an inline code example showing literal HTML). Without
|
|
51
|
-
* the strip, the heuristic would see `<b>` inside the code span and
|
|
52
|
-
* misclassify the whole text as raw HTML.
|
|
53
|
-
*/
|
|
54
|
-
export function isLikelyTelegramHtml(text: string): boolean {
|
|
55
|
-
// Strip fenced code blocks first (greedy, cross-line)
|
|
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
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// Markdown table → Telegram HTML
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Parse a contiguous block of lines as a markdown table.
|
|
87
|
-
*
|
|
88
|
-
* A valid markdown table requires:
|
|
89
|
-
* - A header row: | col | col | (leading/trailing pipes optional)
|
|
90
|
-
* - A separator row: | --- | --- | (cells are only dashes, colons, spaces)
|
|
91
|
-
* - At least one data row.
|
|
92
|
-
*
|
|
93
|
-
* The separator row is the discriminating signal — it prevents plain prose
|
|
94
|
-
* lines that happen to contain a pipe (e.g. `echo foo | bar`) from being
|
|
95
|
-
* mistaken for tables.
|
|
96
|
-
*
|
|
97
|
-
* Returns null when the block is not a valid table.
|
|
98
|
-
*/
|
|
99
|
-
function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null {
|
|
100
|
-
if (lines.length < 3) return null
|
|
101
|
-
|
|
102
|
-
// Separator line: cells contain only dashes, colons, and spaces.
|
|
103
|
-
const sepRe = /^\|?(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*:?-*:?[ \t]*\|?$/
|
|
104
|
-
// A pipe-delimited row: must contain at least one |
|
|
105
|
-
const rowRe = /\|/
|
|
106
|
-
|
|
107
|
-
// Find the separator line index (must be index 1 in this block)
|
|
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
|
-
}
|
|
127
|
-
|
|
128
|
-
if (rows.length === 0) return null
|
|
129
|
-
return { headers, rows }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Render a parsed markdown table as Telegram-compatible HTML.
|
|
134
|
-
*
|
|
135
|
-
* Branch rules:
|
|
136
|
-
* - ≤3 columns AND ≤6 rows → bullet list:
|
|
137
|
-
* Each row is one bullet. First column in <b>; subsequent columns
|
|
138
|
-
* appended as " — value".
|
|
139
|
-
* - otherwise → <pre> block with padded columns.
|
|
140
|
-
*/
|
|
141
|
-
function renderTable(headers: string[], rows: string[][]): string {
|
|
142
|
-
const colCount = headers.length
|
|
143
|
-
const rowCount = rows.length
|
|
144
|
-
|
|
145
|
-
if (colCount <= 3 && rowCount <= 6) {
|
|
146
|
-
// Bullet list rendering
|
|
147
|
-
const bullets = rows.map(row => {
|
|
148
|
-
// Normalise row length to match header count (guard empty cells)
|
|
149
|
-
const cells = headers.map((_, i) => (row[i] ?? '').trim())
|
|
150
|
-
const key = escapeHtml(cells[0] || '—')
|
|
151
|
-
const rest = cells
|
|
152
|
-
.slice(1)
|
|
153
|
-
.filter(v => v !== '')
|
|
154
|
-
.map(v => ` — ${escapeHtml(v)}`)
|
|
155
|
-
.join('')
|
|
156
|
-
return `• <b>${key}</b>${rest}`
|
|
157
|
-
})
|
|
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
|
-
|
|
166
|
-
// Pre-block with padded columns
|
|
167
|
-
// Compute column widths across headers + all rows
|
|
168
|
-
const allRows = [headers, ...rows]
|
|
169
|
-
const widths = headers.map((_, ci) =>
|
|
170
|
-
Math.max(...allRows.map(r => (r[ci] ?? '').length))
|
|
171
|
-
)
|
|
172
|
-
const pad = (s: string, w: number) => s + ' '.repeat(Math.max(0, w - s.length))
|
|
173
|
-
|
|
174
|
-
const formatRow = (r: string[]) =>
|
|
175
|
-
headers.map((_, ci) => pad(r[ci] ?? '', widths[ci])).join(' ')
|
|
176
|
-
|
|
177
|
-
const sepLine = widths.map(w => '-'.repeat(w)).join(' ')
|
|
178
|
-
|
|
179
|
-
const lines = [
|
|
180
|
-
formatRow(headers),
|
|
181
|
-
sepLine,
|
|
182
|
-
...rows.map(r => formatRow(r)),
|
|
183
|
-
]
|
|
184
|
-
return `<pre>${escapeHtml(lines.join('\n'))}</pre>`
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Replace markdown table blocks in `text` with rendered HTML, storing the
|
|
189
|
-
* rendered output in `store` and emitting `placeholderPrefix<n>\x00` tokens
|
|
190
|
-
* so the rest of the pipeline does not re-process them.
|
|
191
|
-
*
|
|
192
|
-
* Tables are identified by their separator line (`| --- |`) which prevents
|
|
193
|
-
* plain prose containing a pipe (e.g. `echo foo | bar`) from being mistaken
|
|
194
|
-
* for a table. Fenced code blocks are extracted before this runs, so
|
|
195
|
-
* table-looking rows inside ``` blocks are already protected.
|
|
196
|
-
*/
|
|
197
|
-
function extractMarkdownTables(
|
|
198
|
-
text: string,
|
|
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
|
-
}
|
|
213
|
-
|
|
214
|
-
// Collect a run of pipe-containing lines as a candidate block
|
|
215
|
-
let j = i
|
|
216
|
-
while (j < inputLines.length && inputLines[j].includes('|')) {
|
|
217
|
-
j++
|
|
218
|
-
}
|
|
219
|
-
const block = inputLines.slice(i, j)
|
|
220
|
-
|
|
221
|
-
const parsed = parseMarkdownTable(block)
|
|
222
|
-
if (parsed) {
|
|
223
|
-
const tableLineCount = 2 + parsed.rows.length
|
|
224
|
-
const remainder = block.slice(tableLineCount)
|
|
225
|
-
const idx = store.length
|
|
226
|
-
store.push(renderTable(parsed.headers, parsed.rows))
|
|
227
|
-
outputLines.push(`${placeholderPrefix}${idx}\x00`)
|
|
228
|
-
for (const r of remainder) outputLines.push(r)
|
|
229
|
-
i = j
|
|
230
|
-
} else {
|
|
231
|
-
for (const b of block) outputLines.push(b)
|
|
232
|
-
i = j
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return outputLines.join('\n')
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Convert markdown to Telegram-compatible HTML.
|
|
241
|
-
* Handles bold, italic, code, code blocks, strikethrough, links.
|
|
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
|
-
}
|
|
253
|
-
|
|
254
|
-
// First, extract code blocks and inline code to protect them from other transforms.
|
|
255
|
-
const codeBlocks: string[] = []
|
|
256
|
-
const BLOCK_PH = '\x00CODEBLOCK'
|
|
257
|
-
const INLINE_PH = '\x00CODEINLINE'
|
|
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
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function escapeHtml(text: string): string {
|
|
374
|
-
// Also escape `"` so callers that interpolate into HTML attribute values
|
|
375
|
-
// don't need a second helper. Safe for tag-content use too.
|
|
376
|
-
return text
|
|
377
|
-
.replace(/&/g, '&')
|
|
378
|
-
.replace(/</g, '<')
|
|
379
|
-
.replace(/>/g, '>')
|
|
380
|
-
.replace(/"/g, '"')
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Last-resort renderer: turn (possibly malformed) Telegram HTML into
|
|
385
|
-
* readable plain text. Used by the gateway's send/edit path when
|
|
386
|
-
* Telegram rejects a chunk with a 400 "can't parse entities" /
|
|
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.
|
|
2
|
+
* Telegram rich-message formatting helpers.
|
|
3
|
+
*
|
|
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
|
+
*
|
|
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
|
+
* - RICH_MESSAGE_MAX_CHARS: the rich-text wire cap (32768).
|
|
406
20
|
*/
|
|
407
|
-
export function telegramHtmlToPlainText(html: string): string {
|
|
408
|
-
const decodeEntities = (s: string): string =>
|
|
409
|
-
s
|
|
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
|
-
)
|
|
447
|
-
|
|
448
|
-
return stripTags(withPlainLinks)
|
|
449
|
-
.replace(/[ \t]+$/gm, '')
|
|
450
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
451
|
-
.trim()
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
// Output sanitizer — enforces fleet-wide Telegram formatting invariants
|
|
456
|
-
// ---------------------------------------------------------------------------
|
|
457
21
|
|
|
458
22
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* leaves semantic decisions (where to bold, link choice, list-vs-prose) to
|
|
464
|
-
* the agent.
|
|
465
|
-
*
|
|
466
|
-
* Rules applied (in order):
|
|
467
|
-
* 1. Strip markdown heading markers (`## Foo` → `<b>Foo</b>\n\n`).
|
|
468
|
-
* Headings that survived the markdown→HTML pass (e.g. when the input
|
|
469
|
-
* was already HTML and passed through isLikelyTelegramHtml) would render
|
|
470
|
-
* as ugly `## Foo` plain text. Convert to bold + blank line.
|
|
471
|
-
* 2. Flatten nested bullet indentation: `\n - sub` → `\n· sub`.
|
|
472
|
-
* 3. Collapse 3+ consecutive blank lines to exactly 2.
|
|
473
|
-
* 4. Strip trailing whitespace on each line.
|
|
474
|
-
* 5. Ensure `<` `>` `&` inside `<code>` and `<pre>` blocks are
|
|
475
|
-
* HTML-escaped (idempotent: won't double-escape existing `&` etc.).
|
|
476
|
-
*
|
|
477
|
-
* The function is idempotent: sanitize(sanitize(x)) === sanitize(x).
|
|
478
|
-
* Content inside `<code>` / `<pre>` blocks is excluded from rules 1–4.
|
|
23
|
+
* Rich-message wire cap. Bot API 10.1 rich messages allow up to 32768
|
|
24
|
+
* UTF-8 characters (JSDoc-only in @grammyjs/types; no exported constant,
|
|
25
|
+
* so we hard-code it). The legacy plain-text `sendMessage`/`editMessageText`
|
|
26
|
+
* cap of 4096 does NOT apply on the rich path.
|
|
479
27
|
*/
|
|
480
|
-
export
|
|
481
|
-
// ── Phase 1: extract <code> and <pre> blocks so rules 1-4 don't touch them.
|
|
482
|
-
//
|
|
483
|
-
// We capture the full tag with its content so we can round-trip correctly.
|
|
484
|
-
// Placeholders are non-printing control sequences that cannot appear in
|
|
485
|
-
// normal text.
|
|
486
|
-
const CODE_PH = '\x00SANCODE'
|
|
487
|
-
const PRE_PH = '\x00SANPRE'
|
|
488
|
-
const codeSegments: string[] = []
|
|
489
|
-
const preSegments: string[] = []
|
|
490
|
-
|
|
491
|
-
// Extract <pre>...</pre> blocks first (they may contain <code> inside).
|
|
492
|
-
let result = text.replace(/<pre>([\s\S]*?)<\/pre>/gi, (_m, inner: string) => {
|
|
493
|
-
const idx = preSegments.length
|
|
494
|
-
// Rule 5: escape unescaped < > & inside pre blocks.
|
|
495
|
-
preSegments.push(`<pre>${escapeUnescapedEntities(inner)}</pre>`)
|
|
496
|
-
return `${PRE_PH}${idx}\x00`
|
|
497
|
-
})
|
|
498
|
-
|
|
499
|
-
// Extract standalone <code>...</code> blocks (not nested inside <pre>).
|
|
500
|
-
result = result.replace(/<code([^>]*)>([\s\S]*?)<\/code>/gi, (_m, attrs: string, inner: string) => {
|
|
501
|
-
const idx = codeSegments.length
|
|
502
|
-
// Rule 5: escape unescaped < > & inside code spans.
|
|
503
|
-
codeSegments.push(`<code${attrs}>${escapeUnescapedEntities(inner)}</code>`)
|
|
504
|
-
return `${CODE_PH}${idx}\x00`
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
// ── Phase 2: apply text-level rules to the remaining (non-code) content.
|
|
508
|
-
|
|
509
|
-
// Rule 1: strip markdown heading markers that survived markdown→HTML pass.
|
|
510
|
-
// Matches lines starting with one or more `#` followed by a space.
|
|
511
|
-
// Preserves the heading text as bold + trailing blank line.
|
|
512
|
-
result = result.replace(/^(#{1,6}) +(.+?)\s*$/gm, (_m, _hashes, title: string) => {
|
|
513
|
-
return `<b>${title}</b>\n`
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
// Rule 2: flatten nested bullet indentation.
|
|
517
|
-
// Matches lines with a tab OR 2+ spaces at the start followed by - or *.
|
|
518
|
-
// A single tab is treated as sufficient indentation (standard 4-space equiv).
|
|
519
|
-
// Converts to a middle-dot bullet so the sub-detail survives as readable text.
|
|
520
|
-
result = result.replace(/^(?:\t+[ \t]*|[ \t]{2,})[*-] /gm, '· ')
|
|
521
|
-
|
|
522
|
-
// Rule 4: strip trailing whitespace on each line.
|
|
523
|
-
result = result.replace(/[ \t]+$/gm, '')
|
|
524
|
-
|
|
525
|
-
// Rule 3: collapse 3+ consecutive blank lines to exactly 2.
|
|
526
|
-
// A "blank line" is a line that contains only optional whitespace (already
|
|
527
|
-
// stripped above, but let's be safe).
|
|
528
|
-
result = result.replace(/(\n[ \t]*){3,}/g, '\n\n')
|
|
529
|
-
|
|
530
|
-
// ── Phase 3: restore placeholders.
|
|
531
|
-
result = result.replace(new RegExp(`${CODE_PH}(\\d+)\x00`, 'g'), (_m, idx) => codeSegments[Number(idx)])
|
|
532
|
-
result = result.replace(new RegExp(`${PRE_PH}(\\d+)\x00`, 'g'), (_m, idx) => preSegments[Number(idx)])
|
|
533
|
-
|
|
534
|
-
return result
|
|
535
|
-
}
|
|
28
|
+
export const RICH_MESSAGE_MAX_CHARS = 32768
|
|
536
29
|
|
|
537
30
|
/**
|
|
538
|
-
* Escape
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
31
|
+
* Escape the GFM-markdown special characters so a dynamic value
|
|
32
|
+
* (a filename, an id, arbitrary user text) renders LITERALLY inside a
|
|
33
|
+
* hand-built markdown card instead of being parsed as formatting.
|
|
34
|
+
*
|
|
35
|
+
* Used wherever the old HTML cards called `escapeHtml(value)` while
|
|
36
|
+
* interpolating into `<b>…</b>` / `<code>…</code>`. The markdown
|
|
37
|
+
* equivalent is `**${escapeMarkdown(value)}**` / `` `${value}` `` (code
|
|
38
|
+
* spans don't need escaping — backtick content is literal).
|
|
39
|
+
*
|
|
40
|
+
* Escapes only the characters that trigger INLINE formatting in
|
|
41
|
+
* rich-markdown — backslash, `` ` ``, `*`, `_`, `~`, `=`, `[`, `]`, `|`.
|
|
42
|
+
* Deliberately does NOT escape `.` `-` `+` `#` `(` `)` `{` `}` `!` `>`:
|
|
43
|
+
* those are only meaningful at line-start (headings, lists, quotes) or in
|
|
44
|
+
* link/structure context, and escaping them mid-word (filenames like
|
|
45
|
+
* `foo.ts`, versions like `v1.2-rc`, URLs) would litter the output with
|
|
46
|
+
* visible backslashes. The backslash is escaped first so we never
|
|
47
|
+
* double-escape.
|
|
546
48
|
*/
|
|
547
|
-
function
|
|
548
|
-
|
|
549
|
-
// pattern. A valid entity is: &[a-zA-Z][a-zA-Z0-9]*; or &#[0-9]+; or &#x[0-9a-fA-F]+;
|
|
550
|
-
let out = inner.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);)/g, '&')
|
|
551
|
-
// Escape bare < and > (they should never appear literally in code content)
|
|
552
|
-
out = out.replace(/</g, '<')
|
|
553
|
-
out = out.replace(/>/g, '>')
|
|
554
|
-
return out
|
|
49
|
+
export function escapeMarkdown(text: string): string {
|
|
50
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
555
51
|
}
|
|
556
52
|
|
|
557
53
|
/**
|
|
@@ -596,8 +92,8 @@ export function repairEscapedWhitespace(text: string): string {
|
|
|
596
92
|
// inline span and expose the block's interior.
|
|
597
93
|
//
|
|
598
94
|
// Inline code uses `[^\`\n]+` (one or more non-backtick, non-newline chars)
|
|
599
|
-
// matching the same definition
|
|
600
|
-
//
|
|
95
|
+
// matching the same definition the chunker uses, so the masked regions are
|
|
96
|
+
// consistent with what the downstream pipeline treats as code.
|
|
601
97
|
const codeMasks: string[] = []
|
|
602
98
|
|
|
603
99
|
const masked = text
|
|
@@ -608,7 +104,7 @@ export function repairEscapedWhitespace(text: string): string {
|
|
|
608
104
|
return `${CODE_MASK_PH}${idx}\x00`
|
|
609
105
|
})
|
|
610
106
|
// Inline code spans: at least one character between backticks, no embedded
|
|
611
|
-
// backtick or newline
|
|
107
|
+
// backtick or newline.
|
|
612
108
|
.replace(/`[^`\n]+`/g, (m) => {
|
|
613
109
|
const idx = codeMasks.length
|
|
614
110
|
codeMasks.push(m)
|
|
@@ -631,19 +127,33 @@ export function repairEscapedWhitespace(text: string): string {
|
|
|
631
127
|
}
|
|
632
128
|
|
|
633
129
|
// ---------------------------------------------------------------------------
|
|
634
|
-
//
|
|
130
|
+
// Markdown-aware chunking — never bisects a code fence or a table row
|
|
635
131
|
// ---------------------------------------------------------------------------
|
|
636
132
|
|
|
637
133
|
/**
|
|
638
|
-
* Split
|
|
639
|
-
*
|
|
640
|
-
*
|
|
134
|
+
* Split a markdown body into chunks that each fit within `maxLen`.
|
|
135
|
+
*
|
|
136
|
+
* The rich-message path is raw GFM markdown, so chunk boundaries must not
|
|
137
|
+
* land inside a fenced code block (``` … ```) or in the middle of a table
|
|
138
|
+
* row — either produces a chunk Telegram renders wrong (an unterminated
|
|
139
|
+
* fence swallows the next chunk's text, a half table row drops cells).
|
|
140
|
+
*
|
|
141
|
+
* Strategy:
|
|
142
|
+
* 1. If the whole body fits, return it as one chunk.
|
|
143
|
+
* 2. Otherwise pick the largest safe cut <= maxLen, preferring a blank
|
|
144
|
+
* line, then a single newline, then a space. The cut is then nudged
|
|
145
|
+
* so it never falls inside an open fenced block or inside a line that
|
|
146
|
+
* is part of a table (a line containing `|`).
|
|
147
|
+
* 3. An unsplittable region (a single code fence longer than maxLen) is
|
|
148
|
+
* emitted whole rather than spun on forever — Telegram will reject an
|
|
149
|
+
* oversized message, which is a louder, debuggable failure than an
|
|
150
|
+
* infinite loop.
|
|
641
151
|
*/
|
|
642
|
-
export function
|
|
643
|
-
if (
|
|
152
|
+
export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHARS): string[] {
|
|
153
|
+
if (text.length <= maxLen) return [text]
|
|
644
154
|
|
|
645
155
|
const chunks: string[] = []
|
|
646
|
-
let rest =
|
|
156
|
+
let rest = text
|
|
647
157
|
|
|
648
158
|
while (rest.length > 0) {
|
|
649
159
|
if (rest.length <= maxLen) {
|
|
@@ -651,7 +161,6 @@ export function splitHtmlChunks(html: string, maxLen = 4000): string[] {
|
|
|
651
161
|
break
|
|
652
162
|
}
|
|
653
163
|
|
|
654
|
-
// Find a good split point
|
|
655
164
|
let cut = maxLen
|
|
656
165
|
const paraIdx = rest.lastIndexOf('\n\n', maxLen)
|
|
657
166
|
const lineIdx = rest.lastIndexOf('\n', maxLen)
|
|
@@ -665,129 +174,59 @@ export function splitHtmlChunks(html: string, maxLen = 4000): string[] {
|
|
|
665
174
|
cut = spaceIdx
|
|
666
175
|
}
|
|
667
176
|
|
|
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.
|
|
177
|
+
// Back off so the cut doesn't fall inside an open ``` fence.
|
|
178
|
+
cut = backOffOpenFence(rest, cut)
|
|
179
|
+
// Back off so the cut doesn't bisect a table row (a line with `|`).
|
|
180
|
+
cut = backOffTableRow(rest, cut)
|
|
181
|
+
|
|
682
182
|
if (cut <= 0) {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// fires on genuinely malformed input.
|
|
689
|
-
if (cut <= 0) cut = rest.length
|
|
183
|
+
// Could not find a safe boundary below maxLen — the region is one
|
|
184
|
+
// indivisible block (e.g. a single huge fenced block). Emit the
|
|
185
|
+
// whole remainder rather than loop forever.
|
|
186
|
+
chunks.push(rest)
|
|
187
|
+
break
|
|
690
188
|
}
|
|
691
189
|
|
|
692
|
-
|
|
190
|
+
chunks.push(rest.slice(0, cut))
|
|
693
191
|
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
192
|
}
|
|
712
193
|
|
|
713
194
|
return chunks
|
|
714
195
|
}
|
|
715
196
|
|
|
716
197
|
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
198
|
+
* Count fenced-code delimiters (``` at line start) up to `cut`. If the
|
|
199
|
+
* count is odd, the cut lands inside an open fence — retreat to just before
|
|
200
|
+
* the opening fence so the boundary sits between complete blocks.
|
|
719
201
|
*/
|
|
720
|
-
function
|
|
202
|
+
function backOffOpenFence(text: string, cut: number): number {
|
|
721
203
|
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
|
-
}
|
|
204
|
+
const before = text.slice(0, cut)
|
|
205
|
+
const fences = before.match(/^```/gm)
|
|
206
|
+
if (fences == null || fences.length % 2 === 0) return cut
|
|
207
|
+
// Odd number of fences → inside an open block. Find the opening fence and
|
|
208
|
+
// cut just before it (so the whole fenced block goes to the next chunk).
|
|
209
|
+
const lastFence = before.lastIndexOf('\n```')
|
|
210
|
+
if (lastFence <= 0) {
|
|
211
|
+
// Fence opens at the very start of `before` — can't retreat past it.
|
|
212
|
+
return 0
|
|
735
213
|
}
|
|
736
|
-
return
|
|
214
|
+
return lastFence
|
|
737
215
|
}
|
|
738
216
|
|
|
739
217
|
/**
|
|
740
|
-
* If
|
|
741
|
-
*
|
|
742
|
-
* `<` without a matching `>` (e.g. chunk ending `<a href="..`).
|
|
218
|
+
* If the cut lands on a line that contains a `|` (a markdown table row),
|
|
219
|
+
* retreat to the start of that line so we never emit a half table row.
|
|
743
220
|
*/
|
|
744
|
-
function
|
|
221
|
+
function backOffTableRow(text: string, cut: number): number {
|
|
745
222
|
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
|
-
}
|
|
223
|
+
const lineStart = text.lastIndexOf('\n', cut - 1) + 1
|
|
224
|
+
const nextNl = text.indexOf('\n', cut)
|
|
225
|
+
const lineEnd = nextNl === -1 ? text.length : nextNl
|
|
226
|
+
const line = text.slice(lineStart, lineEnd)
|
|
227
|
+
if (line.includes('|')) {
|
|
228
|
+
// Cut at the line start so the whole row moves to the next chunk.
|
|
229
|
+
return lineStart > 0 ? lineStart - 1 : 0
|
|
757
230
|
}
|
|
758
231
|
return cut
|
|
759
232
|
}
|
|
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
|
-
}
|