switchroom 0.19.6 → 0.19.8

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.
@@ -1,18 +1,18 @@
1
1
  /**
2
- * Per-chat marker of the most recent gateway-synthesized `subagent_handback`
3
- * enqueue (fix/backstop-duplicate-reply).
2
+ * Per-chat-and-thread marker of the most recent gateway-synthesized
3
+ * `subagent_handback` enqueue (fix/backstop-duplicate-reply).
4
4
  *
5
5
  * A BACKGROUND sub-agent completion is a GATEWAY-SYNTHESIZED event, not model
6
6
  * output: when a background worker terminates the gateway wakes the agent with a
7
- * `subagent_handback` inbound. Recording WHEN one was enqueued, per chat, is the
8
- * ONE deterministic signal that distinguishes the two late-reply cases that both
9
- * resolve a flush-delivered ENDED turn via the latest-ended tier — the case the
10
- * owner-resolution tier alone cannot separate (a DM late reply has no
7
+ * `subagent_handback` inbound. Recording WHEN one was enqueued, per chat/thread,
8
+ * is the ONE deterministic signal that distinguishes the two late-reply cases
9
+ * that both resolve a flush-delivered ENDED turn via the latest-ended tier — the
10
+ * case the owner-resolution tier alone cannot separate (a DM late reply has no
11
11
  * live/origin/quoted attribution, so both land on latest-ended):
12
12
  *
13
13
  * - CASE A — the flushed turn's OWN reworded reply landing late. NO
14
- * `subagent_handback` was enqueued for this chat after the turn ended, so the
15
- * reply is that turn's own answer → the supersede path collapses the
14
+ * `subagent_handback` was enqueued for this chat/thread after the turn ended,
15
+ * so the reply is that turn's own answer → the supersede path collapses the
16
16
  * provisional flush REGARDLESS of the model's rewording (closes the #3429
17
17
  * reworded-duplicate regression: agent:marko 2026-07-20, turns
18
18
  * #1177/#1182/#1201 double-sent).
@@ -22,21 +22,200 @@
22
22
  * and send fresh (two messages), never silently edit/delete the flushed
23
23
  * answer.
24
24
  *
25
- * One entry per chat (overwritten on each enqueue), so bounded by chat count. No
26
- * clock reads beyond the caller-supplied `now`; the gateway wires the actual
27
- * enqueue site and the supersede-path read. Deterministic keyed on a
28
- * gateway-emitted event, never on model discipline (Ken's controls-in-code rule).
25
+ * ## Why thread-keyed (F2, dup-audit 2026-07-21)
26
+ *
27
+ * The supersede registry this marker gates is keyed on `chatId|threadId`
28
+ * (`flushed-turn-supersede.ts` `makeKey`). Keying the marker on `chatId` ALONE
29
+ * was inconsistent: in a forum supergroup a background handback in topic A
30
+ * stamped the chat-wide marker, and a genuine CASE-A reworded own-reply in ANY
31
+ * other topic within the 60 s TTL then computed `handbackCouldOwnReply = true` →
32
+ * kept the content gate → shipped a second visible bubble instead of collapsing.
33
+ * The blast radius was every topic in the chat for 60 s per handback. Keying on
34
+ * `chatId + threadId` — the SAME lane the supersede registry uses — confines a
35
+ * handback's gate-hold to the topic it actually landed in, so an unrelated
36
+ * topic's CASE-A collapse is untouched. A DM (no thread) collapses to the
37
+ * chat-only key, so single-lane behaviour is unchanged.
38
+ *
39
+ * One entry per chat/thread (overwritten on each enqueue), so bounded by
40
+ * chat×topic count. No clock reads beyond the caller-supplied `now`; the gateway
41
+ * wires the actual enqueue site and the supersede-path read. Deterministic —
42
+ * keyed on a gateway-emitted event, never on model discipline (Ken's
43
+ * controls-in-code rule).
44
+ */
45
+ /**
46
+ * ## The enforced invariant (F1, dup-audit 2026-07-21)
47
+ *
48
+ * The whole content-gate bypass rests on this premise: *a decoupled late reply
49
+ * (turn == null) carrying content that is NOT the flushed turn's own answer,
50
+ * resolving an ENDED flushed turn as its owner, can ONLY be a gateway-synthesized
51
+ * completion — and every such completion stamps this marker.* If that premise
52
+ * holds, then marker-ABSENCE in the window proves the late reply IS the flushed
53
+ * turn's own (possibly reworded) answer, so bypassing the content gate is safe
54
+ * (closes the marko duplicate). If a NEW synthesized-inbound source were ever
55
+ * able to land as a decoupled late reply with foreign content WITHOUT stamping,
56
+ * it would silently edit over a delivered answer (the #3429 failure).
57
+ *
58
+ * We make the premise an ENFORCED invariant rather than an architectural
59
+ * coincidence by routing the stamp decision through this ONE predicate at the ONE
60
+ * chokepoint every inbound funnels through (`pendingInboundBuffer.push` — live
61
+ * synthesis, boot-replay, and every future source alike). The membership of the
62
+ * decoupled-completion class is defined HERE and nowhere else:
63
+ *
64
+ * - `subagent_handback` — the only source today that wakes the agent to emit a
65
+ * reply with NO live gateway turn of its own (a background worker completion),
66
+ * so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
67
+ * tier. It is the F1 vector.
68
+ * - Every OTHER synthesized source (cron, resume_*, reaction, vault_*, …) lands
69
+ * as its OWN live inbound turn, so its reply resolves the `live` tier for its
70
+ * own turnId and structurally cannot supersede a different ended turn's flush
71
+ * record (`decideSupersede` requires `record.turnId === liveTurnId`). Those
72
+ * must NOT stamp — stamping them would needlessly hold the content gate open
73
+ * for an unrelated own-reply in the window (a safe but avoidable visible dup).
74
+ *
75
+ * This predicate is therefore the SINGLE point of extension: any future feature
76
+ * that synthesizes an inbound which can land as a DECOUPLED late reply (no live
77
+ * turn) MUST add its `meta.source` here — and because the chokepoint consults
78
+ * this predicate, doing so is the whole wiring. A source that forgets to opt in
79
+ * cannot reach the bypass unnoticed: the negative outcome guard
80
+ * (`send-reply-golden.test.ts`) pins that a decoupled foreign-content reply on a
81
+ * non-live tier never silently edits over the flushed answer.
82
+ *
83
+ * ## Inbound meta.source classification registry (F1 durability — dup-audit
84
+ * MUST-FIX 3, Fable 2026-07-21)
85
+ *
86
+ * The old predicate was a bare `=== 'subagent_handback'` with NO tripwire and an
87
+ * UNSAFE default: adding a new decoupled-completion source tripped nothing — no
88
+ * stamp → content-gate bypass eligible → silent edit-over of a delivered answer.
89
+ * This registry makes the classification EXPLICIT, EXHAUSTIVE and FAIL-SAFE:
90
+ *
91
+ * - Every known `meta.source` is listed with `decoupledCompletion`. Today ONLY
92
+ * `subagent_handback` is true; every other synthesized source lands as its
93
+ * OWN live inbound turn (live tier → cannot supersede a different ended
94
+ * turn's record), so it must NOT stamp.
95
+ * - An UNKNOWN / unclassified source FAILS SAFE: `stampsHandbackMarker` returns
96
+ * `true`, so it STAMPS → the content gate is KEPT → the worst case is a
97
+ * visible duplicate, NEVER a silent edit-over (inverted from the old
98
+ * deny-default, which failed toward silent loss).
99
+ * - The exhaustiveness test (`subagent-handback-marker.test.ts`) scans the
100
+ * gateway for `source:` / `meta.source ===` literals and FAILS when a new one
101
+ * is added without a registry entry — forcing a conscious classification.
102
+ *
103
+ * Defense-in-depth with the tier restriction (`outbound-send-path.ts`): the
104
+ * content-gate bypass is now limited to the `live` + `latest-ended` tiers, so
105
+ * model-steerable `quoted`/`origin` attributions can never bypass regardless of
106
+ * the marker. This registry closes the residual `latest-ended` vector for a
107
+ * FUTURE decoupled source, fail-safe.
29
108
  */
