switchroom 0.21.8 → 0.21.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/cli/switchroom.js +100 -39
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +151 -86
- package/telegram-plugin/format.ts +12 -4
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +73 -14
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/parse.test.ts +30 -5
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
|
@@ -735,9 +735,12 @@ export function normalizePunctuation(text: string): string {
|
|
|
735
735
|
// The dash rewrite runs per line so blockquote lines can be exempted:
|
|
736
736
|
// `>` blockquotes are reserved for VERBATIM quoted text, and rewriting an
|
|
737
737
|
// author's ` — ` to `, ` inside a quotation would corrupt what they quoted.
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
738
|
+
// A line opening with the LEGACY `**>` expandable-quote marker is a
|
|
739
|
+
// blockquote line too (the render path no longer emits `**>` — it is
|
|
740
|
+
// MarkdownV2-only syntax, see render.ts renderBlockquote — but legacy agent
|
|
741
|
+
// output still contains it and parse.ts repairs it into a real quote), so
|
|
742
|
+
// exempt it explicitly even though isBlockquoteLine (which keys on a leading
|
|
743
|
+
// `>`) doesn't catch the `**` prefix. Code spans and link hrefs stay masked
|
|
741
744
|
// throughout, so their dashes are already protected on every line.
|
|
742
745
|
const rewriteDashes = (line: string): string =>
|
|
743
746
|
line
|
|
@@ -1414,7 +1417,12 @@ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
|
|
|
1414
1417
|
/\*\*[^*\n]+\*\*/g, // bold
|
|
1415
1418
|
/__[^_\n]+__/g, // underline
|
|
1416
1419
|
/(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
|
|
1417
|
-
|
|
1420
|
+
// link `[label](href)` — and, via the optional leading `!`, Telegram's
|
|
1421
|
+
// inline-entity form `` /
|
|
1422
|
+
// ``. Without the `!?` the protected span would start at
|
|
1423
|
+
// the `[`, so a cut could land in the one-character gap and strand the `!`
|
|
1424
|
+
// on the previous chunk — silently demoting a date_time entity to a link.
|
|
1425
|
+
/!?\[[^\]\n]*\]\([^)\n]*\)/g,
|
|
1418
1426
|
/~~[^~\n]+~~/g, // strikethrough
|
|
1419
1427
|
/\|\|[^|\n]+\|\|/g, // spoiler
|
|
1420
1428
|
]
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@secretlint/core": "^12.2.0",
|
|
32
32
|
"@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
|
|
33
33
|
"@xterm/headless": "^6.0.0",
|
|
34
|
-
"grammy": "^1.
|
|
34
|
+
"grammy": "^1.45",
|
|
35
35
|
"mdast-util-from-markdown": "^2.0.2",
|
|
36
36
|
"mdast-util-gfm": "^3.0.0",
|
|
37
37
|
"micromark-extension-gfm": "^3.0.0",
|
|
@@ -73,6 +73,13 @@ export function splitCodeSegments(text: string): Segment[] {
|
|
|
73
73
|
// • bare autolinked URLs — `http(s)://…` and `www.…` runs Telegram auto-links.
|
|
74
74
|
// • GFM table rows — a table's structural pipes / empty cells (`|a||b|`) must
|
|
75
75
|
// survive; escaping inside a real table row corrupts the table.
|
|
76
|
+
// • inline-math `$…$` spans — Telegram's rich GFM parser typesets a `$…$`
|
|
77
|
+
// pair as a real mathematical_expression node (wire-verified 2026-08-13).
|
|
78
|
+
// A COMPACT span (whitespace-free inner run that is not a bare currency
|
|
79
|
+
// amount, e.g. `$x^2+y^2$`) is intentional math and must reach the wire
|
|
80
|
+
// byte-identical: no guard may escape its `$`, `^`, `_`, `*`, or `+`.
|
|
81
|
+
// Currency-shaped inners (`$5M-$`) are NOT protected, so the dollar-math
|
|
82
|
+
// guard still breaks accidental `$5M … $10M` currency pairs (#3252).
|
|
76
83
|
// Everything else is prose and stays fully guarded. This is the sibling of the
|
|
77
84
|
// code-span skip: a PROTECTED segment (`code: true`) is emitted verbatim.
|
|
78
85
|
//
|
|
@@ -93,6 +100,19 @@ function isTableCandidateLine(line: string): boolean {
|
|
|
93
100
|
return /^\s*\|/.test(line);
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
/** A compact `$…$` inline-math pair: opening `$`, a whitespace-free inner run
|
|
104
|
+
* with no nested `$`, closing `$`. Anchored — tested at the scanner's current
|
|
105
|
+
* position only. */
|
|
106
|
+
const COMPACT_MATH_PAIR = /^\$([^\s$]+)\$/;
|
|
107
|
+
|
|
108
|
+
/** A currency-shaped inner run: digits plus amount punctuation and magnitude
|
|
109
|
+
* suffix letters only (`5M-`, `0.5`, `10,000`, `5+`). Such a run between two
|
|
110
|
+
* `$` is a pair of ADJACENT currency amounts (`$5M-$10M`), not math — it must
|
|
111
|
+
* stay guardable so the dollar-math guard can break the accidental pair. A
|
|
112
|
+
* run containing any other character (`x^2+y^2`, `\alpha`, `a_b`) is treated
|
|
113
|
+
* as intentional math and protected. */
|
|
114
|
+
const CURRENCY_SHAPED_INNER = /^[0-9.,+\-kKmMbB]+$/;
|
|
115
|
+
|
|
96
116
|
/** Find the [start, end) char ranges (relative to `text`) of GFM table blocks —
|
|
97
117
|
* maximal runs of 2+ consecutive `|`-leading lines that contain a delimiter
|
|
98
118
|
* row. Each returned range spans whole lines INCLUDING their trailing newline,
|
|
@@ -185,6 +205,19 @@ function splitProseProtected(text: string): Segment[] {
|
|
|
185
205
|
continue;
|
|
186
206
|
}
|
|
187
207
|
}
|
|
208
|
+
// 4. Compact inline-math pair `$…$` — a supported Telegram construct
|
|
209
|
+
// (mathematical_expression, wire-verified 2026-08-13) that must reach
|
|
210
|
+
// the wire verbatim. Currency-shaped inners are NOT math (they are two
|
|
211
|
+
// adjacent amounts like `$5M-$10M`) and stay guardable.
|
|
212
|
+
if (ch === "$") {
|
|
213
|
+
const m = COMPACT_MATH_PAIR.exec(text.slice(i));
|
|
214
|
+
if (m && !CURRENCY_SHAPED_INNER.test(m[1])) {
|
|
215
|
+
const end = i + m[0].length;
|
|
216
|
+
pushProtected(i, end);
|
|
217
|
+
i = end;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
188
221
|
i++;
|
|
189
222
|
}
|
|
190
223
|
if (plainStart < text.length) out.push({ code: false, text: text.slice(plainStart) });
|
|
@@ -193,10 +226,11 @@ function splitProseProtected(text: string): Segment[] {
|
|
|
193
226
|
|
|
194
227
|
/** Split rendered markdown into prose / protected segments where a PROTECTED
|
|
195
228
|
* (`code: true`) segment is any content a guard must emit verbatim: code spans,
|
|
196
|
-
* fenced code blocks, markdown link destinations, bare autolinks,
|
|
197
|
-
*
|
|
198
|
-
* all
|
|
199
|
-
* guardable (including a link's
|
|
229
|
+
* fenced code blocks, markdown link destinations, bare autolinks, GFM table
|
|
230
|
+
* rows, and compact `$…$` inline-math spans. This is the link/table/math-aware
|
|
231
|
+
* superset of `splitCodeSegments` that all the #3252 guards route through.
|
|
232
|
+
* Prose segments (`code: false`) remain guardable (including a link's
|
|
233
|
+
* `[label]` text). Deterministic, linear-time. */
|
|
200
234
|
export function splitProtectedSegments(text: string): Segment[] {
|
|
201
235
|
const out: Segment[] = [];
|
|
202
236
|
for (const seg of splitCodeSegments(text)) {
|
|
@@ -29,6 +29,20 @@
|
|
|
29
29
|
// a `$…$` pair if only the leading-`$digit` token is escaped — so we escape the
|
|
30
30
|
// lot once the currency signal + 2-dollar threshold are met.
|
|
31
31
|
//
|
|
32
|
+
// ── INTENTIONAL math is exempt (wire-verified 2026-08-13) ─────────────────
|
|
33
|
+
// Telegram's rich path renders a `$…$` pair as a native mathematical_expression
|
|
34
|
+
// node, and intentional math (`$x^2+y^2$`) must reach the wire byte-identical —
|
|
35
|
+
// an escaped `\$x^2+y^2\$` destroys a SUPPORTED construct. The discrimination
|
|
36
|
+
// lives in `splitProtectedSegments` (code-segments.ts): a COMPACT math span
|
|
37
|
+
// (whitespace-free inner, not currency-shaped) is a protected segment, so this
|
|
38
|
+
// guard neither counts its `$`s toward the 2+ threshold nor escapes them.
|
|
39
|
+
// Currency amounts always sit next to whitespace/prose (`$5M and $10M`) or have
|
|
40
|
+
// a digits-and-punctuation-only inner (`$5M-$10M`), so #3252-class accidental
|
|
41
|
+
// pairs remain fully guarded. Known residual: a SPACED math span (`$a + b$`)
|
|
42
|
+
// is indistinguishable from currency prose and is not exempted — it is escaped
|
|
43
|
+
// when the message also carries a currency signal, rendering as literal text
|
|
44
|
+
// (legible, not broken).
|
|
45
|
+
//
|
|
32
46
|
// Idempotent (F5): the escape uses a negative-lookbehind (`(?<!\\)\$`) so an
|
|
33
47
|
// already-escaped `\$` is never doubled to `\\$`. Running the guard twice (e.g.
|
|
34
48
|
// the streaming path renders then this wrapper re-wraps) is a strict no-op the
|
|
@@ -100,7 +114,8 @@ const UNESCAPED_DOLLAR = /(?<!\\)\$/g;
|
|
|
100
114
|
* them is digit-adjacent (a currency signal). When armed, EVERY unescaped prose
|
|
101
115
|
* `$` is backslash-escaped so no two `$` can pair into a math span — this is
|
|
102
116
|
* what closes the F3 trailing-`$` / `$.50` false-negatives. Code spans / fenced
|
|
103
|
-
* blocks
|
|
117
|
+
* blocks AND compact intentional-math `$…$` spans (protected segments, see
|
|
118
|
+
* code-segments.ts) are never touched. Idempotent (F5) and deterministic.
|
|
104
119
|
*/
|
|
105
120
|
export function guardDollarMath(text: string): string {
|
|
106
121
|
if (!text.includes("$")) return text;
|
|
@@ -30,11 +30,21 @@
|
|
|
30
30
|
// highlight -> `==…==` (Bot API 10.1 marked entity)
|
|
31
31
|
// code -> `` `…` ``
|
|
32
32
|
// link -> `[…](…)`
|
|
33
|
+
// tg-entity -> `` (Bot API date_time / custom-emoji entity)
|
|
34
|
+
// raw -> source bytes verbatim (never escaped) — footnote markers
|
|
35
|
+
// `[^1]` and definition lines `[^1]: …`, which Telegram's
|
|
36
|
+
// rich parser renders natively and escapeMarkdown would break
|
|
33
37
|
//
|
|
34
38
|
// Block
|
|
35
39
|
// paragraph -> children joined; blocks separated by "\n\n"
|
|
36
40
|
// heading -> `#`…`######` line
|
|
37
|
-
// blockquote -> `> …`
|
|
41
|
+
// blockquote -> `> …` on every line. `expandable === true` records that
|
|
42
|
+
// the SOURCE carried the legacy `**> ` marker, but it is
|
|
43
|
+
// NOT a distinct wire style: `**>` is MarkdownV2-only
|
|
44
|
+
// syntax that the rich markdown path renders as literal
|
|
45
|
+
// text (wire-verified 2026-08-13), so the renderer
|
|
46
|
+
// degrades it to a plain quote. Authors wanting a real
|
|
47
|
+
// collapsible use `<details><summary>…</summary>…</details>`.
|
|
38
48
|
// code-block -> ```` ```lang … ``` ````
|
|
39
49
|
// list -> line-per-item with `-`/`1.` markers
|
|
40
50
|
// thematic-break -> `---` thematic break
|
|
@@ -109,6 +119,40 @@ export interface LinkNode extends Pos {
|
|
|
109
119
|
children: Inline[];
|
|
110
120
|
}
|
|
111
121
|
|
|
122
|
+
/** A Telegram rich-markdown INLINE entity written in mdast IMAGE position.
|
|
123
|
+
* The "Rich Markdown style" grammar (https://core.telegram.org/bots/api,
|
|
124
|
+
* quoted in `reference/telegram-formatting-guide.md`) lists exactly two:
|
|
125
|
+
*
|
|
126
|
+
*  custom emoji
|
|
127
|
+
*  date_time
|
|
128
|
+
*
|
|
129
|
+
* (the `date_time` MessageEntity is Bot API 9.5, March 1 2026; the rich-message
|
|
130
|
+
* `RichTextDateTime` class is 10.1, June 11 2026 — both in the Bot API
|
|
131
|
+
* changelog.) `parse.ts` folds ONLY those two `tg:` hrefs into this node;
|
|
132
|
+
* every other image url keeps the historical demote-to-`plain` fallback,
|
|
133
|
+
* because an http(s) `` is a Telegram MEDIA block — "Media can be
|
|
134
|
+
* specified only as a separate block" (same doc) — not an inline entity, and
|
|
135
|
+
* switchroom does not emit media blocks.
|
|
136
|
+
*
|
|
137
|
+
* `label` is the DECODED alternative text (mdast `image.alt`, empty for the
|
|
138
|
+
* emoji form); `href` is the `tg:` URL. Both are re-escaped on render, same
|
|
139
|
+
* as `LinkNode`. */
|
|
140
|
+
export interface TgEntityNode extends Pos {
|
|
141
|
+
type: "tg-entity";
|
|
142
|
+
label: string;
|
|
143
|
+
href: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Verbatim wire passthrough: the node's SOURCE bytes are already the exact
|
|
147
|
+
* syntax Telegram's rich parser expects, so the renderer must emit them
|
|
148
|
+
* unescaped (escapeMarkdown would corrupt them). Used for GFM footnote
|
|
149
|
+
* reference markers (`[^1]`) and footnote definition lines (`[^1]: …`) —
|
|
150
|
+
* both natively supported on the rich path (wire-verified 2026-08-13). */
|
|
151
|
+
export interface RawNode extends Pos {
|
|
152
|
+
type: "raw";
|
|
153
|
+
text: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
112
156
|
export type Inline =
|
|
113
157
|
| PlainNode
|
|
114
158
|
| BoldNode
|
|
@@ -118,7 +162,9 @@ export type Inline =
|
|
|
118
162
|
| SpoilerNode
|
|
119
163
|
| HighlightNode
|
|
120
164
|
| CodeNode
|
|
121
|
-
| LinkNode
|
|
165
|
+
| LinkNode
|
|
166
|
+
| TgEntityNode
|
|
167
|
+
| RawNode;
|
|
122
168
|
|
|
123
169
|
// ---------------------------------------------------------------------------
|
|
124
170
|
// Block nodes
|
|
@@ -139,7 +185,11 @@ export interface HeadingNode extends Pos {
|
|
|
139
185
|
export interface BlockquoteNode extends Pos {
|
|
140
186
|
type: "blockquote";
|
|
141
187
|
children: Block[];
|
|
142
|
-
/**
|
|
188
|
+
/** True when the source carried the LEGACY switchroom `**> ` expandable
|
|
189
|
+
* marker (see parse.ts markExpandableQuotes). Records authoring intent
|
|
190
|
+
* only: `**>` is MarkdownV2 syntax with no rich-markdown equivalent
|
|
191
|
+
* (wire-verified 2026-08-13), so the renderer emits a plain `> ` quote
|
|
192
|
+
* either way. */
|
|
143
193
|
expandable: boolean;
|
|
144
194
|
}
|
|
145
195
|
|
|
@@ -37,12 +37,18 @@
|
|
|
37
37
|
// own reading of the delimiters.
|
|
38
38
|
//
|
|
39
39
|
// Blockquote expandable handling:
|
|
40
|
-
// The IR carries `expandable: boolean` for
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
40
|
+
// The IR carries `expandable: boolean` for the LEGACY switchroom `**> `
|
|
41
|
+
// expandable-quote encoding. `**>` was believed to be the Bot API 10.1
|
|
42
|
+
// expandable-blockquote marker; wire probes (2026-08-13) proved it is
|
|
43
|
+
// MarkdownV2-only syntax that the rich markdown path renders as LITERAL
|
|
44
|
+
// `**>` text, so `render.ts` no longer emits it — an expandable node renders
|
|
45
|
+
// as a plain `> ` quote. Recognition here is kept as INPUT REPAIR: agent
|
|
46
|
+
// output (and Hindsight memories) trained on the old floor card still
|
|
47
|
+
// contains `**> ` quotes, and without this rewrite such a line would reach
|
|
48
|
+
// the wire as a broken literal-`**>` paragraph. micromark does NOT
|
|
49
|
+
// understand `**> ` as a blockquote — the leading `**` makes the line a
|
|
50
|
+
// paragraph with an unclosed strong-emphasis run — so this module
|
|
51
|
+
// pre-transforms each
|
|
46
52
|
// `**>` marker into a plain ` >` marker of IDENTICAL length (`**` → two
|
|
47
53
|
// spaces) before handing the text to mdast. Length preservation keeps every
|
|
48
54
|
// UTF-16 source offset (and therefore the never-lose-text round-trip
|
|
@@ -90,15 +96,35 @@ function slice(source: string, node: MdastNode): string {
|
|
|
90
96
|
return source.slice(start, end);
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
/** The
|
|
94
|
-
* a line (column 0).
|
|
95
|
-
*
|
|
96
|
-
*
|
|
99
|
+
/** The LEGACY switchroom expandable-blockquote marker: `**>` at the very
|
|
100
|
+
* start of a line (column 0). The render path no longer emits it (it is
|
|
101
|
+
* MarkdownV2-only syntax, not rich markdown — see `render.ts`
|
|
102
|
+
* renderBlockquote), but it is still RECOGNISED on input so a legacy `**> `
|
|
103
|
+
* line is repaired into a real blockquote instead of shipping as literal
|
|
104
|
+
* `**>` text. Matching only at column 0 keeps
|
|
97
105
|
* the length-preserving rewrite (`**` → two spaces) inside CommonMark's
|
|
98
106
|
* 3-space blockquote-indent budget — allowing leading indent here would push
|
|
99
107
|
* the rewritten ` >` past 3 spaces and turn it into an indented code block. */
|
|
100
108
|
const EXPANDABLE_MARKER_RE = /^\*\*>/;
|
|
101
109
|
|
|
110
|
+
/** The `tg:` hrefs Telegram's "Rich Markdown style" grammar accepts in IMAGE
|
|
111
|
+
* position — ``. Exactly two are documented
|
|
112
|
+
* (https://core.telegram.org/bots/api): `tg://emoji?id=…` (custom emoji) and
|
|
113
|
+
* `tg://time?unix=…[&format=…]` (the `date_time` entity). Deliberately an
|
|
114
|
+
* ALLOWLIST rather than a bare `tg:` scheme test: an undocumented `tg://…` in
|
|
115
|
+
* image position is not known-good syntax, and demoting it to literal text
|
|
116
|
+
* (the historical behaviour) is safer than shipping a construct Telegram may
|
|
117
|
+
* parse-reject. */
|
|
118
|
+
const TG_INLINE_ENTITY_HREFS = ["tg://emoji", "tg://time"] as const;
|
|
119
|
+
|
|
120
|
+
/** True when an mdast `image` url is one of the documented inline `tg:`
|
|
121
|
+
* entities. Scheme/host comparison is case-insensitive (URLs are), but the
|
|
122
|
+
* ORIGINAL href is what gets re-emitted — we never rewrite the author's bytes. */
|
|
123
|
+
function isTgInlineEntityHref(href: string): boolean {
|
|
124
|
+
const h = href.toLowerCase();
|
|
125
|
+
return TG_INLINE_ENTITY_HREFS.some((base) => h === base || h.startsWith(`${base}?`));
|
|
126
|
+
}
|
|
127
|
+
|
|
102
128
|
/** Pre-transform expandable-blockquote markers so mdast can parse them as
|
|
103
129
|
* ordinary blockquotes, WITHOUT shifting any source offset. Each line that
|
|
104
130
|
* opens with `**>` has its two `*` characters replaced by two spaces
|
|
@@ -170,8 +196,30 @@ function foldInline(node: PhrasingContent, source: string): Inline {
|
|
|
170
196
|
children: foldInlineChildren(node, source),
|
|
171
197
|
...pos(node),
|
|
172
198
|
};
|
|
173
|
-
|
|
174
|
-
|
|
199
|
+
case "image": {
|
|
200
|
+
// GFM's image syntax doubles as Telegram's INLINE-entity syntax:
|
|
201
|
+
// `` (date_time) and
|
|
202
|
+
// `` (custom emoji). Fold those two into a
|
|
203
|
+
// `tg-entity` node so the renderer re-emits the construct verbatim
|
|
204
|
+
// instead of escaping the brackets to literal text. Every OTHER image
|
|
205
|
+
// url — notably the http(s) MEDIA forms, which Telegram accepts only as
|
|
206
|
+
// a SEPARATE block — falls through to the demote-to-`plain` default
|
|
207
|
+
// below, unchanged.
|
|
208
|
+
if (isTgInlineEntityHref(node.url)) {
|
|
209
|
+
return { type: "tg-entity", label: node.alt ?? "", href: node.url, ...pos(node) };
|
|
210
|
+
}
|
|
211
|
+
return { type: "plain", text: slice(source, node), ...pos(node) };
|
|
212
|
+
}
|
|
213
|
+
// GFM footnote reference marker (`[^1]`): natively supported by Telegram's
|
|
214
|
+
// rich markdown path (wire-verified 2026-08-13 — renders as the full
|
|
215
|
+
// superscript + anchor + reference_link machinery). The source bytes ARE
|
|
216
|
+
// the wire syntax, so fold to a `raw` node the renderer emits verbatim;
|
|
217
|
+
// a `plain` node would be escapeMarkdown'd (`\[^1\]`) and break the
|
|
218
|
+
// construct on the wire.
|
|
219
|
+
case "footnoteReference":
|
|
220
|
+
return { type: "raw", text: slice(source, node), ...pos(node) };
|
|
221
|
+
// Not in the palette (break, non-`tg:` image, html, …): keep the raw
|
|
222
|
+
// source text so no content is lost.
|
|
175
223
|
default:
|
|
176
224
|
return { type: "plain", text: slice(source, node), ...pos(node) };
|
|
177
225
|
}
|
|
@@ -332,8 +380,19 @@ function foldBlock(
|
|
|
332
380
|
...pos(node),
|
|
333
381
|
};
|
|
334
382
|
}
|
|
335
|
-
//
|
|
336
|
-
//
|
|
383
|
+
// GFM footnote DEFINITION (`[^1]: body`): natively supported on the wire
|
|
384
|
+
// (2026-08-13 probe — pairs with the reference marker into footer/anchor
|
|
385
|
+
// nodes). Emit the raw source slice VERBATIM via a `raw` inline: a `plain`
|
|
386
|
+
// fold would escapeMarkdown the `[`/`]` (`\[^1\]: body`) and orphan the
|
|
387
|
+
// reference.
|
|
388
|
+
case "footnoteDefinition":
|
|
389
|
+
return {
|
|
390
|
+
type: "paragraph",
|
|
391
|
+
children: [{ type: "raw", text: slice(source, node), ...pos(node) }],
|
|
392
|
+
...pos(node),
|
|
393
|
+
};
|
|
394
|
+
// Not in the palette (html, definition, …): degrade to a paragraph
|
|
395
|
+
// carrying the raw source slice so no content is dropped.
|
|
337
396
|
default:
|
|
338
397
|
return {
|
|
339
398
|
type: "paragraph",
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
// "HTML" }`. There is no HTML anywhere on the current outbound path (see
|
|
17
17
|
// `reference/telegram-formatting-guide.md`). This renderer therefore targets
|
|
18
18
|
// the ACTUAL contract: GFM markdown with the Bot API 10.1 extensions
|
|
19
|
-
// documented in the formatting guide (
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// documented in the formatting guide (spoiler via `||…||`, GFM pipe tables,
|
|
20
|
+
// `<details>` collapsibles passed through as HTML, etc). NOTE: `**> ` is NOT
|
|
21
|
+
// part of that contract — it is MarkdownV2-only syntax the rich path renders
|
|
22
|
+
// as literal text (wire-verified 2026-08-13); this renderer no longer emits
|
|
23
|
+
// it anywhere (see renderBlockquote).
|
|
23
24
|
//
|
|
24
25
|
// Round-trip note: `parse.ts` folds inline text (`PlainNode.text`,
|
|
25
26
|
// `CodeNode.text`, `code-block` `text`, link `href`) into DECODED strings —
|
|
@@ -59,6 +60,14 @@ interface InlineCtx {
|
|
|
59
60
|
inTableCell?: boolean;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/** Collapse any whitespace run containing a newline down to a single space.
|
|
64
|
+
* Used for a `tg-entity` label: `![` … `]` must stay on ONE line or the
|
|
65
|
+
* construct is not an entity any more. mdast decodes a soft line break inside
|
|
66
|
+
* the alt text to a literal `\n`, which is exactly the case this flattens. */
|
|
67
|
+
function collapseLabelBreaks(label: string): string {
|
|
68
|
+
return label.replace(/[ \t]*\r?\n[ \t\r\n]*/g, " ");
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
function renderInline(node: Inline, ctx: InlineCtx = {}): string {
|
|
63
72
|
switch (node.type) {
|
|
64
73
|
case "plain":
|
|
@@ -91,6 +100,25 @@ function renderInline(node: Inline, ctx: InlineCtx = {}): string {
|
|
|
91
100
|
// Escape the href so a literal `)` in the URL can't terminate the
|
|
92
101
|
// destination early and break the link (F3).
|
|
93
102
|
return `[${renderInlineChildren(node.children, ctx)}](${escapeLinkHref(node.href)})`;
|
|
103
|
+
case "tg-entity":
|
|
104
|
+
// `` / ``.
|
|
105
|
+
// The label is PROSE (the alternative text Telegram shows when it can't
|
|
106
|
+
// render the entity), so it is escaped exactly like a `plain` node —
|
|
107
|
+
// without that, a label containing `]` or a formatting delimiter
|
|
108
|
+
// (`![see [22:45]](tg://time?…)`) closes the label early and smuggles
|
|
109
|
+
// raw bracket syntax past the renderer. A newline inside the label would
|
|
110
|
+
// split the construct across lines, so runs of whitespace spanning one
|
|
111
|
+
// are collapsed to a single space first. The href gets the same
|
|
112
|
+
// `escapeLinkHref` treatment a link's does (a no-op for the paren-free
|
|
113
|
+
// `tg:` URLs in practice, load-bearing if one ever carries a `)`).
|
|
114
|
+
return `})`;
|
|
115
|
+
case "raw":
|
|
116
|
+
// Verbatim wire passthrough — the source bytes ARE the wire syntax
|
|
117
|
+
// (footnote reference markers `[^1]` / definition lines `[^1]: …`,
|
|
118
|
+
// which Telegram's rich parser renders natively; escapeMarkdown would
|
|
119
|
+
// escape their `[`/`]` and break the construct — the exact bug this
|
|
120
|
+
// node type exists to prevent).
|
|
121
|
+
return node.text;
|
|
94
122
|
default: {
|
|
95
123
|
// Exhaustiveness guard — the IR union is closed; a new variant must be
|
|
96
124
|
// handled above rather than silently dropped.
|
|
@@ -118,17 +146,20 @@ function prefixLines(text: string, prefix: string): string {
|
|
|
118
146
|
|
|
119
147
|
function renderBlockquote(node: BlockquoteNode): string {
|
|
120
148
|
const inner = renderBlocks(node.children);
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
// Always a plain `> ` blockquote — including for `expandable: true` nodes.
|
|
150
|
+
//
|
|
151
|
+
// This renderer USED to emit `**> ` on the first line of an expandable
|
|
152
|
+
// quote, believing it to be the Bot API 10.1 expandable-blockquote marker.
|
|
153
|
+
// That belief was falsified by raw sendRichMessage wire probes (2026-08-13):
|
|
154
|
+
// `**>` is MarkdownV2 syntax; the rich markdown path renders it as a LITERAL
|
|
155
|
+
// `**> …` paragraph followed by a detached plain quote. The `expandable`
|
|
156
|
+
// flag is retained on the IR (parse.ts still repairs legacy `**>` input into
|
|
157
|
+
// a real blockquote instead of letting the literal `**>` reach the wire),
|
|
158
|
+
// but it is NOT a distinct wire style — the faithful degradation is a plain
|
|
159
|
+
// quote, which shows the full content. An author who wants a genuine
|
|
160
|
+
// collapsible writes `<details><summary>…</summary>…</details>`, which the
|
|
161
|
+
// rich path renders natively (typed `details` node, wire-verified) and which
|
|
162
|
+
// passes through this pipeline verbatim.
|
|
132
163
|
return prefixLines(inner, "> ");
|
|
133
164
|
}
|
|
134
165
|
|
|
@@ -299,6 +330,13 @@ export const SUPPORTED_INLINE = [
|
|
|
299
330
|
"highlight",
|
|
300
331
|
"code",
|
|
301
332
|
"link",
|
|
333
|
+
// `` / `` — the two inline `tg:`
|
|
334
|
+
// entities in Telegram's Rich Markdown grammar. Emitted verbatim (label and
|
|
335
|
+
// href re-escaped); any OTHER image url stays a `plain` node.
|
|
336
|
+
"tg-entity",
|
|
337
|
+
// Verbatim passthrough for constructs whose SOURCE bytes are the wire syntax
|
|
338
|
+
// (footnote markers/definitions). Never escaped, never rewritten.
|
|
339
|
+
"raw",
|
|
302
340
|
] as const;
|
|
303
341
|
|
|
304
342
|
export const SUPPORTED_BLOCK = [
|
|
@@ -3,44 +3,55 @@
|
|
|
3
3
|
//
|
|
4
4
|
// ── Root cause ───────────────────────────────────────────────────────────
|
|
5
5
|
// Assistant replies are composed by a model that habitually emits constructs
|
|
6
|
-
// from OTHER surfaces (GitHub / Obsidian / LaTeX)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// from OTHER surfaces (GitHub / Obsidian / LaTeX). Most of them turn out to be
|
|
7
|
+
// natively supported by Telegram's rich markdown path (wire-verified
|
|
8
|
+
// 2026-08-13 by sending raw `sendRichMessage` probes and reading back the
|
|
9
|
+
// echoed `rich_message.blocks`):
|
|
10
|
+
// • `<details open><summary>S</summary>…</details>` → a real typed
|
|
11
|
+
// `details` node (native collapsible) — SUPPORTED, must pass through.
|
|
12
|
+
// • `$x^2+y^2$` → a `mathematical_expression` node — SUPPORTED (and
|
|
13
|
+
// protected from the sibling guards via `splitProtectedSegments`'s
|
|
14
|
+
// compact-math-span rule; see code-segments.ts).
|
|
15
|
+
// • footnotes `claim[^1]` + `[^1]: body` → full superscript/anchor/
|
|
16
|
+
// reference_link/footer machinery — SUPPORTED, must pass through.
|
|
17
|
+
// • `<sub>`/`<sup>`/`<u>`, `<aside>…<cite>…</cite></aside>`, `tg://time`
|
|
18
|
+
// links, `- [ ]` task lists — all SUPPORTED, never touched here.
|
|
19
|
+
// (An earlier revision of this guard "repaired" `<details>` into a `**> `
|
|
20
|
+
// expandable blockquote and deleted footnote reference markers. Both repairs
|
|
21
|
+
// were built on a false belief: `**>` is MarkdownV2 syntax that the rich
|
|
22
|
+
// markdown path renders as LITERAL `**>` paragraph text — the probe proved the
|
|
23
|
+
// conversion turned a SUPPORTED construct into an UNSUPPORTED one. That logic
|
|
24
|
+
// is deleted, not gated.)
|
|
25
|
+
//
|
|
26
|
+
// What genuinely does NOT render and still needs repair: the caret
|
|
27
|
+
// highlight/superscript shorthand `^…^`. Telegram's rich markdown has no caret
|
|
28
|
+
// syntax — the carets render literally on the reader's screen. The resident
|
|
29
|
+
// floor card tells the model to use `<sup>…</sup>` instead, but prompt
|
|
30
|
+
// discipline is not a guarantee; this guard makes the repair deterministic at
|
|
31
|
+
// send time.
|
|
13
32
|
//
|
|
14
33
|
// ── What it repairs (deterministic, pure string transform) ─────────────────
|
|
15
|
-
// •
|
|
16
|
-
// blockquote (`**> Title` first line + `> …` continuation) — the native
|
|
17
|
-
// equivalent of a collapsible. `<details>` without a `<summary>` folds the
|
|
18
|
-
// whole body into an expandable blockquote. Any orphan `<details>` /
|
|
19
|
-
// `</details>` / `<summary>` tags left over are stripped.
|
|
20
|
-
// • `^highlight^` / `x^2^` caret pairs → the inner text, carets removed
|
|
21
|
-
// (Telegram has no highlight/superscript; the carets render literally).
|
|
22
|
-
// • Footnote reference markers `[^id]` → removed (Telegram has no footnotes).
|
|
23
|
-
// A footnote DEFINITION line `[^id]: …` is left alone (the `]:` lookahead).
|
|
34
|
+
// • `^highlight^` / `x^2^` caret pairs → the inner text, carets removed.
|
|
24
35
|
//
|
|
25
36
|
// ── What it deliberately does NOT touch ────────────────────────────────────
|
|
26
|
-
// • `$…$` math:
|
|
27
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
// double-process and risk corrupting currency prose, so this guard leaves
|
|
30
|
-
// `$` untouched by design. Math repair is COVERED, just in the sibling guard.
|
|
37
|
+
// • `$…$` math: a compact math span is PROTECTED upstream (a `code: true`
|
|
38
|
+
// segment from `splitProtectedSegments`), and accidental currency `$` is
|
|
39
|
+
// owned by `guardDollarMath` (disjoint char set).
|
|
31
40
|
// • `~sub~` tilde pairs: the strikethrough/tilde trigger is owned by
|
|
32
41
|
// `guardAccidentalInlinePairs` (disjoint char set). This guard never
|
|
33
42
|
// inspects or inserts `~`.
|
|
34
43
|
// • `__underline__`: renders as BOLD in Telegram — legible, not broken — so it
|
|
35
44
|
// is left as-is (the floor card asks the model to avoid it, but there is no
|
|
36
45
|
// glyph-level failure to repair).
|
|
46
|
+
// • footnote markers `[^id]` / definitions `[^id]: …`: natively supported,
|
|
47
|
+
// pass through verbatim. (The caret regex below can never touch them: the
|
|
48
|
+
// `]` / `:` break the alphanumeric-only inner run.)
|
|
37
49
|
//
|
|
38
|
-
// Code spans / fenced blocks / link destinations / table rows are
|
|
39
|
-
// verbatim (shared `splitProtectedSegments`). A strict no-op for any
|
|
40
|
-
// without
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// accidental-formatting guards.
|
|
50
|
+
// Code spans / fenced blocks / link destinations / math spans / table rows are
|
|
51
|
+
// emitted verbatim (shared `splitProtectedSegments`). A strict no-op for any
|
|
52
|
+
// body without a caret pair, and idempotent (a stripped caret pair contains no
|
|
53
|
+
// `^`). Safe to compose once per send alongside the #3252 accidental-formatting
|
|
54
|
+
// guards.
|
|
44
55
|
|
|
45
56
|
import { splitProtectedSegments } from "./code-segments.js";
|
|
46
57
|
|
|
@@ -56,69 +67,23 @@ import { splitProtectedSegments } from "./code-segments.js";
|
|
|
56
67
|
* permissive inner run the first two carets of `a^2+b^2=c^2` pair up (`^2+b^`)
|
|
57
68
|
* and get stripped, mangling the math; requiring the inner run to be pure
|
|
58
69
|
* alphanumerics means `^2+b^` never matches (the `+` breaks the run), so
|
|
59
|
-
* `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched.
|
|
70
|
+
* `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched. It also keeps
|
|
71
|
+
* the guard off footnote markers `[^1]` (the `]` breaks the run). */
|
|
60
72
|
const CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
|
|
61
73
|
|
|
62
|
-
/** Footnote reference marker `[^id]` where the id is a short alphanumeric run
|
|
63
|
-
* (`[^1]`, `[^note]`, `[^ref]`) NOT immediately followed by `:` (which would
|
|
64
|
-
* make it a footnote DEFINITION line we leave intact). Removed entirely.
|
|
65
|
-
* Requiring the id to be 1–10 ALPHANUMERIC chars keeps this off regex-ish
|
|
66
|
-
* literals like `[^/]`, `[^\s]`, `[^-a-z]`, whose bodies contain punctuation
|
|
67
|
-
* and so never match. In-prose subscript-ish `array[^i]` outside a code span
|
|
68
|
-
* is a rare theoretical false positive (an `i` id matches); code spans are
|
|
69
|
-
* masked upstream by splitProtectedSegments so real code is safe, and prose
|
|
70
|
-
* that writes a literal `[^i]` reads as footnote noise anyway. */
|
|
71
|
-
const FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
|
|
72
|
-
|
|
73
|
-
/** `<details>…</details>` with an optional leading `<summary>…</summary>`.
|
|
74
|
-
* Dot-all via `[\s\S]`; non-greedy so adjacent blocks don't merge. */
|
|
75
|
-
const DETAILS_BLOCK =
|
|
76
|
-
/<details[^>]*>\s*(?:<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/gi;
|
|
77
|
-
|
|
78
|
-
/** Orphan collapsible tags left after DETAILS_BLOCK (malformed / unpaired). */
|
|
79
|
-
const ORPHAN_TAGS = /<\/?(?:details|summary)[^>]*>/gi;
|
|
80
|
-
|
|
81
|
-
/** Fold `title` + `body` into a Telegram expandable blockquote: the first
|
|
82
|
-
* emitted line carries the `**> ` marker (switchroom's expandable-blockquote
|
|
83
|
-
* encoding — see parse.ts), every subsequent line a plain `> `. */
|
|
84
|
-
function toExpandableBlockquote(title: string, body: string): string {
|
|
85
|
-
const lines: string[] = [];
|
|
86
|
-
const t = title.trim();
|
|
87
|
-
if (t) lines.push(t);
|
|
88
|
-
for (const raw of body.split("\n")) {
|
|
89
|
-
const line = raw.trimEnd();
|
|
90
|
-
// Collapse leading/trailing blank lines but keep interior structure.
|
|
91
|
-
if (line.trim() === "" && lines.length === 0) continue;
|
|
92
|
-
lines.push(line);
|
|
93
|
-
}
|
|
94
|
-
// Trim trailing blanks.
|
|
95
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
96
|
-
if (lines.length === 0) return "";
|
|
97
|
-
return lines
|
|
98
|
-
.map((line, i) => (i === 0 ? `**> ${line}` : `> ${line}`))
|
|
99
|
-
.join("\n");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
74
|
/** Repair unsupported tokens in a single PROSE segment. */
|
|
103
75
|
function repairProse(text: string): string {
|
|
104
|
-
|
|
105
|
-
out = out.replace(DETAILS_BLOCK, (_m, summary: string | undefined, body: string) =>
|
|
106
|
-
toExpandableBlockquote(summary ?? "", body ?? ""),
|
|
107
|
-
);
|
|
108
|
-
out = out.replace(ORPHAN_TAGS, "");
|
|
109
|
-
out = out.replace(CARET_PAIR, (_m, inner: string) => inner);
|
|
110
|
-
out = out.replace(FOOTNOTE_MARKER, "");
|
|
111
|
-
return out;
|
|
76
|
+
return text.replace(CARET_PAIR, (_m, inner: string) => inner);
|
|
112
77
|
}
|
|
113
78
|
|
|
114
79
|
/**
|
|
115
|
-
* Neutralise Telegram-unrenderable
|
|
116
|
-
*
|
|
80
|
+
* Neutralise Telegram-unrenderable caret pairs (`^…^`) on the FINAL rendered
|
|
81
|
+
* rich-markdown string. Code / links / math spans / tables are verbatim.
|
|
117
82
|
* Deterministic, idempotent, and a strict no-op absent any target token.
|
|
118
83
|
*/
|
|
119
84
|
export function guardUnsupportedTokens(text: string): string {
|
|
120
|
-
// Cheap pre-check: nothing to do unless a
|
|
121
|
-
if (
|
|
85
|
+
// Cheap pre-check: nothing to do unless a caret is present at all.
|
|
86
|
+
if (!text.includes("^")) return text;
|
|
122
87
|
return splitProtectedSegments(text)
|
|
123
88
|
.map((seg) => (seg.code ? seg.text : repairProse(seg.text)))
|
|
124
89
|
.join("");
|