switchroom 0.19.7 → 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 +22 -4
- 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
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.19.
|
|
2123
|
+
var VERSION = "0.19.8", COMMIT_SHA = "c356d3ba";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -23861,7 +23861,8 @@ function getBuiltinDefaultSkillEntries() {
|
|
|
23861
23861
|
"switchroom-health",
|
|
23862
23862
|
"switchroom-runtime",
|
|
23863
23863
|
"mental-model-curator",
|
|
23864
|
-
"dev-protocol"
|
|
23864
|
+
"dev-protocol",
|
|
23865
|
+
"telegram-formatting"
|
|
23865
23866
|
];
|
|
23866
23867
|
return [
|
|
23867
23868
|
...anthropic.map((key) => ({ key, optOutKey: key, source: "anthropic" })),
|
|
@@ -29440,6 +29441,11 @@ right answer at all. Structure exists for the reader, not the writer: a two-item
|
|
|
29440
29441
|
bullet list is worse than a sentence, a heading on a three-line reply is noise. When
|
|
29441
29442
|
in doubt, shorter and plainer wins.
|
|
29442
29443
|
|
|
29444
|
+
Full palette when a rich or long message earns it \u2014 expandable blockquotes, spoilers,
|
|
29445
|
+
highlight, code-fence language hints, tables, escaping and chunking rules: load the
|
|
29446
|
+
\`telegram-formatting\` skill. Reach for it only when you're actually composing that
|
|
29447
|
+
message, never for everyday replies.
|
|
29448
|
+
|
|
29443
29449
|
Every turn that answers a user message ends with a user-visible \`reply\`
|
|
29444
29450
|
\u2014 Telegram is all the user sees; your terminal output
|
|
29445
29451
|
never reaches them.`, TELEGRAM_ENV_PLACEHOLDER = `# Set your bot token: TELEGRAM_BOT_TOKEN=your-token-here
|
|
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26663
26663
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26664
26664
|
|
|
26665
26665
|
// src/build-info.ts
|
|
26666
|
-
var VERSION = "0.19.
|
|
26666
|
+
var VERSION = "0.19.8";
|
|
26667
26667
|
|
|
26668
26668
|
// src/cli/resolve-version.ts
|
|
26669
26669
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.19.
|
|
4
|
+
"version": "0.19.8",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -72,11 +72,11 @@ The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each
|
|
|
72
72
|
|
|
73
73
|
### When to synthesize — concrete triggers
|
|
74
74
|
|
|
75
|
-
Auto-recall and auto-retain
|
|
75
|
+
Auto-recall and auto-retain feed the bank but never *synthesize* — that's on you, only if you act on these triggers. Each has a backstop:
|
|
76
76
|
|
|
77
|
-
- **Reflect instead of hand-assembling.**
|
|
78
|
-
- **Propose a model when you keep re-deriving.**
|
|
79
|
-
- **Merge or retire directives when they pile up.** Directives
|
|
77
|
+
- **Reflect instead of hand-assembling.** About to fire 2+ manual `recall`s for one answer ("summarize where Y stands")? Call `mcp__hindsight__reflect` instead. (Backstop: auto-recall injects the top hits every turn — reflect is the escalation.)
|
|
78
|
+
- **Propose a model when you keep re-deriving.** Rebuilt the *same standing answer* across sessions? Propose a mental model via `mcp__switchroom-telegram__mental_model_propose(name, source_query)` (or run the `mental-model-curator` skill). Not for a one-off fact (`retain`) or identity (profile banks own that).
|
|
79
|
+
- **Merge or retire directives when they pile up.** Directives cap at `MAX_DIRECTIVES=15` active per bank — past that the lowest-priority ones drop silently from recall. When they overlap or read stale, run the `mental-model-curator` merge/retire pass (deletes stay operator-approved). (Backstop: `switchroom doctor` WARNs at >12, FAILs at >15.)
|
|
80
80
|
|
|
81
81
|
## Sub-Agent Delegation
|
|
82
82
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: telegram-formatting
|
|
3
|
+
description: >
|
|
4
|
+
Use when composing a rich or long Telegram reply and you want the full
|
|
5
|
+
formatting palette with exact syntax — expandable blockquotes, spoilers,
|
|
6
|
+
highlight, code-fence language hints, GFM tables, nested lists — plus the
|
|
7
|
+
escaping rules and the framework's send-time chunking/normalizer behaviour.
|
|
8
|
+
Load it when a message genuinely needs structure, NOT for everyday short
|
|
9
|
+
replies (plain prose already wins there). Teaches judgment first: which
|
|
10
|
+
construct helps the reader vs when plain text is better. Do NOT use for
|
|
11
|
+
deciding whether to reply, or for non-Telegram output.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Telegram formatting — the full palette
|
|
15
|
+
|
|
16
|
+
Every outbound Switchroom message renders as raw GFM markdown over Telegram
|
|
17
|
+
Bot API 10.1 rich messages (`telegram-plugin/rich-send.ts` `richMessage(md)` →
|
|
18
|
+
`{ markdown }` → `sendRichMessage` / `editMessageText({ markdown })`). No HTML,
|
|
19
|
+
no `parse_mode`. This skill is the depth reference behind the boot-injected
|
|
20
|
+
floor card: the full construct vocabulary, correct syntax, escaping, and the
|
|
21
|
+
send-time behaviour you can rely on.
|
|
22
|
+
|
|
23
|
+
## Judgment first — reach for structure only when it helps the reader
|
|
24
|
+
|
|
25
|
+
The floor card's stance is the law here too: **structure exists for the reader,
|
|
26
|
+
not the writer.** Loading this skill does not mean "use everything below." Match
|
|
27
|
+
the construct to the message.
|
|
28
|
+
|
|
29
|
+
- **Short answers (a line or two): plain prose, no formatting.** "on it,
|
|
30
|
+
pulling the logs now" is already perfect. No bold, no bullets, no headings.
|
|
31
|
+
Most replies live here — don't dress them up.
|
|
32
|
+
- **Default: light structure.** Bold ONLY the one key fact or answer, never
|
|
33
|
+
more. A list only for 3+ genuinely parallel items the reader will scan or
|
|
34
|
+
compare; two items or a flowing thought stay prose. `code spans` for
|
|
35
|
+
identifiers (filenames, commands, config keys, error codes) — tap-to-copy.
|
|
36
|
+
- **Long / multi-section answers may add the rich constructs below** — tables,
|
|
37
|
+
headings, blockquotes, expandable blocks, fences — but only when they cut the
|
|
38
|
+
reader's effort. If the structure doesn't reduce scanning effort, drop it. A
|
|
39
|
+
two-item bullet list is worse than a sentence; a heading on a three-line reply
|
|
40
|
+
is noise. When in doubt, shorter and plainer wins.
|
|
41
|
+
|
|
42
|
+
Over-bolded messages (most of the text bold, whole paragraphs/lists bolded) get
|
|
43
|
+
their bold stripped at send time — so bold sparingly and deliberately.
|
|
44
|
+
|
|
45
|
+
## Full rich vocabulary
|
|
46
|
+
|
|
47
|
+
### Inline spans
|
|
48
|
+
|
|
49
|
+
| Effect | Markdown | When / notes |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| Bold | `**text**` | The one key fact or answer, not decoration. |
|
|
52
|
+
| Italic | `*text*` or `_text_` | Light emphasis, labels, asides. |
|
|
53
|
+
| Strikethrough | `~~text~~` | Retractions, "was X now Y". |
|
|
54
|
+
| Spoiler | `\|\|text\|\|` | Only for an opt-in surprise or a reveal the reader chose to wait for (a punchline they want suspended) — NEVER to hide an answer someone is asking for or anxious about; when in doubt, show it plainly. Surfaces as a `spoiler` entity on the wire (live-verified 2026-07). |
|
|
55
|
+
| Highlight / marked | `==text==` | Surfaces as a `marked` entity on the wire (live-verified 2026-07). `=` is an `escapeMarkdown` special, so dynamic text won't trigger it by accident. |
|
|
56
|
+
| Inline code | `` `text` `` | Identifiers, tap-to-copy. Content is literal — no escaping inside. |
|
|
57
|
+
| Link | `[label](https://…)` | Standard GFM link. |
|
|
58
|
+
|
|
59
|
+
**Do NOT rely on these — they don't render as intended:**
|
|
60
|
+
|
|
61
|
+
- **Underline** — there is NO underline token on this path. `__text__` renders
|
|
62
|
+
as **bold** (Telegram's rich-message markdown parser reads a `__…__` run
|
|
63
|
+
identically to `**…**`, live-verified against the Bot API 2026-07). Use `**`
|
|
64
|
+
for bold and don't reach for underline.
|
|
65
|
+
- **Subscript** `~text~` (single tilde) and **superscript** `^text^` fall back
|
|
66
|
+
to literal text in rich messages — avoid (write "squared", not `x^2^`).
|
|
67
|
+
- **Custom emoji** (premium custom-emoji entity) renders as a normal emoji for
|
|
68
|
+
non-premium viewers — don't rely on it to carry meaning.
|
|
69
|
+
|
|
70
|
+
(Inline math `$…$`, HTML `<details>`/collapsible, and footnotes `[^1]` are NOT
|
|
71
|
+
supported on this path — do not emit them; they degrade to literal or neutralised
|
|
72
|
+
text.)
|
|
73
|
+
|
|
74
|
+
### Block types
|
|
75
|
+
|
|
76
|
+
- **Code fence** — ` ```lang ` … ` ``` `. Multi-line literal output (diffs,
|
|
77
|
+
logs, JSON, command blocks). The language hint (`diff`, `json`, `bash`, …)
|
|
78
|
+
sharpens syntax rendering — use it. Content inside is verbatim, never escape
|
|
79
|
+
it; the only hazard is an embedded ` ``` ` closing the block early, which the
|
|
80
|
+
framework defuses (`preBlock` in `shared/bot-runtime.ts`).
|
|
81
|
+
- **Preformatted block** — a code fence with NO language, for fixed-width
|
|
82
|
+
non-code (ASCII tables, aligned columns).
|
|
83
|
+
- **Bulleted list** — `- item` (also `*` / `+`). 3+ parallel items only.
|
|
84
|
+
- **Numbered list** — `1. item`. Ordered steps or ranked items.
|
|
85
|
+
- **Nested lists** — indent sub-items; tight (no blank lines) vs loose (blank
|
|
86
|
+
lines between items) both render. 3-level nesting is live-verified.
|
|
87
|
+
- **Task list** — `- [ ] todo` / `- [x] done`.
|
|
88
|
+
- **Table** — GFM pipe table (`| col | col |` + `| --- | --- |` separator),
|
|
89
|
+
optional per-column alignment (`:---`, `:---:`, `---:`). 2-D data ONLY (rows ×
|
|
90
|
+
columns) — not a substitute for prose. Chunk-safe: `splitMarkdownChunks` never
|
|
91
|
+
bisects a row.
|
|
92
|
+
- **Blockquote** — `> quoted`. Quoted text or an indented continuation; the
|
|
93
|
+
right way to indent, because Telegram drops leading whitespace.
|
|
94
|
+
- **Expandable blockquote** — `**> …` (Bot API 10.1). A long quote/aside the
|
|
95
|
+
reader can collapse and expand. The flagship rich construct — use it for a
|
|
96
|
+
long quotation, a stack trace, or a detailed aside you don't want dominating
|
|
97
|
+
the message. First line carries the `**> ` marker; continuation lines use `> `.
|
|
98
|
+
- **Section heading** — `#` … `######`. Only in a genuinely long, multi-section
|
|
99
|
+
answer. Never on a short reply.
|
|
100
|
+
- **Divider** — `---` (thematic break). Heavy horizontal rule between genuinely
|
|
101
|
+
separate sections. Use sparingly.
|
|
102
|
+
- **Collage / album** — multiple images grouped in one message (media group).
|
|
103
|
+
Send via the attachment path, not markdown.
|
|
104
|
+
|
|
105
|
+
## Escaping rules
|
|
106
|
+
|
|
107
|
+
Dynamic content (filenames, ids, arbitrary user text) interpolated into a
|
|
108
|
+
hand-built markdown card MUST be escaped so it renders LITERALLY instead of
|
|
109
|
+
being parsed as formatting. Use `escapeMarkdown(value)` from
|
|
110
|
+
`telegram-plugin/format.ts`.
|
|
111
|
+
|
|
112
|
+
`escapeMarkdown` escapes exactly the characters that trigger INLINE formatting:
|
|
113
|
+
backslash, `` ` ``, `*`, `_`, `~`, `=`, `[`, `]`, `|` — the set `` \`*_~=[]| ``.
|
|
114
|
+
The backslash is escaped first so it never double-escapes a following special.
|
|
115
|
+
It deliberately does **not** escape `.` `-` `+` `#` `(` `)` `{` `}` `!` `>`:
|
|
116
|
+
those are only meaningful at line-start or in link/structure context, and
|
|
117
|
+
escaping them mid-word would litter filenames (`foo.ts`), versions (`v1.2-rc`),
|
|
118
|
+
and URLs with visible backslashes.
|
|
119
|
+
|
|
120
|
+
- **Bold/italic a dynamic value:** `` `**${escapeMarkdown(value)}**` ``.
|
|
121
|
+
- **Code-span a dynamic value:** `` `\`${value}\`` `` — code spans need NO
|
|
122
|
+
escaping (backtick content is already literal). This is the preferred, safest
|
|
123
|
+
way to render any identifier.
|
|
124
|
+
|
|
125
|
+
## Send-time behaviour you can rely on
|
|
126
|
+
|
|
127
|
+
- **Chunking.** Hard cap is `RICH_MESSAGE_MAX_CHARS = 32768` (32768 accepted,
|
|
128
|
+
32769 rejected — the single constant, never re-derive it). A longer body is
|
|
129
|
+
split by `splitMarkdownChunks(text, 32768)` in `format.ts`: it cuts at the
|
|
130
|
+
largest safe boundary (blank line → newline → space), **never bisects a fenced
|
|
131
|
+
code block** (`backOffOpenFence`) and **never bisects a table row**
|
|
132
|
+
(`backOffTableRow`). A single indivisible region larger than the cap is
|
|
133
|
+
emitted whole and re-split / hard-sliced at send time rather than hanging.
|
|
134
|
+
Long before 32768, ask whether a wall of text is the right answer at all.
|
|
135
|
+
- **Typography normalizer (deterministic, every message).** Block spacing (one
|
|
136
|
+
blank line between distinct blocks), em/en dashes, and `•` bullet markers are
|
|
137
|
+
rewritten at send time. A LONE `\n` between two prose paragraphs is promoted
|
|
138
|
+
to a real visual break; runs of 3+ newlines collapse to `\n\n`; lists, tables,
|
|
139
|
+
code, and existing `\n\n` gaps are left exactly as written. Don't hand-tune
|
|
140
|
+
spacing or fight the normalizer — write the content, the gateway makes the
|
|
141
|
+
typography consistent.
|
|
142
|
+
|
|
143
|
+
## The one rule that outranks everything here
|
|
144
|
+
|
|
145
|
+
You loaded this skill to format a rich message well — but the best formatting is
|
|
146
|
+
still the least that serves the reader. Use the palette to make a genuinely
|
|
147
|
+
complex answer scannable, never to decorate a simple one.
|
|
@@ -41612,6 +41612,7 @@ function shouldEmitShadowTrace(eventKind, effectCount, globalKind, verbose = gwT
|
|
|
41612
41612
|
}
|
|
41613
41613
|
|
|
41614
41614
|
// shared/bot-runtime.ts
|
|
41615
|
+
init_rich_send();
|
|
41615
41616
|
var tgPostTagStore = new AsyncLocalStorage;
|
|
41616
41617
|
function escapeHtmlForTg(text) {
|
|
41617
41618
|
return text.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
@@ -62511,6 +62512,7 @@ import { createHash as createHash2 } from "crypto";
|
|
|
62511
62512
|
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
62512
62513
|
init_flood_circuit_breaker();
|
|
62513
62514
|
init_format();
|
|
62515
|
+
init_rich_send();
|
|
62514
62516
|
var tgPostTagStore2 = new AsyncLocalStorage2;
|
|
62515
62517
|
function _getTgPostTags() {
|
|
62516
62518
|
return tgPostTagStore2.getStore();
|
|
@@ -62555,6 +62557,21 @@ function installTgPostLogger(bot) {
|
|
|
62555
62557
|
}
|
|
62556
62558
|
});
|
|
62557
62559
|
}
|
|
62560
|
+
function installRichMarkdownGuard(bot) {
|
|
62561
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
62562
|
+
if ((method === "sendRichMessage" || method === "editMessageText") && payload != null) {
|
|
62563
|
+
const p = payload;
|
|
62564
|
+
const rich = p.rich_message;
|
|
62565
|
+
if (rich != null && typeof rich.markdown === "string") {
|
|
62566
|
+
const guarded = guardAccidentalFormatting(rich.markdown);
|
|
62567
|
+
if (guarded !== rich.markdown) {
|
|
62568
|
+
p.rich_message = { ...rich, markdown: guarded };
|
|
62569
|
+
}
|
|
62570
|
+
}
|
|
62571
|
+
}
|
|
62572
|
+
return prev(method, payload, signal);
|
|
62573
|
+
});
|
|
62574
|
+
}
|
|
62558
62575
|
|
|
62559
62576
|
// flood-circuit-breaker.ts
|
|
62560
62577
|
import {
|
|
@@ -93551,10 +93568,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
|
|
|
93551
93568
|
}
|
|
93552
93569
|
|
|
93553
93570
|
// ../src/build-info.ts
|
|
93554
|
-
var VERSION = "0.19.
|
|
93555
|
-
var COMMIT_SHA = "
|
|
93556
|
-
var COMMIT_DATE = "2026-07-
|
|
93557
|
-
var LATEST_PR =
|
|
93571
|
+
var VERSION = "0.19.8";
|
|
93572
|
+
var COMMIT_SHA = "c356d3ba";
|
|
93573
|
+
var COMMIT_DATE = "2026-07-20T23:16:37Z";
|
|
93574
|
+
var LATEST_PR = 3483;
|
|
93558
93575
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
93559
93576
|
|
|
93560
93577
|
// gateway/boot-version.ts
|
|
@@ -106426,6 +106443,7 @@ async function initGatewayBot() {
|
|
|
106426
106443
|
}
|
|
106427
106444
|
bot = new import_grammy15.Bot(TOKEN);
|
|
106428
106445
|
installTgPostLogger(bot);
|
|
106446
|
+
installRichMarkdownGuard(bot);
|
|
106429
106447
|
installUpdateTap(bot, (line) => process.stderr.write(line));
|
|
106430
106448
|
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
106431
106449
|
try {
|
|
@@ -295,7 +295,7 @@ import {
|
|
|
295
295
|
} from '../retry-api-call.js'
|
|
296
296
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
297
297
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
298
|
-
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
298
|
+
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
299
299
|
import {
|
|
300
300
|
floodStatePath,
|
|
301
301
|
floodWindowsPath,
|
|
@@ -22970,7 +22970,7 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22970
22970
|
}
|
|
22971
22971
|
|
|
22972
22972
|
bot = new Bot(TOKEN)
|
|
22973
|
-
installTgPostLogger(bot)
|
|
22973
|
+
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22974
22974
|
|
|
22975
22975
|
// Diagnostic update tap (#3300): one compact line per received update, logged
|
|
22976
22976
|
// BEFORE any specific handler runs, so a routing-layer drop is diagnosable
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
})
|