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
|
@@ -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
|
+
});
|
|
@@ -135,4 +135,106 @@ describe("guardAccidentalFormatting composition (#3252)", () => {
|
|
|
135
135
|
// And re-wrapping (streaming re-render) is byte-stable.
|
|
136
136
|
expect(richMessage(wire.markdown).markdown).toBe(wire.markdown);
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
// ── Wire-verified NATIVE constructs survive the FULL pipeline ────────────
|
|
140
|
+
// Raw sendRichMessage probes (2026-08-13) proved Telegram's rich markdown
|
|
141
|
+
// path natively renders `<details>`, `$…$` inline math, and footnotes. The
|
|
142
|
+
// pre-fix pipeline destroyed all three (details → the unsupported `**>`
|
|
143
|
+
// marker, `$x^2+y^2$` → `\$x^2+y^2\$`, `[^n1]` deleted) — each assertion
|
|
144
|
+
// below FAILS on that pipeline.
|
|
145
|
+
describe("native constructs pass through the full composed guard", () => {
|
|
146
|
+
it("<details open><summary> block survives byte-identical", () => {
|
|
147
|
+
const src = "<details open><summary>S</summary>\n\nbody\n\n</details>";
|
|
148
|
+
expect(guardAccidentalFormatting(src)).toBe(src);
|
|
149
|
+
expect(richMessage(src).markdown).toBe(src);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("compact $math$ span survives byte-identical", () => {
|
|
153
|
+
const src = "inline $x^2+y^2$ done";
|
|
154
|
+
expect(guardAccidentalFormatting(src)).toBe(src);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("footnote reference + definition survive byte-identical", () => {
|
|
158
|
+
const src = "claim[^n1]\n\n[^n1]: body";
|
|
159
|
+
expect(guardAccidentalFormatting(src)).toBe(src);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("`**>` is never emitted for any input the pipeline repairs", () => {
|
|
163
|
+
const out = guardAccidentalFormatting(
|
|
164
|
+
"<details><summary>T</summary>\nbody\n</details>",
|
|
165
|
+
);
|
|
166
|
+
expect(out).not.toContain("**>");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── Guard-interaction cases for the now-passing-through constructs ───────
|
|
171
|
+
// Removing the old token conversions changes what the sibling guards see (a
|
|
172
|
+
// surviving `$`, a surviving `<`). These prove the emphasis / heading /
|
|
173
|
+
// block-construct / inline-pair / dollar guards do not mangle the natives,
|
|
174
|
+
// while their own repairs still fire in the SAME message.
|
|
175
|
+
describe("native constructs vs sibling guards (interaction)", () => {
|
|
176
|
+
it("math span is exempt while surrounding currency is still broken apart", () => {
|
|
177
|
+
const src = "sum $x^2+y^2$ costs $5 and $10 total";
|
|
178
|
+
const out = guardAccidentalFormatting(src);
|
|
179
|
+
// The math span reaches the wire byte-identical…
|
|
180
|
+
expect(out).toContain("$x^2+y^2$");
|
|
181
|
+
// …while the accidental currency pair is still defused (#3252).
|
|
182
|
+
expect(out).toContain("\$5");
|
|
183
|
+
expect(out).toContain("\$10");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("currency-shaped adjacent amounts are NOT mistaken for math", () => {
|
|
187
|
+
// `$5M-$10M` has a whitespace-free `$…$` pair (`$5M-$`) — the currency
|
|
188
|
+
// shape must keep it guardable, else #3252 regresses.
|
|
189
|
+
const out = guardAccidentalFormatting("range is $5M-$10M this year");
|
|
190
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("math span interior is protected from the emphasis and caret guards", () => {
|
|
194
|
+
// `a_b*c` inside math would trip the intra-word `_`/`*` escapes, and
|
|
195
|
+
// `^2y^` is an alphanumeric caret pair the token guard would strip —
|
|
196
|
+
// both must stay verbatim inside the protected span. The `_`/`*`
|
|
197
|
+
// OUTSIDE the span are still guarded — note the protected span's
|
|
198
|
+
// delimiters do NOT count toward the 2+ pairing threshold, so the
|
|
199
|
+
// prose needs its own pairable signals (`2*3 and 4*5`).
|
|
200
|
+
const src = "$a_b*c$ and file_name_here times 2*3 and 4*5";
|
|
201
|
+
const out = guardAccidentalFormatting(src);
|
|
202
|
+
expect(out).toContain("$a_b*c$");
|
|
203
|
+
expect(out).toContain("file\\_name\\_here");
|
|
204
|
+
expect(out).toContain("2\\*3");
|
|
205
|
+
expect(out).toContain("4\\*5");
|
|
206
|
+
expect(guardAccidentalFormatting("inline $x^2y^2$ done")).toBe(
|
|
207
|
+
"inline $x^2y^2$ done",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("<details> lines are invisible to the line-start and heading guards", () => {
|
|
212
|
+
// A details block whose body carries genuine accidental signals: the
|
|
213
|
+
// guards must fire on the BODY prose without touching the tags.
|
|
214
|
+
const src =
|
|
215
|
+
"<details open><summary>Stats</summary>\n\n>2x growth and #1 priority\n\n</details>";
|
|
216
|
+
const out = guardAccidentalFormatting(src);
|
|
217
|
+
expect(out).toContain("<details open><summary>Stats</summary>");
|
|
218
|
+
expect(out).toContain("</details>");
|
|
219
|
+
// Line-start `>2x` and glued `#1` are still repaired inside the body.
|
|
220
|
+
expect(out).toContain("\>2x");
|
|
221
|
+
expect(out).toContain("\#1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("footnote markers survive alongside a firing dollar guard", () => {
|
|
225
|
+
const src = "cost[^1] was $5 then $9\n\n[^1]: the note";
|
|
226
|
+
const out = guardAccidentalFormatting(src);
|
|
227
|
+
expect(out).toContain("cost[^1]");
|
|
228
|
+
expect(out).toContain("[^1]: the note");
|
|
229
|
+
expect(out).toContain("\$5");
|
|
230
|
+
expect(out).toContain("\$9");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("the whole composition stays idempotent with natives present", () => {
|
|
234
|
+
const src =
|
|
235
|
+
"claim[^1] and $x^2+y^2$ in <details><summary>T</summary>\nb\n</details> with $5 and $10\n\n[^1]: n";
|
|
236
|
+
const once = guardAccidentalFormatting(src);
|
|
237
|
+
expect(guardAccidentalFormatting(once)).toBe(once);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
138
240
|
});
|
|
@@ -336,7 +336,7 @@ describe("parse: nested lists", () => {
|
|
|
336
336
|
});
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
-
describe("expandable blockquote (
|
|
339
|
+
describe("expandable blockquote (LEGACY switchroom `**>` marker — input repair only)", () => {
|
|
340
340
|
it("parses a single-line `**>` quote into an expandable blockquote", () => {
|
|
341
341
|
const md = "**> a collapsible line";
|
|
342
342
|
const doc = parse(md);
|
|
@@ -374,10 +374,10 @@ describe("expandable blockquote (Bot API 10.1 `**>` marker)", () => {
|
|
|
374
374
|
});
|
|
375
375
|
|
|
376
376
|
it("only recognises the marker at column 0 (leading indent is not expandable)", () => {
|
|
377
|
-
// The
|
|
378
|
-
// variant is deliberately NOT treated as an expandable
|
|
379
|
-
// would push the length-preserving rewrite past the
|
|
380
|
-
// budget into indented-code-block territory).
|
|
377
|
+
// The legacy encoding only ever placed `**>` at column 0; a
|
|
378
|
+
// leading-indented variant is deliberately NOT treated as an expandable
|
|
379
|
+
// quote (matching it would push the length-preserving rewrite past the
|
|
380
|
+
// 3-space blockquote budget into indented-code-block territory).
|
|
381
381
|
const md = " **> indented";
|
|
382
382
|
const doc = parse(md);
|
|
383
383
|
const bq = doc.blocks[0] as any;
|
|
@@ -391,3 +391,28 @@ describe("expandable blockquote (Bot API 10.1 `**>` marker)", () => {
|
|
|
391
391
|
expect(doc.blocks[0].type).toBe("paragraph");
|
|
392
392
|
});
|
|
393
393
|
});
|
|
394
|
+
|
|
395
|
+
describe("footnotes fold to verbatim `raw` nodes (native Telegram construct)", () => {
|
|
396
|
+
it("a footnote reference marker folds to a raw inline carrying its source bytes", () => {
|
|
397
|
+
// GFM only recognises a reference when a matching definition exists in
|
|
398
|
+
// the document (a lone `[^n1]` is ordinary text).
|
|
399
|
+
const doc = parse("claim[^n1] more\n\n[^n1]: body");
|
|
400
|
+
const para = doc.blocks[0] as Extract<Block, { type: "paragraph" }>;
|
|
401
|
+
const raw = para.children.find((c: Inline) => c.type === "raw") as
|
|
402
|
+
| Extract<Inline, { type: "raw" }>
|
|
403
|
+
| undefined;
|
|
404
|
+
expect(raw).toBeDefined();
|
|
405
|
+
expect(raw!.text).toBe("[^n1]");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("a footnote definition folds to a paragraph with one raw inline (verbatim slice)", () => {
|
|
409
|
+
const doc = parse("[^n1]: body text");
|
|
410
|
+
const para = doc.blocks[0] as Extract<Block, { type: "paragraph" }>;
|
|
411
|
+
expect(para.type).toBe("paragraph");
|
|
412
|
+
expect(para.children).toHaveLength(1);
|
|
413
|
+
expect(para.children[0].type).toBe("raw");
|
|
414
|
+
expect((para.children[0] as Extract<Inline, { type: "raw" }>).text).toBe(
|
|
415
|
+
"[^n1]: body text",
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -195,7 +195,10 @@ describe("render: block palette", () => {
|
|
|
195
195
|
it("plain blockquote", () => {
|
|
196
196
|
expect(render(parse("> quoted line"))).toBe("> quoted line");
|
|
197
197
|
});
|
|
198
|
-
it("expandable
|
|
198
|
+
it("expandable IR flag renders as a PLAIN quote — the retired `**>` marker is never emitted", () => {
|
|
199
|
+
// `**>` is MarkdownV2-only syntax; the rich markdown path renders it as
|
|
200
|
+
// LITERAL `**>` text (wire-proved 2026-08-13 via raw sendRichMessage
|
|
201
|
+
// probes), so the renderer degrades an expandable node to a plain quote.
|
|
199
202
|
const doc: Document = {
|
|
200
203
|
blocks: [
|
|
201
204
|
{
|
|
@@ -214,9 +217,10 @@ describe("render: block palette", () => {
|
|
|
214
217
|
},
|
|
215
218
|
],
|
|
216
219
|
};
|
|
217
|
-
expect(render(doc)).toBe("
|
|
220
|
+
expect(render(doc)).toBe("> hidden gem");
|
|
221
|
+
expect(render(doc)).not.toContain("**>");
|
|
218
222
|
});
|
|
219
|
-
it("multi-line expandable blockquote
|
|
223
|
+
it("multi-line expandable blockquote renders every line with a plain > marker", () => {
|
|
220
224
|
const doc: Document = {
|
|
221
225
|
blocks: [
|
|
222
226
|
{
|
|
@@ -242,8 +246,9 @@ describe("render: block palette", () => {
|
|
|
242
246
|
],
|
|
243
247
|
};
|
|
244
248
|
const out = render(doc);
|
|
249
|
+
expect(out).not.toContain("**>");
|
|
245
250
|
const lines = out.split("\n");
|
|
246
|
-
expect(lines[0]).toBe("
|
|
251
|
+
expect(lines[0]).toBe("> line one");
|
|
247
252
|
for (const line of lines.slice(1)) {
|
|
248
253
|
expect(line.startsWith("> ") || line === ">").toBe(true);
|
|
249
254
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
renderOutbound,
|
|
6
6
|
maybeRenderOutbound,
|
|
7
7
|
} from "../../render/rich-render.js";
|
|
8
|
+
import { guardAccidentalFormatting } from "../../rich-send.js";
|
|
8
9
|
|
|
9
10
|
describe("parseRichRenderEnabled", () => {
|
|
10
11
|
it("defaults ON when unset (escape hatch, not opt-in)", () => {
|
|
@@ -52,8 +53,10 @@ describe("maybeRenderOutbound", () => {
|
|
|
52
53
|
it("default (env unset) routes through parse -> renderSafe", () => {
|
|
53
54
|
const r = maybeRenderOutbound("**> collapsible", {} as NodeJS.ProcessEnv);
|
|
54
55
|
expect(r.mode).toBe("markdown");
|
|
55
|
-
// The expandable
|
|
56
|
-
|
|
56
|
+
// The legacy expandable marker is REPAIRED to a plain quote: `**>` is
|
|
57
|
+
// MarkdownV2-only syntax the rich path renders as literal text
|
|
58
|
+
// (wire-proved 2026-08-13), so the renderer must never re-emit it.
|
|
59
|
+
expect(r.text).toBe("> collapsible");
|
|
57
60
|
});
|
|
58
61
|
|
|
59
62
|
it("default preserves plain prose through the round-trip", () => {
|
|
@@ -84,15 +87,53 @@ describe("maybeRenderOutbound", () => {
|
|
|
84
87
|
SWITCHROOM_RICH_RENDER: "maybe",
|
|
85
88
|
} as NodeJS.ProcessEnv);
|
|
86
89
|
expect(r.mode).toBe("markdown");
|
|
87
|
-
expect(r.text).
|
|
90
|
+
expect(r.text).toBe("> collapsible");
|
|
88
91
|
});
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
describe("renderOutbound (flag-independent)", () => {
|
|
92
|
-
it("
|
|
95
|
+
it("repairs a legacy `**>` quote to a plain quote end to end", () => {
|
|
93
96
|
const r = renderOutbound("**> hidden line one\n> hidden line two");
|
|
94
97
|
expect(r.mode).toBe("markdown");
|
|
95
|
-
|
|
98
|
+
// One coherent quote, no retired marker: on the pre-fix renderer the
|
|
99
|
+
// first line came back as `**> hidden line one`, which Telegram rendered
|
|
100
|
+
// as LITERAL `**>` paragraph text (wire-proved 2026-08-13).
|
|
101
|
+
expect(r.text).not.toContain("**>");
|
|
102
|
+
expect(r.text.split("\n")[0]).toBe("> hidden line one");
|
|
103
|
+
expect(r.text).toContain("> hidden line two");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("passes native constructs through: <details>, $math$, footnotes", () => {
|
|
107
|
+
// Wire-verified natives (2026-08-13) must survive parse -> renderSafe
|
|
108
|
+
// BYTE-IDENTICAL. On the pre-fix pipeline the footnote case failed:
|
|
109
|
+
// escapeMarkdown turned `[^n1]` into `\[^n1\]`, breaking the construct.
|
|
110
|
+
const details =
|
|
111
|
+
"<details open><summary>S</summary>\n\nbody\n\n</details>";
|
|
112
|
+
expect(renderOutbound(details).text).toBe(details);
|
|
113
|
+
|
|
114
|
+
const math = "inline $x^2+y^2$ done";
|
|
115
|
+
expect(renderOutbound(math).text).toBe(math);
|
|
116
|
+
|
|
117
|
+
const footnotes = "claim[^n1] more\n\n[^n1]: body text";
|
|
118
|
+
expect(renderOutbound(footnotes).text).toBe(footnotes);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a tg:// inline entity and a footnote survive TOGETHER in one message", () => {
|
|
122
|
+
// Cross-feature composition (#4683 x #4685): the `tg-entity` fold (mdast
|
|
123
|
+
// image position) and the footnote `raw` fold (footnoteReference /
|
|
124
|
+
// footnoteDefinition) land in the SAME foldInline/foldBlock walk — this
|
|
125
|
+
// pins that neither eats the other. On #4683 alone the footnote came back
|
|
126
|
+
// as `claim\[^n1\]`; on #4685 alone (pre-rebase) the entity came back as
|
|
127
|
+
// `!\[now\](tg://time?unix\=…)` literal text.
|
|
128
|
+
const combined =
|
|
129
|
+
"meet at  as promised[^n1]\n\n[^n1]: agreed yesterday";
|
|
130
|
+
expect(renderOutbound(combined).text).toBe(combined);
|
|
131
|
+
|
|
132
|
+
// Same pair inside ONE paragraph plus the guard seam on top: the composed
|
|
133
|
+
// wire body (renderOutbound then the richMessage guard, i.e. the full
|
|
134
|
+
// streamed send path) is still byte-identical.
|
|
135
|
+
const guarded = guardAccidentalFormatting(renderOutbound(combined).text);
|
|
136
|
+
expect(guarded).toBe(combined);
|
|
96
137
|
});
|
|
97
138
|
|
|
98
139
|
it("falls back to plain mode for oversized atomic content", () => {
|