Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afefcdf8fb | ||
|
|
9fc3926607 | ||
|
|
22c7f659f6 | ||
|
|
79a44d0da4 | ||
|
|
d593a809f0 | ||
|
|
22add31e91 | ||
|
|
b44d740720 | ||
|
|
4d590f9254 | ||
|
|
a5aa48beea | ||
|
|
1bf3861ca4 | ||
|
|
f8052be369 | ||
|
|
a08438ae97 | ||
|
|
fe00d6aacf | ||
|
|
984692cda2 | ||
|
|
4c12c4fc04 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
+62
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"sessions_spawn": {
|
||||
"emoji": "🧑🔧",
|
||||
"title": "Sub-agent",
|
||||
"detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"]
|
||||
"detailKeys": ["label", "agentId", "thinking", "runTimeoutSeconds", "cleanup"]
|
||||
},
|
||||
"session_status": {
|
||||
"emoji": "📊",
|
||||
|
||||
@@ -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)) });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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" });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ChannelEntryMatch } from "../channel-config.js";
|
||||
export { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js";
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user