switchroom 0.18.25 → 0.18.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/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +832 -38
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +305 -41
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- package/telegram-plugin/worker-activity-feed.ts +91 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Outbound guard against accidental inline-emphasis typesetting from `_`/`*`
|
|
2
|
+
// that the model never meant as formatting (issue #3252, sibling of the
|
|
3
|
+
// `$…$` currency-math guard in dollar-math-guard.ts).
|
|
4
|
+
//
|
|
5
|
+
// ── Root cause ───────────────────────────────────────────────────────────
|
|
6
|
+
// Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
|
|
7
|
+
// Telegram as RAW GFM markdown via `sendRichMessage({ markdown })` — Telegram
|
|
8
|
+
// parses the markdown server-side. Telegram's rich GFM parser pairs `_`/`*`
|
|
9
|
+
// delimiters into emphasis spans. The agent DELIBERATELY uses `**bold**`,
|
|
10
|
+
// `*italic*` and `_italic_` (Ken's rich-formatting directive) — that is a
|
|
11
|
+
// wanted feature and MUST survive untouched. But the same characters also
|
|
12
|
+
// occur in prose the model never meant to format:
|
|
13
|
+
// - snake_case identifiers — `file_name_here` italicises "name" once two
|
|
14
|
+
// intra-word `_` pair up (and Telegram's `_` pairing is more liberal than
|
|
15
|
+
// strict CommonMark, which does not emphasise intra-word underscores);
|
|
16
|
+
// - inline multiplication — `a*b*c`, `2*3` — where two intra-word `*` pair
|
|
17
|
+
// and italicise the middle factor;
|
|
18
|
+
// - mid-word `_`/`*` in paths / variable names.
|
|
19
|
+
// These are the direct sibling of the dollar bug: a delimiter that pairs up
|
|
20
|
+
// across prose and typesets a span nobody meant.
|
|
21
|
+
//
|
|
22
|
+
// ── Fix ──────────────────────────────────────────────────────────────────
|
|
23
|
+
// Deterministically break the pairing by backslash-escaping ONLY the clearly-
|
|
24
|
+
// accidental delimiters, identified by a single unambiguous signal:
|
|
25
|
+
//
|
|
26
|
+
// an INTRA-WORD delimiter — a single `_` or `*` with an ASCII alphanumeric
|
|
27
|
+
// character IMMEDIATELY on BOTH sides (`e_n`, `a*b`, `2_f`, `2*3`).
|
|
28
|
+
//
|
|
29
|
+
// This signal is chosen because intended emphasis can NEVER match it: an
|
|
30
|
+
// intended opener (`*italic*`, `_italic_`) is flanked on its OUTER side by a
|
|
31
|
+
// word boundary — whitespace, start-of-string, or punctuation — never by an
|
|
32
|
+
// alphanumeric. So an alphanumeric-on-both-sides delimiter is, by
|
|
33
|
+
// construction, not the opener or closer of an intended span. `**bold**` /
|
|
34
|
+
// `__x__` double runs are excluded for free: the inner neighbour of each `*`
|
|
35
|
+
// in `a**b` is another `*` (not alphanumeric), so neither half matches.
|
|
36
|
+
//
|
|
37
|
+
// What we DELIBERATELY LEAVE ALONE (conservative false-negatives, per the
|
|
38
|
+
// "when in doubt, leave it" doctrine inherited from the dollar guard):
|
|
39
|
+
// - Space-flanked operators (`3 * 4`): a whitespace-flanked `*`/`_` is
|
|
40
|
+
// neither left- nor right-flanking under GFM, so it can never open or
|
|
41
|
+
// close emphasis — there is no bug to fix, and escaping it would be pure
|
|
42
|
+
// churn.
|
|
43
|
+
// - Boundary-flanked delimiters (`rm *`, `*.ts`, leading-`_` `_private`):
|
|
44
|
+
// these are INDISTINGUISHABLE from an intended `*glob*` / `_italic_`
|
|
45
|
+
// opener (`*.ts is a glob*` is a legitimate italic). Escaping them would
|
|
46
|
+
// risk breaking intended emphasis — the one thing this guard must never
|
|
47
|
+
// do — so a glob/leading-underscore that pairs into an accidental span is
|
|
48
|
+
// accepted as a rare false-negative rather than risked as a false-positive.
|
|
49
|
+
// (A future arm could target these behind live Telegram UAT; see below.)
|
|
50
|
+
//
|
|
51
|
+
// Idempotent: the escape is expressed as an intra-word match, so an
|
|
52
|
+
// already-escaped `\_`/`\*` has a backslash (not an alphanumeric) immediately
|
|
53
|
+
// before the delimiter and therefore never re-matches — running the guard
|
|
54
|
+
// twice is a strict no-op. Code spans / fenced code blocks are NEVER touched
|
|
55
|
+
// (verbatim), via the same splitCodeSegments logic the dollar guard uses.
|
|
56
|
+
//
|
|
57
|
+
// ── Where this runs ────────────────────────────────────────────────────────
|
|
58
|
+
// Like the dollar guard, this is intended to be invoked from `richMessage()`
|
|
59
|
+
// (rich-send.ts) — the single seam every `{ markdown }` wire send funnels
|
|
60
|
+
// through. THIS commit only adds the pure function + its test; the seam
|
|
61
|
+
// wiring is integrated separately (all guards in one pass) to avoid conflicts.
|
|
62
|
+
//
|
|
63
|
+
// ── UNVERIFIED (needs live Telegram UAT) ───────────────────────────────────
|
|
64
|
+
// Same residual risk as the dollar guard: this relies on Telegram's server-
|
|
65
|
+
// side rich GFM parser (a) recognising `\_` / `\*` as escapable ASCII
|
|
66
|
+
// punctuation and (b) CONSUMING the backslash so the reader sees a literal
|
|
67
|
+
// `_` / `*` and copy-paste yields `file_name` / `a*b` (no stray `\`). This is
|
|
68
|
+
// asserted by analogy to the `` ~ = | * _ `` chars `escapeMarkdown`
|
|
69
|
+
// (format.ts) already backslash-escapes and that Telegram demonstrably strips.
|
|
70
|
+
// Vitest CANNOT cover server-side rendering; a real intra-word `_`/`*` message
|
|
71
|
+
// through a live agent must confirm it renders literal (not `\_`, not italic).
|
|
72
|
+
|
|
73
|
+
// Segment splitting (code spans / fences AND markdown link destinations /
|
|
74
|
+
// autolinks / table rows) is shared across all #3252 guards — one source of
|
|
75
|
+
// truth in render/code-segments.ts. Routing through `splitProtectedSegments`
|
|
76
|
+
// (not the code-only `splitCodeSegments`) is what makes this guard link-aware:
|
|
77
|
+
// an intra-word `_`/`*` inside a URL path (`.../a_b_c`) is STRUCTURAL, not
|
|
78
|
+
// prose, and must never be escaped (the link would break). See code-segments.ts.
|
|
79
|
+
import { splitProtectedSegments } from "./code-segments.js";
|
|
80
|
+
|
|
81
|
+
/** An intra-word underscore: a single `_` with an ASCII alphanumeric on BOTH
|
|
82
|
+
* immediate sides (`file_name`, `v2_final`). Intended `_italic_` never
|
|
83
|
+
* matches — its opener is boundary-flanked on the outer side. The
|
|
84
|
+
* alphanumeric lookbehind also makes the escape idempotent: an already-
|
|
85
|
+
* escaped `\_` has `\` (not alnum) before the `_`, so it never re-matches. */
|
|
86
|
+
const INTRA_WORD_UNDERSCORE = /(?<=[A-Za-z0-9])_(?=[A-Za-z0-9])/g;
|
|
87
|
+
|
|
88
|
+
/** An intra-word asterisk: a single `*` with an ASCII alphanumeric on BOTH
|
|
89
|
+
* immediate sides (`a*b`, `2*3`). Intended `*italic*` / `**bold**` never
|
|
90
|
+
* match — italic openers are boundary-flanked, and in a `**` run the inner
|
|
91
|
+
* neighbour is `*` (not alnum). Idempotent for the same reason as above. */
|
|
92
|
+
const INTRA_WORD_ASTERISK = /(?<=[A-Za-z0-9])\*(?=[A-Za-z0-9])/g;
|
|
93
|
+
|
|
94
|
+
/** Every unescaped `_` / `*` in prose (the pair-threshold input). An emphasis
|
|
95
|
+
* span needs a MATCHING pair of the same delimiter, so a delimiter can only
|
|
96
|
+
* mis-render when 2+ of it exist. The `(?<!\\)` keeps the count idempotent. */
|
|
97
|
+
const ANY_UNDERSCORE = /(?<!\\)_/g;
|
|
98
|
+
const ANY_ASTERISK = /(?<!\\)\*/g;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Neutralise accidental inline emphasis produced by intra-word `_` / `*`.
|
|
102
|
+
*
|
|
103
|
+
* Operates on the FINAL rendered rich-markdown string (post-`render`).
|
|
104
|
+
*
|
|
105
|
+
* ── Threshold: 2+ of the delimiter (finding 4) ────────────────────────────
|
|
106
|
+
* A `_` (or `*`) escape only fires when the prose holds an intra-word
|
|
107
|
+
* occurrence of that delimiter AND 2+ TOTAL of it. Emphasis is a PAIRED
|
|
108
|
+
* construct — a single `_` (or `*`) in the whole message can never form a span,
|
|
109
|
+
* so escaping a lone intra-word delimiter is pure churn that only widens the
|
|
110
|
+
* false-positive surface (a stray `\` if Telegram ever failed to consume it),
|
|
111
|
+
* with zero correctness gain. Requiring 2+ matches the dollar / inline-pairs
|
|
112
|
+
* "a lone delimiter can't pair" doctrine. This is strictly SAFE: any real
|
|
113
|
+
* mis-render needs 2+ delimiters, and when 2+ exist (e.g. `file_name_here`, or
|
|
114
|
+
* an intra-word `file_name` sitting alongside an intended `_italic_` elsewhere —
|
|
115
|
+
* three underscores that Telegram could mis-pair) the intra-word delimiter IS
|
|
116
|
+
* escaped, so no accidental span survives. Only the provably-inert lone-`_`
|
|
117
|
+
* (`file_name` as the ONLY underscore in the message) is now left byte-identical.
|
|
118
|
+
*
|
|
119
|
+
* Intended `**bold**`, `*italic*` and `_italic_` are LEFT UNTOUCHED by
|
|
120
|
+
* construction (their delimiters are boundary-flanked, never intra-word).
|
|
121
|
+
* Code spans / fenced blocks, link destinations and autolinks are never touched
|
|
122
|
+
* (via `splitProtectedSegments`). Idempotent and deterministic.
|
|
123
|
+
*/
|
|
124
|
+
export function guardAccidentalEmphasis(text: string): string {
|
|
125
|
+
if (!text.includes("_") && !text.includes("*")) return text;
|
|
126
|
+
const segments = splitProtectedSegments(text);
|
|
127
|
+
|
|
128
|
+
// NO-OP guard: only rewrite when a real intra-word signal exists in prose AND
|
|
129
|
+
// 2+ of that delimiter exist (so a pair — hence a mis-render — is possible).
|
|
130
|
+
let hasIntraUnderscore = false;
|
|
131
|
+
let hasIntraAsterisk = false;
|
|
132
|
+
let underscoreCount = 0;
|
|
133
|
+
let asteriskCount = 0;
|
|
134
|
+
for (const seg of segments) {
|
|
135
|
+
if (seg.code) continue;
|
|
136
|
+
if (INTRA_WORD_UNDERSCORE.test(seg.text)) hasIntraUnderscore = true;
|
|
137
|
+
if (INTRA_WORD_ASTERISK.test(seg.text)) hasIntraAsterisk = true;
|
|
138
|
+
underscoreCount += seg.text.match(ANY_UNDERSCORE)?.length ?? 0;
|
|
139
|
+
asteriskCount += seg.text.match(ANY_ASTERISK)?.length ?? 0;
|
|
140
|
+
}
|
|
141
|
+
// Reset lastIndex — the /g regexes above are stateful across .test() calls.
|
|
142
|
+
INTRA_WORD_UNDERSCORE.lastIndex = 0;
|
|
143
|
+
INTRA_WORD_ASTERISK.lastIndex = 0;
|
|
144
|
+
|
|
145
|
+
const armUnderscore = hasIntraUnderscore && underscoreCount >= 2;
|
|
146
|
+
const armAsterisk = hasIntraAsterisk && asteriskCount >= 2;
|
|
147
|
+
if (!armUnderscore && !armAsterisk) return text;
|
|
148
|
+
|
|
149
|
+
return segments
|
|
150
|
+
.map((seg) => {
|
|
151
|
+
if (seg.code) return seg.text;
|
|
152
|
+
let out = seg.text;
|
|
153
|
+
if (armUnderscore) out = out.replace(INTRA_WORD_UNDERSCORE, "\\_");
|
|
154
|
+
if (armAsterisk) out = out.replace(INTRA_WORD_ASTERISK, "\\*");
|
|
155
|
+
return out;
|
|
156
|
+
})
|
|
157
|
+
.join("");
|
|
158
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Outbound guard against ACCIDENTAL inline-pair typesetting — the sibling of the
|
|
2
|
+
// dollar-math guard (#3252 follow-up). Mirrors that guard's doctrine exactly.
|
|
3
|
+
//
|
|
4
|
+
// ── Root cause (same seam, same class of bug) ─────────────────────────────
|
|
5
|
+
// Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
|
|
6
|
+
// Telegram as RAW GFM markdown via `sendRichMessage({ markdown })`; Telegram
|
|
7
|
+
// parses it server-side. Telegram's rich GFM parser honours several PAIRED
|
|
8
|
+
// inline-formatting delimiters that ordinary prose can form by accident:
|
|
9
|
+
//
|
|
10
|
+
// • `~text~` / `~~text~~` → strikethrough
|
|
11
|
+
// • `==text==` → highlight / "marked" (Telegram 10.1 supports it)
|
|
12
|
+
// • `||text||` → spoiler
|
|
13
|
+
// • `` `text` `` → code span
|
|
14
|
+
//
|
|
15
|
+
// switchroom's `escapeMarkdown` (format.ts) backslash-escapes exactly these
|
|
16
|
+
// trigger chars (`` ` `` `*` `_` `~` `=` `|` `[` `]`) for DYNAMIC card content —
|
|
17
|
+
// which is our IN-REPO EVIDENCE that Telegram's rich parser genuinely INTERPRETS
|
|
18
|
+
// `~ = |` and `` ` `` as formatting (otherwise escaping them would be pointless).
|
|
19
|
+
// But the assistant's free-form reply body is NOT run through escapeMarkdown
|
|
20
|
+
// (that would nuke the agent's DELIBERATE `~~strike~~` / `||spoiler||` /
|
|
21
|
+
// `` `code` `` formatting — Ken's rich-formatting directive). So an ordinary
|
|
22
|
+
// reply like "trims it to ~10 units, down from ~20" can accidentally form a
|
|
23
|
+
// `~…~` strikethrough span across "10 units, down from ".
|
|
24
|
+
//
|
|
25
|
+
// ── Which of the four we actually guard (and why) ─────────────────────────
|
|
26
|
+
// Ranked by real-world likelihood in normal agent prose:
|
|
27
|
+
//
|
|
28
|
+
// 1. TILDE `~` — HIGH. Approximation tildes on numbers ("~10", "~$5M",
|
|
29
|
+
// "~.5s") are extremely common in agent prose and two of them form a
|
|
30
|
+
// strikethrough pair. GUARDED.
|
|
31
|
+
// 2. MARK `==` — MEDIUM. Equality/comparison operators in prose ("x==y",
|
|
32
|
+
// "if a==b and c==d") sit word-flanked and two of them form a highlight
|
|
33
|
+
// span. GUARDED.
|
|
34
|
+
// 3. SPOILER `||` — LOW. Logical-OR in prose ("a||b || c||d"). Rare but the
|
|
35
|
+
// same word-flanked shape, same fix. GUARDED (cheap, precise).
|
|
36
|
+
// 4. BACKTICK `` ` `` — SKIPPED as effectively-literal. A TRULY unpaired
|
|
37
|
+
// backtick renders LITERALLY per CommonMark (a backtick run with no
|
|
38
|
+
// equal-length closer is left as text — this is exactly what
|
|
39
|
+
// `findClosingBackticks` returns -1 for, so `splitCodeSegments` already
|
|
40
|
+
// leaves it in a PROSE segment untouched). The only case a stray backtick
|
|
41
|
+
// "mis-renders" is an ODD count where an earlier pair swallows prose into a
|
|
42
|
+
// code span — but that is INDISTINGUISHABLE from an intended `` `code` ``
|
|
43
|
+
// span plus a separate literal backtick, and the agent uses `` `code` ``
|
|
44
|
+
// deliberately. Per the false-negative-beats-false-positive rule we leave
|
|
45
|
+
// it. (Neutralising it would risk mangling real code spans, which the task
|
|
46
|
+
// forbids.) See the design note for the full verification.
|
|
47
|
+
//
|
|
48
|
+
// ── The heuristic — precision over recall, never touch intended formatting ──
|
|
49
|
+
// The hard constraint: the agent DELIBERATELY emits `~~strike~~`, `||spoiler||`
|
|
50
|
+
// and `` `code` `` as wanted formatting. We must neutralise ONLY the
|
|
51
|
+
// unmistakably-accidental shape and leave every intended span byte-identical.
|
|
52
|
+
//
|
|
53
|
+
// • TILDE: a `~` is accidental only when it is DIGIT-ADJACENT — immediately
|
|
54
|
+
// followed by an optional `$`/`.` and a digit (`~10`, `~$5`, `~.5`). Intended
|
|
55
|
+
// strikethrough wraps WORDS (`~~deprecated~~`, `~struck~`) → its tildes are
|
|
56
|
+
// letter-adjacent, never matched. We arm only when 2+ such digit-adjacent
|
|
57
|
+
// tildes exist in prose (one alone can never form a pair), then escape them.
|
|
58
|
+
// This also catches the inner tilde of a `~~10` double-open.
|
|
59
|
+
//
|
|
60
|
+
// • MARK / SPOILER: the delimiter is accidental only when it is a binary
|
|
61
|
+
// OPERATOR — i.e. flanked by a word char on BOTH sides (`x==y`, `a||b`).
|
|
62
|
+
// An INTENDED `==mark==` / `||spoiler||` has its opening delimiter preceded
|
|
63
|
+
// by whitespace/line-start and its closing delimiter followed by
|
|
64
|
+
// whitespace/line-end, so it is NEVER word-flanked on both sides. We arm only
|
|
65
|
+
// when 2+ such both-word-flanked operators exist (a lone one cannot form a
|
|
66
|
+
// span, and a space-flanked `a == b` cannot open a span under CommonMark
|
|
67
|
+
// left/right-flanking rules, so both are correctly left alone), then escape.
|
|
68
|
+
//
|
|
69
|
+
// When in doubt we LEAVE IT: a missed neutralisation (reader sees an accidental
|
|
70
|
+
// strike) is strictly better than shredding the agent's intended formatting.
|
|
71
|
+
//
|
|
72
|
+
// ── Mechanics (identical to the dollar guard) ─────────────────────────────
|
|
73
|
+
// • Code-span/fence aware: reuses `splitCodeSegments` from dollar-math-guard so
|
|
74
|
+
// NOTHING inside a `` `code` `` span or fenced block is ever touched.
|
|
75
|
+
// • Deterministic pure string transform; strict NO-OP unless a real signal is
|
|
76
|
+
// present (2+ of a construct in prose).
|
|
77
|
+
// • Idempotent: escaped output (`\~`, `\=\=`, `\|\|`) can never re-match the
|
|
78
|
+
// triggers (the `~` case uses a `(?<!\\)` negative-lookbehind; the `==`/`||`
|
|
79
|
+
// cases can't recur because the escaped form no longer contains two
|
|
80
|
+
// consecutive `=`/`|`), so running the guard twice is byte-for-byte stable.
|
|
81
|
+
//
|
|
82
|
+
// ── Telegram assumptions requiring live UAT (Vitest CANNOT cover these) ────
|
|
83
|
+
// (a) That Telegram's rich parser CONSUMES the backslash so the reader sees a
|
|
84
|
+
// literal `~` / `=` / `|` (not a visible `\`). Asserted by analogy to
|
|
85
|
+
// `escapeMarkdown`, which backslash-escapes these very chars and which the
|
|
86
|
+
// whole card system depends on Telegram stripping.
|
|
87
|
+
// (b) Whether a SINGLE `~` renders strikethrough on the rich path (GitHub GFM
|
|
88
|
+
// needs `~~`; Telegram MarkdownV2 uses single `~`). Our digit-adjacent
|
|
89
|
+
// heuristic is correct under BOTH readings, but the *likelihood* ranking of
|
|
90
|
+
// the tilde case depends on it.
|
|
91
|
+
// (c) That `==` highlight and `||` spoiler render for the word-flanked operator
|
|
92
|
+
// shape (left/right-flanking behaviour).
|
|
93
|
+
// Send real prose through a live agent and confirm literal glyphs before merge.
|
|
94
|
+
|
|
95
|
+
// `splitProtectedSegments` skips code spans/fences AND markdown link
|
|
96
|
+
// destinations / autolinks / GFM table rows verbatim — so a `~`/`==`/`||` that
|
|
97
|
+
// is STRUCTURAL (in a URL query like `?a==1`, or a table's empty cell `|a||b|`)
|
|
98
|
+
// is never escaped. See code-segments.ts.
|
|
99
|
+
import { splitProtectedSegments } from './code-segments.js'
|
|
100
|
+
|
|
101
|
+
/** Digit-adjacent tilde: `~` directly before an optional `$`/`.` and a digit
|
|
102
|
+
* (`~10`, `~$5`, `~.5`). The `(?<!\\)` keeps it idempotent — an already-escaped
|
|
103
|
+
* `\~` is never re-escaped. Global: used for both counting and replacement. */
|
|
104
|
+
const TILDE_APPROX = /(?<!\\)~(?=\$?\.?\d)/g
|
|
105
|
+
|
|
106
|
+
/** A `==` acting as a binary operator: word char on BOTH sides (`x==y`). Never
|
|
107
|
+
* matches an intended `==mark==` (whose delimiters are whitespace-flanked on
|
|
108
|
+
* the outside). Idempotency is structural — the escaped `\=\=` holds no two
|
|
109
|
+
* consecutive `=`, so it can never re-match. Global. */
|
|
110
|
+
const MARK_OP = /(?<=\w)==(?=\w)/g
|
|
111
|
+
|
|
112
|
+
/** A `||` acting as a binary operator: word char on BOTH sides (`a||b`). Never
|
|
113
|
+
* matches an intended `||spoiler||`. Structurally idempotent like `MARK_OP`.
|
|
114
|
+
* Global. */
|
|
115
|
+
const SPOILER_OP = /(?<=\w)\|\|(?=\w)/g
|
|
116
|
+
|
|
117
|
+
/** Count matches of a global regex in a string (throwaway, resets lastIndex via
|
|
118
|
+
* match). */
|
|
119
|
+
function countMatches(text: string, re: RegExp): number {
|
|
120
|
+
return text.match(re)?.length ?? 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Neutralise ACCIDENTAL inline-pair formatting (`~…~` strikethrough,
|
|
125
|
+
* `==…==` highlight, `||…||` spoiler) that Telegram's rich GFM parser would
|
|
126
|
+
* typeset from ordinary prose.
|
|
127
|
+
*
|
|
128
|
+
* Operates on the FINAL rendered rich-markdown string (post-`render`), at the
|
|
129
|
+
* same `richMessage()` seam as `guardDollarMath`. A strict NO-OP unless the
|
|
130
|
+
* prose (non-code) content holds 2+ of a construct in its clearly-accidental
|
|
131
|
+
* shape (digit-adjacent tildes; word-flanked `==`/`||` operators). Code spans /
|
|
132
|
+
* fenced blocks are NEVER touched, and the agent's DELIBERATE `~~strike~~` /
|
|
133
|
+
* `||spoiler||` / `==mark==` / `` `code` `` formatting passes through untouched.
|
|
134
|
+
* Deterministic and idempotent.
|
|
135
|
+
*/
|
|
136
|
+
export function guardAccidentalInlinePairs(text: string): string {
|
|
137
|
+
// Fast bail: none of the guarded trigger chars are present.
|
|
138
|
+
if (!/[~=|]/.test(text)) return text
|
|
139
|
+
|
|
140
|
+
const segments = splitProtectedSegments(text)
|
|
141
|
+
|
|
142
|
+
// Arm each construct independently, counting only PROSE (never code). A single
|
|
143
|
+
// occurrence can never form a pair, so the threshold is 2.
|
|
144
|
+
let tildes = 0
|
|
145
|
+
let marks = 0
|
|
146
|
+
let spoilers = 0
|
|
147
|
+
for (const seg of segments) {
|
|
148
|
+
if (seg.code) continue
|
|
149
|
+
tildes += countMatches(seg.text, TILDE_APPROX)
|
|
150
|
+
marks += countMatches(seg.text, MARK_OP)
|
|
151
|
+
spoilers += countMatches(seg.text, SPOILER_OP)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const armTilde = tildes >= 2
|
|
155
|
+
const armMark = marks >= 2
|
|
156
|
+
const armSpoiler = spoilers >= 2
|
|
157
|
+
if (!armTilde && !armMark && !armSpoiler) return text
|
|
158
|
+
|
|
159
|
+
return segments
|
|
160
|
+
.map((seg) => {
|
|
161
|
+
if (seg.code) return seg.text
|
|
162
|
+
let out = seg.text
|
|
163
|
+
// `\~` — first tilde of an accidental pair can no longer pair.
|
|
164
|
+
if (armTilde) out = out.replace(TILDE_APPROX, () => '\\~')
|
|
165
|
+
// `\=\=` / `\|\|` — the operator can no longer open/close a span.
|
|
166
|
+
if (armMark) out = out.replace(MARK_OP, () => '\\=\\=')
|
|
167
|
+
if (armSpoiler) out = out.replace(SPOILER_OP, () => '\\|\\|')
|
|
168
|
+
return out
|
|
169
|
+
})
|
|
170
|
+
.join('')
|
|
171
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Outbound guard against accidental LINE-START BLOCK CONSTRUCTS (issue #3252,
|
|
2
|
+
// sibling of dollar-math-guard.ts).
|
|
3
|
+
//
|
|
4
|
+
// ── Root cause ───────────────────────────────────────────────────────────
|
|
5
|
+
// Since the Bot API 10.1 migration (#2669) every assistant reply is sent to
|
|
6
|
+
// Telegram as RAW GFM markdown via `sendRichMessage({ markdown })`. Telegram
|
|
7
|
+
// parses that markdown server-side with a CommonMark/GFM-family parser. That
|
|
8
|
+
// means a line of ordinary PROSE that merely happens to START with a markdown
|
|
9
|
+
// block-construct trigger (`#`, `>`, `-`/`+`/`*`, or `N.`) can be silently
|
|
10
|
+
// promoted to a heading / blockquote / bullet / ordered-list item the model
|
|
11
|
+
// never intended:
|
|
12
|
+
// "> 50% of users" → blockquote ">2x faster" → blockquote
|
|
13
|
+
// "# of items" → heading "#1 priority" → (see below)
|
|
14
|
+
// "- 5 degrees" → bullet "2026. was a great year" → ol item
|
|
15
|
+
//
|
|
16
|
+
// ── Why this is the HARD, AMBIGUOUS guard family ─────────────────────────
|
|
17
|
+
// Unlike the `$…$` currency case (where math typesetting of a two-dollar span
|
|
18
|
+
// is NEVER wanted), the agent DELIBERATELY emits headings, bullets, ordered
|
|
19
|
+
// lists and blockquotes — Ken's rich-formatting directive treats them as a
|
|
20
|
+
// wanted feature. The SAME leading character means "format this" (intended)
|
|
21
|
+
// or "literal prose" (accidental) depending purely on authorial intent, which
|
|
22
|
+
// is not recoverable from the bytes. A blanket line-start escape would DESTROY
|
|
23
|
+
// intended formatting and is unacceptable. So this guard is deliberately
|
|
24
|
+
// CONSERVATIVE: it escapes ONLY the narrow sub-patterns that are unmistakably
|
|
25
|
+
// accidental and can be disambiguated DETERMINISTICALLY from the byte stream,
|
|
26
|
+
// and it leaves every plausibly-intended construct byte-for-byte untouched.
|
|
27
|
+
// Everything that cannot be made safe is DEFERRED (see the block comment on
|
|
28
|
+
// `escapeAccidentalLineStart` and guard-linestart-note.md), NOT guessed at.
|
|
29
|
+
//
|
|
30
|
+
// ── What this guard ACTUALLY escapes (the safe subset) ───────────────────
|
|
31
|
+
// 1. Blockquote comparison-operator: a line-start `>` glued DIRECTLY to a
|
|
32
|
+
// digit or `=` (`>2x`, `>50%`, `>=3`). An INTENDED blockquote is always
|
|
33
|
+
// written `> ` WITH a space; `>` glued to a digit/`=` is a "greater than"
|
|
34
|
+
// comparison in prose, never a blockquote. Escaping only the no-space,
|
|
35
|
+
// digit/`=`-adjacent form leaves every real `> quoted line` untouched.
|
|
36
|
+
// 2. Ordered-list with a 4+ digit "number": a line starting `2026. ` /
|
|
37
|
+
// `1999) ` — i.e. a YEAR or other 4+ digit integer followed by `.`/`)`
|
|
38
|
+
// and a space. No real numbered list is authored starting at item 2026;
|
|
39
|
+
// 4+ digit leading integers are effectively always accidental years/
|
|
40
|
+
// quantities. Real lists (`1.`–`999.`) are LEFT ALONE.
|
|
41
|
+
// Both are backslash-escaped (`\>`, `2026\.`) exactly like the dollar guard —
|
|
42
|
+
// `>`, `.`, `)` are ASCII punctuation, escapable per CommonMark's "any ASCII
|
|
43
|
+
// punctuation may be backslash-escaped" rule; Telegram's rich parser strips
|
|
44
|
+
// the backslash the same way `escapeMarkdown` relies on for `~ = | * _`.
|
|
45
|
+
//
|
|
46
|
+
// ── What is DEFERRED (left to the rich-formatting workstream) ────────────
|
|
47
|
+
// • Heading `# ` (with the required space): indistinguishable from an
|
|
48
|
+
// INTENDED heading. `#1` / `#foo` (NO space) is not a GFM heading at all
|
|
49
|
+
// (ATX headings require `#`+space) → Telegram renders it literally → no
|
|
50
|
+
// guard needed. So there is no safely-guardable heading sub-case.
|
|
51
|
+
// • Bullet lists `-`/`+`/`*` + space (`- 5 degrees`): genuinely ambiguous
|
|
52
|
+
// with the heavily-used bullet construct; the glued form `-5` (no space)
|
|
53
|
+
// is not a list item → already literal → no guard needed. Escaping the
|
|
54
|
+
// spaced form would eat intended bullets, so the whole family is deferred.
|
|
55
|
+
// • Ordered-list with 1–3 digit numbers (`1. `, `42. `): ambiguous with a
|
|
56
|
+
// real numbered list. Decimals (`3.14`) have no space after the dot → not
|
|
57
|
+
// a list item → already literal.
|
|
58
|
+
//
|
|
59
|
+
// ── Where this runs ──────────────────────────────────────────────────────
|
|
60
|
+
// Same seam as the dollar guard: `richMessage()` (rich-send.ts) — the ONE
|
|
61
|
+
// adapter every `{ markdown }` wire send funnels through. Integration of this
|
|
62
|
+
// function into that seam is done SEPARATELY by the integrator; this file only
|
|
63
|
+
// exports the pure transform + its helpers. `plain`-mode degradations bypass
|
|
64
|
+
// `richMessage` (no markdown parsing) and are correctly untouched.
|
|
65
|
+
//
|
|
66
|
+
// Idempotent: an already-escaped `\>` / `2026\.` no longer matches the
|
|
67
|
+
// accidental patterns (the leading char is now `\`), so re-running is a strict
|
|
68
|
+
// no-op. Code spans / fenced code blocks and 4-space indented code lines are
|
|
69
|
+
// NEVER touched.
|
|
70
|
+
//
|
|
71
|
+
// ── Telegram-parser assumptions requiring live UAT (see note) ────────────
|
|
72
|
+
// Vitest cannot cover server-side Telegram rendering. The two claims this
|
|
73
|
+
// guard rests on — (a) Telegram promotes `>2x` (no space) and `2026. ` to
|
|
74
|
+
// blockquote/ordered-list, and (b) it CONSUMES the escaping backslash so the
|
|
75
|
+
// reader sees a literal `>` / `.` (not `\>` / `\.`) — are asserted by analogy
|
|
76
|
+
// to CommonMark + the `escapeMarkdown` chars Telegram demonstrably strips.
|
|
77
|
+
// Both need a live round-trip before merge. See guard-linestart-note.md.
|
|
78
|
+
|
|
79
|
+
// Segment splitting is shared across all #3252 guards — one source of truth in
|
|
80
|
+
// render/code-segments.ts. `splitProtectedSegments` skips code spans/fences AND
|
|
81
|
+
// link destinations / autolinks / GFM table rows verbatim.
|
|
82
|
+
import { splitProtectedSegments } from "./code-segments.js";
|
|
83
|
+
|
|
84
|
+
/** Line-start `>` glued directly to a digit or `=` — the "greater than"
|
|
85
|
+
* comparison-operator prose that Telegram wrongly quotes. NOT matched when a
|
|
86
|
+
* space follows the `>` (that is an intended blockquote). */
|
|
87
|
+
const ACCIDENTAL_BLOCKQUOTE = /^>[0-9=]/;
|
|
88
|
+
|
|
89
|
+
/** Line-start 4+ digit integer followed by `.`/`)` then a space or end-of-line
|
|
90
|
+
* — a year/quantity Telegram wrongly promotes to an ordered-list item. Real
|
|
91
|
+
* lists (1–3 digit markers) are excluded by the `{4,}` bound. */
|
|
92
|
+
const ACCIDENTAL_ORDERED_LIST = /^(\d{4,})([.)])(\s|$)/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Escape the accidental block-construct trigger at the start of ONE line
|
|
96
|
+
* (the string must NOT contain a newline). Applies only when the line is a
|
|
97
|
+
* true line start (the caller guarantees this). A no-op unless the line begins
|
|
98
|
+
* with one of the narrow, deterministically-accidental patterns above.
|
|
99
|
+
*
|
|
100
|
+
* Lines indented 4+ spaces are an indented-code context in CommonMark and are
|
|
101
|
+
* left verbatim.
|
|
102
|
+
*/
|
|
103
|
+
function escapeAccidentalLineStart(line: string): string {
|
|
104
|
+
const indent = /^ */.exec(line)![0];
|
|
105
|
+
// 4+ leading spaces => indented code block; never a block construct here.
|
|
106
|
+
if (indent.length >= 4) return line;
|
|
107
|
+
const rest = line.slice(indent.length);
|
|
108
|
+
|
|
109
|
+
// 1. Accidental blockquote (`>2x`, `>50%`, `>=3`). Idempotent: an already
|
|
110
|
+
// escaped `\>` starts with `\`, so `rest` no longer starts with `>`.
|
|
111
|
+
if (ACCIDENTAL_BLOCKQUOTE.test(rest)) {
|
|
112
|
+
return indent + "\\" + rest;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Accidental ordered-list from a 4+ digit number (`2026. was`).
|
|
116
|
+
// Idempotent: `2026\.` has a `\` where the delimiter was, so the
|
|
117
|
+
// `(\d{4,})([.)])` shape no longer matches.
|
|
118
|
+
const ol = ACCIDENTAL_ORDERED_LIST.exec(rest);
|
|
119
|
+
if (ol) {
|
|
120
|
+
const digits = ol[1];
|
|
121
|
+
const delim = ol[2];
|
|
122
|
+
return indent + digits + "\\" + delim + rest.slice(digits.length + 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return line;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Neutralise accidental line-start block constructs (heading / blockquote /
|
|
130
|
+
* bullet / ordered-list promotion of prose) on the FINAL rendered rich-markdown
|
|
131
|
+
* string (post-`render`). CONSERVATIVE and deterministic: escapes ONLY the two
|
|
132
|
+
* unmistakably-accidental patterns documented above and leaves all plausibly
|
|
133
|
+
* intended formatting untouched. Code spans / fenced blocks / 4-space indented
|
|
134
|
+
* code are never touched. Idempotent and a strict no-op absent a real signal.
|
|
135
|
+
*/
|
|
136
|
+
export function guardAccidentalBlockConstructs(text: string): string {
|
|
137
|
+
// Cheap short-circuit: no `>` and no plausible 4+ digit list marker => no-op.
|
|
138
|
+
if (!text.includes(">") && !/\d{4,}[.)]/.test(text)) return text;
|
|
139
|
+
|
|
140
|
+
const segments = splitProtectedSegments(text);
|
|
141
|
+
let out = "";
|
|
142
|
+
// True at text start and immediately after any emitted `\n`.
|
|
143
|
+
let atLineStart = true;
|
|
144
|
+
|
|
145
|
+
for (const seg of segments) {
|
|
146
|
+
if (seg.code) {
|
|
147
|
+
out += seg.text;
|
|
148
|
+
// Code spans never contain a newline; fenced blocks end on backticks, not
|
|
149
|
+
// a newline. Either way the next char is on the same line unless the code
|
|
150
|
+
// text itself ends with a newline.
|
|
151
|
+
atLineStart = seg.text.endsWith("\n");
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const lines = seg.text.split("\n");
|
|
155
|
+
for (let k = 0; k < lines.length; k++) {
|
|
156
|
+
// A prose segment can begin MID-LINE (right after an inline code span),
|
|
157
|
+
// so its first line is a real line start only if the running flag says so.
|
|
158
|
+
const lineIsAtStart = k === 0 ? atLineStart : true;
|
|
159
|
+
const processed = lineIsAtStart ? escapeAccidentalLineStart(lines[k]) : lines[k];
|
|
160
|
+
out += processed;
|
|
161
|
+
if (k < lines.length - 1) out += "\n";
|
|
162
|
+
}
|
|
163
|
+
atLineStart = seg.text.endsWith("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
@@ -67,6 +67,13 @@ export function renderOutbound(
|
|
|
67
67
|
maxLen: number = RICH_MESSAGE_MAX_CHARS,
|
|
68
68
|
): RenderResult {
|
|
69
69
|
return renderSafe(parse(text), text, maxLen);
|
|
70
|
+
// #3252 note: the `$…$` currency-math neutraliser (`guardDollarMath`) is NOT
|
|
71
|
+
// applied here. It lives at the single wire seam — `richMessage()` in
|
|
72
|
+
// rich-send.ts — through which EVERY `{ markdown }` send funnels (the
|
|
73
|
+
// reply-tool final answer, the draft-stream previews this renderer feeds,
|
|
74
|
+
// cards, approvals). Applying it there guards every markdown-parsed outbound
|
|
75
|
+
// exactly once (F1), and `plain`-mode degradations correctly bypass it. See
|
|
76
|
+
// dollar-math-guard.ts for the rationale.
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
/**
|
|
@@ -18,15 +18,61 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { GrammyError } from 'grammy'
|
|
21
|
+
import { guardDollarMath } from './render/dollar-math-guard.js'
|
|
22
|
+
import { guardAccidentalEmphasis } from './render/emphasis-guard.js'
|
|
23
|
+
import { guardAccidentalBlockConstructs } from './render/line-start-guard.js'
|
|
24
|
+
import { guardAccidentalInlinePairs } from './render/inline-pairs-guard.js'
|
|
21
25
|
|
|
22
26
|
/** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
|
|
23
27
|
export interface InputRichMessageMarkdown {
|
|
24
28
|
markdown: string
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Neutralise ALL accidental Telegram markdown typesetting in one composed pass
|
|
33
|
+
* (#3252). Each sub-guard targets a DISJOINT set of trigger characters, skips
|
|
34
|
+
* code spans / fenced blocks verbatim (shared `splitCodeSegments`), is a strict
|
|
35
|
+
* no-op absent a real signal, and is idempotent — so the composition is itself
|
|
36
|
+
* idempotent and safe to apply once per send.
|
|
37
|
+
*
|
|
38
|
+
* ── Ordering (deliberate, not arbitrary) ──────────────────────────────────
|
|
39
|
+
* `guardDollarMath` runs LAST. It backslash-escapes `$` → `\$`, and the
|
|
40
|
+
* inline-pairs guard's approximation-tilde signal is `~(?=\$?\.?\d)` — a `~`
|
|
41
|
+
* glued to a `$digit`. If the dollar guard ran first, a body like
|
|
42
|
+
* `~$5M and ~$10M` would become `~\$5M …`, and the interposed `\` would hide
|
|
43
|
+
* the digit-adjacent tildes from `guardAccidentalInlinePairs`, leaving the
|
|
44
|
+
* accidental `~…~` strikethrough pair un-neutralised (a false negative /
|
|
45
|
+
* residual bug). Running inline-pairs FIRST escapes the tildes (`\~$5M`), then
|
|
46
|
+
* the dollar guard escapes the `$` — both spans are killed. Every other pair of
|
|
47
|
+
* guards is disjoint in the characters it inspects AND the characters it
|
|
48
|
+
* inserts (`\_ \* \> \. \~ \=\= \|\|` vs `\$`), so no other insertion can
|
|
49
|
+
* create or destroy a signal for a sibling. Verified by composition tests.
|
|
50
|
+
*/
|
|
51
|
+
export function guardAccidentalFormatting(markdown: string): string {
|
|
52
|
+
let out = markdown
|
|
53
|
+
out = guardAccidentalEmphasis(out)
|
|
54
|
+
out = guardAccidentalBlockConstructs(out)
|
|
55
|
+
out = guardAccidentalInlinePairs(out)
|
|
56
|
+
out = guardDollarMath(out)
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wrap raw GFM markdown into the rich-message input object.
|
|
62
|
+
*
|
|
63
|
+
* This is the ONE adapter every `{ markdown }` wire send funnels through
|
|
64
|
+
* (`sendRichMessage` / `editMessageText({ markdown })`) — the reply-tool final
|
|
65
|
+
* answer, draft-stream previews, cards, approvals, banners. It is therefore the
|
|
66
|
+
* single deterministic seam for the #3252 accidental-formatting guards (F1):
|
|
67
|
+
* applying `guardAccidentalFormatting` here guards EVERY markdown-parsed
|
|
68
|
+
* outbound exactly once, without touching `plain`-mode degradations (which
|
|
69
|
+
* bypass this wrapper and go straight to `sendMessage`, where no markdown
|
|
70
|
+
* parsing happens). The composed guard is a strict no-op for any body without
|
|
71
|
+
* an accidental-formatting signal and is idempotent, so callers that already
|
|
72
|
+
* ran it (or the streaming path that renders then re-wraps) stay byte-identical.
|
|
73
|
+
*/
|
|
28
74
|
export function richMessage(markdown: string): InputRichMessageMarkdown {
|
|
29
|
-
return { markdown }
|
|
75
|
+
return { markdown: guardAccidentalFormatting(markdown) }
|
|
30
76
|
}
|
|
31
77
|
|
|
32
78
|
/**
|