109
+ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion: boolean }> = {
110
+ // The ONE decoupled-completion source today: a background worker termination
111
+ // wakes the agent with no live turn of its own → its reply resolves a
112
+ // different, already-ended turn via the latest-ended tier. THE F1 vector.
113
+ subagent_handback: { decoupledCompletion: true },
114
+ // Everything below lands as its OWN live inbound turn (live tier), so its reply
115
+ // resolves the live tier for its own turnId and structurally cannot supersede a
116
+ // different ended turn's record → not a decoupled-completion vector, must not stamp.
117
+ cron: { decoupledCompletion: false },
118
+ reaction: { decoupledCompletion: false },
119
+ subagent_progress: { decoupledCompletion: false },
120
+ resume_interrupted: { decoupledCompletion: false },
121
+ resume_deferred: { decoupledCompletion: false },
122
+ resume_watchdog_timeout: { decoupledCompletion: false },
123
+ vault_grant_approved: { decoupledCompletion: false },
124
+ vault_grant_denied: { decoupledCompletion: false },
125
+ vault_grant_timeout: { decoupledCompletion: false },
126
+ vault_save_completed: { decoupledCompletion: false },
127
+ vault_save_discarded: { decoupledCompletion: false },
128
+ vault_save_failed: { decoupledCompletion: false },
129
+ vault_save_timeout: { decoupledCompletion: false },
130
+ secret_provided: { decoupledCompletion: false },
131
+ secret_declined: { decoupledCompletion: false },
132
+ secret_provide_failed: { decoupledCompletion: false },
133
+ secret_request_timeout: { decoupledCompletion: false },
134
+ mental_model_propose_timeout: { decoupledCompletion: false },
135
+ bridge_dead_restart: { decoupledCompletion: false },
136
+ obligation_represent: { decoupledCompletion: false },
137
+ missed_approval_retry: { decoupledCompletion: false },
138
+ skill_proposal_apply: { decoupledCompletion: false },
139
+ warmup: { decoupledCompletion: false },
140
+ // dup-audit pass-2 (Fable) — sources the widened exhaustiveness scanner now
141
+ // sees. Each lands as its OWN live inbound turn (not a decoupled completion
142
+ // resolving a DIFFERENT ended turn), so it must NOT stamp — else its fail-safe
143
+ // stamp would hold the content gate chat-wide for 60 s and re-open the
144
+ // reworded-own-answer visible dup in that window.
145
+ // - mental_model_proposal_{applied,denied,failed}: resume-synthetic inbounds
146
+ // injected via `deliverResumeSyntheticOrBuffer` as their own live turn.
147
+ // - webhook / linear: built in `src/web/webhook-dispatch.ts`, delivered via
148
+ // the gateway's `webhookInject` (`sendToAgent`, buffer on miss) as their
149
+ // own live turn.
150
+ mental_model_proposal_applied: { decoupledCompletion: false },
151
+ mental_model_proposal_denied: { decoupledCompletion: false },
152
+ mental_model_proposal_failed: { decoupledCompletion: false },
153
+ webhook: { decoupledCompletion: false },
154
+ linear: { decoupledCompletion: false },
155
+ }
156
+
157
+ /**
158
+ * Whether an inbound of this `meta.source` must stamp the decoupled-completion
159
+ * marker. Null/undefined (a normal user inbound — not synthesized) → false.
160
+ * A known source → its registry classification. An UNKNOWN source → true
161
+ * (fail-safe: stamp, so an unclassified future decoupled source can only cause a
162
+ * visible dup, never a silent edit-over).
163
+ */
164
+ export function stampsHandbackMarker(source: string | null | undefined): boolean {
165
+ if (source == null) return false
166
+ const known = INBOUND_SOURCE_CLASSIFICATION[source]
167
+ if (known == null) return true // fail-safe: unknown synthesized source stamps
168
+ return known.decoupledCompletion
169
+ }
170
+
171
+ /** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
172
+ const MAIN_THREAD_KEY = '<main>'
173
+
30
174
  export class SubagentHandbackMarker {
31
- private readonly lastAtByChat = new Map<string, number>()
175
+ // chatId (threadKey last enqueue ms). Nested so the CONTENT-GATE read can
176
+ // query chat-wide (`lastAtInChat`) while the record stays thread-resolved.
177
+ private readonly byChat = new Map<string, Map<string, number>>()
178
+
179
+ private threadKey(threadId: number | undefined): string {
180
+ return threadId == null ? MAIN_THREAD_KEY : String(threadId)
181
+ }
182
+
183
+ /** Record that a `subagent_handback` was enqueued for `chatId`/`threadId` at
184
+ * `now` (ms). Thread-resolved so the record retains the originating topic. */
185
+ record(chatId: string, threadId: number | undefined, now: number): void {
186
+ let inner = this.byChat.get(chatId)
187
+ if (inner == null) {
188
+ inner = new Map<string, number>()
189
+ this.byChat.set(chatId, inner)
190
+ }
191
+ inner.set(this.threadKey(threadId), now)
192
+ }
32
193
 
