Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed1f230ff | ||
|
|
08d9d9f9e3 | ||
|
|
c291b7a992 | ||
|
|
f564fe5adf | ||
|
|
3ce5b642b3 |
No files matched your search
+1
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>`
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+55
-75
@@ -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: {}
|
||||
|
||||
@@ -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"
|
||||
'
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
]);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)" }],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -404,7 +404,6 @@ export type DiscordActionConfig = {
|
||||
moderation?: boolean;
|
||||
emojiUploads?: boolean;
|
||||
stickerUploads?: boolean;
|
||||
channels?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordAccountConfig = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user