Compare commits

..
36 changed files with 249 additions and 1856 deletions

No files matched your search

+1 -8
View File
@@ -8,21 +8,16 @@
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Commands: harden slash command registry and list text-only commands in `/commands`.
- Commands: add `/commands` list including text-only entries. (#497) — thanks @lc0rp
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- iMessage: isolate group-ish threads by `chat_id` when `is_group=false` to avoid DM session bleed. (#535) — thanks @mdahmann
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
@@ -30,7 +25,6 @@
- Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
- Doctor: run legacy state migrations in non-interactive mode without prompts.
@@ -121,7 +115,6 @@
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
## 2026.1.8
+3 -3
View File
@@ -57,7 +57,7 @@ Payload:
"name": "Email",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
"deliver": false,
"provider": "last",
"to": "+15551234567",
"model": "openai/gpt-5.2-mini",
@@ -70,8 +70,8 @@ Payload:
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `false`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
+1 -5
View File
@@ -177,11 +177,7 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--anthropic-api-key <key>`
- `--openai-api-key <key>`
- `--gemini-api-key <key>`
+2 -17
View File
@@ -29,19 +29,6 @@ clawdbot models status
clawdbot doctor
```
Alternative: run the wrapper (also updates Clawdbot config):
```bash
clawdbot models auth setup-token --provider anthropic
```
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
```bash
clawdbot models auth paste-token --provider anthropic
clawdbot models auth paste-token --provider openrouter
```
## Recommended: long‑lived Claude Code token
Run this on the **gateway host** (the machine running the Gateway):
@@ -105,15 +92,13 @@ Use `--agent <id>` to target a specific agent; omit it to use the configured def
2. **Clawdbot** syncs those into
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
loaded.
3. Refreshable OAuth profiles can be refreshed automatically on use. Static
token profiles (including Claude CLI setup-token) are not refreshable by
Clawdbot.
3. OAuth refresh happens automatically on use if a token is expired.
## Troubleshooting
### “No credentials found”
If the Anthropic token profile is missing, run `claude setup-token` on the
If the Anthropic OAuth profile is missing, run `claude setup-token` on the
**gateway host**, then re-check:
```bash
+22 -70
View File
@@ -13,7 +13,6 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
- iMessage provider backed by `imsg` on macOS.
- Deterministic routing: replies always go back to iMessage.
- DMs share the agent's main session; groups are isolated (`imessage:group:<chat_id>`).
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `imessage.groups` (see “Group-ish threads” below).
## Requirements
- macOS with Messages signed in.
@@ -25,61 +24,35 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
1) Ensure Messages is signed in on this Mac.
2) Configure iMessage and start the gateway.
### Dedicated bot macOS user (for isolated identity)
If you want the bot to send from a **separate iMessage identity** (and keep your personal Messages clean), use a dedicated Apple ID + a dedicated macOS user.
1) Create a dedicated Apple ID (example: `my-cool-bot@icloud.com`).
- Apple may require a phone number for verification / 2FA.
2) Create a macOS user (example: `clawdshome`) and sign into it.
3) Open Messages in that macOS user and sign into iMessage using the bot Apple ID.
4) Enable Remote Login (System Settings → General → Sharing → Remote Login).
5) Install `imsg`:
- `brew install steipete/tap/imsg`
6) Set up SSH so `ssh <bot-macos-user>@localhost true` works without a password.
7) Point `imessage.accounts.bot.cliPath` at an SSH wrapper that runs `imsg` as the bot user.
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the *bot macOS user*. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry.
Example wrapper (`chmod +x`). Replace `<bot-macos-user>` with your actual macOS username:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Run an interactive SSH once first to accept host keys:
# ssh <bot-macos-user>@localhost true
exec /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 -T <bot-macos-user>@localhost \
"/usr/local/bin/imsg" "$@"
```
Example config:
```json5
{
imessage: {
enabled: true,
accounts: {
bot: {
name: "Bot",
enabled: true,
cliPath: "/path/to/imsg-bot",
dbPath: "/Users/<bot-macos-user>/Library/Messages/chat.db"
}
}
}
}
```
For single-account setups, use flat options (`imessage.cliPath`, `imessage.dbPath`) instead of the `accounts` map.
### Remote/SSH variant (optional)
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that runs `imsg` on the remote macOS host over SSH. Clawdbot only needs stdio.
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that
execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a
stdio stream; `imsg` still runs on the remote macOS host.
Example wrapper:
Example wrapper (save somewhere in your PATH and `chmod +x`):
```bash
#!/usr/bin/env bash
exec ssh -T mac-mini imsg "$@"
```
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don’t commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
Notes:
- Remote Mac must have Messages signed in and `imsg` installed.
- Full Disk Access + Automation prompts happen on the remote Mac.
- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended.
Example:
```json5
{
imessage: {
enabled: true,
cliPath: "/usr/local/bin/imessage-remote",
dmPolicy: "pairing",
allowFrom: ["+15555550123"]
}
}
```
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Access control (DMs + groups)
DMs:
@@ -100,27 +73,6 @@ Groups:
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope.
- Replies always route back to the same chat id or handle.
## Group-ish threads (`is_group=false`)
Some iMessage threads can have multiple participants but still arrive with `is_group=false` depending on how Messages stores the chat identifier.
If you explicitly configure a `chat_id` under `imessage.groups`, Clawdbot treats that thread as a “group” for:
- session isolation (separate `imessage:group:<chat_id>` session key)
- group allowlisting / mention gating behavior
Example:
```json5
{
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"42": { "requireMention": false }
}
}
}
```
This is useful when you want an isolated personality/model for a specific thread (see [Multi-agent routing](/concepts/multi-agent)). For filesystem isolation, see [Sandboxing](/gateway/sandboxing).
## Media + limits
- Optional attachment ingestion via `imessage.includeAttachments`.
- Media cap via `imessage.mediaMaxMb`.
-1
View File
@@ -36,7 +36,6 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
Text + native (when enabled):
- `/help`
- `/commands`
- `/status`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only)
+1 -2
View File
@@ -80,7 +80,6 @@
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "vitest run --config vitest.live.config.ts",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
@@ -94,7 +93,7 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@buape/carbon": "0.0.0-beta-20260109165250",
"@buape/carbon": "0.13.0",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
+55 -75
View File
@@ -17,8 +17,8 @@ importers:
.:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260109165250
version: 0.0.0-beta-20260109165250(hono@4.11.3)
specifier: 0.13.0
version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -366,8 +366,8 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@buape/carbon@0.0.0-beta-20260109165250':
resolution: {integrity: sha512-Xt8W0RYZxaprp2YxMU1rZRfGkHQWEJ2KZUurMwK/G4WVTfJSWAUeaeOGhpO40fCm/g0iPMuSLgQ4eudGOQHigw==}
'@buape/carbon@0.13.0':
resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
'@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -385,12 +385,8 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@cloudflare/workers-types@4.20251205.0':
resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
'@cloudflare/workers-types@4.20250513.0':
resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -597,8 +593,8 @@ packages:
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
hasBin: true
'@hono/node-server@1.19.6':
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
'@hono/node-server@1.18.2':
resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -1284,8 +1280,8 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/bun@1.3.3':
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==}
'@types/bun@1.2.23':
resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1341,8 +1337,8 @@ packages:
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@24.10.4':
resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==}
'@types/node@22.19.3':
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
@@ -1359,6 +1355,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -1612,8 +1611,10 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bun-types@1.3.3:
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==}
bun-types@1.2.23:
resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==}
peerDependencies:
'@types/react': ^19
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@@ -1725,6 +1726,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -1764,8 +1768,8 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
discord-api-types@0.38.36:
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==}
discord-api-types@0.38.29:
resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
@@ -2668,23 +2672,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -3062,6 +3049,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -3368,24 +3358,20 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260109165250(hono@4.11.3)':
'@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)':
dependencies:
'@types/node': 24.10.4
discord-api-types: 0.38.36
'@types/node': 22.19.3
discord-api-types: 0.38.29
optionalDependencies:
'@cloudflare/workers-types': 4.20251205.0
'@discordjs/voice': 0.19.0
'@hono/node-server': 1.19.6(hono@4.11.3)
'@types/bun': 1.3.3
'@cloudflare/workers-types': 4.20250513.0
'@hono/node-server': 1.18.2(hono@4.11.3)
'@types/bun': 1.2.23(@types/react@19.2.7)
'@types/ws': 8.18.1
ws: 8.18.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@types/react'
- bufferutil
- ffmpeg-static
- hono
- node-opus
- opusscript
- utf-8-validate
'@cacheable/memory@2.0.7':
@@ -3417,23 +3403,7 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@cloudflare/workers-types@4.20251205.0':
optional: true
'@discordjs/voice@0.19.0':
dependencies:
'@types/ws': 8.18.1
discord-api-types: 0.38.37
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
- '@discordjs/opus'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@cloudflare/workers-types@4.20250513.0':
optional: true
'@emnapi/core@1.8.1':
@@ -3571,7 +3541,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hono/node-server@1.19.6(hono@4.11.3)':
'@hono/node-server@1.18.2(hono@4.11.3)':
dependencies:
hono: 4.11.3
optional: true
@@ -4209,9 +4179,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.0.3
'@types/bun@1.3.3':
'@types/bun@1.2.23(@types/react@19.2.7)':
dependencies:
bun-types: 1.3.3
bun-types: 1.2.23(@types/react@19.2.7)
transitivePeerDependencies:
- '@types/react'
optional: true
'@types/chai@5.2.3':
@@ -4280,9 +4252,9 @@ snapshots:
'@types/node@10.17.60': {}
'@types/node@24.10.4':
'@types/node@22.19.3':
dependencies:
undici-types: 7.16.0
undici-types: 6.21.0
'@types/node@25.0.3':
dependencies:
@@ -4298,6 +4270,11 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react@19.2.7':
dependencies:
csstype: 3.2.3
optional: true
'@types/retry@0.12.0': {}
'@types/retry@0.12.5': {}
@@ -4631,9 +4608,10 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bun-types@1.3.3:
bun-types@1.2.23(@types/react@19.2.7):
dependencies:
'@types/node': 25.0.3
'@types/react': 19.2.7
optional: true
bytes@3.1.2: {}
@@ -4751,6 +4729,9 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
csstype@3.2.3:
optional: true
curve25519-js@0.0.4: {}
data-uri-to-buffer@4.0.1: {}
@@ -4772,7 +4753,7 @@ snapshots:
diff@8.0.2: {}
discord-api-types@0.38.36: {}
discord-api-types@0.38.29: {}
discord-api-types@0.38.37: {}
@@ -5699,9 +5680,6 @@ snapshots:
react-is: 17.0.2
optional: true
prism-media@1.3.5:
optional: true
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {}
@@ -6176,6 +6154,8 @@ snapshots:
uint8array-extras@1.5.0: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.18.2: {}
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE_NAME="clawdbot-doctor-install-switch-e2e"
echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running doctor install switch E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
export PATH="/tmp/clawdbot-bin:$PATH"
mkdir -p /tmp/clawdbot-bin
cat > /tmp/clawdbot-bin/systemctl <<"SYSTEMCTL"
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
if [[ "${args[0]:-}" == "--user" ]]; then
args=("${args[@]:1}")
fi
cmd="${args[0]:-}"
case "$cmd" in
status)
exit 0
;;
is-enabled)
unit="${args[1]:-}"
unit_path="$HOME/.config/systemd/user/${unit}"
if [ -f "$unit_path" ]; then
exit 0
fi
exit 1
;;
show)
echo "ActiveState=inactive"
echo "SubState=dead"
echo "MainPID=0"
echo "ExecMainStatus=0"
echo "ExecMainCode=0"
exit 0
;;
*)
exit 0
;;
esac
SYSTEMCTL
chmod +x /tmp/clawdbot-bin/systemctl
cat > /tmp/clawdbot-bin/loginctl <<"LOGINCTL"
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == *"show-user"* ]]; then
echo "Linger=yes"
exit 0
fi
if [[ "$*" == *"enable-linger"* ]]; then
exit 0
fi
exit 0
LOGINCTL
chmod +x /tmp/clawdbot-bin/loginctl
npm install -g --prefix /tmp/npm-prefix /app
npm_bin="/tmp/npm-prefix/bin/clawdbot"
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
git_entry="/app/dist/entry.js"
assert_entrypoint() {
local unit_path="$1"
local expected="$2"
local exec_line=""
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
if [ -z "$exec_line" ]; then
echo "Missing ExecStart in $unit_path"
exit 1
fi
exec_line="${exec_line#ExecStart=}"
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
if [ "$entrypoint" != "$expected" ]; then
echo "Expected entrypoint $expected, got $entrypoint"
exit 1
fi
}
run_flow() {
local name="$1"
local install_cmd="$2"
local install_expected="$3"
local doctor_cmd="$4"
local doctor_expected="$5"
echo "== Flow: $name =="
home_dir=$(mktemp -d "/tmp/clawdbot-switch-${name}.XXXXXX")
export HOME="$home_dir"
export USER="testuser"
eval "$install_cmd"
unit_path="$HOME/.config/systemd/user/clawdbot-gateway.service"
if [ ! -f "$unit_path" ]; then
echo "Missing unit file: $unit_path"
exit 1
fi
assert_entrypoint "$unit_path" "$install_expected"
eval "$doctor_cmd"
assert_entrypoint "$unit_path" "$doctor_expected"
}
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"node $git_entry doctor --repair" \
"$git_entry"
run_flow \
"git-to-npm" \
"node $git_entry daemon install --force" \
"$git_entry" \
"$npm_bin doctor --repair" \
"$npm_entry"
'
-165
View File
@@ -2,10 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,
@@ -13,28 +10,17 @@ import {
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../discord/send.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
@@ -221,157 +207,6 @@ export async function handleDiscordGuildAction(
const event = await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true });
const parentId = readParentIdParam(params);
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = await createChannelDiscord({
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
});
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const parentId = readParentIdParam(params);
const nsfw = params.nsfw as boolean | undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
});
const channel = await editChannelDiscord({
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
});
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const result = await deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId", {
required: true,
});
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
await moveChannelDiscord({
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
position: position ?? undefined,
});
return jsonResult({ ok: true });
}
case "categoryCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = await createChannelDiscord({
guildId,
name,
type: 4,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = await editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const result = await deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
const targetTypeRaw = readStringParam(params, "targetType", {
required: true,
});
const targetType = targetTypeRaw === "member" ? 1 : 0;
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
await setChannelPermissionDiscord({
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
});
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
await removeChannelPermissionDiscord(channelId, targetId);
return jsonResult({ ok: true });
}
default:
throw new Error(`Unknown action: ${action}`);
}
-233
View File
@@ -1,58 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
const createChannelDiscord = vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
}));
const createThreadDiscord = vi.fn(async () => ({}));
const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" }));
const deleteMessageDiscord = vi.fn(async () => ({}));
const editChannelDiscord = vi.fn(async () => ({
id: "C1",
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({}));
const listThreadsDiscord = vi.fn(async () => ({}));
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
const pinMessageDiscord = vi.fn(async () => ({}));
const reactMessageDiscord = vi.fn(async () => ({}));
const readMessagesDiscord = vi.fn(async () => []);
const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const unpinMessageDiscord = vi.fn(async () => ({}));
vi.mock("../../discord/send.js", () => ({
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) =>
fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args),
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args),
removeChannelPermissionDiscord: (...args: unknown[]) =>
removeChannelPermissionDiscord(...args),
removeOwnReactionsDiscord: (...args: unknown[]) =>
removeOwnReactionsDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
@@ -60,8 +40,6 @@ vi.mock("../../discord/send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) =>
setChannelPermissionDiscord(...args),
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
}));
@@ -139,214 +117,3 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/);
});
});
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
const channelsDisabled = () => false;
describe("handleDiscordGuildAction - channel management", () => {
it("creates a channel", async () => {
const result = await handleDiscordGuildAction(
"channelCreate",
{
guildId: "G1",
name: "test-channel",
type: 0,
topic: "Test topic",
},
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "test-channel",
type: 0,
parentId: undefined,
topic: "Test topic",
position: undefined,
nsfw: undefined,
});
expect(result.details).toMatchObject({ ok: true });
});
it("respects channel gating for channelCreate", async () => {
await expect(
handleDiscordGuildAction(
"channelCreate",
{ guildId: "G1", name: "test" },
channelsDisabled,
),
).rejects.toThrow(/Discord channel management is disabled/);
});
it("edits a channel", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
name: "new-name",
topic: "new topic",
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: "new-name",
topic: "new topic",
position: undefined,
parentId: undefined,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("clears the channel parent when parentId is null", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("deletes a channel", async () => {
await handleDiscordGuildAction(
"channelDelete",
{ channelId: "C1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1");
});
it("moves a channel", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
});
});
it("clears the channel parent on move when parentId is null", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("creates a category with type=4", async () => {
await handleDiscordGuildAction(
"categoryCreate",
{ guildId: "G1", name: "My Category" },
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "My Category",
type: 4,
position: undefined,
});
});
it("edits a category", async () => {
await handleDiscordGuildAction(
"categoryEdit",
{ categoryId: "CAT1", name: "Renamed Category" },
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "CAT1",
name: "Renamed Category",
position: undefined,
});
});
it("deletes a category", async () => {
await handleDiscordGuildAction(
"categoryDelete",
{ categoryId: "CAT1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1");
});
it("sets channel permissions for role", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "R1",
targetType: "role",
allow: "1024",
deny: "2048",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "R1",
targetType: 0,
allow: "1024",
deny: "2048",
});
});
it("sets channel permissions for member", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "U1",
targetType: "member",
allow: "1024",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "U1",
targetType: 1,
allow: "1024",
deny: undefined,
});
});
it("removes channel permissions", async () => {
await handleDiscordGuildAction(
"channelPermissionRemove",
{ channelId: "C1", targetId: "R1" },
channelsEnabled,
);
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
});
});
-9
View File
@@ -37,15 +37,6 @@ const guildActions = new Set([
"voiceStatus",
"eventList",
"eventCreate",
"channelCreate",
"channelEdit",
"channelDelete",
"channelMove",
"categoryCreate",
"categoryEdit",
"categoryDelete",
"channelPermissionSet",
"channelPermissionRemove",
]);
const moderationActions = new Set(["timeout", "kick", "ban"]);
-63
View File
@@ -202,67 +202,4 @@ export const DiscordToolSchema = Type.Union([
reason: Type.Optional(Type.String()),
deleteMessageDays: Type.Optional(Type.Number()),
}),
// Channel management actions
Type.Object({
action: Type.Literal("channelCreate"),
guildId: Type.String(),
name: Type.String(),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("channelEdit"),
channelId: Type.String(),
name: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("channelDelete"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelMove"),
guildId: Type.String(),
channelId: Type.String(),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
position: Type.Optional(Type.Number()),
}),
// Category management actions (convenience aliases)
Type.Object({
action: Type.Literal("categoryCreate"),
guildId: Type.String(),
name: Type.String(),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryEdit"),
categoryId: Type.String(),
name: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryDelete"),
categoryId: Type.String(),
}),
// Permission overwrite actions
Type.Object({
action: Type.Literal("channelPermissionSet"),
channelId: Type.String(),
targetId: Type.String(),
targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]),
allow: Type.Optional(Type.String()),
deny: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("channelPermissionRemove"),
channelId: Type.String(),
targetId: Type.String(),
}),
]);
+4 -2
View File
@@ -43,11 +43,13 @@ describe("control command parsing", () => {
expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
expect(hasControlCommand("/compact")).toBe(true);
expect(hasControlCommand("/compact:")).toBe(true);
expect(hasControlCommand("compact")).toBe(false);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
+1 -1
View File
@@ -18,11 +18,11 @@ describe("commands registry", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true);
for (const command of listChatCommands()) {
+12 -105
View File
@@ -17,11 +17,6 @@ export type NativeCommandSpec = {
acceptsArgs: boolean;
};
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
function defineChatCommand(command: {
key: string;
nativeName?: string;
@@ -31,11 +26,8 @@ function defineChatCommand(command: {
textAliases?: string[];
scope?: CommandScope;
}): ChatCommandDefinition {
const aliases = (
command.textAliases ?? (command.textAlias ? [command.textAlias] : [])
)
.map((alias) => alias.trim())
.filter(Boolean);
const aliases =
command.textAliases ?? (command.textAlias ? [command.textAlias] : []);
const scope =
command.scope ??
(command.nativeName ? (aliases.length ? "both" : "native") : "text");
@@ -58,64 +50,16 @@ function registerAlias(
if (!command) {
throw new Error(`registerAlias: unknown command key: ${key}`);
}
const existing = new Set(
command.textAliases.map((alias) => alias.trim().toLowerCase()),
);
const existing = new Set(command.textAliases.map((alias) => alias.trim()));
for (const alias of aliases) {
const trimmed = alias.trim();
if (!trimmed) continue;
const lowered = trimmed.toLowerCase();
if (existing.has(lowered)) continue;
existing.add(lowered);
if (existing.has(trimmed)) continue;
existing.add(trimmed);
command.textAliases.push(trimmed);
}
}
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
const keys = new Set<string>();
const nativeNames = new Set<string>();
const textAliases = new Set<string>();
for (const command of commands) {
if (keys.has(command.key)) {
throw new Error(`Duplicate command key: ${command.key}`);
}
keys.add(command.key);
const nativeName = command.nativeName?.trim();
if (command.scope === "text") {
if (nativeName) {
throw new Error(`Text-only command has native name: ${command.key}`);
}
if (command.textAliases.length === 0) {
throw new Error(`Text-only command missing text alias: ${command.key}`);
}
} else if (!nativeName) {
throw new Error(`Native command missing native name: ${command.key}`);
} else {
const nativeKey = nativeName.toLowerCase();
if (nativeNames.has(nativeKey)) {
throw new Error(`Duplicate native command: ${nativeName}`);
}
nativeNames.add(nativeKey);
}
if (command.scope === "native" && command.textAliases.length > 0) {
throw new Error(`Native-only command has text aliases: ${command.key}`);
}
for (const alias of command.textAliases) {
if (!alias.startsWith("/")) {
throw new Error(`Command alias missing leading '/': ${alias}`);
}
const aliasKey = alias.toLowerCase();
if (textAliases.has(aliasKey)) {
throw new Error(`Duplicate command alias: ${alias}`);
}
textAliases.add(aliasKey);
}
}
}
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
const commands: ChatCommandDefinition[] = [
defineChatCommand({
@@ -246,28 +190,11 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models");
assertCommandRegistry(commands);
return commands;
})();
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
const map = new Map<string, TextAliasSpec>();
for (const command of CHAT_COMMANDS) {
const canonical = `/${command.key}`;
const acceptsArgs = Boolean(command.acceptsArgs);
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (!map.has(normalized)) {
map.set(normalized, { canonical, acceptsArgs });
}
}
}
return map;
})();
let cachedDetection:
| {
exact: Set<string>;
@@ -299,8 +226,8 @@ export function findCommandByNativeName(
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) =>
command.scope !== "text" &&
command.nativeName?.toLowerCase() === normalized,
command.nativeName?.toLowerCase() === normalized &&
command.scope !== "text",
);
}
@@ -312,31 +239,11 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
? (() => {
const [, command, rest] = colonMatch;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: trimmed;
const lowered = normalized.toLowerCase();
const exact = TEXT_ALIAS_MAP.get(lowered);
if (exact) return exact.canonical;
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!tokenMatch) return normalized;
const [, token, rest] = tokenMatch;
const tokenKey = `/${token.toLowerCase()}`;
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
if (!tokenSpec) return normalized;
if (rest && !tokenSpec.acceptsArgs) return normalized;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed;
const [, command, rest] = match;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
+1 -22
View File
@@ -240,23 +240,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
)
.option(
"--token-provider <id>",
"Token provider id (non-interactive; used with --auth-choice token)",
)
.option(
"--token <token>",
"Token value (non-interactive; used with --auth-choice token)",
)
.option(
"--token-profile-id <id>",
"Auth profile id (non-interactive; default: <provider>:manual)",
)
.option(
"--token-expires-in <duration>",
"Optional token expiry duration (e.g. 365d, 12h)",
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--openai-api-key <key>", "OpenAI API key")
@@ -286,7 +270,6 @@ export function buildProgram() {
mode: opts.mode as "local" | "remote" | undefined,
authChoice: opts.authChoice as
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
@@ -299,10 +282,6 @@ export function buildProgram() {
| "minimax"
| "skip"
| undefined,
tokenProvider: opts.tokenProvider as string | undefined,
token: opts.token as string | undefined,
tokenProfileId: opts.tokenProfileId as string | undefined,
tokenExpiresIn: opts.tokenExpiresIn as string | undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined,
openaiApiKey: opts.openaiApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,
+2 -8
View File
@@ -64,23 +64,17 @@ export function buildAuthChoiceOptions(params: {
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude CLI)",
label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires),
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude CLI)",
label: "Anthropic OAuth (Claude CLI)",
hint: "requires Keychain access",
});
}
options.push({
value: "setup-token",
label: "Anthropic token (run setup-token)",
hint: "Runs `claude setup-token`",
});
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
+1 -62
View File
@@ -216,68 +216,7 @@ export async function applyAuthChoice(params: {
provider: "anthropic",
mode: "token",
});
} else if (
params.authChoice === "setup-token" ||
params.authChoice === "oauth"
) {
await params.prompter.note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
const proceed = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig, agentModelOverride };
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
if (typeof res.status === "number" && res.status !== 0) {
await params.prompter.note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (params.authChoice === "token") {
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
+1 -64
View File
@@ -352,7 +352,6 @@ async function promptAuthConfig(
runtime,
) as
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
@@ -404,68 +403,7 @@ async function promptAuthConfig(
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "setup-token" || authChoice === "oauth") {
note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return next;
}
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (!runNow) return next;
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return next;
}
if (typeof res.status === "number" && res.status !== 0) {
note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return next;
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return next;
}
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "token") {
} else if (authChoice === "token" || authChoice === "oauth") {
const provider = guardCancel(
await select({
message: "Token provider",
@@ -788,7 +726,6 @@ async function promptAuthConfig(
: (next.agents?.defaults?.model?.primary ?? "");
const preferAnthropic =
authChoice === "claude-cli" ||
authChoice === "setup-token" ||
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "apiKey";
+24 -51
View File
@@ -22,7 +22,6 @@ import { resolveGatewayService } from "../daemon/service.js";
import {
auditGatewayServiceConfig,
needsNodeRuntimeMigration,
SERVICE_AUDIT_CODES,
} from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -49,17 +48,6 @@ function detectGatewayRuntime(
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
}
function findGatewayEntrypoint(programArguments?: string[]): string | null {
if (!programArguments || programArguments.length === 0) return null;
const gatewayIndex = programArguments.indexOf("gateway");
if (gatewayIndex <= 0) return null;
return programArguments[gatewayIndex - 1] ?? null;
}
function normalizeExecutablePath(value: string): string {
return path.resolve(value);
}
export async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig,
mode: "local" | "remote",
@@ -183,45 +171,6 @@ export async function maybeRepairGatewayServiceConfig(
env: process.env,
command,
});
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
if (
expectedEntrypoint &&
currentEntrypoint &&
normalizeExecutablePath(expectedEntrypoint) !==
normalizeExecutablePath(currentEntrypoint)
) {
audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
message: "Gateway service entrypoint does not match the current install.",
detail: `${currentEntrypoint} -> ${expectedEntrypoint}`,
level: "recommended",
});
}
if (audit.issues.length === 0) return;
note(
@@ -258,6 +207,30 @@ export async function maybeRepairGatewayServiceConfig(
initialValue: true,
});
if (!repair) return;
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
+11 -79
View File
@@ -1,14 +1,10 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import {
type ClawdbotConfig,
CONFIG_PATH_CLAWDBOT,
@@ -210,82 +206,18 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyOpenAICodexModelDefault(nextConfig).next;
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (authChoice === "setup-token" || authChoice === "oauth") {
if (!process.stdin.isTTY) {
runtime.error("`claude setup-token` requires an interactive TTY.");
runtime.exit(1);
return;
}
const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
runtime.error(`claude setup-token failed (exit ${res.status})`);
runtime.exit(1);
return;
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
`No Claude CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
);
runtime.exit(1);
return;
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "token") {
const providerRaw = opts.tokenProvider?.trim();
const tokenRaw = opts.token?.trim();
if (!providerRaw) {
runtime.error(
"Missing --token-provider (required for --auth-choice token).",
);
runtime.exit(1);
return;
}
if (!tokenRaw) {
runtime.error("Missing --token (required for --auth-choice token).");
runtime.exit(1);
return;
}
const provider = normalizeProviderId(providerRaw);
const profileId = (
opts.tokenProfileId?.trim() || `${provider}:manual`
).trim();
const expires =
opts.tokenExpiresIn?.trim() && opts.tokenExpiresIn.trim().length > 0
? Date.now() +
parseDurationMs(String(opts.tokenExpiresIn).trim(), {
defaultUnit: "d",
})
: undefined;
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token: tokenRaw,
...(expires ? { expires } : {}),
},
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider,
mode: "token",
});
} else if (authChoice === "openai-codex" || authChoice === "antigravity") {
} else if (
authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "openai-codex" ||
authChoice === "antigravity"
) {
const label =
authChoice === "antigravity" ? "Antigravity" : "OpenAI Codex OAuth";
authChoice === "antigravity"
? "Antigravity"
: authChoice === "token"
? "Token"
: "OAuth";
runtime.error(`${label} requires interactive mode.`);
runtime.exit(1);
return;
-10
View File
@@ -3,9 +3,7 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote";
export type AuthChoice =
// Legacy alias for `setup-token` (kept for backwards CLI compatibility).
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
@@ -29,14 +27,6 @@ export type OnboardOptions = {
workspace?: string;
nonInteractive?: boolean;
authChoice?: AuthChoice;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProvider?: string;
/** Used when `authChoice=token` in non-interactive mode. */
token?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProfileId?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenExpiresIn?: string;
anthropicApiKey?: string;
openaiApiKey?: string;
geminiApiKey?: string;
+3 -8
View File
@@ -10,11 +10,6 @@ export async function onboardCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
assertSupportedRuntime(runtime);
const authChoice =
opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
const normalizedOpts =
authChoice === opts.authChoice ? opts : { ...opts, authChoice };
if (process.platform === "win32") {
runtime.log(
[
@@ -25,12 +20,12 @@ export async function onboardCommand(
);
}
if (normalizedOpts.nonInteractive) {
await runNonInteractiveOnboarding(normalizedOpts, runtime);
if (opts.nonInteractive) {
await runNonInteractiveOnboarding(opts, runtime);
return;
}
await runInteractiveOnboarding(normalizedOpts, runtime);
await runInteractiveOnboarding(opts, runtime);
}
export type { OnboardOptions } from "./onboard-types.js";
-1
View File
@@ -404,7 +404,6 @@ export type DiscordActionConfig = {
moderation?: boolean;
emojiUploads?: boolean;
stickerUploads?: boolean;
channels?: boolean;
};
export type DiscordAccountConfig = {
-1
View File
@@ -30,7 +30,6 @@ export type ServiceConfigAudit = {
export const SERVICE_AUDIT_CODES = {
gatewayCommandMissing: "gateway-command-missing",
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal",
-267
View File
@@ -1,6 +1,5 @@
import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
@@ -120,168 +119,6 @@ describe("discord tool result dispatch", () => {
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000);
it("caches channel info lookups between messages", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: { dm: { enabled: true, policy: "open" } },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
});
const fetchChannel = vi.fn().mockResolvedValue({
type: ChannelType.DM,
name: "dm",
});
const client = { fetchChannel } as unknown as Client;
const baseMessage = {
content: "hello",
channelId: "cache-channel-1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u-cache", bot: false, username: "Ada" },
};
await handler(
{
message: { ...baseMessage, id: "m-cache-1" },
author: baseMessage.author,
guild_id: null,
},
client,
);
await handler(
{
message: { ...baseMessage, id: "m-cache-2" },
author: baseMessage.author,
guild_id: null,
},
client,
);
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("uses channel id allowlists for non-thread channels with categories", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx: { SessionKey?: string } | undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: {
"*": {
requireMention: false,
channels: { c1: { allow: true } },
},
},
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: {
"*": { requireMention: false, channels: { c1: { allow: true } } },
},
});
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
parentId: "category-1",
}),
rest: { get: vi.fn() },
} as unknown as Client;
await handler(
{
message: {
id: "m-category",
content: "hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
},
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
member: { displayName: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
});
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
@@ -544,110 +381,6 @@ describe("discord tool result dispatch", () => {
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
});
it("treats forum threads as distinct sessions without channel payloads", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx:
| {
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
}
| undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: false } },
});
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({
type: ChannelType.PublicThread,
name: "topic-1",
parentId: "forum-1",
})
.mockResolvedValueOnce({
type: ChannelType.GuildForum,
name: "support",
});
const restGet = vi.fn().mockResolvedValue({
content: "starter message",
author: { id: "u1", username: "Alice", discriminator: "0001" },
timestamp: new Date().toISOString(),
});
const client = {
fetchChannel,
rest: {
get: restGet,
},
} as unknown as Client;
await handler(
{
message: {
id: "m6",
content: "thread reply",
channelId: "t1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
},
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
member: { displayName: "Bob" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe(
"agent:main:discord:channel:forum-1",
);
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support");
expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1"));
});
it("scopes thread sessions to the routed agent", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
+16 -132
View File
@@ -113,20 +113,7 @@ type DiscordThreadStarter = {
timestamp?: number;
};
type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
const DISCORD_CHANNEL_INFO_CACHE = new Map<
string,
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
function logSlowDiscordListener(params: {
@@ -152,22 +139,14 @@ async function resolveDiscordThreadStarter(params: {
channel: DiscordThreadChannel;
client: Client;
parentId?: string;
parentType?: ChannelType;
}): Promise<DiscordThreadStarter | null> {
const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached;
try {
const parentType = params.parentType;
const isForumParent =
parentType === ChannelType.GuildForum ||
parentType === ChannelType.GuildMedia;
const messageChannelId = isForumParent
? params.channel.id
: params.parentId;
if (!messageChannelId) return null;
if (!params.parentId) return null;
const starter = (await params.client.rest.get(
Routes.channelMessage(messageChannelId, params.channel.id),
Routes.channelMessage(params.parentId, params.channel.id),
)) as {
content?: string | null;
embeds?: Array<{ description?: string | null }>;
@@ -247,66 +226,6 @@ export type DiscordMessageHandler = (
client: Client,
) => Promise<void>;
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
type DiscordThreadParentInfo = {
id?: string;
name?: string;
type?: ChannelType;
};
function resolveDiscordThreadChannel(params: {
isGuildMessage: boolean;
message: DiscordMessageEvent["message"];
channelInfo: DiscordChannelInfo | null;
}): DiscordThreadChannel | null {
if (!params.isGuildMessage) return null;
const { message, channelInfo } = params;
const channel =
"channel" in message
? (message as { channel?: unknown }).channel
: undefined;
const isThreadChannel =
channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof (channel as { isThread?: unknown }).isThread === "function" &&
(channel as { isThread: () => boolean }).isThread();
if (isThreadChannel) return channel as unknown as DiscordThreadChannel;
if (!isDiscordThreadType(channelInfo?.type)) return null;
return {
id: message.channelId,
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
};
}
async function resolveDiscordThreadParentInfo(params: {
client: Client;
threadChannel: DiscordThreadChannel;
channelInfo: DiscordChannelInfo | null;
}): Promise<DiscordThreadParentInfo> {
const { threadChannel, channelInfo, client } = params;
const parentId =
threadChannel.parentId ??
threadChannel.parent?.id ??
channelInfo?.parentId ??
undefined;
if (!parentId) return {};
let parentName = threadChannel.parent?.name;
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentName ?? parentInfo?.name;
const parentType = parentInfo?.type;
return { id: parentId, name: parentName, type: parentType };
}
export function resolveDiscordReplyTarget(opts: {
replyToMode: ReplyToMode;
replyToId?: string;
@@ -742,24 +661,17 @@ export function createDiscordMessageHandler(params: {
"name" in message.channel
? message.channel.name
: undefined);
const threadChannel = resolveDiscordThreadChannel({
isGuildMessage,
message,
channelInfo,
});
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentType: ChannelType | undefined;
if (threadChannel) {
const parentInfo = await resolveDiscordThreadParentInfo({
client,
threadChannel,
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentType = parentInfo.type;
}
const isThreadChannel =
isGuildMessage &&
message.channel &&
"isThread" in message.channel &&
message.channel.isThread();
const threadChannel = isThreadChannel
? (message.channel as DiscordThreadChannel)
: null;
const threadParentId =
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
const threadParentName = threadChannel?.parent?.name;
const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName
@@ -1023,7 +935,6 @@ export function createDiscordMessageHandler(params: {
channel: threadChannel,
client,
parentId: threadParentId,
parentType: threadParentType,
});
if (starter?.text) {
const starterEnvelope = formatThreadStarterEnvelope({
@@ -1773,42 +1684,15 @@ async function deliverDiscordReply(params: {
async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) return cached.value;
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
}
): Promise<{ type: ChannelType; name?: string; topic?: string } | null> {
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
if (!channel) return null;
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId =
"parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
return { type: channel.type, name, topic };
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
}
-125
View File
@@ -183,41 +183,6 @@ export type DiscordStickerUpload = {
mediaUrl: string;
};
export type DiscordChannelCreate = {
guildId: string;
name: string;
type?: number;
parentId?: string;
topic?: string;
position?: number;
nsfw?: boolean;
};
export type DiscordChannelEdit = {
channelId: string;
name?: string;
topic?: string;
position?: number;
parentId?: string | null;
nsfw?: boolean;
rateLimitPerUser?: number;
};
export type DiscordChannelMove = {
guildId: string;
channelId: string;
parentId?: string | null;
position?: number;
};
export type DiscordChannelPermissionSet = {
channelId: string;
targetId: string;
targetType: 0 | 1;
allow?: string;
deny?: string;
};
function resolveToken(params: {
explicit?: string;
accountId: string;
@@ -1226,93 +1191,3 @@ export async function banMemberDiscord(
});
return { ok: true };
}
// Channel management functions
export async function createChannelDiscord(
payload: DiscordChannelCreate,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
name: payload.name,
};
if (payload.type !== undefined) body.type = payload.type;
if (payload.parentId) body.parent_id = payload.parentId;
if (payload.topic) body.topic = payload.topic;
if (payload.position !== undefined) body.position = payload.position;
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
return (await rest.post(Routes.guildChannels(payload.guildId), {
body,
})) as APIChannel;
}
export async function editChannelDiscord(
payload: DiscordChannelEdit,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {};
if (payload.name !== undefined) body.name = payload.name;
if (payload.topic !== undefined) body.topic = payload.topic;
if (payload.position !== undefined) body.position = payload.position;
if (payload.parentId !== undefined) body.parent_id = payload.parentId;
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
if (payload.rateLimitPerUser !== undefined)
body.rate_limit_per_user = payload.rateLimitPerUser;
return (await rest.patch(Routes.channel(payload.channelId), {
body,
})) as APIChannel;
}
export async function deleteChannelDiscord(
channelId: string,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.channel(channelId));
return { ok: true, channelId };
}
export async function moveChannelDiscord(
payload: DiscordChannelMove,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
const body: Array<Record<string, unknown>> = [
{
id: payload.channelId,
...(payload.parentId !== undefined && { parent_id: payload.parentId }),
...(payload.position !== undefined && { position: payload.position }),
},
];
await rest.patch(Routes.guildChannels(payload.guildId), { body });
return { ok: true };
}
export async function setChannelPermissionDiscord(
payload: DiscordChannelPermissionSet,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
type: payload.targetType,
};
if (payload.allow !== undefined) body.allow = payload.allow;
if (payload.deny !== undefined) body.deny = payload.deny;
await rest.put(
`/channels/${payload.channelId}/permissions/${payload.targetId}`,
{ body },
);
return { ok: true };
}
export async function removeChannelPermissionDiscord(
channelId: string,
targetId: string,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
await rest.delete(`/channels/${channelId}/permissions/${targetId}`);
return { ok: true };
}
+26 -4
View File
@@ -6,7 +6,6 @@ import {
type HookMappingConfig,
type HooksConfig,
} from "../config/config.js";
import type { HookMessageProvider } from "./hooks.js";
export type HookMappingResolved = {
id: string;
@@ -19,7 +18,15 @@ export type HookMappingResolved = {
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
provider?: HookMessageProvider;
provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -52,7 +59,15 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat";
sessionKey?: string;
deliver?: boolean;
provider?: HookMessageProvider;
provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -90,7 +105,14 @@ type HookTransformResult = Partial<{
name: string;
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to: string;
model: string;
thinking: string;
-19
View File
@@ -66,16 +66,6 @@ describe("gateway hooks helpers", () => {
expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.provider).toBe("last");
expect(ok.value.name).toBe("Hook");
expect(ok.value.deliver).toBe(true);
}
const explicitNoDeliver = normalizeAgentPayload(
{ message: "hello", deliver: false },
{ idFactory: () => "fixed" },
);
expect(explicitNoDeliver.ok).toBe(true);
if (explicitNoDeliver.ok) {
expect(explicitNoDeliver.value.deliver).toBe(false);
}
const imsg = normalizeAgentPayload(
@@ -87,15 +77,6 @@ describe("gateway hooks helpers", () => {
expect(imsg.value.provider).toBe("imessage");
}
const teams = normalizeAgentPayload(
{ message: "yo", provider: "teams" },
{ idFactory: () => "x" },
);
expect(teams.ok).toBe(true);
if (teams.ok) {
expect(teams.value.provider).toBe("msteams");
}
const bad = normalizeAgentPayload({ message: "yo", provider: "sms" });
expect(bad.ok).toBe(false);
});
+33 -32
View File
@@ -138,41 +138,20 @@ export type HookAgentPayload = {
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
};
const HOOK_PROVIDER_VALUES = [
"last",
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
] as const;
export type HookMessageProvider = (typeof HOOK_PROVIDER_VALUES)[number];
const hookProviderSet = new Set<string>(HOOK_PROVIDER_VALUES);
export const HOOK_PROVIDER_ERROR = `provider must be ${HOOK_PROVIDER_VALUES.join("|")}`;
export function resolveHookProvider(raw: unknown): HookMessageProvider | null {
if (raw === undefined) return "last";
if (typeof raw !== "string") return null;
const normalized = normalizeMessageProvider(raw);
if (!normalized || !hookProviderSet.has(normalized)) return null;
return normalized as HookMessageProvider;
}
export function resolveHookDeliver(raw: unknown): boolean {
return raw !== false;
}
export function normalizeAgentPayload(
payload: Record<string, unknown>,
opts?: { idFactory?: () => string },
@@ -196,8 +175,30 @@ export function normalizeAgentPayload(
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim()
: `hook:${idFactory()}`;
const provider = resolveHookProvider(payload.provider);
if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR };
const providerRaw = payload.provider;
const providerNormalized =
typeof providerRaw === "string"
? normalizeMessageProvider(providerRaw)
: undefined;
const provider =
providerNormalized === "whatsapp" ||
providerNormalized === "telegram" ||
providerNormalized === "discord" ||
providerNormalized === "slack" ||
providerNormalized === "signal" ||
providerNormalized === "imessage" ||
providerNormalized === "last"
? providerNormalized
: providerRaw === undefined
? "last"
: null;
if (provider === null) {
return {
ok: false,
error:
"provider must be last|whatsapp|telegram|discord|slack|signal|imessage",
};
}
const toRaw = payload.to;
const to =
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
@@ -209,7 +210,7 @@ export function normalizeAgentPayload(
if (modelRaw !== undefined && !model) {
return { ok: false, error: "model required" };
}
const deliver = resolveHookDeliver(payload.deliver);
const deliver = payload.deliver === true;
const thinkingRaw = payload.thinking;
const thinking =
typeof thinkingRaw === "string" && thinkingRaw.trim()
+11 -12
View File
@@ -11,15 +11,11 @@ import type { createSubsystemLogger } from "../logging.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
extractHookToken,
HOOK_PROVIDER_ERROR,
type HookMessageProvider,
type HooksConfigResolved,
normalizeAgentPayload,
normalizeHookHeaders,
normalizeWakePayload,
readJsonBody,
resolveHookDeliver,
resolveHookProvider,
} from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js";
@@ -36,7 +32,15 @@ type HookDispatchers = {
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -167,18 +171,13 @@ export function createHooksRequestHandler(
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
return true;
}
const provider = resolveHookProvider(mapped.action.provider);
if (!provider) {
sendJson(res, 400, { ok: false, error: HOOK_PROVIDER_ERROR });
return true;
}
const runId = dispatchAgentHook({
message: mapped.action.message,
name: mapped.action.name ?? "Hook",
wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "",
deliver: resolveHookDeliver(mapped.action.deliver),
provider,
deliver: mapped.action.deliver === true,
provider: mapped.action.provider ?? "last",
to: mapped.action.to,
model: mapped.action.model,
thinking: mapped.action.thinking,
+10 -2
View File
@@ -111,7 +111,7 @@ import {
startGatewayConfigReloader,
} from "./config-reload.js";
import { normalizeControlUiBasePath } from "./control-ui.js";
import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js";
import { resolveHooksConfig } from "./hooks.js";
import {
isLoopbackAddress,
isLoopbackHost,
@@ -496,7 +496,15 @@ export async function startGatewayServer(
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
provider: HookMessageProvider;
provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
model?: string;
thinking?: string;
-40
View File
@@ -215,46 +215,6 @@ describe("monitorIMessageProvider", () => {
expect(sendMock).not.toHaveBeenCalled();
});
it("treats configured chat_id as a group session even when is_group is false", async () => {
config = {
...config,
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "2": { requireMention: false } },
},
};
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
method: "message",
params: {
message: {
id: 14,
chat_id: 2,
sender: "+15550001111",
is_from_me: false,
text: "hello",
is_group: false,
},
},
});
await flush();
closeResolve?.();
await run;
expect(replyMock).toHaveBeenCalled();
const ctx = replyMock.mock.calls[0]?.[0] as {
ChatType?: string;
SessionKey?: string;
};
expect(ctx.ChatType).toBe("group");
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
});
it("prefixes tool and final replies with responsePrefix", async () => {
config = {
...config,
+8 -28
View File
@@ -170,36 +170,10 @@ export async function monitorIMessageProvider(
const chatId = message.chat_id ?? undefined;
const chatGuid = message.chat_guid ?? undefined;
const chatIdentifier = message.chat_identifier ?? undefined;
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
const groupListPolicy = groupIdCandidate
? resolveProviderGroupPolicy({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
groupId: groupIdCandidate,
})
: {
allowlistEnabled: false,
allowed: true,
groupConfig: undefined,
defaultConfig: undefined,
};
// Some iMessage threads can have multiple participants but still report
// is_group=false depending on how Messages stores the identifier.
// If the owner explicitly configures a chat_id under imessage.groups, treat
// that thread as a "group" for permission gating and session isolation.
const treatAsGroupByConfig = Boolean(
groupIdCandidate &&
groupListPolicy.allowlistEnabled &&
groupListPolicy.groupConfig,
);
const isGroup = Boolean(message.is_group) || treatAsGroupByConfig;
const isGroup = Boolean(message.is_group);
if (isGroup && !chatId) return;
const groupId = isGroup ? groupIdCandidate : undefined;
const groupId = isGroup ? String(chatId) : undefined;
const storeAllowFrom = await readProviderAllowFromStore("imessage").catch(
() => [],
);
@@ -240,6 +214,12 @@ export async function monitorIMessageProvider(
return;
}
}
const groupListPolicy = resolveProviderGroupPolicy({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
groupId,
});
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
logVerbose(
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,