fix(security): lock down inbound DMs by default
This commit is contained in:
@@ -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 <provider>` + `clawdbot pairing approve --provider <provider> <code>` (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.
|
||||
|
||||
|
||||
@@ -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 <code>`
|
||||
|
||||
### `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,
|
||||
|
||||
@@ -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:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
||||
6. Guild channels: use `channel:<channelId>` 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 <code>`.
|
||||
- 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.
|
||||
|
||||
@@ -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:<chatId>`; 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
|
||||
|
||||
@@ -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:<id>` entries.
|
||||
- Default: `imessage.dmPolicy="pairing"` — unknown DM senders get a pairing code (approve via `clawdbot pairing approve --provider imessage <code>`). `"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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <code>`.
|
||||
|
||||
## “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).
|
||||
|
||||
@@ -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:<channelId>` sessions.
|
||||
- Slash commands use `slack:slash:<userId>` sessions.
|
||||
|
||||
## DM security (pairing)
|
||||
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code.
|
||||
- Approve via: `clawdbot pairing approve --provider slack <code>`.
|
||||
- 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`.
|
||||
|
||||
@@ -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:<chatId>`. 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 <code>` (alias: `clawdbot telegram pairing approve <code>`).
|
||||
- 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.<chatId>.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,
|
||||
|
||||
@@ -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 <code>`).
|
||||
- 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).
|
||||
|
||||
122
src/cli/pairing-cli.ts
Normal file
122
src/cli/pairing-cli.ts
Normal file
@@ -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>",
|
||||
`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>",
|
||||
`Provider (${PROVIDERS.join(", ")})`,
|
||||
)
|
||||
.argument("<code>", "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)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
74
src/cli/telegram-cli.ts
Normal file
74
src/cli/telegram-cli.ts
Normal file
@@ -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("<code>", "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)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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<string | number> | 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 <code>`,
|
||||
);
|
||||
}
|
||||
|
||||
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 <code>",
|
||||
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 <code>",
|
||||
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 <code>",
|
||||
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 <code>",
|
||||
});
|
||||
}
|
||||
|
||||
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 <code>",
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -101,6 +101,12 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
|
||||
@@ -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<string, WhatsAppAccountConfig>;
|
||||
/** 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<string | number>;
|
||||
/** 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<string | number>;
|
||||
/** 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<string | number>;
|
||||
/** Optional allowlist for Signal group senders (E.164). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
@@ -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<string | number>;
|
||||
/** Optional allowlist for group senders or chat_id targets. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -6,6 +6,8 @@ const sendMock = vi.fn();
|
||||
const replyMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
let config: Record<string, unknown> = {};
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <code>",
|
||||
].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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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();
|
||||
|
||||
@@ -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 <code>",
|
||||
].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,
|
||||
|
||||
268
src/pairing/pairing-store.ts
Normal file
268
src/pairing/pairing-store.ts
Normal file
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(
|
||||
filePath: string,
|
||||
fallback: T,
|
||||
): Promise<{ value: T; exists: boolean }> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, "utf-8");
|
||||
const parsed = safeParseJson<T>(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<void> {
|
||||
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<string[]> {
|
||||
const filePath = resolveAllowFromPath(provider, env);
|
||||
const { value } = await readJsonFile<AllowFromStore>(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<AllowFromStore>(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<PairingRequest[]> {
|
||||
const filePath = resolvePairingPath(provider, env);
|
||||
const { value } = await readJsonFile<PairingStore>(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<string, string | undefined | null>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ code: string; created: boolean }> {
|
||||
const env = params.env ?? process.env;
|
||||
const filePath = resolvePairingPath(params.provider, env);
|
||||
const { value } = await readJsonFile<PairingStore>(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<PairingStore>(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 };
|
||||
}
|
||||
@@ -6,6 +6,8 @@ const sendMock = vi.fn();
|
||||
const replyMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
let config: Record<string, unknown> = {};
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <code>",
|
||||
].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;
|
||||
|
||||
@@ -7,6 +7,8 @@ const replyMock = vi.fn();
|
||||
const updateLastRouteMock = vi.fn();
|
||||
const reactMock = vi.fn();
|
||||
let config: Record<string, unknown> = {};
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <code>",
|
||||
].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 <code>",
|
||||
].join("\n"),
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
} else {
|
||||
await respond({
|
||||
text: "You are not authorized to use this command.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoom) {
|
||||
|
||||
@@ -35,10 +35,18 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
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?.();
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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 <code>",
|
||||
].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)}`));
|
||||
}
|
||||
|
||||
51
src/telegram/pairing-store.test.ts
Normal file
51
src/telegram/pairing-store.test.ts
Normal file
@@ -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<T>(fn: (stateDir: string) => Promise<T>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
122
src/telegram/pairing-store.ts
Normal file
122
src/telegram/pairing-store.ts
Normal file
@@ -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<string[]> {
|
||||
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<TelegramPairingListEntry[]> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<typeof import("../config/config.js")>();
|
||||
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()}`,
|
||||
|
||||
@@ -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 <code>",
|
||||
].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) {
|
||||
|
||||
@@ -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<typeof import("../config/config.js")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user