From 967cef80bc21bf2fdf46682b2cd7d3af42d05a68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 17:51:38 +0100 Subject: [PATCH] fix(security): lock down inbound DMs by default --- CHANGELOG.md | 6 + docs/configuration.md | 27 ++- docs/discord.md | 20 +- docs/grammy.md | 2 +- docs/imessage.md | 2 + docs/security.md | 1 + docs/signal.md | 7 +- docs/slack.md | 6 + docs/telegram.md | 11 +- docs/whatsapp.md | 7 +- src/cli/pairing-cli.ts | 122 +++++++++++ src/cli/program.ts | 4 + src/cli/telegram-cli.ts | 74 +++++++ src/commands/doctor.test.ts | 12 ++ src/commands/doctor.ts | 197 ++++++++++++++++- src/config/config.test.ts | 160 ++++++++++++++ src/config/schema.ts | 18 ++ src/config/types.ts | 21 ++ src/config/zod-schema.ts | 104 ++++++++- src/discord/monitor.tool-result.test.ts | 68 +++++- src/discord/monitor.ts | 59 +++++- src/imessage/monitor.test.ts | 57 ++++- src/imessage/monitor.ts | 95 +++++++-- src/pairing/pairing-store.ts | 268 ++++++++++++++++++++++++ src/signal/monitor.tool-result.test.ts | 53 ++++- src/signal/monitor.ts | 70 ++++++- src/slack/monitor.tool-result.test.ts | 58 ++++- src/slack/monitor.ts | 125 +++++++++-- src/telegram/bot.media.test.ts | 10 +- src/telegram/bot.test.ts | 64 +++++- src/telegram/bot.ts | 104 +++++++-- src/telegram/pairing-store.test.ts | 51 +++++ src/telegram/pairing-store.ts | 122 +++++++++++ src/web/inbound.media.test.ts | 12 ++ src/web/inbound.ts | 75 +++++-- src/web/monitor-inbox.test.ts | 204 ++++++++++-------- 36 files changed, 2093 insertions(+), 203 deletions(-) create mode 100644 src/cli/pairing-cli.ts create mode 100644 src/cli/telegram-cli.ts create mode 100644 src/pairing/pairing-store.ts create mode 100644 src/telegram/pairing-store.test.ts create mode 100644 src/telegram/pairing-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d15a97fd7..69ec9d9ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ## Unreleased ### Breaking +- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack. + - Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots). + - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). + - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). + - Approve requests via `clawdbot pairing list --provider ` + `clawdbot pairing approve --provider ` (Telegram also supports `clawdbot telegram pairing ...`). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. @@ -86,6 +91,7 @@ - Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194. - Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs. - Auto-reply: track compaction count in session status; verbose mode announces auto-compactions. +- Telegram: notify users when inbound media exceeds size limits. Thanks @jarvis-medmatic for PR #283. - Telegram: send GIF media as animations (auto-play) and improve filename sniffing. - Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202. diff --git a/docs/configuration.md b/docs/configuration.md index d5a3a909c..0247f3c1e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -184,15 +184,28 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). } ``` +### `whatsapp.dmPolicy` + +Controls how WhatsApp direct chats (DMs) are handled: +- `"pairing"` (default): unknown senders get a pairing code; owner must approve +- `"allowlist"`: only allow senders in `whatsapp.allowFrom` (or paired allow store) +- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`) +- `"disabled"`: ignore all inbound DMs + +Pairing approvals: +- `clawdbot pairing list --provider whatsapp` +- `clawdbot pairing approve --provider whatsapp ` + ### `whatsapp.allowFrom` Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**). -If empty, the default allowlist is your own WhatsApp number (self-chat mode). +If empty and `whatsapp.dmPolicy="pairing"`, unknown senders will receive a pairing code. For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`. ```json5 { whatsapp: { + dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "+447700900123"], textChunkLimit: 4000 // optional outbound chunk size (chars) } @@ -338,8 +351,9 @@ Set `telegram.enabled: false` to disable automatic startup. telegram: { enabled: true, botToken: "your-bot-token", - requireMention: true, - allowFrom: ["123456789"], + dmPolicy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["tg:123456789"], // optional; "open" requires ["*"] + groups: { "*": { requireMention: true } }, mediaMaxMb: 5, proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", @@ -385,7 +399,8 @@ Configure the Discord bot by setting the bot token and optional gating: }, dm: { enabled: true, // disable all DMs when false - allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names) + policy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["1234567890", "steipete"], // optional DM allowlist ("open" requires ["*"]) groupEnabled: false, // enable group DMs groupChannels: ["clawd-dm"] // optional group DM allowlist }, @@ -426,7 +441,8 @@ Slack runs in Socket Mode and requires both a bot token and app token: appToken: "xapp-...", dm: { enabled: true, - allowFrom: ["U123", "U456", "*"], + policy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["U123", "U456", "*"], // optional; "open" requires ["*"] groupEnabled: false, groupChannels: ["G123"] }, @@ -481,6 +497,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. enabled: true, cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", + dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], includeAttachments: false, mediaMaxMb: 16, diff --git a/docs/discord.md b/docs/discord.md index 80e9a3f36..11c581b4f 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -23,11 +23,15 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa - If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`. 5. Direct chats: use `user:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. 6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel. -7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs. -8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. -9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. -10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. -11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). +7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code; approve via `clawdbot pairing approve --provider discord `. + - To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`. + - To hard-allowlist: set `discord.dm.policy="allowlist"` and list senders in `discord.dm.allowFrom`. + - To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`. +8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`. +9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. +10. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. +11. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. +12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). - The `discord` tool is only exposed when the current surface is Discord. 12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session. @@ -138,7 +142,7 @@ Notes: - The bot lacks channel permissions (View/Send/Read History), or - Your config requires mentions and you didn’t mention it, or - Your guild/channel allowlist denies the channel/user. -- **DMs don’t work**: `discord.dm.enabled` may be `false` or `discord.dm.allowFrom` doesn’t include you. +- **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`). ## Capabilities & limits - DMs and guild text channels (threads are treated as separate channels; voice not supported). @@ -183,6 +187,7 @@ Notes: }, dm: { enabled: true, + policy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["123456789012345678", "steipete"], groupEnabled: false, groupChannels: ["clawd-dm"] @@ -208,7 +213,8 @@ Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. - `dm.enabled`: set `false` to ignore all DMs (default `true`). -- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. +- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`. +- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. - `dm.groupEnabled`: enable group DMs (default `false`). - `dm.groupChannels`: optional allowlist for group DM channel ids or slugs. - `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists. diff --git a/docs/grammy.md b/docs/grammy.md index 7e0c3366a..215085258 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -18,7 +18,7 @@ Updated: 2025-12-07 - **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls). - **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface. -- **Config knobs:** `telegram.botToken`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. +- **Config knobs:** `telegram.botToken`, `telegram.dmPolicy`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. Open questions diff --git a/docs/imessage.md b/docs/imessage.md index 526144cf0..aa45aafd6 100644 --- a/docs/imessage.md +++ b/docs/imessage.md @@ -26,6 +26,7 @@ Status: external CLI integration. No daemon. enabled: true, cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", + dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], groupPolicy: "open", groupAllowFrom: ["chat_id:123"], @@ -39,6 +40,7 @@ Status: external CLI integration. No daemon. Notes: - `allowFrom` accepts handles (phone/email) or `chat_id:` entries. +- Default: `imessage.dmPolicy="pairing"` — unknown DM senders get a pairing code (approve via `clawdbot pairing approve --provider imessage `). `"open"` requires `allowFrom=["*"]`. - `groupPolicy` controls group handling (`open|disabled|allowlist`). - `groupAllowFrom` accepts the same entries as `allowFrom`. - `service` defaults to `auto` (use `imessage` or `sms` to pin). diff --git a/docs/security.md b/docs/security.md index edd624f20..913dae52d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -49,6 +49,7 @@ This is social engineering 101. Create distrust, encourage snooping. ``` Only allow specific phone numbers to trigger your AI. Never use `["*"]` in production. +Newer versions default to **DM pairing** (`*.dmPolicy="pairing"`) on most providers; avoid `dmPolicy="open"` unless you explicitly want public inbound access. ### 2. Group Chat Mentions diff --git a/docs/signal.md b/docs/signal.md index 845ce4223..e0022e2a7 100644 --- a/docs/signal.md +++ b/docs/signal.md @@ -51,7 +51,8 @@ You can still run Clawdbot on your own Signal account if your goal is “respond httpPort: 8080, // Who is allowed to talk to the bot (DMs) - allowFrom: ["+15557654321"], // your personal number (or "*") + dmPolicy: "pairing", // pairing | allowlist | open | disabled + allowFrom: ["+15557654321"], // your personal number ("open" requires ["*"]) // Group policy + allowlist groupPolicy: "open", @@ -64,6 +65,10 @@ You can still run Clawdbot on your own Signal account if your goal is “respond - Expect `signal.probe.ok=true` and `signal.probe.version`. 5) DM the bot number from your phone; Clawdbot replies. +## DM pairing +- Default: `signal.dmPolicy="pairing"` — unknown DM senders get a pairing code. +- Approve via: `clawdbot pairing approve --provider signal `. + ## “Do I need a separate number?” - If you want “I text her and she texts me back”, yes: **use a separate Signal account/number for the bot**. - Your personal account can run `signal-cli`, but you can’t self-chat (Signal loop protection; Clawdbot ignores sender==account). diff --git a/docs/slack.md b/docs/slack.md index 97f0f2c0a..8f85eb662 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -148,6 +148,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "groupPolicy": "open", "dm": { "enabled": true, + "policy": "pairing", "allowFrom": ["U123", "U456", "*"], "groupEnabled": false, "groupChannels": ["G123"] @@ -189,6 +190,11 @@ Ack reactions are controlled globally via `messages.ackReaction` + - Channels map to `slack:channel:` sessions. - Slash commands use `slack:slash:` sessions. +## DM security (pairing) +- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code. +- Approve via: `clawdbot pairing approve --provider slack `. +- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`. + ## Group policy - `slack.groupPolicy` controls channel handling (`open|disabled|allowlist`). - `allowlist` requires channels to be listed in `slack.channels`. diff --git a/docs/telegram.md b/docs/telegram.md index df53c8e5e..6fa6426e0 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -23,10 +23,10 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - **Webhook mode** is enabled by setting `telegram.webhookUrl` (optionally `telegram.webhookSecret` / `telegram.webhookPath`). - The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default. - 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: secure by default — unknown senders are gated by `telegram.dmPolicy` (default: `"pairing"`). The bot responds with a pairing code that the owner must approve before messages are processed. If you really want public inbound DMs: set `telegram.dmPolicy="open"` and `telegram.allowFrom=["*"]`. 5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:`. When `telegram.groups` is set, it becomes a group allowlist (use `"*"` to allow all). Mention/command gating defaults come from `telegram.groups`. -6) Optional allowlist: - - Direct chats: `telegram.allowFrom` by chat id (`123456789`, `telegram:123456789`, or `tg:123456789`; prefixes are case-insensitive). +6) Allowlist + pairing: + - Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram ` (alias: `clawdbot telegram pairing approve `). - Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`). ## Capabilities & limits (Bot API) @@ -39,7 +39,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits. - Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. -- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. +- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.dmPolicy`, `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. - Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. @@ -49,12 +49,13 @@ Example config: telegram: { enabled: true, botToken: "123:abc", + dmPolicy: "pairing", // pairing | allowlist | open | disabled replyToMode: "off", groups: { "*": { requireMention: true }, // allow all groups "123456789": { requireMention: false } // group chat id }, - allowFrom: ["123456789"], // direct chat ids allowed (or "*") + allowFrom: ["123456789"], // direct chat ids allowed ("open" requires ["*"]) groupPolicy: "allowlist", groupAllowFrom: ["tg:123456789", "@alice"], mediaMaxMb: 5, diff --git a/docs/whatsapp.md b/docs/whatsapp.md index 2ee5d4bf6..5f2e81395 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -47,8 +47,10 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts. - Status/broadcast chats are ignored. - Direct chats use E.164; groups use group JID. -- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only. - - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode). +- **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`). + - Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp `). + - Open: requires `whatsapp.allowFrom` to include `"*"`. + - Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number. - **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`). - `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`). - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. @@ -120,6 +122,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - Logged-out => stop and require re-link. ## Config quick map +- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled). - `whatsapp.allowFrom` (DM allowlist). - `whatsapp.groupAllowFrom` (group sender allowlist). - `whatsapp.groupPolicy` (group policy). diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts new file mode 100644 index 000000000..eee882da6 --- /dev/null +++ b/src/cli/pairing-cli.ts @@ -0,0 +1,122 @@ +import type { Command } from "commander"; + +import { loadConfig } from "../config/config.js"; +import { sendMessageDiscord } from "../discord/send.js"; +import { sendMessageIMessage } from "../imessage/send.js"; +import { + approveProviderPairingCode, + listProviderPairingRequests, + type PairingProvider, +} from "../pairing/pairing-store.js"; +import { sendMessageSignal } from "../signal/send.js"; +import { sendMessageSlack } from "../slack/send.js"; +import { sendMessageTelegram } from "../telegram/send.js"; +import { resolveTelegramToken } from "../telegram/token.js"; + +const PROVIDERS: PairingProvider[] = [ + "telegram", + "signal", + "imessage", + "discord", + "slack", + "whatsapp", +]; + +function parseProvider(raw: unknown): PairingProvider { + const value = String(raw ?? "") + .trim() + .toLowerCase(); + if ((PROVIDERS as string[]).includes(value)) return value as PairingProvider; + throw new Error( + `Invalid provider: ${value || "(empty)"} (expected one of: ${PROVIDERS.join(", ")})`, + ); +} + +async function notifyApproved(provider: PairingProvider, id: string) { + const message = + "✅ Clawdbot access approved. Send a message to start chatting."; + if (provider === "telegram") { + const cfg = loadConfig(); + const { token } = resolveTelegramToken(cfg); + if (!token) throw new Error("telegram token not configured"); + await sendMessageTelegram(id, message, { token }); + return; + } + if (provider === "discord") { + await sendMessageDiscord(`user:${id}`, message); + return; + } + if (provider === "slack") { + await sendMessageSlack(`user:${id}`, message); + return; + } + if (provider === "signal") { + await sendMessageSignal(id, message); + return; + } + if (provider === "imessage") { + await sendMessageIMessage(id, message); + return; + } + // WhatsApp: approval still works (store); notifying requires an active web session. +} + +export function registerPairingCli(program: Command) { + const pairing = program + .command("pairing") + .description("Secure DM pairing (approve inbound requests)"); + + pairing + .command("list") + .description("List pending pairing requests") + .requiredOption( + "--provider ", + `Provider (${PROVIDERS.join(", ")})`, + ) + .option("--json", "Print JSON", false) + .action(async (opts) => { + const provider = parseProvider(opts.provider); + const requests = await listProviderPairingRequests(provider); + if (opts.json) { + console.log(JSON.stringify({ provider, requests }, null, 2)); + return; + } + if (requests.length === 0) { + console.log(`No pending ${provider} pairing requests.`); + return; + } + for (const r of requests) { + const meta = r.meta ? JSON.stringify(r.meta) : ""; + console.log( + `${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`, + ); + } + }); + + pairing + .command("approve") + .description("Approve a pairing code and allow that sender") + .requiredOption( + "--provider ", + `Provider (${PROVIDERS.join(", ")})`, + ) + .argument("", "Pairing code (shown to the requester)") + .option("--notify", "Notify the requester on the same provider", false) + .action(async (code, opts) => { + const provider = parseProvider(opts.provider); + const approved = await approveProviderPairingCode({ + provider, + code: String(code), + }); + if (!approved) { + throw new Error(`No pending pairing request found for code: ${code}`); + } + + console.log(`Approved ${provider} sender ${approved.id}.`); + + if (!opts.notify) return; + await notifyApproved(provider, approved.id).catch((err) => { + console.log(`Failed to notify requester: ${String(err)}`); + }); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 7c7210a0b..4988dfdf5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -30,7 +30,9 @@ import { registerGatewayCli } from "./gateway-cli.js"; import { registerHooksCli } from "./hooks-cli.js"; import { registerModelsCli } from "./models-cli.js"; import { registerNodesCli } from "./nodes-cli.js"; +import { registerPairingCli } from "./pairing-cli.js"; import { forceFreePort } from "./ports.js"; +import { registerTelegramCli } from "./telegram-cli.js"; import { registerTuiCli } from "./tui-cli.js"; export { forceFreePort }; @@ -507,6 +509,8 @@ Examples: registerCronCli(program); registerDnsCli(program); registerHooksCli(program); + registerPairingCli(program); + registerTelegramCli(program); program .command("status") diff --git a/src/cli/telegram-cli.ts b/src/cli/telegram-cli.ts new file mode 100644 index 000000000..dbe31aec6 --- /dev/null +++ b/src/cli/telegram-cli.ts @@ -0,0 +1,74 @@ +import type { Command } from "commander"; + +import { loadConfig } from "../config/config.js"; +import { + approveTelegramPairingCode, + listTelegramPairingRequests, +} from "../telegram/pairing-store.js"; +import { sendMessageTelegram } from "../telegram/send.js"; +import { resolveTelegramToken } from "../telegram/token.js"; + +export function registerTelegramCli(program: Command) { + const telegram = program + .command("telegram") + .description("Telegram helpers (pairing, allowlists)"); + + const pairing = telegram + .command("pairing") + .description("Secure DM pairing (approve inbound requests)"); + + pairing + .command("list") + .description("List pending Telegram pairing requests") + .option("--json", "Print JSON", false) + .action(async (opts) => { + const requests = await listTelegramPairingRequests(); + if (opts.json) { + console.log(JSON.stringify({ requests }, null, 2)); + return; + } + if (requests.length === 0) { + console.log("No pending Telegram pairing requests."); + return; + } + for (const r of requests) { + const name = [r.firstName, r.lastName].filter(Boolean).join(" ").trim(); + const username = r.username ? `@${r.username}` : ""; + const who = [name, username].filter(Boolean).join(" ").trim(); + console.log( + `${r.code} chatId=${r.chatId}${who ? ` ${who}` : ""} ${r.createdAt}`, + ); + } + }); + + pairing + .command("approve") + .description("Approve a pairing code and allow that chatId") + .argument("", "Pairing code (shown to the requester)") + .option("--no-notify", "Do not notify the requester on Telegram") + .action(async (code, opts) => { + const approved = await approveTelegramPairingCode({ code: String(code) }); + if (!approved) { + throw new Error(`No pending pairing request found for code: ${code}`); + } + + console.log(`Approved Telegram chatId ${approved.chatId}.`); + + if (opts.notify === false) return; + const cfg = loadConfig(); + const { token } = resolveTelegramToken(cfg); + if (!token) { + console.log( + "Telegram token not configured; skipping requester notification.", + ); + return; + } + await sendMessageTelegram( + approved.chatId, + "✅ Clawdbot access approved. Send a message to start chatting.", + { token }, + ).catch((err) => { + console.log(`Failed to notify requester: ${String(err)}`); + }); + }); +} diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 8902edfe0..3f2de8508 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -93,6 +93,18 @@ vi.mock("../daemon/service.js", () => ({ }), })); +vi.mock("../telegram/pairing-store.js", () => ({ + readTelegramAllowFromStore: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../telegram/token.js", () => ({ + resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })), +})); + vi.mock("../runtime.js", () => ({ defaultRuntime: { log: () => {}, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 8d99ae713..b53e69cfd 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,10 +27,13 @@ import { } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; import { runCommandWithTimeout, runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { resolveUserPath, sleep } from "../utils.js"; +import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; +import { resolveTelegramToken } from "../telegram/token.js"; +import { normalizeE164, resolveUserPath, sleep } from "../utils.js"; import { healthCommand } from "./health.js"; import { applyWizardMetadata, @@ -50,6 +53,196 @@ function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { return path.join(os.homedir(), ".clawdis", "clawdis.json"); } +async function noteSecurityWarnings(cfg: ClawdbotConfig) { + const warnings: string[] = []; + + const warnDmPolicy = async (params: { + label: string; + provider: + | "telegram" + | "signal" + | "imessage" + | "discord" + | "slack" + | "whatsapp"; + dmPolicy: string; + allowFrom?: Array | null; + allowFromPath: string; + approveHint: string; + normalizeEntry?: (raw: string) => string; + }) => { + const dmPolicy = params.dmPolicy; + const configAllowFrom = (params.allowFrom ?? []).map((v) => + String(v).trim(), + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readProviderAllowFromStore( + params.provider, + ).catch(() => []); + const normalizedCfg = configAllowFrom + .filter((v) => v !== "*") + .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) + .map((v) => v.trim()) + .filter(Boolean); + const normalizedStore = storeAllowFrom + .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v)) + .map((v) => v.trim()) + .filter(Boolean); + const allowCount = Array.from( + new Set([...normalizedCfg, ...normalizedStore]), + ).length; + + if (dmPolicy === "open") { + const policyPath = `${params.allowFromPath}policy`; + const allowFromPath = `${params.allowFromPath}allowFrom`; + warnings.push( + `- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`, + ); + if (!hasWildcard) { + warnings.push( + `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`, + ); + } + return; + } + + if (dmPolicy === "disabled") { + const policyPath = `${params.allowFromPath}policy`; + warnings.push( + `- ${params.label} DMs: disabled (${policyPath}="disabled").`, + ); + return; + } + + if (allowCount === 0) { + const policyPath = `${params.allowFromPath}policy`; + warnings.push( + `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, + ); + warnings.push(` ${params.approveHint}`); + } + }; + + const telegramConfigured = Boolean(cfg.telegram); + const { token: telegramToken } = resolveTelegramToken(cfg); + if (telegramConfigured && telegramToken.trim()) { + const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; + const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) => + String(v).trim(), + ); + const hasWildcard = configAllowFrom.includes("*"); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const allowCount = Array.from( + new Set([ + ...configAllowFrom + .filter((v) => v !== "*") + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter(Boolean), + ...storeAllowFrom.filter((v) => v !== "*"), + ]), + ).length; + + if (dmPolicy === "open") { + warnings.push( + `- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`, + ); + if (!hasWildcard) { + warnings.push( + `- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`, + ); + } + } else if (dmPolicy === "disabled") { + warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`); + } else if (allowCount === 0) { + warnings.push( + `- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, + ); + warnings.push( + ` Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve `, + ); + } + + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + const groupAllowlistConfigured = + cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0; + if (groupPolicy === "open" && !groupAllowlistConfigured) { + warnings.push( + `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`, + ); + } + } + + if (cfg.discord?.enabled !== false) { + await warnDmPolicy({ + label: "Discord", + provider: "discord", + dmPolicy: cfg.discord?.dm?.policy ?? "pairing", + allowFrom: cfg.discord?.dm?.allowFrom ?? [], + allowFromPath: "discord.dm.", + approveHint: + "Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord ", + normalizeEntry: (raw) => + raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), + }); + } + + if (cfg.slack?.enabled !== false) { + await warnDmPolicy({ + label: "Slack", + provider: "slack", + dmPolicy: cfg.slack?.dm?.policy ?? "pairing", + allowFrom: cfg.slack?.dm?.allowFrom ?? [], + allowFromPath: "slack.dm.", + approveHint: + "Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack ", + normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), + }); + } + + if (cfg.signal?.enabled !== false) { + await warnDmPolicy({ + label: "Signal", + provider: "signal", + dmPolicy: cfg.signal?.dmPolicy ?? "pairing", + allowFrom: cfg.signal?.allowFrom ?? [], + allowFromPath: "signal.", + approveHint: + "Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal ", + normalizeEntry: (raw) => + normalizeE164(raw.replace(/^signal:/i, "").trim()), + }); + } + + if (cfg.imessage?.enabled !== false) { + await warnDmPolicy({ + label: "iMessage", + provider: "imessage", + dmPolicy: cfg.imessage?.dmPolicy ?? "pairing", + allowFrom: cfg.imessage?.allowFrom ?? [], + allowFromPath: "imessage.", + approveHint: + "Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage ", + }); + } + + if (cfg.whatsapp) { + await warnDmPolicy({ + label: "WhatsApp", + provider: "whatsapp", + dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing", + allowFrom: cfg.whatsapp?.allowFrom ?? [], + allowFromPath: "whatsapp.", + approveHint: + "Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp ", + normalizeEntry: (raw) => normalizeE164(raw), + }); + } + + if (warnings.length > 0) { + note(warnings.join("\n"), "Security"); + } +} + function replacePathSegment( value: string | undefined, from: string, @@ -645,6 +838,8 @@ export async function doctorCommand( await maybeMigrateLegacyGatewayService(cfg, runtime); + await noteSecurityWarnings(cfg); + if (process.platform === "linux" && resolveMode(cfg) === "local") { const service = resolveGatewayService(); let loaded = false; diff --git a/src/config/config.test.ts b/src/config/config.test.ts index cef464679..f69ec69ae 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -679,6 +679,166 @@ describe("legacy config detection", () => { } }); + it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + telegram: { dmPolicy: "open", allowFrom: ["123456789"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("telegram.allowFrom"); + } + }); + + it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.telegram?.dmPolicy).toBe("open"); + } + }); + + it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ telegram: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.telegram?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("whatsapp.allowFrom"); + } + }); + + it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + whatsapp: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.whatsapp?.dmPolicy).toBe("open"); + } + }); + + it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ whatsapp: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.whatsapp?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + signal: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("signal.allowFrom"); + } + }); + + it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + signal: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.signal?.dmPolicy).toBe("open"); + } + }); + + it("defaults signal.dmPolicy to pairing when signal section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ signal: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.signal?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("imessage.allowFrom"); + } + }); + + it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + imessage: { dmPolicy: "open", allowFrom: ["*"] }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.imessage?.dmPolicy).toBe("open"); + } + }); + + it("defaults imessage.dmPolicy to pairing when imessage section exists", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ imessage: {} }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.imessage?.dmPolicy).toBe("pairing"); + } + }); + + it('rejects discord.dm.policy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + discord: { dm: { policy: "open", allowFrom: ["123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("discord.dm.allowFrom"); + } + }); + + it('rejects slack.dm.policy="open" without allowFrom "*"', async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + slack: { dm: { policy: "open", allowFrom: ["U123"] } }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("slack.dm.allowFrom"); + } + }); + it("rejects legacy agent.model string", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/schema.ts b/src/config/schema.ts index 9b4ff37e2..3696fdeac 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -101,6 +101,12 @@ const FIELD_LABELS: Record = { "messages.ackReactionScope": "Ack Reaction Scope", "talk.apiKey": "Talk API Key", "telegram.botToken": "Telegram Bot Token", + "telegram.dmPolicy": "Telegram DM Policy", + "whatsapp.dmPolicy": "WhatsApp DM Policy", + "signal.dmPolicy": "Signal DM Policy", + "imessage.dmPolicy": "iMessage DM Policy", + "discord.dm.policy": "Discord DM Policy", + "slack.dm.policy": "Slack DM Policy", "discord.token": "Discord Bot Token", "slack.botToken": "Slack Bot Token", "slack.appToken": "Slack App Token", @@ -137,6 +143,18 @@ const FIELD_HELP: Record = { "Emoji reaction used to acknowledge inbound messages (empty disables).", "messages.ackReactionScope": 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "telegram.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].', + "whatsapp.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].', + "signal.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].', + "imessage.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires imessage.allowFrom=["*"].', + "discord.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires discord.dm.allowFrom=["*"].', + "slack.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].', }; const FIELD_PLACEHOLDERS: Record = { diff --git a/src/config/types.ts b/src/config/types.ts index 7d7bb92b5..c88e31901 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,6 +2,7 @@ export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type ReplyToMode = "off" | "first" | "all"; export type GroupPolicy = "open" | "disabled" | "allowlist"; +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { @@ -79,6 +80,8 @@ export type AgentElevatedAllowFromConfig = { export type WhatsAppConfig = { /** Optional per-account WhatsApp configuration (multi-account). */ accounts?: Record; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; /** Optional allowlist for WhatsApp group senders (E.164). */ @@ -105,6 +108,8 @@ export type WhatsAppAccountConfig = { enabled?: boolean; /** Override auth directory (Baileys multi-file auth state). */ authDir?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; allowFrom?: string[]; groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; @@ -222,6 +227,14 @@ export type HooksConfig = { }; export type TelegramConfig = { + /** + * Controls how Telegram direct chats (DMs) are handled: + * - "pairing" (default): unknown senders get a pairing code; owner must approve + * - "allowlist": only allow senders in allowFrom (or paired allow store) + * - "open": allow all inbound DMs (requires allowFrom to include "*") + * - "disabled": ignore all inbound DMs + */ + dmPolicy?: DmPolicy; /** If false, do not start the Telegram provider. Default: true. */ enabled?: boolean; botToken?: string; @@ -257,6 +270,8 @@ export type TelegramConfig = { export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; /** Allowlist for DM senders (ids or names). */ allowFrom?: Array; /** If true, allow group DMs (default: false). */ @@ -344,6 +359,8 @@ export type DiscordConfig = { export type SlackDmConfig = { /** If false, ignore all incoming Slack DMs. Default: true. */ enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; /** Allowlist for DM senders (ids). */ allowFrom?: Array; /** If true, allow group DMs (default: false). */ @@ -424,6 +441,8 @@ export type SignalConfig = { ignoreAttachments?: boolean; ignoreStories?: boolean; sendReadReceipts?: boolean; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; allowFrom?: Array; /** Optional allowlist for Signal group senders (E.164). */ groupAllowFrom?: Array; @@ -450,6 +469,8 @@ export type IMessageConfig = { service?: "imessage" | "sms" | "auto"; /** Optional default region (used when sending SMS). */ region?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; /** Optional allowlist for inbound handles or chat_id targets. */ allowFrom?: Array; /** Optional allowlist for group senders or chat_id targets. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 6039afb70..d2efe9bd8 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -87,6 +87,8 @@ const ReplyToModeSchema = z.union([ // - .default("open") ensures runtime always resolves to "open" if not provided const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); +const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); + const QueueModeBySurfaceSchema = z .object({ whatsapp: QueueModeSchema.optional(), @@ -674,6 +676,7 @@ export const ClawdbotSchema = z.object({ enabled: z.boolean().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), @@ -689,9 +692,23 @@ export const ClawdbotSchema = z.object({ ) .optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"', + }); + }) .optional(), ) .optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), @@ -707,10 +724,24 @@ export const ClawdbotSchema = z.object({ ) .optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'whatsapp.dmPolicy="open" requires whatsapp.allowFrom to include "*"', + }); + }) .optional(), telegram: z .object({ enabled: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), botToken: z.string().optional(), tokenFile: z.string().optional(), replyToMode: ReplyToModeSchema.optional(), @@ -734,6 +765,19 @@ export const ClawdbotSchema = z.object({ webhookSecret: z.string().optional(), webhookPath: z.string().optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"', + }); + }) .optional(), discord: z .object({ @@ -774,10 +818,24 @@ export const ClawdbotSchema = z.object({ dm: z .object({ enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) + .superRefine((value, ctx) => { + if (value.policy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"', + }); + }) .optional(), guilds: z .record( @@ -842,10 +900,24 @@ export const ClawdbotSchema = z.object({ dm: z .object({ enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) + .superRefine((value, ctx) => { + if (value.policy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"', + }); + }) .optional(), channels: z .record( @@ -875,11 +947,25 @@ export const ClawdbotSchema = z.object({ ignoreAttachments: z.boolean().optional(), ignoreStories: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), - mediaMaxMb: z.number().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), + }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'signal.dmPolicy="open" requires signal.allowFrom to include "*"', + }); }) .optional(), imessage: z @@ -891,11 +977,12 @@ export const ClawdbotSchema = z.object({ .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) .optional(), region: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), includeAttachments: z.boolean().optional(), - mediaMaxMb: z.number().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(), groups: z .record( @@ -908,6 +995,19 @@ export const ClawdbotSchema = z.object({ ) .optional(), }) + .superRefine((value, ctx) => { + if (value.dmPolicy !== "open") return; + const allow = (value.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean); + if (allow.includes("*")) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["allowFrom"], + message: + 'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"', + }); + }) .optional(), bridge: z .object({ diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 2d000c61d..c3d8f7186 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -6,6 +6,8 @@ const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); let config: Record = {}; +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -23,6 +25,13 @@ vi.mock("./send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -53,7 +62,10 @@ vi.mock("discord.js", () => { } } login = vi.fn().mockResolvedValue(undefined); - destroy = vi.fn().mockResolvedValue(undefined); + destroy = vi.fn().mockImplementation(async () => { + handlers.clear(); + Client.lastClient = null; + }); } return { @@ -98,12 +110,16 @@ async function waitForClient() { beforeEach(() => { config = { messages: { responsePrefix: "PFX" }, - discord: { dm: { enabled: true } }, + discord: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); updateLastRouteMock.mockReset(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); }); describe("monitorDiscordProvider tool results", () => { @@ -152,7 +168,7 @@ describe("monitorDiscordProvider tool results", () => { config = { messages: { responsePrefix: "PFX" }, discord: { - dm: { enabled: true }, + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, guilds: { "*": { requireMention: true } }, }, routing: { @@ -202,4 +218,50 @@ describe("monitorDiscordProvider tool results", () => { expect(replyMock).toHaveBeenCalledTimes(1); expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); + + it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { + config = { + ...config, + discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } }, + }; + + const controller = new AbortController(); + const run = monitorDiscordProvider({ + token: "token", + abortSignal: controller.signal, + }); + + const discord = await import("discord.js"); + const client = await waitForClient(); + if (!client) throw new Error("Discord client not created"); + + const reply = vi.fn().mockResolvedValue(undefined); + client.emit(discord.Events.MessageCreate, { + id: "m3", + content: "hello", + author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, + channelId: "c1", + channel: { + type: discord.ChannelType.DM, + isSendable: () => false, + }, + guild: undefined, + mentions: { has: () => false }, + attachments: { first: () => undefined }, + type: discord.MessageType.Default, + createdTimestamp: Date.now(), + reply, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(reply).toHaveBeenCalledTimes(1); + expect(String(reply.mock.calls[0]?.[0] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); }); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 15253ba03..f89ed13be 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -41,6 +41,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; @@ -142,6 +146,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; const groupPolicy = cfg.discord?.groupPolicy ?? "open"; + const dmPolicy = dmConfig?.policy ?? "pairing"; const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; @@ -160,7 +165,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (shouldLogVerbose()) { logVerbose( - `discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`, + `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`, ); } @@ -210,6 +215,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { logVerbose("discord: drop dm (dms disabled)"); return; } + if (isDirectMessage && dmPolicy === "disabled") { + logVerbose("discord: drop dm (dmPolicy: disabled)"); + return; + } const botId = client.user?.id; const forwardedSnapshot = resolveForwardedSnapshot(message); const forwardedText = forwardedSnapshot @@ -386,22 +395,58 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { - const allowList = normalizeDiscordAllowList(allowFrom, [ + if (isDirectMessage && dmPolicy !== "open") { + const storeAllowFrom = await readProviderAllowFromStore( + "discord", + ).catch(() => []); + const effectiveAllowFrom = Array.from( + new Set([...(allowFrom ?? []), ...storeAllowFrom]), + ); + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ "discord:", "user:", ]); const permitted = - allowList && + allowList != null && allowListMatches(allowList, { id: message.author.id, name: message.author.username, tag: message.author.tag, }); if (!permitted) { - logVerbose( - `Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`, - ); + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "discord", + id: message.author.id, + meta: { + username: message.author.username, + tag: message.author.tag, + }, + }); + logVerbose( + `discord pairing request sender=${message.author.id} tag=${message.author.tag} code=${code}`, + ); + try { + await message.reply( + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider discord ", + ].join("\n"), + ); + } catch (err) { + logVerbose( + `discord pairing reply failed for ${message.author.id}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized discord sender ${message.author.id} (dmPolicy=${dmPolicy})`, + ); + } return; } } diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 92fbf2a18..0a98d8749 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -7,6 +7,8 @@ const stopMock = vi.fn(); const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); let config: Record = {}; let notificationHandler: @@ -30,6 +32,13 @@ vi.mock("./send.js", () => ({ sendMessageIMessage: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -63,7 +72,11 @@ async function waitForSubscribe() { beforeEach(() => { config = { - imessage: { groups: { "*": { requireMention: true } } }, + imessage: { + dmPolicy: "open", + allowFrom: ["*"], + groups: { "*": { requireMention: true } }, + }, session: { mainKey: "main" }, routing: { groupChat: { mentionPatterns: ["@clawd"] }, @@ -79,6 +92,10 @@ beforeEach(() => { sendMock.mockReset().mockResolvedValue({ messageId: "ok" }); replyMock.mockReset().mockResolvedValue({ text: "ok" }); updateLastRouteMock.mockReset(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); notificationHandler = undefined; closeResolve = undefined; }); @@ -234,6 +251,44 @@ describe("monitorIMessageProvider", () => { expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => { + config = { + ...config, + imessage: { + dmPolicy: "pairing", + allowFrom: [], + groups: { "*": { requireMention: true } }, + }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 99, + chat_id: 77, + sender: "+15550001111", + is_from_me: false, + text: "hello", + is_group: false, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); + it("delivers group replies when mentioned", async () => { replyMock.mockResolvedValueOnce({ text: "yo" }); const run = monitorIMessageProvider(); diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index e719796a6..a9696f605 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -16,6 +16,10 @@ import { import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { mediaKindFromMime } from "../media/constants.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; import type { RuntimeEnv } from "../runtime.js"; import { createIMessageRpcClient } from "./client.js"; import { sendMessageIMessage } from "./send.js"; @@ -130,6 +134,7 @@ export async function monitorIMessageProvider( const allowFrom = resolveAllowFrom(opts); const groupAllowFrom = resolveGroupAllowFrom(opts); const groupPolicy = cfg.imessage?.groupPolicy ?? "open"; + const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing"; const mentionRegexes = buildMentionRegexes(cfg); const includeAttachments = opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; @@ -153,20 +158,34 @@ export async function monitorIMessageProvider( if (isGroup && !chatId) return; const groupId = isGroup ? String(chatId) : undefined; + const storeAllowFrom = await readProviderAllowFromStore("imessage").catch( + () => [], + ); + const effectiveDmAllowFrom = Array.from( + new Set([...allowFrom, ...storeAllowFrom]), + ) + .map((v) => String(v).trim()) + .filter(Boolean); + const effectiveGroupAllowFrom = Array.from( + new Set([...groupAllowFrom, ...storeAllowFrom]), + ) + .map((v) => String(v).trim()) + .filter(Boolean); + if (isGroup) { if (groupPolicy === "disabled") { logVerbose("Blocked iMessage group message (groupPolicy: disabled)"); return; } if (groupPolicy === "allowlist") { - if (groupAllowFrom.length === 0) { + if (effectiveGroupAllowFrom.length === 0) { logVerbose( "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", ); return; } const allowed = isAllowedIMessageSender({ - allowFrom: groupAllowFrom, + allowFrom: effectiveGroupAllowFrom, sender, chatId: chatId ?? undefined, chatGuid, @@ -192,16 +211,64 @@ export async function monitorIMessageProvider( } } - const dmAuthorized = isAllowedIMessageSender({ - allowFrom, - sender, - chatId: chatId ?? undefined, - chatGuid, - chatIdentifier, - }); - if (!isGroup && !dmAuthorized) { - logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`); - return; + const dmHasWildcard = effectiveDmAllowFrom.includes("*"); + const dmAuthorized = + dmPolicy === "open" + ? true + : dmHasWildcard || + (effectiveDmAllowFrom.length > 0 && + isAllowedIMessageSender({ + allowFrom: effectiveDmAllowFrom, + sender, + chatId: chatId ?? undefined, + chatGuid, + chatIdentifier, + })); + if (!isGroup) { + if (dmPolicy === "disabled") return; + if (!dmAuthorized) { + if (dmPolicy === "pairing") { + const senderId = normalizeIMessageHandle(sender); + const { code } = await upsertProviderPairingRequest({ + provider: "imessage", + id: senderId, + meta: { + sender: senderId, + chatId: chatId ? String(chatId) : undefined, + }, + }); + logVerbose( + `imessage pairing request sender=${senderId} code=${code}`, + ); + try { + await sendMessageIMessage( + sender, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider imessage ", + ].join("\n"), + { + client, + maxBytes: mediaMaxBytes, + ...(chatId ? { chatId } : {}), + }, + ); + } catch (err) { + logVerbose( + `imessage pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked iMessage sender ${sender} (dmPolicy=${dmPolicy})`, + ); + } + return; + } } const messageText = (message.text ?? "").trim(); @@ -217,9 +284,9 @@ export async function monitorIMessageProvider( }); const canDetectMention = mentionRegexes.length > 0; const commandAuthorized = isGroup - ? groupAllowFrom.length > 0 + ? effectiveGroupAllowFrom.length > 0 ? isAllowedIMessageSender({ - allowFrom: groupAllowFrom, + allowFrom: effectiveGroupAllowFrom, sender, chatId: chatId ?? undefined, chatGuid, diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts new file mode 100644 index 000000000..02f31e345 --- /dev/null +++ b/src/pairing/pairing-store.ts @@ -0,0 +1,268 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; + +export type PairingProvider = + | "telegram" + | "signal" + | "imessage" + | "discord" + | "slack" + | "whatsapp"; + +export type PairingRequest = { + id: string; + code: string; + createdAt: string; + lastSeenAt: string; + meta?: Record; +}; + +type PairingStore = { + version: 1; + requests: PairingRequest[]; +}; + +type AllowFromStore = { + version: 1; + allowFrom: string[]; +}; + +function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = resolveStateDir(env, os.homedir); + return resolveOAuthDir(env, stateDir); +} + +function resolvePairingPath( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): string { + return path.join(resolveCredentialsDir(env), `${provider}-pairing.json`); +} + +function resolveAllowFromPath( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): string { + return path.join(resolveCredentialsDir(env), `${provider}-allowFrom.json`); +} + +function safeParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function readJsonFile( + filePath: string, + fallback: T, +): Promise<{ value: T; exists: boolean }> { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = safeParseJson(raw); + if (parsed == null) return { value: fallback, exists: true }; + return { value: parsed, exists: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") return { value: fallback, exists: false }; + return { value: fallback, exists: false }; + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile( + filePath, + `${JSON.stringify(value, null, 2)}\n`, + "utf-8", + ); +} + +function randomCode(): string { + // Human-friendly: 8 chars, upper, no ambiguous chars (0O1I). + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let out = ""; + for (let i = 0; i < 8; i++) { + out += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return out; +} + +function normalizeId(value: string | number): string { + return String(value).trim(); +} + +function normalizeAllowEntry(provider: PairingProvider, entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) return ""; + if (trimmed === "*") return ""; + if (provider === "telegram") return trimmed.replace(/^(telegram|tg):/i, ""); + if (provider === "signal") return trimmed.replace(/^signal:/i, ""); + if (provider === "discord") return trimmed.replace(/^(discord|user):/i, ""); + if (provider === "slack") return trimmed.replace(/^(slack|user):/i, ""); + return trimmed; +} + +export async function readProviderAllowFromStore( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const filePath = resolveAllowFromPath(provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + allowFrom: [], + }); + const list = Array.isArray(value.allowFrom) ? value.allowFrom : []; + return list + .map((v) => normalizeAllowEntry(provider, String(v))) + .filter(Boolean); +} + +export async function addProviderAllowFromStoreEntry(params: { + provider: PairingProvider; + entry: string | number; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; allowFrom: string[] }> { + const env = params.env ?? process.env; + const filePath = resolveAllowFromPath(params.provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + allowFrom: [], + }); + const current = (Array.isArray(value.allowFrom) ? value.allowFrom : []) + .map((v) => normalizeAllowEntry(params.provider, String(v))) + .filter(Boolean); + const normalized = normalizeAllowEntry( + params.provider, + normalizeId(params.entry), + ); + if (!normalized) return { changed: false, allowFrom: current }; + if (current.includes(normalized)) + return { changed: false, allowFrom: current }; + const next = [...current, normalized]; + await writeJsonFile(filePath, { + version: 1, + allowFrom: next, + } satisfies AllowFromStore); + return { changed: true, allowFrom: next }; +} + +export async function listProviderPairingRequests( + provider: PairingProvider, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const filePath = resolvePairingPath(provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const reqs = Array.isArray(value.requests) ? value.requests : []; + return reqs + .filter( + (r) => + r && + typeof r.id === "string" && + typeof r.code === "string" && + typeof r.createdAt === "string", + ) + .slice() + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +export async function upsertProviderPairingRequest(params: { + provider: PairingProvider; + id: string | number; + meta?: Record; + env?: NodeJS.ProcessEnv; +}): Promise<{ code: string; created: boolean }> { + const env = params.env ?? process.env; + const filePath = resolvePairingPath(params.provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const now = new Date().toISOString(); + const id = normalizeId(params.id); + const meta = + params.meta && typeof params.meta === "object" + ? Object.fromEntries( + Object.entries(params.meta) + .map(([k, v]) => [k, String(v ?? "").trim()] as const) + .filter(([_, v]) => Boolean(v)), + ) + : undefined; + + const reqs = Array.isArray(value.requests) ? value.requests : []; + const existingIdx = reqs.findIndex((r) => r.id === id); + if (existingIdx >= 0) { + const existing = reqs[existingIdx]; + const existingCode = + existing && typeof existing.code === "string" ? existing.code.trim() : ""; + const code = existingCode || randomCode(); + const next: PairingRequest = { + id, + code, + createdAt: existing?.createdAt ?? now, + lastSeenAt: now, + meta: meta ?? existing?.meta, + }; + reqs[existingIdx] = next; + await writeJsonFile(filePath, { + version: 1, + requests: reqs, + } satisfies PairingStore); + return { code, created: false }; + } + + const code = randomCode(); + const next: PairingRequest = { + id, + code, + createdAt: now, + lastSeenAt: now, + ...(meta ? { meta } : {}), + }; + await writeJsonFile(filePath, { + version: 1, + requests: [...reqs, next], + } satisfies PairingStore); + return { code, created: true }; +} + +export async function approveProviderPairingCode(params: { + provider: PairingProvider; + code: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ id: string; entry?: PairingRequest } | null> { + const env = params.env ?? process.env; + const code = params.code.trim().toUpperCase(); + if (!code) return null; + + const filePath = resolvePairingPath(params.provider, env); + const { value } = await readJsonFile(filePath, { + version: 1, + requests: [], + }); + const reqs = Array.isArray(value.requests) ? value.requests : []; + const idx = reqs.findIndex( + (r) => String(r.code ?? "").toUpperCase() === code, + ); + if (idx < 0) return null; + const entry = reqs[idx]; + if (!entry) return null; + reqs.splice(idx, 1); + await writeJsonFile(filePath, { + version: 1, + requests: reqs, + } satisfies PairingStore); + await addProviderAllowFromStoreEntry({ + provider: params.provider, + entry: entry.id, + env, + }); + return { id: entry.id, entry }; +} diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index 5bf354a53..3bd3ed130 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -6,6 +6,8 @@ const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); let config: Record = {}; +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -23,6 +25,13 @@ vi.mock("./send.js", () => ({ sendMessageSignal: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -47,7 +56,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { config = { messages: { responsePrefix: "PFX" }, - signal: { autoStart: false }, + signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); @@ -56,6 +65,10 @@ beforeEach(() => { streamMock.mockReset(); signalCheckMock.mockReset().mockResolvedValue({}); signalRpcRequestMock.mockReset().mockResolvedValue({}); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); }); describe("monitorSignalProvider tool results", () => { @@ -93,4 +106,42 @@ describe("monitorSignalProvider tool results", () => { expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + + it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { + config = { + ...config, + signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] }, + }; + + streamMock.mockImplementation(async ({ onEvent }) => { + const payload = { + envelope: { + sourceNumber: "+15550001111", + sourceName: "Ada", + timestamp: 1, + dataMessage: { + message: "hello", + }, + }, + }; + await onEvent({ + event: "receive", + data: JSON.stringify(payload), + }); + }); + + await monitorSignalProvider({ + autoStart: false, + baseUrl: "http://127.0.0.1:8080", + }); + + await flush(); + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); }); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 0c3215153..33bbcef65 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -8,6 +8,10 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; import { signalCheck, signalRpcRequest, streamSignalEvents } from "./client.js"; @@ -110,7 +114,7 @@ function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] { } function isAllowedSender(sender: string, allowFrom: string[]): boolean { - if (allowFrom.length === 0) return true; + if (allowFrom.length === 0) return false; if (allowFrom.includes("*")) return true; const normalizedAllow = allowFrom .map((entry) => entry.replace(/^signal:/i, "")) @@ -245,6 +249,7 @@ export async function monitorSignalProvider( const textLimit = resolveTextChunkLimit(cfg, "signal"); const baseUrl = resolveBaseUrl(opts); const account = resolveAccount(opts); + const dmPolicy = cfg.signal?.dmPolicy ?? "pairing"; const allowFrom = resolveAllowFrom(opts); const groupAllowFrom = resolveGroupAllowFrom(opts); const groupPolicy = cfg.signal?.groupPolicy ?? "open"; @@ -317,18 +322,67 @@ export async function monitorSignalProvider( const groupId = dataMessage.groupInfo?.groupId ?? undefined; const groupName = dataMessage.groupInfo?.groupName ?? undefined; const isGroup = Boolean(groupId); + const storeAllowFrom = await readProviderAllowFromStore("signal").catch( + () => [], + ); + const effectiveDmAllow = [...allowFrom, ...storeAllowFrom]; + const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom]; + const dmAllowed = + dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow); + + if (!isGroup) { + if (dmPolicy === "disabled") return; + if (!dmAllowed) { + if (dmPolicy === "pairing") { + const senderId = normalizeE164(sender); + const { code } = await upsertProviderPairingRequest({ + provider: "signal", + id: senderId, + meta: { + name: envelope.sourceName ?? undefined, + }, + }); + logVerbose( + `signal pairing request sender=${senderId} code=${code}`, + ); + try { + await sendMessageSignal( + senderId, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider signal ", + ].join("\n"), + { baseUrl, account, maxBytes: mediaMaxBytes }, + ); + } catch (err) { + logVerbose( + `signal pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } if (isGroup && groupPolicy === "disabled") { logVerbose("Blocked signal group message (groupPolicy: disabled)"); return; } if (isGroup && groupPolicy === "allowlist") { - if (groupAllowFrom.length === 0) { + if (effectiveGroupAllow.length === 0) { logVerbose( "Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)", ); return; } - if (!isAllowedSender(sender, groupAllowFrom)) { + if (!isAllowedSender(sender, effectiveGroupAllow)) { logVerbose( `Blocked signal group sender ${sender} (not in groupAllowFrom)`, ); @@ -337,14 +391,10 @@ export async function monitorSignalProvider( } const commandAuthorized = isGroup - ? groupAllowFrom.length > 0 - ? isAllowedSender(sender, groupAllowFrom) + ? effectiveGroupAllow.length > 0 + ? isAllowedSender(sender, effectiveGroupAllow) : true - : isAllowedSender(sender, allowFrom); - if (!isGroup && !commandAuthorized) { - logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`); - return; - } + : dmAllowed; const messageText = (dataMessage.message ?? "").trim(); let mediaPath: string | undefined; diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 65b52c2cf..cca971567 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -7,6 +7,8 @@ const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); const reactMock = vi.fn(); let config: Record = {}; +const readAllowFromStoreMock = vi.fn(); +const upsertPairingRequestMock = vi.fn(); const getSlackHandlers = () => ( globalThis as { @@ -32,6 +34,13 @@ vi.mock("./send.js", () => ({ sendMessageSlack: (...args: unknown[]) => sendMock(...args), })); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), @@ -89,13 +98,17 @@ beforeEach(() => { ackReaction: "👀", ackReactionScope: "group-mentions", }, - slack: { dm: { enabled: true }, groupDm: { enabled: false } }, + slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); updateLastRouteMock.mockReset(); reactMock.mockReset(); + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true }); }); describe("monitorSlackProvider tool results", () => { @@ -140,8 +153,7 @@ describe("monitorSlackProvider tool results", () => { config = { messages: { responsePrefix: "PFX" }, slack: { - dm: { enabled: true }, - groupDm: { enabled: false }, + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, channels: { C1: { allow: true, requireMention: true } }, }, routing: { @@ -291,4 +303,44 @@ describe("monitorSlackProvider tool results", () => { name: "👀", }); }); + + it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { + config = { + ...config, + slack: { dm: { enabled: true, policy: "pairing", allowFrom: [] } }, + }; + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing code: PAIRCODE", + ); + }); }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 30498ca95..d194f2b2a 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -30,6 +30,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; import type { RuntimeEnv } from "../runtime.js"; import { reactSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; @@ -374,6 +378,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }; const dmConfig = cfg.slack?.dm; + const dmPolicy = dmConfig?.policy ?? "pairing"; const allowFrom = normalizeAllowList(dmConfig?.allowFrom); const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); @@ -578,17 +583,63 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { return; } - if (isDirectMessage && allowFrom.length > 0) { - const permitted = allowListMatches({ - allowList: normalizeAllowListLower(allowFrom), - id: message.user, - }); - if (!permitted) { - logVerbose( - `Blocked unauthorized slack sender ${message.user} (not in allowFrom)`, - ); + const storeAllowFrom = await readProviderAllowFromStore("slack").catch( + () => [], + ); + const effectiveAllowFrom = normalizeAllowList([ + ...allowFrom, + ...storeAllowFrom, + ]); + const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom); + + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + logVerbose("slack: drop dm (dms disabled)"); return; } + if (dmPolicy !== "open") { + const permitted = allowListMatches({ + allowList: effectiveAllowFromLower, + id: message.user, + }); + if (!permitted) { + if (dmPolicy === "pairing") { + const sender = await resolveUserName(message.user); + const senderName = sender?.name ?? undefined; + const { code } = await upsertProviderPairingRequest({ + provider: "slack", + id: message.user, + meta: { name: senderName }, + }); + logVerbose( + `slack pairing request sender=${message.user} name=${senderName ?? "unknown"} code=${code}`, + ); + try { + await sendMessageSlack( + message.channel, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider slack ", + ].join("\n"), + { token: botToken, client: app.client }, + ); + } catch (err) { + logVerbose( + `slack pairing reply failed for ${message.user}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized slack sender ${message.user} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } } const channelConfig = isRoom @@ -606,7 +657,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { matchesMentionPatterns(message.text ?? "", mentionRegexes))); const sender = await resolveUserName(message.user); const senderName = sender?.name ?? message.user; - const allowList = normalizeAllowListLower(allowFrom); + const allowList = effectiveAllowFromLower; const commandAuthorized = allowList.length === 0 || allowListMatches({ @@ -1301,20 +1352,56 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } } - if (isDirectMessage && allowFrom.length > 0) { - const sender = await resolveUserName(command.user_id); - const permitted = allowListMatches({ - allowList: normalizeAllowListLower(allowFrom), - id: command.user_id, - name: sender?.name ?? undefined, - }); - if (!permitted) { + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { await respond({ - text: "You are not authorized to use this command.", + text: "Slack DMs are disabled.", response_type: "ephemeral", }); return; } + if (dmPolicy !== "open") { + const storeAllowFrom = await readProviderAllowFromStore( + "slack", + ).catch(() => []); + const effectiveAllowFrom = normalizeAllowList([ + ...allowFrom, + ...storeAllowFrom, + ]); + const sender = await resolveUserName(command.user_id); + const permitted = allowListMatches({ + allowList: normalizeAllowListLower(effectiveAllowFrom), + id: command.user_id, + name: sender?.name ?? undefined, + }); + if (!permitted) { + if (dmPolicy === "pairing") { + const senderName = sender?.name ?? undefined; + const { code } = await upsertProviderPairingRequest({ + provider: "slack", + id: command.user_id, + meta: { name: senderName }, + }); + await respond({ + text: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider slack ", + ].join("\n"), + response_type: "ephemeral", + }); + } else { + await respond({ + text: "You are not authorized to use this command.", + response_type: "ephemeral", + }); + } + return; + } + } } if (isRoom) { diff --git a/src/telegram/bot.media.test.ts b/src/telegram/bot.media.test.ts index 09ca06c20..068f0fa7c 100644 --- a/src/telegram/bot.media.test.ts +++ b/src/telegram/bot.media.test.ts @@ -35,10 +35,18 @@ vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadConfig: () => ({}), + loadConfig: () => ({ telegram: { dmPolicy: "open", allowFrom: ["*"] } }), }; }); +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), +})); + vi.mock("../auto-reply/reply.js", () => { const replySpy = vi.fn(async (_ctx, opts) => { await opts?.onReplyStart?.(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index b635d3a28..27ba44a1f 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -21,6 +21,21 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted( + () => ({ + readTelegramAllowFromStore: vi.fn(async () => [] as string[]), + upsertTelegramPairingRequest: vi.fn(async () => ({ + code: "PAIRCODE", + created: true, + })), + }), +); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +})); + const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); @@ -73,7 +88,9 @@ vi.mock("../auto-reply/reply.js", () => { describe("createTelegramBot", () => { beforeEach(() => { - loadConfig.mockReturnValue({}); + loadConfig.mockReturnValue({ + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }); loadWebMedia.mockReset(); sendAnimationSpy.mockReset(); sendPhotoSpy.mockReset(); @@ -130,6 +147,46 @@ describe("createTelegramBot", () => { } }); + it("requests pairing by default for unknown DM senders", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ telegram: { dmPolicy: "pairing" } }); + readTelegramAllowFromStore.mockResolvedValue([]); + upsertTelegramPairingRequest.mockResolvedValue({ + code: "PAIRME12", + created: true, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain( + "Pairing code:", + ); + expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12"); + }); + it("triggers typing cue via onReplyStart", async () => { onSpy.mockReset(); sendChatActionSpy.mockReset(); @@ -397,7 +454,10 @@ describe("createTelegramBot", () => { await opts?.onToolResult?.({ text: "tool result" }); return { text: "final reply" }; }); - loadConfig.mockReturnValue({ messages: { responsePrefix: "PFX" } }); + loadConfig.mockReturnValue({ + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + messages: { responsePrefix: "PFX" }, + }); createTelegramBot({ token: "tok" }); const handler = onSpy.mock.calls[0][1] as ( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index f6b7317f5..b7e4ff5b8 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -35,6 +35,10 @@ import { } from "../providers/location.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; +import { + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +} from "./pairing-store.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; @@ -111,6 +115,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "telegram"); + const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; const groupAllowFrom = opts.groupAllowFrom ?? @@ -150,8 +155,6 @@ export function createTelegramBot(opts: TelegramBotOptions) { (entry) => entry === username || entry === `@${username}`, ); }; - const dmAllow = normalizeAllowFrom(allowFrom); - const groupAllow = normalizeAllowFrom(groupAllowFrom); const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; @@ -177,10 +180,19 @@ export function createTelegramBot(opts: TelegramBotOptions) { const processMessage = async ( primaryCtx: TelegramContext, allMedia: Array<{ path: string; contentType?: string }>, + storeAllowFrom: string[], ) => { const msg = primaryCtx.message; const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const effectiveDmAllow = normalizeAllowFrom([ + ...(allowFrom ?? []), + ...storeAllowFrom, + ]); + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowFrom ?? []), + ...storeAllowFrom, + ]); const sendTyping = async () => { try { @@ -192,14 +204,70 @@ export function createTelegramBot(opts: TelegramBotOptions) { } }; - // allowFrom for direct chats - if (!isGroup && dmAllow.hasEntries) { - const candidate = String(chatId); - if (!isSenderAllowed({ allow: dmAllow, senderId: candidate })) { - logVerbose( - `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, - ); - return; + // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" + if (!isGroup) { + if (dmPolicy === "disabled") return; + + if (dmPolicy !== "open") { + const candidate = String(chatId); + const senderUsername = msg.from?.username ?? ""; + const allowed = + effectiveDmAllow.hasWildcard || + (effectiveDmAllow.hasEntries && + isSenderAllowed({ + allow: effectiveDmAllow, + senderId: candidate, + senderUsername, + })); + if (!allowed) { + if (dmPolicy === "pairing") { + try { + const from = msg.from as + | { + first_name?: string; + last_name?: string; + username?: string; + } + | undefined; + const { code } = await upsertTelegramPairingRequest({ + chatId: candidate, + username: from?.username, + firstName: from?.first_name, + lastName: from?.last_name, + }); + logger.info( + { + chatId: candidate, + username: from?.username, + firstName: from?.first_name, + lastName: from?.last_name, + code, + }, + "telegram pairing request", + ); + await bot.api.sendMessage( + chatId, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot telegram pairing approve ", + ].join("\n"), + ); + } catch (err) { + logVerbose( + `telegram pairing reply failed for chat ${chatId}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy})`, + ); + } + return; + } } } @@ -207,7 +275,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderUsername = msg.from?.username ?? ""; const commandAuthorized = isSenderAllowed({ - allow: isGroup ? groupAllow : dmAllow, + allow: isGroup ? effectiveGroupAllow : effectiveDmAllow, senderId, senderUsername, }); @@ -407,6 +475,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); if (isGroup) { // Group policy filtering: controls how group messages are handled @@ -419,6 +488,10 @@ export function createTelegramBot(opts: TelegramBotOptions) { return; } if (groupPolicy === "allowlist") { + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowFrom ?? []), + ...storeAllowFrom, + ]); // For allowlist mode, the sender (msg.from.id) must be in allowFrom const senderId = msg.from?.id; if (senderId == null) { @@ -427,7 +500,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { ); return; } - if (!groupAllow.hasEntries) { + if (!effectiveGroupAllow.hasEntries) { logVerbose( "Blocked telegram group message (groupPolicy: allowlist, no groupAllowFrom)", ); @@ -436,7 +509,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const senderUsername = msg.from?.username ?? ""; if ( !isSenderAllowed({ - allow: groupAllow, + allow: effectiveGroupAllow, senderId: String(senderId), senderUsername, }) @@ -510,7 +583,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const allMedia = media ? [{ path: media.path, contentType: media.contentType }] : []; - await processMessage(ctx, allMedia); + await processMessage(ctx, allMedia, storeAllowFrom); } catch (err) { runtime.error?.(danger(`handler failed: ${String(err)}`)); } @@ -538,7 +611,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { } } - await processMessage(primaryEntry.ctx, allMedia); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom); } catch (err) { runtime.error?.(danger(`media group handler failed: ${String(err)}`)); } diff --git a/src/telegram/pairing-store.test.ts b/src/telegram/pairing-store.test.ts new file mode 100644 index 000000000..ce5e04b29 --- /dev/null +++ b/src/telegram/pairing-store.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + approveTelegramPairingCode, + listTelegramPairingRequests, + readTelegramAllowFromStore, + upsertTelegramPairingRequest, +} from "./pairing-store.js"; + +async function withTempStateDir(fn: (stateDir: string) => Promise) { + const previous = process.env.CLAWDBOT_STATE_DIR; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-pairing-")); + process.env.CLAWDBOT_STATE_DIR = dir; + try { + return await fn(dir); + } finally { + if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previous; + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("telegram pairing store", () => { + it("creates pairing request and approves it into allow store", async () => { + await withTempStateDir(async () => { + const created = await upsertTelegramPairingRequest({ + chatId: "123456789", + username: "ada", + }); + expect(created.code).toBeTruthy(); + + const list = await listTelegramPairingRequests(); + expect(list).toHaveLength(1); + expect(list[0]?.chatId).toBe("123456789"); + expect(list[0]?.code).toBe(created.code); + + const approved = await approveTelegramPairingCode({ code: created.code }); + expect(approved?.chatId).toBe("123456789"); + + const listAfter = await listTelegramPairingRequests(); + expect(listAfter).toHaveLength(0); + + const allow = await readTelegramAllowFromStore(); + expect(allow).toContain("123456789"); + }); + }); +}); diff --git a/src/telegram/pairing-store.ts b/src/telegram/pairing-store.ts new file mode 100644 index 000000000..7d8c6e542 --- /dev/null +++ b/src/telegram/pairing-store.ts @@ -0,0 +1,122 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + addProviderAllowFromStoreEntry, + approveProviderPairingCode, + listProviderPairingRequests, + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; + +export type TelegramPairingListEntry = { + chatId: string; + username?: string; + firstName?: string; + lastName?: string; + code: string; + createdAt: string; + lastSeenAt: string; +}; + +const PROVIDER = "telegram" as const; + +export async function readTelegramAllowFromStore( + env: NodeJS.ProcessEnv = process.env, +): Promise { + return readProviderAllowFromStore(PROVIDER, env); +} + +export async function addTelegramAllowFromStoreEntry(params: { + entry: string | number; + env?: NodeJS.ProcessEnv; +}): Promise<{ changed: boolean; allowFrom: string[] }> { + return addProviderAllowFromStoreEntry({ + provider: PROVIDER, + entry: params.entry, + env: params.env, + }); +} + +export async function listTelegramPairingRequests( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const list = await listProviderPairingRequests(PROVIDER, env); + return list.map((r) => ({ + chatId: r.id, + code: r.code, + createdAt: r.createdAt, + lastSeenAt: r.lastSeenAt, + username: r.meta?.username, + firstName: r.meta?.firstName, + lastName: r.meta?.lastName, + })); +} + +export async function upsertTelegramPairingRequest(params: { + chatId: string | number; + username?: string; + firstName?: string; + lastName?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ code: string; created: boolean }> { + return upsertProviderPairingRequest({ + provider: PROVIDER, + id: String(params.chatId), + env: params.env, + meta: { + username: params.username, + firstName: params.firstName, + lastName: params.lastName, + }, + }); +} + +export async function approveTelegramPairingCode(params: { + code: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ chatId: string; entry?: TelegramPairingListEntry } | null> { + const res = await approveProviderPairingCode({ + provider: PROVIDER, + code: params.code, + env: params.env, + }); + if (!res) return null; + const entry = res.entry + ? { + chatId: res.entry.id, + code: res.entry.code, + createdAt: res.entry.createdAt, + lastSeenAt: res.entry.lastSeenAt, + username: res.entry.meta?.username, + firstName: res.entry.meta?.firstName, + lastName: res.entry.meta?.lastName, + } + : undefined; + return { chatId: res.id, entry }; +} + +export async function resolveTelegramEffectiveAllowFrom(params: { + cfg: ClawdbotConfig; + env?: NodeJS.ProcessEnv; +}): Promise<{ dm: string[]; group: string[] }> { + const env = params.env ?? process.env; + const cfgAllowFrom = (params.cfg.telegram?.allowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter((v) => v !== "*"); + const cfgGroupAllowFrom = (params.cfg.telegram?.groupAllowFrom ?? []) + .map((v) => String(v).trim()) + .filter(Boolean) + .map((v) => v.replace(/^(telegram|tg):/i, "")) + .filter((v) => v !== "*"); + const storeAllowFrom = await readTelegramAllowFromStore(env); + + const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom])); + const group = Array.from( + new Set([ + ...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom), + ...storeAllowFrom, + ]), + ); + return { dm, group }; +} diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index fb4fd78ec..ee4ef47e0 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -5,6 +5,11 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); +const upsertPairingRequestMock = vi + .fn() + .mockResolvedValue({ code: "PAIRCODE", created: true }); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -21,6 +26,13 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + const HOME = path.join( os.tmpdir(), `clawdbot-inbound-media-${crypto.randomUUID()}`, diff --git a/src/web/inbound.ts b/src/web/inbound.ts index ef46b4b36..04c445bf3 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -16,6 +16,10 @@ import { loadConfig } from "../config/config.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + readProviderAllowFromStore, + upsertProviderPairingRequest, +} from "../pairing/pairing-store.js"; import { formatLocationText, type NormalizedLocation, @@ -168,16 +172,14 @@ export async function monitorWebInbox(options: { // Filter unauthorized senders early to prevent wasted processing // and potential session corruption from Bad MAC errors const cfg = loadConfig(); + const dmPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; const configuredAllowFrom = cfg.whatsapp?.allowFrom; - // Without user config, default to self-only DM access so the owner can talk to themselves - const defaultAllowFrom = - (!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164 - ? [selfE164] - : undefined; - const allowFrom = - configuredAllowFrom && configuredAllowFrom.length > 0 - ? configuredAllowFrom - : defaultAllowFrom; + const storeAllowFrom = await readProviderAllowFromStore("whatsapp").catch( + () => [], + ); + const allowFrom = Array.from( + new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), + ); const groupAllowFrom = cfg.whatsapp?.groupAllowFrom ?? (configuredAllowFrom && configuredAllowFrom.length > 0 @@ -227,17 +229,54 @@ export async function monitorWebInbox(options: { } } - // DM allowlist filtering (unchanged behavior) - const allowlistEnabled = - !group && Array.isArray(allowFrom) && allowFrom.length > 0; - if (!isSamePhone && allowlistEnabled) { - const candidate = from; - if (!dmHasWildcard && !normalizedAllowFrom.includes(candidate)) { - logVerbose( - `Blocked unauthorized sender ${candidate} (not in allowFrom list)`, - ); + // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled" + if (!group) { + if (dmPolicy === "disabled") { + logVerbose("Blocked dm (dmPolicy: disabled)"); continue; } + if (dmPolicy !== "open" && !isSamePhone) { + const candidate = from; + const allowed = + dmHasWildcard || + (normalizedAllowFrom.length > 0 && + normalizedAllowFrom.includes(candidate)); + if (!allowed) { + if (dmPolicy === "pairing") { + const { code } = await upsertProviderPairingRequest({ + provider: "whatsapp", + id: candidate, + meta: { + name: (msg.pushName ?? "").trim() || undefined, + }, + }); + logVerbose( + `whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"} code=${code}`, + ); + try { + await sock.sendMessage(remoteJid, { + text: [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve --provider whatsapp ", + ].join("\n"), + }); + } catch (err) { + logVerbose( + `whatsapp pairing reply failed for ${candidate}: ${String(err)}`, + ); + } + } else { + logVerbose( + `Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`, + ); + } + continue; + } + } } if (id && !isSelfChat) { diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 2f23c3c52..bd9946b4b 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -19,6 +19,11 @@ const mockLoadConfig = vi.fn().mockReturnValue({ }, }); +const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); +const upsertPairingRequestMock = vi + .fn() + .mockResolvedValue({ code: "PAIRCODE", created: true }); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -27,6 +32,13 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../pairing/pairing-store.js", () => ({ + readProviderAllowFromStore: (...args: unknown[]) => + readAllowFromStoreMock(...args), + upsertProviderPairingRequest: (...args: unknown[]) => + upsertPairingRequestMock(...args), +})); + vi.mock("./session.js", () => { const { EventEmitter } = require("node:events"); const ev = new EventEmitter(); @@ -64,6 +76,11 @@ import { monitorWebInbox } from "./inbound.js"; describe("web monitor inbox", () => { beforeEach(() => { vi.clearAllMocks(); + readAllowFromStoreMock.mockResolvedValue([]); + upsertPairingRequestMock.mockResolvedValue({ + code: "PAIRCODE", + created: true, + }); }); afterEach(() => { @@ -564,6 +581,10 @@ describe("web monitor inbox", () => { expect(onMessage).not.toHaveBeenCalled(); // Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn). expect(sock.readMessages).not.toHaveBeenCalled(); + expect(sock.sendMessage).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Pairing code: PAIRCODE"), + }); // Reset mock for other tests mockLoadConfig.mockReturnValue({ @@ -980,105 +1001,118 @@ describe("web monitor inbox", () => { await listener.close(); }); -}); -it("defaults to self-only when no config is present", async () => { - // No config file => allowFrom should be derived from selfE164 - mockLoadConfig.mockReturnValue({}); - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = await createWaSocket(); + it("locks down when no config is present (pairing for unknown senders)", async () => { + // No config file => locked-down defaults apply (pairing for unknown senders) + mockLoadConfig.mockReturnValue({}); - // Message from someone else should be blocked - const upsertBlocked = { - type: "notify", - messages: [ - { - key: { - id: "no-config-1", - fromMe: false, - remoteJid: "999@s.whatsapp.net", + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + // Message from someone else should be blocked + const upsertBlocked = { + type: "notify", + messages: [ + { + key: { + id: "no-config-1", + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, }, - message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, - }, - ], - }; + ], + }; - sock.ev.emit("messages.upsert", upsertBlocked); - await new Promise((resolve) => setImmediate(resolve)); - expect(onMessage).not.toHaveBeenCalled(); + sock.ev.emit("messages.upsert", upsertBlocked); + await new Promise((resolve) => setImmediate(resolve)); + expect(onMessage).not.toHaveBeenCalled(); + expect(sock.sendMessage).toHaveBeenCalledTimes(1); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: expect.stringContaining("Pairing code: PAIRCODE"), + }); - // Message from self should be allowed - const upsertSelf = { - type: "notify", - messages: [ - { - key: { - id: "no-config-2", - fromMe: false, - remoteJid: "123@s.whatsapp.net", + // Message from self should be allowed + const upsertSelf = { + type: "notify", + messages: [ + { + key: { + id: "no-config-2", + fromMe: false, + remoteJid: "123@s.whatsapp.net", + }, + message: { conversation: "self ping" }, + messageTimestamp: 1_700_000_001, }, - message: { conversation: "self ping" }, - messageTimestamp: 1_700_000_001, + ], + }; + + sock.ev.emit("messages.upsert", upsertSelf); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: "self ping", + from: "+123", + to: "+123", + }), + ); + + // Reset mock for other tests + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["*"], }, - ], - }; + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, + }); - sock.ev.emit("messages.upsert", upsertSelf); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ body: "self ping", from: "+123", to: "+123" }), - ); - - // Reset mock for other tests - mockLoadConfig.mockReturnValue({ - whatsapp: { - allowFrom: ["*"], - }, - messages: { - messagePrefix: undefined, - responsePrefix: undefined, - }, + await listener.close(); }); - await listener.close(); -}); + it("handles append messages by marking them read but skipping auto-reply", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); -it("handles append messages by marking them read but skipping auto-reply", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = await createWaSocket(); + const upsert = { + type: "append", + messages: [ + { + key: { + id: "history1", + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "old message" }, + messageTimestamp: 1_700_000_000, + pushName: "History Sender", + }, + ], + }; - const upsert = { - type: "append", - messages: [ + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Verify it WAS marked as read + expect(sock.readMessages).toHaveBeenCalledWith([ { - key: { id: "history1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "old message" }, - messageTimestamp: 1_700_000_000, - pushName: "History Sender", + remoteJid: "999@s.whatsapp.net", + id: "history1", + participant: undefined, + fromMe: false, }, - ], - }; + ]); - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); + // Verify it WAS NOT passed to onMessage + expect(onMessage).not.toHaveBeenCalled(); - // Verify it WAS marked as read - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "999@s.whatsapp.net", - id: "history1", - participant: undefined, - fromMe: false, - }, - ]); - - // Verify it WAS NOT passed to onMessage - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); + await listener.close(); + }); });