feat: add /allowlist command
This commit is contained in:
@@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
|
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Commands: add `/allowlist` slash command for listing and editing channel allowlists.
|
||||||
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||||
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
|
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ Text + native (when enabled):
|
|||||||
- `/commands`
|
- `/commands`
|
||||||
- `/skill <name> [input]` (run a skill by name)
|
- `/skill <name> [input]` (run a skill by name)
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||||
|
- `/allowlist` (list/add/remove allowlist entries)
|
||||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||||
@@ -93,6 +94,7 @@ Notes:
|
|||||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||||
|
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
||||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Show current status.",
|
description: "Show current status.",
|
||||||
textAlias: "/status",
|
textAlias: "/status",
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "allowlist",
|
||||||
|
description: "List/add/remove allowlist entries.",
|
||||||
|
textAlias: "/allowlist",
|
||||||
|
acceptsArgs: true,
|
||||||
|
scope: "text",
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "context",
|
key: "context",
|
||||||
nativeName: "context",
|
nativeName: "context",
|
||||||
|
|||||||
139
src/auto-reply/reply/commands-allowlist.test.ts
Normal file
139
src/auto-reply/reply/commands-allowlist.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||||
|
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||||
|
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||||
|
writeConfigFile: writeConfigFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||||
|
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||||
|
"../../pairing/pairing-store.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||||
|
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||||
|
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||||
|
"../../channels/plugins/pairing.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listPairingChannels: () => ["telegram"],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "telegram",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handleCommands /allowlist", () => {
|
||||||
|
it("lists config + store allowFrom entries", async () => {
|
||||||
|
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist list dm", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Channel: telegram");
|
||||||
|
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||||
|
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds entries to config and pairing store", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||||
|
changed: true,
|
||||||
|
allowFrom: ["123", "789"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist add dm 789", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||||
|
channel: "telegram",
|
||||||
|
entry: "789",
|
||||||
|
});
|
||||||
|
expect(result.reply?.text).toContain("DM allowlist added");
|
||||||
|
});
|
||||||
|
});
|
||||||
657
src/auto-reply/reply/commands-allowlist.ts
Normal file
657
src/auto-reply/reply/commands-allowlist.ts
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
import {
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
validateConfigObjectWithPlugins,
|
||||||
|
writeConfigFile,
|
||||||
|
} from "../../config/config.js";
|
||||||
|
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
|
||||||
|
import { getChannelDock } from "../../channels/dock.js";
|
||||||
|
import { normalizeChannelId } from "../../channels/registry.js";
|
||||||
|
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||||
|
import { resolveDiscordAccount } from "../../discord/accounts.js";
|
||||||
|
import { resolveIMessageAccount } from "../../imessage/accounts.js";
|
||||||
|
import { resolveSignalAccount } from "../../signal/accounts.js";
|
||||||
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
|
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||||
|
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||||
|
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
||||||
|
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
||||||
|
import {
|
||||||
|
addChannelAllowFromStoreEntry,
|
||||||
|
readChannelAllowFromStore,
|
||||||
|
removeChannelAllowFromStoreEntry,
|
||||||
|
} from "../../pairing/pairing-store.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
|
type AllowlistScope = "dm" | "group" | "all";
|
||||||
|
type AllowlistAction = "list" | "add" | "remove";
|
||||||
|
type AllowlistTarget = "both" | "config" | "store";
|
||||||
|
|
||||||
|
type AllowlistCommand =
|
||||||
|
| {
|
||||||
|
action: "list";
|
||||||
|
scope: AllowlistScope;
|
||||||
|
channel?: string;
|
||||||
|
account?: string;
|
||||||
|
resolve?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
action: "add" | "remove";
|
||||||
|
scope: AllowlistScope;
|
||||||
|
channel?: string;
|
||||||
|
account?: string;
|
||||||
|
entry: string;
|
||||||
|
resolve?: boolean;
|
||||||
|
target: AllowlistTarget;
|
||||||
|
}
|
||||||
|
| { action: "error"; message: string };
|
||||||
|
|
||||||
|
const ACTIONS = new Set(["list", "add", "remove"]);
|
||||||
|
const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
|
||||||
|
|
||||||
|
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
|
||||||
|
const rest = trimmed.slice("/allowlist".length).trim();
|
||||||
|
if (!rest) return { action: "list", scope: "dm" };
|
||||||
|
|
||||||
|
const tokens = rest.split(/\s+/);
|
||||||
|
let action: AllowlistAction = "list";
|
||||||
|
let scope: AllowlistScope = "dm";
|
||||||
|
let resolve = false;
|
||||||
|
let target: AllowlistTarget = "both";
|
||||||
|
let channel: string | undefined;
|
||||||
|
let account: string | undefined;
|
||||||
|
const entryTokens: string[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
if (tokens[i] && ACTIONS.has(tokens[i].toLowerCase())) {
|
||||||
|
action = tokens[i].toLowerCase() as AllowlistAction;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (tokens[i] && SCOPES.has(tokens[i].toLowerCase() as AllowlistScope)) {
|
||||||
|
scope = tokens[i].toLowerCase() as AllowlistScope;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (; i < tokens.length; i += 1) {
|
||||||
|
const token = tokens[i];
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (lowered === "--resolve" || lowered === "resolve") {
|
||||||
|
resolve = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--config" || lowered === "config") {
|
||||||
|
target = "config";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--store" || lowered === "store") {
|
||||||
|
target = "store";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--channel" && tokens[i + 1]) {
|
||||||
|
channel = tokens[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--account" && tokens[i + 1]) {
|
||||||
|
account = tokens[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kv = token.split("=");
|
||||||
|
if (kv.length === 2) {
|
||||||
|
const key = kv[0]?.trim().toLowerCase();
|
||||||
|
const value = kv[1]?.trim();
|
||||||
|
if (key === "channel") {
|
||||||
|
if (value) channel = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "account") {
|
||||||
|
if (value) account = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
|
||||||
|
scope = value.toLowerCase() as AllowlistScope;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryTokens.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "add" || action === "remove") {
|
||||||
|
const entry = entryTokens.join(" ").trim();
|
||||||
|
if (!entry) {
|
||||||
|
return { action: "error", message: "Usage: /allowlist add|remove <entry>" };
|
||||||
|
}
|
||||||
|
return { action, scope, entry, channel, account, resolve, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "list", scope, channel, account, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowFrom(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
channelId: ChannelId;
|
||||||
|
accountId?: string | null;
|
||||||
|
values: Array<string | number>;
|
||||||
|
}): string[] {
|
||||||
|
const dock = getChannelDock(params.channelId);
|
||||||
|
if (dock?.config?.formatAllowFrom) {
|
||||||
|
return dock.config.formatAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
|
allowFrom: params.values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return params.values.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
|
||||||
|
if (entries.length === 0) return "(none)";
|
||||||
|
return entries
|
||||||
|
.map((entry) => {
|
||||||
|
const name = resolved?.get(entry);
|
||||||
|
return name ? `${entry} (${name})` : entry;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccountTarget(
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
channelId: ChannelId,
|
||||||
|
accountId?: string | null,
|
||||||
|
) {
|
||||||
|
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
|
||||||
|
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
|
||||||
|
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
|
||||||
|
if (!useAccount) {
|
||||||
|
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
|
||||||
|
}
|
||||||
|
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
|
||||||
|
const account = (accounts[normalizedAccountId] ??= {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
target: account,
|
||||||
|
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!current || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNestedObject(
|
||||||
|
root: Record<string, unknown>,
|
||||||
|
path: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let current = root;
|
||||||
|
for (const key of path) {
|
||||||
|
const existing = current[key];
|
||||||
|
if (!existing || typeof existing !== "object") {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
|
||||||
|
if (path.length === 0) return;
|
||||||
|
if (path.length === 1) {
|
||||||
|
root[path[0]] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = ensureNestedObject(root, path.slice(0, -1));
|
||||||
|
parent[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
|
||||||
|
if (path.length === 0) return;
|
||||||
|
if (path.length === 1) {
|
||||||
|
delete root[path[0]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = getNestedValue(root, path.slice(0, -1));
|
||||||
|
if (!parent || typeof parent !== "object") return;
|
||||||
|
delete (parent as Record<string, unknown>)[path[path.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChannelAllowFromPaths(
|
||||||
|
channelId: ChannelId,
|
||||||
|
scope: AllowlistScope,
|
||||||
|
): string[] | null {
|
||||||
|
if (scope === "all") return null;
|
||||||
|
if (scope === "dm") {
|
||||||
|
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
|
||||||
|
if (
|
||||||
|
channelId === "telegram" ||
|
||||||
|
channelId === "whatsapp" ||
|
||||||
|
channelId === "signal" ||
|
||||||
|
channelId === "imessage"
|
||||||
|
) {
|
||||||
|
return ["allowFrom"];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (scope === "group") {
|
||||||
|
if (
|
||||||
|
channelId === "telegram" ||
|
||||||
|
channelId === "whatsapp" ||
|
||||||
|
channelId === "signal" ||
|
||||||
|
channelId === "imessage"
|
||||||
|
) {
|
||||||
|
return ["groupAllowFrom"];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSlackNames(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
entries: string[];
|
||||||
|
}) {
|
||||||
|
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const token = account.config.userToken?.trim() || account.botToken?.trim();
|
||||||
|
if (!token) return new Map<string, string>();
|
||||||
|
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDiscordNames(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
entries: string[];
|
||||||
|
}) {
|
||||||
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const token = account.token?.trim();
|
||||||
|
if (!token) return new Map<string, string>();
|
||||||
|
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
|
||||||
|
if (!parsed) return null;
|
||||||
|
if (parsed.action === "error") {
|
||||||
|
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
|
||||||
|
}
|
||||||
|
if (!params.command.isAuthorizedSender) {
|
||||||
|
logVerbose(
|
||||||
|
`Ignoring /allowlist from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
return { shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId =
|
||||||
|
normalizeChannelId(parsed.channel) ??
|
||||||
|
params.command.channelId ??
|
||||||
|
normalizeChannelId(params.command.channel);
|
||||||
|
if (!channelId) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ Unknown channel. Add channel=<id> to the command." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
|
||||||
|
const scope = parsed.scope;
|
||||||
|
|
||||||
|
if (parsed.action === "list") {
|
||||||
|
const pairingChannels = listPairingChannels();
|
||||||
|
const supportsStore = pairingChannels.includes(channelId);
|
||||||
|
const storeAllowFrom = supportsStore
|
||||||
|
? await readChannelAllowFromStore(channelId).catch(() => [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let dmAllowFrom: string[] = [];
|
||||||
|
let groupAllowFrom: string[] = [];
|
||||||
|
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
|
||||||
|
let dmPolicy: string | undefined;
|
||||||
|
let groupPolicy: string | undefined;
|
||||||
|
|
||||||
|
if (channelId === "telegram") {
|
||||||
|
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.config.dmPolicy;
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
const groups = account.config.groups ?? {};
|
||||||
|
for (const [groupId, groupCfg] of Object.entries(groups)) {
|
||||||
|
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
groupOverrides.push({ label: groupId, entries });
|
||||||
|
}
|
||||||
|
const topics = groupCfg?.topics ?? {};
|
||||||
|
for (const [topicId, topicCfg] of Object.entries(topics)) {
|
||||||
|
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||||
|
if (topicEntries.length > 0) {
|
||||||
|
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (channelId === "whatsapp") {
|
||||||
|
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.dmPolicy;
|
||||||
|
groupPolicy = account.groupPolicy;
|
||||||
|
} else if (channelId === "signal") {
|
||||||
|
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.config.dmPolicy;
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
} else if (channelId === "imessage") {
|
||||||
|
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.config.dmPolicy;
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
} else if (channelId === "slack") {
|
||||||
|
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
|
||||||
|
groupPolicy = account.groupPolicy;
|
||||||
|
const channels = account.channels ?? {};
|
||||||
|
groupOverrides = Object.entries(channels)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const entries = (value?.users ?? []).map(String).filter(Boolean);
|
||||||
|
return entries.length > 0 ? { label: key, entries } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
|
||||||
|
} else if (channelId === "discord") {
|
||||||
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
const guilds = account.config.guilds ?? {};
|
||||||
|
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
|
||||||
|
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
groupOverrides.push({ label: `guild ${guildKey}`, entries });
|
||||||
|
}
|
||||||
|
const channels = guildCfg?.channels ?? {};
|
||||||
|
for (const [channelKey, channelCfg] of Object.entries(channels)) {
|
||||||
|
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
|
||||||
|
if (channelEntries.length > 0) {
|
||||||
|
groupOverrides.push({
|
||||||
|
label: `guild ${guildKey} / channel ${channelKey}`,
|
||||||
|
entries: channelEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmDisplay = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: dmAllowFrom,
|
||||||
|
});
|
||||||
|
const groupDisplay = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: groupAllowFrom,
|
||||||
|
});
|
||||||
|
const groupOverrideEntries = groupOverrides.flatMap((entry) => entry.entries);
|
||||||
|
const groupOverrideDisplay = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: groupOverrideEntries,
|
||||||
|
});
|
||||||
|
const resolvedDm =
|
||||||
|
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
|
||||||
|
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||||
|
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
|
||||||
|
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||||
|
: undefined;
|
||||||
|
const resolvedGroup =
|
||||||
|
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
|
||||||
|
? await resolveSlackNames({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId,
|
||||||
|
entries: groupOverrideDisplay,
|
||||||
|
})
|
||||||
|
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
|
||||||
|
? await resolveDiscordNames({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId,
|
||||||
|
entries: groupOverrideDisplay,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const lines: string[] = ["🧾 Allowlist"];
|
||||||
|
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
|
||||||
|
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
|
||||||
|
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
|
||||||
|
|
||||||
|
const showDm = scope === "dm" || scope === "all";
|
||||||
|
const showGroup = scope === "group" || scope === "all";
|
||||||
|
if (showDm) {
|
||||||
|
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
|
||||||
|
}
|
||||||
|
if (supportsStore && storeAllowFrom.length > 0) {
|
||||||
|
const storeLabel = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: storeAllowFrom,
|
||||||
|
});
|
||||||
|
lines.push(`Paired allowFrom (store): ${formatEntryList(storeLabel)}`);
|
||||||
|
}
|
||||||
|
if (showGroup) {
|
||||||
|
if (groupAllowFrom.length > 0) {
|
||||||
|
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
|
||||||
|
}
|
||||||
|
if (groupOverrides.length > 0) {
|
||||||
|
lines.push("Group overrides:");
|
||||||
|
for (const entry of groupOverrides) {
|
||||||
|
const normalized = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: entry.entries,
|
||||||
|
});
|
||||||
|
lines.push(`- ${entry.label}: ${formatEntryList(normalized, resolvedGroup)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.cfg.commands?.config !== true) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ /allowlist edits are disabled. Set commands.config=true to enable." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUpdateConfig = parsed.target !== "store";
|
||||||
|
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
|
||||||
|
|
||||||
|
if (shouldUpdateConfig) {
|
||||||
|
const allowWrites = resolveChannelConfigWrites({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
});
|
||||||
|
if (!allowWrites) {
|
||||||
|
const hint = `channels.${channelId}.configWrites=true`;
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
|
||||||
|
if (!allowlistPath) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ Config file is invalid; fix it before using /allowlist." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
|
||||||
|
const {
|
||||||
|
target,
|
||||||
|
pathPrefix,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||||
|
const existingRaw = getNestedValue(target, allowlistPath);
|
||||||
|
const existing = Array.isArray(existingRaw)
|
||||||
|
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizedEntry = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
values: [parsed.entry],
|
||||||
|
});
|
||||||
|
if (normalizedEntry.length === 0) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ Invalid allowlist entry." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNormalized = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
values: existing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldMatch = (value: string) => normalizedEntry.includes(value);
|
||||||
|
|
||||||
|
let configChanged = false;
|
||||||
|
let next = existing;
|
||||||
|
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
|
||||||
|
if (parsed.action === "add") {
|
||||||
|
if (!configHasEntry) {
|
||||||
|
next = [...existing, parsed.entry.trim()];
|
||||||
|
configChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.action === "remove") {
|
||||||
|
const keep: string[] = [];
|
||||||
|
for (const entry of existing) {
|
||||||
|
const normalized = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
values: [entry],
|
||||||
|
});
|
||||||
|
if (normalized.some((value) => shouldMatch(value))) {
|
||||||
|
configChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
keep.push(entry);
|
||||||
|
}
|
||||||
|
next = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configChanged) {
|
||||||
|
if (next.length === 0) {
|
||||||
|
deleteNestedValue(target, allowlistPath);
|
||||||
|
} else {
|
||||||
|
setNestedValue(target, allowlistPath, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configChanged) {
|
||||||
|
const validated = validateConfigObjectWithPlugins(parsedConfig);
|
||||||
|
if (!validated.ok) {
|
||||||
|
const issue = validated.issues[0];
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await writeConfigFile(validated.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configChanged && !shouldTouchStore) {
|
||||||
|
const message = parsed.action === "add" ? "✅ Already allowlisted." : "⚠️ Entry not found.";
|
||||||
|
return { shouldContinue: false, reply: { text: message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldTouchStore) {
|
||||||
|
if (parsed.action === "add") {
|
||||||
|
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
} else if (parsed.action === "remove") {
|
||||||
|
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||||
|
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||||
|
const locations: string[] = [];
|
||||||
|
if (configChanged) {
|
||||||
|
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
|
||||||
|
}
|
||||||
|
if (shouldTouchStore) {
|
||||||
|
locations.push("pairing store");
|
||||||
|
}
|
||||||
|
const targetLabel = locations.length > 0 ? locations.join(" + ") : "no-op";
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text: `✅ ${scopeLabel} allowlist ${actionLabel}: ${targetLabel}.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldTouchStore) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ This channel does not support allowlist storage." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.action === "add") {
|
||||||
|
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
} else if (parsed.action === "remove") {
|
||||||
|
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||||
|
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` },
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
} from "./commands-info.js";
|
} from "./commands-info.js";
|
||||||
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||||
import {
|
import {
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
@@ -37,6 +38,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleHelpCommand,
|
handleHelpCommand,
|
||||||
handleCommandsListCommand,
|
handleCommandsListCommand,
|
||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
|
handleAllowlistCommand,
|
||||||
handleContextCommand,
|
handleContextCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
handleSubagentsCommand,
|
handleSubagentsCommand,
|
||||||
|
|||||||
@@ -245,6 +245,37 @@ export async function addChannelAllowFromStoreEntry(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeChannelAllowFromStoreEntry(params: {
|
||||||
|
channel: PairingChannel;
|
||||||
|
entry: string | number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const filePath = resolveAllowFromPath(params.channel, env);
|
||||||
|
return await withFileLock(
|
||||||
|
filePath,
|
||||||
|
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
|
||||||
|
async () => {
|
||||||
|
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
||||||
|
version: 1,
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
|
||||||
|
.map((v) => normalizeAllowEntry(params.channel, String(v)))
|
||||||
|
.filter(Boolean);
|
||||||
|
const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
|
||||||
|
if (!normalized) return { changed: false, allowFrom: current };
|
||||||
|
const next = current.filter((entry) => entry !== normalized);
|
||||||
|
if (next.length === current.length) return { changed: false, allowFrom: current };
|
||||||
|
await writeJsonFile(filePath, {
|
||||||
|
version: 1,
|
||||||
|
allowFrom: next,
|
||||||
|
} satisfies AllowFromStore);
|
||||||
|
return { changed: true, allowFrom: next };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listChannelPairingRequests(
|
export async function listChannelPairingRequests(
|
||||||
channel: PairingChannel,
|
channel: PairingChannel,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
|||||||
Reference in New Issue
Block a user