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.
Files changed (118) hide show
  1. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  2. package/dist/cli/skill-validate-pretool.mjs +2865 -48
  3. package/dist/cli/switchroom.js +338 -92
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +5 -5
  6. package/telegram-plugin/answer-stream.ts +13 -43
  7. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  8. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  9. package/telegram-plugin/card-format.ts +28 -25
  10. package/telegram-plugin/credits-watch.ts +5 -10
  11. package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
  12. package/telegram-plugin/draft-stream.ts +4 -4
  13. package/telegram-plugin/format.ts +101 -662
  14. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  15. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  16. package/telegram-plugin/gateway/approval-card.ts +16 -6
  17. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  18. package/telegram-plugin/gateway/auth-command.ts +74 -74
  19. package/telegram-plugin/gateway/auth-line.ts +5 -15
  20. package/telegram-plugin/gateway/boot-card.ts +20 -22
  21. package/telegram-plugin/gateway/boot-version.ts +3 -2
  22. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  23. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  24. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  25. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  26. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  27. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  28. package/telegram-plugin/gateway/effort-command.ts +17 -17
  29. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  30. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  31. package/telegram-plugin/gateway/gateway.ts +751 -624
  32. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  33. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  34. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  35. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  36. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  37. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  38. package/telegram-plugin/gateway/model-command.ts +25 -25
  39. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  40. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  41. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  42. package/telegram-plugin/issues-card.ts +6 -7
  43. package/telegram-plugin/model-unavailable.ts +8 -12
  44. package/telegram-plugin/operator-events-history.ts +1 -1
  45. package/telegram-plugin/operator-events.ts +24 -28
  46. package/telegram-plugin/package.json +1 -1
  47. package/telegram-plugin/pending-work-progress.ts +36 -36
  48. package/telegram-plugin/permission-title.ts +36 -20
  49. package/telegram-plugin/pty-partial-handler.ts +5 -13
  50. package/telegram-plugin/quota-check.ts +5 -5
  51. package/telegram-plugin/quota-watch.ts +13 -18
  52. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  53. package/telegram-plugin/registry/turns-schema.ts +44 -2
  54. package/telegram-plugin/retry-api-call.ts +15 -7
  55. package/telegram-plugin/rich-send.ts +57 -0
  56. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  57. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  58. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  59. package/telegram-plugin/silence-poke.ts +26 -69
  60. package/telegram-plugin/slot-banner-driver.ts +9 -6
  61. package/telegram-plugin/slot-banner.ts +5 -8
  62. package/telegram-plugin/steering.ts +0 -4
  63. package/telegram-plugin/stream-controller.ts +59 -62
  64. package/telegram-plugin/stream-reply-handler.ts +40 -97
  65. package/telegram-plugin/subagent-watcher.ts +2 -2
  66. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  67. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  68. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  69. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  70. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  71. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  72. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  73. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  74. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  75. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  76. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  77. package/telegram-plugin/tests/card-format.test.ts +6 -4
  78. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  79. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  80. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  81. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  82. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  83. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  84. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  85. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  86. package/telegram-plugin/tests/model-command.test.ts +2 -2
  87. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  88. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  89. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  90. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  91. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  92. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  93. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  94. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  95. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  96. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  97. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  98. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  99. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  100. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  101. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  102. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  103. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  104. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  105. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  106. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  107. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  108. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  109. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  110. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  111. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  112. package/telegram-plugin/text-voice-scrub.ts +8 -11
  113. package/telegram-plugin/tool-activity-summary.ts +29 -29
  114. package/telegram-plugin/welcome-text.ts +82 -83
  115. package/telegram-plugin/worker-activity-feed.ts +2 -3
  116. package/telegram-plugin/html-sanitize.ts +0 -244
  117. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  118. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -1,557 +1,53 @@
