switchroom 0.5.0 → 0.7.8

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 (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +503 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +558 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -155,7 +155,15 @@ export function toolLabel(
155
155
  tool: string,
156
156
  input?: Record<string, unknown>,
157
157
  preamble?: string,
158
+ precomputedLabel?: string,
158
159
  ): string {
160
+ // Precomputed sidecar label (PreToolUse hook, #783) — top of the
161
+ // precedence ladder per spec. The hook deliberately emits NO label
162
+ // for Bash/Task/Agent/TodoWrite, so falling through to the
163
+ // existing description path for those is automatic.
164
+ if (precomputedLabel && precomputedLabel.trim().length > 0) {
165
+ return truncate(firstLine(precomputedLabel.trim()), MAX_DESCRIPTION_CHARS)
166
+ }
159
167
  if (!input || typeof input !== 'object') return ''
160
168
  const str = (k: string): string | undefined =>
161
169
  typeof input[k] === 'string' ? (input[k] as string) : undefined
@@ -307,6 +315,53 @@ export function toolLabel(
307
315
  * labels, and otherwise capitalise the first letter and keep the rest
308
316
  * verbatim so unknown servers still render cleanly.
309
317
  */
318
+ /**
319
+ * Public alias for `mcpBaseLabel` — the progress card uses this when an
320
+ * `mcp__*` tool fires with no human label so the row can still render
321
+ * something readable (e.g. "Telegram: reply") instead of the raw tool
322
+ * name.
323
+ */
324
+ export function mcpDisplayName(tool: string): string {
325
+ return mcpBaseLabel(tool)
326
+ }
327
+
328
+ /**
329
+ * Human-readable fallback for a bare tool name when no label is
330
+ * available (TodoWrite, Task housekeeping, etc.). Returns the lowercased
331
+ * raw tool name when the tool isn't in the map.
332
+ */
333
+ const TOOL_FALLBACK_LABELS: Record<string, string> = {
334
+ Bash: 'running command',
335
+ BashOutput: 'running command',
336
+ Edit: 'editing file',
337
+ Write: 'editing file',
338
+ NotebookEdit: 'editing file',
339
+ Read: 'reading file',
340
+ Grep: 'searching',
341
+ Glob: 'searching',
342
+ WebFetch: 'fetching',
343
+ WebSearch: 'searching the web',
344
+ Task: 'delegating',
345
+ Agent: 'delegating',
346
+ TodoWrite: 'updating tasks',
347
+ TaskCreate: 'updating tasks',
348
+ TaskUpdate: 'updating tasks',
349
+ TaskList: 'updating tasks',
350
+ TaskGet: 'updating tasks',
351
+ TaskStop: 'updating tasks',
352
+ TaskOutput: 'updating tasks',
353
+ Skill: 'running skill',
354
+ SlashCommand: 'running command',
355
+ KillShell: 'running command',
356
+ ToolSearch: 'searching',
357
+ }
358
+
359
+ export function toolFallbackLabel(tool: string): string {
360
+ if (!tool) return ''
361
+ if (TOOL_FALLBACK_LABELS[tool]) return TOOL_FALLBACK_LABELS[tool]
362
+ return tool.toLowerCase()
363
+ }
364
+
310
365
  function mcpBaseLabel(tool: string): string {
311
366
  if (!tool.startsWith('mcp__')) return ''
312
367
  const parts = tool.slice('mcp__'.length).split('__')
@@ -16,6 +16,7 @@ import type { FleetMember, FleetStatus } from './fleet-state.js'
16
16
  import { cap } from './fleet-state.js'
17
17
  import type { ProgressCardState, RenderOptions, TaskNum } from './progress-card.js'
18
18
  import { escapeHtml, formatDuration } from './card-format.js'
19
+ import { mcpDisplayName, toolFallbackLabel } from './tool-labels.js'
19
20
 
20
21
  const PARENT_BULLET_CAP = 8
21
22
  const FLEET_ROW_CAP = 5
@@ -86,7 +87,15 @@ export function phaseFor(
86
87
  if (stalledClose) return { icon: '⚠', label: 'Forced close' }
87
88
 
88
89
  const fleetRunning = anyFleetActive(fleet)
89
- const fleetAllStuck = fleet.size > 0 && [...fleet.values()].filter((m) => m.status === 'running' || m.status === 'stuck').every((m) => isStuck(m, now))
90
+ // Stalled = "the only fleet members that could still make progress have
91
+ // gone idle past the threshold". An empty filtered list (every fleet
92
+ // member is already terminal) MUST NOT count as stalled — Array#every
93
+ // returns true vacuously on `[]`, which previously caused the card to
94
+ // freeze at ⚠ Stalled the moment the last sub-agent finished while the
95
+ // parent was still running. We require at least one running-or-stuck
96
+ // member before claiming the fleet is stalled.
97
+ const runningOrStuck = [...fleet.values()].filter((m) => m.status === 'running' || m.status === 'stuck')
98
+ const fleetAllStuck = runningOrStuck.length > 0 && runningOrStuck.every((m) => isStuck(m, now))
90
99
 
91
100
  // SilentEnd: parent terminated without a reply. Lifted above the
92
101
  // background/done branches so a still-running fleet can't mask it,
@@ -98,8 +107,11 @@ export function phaseFor(
98
107
  // Stalled: every running-or-stuck member is past the idle threshold.
99
108
  // Members already terminal (done/failed) are excluded from this check —
100
109
  // a fleet of [done, stuck] still surfaces as Stalled because the only
101
- // member that could still make progress is no longer doing so.
102
- if (fleet.size > 0 && fleetAllStuck && !parentDone) {
110
+ // member that could still make progress is no longer doing so. The
111
+ // `fleetAllStuck` calc itself guards against an empty filtered list
112
+ // (otherwise `[].every()` would lock the card at Stalled the moment
113
+ // the last sub-agent finished).
114
+ if (fleetAllStuck && !parentDone) {
103
115
  return { icon: '⚠', label: 'Stalled' }
104
116
  }
105
117
 
@@ -159,12 +171,20 @@ function renderParentZone(state: ProgressCardState): string {
159
171
  const lastIdx = visible.length - 1
160
172
  for (let i = 0; i < visible.length; i++) {
161
173
  const it = visible[i]
162
- const tool = escapeHtml(it.tool || '')
163
- const label = it.label ? ` <code>${escapeHtml(truncate(it.label, 80))}</code>` : ''
174
+ let text: string
175
+ if (it.label) {
176
+ text = escapeHtml(truncate(it.label, 80))
177
+ } else {
178
+ const tool = it.tool || ''
179
+ const fallback = tool.startsWith('mcp__')
180
+ ? mcpDisplayName(tool) || toolFallbackLabel(tool)
181
+ : toolFallbackLabel(tool)
182
+ text = escapeHtml(fallback)
183
+ }
164
184
  if (inFlight && i === lastIdx) {
165
- lines.push(`◉ <b>${tool}</b>${label}`)
185
+ lines.push(`◉ ${text}`)
166
186
  } else {
167
- lines.push(`● ${tool}${label}`)
187
+ lines.push(`● ${text}`)
168
188
  }
169
189
  }
170
190
  return lines.join('\n')
@@ -0,0 +1,160 @@
1
+ # UAT Harness — One-time Setup
2
+
3
+ This is the operator runbook for bringing up the Telegram UAT harness
4
+ introduced by epic [#863](https://github.com/switchroom/switchroom/issues/863).
5
+ Phase 1 ships scaffolding only — every step in this file is a manual
6
+ prerequisite that must be completed once before the first scenario can
7
+ run against real Telegram.
8
+
9
+ > ⚠️ **Security floor.** The mtcute *session string* this harness mints
10
+ > is **bearer-equivalent to the driver Telegram user account**. Anyone
11
+ > holding it can read every chat the user can read and impersonate them
12
+ > in messages. Treat it with the same care as a long-lived OAuth refresh
13
+ > token:
14
+ >
15
+ > - **Never** log it. **Never** echo it to a terminal. **Never** commit
16
+ > it. **Never** paste it into chat for "debugging."
17
+ > - It lives in vault under key `telegram-uat-driver-session` and that
18
+ > is the only legitimate location.
19
+ > - Same rules apply to `telegram-test-bot-token` (a normal bot token,
20
+ > but a bot the harness drives autonomously — leak = remote control).
21
+
22
+ ---
23
+
24
+ ## 1. BotFather: create the test bot
25
+
26
+ 1. Open `@BotFather` from the operator's Telegram account.
27
+ 2. `/newbot` → name (e.g. `Switchroom UAT`) → username (e.g.
28
+ `@switchroom_uat_bot`).
29
+ 3. Copy the HTTP API token BotFather returns.
30
+ 4. **Disable privacy mode** so the test bot sees all messages in groups,
31
+ not just commands: `/setprivacy` → select the bot → `Disable`.
32
+ (Privacy mode does not affect the driver's ability to read the bot —
33
+ bots cannot read other bots regardless. This is for the bot reading
34
+ the driver user.)
35
+ 5. Vault the token:
36
+ ```bash
37
+ switchroom vault set telegram-test-bot-token
38
+ # paste token at the prompt; do not pass via argv
39
+ ```
40
+ 6. Sanity check:
41
+ ```bash
42
+ TOKEN=$(switchroom vault get telegram-test-bot-token)
43
+ curl -s "https://api.telegram.org/bot${TOKEN}/getMe" | jq .ok
44
+ # expect: true
45
+ unset TOKEN
46
+ ```
47
+
48
+ ## 2. Create the test supergroup
49
+
50
+ 1. New Group → add the test bot + the driver user → "Upgrade to
51
+ supergroup" (Settings → Group Type, or just enable Topics; both
52
+ actions imply supergroup).
53
+ 2. Settings → **Topics: Enabled**.
54
+ 3. Settings → Administrators → promote both:
55
+ - test bot — needs at least: Manage Topics, Pin Messages, Delete
56
+ Messages.
57
+ - driver user — needs at least: Manage Topics (so per-scenario
58
+ topic creation works without the bot doing it).
59
+ 4. Note the chat id. Easiest: forward any message from the supergroup
60
+ to `@RawDataBot` and copy `forward_from_chat.id`. It will be
61
+ negative and ~13 digits (`-100…`).
62
+ 5. Stash the chat id under your shell profile or a UAT env file (NOT
63
+ in the repo):
64
+ ```bash
65
+ echo 'export SWITCHROOM_UAT_CHAT_ID=-1001234567890' >> ~/.config/switchroom/uat.env
66
+ ```
67
+
68
+ ## 3. Driver user: mint the mtcute session
69
+
70
+ The mtcute MTProto driver runs as a **Telegram user account**, not a
71
+ bot, because bots cannot read other bots' messages even with admin
72
+ rights. ([Telegram Bots FAQ](https://core.telegram.org/bots/faq).)
73
+
74
+ You will need:
75
+ - An `api_id` and `api_hash` from <https://my.telegram.org/apps> (one
76
+ per developer; reusable across projects).
77
+ - The driver user's phone number, the SMS/Telegram login code, and the
78
+ 2FA password if set.
79
+
80
+ Run:
81
+ ```bash
82
+ cd telegram-plugin
83
+ TELEGRAM_API_ID=12345 TELEGRAM_API_HASH=abcdef0123... bun run uat:login
84
+ ```
85
+
86
+ The script prompts for phone, login code, and 2FA password on stdin,
87
+ captures the session string in memory, and writes it to vault as
88
+ `telegram-uat-driver-session`. It **never prints the session string
89
+ to stdout or stderr** — if you see one in your scrollback, file an
90
+ incident.
91
+
92
+ ## 4. 2FA / new-device re-login playbook
93
+
94
+ mtcute session strings can get invalidated when:
95
+ - The user changes/sets a 2FA password.
96
+ - The user terminates the session from another client (Settings →
97
+ Devices → Active sessions → Terminate).
98
+ - Telegram's anti-abuse heuristics decide the session is suspicious
99
+ (rare, but happens after long idle + IP change).
100
+
101
+ When a scenario fails with `AUTH_KEY_UNREGISTERED`,
102
+ `SESSION_REVOKED`, or `SESSION_PASSWORD_NEEDED`:
103
+
104
+ 1. Confirm via the Telegram app (Settings → Devices) that the prior
105
+ session is gone.
106
+ 2. Re-run `bun run uat:login` from the operator's machine.
107
+ 3. Enter the current 2FA password when prompted.
108
+ 4. The script overwrites the vault key. No other action required —
109
+ nothing caches the old string.
110
+
111
+ If the driver account is locked entirely (e.g. SPAM_WAIT), only the
112
+ account owner can resolve it via support@telegram.org. The harness has
113
+ no recourse.
114
+
115
+ ## 5. Worktree-based agent install (NOT `switchroom agent add`)
116
+
117
+ The UAT harness does **not** persistently install the test-harness
118
+ agent through `switchroom agent add` (which writes a systemd unit + a
119
+ persistent state dir — wrong shape for hermetic test runs). Instead,
120
+ the harness `exec`s the agent as a child process per scenario with:
121
+
122
+ - `STATE_DIR=$(mktemp -d)` — ephemeral; teardown rm-rfs it.
123
+ - A unique `TELEGRAM_GATEWAY_PORT` (see port allocator note below).
124
+ - `SWITCHROOM_AGENT_NAME=test-harness`.
125
+ - The test bot token loaded from `telegram-test-bot-token`.
126
+
127
+ The Phase 1 scaffold stubs this out in `harness.ts`; Phase 2 wires it
128
+ end-to-end.
129
+
130
+ ## 6. Port allocator vs unix sockets
131
+
132
+ Phase 1 commits to a **process-wide port allocator** (see
133
+ `uat/port-allocator.ts`) rather than unix sockets. Rationale:
134
+
135
+ - The gateway already speaks IP loopback to the bridge; switching to
136
+ unix sockets is a code change in `gateway/` we don't want bundled
137
+ with the UAT scaffold work.
138
+ - Tests only ever run from one harness process, so a node-local
139
+ monotonic counter starting at a high ephemeral port (default 47000)
140
+ is enough to avoid collisions with the system + with sibling
141
+ scenarios in the same run.
142
+ - The allocator also `bind()`s a probe socket and releases it before
143
+ returning, which catches "port already in use by another process"
144
+ before the agent boots and produces a confusing crash.
145
+
146
+ If we ever want concurrent harness runs from CI, swap to unix sockets;
147
+ the harness API takes a `transport` shape so it's a one-line change.
148
+
149
+ ## 7. Verification checklist before running scenarios
150
+
151
+ - [ ] `switchroom vault get telegram-test-bot-token` returns a token.
152
+ - [ ] `switchroom vault get telegram-uat-driver-session` returns a
153
+ session string (the command output may be redacted by the
154
+ vault — that's fine, you only need exit code 0).
155
+ - [ ] `$SWITCHROOM_UAT_CHAT_ID` exported and is a negative int.
156
+ - [ ] Test bot is admin in the supergroup.
157
+ - [ ] Driver user is admin in the supergroup.
158
+ - [ ] Topics enabled in the supergroup.
159
+
160
+ When all six are checked, `bun run test:uat` is safe to run.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Eventual-assertion helpers for UAT scenarios.
3
+ *
4
+ * Issue: https://github.com/switchroom/switchroom/issues/866
5
+ *
6
+ * Real Telegram is eventually consistent across the bot API, MTProto,
7
+ * and CDN edges. Every assertion in a UAT scenario is a `waitFor`-
8
+ * shape: poll a predicate until it goes truthy or a deadline trips.
9
+ * Avoid `setTimeout(..., N); expect(...)` patterns at all cost.
10
+ */
11
+
12
+ import type { Driver, ObservedMessage, ObservedReaction } from "./driver.js";
13
+
14
+ export interface PollOptions {
15
+ /** Hard deadline; the predicate must resolve truthy before this. */
16
+ timeout: number;
17
+ /** Poll cadence in ms. Default 250ms. */
18
+ interval?: number;
19
+ }
20
+
21
+ /**
22
+ * Poll `predicate` every `interval` ms until it returns a truthy
23
+ * value, then resolve with that value. Reject when `timeout` ms
24
+ * elapse without success.
25
+ *
26
+ * The predicate may throw — exceptions are caught and treated as a
27
+ * "not yet" signal until the deadline. The last-seen exception is
28
+ * attached to the timeout error so flakes are debuggable.
29
+ */
30
+ export async function pollUntil<T>(
31
+ predicate: () => Promise<T | undefined | null | false> | T | undefined | null | false,
32
+ opts: PollOptions,
33
+ ): Promise<T> {
34
+ const interval = opts.interval ?? 250;
35
+ const deadline = Date.now() + opts.timeout;
36
+ let lastError: unknown;
37
+
38
+ while (Date.now() < deadline) {
39
+ try {
40
+ const result = await predicate();
41
+ if (result) return result as T;
42
+ } catch (err) {
43
+ lastError = err;
44
+ }
45
+ const remaining = deadline - Date.now();
46
+ if (remaining <= 0) break;
47
+ await sleep(Math.min(interval, remaining));
48
+ }
49
+
50
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
51
+ throw new Error(`pollUntil: deadline exceeded after ${opts.timeout}ms${detail}`);
52
+ }
53
+
54
+ /**
55
+ * Sugar over `pollUntil` for the boolean-predicate case where the
56
+ * caller wants a clear assertion message.
57
+ */
58
+ export async function expectEventually(
59
+ predicate: () => Promise<boolean> | boolean,
60
+ opts: PollOptions,
61
+ msg: string,
62
+ ): Promise<void> {
63
+ await pollUntil(async () => {
64
+ const ok = await predicate();
65
+ return ok || undefined;
66
+ }, opts).catch((err) => {
67
+ throw new Error(`expectEventually(${msg}): ${(err as Error).message}`);
68
+ });
69
+ }
70
+
71
+ // ---------- Phase 2 stubs ----------
72
+
73
+ /**
74
+ * TODO(#866): wait for the bot to send a message in `chatId`/topic
75
+ * matching `match` (substring, regex, or predicate over the raw
76
+ * `ObservedMessage`). Returns the matched message.
77
+ */
78
+ export async function expectMessage(
79
+ _driver: Driver,
80
+ _chatId: number,
81
+ _match: string | RegExp | ((m: ObservedMessage) => boolean),
82
+ _opts: PollOptions & { threadId?: number; from?: "bot" | "user" },
83
+ ): Promise<ObservedMessage> {
84
+ throw new Error("expectMessage not implemented (Phase 2)");
85
+ }
86
+
87
+ /**
88
+ * TODO(#866): wait for a reaction sequence on `messageId`. Each
89
+ * emoji in `sequence` must appear (add op) in order; intermediate
90
+ * other reactions are tolerated. Returns the full observed reaction
91
+ * trail.
92
+ */
93
+ export async function expectReaction(
94
+ _driver: Driver,
95
+ _chatId: number,
96
+ _messageId: number,
97
+ _sequence: string[],
98
+ _opts: PollOptions,
99
+ ): Promise<ObservedReaction[]> {
100
+ throw new Error("expectReaction not implemented (Phase 2)");
101
+ }
102
+
103
+ export interface PinnedCardSnapshot {
104
+ messageId: number;
105
+ text: string;
106
+ html?: string;
107
+ /** Production phase markers: `boot` | `working` | `done` | `error`. */
108
+ phase: string;
109
+ }
110
+
111
+ /**
112
+ * TODO(#866): wait for a pinned message to appear in
113
+ * `chatId`/topic (the progress card). Resolves with a snapshot of
114
+ * its current text/phase.
115
+ */
116
+ export async function expectPinnedCard(
117
+ _driver: Driver,
118
+ _chatId: number,
119
+ _opts: PollOptions & { threadId?: number },
120
+ ): Promise<PinnedCardSnapshot> {
121
+ throw new Error("expectPinnedCard not implemented (Phase 2)");
122
+ }
123
+
124
+ /**
125
+ * TODO(#866): wait for the pinned progress card to transition to
126
+ * `phase`. The harness must read live edits, not just the snapshot
127
+ * captured by `expectPinnedCard`.
128
+ */
129
+ export async function waitForCardPhase(
130
+ _driver: Driver,
131
+ _card: PinnedCardSnapshot,
132
+ _phase: "boot" | "working" | "done" | "error",
133
+ _opts: PollOptions,
134
+ ): Promise<PinnedCardSnapshot> {
135
+ throw new Error("waitForCardPhase not implemented (Phase 2)");
136
+ }
137
+
138
+ function sleep(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * mtcute-backed Telegram user-account driver for the UAT harness.
3
+ *
4
+ * Issue: https://github.com/switchroom/switchroom/issues/865
5
+ *
6
+ * The driver is a Telegram **user account** (not a bot) because bots
7
+ * cannot read other bots' messages even with privacy mode disabled
8
+ * and admin rights — see Telegram Bots FAQ. The driver sends fixture
9
+ * inbounds and observes everything the test bot emits.
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.
15
+ *
16
+ * Security: never log session strings, never log message bodies that
17
+ * might contain auth codes (see `auth-code-redact.ts` for the
18
+ * production pattern).
19
+ */
20
+
21
+ import { TelegramClient } from "@mtcute/node";
22
+
23
+ export interface DriverOptions {
24
+ /** Telegram developer credential — `api_id` from my.telegram.org. */
25
+ apiId: number;
26
+ /** Telegram developer credential — `api_hash` from my.telegram.org. */
27
+ apiHash: string;
28
+ /**
29
+ * Session string previously minted by `bun run uat:login` and
30
+ * stored in vault under `telegram-uat-driver-session`. Bearer-
31
+ * equivalent — never log.
32
+ */
33
+ session: string;
34
+ }
35
+
36
+ export interface SendTextOptions {
37
+ /** Forum topic id, when targeting a topic in a supergroup. */
38
+ messageThreadId?: number;
39
+ /** Reply-quote a specific earlier message id. */
40
+ replyTo?: number;
41
+ }
42
+
43
+ export interface ObservedMessage {
44
+ chatId: number;
45
+ messageId: number;
46
+ threadId?: number;
47
+ text: string;
48
+ /** raw HTML if the message was sent with `parse_mode: HTML`. */
49
+ html?: string;
50
+ fromBot: boolean;
51
+ date: Date;
52
+ }
53
+
54
+ export interface ObservedReaction {
55
+ chatId: number;
56
+ messageId: number;
57
+ emoji: string;
58
+ /** Reaction add (`+`) vs remove (`-`). */
59
+ op: "+" | "-";
60
+ date: Date;
61
+ }
62
+
63
+ export interface ObservedPin {
64
+ chatId: number;
65
+ messageId: number;
66
+ pinned: boolean;
67
+ date: Date;
68
+ }
69
+
70
+ /**
71
+ * Thin wrapper. Concrete mtcute use is intentionally narrow so the
72
+ * scenarios don't get tangled up in raw MTProto types.
73
+ */
74
+ export class Driver {
75
+ private client: TelegramClient | null = null;
76
+
77
+ constructor(private readonly opts: DriverOptions) {}
78
+
79
+ 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.
86
+ this.client = new TelegramClient({
87
+ apiId: this.opts.apiId,
88
+ apiHash: this.opts.apiHash,
89
+ });
90
+ void this.opts.session;
91
+ }
92
+
93
+ async disconnect(): Promise<void> {
94
+ if (!this.client) return;
95
+ await this.client.destroy();
96
+ this.client = null;
97
+ }
98
+
99
+ async sendText(
100
+ chatId: number,
101
+ text: string,
102
+ opts?: SendTextOptions,
103
+ ): Promise<{ messageId: number }> {
104
+ 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
112
+ return { messageId: sent.id };
113
+ }
114
+
115
+ // -------- Phase 2 stubs --------
116
+
117
+ /**
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/`.
122
+ */
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)");
129
+ }
130
+
131
+ /**
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.
136
+ */
137
+ observeMessages(
138
+ _chatId: number,
139
+ _opts?: { threadId?: number },
140
+ ): AsyncIterable<ObservedMessage> {
141
+ throw new Error("Driver.observeMessages not implemented (Phase 2)");
142
+ }
143
+
144
+ /**
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.
149
+ */
150
+ observeReactions(
151
+ _chatId: number,
152
+ _opts?: { messageId?: number },
153
+ ): AsyncIterable<ObservedReaction> {
154
+ throw new Error("Driver.observeReactions not implemented (Phase 2)");
155
+ }
156
+
157
+ /**
158
+ * TODO(#865): subscribe to pin/unpin events on `chatId`/topic.
159
+ * Used for progress-card-lifecycle assertions.
160
+ */
161
+ observePins(
162
+ _chatId: number,
163
+ _opts?: { threadId?: number },
164
+ ): AsyncIterable<ObservedPin> {
165
+ throw new Error("Driver.observePins not implemented (Phase 2)");
166
+ }
167
+
168
+ private requireClient(): TelegramClient {
169
+ if (!this.client) {
170
+ throw new Error("Driver not connected — call connect() first");
171
+ }
172
+ return this.client;
173
+ }
174
+ }