switchroom 0.7.15 → 0.10.0

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 (301) hide show
  1. package/README.md +51 -59
  2. package/bin/run-hook.sh +27 -11
  3. package/bin/timezone-hook.sh +9 -7
  4. package/dist/agent-scheduler/index.js +410 -133
  5. package/dist/auth-broker/index.js +13932 -0
  6. package/dist/cli/switchroom.js +26937 -5601
  7. package/dist/host-control/main.js +12702 -0
  8. package/dist/vault/approvals/kernel-server.js +467 -184
  9. package/dist/vault/broker/server.js +1430 -724
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +7 -4
  16. package/profiles/_base/settings.json.hbs +20 -5
  17. package/profiles/_base/start.sh.hbs +16 -3
  18. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  19. package/profiles/_shared/telegram-style.md.hbs +20 -90
  20. package/profiles/_shared/vault-protocol.md.hbs +68 -0
  21. package/profiles/default/CLAUDE.md +50 -96
  22. package/profiles/default/CLAUDE.md.hbs +36 -6
  23. package/profiles/default/workspace/SOUL.md.hbs +12 -5
  24. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  25. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  26. package/skills/buildkite-api/SKILL.md +31 -8
  27. package/skills/buildkite-cli/SKILL.md +27 -9
  28. package/skills/buildkite-migration/SKILL.md +22 -9
  29. package/skills/buildkite-pipelines/SKILL.md +26 -9
  30. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  31. package/skills/buildkite-test-engine/SKILL.md +25 -8
  32. package/skills/docx/SKILL.md +1 -1
  33. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  34. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  35. package/skills/file-bug/SKILL.md +34 -6
  36. package/skills/humanizer/SKILL.md +15 -0
  37. package/skills/humanizer-calibrate/SKILL.md +7 -1
  38. package/skills/mcp-builder/SKILL.md +1 -1
  39. package/skills/pdf/SKILL.md +1 -1
  40. package/skills/pptx/SKILL.md +1 -1
  41. package/skills/skill-creator/SKILL.md +21 -1
  42. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  44. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  45. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  46. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  47. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  48. package/skills/switchroom-cli/SKILL.md +63 -64
  49. package/skills/switchroom-health/SKILL.md +23 -10
  50. package/skills/switchroom-install/SKILL.md +3 -3
  51. package/skills/switchroom-manage/SKILL.md +26 -19
  52. package/skills/switchroom-runtime/SKILL.md +191 -0
  53. package/skills/switchroom-status/SKILL.md +27 -2
  54. package/skills/telegram-test-harness/SKILL.md +3 -0
  55. package/skills/token-helpers/SKILL.md +24 -1
  56. package/skills/webapp-testing/SKILL.md +31 -1
  57. package/skills/xlsx/SKILL.md +1 -1
  58. package/telegram-plugin/admin-commands/index.ts +7 -5
  59. package/telegram-plugin/analytics-posthog.ts +191 -0
  60. package/telegram-plugin/bridge/bridge.ts +69 -0
  61. package/telegram-plugin/bridge/ipc-client.ts +4 -1
  62. package/telegram-plugin/dist/bridge/bridge.js +194 -119
  63. package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
  64. package/telegram-plugin/dist/server.js +245 -189
  65. package/telegram-plugin/first-paint.ts +3 -24
  66. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  67. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  68. package/telegram-plugin/gateway/auth-command.ts +794 -0
  69. package/telegram-plugin/gateway/auth-line.ts +123 -0
  70. package/telegram-plugin/gateway/boot-card.ts +169 -40
  71. package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
  72. package/telegram-plugin/gateway/boot-probes.ts +166 -123
  73. package/telegram-plugin/gateway/boot-reason.ts +41 -7
  74. package/telegram-plugin/gateway/boot-version.ts +66 -0
  75. package/telegram-plugin/gateway/gateway.ts +3499 -1885
  76. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  77. package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
  78. package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
  79. package/telegram-plugin/gateway/quarantine.ts +69 -0
  80. package/telegram-plugin/gateway/quota-cache.ts +9 -4
  81. package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
  82. package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
  83. package/telegram-plugin/gateway/recent-denials.ts +77 -0
  84. package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
  85. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
  86. package/telegram-plugin/history.ts +91 -0
  87. package/telegram-plugin/hooks/hooks.json +10 -0
  88. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
  89. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
  90. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
  91. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  92. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  93. package/telegram-plugin/inbound-classifier.ts +50 -0
  94. package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
  95. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  96. package/telegram-plugin/package.json +4 -2
  97. package/telegram-plugin/permission-rule.ts +51 -0
  98. package/telegram-plugin/permission-title.ts +56 -0
  99. package/telegram-plugin/quota-check.ts +19 -41
  100. package/telegram-plugin/registry/reaper.ts +223 -0
  101. package/telegram-plugin/retry-api-call.ts +80 -0
  102. package/telegram-plugin/runtime-metrics.ts +177 -0
  103. package/telegram-plugin/scripts/build.mjs +0 -1
  104. package/telegram-plugin/secret-detect/index.ts +24 -0
  105. package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
  106. package/telegram-plugin/secret-detect/vault-error.ts +78 -11
  107. package/telegram-plugin/secret-detect/vault-write.ts +14 -2
  108. package/telegram-plugin/server.js +41795 -0
  109. package/telegram-plugin/session-tail.ts +6 -1
  110. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  111. package/telegram-plugin/silence-poke.ts +420 -0
  112. package/telegram-plugin/silent-end.ts +174 -0
  113. package/telegram-plugin/stream-controller.ts +13 -0
  114. package/telegram-plugin/stream-reply-handler.ts +7 -0
  115. package/telegram-plugin/subagent-watcher.ts +213 -4
  116. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  117. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  118. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  119. package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
  120. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
  121. package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
  122. package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
  123. package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
  124. package/telegram-plugin/tests/boot-probes.test.ts +216 -10
  125. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  126. package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
  127. package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
  128. package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
  129. package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
  130. package/telegram-plugin/tests/history-reaper.test.ts +378 -0
  131. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  132. package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
  133. package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
  134. package/telegram-plugin/tests/issues-card.test.ts +49 -0
  135. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
  136. package/telegram-plugin/tests/permission-rule.test.ts +80 -1
  137. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  138. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  139. package/telegram-plugin/tests/races.test.ts +179 -0
  140. package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
  141. package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
  142. package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
  143. package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
  144. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
  145. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
  146. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
  147. package/telegram-plugin/tests/silence-poke.test.ts +493 -0
  148. package/telegram-plugin/tests/silent-end.test.ts +206 -0
  149. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
  150. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
  151. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
  152. package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
  153. package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
  154. package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
  155. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
  156. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
  157. package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
  158. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
  159. package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
  160. package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
  161. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
  162. package/telegram-plugin/turn-signal-tracker.ts +100 -24
  163. package/telegram-plugin/uat/SETUP.md +210 -35
  164. package/telegram-plugin/uat/assertions.ts +264 -37
  165. package/telegram-plugin/uat/driver-info.ts +57 -0
  166. package/telegram-plugin/uat/driver.ts +590 -51
  167. package/telegram-plugin/uat/harness.ts +140 -94
  168. package/telegram-plugin/uat/load-env.test.ts +72 -0
  169. package/telegram-plugin/uat/load-env.ts +48 -0
  170. package/telegram-plugin/uat/login.ts +96 -53
  171. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  172. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  173. package/telegram-plugin/uat/runners/report.ts +150 -0
  174. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  175. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  176. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  177. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  178. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  179. package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
  180. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
  181. package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
  182. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
  183. package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
  184. package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
  185. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
  186. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
  187. package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
  188. package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
  189. package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
  190. package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
  191. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
  192. package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
  193. package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
  194. package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
  195. package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
  196. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
  197. package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
  198. package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
  199. package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
  200. package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
  201. package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
  202. package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
  203. package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
  204. package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
  205. package/telegram-plugin/vault-approval-posture.ts +42 -0
  206. package/telegram-plugin/welcome-text.ts +1 -0
  207. package/telegram-plugin/active-pins-sweep.ts +0 -204
  208. package/telegram-plugin/active-pins.ts +0 -146
  209. package/telegram-plugin/auth-dashboard.ts +0 -1104
  210. package/telegram-plugin/auth-slot-parser.ts +0 -497
  211. package/telegram-plugin/card-event-log.ts +0 -138
  212. package/telegram-plugin/dist/foreman/foreman.js +0 -31106
  213. package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
  214. package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
  215. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  216. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  217. package/telegram-plugin/foreman/foreman.ts +0 -1165
  218. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  219. package/telegram-plugin/foreman/setup-state.ts +0 -239
  220. package/telegram-plugin/foreman/state.ts +0 -203
  221. package/telegram-plugin/pin-event-log.ts +0 -76
  222. package/telegram-plugin/progress-card-driver.ts +0 -2886
  223. package/telegram-plugin/progress-card-pin-manager.ts +0 -589
  224. package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
  225. package/telegram-plugin/progress-card.ts +0 -1409
  226. package/telegram-plugin/tests/HARNESS.md +0 -340
  227. package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
  228. package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
  229. package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
  230. package/telegram-plugin/tests/active-pins.test.ts +0 -187
  231. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  232. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  233. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  234. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  235. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  236. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  237. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
  238. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  239. package/telegram-plugin/tests/card-event-log.test.ts +0 -145
  240. package/telegram-plugin/tests/first-paint.test.ts +0 -257
  241. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  242. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  243. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  244. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  245. package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
  246. package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
  247. package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
  248. package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
  249. package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
  250. package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
  251. package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
  252. package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
  253. package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
  254. package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
  255. package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
  256. package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
  257. package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
  258. package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
  259. package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
  260. package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
  261. package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
  262. package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
  263. package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
  264. package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
  265. package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
  266. package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
  267. package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
  268. package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
  269. package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
  270. package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
  271. package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
  272. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  273. package/telegram-plugin/tests/setup-state.test.ts +0 -146
  274. package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
  275. package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
  276. package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
  277. package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
  278. package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
  279. package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
  280. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
  281. package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
  282. package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
  283. package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
  284. package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
  285. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
  286. package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
  287. package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
  288. package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
  289. package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
  290. package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
  291. package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
  292. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
  293. package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
  294. package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
  295. package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
  296. package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
  297. package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
  298. package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
  299. package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
  300. package/telegram-plugin/two-zone-card.ts +0 -269
  301. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
