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.
- package/dist/cli/switchroom.js +8 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -4
- package/skills/telegram-formatting/SKILL.md +147 -0
- package/telegram-plugin/dist/gateway/gateway.js +124 -15
- package/telegram-plugin/gateway/gateway.ts +10 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +55 -16
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -8
- package/telegram-plugin/gateway/subagent-handback-marker.ts +198 -19
- package/telegram-plugin/render/ir.ts +34 -26
- package/telegram-plugin/render/render.ts +12 -3
- package/telegram-plugin/rich-send.ts +16 -10
- package/telegram-plugin/shared/bot-runtime.ts +57 -0
- package/telegram-plugin/tests/format-guard-pins.test.ts +93 -0
- package/telegram-plugin/tests/render/underline-wire-outcome.test.ts +32 -0
- package/telegram-plugin/tests/rich-markdown-guard-transformer.test.ts +121 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -7
- package/telegram-plugin/tests/stream-render-golden.test.ts +140 -4
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +143 -14
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-chat marker of the most recent gateway-synthesized
|
|
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,
|
|
8
|
-
* ONE deterministic signal that distinguishes the two late-reply cases
|
|
9
|
-
* resolve a flush-delivered ENDED turn via the latest-ended tier — 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,
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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;
|
|
5
|
-
//
|
|
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
|
-
//
|
|
17
|
+
// IR node -> emitted GFM markdown (see render.ts `renderInline`/block render):
|
|
14
18
|
//
|
|
15
19
|
// Inline
|
|
16
|
-
// plain ->
|
|
17
|
-
// bold ->
|
|
18
|
-
// italic ->
|
|
19
|
-
// underline ->
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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 ->
|
|
29
|
-
// blockquote ->
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
* GFM/micromark folds both into a single `strong` mdast node.
|
|
66
|
-
*
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// wire format is GFM markdown, NOT HTML
|
|
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
|
|
71
|
-
*
|
|
72
|
-
* answer, draft-stream previews, cards,
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
+
});
|