switchroom 0.21.8 → 0.21.10
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 +919 -348
- package/telegram-plugin/format.ts +30 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +7 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- 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/html-fold.ts +354 -0
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +642 -42
- 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/html-dialect-content-loss.test.ts +253 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +39 -10
- 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/send-reply-golden.test.ts +99 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
- package/telegram-plugin/voice-normalize-text.ts +48 -9
|
@@ -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("");
|
|
@@ -54,16 +54,31 @@ export interface InputRichMessageMarkdown {
|
|
|
54
54
|
* guards is disjoint in the characters it inspects AND the characters it
|
|
55
55
|
* inserts (`\_ \* \> \. \~ \=\= \|\|` vs `\$`), so no other insertion can
|
|
56
56
|
* create or destroy a signal for a sibling. Verified by composition tests.
|
|
57
|
+
*
|
|
58
|
+
* Intentional inline math (`$x^2+y^2$`, a native Telegram construct) is
|
|
59
|
+
* PROTECTED from every sub-guard: `splitProtectedSegments` treats a compact
|
|
60
|
+
* non-currency `$…$` span like a code span (see code-segments.ts), so the
|
|
61
|
+
* dollar guard neither counts nor escapes its `$` and the emphasis/caret
|
|
62
|
+
* guards never rewrite its interior.
|
|
57
63
|
*/
|
|
58
64
|
export function guardAccidentalFormatting(markdown: string): string {
|
|
59
65
|
let out = markdown
|
|
60
|
-
// Repair Telegram-unrenderable tokens FIRST
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
66
|
+
// Repair Telegram-unrenderable tokens FIRST. This pass now strips ONLY caret
|
|
67
|
+
// pairs (`^x^` → `x`): removing a `^` inserts no trigger char for any
|
|
68
|
+
// sibling guard, so it neither creates nor destroys their signals.
|
|
69
|
+
//
|
|
70
|
+
// What this pass deliberately NO LONGER does (wire-verified 2026-08-13 by
|
|
71
|
+
// raw `sendRichMessage` probes): it used to fold `<details>` into a `**> `
|
|
72
|
+
// "expandable blockquote" and delete footnote markers `[^1]`. Both beliefs
|
|
73
|
+
// were false — `<details><summary>` and footnotes are NATIVE rich-markdown
|
|
74
|
+
// constructs (typed `details` / footnote nodes on the wire), while `**>` is
|
|
75
|
+
// MarkdownV2-only syntax the rich path renders as LITERAL `**>` text. The
|
|
76
|
+
// conversion destroyed a supported construct to emit an unsupported one, so
|
|
77
|
+
// it is deleted. `<details>`, footnotes, `<sub>`/`<sup>`/`<u>`, `<aside>`,
|
|
78
|
+
// `tg://` links and task lists all pass through every guard untouched: none
|
|
79
|
+
// of `< > [ ] : /` is a trigger char for the emphasis / heading /
|
|
80
|
+
// block-construct / inline-pair / dollar guards ('composition' tests cover
|
|
81
|
+
// this interaction).
|
|
67
82
|
out = guardUnsupportedTokens(out)
|
|
68
83
|
out = guardAccidentalEmphasis(out)
|
|
69
84
|
out = guardAccidentalHeading(out)
|
|
@@ -251,8 +251,9 @@ export function installTgPostLogger(bot: Bot): void {
|
|
|
251
251
|
* call site, `ctx.*` sugar, `lockedBot`, or `bot.api.raw`, so it closes the
|
|
252
252
|
* whole bypass class deterministically.
|
|
253
253
|
*
|
|
254
|
-
* Payload shape (verified against grammy 1.
|
|
255
|
-
* lockfile version
|
|
254
|
+
* Payload shape (verified against grammy 1.45.1 `out/core/api.js`, the pinned
|
|
255
|
+
* lockfile version — byte-identical to the 1.44.0 shape this was originally
|
|
256
|
+
* written against, re-checked on the 1.45.1 bump):
|
|
256
257
|
* - `sendRichMessage(chat_id, rich_message, ...)` → raw payload
|
|
257
258
|
* `{ chat_id, rich_message: { markdown }, ... }`
|
|
258
259
|
* - `editMessageText(chat_id, message_id, arg, ...)` → raw payload
|
|
@@ -193,10 +193,12 @@ export async function createTelegraphPage(
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
* A blockquote line: a plain `> ` marker OR the expandable-blockquote
|
|
197
|
-
* `**> ` (
|
|
198
|
-
*
|
|
199
|
-
* `
|
|
196
|
+
* A blockquote line: a plain `> ` marker OR the LEGACY expandable-blockquote
|
|
197
|
+
* opener `**> ` (a switchroom encoding once believed to be Bot API 10.1
|
|
198
|
+
* syntax; wire probes 2026-08-13 showed the rich path renders `**>` as
|
|
199
|
+
* literal text, so `render/render.ts` no longer emits it — but legacy agent
|
|
200
|
+
* output still contains it and it must be tolerated on input here).
|
|
201
|
+
* Telegra.ph has no collapsible
|
|
200
202
|
* blockquote tag, so an expandable quote degrades to a normal `<blockquote>`;
|
|
201
203
|
* the `**` prefix must still be recognised and stripped here, else the
|
|
202
204
|
* unterminated `**` renders as literal `**>` text and the `>` continuation
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time contract for the grammy 1.44.0 → 1.45.1 bump
|
|
3
|
+
* (`@grammyjs/types` 3.28.0 → 4.0.0).
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists
|
|
6
|
+
* --------------------
|
|
7
|
+
* 4.0.0 turned `InputRichMessage` from a plain interface into a GENERIC one:
|
|
8
|
+
*
|
|
9
|
+
* 3.28.0 `export interface InputRichMessage {` (rich.d.ts:280)
|
|
10
|
+
* 4.0.0 `export interface InputRichMessage<F> {` (rich.d.ts:283)
|
|
11
|
+
*
|
|
12
|
+
* `F` is the file-payload type threaded through the new Bot API 10.2 `blocks`
|
|
13
|
+
* / `media` fields. Switchroom never uses either — every outbound message is
|
|
14
|
+
* the plain `{ markdown }` shape (`rich-send.ts:96`), and the plugin defines
|
|
15
|
+
* its OWN local `InputRichMessageMarkdown` (`rich-send.ts:28`) rather than
|
|
16
|
+
* importing grammy's. So the bump is only safe as long as a bare
|
|
17
|
+
* `{ markdown: string }` literal still satisfies the generic parameter at
|
|
18
|
+
* every call site.
|
|
19
|
+
*
|
|
20
|
+
* That invariant is invisible to the rest of the suite for two reasons, and
|
|
21
|
+
* both are why this test spends ~1s on a real compile instead of asserting on
|
|
22
|
+
* runtime values:
|
|
23
|
+
*
|
|
24
|
+
* 1. Every send in the regression suite is MOCKED — no mock can notice that
|
|
25
|
+
* a payload stopped typechecking.
|
|
26
|
+
* 2. The repo's `tsc --noEmit` does NOT cover this directory. The root
|
|
27
|
+
* `tsconfig.json` `include` is `["src/**\/*.ts", "bin/**\/*.ts",
|
|
28
|
+
* "scripts/**\/*.ts"]` — `telegram-plugin/` is absent, so a type error
|
|
29
|
+
* here is invisible to `npm run lint` (verified empirically: a deliberate
|
|
30
|
+
* `const x: number = "s"` in `rich-send.ts` leaves `tsc --noEmit` at exit
|
|
31
|
+
* 0). A plain `.ts` fixture would therefore be a NO-OP as a guard.
|
|
32
|
+
*
|
|
33
|
+
* So the check has to run the compiler itself. The negative controls below are
|
|
34
|
+
* load-bearing: if the harness ever silently stopped compiling (bad fixture
|
|
35
|
+
* path, unresolved `grammy`, swallowed diagnostics), the "must fail" cases
|
|
36
|
+
* would go green and this file goes red — it cannot rot into a vacuous pass.
|
|
37
|
+
*/
|
|
38
|
+
import { describe, it, expect, afterAll } from 'vitest'
|
|
39
|
+
import ts from 'typescript'
|
|
40
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
41
|
+
import { fileURLToPath } from 'node:url'
|
|
42
|
+
import { dirname, join, resolve } from 'node:path'
|
|
43
|
+
|
|
44
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
|
|
46
|
+
// Fixtures must live INSIDE the repo tree: they `import 'grammy'`, and module
|
|
47
|
+
// resolution walks up from the file to the hoisted root `node_modules`. A
|
|
48
|
+
// fixture in `os.tmpdir()` would fail to resolve grammy and every case would
|
|
49
|
+
// report a misleading "cannot find module" instead of the real answer.
|
|
50
|
+
const fixtureDir = mkdtempSync(join(here, 'grammy-types-fixture-'))
|
|
51
|
+
afterAll(() => rmSync(fixtureDir, { recursive: true, force: true }))
|
|
52
|
+
|
|
53
|
+
/** Mirrors the compiler settings the plugin is actually authored against. */
|
|
54
|
+
const COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
55
|
+
target: ts.ScriptTarget.ES2022,
|
|
56
|
+
module: ts.ModuleKind.ESNext,
|
|
57
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
58
|
+
strict: true,
|
|
59
|
+
// Matches the root tsconfig: we are asserting on OUR call shapes, not
|
|
60
|
+
// auditing grammy's own .d.ts files.
|
|
61
|
+
skipLibCheck: true,
|
|
62
|
+
noEmit: true,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Typecheck one fixture source and return its syntactic + semantic errors,
|
|
67
|
+
* formatted `TSxxxx: message`. Empty array means "compiles clean".
|
|
68
|
+
*/
|
|
69
|
+
function typecheck(name: string, source: string): string[] {
|
|
70
|
+
const file = resolve(fixtureDir, `${name}.ts`)
|
|
71
|
+
writeFileSync(file, source)
|
|
72
|
+
const program = ts.createProgram([file], COMPILER_OPTIONS)
|
|
73
|
+
return ts
|
|
74
|
+
.getPreEmitDiagnostics(program)
|
|
75
|
+
.filter((d) => d.file?.fileName === file.split('\\').join('/'))
|
|
76
|
+
.map((d) => `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('grammy rich-message input still accepts the plain { markdown } shape', () => {
|
|
80
|
+
it('accepts a bare { markdown } literal on sendRichMessage and editMessageText', () => {
|
|
81
|
+
expect(
|
|
82
|
+
typecheck(
|
|
83
|
+
'plain-literal',
|
|
84
|
+
`
|
|
85
|
+
import type { Bot } from 'grammy'
|
|
86
|
+
declare const bot: Bot
|
|
87
|
+
export async function send(): Promise<void> {
|
|
88
|
+
await bot.api.sendRichMessage(1, { markdown: 'hi' })
|
|
89
|
+
await bot.api.editMessageText(1, 2, { markdown: 'hi' })
|
|
90
|
+
}
|
|
91
|
+
`,
|
|
92
|
+
),
|
|
93
|
+
).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("accepts the production helper's return type (rich-send.ts richMessage())", () => {
|
|
97
|
+
// The real seam: `richMessage()` returns the plugin's OWN local
|
|
98
|
+
// `InputRichMessageMarkdown`, which must stay structurally assignable to
|
|
99
|
+
// grammy's generic parameter. This is the assertion that would break if a
|
|
100
|
+
// future @grammyjs/types made `blocks`/`media` mandatory or constrained `F`.
|
|
101
|
+
expect(
|
|
102
|
+
typecheck(
|
|
103
|
+
'production-helper',
|
|
104
|
+
`
|
|
105
|
+
import type { Bot } from 'grammy'
|
|
106
|
+
import { richMessage } from '${join(here, '..', 'rich-send.js').split('\\').join('/')}'
|
|
107
|
+
declare const bot: Bot
|
|
108
|
+
export async function send(): Promise<void> {
|
|
109
|
+
await bot.api.sendRichMessage(1, richMessage('hi'))
|
|
110
|
+
await bot.api.editMessageText(1, 2, richMessage('hi'))
|
|
111
|
+
}
|
|
112
|
+
`,
|
|
113
|
+
),
|
|
114
|
+
).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('still accepts a { markdown } payload through bot.api.raw', () => {
|
|
118
|
+
// `installRichMarkdownGuard` (shared/bot-runtime.ts) inspects the RAW
|
|
119
|
+
// payload `{ chat_id, rich_message: { markdown } }`, so pin that shape too.
|
|
120
|
+
expect(
|
|
121
|
+
typecheck(
|
|
122
|
+
'raw-payload',
|
|
123
|
+
`
|
|
124
|
+
import type { Bot } from 'grammy'
|
|
125
|
+
declare const bot: Bot
|
|
126
|
+
export async function send(): Promise<void> {
|
|
127
|
+
await bot.api.raw.sendRichMessage({ chat_id: 1, rich_message: { markdown: 'hi' } })
|
|
128
|
+
}
|
|
129
|
+
`,
|
|
130
|
+
),
|
|
131
|
+
).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// ── Negative controls: prove the harness has teeth ──────────────────────
|
|
135
|
+
// Without these, every assertion above would pass just as happily against a
|
|
136
|
+
// harness that had silently stopped compiling anything at all.
|
|
137
|
+
|
|
138
|
+
it('NEGATIVE CONTROL: rejects a wrongly-typed markdown field', () => {
|
|
139
|
+
const errors = typecheck(
|
|
140
|
+
'wrong-type',
|
|
141
|
+
`
|
|
142
|
+
import type { Bot } from 'grammy'
|
|
143
|
+
declare const bot: Bot
|
|
144
|
+
export async function send(): Promise<void> {
|
|
145
|
+
await bot.api.sendRichMessage(1, { markdown: 123 })
|
|
146
|
+
}
|
|
147
|
+
`,
|
|
148
|
+
)
|
|
149
|
+
expect(errors.join('\n')).toContain('TS2322')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('NEGATIVE CONTROL: rejects an unknown field on the rich-message literal', () => {
|
|
153
|
+
const errors = typecheck(
|
|
154
|
+
'unknown-field',
|
|
155
|
+
`
|
|
156
|
+
import type { Bot } from 'grammy'
|
|
157
|
+
declare const bot: Bot
|
|
158
|
+
export async function send(): Promise<void> {
|
|
159
|
+
await bot.api.sendRichMessage(1, { markdwon: 'typo' })
|
|
160
|
+
}
|
|
161
|
+
`,
|
|
162
|
+
)
|
|
163
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('grammy supply-chain pin', () => {
|
|
168
|
+
// Assert on `bun.lock` rather than the resolved package: grammy's `exports`
|
|
169
|
+
// map deliberately hides `./package.json`, and the lockfile is the artifact
|
|
170
|
+
// CI actually installs from (`bun install --frozen-lockfile`), so it is both
|
|
171
|
+
// reachable and the more honest source of truth for what ships.
|
|
172
|
+
const lock = readFileSync(resolve(here, '..', '..', 'bun.lock'), 'utf8')
|
|
173
|
+
|
|
174
|
+
/** Pull the resolved version bun.lock pins for a package. */
|
|
175
|
+
function lockedVersion(pkg: string): string {
|
|
176
|
+
const escaped = pkg.replace('/', '\\/')
|
|
177
|
+
const m = new RegExp(`"${escaped}": \\["${escaped}@(\\d+\\.\\d+\\.\\d+)"`).exec(lock)
|
|
178
|
+
if (!m) throw new Error(`no bun.lock pin found for ${pkg}`)
|
|
179
|
+
return m[1]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// These assert a FLOOR, not an exact version: a later 1.46/5.x bump is a
|
|
183
|
+
// legitimate change and must not go red here for no substantive reason. What
|
|
184
|
+
// must never happen silently is a slide BACK below the floor — that would
|
|
185
|
+
// return @grammyjs/types to 3.28.0 and drop the Bot API 10.2 surface this
|
|
186
|
+
// bump exists to unlock, while every assertion above stayed green (the plain
|
|
187
|
+
// `{ markdown }` shape compiles under both).
|
|
188
|
+
|
|
189
|
+
it('keeps grammy at or above 1.45 (the floor that ships @grammyjs/types 4.x)', () => {
|
|
190
|
+
const [major, minor] = lockedVersion('grammy').split('.').map(Number)
|
|
191
|
+
expect(major).toBe(1)
|
|
192
|
+
expect(minor).toBeGreaterThanOrEqual(45)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('keeps @grammyjs/types at or above the 4.x major grammy 1.45 depends on', () => {
|
|
196
|
+
const [major] = lockedVersion('@grammyjs/types').split('.').map(Number)
|
|
197
|
+
expect(major).toBeGreaterThanOrEqual(4)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -160,3 +160,46 @@ describe("guardDollarMath — link / table awareness (findings 1 & 3)", () => {
|
|
|
160
160
|
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("guardDollarMath — intentional math spans are exempt (wire-verified 2026-08-13)", () => {
|
|
165
|
+
it("never escapes a compact `$…$` math span", () => {
|
|
166
|
+
// Telegram renders `$x^2+y^2$` as a native mathematical_expression node;
|
|
167
|
+
// escaping it destroys a SUPPORTED construct. On the pre-fix guard this
|
|
168
|
+
// input came back as `inline \$x^2+y^2\$ done`.
|
|
169
|
+
const s = "inline $x^2+y^2$ done";
|
|
170
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("math span dollars do not count toward the 2+ arming threshold", () => {
|
|
174
|
+
// Only ONE prose `$` outside the math span → can never pair → untouched.
|
|
175
|
+
const s = "solve $x^2+y^2$ for the $BUDGET case";
|
|
176
|
+
expect(guardDollarMath(s)).toBe(s);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("still escapes currency in the SAME message as an exempt math span", () => {
|
|
180
|
+
const out = guardDollarMath("sum $x^2+y^2$ costs $5 and $10");
|
|
181
|
+
expect(out).toContain("$x^2+y^2$");
|
|
182
|
+
expect(out).toContain("\\$5");
|
|
183
|
+
expect(out).toContain("\\$10");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("a currency-shaped compact pair (`$5M-$10M`) is NOT treated as math", () => {
|
|
187
|
+
// The `$5M-$` inner run is digits/magnitude punctuation only — two
|
|
188
|
+
// adjacent amounts, the exact #3252 accident. Must still be escaped.
|
|
189
|
+
const out = guardDollarMath("the range is $5M-$10M this year");
|
|
190
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("spaced `$…$` spans are NOT exempt (indistinguishable from currency prose)", () => {
|
|
194
|
+
// Documented residual: `$a + b$` cannot be told apart from two stray
|
|
195
|
+
// currency signs, so when a currency signal arms the guard it is escaped
|
|
196
|
+
// (renders as literal text — legible, not broken).
|
|
197
|
+
const out = guardDollarMath("we know $a + b$ plus $5 fees");
|
|
198
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("is idempotent with a math span present", () => {
|
|
202
|
+
const once = guardDollarMath("sum $x^2+y^2$ costs $5 and $10");
|
|
203
|
+
expect(guardDollarMath(once)).toBe(once);
|
|
204
|
+
});
|
|
205
|
+
});
|