fix: harden pairing flow
This commit is contained in:
@@ -17,6 +17,8 @@
|
|||||||
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
||||||
|
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
|
||||||
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
||||||
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
|
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
|
||||||
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ Controls how WhatsApp direct chats (DMs) are handled:
|
|||||||
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
|
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
|
||||||
- `"disabled"`: ignore all inbound DMs
|
- `"disabled"`: ignore all inbound DMs
|
||||||
|
|
||||||
|
Pairing codes expire after 1 hour; the bot only sends a pairing code when a new request is created.
|
||||||
|
|
||||||
Pairing approvals:
|
Pairing approvals:
|
||||||
- `clawdbot pairing list --provider whatsapp`
|
- `clawdbot pairing list --provider whatsapp`
|
||||||
- `clawdbot pairing approve --provider whatsapp <code>`
|
- `clawdbot pairing approve --provider whatsapp <code>`
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Clawdbot’s stance:
|
|||||||
|
|
||||||
All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed:
|
All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed:
|
||||||
|
|
||||||
- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved.
|
- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved. Codes expire after 1 hour; repeated DMs won’t resend a code until a new request is created.
|
||||||
- `allowlist`: unknown senders are blocked (no pairing handshake).
|
- `allowlist`: unknown senders are blocked (no pairing handshake).
|
||||||
- `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in).
|
- `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in).
|
||||||
- `disabled`: ignore inbound DMs entirely.
|
- `disabled`: ignore inbound DMs entirely.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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`.
|
- 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.
|
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.
|
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||||
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>`.
|
7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); 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 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 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"`.
|
- To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Example:
|
|||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `imessage.dmPolicy = "pairing"`.
|
- Default: `imessage.dmPolicy = "pairing"`.
|
||||||
- Unknown senders receive a pairing code; messages are ignored until approved.
|
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||||
- Approve via:
|
- Approve via:
|
||||||
- `clawdbot pairing list --provider imessage`
|
- `clawdbot pairing list --provider imessage`
|
||||||
- `clawdbot pairing approve --provider imessage <CODE>`
|
- `clawdbot pairing approve --provider imessage <CODE>`
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Example:
|
|||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `signal.dmPolicy = "pairing"`.
|
- Default: `signal.dmPolicy = "pairing"`.
|
||||||
- Unknown senders receive a pairing code; messages are ignored until approved.
|
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||||
- Approve via:
|
- Approve via:
|
||||||
- `clawdbot pairing list --provider signal`
|
- `clawdbot pairing list --provider signal`
|
||||||
- `clawdbot pairing approve --provider signal <CODE>`
|
- `clawdbot pairing approve --provider signal <CODE>`
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||||
|
|
||||||
## DM security (pairing)
|
## DM security (pairing)
|
||||||
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code.
|
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||||
- Approve via: `clawdbot pairing approve --provider slack <code>`.
|
- Approve via: `clawdbot pairing approve --provider slack <code>`.
|
||||||
- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`.
|
- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
|||||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
- Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved.
|
- Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
|
||||||
- Approve via:
|
- Approve via:
|
||||||
- `clawdbot pairing list --provider telegram`
|
- `clawdbot pairing list --provider telegram`
|
||||||
- `clawdbot pairing approve --provider telegram <CODE>`
|
- `clawdbot pairing approve --provider telegram <CODE>`
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
|||||||
- Status/broadcast chats are ignored.
|
- Status/broadcast chats are ignored.
|
||||||
- Direct chats use E.164; groups use group JID.
|
- Direct chats use E.164; groups use group JID.
|
||||||
- **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
|
- **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>`).
|
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp <code>`; codes expire after 1 hour).
|
||||||
- Open: requires `whatsapp.allowFrom` to include `"*"`.
|
- Open: requires `whatsapp.allowFrom` to include `"*"`.
|
||||||
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
|
- 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`).
|
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ When a provider is configured with DM policy `pairing`, unknown senders get a sh
|
|||||||
|
|
||||||
Default DM policies are documented in: [Security](/gateway/security)
|
Default DM policies are documented in: [Security](/gateway/security)
|
||||||
|
|
||||||
|
Pairing codes:
|
||||||
|
- 8 characters, uppercase, no ambiguous chars (`0O1I`).
|
||||||
|
- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender).
|
||||||
|
|
||||||
### Approve a sender
|
### Approve a sender
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
if (!permitted) {
|
if (!permitted) {
|
||||||
commandAuthorized = false;
|
commandAuthorized = false;
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
id: author.id,
|
id: author.id,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -420,26 +420,28 @@ export function createDiscordMessageHandler(params: {
|
|||||||
name: author.username ?? undefined,
|
name: author.username ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(
|
if (created) {
|
||||||
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} code=${code}`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await sendMessageDiscord(
|
|
||||||
`user:${author.id}`,
|
|
||||||
[
|
|
||||||
"Clawdbot: access not configured.",
|
|
||||||
"",
|
|
||||||
`Pairing code: ${code}`,
|
|
||||||
"",
|
|
||||||
"Ask the bot owner to approve with:",
|
|
||||||
"clawdbot pairing approve --provider discord <code>",
|
|
||||||
].join("\n"),
|
|
||||||
{ token, rest: client.rest },
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord pairing reply failed for ${author.id}: ${String(err)}`,
|
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)}`,
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
await sendMessageDiscord(
|
||||||
|
`user:${author.id}`,
|
||||||
|
[
|
||||||
|
"Clawdbot: access not configured.",
|
||||||
|
"",
|
||||||
|
`Pairing code: ${code}`,
|
||||||
|
"",
|
||||||
|
"Ask the bot owner to approve with:",
|
||||||
|
"clawdbot pairing approve --provider discord <code>",
|
||||||
|
].join("\n"),
|
||||||
|
{ token, rest: client.rest },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(
|
||||||
|
`discord pairing reply failed for ${author.id}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@@ -1107,7 +1109,7 @@ function createDiscordNativeCommand(params: {
|
|||||||
if (!permitted) {
|
if (!permitted) {
|
||||||
commandAuthorized = false;
|
commandAuthorized = false;
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
id: user.id,
|
id: user.id,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -1115,17 +1117,19 @@ function createDiscordNativeCommand(params: {
|
|||||||
name: user.username ?? undefined,
|
name: user.username ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await interaction.reply({
|
if (created) {
|
||||||
content: [
|
await interaction.reply({
|
||||||
"Clawdbot: access not configured.",
|
content: [
|
||||||
"",
|
"Clawdbot: access not configured.",
|
||||||
`Pairing code: ${code}`,
|
"",
|
||||||
"",
|
`Pairing code: ${code}`,
|
||||||
"Ask the bot owner to approve with:",
|
"",
|
||||||
"clawdbot pairing approve --provider discord <code>",
|
"Ask the bot owner to approve with:",
|
||||||
].join("\n"),
|
"clawdbot pairing approve --provider discord <code>",
|
||||||
ephemeral: true,
|
].join("\n"),
|
||||||
});
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: "You are not authorized to use this command.",
|
content: "You are not authorized to use this command.",
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export async function monitorIMessageProvider(
|
|||||||
if (!dmAuthorized) {
|
if (!dmAuthorized) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const senderId = normalizeIMessageHandle(sender);
|
const senderId = normalizeIMessageHandle(sender);
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -238,30 +238,30 @@ export async function monitorIMessageProvider(
|
|||||||
chatId: chatId ? String(chatId) : undefined,
|
chatId: chatId ? String(chatId) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(
|
if (created) {
|
||||||
`imessage pairing request sender=${senderId} code=${code}`,
|
logVerbose(`imessage pairing request sender=${senderId}`);
|
||||||
);
|
try {
|
||||||
try {
|
await sendMessageIMessage(
|
||||||
await sendMessageIMessage(
|
sender,
|
||||||
sender,
|
[
|
||||||
[
|
"Clawdbot: access not configured.",
|
||||||
"Clawdbot: access not configured.",
|
"",
|
||||||
"",
|
`Pairing code: ${code}`,
|
||||||
`Pairing code: ${code}`,
|
"",
|
||||||
"",
|
"Ask the bot owner to approve with:",
|
||||||
"Ask the bot owner to approve with:",
|
"clawdbot pairing approve --provider imessage <code>",
|
||||||
"clawdbot pairing approve --provider imessage <code>",
|
].join("\n"),
|
||||||
].join("\n"),
|
{
|
||||||
{
|
client,
|
||||||
client,
|
maxBytes: mediaMaxBytes,
|
||||||
maxBytes: mediaMaxBytes,
|
...(chatId ? { chatId } : {}),
|
||||||
...(chatId ? { chatId } : {}),
|
},
|
||||||
},
|
);
|
||||||
);
|
} catch (err) {
|
||||||
} catch (err) {
|
logVerbose(
|
||||||
logVerbose(
|
`imessage pairing reply failed for ${senderId}: ${String(err)}`,
|
||||||
`imessage pairing reply failed for ${senderId}: ${String(err)}`,
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|||||||
109
src/pairing/pairing-store.test.ts
Normal file
109
src/pairing/pairing-store.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resolveOAuthDir } from "../config/paths.js";
|
||||||
|
import {
|
||||||
|
listProviderPairingRequests,
|
||||||
|
upsertProviderPairingRequest,
|
||||||
|
} 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("pairing store", () => {
|
||||||
|
it("reuses pending code and reports created=false", async () => {
|
||||||
|
await withTempStateDir(async () => {
|
||||||
|
const first = await upsertProviderPairingRequest({
|
||||||
|
provider: "discord",
|
||||||
|
id: "u1",
|
||||||
|
});
|
||||||
|
const second = await upsertProviderPairingRequest({
|
||||||
|
provider: "discord",
|
||||||
|
id: "u1",
|
||||||
|
});
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.code).toBe(first.code);
|
||||||
|
|
||||||
|
const list = await listProviderPairingRequests("discord");
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0]?.code).toBe(first.code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires pending requests after TTL", async () => {
|
||||||
|
await withTempStateDir(async (stateDir) => {
|
||||||
|
const created = await upsertProviderPairingRequest({
|
||||||
|
provider: "signal",
|
||||||
|
id: "+15550001111",
|
||||||
|
});
|
||||||
|
expect(created.created).toBe(true);
|
||||||
|
|
||||||
|
const oauthDir = resolveOAuthDir(process.env, stateDir);
|
||||||
|
const filePath = path.join(oauthDir, "signal-pairing.json");
|
||||||
|
const raw = await fs.readFile(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
requests?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||||
|
const requests = (parsed.requests ?? []).map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
createdAt: expiredAt,
|
||||||
|
lastSeenAt: expiredAt,
|
||||||
|
}));
|
||||||
|
await fs.writeFile(
|
||||||
|
filePath,
|
||||||
|
`${JSON.stringify({ version: 1, requests }, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await listProviderPairingRequests("signal");
|
||||||
|
expect(list).toHaveLength(0);
|
||||||
|
|
||||||
|
const next = await upsertProviderPairingRequest({
|
||||||
|
provider: "signal",
|
||||||
|
id: "+15550001111",
|
||||||
|
});
|
||||||
|
expect(next.created).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regenerates when a generated code collides", async () => {
|
||||||
|
await withTempStateDir(async () => {
|
||||||
|
const spy = vi.spyOn(crypto, "randomInt");
|
||||||
|
try {
|
||||||
|
spy.mockReturnValue(0);
|
||||||
|
const first = await upsertProviderPairingRequest({
|
||||||
|
provider: "telegram",
|
||||||
|
id: "123",
|
||||||
|
});
|
||||||
|
expect(first.code).toBe("AAAAAAAA");
|
||||||
|
|
||||||
|
const sequence = Array(8).fill(0).concat(Array(8).fill(1));
|
||||||
|
let idx = 0;
|
||||||
|
spy.mockImplementation(() => sequence[idx++] ?? 1);
|
||||||
|
const second = await upsertProviderPairingRequest({
|
||||||
|
provider: "telegram",
|
||||||
|
id: "456",
|
||||||
|
});
|
||||||
|
expect(second.code).toBe("BBBBBBBB");
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,26 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import lockfile from "proper-lockfile";
|
||||||
|
|
||||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||||
|
|
||||||
|
const PAIRING_CODE_LENGTH = 8;
|
||||||
|
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
|
||||||
|
const PAIRING_STORE_LOCK_OPTIONS = {
|
||||||
|
retries: {
|
||||||
|
retries: 10,
|
||||||
|
factor: 2,
|
||||||
|
minTimeout: 100,
|
||||||
|
maxTimeout: 10_000,
|
||||||
|
randomize: true,
|
||||||
|
},
|
||||||
|
stale: 30_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type PairingProvider =
|
export type PairingProvider =
|
||||||
| "telegram"
|
| "telegram"
|
||||||
| "signal"
|
| "signal"
|
||||||
@@ -74,24 +91,92 @@ async function readJsonFile<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
const dir = path.dirname(filePath);
|
||||||
await fs.promises.writeFile(
|
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
filePath,
|
const tmp = path.join(
|
||||||
`${JSON.stringify(value, null, 2)}\n`,
|
dir,
|
||||||
"utf-8",
|
`${path.basename(filePath)}.${crypto.randomUUID()}.tmp`,
|
||||||
);
|
);
|
||||||
|
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
await fs.promises.chmod(tmp, 0o600);
|
||||||
|
await fs.promises.rename(tmp, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureJsonFile(filePath: string, fallback: unknown) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath);
|
||||||
|
} catch {
|
||||||
|
await writeJsonFile(filePath, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFileLock<T>(
|
||||||
|
filePath: string,
|
||||||
|
fallback: unknown,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
await ensureJsonFile(filePath, fallback);
|
||||||
|
let release: (() => Promise<void>) | undefined;
|
||||||
|
try {
|
||||||
|
release = await lockfile.lock(filePath, PAIRING_STORE_LOCK_OPTIONS);
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
await release();
|
||||||
|
} catch {
|
||||||
|
// ignore unlock errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestamp(value: string | undefined): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (!Number.isFinite(parsed)) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(entry: PairingRequest, nowMs: number): boolean {
|
||||||
|
const createdAt = parseTimestamp(entry.createdAt);
|
||||||
|
if (!createdAt) return true;
|
||||||
|
return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
|
||||||
|
const kept: PairingRequest[] = [];
|
||||||
|
let removed = false;
|
||||||
|
for (const req of reqs) {
|
||||||
|
if (isExpired(req, nowMs)) {
|
||||||
|
removed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(req);
|
||||||
|
}
|
||||||
|
return { requests: kept, removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomCode(): string {
|
function randomCode(): string {
|
||||||
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
|
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
let out = "";
|
let out = "";
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
|
||||||
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length);
|
||||||
|
out += PAIRING_CODE_ALPHABET[idx];
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateUniqueCode(existing: Set<string>): string {
|
||||||
|
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||||
|
const code = randomCode();
|
||||||
|
if (!existing.has(code)) return code;
|
||||||
|
}
|
||||||
|
throw new Error("failed to generate unique pairing code");
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeId(value: string | number): string {
|
function normalizeId(value: string | number): string {
|
||||||
return String(value).trim();
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
@@ -129,26 +214,32 @@ export async function addProviderAllowFromStoreEntry(params: {
|
|||||||
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const filePath = resolveAllowFromPath(params.provider, env);
|
const filePath = resolveAllowFromPath(params.provider, env);
|
||||||
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
return await withFileLock(
|
||||||
version: 1,
|
filePath,
|
||||||
allowFrom: [],
|
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
|
||||||
});
|
async () => {
|
||||||
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
|
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
||||||
.map((v) => normalizeAllowEntry(params.provider, String(v)))
|
version: 1,
|
||||||
.filter(Boolean);
|
allowFrom: [],
|
||||||
const normalized = normalizeAllowEntry(
|
});
|
||||||
params.provider,
|
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
|
||||||
normalizeId(params.entry),
|
.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 };
|
||||||
|
},
|
||||||
);
|
);
|
||||||
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(
|
export async function listProviderPairingRequests(
|
||||||
@@ -156,21 +247,35 @@ export async function listProviderPairingRequests(
|
|||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): Promise<PairingRequest[]> {
|
): Promise<PairingRequest[]> {
|
||||||
const filePath = resolvePairingPath(provider, env);
|
const filePath = resolvePairingPath(provider, env);
|
||||||
const { value } = await readJsonFile<PairingStore>(filePath, {
|
return await withFileLock(
|
||||||
version: 1,
|
filePath,
|
||||||
requests: [],
|
{ version: 1, requests: [] } satisfies PairingStore,
|
||||||
});
|
async () => {
|
||||||
const reqs = Array.isArray(value.requests) ? value.requests : [];
|
const { value } = await readJsonFile<PairingStore>(filePath, {
|
||||||
return reqs
|
version: 1,
|
||||||
.filter(
|
requests: [],
|
||||||
(r) =>
|
});
|
||||||
r &&
|
const reqs = Array.isArray(value.requests) ? value.requests : [];
|
||||||
typeof r.id === "string" &&
|
const nowMs = Date.now();
|
||||||
typeof r.code === "string" &&
|
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
|
||||||
typeof r.createdAt === "string",
|
if (removed) {
|
||||||
)
|
await writeJsonFile(filePath, {
|
||||||
.slice()
|
version: 1,
|
||||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
requests: pruned,
|
||||||
|
} satisfies PairingStore);
|
||||||
|
}
|
||||||
|
return pruned
|
||||||
|
.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: {
|
export async function upsertProviderPairingRequest(params: {
|
||||||
@@ -181,56 +286,75 @@ export async function upsertProviderPairingRequest(params: {
|
|||||||
}): Promise<{ code: string; created: boolean }> {
|
}): Promise<{ code: string; created: boolean }> {
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const filePath = resolvePairingPath(params.provider, env);
|
const filePath = resolvePairingPath(params.provider, env);
|
||||||
const { value } = await readJsonFile<PairingStore>(filePath, {
|
return await withFileLock(
|
||||||
version: 1,
|
filePath,
|
||||||
requests: [],
|
{ version: 1, requests: [] } satisfies PairingStore,
|
||||||
});
|
async () => {
|
||||||
const now = new Date().toISOString();
|
const { value } = await readJsonFile<PairingStore>(filePath, {
|
||||||
const id = normalizeId(params.id);
|
version: 1,
|
||||||
const meta =
|
requests: [],
|
||||||
params.meta && typeof params.meta === "object"
|
});
|
||||||
? Object.fromEntries(
|
const now = new Date().toISOString();
|
||||||
Object.entries(params.meta)
|
const nowMs = Date.now();
|
||||||
.map(([k, v]) => [k, String(v ?? "").trim()] as const)
|
const id = normalizeId(params.id);
|
||||||
.filter(([_, v]) => Boolean(v)),
|
const meta =
|
||||||
)
|
params.meta && typeof params.meta === "object"
|
||||||
: undefined;
|
? 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 : [];
|
let reqs = Array.isArray(value.requests) ? value.requests : [];
|
||||||
const existingIdx = reqs.findIndex((r) => r.id === id);
|
const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
|
||||||
if (existingIdx >= 0) {
|
reqs = pruned;
|
||||||
const existing = reqs[existingIdx];
|
const existingIdx = reqs.findIndex((r) => r.id === id);
|
||||||
const existingCode =
|
const existingCodes = new Set(
|
||||||
existing && typeof existing.code === "string" ? existing.code.trim() : "";
|
reqs.map((req) =>
|
||||||
const code = existingCode || randomCode();
|
String(req.code ?? "")
|
||||||
const next: PairingRequest = {
|
.trim()
|
||||||
id,
|
.toUpperCase(),
|
||||||
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();
|
if (existingIdx >= 0) {
|
||||||
const next: PairingRequest = {
|
const existing = reqs[existingIdx];
|
||||||
id,
|
const existingCode =
|
||||||
code,
|
existing && typeof existing.code === "string"
|
||||||
createdAt: now,
|
? existing.code.trim()
|
||||||
lastSeenAt: now,
|
: "";
|
||||||
...(meta ? { meta } : {}),
|
const code = existingCode || generateUniqueCode(existingCodes);
|
||||||
};
|
const next: PairingRequest = {
|
||||||
await writeJsonFile(filePath, {
|
id,
|
||||||
version: 1,
|
code,
|
||||||
requests: [...reqs, next],
|
createdAt: existing?.createdAt ?? now,
|
||||||
} satisfies PairingStore);
|
lastSeenAt: now,
|
||||||
return { code, created: true };
|
meta: meta ?? existing?.meta,
|
||||||
|
};
|
||||||
|
reqs[existingIdx] = next;
|
||||||
|
await writeJsonFile(filePath, {
|
||||||
|
version: 1,
|
||||||
|
requests: reqs,
|
||||||
|
} satisfies PairingStore);
|
||||||
|
return { code, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateUniqueCode(existingCodes);
|
||||||
|
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: {
|
export async function approveProviderPairingCode(params: {
|
||||||
@@ -243,26 +367,42 @@ export async function approveProviderPairingCode(params: {
|
|||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
|
|
||||||
const filePath = resolvePairingPath(params.provider, env);
|
const filePath = resolvePairingPath(params.provider, env);
|
||||||
const { value } = await readJsonFile<PairingStore>(filePath, {
|
return await withFileLock(
|
||||||
version: 1,
|
filePath,
|
||||||
requests: [],
|
{ version: 1, requests: [] } satisfies PairingStore,
|
||||||
});
|
async () => {
|
||||||
const reqs = Array.isArray(value.requests) ? value.requests : [];
|
const { value } = await readJsonFile<PairingStore>(filePath, {
|
||||||
const idx = reqs.findIndex(
|
version: 1,
|
||||||
(r) => String(r.code ?? "").toUpperCase() === code,
|
requests: [],
|
||||||
|
});
|
||||||
|
const reqs = Array.isArray(value.requests) ? value.requests : [];
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
|
||||||
|
const idx = pruned.findIndex(
|
||||||
|
(r) => String(r.code ?? "").toUpperCase() === code,
|
||||||
|
);
|
||||||
|
if (idx < 0) {
|
||||||
|
if (removed) {
|
||||||
|
await writeJsonFile(filePath, {
|
||||||
|
version: 1,
|
||||||
|
requests: pruned,
|
||||||
|
} satisfies PairingStore);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = pruned[idx];
|
||||||
|
if (!entry) return null;
|
||||||
|
pruned.splice(idx, 1);
|
||||||
|
await writeJsonFile(filePath, {
|
||||||
|
version: 1,
|
||||||
|
requests: pruned,
|
||||||
|
} satisfies PairingStore);
|
||||||
|
await addProviderAllowFromStoreEntry({
|
||||||
|
provider: params.provider,
|
||||||
|
entry: entry.id,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return { id: entry.id, entry };
|
||||||
|
},
|
||||||
);
|
);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,4 +144,47 @@ describe("monitorSignalProvider tool results", () => {
|
|||||||
"Pairing code: PAIRCODE",
|
"Pairing code: PAIRCODE",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not resend pairing code when a request is already pending", async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] },
|
||||||
|
};
|
||||||
|
upsertPairingRequestMock
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||||
|
|
||||||
|
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 onEvent({
|
||||||
|
event: "receive",
|
||||||
|
data: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
envelope: { ...payload.envelope, timestamp: 2 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await monitorSignalProvider({
|
||||||
|
autoStart: false,
|
||||||
|
baseUrl: "http://127.0.0.1:8080",
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,33 +336,33 @@ export async function monitorSignalProvider(
|
|||||||
if (!dmAllowed) {
|
if (!dmAllowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const senderId = normalizeE164(sender);
|
const senderId = normalizeE164(sender);
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "signal",
|
provider: "signal",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: {
|
meta: {
|
||||||
name: envelope.sourceName ?? undefined,
|
name: envelope.sourceName ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(
|
if (created) {
|
||||||
`signal pairing request sender=${senderId} code=${code}`,
|
logVerbose(`signal pairing request sender=${senderId}`);
|
||||||
);
|
try {
|
||||||
try {
|
await sendMessageSignal(
|
||||||
await sendMessageSignal(
|
senderId,
|
||||||
senderId,
|
[
|
||||||
[
|
"Clawdbot: access not configured.",
|
||||||
"Clawdbot: access not configured.",
|
"",
|
||||||
"",
|
`Pairing code: ${code}`,
|
||||||
`Pairing code: ${code}`,
|
"",
|
||||||
"",
|
"Ask the bot owner to approve with:",
|
||||||
"Ask the bot owner to approve with:",
|
"clawdbot pairing approve --provider signal <code>",
|
||||||
"clawdbot pairing approve --provider signal <code>",
|
].join("\n"),
|
||||||
].join("\n"),
|
{ baseUrl, account, maxBytes: mediaMaxBytes },
|
||||||
{ baseUrl, account, maxBytes: mediaMaxBytes },
|
);
|
||||||
);
|
} catch (err) {
|
||||||
} catch (err) {
|
logVerbose(
|
||||||
logVerbose(
|
`signal pairing reply failed for ${senderId}: ${String(err)}`,
|
||||||
`signal pairing reply failed for ${senderId}: ${String(err)}`,
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|||||||
@@ -399,4 +399,43 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
"Pairing code: PAIRCODE",
|
"Pairing code: PAIRCODE",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not resend pairing code when a request is already pending", async () => {
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
slack: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
|
||||||
|
};
|
||||||
|
upsertPairingRequestMock
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
type: "message",
|
||||||
|
user: "U1",
|
||||||
|
text: "hello",
|
||||||
|
ts: "123",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "im",
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler({ event: baseEvent });
|
||||||
|
await handler({ event: { ...baseEvent, ts: "124", text: "hello again" } });
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
controller.abort();
|
||||||
|
await run;
|
||||||
|
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -653,31 +653,33 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const sender = await resolveUserName(message.user);
|
const sender = await resolveUserName(message.user);
|
||||||
const senderName = sender?.name ?? undefined;
|
const senderName = sender?.name ?? undefined;
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
id: message.user,
|
id: message.user,
|
||||||
meta: { name: senderName },
|
meta: { name: senderName },
|
||||||
});
|
});
|
||||||
logVerbose(
|
if (created) {
|
||||||
`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(
|
logVerbose(
|
||||||
`slack pairing reply failed for ${message.user}: ${String(err)}`,
|
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
|
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 {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@@ -1468,22 +1470,24 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
});
|
});
|
||||||
if (!permitted) {
|
if (!permitted) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
id: command.user_id,
|
id: command.user_id,
|
||||||
meta: { name: senderName },
|
meta: { name: senderName },
|
||||||
});
|
});
|
||||||
await respond({
|
if (created) {
|
||||||
text: [
|
await respond({
|
||||||
"Clawdbot: access not configured.",
|
text: [
|
||||||
"",
|
"Clawdbot: access not configured.",
|
||||||
`Pairing code: ${code}`,
|
"",
|
||||||
"",
|
`Pairing code: ${code}`,
|
||||||
"Ask the bot owner to approve with:",
|
"",
|
||||||
"clawdbot pairing approve --provider slack <code>",
|
"Ask the bot owner to approve with:",
|
||||||
].join("\n"),
|
"clawdbot pairing approve --provider slack <code>",
|
||||||
response_type: "ephemeral",
|
].join("\n"),
|
||||||
});
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await respond({
|
await respond({
|
||||||
text: "You are not authorized to use this command.",
|
text: "You are not authorized to use this command.",
|
||||||
|
|||||||
@@ -193,6 +193,47 @@ describe("createTelegramBot", () => {
|
|||||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12");
|
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not resend pairing code when a request is already pending", 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
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRME12", created: true })
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRME12", created: false });
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = onSpy.mock.calls[0][1] as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
text: "hello",
|
||||||
|
date: 1736380800,
|
||||||
|
from: { id: 999, username: "random" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
message,
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
await handler({
|
||||||
|
message: { ...message, text: "hello again" },
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("triggers typing cue via onReplyStart", async () => {
|
it("triggers typing cue via onReplyStart", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
sendChatActionSpy.mockReset();
|
sendChatActionSpy.mockReset();
|
||||||
|
|||||||
@@ -247,33 +247,34 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
const { code } = await upsertTelegramPairingRequest({
|
const { code, created } = await upsertTelegramPairingRequest({
|
||||||
chatId: candidate,
|
chatId: candidate,
|
||||||
username: from?.username,
|
username: from?.username,
|
||||||
firstName: from?.first_name,
|
firstName: from?.first_name,
|
||||||
lastName: from?.last_name,
|
lastName: from?.last_name,
|
||||||
});
|
});
|
||||||
logger.info(
|
if (created) {
|
||||||
{
|
logger.info(
|
||||||
chatId: candidate,
|
{
|
||||||
username: from?.username,
|
chatId: candidate,
|
||||||
firstName: from?.first_name,
|
username: from?.username,
|
||||||
lastName: from?.last_name,
|
firstName: from?.first_name,
|
||||||
code,
|
lastName: from?.last_name,
|
||||||
},
|
},
|
||||||
"telegram pairing request",
|
"telegram pairing request",
|
||||||
);
|
);
|
||||||
await bot.api.sendMessage(
|
await bot.api.sendMessage(
|
||||||
chatId,
|
chatId,
|
||||||
[
|
[
|
||||||
"Clawdbot: access not configured.",
|
"Clawdbot: access not configured.",
|
||||||
"",
|
"",
|
||||||
`Pairing code: ${code}`,
|
`Pairing code: ${code}`,
|
||||||
"",
|
"",
|
||||||
"Ask the bot owner to approve with:",
|
"Ask the bot owner to approve with:",
|
||||||
"clawdbot telegram pairing approve <code>",
|
"clawdbot telegram pairing approve <code>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram pairing reply failed for chat ${chatId}: ${String(err)}`,
|
`telegram pairing reply failed for chat ${chatId}: ${String(err)}`,
|
||||||
|
|||||||
@@ -258,31 +258,33 @@ export async function monitorWebInbox(options: {
|
|||||||
normalizedAllowFrom.includes(candidate));
|
normalizedAllowFrom.includes(candidate));
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
if (dmPolicy === "pairing") {
|
if (dmPolicy === "pairing") {
|
||||||
const { code } = await upsertProviderPairingRequest({
|
const { code, created } = await upsertProviderPairingRequest({
|
||||||
provider: "whatsapp",
|
provider: "whatsapp",
|
||||||
id: candidate,
|
id: candidate,
|
||||||
meta: {
|
meta: {
|
||||||
name: (msg.pushName ?? "").trim() || undefined,
|
name: (msg.pushName ?? "").trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(
|
if (created) {
|
||||||
`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(
|
logVerbose(
|
||||||
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
|
`whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
|
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 {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|||||||
@@ -1005,6 +1005,9 @@ describe("web monitor inbox", () => {
|
|||||||
it("locks down when no config is present (pairing for unknown senders)", async () => {
|
it("locks down when no config is present (pairing for unknown senders)", async () => {
|
||||||
// No config file => locked-down defaults apply (pairing for unknown senders)
|
// No config file => locked-down defaults apply (pairing for unknown senders)
|
||||||
mockLoadConfig.mockReturnValue({});
|
mockLoadConfig.mockReturnValue({});
|
||||||
|
upsertPairingRequestMock
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
|
||||||
|
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||||
|
|
||||||
const onMessage = vi.fn();
|
const onMessage = vi.fn();
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||||
@@ -1034,6 +1037,26 @@ describe("web monitor inbox", () => {
|
|||||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const upsertBlockedAgain = {
|
||||||
|
type: "notify",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: {
|
||||||
|
id: "no-config-1b",
|
||||||
|
fromMe: false,
|
||||||
|
remoteJid: "999@s.whatsapp.net",
|
||||||
|
},
|
||||||
|
message: { conversation: "ping again" },
|
||||||
|
messageTimestamp: 1_700_000_002,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.ev.emit("messages.upsert", upsertBlockedAgain);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(onMessage).not.toHaveBeenCalled();
|
||||||
|
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Message from self should be allowed
|
// Message from self should be allowed
|
||||||
const upsertSelf = {
|
const upsertSelf = {
|
||||||
type: "notify",
|
type: "notify",
|
||||||
|
|||||||
Reference in New Issue
Block a user