switchroom 0.21.8 → 0.21.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +100 -39
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +919 -348
- package/telegram-plugin/format.ts +30 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +7 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/html-fold.ts +354 -0
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +642 -42
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +253 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +39 -10
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/send-reply-golden.test.ts +99 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
- package/telegram-plugin/voice-normalize-text.ts +48 -9
|
@@ -99,9 +99,26 @@ export function codeSpanSafe(s: string): string {
|
|
|
99
99
|
* `\` first (so we never double-escape a following escape), then BOTH `(` and
|
|
100
100
|
* `)` — the whole URL is preserved balanced and micromark decodes it back to
|
|
101
101
|
* the original href on round-trip. Bot API 10.1 lists `(`/`)` as escapable.
|
|
102
|
+
*
|
|
103
|
+
* WHITESPACE and control characters are PERCENT-ENCODED rather than
|
|
104
|
+
* backslash-escaped. A bare link destination ends at the first ASCII
|
|
105
|
+
* whitespace character, so a space or a newline inside the href does not just
|
|
106
|
+
* truncate the URL — the remainder is re-read as a link title, or (for a
|
|
107
|
+
* newline) the inline link is terminated outright, leaving a structurally
|
|
108
|
+
* broken construct with URL fragments visible as prose. Backslash cannot
|
|
109
|
+
* rescue that: whitespace is not escapable in a bare destination. `%20` /
|
|
110
|
+
* `%0A` are the canonical URL encodings, so the href a client resolves is
|
|
111
|
+
* equivalent to the one the author wrote.
|
|
102
112
|
*/
|
|
103
113
|
export function escapeLinkHref(href: string): string {
|
|
104
|
-
return href
|
|
114
|
+
return href
|
|
115
|
+
.replace(/\\/g, '\\\\')
|
|
116
|
+
.replace(/\(/g, '\\(')
|
|
117
|
+
.replace(/\)/g, '\\)')
|
|
118
|
+
.replace(
|
|
119
|
+
/[\x00-\x20\x7f]/g,
|
|
120
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`,
|
|
121
|
+
)
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
/**
|
|
@@ -735,9 +752,12 @@ export function normalizePunctuation(text: string): string {
|
|
|
735
752
|
// The dash rewrite runs per line so blockquote lines can be exempted:
|
|
736
753
|
// `>` blockquotes are reserved for VERBATIM quoted text, and rewriting an
|
|
737
754
|
// author's ` — ` to `, ` inside a quotation would corrupt what they quoted.
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
755
|
+
// A line opening with the LEGACY `**>` expandable-quote marker is a
|
|
756
|
+
// blockquote line too (the render path no longer emits `**>` — it is
|
|
757
|
+
// MarkdownV2-only syntax, see render.ts renderBlockquote — but legacy agent
|
|
758
|
+
// output still contains it and parse.ts repairs it into a real quote), so
|
|
759
|
+
// exempt it explicitly even though isBlockquoteLine (which keys on a leading
|
|
760
|
+
// `>`) doesn't catch the `**` prefix. Code spans and link hrefs stay masked
|
|
741
761
|
// throughout, so their dashes are already protected on every line.
|
|
742
762
|
const rewriteDashes = (line: string): string =>
|
|
743
763
|
line
|
|
@@ -1414,7 +1434,12 @@ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
|
|
|
1414
1434
|
/\*\*[^*\n]+\*\*/g, // bold
|
|
1415
1435
|
/__[^_\n]+__/g, // underline
|
|
1416
1436
|
/(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
|
|
1417
|
-
|
|
1437
|
+
// link `[label](href)` — and, via the optional leading `!`, Telegram's
|
|
1438
|
+
// inline-entity form `` /
|
|
1439
|
+
// ``. Without the `!?` the protected span would start at
|
|
1440
|
+
// the `[`, so a cut could land in the one-character gap and strand the `!`
|
|
1441
|
+
// on the previous chunk — silently demoting a date_time entity to a link.
|
|
1442
|
+
/!?\[[^\]\n]*\]\([^)\n]*\)/g,
|
|
1418
1443
|
/~~[^~\n]+~~/g, // strikethrough
|
|
1419
1444
|
/\|\|[^|\n]+\|\|/g, // spoiler
|
|
1420
1445
|
]
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
isQuoteRejectionError,
|
|
44
44
|
sendOptsHaveQuote,
|
|
45
45
|
} from '../reply-quote.js'
|
|
46
|
+
import { captureSpeechText } from './speech-capture.js'
|
|
46
47
|
|
|
47
48
|
// ── send-orchestration façade imports (#2996 P2) ──
|
|
48
49
|
// Pure/deterministic helpers are imported; stateful or side-effecting gateway
|
|
@@ -1376,6 +1377,12 @@ export async function sendReply(
|
|
|
1376
1377
|
// plain-text TTS input); synthesis happens just before the send so a
|
|
1377
1378
|
// voice-only reply can suppress the text chunk loop on success. Voice is
|
|
1378
1379
|
// fully best-effort — every failure below falls back to the text reply.
|
|
1380
|
+
// Raw-corpus capture (TTS redesign PR-0, flag-gated, off by default): `text`
|
|
1381
|
+
// here IS Stage A's future input — capture it byte-for-byte BEFORE the
|
|
1382
|
+
// resolve call, ahead of any TTS normalisation, so the redesign's property
|
|
1383
|
+
// tests can validate against real markdown instead of synthetic fixtures
|
|
1384
|
+
// only. See telegram-plugin/gateway/speech-capture.ts.
|
|
1385
|
+
captureSpeechText(text)
|
|
1379
1386
|
const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
|
|
1380
1387
|
const configParseMode = access.parseMode ?? 'html'
|
|
1381
1388
|
const format = (args.format as string | undefined) ?? configParseMode
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw pre-normalisation speech-text capture (TTS normalisation redesign,
|
|
3
|
+
* PR-0 — `/tmp/claude-0/tts/out/fable-plan-v2.md` §9).
|
|
4
|
+
*
|
|
5
|
+
* Flag-gated on `SWITCHROOM_SPEECH_CAPTURE=1` (or `=true`), OFF by default:
|
|
6
|
+
* unset (or any other value) is a true no-op — `captureSpeechText` returns
|
|
7
|
+
* immediately without touching the filesystem, so the send path pays zero
|
|
8
|
+
* cost. When enabled, appends exactly one JSON line `{ts, text}` — where
|
|
9
|
+
* `text` is the EXACT string about to be passed to `resolveVoiceOutPlan`,
|
|
10
|
+
* byte-for-byte, no re-encoding — to `$TELEGRAM_STATE_DIR/speech-capture.jsonl`
|
|
11
|
+
* before that call (fable-plan-v2.md §0 C-4: that string IS Stage A's future
|
|
12
|
+
* input). NOT the same string the visible rich render displays — the render
|
|
13
|
+
* path additionally runs `computeEffectiveText`/`addParagraphSpacers` (U+00A0
|
|
14
|
+
* paragraph spacers) downstream of this capture point; this captures the
|
|
15
|
+
* plain pre-spacer prose Stage A will actually consume.
|
|
16
|
+
*
|
|
17
|
+
* Why this exists: `corpus.json` (the existing replay corpus) was captured
|
|
18
|
+
* downstream of the legacy pass-1 normaliser, so it carries zero tables,
|
|
19
|
+
* fences, pipes, or emoji (fable-plan-v2.md §0 C-3) — useless for validating
|
|
20
|
+
* the new Stage-A renderer beyond synthetic fixtures. `telegram/history.db`
|
|
21
|
+
* is also unusable: it stores text as Telegram echoed it back (rendered),
|
|
22
|
+
* not the pre-render markdown. Capture must happen in-process, here.
|
|
23
|
+
*
|
|
24
|
+
* Privacy: the capture file holds the full plaintext body of every outbound
|
|
25
|
+
* reply while the flag is on, so it is created `0o600` (owner-only) — NOT
|
|
26
|
+
* the default-umask `0o644` `appendFileSync` would otherwise produce, which
|
|
27
|
+
* would leave it world-readable in a directory where `access.json` (the chat
|
|
28
|
+
* allowlist) is deliberately `0o600`. Mode only applies at file CREATION
|
|
29
|
+
* (Node ignores `mode` on an append to an existing file), matching the
|
|
30
|
+
* established pattern in this codebase (`shown-ledger.ts`, `outbox.ts`).
|
|
31
|
+
*
|
|
32
|
+
* Write failures are swallowed — capture must never break a send — but never
|
|
33
|
+
* silently: the first write failure (and, rate-limited, subsequent ones)
|
|
34
|
+
* logs one stderr line, because a foreign-uid EACCES on this file (the #4371
|
|
35
|
+
* failure class: some other process touches it, it becomes root-owned, the
|
|
36
|
+
* agent uid EACCESes on every append thereafter) must be observable across a
|
|
37
|
+
* 7-day unattended capture window, not discovered after the fact from an
|
|
38
|
+
* empty corpus.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately does NOT bound or rotate the capture file: the spec (§9 PR-0)
|
|
41
|
+
* describes a plain unbounded append for a time-boxed 7-day fleet-wide
|
|
42
|
+
* capture window (measured fleet-wide: ~2.9 MB over 7 days), after which the
|
|
43
|
+
* file is reviewed, secrets-scrubbed, and checked into the repo as a static
|
|
44
|
+
* fixture — it is not a long-lived production log.
|
|
45
|
+
*
|
|
46
|
+
* The enabled check reads `process.env` on every call (deliberately NOT
|
|
47
|
+
* cached at module scope, unlike `current-turn-map.ts`'s
|
|
48
|
+
* `EMISSION_AUTHORITY_ENABLED` kill-switch convention): the read is a single
|
|
49
|
+
* property lookup, not the per-turn state-store cost that convention exists
|
|
50
|
+
* to avoid, and caching it would make the flag un-togglable within a single
|
|
51
|
+
* test process — which the load-bearing "a capture write failure never
|
|
52
|
+
* breaks the reply" guarantee needs to exercise against the REAL `sendReply`
|
|
53
|
+
* wiring (see `send-reply-golden.test.ts`), not a synthetic call.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { appendFileSync } from 'node:fs'
|
|
57
|
+
import { join } from 'node:path'
|
|
58
|
+
|
|
59
|
+
export const SPEECH_CAPTURE_FILE_NAME = 'speech-capture.jsonl'
|
|
60
|
+
|
|
61
|
+
/** File created owner-read-write only (see the Privacy note above). */
|
|
62
|
+
const SPEECH_CAPTURE_FILE_MODE = 0o600
|
|
63
|
+
|
|
64
|
+
export interface CaptureSpeechTextOptions {
|
|
65
|
+
/** Override the enabled flag (tests only); default reads
|
|
66
|
+
* `SWITCHROOM_SPEECH_CAPTURE` from the environment. */
|
|
67
|
+
enabled?: boolean
|
|
68
|
+
/** Override the destination directory (tests only); default
|
|
69
|
+
* `process.env.TELEGRAM_STATE_DIR`. */
|
|
70
|
+
stateDir?: string
|
|
71
|
+
/** Override the clock (tests only). */
|
|
72
|
+
now?: () => number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function envFlagEnabled(): boolean {
|
|
76
|
+
const v = process.env.SWITCHROOM_SPEECH_CAPTURE
|
|
77
|
+
return v === '1' || v === 'true'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isCaptureEnabled(override: boolean | undefined): boolean {
|
|
81
|
+
if (override !== undefined) return override
|
|
82
|
+
return envFlagEnabled()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One-shot "capture is live" log line, latched the first time capture
|
|
86
|
+
// actually fires (not at module import — importing this module must stay a
|
|
87
|
+
// true no-op regardless of whether the flag ends up used) — and a
|
|
88
|
+
// rate-limited write-error log so a foreign-uid EACCES (#4371 class) is
|
|
89
|
+
// observable within the window, not just discoverable from an empty corpus
|
|
90
|
+
// after the fact.
|
|
91
|
+
let loggedEnabledOnce = false
|
|
92
|
+
// `null`, not `0`: a real failure at `Date.now() === 0` (or in a test driven
|
|
93
|
+
// by `vi.setSystemTime(0)`) must still log — `0` is a valid past timestamp,
|
|
94
|
+
// not "never logged", so it cannot double as the sentinel.
|
|
95
|
+
let lastWriteErrorLoggedAt: number | null = null
|
|
96
|
+
const WRITE_ERROR_LOG_INTERVAL_MS = 5 * 60_000
|
|
97
|
+
|
|
98
|
+
function logEnabledOnce(): void {
|
|
99
|
+
if (loggedEnabledOnce) return
|
|
100
|
+
loggedEnabledOnce = true
|
|
101
|
+
try {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`telegram gateway: speech-capture: enabled — writing $TELEGRAM_STATE_DIR/${SPEECH_CAPTURE_FILE_NAME}\n`,
|
|
104
|
+
)
|
|
105
|
+
} catch {
|
|
106
|
+
// Logging must never break the send path.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function logWriteErrorRateLimited(err: unknown, nowMs: number): void {
|
|
111
|
+
if (lastWriteErrorLoggedAt !== null && nowMs - lastWriteErrorLoggedAt < WRITE_ERROR_LOG_INTERVAL_MS) return
|
|
112
|
+
lastWriteErrorLoggedAt = nowMs
|
|
113
|
+
try {
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
`telegram gateway: speech-capture: write failed (further failures rate-limited ` +
|
|
117
|
+
`${Math.round(WRITE_ERROR_LOG_INTERVAL_MS / 60_000)}m) err=${msg}\n`,
|
|
118
|
+
)
|
|
119
|
+
} catch {
|
|
120
|
+
// Logging must never break the send path.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Append one `{ts, text}` JSON line to the raw-corpus capture file when
|
|
126
|
+
* capture is enabled. `text` must be the EXACT string about to be passed to
|
|
127
|
+
* `resolveVoiceOutPlan` — no trimming, no re-encoding, byte-for-byte
|
|
128
|
+
* preservation of markdown (tables, fences, pipes, emoji, backticks, ...).
|
|
129
|
+
* Off by default; a true no-op when the flag is unset. Never throws.
|
|
130
|
+
*/
|
|
131
|
+
export function captureSpeechText(text: string, opts: CaptureSpeechTextOptions = {}): void {
|
|
132
|
+
if (!isCaptureEnabled(opts.enabled)) return
|
|
133
|
+
logEnabledOnce()
|
|
134
|
+
try {
|
|
135
|
+
const stateDir = opts.stateDir ?? process.env.TELEGRAM_STATE_DIR
|
|
136
|
+
if (stateDir == null || stateDir.length === 0) return
|
|
137
|
+
const now = opts.now ?? Date.now
|
|
138
|
+
const line = `${JSON.stringify({ ts: now(), text })}\n`
|
|
139
|
+
appendFileSync(join(stateDir, SPEECH_CAPTURE_FILE_NAME), line, {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
mode: SPEECH_CAPTURE_FILE_MODE,
|
|
142
|
+
})
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// Capture must never break the send path — but never silently either
|
|
145
|
+
// (M2/#4371 class).
|
|
146
|
+
logWriteErrorRateLimited(err, Date.now())
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Test-only: reset the one-shot enable log and the write-error rate limiter,
|
|
152
|
+
* so a test asserting stderr output is independent of import/call order
|
|
153
|
+
* versus every other test sharing this module instance.
|
|
154
|
+
*/
|
|
155
|
+
export function __resetSpeechCaptureLogStateForTests(): void {
|
|
156
|
+
loggedEnabledOnce = false
|
|
157
|
+
lastWriteErrorLoggedAt = null
|
|
158
|
+
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@secretlint/core": "^12.2.0",
|
|
32
32
|
"@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
|
|
33
33
|
"@xterm/headless": "^6.0.0",
|
|
34
|
-
"grammy": "^1.
|
|
34
|
+
"grammy": "^1.45",
|
|
35
35
|
"mdast-util-from-markdown": "^2.0.2",
|
|
36
36
|
"mdast-util-gfm": "^3.0.0",
|
|
37
37
|
"micromark-extension-gfm": "^3.0.0",
|
|
@@ -73,6 +73,13 @@ export function splitCodeSegments(text: string): Segment[] {
|
|
|
73
73
|
// • bare autolinked URLs — `http(s)://…` and `www.…` runs Telegram auto-links.
|
|
74
74
|
// • GFM table rows — a table's structural pipes / empty cells (`|a||b|`) must
|
|
75
75
|
// survive; escaping inside a real table row corrupts the table.
|
|
76
|
+
// • inline-math `$…$` spans — Telegram's rich GFM parser typesets a `$…$`
|
|
77
|
+
// pair as a real mathematical_expression node (wire-verified 2026-08-13).
|
|
78
|
+
// A COMPACT span (whitespace-free inner run that is not a bare currency
|
|
79
|
+
// amount, e.g. `$x^2+y^2$`) is intentional math and must reach the wire
|
|
80
|
+
// byte-identical: no guard may escape its `$`, `^`, `_`, `*`, or `+`.
|
|
81
|
+
// Currency-shaped inners (`$5M-$`) are NOT protected, so the dollar-math
|
|
82
|
+
// guard still breaks accidental `$5M … $10M` currency pairs (#3252).
|
|
76
83
|
// Everything else is prose and stays fully guarded. This is the sibling of the
|
|
77
84
|
// code-span skip: a PROTECTED segment (`code: true`) is emitted verbatim.
|
|
78
85
|
//
|
|
@@ -93,6 +100,19 @@ function isTableCandidateLine(line: string): boolean {
|
|
|
93
100
|
return /^\s*\|/.test(line);
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
/** A compact `$…$` inline-math pair: opening `$`, a whitespace-free inner run
|
|
104
|
+
* with no nested `$`, closing `$`. Anchored — tested at the scanner's current
|
|
105
|
+
* position only. */
|
|
106
|
+
const COMPACT_MATH_PAIR = /^\$([^\s$]+)\$/;
|
|
107
|
+
|
|
108
|
+
/** A currency-shaped inner run: digits plus amount punctuation and magnitude
|
|
109
|
+
* suffix letters only (`5M-`, `0.5`, `10,000`, `5+`). Such a run between two
|
|
110
|
+
* `$` is a pair of ADJACENT currency amounts (`$5M-$10M`), not math — it must
|
|
111
|
+
* stay guardable so the dollar-math guard can break the accidental pair. A
|
|
112
|
+
* run containing any other character (`x^2+y^2`, `\alpha`, `a_b`) is treated
|
|
113
|
+
* as intentional math and protected. */
|
|
114
|
+
const CURRENCY_SHAPED_INNER = /^[0-9.,+\-kKmMbB]+$/;
|
|
115
|
+
|
|
96
116
|
/** Find the [start, end) char ranges (relative to `text`) of GFM table blocks —
|
|
97
117
|
* maximal runs of 2+ consecutive `|`-leading lines that contain a delimiter
|
|
98
118
|
* row. Each returned range spans whole lines INCLUDING their trailing newline,
|
|
@@ -185,6 +205,19 @@ function splitProseProtected(text: string): Segment[] {
|
|
|
185
205
|
continue;
|
|
186
206
|
}
|
|
187
207
|
}
|
|
208
|
+
// 4. Compact inline-math pair `$…$` — a supported Telegram construct
|
|
209
|
+
// (mathematical_expression, wire-verified 2026-08-13) that must reach
|
|
210
|
+
// the wire verbatim. Currency-shaped inners are NOT math (they are two
|
|
211
|
+
// adjacent amounts like `$5M-$10M`) and stay guardable.
|
|
212
|
+
if (ch === "$") {
|
|
213
|
+
const m = COMPACT_MATH_PAIR.exec(text.slice(i));
|
|
214
|
+
if (m && !CURRENCY_SHAPED_INNER.test(m[1])) {
|
|
215
|
+
const end = i + m[0].length;
|
|
216
|
+
pushProtected(i, end);
|
|
217
|
+
i = end;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
188
221
|
i++;
|
|
189
222
|
}
|
|
190
223
|
if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
|
|
@@ -193,10 +226,11 @@ function splitProseProtected(text: string): Segment[] {
|
|
|
193
226
|
|
|
194
227
|
/** Split rendered markdown into prose / protected segments where a PROTECTED
|
|
195
228
|
* (`code: true`) segment is any content a guard must emit verbatim: code spans,
|
|
196
|
-
* fenced code blocks, markdown link destinations, bare autolinks,
|
|
197
|
-
*
|
|
198
|
-
* all
|
|
199
|
-
* guardable (including a link's
|
|
229
|
+
* fenced code blocks, markdown link destinations, bare autolinks, GFM table
|
|
230
|
+
* rows, and compact `$…$` inline-math spans. This is the link/table/math-aware
|
|
231
|
+
* superset of `splitCodeSegments` that all the #3252 guards route through.
|
|
232
|
+
* Prose segments (`code: false`) remain guardable (including a link's
|
|
233
|
+
* `[label]` text). Deterministic, linear-time. */
|
|
200
234
|
export function splitProtectedSegments(text: string): Segment[] {
|
|
201
235
|
const out: Segment[] = [];
|
|
202
236
|
for (const seg of splitCodeSegments(text)) {
|
|
@@ -29,6 +29,20 @@
|
|
|
29
29
|
// a `$…$` pair if only the leading-`$digit` token is escaped — so we escape the
|
|
30
30
|
// lot once the currency signal + 2-dollar threshold are met.
|
|
31
31
|
//
|
|
32
|
+
// ── INTENTIONAL math is exempt (wire-verified 2026-08-13) ─────────────────
|
|
33
|
+
// Telegram's rich path renders a `$…$` pair as a native mathematical_expression
|
|
34
|
+
// node, and intentional math (`$x^2+y^2$`) must reach the wire byte-identical —
|
|
35
|
+
// an escaped `\$x^2+y^2\$` destroys a SUPPORTED construct. The discrimination
|
|
36
|
+
// lives in `splitProtectedSegments` (code-segments.ts): a COMPACT math span
|
|
37
|
+
// (whitespace-free inner, not currency-shaped) is a protected segment, so this
|
|
38
|
+
// guard neither counts its `$`s toward the 2+ threshold nor escapes them.
|
|
39
|
+
// Currency amounts always sit next to whitespace/prose (`$5M and $10M`) or have
|
|
40
|
+
// a digits-and-punctuation-only inner (`$5M-$10M`), so #3252-class accidental
|
|
41
|
+
// pairs remain fully guarded. Known residual: a SPACED math span (`$a + b$`)
|
|
42
|
+
// is indistinguishable from currency prose and is not exempted — it is escaped
|
|
43
|
+
// when the message also carries a currency signal, rendering as literal text
|
|
44
|
+
// (legible, not broken).
|
|
45
|
+
//
|
|
32
46
|
// Idempotent (F5): the escape uses a negative-lookbehind (`(?<!\\)\$`) so an
|
|
33
47
|
// already-escaped `\$` is never doubled to `\\$`. Running the guard twice (e.g.
|
|
34
48
|
// the streaming path renders then this wrapper re-wraps) is a strict no-op the
|
|
@@ -100,7 +114,8 @@ const UNESCAPED_DOLLAR = /(?<!\\)\$/g;
|
|
|
100
114
|
* them is digit-adjacent (a currency signal). When armed, EVERY unescaped prose
|
|
101
115
|
* `$` is backslash-escaped so no two `$` can pair into a math span — this is
|
|
102
116
|
* what closes the F3 trailing-`$` / `$.50` false-negatives. Code spans / fenced
|
|
103
|
-
* blocks
|
|
117
|
+
* blocks AND compact intentional-math `$…$` spans (protected segments, see
|
|
118
|
+
* code-segments.ts) are never touched. Idempotent (F5) and deterministic.
|
|
104
119
|
*/
|
|
105
120
|
export function guardDollarMath(text: string): string {
|
|
106
121
|
if (!text.includes("$")) return text;
|