switchroom 0.16.38 → 0.16.47
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/agent-scheduler/index.js +8 -2
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/notion-write-pretool.mjs +7 -1
- package/dist/cli/switchroom.js +1259 -375
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +116 -84
- package/dist/vault/approvals/kernel-server.js +8 -2
- package/dist/vault/broker/server.js +8 -2
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +20 -2
- package/telegram-plugin/dist/gateway/gateway.js +2197 -964
- package/telegram-plugin/dist/server.js +20 -2
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +310 -70
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { decodeRichMessage } from "./driver.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Direct unit tests for `decodeRichMessage` — the Bot API 10.1 rich-message
|
|
6
|
+
* decoder in the MTProto UAT driver. These are HOSTED (no live Telegram, no
|
|
7
|
+
* driver session): they feed hand-built Instant-View page trees whose shapes
|
|
8
|
+
* are grounded 1:1 against `@mtcute/tl@223.0.0` (the TL layer mtcute 0.30
|
|
9
|
+
* ships) and assert the flat `{ text, entities }` the decoder produces.
|
|
10
|
+
*
|
|
11
|
+
* They are the CI-verifiable floor under the live render scenario
|
|
12
|
+
* (`scenarios/jtbd-rich-formatting-render-dm.test.ts`), which can only run on
|
|
13
|
+
* the gated uat-host. If Telegram's real GFM parser ever emits a shape these
|
|
14
|
+
* fixtures don't model, the live scenario catches it; these tests pin the
|
|
15
|
+
* decoder's behavior for the shapes we DO model so a decoder regression reds
|
|
16
|
+
* in ordinary CI without needing the runner.
|
|
17
|
+
*
|
|
18
|
+
* TL shape sources (all from `@mtcute/tl@223.0.0` index.d.ts):
|
|
19
|
+
* RichText: textPlain{text:string} · textConcat{texts:RichText[]} ·
|
|
20
|
+
* textBold/Italic/Underline/Strike/Fixed{text:RichText} ·
|
|
21
|
+
* textUrl{text:RichText,url:string} · textEmail{text,email} ·
|
|
22
|
+
* textMarked{text:RichText} (no textSpoiler exists → spoiler)
|
|
23
|
+
* PageBlock: pageBlockParagraph{text} · pageBlockPreformatted{text,language}
|
|
24
|
+
* · pageBlockHeader/Subheader{text} · pageBlockBlockquote/
|
|
25
|
+
* Pullquote{text,caption} · pageBlockList{items:PageListItem[]} ·
|
|
26
|
+
* pageBlockOrderedList{items:PageListOrderedItem[]} ·
|
|
27
|
+
* pageBlockTable{title,rows:PageTableRow[]} · pageBlockDivider
|
|
28
|
+
* Items: pageListItemText{text} · pageListItemBlocks{blocks} ·
|
|
29
|
+
* pageListOrderedItemText{num,text} ·
|
|
30
|
+
* pageListOrderedItemBlocks{num,blocks}
|
|
31
|
+
* Table: pageTableRow{cells:PageTableCell[]} ·
|
|
32
|
+
* pageTableCell{header?,text?:RichText}
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ── RichText builders (grounded shapes) ─────────────────────────────────────
|
|
36
|
+
const plain = (text: string) => ({ _: "textPlain", text });
|
|
37
|
+
const concat = (...texts: unknown[]) => ({ _: "textConcat", texts });
|
|
38
|
+
const bold = (text: unknown) => ({ _: "textBold", text });
|
|
39
|
+
const italic = (text: unknown) => ({ _: "textItalic", text });
|
|
40
|
+
const underline = (text: unknown) => ({ _: "textUnderline", text });
|
|
41
|
+
const strike = (text: unknown) => ({ _: "textStrike", text });
|
|
42
|
+
const fixed = (text: unknown) => ({ _: "textFixed", text });
|
|
43
|
+
const marked = (text: unknown) => ({ _: "textMarked", text }); // → spoiler
|
|
44
|
+
const url = (text: unknown, u: string) => ({ _: "textUrl", text, url: u });
|
|
45
|
+
const email = (text: unknown, e: string) => ({ _: "textEmail", text, email: e });
|
|
46
|
+
|
|
47
|
+
// ── PageBlock builders ──────────────────────────────────────────────────────
|
|
48
|
+
const para = (text: unknown) => ({ _: "pageBlockParagraph", text });
|
|
49
|
+
const pre = (text: unknown, language = "") => ({
|
|
50
|
+
_: "pageBlockPreformatted",
|
|
51
|
+
text,
|
|
52
|
+
language,
|
|
53
|
+
});
|
|
54
|
+
const header = (text: unknown) => ({ _: "pageBlockHeader", text });
|
|
55
|
+
const subheader = (text: unknown) => ({ _: "pageBlockSubheader", text });
|
|
56
|
+
const blockquote = (text: unknown, caption: unknown = { _: "textEmpty" }) => ({
|
|
57
|
+
_: "pageBlockBlockquote",
|
|
58
|
+
text,
|
|
59
|
+
caption,
|
|
60
|
+
});
|
|
61
|
+
const list = (...items: unknown[]) => ({ _: "pageBlockList", items });
|
|
62
|
+
const orderedList = (...items: unknown[]) => ({
|
|
63
|
+
_: "pageBlockOrderedList",
|
|
64
|
+
items,
|
|
65
|
+
});
|
|
66
|
+
const table = (title: unknown, rows: unknown[]) => ({
|
|
67
|
+
_: "pageBlockTable",
|
|
68
|
+
title,
|
|
69
|
+
rows,
|
|
70
|
+
});
|
|
71
|
+
const divider = () => ({ _: "pageBlockDivider" });
|
|
72
|
+
|
|
73
|
+
const itemText = (text: unknown) => ({ _: "pageListItemText", text });
|
|
74
|
+
const ordItemText = (num: string, text: unknown) => ({
|
|
75
|
+
_: "pageListOrderedItemText",
|
|
76
|
+
num,
|
|
77
|
+
text,
|
|
78
|
+
});
|
|
79
|
+
const row = (...cells: unknown[]) => ({ _: "pageTableRow", cells });
|
|
80
|
+
const cell = (text: unknown, header = false) => ({
|
|
81
|
+
_: "pageTableCell",
|
|
82
|
+
...(header ? { header: true } : {}),
|
|
83
|
+
text,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const rich = (...blocks: unknown[]) => ({ _: "richMessage", blocks });
|
|
87
|
+
|
|
88
|
+
// A convenience matcher — find the single entity of a kind.
|
|
89
|
+
const entOf = (
|
|
90
|
+
r: { entities: Array<{ kind: string }> },
|
|
91
|
+
kind: string,
|
|
92
|
+
) => r.entities.filter((e) => e.kind === kind);
|
|
93
|
+
|
|
94
|
+
describe("decodeRichMessage — non-rich guards", () => {
|
|
95
|
+
it("returns null for a non-richMessage value", () => {
|
|
96
|
+
expect(decodeRichMessage(undefined)).toBeNull();
|
|
97
|
+
expect(decodeRichMessage(null)).toBeNull();
|
|
98
|
+
expect(decodeRichMessage({ _: "message", blocks: [] })).toBeNull();
|
|
99
|
+
expect(decodeRichMessage({ _: "richMessage" })).toBeNull(); // no blocks[]
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("decodes an empty block list to empty output", () => {
|
|
103
|
+
expect(decodeRichMessage(rich())).toEqual({ text: "", entities: [] });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("decodeRichMessage — inline RichText marks (existing coverage)", () => {
|
|
108
|
+
it("bold / italic / code with exact spans", () => {
|
|
109
|
+
const r = decodeRichMessage(
|
|
110
|
+
rich(
|
|
111
|
+
para(
|
|
112
|
+
concat(
|
|
113
|
+
plain("a "),
|
|
114
|
+
bold(plain("bold")),
|
|
115
|
+
plain(" "),
|
|
116
|
+
italic(plain("it")),
|
|
117
|
+
plain(" "),
|
|
118
|
+
fixed(plain("code")),
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
)!;
|
|
123
|
+
expect(r.text).toBe("a bold it code");
|
|
124
|
+
expect(entOf(r, "bold")[0]).toMatchObject({ offset: 2, length: 4, text: "bold" });
|
|
125
|
+
expect(entOf(r, "italic")[0]).toMatchObject({ text: "it" });
|
|
126
|
+
expect(entOf(r, "code")[0]).toMatchObject({ text: "code" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("text_link carries the destination url; email classified", () => {
|
|
130
|
+
const r = decodeRichMessage(
|
|
131
|
+
rich(
|
|
132
|
+
para(
|
|
133
|
+
concat(
|
|
134
|
+
url(plain("repo"), "https://example.com/x"),
|
|
135
|
+
plain(" "),
|
|
136
|
+
email(plain("a@b.com"), "a@b.com"),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
)!;
|
|
141
|
+
expect(entOf(r, "text_link")[0]).toMatchObject({
|
|
142
|
+
text: "repo",
|
|
143
|
+
url: "https://example.com/x",
|
|
144
|
+
});
|
|
145
|
+
expect(entOf(r, "email")[0]).toMatchObject({ text: "a@b.com" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("pageBlockPreformatted → a single pre entity with language", () => {
|
|
149
|
+
const r = decodeRichMessage(rich(pre(plain('echo "hi"'), "bash")))!;
|
|
150
|
+
expect(r.text).toBe('echo "hi"');
|
|
151
|
+
expect(entOf(r, "pre")[0]).toMatchObject({
|
|
152
|
+
offset: 0,
|
|
153
|
+
length: 9,
|
|
154
|
+
language: "bash",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("decodeRichMessage — strikethrough / underline / spoiler marks", () => {
|
|
160
|
+
it("textStrike → strikethrough, textUnderline → underline", () => {
|
|
161
|
+
const r = decodeRichMessage(
|
|
162
|
+
rich(para(concat(strike(plain("gone")), plain(" "), underline(plain("keep"))))),
|
|
163
|
+
)!;
|
|
164
|
+
expect(r.text).toBe("gone keep");
|
|
165
|
+
expect(entOf(r, "strikethrough")[0]).toMatchObject({ text: "gone", offset: 0 });
|
|
166
|
+
expect(entOf(r, "underline")[0]).toMatchObject({ text: "keep", offset: 5 });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("textMarked → spoiler (TL 223 has no textSpoiler)", () => {
|
|
170
|
+
const r = decodeRichMessage(rich(para(concat(plain("psst "), marked(plain("secret"))))))!;
|
|
171
|
+
expect(r.text).toBe("psst secret");
|
|
172
|
+
expect(entOf(r, "spoiler")[0]).toMatchObject({ text: "secret", offset: 5 });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("decodeRichMessage — headings", () => {
|
|
177
|
+
it("pageBlockHeader / Subheader render as bold over the line", () => {
|
|
178
|
+
const r = decodeRichMessage(
|
|
179
|
+
rich(header(plain("Title")), subheader(plain("Sub")), para(plain("body"))),
|
|
180
|
+
)!;
|
|
181
|
+
expect(r.text).toBe("Title\nSub\nbody");
|
|
182
|
+
const bolds = entOf(r, "bold");
|
|
183
|
+
expect(bolds.map((e) => e.text).sort()).toEqual(["Sub", "Title"]);
|
|
184
|
+
expect(bolds.find((e) => e.text === "Title")).toMatchObject({ offset: 0, length: 5 });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("decodeRichMessage — blockquotes", () => {
|
|
189
|
+
it("pageBlockBlockquote → blockquote entity over the body", () => {
|
|
190
|
+
const r = decodeRichMessage(rich(blockquote(plain("quoted line"))))!;
|
|
191
|
+
expect(r.text).toBe("quoted line");
|
|
192
|
+
expect(entOf(r, "blockquote")[0]).toMatchObject({
|
|
193
|
+
offset: 0,
|
|
194
|
+
length: 11,
|
|
195
|
+
text: "quoted line",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("blockquote caption follows on its own line, unstyled", () => {
|
|
200
|
+
const r = decodeRichMessage(rich(blockquote(plain("body"), plain("— src"))))!;
|
|
201
|
+
expect(r.text).toBe("body\n— src");
|
|
202
|
+
expect(entOf(r, "blockquote")[0]).toMatchObject({ text: "body" });
|
|
203
|
+
// caption is not part of the blockquote span
|
|
204
|
+
expect(entOf(r, "blockquote")[0].length).toBe(4);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("decodeRichMessage — lists", () => {
|
|
209
|
+
it("unordered list → bulleted lines, inline marks preserved", () => {
|
|
210
|
+
const r = decodeRichMessage(
|
|
211
|
+
rich(list(itemText(plain("first")), itemText(bold(plain("second"))))),
|
|
212
|
+
)!;
|
|
213
|
+
expect(r.text).toBe("• first\n• second");
|
|
214
|
+
expect(entOf(r, "bold")[0]).toMatchObject({ text: "second" });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("ordered list → numbered lines using the wire .num label", () => {
|
|
218
|
+
const r = decodeRichMessage(
|
|
219
|
+
rich(
|
|
220
|
+
orderedList(
|
|
221
|
+
ordItemText("1", plain("alpha")),
|
|
222
|
+
ordItemText("2", plain("beta")),
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
)!;
|
|
226
|
+
expect(r.text).toBe("1. alpha\n2. beta");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("decodeRichMessage — tables", () => {
|
|
231
|
+
it("table rows join with ' | ', header cells emit bold", () => {
|
|
232
|
+
const r = decodeRichMessage(
|
|
233
|
+
rich(
|
|
234
|
+
table({ _: "textEmpty" }, [
|
|
235
|
+
row(cell(plain("H1"), true), cell(plain("H2"), true)),
|
|
236
|
+
row(cell(plain("a")), cell(plain("b"))),
|
|
237
|
+
]),
|
|
238
|
+
),
|
|
239
|
+
)!;
|
|
240
|
+
expect(r.text).toBe("H1 | H2\na | b");
|
|
241
|
+
const bolds = entOf(r, "bold").map((e) => e.text).sort();
|
|
242
|
+
expect(bolds).toEqual(["H1", "H2"]);
|
|
243
|
+
// body cells are not bold
|
|
244
|
+
expect(entOf(r, "bold").some((e) => e.text === "a")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("table title renders on its own line above the rows", () => {
|
|
248
|
+
const r = decodeRichMessage(
|
|
249
|
+
rich(table(plain("Scores"), [row(cell(plain("x")), cell(plain("y")))])),
|
|
250
|
+
)!;
|
|
251
|
+
expect(r.text).toBe("Scores\nx | y");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("decodeRichMessage — divider", () => {
|
|
256
|
+
it("pageBlockDivider → a literal rule line, no entity", () => {
|
|
257
|
+
const r = decodeRichMessage(rich(para(plain("above")), divider(), para(plain("below"))))!;
|
|
258
|
+
expect(r.text).toBe("above\n———\nbelow");
|
|
259
|
+
expect(r.entities).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("decodeRichMessage — full torture tree (offsets stay UTF-16 correct)", () => {
|
|
264
|
+
it("mixed blocks decode with coherent offsets across the whole body", () => {
|
|
265
|
+
const r = decodeRichMessage(
|
|
266
|
+
rich(
|
|
267
|
+
header(plain("Report")),
|
|
268
|
+
para(
|
|
269
|
+
concat(
|
|
270
|
+
bold(plain("bold")),
|
|
271
|
+
plain(" "),
|
|
272
|
+
italic(plain("it")),
|
|
273
|
+
plain(" "),
|
|
274
|
+
strike(plain("old")),
|
|
275
|
+
plain(" "),
|
|
276
|
+
marked(plain("spoil")),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
blockquote(plain("a wise quote")),
|
|
280
|
+
list(itemText(plain("one")), itemText(plain("two"))),
|
|
281
|
+
orderedList(ordItemText("1", plain("step"))),
|
|
282
|
+
divider(),
|
|
283
|
+
pre(plain("code()"), "js"),
|
|
284
|
+
),
|
|
285
|
+
)!;
|
|
286
|
+
// Every entity's recorded text must equal the slice at its offset —
|
|
287
|
+
// the invariant that proves offset bookkeeping is coherent.
|
|
288
|
+
for (const e of r.entities) {
|
|
289
|
+
expect(r.text.slice(e.offset, e.offset + e.length)).toBe(e.text);
|
|
290
|
+
}
|
|
291
|
+
// Sanity on the kinds present.
|
|
292
|
+
const kinds = new Set(r.entities.map((e) => e.kind));
|
|
293
|
+
for (const k of ["bold", "italic", "strikethrough", "spoiler", "blockquote", "pre"]) {
|
|
294
|
+
expect(kinds.has(k)).toBe(true);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { randomBytes } from 'crypto'
|
|
20
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs'
|
|
21
|
+
import { dirname } from 'path'
|
|
20
22
|
|
|
21
23
|
/** Reserved callback_data prefix for on-demand Listen buttons. Handled
|
|
22
24
|
* INTERNALLY by the gateway's callback dispatcher (never routed to the agent
|
|
@@ -24,8 +26,14 @@ import { randomBytes } from 'crypto'
|
|
|
24
26
|
export const VOICE_ONDEMAND_CALLBACK_PREFIX = 'voice:'
|
|
25
27
|
|
|
26
28
|
/** Cache entry TTL. A button tapped after this degrades to an "expired" toast
|
|
27
|
-
* rather than pinning reply text in memory forever.
|
|
28
|
-
|
|
29
|
+
* rather than pinning reply text in memory forever.
|
|
30
|
+
*
|
|
31
|
+
* 7 days since #2763 (was 1h): eager pre-synthesis keeps the spoken file on
|
|
32
|
+
* disk for 7 days, and the acceptance bar is "tapping Listen on any message
|
|
33
|
+
* <7 days old plays instantly" — which needs the ENTRY alive that long too
|
|
34
|
+
* (the lazy fallback also reads it). Memory stays bounded by
|
|
35
|
+
* VOICE_ONDEMAND_MAX_ENTRIES; the entry is a few hundred bytes of text. */
|
|
36
|
+
export const VOICE_ONDEMAND_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
29
37
|
|
|
30
38
|
/** Bounded cache size; oldest entries are evicted past this. */
|
|
31
39
|
export const VOICE_ONDEMAND_MAX_ENTRIES = 500
|
|
@@ -37,34 +45,86 @@ export type VoiceOnDemandPayload = {
|
|
|
37
45
|
voice?: string
|
|
38
46
|
/** Resolved + clamped playback speed. */
|
|
39
47
|
speed: number
|
|
48
|
+
/** Absolute path of the eagerly pre-synthesized audio file (#2763), set by
|
|
49
|
+
* the pre-synth queue after a successful background synth. Absent on
|
|
50
|
+
* pre-feature entries, on kill-switched gateways, and until the job runs. */
|
|
51
|
+
filePath?: string
|
|
52
|
+
/** Epoch ms the entry was stored (put time) — recorded alongside filePath
|
|
53
|
+
* per #2763 so the sweep/introspection can reason about entry age. */
|
|
54
|
+
createdAt?: number
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
type StoredEntry = VoiceOnDemandPayload & { expiresAt: number }
|
|
43
58
|
|
|
59
|
+
/** On-disk shape of the persisted cache: a versioned envelope around the
|
|
60
|
+
* token→entry map. Versioned so a future schema change can be detected and
|
|
61
|
+
* discarded rather than mis-parsed. */
|
|
62
|
+
type PersistedCache = {
|
|
63
|
+
version: 1
|
|
64
|
+
entries: Record<string, StoredEntry>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Options for {@link VoiceOnDemandCache}. `persistPath`, when set, backs the
|
|
69
|
+
* cache with a JSON file so Listen tokens survive a gateway restart — a button
|
|
70
|
+
* tapped after a restart still synthesizes instead of degrading to "Voice
|
|
71
|
+
* expired". Omit it (the default) for a pure in-memory cache.
|
|
72
|
+
*/
|
|
73
|
+
export type VoiceOnDemandCacheOptions = {
|
|
74
|
+
ttlMs?: number
|
|
75
|
+
maxEntries?: number
|
|
76
|
+
now?: () => number
|
|
77
|
+
/** Absolute path to the JSON persistence file (e.g. under TELEGRAM_STATE_DIR,
|
|
78
|
+
* which lives on a volume that survives container recreation). When set, the
|
|
79
|
+
* cache loads existing (non-expired) entries on construction and rewrites the
|
|
80
|
+
* file on every mutation. */
|
|
81
|
+
persistPath?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
44
84
|
/**
|
|
45
85
|
* Bounded, TTL'd LRU cache from Listen token → synthesis payload. Keyed by
|
|
46
86
|
* token (not message_id) because the message_id isn't known when the button
|
|
47
87
|
* is sent. A Map preserves insertion order, so the first key is the oldest.
|
|
88
|
+
*
|
|
89
|
+
* Optionally durable: pass `persistPath` to back the store with a JSON file so
|
|
90
|
+
* tokens survive a gateway restart. Persistence uses a synchronous
|
|
91
|
+
* write-to-temp + atomic rename on each mutation — the store is small (≤
|
|
92
|
+
* maxEntries) and writes are infrequent (one per reply that offers a Listen
|
|
93
|
+
* button), so the cost is negligible and the module stays dependency-free.
|
|
94
|
+
* Any load/write error is swallowed (logged to stderr): a broken persistence
|
|
95
|
+
* file must never take the gateway down — the cache simply degrades to the old
|
|
96
|
+
* in-memory behaviour.
|
|
48
97
|
*/
|
|
49
98
|
export class VoiceOnDemandCache {
|
|
50
99
|
private readonly store = new Map<string, StoredEntry>()
|
|
100
|
+
private readonly ttlMs: number
|
|
101
|
+
private readonly maxEntries: number
|
|
102
|
+
private readonly now: () => number
|
|
103
|
+
private readonly persistPath: string | undefined
|
|
51
104
|
|
|
52
|
-
constructor(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
constructor(options: VoiceOnDemandCacheOptions = {}) {
|
|
106
|
+
this.ttlMs = options.ttlMs ?? VOICE_ONDEMAND_TTL_MS
|
|
107
|
+
this.maxEntries = options.maxEntries ?? VOICE_ONDEMAND_MAX_ENTRIES
|
|
108
|
+
this.now = options.now ?? Date.now
|
|
109
|
+
this.persistPath = options.persistPath
|
|
110
|
+
if (this.persistPath !== undefined) this.load()
|
|
111
|
+
}
|
|
57
112
|
|
|
58
113
|
/** Store a payload under `token`, evicting the oldest entries past the cap. */
|
|
59
114
|
put(token: string, payload: VoiceOnDemandPayload): void {
|
|
60
115
|
// Re-insert to move an existing key to the newest (most-recent) position.
|
|
61
116
|
this.store.delete(token)
|
|
62
|
-
this.store.set(token, {
|
|
117
|
+
this.store.set(token, {
|
|
118
|
+
createdAt: this.now(),
|
|
119
|
+
...payload,
|
|
120
|
+
expiresAt: this.now() + this.ttlMs,
|
|
121
|
+
})
|
|
63
122
|
while (this.store.size > this.maxEntries) {
|
|
64
123
|
const oldest = this.store.keys().next().value
|
|
65
124
|
if (oldest === undefined) break
|
|
66
125
|
this.store.delete(oldest)
|
|
67
126
|
}
|
|
127
|
+
this.flush()
|
|
68
128
|
}
|
|
69
129
|
|
|
70
130
|
/** Look up a payload; returns null on miss or expiry (and evicts expired). */
|
|
@@ -73,16 +133,107 @@ export class VoiceOnDemandCache {
|
|
|
73
133
|
if (entry == null) return null
|
|
74
134
|
if (entry.expiresAt <= this.now()) {
|
|
75
135
|
this.store.delete(token)
|
|
136
|
+
this.flush()
|
|
76
137
|
return null
|
|
77
138
|
}
|
|
78
|
-
const { text, voice, speed } = entry
|
|
79
|
-
|
|
139
|
+
const { text, voice, speed, filePath } = entry
|
|
140
|
+
// createdAt stays internal (persisted for sweep/introspection) so the
|
|
141
|
+
// returned shape only grows when a pre-synth file actually exists —
|
|
142
|
+
// pre-#2763 callers and tests see the exact old payload.
|
|
143
|
+
return {
|
|
144
|
+
text,
|
|
145
|
+
speed,
|
|
146
|
+
...(voice !== undefined ? { voice } : {}),
|
|
147
|
+
...(filePath !== undefined ? { filePath } : {}),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Record the pre-synthesized audio file path on an existing entry (#2763).
|
|
152
|
+
* No-op if the entry has expired or been evicted meanwhile (the file will
|
|
153
|
+
* simply age out via the sweep). Does NOT bump LRU position or TTL — the
|
|
154
|
+
* entry's lifetime is anchored at put time. */
|
|
155
|
+
setFilePath(token: string, filePath: string): void {
|
|
156
|
+
const entry = this.store.get(token)
|
|
157
|
+
if (entry == null || entry.expiresAt <= this.now()) return
|
|
158
|
+
entry.filePath = filePath
|
|
159
|
+
this.flush()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Drop entries whose pre-synth files the sweep deleted (#2763). Unknown
|
|
163
|
+
* tokens are ignored (file may belong to an already-evicted entry). */
|
|
164
|
+
prune(tokens: Iterable<string>): void {
|
|
165
|
+
let changed = false
|
|
166
|
+
for (const token of tokens) {
|
|
167
|
+
if (this.store.delete(token)) changed = true
|
|
168
|
+
}
|
|
169
|
+
if (changed) this.flush()
|
|
80
170
|
}
|
|
81
171
|
|
|
82
172
|
/** Current entry count (test/introspection aid). */
|
|
83
173
|
get size(): number {
|
|
84
174
|
return this.store.size
|
|
85
175
|
}
|
|
176
|
+
|
|
177
|
+
/** Load persisted entries, dropping any already expired. Best-effort: a
|
|
178
|
+
* missing or corrupt file leaves the cache empty (fresh-start). */
|
|
179
|
+
private load(): void {
|
|
180
|
+
if (this.persistPath === undefined) return
|
|
181
|
+
let raw: string
|
|
182
|
+
try {
|
|
183
|
+
raw = readFileSync(this.persistPath, 'utf8')
|
|
184
|
+
} catch {
|
|
185
|
+
// No file yet (first boot) — nothing to load.
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(raw) as PersistedCache
|
|
190
|
+
if (parsed == null || parsed.version !== 1 || typeof parsed.entries !== 'object') return
|
|
191
|
+
const nowMs = this.now()
|
|
192
|
+
// Object.entries preserves insertion order for string keys, which is the
|
|
193
|
+
// insertion order we wrote — so LRU ordering survives the round-trip.
|
|
194
|
+
for (const [token, entry] of Object.entries(parsed.entries)) {
|
|
195
|
+
if (
|
|
196
|
+
entry == null ||
|
|
197
|
+
typeof entry.expiresAt !== 'number' ||
|
|
198
|
+
typeof entry.text !== 'string' ||
|
|
199
|
+
typeof entry.speed !== 'number'
|
|
200
|
+
) {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (entry.expiresAt <= nowMs) continue // already expired — drop
|
|
204
|
+
this.store.set(token, entry)
|
|
205
|
+
}
|
|
206
|
+
// Honour the cap in case the file predates a smaller maxEntries.
|
|
207
|
+
while (this.store.size > this.maxEntries) {
|
|
208
|
+
const oldest = this.store.keys().next().value
|
|
209
|
+
if (oldest === undefined) break
|
|
210
|
+
this.store.delete(oldest)
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
process.stderr.write(
|
|
214
|
+
`voice-ondemand: failed to parse persisted cache ${this.persistPath}: ${String(err)}\n`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Write the current store to disk atomically. No-op when not persisting. */
|
|
220
|
+
private flush(): void {
|
|
221
|
+
if (this.persistPath === undefined) return
|
|
222
|
+
const doc: PersistedCache = {
|
|
223
|
+
version: 1,
|
|
224
|
+
entries: Object.fromEntries(this.store),
|
|
225
|
+
}
|
|
226
|
+
const tmp = `${this.persistPath}.tmp`
|
|
227
|
+
try {
|
|
228
|
+
mkdirSync(dirname(this.persistPath), { recursive: true })
|
|
229
|
+
writeFileSync(tmp, JSON.stringify(doc), 'utf8')
|
|
230
|
+
renameSync(tmp, this.persistPath)
|
|
231
|
+
} catch (err) {
|
|
232
|
+
process.stderr.write(
|
|
233
|
+
`voice-ondemand: failed to persist cache ${this.persistPath}: ${String(err)}\n`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
86
237
|
}
|
|
87
238
|
|
|
88
239
|
/** Mint a short, unguessable Listen token (8 hex chars). */
|