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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Telegram inline `tg:` entities in mdast IMAGE position survive the whole
|
|
2
|
+
// outbound pipeline.
|
|
3
|
+
//
|
|
4
|
+
// Bot API grammar (https://core.telegram.org/bots/api, "Rich Markdown style"):
|
|
5
|
+
//
|
|
6
|
+
//  custom emoji
|
|
7
|
+
//  date_time
|
|
8
|
+
//
|
|
9
|
+
// (the `date_time` MessageEntity landed in Bot API 9.5, 2026-03-01; the
|
|
10
|
+
// rich-message `RichTextDateTime` class in 10.1, 2026-06-11 — Bot API
|
|
11
|
+
// changelog.) grammy 1.44.0 speaks 10.1, so the wire has accepted this since
|
|
12
|
+
// #2669 — but the RENDER path escaped the brackets to literal text, so the
|
|
13
|
+
// syntax was dead on the streamed send path.
|
|
14
|
+
//
|
|
15
|
+
// Two DISTINCT outbound surfaces are pinned here, because they do not share a
|
|
16
|
+
// pipeline:
|
|
17
|
+
// - the STREAMED path (stream-controller.ts:272) → `renderOutboundChunks` →
|
|
18
|
+
// `parse → renderSafe`. This is the surface the escaping bug lived on.
|
|
19
|
+
// - the REPLY-TOOL path (outbound-send-path.ts) → `computeReplyChunks` →
|
|
20
|
+
// `richMessage()`, which NEVER runs the renderer — only the
|
|
21
|
+
// accidental-formatting guards. It was already correct; these tests pin it
|
|
22
|
+
// so a future guard can't silently start eating the syntax.
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from "vitest";
|
|
25
|
+
import { parse } from "../../render/parse.js";
|
|
26
|
+
import { renderSafe, SUPPORTED_INLINE } from "../../render/render.js";
|
|
27
|
+
import { renderOutboundChunks } from "../../render/rich-render.js";
|
|
28
|
+
import { guardAccidentalFormatting, richMessage } from "../../rich-send.js";
|
|
29
|
+
import { computeReplyChunks } from "../../gateway/outbound-send-path.js";
|
|
30
|
+
import { splitMarkdownChunks, RICH_MESSAGE_MAX_CHARS } from "../../format.js";
|
|
31
|
+
import type { Inline } from "../../render/ir.js";
|
|
32
|
+
|
|
33
|
+
/** The doc's own date_time example, verbatim. */
|
|
34
|
+
const DATE_TIME = "";
|
|
35
|
+
/** The doc's own custom-emoji example, verbatim (empty label). */
|
|
36
|
+
const EMOJI = "";
|
|
37
|
+
|
|
38
|
+
/** parse → renderSafe, the streamed path's core. */
|
|
39
|
+
const roundTrip = (md: string): string => renderSafe(parse(md), md).text;
|
|
40
|
+
|
|
41
|
+
/** Flatten every inline node in the rendered IR of `md`. */
|
|
42
|
+
function inlines(md: string): Inline[] {
|
|
43
|
+
const out: Inline[] = [];
|
|
44
|
+
const walk = (n: Inline): void => {
|
|
45
|
+
out.push(n);
|
|
46
|
+
for (const c of (n as { children?: Inline[] }).children ?? []) walk(c);
|
|
47
|
+
};
|
|
48
|
+
for (const b of parse(md).blocks) {
|
|
49
|
+
for (const n of (b as { children?: Inline[] }).children ?? []) walk(n);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("tg:// inline entities: parse → renderSafe round-trip", () => {
|
|
55
|
+
// THE bug this PR fixes: at HEAD the image node demoted to `plain` and
|
|
56
|
+
// `escapeMarkdown` backslash-escaped `[`, `]` and `=`, shipping
|
|
57
|
+
// `!\[22:45 tomorrow\](tg://time?unix\=…&format\=wDT)` — literal text, no
|
|
58
|
+
// entity. Byte-exact preservation is the whole contract.
|
|
59
|
+
it("preserves a date_time entity byte-exact", () => {
|
|
60
|
+
const body = `Meeting at ${DATE_TIME} sharp.`;
|
|
61
|
+
expect(roundTrip(body)).toBe(body);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("preserves the empty-label custom-emoji entity byte-exact", () => {
|
|
65
|
+
expect(roundTrip(`hi ${EMOJI} there`)).toBe(`hi ${EMOJI} there`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("preserves a date_time entity with no `format` parameter", () => {
|
|
69
|
+
const body = "";
|
|
70
|
+
expect(roundTrip(body)).toBe(body);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("folds the entity into a `tg-entity` IR node, not `plain`", () => {
|
|
74
|
+
const node = inlines(`x ${DATE_TIME}`).find((n) => n.type === "tg-entity");
|
|
75
|
+
expect(node).toMatchObject({
|
|
76
|
+
type: "tg-entity",
|
|
77
|
+
label: "22:45 tomorrow",
|
|
78
|
+
href: "tg://time?unix=1647531900&format=wDT",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("survives inside a blockquote, a heading, a list item and a table cell", () => {
|
|
83
|
+
for (const body of [
|
|
84
|
+
`> when: ${DATE_TIME}`,
|
|
85
|
+
`## Due ${DATE_TIME}`,
|
|
86
|
+
`- due ${DATE_TIME}`,
|
|
87
|
+
`| when | who |\n| --- | --- |\n| ${DATE_TIME} | me |`,
|
|
88
|
+
]) {
|
|
89
|
+
expect(roundTrip(body)).toContain(DATE_TIME);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("matches the `tg:` href case-insensitively (URL schemes are)", () => {
|
|
94
|
+
const body = "";
|
|
95
|
+
// Folded as an entity, and the author's ORIGINAL bytes are re-emitted —
|
|
96
|
+
// the parser never rewrites the href.
|
|
97
|
+
expect(inlines(body).some((n) => n.type === "tg-entity")).toBe(true);
|
|
98
|
+
expect(roundTrip(body)).toBe(body);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("lists `tg-entity` in the renderer's construct allowlist", () => {
|
|
102
|
+
expect(SUPPORTED_INLINE).toContain("tg-entity");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("tg:// inline entities: label escaping (no bracket breakout)", () => {
|
|
107
|
+
// A model-authored label carrying `]` would close the label early and
|
|
108
|
+
// smuggle raw bracket syntax past the renderer if the label were re-emitted
|
|
109
|
+
// undecorated. It is prose, so it gets `escapeMarkdown` exactly like a
|
|
110
|
+
// `plain` node.
|
|
111
|
+
it("escapes brackets in the label instead of letting them close it early", () => {
|
|
112
|
+
const out = roundTrip("![see [22:45]](tg://time?unix=1&format=t)");
|
|
113
|
+
expect(out).toBe("![see \\[22:45\\]](tg://time?unix=1&format=t)");
|
|
114
|
+
// No BARE `]` before the destination — the only unescaped one closes the label.
|
|
115
|
+
expect(out.slice(0, out.indexOf("](")).includes("\\]")).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("escapes emphasis delimiters in the label", () => {
|
|
119
|
+
expect(roundTrip("")).toBe(
|
|
120
|
+
"",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("collapses a soft line break in the label so the construct stays on one line", () => {
|
|
125
|
+
const out = roundTrip("");
|
|
126
|
+
expect(out).toBe("");
|
|
127
|
+
expect(out).not.toContain("\n");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("tg:// inline entities: no regression of the image demotion", () => {
|
|
132
|
+
// Deliberate, unchanged: an http(s) `` is a Telegram MEDIA block —
|
|
133
|
+
// "Media can be specified only as a separate block" (Rich Markdown style) —
|
|
134
|
+
// not an inline entity, and switchroom emits no media blocks. It keeps
|
|
135
|
+
// degrading to escaped literal text.
|
|
136
|
+
it("still degrades an http(s) image link to escaped plain text", () => {
|
|
137
|
+
expect(roundTrip("")).toBe(
|
|
138
|
+
"!\\[alt\\](https://x.example/y.png)",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("still degrades an UNDOCUMENTED tg:// image href to escaped plain text", () => {
|
|
143
|
+
// The allowlist is deliberate: only `tg://time` and `tg://emoji` are
|
|
144
|
+
// documented in image position, so anything else stays literal rather than
|
|
145
|
+
// shipping syntax Telegram may parse-reject.
|
|
146
|
+
expect(roundTrip("")).toBe("!\\[t\\](tg://photo?id\\=1)");
|
|
147
|
+
expect(roundTrip("")).toBe("!\\[t\\](tg://user?id\\=1)");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("leaves an ordinary `[label](tg://user?id=…)` mention link alone", () => {
|
|
151
|
+
const body = "ping [Ken](tg://user?id=123456789)";
|
|
152
|
+
expect(roundTrip(body)).toBe(body);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("tg:// inline entities: code context stays literal", () => {
|
|
157
|
+
it("keeps the syntax verbatim inside an inline code span", () => {
|
|
158
|
+
const body = "use `` for that";
|
|
159
|
+
expect(roundTrip(body)).toBe(body);
|
|
160
|
+
// and it is a `code` node, NOT a folded entity
|
|
161
|
+
expect(inlines(body).some((n) => n.type === "tg-entity")).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps the syntax verbatim inside a fenced code block", () => {
|
|
165
|
+
const body = "```\n\n```";
|
|
166
|
+
expect(roundTrip(body)).toBe(body);
|
|
167
|
+
expect(inlines(body).some((n) => n.type === "tg-entity")).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("tg:// inline entities: the accidental-formatting guards are a no-op", () => {
|
|
172
|
+
// `guardAccidentalFormatting` is the universal seam (installed as a grammy
|
|
173
|
+
// API transformer in shared/bot-runtime.ts), so it runs over EVERY outbound
|
|
174
|
+
// body — rendered or hand-built card alike. Its documented trigger set
|
|
175
|
+
// (`_ * > . ~ == || $ #`) does not intersect this syntax, but nothing
|
|
176
|
+
// asserted that until now.
|
|
177
|
+
const bodies = [
|
|
178
|
+
`Meeting at ${DATE_TIME} sharp.`,
|
|
179
|
+
`${DATE_TIME} at the very start of the line`,
|
|
180
|
+
EMOJI,
|
|
181
|
+
`- due ${DATE_TIME}\n- and ${DATE_TIME}`,
|
|
182
|
+
`> when: ${DATE_TIME}`,
|
|
183
|
+
`| when |\n| --- |\n| ${DATE_TIME} |`,
|
|
184
|
+
"![see \\[22:45\\]](tg://time?unix=1&format=t)",
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
it("leaves every tg:// entity body byte-identical", () => {
|
|
188
|
+
for (const body of bodies) expect(guardAccidentalFormatting(body)).toBe(body);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("is idempotent over the rendered form too", () => {
|
|
192
|
+
for (const body of bodies) {
|
|
193
|
+
const rendered = roundTrip(body);
|
|
194
|
+
expect(guardAccidentalFormatting(rendered)).toBe(rendered);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("tg:// inline entities: live outbound seams", () => {
|
|
200
|
+
it("survives the STREAMED seam (renderOutboundChunks)", () => {
|
|
201
|
+
const body = `Standup starts ${DATE_TIME}.`;
|
|
202
|
+
const pieces = renderOutboundChunks(body);
|
|
203
|
+
expect(pieces).toHaveLength(1);
|
|
204
|
+
expect(pieces[0].mode).toBe("markdown");
|
|
205
|
+
expect(pieces[0].text).toBe(body);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("survives the REPLY-TOOL seam (computeReplyChunks → richMessage)", () => {
|
|
209
|
+
// This surface never touches the renderer — it is guards-only. Pinned so a
|
|
210
|
+
// future guard cannot start escaping the syntax unnoticed.
|
|
211
|
+
const body = `Standup starts ${DATE_TIME}.`;
|
|
212
|
+
const chunks = computeReplyChunks({
|
|
213
|
+
effectiveText: body,
|
|
214
|
+
literalText: false,
|
|
215
|
+
limit: RICH_MESSAGE_MAX_CHARS,
|
|
216
|
+
chunkMode: "length",
|
|
217
|
+
});
|
|
218
|
+
expect(chunks.map((c) => richMessage(c).markdown).join("")).toContain(DATE_TIME);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("tg:// inline entities: chunk boundaries never bisect the construct", () => {
|
|
223
|
+
// `INLINE_SPAN_PATTERNS`' link pattern used to start at the `[`, leaving the
|
|
224
|
+
// leading `!` outside the protected span: a cut inside the construct
|
|
225
|
+
// retreated only to the `[`, stranding `!` on the previous chunk and
|
|
226
|
+
// silently demoting a date_time entity to an ordinary link.
|
|
227
|
+
it("keeps a cap-straddling date_time entity whole in one chunk", () => {
|
|
228
|
+
const body = `${"x".repeat(80)} ${DATE_TIME}`;
|
|
229
|
+
const chunks = splitMarkdownChunks(body, 110);
|
|
230
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
231
|
+
// Exactly one chunk carries the entity, whole.
|
|
232
|
+
expect(chunks.filter((c) => c.includes(DATE_TIME))).toHaveLength(1);
|
|
233
|
+
// And no chunk ends on the stranded `!`.
|
|
234
|
+
for (const c of chunks) expect(c.endsWith("!")).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("still keeps an ordinary link whole (unchanged behaviour)", () => {
|
|
238
|
+
const link = "[a fairly long link label here](https://example.com/some/path)";
|
|
239
|
+
const chunks = splitMarkdownChunks(`${"x".repeat(80)} ${link}`, 110);
|
|
240
|
+
expect(chunks.filter((c) => c.includes(link))).toHaveLength(1);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -3,44 +3,61 @@ import { guardUnsupportedTokens } from "../../render/unsupported-token-guard.js"
|
|
|
3
3
|
import { richMessage } from "../../rich-send.js";
|
|
4
4
|
|
|
5
5
|
describe("guardUnsupportedTokens — deterministic send-time repair", () => {
|
|
6
|
-
|
|
6
|
+
// ── Natively-supported constructs pass through BYTE-IDENTICAL ────────────
|
|
7
|
+
// Wire-verified 2026-08-13: raw sendRichMessage probes showed `<details>`,
|
|
8
|
+
// footnotes, `<sub>`/`<sup>`/`<u>`, `<aside>`, `tg://time` and task lists
|
|
9
|
+
// all parse into real typed nodes on Telegram's rich markdown path. An
|
|
10
|
+
// earlier revision of this guard "repaired" `<details>` into a `**> `
|
|
11
|
+
// expandable blockquote (MarkdownV2-only syntax that renders as LITERAL
|
|
12
|
+
// `**>` text on this path) and deleted footnote markers — both conversions
|
|
13
|
+
// destroyed supported constructs and are deleted. These tests would FAIL on
|
|
14
|
+
// the old guard.
|
|
15
|
+
it("passes a <details><summary> block through untouched (native construct)", () => {
|
|
7
16
|
const input =
|
|
8
|
-
"Here is the trace:\n<details><summary>Stack trace</summary>\nline 1\nline 2\n</details>\ndone";
|
|
9
|
-
|
|
10
|
-
// Anti-tautology: the raw HTML tags MUST be gone from the wire body.
|
|
11
|
-
expect(out).not.toContain("<details>");
|
|
12
|
-
expect(out).not.toContain("</details>");
|
|
13
|
-
expect(out).not.toContain("<summary>");
|
|
14
|
-
// Summary becomes the expandable-blockquote first line (`**> ` marker).
|
|
15
|
-
expect(out).toContain("**> Stack trace");
|
|
16
|
-
// Body lines become plain `> ` continuation lines.
|
|
17
|
-
expect(out).toContain("> line 1");
|
|
18
|
-
expect(out).toContain("> line 2");
|
|
17
|
+
"Here is the trace:\n<details open><summary>Stack trace</summary>\n\nline 1\nline 2\n\n</details>\ndone";
|
|
18
|
+
expect(guardUnsupportedTokens(input)).toBe(input);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it("
|
|
22
|
-
const
|
|
23
|
-
expect(
|
|
24
|
-
expect(out).toContain("**> hidden body text");
|
|
21
|
+
it("passes a <details> without a <summary> through untouched", () => {
|
|
22
|
+
const input = "<details>hidden body text</details>";
|
|
23
|
+
expect(guardUnsupportedTokens(input)).toBe(input);
|
|
25
24
|
});
|
|
26
25
|
|
|
27
|
-
it("
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
);
|
|
31
|
-
expect(guardUnsupportedTokens("a ^highlighted^ word")).toBe(
|
|
32
|
-
"a highlighted word",
|
|
26
|
+
it("never converts <details> into the retired `**>` marker", () => {
|
|
27
|
+
const out = guardUnsupportedTokens(
|
|
28
|
+
"<details><summary>More</summary>\nbody\n</details>",
|
|
33
29
|
);
|
|
30
|
+
expect(out).not.toContain("**>");
|
|
31
|
+
expect(out).toContain("<details>");
|
|
34
32
|
});
|
|
35
33
|
|
|
36
|
-
it("
|
|
34
|
+
it("keeps footnote reference markers AND definition lines (native construct)", () => {
|
|
37
35
|
expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
|
|
38
|
-
"see the note here",
|
|
36
|
+
"see the note[^1] here",
|
|
39
37
|
);
|
|
40
|
-
// A `[^1]:` definition line is left intact (the negative lookahead).
|
|
41
38
|
expect(guardUnsupportedTokens("[^1]: the definition")).toBe(
|
|
42
39
|
"[^1]: the definition",
|
|
43
40
|
);
|
|
41
|
+
// Alphanumeric ids too — the pair renders as real footnote machinery.
|
|
42
|
+
expect(guardUnsupportedTokens("claim[^note] holds\n\n[^note]: body")).toBe(
|
|
43
|
+
"claim[^note] holds\n\n[^note]: body",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("passes <sub>/<sup>/<u>/<aside> HTML tags through untouched", () => {
|
|
48
|
+
const input =
|
|
49
|
+
"H<sub>2</sub>O and x<sup>2</sup> and <u>under</u>\n<aside>pull\n<cite>credit</cite></aside>";
|
|
50
|
+
expect(guardUnsupportedTokens(input)).toBe(input);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── The one genuinely-unsupported token: caret pairs ─────────────────────
|
|
54
|
+
it("strips caret highlight / superscript pairs to their inner text", () => {
|
|
55
|
+
expect(guardUnsupportedTokens("energy is x^2^ joules")).toBe(
|
|
56
|
+
"energy is x2 joules",
|
|
57
|
+
);
|
|
58
|
+
expect(guardUnsupportedTokens("a ^highlighted^ word")).toBe(
|
|
59
|
+
"a highlighted word",
|
|
60
|
+
);
|
|
44
61
|
});
|
|
45
62
|
|
|
46
63
|
it("leaves unpaired carets scattered across prose intact (no interior space)", () => {
|
|
@@ -71,27 +88,18 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
|
|
|
71
88
|
expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
|
|
72
89
|
});
|
|
73
90
|
|
|
74
|
-
it("
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(guardUnsupportedTokens(
|
|
81
|
-
"as shown above",
|
|
82
|
-
);
|
|
83
|
-
expect(guardUnsupportedTokens("point[^fn1] made")).toBe("point made");
|
|
84
|
-
// A single-letter id is a footnote too.
|
|
85
|
-
expect(guardUnsupportedTokens("claim[^a] holds")).toBe("claim holds");
|
|
86
|
-
// Definition lines with an alphanumeric id are still left intact (`(?!:)`).
|
|
87
|
-
expect(guardUnsupportedTokens("[^note]: the definition")).toBe(
|
|
88
|
-
"[^note]: the definition",
|
|
89
|
-
);
|
|
91
|
+
it("never strips carets inside a $…$ math span (protected segment)", () => {
|
|
92
|
+
// `$x^2y^2$` contains an alphanumeric-only caret pair (`^2y^`) that the
|
|
93
|
+
// caret regex WOULD strip in bare prose — but the compact math span is a
|
|
94
|
+
// protected segment (code-segments.ts), so the wire bytes survive intact
|
|
95
|
+
// for Telegram to typeset as a mathematical_expression node.
|
|
96
|
+
const math = "inline $x^2y^2$ done";
|
|
97
|
+
expect(guardUnsupportedTokens(math)).toBe(math);
|
|
90
98
|
});
|
|
91
99
|
|
|
92
100
|
it("leaves regex-literal negated char classes intact", () => {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
101
|
+
// The guard no longer touches `[^…]` at all (footnotes are supported),
|
|
102
|
+
// so every negated-char-class shape survives verbatim.
|
|
95
103
|
expect(guardUnsupportedTokens("use [^/] to match")).toBe(
|
|
96
104
|
"use [^/] to match",
|
|
97
105
|
);
|
|
@@ -99,23 +107,14 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
|
|
|
99
107
|
"strip [^a-z] chars",
|
|
100
108
|
);
|
|
101
109
|
expect(guardUnsupportedTokens('match [^"] here')).toBe('match [^"] here');
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// which splitProtectedSegments masks (see code-span test below).
|
|
106
|
-
expect(guardUnsupportedTokens("array[^index] lookup")).toBe("array lookup");
|
|
107
|
-
// …but inside a code span the same literal is untouched.
|
|
110
|
+
expect(guardUnsupportedTokens("array[^index] lookup")).toBe(
|
|
111
|
+
"array[^index] lookup",
|
|
112
|
+
);
|
|
108
113
|
expect(guardUnsupportedTokens("`array[^index]` lookup")).toBe(
|
|
109
114
|
"`array[^index]` lookup",
|
|
110
115
|
);
|
|
111
116
|
});
|
|
112
117
|
|
|
113
|
-
it("repairs a real numeric footnote marker", () => {
|
|
114
|
-
expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
|
|
115
|
-
"see the note here",
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
118
|
it("is a strict no-op for clean markdown (no target tokens)", () => {
|
|
120
119
|
const clean =
|
|
121
120
|
"**Answer:** the `config.yaml` file. See [docs](https://example.com/x).";
|
|
@@ -139,18 +138,19 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
|
|
|
139
138
|
expect(guardUnsupportedTokens(once)).toBe(once);
|
|
140
139
|
});
|
|
141
140
|
|
|
142
|
-
it("OUTCOME: the composed richMessage wire body
|
|
143
|
-
// End-to-end through the real send-path composition
|
|
144
|
-
// anti-tautology anchor:
|
|
145
|
-
//
|
|
146
|
-
//
|
|
141
|
+
it("OUTCOME: the composed richMessage wire body preserves supported constructs and repairs carets", () => {
|
|
142
|
+
// End-to-end through the real send-path composition (the FULL
|
|
143
|
+
// guardAccidentalFormatting pipeline). This is the anti-tautology anchor:
|
|
144
|
+
// on the pre-fix pipeline, `<details>` was folded into the unsupported
|
|
145
|
+
// `**>` marker and `[^1]` was deleted — every assertion below would FAIL.
|
|
147
146
|
const { markdown } = richMessage(
|
|
148
|
-
"note[^1]\n<details><summary>More</summary>\ndetail line\n</details>\nx^2^",
|
|
147
|
+
"note[^1]\n<details><summary>More</summary>\ndetail line\n</details>\nx^2^\n\n[^1]: the note",
|
|
149
148
|
);
|
|
150
|
-
expect(markdown).
|
|
151
|
-
expect(markdown).
|
|
152
|
-
expect(markdown).toContain("
|
|
153
|
-
expect(markdown).toContain("
|
|
154
|
-
expect(markdown).toContain("
|
|
149
|
+
expect(markdown).toContain("<details><summary>More</summary>");
|
|
150
|
+
expect(markdown).toContain("</details>");
|
|
151
|
+
expect(markdown).toContain("note[^1]");
|
|
152
|
+
expect(markdown).toContain("[^1]: the note");
|
|
153
|
+
expect(markdown).not.toContain("**>");
|
|
154
|
+
expect(markdown).toContain("x2"); // caret pair repaired
|
|
155
155
|
});
|
|
156
156
|
});
|
|
@@ -43,9 +43,9 @@ const CHAT = 5550001
|
|
|
43
43
|
/**
|
|
44
44
|
* The response Telegram actually returns for `sendRichMessage` — the body lives
|
|
45
45
|
* in `rich_message.blocks`; `text` and `caption` are ABSENT. Verified against
|
|
46
|
-
* `@grammyjs/types`
|
|
47
|
-
* `message.d.ts:
|
|
48
|
-
* MsgWith<"rich_message">`) and `:
|
|
46
|
+
* `@grammyjs/types` 4.0.0 (the version `grammy@^1.45` resolves)
|
|
47
|
+
* `message.d.ts:98` (`RichMessageMessage = CommonMessage &
|
|
48
|
+
* MsgWith<"rich_message">`) and `:184` (`rich_message?: RichMessage`), and
|
|
49
49
|
* against the Bot API reference: `sendRichMessage` "On success, the sent
|
|
50
50
|
* Message is returned", `Message.rich_message: RichMessage` "Optional. Message
|
|
51
51
|
* is a rich formatted message", `RichMessage.blocks` "Content of the message".
|
|
@@ -165,7 +165,7 @@ describe('markdownToTelegraphNodes — block elements', () => {
|
|
|
165
165
|
})
|
|
166
166
|
|
|
167
167
|
it('folds an expandable `**>` quote into one blockquote, marker stripped', () => {
|
|
168
|
-
// The
|
|
168
|
+
// The LEGACY switchroom expandable-quote encoding: `**>` opener on the first
|
|
169
169
|
// line, `>` continuation lines. Telegra.ph has no collapsible-blockquote
|
|
170
170
|
// tag, so it degrades to a normal blockquote — but the `**>` marker MUST be
|
|
171
171
|
// recognised. Before the fix the `**>` line became a paragraph with literal
|
|
@@ -271,13 +271,16 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
|
|
|
271
271
|
);
|
|
272
272
|
|
|
273
273
|
// ---------------------------------------------------------------------------
|
|
274
|
-
// Rich-render wiring proof —
|
|
274
|
+
// Rich-render wiring proof — legacy `**> ` quote repair round-trip.
|
|
275
275
|
//
|
|
276
276
|
// This is the piece the unit suite CANNOT prove: that a REAL markdown reply
|
|
277
|
-
// carrying the
|
|
277
|
+
// carrying the LEGACY switchroom expandable-quote marker (`**> `) flows
|
|
278
278
|
// through the live send path — parse.ts (marker -> IR `expandable: true`) ->
|
|
279
|
-
// render.ts (IR ->
|
|
280
|
-
//
|
|
279
|
+
// render.ts (IR -> plain `> ` markdown; `**>` is MarkdownV2-only and renders
|
|
280
|
+
// as literal text on the rich path, wire-proved 2026-08-13, so the renderer
|
|
281
|
+
// no longer emits it) -> renderSafe -> sendRichMessage — and that Telegram
|
|
282
|
+
// parses the repaired output back to a blockquote entity with NO literal
|
|
283
|
+
// `**>` glyphs reaching the reader.
|
|
281
284
|
//
|
|
282
285
|
// Runs ONLY when the rich-render flag is on in this process (see
|
|
283
286
|
// RICH_RENDER_ON) AND the driver creds are present; self-skips green
|
|
@@ -299,7 +302,7 @@ const EXPANDABLE_SAMPLE = [
|
|
|
299
302
|
"uat: expandable/collapsible content round-trips through the live renderer",
|
|
300
303
|
() => {
|
|
301
304
|
it(
|
|
302
|
-
"a `**>` reply
|
|
305
|
+
"a legacy `**>` reply is repaired to a plain quote and decodes as a blockquote entity",
|
|
303
306
|
async () => {
|
|
304
307
|
const sc = await spinUp({ agent: AGENT });
|
|
305
308
|
try {
|
|
@@ -318,9 +321,9 @@ const EXPANDABLE_SAMPLE = [
|
|
|
318
321
|
).not.toBe("\x01");
|
|
319
322
|
expect(reply.text).toContain(EXP_MARKER);
|
|
320
323
|
|
|
321
|
-
// The
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
+
// The legacy quote must survive as a real blockquote entity on the
|
|
325
|
+
// wire — proof the parse->render->send path repaired the `**>` line
|
|
326
|
+
// into the quote instead of shipping stray literal text.
|
|
324
327
|
const present = kindsPresent(reply);
|
|
325
328
|
expect(
|
|
326
329
|
[...present],
|
|
@@ -331,6 +334,12 @@ const EXPANDABLE_SAMPLE = [
|
|
|
331
334
|
// The quoted body text must round-trip (content never lost).
|
|
332
335
|
expect(reply.text).toContain("first hidden line");
|
|
333
336
|
|
|
337
|
+
// And the retired marker must never reach the reader as literal
|
|
338
|
+
// glyphs — the exact failure mode of the pre-repair renderer
|
|
339
|
+
// (Telegram rendered the `**> ` opener as a literal `**>` paragraph
|
|
340
|
+
// while the `> ` continuation lines formed a detached quote).
|
|
341
|
+
expect(reply.text).not.toContain("**>");
|
|
342
|
+
|
|
334
343
|
console.info(
|
|
335
344
|
"[uat] expandable round-trip entity structure: " +
|
|
336
345
|
JSON.stringify(
|