openclaw/src/auto-reply/reply/commands-allowlist.ts

743 lines
24 KiB
TypeScript

import { getChannelDock } from "../../channels/dock.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
readConfigFileSnapshot,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { resolveIMessageAccount } from "../../imessage/accounts.js";
import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
import {
addChannelAllowFromStoreEntry,
readChannelAllowFromStore,
removeChannelAllowFromStoreEntry,
} from "../../pairing/pairing-store.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../../routing/session-key.js";
import { resolveSignalAccount } from "../../signal/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
type AllowlistScope = "dm" | "group" | "all";
type AllowlistAction = "list" | "add" | "remove";
type AllowlistTarget = "both" | "config" | "store";
type ResolvedAllowlistName = {
input: string;
resolved: boolean;
name?: string | null;
};
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: OpenClawConfig;
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 extractConfigAllowlist(account: {
config?: {
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: string;
groupPolicy?: string;
};
}) {
return {
dmAllowFrom: (account.config?.allowFrom ?? []).map(String),
groupAllowFrom: (account.config?.groupAllowFrom ?? []).map(String),
dmPolicy: account.config?.dmPolicy,
groupPolicy: account.config?.groupPolicy,
};
}
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);
if (isBlockedObjectKey(normalizedAccountId)) {
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: DEFAULT_ACCOUNT_ID };
}
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 existingAccount = Object.hasOwn(accounts, normalizedAccountId)
? accounts[normalizedAccountId]
: undefined;
if (!existingAccount || typeof existingAccount !== "object") {
accounts[normalizedAccountId] = {};
}
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 {
const supportsGroupAllowlist =
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage";
if (scope === "all") {
return null;
}
if (scope === "dm") {
if (channelId === "slack" || channelId === "discord") {
// Canonical DM allowlist location for Slack/Discord. Legacy: dm.allowFrom.
return ["allowFrom"];
}
if (supportsGroupAllowlist) {
return ["allowFrom"];
}
return null;
}
if (scope === "group") {
if (supportsGroupAllowlist) {
return ["groupAllowFrom"];
}
return null;
}
return null;
}
function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map<string, string> {
const map = new Map<string, string>();
for (const entry of entries) {
if (entry.resolved && entry.name) {
map.set(entry.input, entry.name);
}
}
return map;
}
async function resolveSlackNames(params: {
cfg: OpenClawConfig;
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 });
return mapResolvedAllowlistNames(resolved);
}
async function resolveDiscordNames(params: {
cfg: OpenClawConfig;
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 });
return mapResolvedAllowlistNames(resolved);
}
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}` } };
}
const unauthorized = rejectUnauthorizedCommand(params, "/allowlist");
if (unauthorized) {
return unauthorized;
}
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." },
};
}
if (parsed.account?.trim() && !normalizeOptionalAccountId(parsed.account)) {
return {
shouldContinue: false,
reply: {
text: "⚠️ Invalid account id. Reserved keys (__proto__, constructor, prototype) are blocked.",
},
};
}
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, process.env, accountId).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, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
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, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
} else if (channelId === "imessage") {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
} else if (channelId === "slack") {
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? account.config.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.allowFrom ?? 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") } };
}
const disabled = requireCommandFlagEnabled(params.cfg, {
label: "/allowlist edits",
configKey: "config",
disabledVerb: "are",
});
if (disabled) {
return disabled;
}
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 existing: string[] = [];
const existingPaths =
scope === "dm" && (channelId === "slack" || channelId === "discord")
? // Read both while legacy alias may still exist; write canonical below.
[allowlistPath, ["dm", "allowFrom"]]
: [allowlistPath];
for (const path of existingPaths) {
const existingRaw = getNestedValue(target, path);
if (!Array.isArray(existingRaw)) {
continue;
}
for (const entry of existingRaw) {
const value = String(entry).trim();
if (!value || existing.includes(value)) {
continue;
}
existing.push(value);
}
}
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 (scope === "dm" && (channelId === "slack" || channelId === "discord")) {
// Remove legacy DM allowlist alias to prevent drift.
deleteNestedValue(target, ["dm", "allowFrom"]);
}
}
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.` },
};
};