33
- /** Record that a `subagent_handback` was enqueued for `chatId` at `now` (ms). */
34
- record(chatId: string, now: number): void {
35
- this.lastAtByChat.set(chatId, now)
194
+ /** Wall-clock ms of the most recent handback enqueue for a SPECIFIC
195
+ * `chatId`/`threadId` lane, or null. (Diagnostics / unit tests.) */
196
+ lastAt(chatId: string, threadId: number | undefined): number | null {
197
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null
36
198
  }
37
199
 
38
- /** Wall-clock ms of the most recent handback enqueue for `chatId`, or null. */
39
- lastAt(chatId: string): number | null {
40
- return this.lastAtByChat.get(chatId) ?? null
200
+ /**
201
+ * Wall-clock ms of the most recent handback enqueue ANYWHERE in `chatId`
202
+ * (across every topic lane), or null. This is what the content-gate read uses
203
+ * (dup-audit MUST-FIX 2, Fable 2026-07-21): the owner-resolution latest-ended
204
+ * tier is CHAT-WIDE (`findLatestEndedTurnForChat` ignores thread), so a
205
+ * background handback in topic A can resolve — and supersede — topic B's
206
+ * ended turn. A thread-SPECIFIC gate read (the F2 regression) let a reply
207
+ * dodge that handback by carrying a different `message_thread_id`, silently
208
+ * editing over the answer. Querying chat-wide makes the gate un-steerable by
209
+ * the reply's own thread arg: any in-window handback in the chat keeps the
210
+ * content gate. The cost is the F2 visible-dup (a handback in one topic keeps
211
+ * the gate for a coinciding own-reply in another for ≤TTL) — a self-healing
212
+ * visible duplicate, which is strictly better than a silent edit-over.
213
+ */
214
+ lastAtInChat(chatId: string): number | null {
215
+ const inner = this.byChat.get(chatId)
216
+ if (inner == null || inner.size === 0) return null
217
+ let max = -Infinity
218
+ for (const ts of inner.values()) if (ts > max) max = ts
219
+ return max === -Infinity ? null : max
41
220
  }
42
221
  }
@@ -1,38 +1,44 @@
1
- // Typed intermediate representation (IR) for the Telegram HTML render engine.
1
+ // Typed intermediate representation (IR) for the Telegram rich-markdown render
2
+ // engine. (Historical note: this file and render.ts were named for an "HTML
3
+ // render engine" during Increment 1, before the Bot API 10.1 migration (#2669)
4
+ // made GFM `{ markdown }` the live send path. There is NO HTML anywhere on the
5
+ // outbound path today — the renderer in render.ts emits raw GFM markdown for
6
+ // the `markdown` field of `InputRichMessageMarkdown`.)
2
7
  //
3
8
  // This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
4
- // mdast tree into this shape; a later increment's renderer walks it and emits
5
- // Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
6
- // is no renderer yet.
9
+ // mdast tree into this shape; `render.ts` walks it and emits Telegram
10
+ // rich-message GFM markdown.
7
11
  //
8
12
  // Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
9
13
  // from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
10
14
  // code-unit indices into the original markdown string, so
11
15
  // `source.slice(node.start, node.end)` round-trips to the node's source text.
12
16
  //
13
- // Telegram HTML tag mapping (for the next increment NOT implemented here):
17
+ // IR node -> emitted GFM markdown (see render.ts `renderInline`/block render):
14
18
  //
15
19
  // Inline
16
- // plain -> (raw text, HTML-escaped)
17
- // bold -> <b>…</b> (markdown `**…**`)
18
- // italic -> <i>…</i> (markdown `*…*`)
19
- // underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
20
- // strike -> <s>…</s> (markdown `~~…~~`)
21
- // spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
22
- // highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
23
- // code -> <code>…</code>
24
- // link -> <a href="…">…</a>
20
+ // plain -> raw text (escapeMarkdown'd)
21
+ // bold -> `**…**`
22
+ // italic -> `*…*`
23
+ // underline -> `__…__` — NOTE: the wire renders `__…__` as BOLD, not
24
+ // underline. Telegram's rich-message markdown has no underline
25
+ // token (live-verified, see reference/telegram-formatting-guide.md).
26
+ // The node preserves the author's `__` bytes faithfully; it is
27
+ // a distinct IR node but NOT a distinct wire style.
28
+ // strike -> `~~…~~`
29
+ // spoiler -> `||…||`
30
+ // highlight -> `==…==` (Bot API 10.1 marked entity)
31
+ // code -> `` `…` ``
32
+ // link -> `[…](…)`
25
33
  //
26
34
  // Block
27
35
  // paragraph -> children joined; blocks separated by "\n\n"
28
- // heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
29
- // blockquote -> <blockquote>…</blockquote>
30
- // (expandable === true -> <blockquote expandable>)
31
- // code-block -> <pre><code class="language-…">…</code></pre>
32
- // list -> rendered line-per-item with "•"/"1." bullets
33
- // (Telegram HTML has no <ul>/<ol>)
34
- // thematic-break -> a horizontal-rule text line (e.g. "───")
35
- // table -> monospaced <pre> table (Telegram HTML has no <table>)
36
+ // heading -> `#`…`######` line
37
+ // blockquote -> `> …` (expandable === true -> `**> …` expandable blockquote)
38
+ // code-block -> ```` ```lang ``` ````
39
+ // list -> line-per-item with `-`/`1.` markers
40
+ // thematic-break -> `---` thematic break
41
+ // table -> GFM pipe table
36
42
 
37
43
  export interface Pos {
38
44
  /** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
@@ -60,10 +66,12 @@ export interface ItalicNode extends Pos {
60
66
  children: Inline[];
61
67
  }
62
68
 
63
- /** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
64
- * double-underscore run is UNDERLINE distinct from `**…**` bold, even though
65
- * GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
66
- * disambiguates the two by looking at the run's source delimiter. */
69
+ /** A `__…__` double-underscore run. `parse.ts` keeps it as a distinct node
70
+ * (separate from `**…**` bold) by looking at the source delimiter, even though
71
+ * GFM/micromark folds both into a single `strong` mdast node. NOTE: on the
72
+ * Telegram wire this renders as BOLD, not a distinct underline style — Bot API
73
+ * 10.1 rich markdown has no underline entity here, so the round-trip is faithful
74
+ * but the delivered text is bold. Kept distinct only to preserve authoring intent. */
67
75
  export interface UnderlineNode extends Pos {
68
76
  type: "underline";
69
77
  children: Inline[];
@@ -1,6 +1,7 @@
1
- // IR -> Telegram rich-markdown renderer for the Telegram HTML render engine
2
- // (name kept for continuity with render/ir.ts + render/parse.ts; the actual
3
- // wire format is GFM markdown, NOT HTML see the note below).
1
+ // IR -> Telegram rich-markdown renderer. (The render/ir.ts + render/parse.ts
2
+ // files were originally named for an "HTML render engine"; that name is
3
+ // historical — the actual wire format is GFM markdown, NOT HTML. See the note
4
+ // below and the refreshed header in render/ir.ts.)
4
5
  //
5
6
  // Increment 2 of the render pipeline: takes the typed IR produced by
6
7
  // `parse.ts` (per `ir.ts`) and emits a string suitable for the `markdown`
@@ -67,6 +68,11 @@ function renderInline(node: Inline, ctx: InlineCtx = {}): string {
67
68
  case "italic":
68
69
  return `*${renderInlineChildren(node.children, ctx)}*`;
69
70
  case "underline":
71
+ // The wire renders `__…__` as BOLD, not underline — Telegram's
72
+ // rich-message markdown has no underline token (live-verified; see
73
+ // reference/telegram-formatting-guide.md). We preserve the author's `__`
74
+ // bytes faithfully rather than rewriting them to `**`; the IR keeps
75
+ // underline as a distinct node, but it is NOT a distinct wire style.
70
76
  return `__${renderInlineChildren(node.children, ctx)}__`;
71
77
  case "strike":
72
78
  return `~~${renderInlineChildren(node.children, ctx)}~~`;
@@ -284,6 +290,9 @@ export const SUPPORTED_INLINE = [
284
290
  "plain",
285
291
  "bold",
286
292
  "italic",
293
+ // "underline" parses `__…__` into a distinct node and round-trips it, but the
294
+ // wire renders it as BOLD (no underline token on this path). Kept for faithful
295
+ // `__` byte round-trip, NOT because it is a distinct rendered style.
287
296
  "underline",
288
297
  "strike",
289
298
  "spoiler",
@@ -67,16 +67,22 @@ export function guardAccidentalFormatting(markdown: string): string {
67
67
  /**
68
68
  * Wrap raw GFM markdown into the rich-message input object.
69
69
  *
70
- * This is the ONE adapter every `{ markdown }` wire send funnels through
71
- * (`sendRichMessage` / `editMessageText({ markdown })`) the reply-tool final
72
- * answer, draft-stream previews, cards, approvals, banners. It is therefore the
73
- * single deterministic seam for the #3252 accidental-formatting guards (F1):
74
- * applying `guardAccidentalFormatting` here guards EVERY markdown-parsed
75
- * outbound exactly once, without touching `plain`-mode degradations (which
76
- * bypass this wrapper and go straight to `sendMessage`, where no markdown
77
- * parsing happens). The composed guard is a strict no-op for any body without
78
- * an accidental-formatting signal and is idempotent, so callers that already
79
- * ran it (or the streaming path that renders then re-wraps) stay byte-identical.
70
+ * This is a CONVENIENCE adapter NOT the universal seam. It applies
71
+ * `guardAccidentalFormatting` for the many callers that build a body through
72
+ * it (the reply-tool final answer, draft-stream previews, cards), but it is
73
+ * NOT the one place every `{ markdown }` wire send funnels through: several
74
+ * sites build a raw `{ markdown }` and call `sendRichMessage` /
75
+ * `editMessageText` directly, bypassing this wrapper (see the correctness
76
+ * audit's F1 list banners, switchroomReply html, approval/folder-picker
77
+ * edits). The REAL universal seam for the #3252 accidental-formatting guards
78
+ * is the grammy API transformer `installRichMarkdownGuard`
79
+ * (`shared/bot-runtime.ts`), installed on the production Bot in gateway boot,
80
+ * which guards EVERY sendRichMessage/editMessageText payload regardless of the
81
+ * call site. This wrapper is kept belt-and-braces: the composed guard is a
82
+ * strict no-op for any body without an accidental-formatting signal and is
83
+ * idempotent, so a body guarded here and re-guarded by the transformer stays
84
+ * byte-identical. `plain`-mode degradations bypass both (they go straight to
85
+ * `sendMessage`, where no markdown parsing happens).
80
86
  */
81
87
  export function richMessage(markdown: string): InputRichMessageMarkdown {
82
88
  return { markdown: guardAccidentalFormatting(markdown) }
@@ -32,6 +32,7 @@ import { createRetryApiCall } from '../retry-api-call.js'
32
32
  import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
33
33
  import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
34
34
  import { shouldEmitTgPost } from './gw-trace-gate.js'
35
+ import { guardAccidentalFormatting } from '../rich-send.js'
35
36
 
36
37
  // ─── tg-post tag plumbing ─────────────────────────────────────────────────
37
38
 
@@ -147,6 +148,62 @@ export function installTgPostLogger(bot: Bot): void {
147
148
  })
148
149
  }
149
150
 
151
+ /**
152
+ * Universal accidental-formatting guard, installed as a grammy API transformer
153
+ * on the single production Bot (#3252/#3463 follow-up). This is the REAL
154
+ * universal seam — not `richMessage()`. `richMessage()` only guards bodies its
155
+ * callers remember to wrap; the correctness audit found ~6 sites that build a
156
+ * raw `{ markdown }` and call `sendRichMessage` / `editMessageText` directly,
157
+ * bypassing it (`shared/bot-runtime.ts` switchroomReply html path,
158
+ * `slot-banner-driver.ts` OAuth banners, and edits in `folder-picker-handler`,
159
+ * `approval-callback`, `inline-keyboard-callbacks`). A transformer at the
160
+ * grammy `bot.api.config.use` layer sees every rich send regardless of the
161
+ * call site, `ctx.*` sugar, `lockedBot`, or `bot.api.raw`, so it closes the
162
+ * whole bypass class deterministically.
163
+ *
164
+ * Payload shape (verified against grammy 1.44.0 `out/core/api.js`, the pinned
165
+ * lockfile version):
166
+ * - `sendRichMessage(chat_id, rich_message, ...)` → raw payload
167
+ * `{ chat_id, rich_message: { markdown }, ... }`
168
+ * - `editMessageText(chat_id, message_id, arg, ...)` → raw payload
169
+ * `{ ..., rich_message: { markdown } }` when `arg` is an object, or
170
+ * `{ ..., text }` when `arg` is a plain string.
171
+ * The markdown therefore lives at `payload.rich_message.markdown`, NOT
172
+ * `payload.markdown` (gating on the latter matches nothing — a silent no-op).
173
+ * Gating on `rich_message?.markdown` also structurally skips every literal /
174
+ * plain-string edit (they carry `text`, not `rich_message`), so those pass
175
+ * through byte-identical. `sendRichMessageDraft` is not wired in the repo
176
+ * (draft streaming uses sendMessage+editMessageText); extend the method gate
177
+ * here if a future draft adopter starts using it.
178
+ *
179
+ * The composed `guardAccidentalFormatting` is idempotent, so double-guarding a
180
+ * `richMessage()`-wrapped body that also passes through here is byte-identical
181
+ * (the internal guard in `richMessage()` is kept belt-and-braces).
182
+ *
183
+ * We clone `rich_message` before mutating: callers can share the object by
184
+ * reference (e.g. `richMessage()` output reused across a retry), and a
185
+ * transformer must not mutate the caller's input.
186
+ */
187
+ export function installRichMarkdownGuard(bot: Bot): void {
188
+ bot.api.config.use(async (prev, method, payload, signal) => {
189
+ if (
190
+ (method === 'sendRichMessage' || method === 'editMessageText') &&
191
+ payload != null
192
+ ) {
193
+ const p = payload as Record<string, unknown>
194
+ const rich = p.rich_message as { markdown?: unknown } | undefined
195
+ if (rich != null && typeof rich.markdown === 'string') {
196
+ const guarded = guardAccidentalFormatting(rich.markdown)
197
+ if (guarded !== rich.markdown) {
198
+ // Clone rather than mutate the caller's shared object.
199
+ p.rich_message = { ...rich, markdown: guarded }
200
+ }
201
+ }
202
+ }
203
+ return prev(method, payload, signal)
204
+ })
205
+ }
206
+
150
207
  // ─── robustApiCall factory ────────────────────────────────────────────────
151
208
 
152
209
  /**
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pin tests for the accidental-formatting guard (#3252/#3463).
3
+ *
4
+ * Two internal-hardening assertions, no user-visible change:
5
+ *
6
+ * 1. Documents the `#{1,6}` cap in ACCIDENTAL_HEADING as intentional: a run of
7
+ * 7+ `#` glued to non-space is left UNescaped (CommonMark caps heading
8
+ * promotion at 6 `#`, and the guard mirrors that). Correctness-audit F2
9
+ * flagged this as asserted-but-untested.
10
+ *
11
+ * 2. A wiring assertion that PR1's `installRichMarkdownGuard` transformer is
12
+ * actually installed on the production Bot in `initGatewayBot()`, so the
13
+ * universal seam can't be silently dropped in a later refactor. Grammy's
14
+ * installed transformers are anonymous fns (nothing to grip at runtime), so
15
+ * this is a source-level AST assertion on the boot path — the same approach
16
+ * `gateway-bot-construction-deferral.test.ts` uses.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import { readFileSync } from 'node:fs'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { dirname, resolve } from 'node:path'
22
+ import ts from 'typescript'
23
+ import { guardAccidentalFormatting } from '../rich-send.js'
24
+ import { guardAccidentalHeading } from '../render/line-start-guard.js'
25
+
26
+ describe('accidental-heading guard: #{1,6} cap is intentional (F2)', () => {
27
+ it('escapes a 6-# run glued to non-space (upper bound of the cap)', () => {
28
+ expect(guardAccidentalHeading('######x')).toBe('\\######x')
29
+ expect(guardAccidentalFormatting('######x')).toBe('\\######x')
30
+ })
31
+
32
+ it('leaves a 7-# run glued to non-space UNescaped (past the CommonMark cap)', () => {
33
+ expect(guardAccidentalHeading('#######x')).toBe('#######x')
34
+ expect(guardAccidentalFormatting('#######x')).toBe('#######x')
35
+ })
36
+
37
+ it('leaves an 8+-# run glued to non-space UNescaped', () => {
38
+ expect(guardAccidentalHeading('##########x')).toBe('##########x')
39
+ expect(guardAccidentalFormatting('##########x')).toBe('##########x')
40
+ })
41
+ })
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url))
44
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
45
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
46
+ const sourceFile = ts.createSourceFile(
47
+ GATEWAY_PATH,
48
+ GATEWAY_SRC,
49
+ ts.ScriptTarget.Latest,
50
+ true,
51
+ ts.ScriptKind.TS,
52
+ )
53
+
54
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
55
+ for (const s of sourceFile.statements) {
56
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ function countCallsTo(root: ts.Node, name: string): number {
62
+ let count = 0
63
+ const visit = (node: ts.Node): void => {
64
+ if (
65
+ ts.isCallExpression(node) &&
66
+ ts.isIdentifier(node.expression) &&
67
+ node.expression.text === name
68
+ ) {
69
+ count++
70
+ // Installed on the constructed bot instance.
71
+ expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
72
+ }
73
+ ts.forEachChild(node, visit)
74
+ }
75
+ visit(root)
76
+ return count
77
+ }
78
+
79
+ describe('boot wiring: installRichMarkdownGuard is installed on the production Bot', () => {
80
+ it('imports installRichMarkdownGuard from ../shared/bot-runtime.js', () => {
81
+ // The import must exist for the boot call to resolve; a refactor that drops
82
+ // the import would break the seam.
83
+ expect(GATEWAY_SRC).toMatch(
84
+ /import\s*\{[^}]*\binstallRichMarkdownGuard\b[^}]*\}\s*from\s*'\.\.\/shared\/bot-runtime\.js'/,
85
+ )
86
+ })
87
+
88
+ it('calls installRichMarkdownGuard(bot) exactly once inside initGatewayBot()', () => {
89
+ const fn = findFunction('initGatewayBot')
90
+ expect(fn?.body).toBeDefined()
91
+ expect(countCallsTo(fn!.body!, 'installRichMarkdownGuard')).toBe(1)
92
+ })
93
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Truth-pass pin (#3252 formatting-truth follow-up): the REAL wire outcome for
3
+ * a `__…__` run.
4
+ *
5
+ * The parser folds `__x__` into a distinct `underline` IR node and the renderer
6
+ * round-trips it back to `__x__` (asserted in parse.test.ts / render.test.ts).
7
+ * But Telegram's rich-message markdown has NO underline token: the wire renders
8
+ * `__x__` identically to `**x__` — i.e. as BOLD (live-verified 2026-07, see
9
+ * reference/telegram-formatting-guide.md).
10
+ *
11
+ * Decision (documented in the fmt-audit BUILD-LOG): we keep the underline node
12
+ * and faithfully preserve the author's `__` bytes rather than rewriting them to
13
+ * `**` — full removal would invert several green tests and change wire bytes for
14
+ * zero wire-visible benefit (both render as bold). This test pins the honest
15
+ * contract: `__x__` emits `__x__`, which the wire treats as bold, NOT a distinct
16
+ * underline style.
17
+ */
18
+ import { describe, it, expect } from "vitest";
19
+ import { parse } from "../../render/parse.js";
20
+ import { render } from "../../render/render.js";
21
+
22
+ describe("underline: real wire outcome for `__…__`", () => {
23
+ it("round-trips `__x__` to `__x__` on the wire (which Telegram renders as BOLD)", () => {
24
+ // The emitted bytes preserve the author's `__` delimiters verbatim.
25
+ expect(render(parse("__x__"))).toBe("__x__");
26
+ });
27
+
28
+ it("does NOT rewrite `__x__` to `**x**` (author bytes preserved, not normalised to bold syntax)", () => {
29
+ expect(render(parse("__underlined__"))).not.toContain("**");
30
+ expect(render(parse("__underlined__"))).toBe("__underlined__");
31
+ });
32
+ });