switchroom 0.16.23 → 0.16.27

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