@@ -8,17 +8,23 @@
8
8
  * and admin rights — see Telegram Bots FAQ. The driver sends fixture
9
9
  * inbounds and observes everything the test bot emits.
10
10
  *
11
- * Phase 1: typed wrapper around mtcute with `connect` / `disconnect` /
12
- * `sendText` implemented. `sendVoice`, `observeMessages`,
13
- * `observeReactions`, `observePins` are stubs with TODO markers — they
14
- * land in Phase 2 alongside the scenario catalog.
11
+ * Phase 2 wires real mtcute lifecycle: `MemoryStorage` so no SQLite
12
+ * is touched, `importSession` to load the bearer string, real
13
+ * `connect`/`disconnect`, real `sendText` (with forum-topic routing),
14
+ * and `observeMessages` backed by `onNewMessage`/`onEditMessage`.
15
15
  *
16
16
  * Security: never log session strings, never log message bodies that
17
17
  * might contain auth codes (see `auth-code-redact.ts` for the
18
18
  * production pattern).
19
19
  */
20
20
 
21
- import { TelegramClient } from "@mtcute/node";
21
+ import {
22
+ MemoryStorage,
23
+ TelegramClient,
24
+ getMarkedPeerId,
25
+ InputMedia,
26
+ } from "@mtcute/node";
27
+ import type { Message } from "@mtcute/node";
22
28
 
