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.
Files changed (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +905 -758
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_base/start.sh.hbs +111 -34
  6. package/skills/switchroom-runtime/SKILL.md +2 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
  8. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  9. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  10. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  11. package/telegram-plugin/gateway/boot-card.ts +27 -0
  12. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  13. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  14. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  15. package/telegram-plugin/gateway/gateway.ts +1169 -3043
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  17. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  18. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  19. package/telegram-plugin/gateway/model-command.ts +23 -11
  20. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  21. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  22. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  23. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  24. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  25. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  26. package/telegram-plugin/hooks/hooks.json +10 -10
  27. package/telegram-plugin/hooks/run-hook.sh +84 -0
  28. package/telegram-plugin/model-unavailable.ts +26 -0
  29. package/telegram-plugin/pty-partial-handler.ts +39 -0
  30. package/telegram-plugin/render/rich-render.ts +79 -1
  31. package/telegram-plugin/retry-api-call.ts +62 -0
  32. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  33. package/telegram-plugin/silence-poke.ts +14 -0
  34. package/telegram-plugin/stream-controller.ts +156 -38
  35. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  36. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  37. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  38. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  39. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  40. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  41. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  42. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  43. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  44. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  45. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  46. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  47. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  48. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  49. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  50. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  51. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  52. package/telegram-plugin/tests/model-command.test.ts +2 -2
  53. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  54. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  55. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  56. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  57. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  58. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  59. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  60. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  61. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  62. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  65. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  67. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  68. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  69. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  70. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  71. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  72. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  73. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  74. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  75. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  76. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  77. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  78. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  79. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  80. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  81. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  82. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  83. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  84. package/telegram-plugin/voice-ondemand.ts +25 -1
  85. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
3
+ * § Prove it — "Card survives a gateway restart (DM)".
4
+ *
5
+ * Contract under test (#2989 durability fix): an agent-initiated
6
+ * `vault_request_access` card issued BEFORE a gateway restart is still
7
+ * tappable AFTER it. The gateway persists card metadata to the durable
8
+ * pending-card store, restores it on boot, and a tap on the still-valid card
9
+ * grants and resumes the parked agent exactly like a pre-restart tap —
10
+ * never the "Card expired — ask the agent to re-request" tombstone while the
11
+ * agent is parked and structurally cannot re-request.
12
+ *
13
+ * Load-bearing assertions:
14
+ * 1. The post-restart tap does NOT produce the expired-card tombstone edit.
15
+ * 2. The approve flow completes (passphrase → Granted card edit).
16
+ * 3. The parked agent resumes on the grant with no driver nudge.
17
+ *
18
+ * **Skipped by default.** To unskip:
19
+ *
20
+ * 1. Standard UAT preflight (`uat/SETUP.md` §5-6).
21
+ * 2. NOPASSWD sudo on the runner host (the scenario restarts test-harness).
22
+ * 3. `TELEGRAM_UAT_VAULT_PASSPHRASE` set in env.
23
+ * 4. Pre-create a sacrificial vault key:
24
+ *
25
+ * ```bash
26
+ * TMPF=$(mktemp) && printf '%s' 'sentinel-2989-value' > "$TMPF" && \
27
+ * switchroom vault set uat/card-survives-restart --file "$TMPF" \
28
+ * --format string ; shred -u "$TMPF"
29
+ * ```
30
+ *
31
+ * 5. Remove `describe.skip` below.
32
+ *
33
+ * Why skipped: mutates vault state (mints a grant) and bounces the harness
34
+ * agent. Cleanup is operator-side (`switchroom vault revoke <grant-id>`).
35
+ *
36
+ * Module-level twin (every CI run): the restart round-trip in
37
+ * `tests/approval-card-restart-outcome.test.ts` and the store contract in
38
+ * `tests/pending-card-store.test.ts`.
39
+ */
40
+
41
+ import { describe, expect, it } from "vitest";
42
+ import { execSync, spawn } from "node:child_process";
43
+ import { spinUp } from "../harness.js";
44
+
45
+ const AGENT = "test-harness";
46
+ const KEY = "uat/card-survives-restart";
47
+ const CARD_BUDGET_MS = 120_000;
48
+ const BOOT_BUDGET_MS = 180_000;
49
+ const RESUME_BUDGET_MS = 180_000;
50
+
51
+ function canShellSudo(): boolean {
52
+ try {
53
+ execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function kickRestartDetached(name: string): void {
61
+ const child = spawn(
62
+ "sudo",
63
+ ["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
64
+ "switchroom", "agent", "restart", name, "--force"],
65
+ { detached: true, stdio: "ignore" },
66
+ );
67
+ child.unref();
68
+ }
69
+
70
+ describe.skip("uat: approval card survives a gateway restart (DM, #2989)", () => {
71
+ it(
72
+ "card issued pre-restart is tappable post-restart: grant lands and the agent resumes",
73
+ async () => {
74
+ if (!canShellSudo()) {
75
+ throw new Error("NOPASSWD sudo required — see header to set up.");
76
+ }
77
+ const passphrase = process.env.TELEGRAM_UAT_VAULT_PASSPHRASE;
78
+ if (!passphrase) {
79
+ throw new Error(
80
+ "TELEGRAM_UAT_VAULT_PASSPHRASE must be set in env (see uat/SETUP.md).",
81
+ );
82
+ }
83
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
84
+ try {
85
+ // 1. Park the agent on an approval card.
86
+ await sc.sendDM(
87
+ `Please call your vault_request_access MCP tool for the key ` +
88
+ `\`${KEY}\` (scope read, 30d, reason "UAT #2989 card durability"). ` +
89
+ `END YOUR TURN cleanly and wait; when the operator approves, resume ` +
90
+ `by running \`switchroom vault get ${KEY}\` and reporting success.`,
91
+ );
92
+ const card = await sc.expectMessage(/wants vault access/, {
93
+ from: "bot",
94
+ timeout: CARD_BUDGET_MS,
95
+ });
96
+
97
+ // 2. Bounce the gateway BEFORE anyone taps.
98
+ kickRestartDetached(AGENT);
99
+ await new Promise((r) => setTimeout(r, BOOT_BUDGET_MS));
100
+
101
+ // 3. Tap Approve on the ORIGINAL, pre-restart card message.
102
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
103
+ const approveButton = kb!
104
+ .flat()
105
+ .find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
106
+ expect(approveButton).toBeDefined();
107
+ await sc.driver.pressButton(
108
+ sc.botUserId,
109
+ card.messageId,
110
+ approveButton!.callbackData!,
111
+ );
112
+
113
+ // 4. The restored card must run the REAL approve flow — the expired
114
+ // tombstone ("expired before you tapped") is the pre-#2989 defect.
115
+ const next = await sc.expectMessage(
116
+ (m) =>
117
+ /Reply with your passphrase|Granted|expired before you tapped/i.test(m.text),
118
+ { from: "bot", timeout: 60_000 },
119
+ );
120
+ expect(next.text).not.toMatch(/expired before you tapped/i);
121
+
122
+ if (/passphrase/i.test(next.text)) {
123
+ await sc.sendDM(passphrase);
124
+ }
125
+
126
+ // 5. Grant lands and the parked agent resumes without a nudge.
127
+ // Edits excluded — the "✅ Granted" edit of the card itself must
128
+ // not satisfy this; only a NEW message from the resumed turn does.
129
+ const resumed = await sc.expectMessage(
130
+ (m) => !m.edited && /(granted|access|vault get|succe)/i.test(m.text) && m.text.length > 40,
131
+ { from: "bot", timeout: RESUME_BUDGET_MS },
132
+ );
133
+ expect(resumed.text.length).toBeGreaterThan(40);
134
+ } finally {
135
+ await sc.tearDown();
136
+ }
137
+ },
138
+ CARD_BUDGET_MS + BOOT_BUDGET_MS + RESUME_BUDGET_MS + 120_000,
139
+ );
140
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
3
+ * § Prove it — "Denied request resumes the turn (DM)".
4
+ *
5
+ * Contract under test: the operator taps Deny on an agent-initiated
6
+ * `vault_request_access` card; the parked turn RESUMES (the gateway injects
7
+ * the `vault_grant_denied` synthetic), and the agent states plainly what it
8
+ * now can't do and continues or degrades — never left waiting on a card it
9
+ * already got an answer to, and never treating the denial as a reason to
10
+ * silently strand.
11
+ *
12
+ * Load-bearing assertion: after the Deny tap, the DRIVER sees a NEW bot turn
13
+ * that acknowledges the denial/degraded path WITHOUT the driver sending any
14
+ * further message. (The unit-level twin pins the synthetic's envelope in
15
+ * `tests/vault-grant-inbound-builders.test.ts`; this scenario proves the
16
+ * user-visible outcome over real Telegram.)
17
+ *
18
+ * Needs only the standard UAT preflight (`uat/SETUP.md` §5-6) — a Deny mints
19
+ * nothing and mutates no vault state, so no passphrase or sacrificial key is
20
+ * required. The requested key deliberately does not exist.
21
+ */
22
+
23
+ import { describe, expect, it } from "vitest";
24
+ import { spinUp } from "../harness.js";
25
+
26
+ const KEY = "uat/deny-resume-nonexistent-key";
27
+ const CARD_BUDGET_MS = 120_000;
28
+ const RESUME_BUDGET_MS = 120_000;
29
+
30
+ // The deny synthetic steers the model toward naming the blocked capability
31
+ // and a fallback — this framing is the stable signal of a resumed turn.
32
+ const DENY_RESUME_FRAMING =
33
+ /den(?:ied|ial)|can(?:'|no)t|won'?t be able|without (?:that|the) (?:key|access|credential)|not (?:been )?grant/i;
34
+
35
+ describe("uat: denied vault request resumes the parked turn (DM)", () => {
36
+ it(
37
+ "operator taps Deny → parked turn resumes and the agent names what it can't do, with no nudge",
38
+ async () => {
39
+ const sc = await spinUp({ agent: "test-harness" });
40
+ try {
41
+ // 1. Park the agent on an approval card.
42
+ await sc.sendDM(
43
+ `Please call your vault_request_access MCP tool for the key ` +
44
+ `\`${KEY}\` (scope read, reason "UAT deny-resumes-turn"). Then END ` +
45
+ `YOUR TURN cleanly and wait. When you hear the operator's decision, ` +
46
+ `resume: if denied, tell me plainly that you can't read that key ` +
47
+ `and what you'd do instead.`,
48
+ );
49
+
50
+ // 2. Wait for the approval card and find the Deny button.
51
+ const card = await sc.expectMessage(/wants vault access/, {
52
+ from: "bot",
53
+ timeout: CARD_BUDGET_MS,
54
+ });
55
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
56
+ const denyButton = kb!
57
+ .flat()
58
+ .find((b) => b.callbackData !== undefined && /deny/i.test(b.text));
59
+ expect(denyButton).toBeDefined();
60
+
61
+ // 3. Tap Deny. NO further driver message after this point.
62
+ await sc.driver.pressButton(
63
+ sc.botUserId,
64
+ card.messageId,
65
+ denyButton!.callbackData!,
66
+ );
67
+
68
+ // 4. The deny synthetic must wake the parked turn: a substantive
69
+ // bot reply that acknowledges the denial arrives unprompted.
70
+ // Edits are excluded — the gateway's own "🚫 Denied" edit of the
71
+ // approval card must not satisfy this; only a genuinely NEW
72
+ // message from a resumed turn counts.
73
+ const reply = await sc.expectMessage(
74
+ (m) => !m.edited && DENY_RESUME_FRAMING.test(m.text) && m.text.length > 40,
75
+ { from: "bot", timeout: RESUME_BUDGET_MS },
76
+ );
77
+ expect(reply.text).toMatch(DENY_RESUME_FRAMING);
78
+ } finally {
79
+ await sc.tearDown();
80
+ }
81
+ },
82
+ CARD_BUDGET_MS + RESUME_BUDGET_MS + 60_000,
83
+ );
84
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * UAT scenario named by `reference/jobs/approve-what-my-agent-can-touch.md`
3
+ * § Prove it — "Timeout wakes the agent as timeout-not-denial (DM)".
4
+ *
5
+ * Contract under test: an agent-initiated `vault_request_access` card ages
6
+ * past the approval TTL (60-min default) with NO operator tap. The reaper
7
+ * expires the card, records the missed-approvals re-offer, and injects the
8
+ * `vault_grant_timeout` synthetic — waking the parked agent with a *timeout,
9
+ * not a denial* outcome. The agent then says the request went unanswered
10
+ * (rather than reading silence as a "no" and rather than staying parked).
11
+ *
12
+ * A real 60-min wait is not viable in a scenario, so this test requires the
13
+ * test-harness gateway to run with a SHORT approval TTL:
14
+ *
15
+ * 1. Standard UAT preflight (`uat/SETUP.md` §5-6).
16
+ * 2. Set `channels.telegram.approval_timeout_minutes: 2` on the
17
+ * test-harness agent (threads to `SWITCHROOM_TG_APPROVAL_TIMEOUT_MS`,
18
+ * see `approvalTtlMs()` in gateway/permission-timeout.ts) and restart it.
19
+ * 3. Export `SWITCHROOM_UAT_APPROVAL_TTL_MINUTES=2` in the runner env so
20
+ * this scenario unskips and sizes its wait window.
21
+ *
22
+ * Self-skips green when `SWITCHROOM_UAT_APPROVAL_TTL_MINUTES` is unset (the
23
+ * default 60-min TTL cannot be observed within test wall-clock). The
24
+ * module-level twin covering the same outcome deterministically is
25
+ * `tests/approval-card-restart-outcome.test.ts` plus
26
+ * `tests/approval-timeout-inbound-builders.test.ts` (the "TIMEOUT, not a
27
+ * denial" wording pin).
28
+ */
29
+
30
+ import { describe, expect, it } from "vitest";
31
+ import { spinUp } from "../harness.js";
32
+
33
+ const TTL_MINUTES = Number.parseInt(
34
+ process.env.SWITCHROOM_UAT_APPROVAL_TTL_MINUTES ?? "",
35
+ 10,
36
+ );
37
+ const ttlTuned = Number.isFinite(TTL_MINUTES) && TTL_MINUTES > 0 && TTL_MINUTES <= 5;
38
+
39
+ const KEY = "uat/timeout-wake-nonexistent-key";
40
+ const CARD_BUDGET_MS = 120_000;
41
+ // TTL + reaper tick slack + model turn budget.
42
+ const WAKE_BUDGET_MS = (ttlTuned ? TTL_MINUTES : 2) * 60_000 + 240_000;
43
+
44
+ // The timeout synthetic's load-bearing wording is "TIMEOUT, not a denial" —
45
+ // the model is steered to say the request went unanswered, not refused.
46
+ const TIMEOUT_FRAMING =
47
+ /unanswer|timed?[ -]?out|no (?:response|answer|decision)|didn'?t (?:hear|get|respond)|went without/i;
48
+
49
+ (ttlTuned ? describe : describe.skip)(
50
+ "uat: unanswered approval card times out and wakes the agent as timeout-not-denial (DM)",
51
+ () => {
52
+ it(
53
+ "card ages past the TTL with no tap → agent says the request went unanswered, unprompted",
54
+ async () => {
55
+ const sc = await spinUp({ agent: "test-harness" });
56
+ try {
57
+ // 1. Park the agent on an approval card that nobody will answer.
58
+ await sc.sendDM(
59
+ `Please call your vault_request_access MCP tool for the key ` +
60
+ `\`${KEY}\` (scope read, reason "UAT timeout-wakes-agent"). Then ` +
61
+ `END YOUR TURN cleanly and wait for the operator. If the request ` +
62
+ `times out unanswered, tell me plainly that it went unanswered ` +
63
+ `(not that it was denied) and what you'll do about it.`,
64
+ );
65
+
66
+ // 2. The card must render — and then we deliberately never tap it.
67
+ await sc.expectMessage(/wants vault access/, {
68
+ from: "bot",
69
+ timeout: CARD_BUDGET_MS,
70
+ });
71
+
72
+ // 3. TTL elapses → reaper fires the vault_grant_timeout synthetic →
73
+ // a substantive bot reply arrives with timeout (never denial)
74
+ // framing, with no further driver message. Edits are excluded —
75
+ // the reaper's own "timed out" edit of the approval card must
76
+ // not satisfy this; only a NEW message from the woken turn does.
77
+ const reply = await sc.expectMessage(
78
+ (m) => !m.edited && TIMEOUT_FRAMING.test(m.text) && m.text.length > 40,
79
+ { from: "bot", timeout: WAKE_BUDGET_MS },
80
+ );
81
+ expect(reply.text).toMatch(TIMEOUT_FRAMING);
82
+ // A timeout must never be narrated as an operator refusal.
83
+ expect(reply.text).not.toMatch(/operator denied|was denied|refused my request/i);
84
+ } finally {
85
+ await sc.tearDown();
86
+ }
87
+ },
88
+ CARD_BUDGET_MS + WAKE_BUDGET_MS + 60_000,
89
+ );
90
+ },
91
+ );
@@ -49,6 +49,15 @@ export type VoiceOnDemandPayload = {
49
49
  * the pre-synth queue after a successful background synth. Absent on
50
50
  * pre-feature entries, on kill-switched gateways, and until the job runs. */
51
51
  filePath?: string
52
+ /** Telegram's reusable file_id for the audio, captured from the FIRST
53
+ * successful `sendVoice` for this entry. On subsequent taps the gateway
54
+ * sends BY this id (a plain string, not an InputFile) so Telegram serves
55
+ * the already-uploaded bytes — no disk read, no re-upload round-trip, so
56
+ * the voice note arrives near-instantly. Absent until the first send;
57
+ * refreshed if a stale id is rejected and the audio is re-uploaded. It
58
+ * lives on the entry, so the 7-day sweep's `prune()` drops it together
59
+ * with the entry — a dead id is never resurrected. */
60
+ telegramFileId?: string
52
61
  /** Epoch ms the entry was stored (put time) — recorded alongside filePath
53
62
  * per #2763 so the sweep/introspection can reason about entry age. */
54
63
  createdAt?: number
@@ -136,7 +145,7 @@ export class VoiceOnDemandCache {
136
145
  this.flush()
137
146
  return null
138
147
  }
139
- const { text, voice, speed, filePath } = entry
148
+ const { text, voice, speed, filePath, telegramFileId } = entry
140
149
  // createdAt stays internal (persisted for sweep/introspection) so the
141
150
  // returned shape only grows when a pre-synth file actually exists —
142
151
  // pre-#2763 callers and tests see the exact old payload.
@@ -145,6 +154,7 @@ export class VoiceOnDemandCache {
145
154
  speed,
146
155
  ...(voice !== undefined ? { voice } : {}),
147
156
  ...(filePath !== undefined ? { filePath } : {}),
157
+ ...(telegramFileId !== undefined ? { telegramFileId } : {}),
148
158
  }
149
159
  }
150
160
 
@@ -159,6 +169,20 @@ export class VoiceOnDemandCache {
159
169
  this.flush()
160
170
  }
161
171
 
172
+ /** Record (or refresh) Telegram's reusable file_id on an existing entry,
173
+ * captured from a successful `sendVoice`. Mirrors {@link setFilePath}: no-op
174
+ * if the entry has expired or been evicted meanwhile, and does NOT bump LRU
175
+ * position or TTL — the entry's lifetime stays anchored at put time so a
176
+ * captured id can never resurrect a dying entry. Passing an empty string is
177
+ * ignored (nothing to store). */
178
+ setTelegramFileId(token: string, fileId: string): void {
179
+ if (fileId.length === 0) return
180
+ const entry = this.store.get(token)
181
+ if (entry == null || entry.expiresAt <= this.now()) return
182
+ entry.telegramFileId = fileId
183
+ this.flush()
184
+ }
185
+
162
186
  /** Drop entries whose pre-synth files the sweep deleted (#2763). Unknown
163
187
  * tokens are ignored (file may belong to an already-evicted entry). */
164
188
  prune(tokens: Iterable<string>): void {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * On-demand voice send with Telegram file_id reuse.
3
+ *
4
+ * The Listen-button tap used to re-upload the audio bytes as a fresh InputFile
5
+ * on EVERY send — an upload + server-side ingest round-trip each time, which is
6
+ * the delay users felt on repeat taps. Telegram returns a reusable `file_id`
7
+ * after the first upload; sending BY that id (a plain string, not an InputFile)
8
+ * makes Telegram serve the already-stored bytes, which is near-instant and
9
+ * skips the disk read entirely.
10
+ *
11
+ * {@link sendVoiceReusingFileId} is the dependency-free decision core so it can
12
+ * be unit-tested without importing the 25k-line gateway (which has import-time
13
+ * side effects) or grammy. The gateway supplies the concrete send closures.
14
+ *
15
+ * Contract:
16
+ * 1. If a stored file_id exists → send BY id (no disk read, no upload). On
17
+ * success capture the returned id (Telegram may echo the same one).
18
+ * 2. If the stored id send fails with ANY HTTP 400 → treat the id as
19
+ * stale/invalid (regardless of the exact wording), fall through to the
20
+ * upload path ONCE and refresh the stored id so a dead id can never
21
+ * permanently break Listen. Any NON-400 error (network / 5xx / 403
22
+ * blocked-by-user) is a real failure and is surfaced (no silent
23
+ * re-upload storm, no papering over a 403).
24
+ * 3. If no id yet → load the audio (disk fast-path or on-demand synth) and
25
+ * send it as an InputFile, capturing the returned file_id for next time.
26
+ */
27
+
28
+ /** The subset of a Telegram Message we read back: the sent voice's file_id. */
29
+ export type SentVoiceMessage = { voice?: { file_id?: string } } | undefined | null
30
+
31
+ /** Extract the reusable file_id from a `sendVoice` response, if present. */
32
+ export function extractVoiceFileId(sent: SentVoiceMessage): string | undefined {
33
+ const id = sent?.voice?.file_id
34
+ return typeof id === 'string' && id.length > 0 ? id : undefined
35
+ }
36
+
37
+ /**
38
+ * True iff `err` looks like Telegram rejecting a file_id we sent (expired or
39
+ * malformed id) — the one case where re-uploading from disk is the right
40
+ * recovery. Duck-typed so this module stays grammy-free: a GrammyError carries
41
+ * `error_code` (number) and `description` (string). A 400 whose text names the
42
+ * file identifier is a stale-id signal; any other error is a genuine failure
43
+ * the caller must NOT paper over with a silent re-upload.
44
+ */
45
+ export function isInvalidTelegramFileIdError(err: unknown): boolean {
46
+ if (err == null || typeof err !== 'object') return false
47
+ const e = err as { error_code?: unknown; description?: unknown; message?: unknown }
48
+ const code = typeof e.error_code === 'number' ? e.error_code : undefined
49
+ if (code !== undefined && code !== 400) return false
50
+ const text = String(e.description ?? e.message ?? '').toLowerCase()
51
+ return (
52
+ text.includes('file identifier') ||
53
+ text.includes('file_id') ||
54
+ text.includes('wrong file') ||
55
+ text.includes('wrong remote file') ||
56
+ text.includes('wrong padding')
57
+ )
58
+ }
59
+
60
+ /**
61
+ * True iff `err` is an HTTP 400-class Telegram rejection (grammy `error_code`
62
+ * 400), regardless of the description wording. This is the re-upload trigger:
63
+ * a send-by-file_id that fails with a 400 is treated as a stale/invalid id and
64
+ * recovered by re-uploading from disk ONCE, superseding the narrower
65
+ * substring-matched {@link isInvalidTelegramFileIdError}. Rationale: a
66
+ * re-upload is always safe recovery and can never be worse than re-hitting a
67
+ * guaranteed-failing id, whereas Telegram may return a stale-id 400 with
68
+ * wording outside the known substring set. Non-400 errors (network / 5xx /
69
+ * 403 blocked-by-user) are NOT 400 and must remain real failures — never
70
+ * re-uploaded. Duck-typed so this module stays grammy-free.
71
+ */
72
+ export function isHttp400Error(err: unknown): boolean {
73
+ if (err == null || typeof err !== 'object') return false
74
+ const e = err as { error_code?: unknown }
75
+ return e.error_code === 400
76
+ }
77
+
78
+ export type ReuseFileIdDeps = {
79
+ /** The entry's stored Telegram file_id, or null if none captured yet. */
80
+ fileId: string | null
81
+ /** Send by an already-uploaded file_id string. Resolves to the sent Message. */
82
+ sendByFileId: (fileId: string) => Promise<SentVoiceMessage>
83
+ /** Load the audio bytes (disk fast-path, then on-demand synth). Returns null
84
+ * when audio is unavailable — the impl has already surfaced any user-facing
85
+ * toast in that case. Only invoked on the upload path (never when a valid
86
+ * file_id served the tap). */
87
+ loadAudio: () => Promise<Uint8Array | null>
88
+ /** Upload the audio as an InputFile. Resolves to the sent Message. */
89
+ sendByUpload: (audio: Uint8Array) => Promise<SentVoiceMessage>
90
+ /** Persist a freshly captured/refreshed file_id onto the cache entry. */
91
+ onFileId: (fileId: string) => void
92
+ /** Classify a `sendByFileId` rejection: true → re-upload from disk once +
93
+ * refresh the id; false → surface as a real failure. Defaults to
94
+ * {@link isHttp400Error} — ANY HTTP 400 is treated as a stale/invalid id
95
+ * (safe recovery), superseding the narrower substring-matched
96
+ * {@link isInvalidTelegramFileIdError}. Non-400 errors (network / 5xx /
97
+ * 403) stay real failures and never re-upload. */
98
+ isInvalidFileIdError?: (err: unknown) => boolean
99
+ log?: (line: string) => void
100
+ }
101
+
102
+ export type ReuseFileIdResult =
103
+ | { ok: true; path: 'file_id' | 'upload'; refreshed: boolean }
104
+ | { ok: false; reason: 'no-audio' }
105
+ | { ok: false; reason: 'send-failed'; error: unknown }
106
+
107
+ /**
108
+ * Send a voice note reusing a stored file_id when possible, with a disk/synth
109
+ * re-upload fallback on a stale id. See the module header for the full
110
+ * contract. Never throws — send failures are returned as `{ ok: false }`.
111
+ */
112
+ export async function sendVoiceReusingFileId(deps: ReuseFileIdDeps): Promise<ReuseFileIdResult> {
113
+ const shouldReupload = deps.isInvalidFileIdError ?? isHttp400Error
114
+
115
+ // 1. Fast path — reuse the stored file_id. No disk read, no re-upload.
116
+ if (deps.fileId != null && deps.fileId.length > 0) {
117
+ try {
118
+ const sent = await deps.sendByFileId(deps.fileId)
119
+ const fresh = extractVoiceFileId(sent)
120
+ if (fresh != null) deps.onFileId(fresh)
121
+ return { ok: true, path: 'file_id', refreshed: false }
122
+ } catch (err) {
123
+ if (!shouldReupload(err)) {
124
+ // Non-400 (network / 5xx / 403 blocked-by-user) — a real failure, not a
125
+ // stale id. Never re-upload; surface it so the caller can retry/report.
126
+ return { ok: false, reason: 'send-failed', error: err }
127
+ }
128
+ // Any 400 — treat the stored id as stale/invalid regardless of wording and
129
+ // recover by re-uploading below ONCE + refreshing the id. This can never
130
+ // be worse than re-hitting a guaranteed-failing id, and covers stale-id
131
+ // 400s whose text falls outside isInvalidTelegramFileIdError's substrings.
132
+ deps.log?.(
133
+ `voice-ondemand: stored file_id rejected (${String(
134
+ (err as { description?: unknown; message?: unknown })?.description ??
135
+ (err as { message?: unknown })?.message ??
136
+ err,
137
+ )}) — re-uploading from disk\n`,
138
+ )
139
+ }
140
+ }
141
+
142
+ // 2. Upload path — load audio then send as an InputFile; capture the id.
143
+ const audio = await deps.loadAudio()
144
+ if (audio == null) return { ok: false, reason: 'no-audio' }
145
+ const refreshed = deps.fileId != null && deps.fileId.length > 0
146
+ try {
147
+ const sent = await deps.sendByUpload(audio)
148
+ const fresh = extractVoiceFileId(sent)
149
+ if (fresh != null) deps.onFileId(fresh)
150
+ return { ok: true, path: 'upload', refreshed }
151
+ } catch (err) {
152
+ return { ok: false, reason: 'send-failed', error: err }
153
+ }
154
+ }