Compare commits

..
Author SHA1 Message Date
Peter Steinberger afefcdf8fb fix: improve Perplexity web_search defaults (#1131) (thanks @CMLKevin) 2026-01-18 01:15:28 +00:00
Kevin Lin 9fc3926607 feat(web): add Perplexity Sonar as alternative search provider 2026-01-18 00:57:32 +00:00
Peter Steinbergerandthewilloftheshadow 22c7f659f6 fix: surface match metadata in audits and slack logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:50:36 +00:00
Peter Steinberger 79a44d0da4 refactor(channels): unify target parsing 2026-01-18 00:31:42 +00:00
Peter Steinberger d593a809f0 fix: apply openai batch defaults 2026-01-18 00:29:18 +00:00
Peter Steinberger 22add31e91 docs: update changelog for sessions_spawn thinking 2026-01-18 00:17:28 +00:00
Peter SteinbergerandNicholas Spisak b44d740720 refactor: centralize cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-18 00:16:01 +00:00
Peter Steinberger 4d590f9254 refactor(slack): centralize target parsing 2026-01-18 00:15:05 +00:00
Peter Steinbergerandthewilloftheshadow a5aa48beea feat: add dm allowlist match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:14:44 +00:00
Peter Steinberger 1bf3861ca4 feat: add thinking override to sessions_spawn 2026-01-18 00:14:18 +00:00
joshrad-dev f8052be369 docs: add docs for Copilot device flow 2026-01-18 00:06:04 +00:00
Peter SteinbergerandJonathan Rhyne a08438ae97 refactor(discord): centralize target parsing
Co-authored-by: Jonathan Rhyne <jonathan@pspdfkit.com>
2026-01-18 00:04:38 +00:00
Peter Steinbergerandthewilloftheshadow fe00d6aacf feat: add matrix room match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:00:00 +00:00
Peter Steinbergerandthewilloftheshadow 984692cda2 refactor: reuse channel config resolver in matrix extension
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:53:05 +00:00
Peter Steinbergerandthewilloftheshadow 4c12c4fc04 feat: add channel match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:48:45 +00:00
70 changed files with 1785 additions and 414 deletions

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
Docs: https://docs.clawd.bot
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Tools: add Perplexity Sonar provider for `web_search` with direct Perplexity/OpenRouter support. (#1131) — thanks @CMLKevin.
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
## 2026.1.17-3
### Changes
+1
View File
@@ -175,6 +175,7 @@ Notes:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread id explicitly.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
+1
View File
@@ -216,6 +216,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
+55
View File
@@ -0,0 +1,55 @@
---
summary: "Perplexity Sonar setup for web_search"
read_when:
- You want to use Perplexity Sonar for web search
- You need PERPLEXITY_API_KEY or OpenRouter setup
---
# Perplexity Sonar
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
through Perplexity’s direct API or via OpenRouter.
## API options
### Perplexity (direct)
- Base URL: https://api.perplexity.ai
- Environment variable: `PERPLEXITY_API_KEY`
### OpenRouter (alternative)
- Base URL: https://openrouter.ai/api/v1
- Environment variable: `OPENROUTER_API_KEY`
- Supports prepaid/crypto credits.
## Config example
```json5
{
tools: {
web: {
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
}
}
}
}
}
```
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate.
## Models
- `perplexity/sonar` — fast Q&A with web search
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
- `perplexity/sonar-reasoning-pro` — deep research
See [Web tools](/tools/web) for the full web_search configuration.
+49
View File
@@ -0,0 +1,49 @@
---
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
read_when:
- You want to use GitHub Copilot as a model provider
- You need the `clawdbot models auth login-github-copilot` flow
---
# Github Copilot
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.
## CLI setup
```bash
clawdbot models auth login-github-copilot
```
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
### Optional flags
```bash
clawdbot models auth login-github-copilot --profile-id github-copilot:work
clawdbot models auth login-github-copilot --yes
```
## Set a default model
```bash
clawdbot models set github-copilot/gpt-4o
```
### Config snippet
```json5
{
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
}
```
## Notes
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Clawdbot runs.
+1
View File
@@ -26,6 +26,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Github Copilot](/providers/github-copilot)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
+1
View File
@@ -27,6 +27,7 @@ Tool params:
- `label?` (optional)
- `agentId?` (optional; spawn under another agent id if allowed)
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
- `thinking?` (optional; overrides thinking level for the sub-agent run)
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
- `cleanup?` (`delete|keep`, default `keep`)
+91 -6
View File
@@ -1,15 +1,16 @@
---
summary: "Web search + fetch tools (Brave Search API)"
summary: "Web search + fetch tools (Brave Search API, Perplexity Sonar)"
read_when:
- You want to enable web_search or web_fetch
- You need Brave Search API key setup
- You want to use Perplexity Sonar for web search
---
# Web tools
Clawdbot ships two lightweight web tools:
- `web_search` — Brave Search API queries (fast, structured results).
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter).
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
@@ -17,13 +18,37 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
## How it works
- `web_search` calls Brave’s Search API and returns structured results
(title, URL, snippet). No browser is involved.
- `web_search` calls your configured provider and returns results.
- **Brave** (default): returns structured results (title, URL, snippet).
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
- Results are cached by query for 15 minutes (configurable).
- `web_fetch` does a plain HTTP GET and extracts readable content
(HTML → markdown/text). It does **not** execute JavaScript.
- `web_fetch` is enabled by default (unless explicitly disabled).
## Choosing a search provider
Provider docs: [Brave Search API](https://brave.com/search/api/) and [Perplexity Sonar](/perplexity).
| Provider | Pros | Cons | API Key |
|----------|------|------|---------|
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter credits | `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` |
Set the provider in config:
```json5
{
tools: {
web: {
search: {
provider: "brave" // or "perplexity"
}
}
}
}
```
## Getting a Brave API key
1) Create a Brave Search API account at https://brave.com/search/api/
@@ -42,14 +67,74 @@ current limits and pricing.
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
## Using Perplexity Sonar
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
answers with citations. You can use them either directly via Perplexity’s API or via OpenRouter
(supports crypto/prepaid).
See [Perplexity Sonar](/perplexity) for a dedicated setup guide.
### Getting an API key
**Perplexity (direct):**
1) Create a Perplexity account and generate an API key.
2) Set `PERPLEXITY_API_KEY` in the Gateway environment or store the key in config.
**OpenRouter (alternative):**
1) Create an account at https://openrouter.ai/
2) Add credits (supports crypto, prepaid, or credit card)
3) Generate an API key in your account settings
### Setting up Perplexity search
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "perplexity",
perplexity: {
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
apiKey: "pplx-... or sk-or-v1-...",
// Base URL:
// - https://api.perplexity.ai (default when PERPLEXITY_API_KEY is set)
// - https://openrouter.ai/api/v1 (default when only OPENROUTER_API_KEY is set)
baseUrl: "https://api.perplexity.ai",
// Model (defaults to perplexity/sonar-pro)
model: "perplexity/sonar-pro"
}
}
}
}
}
```
**Environment alternative:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` in the Gateway
environment. For a daemon install, put it in `~/.clawdbot/.env`. If you set both keys, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`) to disambiguate.
### Available Perplexity models
| Model | Description | Best for |
|-------|-------------|----------|
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
## web_search
Search the web with Brave’s API.
Search the web using your configured provider.
### Requirements
- `tools.web.search.enabled` must not be `false` (default: enabled)
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY` (direct), `OPENROUTER_API_KEY` (OpenRouter), or `tools.web.search.perplexity.apiKey`
### Config
@@ -10,23 +10,49 @@ function normalizeMatrixUser(raw?: string | null): string {
return (raw ?? "").trim().toLowerCase();
}
export type MatrixAllowListMatch = {
allowed: boolean;
matchKey?: string;
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart";
};
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
userName?: string;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) return { allowed: false };
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const userName = normalizeMatrixUser(params.userName);
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
{ value: userName, source: "name" },
{ value: localPart, source: "localpart" },
];
for (const candidate of candidates) {
if (!candidate.value) continue;
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: {
allowList: string[];
userId?: string;
userName?: string;
}) {
const allowList = params.allowList;
if (allowList.length === 0) return false;
if (allowList.includes("*")) return true;
const userId = normalizeMatrixUser(params.userId);
const userName = normalizeMatrixUser(params.userName);
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
const candidates = [
userId,
userId ? `matrix:${userId}` : "",
userId ? `user:${userId}` : "",
userName,
localPart,
].filter(Boolean);
return candidates.some((value) => allowList.includes(value));
return resolveMatrixAllowListMatch(params).allowed;
}
+34 -13
View File
@@ -41,7 +41,11 @@ import {
parsePollStartContent,
} from "../poll-types.js";
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
import { resolveMatrixAllowListMatches, normalizeAllowListLower } from "./allowlist.js";
import {
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
normalizeAllowListLower,
} from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
import { downloadMatrixMedia } from "./media.js";
@@ -184,18 +188,21 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
aliases: roomAliases,
name: roomName,
});
const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`;
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
logVerbose(`matrix: room disabled room=${roomId}`);
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) {
logVerbose("matrix: drop room message (no allowlist)");
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfigInfo.config) {
logVerbose("matrix: drop room message (not in allowlist)");
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
@@ -207,14 +214,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") return;
if (dmPolicy !== "open") {
const permitted =
effectiveAllowFrom.length > 0 &&
resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
if (!permitted) {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "matrix",
@@ -222,6 +230,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
meta: { name: senderName },
});
if (created) {
logVerbose(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
@@ -240,6 +251,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
}
}
if (dmPolicy !== "pairing") {
logVerbose(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return;
}
}
@@ -252,10 +268,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userName: senderName,
});
if (!userAllowed) {
logVerbose(`matrix: blocked sender ${senderId} (room users allowlist)`);
logVerbose(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta})`,
);
return;
}
}
if (isRoom) {
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody = content.body.trim();
let media: {
+24 -14
View File
@@ -1,9 +1,15 @@
import type { MatrixConfig, MatrixRoomConfig } from "../../types.js";
import {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
} from "../../../../../src/channels/plugins/channel-config.js";
export type MatrixRoomConfigResolved = {
allowed: boolean;
allowlistConfigured: boolean;
config?: MatrixRoomConfig;
matchKey?: string;
matchSource?: "direct" | "wildcard";
};
export function resolveMatrixRoomConfig(params: {
@@ -15,22 +21,26 @@ export function resolveMatrixRoomConfig(params: {
const rooms = params.rooms ?? {};
const keys = Object.keys(rooms);
const allowlistConfigured = keys.length > 0;
const candidates = [
const candidates = buildChannelKeyCandidates(
params.roomId,
`room:${params.roomId}`,
...params.aliases,
params.name ?? "",
].filter(Boolean);
let matched: MatrixRoomConfigResolved["config"] | undefined;
for (const candidate of candidates) {
if (rooms[candidate]) {
matched = rooms[candidate];
break;
}
}
if (!matched && rooms["*"]) {
matched = rooms["*"];
}
const allowed = matched ? matched.enabled !== false && matched.allow !== false : false;
return { allowed, allowlistConfigured, config: matched };
);
const { entry: matched, key: matchedKey, wildcardEntry, wildcardKey } = resolveChannelEntryMatch({
entries: rooms,
keys: candidates,
wildcardKey: "*",
});
const resolved = matched ?? wildcardEntry;
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
const matchKey = matchedKey ?? wildcardKey;
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
return {
allowed,
allowlistConfigured,
config: resolved,
matchKey,
matchSource,
};
}
+12
View File
@@ -89,6 +89,18 @@ describe("msteams policy", () => {
});
});
it("inherits team mention settings when channel config is missing", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: { requireMention: true },
teamConfig: { requireMention: false },
});
expect(policy).toEqual({
requireMention: false,
replyStyle: "top-level",
});
});
it("uses explicit replyStyle even when requireMention defaults would differ", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
@@ -54,6 +54,7 @@ describe("sessions tools", () => {
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number");
expect(schemaProp("sessions_list", "messageLimit").type).toBe("number");
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "thinking").type).toBe("string");
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number");
});
@@ -92,6 +92,68 @@ describe("clawdbot-tools: subagents", () => {
model: "claude-haiku-4-5",
});
});
it("sessions_spawn forwards thinking overrides to the agent run", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "run-thinking", status: "accepted" };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call-thinking", {
task: "do thing",
thinking: "high",
});
expect(result.details).toMatchObject({
status: "accepted",
});
const agentCall = calls.find((call) => call.method === "agent");
expect(agentCall?.params).toMatchObject({
thinking: "high",
});
});
it("sessions_spawn rejects invalid thinking levels", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
calls.push(request);
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call-thinking-invalid", {
task: "do thing",
thinking: "banana",
});
expect(result.details).toMatchObject({
status: "error",
});
expect(String(result.details?.error)).toMatch(/Invalid thinking level/i);
expect(calls).toHaveLength(0);
});
it("sessions_spawn applies default subagent model from defaults config", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
+33
View File
@@ -67,6 +67,39 @@ describe("memory search config", () => {
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
});
it("includes batch defaults for openai without remote overrides", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote?.batch).toEqual({
enabled: true,
wait: true,
pollIntervalMs: 5000,
timeoutMinutes: 60,
});
});
it("keeps remote unset for local provider without overrides", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
provider: "local",
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote).toBeUndefined();
});
it("merges remote defaults with agent overrides", () => {
const cfg = {
agents: {
+2 -1
View File
@@ -95,6 +95,7 @@ function mergeConfig(
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
const includeRemote = hasRemote || provider === "openai";
const batch = {
enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
@@ -107,7 +108,7 @@ function mergeConfig(
defaults?.remote?.batch?.timeoutMinutes ??
60,
};
const remote = hasRemote
const remote = includeRemote
? {
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
+1 -1
View File
@@ -168,7 +168,7 @@ export function buildAgentSystemPrompt(params: {
ls: "List directory contents",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_search: "Search the web (Brave or Perplexity)",
web_fetch: "Fetch and extract readable content from a URL",
// Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser",
+1 -1
View File
@@ -266,7 +266,7 @@
"sessions_spawn": {
"emoji": "🧑‍🔧",
"title": "Sub-agent",
"detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"]
"detailKeys": ["label", "agentId", "thinking", "runTimeoutSeconds", "cleanup"]
},
"session_status": {
"emoji": "📊",
+18 -33
View File
@@ -28,6 +28,7 @@ import {
readStringParam,
} from "./common.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { resolveDiscordChannelId } from "../../discord/targets.js";
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
@@ -51,6 +52,12 @@ export async function handleDiscordMessagingAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveDiscordChannelId(
readStringParam(params, "channelId", {
required: true,
}),
);
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
@@ -63,9 +70,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -87,9 +92,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -145,9 +148,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("permissions")) {
throw new Error("Discord permissions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const permissions = await fetchChannelPermissionsDiscord(channelId);
return jsonResult({ ok: true, permissions });
}
@@ -183,9 +184,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messages = await readMessagesDiscord(channelId, {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
@@ -223,9 +222,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("messages")) {
throw new Error("Discord message edits are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -241,9 +238,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("messages")) {
throw new Error("Discord message deletes are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -254,9 +249,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
@@ -299,9 +292,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const content = readStringParam(params, "content", {
required: true,
});
@@ -317,9 +308,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -330,9 +319,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -343,9 +330,7 @@ export async function handleDiscordMessagingAction(
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
+26
View File
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import { Type } from "@sinclair/typebox";
import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thinking.js";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
@@ -29,12 +30,22 @@ const SessionsSpawnToolSchema = Type.Object({
label: Type.Optional(Type.String()),
agentId: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
cleanup: optionalStringEnum(["delete", "keep"] as const),
});
function splitModelRef(ref?: string) {
if (!ref) return { provider: undefined, model: undefined };
const trimmed = ref.trim();
if (!trimmed) return { provider: undefined, model: undefined };
const [provider, model] = trimmed.split("/", 2);
if (model) return { provider, model };
return { provider: undefined, model: trimmed };
}
function normalizeModelSelection(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
@@ -64,6 +75,7 @@ export function createSessionsSpawnTool(opts?: {
const label = typeof params.label === "string" ? params.label.trim() : "";
const requestedAgentId = readStringParam(params, "agentId");
const modelOverride = readStringParam(params, "model");
const thinkingOverrideRaw = readStringParam(params, "thinking");
const cleanup =
params.cleanup === "keep" || params.cleanup === "delete"
? (params.cleanup as "keep" | "delete")
@@ -143,6 +155,19 @@ export function createSessionsSpawnTool(opts?: {
normalizeModelSelection(modelOverride) ??
normalizeModelSelection(targetAgentConfig?.subagents?.model) ??
normalizeModelSelection(cfg.agents?.defaults?.subagents?.model);
let thinkingOverride: string | undefined;
if (thinkingOverrideRaw) {
const normalized = normalizeThinkLevel(thinkingOverrideRaw);
if (!normalized) {
const { provider, model } = splitModelRef(resolvedModel);
const hint = formatThinkingLevels(provider, model);
return jsonResult({
status: "error",
error: `Invalid thinking level "${thinkingOverrideRaw}". Use one of: ${hint}.`,
});
}
thinkingOverride = normalized;
}
if (resolvedModel) {
try {
await callGateway({
@@ -187,6 +212,7 @@ export function createSessionsSpawnTool(opts?: {
deliver: false,
lane: AGENT_LANE_SUBAGENT,
extraSystemPrompt: childSystemPrompt,
thinking: thinkingOverride,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
+14
View File
@@ -48,6 +48,20 @@ describe("handleSlackAction", () => {
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
});
it("strips channel: prefix for channelId params", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await handleSlackAction(
{
action: "react",
channelId: "channel:C1",
messageId: "123.456",
emoji: "✅",
},
cfg,
);
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
});
it("removes reactions on empty emoji", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
await handleSlackAction(
+17 -17
View File
@@ -17,6 +17,7 @@ import {
sendSlackMessage,
unpinSlackMessage,
} from "../../slack/actions.js";
import { parseSlackTarget, resolveSlackChannelId } from "../../slack/targets.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
@@ -52,10 +53,9 @@ function resolveThreadTsFromContext(
// No context or missing required fields
if (!context?.currentThreadTs || !context?.currentChannelId) return undefined;
// Normalize target (strip "channel:" prefix if present)
const normalizedTarget = targetChannel.startsWith("channel:")
? targetChannel.slice("channel:".length)
: targetChannel;
const parsedTarget = parseSlackTarget(targetChannel, { defaultKind: "channel" });
if (!parsedTarget || parsedTarget.kind !== "channel") return undefined;
const normalizedTarget = parsedTarget.id;
// Different channel - don't inject
if (normalizedTarget !== context.currentChannelId) return undefined;
@@ -76,6 +76,12 @@ export async function handleSlackAction(
cfg: ClawdbotConfig,
context?: SlackActionContext,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveSlackChannelId(
readStringParam(params, "channelId", {
required: true,
}),
);
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const account = resolveSlackAccount({ cfg, accountId });
@@ -109,7 +115,7 @@ export async function handleSlackAction(
if (!isActionEnabled("reactions")) {
throw new Error("Slack reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", { required: true });
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", { required: true });
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
@@ -166,8 +172,8 @@ export async function handleSlackAction(
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && context.currentChannelId) {
const normalizedTarget = to.startsWith("channel:") ? to.slice("channel:".length) : to;
if (normalizedTarget === context.currentChannelId) {
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
context.hasRepliedRef.value = true;
}
}
@@ -175,9 +181,7 @@ export async function handleSlackAction(
return jsonResult({ ok: true, result });
}
case "editMessage": {
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -192,9 +196,7 @@ export async function handleSlackAction(
return jsonResult({ ok: true });
}
case "deleteMessage": {
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -206,9 +208,7 @@ export async function handleSlackAction(
return jsonResult({ ok: true });
}
case "readMessages": {
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channelId = resolveChannelId();
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
@@ -237,7 +237,7 @@ export async function handleSlackAction(
if (!isActionEnabled("pins")) {
throw new Error("Slack pins are disabled.");
}
const channelId = readStringParam(params, "channelId", { required: true });
const channelId = resolveChannelId();
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
@@ -89,3 +89,102 @@ describe("web_search country and language parameters", () => {
expect(url.searchParams.get("ui_lang")).toBe("de");
});
});
describe("web_search Perplexity provider configuration", () => {
const priorFetch = global.fetch;
beforeEach(() => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
});
afterEach(() => {
vi.unstubAllEnvs();
// @ts-expect-error global fetch cleanup
global.fetch = priorFetch;
});
it("defaults to Perplexity base URL when PERPLEXITY_API_KEY is set", async () => {
const mockFetch = vi.fn((_input: RequestInfo, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "ok" } }],
citations: [],
}),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit?];
expect(url).toBe("https://api.perplexity.ai/chat/completions");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer pplx-test");
});
it("uses OpenRouter base URL when configured, even with PERPLEXITY_API_KEY set", async () => {
const mockFetch = vi.fn((_input: RequestInfo, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "ok" } }],
citations: [],
}),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: { provider: "perplexity", perplexity: { baseUrl: "https://openrouter.ai/api/v1" } },
},
},
},
sandboxed: true,
});
await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit?];
expect(url).toBe("https://openrouter.ai/api/v1/chat/completions");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer sk-or-test");
});
it("returns missing key when OpenRouter base URL is configured without OPENROUTER_API_KEY", async () => {
vi.stubEnv("OPENROUTER_API_KEY", "");
const mockFetch = vi.fn();
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: { provider: "perplexity", perplexity: { baseUrl: "https://openrouter.ai/api/v1" } },
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.(1, { query: "test" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "missing_openrouter_api_key" });
});
});
+230 -8
View File
@@ -5,7 +5,7 @@ import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const SEARCH_PROVIDERS = ["brave"] as const;
const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
const EXTRACT_MODES = ["markdown", "text"] as const;
const DEFAULT_SEARCH_COUNT = 5;
@@ -20,6 +20,9 @@ const DEFAULT_FETCH_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const DEFAULT_PERPLEXITY_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
@@ -196,7 +199,34 @@ function resolveFirecrawlMaxAgeMsOrDefault(firecrawl?: FirecrawlFetchConfig): nu
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
}
function missingSearchKeyPayload() {
function missingSearchKeyPayload(
provider: (typeof SEARCH_PROVIDERS)[number],
perplexityBaseUrl?: string,
) {
if (provider === "perplexity") {
if (perplexityBaseUrl && isOpenRouterBaseUrl(perplexityBaseUrl)) {
return {
error: "missing_openrouter_api_key",
message:
"web_search (perplexity via OpenRouter) needs an API key. Set OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
if (perplexityBaseUrl && isPerplexityBaseUrl(perplexityBaseUrl)) {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity direct) needs an API key. Set PERPLEXITY_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
return {
error: "missing_brave_api_key",
message:
@@ -210,10 +240,99 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (raw === "perplexity") return "perplexity";
if (raw === "brave") return "brave";
return "brave";
}
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
function normalizeBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
}
function isPerplexityBaseUrl(value: string): boolean {
try {
return new URL(value).hostname === "api.perplexity.ai";
} catch {
return false;
}
}
function isOpenRouterBaseUrl(value: string): boolean {
try {
return new URL(value).hostname === "openrouter.ai";
} catch {
return false;
}
}
function inferPerplexityBaseUrlFromKey(apiKey: string): string | undefined {
if (apiKey.startsWith("pplx-")) return DEFAULT_PERPLEXITY_BASE_URL;
if (apiKey.startsWith("sk-or-")) return DEFAULT_OPENROUTER_BASE_URL;
return undefined;
}
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
if (!search || typeof search !== "object") return {};
const perplexity = "perplexity" in search ? search.perplexity : undefined;
if (!perplexity || typeof perplexity !== "object") return {};
return perplexity as PerplexityConfig;
}
function resolvePerplexityApiKey(
perplexity: PerplexityConfig | undefined,
baseUrl: string,
): string | undefined {
const fromConfig =
perplexity && "apiKey" in perplexity && typeof perplexity.apiKey === "string"
? perplexity.apiKey.trim()
: "";
if (fromConfig) return fromConfig;
const fromEnvPerplexity = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const fromEnvOpenRouter = (process.env.OPENROUTER_API_KEY ?? "").trim();
if (isPerplexityBaseUrl(baseUrl)) {
return fromEnvPerplexity || undefined;
}
if (isOpenRouterBaseUrl(baseUrl)) {
return fromEnvOpenRouter || undefined;
}
return fromEnvPerplexity || fromEnvOpenRouter || undefined;
}
function resolvePerplexityBaseUrl(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
? perplexity.baseUrl.trim()
: "";
if (fromConfig) return normalizeBaseUrl(fromConfig);
const fromConfigKey =
perplexity && "apiKey" in perplexity && typeof perplexity.apiKey === "string"
? perplexity.apiKey.trim()
: "";
const inferredFromConfigKey = fromConfigKey
? inferPerplexityBaseUrlFromKey(fromConfigKey)
: undefined;
if (inferredFromConfigKey) return inferredFromConfigKey;
const fromEnvPerplexity = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const fromEnvOpenRouter = (process.env.OPENROUTER_API_KEY ?? "").trim();
if (fromEnvPerplexity) return DEFAULT_PERPLEXITY_BASE_URL;
if (fromEnvOpenRouter) return DEFAULT_OPENROUTER_BASE_URL;
return DEFAULT_OPENROUTER_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "model" in perplexity && typeof perplexity.model === "string"
? perplexity.model.trim()
: "";
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}
function resolveTimeoutSeconds(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(1, Math.floor(parsed));
@@ -486,6 +605,63 @@ export async function fetchFirecrawlContent(params: {
};
}
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
citations?: string[];
};
function resolvePerplexityEndpoint(baseUrl: string): string {
const trimmed = normalizeBaseUrl(baseUrl);
if (!trimmed) return `${DEFAULT_OPENROUTER_BASE_URL}/chat/completions`;
if (trimmed.endsWith("/chat/completions")) return trimmed;
return `${trimmed}/chat/completions`;
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = resolvePerplexityEndpoint(params.baseUrl);
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://clawdbot.com",
"X-Title": "Clawdbot Web Search",
},
body: JSON.stringify({
model: params.model,
messages: [
{
role: "user",
content: params.query,
},
],
}),
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
if (!res.ok) {
const detail = await readResponseText(res);
throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as PerplexitySearchResponse;
const content = data.choices?.[0]?.message?.content ?? "No response";
const citations = data.citations ?? [];
return { content, citations };
}
async function runWebSearch(params: {
query: string;
count: number;
@@ -496,14 +672,42 @@ async function runWebSearch(params: {
country?: string;
search_lang?: string;
ui_lang?: string;
perplexityBaseUrl?: string;
perplexityModel?: string;
}): Promise<Record<string, unknown>> {
const providerKey =
params.provider === "perplexity"
? `${params.provider}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.perplexityBaseUrl ?? DEFAULT_OPENROUTER_BASE_URL}`
: params.provider;
const cacheKey = normalizeCacheKey(
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
`${providerKey}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
);
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true };
const start = Date.now();
if (params.provider === "perplexity") {
const { content, citations } = await runPerplexitySearch({
query: params.query,
apiKey: params.apiKey,
baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
timeoutSeconds: params.timeoutSeconds,
});
const payload = {
query: params.query,
provider: params.provider,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
tookMs: Date.now() - start,
content,
citations,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
if (params.provider !== "brave") {
throw new Error("Unsupported web search provider.");
}
@@ -772,16 +976,32 @@ export function createWebSearchTool(options?: {
}): AnyAgentTool | null {
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) return null;
const provider = resolveSearchProvider(search);
const perplexityConfig = resolvePerplexityConfig(search);
const perplexityBaseUrl = resolvePerplexityBaseUrl(perplexityConfig);
const perplexityModel = resolvePerplexityModel(perplexityConfig);
// Determine description based on provider
const description =
provider === "perplexity"
? "Search the web using Perplexity Sonar (direct API or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
return {
label: "Web Search",
name: "web_search",
description:
"Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
description,
parameters: WebSearchSchema,
execute: async (_toolCallId, args) => {
const apiKey = resolveSearchApiKey(search);
// Resolve API key based on provider
const apiKey =
provider === "perplexity"
? resolvePerplexityApiKey(perplexityConfig, perplexityBaseUrl)
: resolveSearchApiKey(search);
if (!apiKey) {
return jsonResult(missingSearchKeyPayload());
return jsonResult(missingSearchKeyPayload(provider, perplexityBaseUrl));
}
const params = args as Record<string, unknown>;
const query = readStringParam(params, "query", { required: true });
@@ -796,10 +1016,12 @@ export function createWebSearchTool(options?: {
apiKey,
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
provider: resolveSearchProvider(search),
provider,
country,
search_lang,
ui_lang,
perplexityBaseUrl,
perplexityModel,
});
return jsonResult(result);
},
@@ -7,6 +7,7 @@ import {
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
import type { ChannelMessageActionContext } from "../../types.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
import { resolveDiscordChannelId } from "../../../../discord/targets.js";
const providerId = "discord";
@@ -22,7 +23,9 @@ export async function handleDiscordMessageAction(
const { action, params, cfg } = ctx;
const resolveChannelId = () =>
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
resolveDiscordChannelId(
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }),
);
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
+2
View File
@@ -0,0 +1,2 @@
export type { ChannelEntryMatch } from "../channel-config.js";
export { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js";
+1 -1
View File
@@ -4,7 +4,7 @@ import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { normalizeSlackMessagingTarget } from "./normalize-target.js";
import { normalizeSlackMessagingTarget } from "./normalize/slack.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
export type DirectoryConfigParams = {
+4 -1
View File
@@ -26,7 +26,10 @@ import {
} from "./config-helpers.js";
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize-target.js";
import {
looksLikeDiscordTargetId,
normalizeDiscordMessagingTarget,
} from "./normalize/discord.js";
import { discordOnboardingAdapter } from "./onboarding/discord.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+5
View File
@@ -84,4 +84,9 @@ export {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
} from "./directory-config.js";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
type ChannelEntryMatch,
} from "./channel-config.js";
export type { ChannelId, ChannelPlugin } from "./types.js";
-143
View File
@@ -1,143 +0,0 @@
import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
export function normalizeSlackMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("slack:")) {
const id = trimmed.slice(6).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
return `channel:${trimmed}`.toLowerCase();
}
export function looksLikeSlackTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) return true;
if (/^(user|channel):/i.test(trimmed)) return true;
if (/^slack:/i.test(trimmed)) return true;
if (/^[@#]/.test(trimmed)) return true;
return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed);
}
export function normalizeDiscordMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("discord:")) {
const id = trimmed.slice(8).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
return `channel:${trimmed}`.toLowerCase();
}
export function looksLikeDiscordTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^<@!?\d+>$/.test(trimmed)) return true;
if (/^(user|channel|discord):/i.test(trimmed)) return true;
if (/^\d{6,}$/.test(trimmed)) return true;
return false;
}
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("telegram:")) {
normalized = normalized.slice("telegram:".length).trim();
} else if (normalized.startsWith("tg:")) {
normalized = normalized.slice("tg:".length).trim();
}
if (!normalized) return undefined;
const tmeMatch =
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
if (!normalized) return undefined;
return `telegram:${normalized}`.toLowerCase();
}
export function looksLikeTelegramTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(telegram|tg):/i.test(trimmed)) return true;
if (trimmed.startsWith("@")) return true;
return /^-?\d{6,}$/.test(trimmed);
}
export function normalizeSignalMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.toLowerCase().startsWith("signal:")) {
normalized = normalized.slice("signal:".length).trim();
}
if (!normalized) return undefined;
const lower = normalized.toLowerCase();
if (lower.startsWith("group:")) {
const id = normalized.slice("group:".length).trim();
return id ? `group:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("username:")) {
const id = normalized.slice("username:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("u:")) {
const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
return normalized.toLowerCase();
}
export function looksLikeSignalTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
return /^\+?\d{3,}$/.test(trimmed);
}
export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
return normalizeWhatsAppTarget(trimmed) ?? undefined;
}
export function looksLikeWhatsAppTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^whatsapp:/i.test(trimmed)) return true;
if (trimmed.includes("@")) return true;
return /^\+?\d{3,}$/.test(trimmed);
}
+16
View File
@@ -0,0 +1,16 @@
import { parseDiscordTarget } from "../../../discord/targets.js";
export function normalizeDiscordMessagingTarget(raw: string): string | undefined {
// Default bare IDs to channels so routing is stable across tool actions.
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
return target?.normalized;
}
export function looksLikeDiscordTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^<@!?\d+>$/.test(trimmed)) return true;
if (/^(user|channel|discord):/i.test(trimmed)) return true;
if (/^\d{6,}$/.test(trimmed)) return true;
return false;
}
+30
View File
@@ -0,0 +1,30 @@
export function normalizeSignalMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.toLowerCase().startsWith("signal:")) {
normalized = normalized.slice("signal:".length).trim();
}
if (!normalized) return undefined;
const lower = normalized.toLowerCase();
if (lower.startsWith("group:")) {
const id = normalized.slice("group:".length).trim();
return id ? `group:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("username:")) {
const id = normalized.slice("username:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("u:")) {
const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
return normalized.toLowerCase();
}
export function looksLikeSignalTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
return /^\+?\d{3,}$/.test(trimmed);
}
+16
View File
@@ -0,0 +1,16 @@
import { parseSlackTarget } from "../../../slack/targets.js";
export function normalizeSlackMessagingTarget(raw: string): string | undefined {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
return target?.normalized;
}
export function looksLikeSlackTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) return true;
if (/^(user|channel):/i.test(trimmed)) return true;
if (/^slack:/i.test(trimmed)) return true;
if (/^[@#]/.test(trimmed)) return true;
return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed);
}
@@ -0,0 +1,25 @@
export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("telegram:")) {
normalized = normalized.slice("telegram:".length).trim();
} else if (normalized.startsWith("tg:")) {
normalized = normalized.slice("tg:".length).trim();
}
if (!normalized) return undefined;
const tmeMatch =
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
if (!normalized) return undefined;
return `telegram:${normalized}`.toLowerCase();
}
export function looksLikeTelegramTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(telegram|tg):/i.test(trimmed)) return true;
if (trimmed.startsWith("@")) return true;
return /^-?\d{6,}$/.test(trimmed);
}
@@ -0,0 +1,15 @@
import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js";
export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
return normalizeWhatsAppTarget(trimmed) ?? undefined;
}
export function looksLikeWhatsAppTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^whatsapp:/i.test(trimmed)) return true;
if (trimmed.includes("@")) return true;
return /^\+?\d{3,}$/.test(trimmed);
}
+4 -1
View File
@@ -18,7 +18,10 @@ import {
} from "./config-helpers.js";
import { formatPairingApproveHint } from "./helpers.js";
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize-target.js";
import {
looksLikeSignalTargetId,
normalizeSignalMessagingTarget,
} from "./normalize/signal.js";
import { signalOnboardingAdapter } from "./onboarding/signal.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+4 -1
View File
@@ -1,6 +1,7 @@
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
import { handleSlackAction, type SlackActionContext } from "../../agents/tools/slack-actions.js";
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
import { resolveSlackChannelId } from "../../slack/targets.js";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionContext,
@@ -60,7 +61,9 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
const accountId = ctx.accountId ?? undefined;
const toolContext = ctx.toolContext as SlackActionContext | undefined;
const resolveChannelId = () =>
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
resolveSlackChannelId(
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }),
);
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
+1 -1
View File
@@ -20,7 +20,7 @@ import {
} from "./config-helpers.js";
import { resolveSlackGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize-target.js";
import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize/slack.js";
import { slackOnboardingAdapter } from "./onboarding/slack.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+11 -1
View File
@@ -24,6 +24,8 @@ type DiscordPermissionsAuditSummary = {
ok?: boolean;
missing?: string[];
error?: string | null;
matchKey?: string;
matchSource?: string;
}>;
};
@@ -72,11 +74,15 @@ function readDiscordPermissionsAuditSummary(value: unknown): DiscordPermissionsA
? entry.missing.map((v) => asString(v)).filter(Boolean)
: undefined;
const error = asString(entry.error) ?? null;
const matchKey = asString(entry.matchKey) ?? undefined;
const matchSource = asString(entry.matchSource) ?? undefined;
return {
channelId,
ok,
missing: missing?.length ? missing : undefined,
error,
matchKey,
matchSource,
};
})
.filter(Boolean) as DiscordPermissionsAuditSummary["channels"])
@@ -122,11 +128,15 @@ export function collectDiscordStatusIssues(
if (channel.ok === true) continue;
const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : "";
const error = channel.error ? `: ${channel.error}` : "";
const matchMeta =
channel.matchKey || channel.matchSource
? ` (matchKey=${channel.matchKey ?? "none"} matchSource=${channel.matchSource ?? "none"})`
: "";
issues.push({
channel: "discord",
accountId,
kind: "permissions",
message: `Channel ${channel.channelId} permission check failed.${missing}${error}`,
message: `Channel ${channel.channelId} permission check failed.${missing}${error}${matchMeta}`,
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
});
}
+10 -2
View File
@@ -17,6 +17,8 @@ type TelegramGroupMembershipAuditSummary = {
ok?: boolean;
status?: string | null;
error?: string | null;
matchKey?: string;
matchSource?: string;
}>;
};
@@ -53,7 +55,9 @@ function readTelegramGroupMembershipAuditSummary(
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
const status = asString(entry.status) ?? null;
const error = asString(entry.error) ?? null;
return { chatId, ok, status, error };
const matchKey = asString(entry.matchKey) ?? undefined;
const matchSource = asString(entry.matchSource) ?? undefined;
return { chatId, ok, status, error, matchKey, matchSource };
})
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
: undefined;
@@ -107,11 +111,15 @@ export function collectTelegramStatusIssues(
if (group.ok === true) continue;
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
const matchMeta =
group.matchKey || group.matchSource
? ` (matchKey=${group.matchKey ?? "none"} matchSource=${group.matchSource ?? "none"})`
: "";
issues.push({
channel: "telegram",
accountId,
kind: "runtime",
message: `Group ${group.chatId} not reachable by bot.${status}${err}`,
message: `Group ${group.chatId} not reachable by bot.${status}${err}${matchMeta}`,
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
});
}
+4 -1
View File
@@ -26,7 +26,10 @@ import {
} from "./config-helpers.js";
import { resolveTelegramGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize-target.js";
import {
looksLikeTelegramTargetId,
normalizeTelegramMessagingTarget,
} from "./normalize/telegram.js";
import { telegramOnboardingAdapter } from "./onboarding/telegram.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import {
+4 -1
View File
@@ -26,7 +26,10 @@ import { buildChannelConfigSchema } from "./config-schema.js";
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.js";
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize-target.js";
import {
looksLikeWhatsAppTargetId,
normalizeWhatsAppMessagingTarget,
} from "./normalize/whatsapp.js";
import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js";
import {
applyAccountNameToChannelSection,
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js";
describe("ensureTargetId", () => {
it("returns the candidate when it matches", () => {
expect(
ensureTargetId({
candidate: "U123",
pattern: /^[A-Z0-9]+$/i,
errorMessage: "bad",
}),
).toBe("U123");
});
it("throws with the provided message on mismatch", () => {
expect(() =>
ensureTargetId({
candidate: "not-ok",
pattern: /^[A-Z0-9]+$/i,
errorMessage: "Bad target",
}),
).toThrow(/Bad target/);
});
});
describe("requireTargetKind", () => {
it("returns the target id when the kind matches", () => {
const target = buildMessagingTarget("channel", "C123", "C123");
expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123");
});
it("throws when the kind is missing or mismatched", () => {
expect(() => requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" })).toThrow(
/Slack channel id is required/,
);
const target = buildMessagingTarget("user", "U123", "U123");
expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow(
/Slack channel id is required/,
);
});
});
+56
View File
@@ -0,0 +1,56 @@
export type MessagingTargetKind = "user" | "channel";
export type MessagingTarget = {
kind: MessagingTargetKind;
id: string;
raw: string;
normalized: string;
};
export type MessagingTargetParseOptions = {
defaultKind?: MessagingTargetKind;
ambiguousMessage?: string;
};
export function normalizeTargetId(kind: MessagingTargetKind, id: string): string {
return `${kind}:${id}`.toLowerCase();
}
export function buildMessagingTarget(
kind: MessagingTargetKind,
id: string,
raw: string,
): MessagingTarget {
return {
kind,
id,
raw,
normalized: normalizeTargetId(kind, id),
};
}
export function ensureTargetId(params: {
candidate: string;
pattern: RegExp;
errorMessage: string;
}): string {
if (!params.pattern.test(params.candidate)) {
throw new Error(params.errorMessage);
}
return params.candidate;
}
export function requireTargetKind(params: {
platform: string;
target: MessagingTarget | undefined;
kind: MessagingTargetKind;
}): string {
const kindLabel = params.kind;
if (!params.target) {
throw new Error(`${params.platform} ${kindLabel} id is required.`);
}
if (params.target.kind !== params.kind) {
throw new Error(`${params.platform} ${kindLabel} id is required (use ${kindLabel}:<id>).`);
}
return params.target.id;
}
+9 -9
View File
@@ -1,8 +1,8 @@
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { normalizeDiscordMessagingTarget } from "../../channels/plugins/normalize-target.js";
import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js";
import { fetchChannelPermissionsDiscord } from "../../discord/send.js";
import { parseDiscordTarget } from "../../discord/targets.js";
import { danger } from "../../globals.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
@@ -88,24 +88,24 @@ function formatSupport(capabilities?: ChannelCapabilities) {
function summarizeDiscordTarget(raw?: string): DiscordTargetSummary | undefined {
if (!raw) return undefined;
const normalized = normalizeDiscordMessagingTarget(raw);
if (!normalized) return { raw };
if (normalized.startsWith("channel:")) {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
if (!target) return { raw };
if (target.kind === "channel") {
return {
raw,
normalized,
normalized: target.normalized,
kind: "channel",
channelId: normalized.slice("channel:".length),
channelId: target.id,
};
}
if (normalized.startsWith("user:")) {
if (target.kind === "user") {
return {
raw,
normalized,
normalized: target.normalized,
kind: "user",
};
}
return { raw, normalized };
return { raw, normalized: target.normalized };
}
function formatDiscordIntents(intents?: {
+80 -20
View File
@@ -97,12 +97,15 @@ async function promptWebToolsConfig(
): Promise<ClawdbotConfig> {
const existingSearch = nextConfig.tools?.web?.search;
const existingFetch = nextConfig.tools?.web?.fetch;
const hasSearchKey = Boolean(existingSearch?.apiKey);
const existingProvider = existingSearch?.provider === "perplexity" ? "perplexity" : "brave";
const hasBraveKey = Boolean(existingSearch?.apiKey);
const hasPerplexityKey = Boolean(existingSearch?.perplexity?.apiKey);
const hasSearchKey = existingProvider === "perplexity" ? hasPerplexityKey : hasBraveKey;
note(
[
"Web search lets your agent look things up online using the `web_search` tool.",
"It requires a Brave Search API key (you can store it in the config or set BRAVE_API_KEY in the Gateway environment).",
"Choose Brave (structured results) or Perplexity Sonar (answers + citations).",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
@@ -110,7 +113,7 @@ async function promptWebToolsConfig(
const enableSearch = guardCancel(
await confirm({
message: "Enable web_search (Brave Search)?",
message: "Enable web_search?",
initialValue: existingSearch?.enabled ?? hasSearchKey,
}),
runtime,
@@ -122,27 +125,84 @@ async function promptWebToolsConfig(
};
if (enableSearch) {
const keyInput = guardCancel(
await text({
message: hasSearchKey
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
placeholder: hasSearchKey ? "Leave blank to keep current" : "BSA...",
const provider = guardCancel(
await select<"brave" | "perplexity">({
message: "Web search provider",
options: [
{ value: "brave", label: "Brave Search (structured results)" },
{ value: "perplexity", label: "Perplexity Sonar (answers + citations)" },
],
initialValue: existingProvider,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key) {
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasSearchKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
nextSearch = { ...nextSearch, provider };
if (provider === "brave") {
const keyInput = guardCancel(
await text({
message: hasBraveKey
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
placeholder: hasBraveKey ? "Leave blank to keep current" : "BSA...",
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key) {
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasBraveKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
);
}
} else {
const existingPerplexity = existingSearch?.perplexity ?? {};
const keyInput = guardCancel(
await text({
message: hasPerplexityKey
? "Perplexity/OpenRouter API key (leave blank to keep current or use PERPLEXITY_API_KEY/OPENROUTER_API_KEY)"
: "Perplexity/OpenRouter API key (paste it here; leave blank to use PERPLEXITY_API_KEY/OPENROUTER_API_KEY)",
placeholder: hasPerplexityKey ? "Leave blank to keep current" : "pplx-... or sk-or-...",
}),
runtime,
);
const key = String(keyInput ?? "").trim();
let nextPerplexity = { ...existingPerplexity };
if (key) {
nextPerplexity = { ...nextPerplexity, apiKey: key };
}
const baseUrlInput = guardCancel(
await text({
message: "Perplexity base URL (optional; leave blank for default)",
placeholder: existingPerplexity.baseUrl ?? "https://api.perplexity.ai",
}),
runtime,
);
const baseUrl = String(baseUrlInput ?? "").trim();
if (baseUrl) {
nextPerplexity = { ...nextPerplexity, baseUrl };
}
nextSearch = { ...nextSearch, perplexity: nextPerplexity };
if (!key && !hasPerplexityKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set PERPLEXITY_API_KEY/OPENROUTER_API_KEY in the Gateway environment.",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
);
}
}
}
@@ -123,7 +123,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
if (!opts.json) {
runtime.log(
"Tip: run `clawdbot configure --section web` to store your Brave API key for web_search. Docs: https://docs.clawd.bot/tools/web",
"Tip: run `clawdbot configure --section web` to choose Brave or Perplexity and store your API key for web_search. Docs: https://docs.clawd.bot/tools/web",
);
}
}
@@ -45,7 +45,7 @@ export async function runNonInteractiveOnboardingRemote(params: {
runtime.log(`Remote gateway: ${remoteUrl}`);
runtime.log(`Auth: ${payload.auth}`);
runtime.log(
"Tip: run `clawdbot configure --section web` to store your Brave API key for web_search. Docs: https://docs.clawd.bot/tools/web",
"Tip: run `clawdbot configure --section web` to choose Brave or Perplexity and store your API key for web_search. Docs: https://docs.clawd.bot/tools/web",
);
}
}
+8 -2
View File
@@ -315,9 +315,15 @@ const FIELD_HELP: Record<string, string> = {
"tools.message.crossContext.marker.suffix":
'Text suffix for cross-context markers (supports "{channel}").',
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
"tools.web.search.enabled": "Enable the web_search tool (requires Brave API key).",
"tools.web.search.provider": 'Search provider (only "brave" supported today).',
"tools.web.search.enabled": "Enable the web_search tool (requires provider API key).",
"tools.web.search.provider": 'Search provider ("brave" or "perplexity").',
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"tools.web.search.perplexity.apiKey":
"Perplexity/OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env vars).",
"tools.web.search.perplexity.baseUrl":
"Perplexity/OpenRouter base URL for web_search (defaults to Perplexity when PERPLEXITY_API_KEY is set, otherwise OpenRouter).",
"tools.web.search.perplexity.model":
'Perplexity Sonar model to use for web_search (default: "perplexity/sonar-pro").',
"tools.web.search.maxResults": "Default number of results to return (1-10).",
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
"tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
+11 -2
View File
@@ -222,8 +222,8 @@ export type ToolsConfig = {
search?: {
/** Enable web search tool (default: true when API key is present). */
enabled?: boolean;
/** Search provider (currently "brave"). */
provider?: "brave";
/** Search provider ("brave" or "perplexity"). */
provider?: "brave" | "perplexity";
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
apiKey?: string;
/** Default search results count (1-10). */
@@ -232,6 +232,15 @@ export type ToolsConfig = {
timeoutSeconds?: number;
/** Cache TTL in minutes for search results. */
cacheTtlMinutes?: number;
/** Perplexity-specific configuration (used when provider="perplexity"). */
perplexity?: {
/** API key for Perplexity or OpenRouter (defaults to PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). */
apiKey?: string;
/** Base URL for API requests (defaults to Perplexity if PERPLEXITY_API_KEY is set, otherwise OpenRouter). */
baseUrl?: string;
/** Model to use (defaults to "perplexity/sonar-pro"). */
model?: string;
};
};
fetch?: {
/** Enable web fetch tool (default: true). */
+6
View File
@@ -8,6 +8,8 @@ export type DiscordChannelPermissionsAuditEntry = {
ok: boolean;
missing?: string[];
error?: string | null;
matchKey?: string;
matchSource?: "id";
};
export type DiscordChannelPermissionsAudit = {
@@ -97,12 +99,16 @@ export async function auditDiscordChannelPermissions(params: {
ok: missing.length === 0,
missing: missing.length ? missing : undefined,
error: null,
matchKey: channelId,
matchSource: "id",
});
} catch (err) {
channels.push({
channelId,
ok: false,
error: err instanceof Error ? err.message : String(err),
matchKey: channelId,
matchSource: "id",
});
}
}
+28
View File
@@ -249,6 +249,34 @@ describe("discord mention gating", () => {
}),
).toBe(false);
});
it("inherits parent channel mention rules for threads", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
"parent-1": { allow: true, requireMention: false },
},
};
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-1",
channelName: "topic",
channelSlug: "topic",
parentId: "parent-1",
parentName: "Parent",
parentSlug: "parent",
scope: "thread",
});
expect(channelConfig?.matchSource).toBe("parent");
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
channelConfig,
guildInfo,
}),
).toBe(false);
});
});
describe("discord groupPolicy gating", () => {
+28
View File
@@ -9,6 +9,12 @@ export type DiscordAllowList = {
names: Set<string>;
};
export type DiscordAllowListMatch = {
allowed: boolean;
matchKey?: string;
matchSource?: "wildcard" | "id" | "name" | "tag";
};
export type DiscordGuildEntryResolved = {
id?: string;
slug?: string;
@@ -92,6 +98,28 @@ export function allowListMatches(
return false;
}
export function resolveDiscordAllowListMatch(params: {
allowList: DiscordAllowList;
candidate: { id?: string; name?: string; tag?: string };
}): DiscordAllowListMatch {
const { allowList, candidate } = params;
if (allowList.allowAll) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
if (candidate.id && allowList.ids.has(candidate.id)) {
return { allowed: true, matchKey: candidate.id, matchSource: "id" };
}
const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
if (nameSlug && allowList.names.has(nameSlug)) {
return { allowed: true, matchKey: nameSlug, matchSource: "name" };
}
const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : "";
if (tagSlug && allowList.names.has(tagSlug)) {
return { allowed: true, matchKey: tagSlug, matchSource: "tag" };
}
return { allowed: false };
}
export function resolveDiscordUserAllowed(params: {
allowList?: Array<string | number>;
userId: string;
@@ -22,6 +22,7 @@ import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordShouldRequireMention,
@@ -89,13 +90,20 @@ export async function preflightDiscordMessage(
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
const permitted = allowList
? allowListMatches(allowList, {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
},
})
: false;
: { allowed: false };
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
const permitted = allowMatch.allowed;
if (!permitted) {
commandAuthorized = false;
if (dmPolicy === "pairing") {
@@ -109,7 +117,7 @@ export async function preflightDiscordMessage(
});
if (created) {
logVerbose(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)}`,
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
);
try {
await sendMessageDiscord(
@@ -130,7 +138,9 @@ export async function preflightDiscordMessage(
}
}
} else {
logVerbose(`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`);
logVerbose(
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return null;
}
@@ -252,8 +262,11 @@ export async function preflightDiscordMessage(
scope: threadChannel ? "thread" : "channel",
})
: null;
const channelMatchMeta = `matchKey=${channelConfig?.matchKey ?? "none"} matchSource=${
channelConfig?.matchSource ?? "none"
}`;
if (isGuildMessage && channelConfig?.enabled === false) {
logVerbose(`Blocked discord channel ${message.channelId} (channel disabled)`);
logVerbose(`Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`);
return null;
}
@@ -280,21 +293,28 @@ export async function preflightDiscordMessage(
})
) {
if (params.groupPolicy === "disabled") {
logVerbose("discord: drop guild message (groupPolicy: disabled)");
logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`);
} else if (!channelAllowlistConfigured) {
logVerbose("discord: drop guild message (groupPolicy: allowlist, no channel allowlist)");
logVerbose(
`discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`,
);
} else {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`,
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`,
);
}
return null;
}
if (isGuildMessage && channelConfig?.allowed === false) {
logVerbose(`Blocked discord channel ${message.channelId} not in guild channel allowlist`);
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (${channelMatchMeta})`,
);
return null;
}
if (isGuildMessage) {
logVerbose(`discord: allow channel ${message.channelId} (${channelMatchMeta})`);
}
const textForHistory = resolveDiscordMessageText(message, {
includeForwarded: true,
+6 -28
View File
@@ -12,6 +12,7 @@ import { resolveDiscordAccount } from "./accounts.js";
import { chunkDiscordText } from "./chunk.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js";
import { parseDiscordTarget } from "./targets.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000;
@@ -90,36 +91,13 @@ function normalizeReactionEmoji(raw: string) {
}
function parseRecipient(raw: string): DiscordRecipient {
const trimmed = raw.trim();
if (!trimmed) {
const target = parseDiscordTarget(raw, {
ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
});
if (!target) {
throw new Error("Recipient is required for Discord sends");
}
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return { kind: "user", id: mentionMatch[1] };
}
if (trimmed.startsWith("user:")) {
return { kind: "user", id: trimmed.slice("user:".length) };
}
if (trimmed.startsWith("channel:")) {
return { kind: "channel", id: trimmed.slice("channel:".length) };
}
if (trimmed.startsWith("discord:")) {
return { kind: "user", id: trimmed.slice("discord:".length) };
}
if (trimmed.startsWith("@")) {
const candidate = trimmed.slice(1);
if (!/^\d+$/.test(candidate)) {
throw new Error("Discord DMs require a user id (use user:<id> or a <@id> mention)");
}
return { kind: "user", id: candidate };
}
if (/^\d+$/.test(trimmed)) {
throw new Error(
`Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
);
}
return { kind: "channel", id: trimmed };
return { kind: target.kind, id: target.id };
}
function normalizeStickerIds(raw: string[]) {
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js";
import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js";
describe("parseDiscordTarget", () => {
it("parses user mention and prefixes", () => {
expect(parseDiscordTarget("<@123>")).toMatchObject({
kind: "user",
id: "123",
normalized: "user:123",
});
expect(parseDiscordTarget("<@!456>")).toMatchObject({
kind: "user",
id: "456",
normalized: "user:456",
});
expect(parseDiscordTarget("user:789")).toMatchObject({
kind: "user",
id: "789",
normalized: "user:789",
});
expect(parseDiscordTarget("discord:987")).toMatchObject({
kind: "user",
id: "987",
normalized: "user:987",
});
});
it("parses channel targets", () => {
expect(parseDiscordTarget("channel:555")).toMatchObject({
kind: "channel",
id: "555",
normalized: "channel:555",
});
expect(parseDiscordTarget("general")).toMatchObject({
kind: "channel",
id: "general",
normalized: "channel:general",
});
});
it("rejects ambiguous numeric ids without a default kind", () => {
expect(() => parseDiscordTarget("123")).toThrow(/Ambiguous Discord recipient/);
});
it("accepts numeric ids when a default kind is provided", () => {
expect(parseDiscordTarget("123", { defaultKind: "channel" })).toMatchObject({
kind: "channel",
id: "123",
normalized: "channel:123",
});
});
it("rejects non-numeric @ mentions", () => {
expect(() => parseDiscordTarget("@bob")).toThrow(/Discord DMs require a user id/);
});
});
describe("resolveDiscordChannelId", () => {
it("strips channel: prefix and accepts raw ids", () => {
expect(resolveDiscordChannelId("channel:123")).toBe("123");
expect(resolveDiscordChannelId("123")).toBe("123");
});
it("rejects user targets", () => {
expect(() => resolveDiscordChannelId("user:123")).toThrow(/channel id is required/i);
});
});
describe("normalizeDiscordMessagingTarget", () => {
it("defaults raw numeric ids to channels", () => {
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");
});
});
+62
View File
@@ -0,0 +1,62 @@
import {
buildMessagingTarget,
ensureTargetId,
requireTargetKind,
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
} from "../channels/targets.js";
export type DiscordTargetKind = MessagingTargetKind;
export type DiscordTarget = MessagingTarget;
type DiscordTargetParseOptions = MessagingTargetParseOptions;
export function parseDiscordTarget(
raw: string,
options: DiscordTargetParseOptions = {},
): DiscordTarget | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return buildMessagingTarget("user", mentionMatch[1], trimmed);
}
if (trimmed.startsWith("user:")) {
const id = trimmed.slice("user:".length).trim();
return id ? buildMessagingTarget("user", id, trimmed) : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice("channel:".length).trim();
return id ? buildMessagingTarget("channel", id, trimmed) : undefined;
}
if (trimmed.startsWith("discord:")) {
const id = trimmed.slice("discord:".length).trim();
return id ? buildMessagingTarget("user", id, trimmed) : undefined;
}
if (trimmed.startsWith("@")) {
const candidate = trimmed.slice(1).trim();
const id = ensureTargetId({
candidate,
pattern: /^\d+$/,
errorMessage: "Discord DMs require a user id (use user:<id> or a <@id> mention)",
});
return buildMessagingTarget("user", id, trimmed);
}
if (/^\d+$/.test(trimmed)) {
if (options.defaultKind) {
return buildMessagingTarget(options.defaultKind, trimmed, trimmed);
}
throw new Error(
options.ambiguousMessage ??
`Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
);
}
return buildMessagingTarget("channel", trimmed, trimmed);
}
export function resolveDiscordChannelId(raw: string): string {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
return requireTargetKind({ platform: "Discord", target, kind: "channel" });
}
+38 -12
View File
@@ -14,22 +14,48 @@ export function normalizeAllowListLower(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
}
export function allowListMatches(params: { allowList: string[]; id?: string; name?: string }) {
export type SlackAllowListMatch = {
allowed: boolean;
matchKey?: string;
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "prefixed-name" | "slug";
};
export function resolveSlackAllowListMatch(params: {
allowList: string[];
id?: string;
name?: string;
}): SlackAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) return false;
if (allowList.includes("*")) return true;
if (allowList.length === 0) return { allowed: false };
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const id = params.id?.toLowerCase();
const name = params.name?.toLowerCase();
const slug = normalizeSlackSlug(name);
const candidates = [
id,
id ? `slack:${id}` : undefined,
id ? `user:${id}` : undefined,
name,
name ? `slack:${name}` : undefined,
slug,
].filter(Boolean) as string[];
return candidates.some((value) => allowList.includes(value));
const candidates: Array<{ value?: string; source: SlackAllowListMatch["matchSource"] }> = [
{ value: id, source: "id" },
{ value: id ? `slack:${id}` : undefined, source: "prefixed-id" },
{ value: id ? `user:${id}` : undefined, source: "prefixed-user" },
{ value: name, source: "name" },
{ value: name ? `slack:${name}` : undefined, source: "prefixed-name" },
{ value: slug, source: "slug" },
];
for (const candidate of candidates) {
if (!candidate.value) continue;
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function allowListMatches(params: { allowList: string[]; id?: string; name?: string }) {
return resolveSlackAllowListMatch(params).allowed;
}
export function resolveSlackUserAllowed(params: {
+14
View File
@@ -28,4 +28,18 @@ describe("resolveSlackChannelConfig", () => {
});
expect(res).toMatchObject({ requireMention: true });
});
it("uses wildcard entries when no direct channel config exists", () => {
const res = resolveSlackChannelConfig({
channelId: "C1",
channels: { "*": { allow: true, requireMention: false } },
defaultRequireMention: true,
});
expect(res).toMatchObject({
allowed: true,
requireMention: false,
matchKey: "*",
matchSource: "wildcard",
});
});
});
+11 -1
View File
@@ -310,6 +310,9 @@ export function createSlackMonitorContext(params: {
channels: params.channelsConfig,
defaultRequireMention,
});
const channelMatchMeta = `matchKey=${channelConfig?.matchKey ?? "none"} matchSource=${
channelConfig?.matchSource ?? "none"
}`;
const channelAllowed = channelConfig?.allowed !== false;
const channelAllowlistConfigured =
Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0;
@@ -320,9 +323,16 @@ export function createSlackMonitorContext(params: {
channelAllowed,
})
) {
logVerbose(
`slack: drop channel ${p.channelId} (groupPolicy=${params.groupPolicy}, ${channelMatchMeta})`,
);
return false;
}
if (!channelAllowed) return false;
if (!channelAllowed) {
logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`);
return false;
}
logVerbose(`slack: allow channel ${p.channelId} (${channelMatchMeta})`);
}
return true;
+12 -7
View File
@@ -27,7 +27,7 @@ import { reactSlackMessage } from "../../actions.js";
import { sendMessageSlack } from "../../send.js";
import type { SlackMessageEvent } from "../../types.js";
import { allowListMatches, resolveSlackUserAllowed } from "../allow-list.js";
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
@@ -121,11 +121,14 @@ export async function prepareSlackMessage(params: {
return null;
}
if (ctx.dmPolicy !== "open") {
const permitted = allowListMatches({
const allowMatch = resolveSlackAllowListMatch({
allowList: allowFromLower,
id: directUserId,
});
if (!permitted) {
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
if (!allowMatch.allowed) {
if (ctx.dmPolicy === "pairing") {
const sender = await ctx.resolveUserName(directUserId);
const senderName = sender?.name ?? undefined;
@@ -136,7 +139,9 @@ export async function prepareSlackMessage(params: {
});
if (created) {
logVerbose(
`slack pairing request sender=${directUserId} name=${senderName ?? "unknown"}`,
`slack pairing request sender=${directUserId} name=${
senderName ?? "unknown"
} (${allowMatchMeta})`,
);
try {
await sendMessageSlack(
@@ -158,7 +163,7 @@ export async function prepareSlackMessage(params: {
}
} else {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy})`,
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
}
return null;
@@ -225,11 +230,11 @@ export async function prepareSlackMessage(params: {
surface: "slack",
});
const ownerAuthorized = allowListMatches({
const ownerAuthorized = resolveSlackAllowListMatch({
allowList: allowFromLower,
id: senderId,
name: senderName,
});
}).allowed;
const channelUsersAllowlistConfigured =
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
const channelCommandAuthorized =
+16 -5
View File
@@ -25,9 +25,9 @@ import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-
import type { ResolvedSlackAccount } from "../accounts.js";
import {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "./allow-list.js";
import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js";
@@ -201,12 +201,15 @@ export function registerSlackMonitorSlashCommands(params: {
if (ctx.dmPolicy !== "open") {
const sender = await ctx.resolveUserName(command.user_id);
const senderName = sender?.name ?? undefined;
const permitted = allowListMatches({
const allowMatch = resolveSlackAllowListMatch({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
});
if (!permitted) {
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
if (!allowMatch.allowed) {
if (ctx.dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
@@ -214,6 +217,11 @@ export function registerSlackMonitorSlashCommands(params: {
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${command.user_id} name=${
senderName ?? "unknown"
} (${allowMatchMeta})`,
);
await respond({
text: buildPairingReply({
channel: "slack",
@@ -224,6 +232,9 @@ export function registerSlackMonitorSlashCommands(params: {
});
}
} else {
logVerbose(
`slack: blocked slash sender ${command.user_id} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
@@ -289,11 +300,11 @@ export function registerSlackMonitorSlashCommands(params: {
return;
}
const ownerAllowed = allowListMatches({
const ownerAllowed = resolveSlackAllowListMatch({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
});
}).allowed;
if (isRoomish) {
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: ctx.useAccessGroups,
+4 -30
View File
@@ -7,6 +7,7 @@ import { loadWebMedia } from "../web/media.js";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { parseSlackTarget } from "./targets.js";
import { resolveSlackBotToken } from "./token.js";
const SLACK_TEXT_LIMIT = 4000;
@@ -57,38 +58,11 @@ function resolveToken(params: {
}
function parseRecipient(raw: string): SlackRecipient {
const trimmed = raw.trim();
if (!trimmed) {
const target = parseSlackTarget(raw);
if (!target) {
throw new Error("Recipient is required for Slack sends");
}
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch) {
return { kind: "user", id: mentionMatch[1] };
}
if (trimmed.startsWith("user:")) {
return { kind: "user", id: trimmed.slice("user:".length) };
}
if (trimmed.startsWith("channel:")) {
return { kind: "channel", id: trimmed.slice("channel:".length) };
}
if (trimmed.startsWith("slack:")) {
return { kind: "user", id: trimmed.slice("slack:".length) };
}
if (trimmed.startsWith("@")) {
const candidate = trimmed.slice(1);
if (!/^[A-Z0-9]+$/i.test(candidate)) {
throw new Error("Slack DMs require a user id (use user:<id> or <@id>)");
}
return { kind: "user", id: candidate };
}
if (trimmed.startsWith("#")) {
const candidate = trimmed.slice(1);
if (!/^[A-Z0-9]+$/i.test(candidate)) {
throw new Error("Slack channels require a channel id (use channel:<id>)");
}
return { kind: "channel", id: candidate };
}
return { kind: "channel", id: trimmed };
return { kind: target.kind, id: target.id };
}
async function resolveChannelId(
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { normalizeSlackMessagingTarget } from "../channels/plugins/normalize/slack.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
describe("parseSlackTarget", () => {
it("parses user mentions and prefixes", () => {
expect(parseSlackTarget("<@U123>")).toMatchObject({
kind: "user",
id: "U123",
normalized: "user:u123",
});
expect(parseSlackTarget("user:U456")).toMatchObject({
kind: "user",
id: "U456",
normalized: "user:u456",
});
expect(parseSlackTarget("slack:U789")).toMatchObject({
kind: "user",
id: "U789",
normalized: "user:u789",
});
});
it("parses channel targets", () => {
expect(parseSlackTarget("channel:C123")).toMatchObject({
kind: "channel",
id: "C123",
normalized: "channel:c123",
});
expect(parseSlackTarget("#C999")).toMatchObject({
kind: "channel",
id: "C999",
normalized: "channel:c999",
});
});
it("rejects invalid @ and # targets", () => {
expect(() => parseSlackTarget("@bob-1")).toThrow(/Slack DMs require a user id/);
expect(() => parseSlackTarget("#general-1")).toThrow(/Slack channels require a channel id/);
});
});
describe("resolveSlackChannelId", () => {
it("strips channel: prefix and accepts raw ids", () => {
expect(resolveSlackChannelId("channel:C123")).toBe("C123");
expect(resolveSlackChannelId("C123")).toBe("C123");
});
it("rejects user targets", () => {
expect(() => resolveSlackChannelId("user:U123")).toThrow(/channel id is required/i);
});
});
describe("normalizeSlackMessagingTarget", () => {
it("defaults raw ids to channels", () => {
expect(normalizeSlackMessagingTarget("C123")).toBe("channel:c123");
});
});
+65
View File
@@ -0,0 +1,65 @@
import {
buildMessagingTarget,
ensureTargetId,
requireTargetKind,
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
} from "../channels/targets.js";
export type SlackTargetKind = MessagingTargetKind;
export type SlackTarget = MessagingTarget;
type SlackTargetParseOptions = MessagingTargetParseOptions;
export function parseSlackTarget(
raw: string,
options: SlackTargetParseOptions = {},
): SlackTarget | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch) {
return buildMessagingTarget("user", mentionMatch[1], trimmed);
}
if (trimmed.startsWith("user:")) {
const id = trimmed.slice("user:".length).trim();
return id ? buildMessagingTarget("user", id, trimmed) : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice("channel:".length).trim();
return id ? buildMessagingTarget("channel", id, trimmed) : undefined;
}
if (trimmed.startsWith("slack:")) {
const id = trimmed.slice("slack:".length).trim();
return id ? buildMessagingTarget("user", id, trimmed) : undefined;
}
if (trimmed.startsWith("@")) {
const candidate = trimmed.slice(1).trim();
const id = ensureTargetId({
candidate,
pattern: /^[A-Z0-9]+$/i,
errorMessage: "Slack DMs require a user id (use user:<id> or <@id>)",
});
return buildMessagingTarget("user", id, trimmed);
}
if (trimmed.startsWith("#")) {
const candidate = trimmed.slice(1).trim();
const id = ensureTargetId({
candidate,
pattern: /^[A-Z0-9]+$/i,
errorMessage: "Slack channels require a channel id (use channel:<id>)",
});
return buildMessagingTarget("channel", id, trimmed);
}
if (options.defaultKind) {
return buildMessagingTarget(options.defaultKind, trimmed, trimmed);
}
return buildMessagingTarget("channel", trimmed, trimmed);
}
export function resolveSlackChannelId(raw: string): string {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
return requireTargetKind({ platform: "Slack", target, kind: "channel" });
}
+16 -3
View File
@@ -8,6 +8,8 @@ export type TelegramGroupMembershipAuditEntry = {
ok: boolean;
status?: string | null;
error?: string | null;
matchKey?: string;
matchSource?: "id";
};
export type TelegramGroupMembershipAudit = {
@@ -105,9 +107,16 @@ export async function auditTelegramGroupMembership(params: {
isRecord(json) && json.ok === false && typeof json.description === "string"
? json.description
: `getChatMember failed (${res.status})`;
groups.push({ chatId, ok: false, status: null, error: desc });
continue;
}
groups.push({
chatId,
ok: false,
status: null,
error: desc,
matchKey: chatId,
matchSource: "id",
});
continue;
}
const status = isRecord((json as TelegramApiOk<unknown>).result)
? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null)
: null;
@@ -117,6 +126,8 @@ export async function auditTelegramGroupMembership(params: {
ok,
status,
error: ok ? null : "bot not in group",
matchKey: chatId,
matchSource: "id",
});
} catch (err) {
groups.push({
@@ -124,6 +135,8 @@ export async function auditTelegramGroupMembership(params: {
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
matchKey: chatId,
matchSource: "id",
});
}
}
+30
View File
@@ -5,6 +5,12 @@ export type NormalizedAllowFrom = {
hasEntries: boolean;
};
export type AllowFromMatch = {
allowed: boolean;
matchKey?: string;
matchSource?: "wildcard" | "id" | "username";
};
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
const hasWildcard = entries.includes("*");
@@ -40,3 +46,27 @@ export const isSenderAllowed = (params: {
if (!username) return false;
return allow.entriesLower.some((entry) => entry === username || entry === `@${username}`);
};
export const resolveSenderAllowMatch = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
}): AllowFromMatch => {
const { allow, senderId, senderUsername } = params;
if (allow.hasWildcard) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
if (!allow.hasEntries) return { allowed: false };
if (senderId && allow.entries.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
const username = senderUsername?.toLowerCase();
if (!username) return { allowed: false };
const entry = allow.entriesLower.find(
(candidate) => candidate === username || candidate === `@${username}`,
);
if (entry) {
return { allowed: true, matchKey: entry, matchSource: "username" };
}
return { allowed: false };
};
+20 -9
View File
@@ -34,7 +34,12 @@ import {
hasBotMention,
resolveTelegramForumThreadId,
} from "./bot/helpers.js";
import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
resolveSenderAllowMatch,
} from "./bot-access.js";
import { upsertTelegramPairingRequest } from "./pairing-store.js";
import type { TelegramContext } from "./bot/types.js";
@@ -174,14 +179,16 @@ export const buildTelegramMessageContext = async ({
if (dmPolicy !== "open") {
const candidate = String(chatId);
const senderUsername = msg.from?.username ?? "";
const allowMatch = resolveSenderAllowMatch({
allow: effectiveDmAllow,
senderId: candidate,
senderUsername,
});
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
const allowed =
effectiveDmAllow.hasWildcard ||
(effectiveDmAllow.hasEntries &&
isSenderAllowed({
allow: effectiveDmAllow,
senderId: candidate,
senderUsername,
}));
effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed);
if (!allowed) {
if (dmPolicy === "pairing") {
try {
@@ -207,6 +214,8 @@ export const buildTelegramMessageContext = async ({
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
matchKey: allowMatch.matchKey ?? "none",
matchSource: allowMatch.matchSource ?? "none",
},
"telegram pairing request",
);
@@ -228,7 +237,9 @@ export const buildTelegramMessageContext = async ({
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
}
} else {
logVerbose(`Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy})`);
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return null;
}
+44
View File
@@ -1159,6 +1159,50 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("inherits group allowlist + requireMention in topics", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
requireMention: false,
allowFrom: ["123456789"],
topics: {
"99": {},
},
},
},
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 123456789, username: "testuser" },
text: "hello",
date: 1736380800,
message_thread_id: 99,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("honors groups default when no explicit group override exists", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
+32 -9
View File
@@ -375,29 +375,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
);
}
const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim();
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv);
const webSearchProvider =
nextConfig.tools?.web?.search?.provider === "perplexity" ? "perplexity" : "brave";
const braveKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
const braveEnv = (process.env.BRAVE_API_KEY ?? "").trim();
const perplexityKey = (nextConfig.tools?.web?.search?.perplexity?.apiKey ?? "").trim();
const perplexityEnv = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const openRouterEnv = (process.env.OPENROUTER_API_KEY ?? "").trim();
const perplexityBaseUrl = (nextConfig.tools?.web?.search?.perplexity?.baseUrl ?? "").trim();
const hasWebSearchKey =
webSearchProvider === "perplexity"
? Boolean(perplexityKey || perplexityEnv || openRouterEnv)
: Boolean(braveKey || braveEnv);
await prompter.note(
hasWebSearchKey
? [
"Web search is enabled, so your agent can look things up online when needed.",
"",
webSearchKey
? "API key: stored in config (tools.web.search.apiKey)."
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
webSearchProvider === "perplexity"
? perplexityKey
? "API key: stored in config (tools.web.search.perplexity.apiKey)."
: perplexityBaseUrl.includes("openrouter.ai")
? "API key: provided via OPENROUTER_API_KEY env var (Gateway environment)."
: perplexityBaseUrl.includes("api.perplexity.ai")
? "API key: provided via PERPLEXITY_API_KEY env var (Gateway environment)."
: perplexityEnv
? "API key: provided via PERPLEXITY_API_KEY env var (Gateway environment)."
: "API key: provided via OPENROUTER_API_KEY env var (Gateway environment)."
: braveKey
? "API key: stored in config (tools.web.search.apiKey)."
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n")
: [
"If you want your agent to be able to search the web, you’ll need an API key.",
"",
"Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.",
webSearchProvider === "perplexity"
? "Clawdbot can use Perplexity Sonar for the `web_search` tool. Without a Perplexity/OpenRouter API key, web search won’t work."
: "Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search won’t work.",
"",
"Set it up interactively:",
"- Run: clawdbot configure --section web",
"- Enable web_search and paste your Brave Search API key",
"- Enable web_search and paste your API key",
"",
"Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
webSearchProvider === "perplexity"
? "Alternative: set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment (no config changes)."
: "Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search (optional)",