switchroom 0.18.7 → 0.18.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/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level regression test for the chunk-boundary cap bug
|
|
3
|
+
* (fix/rich-render-chunk-boundary-cap).
|
|
4
|
+
*
|
|
5
|
+
* With `SWITCHROOM_RICH_RENDER` on, a near-cap body full of escapable chars
|
|
6
|
+
* (`_ * |`) makes `renderSafe` degrade the whole document to plain (its escaped
|
|
7
|
+
* rich form exceeds RICH_MESSAGE_MAX_CHARS). BEFORE the fix, the stream
|
|
8
|
+
* controller shipped that ~32k plain body through the plain `sendMessage`
|
|
9
|
+
* endpoint in ONE call — Telegram's plain endpoint caps at 4096, so the send
|
|
10
|
+
* is rejected (`message is too long`) and the streamed answer is dropped.
|
|
11
|
+
*
|
|
12
|
+
* AFTER the fix, `renderOutboundChunks` re-splits at safe boundaries so every
|
|
13
|
+
* emitted send fits its own wire cap: rich pieces <= 32768, plain pieces
|
|
14
|
+
* <= 4096, and no fenced block is bisected.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
17
|
+
import { createStreamController } from "../stream-controller.js";
|
|
18
|
+
import { createFakeBotApi } from "./fake-bot-api.js";
|
|
19
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
20
|
+
|
|
21
|
+
const PLAIN_CAP = 4096; // Telegram's legacy plain-text sendMessage cap (format.ts:29).
|
|
22
|
+
|
|
23
|
+
function fenceCount(s: string): number {
|
|
24
|
+
return (s.match(/^```/gm) ?? []).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("stream-controller enforces the wire cap on the post-escape body", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
delete process.env.SWITCHROOM_RICH_RENDER;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("REGRESSION: near-cap escapable first send never exceeds the plain wire cap", async () => {
|
|
33
|
+
process.env.SWITCHROOM_RICH_RENDER = "1";
|
|
34
|
+
const bot = createFakeBotApi({ startMessageId: 1000 });
|
|
35
|
+
const unit = "a_b*c|d ";
|
|
36
|
+
const body = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
37
|
+
|
|
38
|
+
const stream = createStreamController({
|
|
39
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
40
|
+
chatId: "c1",
|
|
41
|
+
throttleMs: 0,
|
|
42
|
+
});
|
|
43
|
+
await stream.update(body);
|
|
44
|
+
await stream.finalize();
|
|
45
|
+
|
|
46
|
+
expect(bot.state.sent.length).toBeGreaterThan(0);
|
|
47
|
+
for (const s of bot.state.sent) {
|
|
48
|
+
// Every send fits the rich cap...
|
|
49
|
+
expect(s.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
50
|
+
// ...and a PLAIN send (rich !== true) additionally fits the 4096 plain
|
|
51
|
+
// endpoint cap — the invariant HEAD violated (one ~32k plain send).
|
|
52
|
+
if (!s.rich) expect(s.text.length).toBeLessThanOrEqual(PLAIN_CAP);
|
|
53
|
+
// No send bisects a fenced block.
|
|
54
|
+
expect(fenceCount(s.text) % 2).toBe(0);
|
|
55
|
+
}
|
|
56
|
+
}, 30000);
|
|
57
|
+
|
|
58
|
+
it("BLOCKER: multi-update oversize stream emits tails ONCE, not per edit tick", async () => {
|
|
59
|
+
// Reproduces the duplicate-flood blocker: an oversize body splits into N
|
|
60
|
+
// pieces. The FIRST flush (send path) emits the anchor + (N-1) tail
|
|
61
|
+
// messages. Every SUBSEQUENT throttled flush routes through the EDIT
|
|
62
|
+
// callback. The pre-fix code re-sent all (N-1) tails as brand-new messages
|
|
63
|
+
// on each edit tick, so `sent.length` grew by (N-1) every update. After the
|
|
64
|
+
// fix, tails are parked once and edited in place — `sent.length` is flat.
|
|
65
|
+
process.env.SWITCHROOM_RICH_RENDER = "1";
|
|
66
|
+
const bot = createFakeBotApi({ startMessageId: 3000 });
|
|
67
|
+
const unit = "a_b*c|d ";
|
|
68
|
+
// Near-cap body that degrades to plain and splits into several pieces.
|
|
69
|
+
const base = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
70
|
+
// Distinct short prefix per flush so the HEAD piece actually changes —
|
|
71
|
+
// an unchanged head yields a not-modified edit, which short-circuits before
|
|
72
|
+
// the tail loop and would hide the duplicate-resend bug.
|
|
73
|
+
const bodyFor = (i: number) => `v${i} ${base}`;
|
|
74
|
+
|
|
75
|
+
const stream = createStreamController({
|
|
76
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
77
|
+
chatId: "c1",
|
|
78
|
+
throttleMs: 0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// First flush → send path: anchor + tails, emitted exactly once.
|
|
82
|
+
await stream.update(bodyFor(1));
|
|
83
|
+
const afterFirst = bot.state.sent.length;
|
|
84
|
+
expect(afterFirst).toBeGreaterThan(1); // genuinely multi-piece
|
|
85
|
+
|
|
86
|
+
// Drive several more oversize updates — each routes through the EDIT
|
|
87
|
+
// callback. The count must NOT grow: tails are edited in place, not resent.
|
|
88
|
+
await stream.update(bodyFor(2));
|
|
89
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
90
|
+
await stream.update(bodyFor(3));
|
|
91
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
92
|
+
await stream.update(bodyFor(4));
|
|
93
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
94
|
+
await stream.finalize();
|
|
95
|
+
|
|
96
|
+
// Final: exactly the anchor + tail set, no duplicates across the lifetime.
|
|
97
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
98
|
+
const ids = bot.state.sent.map((s) => s.message_id);
|
|
99
|
+
expect(new Set(ids).size).toBe(ids.length); // no duplicate message ids
|
|
100
|
+
|
|
101
|
+
// Every currently-visible message fits its wire cap and (for plain) 4096.
|
|
102
|
+
for (const s of bot.state.sent) {
|
|
103
|
+
const cur = bot.textOf(s.message_id) ?? "";
|
|
104
|
+
expect(cur.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
105
|
+
if (!s.rich) expect(cur.length).toBeLessThanOrEqual(PLAIN_CAP);
|
|
106
|
+
}
|
|
107
|
+
}, 30000);
|
|
108
|
+
|
|
109
|
+
it("flag OFF leaves the single-send path untouched", async () => {
|
|
110
|
+
const bot = createFakeBotApi({ startMessageId: 2000 });
|
|
111
|
+
const stream = createStreamController({
|
|
112
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
113
|
+
chatId: "c1",
|
|
114
|
+
throttleMs: 0,
|
|
115
|
+
});
|
|
116
|
+
await stream.update("**hi** _there_");
|
|
117
|
+
await stream.finalize();
|
|
118
|
+
expect(bot.state.sent).toHaveLength(1);
|
|
119
|
+
expect(bot.state.sent[0].rich).toBe(true);
|
|
120
|
+
expect(bot.state.sent[0].text).toBe("**hi** _there_");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -261,10 +261,24 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
261
261
|
)
|
|
262
262
|
|
|
263
263
|
it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
264
|
+
// #2996: the reply-path entry pipeline moved into outbound-send-path.ts
|
|
265
|
+
// (`normalizeOutboundBody`). The reply path delegates to it via
|
|
266
|
+
// `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`; the
|
|
267
|
+
// redact→normalize→scrub ordering is now pinned in the module source.
|
|
268
|
+
const replyDelegates = gatewaySrc.indexOf(
|
|
269
|
+
`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
|
|
270
|
+
gatewaySrc.indexOf('async function executeReply('),
|
|
271
|
+
)
|
|
272
|
+
expect(replyDelegates).toBeGreaterThan(0)
|
|
273
|
+
|
|
274
|
+
const moduleSrc = readFileSync(
|
|
275
|
+
new URL('../gateway/outbound-send-path.ts', import.meta.url),
|
|
276
|
+
'utf8',
|
|
277
|
+
)
|
|
278
|
+
const start = moduleSrc.indexOf('export function normalizeOutboundBody(')
|
|
279
|
+
const redactIdx = moduleSrc.indexOf('redact(text, site)', start)
|
|
280
|
+
const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
|
|
281
|
+
const scrubIdx = moduleSrc.indexOf('scrubVoice(text)', start)
|
|
268
282
|
expect(start).toBeGreaterThan(0)
|
|
269
283
|
expect(redactIdx).toBeGreaterThan(start)
|
|
270
284
|
expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the reply redact
|
|
@@ -41,10 +41,14 @@ import { readFileSync } from 'node:fs'
|
|
|
41
41
|
import { resolve } from 'node:path'
|
|
42
42
|
import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
45
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
46
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
47
|
+
// same runtime source text.
|
|
48
|
+
const gatewaySrc =
|
|
49
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
50
|
+
'\n' +
|
|
51
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
48
52
|
|
|
49
53
|
function sliceAccessApproveBlock(): string {
|
|
50
54
|
const fn =
|
|
@@ -73,7 +77,10 @@ describe('vault grant approval posture — module-level wiring', () => {
|
|
|
73
77
|
describe('handleVaultRequestAccessCallback — posture branch', () => {
|
|
74
78
|
it('mints via posture attestation (NOT in-memory passphrase) when posture is telegram-id', () => {
|
|
75
79
|
const approveBlock = sliceAccessApproveBlock()
|
|
76
|
-
|
|
80
|
+
// #2996 Phase 5: inside the extracted handler module the mutable gateway
|
|
81
|
+
// `let` is read via the injected getter (gateway wires it as
|
|
82
|
+
// `getVaultApprovalAuthMode: () => VAULT_APPROVAL_AUTH_MODE`).
|
|
83
|
+
expect(approveBlock).toMatch(/getVaultApprovalAuthMode\(\) === ['"]telegram-id['"]/)
|
|
77
84
|
// Pinned: the call shape MUST be `{ kind: 'posture' }`. If the
|
|
78
85
|
// gateway ever reverts to passing a real passphrase here, the
|
|
79
86
|
// bypass surface returns.
|
|
@@ -138,7 +145,8 @@ describe('handleVaultRequestSaveCallback — posture-attested broker put (#1115
|
|
|
138
145
|
// calls `defaultVaultWritePosture` (posture-attested broker put,
|
|
139
146
|
// no passphrase). Under passphrase mode it keeps the legacy
|
|
140
147
|
// cached-passphrase + shell-out path.
|
|
141
|
-
|
|
148
|
+
// #2996 Phase 5: getter read of the gateway's mutable posture `let`.
|
|
149
|
+
expect(fnBlock).toMatch(/getVaultApprovalAuthMode\(\) === 'telegram-id'/)
|
|
142
150
|
expect(fnBlock).toMatch(/defaultVaultWritePosture\(/)
|
|
143
151
|
// Passphrase-mode branch still present.
|
|
144
152
|
expect(fnBlock).toMatch(/vaultPassphraseCache\.get\(pending\.chat_id\)/)
|
|
@@ -203,7 +211,7 @@ describe('allowlist is the first gate in every vault callback handler', () => {
|
|
|
203
211
|
'mintGrantViaBroker',
|
|
204
212
|
'performVaultAccessApproval',
|
|
205
213
|
'pendingVaultOps.set',
|
|
206
|
-
"
|
|
214
|
+
"getVaultApprovalAuthMode() === 'telegram-id'",
|
|
207
215
|
'attest_via_posture',
|
|
208
216
|
]) {
|
|
209
217
|
expect(
|
|
@@ -23,10 +23,14 @@ import { describe, it, expect } from "vitest";
|
|
|
23
23
|
import { readFileSync } from "node:fs";
|
|
24
24
|
import { resolve } from "node:path";
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
27
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
28
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
29
|
+
// same runtime source text.
|
|
30
|
+
const gatewaySrc =
|
|
31
|
+
readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
|
|
32
|
+
"\n" +
|
|
33
|
+
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
|
|
30
34
|
|
|
31
35
|
function extractPerformBlock(): string {
|
|
32
36
|
const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
|
|
@@ -22,10 +22,14 @@ import { describe, it, expect } from "vitest";
|
|
|
22
22
|
import { readFileSync } from "node:fs";
|
|
23
23
|
import { resolve } from "node:path";
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
26
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
27
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
28
|
+
// same runtime source text.
|
|
29
|
+
const gatewaySrc =
|
|
30
|
+
readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
|
|
31
|
+
"\n" +
|
|
32
|
+
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
|
|
29
33
|
|
|
30
34
|
function extractPerformBlock(): string {
|
|
31
35
|
const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
|
|
@@ -40,7 +40,14 @@ function readSrc(rel: string): string {
|
|
|
40
40
|
// `telegram-plugin/docs/gateway-server-split.md` for the F4 cleanup notes.
|
|
41
41
|
|
|
42
42
|
describe('/vault grant inline-keyboard wizard — gateway (#227, #262, #265)', () => {
|
|
43
|
-
|
|
43
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
44
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
45
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
46
|
+
// same runtime source text.
|
|
47
|
+
const gatewaySrc =
|
|
48
|
+
readSrc('telegram-plugin/gateway/gateway.ts') +
|
|
49
|
+
'\n' +
|
|
50
|
+
readSrc('telegram-plugin/gateway/callback-query-handlers.ts')
|
|
44
51
|
|
|
45
52
|
it('gateway.ts: dispatches /vault grant to the wizard entry', () => {
|
|
46
53
|
expect(gatewaySrc).toMatch(/\/vault grant/i)
|
|
@@ -33,7 +33,14 @@ import { join, dirname } from 'node:path'
|
|
|
33
33
|
const __dir = dirname(fileURLToPath(import.meta.url))
|
|
34
34
|
const pluginDir = join(__dir, '..')
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
37
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
38
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
39
|
+
// same runtime source text.
|
|
40
|
+
const gatewaySrc =
|
|
41
|
+
readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8') +
|
|
42
|
+
'\n' +
|
|
43
|
+
readFileSync(join(pluginDir, 'gateway', 'callback-query-handlers.ts'), 'utf8')
|
|
37
44
|
|
|
38
45
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
39
46
|
|
|
@@ -27,10 +27,14 @@ import { describe, it, expect } from "vitest";
|
|
|
27
27
|
import { readFileSync } from "node:fs";
|
|
28
28
|
import { resolve } from "node:path";
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
31
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
32
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
33
|
+
// same runtime source text.
|
|
34
|
+
const gatewaySrc =
|
|
35
|
+
readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
|
|
36
|
+
"\n" +
|
|
37
|
+
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
|
|
34
38
|
|
|
35
39
|
/** The exported regex literal — what the gateway actually validates against. */
|
|
36
40
|
function extractVaultKeyRegex(): RegExp {
|
|
@@ -23,10 +23,14 @@ const bridgeSrc = readFileSync(
|
|
|
23
23
|
resolve(__dirname, '..', 'bridge', 'bridge.ts'),
|
|
24
24
|
'utf-8',
|
|
25
25
|
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
27
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
28
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
29
|
+
// same runtime source text.
|
|
30
|
+
const gatewaySrc =
|
|
31
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
32
|
+
'\n' +
|
|
33
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
30
34
|
|
|
31
35
|
describe('vault_request_access (#1012)', () => {
|
|
32
36
|
it('bridge advertises the tool to MCP clients', () => {
|
|
@@ -22,10 +22,14 @@ import { describe, it, expect } from 'vitest'
|
|
|
22
22
|
import { readFileSync } from 'node:fs'
|
|
23
23
|
import { resolve } from 'node:path'
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
26
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
27
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
28
|
+
// same runtime source text.
|
|
29
|
+
const gatewaySrc =
|
|
30
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
31
|
+
'\n' +
|
|
32
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
29
33
|
|
|
30
34
|
describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
31
35
|
it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram file_id reuse for the on-demand '🔊 Listen' send path.
|
|
3
|
+
*
|
|
4
|
+
* gateway.ts is a 25k-line module with import-time side effects, so the send
|
|
5
|
+
* decision core lives in ../voice-send.ts (imported by the gateway). These
|
|
6
|
+
* tests drive the real orchestrator + cache primitive with a mocked bot api,
|
|
7
|
+
* proving the observable contract the gateway wires up:
|
|
8
|
+
*
|
|
9
|
+
* (a) FIRST tap (no stored id) uploads via InputFile and captures the
|
|
10
|
+
* returned file_id onto the cache entry;
|
|
11
|
+
* (b) SECOND tap sends BY the file_id string — no disk read, no InputFile;
|
|
12
|
+
* (c) a file_id send REJECTED as stale (400) falls back to a re-upload and
|
|
13
|
+
* refreshes the stored id, so a dead id never permanently breaks Listen.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'bun:test'
|
|
17
|
+
import {
|
|
18
|
+
sendVoiceReusingFileId,
|
|
19
|
+
extractVoiceFileId,
|
|
20
|
+
isInvalidTelegramFileIdError,
|
|
21
|
+
isHttp400Error,
|
|
22
|
+
type SentVoiceMessage,
|
|
23
|
+
} from '../voice-send.js'
|
|
24
|
+
import { VoiceOnDemandCache } from '../voice-ondemand.js'
|
|
25
|
+
|
|
26
|
+
/** A minimal grammy-shaped 400 error (duck-typed the way the gateway sees it). */
|
|
27
|
+
function grammy400(description: string): Error {
|
|
28
|
+
return Object.assign(new Error(description), { error_code: 400, description })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Sent-message stub echoing a file_id, as Telegram returns from sendVoice. */
|
|
32
|
+
function sentWithFileId(fileId: string): SentVoiceMessage {
|
|
33
|
+
return { voice: { file_id: fileId } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OGG = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
|
|
37
|
+
|
|
38
|
+
describe('sendVoiceReusingFileId — file_id reuse contract', () => {
|
|
39
|
+
it('(a) first tap: no stored id → uploads via InputFile, captures the id', async () => {
|
|
40
|
+
const calls: string[] = []
|
|
41
|
+
let byFileId = 0
|
|
42
|
+
let uploaded = 0
|
|
43
|
+
let captured: string | null = null
|
|
44
|
+
|
|
45
|
+
const res = await sendVoiceReusingFileId({
|
|
46
|
+
fileId: null,
|
|
47
|
+
sendByFileId: async () => {
|
|
48
|
+
byFileId++
|
|
49
|
+
return null
|
|
50
|
+
},
|
|
51
|
+
loadAudio: async () => {
|
|
52
|
+
calls.push('loadAudio')
|
|
53
|
+
return OGG
|
|
54
|
+
},
|
|
55
|
+
sendByUpload: async (audio) => {
|
|
56
|
+
uploaded++
|
|
57
|
+
expect(audio).toBe(OGG)
|
|
58
|
+
return sentWithFileId('FILE_ID_1')
|
|
59
|
+
},
|
|
60
|
+
onFileId: (id) => {
|
|
61
|
+
captured = id
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(res).toEqual({ ok: true, path: 'upload', refreshed: false })
|
|
66
|
+
expect(byFileId).toBe(0) // never tried the id path — none stored
|
|
67
|
+
expect(uploaded).toBe(1) // exactly one upload
|
|
68
|
+
expect(calls).toEqual(['loadAudio']) // audio WAS loaded on the first tap
|
|
69
|
+
expect(captured).toBe('FILE_ID_1') // id captured for reuse
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('(b) second tap: stored id → sends by file_id string, no disk read, no InputFile', async () => {
|
|
73
|
+
let loadAudioCalls = 0
|
|
74
|
+
let uploadCalls = 0
|
|
75
|
+
let sentId: string | null = null
|
|
76
|
+
|
|
77
|
+
const res = await sendVoiceReusingFileId({
|
|
78
|
+
fileId: 'FILE_ID_1',
|
|
79
|
+
sendByFileId: async (fid) => {
|
|
80
|
+
sentId = fid
|
|
81
|
+
return sentWithFileId('FILE_ID_1') // Telegram echoes the same id
|
|
82
|
+
},
|
|
83
|
+
loadAudio: async () => {
|
|
84
|
+
loadAudioCalls++ // MUST NOT happen — proves no disk read
|
|
85
|
+
return OGG
|
|
86
|
+
},
|
|
87
|
+
sendByUpload: async () => {
|
|
88
|
+
uploadCalls++ // MUST NOT happen — proves no re-upload
|
|
89
|
+
return null
|
|
90
|
+
},
|
|
91
|
+
onFileId: () => {},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(res).toEqual({ ok: true, path: 'file_id', refreshed: false })
|
|
95
|
+
expect(sentId).toBe('FILE_ID_1') // sent BY the string id
|
|
96
|
+
expect(loadAudioCalls).toBe(0) // no disk read
|
|
97
|
+
expect(uploadCalls).toBe(0) // no InputFile upload
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('(c) stale id (400): falls back to re-upload and refreshes the stored id', async () => {
|
|
101
|
+
let byFileId = 0
|
|
102
|
+
let uploaded = 0
|
|
103
|
+
let refreshedTo: string | null = null
|
|
104
|
+
|
|
105
|
+
const res = await sendVoiceReusingFileId({
|
|
106
|
+
fileId: 'STALE_ID',
|
|
107
|
+
sendByFileId: async () => {
|
|
108
|
+
byFileId++
|
|
109
|
+
throw grammy400('Bad Request: wrong file identifier/HTTP URL specified')
|
|
110
|
+
},
|
|
111
|
+
loadAudio: async () => OGG,
|
|
112
|
+
sendByUpload: async () => {
|
|
113
|
+
uploaded++
|
|
114
|
+
return sentWithFileId('FRESH_ID')
|
|
115
|
+
},
|
|
116
|
+
onFileId: (id) => {
|
|
117
|
+
refreshedTo = id
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
|
|
122
|
+
expect(byFileId).toBe(1) // tried the stale id once
|
|
123
|
+
expect(uploaded).toBe(1) // then re-uploaded
|
|
124
|
+
expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a NON-stale send error is a real failure — no silent re-upload', async () => {
|
|
128
|
+
let uploaded = 0
|
|
129
|
+
const res = await sendVoiceReusingFileId({
|
|
130
|
+
fileId: 'FILE_ID_1',
|
|
131
|
+
sendByFileId: async () => {
|
|
132
|
+
throw Object.assign(new Error('forbidden'), { error_code: 403 })
|
|
133
|
+
},
|
|
134
|
+
loadAudio: async () => OGG,
|
|
135
|
+
sendByUpload: async () => {
|
|
136
|
+
uploaded++
|
|
137
|
+
return null
|
|
138
|
+
},
|
|
139
|
+
onFileId: () => {},
|
|
140
|
+
})
|
|
141
|
+
expect(res.ok).toBe(false)
|
|
142
|
+
expect(uploaded).toBe(0) // did NOT paper over a 403 with an upload
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('(C1) UNKNOWN-wording 400: re-uploads + refreshes id even outside the stale-id substrings', async () => {
|
|
146
|
+
// A 400 whose text is NOT in isInvalidTelegramFileIdError's substring set.
|
|
147
|
+
// Pre-tweak (substring-gated) this classified as send-failed and never
|
|
148
|
+
// refreshed the id; post-tweak ANY 400 recovers via re-upload.
|
|
149
|
+
let byFileId = 0
|
|
150
|
+
let uploaded = 0
|
|
151
|
+
let refreshedTo: string | null = null
|
|
152
|
+
|
|
153
|
+
const err400 = grammy400('Bad Request: some brand-new file rejection wording')
|
|
154
|
+
expect(isInvalidTelegramFileIdError(err400)).toBe(false) // guard: outside old substrings
|
|
155
|
+
expect(isHttp400Error(err400)).toBe(true) // but IS a 400 → now re-uploadable
|
|
156
|
+
|
|
157
|
+
const res = await sendVoiceReusingFileId({
|
|
158
|
+
fileId: 'STALE_ID',
|
|
159
|
+
sendByFileId: async () => {
|
|
160
|
+
byFileId++
|
|
161
|
+
throw err400
|
|
162
|
+
},
|
|
163
|
+
loadAudio: async () => OGG,
|
|
164
|
+
sendByUpload: async () => {
|
|
165
|
+
uploaded++
|
|
166
|
+
return sentWithFileId('FRESH_ID')
|
|
167
|
+
},
|
|
168
|
+
onFileId: (id) => {
|
|
169
|
+
refreshedTo = id
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(res).toEqual({ ok: true, path: 'upload', refreshed: true })
|
|
174
|
+
expect(byFileId).toBe(1) // tried the id once
|
|
175
|
+
expect(uploaded).toBe(1) // then re-uploaded on the unknown 400
|
|
176
|
+
expect(refreshedTo).toBe('FRESH_ID') // stored id refreshed, not left stale
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('(C1) a network error (no error_code) still does NOT re-upload', async () => {
|
|
180
|
+
let uploaded = 0
|
|
181
|
+
const res = await sendVoiceReusingFileId({
|
|
182
|
+
fileId: 'FILE_ID_1',
|
|
183
|
+
sendByFileId: async () => {
|
|
184
|
+
throw new Error('ECONNRESET') // no error_code → not a 400
|
|
185
|
+
},
|
|
186
|
+
loadAudio: async () => OGG,
|
|
187
|
+
sendByUpload: async () => {
|
|
188
|
+
uploaded++
|
|
189
|
+
return null
|
|
190
|
+
},
|
|
191
|
+
onFileId: () => {},
|
|
192
|
+
})
|
|
193
|
+
expect(res.ok).toBe(false)
|
|
194
|
+
expect(uploaded).toBe(0) // transient network error is a real failure, not a re-upload
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('(C1) re-upload after a 400 ALSO fails → returns failure, no second re-upload', async () => {
|
|
198
|
+
let byFileId = 0
|
|
199
|
+
let uploaded = 0
|
|
200
|
+
let refreshed: string | null = null
|
|
201
|
+
|
|
202
|
+
const res = await sendVoiceReusingFileId({
|
|
203
|
+
fileId: 'STALE_ID',
|
|
204
|
+
sendByFileId: async () => {
|
|
205
|
+
byFileId++
|
|
206
|
+
throw grammy400('Bad Request: totally unfamiliar 400 wording')
|
|
207
|
+
},
|
|
208
|
+
loadAudio: async () => OGG,
|
|
209
|
+
sendByUpload: async () => {
|
|
210
|
+
uploaded++
|
|
211
|
+
throw grammy400('Bad Request: upload also rejected') // recovery send fails too
|
|
212
|
+
},
|
|
213
|
+
onFileId: (id) => {
|
|
214
|
+
refreshed = id
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
expect(res.ok).toBe(false)
|
|
219
|
+
if (!res.ok) expect(res.reason).toBe('send-failed')
|
|
220
|
+
expect(byFileId).toBe(1) // id tried once
|
|
221
|
+
expect(uploaded).toBe(1) // re-uploaded exactly once — NO second attempt / loop
|
|
222
|
+
expect(refreshed).toBeNull() // failed upload never refreshed the id
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('no-audio on the upload path returns { ok:false, reason:"no-audio" }', async () => {
|
|
226
|
+
const res = await sendVoiceReusingFileId({
|
|
227
|
+
fileId: null,
|
|
228
|
+
sendByFileId: async () => null,
|
|
229
|
+
loadAudio: async () => null, // swept + synth unavailable
|
|
230
|
+
sendByUpload: async () => {
|
|
231
|
+
throw new Error('should not be called')
|
|
232
|
+
},
|
|
233
|
+
onFileId: () => {},
|
|
234
|
+
})
|
|
235
|
+
expect(res).toEqual({ ok: false, reason: 'no-audio' })
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('extractVoiceFileId / isInvalidTelegramFileIdError', () => {
|
|
240
|
+
it('extracts a present file_id, ignores missing/empty', () => {
|
|
241
|
+
expect(extractVoiceFileId({ voice: { file_id: 'abc' } })).toBe('abc')
|
|
242
|
+
expect(extractVoiceFileId({ voice: {} })).toBeUndefined()
|
|
243
|
+
expect(extractVoiceFileId({ voice: { file_id: '' } })).toBeUndefined()
|
|
244
|
+
expect(extractVoiceFileId(null)).toBeUndefined()
|
|
245
|
+
expect(extractVoiceFileId(undefined)).toBeUndefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('classifies a 400 wrong-file-identifier as stale, others as not', () => {
|
|
249
|
+
expect(
|
|
250
|
+
isInvalidTelegramFileIdError(grammy400('Bad Request: wrong file identifier/HTTP URL specified')),
|
|
251
|
+
).toBe(true)
|
|
252
|
+
expect(isInvalidTelegramFileIdError(grammy400('Bad Request: wrong remote file identifier'))).toBe(
|
|
253
|
+
true,
|
|
254
|
+
)
|
|
255
|
+
// Not a file_id problem — genuine failures the caller must surface.
|
|
256
|
+
expect(isInvalidTelegramFileIdError(grammy400('Bad Request: chat not found'))).toBe(false)
|
|
257
|
+
expect(
|
|
258
|
+
isInvalidTelegramFileIdError(Object.assign(new Error('forbidden'), { error_code: 403 })),
|
|
259
|
+
).toBe(false)
|
|
260
|
+
expect(isInvalidTelegramFileIdError(new Error('network'))).toBe(false)
|
|
261
|
+
expect(isInvalidTelegramFileIdError(null)).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('isHttp400Error: true only for error_code 400, wording-agnostic', () => {
|
|
265
|
+
expect(isHttp400Error(grammy400('any wording at all'))).toBe(true)
|
|
266
|
+
expect(isHttp400Error(grammy400('Bad Request: chat not found'))).toBe(true) // 400 regardless of text
|
|
267
|
+
expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 403 }))).toBe(false)
|
|
268
|
+
expect(isHttp400Error(Object.assign(new Error('x'), { error_code: 500 }))).toBe(false)
|
|
269
|
+
expect(isHttp400Error(new Error('network'))).toBe(false) // no error_code
|
|
270
|
+
expect(isHttp400Error(null)).toBe(false)
|
|
271
|
+
expect(isHttp400Error(undefined)).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('VoiceOnDemandCache.setTelegramFileId — capture + sweep drop', () => {
|
|
276
|
+
it('stores an id on a live entry and returns it via get()', () => {
|
|
277
|
+
const cache = new VoiceOnDemandCache()
|
|
278
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
279
|
+
cache.setTelegramFileId('tok', 'FILE_ID_1')
|
|
280
|
+
expect(cache.get('tok')?.telegramFileId).toBe('FILE_ID_1')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('ignores an empty id and unknown/evicted tokens', () => {
|
|
284
|
+
const cache = new VoiceOnDemandCache()
|
|
285
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
286
|
+
cache.setTelegramFileId('tok', '') // ignored
|
|
287
|
+
expect(cache.get('tok')?.telegramFileId).toBeUndefined()
|
|
288
|
+
cache.setTelegramFileId('missing', 'X') // no-op, no throw
|
|
289
|
+
expect(cache.get('missing')).toBeNull()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('drops the stored id when the 7-day sweep prunes the entry — no resurrection', () => {
|
|
293
|
+
const cache = new VoiceOnDemandCache()
|
|
294
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
295
|
+
cache.setTelegramFileId('tok', 'FILE_ID_1')
|
|
296
|
+
cache.prune(['tok']) // sweep deleted the on-disk file for this token
|
|
297
|
+
expect(cache.get('tok')).toBeNull() // entry AND its file_id are gone
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('does not store an id on an already-expired entry', () => {
|
|
301
|
+
let t = 1000
|
|
302
|
+
const cache = new VoiceOnDemandCache({ ttlMs: 100, now: () => t })
|
|
303
|
+
cache.put('tok', { text: 'hi', speed: 1.1 })
|
|
304
|
+
t = 2000 // past TTL
|
|
305
|
+
cache.setTelegramFileId('tok', 'FILE_ID_1') // no-op on expired entry
|
|
306
|
+
expect(cache.get('tok')).toBeNull()
|
|
307
|
+
})
|
|
308
|
+
})
|