refactor: move whatsapp allowFrom config
This commit is contained in:
parent
58d32d4542
commit
0766c5e3cb
@ -14,6 +14,7 @@
|
|||||||
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
||||||
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
||||||
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
|
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
|
||||||
|
- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate).
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
@ -61,7 +62,7 @@
|
|||||||
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
||||||
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
||||||
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
||||||
- CLI onboarding: always prompt for WhatsApp `routing.allowFrom` and print (optionally open) the Control UI URL when done.
|
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
|
||||||
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
||||||
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
||||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||||
|
|||||||
@ -157,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+1234567890"]
|
allowFrom: ["+1234567890"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,7 +166,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
### WhatsApp
|
### WhatsApp
|
||||||
|
|
||||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||||
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che
|
|||||||
|
|
||||||
At minimum, set:
|
At minimum, set:
|
||||||
- `agent.workspace`
|
- `agent.workspace`
|
||||||
- `routing.allowFrom` (strongly recommended)
|
- `whatsapp.allowFrom` (strongly recommended)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ You’re putting an agent in a position to:
|
|||||||
- send messages back out via WhatsApp/Telegram/Discord
|
- send messages back out via WhatsApp/Telegram/Discord
|
||||||
|
|
||||||
Start conservative:
|
Start conservative:
|
||||||
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||||
- Use a dedicated WhatsApp number for the assistant.
|
- Use a dedicated WhatsApp number for the assistant.
|
||||||
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ clawdis gateway --port 18789
|
|||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+15555550123"]
|
allowFrom: ["+15555550123"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,8 +124,10 @@ Example:
|
|||||||
// Start with 0; enable later.
|
// Start with 0; enable later.
|
||||||
heartbeat: { every: "0m" }
|
heartbeat: { every: "0m" }
|
||||||
},
|
},
|
||||||
|
whatsapp: {
|
||||||
|
allowFrom: ["+15555550123"]
|
||||||
|
},
|
||||||
routing: {
|
routing: {
|
||||||
allowFrom: ["+15555550123"],
|
|
||||||
groupChat: {
|
groupChat: {
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
mentionPatterns: ["@clawd", "clawd"]
|
mentionPatterns: ["@clawd", "clawd"]
|
||||||
|
|||||||
@ -9,7 +9,7 @@ read_when:
|
|||||||
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed).
|
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed).
|
||||||
|
|
||||||
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
||||||
- restrict who can trigger the bot (`routing.allowFrom`)
|
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
|
||||||
- tune group mention behavior (`routing.groupChat`)
|
- tune group mention behavior (`routing.groupChat`)
|
||||||
- customize message prefixes (`messages`)
|
- customize message prefixes (`messages`)
|
||||||
- set the agent’s workspace (`agent.workspace`)
|
- set the agent’s workspace (`agent.workspace`)
|
||||||
@ -21,7 +21,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: { workspace: "~/clawd" },
|
agent: { workspace: "~/clawd" },
|
||||||
routing: { allowFrom: ["+15555550123"] }
|
whatsapp: { allowFrom: ["+15555550123"] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -76,13 +76,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `routing.allowFrom`
|
### `whatsapp.allowFrom`
|
||||||
|
|
||||||
Allowlist of E.164 phone numbers that may trigger auto-replies.
|
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: { allowFrom: ["+15555550123", "+447700900123"] }
|
whatsapp: { allowFrom: ["+15555550123", "+447700900123"] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
36
docs/doctor.md
Normal file
36
docs/doctor.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
summary: "Doctor command: health checks, config migrations, and repair steps"
|
||||||
|
read_when:
|
||||||
|
- Adding or modifying doctor migrations
|
||||||
|
- Introducing breaking config changes
|
||||||
|
---
|
||||||
|
# Doctor
|
||||||
|
|
||||||
|
`clawdis doctor` is the repair + migration tool for Clawdis. It runs a quick health check, audits skills, and can migrate deprecated config entries to the new schema.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
- Runs a health check and offers to restart the gateway if it looks unhealthy.
|
||||||
|
- Prints a skills status summary (eligible/missing/blocked).
|
||||||
|
- Detects deprecated config keys and offers to migrate them.
|
||||||
|
|
||||||
|
## Legacy config migrations
|
||||||
|
When the config contains deprecated keys, other commands will refuse to run and ask you to run `clawdis doctor`.
|
||||||
|
Doctor will:
|
||||||
|
- Explain which legacy keys were found.
|
||||||
|
- Show the migration it applied.
|
||||||
|
- Rewrite `~/.clawdis/clawdis.json` with the updated schema.
|
||||||
|
|
||||||
|
Current migrations:
|
||||||
|
- `routing.allowFrom` → `whatsapp.allowFrom`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdis doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to review changes before writing, open the config file first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.clawdis/clawdis.json
|
||||||
|
```
|
||||||
@ -9,7 +9,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
|
|||||||
|
|
||||||
## What’s implemented (2025-12-03)
|
## What’s implemented (2025-12-03)
|
||||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config.
|
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config.
|
||||||
- Group allowlist bypass: we still enforce `routing.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||||
- Per-group sessions: session keys look like `whatsapp:group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
- Per-group sessions: session keys look like `whatsapp:group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||||
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
||||||
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
||||||
@ -45,7 +45,7 @@ Use the group chat command:
|
|||||||
- `/activation mention`
|
- `/activation mention`
|
||||||
- `/activation always`
|
- `/activation always`
|
||||||
|
|
||||||
Only the owner number (from `routing.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
|
Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
1) Add Clawd UK (`+447700900123`) to the group.
|
1) Add Clawd UK (`+447700900123`) to the group.
|
||||||
|
|||||||
@ -40,7 +40,7 @@ Group owners can toggle per-group activation:
|
|||||||
- `/activation mention`
|
- `/activation mention`
|
||||||
- `/activation always`
|
- `/activation always`
|
||||||
|
|
||||||
Owner is determined by `routing.allowFrom` (or the bot’s default identity when unset).
|
Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`.
|
||||||
|
|
||||||
## Context fields
|
## Context fields
|
||||||
Group inbound payloads set:
|
Group inbound payloads set:
|
||||||
|
|||||||
@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
|||||||
## When something fails
|
## When something fails
|
||||||
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
||||||
- Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy).
|
- Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy).
|
||||||
- No inbound messages → confirm linked phone is online and the sender is allowed (`routing.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`).
|
- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`).
|
||||||
|
|
||||||
## Dedicated "health" command
|
## Dedicated "health" command
|
||||||
`clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default.
|
`clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default.
|
||||||
|
|||||||
@ -100,16 +100,14 @@ clawdis send --to +15555550123 --message "Hello from CLAWDIS"
|
|||||||
Config lives at `~/.clawdis/clawdis.json`.
|
Config lives at `~/.clawdis/clawdis.json`.
|
||||||
|
|
||||||
- If you **do nothing**, CLAWDIS uses the bundled Pi binary in RPC mode with per-sender sessions.
|
- If you **do nothing**, CLAWDIS uses the bundled Pi binary in RPC mode with per-sender sessions.
|
||||||
- If you want to lock it down, start with `routing.allowFrom` and (for groups) mention rules.
|
- If you want to lock it down, start with `whatsapp.allowFrom` and (for groups) mention rules.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: {
|
whatsapp: { allowFrom: ["+15555550123"] },
|
||||||
allowFrom: ["+15555550123"],
|
routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } }
|
||||||
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ This is social engineering 101. Create distrust, encourage snooping.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"routing": {
|
"whatsapp": {
|
||||||
"allowFrom": ["+15555550123"]
|
"allowFrom": ["+15555550123"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
|||||||
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
|
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
|
||||||
4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
|
4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
|
||||||
5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:<chatId>` and require mention/command to trigger replies.
|
5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:<chatId>` and require mention/command to trigger replies.
|
||||||
6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
||||||
|
|
||||||
## Capabilities & limits (Bot API)
|
## Capabilities & limits (Bot API)
|
||||||
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
||||||
|
|||||||
@ -22,9 +22,9 @@ The agent was interrupted mid-response.
|
|||||||
|
|
||||||
### Messages Not Triggering
|
### Messages Not Triggering
|
||||||
|
|
||||||
**Check 1:** Is the sender in `routing.allowFrom`?
|
**Check 1:** Is the sender in `whatsapp.allowFrom`?
|
||||||
```bash
|
```bash
|
||||||
cat ~/.clawdis/clawdis.json | jq '.routing.allowFrom'
|
cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check 2:** For group chats, is mention required?
|
**Check 2:** For group chats, is mention required?
|
||||||
|
|||||||
@ -31,8 +31,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
|
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
|
||||||
- Status/broadcast chats are ignored.
|
- Status/broadcast chats are ignored.
|
||||||
- Direct chats use E.164; groups use group JID.
|
- Direct chats use E.164; groups use group JID.
|
||||||
- **Allowlist**: `routing.allowFrom` enforced for direct chats only.
|
- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only.
|
||||||
- If `routing.allowFrom` is empty, default allowlist = self number (self-chat mode).
|
- If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode).
|
||||||
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
||||||
- Read receipts sent for non-self-chat DMs.
|
- Read receipts sent for non-self-chat DMs.
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- `mention` (default): requires @mention or regex match.
|
- `mention` (default): requires @mention or regex match.
|
||||||
- `always`: always triggers.
|
- `always`: always triggers.
|
||||||
- `/activation mention|always` is owner-only.
|
- `/activation mention|always` is owner-only.
|
||||||
- Owner = `routing.allowFrom` (or self E.164 if unset).
|
- Owner = `whatsapp.allowFrom` (or self E.164 if unset).
|
||||||
- **History injection**:
|
- **History injection**:
|
||||||
- Recent messages (default 50) inserted under:
|
- Recent messages (default 50) inserted under:
|
||||||
`[Chat messages since your last reply - for context]`
|
`[Chat messages since your last reply - for context]`
|
||||||
@ -98,7 +98,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- Logged-out => stop and require re-link.
|
- Logged-out => stop and require re-link.
|
||||||
|
|
||||||
## Config quick map
|
## Config quick map
|
||||||
- `routing.allowFrom` (DM allowlist).
|
- `whatsapp.allowFrom` (DM allowlist).
|
||||||
- `routing.groupChat.mentionPatterns`
|
- `routing.groupChat.mentionPatterns`
|
||||||
- `routing.groupChat.historyLimit`
|
- `routing.groupChat.historyLimit`
|
||||||
- `messages.messagePrefix` (inbound prefix)
|
- `messages.messagePrefix` (inbound prefix)
|
||||||
|
|||||||
@ -118,7 +118,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: path.join(home, "sessions.json") },
|
session: { store: path.join(home, "sessions.json") },
|
||||||
@ -168,7 +168,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -195,7 +195,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -208,7 +208,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -264,7 +264,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
@ -325,7 +325,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
@ -506,7 +506,7 @@ describe("directive parsing", () => {
|
|||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
allowedModels: ["openai/gpt-4.1-mini"],
|
allowedModels: ["openai/gpt-4.1-mini"],
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
|
|||||||
@ -42,7 +42,7 @@ function makeCfg(home: string) {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: join(home, "sessions.json") },
|
session: { store: join(home, "sessions.json") },
|
||||||
@ -283,8 +283,10 @@ describe("trigger handling", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
groupChat: { requireMention: false },
|
groupChat: { requireMention: false },
|
||||||
},
|
},
|
||||||
session: { store: join(home, "sessions.json") },
|
session: { store: join(home, "sessions.json") },
|
||||||
@ -324,7 +326,7 @@ describe("trigger handling", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@ -363,7 +365,7 @@ describe("trigger handling", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@ -841,14 +841,20 @@ export async function getReplyFromConfig(
|
|||||||
const perMessageQueueMode =
|
const perMessageQueueMode =
|
||||||
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
const isWhatsAppSurface =
|
||||||
|
surface === "whatsapp" ||
|
||||||
|
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||||
|
(ctx.To ?? "").startsWith("whatsapp:");
|
||||||
|
|
||||||
|
// WhatsApp owner allowlist (E.164 without whatsapp: prefix); used for group activation only.
|
||||||
|
const configuredAllowFrom = isWhatsAppSurface
|
||||||
|
? cfg.whatsapp?.allowFrom
|
||||||
|
: undefined;
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||||
const isSamePhone = from && to && from === to;
|
|
||||||
// If no config is present, default to self-only DM access.
|
|
||||||
const defaultAllowFrom =
|
const defaultAllowFrom =
|
||||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
||||||
? [to]
|
? [to]
|
||||||
: undefined;
|
: undefined;
|
||||||
const allowFrom =
|
const allowFrom =
|
||||||
@ -862,10 +868,12 @@ export async function getReplyFromConfig(
|
|||||||
: rawBodyNormalized;
|
: rawBodyNormalized;
|
||||||
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
||||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||||
const ownerCandidates = (allowFrom ?? []).filter(
|
const ownerCandidates = isWhatsAppSurface
|
||||||
(entry) => entry && entry !== "*",
|
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||||
);
|
: [];
|
||||||
if (ownerCandidates.length === 0 && to) ownerCandidates.push(to);
|
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
|
||||||
|
ownerCandidates.push(to);
|
||||||
|
}
|
||||||
const ownerList = ownerCandidates
|
const ownerList = ownerCandidates
|
||||||
.map((entry) => normalizeE164(entry))
|
.map((entry) => normalizeE164(entry))
|
||||||
.filter((entry): entry is string => Boolean(entry));
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
@ -876,20 +884,6 @@ export async function getReplyFromConfig(
|
|||||||
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same-phone mode (self-messaging) is always allowed
|
|
||||||
if (isSamePhone) {
|
|
||||||
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
|
||||||
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
|
||||||
// Support "*" as wildcard to allow all senders
|
|
||||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
|
||||||
logVerbose(
|
|
||||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
|
||||||
);
|
|
||||||
cleanupTyping();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activationCommand.hasCommand) {
|
if (activationCommand.hasCommand) {
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { danger, setVerbose } from "../globals.js";
|
|||||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { readConfigFileSnapshot } from "../config/config.js";
|
||||||
import { registerBrowserCli } from "./browser-cli.js";
|
import { registerBrowserCli } from "./browser-cli.js";
|
||||||
import { registerCanvasCli } from "./canvas-cli.js";
|
import { registerCanvasCli } from "./canvas-cli.js";
|
||||||
import { registerCronCli } from "./cron-cli.js";
|
import { registerCronCli } from "./cron-cli.js";
|
||||||
@ -68,6 +69,21 @@ export function buildProgram() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||||
|
|
||||||
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
|
if (actionCommand.name() === "doctor") return;
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
if (snapshot.legacyIssues.length === 0) return;
|
||||||
|
const issues = snapshot.legacyIssues
|
||||||
|
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||||
|
.join("\n");
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
`Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
"clawdis login --verbose",
|
"clawdis login --verbose",
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export async function agentCommand(
|
|||||||
});
|
});
|
||||||
const workspaceDir = workspace.dir;
|
const workspaceDir = workspace.dir;
|
||||||
|
|
||||||
const allowFrom = (cfg.routing?.allowFrom ?? [])
|
const allowFrom = (cfg.whatsapp?.allowFrom ?? [])
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
.filter((val) => val.length > 1);
|
.filter((val) => val.length > 1);
|
||||||
|
|
||||||
@ -451,7 +451,7 @@ export async function agentCommand(
|
|||||||
if (deliver) {
|
if (deliver) {
|
||||||
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
|
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
"Delivering to WhatsApp requires --to <E.164> or routing.allowFrom[0]",
|
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
|
||||||
);
|
);
|
||||||
if (!bestEffortDeliver) throw err;
|
if (!bestEffortDeliver) throw err;
|
||||||
logDeliveryError(err);
|
logDeliveryError(err);
|
||||||
|
|||||||
96
src/commands/doctor.test.ts
Normal file
96
src/commands/doctor.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readConfigFileSnapshot = vi.fn();
|
||||||
|
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const validateConfigObject = vi.fn((raw: unknown) => ({
|
||||||
|
ok: true as const,
|
||||||
|
config: raw as Record<string, unknown>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@clack/prompts", () => ({
|
||||||
|
confirm: vi.fn().mockResolvedValue(true),
|
||||||
|
intro: vi.fn(),
|
||||||
|
note: vi.fn(),
|
||||||
|
outro: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../agents/skills-status.js", () => ({
|
||||||
|
buildWorkspaceSkillStatus: () => ({ skills: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
writeConfigFile,
|
||||||
|
validateConfigObject,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../runtime.js", () => ({
|
||||||
|
defaultRuntime: {
|
||||||
|
log: () => {},
|
||||||
|
error: () => {},
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../utils.js", () => ({
|
||||||
|
resolveUserPath: (value: string) => value,
|
||||||
|
sleep: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./health.js", () => ({
|
||||||
|
healthCommand: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./onboard-helpers.js", () => ({
|
||||||
|
applyWizardMetadata: (cfg: Record<string, unknown>) => cfg,
|
||||||
|
DEFAULT_WORKSPACE: "/tmp",
|
||||||
|
guardCancel: (value: unknown) => value,
|
||||||
|
printWizardHeader: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("doctor", () => {
|
||||||
|
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||||
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
path: "/tmp/clawdis.json",
|
||||||
|
exists: true,
|
||||||
|
raw: "{}",
|
||||||
|
parsed: { routing: { allowFrom: ["+15555550123"] } },
|
||||||
|
valid: false,
|
||||||
|
config: {},
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
path: "routing.allowFrom",
|
||||||
|
message: "legacy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyIssues: [
|
||||||
|
{
|
||||||
|
path: "routing.allowFrom",
|
||||||
|
message: "legacy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { doctorCommand } = await import("./doctor.js");
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await doctorCommand(runtime);
|
||||||
|
|
||||||
|
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||||
|
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect((written.whatsapp as Record<string, unknown>)?.allowFrom).toEqual([
|
||||||
|
"+15555550123",
|
||||||
|
]);
|
||||||
|
expect(written.routing).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
|||||||
import {
|
import {
|
||||||
CONFIG_PATH_CLAWDIS,
|
CONFIG_PATH_CLAWDIS,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
|
validateConfigObject,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
@ -19,6 +20,65 @@ import {
|
|||||||
printWizardHeader,
|
printWizardHeader,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
|
|
||||||
|
type LegacyMigration = {
|
||||||
|
id: string;
|
||||||
|
describe: string;
|
||||||
|
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_MIGRATIONS: LegacyMigration[] = [
|
||||||
|
// Legacy migration (2026-01-02, commit: TBD) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
||||||
|
{
|
||||||
|
id: "routing.allowFrom->whatsapp.allowFrom",
|
||||||
|
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
||||||
|
apply: (raw, changes) => {
|
||||||
|
const routing = raw.routing;
|
||||||
|
if (!routing || typeof routing !== "object") return;
|
||||||
|
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||||
|
if (allowFrom === undefined) return;
|
||||||
|
|
||||||
|
const whatsapp =
|
||||||
|
raw.whatsapp && typeof raw.whatsapp === "object"
|
||||||
|
? (raw.whatsapp as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (whatsapp.allowFrom === undefined) {
|
||||||
|
whatsapp.allowFrom = allowFrom;
|
||||||
|
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||||
|
} else {
|
||||||
|
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
|
||||||
|
}
|
||||||
|
|
||||||
|
delete (routing as Record<string, unknown>).allowFrom;
|
||||||
|
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||||
|
delete raw.routing;
|
||||||
|
}
|
||||||
|
raw.whatsapp = whatsapp;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyLegacyMigrations(raw: unknown): {
|
||||||
|
config: ClawdisConfig | null;
|
||||||
|
changes: string[];
|
||||||
|
} {
|
||||||
|
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
|
||||||
|
const next = structuredClone(raw) as Record<string, unknown>;
|
||||||
|
const changes: string[] = [];
|
||||||
|
for (const migration of LEGACY_MIGRATIONS) {
|
||||||
|
migration.apply(next, changes);
|
||||||
|
}
|
||||||
|
if (changes.length === 0) return { config: null, changes: [] };
|
||||||
|
const validated = validateConfigObject(next);
|
||||||
|
if (!validated.ok) {
|
||||||
|
changes.push(
|
||||||
|
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||||
|
);
|
||||||
|
return { config: null, changes };
|
||||||
|
}
|
||||||
|
return { config: validated.config, changes };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMode(cfg: ClawdisConfig): "local" | "remote" {
|
function resolveMode(cfg: ClawdisConfig): "local" | "remote" {
|
||||||
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||||
}
|
}
|
||||||
@ -29,10 +89,37 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
|
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
|
||||||
if (snapshot.exists && !snapshot.valid) {
|
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||||
note("Config invalid; doctor will run with defaults.", "Config");
|
note("Config invalid; doctor will run with defaults.", "Config");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (snapshot.legacyIssues.length > 0) {
|
||||||
|
note(
|
||||||
|
snapshot.legacyIssues
|
||||||
|
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||||
|
.join("\n"),
|
||||||
|
"Legacy config keys detected",
|
||||||
|
);
|
||||||
|
const migrate = guardCancel(
|
||||||
|
await confirm({
|
||||||
|
message: "Migrate legacy config entries now?",
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
if (migrate) {
|
||||||
|
const { config: migrated, changes } = applyLegacyMigrations(
|
||||||
|
snapshot.parsed,
|
||||||
|
);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
note(changes.join("\n"), "Doctor changes");
|
||||||
|
}
|
||||||
|
if (migrated) {
|
||||||
|
cfg = migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceDir = resolveUserPath(
|
const workspaceDir = resolveUserPath(
|
||||||
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
|
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -64,11 +64,11 @@ function noteDiscordTokenHelp(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
|
function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
routing: {
|
whatsapp: {
|
||||||
...cfg.routing,
|
...cfg.whatsapp,
|
||||||
allowFrom,
|
allowFrom,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -78,13 +78,13 @@ async function promptWhatsAppAllowFrom(
|
|||||||
cfg: ClawdisConfig,
|
cfg: ClawdisConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
): Promise<ClawdisConfig> {
|
): Promise<ClawdisConfig> {
|
||||||
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
|
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||||
const existingLabel =
|
const existingLabel =
|
||||||
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"WhatsApp direct chats are gated by `routing.allowFrom`.",
|
"WhatsApp direct chats are gated by `whatsapp.allowFrom`.",
|
||||||
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
||||||
`Current: ${existingLabel}`,
|
`Current: ${existingLabel}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@ -114,8 +114,8 @@ async function promptWhatsAppAllowFrom(
|
|||||||
) as (typeof options)[number]["value"];
|
) as (typeof options)[number]["value"];
|
||||||
|
|
||||||
if (mode === "keep") return cfg;
|
if (mode === "keep") return cfg;
|
||||||
if (mode === "self") return setRoutingAllowFrom(cfg, undefined);
|
if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
|
||||||
if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]);
|
if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
|
||||||
|
|
||||||
const allowRaw = guardCancel(
|
const allowRaw = guardCancel(
|
||||||
await text({
|
await text({
|
||||||
@ -148,7 +148,7 @@ async function promptWhatsAppAllowFrom(
|
|||||||
part === "*" ? "*" : normalizeE164(part),
|
part === "*" ? "*" : normalizeE164(part),
|
||||||
);
|
);
|
||||||
const unique = [...new Set(normalized.filter(Boolean))];
|
const unique = [...new Set(normalized.filter(Boolean))];
|
||||||
return setRoutingAllowFrom(cfg, unique);
|
return setWhatsAppAllowFrom(cfg, unique);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupProviders(
|
export async function setupProviders(
|
||||||
|
|||||||
@ -488,3 +488,37 @@ describe("talk.voiceAliases", () => {
|
|||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("legacy config detection", () => {
|
||||||
|
it("rejects routing.allowFrom", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { validateConfigObject } = await import("./config.js");
|
||||||
|
const res = validateConfigObject({
|
||||||
|
routing: { allowFrom: ["+15555550123"] },
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces legacy issues in snapshot", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const { readConfigFileSnapshot } = await import("./config.js");
|
||||||
|
const snap = await readConfigFileSnapshot();
|
||||||
|
|
||||||
|
expect(snap.valid).toBe(false);
|
||||||
|
expect(snap.legacyIssues.length).toBe(1);
|
||||||
|
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -58,6 +58,11 @@ export type WebConfig = {
|
|||||||
reconnect?: WebReconnectConfig;
|
reconnect?: WebReconnectConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WhatsAppConfig = {
|
||||||
|
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||||
|
allowFrom?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type BrowserConfig = {
|
export type BrowserConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||||
@ -260,7 +265,6 @@ export type GroupChatConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RoutingConfig = {
|
export type RoutingConfig = {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
|
||||||
transcribeAudio?: {
|
transcribeAudio?: {
|
||||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||||
command: string[];
|
command: string[];
|
||||||
@ -525,6 +529,7 @@ export type ClawdisConfig = {
|
|||||||
messages?: MessagesConfig;
|
messages?: MessagesConfig;
|
||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
|
whatsapp?: WhatsAppConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
signal?: SignalConfig;
|
signal?: SignalConfig;
|
||||||
@ -693,7 +698,6 @@ const HeartbeatSchema = z
|
|||||||
|
|
||||||
const RoutingSchema = z
|
const RoutingSchema = z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
transcribeAudio: TranscribeAudioSchema,
|
transcribeAudio: TranscribeAudioSchema,
|
||||||
queue: z
|
queue: z
|
||||||
@ -909,6 +913,11 @@ const ClawdisSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
whatsapp: z
|
||||||
|
.object({
|
||||||
|
allowFrom: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
telegram: z
|
telegram: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@ -1131,6 +1140,11 @@ export type ConfigValidationIssue = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LegacyConfigIssue = {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConfigFileSnapshot = {
|
export type ConfigFileSnapshot = {
|
||||||
path: string;
|
path: string;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
@ -1139,8 +1153,42 @@ export type ConfigFileSnapshot = {
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
config: ClawdisConfig;
|
config: ClawdisConfig;
|
||||||
issues: ConfigValidationIssue[];
|
issues: ConfigValidationIssue[];
|
||||||
|
legacyIssues: LegacyConfigIssue[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LegacyConfigRule = {
|
||||||
|
path: string[];
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||||
|
{
|
||||||
|
path: ["routing", "allowFrom"],
|
||||||
|
message:
|
||||||
|
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||||
|
if (!raw || typeof raw !== "object") return [];
|
||||||
|
const root = raw as Record<string, unknown>;
|
||||||
|
const issues: LegacyConfigIssue[] = [];
|
||||||
|
for (const rule of LEGACY_CONFIG_RULES) {
|
||||||
|
let cursor: unknown = root;
|
||||||
|
for (const key of rule.path) {
|
||||||
|
if (!cursor || typeof cursor !== "object") {
|
||||||
|
cursor = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = (cursor as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
if (cursor !== undefined) {
|
||||||
|
issues.push({ path: rule.path.join("."), message: rule.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(text: string): string {
|
function escapeRegExp(text: string): string {
|
||||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
@ -1199,6 +1247,16 @@ export function validateConfigObject(
|
|||||||
):
|
):
|
||||||
| { ok: true; config: ClawdisConfig }
|
| { ok: true; config: ClawdisConfig }
|
||||||
| { ok: false; issues: ConfigValidationIssue[] } {
|
| { ok: false; issues: ConfigValidationIssue[] } {
|
||||||
|
const legacyIssues = findLegacyConfigIssues(raw);
|
||||||
|
if (legacyIssues.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
issues: legacyIssues.map((iss) => ({
|
||||||
|
path: iss.path,
|
||||||
|
message: iss.message,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
const validated = ClawdisSchema.safeParse(raw);
|
const validated = ClawdisSchema.safeParse(raw);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
return {
|
return {
|
||||||
@ -1271,6 +1329,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
const exists = fs.existsSync(configPath);
|
const exists = fs.existsSync(configPath);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
const config = applyTalkApiKey({});
|
const config = applyTalkApiKey({});
|
||||||
|
const legacyIssues: LegacyConfigIssue[] = [];
|
||||||
return {
|
return {
|
||||||
path: configPath,
|
path: configPath,
|
||||||
exists: false,
|
exists: false,
|
||||||
@ -1279,6 +1338,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config,
|
config,
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1296,9 +1356,12 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
issues: [
|
issues: [
|
||||||
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
||||||
],
|
],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
|
||||||
|
|
||||||
const validated = validateConfigObject(parsedRes.parsed);
|
const validated = validateConfigObject(parsedRes.parsed);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return {
|
return {
|
||||||
@ -1309,6 +1372,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: false,
|
valid: false,
|
||||||
config: {},
|
config: {},
|
||||||
issues: validated.issues,
|
issues: validated.issues,
|
||||||
|
legacyIssues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1320,6 +1384,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: applyTalkApiKey(validated.config),
|
config: applyTalkApiKey(validated.config),
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@ -1330,6 +1395,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: false,
|
valid: false,
|
||||||
config: {},
|
config: {},
|
||||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,7 +103,7 @@ function resolveDeliveryTarget(
|
|||||||
|
|
||||||
const sanitizedWhatsappTo = (() => {
|
const sanitizedWhatsappTo = (() => {
|
||||||
if (channel !== "whatsapp") return to;
|
if (channel !== "whatsapp") return to;
|
||||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||||
if (rawAllow.includes("*")) return to;
|
if (rawAllow.includes("*")) return to;
|
||||||
const allowFrom = rawAllow
|
const allowFrom = rawAllow
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
|
|||||||
@ -163,6 +163,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: {},
|
config: {},
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -176,6 +177,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: parsed,
|
config: parsed,
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@ -186,6 +188,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
valid: false,
|
valid: false,
|
||||||
config: {},
|
config: {},
|
||||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -206,7 +209,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: testAllowFrom,
|
allowFrom: testAllowFrom,
|
||||||
},
|
},
|
||||||
session: { mainKey: "main", store: testSessionStorePath },
|
session: { mainKey: "main", store: testSessionStorePath },
|
||||||
|
|||||||
@ -6641,7 +6641,7 @@ export async function startGatewayServer(
|
|||||||
if (explicit) return resolvedTo;
|
if (explicit) return resolvedTo;
|
||||||
|
|
||||||
const cfg = cfgForAgent ?? loadConfig();
|
const cfg = cfgForAgent ?? loadConfig();
|
||||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||||
if (rawAllow.includes("*")) return resolvedTo;
|
if (rawAllow.includes("*")) return resolvedTo;
|
||||||
const allowFrom = rawAllow
|
const allowFrom = rawAllow
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
|
|||||||
@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
|||||||
|
|
||||||
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const raw =
|
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
|
||||||
opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
|
||||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
|||||||
it("applies allowFrom fallback for WhatsApp targets", () => {
|
it("applies allowFrom fallback for WhatsApp targets", () => {
|
||||||
const cfg: ClawdisConfig = {
|
const cfg: ClawdisConfig = {
|
||||||
agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
|
agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
|
||||||
routing: { allowFrom: ["+1555", "+1666"] },
|
whatsapp: { allowFrom: ["+1555", "+1666"] },
|
||||||
};
|
};
|
||||||
const entry = {
|
const entry = {
|
||||||
...baseEntry,
|
...baseEntry,
|
||||||
@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
agent: {
|
agent: {
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
agent: {
|
agent: {
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
return { channel, to };
|
return { channel, to };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||||
if (rawAllow.includes("*")) return { channel, to };
|
if (rawAllow.includes("*")) return { channel, to };
|
||||||
const allowFrom = rawAllow
|
const allowFrom = rawAllow
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const allowFrom = cfg.routing?.allowFrom ?? [];
|
const allowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||||
const sender = resolveHeartbeatSender({
|
const sender = resolveHeartbeatSender({
|
||||||
allowFrom,
|
allowFrom,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
|
|||||||
@ -80,8 +80,8 @@ export async function buildProviderSummary(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowFrom = effective.routing?.allowFrom?.length
|
const allowFrom = effective.whatsapp?.allowFrom?.length
|
||||||
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean)
|
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
if (allowFrom.length) {
|
if (allowFrom.length) {
|
||||||
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));
|
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));
|
||||||
|
|||||||
@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined {
|
|||||||
|
|
||||||
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const raw =
|
const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
|
||||||
opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
|
||||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export function normalizeE164(number: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account,
|
* "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account,
|
||||||
* and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
* and `whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
|
||||||
* "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
|
* "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
|
||||||
*/
|
*/
|
||||||
export function isSelfChatMode(
|
export function isSelfChatMode(
|
||||||
|
|||||||
@ -111,7 +111,7 @@ describe("partial reply gating", () => {
|
|||||||
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
|
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
|
||||||
|
|
||||||
const mockConfig: ClawdisConfig = {
|
const mockConfig: ClawdisConfig = {
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -158,7 +158,7 @@ describe("partial reply gating", () => {
|
|||||||
const replyResolver = vi.fn().mockResolvedValue(undefined);
|
const replyResolver = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
const mockConfig: ClawdisConfig = {
|
const mockConfig: ClawdisConfig = {
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: store.storePath, mainKey: "main" },
|
session: { store: store.storePath, mainKey: "main" },
|
||||||
@ -1097,9 +1097,11 @@ describe("web auto-reply", () => {
|
|||||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
routing: {
|
whatsapp: {
|
||||||
// Self-chat heuristic: allowFrom includes selfE164.
|
// Self-chat heuristic: allowFrom includes selfE164.
|
||||||
allowFrom: ["+999"],
|
allowFrom: ["+999"],
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
groupChat: {
|
groupChat: {
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
mentionPatterns: ["\\bclawd\\b"],
|
mentionPatterns: ["\\bclawd\\b"],
|
||||||
@ -1247,7 +1249,7 @@ describe("web auto-reply", () => {
|
|||||||
it("prefixes body with same-phone marker when from === to", async () => {
|
it("prefixes body with same-phone marker when from === to", async () => {
|
||||||
// Enable messagePrefix for same-phone mode testing
|
// Enable messagePrefix for same-phone mode testing
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -1372,7 +1374,7 @@ describe("web auto-reply", () => {
|
|||||||
|
|
||||||
it("applies responsePrefix to regular replies", async () => {
|
it("applies responsePrefix to regular replies", async () => {
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -1417,7 +1419,7 @@ describe("web auto-reply", () => {
|
|||||||
|
|
||||||
it("does not deliver HEARTBEAT_OK responses", async () => {
|
it("does not deliver HEARTBEAT_OK responses", async () => {
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -1462,7 +1464,7 @@ describe("web auto-reply", () => {
|
|||||||
|
|
||||||
it("does not double-prefix if responsePrefix already present", async () => {
|
it("does not double-prefix if responsePrefix already present", async () => {
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -1508,7 +1510,7 @@ describe("web auto-reply", () => {
|
|||||||
|
|
||||||
it("sends tool summaries immediately with responsePrefix", async () => {
|
it("sends tool summaries immediately with responsePrefix", async () => {
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
|
|||||||
@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
||||||
return { mentionRegexes, allowFrom: cfg.routing?.allowFrom };
|
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBotMentioned(
|
function isBotMentioned(
|
||||||
@ -448,8 +448,8 @@ export function resolveHeartbeatRecipients(
|
|||||||
|
|
||||||
const sessionRecipients = getSessionRecipients(cfg);
|
const sessionRecipients = getSessionRecipients(cfg);
|
||||||
const allowFrom =
|
const allowFrom =
|
||||||
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0
|
Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0
|
||||||
? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
||||||
@ -918,7 +918,7 @@ export async function monitorWebProvider(
|
|||||||
// Build message prefix: explicit config > default based on allowFrom
|
// Build message prefix: explicit config > default based on allowFrom
|
||||||
let messagePrefix = cfg.messages?.messagePrefix;
|
let messagePrefix = cfg.messages?.messagePrefix;
|
||||||
if (messagePrefix === undefined) {
|
if (messagePrefix === undefined) {
|
||||||
const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0;
|
const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0;
|
||||||
messagePrefix = hasAllowFrom ? "" : "[clawdis]";
|
messagePrefix = hasAllowFrom ? "" : "[clawdis]";
|
||||||
}
|
}
|
||||||
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
loadConfig: vi.fn().mockReturnValue({
|
loadConfig: vi.fn().mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"], // Allow all in tests
|
allowFrom: ["*"], // Allow all in tests
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
|
|||||||
@ -157,7 +157,7 @@ export async function monitorWebInbox(options: {
|
|||||||
// Filter unauthorized senders early to prevent wasted processing
|
// Filter unauthorized senders early to prevent wasted processing
|
||||||
// and potential session corruption from Bad MAC errors
|
// and potential session corruption from Bad MAC errors
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
const configuredAllowFrom = cfg.whatsapp?.allowFrom;
|
||||||
// Without user config, default to self-only DM access so the owner can talk to themselves
|
// Without user config, default to self-only DM access so the owner can talk to themselves
|
||||||
const defaultAllowFrom =
|
const defaultAllowFrom =
|
||||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164
|
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164
|
||||||
|
|||||||
@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"], // Allow all in tests by default
|
allowFrom: ["*"], // Allow all in tests by default
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -450,7 +450,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
|
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+111"], // does not include +777
|
allowFrom: ["+111"], // does not include +777
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -506,7 +506,7 @@ describe("web monitor inbox", () => {
|
|||||||
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
|
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
|
||||||
// from unauthorized senders corrupting sessions
|
// from unauthorized senders corrupting sessions
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+111"], // Only allow +111
|
allowFrom: ["+111"], // Only allow +111
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -546,7 +546,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
// Reset mock for other tests
|
// Reset mock for other tests
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -561,7 +561,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
it("skips read receipts in self-chat mode", async () => {
|
it("skips read receipts in self-chat mode", async () => {
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
// Self-chat heuristic: allowFrom includes selfE164 (+123).
|
// Self-chat heuristic: allowFrom includes selfE164 (+123).
|
||||||
allowFrom: ["+123"],
|
allowFrom: ["+123"],
|
||||||
},
|
},
|
||||||
@ -598,7 +598,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
// Reset mock for other tests
|
// Reset mock for other tests
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -613,7 +613,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
it("lets group messages through even when sender not in allowFrom", async () => {
|
it("lets group messages through even when sender not in allowFrom", async () => {
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+1234"],
|
allowFrom: ["+1234"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -655,7 +655,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
it("allows messages from senders in allowFrom list", async () => {
|
it("allows messages from senders in allowFrom list", async () => {
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+111", "+999"], // Allow +999
|
allowFrom: ["+111", "+999"], // Allow +999
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -690,7 +690,7 @@ describe("web monitor inbox", () => {
|
|||||||
|
|
||||||
// Reset mock for other tests
|
// Reset mock for other tests
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -707,7 +707,7 @@ describe("web monitor inbox", () => {
|
|||||||
// Same-phone mode: when from === selfJid, should always be allowed
|
// Same-phone mode: when from === selfJid, should always be allowed
|
||||||
// This allows users to message themselves even with restrictive allowFrom
|
// This allows users to message themselves even with restrictive allowFrom
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+111"], // Only allow +111, but self is +123
|
allowFrom: ["+111"], // Only allow +111, but self is +123
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => {
|
|||||||
|
|
||||||
// Reset mock for other tests
|
// Reset mock for other tests
|
||||||
mockLoadConfig.mockReturnValue({
|
mockLoadConfig.mockReturnValue({
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js";
|
|||||||
// Use globalThis to store the mock config so it survives vi.mock hoisting
|
// Use globalThis to store the mock config so it survives vi.mock hoisting
|
||||||
const CONFIG_KEY = Symbol.for("clawdis:testConfigMock");
|
const CONFIG_KEY = Symbol.for("clawdis:testConfigMock");
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
routing: {
|
whatsapp: {
|
||||||
// Tests can override; default remains open to avoid surprising fixtures
|
// Tests can override; default remains open to avoid surprising fixtures
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user