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.
- package/README.md +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +503 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +558 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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(`◉
|
|
185
|
+
lines.push(`◉ ${text}`)
|
|
166
186
|
} else {
|
|
167
|
-
lines.push(`● ${
|
|
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
|
+
}
|