switchroom 0.19.7 → 0.19.9

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,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
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Wire-level outcome tests for the universal accidental-formatting guard
3
+ * (`installRichMarkdownGuard`, #3252/#3463 follow-up).
4
+ *
5
+ * These MUST go through a REAL grammy `Bot` with a stubbed transport. The
6
+ * existing unit/golden harnesses (`tests/bot-api.harness.ts` etc.) mock the
7
+ * `api` object ABOVE the grammy transformer layer, so `api.config.use`
8
+ * transformers never run there — a test written through them is a FALSE guard
9
+ * (it stays green with the transformer absent or mis-gated). By stubbing
10
+ * `client.fetch` we capture the exact serialized wire body AFTER the
11
+ * transformer has mutated the raw payload, which is the only place the guard's
12
+ * effect is observable.
13
+ *
14
+ * Red-team F-R1: the markdown lives at `payload.rich_message.markdown`, NOT
15
+ * `payload.markdown`. A guard gating on the latter matches nothing and every
16
+ * one of these assertions would still fail — so these tests pin the correct
17
+ * field shape too.
18
+ */
19
+ import { describe, it, expect } from 'vitest'
20
+ import { Bot } from 'grammy'
21
+ import { installTgPostLogger, installRichMarkdownGuard } from '../shared/bot-runtime.js'
22
+
23
+ interface CapturedCall {
24
+ method: string
25
+ body: Record<string, unknown>
26
+ }
27
+
28
+ /**
29
+ * Build a real grammy Bot whose transport is stubbed: every API call is
30
+ * captured (method + parsed JSON body) and answered with a minimal ok
31
+ * response. `botInfo` is supplied so `bot.init()` never hits the network.
32
+ */
33
+ function makeCapturingBot(): { bot: Bot; calls: CapturedCall[] } {
34
+ const calls: CapturedCall[] = []
35
+ const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
36
+ const method = String(url).split('/').pop() ?? ''
37
+ let body: Record<string, unknown> = {}
38
+ if (typeof init?.body === 'string') {
39
+ body = JSON.parse(init.body) as Record<string, unknown>
40
+ }
41
+ calls.push({ method, body })
42
+ // Minimal Telegram ok envelope. `result` shape doesn't matter for these
43
+ // send/edit calls — grammy only reads `ok`/`result`.
44
+ return {
45
+ ok: true,
46
+ status: 200,
47
+ json: async () => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } } }),
48
+ } as unknown as Response
49
+ }) as unknown as typeof fetch
50
+
51
+ const bot = new Bot('123456:TEST_TOKEN', {
52
+ botInfo: {
53
+ id: 123456,
54
+ is_bot: true,
55
+ first_name: 'Test',
56
+ username: 'test_bot',
57
+ can_join_groups: false,
58
+ can_read_all_group_messages: false,
59
+ supports_inline_queries: false,
60
+ can_connect_to_business: false,
61
+ has_main_web_app: false,
62
+ },
63
+ client: { fetch: fakeFetch },
64
+ })
65
+ // Install exactly the production transformer stack ordering: logger first,
66
+ // then guard (guard composes outermost — grammy runs last-installed first).
67
+ installTgPostLogger(bot)
68
+ installRichMarkdownGuard(bot)
69
+ return { bot, calls }
70
+ }
71
+
72
+ function lastRichMarkdown(calls: CapturedCall[]): unknown {
73
+ const c = calls[calls.length - 1]
74
+ return (c.body.rich_message as { markdown?: unknown } | undefined)?.markdown
75
+ }
76
+
77
+ describe('installRichMarkdownGuard — universal wire seam', () => {
78
+ it('(a) escapes a raw { markdown } sendRichMessage on the wire', async () => {
79
+ const { bot, calls } = makeCapturingBot()
80
+ await bot.api.sendRichMessage(1, { markdown: '#3460 done' })
81
+ expect(calls[calls.length - 1].method).toBe('sendRichMessage')
82
+ expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
83
+ })
84
+
85
+ it('(b) escapes an editMessageText object-arg { markdown } on the wire', async () => {
86
+ const { bot, calls } = makeCapturingBot()
87
+ await bot.api.editMessageText(1, 42, { markdown: '#3460 done' })
88
+ expect(calls[calls.length - 1].method).toBe('editMessageText')
89
+ expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
90
+ })
91
+
92
+ it('(c) leaves a literalText / string-arg edit UNTOUCHED (carries text, not rich_message)', async () => {
93
+ const { bot, calls } = makeCapturingBot()
94
+ await bot.api.editMessageText(1, 42, '#3460 done')
95
+ const c = calls[calls.length - 1]
96
+ expect(c.method).toBe('editMessageText')
97
+ expect(c.body.text).toBe('#3460 done')
98
+ expect(c.body.rich_message).toBeUndefined()
99
+ })
100
+
101
+ it('(d) leaves a genuine heading byte-identical', async () => {
102
+ const { bot, calls } = makeCapturingBot()
103
+ await bot.api.sendRichMessage(1, { markdown: '# Title\n## Sub' })
104
+ expect(lastRichMarkdown(calls)).toBe('# Title\n## Sub')
105
+ })
106
+
107
+ it('(e) is idempotent on an already-guarded (richMessage-wrapped) body', async () => {
108
+ const { bot, calls } = makeCapturingBot()
109
+ // Simulate a body that already went through richMessage()'s internal guard.
110
+ await bot.api.sendRichMessage(1, { markdown: '\\#3460 done' })
111
+ expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
112
+ })
113
+
114
+ it('does not mutate the caller\'s shared input object', async () => {
115
+ const { bot } = makeCapturingBot()
116
+ const input = { markdown: '#3460 done' }
117
+ await bot.api.sendRichMessage(1, input)
118
+ // Caller's object is unchanged; only the cloned wire payload is escaped.
119
+ expect(input.markdown).toBe('#3460 done')
120
+ })
121
+ })