1
1
  /**
2
- * Telegram-flavored markdown→HTML rendering and chunking.
3
- *
4
- * Extracted from server.ts so tests can import these helpers without
5
- * triggering the bot startup side effects (env loading, token check,
6
- * grammy instantiation). server.ts re-exports the public API for
7
- * backwards compatibility with any external callers.
8
- *
9
- * Three pieces:
10
- * - markdownToHtml + isLikelyTelegramHtml: convert model output to
11
- * Telegram-safe HTML, preserving any embedded whitelisted Telegram
12
- * HTML tags so the model can mix markdown bold with raw <b>/<i>/<a>.
13
- * - splitHtmlChunks: split a long HTML message into <=4096-char chunks
14
- * that preserve open/close tag balance and don't bisect HTML entities.
15
- * - escapeHtml: the three-char escape used everywhere.
16
- */
17
-
18
- /**
19
- * Telegram-supported HTML tags. Anything outside this set is either
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, '&amp;')
378
- .replace(/</g, '&lt;')
379
- .replace(/>/g, '&gt;')
380
- .replace(/"/g, '&quot;')
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(/&amp;/g, '&')
411
- .replace(/&lt;/g, '<')
412
- .replace(/&gt;/g, '>')
413
- .replace(/&quot;/g, '"')
414
- .replace(/&#0*39;|&#x0*27;|&apos;/gi, "'")
415
- .replace(/&nbsp;/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
- * Normalize outbound Telegram HTML text against well-known invariants.
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.
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 `&amp;` 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 function sanitizeForTelegram(text: string): string {
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 `<`, `>`, and `&` characters that are NOT already part of an HTML
539
- * entity or tag. Used inside `<code>` and `<pre>` content to correct
540
- * unescaped characters without double-escaping existing `&amp;`, `&lt;`, etc.
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.
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 escapeUnescapedEntities(inner: string): string {
548
- // Escape bare & first: replace & that is NOT followed by a valid entity
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, '&amp;')
551
- // Escape bare < and > (they should never appear literally in code content)
552
- out = out.replace(/</g, '&lt;')
553
- out = out.replace(/>/g, '&gt;')
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 that markdownToHtml uses, so the masked regions
600
- // are consistent with what the downstream pipeline treats as code.
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 (matches markdownToHtml's /`([^`\n]+)`/ definition).
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
- // Smart HTML chunking — preserves open/close tag boundaries
130
+ // Markdown-aware chunking — never bisects a code fence or a table row
635
131
  // ---------------------------------------------------------------------------
636
132
 
637
133
  /**
638
- * Split HTML text into chunks that fit within maxLen, preserving tag integrity.
639
- * At split boundaries, open tags are closed and reopened in the next chunk.
640
- * Prefers splitting at \n\n, then \n, then spaces.
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 splitHtmlChunks(html: string, maxLen = 4000): string[] {
643
- if (html.length <= maxLen) return [html]
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 = html
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
- // Defense-in-depth: refuse to split inside an HTML entity (&amp;,
669
- // &lt;, &#x1f4a9;). If the cut would land mid-entity, back up to
670
- // before the `&`. Telegram rejects messages with broken entities.
671
- cut = backOffEntity(rest, cut)
672
- // Same idea for a bisected tag: if the cut lands inside `<...>` (or
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
- cut = Math.min(maxLen, rest.length)
684
- cut = backOffOpenTag(rest, cut)
685
- // If even the maxLen cut bisects a tag, emit the whole remainder
686
- // as one chunk rather than spin forever. Telegram will reject
687
- // a 4k+ message before it rejects a split one, but this only
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
- let segment = rest.slice(0, cut)
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
- * If `cut` lies inside an HTML entity (a `&...;` sequence), back it up to
718
- * just before the `&` so the chunk boundary doesn't bisect the entity.
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 backOffEntity(text: string, cut: number): number {
202
+ function backOffOpenFence(text: string, cut: number): number {
721
203
  if (cut <= 0 || cut >= text.length) return cut
722
- // Look backward up to 10 chars for an unterminated entity
723
- const lookback = Math.max(0, cut - 10)
724
- for (let i = cut - 1; i >= lookback; i--) {
725
- const ch = text[i]
726
- if (ch === ';') return cut // entity already closed before cut → safe
727
- if (ch === '&') {
728
- const closeIdx = text.indexOf(';', cut)
729
- if (closeIdx !== -1 && closeIdx - i <= 10) {
730
- // The entity spans the cut — back up to just before the `&`
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 cut
214
+ return lastFence
737
215
  }
738
216
 
739
217
  /**
740
- * If `cut` lands inside an HTML tag (between `<` and the next `>`), back
741
- * up to before the `<`. Telegram rejects messages that contain a stray
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 backOffOpenTag(text: string, cut: number): number {
221
+ function backOffTableRow(text: string, cut: number): number {
745
222
  if (cut <= 0 || cut >= text.length) return cut
746
- // Scan backward for the nearest `<` or `>` before the cut. If we hit
747
- // `>` first the cut is outside any tag → safe. If we hit `<` first,
748
- // check whether its closing `>` lies at or after the cut → bisected.
749
- for (let i = cut - 1; i >= 0; i--) {
750
- const ch = text[i]
751
- if (ch === '>') return cut
752
- if (ch === '<') {
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
- }