23
29
  export interface DriverOptions {
24
30
  /** Telegram developer credential — `api_id` from my.telegram.org. */
@@ -34,7 +40,12 @@ export interface DriverOptions {
34
40
  }
35
41
 
36
42
  export interface SendTextOptions {
37
- /** Forum topic id, when targeting a topic in a supergroup. */
43
+ /**
44
+ * Forum topic id. For supergroups with topics enabled this is the
45
+ * `message_thread_id` from the Bot API. mtcute maps it to the
46
+ * `replyTo` parameter on send — the topic's "top message id" is
47
+ * what the server expects.
48
+ */
38
49
  messageThreadId?: number;
39
50
  /** Reply-quote a specific earlier message id. */
40
51
  replyTo?: number;
@@ -45,12 +56,48 @@ export interface ObservedMessage {
45
56
  messageId: number;
46
57
  threadId?: number;
47
58
  text: string;
48
- /** raw HTML if the message was sent with `parse_mode: HTML`. */
49
- html?: string;
59
+ /**
60
+ * Sender's user_id (or channel_id, for posts in a channel). Used by
61
+ * `expectMessage` to filter `from: "bot"` vs `from: "driver"`.
62
+ */
63
+ senderUserId: number;
50
64
  fromBot: boolean;
51
65
  date: Date;
66
+ /** `true` when this observation is an edit of an earlier message. */
67
+ edited: boolean;
68
+ /**
69
+ * `true` when this message was delivered as a silent push (Telegram
70
+ * `silent` flag, set by the sender's `disable_notification: true`).
71
+ * Used to verify the conversational-pacing contract: mid-turn updates
72
+ * must be silent, only the final answer should ping.
73
+ */
74
+ silent: boolean;
52
75
  }
53
76
 
77
+ export interface ObservedButton {
78
+ /** Visible label on the button. */
79
+ text: string;
80
+ /**
81
+ * Inline-callback button payload (Bot API `callback_data`). UTF-8
82
+ * decoded from the raw `Uint8Array` mtcute exposes. Undefined for
83
+ * URL buttons / web-app buttons / other non-callback button kinds.
84
+ */
85
+ callbackData?: string;
86
+ /** URL for `url` buttons; undefined for callback buttons. */
87
+ url?: string;
88
+ }
89
+
90
+ /**
91
+ * 2-D matrix of inline buttons, matching Bot API's
92
+ * `inline_keyboard: [[{text, callback_data}, ...], ...]`.
93
+ *
94
+ * Only `type === "inline"` keyboards are returned by `getKeyboard` —
95
+ * reply-keyboard / force-reply / hide markups aren't used by the
96
+ * gateway for vault UX flows and would require a separate driver
97
+ * surface (typing the reply instead of tapping).
98
+ */
99
+ export type ObservedKeyboard = ObservedButton[][];
100
+
54
101
  export interface ObservedReaction {
55
102
  chatId: number;
56
103
  messageId: number;
@@ -77,17 +124,30 @@ export class Driver {
77
124
  constructor(private readonly opts: DriverOptions) {}
78
125
 
79
126
  async connect(): Promise<void> {
80
- // mtcute v0.27 takes session via the `storage` option. For a
81
- // string-session driver we'll use `@mtcute/core/utils.js`'s
82
- // string-session-storage in Phase 2; Phase 1 just records the
83
- // shape so the harness compiles.
84
- // TODO(#865): wire StringSessionStorage from @mtcute/core/utils
85
- // and feed `this.opts.session` through it.
127
+ // MemoryStorage keeps all session state in memory the session
128
+ // string we hold in `opts.session` is the only durable source of
129
+ // truth. This sidesteps SQLite entirely (native bindings, file
130
+ // locking, ephemeral STATE_DIR cleanup) and makes per-scenario
131
+ // teardown a no-op for the driver's storage layer.
86
132
  this.client = new TelegramClient({
87
133
  apiId: this.opts.apiId,
88
134
  apiHash: this.opts.apiHash,
135
+ storage: new MemoryStorage(),
89
136
  });
90
- void this.opts.session;
137
+
138
+ // `force: true` because MemoryStorage is always empty at construct
139
+ // time — without force, mtcute treats the (non-existent) prior
140
+ // session as authoritative and silently ignores ours.
141
+ await this.client.importSession(this.opts.session, true);
142
+ await this.client.connect();
143
+ // `connect()` opens the transport but does NOT start the updates
144
+ // dispatch loop — that's `start()`'s job. For a returning session
145
+ // (no interactive login) we have to call `startUpdatesLoop()`
146
+ // ourselves, otherwise `onNewMessage` / `onEditMessage` never
147
+ // fire and `observeMessages` silently waits forever. Symptom:
148
+ // `expectMessage` timing out even though the bot's reply has
149
+ // arrived in the chat (visible in Telegram).
150
+ await this.client.startUpdatesLoop();
91
151
  }
92
152
 
93
153
  async disconnect(): Promise<void> {
@@ -102,67 +162,532 @@ export class Driver {
102
162
  opts?: SendTextOptions,
103
163
  ): Promise<{ messageId: number }> {
104
164
  const c = this.requireClient();
105
- // mtcute exposes `sendText(peer, text, params)`. Forum topic
106
- // targeting is via `params.replyTo` carrying a topic ref in
107
- // newer mtcute; precise shape verified in Phase 2.
108
- const sent = await c.sendText(chatId, text, {
109
- replyTo: opts?.replyTo,
110
- } as Parameters<TelegramClient["sendText"]>[2]);
111
- void opts?.messageThreadId; // TODO(#865): topic routing in Phase 2
165
+ // mtcute's CommonSendParams.replyTo doubles as the forum-topic
166
+ // target passing the topic's top-message id routes the new
167
+ // message into that topic. Explicit `opts.replyTo` (quote-reply)
168
+ // takes precedence if both are set; this matches Bot API
169
+ // behaviour where `reply_to_message_id` overrides
170
+ // `message_thread_id`.
171
+ const replyTo = opts?.replyTo ?? opts?.messageThreadId;
172
+ const sent = await c.sendText(chatId, text, replyTo ? { replyTo } : undefined);
112
173
  return { messageId: sent.id };
113
174
  }
114
175
 
115
- // -------- Phase 2 stubs --------
176
+ /**
177
+ * Return the driver's own Telegram user_id. Cached after first call.
178
+ */
179
+ async getMyUserId(): Promise<number> {
180
+ const c = this.requireClient();
181
+ const me = await c.getMe();
182
+ return me.id;
183
+ }
116
184
 
117
185
  /**
118
- * TODO(#865): send a voice note as the driver user. Needed for the
119
- * `voice-inbound.test.ts` scenario. mtcute's `sendVoice` takes an
120
- * OGG/Opus buffer or a path; we'll stage fixtures under
121
- * `uat/fixtures/voice/`.
186
+ * Unpin every pinned message in `chatId`. Used by the harness's
187
+ * settle phase: a stale pin from the previous scenario's turn would
188
+ * otherwise be reused by the gateway via edit (no new pin event),
189
+ * making `expectPinnedCard` time out. Best-effort — logs and swallows
190
+ * Telegram errors so an unrelated network drop / flood-wait doesn't
191
+ * abort spinUp before the scenario runs. The next assertion (e.g.
192
+ * `expectPinnedCard`) will fail loudly with its own deadline if the
193
+ * unpin actually mattered, so the warning is enough to root-cause
194
+ * post-hoc without a silent failure mode.
122
195
  */
123
- async sendVoice(
124
- _chatId: number,
125
- _oggPath: string,
126
- _opts?: SendTextOptions,
127
- ): Promise<{ messageId: number }> {
128
- throw new Error("Driver.sendVoice not implemented (Phase 2)");
196
+ async unpinAllMessages(chatId: number): Promise<void> {
197
+ const c = this.requireClient();
198
+ await c.unpinAllMessages(chatId).catch((err: unknown) => {
199
+ const msg = err instanceof Error ? err.message : String(err);
200
+ // eslint-disable-next-line no-console -- harness diagnostic
201
+ console.warn(`[uat/driver] unpinAllMessages(${chatId}) failed: ${msg}`);
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Resolve a bot username (with or without `@`) to its user_id. The
207
+ * resulting id doubles as the chat_id for DMing the bot from the
208
+ * driver — Telegram DMs use the peer's user_id as the chat_id.
209
+ */
210
+ async resolveBotUserId(username: string): Promise<number> {
211
+ const c = this.requireClient();
212
+ const handle = username.startsWith("@") ? username : `@${username}`;
213
+ const peer = await c.resolvePeer(handle);
214
+ // For a bot/user the resolved peer is `inputPeerUser` carrying the
215
+ // numeric `userId` we need.
216
+ const u = peer as { userId?: number; channelId?: number };
217
+ if (typeof u.userId === "number") return u.userId;
218
+ throw new Error(
219
+ `Driver.resolveBotUserId: '${handle}' did not resolve to a user (got ${JSON.stringify(peer)})`,
220
+ );
129
221
  }
130
222
 
131
223
  /**
132
- * TODO(#865): subscribe to new + edited messages in `chatId`/topic.
133
- * Returns an async iterable so scenarios can `for await` until a
134
- * predicate matches. Should backfill via `getHistory(limit:50)` to
135
- * catch messages that arrived between connect and observe-start.
224
+ * Subscribe to new + edited messages in `chatId` (optionally
225
+ * filtered to a forum topic). Returns an async iterable so scenarios
226
+ * can `for await` until a predicate matches. Each yielded value
227
+ * carries an `edited` flag so the scenario can distinguish initial
228
+ * sends from progress-card edits.
229
+ *
230
+ * Backfill: mtcute's emitters fire only for live updates, not
231
+ * history. Scenarios that need to observe a message sent before
232
+ * the observer started should poll `getHistory` directly (Phase 3
233
+ * helper). The smoke test sends *after* `observeMessages` starts,
234
+ * so no backfill needed.
136
235
  */
137
236
  observeMessages(
138
- _chatId: number,
139
- _opts?: { threadId?: number },
237
+ chatId: number,
238
+ opts?: { threadId?: number },
140
239
  ): AsyncIterable<ObservedMessage> {
141
- throw new Error("Driver.observeMessages not implemented (Phase 2)");
240
+ const c = this.requireClient();
241
+ const targetThread = opts?.threadId;
242
+ const queue: ObservedMessage[] = [];
243
+ const waiters: Array<(m: IteratorResult<ObservedMessage>) => void> = [];
244
+ let closed = false;
245
+
246
+ const dispatch = (m: ObservedMessage): void => {
247
+ const w = waiters.shift();
248
+ if (w) w({ value: m, done: false });
249
+ else queue.push(m);
250
+ };
251
+
252
+ const onNew = (msg: Message): void => {
253
+ const observed = toObserved(msg, false);
254
+ if (observed.chatId !== chatId) return;
255
+ if (targetThread !== undefined && observed.threadId !== targetThread) return;
256
+ dispatch(observed);
257
+ };
258
+ const onEdit = (msg: Message): void => {
259
+ const observed = toObserved(msg, true);
260
+ if (observed.chatId !== chatId) return;
261
+ if (targetThread !== undefined && observed.threadId !== targetThread) return;
262
+ dispatch(observed);
263
+ };
264
+
265
+ c.onNewMessage.add(onNew);
266
+ c.onEditMessage.add(onEdit);
267
+
268
+ const close = (): void => {
269
+ if (closed) return;
270
+ closed = true;
271
+ c.onNewMessage.remove(onNew);
272
+ c.onEditMessage.remove(onEdit);
273
+ while (waiters.length > 0) {
274
+ waiters.shift()?.({ value: undefined as never, done: true });
275
+ }
276
+ };
277
+
278
+ return {
279
+ [Symbol.asyncIterator](): AsyncIterator<ObservedMessage> {
280
+ return {
281
+ next(): Promise<IteratorResult<ObservedMessage>> {
282
+ if (queue.length > 0) {
283
+ return Promise.resolve({ value: queue.shift()!, done: false });
284
+ }
285
+ if (closed) {
286
+ return Promise.resolve({ value: undefined as never, done: true });
287
+ }
288
+ return new Promise((resolve) => waiters.push(resolve));
289
+ },
290
+ return(): Promise<IteratorResult<ObservedMessage>> {
291
+ close();
292
+ return Promise.resolve({ value: undefined as never, done: true });
293
+ },
294
+ };
295
+ },
296
+ };
142
297
  }
143
298
 
144
299
  /**
145
- * TODO(#865): subscribe to message-reaction updates. Note: mtcute
146
- * delivers `updateMessageReactions` as a delta (full set after the
147
- * change); the driver should compute add/remove ops vs the prior
148
- * snapshot so scenarios can assert on the 👀→🤔→🔥→👍 sequence.
300
+ * Subscribe to message-reaction add/remove ops in `chatId` (and
301
+ * optionally on a specific `messageId`).
302
+ *
303
+ * Implementation notes:
304
+ *
305
+ * - mtcute parses `updateBotMessageReaction` for bot accounts; for
306
+ * USER accounts (the driver) we have to handle the raw
307
+ * `updateMessageReactions` ourselves via `onRawUpdate`. The TL
308
+ * carries the full new reaction set, not a delta — we diff
309
+ * against the prior set we've cached for the same `msgId` to
310
+ * emit add (`+`) / remove (`-`) ops.
311
+ *
312
+ * - DM / group / supergroup all supported. `chatId` follows the
313
+ * Bot API marked-id convention (positive for users, negative
314
+ * for groups, -100… for supergroups/channels). Internally we
315
+ * normalize the raw `peer` field with mtcute's `getMarkedPeerId`
316
+ * so callers never see raw TL peer types.
317
+ *
318
+ * - `threadId` filters to a forum-topic id (the raw update's
319
+ * `topMsgId`). Useful for supergroup-with-topics scenarios; a
320
+ * no-op for DMs/basic groups.
321
+ *
322
+ * - Reactions are emitted only when they CHANGE. The initial
323
+ * reaction-add fires as `op: "+"`; a follow-up
324
+ * `setMessageReaction` that REPLACES the prior emoji emits `-`
325
+ * for the old + `+` for the new.
326
+ *
327
+ * - Custom emojis (`reactionCustomEmoji`) are skipped — scenarios
328
+ * that need them aren't in scope and parsing them would require
329
+ * resolving the document id to an alias.
149
330
  */
150
331
  observeReactions(
151
- _chatId: number,
152
- _opts?: { messageId?: number },
332
+ chatId: number,
333
+ opts?: { messageId?: number; threadId?: number },
153
334
  ): AsyncIterable<ObservedReaction> {
154
- throw new Error("Driver.observeReactions not implemented (Phase 2)");
335
+ const c = this.requireClient();
336
+ const targetMsgId = opts?.messageId;
337
+ const targetThread = opts?.threadId;
338
+ const queue: ObservedReaction[] = [];
339
+ const waiters: Array<(m: IteratorResult<ObservedReaction>) => void> = [];
340
+ let closed = false;
341
+ const prior = new Map<number, Set<string>>();
342
+
343
+ const dispatch = (r: ObservedReaction): void => {
344
+ const w = waiters.shift();
345
+ if (w) w({ value: r, done: false });
346
+ else queue.push(r);
347
+ };
348
+
349
+ const onRaw = (info: { update: unknown }): void => {
350
+ const u = info.update as {
351
+ _: string;
352
+ peer?: unknown;
353
+ msgId?: number;
354
+ topMsgId?: number;
355
+ reactions?: {
356
+ results?: Array<{
357
+ reaction: { _: string; emoticon?: string };
358
+ }>;
359
+ };
360
+ };
361
+ if (u._ !== "updateMessageReactions") return;
362
+ if (!u.peer) return;
363
+ // mtcute's getMarkedPeerId handles peerUser / peerChat / peerChannel
364
+ // uniformly — normalizes to Bot API marked-id form (-100... for
365
+ // supergroups, -... for basic groups, positive for users).
366
+ let peerId: number;
367
+ try {
368
+ peerId = getMarkedPeerId(u.peer as Parameters<typeof getMarkedPeerId>[0]);
369
+ } catch {
370
+ return; // unrecognized peer shape
371
+ }
372
+ if (peerId !== chatId) return;
373
+ const msgId = u.msgId;
374
+ if (typeof msgId !== "number") return;
375
+ if (targetMsgId !== undefined && msgId !== targetMsgId) return;
376
+ if (targetThread !== undefined && u.topMsgId !== targetThread) return;
377
+
378
+ const now = new Set<string>();
379
+ for (const rc of u.reactions?.results ?? []) {
380
+ if (
381
+ rc.reaction?._ === "reactionEmoji" &&
382
+ typeof rc.reaction.emoticon === "string"
383
+ ) {
384
+ now.add(rc.reaction.emoticon);
385
+ }
386
+ }
387
+ const before = prior.get(msgId) ?? new Set<string>();
388
+ const date = new Date();
389
+ for (const e of now) {
390
+ if (!before.has(e)) {
391
+ dispatch({ chatId, messageId: msgId, emoji: e, op: "+", date });
392
+ }
393
+ }
394
+ for (const e of before) {
395
+ if (!now.has(e)) {
396
+ dispatch({ chatId, messageId: msgId, emoji: e, op: "-", date });
397
+ }
398
+ }
399
+ prior.set(msgId, now);
400
+ };
401
+
402
+ c.onRawUpdate.add(onRaw);
403
+
404
+ const close = (): void => {
405
+ if (closed) return;
406
+ closed = true;
407
+ c.onRawUpdate.remove(onRaw);
408
+ while (waiters.length > 0) {
409
+ waiters.shift()?.({ value: undefined as never, done: true });
410
+ }
411
+ };
412
+
413
+ return {
414
+ [Symbol.asyncIterator](): AsyncIterator<ObservedReaction> {
415
+ return {
416
+ next(): Promise<IteratorResult<ObservedReaction>> {
417
+ if (queue.length > 0) {
418
+ return Promise.resolve({ value: queue.shift()!, done: false });
419
+ }
420
+ if (closed) {
421
+ return Promise.resolve({ value: undefined as never, done: true });
422
+ }
423
+ return new Promise((resolve) => waiters.push(resolve));
424
+ },
425
+ return(): Promise<IteratorResult<ObservedReaction>> {
426
+ close();
427
+ return Promise.resolve({ value: undefined as never, done: true });
428
+ },
429
+ };
430
+ },
431
+ };
155
432
  }
156
433
 
157
434
  /**
158
- * TODO(#865): subscribe to pin/unpin events on `chatId`/topic.
159
- * Used for progress-card-lifecycle assertions.
435
+ * Subscribe to pin/unpin events on `chatId`. Used for
436
+ * progress-card-lifecycle assertions.
437
+ *
438
+ * mtcute doesn't expose a parsed event for `updatePinnedMessages`
439
+ * — it comes through raw. Same shape as `observeReactions`. The
440
+ * raw update carries `messages: number[]` (one or many msg ids
441
+ * pinned/unpinned in one batch) plus a `pinned?: boolean` flag.
442
+ *
443
+ * DM / group / supergroup all supported. `chatId` follows the Bot
444
+ * API marked-id convention; internally we normalize the raw `peer`
445
+ * field with mtcute's `getMarkedPeerId`.
446
+ *
447
+ * Forum-topic filtering (the `threadId` opt) is currently unused
448
+ * here — `updatePinnedMessages` doesn't carry `topMsgId`, only
449
+ * the chat-level peer + message ids. Scenarios that need per-topic
450
+ * pin scoping should filter consumer-side via `driver.getMessage`
451
+ * to look up the pinned message's thread context.
160
452
  */
161
453
  observePins(
162
- _chatId: number,
454
+ chatId: number,
163
455
  _opts?: { threadId?: number },
164
456
  ): AsyncIterable<ObservedPin> {
165
- throw new Error("Driver.observePins not implemented (Phase 2)");
457
+ const c = this.requireClient();
458
+ const queue: ObservedPin[] = [];
459
+ const waiters: Array<(p: IteratorResult<ObservedPin>) => void> = [];
460
+ let closed = false;
461
+
462
+ const dispatch = (p: ObservedPin): void => {
463
+ const w = waiters.shift();
464
+ if (w) w({ value: p, done: false });
465
+ else queue.push(p);
466
+ };
467
+
468
+ const onRaw = (info: { update: unknown }): void => {
469
+ const u = info.update as {
470
+ _: string;
471
+ pinned?: boolean;
472
+ peer?: unknown;
473
+ messages?: number[];
474
+ };
475
+ if (u._ !== "updatePinnedMessages") return;
476
+ if (!u.peer) return;
477
+ let peerId: number;
478
+ try {
479
+ peerId = getMarkedPeerId(u.peer as Parameters<typeof getMarkedPeerId>[0]);
480
+ } catch {
481
+ return; // unrecognized peer shape
482
+ }
483
+ if (peerId !== chatId) return;
484
+ const ids = u.messages ?? [];
485
+ const pinned = u.pinned !== false; // default-true per TL (`pinned` omitted = pin)
486
+ const date = new Date();
487
+ for (const messageId of ids) {
488
+ dispatch({ chatId, messageId, pinned, date });
489
+ }
490
+ };
491
+
492
+ c.onRawUpdate.add(onRaw);
493
+
494
+ const close = (): void => {
495
+ if (closed) return;
496
+ closed = true;
497
+ c.onRawUpdate.remove(onRaw);
498
+ while (waiters.length > 0) {
499
+ waiters.shift()?.({ value: undefined as never, done: true });
500
+ }
501
+ };
502
+
503
+ return {
504
+ [Symbol.asyncIterator](): AsyncIterator<ObservedPin> {
505
+ return {
506
+ next(): Promise<IteratorResult<ObservedPin>> {
507
+ if (queue.length > 0) {
508
+ return Promise.resolve({ value: queue.shift()!, done: false });
509
+ }
510
+ if (closed) {
511
+ return Promise.resolve({ value: undefined as never, done: true });
512
+ }
513
+ return new Promise((resolve) => waiters.push(resolve));
514
+ },
515
+ return(): Promise<IteratorResult<ObservedPin>> {
516
+ close();
517
+ return Promise.resolve({ value: undefined as never, done: true });
518
+ },
519
+ };
520
+ },
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Fetch a single message by id. Used by `expectPinnedCard` to grab
526
+ * the card text once a pin event fires (the pin update carries
527
+ * just the id — content has to be looked up separately).
528
+ *
529
+ * Returns `null` when the message doesn't exist or has been
530
+ * deleted between the pin event and this lookup.
531
+ */
532
+ async getMessage(
533
+ chatId: number,
534
+ messageId: number,
535
+ ): Promise<ObservedMessage | null> {
536
+ const c = this.requireClient();
537
+ const results = await c.getMessages(chatId, [messageId]);
538
+ const msg = results[0];
539
+ if (!msg) return null;
540
+ return toObserved(msg, false);
541
+ }
542
+
543
+ /**
544
+ * Fetch the inline keyboard attached to a bot message, if any.
545
+ * Returns `null` for messages without an inline_keyboard (or with
546
+ * a non-inline markup like force-reply).
547
+ *
548
+ * Used by vault UX scenarios that need to:
549
+ * 1. Drive the gateway-published vault save card, audit/allow flow,
550
+ * or grant approval card (epic #1012).
551
+ * 2. Find the `[Allow]` / `[Save]` / `[Approve]` button to press by
552
+ * its label, then pass `button.callbackData` to `pressButton`.
553
+ *
554
+ * Callback `data` is decoded from the raw `Uint8Array` to UTF-8
555
+ * string; the gateway always encodes callback_data as ASCII/UTF-8,
556
+ * so this matches Bot API consumers' view. URL buttons surface as
557
+ * `{ text, url }` with `callbackData: undefined`.
558
+ */
559
+ async getKeyboard(
560
+ chatId: number,
561
+ messageId: number,
562
+ ): Promise<ObservedKeyboard | null> {
563
+ const c = this.requireClient();
564
+ const results = await c.getMessages(chatId, [messageId]);
565
+ const msg = results[0] as { markup?: { type: string; buttons: unknown[][] } } | null;
566
+ if (!msg) return null;
567
+ const markup = msg.markup;
568
+ if (!markup || markup.type !== "inline") return null;
569
+ const decoder = new TextDecoder();
570
+ return markup.buttons.map((row) =>
571
+ row.map((b) => {
572
+ const btn = b as {
573
+ _: string;
574
+ text: string;
575
+ data?: Uint8Array;
576
+ url?: string;
577
+ };
578
+ const out: ObservedButton = { text: btn.text };
579
+ if (btn._ === "keyboardButtonCallback" && btn.data) {
580
+ out.callbackData = decoder.decode(btn.data);
581
+ }
582
+ if (btn._ === "keyboardButtonUrl" && btn.url) {
583
+ out.url = btn.url;
584
+ }
585
+ return out;
586
+ }),
587
+ );
588
+ }
589
+
590
+ /**
591
+ * Press an inline-keyboard callback button — the MTProto path that
592
+ * mirrors what tapping the button in the Telegram client does.
593
+ *
594
+ * The bot receives a `callback_query` update. Bot-side handlers
595
+ * (e.g. the gateway's vault-audit one-tap allow handler from
596
+ * #969 P2b, or the agent-grant-request flow proposed in #1012)
597
+ * fire as if a real operator tapped.
598
+ *
599
+ * Note: the driver user must be in the bot's admin allowlist for
600
+ * any admin-gated button (most `/vault` callbacks are admin-gated).
601
+ * The harness's `test-harness` agent already includes the driver
602
+ * via `--allow-from` at agent-add time, so admin actions work
603
+ * end-to-end out of the box.
604
+ */
605
+ async pressButton(
606
+ chatId: number,
607
+ messageId: number,
608
+ callbackData: string,
609
+ ): Promise<void> {
610
+ const c = this.requireClient();
611
+ await c.getCallbackAnswer({
612
+ chatId,
613
+ message: messageId,
614
+ data: callbackData,
615
+ });
616
+ }
617
+
618
+ /**
619
+ * Send a voice note. Wraps mtcute's `sendMedia` + `InputMedia.voice`
620
+ * factory. The `oggPath` must be a path to an OGG/Opus audio file
621
+ * (Telegram only accepts that codec for voice notes); other audio
622
+ * formats render as a generic audio attachment and `voice_in`
623
+ * transcription on the bot side will skip them.
624
+ *
625
+ * Generating a fixture locally:
626
+ * ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 \
627
+ * -c:a libopus -b:a 32k tests/fixtures/voice/silence-1s.opus
628
+ *
629
+ * The scenario at `scenarios/voice-inbound-dm.test.ts` references
630
+ * a fixture path but is `describe.skip`'d until the fixture is
631
+ * committed (kept out of git to keep the repo small until needed).
632
+ */
633
+ async sendVoice(
634
+ chatId: number,
635
+ oggPath: string,
636
+ opts?: SendTextOptions,
637
+ ): Promise<{ messageId: number }> {
638
+ const c = this.requireClient();
639
+ const replyTo = opts?.replyTo ?? opts?.messageThreadId;
640
+ const media = InputMedia.voice(oggPath);
641
+ const sent = await c.sendMedia(
642
+ chatId,
643
+ media,
644
+ replyTo ? { replyTo } : undefined,
645
+ );
646
+ return { messageId: sent.id };
647
+ }
648
+
649
+ /**
650
+ * Send or remove an emoji reaction on a target message. Used by the
651
+ * UAT reaction-trigger scenario (#1074) to exercise the gateway's
652
+ * MessageReactionUpdated handler — the driver reacts to a bot reply,
653
+ * the bot's reaction-trigger pipeline synthesizes a new inbound turn
654
+ * to the agent.
655
+ *
656
+ * Pass `emoji: null` to remove the existing reaction (mtcute's
657
+ * `sendReaction` collapses send + remove into one method).
658
+ */
659
+ async sendReaction(
660
+ chatId: number,
661
+ messageId: number,
662
+ emoji: string | null,
663
+ ): Promise<void> {
664
+ const c = this.requireClient();
665
+ await c.sendReaction({
666
+ chatId,
667
+ message: messageId,
668
+ emoji: emoji === null ? null : emoji,
669
+ });
670
+ }
671
+
672
+ /**
673
+ * Send a geolocation point. Used by the UAT location-inbound scenario
674
+ * to exercise the gateway's `message:location` handler (#1077).
675
+ */
676
+ async sendLocation(
677
+ chatId: number,
678
+ latitude: number,
679
+ longitude: number,
680
+ opts?: SendTextOptions,
681
+ ): Promise<{ messageId: number }> {
682
+ const c = this.requireClient();
683
+ const replyTo = opts?.replyTo ?? opts?.messageThreadId;
684
+ const media = InputMedia.geo(latitude, longitude);
685
+ const sent = await c.sendMedia(
686
+ chatId,
687
+ media,
688
+ replyTo ? { replyTo } : undefined,
689
+ );
690
+ return { messageId: sent.id };
166
691
  }
167
692
 
168
693
  private requireClient(): TelegramClient {
@@ -172,3 +697,17 @@ export class Driver {
172
697
  return this.client;
173
698
  }
174
699
  }
700
+
701
+ function toObserved(msg: Message, edited: boolean): ObservedMessage {
702
+ return {
703
+ chatId: msg.chat.id,
704
+ messageId: msg.id,
705
+ threadId: msg.replyToMessage?.threadId ?? undefined,
706
+ text: msg.text ?? "",
707
+ senderUserId: msg.sender.id,
708
+ fromBot: msg.sender.type === "user" && msg.sender.isBot === true,
709
+ date: msg.date,
710
+ edited,
711
+ silent: msg.isSilent,
712
+ };
713
+ }