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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.7", COMMIT_SHA = "2ddec4ae";
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.7";
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.7",
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 keep the bank fed, but they never *synthesize* — that's on you, and it only happens if you act on these triggers. Each is paired with a deterministic backstop so nothing depends on you remembering:
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.** When a question needs an answer *across many past memories* — "what did we decide about X", "summarize where Y stands", "have I seen this before" — call `mcp__hindsight__reflect` rather than firing several `recall`s and stitching the fragments yourself. Rule of thumb: if you're about to make 2+ manual `recall` calls to reconstruct one answer, that's a `reflect`. (Backstop: auto-recall already injects the top hits every turn — reflect is the escalation when those aren't enough, not a replacement.)
78
- - **Propose a model when you keep re-deriving.** When you notice you've rebuilt the *same standing answer* two or three sessions running — the recurring state of your specialty a user keeps asking around — stop re-deriving and propose a mental model via `mcp__switchroom-telegram__mental_model_propose(name, source_query)` (or run the `mental-model-curator` skill to survey the bank and propose the earned few). One organic proposal beats ten reflexive recalls. Don't propose for a one-off fact — that's a `retain`; don't propose identity/"who is the user" — profile banks own that.
79
- - **Merge or retire directives when they pile up.** Directives are capped at `MAX_DIRECTIVES=15` active per bank — past that, the lowest-priority ones are silently truncated from the `<active_directives>` recall block and never reach you. When directives start overlapping or reading stale, run the `mental-model-curator` skill's directive merge/retire pass (it proposes; deletes stay operator-approved). (Backstop: `switchroom doctor` WARNs at >12 and FAILs at >15 active directives, so the pile-up surfaces on the issues card even if you don't notice it in-session.)
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.7";
93555
- var COMMIT_SHA = "2ddec4ae";
93556
- var COMMIT_DATE = "2026-07-20T19:59:43Z";
93557
- var LATEST_PR = 3478;
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 HTML render engine.
1
+ // Typed intermediate representation (IR) for the Telegram rich-markdown render
2
+ // engine. (Historical note: this file and render.ts were named for an "HTML
3
+ // render engine" during Increment 1, before the Bot API 10.1 migration (#2669)
4
+ // made GFM `{ markdown }` the live send path. There is NO HTML anywhere on the
5
+ // outbound path today — the renderer in render.ts emits raw GFM markdown for
6
+ // the `markdown` field of `InputRichMessageMarkdown`.)
2
7
  //
3
8
  // This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
4
- // mdast tree into this shape; a later increment's renderer walks it and emits
5
- // Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
6
- // is no renderer yet.
9
+ // mdast tree into this shape; `render.ts` walks it and emits Telegram
10
+ // rich-message GFM markdown.
7
11
  //
8
12
  // Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
9
13
  // from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
10
14
  // code-unit indices into the original markdown string, so
11
15
  // `source.slice(node.start, node.end)` round-trips to the node's source text.
12
16
  //
13
- // Telegram HTML tag mapping (for the next increment NOT implemented here):
17
+ // IR node -> emitted GFM markdown (see render.ts `renderInline`/block render):
14
18
  //
15
19
  // Inline
16
- // plain -> (raw text, HTML-escaped)
17
- // bold -> <b>…</b> (markdown `**…**`)
18
- // italic -> <i>…</i> (markdown `*…*`)
19
- // underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
20
- // strike -> <s>…</s> (markdown `~~…~~`)
21
- // spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
22
- // highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
23
- // code -> <code>…</code>
24
- // link -> <a href="…">…</a>
20
+ // plain -> raw text (escapeMarkdown'd)
21
+ // bold -> `**…**`
22
+ // italic -> `*…*`
23
+ // underline -> `__…__` — NOTE: the wire renders `__…__` as BOLD, not
24
+ // underline. Telegram's rich-message markdown has no underline
25
+ // token (live-verified, see reference/telegram-formatting-guide.md).
26
+ // The node preserves the author's `__` bytes faithfully; it is
27
+ // a distinct IR node but NOT a distinct wire style.
28
+ // strike -> `~~…~~`
29
+ // spoiler -> `||…||`
30
+ // highlight -> `==…==` (Bot API 10.1 marked entity)
31
+ // code -> `` `…` ``
32
+ // link -> `[…](…)`
25
33
  //
