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.
- package/dist/auth-broker/index.js +9 -8
- package/dist/cli/switchroom.js +903 -695
- package/dist/host-control/main.js +15 -14
- package/dist/vault/approvals/kernel-server.js +5 -4
- package/dist/vault/broker/server.js +9 -8
- 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 +30 -11
- package/telegram-plugin/gateway/gateway.ts +2 -2
- 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
|
@@ -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
|
+
});
|
|
@@ -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
|
+
})
|