switchroom 0.16.23 → 0.16.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram-HTML sanitizer.
|
|
3
|
-
*
|
|
4
|
-
* Telegram's HTML parser is strict: it accepts only a small whitelist of
|
|
5
|
-
* tags (b/strong, i/em, u/ins, s/strike/del, code, pre, a, tg-spoiler,
|
|
6
|
-
* span class="tg-spoiler", tg-emoji, blockquote) and rejects the entire
|
|
7
|
-
* message with `400 Bad Request: can't parse entities` when it encounters
|
|
8
|
-
* an unknown tag, an unbalanced tag, or a stray `<` that doesn't open a
|
|
9
|
-
* known tag.
|
|
10
|
-
*
|
|
11
|
-
* `markdownToHtml()` already produces well-formed HTML for content the
|
|
12
|
-
* model wrote *as markdown*. This module is the second line of defence
|
|
13
|
-
* for content the model wrote *as raw HTML* (or as a mix that produces
|
|
14
|
-
* malformed HTML after rendering): we run the rendered string through a
|
|
15
|
-
* pure-text scanner that:
|
|
16
|
-
*
|
|
17
|
-
* 1. Escapes any `<` / `>` that doesn't open or close a whitelisted
|
|
18
|
-
* tag (e.g. `<frobnicate>` → `<frobnicate>`,
|
|
19
|
-
* `1 < 2 < 3` → `1 < 2 < 3`).
|
|
20
|
-
* 2. Escapes attributes that aren't on the small per-tag allowlist
|
|
21
|
-
* (only `<a href>`, `<code class>`, `<span class="tg-spoiler">`,
|
|
22
|
-
* `<tg-emoji emoji-id>`, `<blockquote expandable>` are accepted —
|
|
23
|
-
* everything else gets stripped from the tag).
|
|
24
|
-
* 3. Auto-closes any tag opened but never closed (so an "unclosed
|
|
25
|
-
* `<b>` at end of message" doesn't trip Telegram's parser).
|
|
26
|
-
* 4. Drops any unmatched closing tag (e.g. `</foo>` without an opener).
|
|
27
|
-
*
|
|
28
|
-
* The sanitizer is conservative: when in doubt, escape. The output is
|
|
29
|
-
* guaranteed to round-trip through Telegram's HTML parser without a
|
|
30
|
-
* `can't parse entities` 400 — at the cost of some false positives where
|
|
31
|
-
* raw `<` characters inside model prose end up as `<` (which is the
|
|
32
|
-
* correct Telegram-HTML behaviour anyway).
|
|
33
|
-
*
|
|
34
|
-
* Idempotent: sanitize(sanitize(x)) === sanitize(x).
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/** Telegram's parse_mode=HTML allowed tag names. */
|
|
38
|
-
const ALLOWED_TAGS = new Set([
|
|
39
|
-
'b', 'strong',
|
|
40
|
-
'i', 'em',
|
|
41
|
-
'u', 'ins',
|
|
42
|
-
's', 'strike', 'del',
|
|
43
|
-
'a',
|
|
44
|
-
'code',
|
|
45
|
-
'pre',
|
|
46
|
-
'span',
|
|
47
|
-
'tg-spoiler',
|
|
48
|
-
'tg-emoji',
|
|
49
|
-
'blockquote',
|
|
50
|
-
])
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Per-tag attribute allowlist. Keys are tag names; values are the set of
|
|
54
|
-
* attribute names that survive the sanitizer. Anything not listed is
|
|
55
|
-
* silently dropped from the opening tag.
|
|
56
|
-
*/
|
|
57
|
-
const ALLOWED_ATTRS: Record<string, Set<string>> = {
|
|
58
|
-
a: new Set(['href']),
|
|
59
|
-
code: new Set(['class']),
|
|
60
|
-
span: new Set(['class']),
|
|
61
|
-
'tg-emoji': new Set(['emoji-id']),
|
|
62
|
-
blockquote: new Set(['expandable']),
|
|
63
|
-
pre: new Set(['language']),
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Allowed schemes for `<a href>` — block javascript:, data:, etc. */
|
|
67
|
-
const ALLOWED_HREF_SCHEMES = /^(?:https?|mailto|tel|tg):/i
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Strip every `<…>` from a string, escaping the `<` and `>`. Used as the
|
|
71
|
-
* fallback when the input genuinely cannot be made HTML-safe.
|
|
72
|
-
*/
|
|
73
|
-
export function escapeAllHtml(text: string): string {
|
|
74
|
-
return text
|
|
75
|
-
.replace(/&/g, '&')
|
|
76
|
-
.replace(/</g, '<')
|
|
77
|
-
.replace(/>/g, '>')
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Sanitize a string of Telegram HTML so it cannot trip the
|
|
82
|
-
* `can't parse entities` 400. See module docstring for the rules.
|
|
83
|
-
*
|
|
84
|
-
* @param input - the rendered HTML (post markdownToHtml).
|
|
85
|
-
* @returns the sanitized HTML, guaranteed parse-mode=HTML safe.
|
|
86
|
-
*/
|
|
87
|
-
export function sanitizeTelegramHtml(input: string): string {
|
|
88
|
-
// Walk the input character by character. When we see `<`, try to match
|
|
89
|
-
// a tag against our whitelist. If it matches, emit a normalized form;
|
|
90
|
-
// otherwise escape the `<` and continue.
|
|
91
|
-
//
|
|
92
|
-
// Tag regex: opening tag name, optional attributes (anything up to
|
|
93
|
-
// the next `>` that isn't inside a quoted string), and the closing
|
|
94
|
-
// `>`. We use a small hand-rolled parser rather than a generic regex
|
|
95
|
-
// because we need to validate attributes per-tag.
|
|
96
|
-
const out: string[] = []
|
|
97
|
-
const stack: string[] = [] // names of open tags, oldest-first
|
|
98
|
-
let i = 0
|
|
99
|
-
const len = input.length
|
|
100
|
-
|
|
101
|
-
while (i < len) {
|
|
102
|
-
const ch = input[i]
|
|
103
|
-
|
|
104
|
-
if (ch === '&') {
|
|
105
|
-
// Pass through known entities verbatim; escape bare `&`.
|
|
106
|
-
// A "known entity" is `&` followed by [a-z]+; or `&#` followed by digits;
|
|
107
|
-
// both terminated by `;` within ~10 chars.
|
|
108
|
-
const m = /^&(?:#\d+|#x[0-9a-f]+|[a-z]+);/i.exec(input.slice(i, i + 12))
|
|
109
|
-
if (m) {
|
|
110
|
-
out.push(m[0])
|
|
111
|
-
i += m[0].length
|
|
112
|
-
} else {
|
|
113
|
-
out.push('&')
|
|
114
|
-
i++
|
|
115
|
-
}
|
|
116
|
-
continue
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (ch !== '<') {
|
|
120
|
-
out.push(ch)
|
|
121
|
-
i++
|
|
122
|
-
continue
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// We're at a `<`. Try to parse a tag.
|
|
126
|
-
const tagMatch = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/.exec(input.slice(i))
|
|
127
|
-
if (!tagMatch) {
|
|
128
|
-
// Stray `<` — escape it.
|
|
129
|
-
out.push('<')
|
|
130
|
-
i++
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
const isClose = tagMatch[1] === '/'
|
|
134
|
-
const tagName = tagMatch[2].toLowerCase()
|
|
135
|
-
const attrText = tagMatch[3]
|
|
136
|
-
|
|
137
|
-
if (!ALLOWED_TAGS.has(tagName)) {
|
|
138
|
-
// Unknown tag — escape the whole `<…>` literal.
|
|
139
|
-
out.push(escapeAllHtml(tagMatch[0]))
|
|
140
|
-
i += tagMatch[0].length
|
|
141
|
-
continue
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (isClose) {
|
|
145
|
-
// Closing tag: only emit if it matches the most recently opened
|
|
146
|
-
// tag of the same name. If the stack contains a matching opener
|
|
147
|
-
// earlier, close out everything above it (auto-close any
|
|
148
|
-
// unbalanced inner tags). If there's no matching opener at all,
|
|
149
|
-
// drop the closing tag silently.
|
|
150
|
-
const idx = stack.lastIndexOf(tagName)
|
|
151
|
-
if (idx === -1) {
|
|
152
|
-
// No matching opener — drop the close tag.
|
|
153
|
-
i += tagMatch[0].length
|
|
154
|
-
continue
|
|
155
|
-
}
|
|
156
|
-
// Auto-close anything above the matching opener.
|
|
157
|
-
while (stack.length > idx + 1) {
|
|
158
|
-
const top = stack.pop()!
|
|
159
|
-
out.push(`</${top}>`)
|
|
160
|
-
}
|
|
161
|
-
stack.pop()
|
|
162
|
-
out.push(`</${tagName}>`)
|
|
163
|
-
i += tagMatch[0].length
|
|
164
|
-
continue
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Opening tag: filter attributes.
|
|
168
|
-
const cleanAttrs = sanitizeAttrs(tagName, attrText)
|
|
169
|
-
out.push(`<${tagName}${cleanAttrs}>`)
|
|
170
|
-
// Self-closing void tags? Telegram has none — every allowed tag is
|
|
171
|
-
// a container. Push onto stack so we can auto-close later.
|
|
172
|
-
stack.push(tagName)
|
|
173
|
-
i += tagMatch[0].length
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Auto-close any unclosed tags so the output is balanced.
|
|
177
|
-
while (stack.length > 0) {
|
|
178
|
-
const top = stack.pop()!
|
|
179
|
-
out.push(`</${top}>`)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return out.join('')
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Filter the attribute string of an opening tag against the per-tag
|
|
187
|
-
* allowlist. Returns the cleaned attribute string with a leading space
|
|
188
|
-
* (or empty string when no attributes survive).
|
|
189
|
-
*/
|
|
190
|
-
function sanitizeAttrs(tagName: string, attrText: string): string {
|
|
191
|
-
const allowed = ALLOWED_ATTRS[tagName]
|
|
192
|
-
if (!allowed || allowed.size === 0) return ''
|
|
193
|
-
|
|
194
|
-
// Match attribute name=value or bare name. Value may be double-quoted,
|
|
195
|
-
// single-quoted, or unquoted.
|
|
196
|
-
const attrRe = /([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g
|
|
197
|
-
const kept: string[] = []
|
|
198
|
-
let m: RegExpExecArray | null
|
|
199
|
-
while ((m = attrRe.exec(attrText)) != null) {
|
|
200
|
-
const name = m[1].toLowerCase()
|
|
201
|
-
if (!allowed.has(name)) continue
|
|
202
|
-
const rawValue = m[2] ?? m[3] ?? m[4] ?? ''
|
|
203
|
-
|
|
204
|
-
// Per-attribute value sanitization.
|
|
205
|
-
if (tagName === 'a' && name === 'href') {
|
|
206
|
-
const trimmed = rawValue.trim()
|
|
207
|
-
if (!ALLOWED_HREF_SCHEMES.test(trimmed)) continue
|
|
208
|
-
kept.push(`href="${escapeAttrValue(trimmed)}"`)
|
|
209
|
-
continue
|
|
210
|
-
}
|
|
211
|
-
if (rawValue.length === 0) {
|
|
212
|
-
// Bare attribute like `<blockquote expandable>`.
|
|
213
|
-
kept.push(name)
|
|
214
|
-
continue
|
|
215
|
-
}
|
|
216
|
-
kept.push(`${name}="${escapeAttrValue(rawValue)}"`)
|
|
217
|
-
}
|
|
218
|
-
return kept.length > 0 ? ' ' + kept.join(' ') : ''
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function escapeAttrValue(v: string): string {
|
|
222
|
-
return v
|
|
223
|
-
.replace(/&/g, '&')
|
|
224
|
-
.replace(/"/g, '"')
|
|
225
|
-
.replace(/</g, '<')
|
|
226
|
-
.replace(/>/g, '>')
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Convert a rendered HTML body to a plain-text fallback. Used when
|
|
231
|
-
* Telegram still rejects the (presumably-already-sanitized) HTML — we
|
|
232
|
-
* strip every tag and unescape entities so the message lands as text
|
|
233
|
-
* instead of disappearing.
|
|
234
|
-
*/
|
|
235
|
-
export function htmlToPlainText(html: string): string {
|
|
236
|
-
return html
|
|
237
|
-
// Drop tags entirely.
|
|
238
|
-
.replace(/<\/?[a-zA-Z][a-zA-Z0-9-]*\b[^>]*>/g, '')
|
|
239
|
-
// Unescape the four entities the encoder produces.
|
|
240
|
-
.replace(/</g, '<')
|
|
241
|
-
.replace(/>/g, '>')
|
|
242
|
-
.replace(/"/g, '"')
|
|
243
|
-
.replace(/&/g, '&')
|
|
244
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the Telegram-HTML sanitizer (issue #657).
|
|
3
|
-
*
|
|
4
|
-
* The sanitizer is the pre-validation layer that runs after
|
|
5
|
-
* markdownToHtml() and before bot.api.sendMessage. Its job is to
|
|
6
|
-
* guarantee that whatever HTML it emits, Telegram will accept under
|
|
7
|
-
* parse_mode=HTML — closing the `400 Bad Request: can't parse entities`
|
|
8
|
-
* loophole that produced the duplicate-message symptom in #657.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect } from 'vitest'
|
|
12
|
-
import {
|
|
13
|
-
sanitizeTelegramHtml,
|
|
14
|
-
htmlToPlainText,
|
|
15
|
-
escapeAllHtml,
|
|
16
|
-
} from '../html-sanitize.js'
|
|
17
|
-
|
|
18
|
-
describe('sanitizeTelegramHtml', () => {
|
|
19
|
-
it('passes whitelisted tags through unchanged', () => {
|
|
20
|
-
expect(sanitizeTelegramHtml('<b>bold</b>')).toBe('<b>bold</b>')
|
|
21
|
-
expect(sanitizeTelegramHtml('<i>italic</i>')).toBe('<i>italic</i>')
|
|
22
|
-
expect(sanitizeTelegramHtml('<code>x</code>')).toBe('<code>x</code>')
|
|
23
|
-
expect(sanitizeTelegramHtml('<pre>block</pre>')).toBe('<pre>block</pre>')
|
|
24
|
-
expect(sanitizeTelegramHtml('<blockquote>q</blockquote>')).toBe('<blockquote>q</blockquote>')
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('escapes unknown tags', () => {
|
|
28
|
-
expect(sanitizeTelegramHtml('<frobnicate>x</frobnicate>')).toBe(
|
|
29
|
-
'<frobnicate>x</frobnicate>',
|
|
30
|
-
)
|
|
31
|
-
expect(sanitizeTelegramHtml('<div>hi</div>')).toBe('<div>hi</div>')
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('escapes stray < that does not open a tag', () => {
|
|
35
|
-
expect(sanitizeTelegramHtml('1 < 2 < 3')).toBe('1 < 2 < 3')
|
|
36
|
-
expect(sanitizeTelegramHtml('a < b')).toBe('a < b')
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('auto-closes unclosed whitelisted tags', () => {
|
|
40
|
-
expect(sanitizeTelegramHtml('<b>unfinished')).toBe('<b>unfinished</b>')
|
|
41
|
-
expect(sanitizeTelegramHtml('<b><i>nested')).toBe('<b><i>nested</i></b>')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('drops unmatched closing tags', () => {
|
|
45
|
-
expect(sanitizeTelegramHtml('hello</b>')).toBe('hello')
|
|
46
|
-
expect(sanitizeTelegramHtml('</i>plain')).toBe('plain')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('auto-closes inner tags when an outer is closed early', () => {
|
|
50
|
-
// <b><i>x</b> → <b><i>x</i></b>
|
|
51
|
-
const out = sanitizeTelegramHtml('<b><i>x</b>')
|
|
52
|
-
expect(out).toBe('<b><i>x</i></b>')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('strips disallowed attributes', () => {
|
|
56
|
-
expect(sanitizeTelegramHtml('<b class="evil" onclick="x()">x</b>')).toBe('<b>x</b>')
|
|
57
|
-
// <code class> is allowed
|
|
58
|
-
expect(sanitizeTelegramHtml('<code class="language-ts">x</code>')).toBe(
|
|
59
|
-
'<code class="language-ts">x</code>',
|
|
60
|
-
)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('blocks dangerous href schemes', () => {
|
|
64
|
-
expect(sanitizeTelegramHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>')
|
|
65
|
-
expect(sanitizeTelegramHtml('<a href="data:text/html,x">x</a>')).toBe('<a>x</a>')
|
|
66
|
-
expect(sanitizeTelegramHtml('<a href="https://example.com">x</a>')).toBe(
|
|
67
|
-
'<a href="https://example.com">x</a>',
|
|
68
|
-
)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('escapes naked ampersands but preserves entities', () => {
|
|
72
|
-
expect(sanitizeTelegramHtml('a & b')).toBe('a & b')
|
|
73
|
-
expect(sanitizeTelegramHtml('a & b')).toBe('a & b')
|
|
74
|
-
expect(sanitizeTelegramHtml('a <b>')).toBe('a <b>')
|
|
75
|
-
expect(sanitizeTelegramHtml('a {')).toBe('a {')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('is idempotent', () => {
|
|
79
|
-
const fixtures = [
|
|
80
|
-
'<b>x</b>',
|
|
81
|
-
'<b><i>nested</i></b>',
|
|
82
|
-
'<b>unclosed',
|
|
83
|
-
'<frobnicate>x</frobnicate>',
|
|
84
|
-
'1 < 2',
|
|
85
|
-
'<b><i>x</b>',
|
|
86
|
-
'a & b & c',
|
|
87
|
-
]
|
|
88
|
-
for (const f of fixtures) {
|
|
89
|
-
const once = sanitizeTelegramHtml(f)
|
|
90
|
-
const twice = sanitizeTelegramHtml(once)
|
|
91
|
-
expect(twice).toBe(once)
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('handles the #657 repro fixture (1799-char "Where I route" message shape)', () => {
|
|
96
|
-
// Reconstruct the structural shape of the body that tripped Telegram.
|
|
97
|
-
// The exact text isn't checked into the repo; what matters is the
|
|
98
|
-
// pattern: nested <code> inside <b> inside <pre>, plus a stray `<`
|
|
99
|
-
// in the middle of the prose, plus an unclosed tag near the end.
|
|
100
|
-
const fixture = [
|
|
101
|
-
'<b>Where I route:</b>',
|
|
102
|
-
'',
|
|
103
|
-
'<pre><b><code>switchroom <cmd></code></b></pre>',
|
|
104
|
-
'',
|
|
105
|
-
'When the user types `cmd <args>` in the bridge, the gateway forwards…',
|
|
106
|
-
'',
|
|
107
|
-
'<b>Some unclosed bold here',
|
|
108
|
-
'<frobnicate>not a real tag</frobnicate>',
|
|
109
|
-
].join('\n')
|
|
110
|
-
|
|
111
|
-
const out = sanitizeTelegramHtml(fixture)
|
|
112
|
-
|
|
113
|
-
// Must not contain any unknown tag literally.
|
|
114
|
-
expect(out).not.toMatch(/<frobnicate/)
|
|
115
|
-
// Stray < (in `cmd <args>`) is escaped.
|
|
116
|
-
expect(out).toContain('<args>')
|
|
117
|
-
// The unclosed <b> at end has been auto-closed.
|
|
118
|
-
const opens = (out.match(/<b\b[^>]*>/g) ?? []).length
|
|
119
|
-
const closes = (out.match(/<\/b>/g) ?? []).length
|
|
120
|
-
expect(opens).toBe(closes)
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('treats an attribute on a void/unknown tag like any unknown tag', () => {
|
|
124
|
-
expect(sanitizeTelegramHtml('<img src="x">')).toBe('<img src="x">')
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('preserves blockquote expandable bare attribute', () => {
|
|
128
|
-
expect(sanitizeTelegramHtml('<blockquote expandable>x</blockquote>')).toBe(
|
|
129
|
-
'<blockquote expandable>x</blockquote>',
|
|
130
|
-
)
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
describe('htmlToPlainText', () => {
|
|
135
|
-
it('strips tags and unescapes entities', () => {
|
|
136
|
-
expect(htmlToPlainText('<b>hi</b>')).toBe('hi')
|
|
137
|
-
expect(htmlToPlainText('<b>a</b> <b> c')).toBe('a <b> c')
|
|
138
|
-
expect(htmlToPlainText('a & b')).toBe('a & b')
|
|
139
|
-
})
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
describe('escapeAllHtml', () => {
|
|
143
|
-
it('escapes all three HTML metachars', () => {
|
|
144
|
-
expect(escapeAllHtml('<b>&hi</b>')).toBe('<b>&hi</b>')
|
|
145
|
-
})
|
|
146
|
-
})
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for stream-reply parseMode rotation.
|
|
3
|
-
*
|
|
4
|
-
* Covers the specific bug-1 scenario in stream-reply-handler.ts: when
|
|
5
|
-
* two stream_reply calls land on the same chat+thread+lane but with
|
|
6
|
-
* different parseMode (e.g. PTY-tail auto-stream creates a text-mode
|
|
7
|
-
* stream, then an explicit stream_reply with format:'html' reuses it),
|
|
8
|
-
* the stale controller must be finalized + discarded and a fresh one
|
|
9
|
-
* created with the new mode.
|
|
10
|
-
*
|
|
11
|
-
* Without that, the second call inherits the wrong parseMode and sends
|
|
12
|
-
* literal markdown, or edits the wrong baked-in format into Telegram.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
16
|
-
import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
|
|
17
|
-
import type { DraftStreamHandle } from '../draft-stream.js'
|
|
18
|
-
import { markdownToHtml as realMarkdownToHtml } from '../format.js'
|
|
19
|
-
import { createFakeBotApi, type FakeBot } from './fake-bot-api.js'
|
|
20
|
-
|
|
21
|
-
function makeState(): StreamReplyState {
|
|
22
|
-
return {
|
|
23
|
-
activeDraftStreams: new Map<string, DraftStreamHandle>(),
|
|
24
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamReplyDeps {
|
|
29
|
-
return {
|
|
30
|
-
bot: bot as unknown as StreamReplyDeps['bot'],
|
|
31
|
-
markdownToHtml: (t) => realMarkdownToHtml(t),
|
|
32
|
-
escapeMarkdownV2: (t) => `ESC(${t})`,
|
|
33
|
-
repairEscapedWhitespace: (t) => t,
|
|
34
|
-
assertAllowedChat: () => {},
|
|
35
|
-
resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
36
|
-
disableLinkPreview: true,
|
|
37
|
-
defaultFormat: 'html',
|
|
38
|
-
logStreamingEvent: () => {},
|
|
39
|
-
historyEnabled: false,
|
|
40
|
-
recordOutbound: () => {},
|
|
41
|
-
writeError: () => {},
|
|
42
|
-
throttleMs: 0,
|
|
43
|
-
...overrides,
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe('stream-reply parseMode rotation', () => {
|
|
48
|
-
let bot: FakeBot
|
|
49
|
-
|
|
50
|
-
beforeEach(() => {
|
|
51
|
-
bot = createFakeBotApi({ startMessageId: 500 })
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('same format → stream is reused (no rotation)', async () => {
|
|
55
|
-
const state = makeState()
|
|
56
|
-
const deps = makeDeps(bot)
|
|
57
|
-
|
|
58
|
-
await handleStreamReply({ chat_id: 'c', text: 'v1', format: 'html' }, state, deps)
|
|
59
|
-
const streamBefore = state.activeDraftStreams.get('c:_')
|
|
60
|
-
await handleStreamReply({ chat_id: 'c', text: 'v2', format: 'html' }, state, deps)
|
|
61
|
-
const streamAfter = state.activeDraftStreams.get('c:_')
|
|
62
|
-
|
|
63
|
-
expect(streamBefore).toBe(streamAfter) // identity preserved
|
|
64
|
-
expect(bot.state.sent).toHaveLength(1)
|
|
65
|
-
expect(bot.api.editMessageText).toHaveBeenCalled()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('html → markdownv2 rotation: old stream finalized, new one created', async () => {
|
|
69
|
-
const state = makeState()
|
|
70
|
-
const deps = makeDeps(bot)
|
|
71
|
-
|
|
72
|
-
await handleStreamReply({ chat_id: 'c', text: 'hello', format: 'html' }, state, deps)
|
|
73
|
-
expect(state.activeDraftParseModes.get('c:_')).toBe('HTML')
|
|
74
|
-
const firstStreamHandle = state.activeDraftStreams.get('c:_')
|
|
75
|
-
|
|
76
|
-
// Now call with a different parse mode.
|
|
77
|
-
await handleStreamReply({ chat_id: 'c', text: 'hello2', format: 'markdownv2' }, state, deps)
|
|
78
|
-
expect(state.activeDraftParseModes.get('c:_')).toBe('MarkdownV2')
|
|
79
|
-
const secondStreamHandle = state.activeDraftStreams.get('c:_')
|
|
80
|
-
|
|
81
|
-
// Stream instance should have been replaced.
|
|
82
|
-
expect(secondStreamHandle).not.toBe(firstStreamHandle)
|
|
83
|
-
// Two messages sent — one in html, one in markdownv2.
|
|
84
|
-
expect(bot.state.sent).toHaveLength(2)
|
|
85
|
-
expect(bot.state.sent[0].parse_mode).toBe('HTML')
|
|
86
|
-
expect(bot.state.sent[1].parse_mode).toBe('MarkdownV2')
|
|
87
|
-
// The second message's text ran through the MarkdownV2 escaper.
|
|
88
|
-
expect(bot.state.sent[1].text).toBe('ESC(hello2)')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('undefined (text) → html rotation: old stream finalized, new one created', async () => {
|
|
92
|
-
const state = makeState()
|
|
93
|
-
const deps = makeDeps(bot, { defaultFormat: 'text' })
|
|
94
|
-
|
|
95
|
-
// First call in text mode (no parse_mode).
|
|
96
|
-
await handleStreamReply({ chat_id: 'c', text: 'plain', format: 'text' }, state, deps)
|
|
97
|
-
expect(state.activeDraftParseModes.get('c:_')).toBeUndefined()
|
|
98
|
-
expect(bot.state.sent[0].parse_mode).toBeUndefined()
|
|
99
|
-
|
|
100
|
-
// Second call in html.
|
|
101
|
-
await handleStreamReply({ chat_id: 'c', text: 'rich', format: 'html' }, state, deps)
|
|
102
|
-
expect(state.activeDraftParseModes.get('c:_')).toBe('HTML')
|
|
103
|
-
expect(bot.state.sent).toHaveLength(2)
|
|
104
|
-
expect(bot.state.sent[1].parse_mode).toBe('HTML')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('markdownv2 → undefined rotation', async () => {
|
|
108
|
-
const state = makeState()
|
|
109
|
-
const deps = makeDeps(bot)
|
|
110
|
-
|
|
111
|
-
await handleStreamReply({ chat_id: 'c', text: 'a', format: 'markdownv2' }, state, deps)
|
|
112
|
-
expect(state.activeDraftParseModes.get('c:_')).toBe('MarkdownV2')
|
|
113
|
-
|
|
114
|
-
await handleStreamReply({ chat_id: 'c', text: 'b', format: 'text' }, state, deps)
|
|
115
|
-
expect(state.activeDraftParseModes.get('c:_')).toBeUndefined()
|
|
116
|
-
expect(bot.state.sent).toHaveLength(2)
|
|
117
|
-
expect(bot.state.sent[0].parse_mode).toBe('MarkdownV2')
|
|
118
|
-
expect(bot.state.sent[1].parse_mode).toBeUndefined()
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('rotation across two distinct lanes is independent', async () => {
|
|
122
|
-
const state = makeState()
|
|
123
|
-
const deps = makeDeps(bot)
|
|
124
|
-
|
|
125
|
-
await handleStreamReply(
|
|
126
|
-
{ chat_id: 'c', text: 'main', format: 'html', lane: 'main' },
|
|
127
|
-
state,
|
|
128
|
-
deps,
|
|
129
|
-
)
|
|
130
|
-
await handleStreamReply(
|
|
131
|
-
{ chat_id: 'c', text: 'activity', format: 'text', lane: 'activity' },
|
|
132
|
-
state,
|
|
133
|
-
deps,
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
expect(state.activeDraftParseModes.get('c:_:main')).toBe('HTML')
|
|
137
|
-
expect(state.activeDraftParseModes.get('c:_:activity')).toBeUndefined()
|
|
138
|
-
// Two messages: different lanes, different modes, different streams.
|
|
139
|
-
expect(bot.state.sent).toHaveLength(2)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('rotation across threads is independent', async () => {
|
|
143
|
-
const state = makeState()
|
|
144
|
-
const deps = makeDeps(bot)
|
|
145
|
-
|
|
146
|
-
await handleStreamReply(
|
|
147
|
-
{ chat_id: 'c', text: 'thread-a', format: 'html', message_thread_id: '1' },
|
|
148
|
-
state,
|
|
149
|
-
deps,
|
|
150
|
-
)
|
|
151
|
-
await handleStreamReply(
|
|
152
|
-
{ chat_id: 'c', text: 'thread-b', format: 'markdownv2', message_thread_id: '2' },
|
|
153
|
-
state,
|
|
154
|
-
deps,
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
expect(state.activeDraftStreams.size).toBe(2)
|
|
158
|
-
expect(state.activeDraftParseModes.get('c:1')).toBe('HTML')
|
|
159
|
-
expect(state.activeDraftParseModes.get('c:2')).toBe('MarkdownV2')
|
|
160
|
-
expect(bot.state.sent).toHaveLength(2)
|
|
161
|
-
})
|
|
162
|
-
})
|