Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3c53f4596 | ||
|
|
37389005fc | ||
|
|
6904a79d1d | ||
|
|
62bf779c14 | ||
|
|
5c13dcdb7b | ||
|
|
706cbe89ec | ||
|
|
a6a469435a | ||
|
|
834e2b82f3 | ||
|
|
18bc9db6b3 | ||
|
|
258184232d | ||
|
|
6156402c1a | ||
|
|
9279795c47 | ||
|
|
d0450bb425 | ||
|
|
1cfe409a09 | ||
|
|
cfcff68e91 | ||
|
|
d588bb00d4 | ||
|
|
e74656fa71 | ||
|
|
d64ed620d5 | ||
|
|
377885bd36 | ||
|
|
cf1e0d743c | ||
|
|
b23ce86c81 | ||
|
|
cae8e8d6e8 | ||
|
|
24af8adcf9 | ||
|
|
c3083f0186 | ||
|
|
083877d286 | ||
|
|
044ea90dde | ||
|
|
9c9f89e9be | ||
|
|
401f2a77e3 | ||
|
|
b1ddf1f048 | ||
|
|
51725a71a3 | ||
|
|
f146c9ef16 | ||
|
|
abfd5719d6 | ||
|
|
3a0cc3c927 | ||
|
|
40fcfc9479 | ||
|
|
cac467a2df | ||
|
|
6ef9fc64d7 | ||
|
|
4658f937cc | ||
|
|
98b875cd0f | ||
|
|
750dea3edf | ||
|
|
5f4df5e336 | ||
|
|
aa64abe84c | ||
|
|
a4806e9417 | ||
|
|
5d34e27078 | ||
|
|
16f893c46b | ||
|
|
1be8a39442 | ||
|
|
1309cee124 | ||
|
|
8aa80fa464 | ||
|
|
b97a9235e0 | ||
|
|
6c8b2a8dc9 | ||
|
|
e52a2888cc | ||
|
|
c678216ac2 | ||
|
|
daafa9e3bc | ||
|
|
ab28eb5109 | ||
|
|
5965708f1d | ||
|
|
a574ae5415 | ||
|
|
713eb898f6 | ||
|
|
8445c9a5e8 | ||
|
|
559e175b38 |
No files matched your search
+11
-1
@@ -6,25 +6,34 @@
|
||||
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
|
||||
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
|
||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
||||
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
|
||||
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
|
||||
- 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: add `/commands` list including text-only entries. (#497) — thanks @lc0rp
|
||||
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
|
||||
- Commands: harden slash command registry and list text-only commands in `/commands`.
|
||||
- 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
|
||||
- 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
|
||||
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
|
||||
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
|
||||
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
|
||||
- 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.
|
||||
@@ -115,6 +124,7 @@
|
||||
- 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
|
||||
|
||||
|
||||
@@ -331,7 +331,8 @@ actor MacNodeBridgeSession {
|
||||
let now = self.clock.now
|
||||
if now > last.advanced(by: timeout) {
|
||||
let age = last.duration(to: now)
|
||||
self.logger.warning("Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
|
||||
self.logger.warning(
|
||||
"Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
|
||||
await self.disconnect()
|
||||
return
|
||||
}
|
||||
@@ -341,7 +342,8 @@ actor MacNodeBridgeSession {
|
||||
do {
|
||||
try await self.send(BridgePing(type: "ping", id: id))
|
||||
} catch {
|
||||
self.logger.warning("Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
|
||||
self.logger.warning(
|
||||
"Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
|
||||
await self.disconnect()
|
||||
return
|
||||
}
|
||||
@@ -356,7 +358,8 @@ actor MacNodeBridgeSession {
|
||||
private func handleConnectionState(_ state: NWConnection.State) async {
|
||||
switch state {
|
||||
case let .failed(error):
|
||||
self.logger.warning("Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
|
||||
self.logger.warning(
|
||||
"Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
|
||||
await self.disconnect()
|
||||
case .cancelled:
|
||||
self.logger.warning("Node bridge connection cancelled; disconnecting.")
|
||||
|
||||
@@ -57,7 +57,7 @@ Payload:
|
||||
"name": "Email",
|
||||
"sessionKey": "hook:email:msg-123",
|
||||
"wakeMode": "now",
|
||||
"deliver": false,
|
||||
"deliver": true,
|
||||
"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 `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`.
|
||||
- `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`.
|
||||
- `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`).
|
||||
|
||||
+5
-1
@@ -177,7 +177,11 @@ Options:
|
||||
- `--workspace <dir>`
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
|
||||
- `--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`)
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--openai-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
|
||||
@@ -679,6 +679,7 @@
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux",
|
||||
"platforms/hetzner",
|
||||
"platforms/exe-dev"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -29,6 +29,19 @@ 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):
|
||||
@@ -92,13 +105,15 @@ 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. OAuth refresh happens automatically on use if a token is expired.
|
||||
3. Refreshable OAuth profiles can be refreshed automatically on use. Static
|
||||
token profiles (including Claude CLI setup-token) are not refreshable by
|
||||
Clawdbot.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### “No credentials found”
|
||||
|
||||
If the Anthropic OAuth profile is missing, run `claude setup-token` on the
|
||||
If the Anthropic token profile is missing, run `claude setup-token` on the
|
||||
**gateway host**, then re-check:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1464,6 +1464,67 @@ Notes:
|
||||
- Responses API enables clean reasoning/output separation; WhatsApp sees only final text.
|
||||
- Adjust `contextWindow`/`maxTokens` if your LM Studio context length differs.
|
||||
|
||||
### MiniMax API (platform.minimax.io)
|
||||
|
||||
Use MiniMax's Anthropic-compatible API directly without LM Studio:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"minimax/MiniMax-M2.1": { alias: "Minimax" }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
apiKey: "${MINIMAX_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.1-lightning",
|
||||
name: "MiniMax M2.1 Lightning",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2",
|
||||
name: "MiniMax M2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Set `MINIMAX_API_KEY` environment variable or use `clawdbot onboard --auth-choice minimax-api`
|
||||
- Available models: `MiniMax-M2.1` (default), `MiniMax-M2.1-lightning` (~100 tps), `MiniMax-M2` (reasoning)
|
||||
- Pricing is a placeholder; MiniMax doesn't publish public rates. Override in `models.json` for accurate cost tracking.
|
||||
|
||||
Notes:
|
||||
- Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`,
|
||||
`google-generative-ai`
|
||||
|
||||
@@ -51,6 +51,8 @@ It writes config/workspace on the host:
|
||||
- `~/.clawdbot/`
|
||||
- `~/clawd`
|
||||
|
||||
Running on a VPS? See [Hetzner (Docker VPS)](/platforms/hetzner).
|
||||
|
||||
### Manual flow (compose)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
summary: "Run Clawdbot Gateway on Hetzner (Docker + VPS) with durable state and baked-in binaries"
|
||||
read_when:
|
||||
- You want a production-grade, always-on Gateway on your own VPS
|
||||
- You want full control over persistence, binaries, and restart behavior
|
||||
- You are running Clawdbot in Docker on Hetzner or a similar provider
|
||||
---
|
||||
|
||||
# Clawdbot on Hetzner (Docker, Production VPS Guide)
|
||||
|
||||
## Goal
|
||||
Run a persistent Clawdbot Gateway on a Hetzner VPS using Docker, with durable state, baked-in binaries, and safe restart behavior.
|
||||
|
||||
The Gateway can be accessed via:
|
||||
- SSH port forwarding from your laptop
|
||||
- Direct port exposure if you manage firewalling and tokens yourself
|
||||
|
||||
This guide assumes Ubuntu or Debian on Hetzner.
|
||||
If you are on another Linux VPS, map packages accordingly.
|
||||
For the generic Docker flow, see [Docker](/install/docker).
|
||||
|
||||
---
|
||||
|
||||
## Quick path (experienced operators)
|
||||
|
||||
1) Provision Hetzner VPS
|
||||
2) Install Docker
|
||||
3) Clone Clawdbot repository
|
||||
4) Create persistent host directories
|
||||
5) Configure `.env` and `docker-compose.yml`
|
||||
6) Bake required binaries into the image
|
||||
7) `docker compose up -d`
|
||||
8) Verify persistence and Gateway access
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
- Hetzner VPS with root access
|
||||
- SSH access from your laptop
|
||||
- Docker and Docker Compose
|
||||
- Model auth credentials
|
||||
- Optional provider credentials
|
||||
- WhatsApp QR
|
||||
- Telegram bot token
|
||||
- Gmail OAuth
|
||||
|
||||
---
|
||||
|
||||
## 1) Provision the VPS
|
||||
|
||||
Create an Ubuntu or Debian VPS in Hetzner.
|
||||
|
||||
Connect as root:
|
||||
|
||||
```bash
|
||||
ssh root@YOUR_VPS_IP
|
||||
```
|
||||
|
||||
This guide assumes the VPS is stateful.
|
||||
Do not treat it as disposable infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## 2) Install Docker (on the VPS)
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y git curl ca-certificates
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Clone the Clawdbot repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
```
|
||||
|
||||
This guide assumes you will build a custom image to guarantee binary persistence.
|
||||
|
||||
---
|
||||
|
||||
## 4) Create persistent host directories
|
||||
|
||||
Docker containers are ephemeral.
|
||||
All long-lived state must live on the host.
|
||||
|
||||
```bash
|
||||
mkdir -p /root/.clawdbot
|
||||
mkdir -p /root/clawd
|
||||
|
||||
# Set ownership to the container user (uid 1000):
|
||||
chown -R 1000:1000 /root/.clawdbot
|
||||
chown -R 1000:1000 /root/clawd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Configure environment variables
|
||||
|
||||
Create `.env` in the repository root.
|
||||
|
||||
```bash
|
||||
CLAWDBOT_IMAGE=clawdbot:latest
|
||||
CLAWDBOT_GATEWAY_TOKEN=change-me-now
|
||||
CLAWDBOT_GATEWAY_BIND=lan
|
||||
CLAWDBOT_GATEWAY_PORT=18789
|
||||
CLAWDBOT_BRIDGE_PORT=18790
|
||||
|
||||
CLAWDBOT_CONFIG_DIR=/root/.clawdbot
|
||||
CLAWDBOT_WORKSPACE_DIR=/root/clawd
|
||||
|
||||
GOG_KEYRING_PASSWORD=change-me-now
|
||||
XDG_CONFIG_HOME=/home/node/.clawdbot
|
||||
```
|
||||
|
||||
**Do not commit this file.**
|
||||
|
||||
---
|
||||
|
||||
## 6) Docker Compose configuration
|
||||
|
||||
Create or update `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
clawdbot-gateway:
|
||||
image: ${CLAWDBOT_IMAGE}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- NODE_ENV=production
|
||||
- TERM=xterm-256color
|
||||
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
|
||||
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
|
||||
- CLAWDBOT_BRIDGE_PORT=${CLAWDBOT_BRIDGE_PORT}
|
||||
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
|
||||
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
|
||||
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
|
||||
- PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
volumes:
|
||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
||||
ports:
|
||||
- "${CLAWDBOT_GATEWAY_PORT}:18789"
|
||||
- "${CLAWDBOT_BRIDGE_PORT}:18790"
|
||||
- "18793:18793"
|
||||
command:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway-daemon",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND}",
|
||||
"--port",
|
||||
"${CLAWDBOT_GATEWAY_PORT}"
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Bake required binaries into the image (critical)
|
||||
|
||||
Installing binaries inside a running container is a trap.
|
||||
Anything installed at runtime will be lost on restart.
|
||||
|
||||
All external binaries required by skills must be installed at image build time.
|
||||
|
||||
The examples below show three common binaries only:
|
||||
- `gog` for Gmail access
|
||||
- `goplaces` for Google Places
|
||||
- `wacli` for WhatsApp
|
||||
|
||||
These are examples, not a complete list.
|
||||
You may install as many binaries as needed using the same pattern.
|
||||
|
||||
If you add new skills later that depend on additional binaries, you must:
|
||||
1. Update the Dockerfile
|
||||
2. Rebuild the image
|
||||
3. Restart the containers
|
||||
|
||||
**Example Dockerfile**
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Example binary 1: Gmail CLI
|
||||
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
|
||||
|
||||
# Example binary 2: Google Places CLI
|
||||
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
|
||||
|
||||
# Example binary 3: WhatsApp CLI
|
||||
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
|
||||
|
||||
# Add more binaries below using the same pattern
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN corepack enable
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
CMD ["node","dist/index.js"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Build and launch
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d clawdbot-gateway
|
||||
```
|
||||
|
||||
Verify binaries:
|
||||
|
||||
```bash
|
||||
docker compose exec clawdbot-gateway which gog
|
||||
docker compose exec clawdbot-gateway which goplaces
|
||||
docker compose exec clawdbot-gateway which wacli
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
/usr/local/bin/gog
|
||||
/usr/local/bin/goplaces
|
||||
/usr/local/bin/wacli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9) Verify Gateway
|
||||
|
||||
```bash
|
||||
docker compose logs -f clawdbot-gateway
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```
|
||||
[gateway] listening on ws://0.0.0.0:18789
|
||||
```
|
||||
|
||||
From your laptop:
|
||||
|
||||
```bash
|
||||
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_VPS_IP
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
`http://127.0.0.1:18789/`
|
||||
|
||||
Paste your gateway token.
|
||||
|
||||
---
|
||||
|
||||
## What persists where (source of truth)
|
||||
|
||||
Clawdbot runs in Docker, but Docker is not the source of truth.
|
||||
All long-lived state must survive restarts, rebuilds, and reboots.
|
||||
|
||||
| Component | Location | Persistence mechanism | Notes |
|
||||
|---|---|---|---|
|
||||
| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
|
||||
| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
|
||||
| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
|
||||
| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
|
||||
| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
|
||||
| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
|
||||
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
|
||||
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
|
||||
| OS packages | Container filesystem | Docker image | Do not install at runtime |
|
||||
| Docker container | Ephemeral | Restartable | Safe to destroy |
|
||||
@@ -20,6 +20,11 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
- Windows: [Windows](/platforms/windows)
|
||||
- Linux: [Linux](/platforms/linux)
|
||||
|
||||
## VPS & hosting
|
||||
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
## Common links
|
||||
|
||||
- Install guide: [Getting Started](/start/getting-started)
|
||||
|
||||
@@ -36,6 +36,7 @@ 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)
|
||||
|
||||
+6
-2
@@ -80,6 +80,7 @@
|
||||
"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",
|
||||
@@ -93,7 +94,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"dependencies": {
|
||||
"@buape/carbon": "0.13.0",
|
||||
"@buape/carbon": "0.0.0-beta-20260109165250",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
@@ -169,7 +170,9 @@
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch"
|
||||
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
|
||||
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
@@ -207,6 +210,7 @@
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
|
||||
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
|
||||
"qrcode-terminal": "patches/qrcode-terminal.patch",
|
||||
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
|
||||
index 0000000..1111111 100644
|
||||
--- a/dist/providers/google-gemini-cli.js
|
||||
+++ b/dist/providers/google-gemini-cli.js
|
||||
@@ -248,6 +248,12 @@ async function* streamGeminiCli(model, context, credentials, options) {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
const errorText = await response.text();
|
||||
+ // PATCH: Fail immediately on 429 for Antigravity to let caller rotate accounts.
|
||||
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
|
||||
+ if (isAntigravity && response.status === 429) {
|
||||
+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`);
|
||||
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
|
||||
+ }
|
||||
// Check if retryable
|
||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||
// Use server-provided delay or exponential backoff
|
||||
@@ -26,3 +26,23 @@ index 0000000..1111111 100644
|
||||
}
|
||||
/** Result from createAgentSession */
|
||||
export interface CreateAgentSessionResult {
|
||||
diff --git a/dist/core/session-manager.js b/dist/core/session-manager.js
|
||||
index b2aba5280d002253b0938b75aedbb9e6e6c4dcf8..67464efff535dbd7a8e6ed825aab2b305ca2aee2 100644
|
||||
--- a/dist/core/session-manager.js
|
||||
+++ b/dist/core/session-manager.js
|
||||
@@ -161,6 +161,15 @@ export function buildSessionContext(entries, leafId, byId) {
|
||||
const messages = [];
|
||||
const appendMessage = (entry) => {
|
||||
if (entry.type === "message") {
|
||||
+ // PATCH: Filter out empty error assistant messages to prevent session corruption
|
||||
+ // When 429/500 errors occur during tool execution, empty error messages get persisted
|
||||
+ // to the session file. These break the tool_use -> tool_result chain for Claude/Gemini.
|
||||
+ const msg = entry.message;
|
||||
+ if (msg.role === "assistant" &&
|
||||
+ msg.stopReason === "error" &&
|
||||
+ (!msg.content || msg.content.length === 0)) {
|
||||
+ return; // Skip empty error messages
|
||||
+ }
|
||||
messages.push(entry.message);
|
||||
}
|
||||
else if (entry.type === "custom_message") {
|
||||
Generated
+87
-61
@@ -11,14 +11,20 @@ patchedDependencies:
|
||||
'@mariozechner/pi-agent-core':
|
||||
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
|
||||
path: patches/@mariozechner__pi-agent-core.patch
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
'@mariozechner/pi-coding-agent':
|
||||
hash: 58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e
|
||||
path: patches/@mariozechner__pi-coding-agent.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@buape/carbon':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
|
||||
specifier: 0.0.0-beta-20260109165250
|
||||
version: 0.0.0-beta-20260109165250(hono@4.11.3)
|
||||
'@clack/prompts':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
@@ -36,10 +42,10 @@ importers:
|
||||
version: 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.0(ws@8.19.0)(zod@4.3.5)
|
||||
version: 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.0(ws@8.19.0)(zod@4.3.5)
|
||||
version: 0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.0
|
||||
@@ -366,8 +372,8 @@ packages:
|
||||
'@borewit/text-codec@0.2.1':
|
||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||
|
||||
'@buape/carbon@0.13.0':
|
||||
resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
|
||||
'@buape/carbon@0.0.0-beta-20260109165250':
|
||||
resolution: {integrity: sha512-Xt8W0RYZxaprp2YxMU1rZRfGkHQWEJ2KZUurMwK/G4WVTfJSWAUeaeOGhpO40fCm/g0iPMuSLgQ4eudGOQHigw==}
|
||||
|
||||
'@cacheable/memory@2.0.7':
|
||||
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
|
||||
@@ -385,8 +391,12 @@ packages:
|
||||
'@clack/prompts@0.11.0':
|
||||
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
|
||||
|
||||
'@cloudflare/workers-types@4.20250513.0':
|
||||
resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==}
|
||||
'@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'}
|
||||
|
||||
'@emnapi/core@1.8.1':
|
||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||
@@ -593,8 +603,8 @@ packages:
|
||||
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
|
||||
hasBin: true
|
||||
|
||||
'@hono/node-server@1.18.2':
|
||||
resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
|
||||
'@hono/node-server@1.19.6':
|
||||
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
@@ -1280,8 +1290,8 @@ packages:
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/bun@1.2.23':
|
||||
resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
|
||||
'@types/bun@1.3.3':
|
||||
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
@@ -1337,8 +1347,8 @@ packages:
|
||||
'@types/node@10.17.60':
|
||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||
|
||||
'@types/node@22.19.3':
|
||||
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
||||
'@types/node@24.10.4':
|
||||
resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==}
|
||||
|
||||
'@types/node@25.0.3':
|
||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||
@@ -1355,9 +1365,6 @@ 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==}
|
||||
|
||||
@@ -1611,10 +1618,8 @@ packages:
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
bun-types@1.2.23:
|
||||
resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19
|
||||
bun-types@1.3.3:
|
||||
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
@@ -1726,9 +1731,6 @@ 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==}
|
||||
|
||||
@@ -1768,8 +1770,8 @@ packages:
|
||||
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
discord-api-types@0.38.29:
|
||||
resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
|
||||
discord-api-types@0.38.36:
|
||||
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==}
|
||||
|
||||
discord-api-types@0.38.37:
|
||||
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
|
||||
@@ -2672,6 +2674,23 @@ 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==}
|
||||
|
||||
@@ -3049,9 +3068,6 @@ 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==}
|
||||
|
||||
@@ -3358,20 +3374,24 @@ snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)':
|
||||
'@buape/carbon@0.0.0-beta-20260109165250(hono@4.11.3)':
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
discord-api-types: 0.38.29
|
||||
'@types/node': 24.10.4
|
||||
discord-api-types: 0.38.36
|
||||
optionalDependencies:
|
||||
'@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)
|
||||
'@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
|
||||
'@types/ws': 8.18.1
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@discordjs/opus'
|
||||
- bufferutil
|
||||
- ffmpeg-static
|
||||
- hono
|
||||
- node-opus
|
||||
- opusscript
|
||||
- utf-8-validate
|
||||
|
||||
'@cacheable/memory@2.0.7':
|
||||
@@ -3403,7 +3423,23 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@cloudflare/workers-types@4.20250513.0':
|
||||
'@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
|
||||
optional: true
|
||||
|
||||
'@emnapi/core@1.8.1':
|
||||
@@ -3541,7 +3577,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@hono/node-server@1.18.2(hono@4.11.3)':
|
||||
'@hono/node-server@1.19.6(hono@4.11.3)':
|
||||
dependencies:
|
||||
hono: 4.11.3
|
||||
optional: true
|
||||
@@ -3747,7 +3783,7 @@ snapshots:
|
||||
|
||||
'@mariozechner/pi-agent-core@0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.41.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -3757,7 +3793,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.41.0(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-ai@0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||
'@google/genai': 1.34.0
|
||||
@@ -3777,11 +3813,11 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.41.0(ws@8.19.0)(zod@4.3.5)':
|
||||
'@mariozechner/pi-coding-agent@0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/pi-agent-core': 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)
|
||||
'@mariozechner/pi-tui': 0.41.0
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
@@ -4179,11 +4215,9 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.0.3
|
||||
|
||||
'@types/bun@1.2.23(@types/react@19.2.7)':
|
||||
'@types/bun@1.3.3':
|
||||
dependencies:
|
||||
bun-types: 1.2.23(@types/react@19.2.7)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
bun-types: 1.3.3
|
||||
optional: true
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
@@ -4252,9 +4286,9 @@ snapshots:
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
'@types/node@22.19.3':
|
||||
'@types/node@24.10.4':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/node@25.0.3':
|
||||
dependencies:
|
||||
@@ -4270,11 +4304,6 @@ 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': {}
|
||||
@@ -4608,10 +4637,9 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bun-types@1.2.23(@types/react@19.2.7):
|
||||
bun-types@1.3.3:
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
'@types/react': 19.2.7
|
||||
optional: true
|
||||
|
||||
bytes@3.1.2: {}
|
||||
@@ -4729,9 +4757,6 @@ 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: {}
|
||||
@@ -4753,7 +4778,7 @@ snapshots:
|
||||
|
||||
diff@8.0.2: {}
|
||||
|
||||
discord-api-types@0.38.29: {}
|
||||
discord-api-types@0.38.36: {}
|
||||
|
||||
discord-api-types@0.38.37: {}
|
||||
|
||||
@@ -5680,6 +5705,9 @@ 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: {}
|
||||
@@ -6154,8 +6182,6 @@ snapshots:
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@7.18.2: {}
|
||||
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/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
|
||||
|
||||
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
|
||||
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
|
||||
|
||||
# Install the npm-global variant from the local /app source.
|
||||
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
|
||||
}
|
||||
|
||||
# Each flow: install service with one variant, run doctor from the other,
|
||||
# and verify ExecStart entrypoint switches accordingly.
|
||||
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"
|
||||
'
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
|
||||
const MINIMAX_BASE_URL =
|
||||
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
|
||||
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
|
||||
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
@@ -11,15 +11,16 @@ const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
|
||||
|
||||
describeLive("minimax live", () => {
|
||||
it("returns assistant text", async () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
id: MINIMAX_MODEL,
|
||||
name: `MiniMax ${MINIMAX_MODEL}`,
|
||||
api: "openai-completions",
|
||||
api: "anthropic-messages",
|
||||
provider: "minimax",
|
||||
baseUrl: MINIMAX_BASE_URL,
|
||||
reasoning: false,
|
||||
reasoning: MINIMAX_MODEL === "MiniMax-M2",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
// Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display)
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
createScheduledEventDiscord,
|
||||
deleteChannelDiscord,
|
||||
editChannelDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchMemberInfoDiscord,
|
||||
fetchRoleInfoDiscord,
|
||||
@@ -10,17 +13,28 @@ 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>,
|
||||
@@ -207,6 +221,157 @@ 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,38 +1,58 @@
|
||||
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),
|
||||
@@ -40,6 +60,8 @@ 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),
|
||||
}));
|
||||
|
||||
@@ -117,3 +139,214 @@ 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,6 +37,15 @@ const guildActions = new Set([
|
||||
"voiceStatus",
|
||||
"eventList",
|
||||
"eventCreate",
|
||||
"channelCreate",
|
||||
"channelEdit",
|
||||
"channelDelete",
|
||||
"channelMove",
|
||||
"categoryCreate",
|
||||
"categoryEdit",
|
||||
"categoryDelete",
|
||||
"channelPermissionSet",
|
||||
"channelPermissionRemove",
|
||||
]);
|
||||
|
||||
const moderationActions = new Set(["timeout", "kick", "ban"]);
|
||||
|
||||
@@ -202,4 +202,67 @@ 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,13 +43,11 @@ 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,6 +17,11 @@ export type NativeCommandSpec = {
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
type TextAliasSpec = {
|
||||
canonical: string;
|
||||
acceptsArgs: boolean;
|
||||
};
|
||||
|
||||
function defineChatCommand(command: {
|
||||
key: string;
|
||||
nativeName?: string;
|
||||
@@ -26,8 +31,11 @@ function defineChatCommand(command: {
|
||||
textAliases?: string[];
|
||||
scope?: CommandScope;
|
||||
}): ChatCommandDefinition {
|
||||
const aliases =
|
||||
command.textAliases ?? (command.textAlias ? [command.textAlias] : []);
|
||||
const aliases = (
|
||||
command.textAliases ?? (command.textAlias ? [command.textAlias] : [])
|
||||
)
|
||||
.map((alias) => alias.trim())
|
||||
.filter(Boolean);
|
||||
const scope =
|
||||
command.scope ??
|
||||
(command.nativeName ? (aliases.length ? "both" : "native") : "text");
|
||||
@@ -50,16 +58,64 @@ function registerAlias(
|
||||
if (!command) {
|
||||
throw new Error(`registerAlias: unknown command key: ${key}`);
|
||||
}
|
||||
const existing = new Set(command.textAliases.map((alias) => alias.trim()));
|
||||
const existing = new Set(
|
||||
command.textAliases.map((alias) => alias.trim().toLowerCase()),
|
||||
);
|
||||
for (const alias of aliases) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed) continue;
|
||||
if (existing.has(trimmed)) continue;
|
||||
existing.add(trimmed);
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (existing.has(lowered)) continue;
|
||||
existing.add(lowered);
|
||||
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({
|
||||
@@ -190,11 +246,28 @@ 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>;
|
||||
@@ -226,8 +299,8 @@ export function findCommandByNativeName(
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return CHAT_COMMANDS.find(
|
||||
(command) =>
|
||||
command.nativeName?.toLowerCase() === normalized &&
|
||||
command.scope !== "text",
|
||||
command.scope !== "text" &&
|
||||
command.nativeName?.toLowerCase() === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,11 +312,31 @@ export function buildCommandText(commandName: string, args?: string): string {
|
||||
export function normalizeCommandBody(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) return trimmed;
|
||||
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
|
||||
if (!match) return trimmed;
|
||||
const [, command, rest] = match;
|
||||
const normalizedRest = rest.trimStart();
|
||||
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
|
||||
|
||||
+22
-1
@@ -240,7 +240,23 @@ export function buildProgram() {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
|
||||
"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)",
|
||||
)
|
||||
.option("--anthropic-api-key <key>", "Anthropic API key")
|
||||
.option("--openai-api-key <key>", "OpenAI API key")
|
||||
@@ -270,6 +286,7 @@ export function buildProgram() {
|
||||
mode: opts.mode as "local" | "remote" | undefined,
|
||||
authChoice: opts.authChoice as
|
||||
| "oauth"
|
||||
| "setup-token"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
@@ -282,6 +299,10 @@ 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,17 +64,23 @@ export function buildAuthChoiceOptions(params: {
|
||||
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
|
||||
options.push({
|
||||
value: "claude-cli",
|
||||
label: "Anthropic OAuth (Claude CLI)",
|
||||
label: "Anthropic token (Claude CLI)",
|
||||
hint: formatOAuthHint(claudeCli.expires),
|
||||
});
|
||||
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
|
||||
options.push({
|
||||
value: "claude-cli",
|
||||
label: "Anthropic OAuth (Claude CLI)",
|
||||
label: "Anthropic token (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)",
|
||||
@@ -95,6 +101,10 @@ export function buildAuthChoiceOptions(params: {
|
||||
// Token flow is currently Anthropic-only; use CLI for advanced providers.
|
||||
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
|
||||
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
|
||||
options.push({
|
||||
value: "minimax-api",
|
||||
label: "MiniMax API (platform.minimax.io)",
|
||||
});
|
||||
if (params.includeSkip) {
|
||||
options.push({ value: "skip", label: "Skip for now" });
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
} from "./google-gemini-model-default.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiProviderConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxHostedConfig,
|
||||
applyMinimaxHostedProviderConfig,
|
||||
@@ -216,7 +218,68 @@ export async function applyAuthChoice(params: {
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
} else if (params.authChoice === "token" || params.authChoice === "oauth") {
|
||||
} 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") {
|
||||
const provider = (await params.prompter.select({
|
||||
message: "Token provider",
|
||||
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
|
||||
@@ -568,6 +631,24 @@ export async function applyAuthChoice(params: {
|
||||
agentModelOverride = "lmstudio/minimax-m2.1-gs32";
|
||||
await noteAgentModel("lmstudio/minimax-m2.1-gs32");
|
||||
}
|
||||
} else if (params.authChoice === "minimax-api") {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter MiniMax API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
await setMinimaxApiKey(String(key).trim(), params.agentDir);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "minimax:default",
|
||||
provider: "minimax",
|
||||
mode: "api_key",
|
||||
});
|
||||
if (params.setDefaultModel) {
|
||||
nextConfig = applyMinimaxApiConfig(nextConfig);
|
||||
} else {
|
||||
nextConfig = applyMinimaxApiProviderConfig(nextConfig);
|
||||
agentModelOverride = "minimax/MiniMax-M2.1";
|
||||
await noteAgentModel("minimax/MiniMax-M2.1");
|
||||
}
|
||||
}
|
||||
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
|
||||
@@ -352,6 +352,7 @@ async function promptAuthConfig(
|
||||
runtime,
|
||||
) as
|
||||
| "oauth"
|
||||
| "setup-token"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
@@ -403,7 +404,68 @@ async function promptAuthConfig(
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
} else if (authChoice === "token" || authChoice === "oauth") {
|
||||
} 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") {
|
||||
const provider = guardCancel(
|
||||
await select({
|
||||
message: "Token provider",
|
||||
@@ -726,6 +788,7 @@ async function promptAuthConfig(
|
||||
: (next.agents?.defaults?.model?.primary ?? "");
|
||||
const preferAnthropic =
|
||||
authChoice === "claude-cli" ||
|
||||
authChoice === "setup-token" ||
|
||||
authChoice === "token" ||
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "apiKey";
|
||||
|
||||
@@ -22,6 +22,7 @@ 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";
|
||||
@@ -48,6 +49,17 @@ 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",
|
||||
@@ -171,6 +183,45 @@ 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(
|
||||
@@ -207,30 +258,6 @@ 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,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiProviderConfig,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
|
||||
@@ -105,3 +107,146 @@ describe("applyAuthProfileConfig", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMinimaxApiConfig", () => {
|
||||
it("adds minimax provider with correct settings", () => {
|
||||
const cfg = applyMinimaxApiConfig({});
|
||||
expect(cfg.models?.providers?.minimax).toMatchObject({
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets correct primary model", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1-lightning");
|
||||
expect(cfg.agents?.defaults?.model?.primary).toBe(
|
||||
"minimax/MiniMax-M2.1-lightning",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets reasoning flag for MiniMax-M2 model", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2");
|
||||
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set reasoning for non-M2 models", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1");
|
||||
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves existing model fallbacks", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { fallbacks: ["anthropic/claude-opus-4-5"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.agents?.defaults?.model?.fallbacks).toEqual([
|
||||
"anthropic/claude-opus-4-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds model alias", () => {
|
||||
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1");
|
||||
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"]?.alias).toBe(
|
||||
"Minimax",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing model params when adding alias", () => {
|
||||
const cfg = applyMinimaxApiConfig(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"minimax/MiniMax-M2.1": {
|
||||
alias: "MiniMax",
|
||||
params: { custom: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"MiniMax-M2.1",
|
||||
);
|
||||
expect(
|
||||
cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"],
|
||||
).toMatchObject({ alias: "Minimax", params: { custom: "value" } });
|
||||
});
|
||||
|
||||
it("replaces existing minimax provider entirely", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://old.example.com",
|
||||
apiKey: "old-key",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "old-model",
|
||||
name: "Old",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
|
||||
"https://api.minimax.io/anthropic",
|
||||
);
|
||||
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
|
||||
expect(cfg.models?.providers?.minimax?.models[0]?.id).toBe("MiniMax-M2.1");
|
||||
});
|
||||
|
||||
it("preserves other providers when adding minimax", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
apiKey: "anthropic-key",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.models?.providers?.anthropic).toBeDefined();
|
||||
expect(cfg.models?.providers?.minimax).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves existing models mode", () => {
|
||||
const cfg = applyMinimaxApiConfig({
|
||||
models: { mode: "replace", providers: {} },
|
||||
});
|
||||
expect(cfg.models?.mode).toBe("replace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMinimaxApiProviderConfig", () => {
|
||||
it("does not overwrite existing primary model", () => {
|
||||
const cfg = applyMinimaxApiProviderConfig({
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
});
|
||||
expect(cfg.agents?.defaults?.model?.primary).toBe(
|
||||
"anthropic/claude-opus-4-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -263,3 +263,73 @@ export function applyMinimaxHostedConfig(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic)
|
||||
export function applyMinimaxApiProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
modelId: string = "MiniMax-M2.1",
|
||||
): ClawdbotConfig {
|
||||
const providers = { ...cfg.models?.providers };
|
||||
providers.minimax = {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
apiKey: "", // Resolved via MINIMAX_API_KEY env var or auth profile
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: `MiniMax ${modelId}`,
|
||||
reasoning: modelId === "MiniMax-M2",
|
||||
input: ["text"],
|
||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[`minimax/${modelId}`] = {
|
||||
...models[`minimax/${modelId}`],
|
||||
alias: "Minimax",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: { mode: cfg.models?.mode ?? "merge", providers },
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxApiConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
modelId: string = "MiniMax-M2.1",
|
||||
): ClawdbotConfig {
|
||||
const next = applyMinimaxApiProviderConfig(cfg, modelId);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: `minimax/${modelId}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js"
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxHostedConfig,
|
||||
setAnthropicApiKey,
|
||||
@@ -173,6 +174,21 @@ export async function runNonInteractiveOnboarding(
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyMinimaxHostedConfig(nextConfig);
|
||||
} else if (authChoice === "minimax-api") {
|
||||
const key =
|
||||
opts.minimaxApiKey?.trim() || resolveEnvApiKey("minimax")?.apiKey;
|
||||
if (!key) {
|
||||
runtime.error("Missing --minimax-api-key (or MINIMAX_API_KEY in env).");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
await setMinimaxApiKey(key);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "minimax:default",
|
||||
provider: "minimax",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyMinimaxApiConfig(nextConfig);
|
||||
} else if (authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
|
||||
@@ -3,7 +3,9 @@ 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"
|
||||
@@ -14,6 +16,7 @@ export type AuthChoice =
|
||||
| "gemini-api-key"
|
||||
| "minimax-cloud"
|
||||
| "minimax"
|
||||
| "minimax-api"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
@@ -27,6 +30,14 @@ 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,6 +10,11 @@ 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(
|
||||
[
|
||||
@@ -20,12 +25,12 @@ export async function onboardCommand(
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.nonInteractive) {
|
||||
await runNonInteractiveOnboarding(opts, runtime);
|
||||
if (normalizedOpts.nonInteractive) {
|
||||
await runNonInteractiveOnboarding(normalizedOpts, runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
await runInteractiveOnboarding(opts, runtime);
|
||||
await runInteractiveOnboarding(normalizedOpts, runtime);
|
||||
}
|
||||
|
||||
export type { OnboardOptions } from "./onboard-types.js";
|
||||
@@ -404,6 +404,7 @@ export type DiscordActionConfig = {
|
||||
moderation?: boolean;
|
||||
emojiUploads?: boolean;
|
||||
stickerUploads?: boolean;
|
||||
channels?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordAccountConfig = {
|
||||
|
||||
@@ -30,6 +30,7 @@ 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,5 +1,6 @@
|
||||
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();
|
||||
@@ -119,6 +120,168 @@ 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 = {
|
||||
@@ -381,6 +544,110 @@ 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");
|
||||
|
||||
|
||||
+132
-16
@@ -113,7 +113,20 @@ 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: {
|
||||
@@ -139,14 +152,22 @@ 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 {
|
||||
if (!params.parentId) return null;
|
||||
const parentType = params.parentType;
|
||||
const isForumParent =
|
||||
parentType === ChannelType.GuildForum ||
|
||||
parentType === ChannelType.GuildMedia;
|
||||
const messageChannelId = isForumParent
|
||||
? params.channel.id
|
||||
: params.parentId;
|
||||
if (!messageChannelId) return null;
|
||||
const starter = (await params.client.rest.get(
|
||||
Routes.channelMessage(params.parentId, params.channel.id),
|
||||
Routes.channelMessage(messageChannelId, params.channel.id),
|
||||
)) as {
|
||||
content?: string | null;
|
||||
embeds?: Array<{ description?: string | null }>;
|
||||
@@ -226,6 +247,66 @@ 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;
|
||||
@@ -661,17 +742,24 @@ export function createDiscordMessageHandler(params: {
|
||||
"name" in message.channel
|
||||
? message.channel.name
|
||||
: undefined);
|
||||
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 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 threadName = threadChannel?.name;
|
||||
const configChannelName = threadParentName ?? channelName;
|
||||
const configChannelSlug = configChannelName
|
||||
@@ -935,6 +1023,7 @@ export function createDiscordMessageHandler(params: {
|
||||
channel: threadChannel,
|
||||
client,
|
||||
parentId: threadParentId,
|
||||
parentType: threadParentType,
|
||||
});
|
||||
if (starter?.text) {
|
||||
const starterEnvelope = formatThreadStarterEnvelope({
|
||||
@@ -1684,15 +1773,42 @@ async function deliverDiscordReply(params: {
|
||||
async function resolveDiscordChannelInfo(
|
||||
client: Client,
|
||||
channelId: string,
|
||||
): Promise<{ type: ChannelType; name?: string; topic?: string } | null> {
|
||||
): 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);
|
||||
}
|
||||
try {
|
||||
const channel = await client.fetchChannel(channelId);
|
||||
if (!channel) return null;
|
||||
if (!channel) {
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
|
||||
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
|
||||
return { type: channel.type, name, topic };
|
||||
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;
|
||||
} 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,6 +183,41 @@ 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;
|
||||
@@ -1191,3 +1226,93 @@ 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,6 +6,7 @@ import {
|
||||
type HookMappingConfig,
|
||||
type HooksConfig,
|
||||
} from "../config/config.js";
|
||||
import type { HookMessageProvider } from "./hooks.js";
|
||||
|
||||
export type HookMappingResolved = {
|
||||
id: string;
|
||||
@@ -18,15 +19,7 @@ export type HookMappingResolved = {
|
||||
messageTemplate?: string;
|
||||
textTemplate?: string;
|
||||
deliver?: boolean;
|
||||
provider?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
provider?: HookMessageProvider;
|
||||
to?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
@@ -59,15 +52,7 @@ export type HookAction =
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey?: string;
|
||||
deliver?: boolean;
|
||||
provider?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
provider?: HookMessageProvider;
|
||||
to?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
@@ -105,14 +90,7 @@ type HookTransformResult = Partial<{
|
||||
name: string;
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
provider:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
provider: HookMessageProvider;
|
||||
to: string;
|
||||
model: string;
|
||||
thinking: string;
|
||||
|
||||
@@ -66,6 +66,16 @@ 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(
|
||||
@@ -77,6 +87,15 @@ 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);
|
||||
});
|
||||
|
||||
+32
-33
@@ -138,20 +138,41 @@ export type HookAgentPayload = {
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
provider:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
provider: HookMessageProvider;
|
||||
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 },
|
||||
@@ -175,30 +196,8 @@ export function normalizeAgentPayload(
|
||||
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
||||
? sessionKeyRaw.trim()
|
||||
: `hook:${idFactory()}`;
|
||||
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 provider = resolveHookProvider(payload.provider);
|
||||
if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR };
|
||||
const toRaw = payload.to;
|
||||
const to =
|
||||
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
||||
@@ -210,7 +209,7 @@ export function normalizeAgentPayload(
|
||||
if (modelRaw !== undefined && !model) {
|
||||
return { ok: false, error: "model required" };
|
||||
}
|
||||
const deliver = payload.deliver === true;
|
||||
const deliver = resolveHookDeliver(payload.deliver);
|
||||
const thinkingRaw = payload.thinking;
|
||||
const thinking =
|
||||
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
||||
|
||||
+12
-11
@@ -11,11 +11,15 @@ 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";
|
||||
|
||||
@@ -32,15 +36,7 @@ type HookDispatchers = {
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
provider:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
provider: HookMessageProvider;
|
||||
to?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
@@ -171,13 +167,18 @@ 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: mapped.action.deliver === true,
|
||||
provider: mapped.action.provider ?? "last",
|
||||
deliver: resolveHookDeliver(mapped.action.deliver),
|
||||
provider,
|
||||
to: mapped.action.to,
|
||||
model: mapped.action.model,
|
||||
thinking: mapped.action.thinking,
|
||||
|
||||
+2
-10
@@ -111,7 +111,7 @@ import {
|
||||
startGatewayConfigReloader,
|
||||
} from "./config-reload.js";
|
||||
import { normalizeControlUiBasePath } from "./control-ui.js";
|
||||
import { resolveHooksConfig } from "./hooks.js";
|
||||
import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js";
|
||||
import {
|
||||
isLoopbackAddress,
|
||||
isLoopbackHost,
|
||||
@@ -496,15 +496,7 @@ export async function startGatewayServer(
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
deliver: boolean;
|
||||
provider:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
provider: HookMessageProvider;
|
||||
to?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
|
||||
+12
-1
@@ -6,6 +6,10 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
||||
const HOME = path.join(realOs.tmpdir(), "clawdbot-home-test");
|
||||
const PREV_CLAWDBOT_STATE_DIR = process.env.CLAWDBOT_STATE_DIR;
|
||||
const PREV_CLAWDIS_STATE_DIR = process.env.CLAWDIS_STATE_DIR;
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
delete process.env.CLAWDIS_STATE_DIR;
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() },
|
||||
@@ -22,11 +26,18 @@ describe("media store", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
if (PREV_CLAWDBOT_STATE_DIR !== undefined)
|
||||
process.env.CLAWDBOT_STATE_DIR = PREV_CLAWDBOT_STATE_DIR;
|
||||
else delete process.env.CLAWDBOT_STATE_DIR;
|
||||
|
||||
if (PREV_CLAWDIS_STATE_DIR !== undefined)
|
||||
process.env.CLAWDIS_STATE_DIR = PREV_CLAWDIS_STATE_DIR;
|
||||
else delete process.env.CLAWDIS_STATE_DIR;
|
||||
});
|
||||
|
||||
it("creates and returns media directory", async () => {
|
||||
const dir = await store.ensureMediaDir();
|
||||
expect(dir).toContain("clawdbot-home-test");
|
||||
expect(path.basename(dir)).toBe("media");
|
||||
const stat = await fs.stat(dir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
+17
-1
@@ -14,9 +14,16 @@ const runtime = {
|
||||
describe("web logout", () => {
|
||||
const origHomedir = os.homedir;
|
||||
let tmpDir: string;
|
||||
let prevStateDir: string | undefined;
|
||||
let prevClawdisStateDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
prevClawdisStateDir = process.env.CLAWDIS_STATE_DIR;
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
delete process.env.CLAWDIS_STATE_DIR;
|
||||
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-logout-"));
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tmpDir);
|
||||
vi.resetModules();
|
||||
@@ -36,6 +43,14 @@ describe("web logout", () => {
|
||||
await fsPromises
|
||||
.rm(tmpDir, { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
|
||||
if (prevStateDir !== undefined) process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||
else delete process.env.CLAWDBOT_STATE_DIR;
|
||||
|
||||
if (prevClawdisStateDir !== undefined)
|
||||
process.env.CLAWDIS_STATE_DIR = prevClawdisStateDir;
|
||||
else delete process.env.CLAWDIS_STATE_DIR;
|
||||
|
||||
// restore for safety
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
(os.homedir as unknown as typeof origHomedir) = origHomedir;
|
||||
@@ -47,7 +62,8 @@ describe("web logout", () => {
|
||||
async () => {
|
||||
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
|
||||
|
||||
expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
|
||||
const rel = path.relative(tmpDir, WA_WEB_AUTH_DIR);
|
||||
expect(rel && !rel.startsWith("..") && !path.isAbsolute(rel)).toBe(true);
|
||||
fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
|
||||
const result = await logoutWeb({ runtime: runtime as never });
|
||||
|
||||
Reference in new issue
Block a user