26
34
  // Block
27
35
  // paragraph -> children joined; blocks separated by "\n\n"
28
- // heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
29
- // blockquote -> <blockquote>…</blockquote>
30
- // (expandable === true -> <blockquote expandable>)
31
- // code-block -> <pre><code class="language-…">…</code></pre>
32
- // list -> rendered line-per-item with "•"/"1." bullets
33
- // (Telegram HTML has no <ul>/<ol>)
34
- // thematic-break -> a horizontal-rule text line (e.g. "───")
35
- // table -> monospaced <pre> table (Telegram HTML has no <table>)
36
+ // heading -> `#`…`######` line
37
+ // blockquote -> `> …` (expandable === true -> `**> …` expandable blockquote)
38
+ // code-block -> ```` ```lang ``` ````
39
+ // list -> line-per-item with `-`/`1.` markers
40
+ // thematic-break -> `---` thematic break
41
+ // table -> GFM pipe table
36
42
 
37
43
  export interface Pos {
38
44
  /** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
@@ -60,10 +66,12 @@ export interface ItalicNode extends Pos {
60
66
  children: Inline[];
61
67
  }
62
68
 
63
- /** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
64
- * double-underscore run is UNDERLINE distinct from `**…**` bold, even though
65
- * GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
66
- * disambiguates the two by looking at the run's source delimiter. */
69
+ /** A `__…__` double-underscore run. `parse.ts` keeps it as a distinct node
70
+ * (separate from `**…**` bold) by looking at the source delimiter, even though
71
+ * GFM/micromark folds both into a single `strong` mdast node. NOTE: on the
72
+ * Telegram wire this renders as BOLD, not a distinct underline style — Bot API
73
+ * 10.1 rich markdown has no underline entity here, so the round-trip is faithful
74
+ * but the delivered text is bold. Kept distinct only to preserve authoring intent. */
67
75
  export interface UnderlineNode extends Pos {
68
76
  type: "underline";
69
77
  children: Inline[];
@@ -1,6 +1,7 @@
1
- // IR -> Telegram rich-markdown renderer for the Telegram HTML render engine
2
- // (name kept for continuity with render/ir.ts + render/parse.ts; the actual
3
- // wire format is GFM markdown, NOT HTML see the note below).
1
+ // IR -> Telegram rich-markdown renderer. (The render/ir.ts + render/parse.ts
2
+ // files were originally named for an "HTML render engine"; that name is
3
+ // historical — the actual wire format is GFM markdown, NOT HTML. See the note
4
+ // below and the refreshed header in render/ir.ts.)
4
5
  //
5
6
  // Increment 2 of the render pipeline: takes the typed IR produced by
6
7
  // `parse.ts` (per `ir.ts`) and emits a string suitable for the `markdown`
@@ -67,6 +68,11 @@ function renderInline(node: Inline, ctx: InlineCtx = {}): string {
67
68
  case "italic":
68
69
  return `*${renderInlineChildren(node.children, ctx)}*`;
69
70
  case "underline":
71
+ // The wire renders `__…__` as BOLD, not underline — Telegram's
72
+ // rich-message markdown has no underline token (live-verified; see
73
+ // reference/telegram-formatting-guide.md). We preserve the author's `__`
74
+ // bytes faithfully rather than rewriting them to `**`; the IR keeps
75
+ // underline as a distinct node, but it is NOT a distinct wire style.
70
76
  return `__${renderInlineChildren(node.children, ctx)}__`;
71
77
  case "strike":
72
78
  return `~~${renderInlineChildren(node.children, ctx)}~~`;
@@ -284,6 +290,9 @@ export const SUPPORTED_INLINE = [
284
290
  "plain",
285
291
  "bold",
286
292
  "italic",
293
+ // "underline" parses `__…__` into a distinct node and round-trips it, but the
294
+ // wire renders it as BOLD (no underline token on this path). Kept for faithful
295
+ // `__` byte round-trip, NOT because it is a distinct rendered style.
287
296
  "underline",
288
297
  "strike",
289
298
  "spoiler",
@@ -67,16 +67,22 @@ export function guardAccidentalFormatting(markdown: string): string {
67
67
  /**
68
68
  * Wrap raw GFM markdown into the rich-message input object.
69
69
  *
70
- * This is the ONE adapter every `{ markdown }` wire send funnels through
71
- * (`sendRichMessage` / `editMessageText({ markdown })`) the reply-tool final
72
- * answer, draft-stream previews, cards, approvals, banners. It is therefore the
73
- * single deterministic seam for the #3252 accidental-formatting guards (F1):
74
- * applying `guardAccidentalFormatting` here guards EVERY markdown-parsed
75
- * outbound exactly once, without touching `plain`-mode degradations (which
76
- * bypass this wrapper and go straight to `sendMessage`, where no markdown
77
- * parsing happens). The composed guard is a strict no-op for any body without
78
- * an accidental-formatting signal and is idempotent, so callers that already
79
- * ran it (or the streaming path that renders then re-wraps) stay byte-identical.
70
+ * This is a CONVENIENCE adapter NOT the universal seam. It applies
71
+ * `guardAccidentalFormatting` for the many callers that build a body through
72
+ * it (the reply-tool final answer, draft-stream previews, cards), but it is
73
+ * NOT the one place every `{ markdown }` wire send funnels through: several
74
+ * sites build a raw `{ markdown }` and call `sendRichMessage` /
75
+ * `editMessageText` directly, bypassing this wrapper (see the correctness
76
+ * audit's F1 list banners, switchroomReply html, approval/folder-picker
77
+ * edits). The REAL universal seam for the #3252 accidental-formatting guards
78
+ * is the grammy API transformer `installRichMarkdownGuard`
79
+ * (`shared/bot-runtime.ts`), installed on the production Bot in gateway boot,
80
+ * which guards EVERY sendRichMessage/editMessageText payload regardless of the
81
+ * call site. This wrapper is kept belt-and-braces: the composed guard is a
82
+ * strict no-op for any body without an accidental-formatting signal and is
83
+ * idempotent, so a body guarded here and re-guarded by the transformer stays
84
+ * byte-identical. `plain`-mode degradations bypass both (they go straight to
85
+ * `sendMessage`, where no markdown parsing happens).
80
86
  */
81
87
  export function richMessage(markdown: string): InputRichMessageMarkdown {
82
88
  return { markdown: guardAccidentalFormatting(markdown) }
@@ -32,6 +32,7 @@ import { createRetryApiCall } from '../retry-api-call.js'
32
32
  import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
33
33
  import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
34
34
  import { shouldEmitTgPost } from './gw-trace-gate.js'
35
+ import { guardAccidentalFormatting } from '../rich-send.js'
35
36
 
36
37
  // ─── tg-post tag plumbing ─────────────────────────────────────────────────
37
38
 
@@ -147,6 +148,62 @@ export function installTgPostLogger(bot: Bot): void {
147
148
  })
148
149
  }
149
150
 
151
+ /**
152
+ * Universal accidental-formatting guard, installed as a grammy API transformer
153
+ * on the single production Bot (#3252/#3463 follow-up). This is the REAL
154
+ * universal seam — not `richMessage()`. `richMessage()` only guards bodies its
155
+ * callers remember to wrap; the correctness audit found ~6 sites that build a
156
+ * raw `{ markdown }` and call `sendRichMessage` / `editMessageText` directly,
157
+ * bypassing it (`shared/bot-runtime.ts` switchroomReply html path,
158
+ * `slot-banner-driver.ts` OAuth banners, and edits in `folder-picker-handler`,
159
+ * `approval-callback`, `inline-keyboard-callbacks`). A transformer at the
160
+ * grammy `bot.api.config.use` layer sees every rich send regardless of the
161
+ * call site, `ctx.*` sugar, `lockedBot`, or `bot.api.raw`, so it closes the
162
+ * whole bypass class deterministically.
163
+ *
164
+ * Payload shape (verified against grammy 1.44.0 `out/core/api.js`, the pinned
165
+ * lockfile version):
166
+ * - `sendRichMessage(chat_id, rich_message, ...)` → raw payload
167
+ * `{ chat_id, rich_message: { markdown }, ... }`
168
+ * - `editMessageText(chat_id, message_id, arg, ...)` → raw payload
169
+ * `{ ..., rich_message: { markdown } }` when `arg` is an object, or
170
+ * `{ ..., text }` when `arg` is a plain string.
171
+ * The markdown therefore lives at `payload.rich_message.markdown`, NOT
172
+ * `payload.markdown` (gating on the latter matches nothing — a silent no-op).
173
+ * Gating on `rich_message?.markdown` also structurally skips every literal /
174
+ * plain-string edit (they carry `text`, not `rich_message`), so those pass
175
+ * through byte-identical. `sendRichMessageDraft` is not wired in the repo
176
+ * (draft streaming uses sendMessage+editMessageText); extend the method gate
177
+ * here if a future draft adopter starts using it.
178
+ *
179
+ * The composed `guardAccidentalFormatting` is idempotent, so double-guarding a
180
+ * `richMessage()`-wrapped body that also passes through here is byte-identical
181
+ * (the internal guard in `richMessage()` is kept belt-and-braces).
182
+ *
183
+ * We clone `rich_message` before mutating: callers can share the object by
184
+ * reference (e.g. `richMessage()` output reused across a retry), and a
185
+ * transformer must not mutate the caller's input.
186
+ */
187
+ export function installRichMarkdownGuard(bot: Bot): void {
188
+ bot.api.config.use(async (prev, method, payload, signal) => {
189
+ if (
190
+ (method === 'sendRichMessage' || method === 'editMessageText') &&
191
+ payload != null
192
+ ) {
193
+ const p = payload as Record<string, unknown>
194
+ const rich = p.rich_message as { markdown?: unknown } | undefined
195
+ if (rich != null && typeof rich.markdown === 'string') {
196
+ const guarded = guardAccidentalFormatting(rich.markdown)
197
+ if (guarded !== rich.markdown) {
198
+ // Clone rather than mutate the caller's shared object.
199
+ p.rich_message = { ...rich, markdown: guarded }
200
+ }
201
+ }
202
+ }
203
+ return prev(method, payload, signal)
204
+ })
205
+ }
206
+
150
207
  // ─── robustApiCall factory ────────────────────────────────────────────────
151
208
 
152
209
  /**
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pin tests for the accidental-formatting guard (#3252/#3463).
3
+ *
4
+ * Two internal-hardening assertions, no user-visible change:
5
+ *
6
+ * 1. Documents the `#{1,6}` cap in ACCIDENTAL_HEADING as intentional: a run of
7
+ * 7+ `#` glued to non-space is left UNescaped (CommonMark caps heading
8
+ * promotion at 6 `#`, and the guard mirrors that). Correctness-audit F2
9
+ * flagged this as asserted-but-untested.
10
+ *
11
+ * 2. A wiring assertion that PR1's `installRichMarkdownGuard` transformer is
12
+ * actually installed on the production Bot in `initGatewayBot()`, so the
13
+ * universal seam can't be silently dropped in a later refactor. Grammy's
14
+ * installed transformers are anonymous fns (nothing to grip at runtime), so
15
+ * this is a source-level AST assertion on the boot path — the same approach
16
+ * `gateway-bot-construction-deferral.test.ts` uses.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import { readFileSync } from 'node:fs'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { dirname, resolve } from 'node:path'
22
+ import ts from 'typescript'
23
+ import { guardAccidentalFormatting } from '../rich-send.js'
24
+ import { guardAccidentalHeading } from '../render/line-start-guard.js'
25
+
26
+ describe('accidental-heading guard: #{1,6} cap is intentional (F2)', () => {
27
+ it('escapes a 6-# run glued to non-space (upper bound of the cap)', () => {
28
+ expect(guardAccidentalHeading('######x')).toBe('\\######x')
29
+ expect(guardAccidentalFormatting('######x')).toBe('\\######x')
30
+ })
31
+
32
+ it('leaves a 7-# run glued to non-space UNescaped (past the CommonMark cap)', () => {
33
+ expect(guardAccidentalHeading('#######x')).toBe('#######x')
34
+ expect(guardAccidentalFormatting('#######x')).toBe('#######x')
35
+ })
36
+
37
+ it('leaves an 8+-# run glued to non-space UNescaped', () => {
38
+ expect(guardAccidentalHeading('##########x')).toBe('##########x')
39
+ expect(guardAccidentalFormatting('##########x')).toBe('##########x')
40
+ })
41
+ })
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url))
44
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
45
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
46
+ const sourceFile = ts.createSourceFile(
47
+ GATEWAY_PATH,
48
+ GATEWAY_SRC,
49
+ ts.ScriptTarget.Latest,
50
+ true,
51
+ ts.ScriptKind.TS,
52
+ )
53
+
54
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
55
+ for (const s of sourceFile.statements) {
56
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ function countCallsTo(root: ts.Node, name: string): number {
62
+ let count = 0
63
+ const visit = (node: ts.Node): void => {
64
+ if (
65
+ ts.isCallExpression(node) &&
66
+ ts.isIdentifier(node.expression) &&
67
+ node.expression.text === name
68
+ ) {
69
+ count++
70
+ // Installed on the constructed bot instance.
71
+ expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
72
+ }
73
+ ts.forEachChild(node, visit)
74
+ }
75
+ visit(root)
76
+ return count
77
+ }
78
+
79
+ describe('boot wiring: installRichMarkdownGuard is installed on the production Bot', () => {
80
+ it('imports installRichMarkdownGuard from ../shared/bot-runtime.js', () => {
81
+ // The import must exist for the boot call to resolve; a refactor that drops
82
+ // the import would break the seam.
83
+ expect(GATEWAY_SRC).toMatch(
84
+ /import\s*\{[^}]*\binstallRichMarkdownGuard\b[^}]*\}\s*from\s*'\.\.\/shared\/bot-runtime\.js'/,
85
+ )
86
+ })
87
+
88
+ it('calls installRichMarkdownGuard(bot) exactly once inside initGatewayBot()', () => {
89
+ const fn = findFunction('initGatewayBot')
90
+ expect(fn?.body).toBeDefined()
91
+ expect(countCallsTo(fn!.body!, 'installRichMarkdownGuard')).toBe(1)
92
+ })
93
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Truth-pass pin (#3252 formatting-truth follow-up): the REAL wire outcome for
3
+ * a `__…__` run.
4
+ *
5
+ * The parser folds `__x__` into a distinct `underline` IR node and the renderer
6
+ * round-trips it back to `__x__` (asserted in parse.test.ts / render.test.ts).
7
+ * But Telegram's rich-message markdown has NO underline token: the wire renders
8
+ * `__x__` identically to `**x__` — i.e. as BOLD (live-verified 2026-07, see
9
+ * reference/telegram-formatting-guide.md).
10
+ *
11
+ * Decision (documented in the fmt-audit BUILD-LOG): we keep the underline node
12
+ * and faithfully preserve the author's `__` bytes rather than rewriting them to
13
+ * `**` — full removal would invert several green tests and change wire bytes for
14
+ * zero wire-visible benefit (both render as bold). This test pins the honest
15
+ * contract: `__x__` emits `__x__`, which the wire treats as bold, NOT a distinct
16
+ * underline style.
17
+ */
18
+ import { describe, it, expect } from "vitest";
19
+ import { parse } from "../../render/parse.js";
20
+ import { render } from "../../render/render.js";
21
+
22
+ describe("underline: real wire outcome for `__…__`", () => {
23
+ it("round-trips `__x__` to `__x__` on the wire (which Telegram renders as BOLD)", () => {
24
+ // The emitted bytes preserve the author's `__` delimiters verbatim.
25
+ expect(render(parse("__x__"))).toBe("__x__");
26
+ });
27
+
28
+ it("does NOT rewrite `__x__` to `**x**` (author bytes preserved, not normalised to bold syntax)", () => {
29
+ expect(render(parse("__underlined__"))).not.toContain("**");
30
+ expect(render(parse("__underlined__"))).toBe("__underlined__");
31
+ });
32
+ });
@@ -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
+ })