feat(channels): add resolve command + defaults

This commit is contained in:
Peter Steinberger
2026-01-18 00:41:57 +00:00
parent b543339373
commit c7ea47e886
60 changed files with 4418 additions and 101 deletions

View File

@@ -9,11 +9,9 @@ import {
collectDiscordAuditChannelIds,
} from "../../discord/audit.js";
import { probeDiscord } from "../../discord/probe.js";
import {
listGuildChannelsDiscord,
sendMessageDiscord,
sendPollDiscord,
} from "../../discord/send.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getChatChannelMeta } from "../registry.js";
@@ -42,6 +40,10 @@ import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
const meta = getChatChannelMeta("discord");
@@ -123,9 +125,10 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
};
},
collectWarnings: ({ account }) => {
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const groupPolicy = account.config.groupPolicy ?? "allowlist";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
const channelAllowlistConfigured = guildsConfigured;
@@ -165,29 +168,41 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
self: async () => null,
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
listGroupsLive: async ({ cfg, accountId, query, limit }) => {
listPeersLive: async (params) => listDiscordDirectoryPeersLive(params),
listGroupsLive: async (params) => listDiscordDirectoryGroupsLive(params),
},
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const account = resolveDiscordAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
const guildIds = Object.keys(account.config.guilds ?? {}).filter((id) => /^\d+$/.test(id));
const rows: Array<{ kind: "group"; id: string; name?: string; raw?: unknown }> = [];
for (const guildId of guildIds) {
const channels = await listGuildChannelsDiscord(guildId, {
accountId: account.accountId,
});
for (const channel of channels) {
const name = typeof channel.name === "string" ? channel.name : undefined;
if (q && name && !name.toLowerCase().includes(q)) continue;
rows.push({
kind: "group",
id: `channel:${channel.id}`,
name: name ?? undefined,
raw: channel,
});
}
const token = account.token?.trim();
if (!token) {
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Discord token",
}));
}
const filtered = q ? rows.filter((row) => row.name?.toLowerCase().includes(q)) : rows;
const limited = typeof limit === "number" && limit > 0 ? filtered.slice(0, limit) : filtered;
return limited;
if (kind === "group") {
const resolved = await resolveDiscordChannelAllowlist({ token, entries: inputs });
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.channelId ?? entry.guildId,
name:
entry.channelName ??
entry.guildName ??
(entry.guildId && !entry.channelId ? entry.guildId : undefined),
note: entry.note,
}));
}
const resolved = await resolveDiscordUserAllowlist({ token, entries: inputs });
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
},
},
actions: discordMessageActions,

View File

@@ -95,8 +95,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
approveHint: formatPairingApproveHint("imessage"),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`,

View File

@@ -0,0 +1,93 @@
import type { WizardPrompter } from "../../../wizard/prompts.js";
export type ChannelAccessPolicy = "allowlist" | "open" | "disabled";
export function parseAllowlistEntries(raw: string): string[] {
return String(raw ?? "")
.split(/[,\n]/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
export function formatAllowlistEntries(entries: string[]): string {
return entries.map((entry) => entry.trim()).filter(Boolean).join(", ");
}
export async function promptChannelAccessPolicy(params: {
prompter: WizardPrompter;
label: string;
currentPolicy?: ChannelAccessPolicy;
allowOpen?: boolean;
allowDisabled?: boolean;
}): Promise<ChannelAccessPolicy> {
const options: Array<{ value: ChannelAccessPolicy; label: string }> = [
{ value: "allowlist", label: "Allowlist (recommended)" },
];
if (params.allowOpen !== false) {
options.push({ value: "open", label: "Open (allow all channels)" });
}
if (params.allowDisabled !== false) {
options.push({ value: "disabled", label: "Disabled (block all channels)" });
}
const initialValue = params.currentPolicy ?? "allowlist";
return (await params.prompter.select({
message: `${params.label} access`,
options,
initialValue,
})) as ChannelAccessPolicy;
}
export async function promptChannelAllowlist(params: {
prompter: WizardPrompter;
label: string;
currentEntries?: string[];
placeholder?: string;
}): Promise<string[]> {
const initialValue =
params.currentEntries && params.currentEntries.length > 0
? formatAllowlistEntries(params.currentEntries)
: undefined;
const raw = await params.prompter.text({
message: `${params.label} allowlist (comma-separated)`,
placeholder: params.placeholder,
initialValue,
});
return parseAllowlistEntries(raw);
}
export async function promptChannelAccessConfig(params: {
prompter: WizardPrompter;
label: string;
currentPolicy?: ChannelAccessPolicy;
currentEntries?: string[];
placeholder?: string;
allowOpen?: boolean;
allowDisabled?: boolean;
defaultPrompt?: boolean;
updatePrompt?: boolean;
}): Promise<{ policy: ChannelAccessPolicy; entries: string[] } | null> {
const hasEntries = (params.currentEntries ?? []).length > 0;
const shouldPrompt = params.defaultPrompt ?? !hasEntries;
const wants = await params.prompter.confirm({
message: params.updatePrompt
? `Update ${params.label} access?`
: `Configure ${params.label} access?`,
initialValue: shouldPrompt,
});
if (!wants) return null;
const policy = await promptChannelAccessPolicy({
prompter: params.prompter,
label: params.label,
currentPolicy: params.currentPolicy,
allowOpen: params.allowOpen,
allowDisabled: params.allowDisabled,
});
if (policy !== "allowlist") return { policy, entries: [] };
const entries = await promptChannelAllowlist({
prompter: params.prompter,
label: params.label,
currentEntries: params.currentEntries,
placeholder: params.placeholder,
});
return { policy, entries };
}

View File

@@ -5,10 +5,13 @@ import {
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../../discord/accounts.js";
import { normalizeDiscordSlug } from "../../../discord/monitor/allow-list.js";
import { resolveDiscordChannelAllowlist } from "../../../discord/resolve-channels.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { promptChannelAccessConfig } from "./channel-access.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "discord" as const;
@@ -46,6 +49,103 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
);
}
function setDiscordGroupPolicy(
cfg: ClawdbotConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
enabled: true,
groupPolicy,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
enabled: true,
accounts: {
...cfg.channels?.discord?.accounts,
[accountId]: {
...cfg.channels?.discord?.accounts?.[accountId],
enabled: cfg.channels?.discord?.accounts?.[accountId]?.enabled ?? true,
groupPolicy,
},
},
},
},
};
}
function setDiscordGuildChannelAllowlist(
cfg: ClawdbotConfig,
accountId: string,
entries: Array<{
guildKey: string;
channelKey?: string;
}>,
): ClawdbotConfig {
const baseGuilds =
accountId === DEFAULT_ACCOUNT_ID
? (cfg.channels?.discord?.guilds ?? {})
: (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {});
const guilds: Record<string, { channels?: Record<string, { allow: boolean }> }> = {
...baseGuilds,
};
for (const entry of entries) {
const guildKey = entry.guildKey || "*";
const existing = guilds[guildKey] ?? {};
if (entry.channelKey) {
const channels = { ...(existing.channels ?? {}) };
channels[entry.channelKey] = { allow: true };
guilds[guildKey] = { ...existing, channels };
} else {
guilds[guildKey] = existing;
}
}
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
enabled: true,
guilds,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
enabled: true,
accounts: {
...cfg.channels?.discord?.accounts,
[accountId]: {
...cfg.channels?.discord?.accounts?.[accountId],
enabled: cfg.channels?.discord?.accounts?.[accountId]?.enabled ?? true,
guilds,
},
},
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Discord",
channel,
@@ -174,6 +274,91 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap(
([guildKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) return [guildKey];
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
},
);
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Discord channels",
currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist",
currentEntries,
placeholder: "My Server/#general, guildId/channelId, #support",
updatePrompt: Boolean(resolvedAccount.config.guilds),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setDiscordGroupPolicy(next, discordAccountId, accessConfig.policy);
} else {
const accountWithTokens = resolveDiscordAccount({
cfg: next,
accountId: discordAccountId,
});
let resolved = accessConfig.entries.map((input) => ({ input, resolved: false }));
if (accountWithTokens.token && accessConfig.entries.length > 0) {
try {
resolved = await resolveDiscordChannelAllowlist({
token: accountWithTokens.token,
entries: accessConfig.entries,
});
const resolvedChannels = resolved.filter(
(entry) => entry.resolved && entry.channelId,
);
const resolvedGuilds = resolved.filter(
(entry) => entry.resolved && entry.guildId && !entry.channelId,
);
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
if (resolvedChannels.length > 0 || resolvedGuilds.length > 0 || unresolved.length > 0) {
const summary: string[] = [];
if (resolvedChannels.length > 0) {
summary.push(
`Resolved channels: ${resolvedChannels
.map((entry) => entry.channelId)
.filter(Boolean)
.join(", ")}`,
);
}
if (resolvedGuilds.length > 0) {
summary.push(
`Resolved guilds: ${resolvedGuilds
.map((entry) => entry.guildId)
.filter(Boolean)
.join(", ")}`,
);
}
if (unresolved.length > 0) {
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
}
await prompter.note(summary.join("\n"), "Discord channels");
}
} catch (err) {
await prompter.note(
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
"Discord channels",
);
}
}
const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = [];
for (const entry of resolved) {
const guildKey =
entry.guildId ??
(entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ??
"*";
const channelKey =
entry.channelId ??
(entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined);
if (!channelKey && guildKey === "*") continue;
allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) });
}
next = setDiscordGroupPolicy(next, discordAccountId, "allowlist");
next = setDiscordGuildChannelAllowlist(next, discordAccountId, allowlistEntries);
}
}
return { cfg: next, accountId: discordAccountId };
},
dmPolicy,

View File

@@ -6,9 +6,11 @@ import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../../../slack/accounts.js";
import { resolveSlackChannelAllowlist } from "../../../slack/resolve-channels.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { promptChannelAccessConfig } from "./channel-access.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "slack" as const;
@@ -121,6 +123,85 @@ async function noteSlackTokenHelp(prompter: WizardPrompter, botName: string): Pr
);
}
function setSlackGroupPolicy(
cfg: ClawdbotConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
enabled: true,
groupPolicy,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
enabled: true,
accounts: {
...cfg.channels?.slack?.accounts,
[accountId]: {
...cfg.channels?.slack?.accounts?.[accountId],
enabled: cfg.channels?.slack?.accounts?.[accountId]?.enabled ?? true,
groupPolicy,
},
},
},
},
};
}
function setSlackChannelAllowlist(
cfg: ClawdbotConfig,
accountId: string,
channelKeys: string[],
): ClawdbotConfig {
const channels = Object.fromEntries(
channelKeys.map((key) => [key, { allow: true }]),
);
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
enabled: true,
channels,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
enabled: true,
accounts: {
...cfg.channels?.slack?.accounts,
[accountId]: {
...cfg.channels?.slack?.accounts?.[accountId],
enabled: cfg.channels?.slack?.accounts?.[accountId]?.enabled ?? true,
channels,
},
},
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Slack",
channel,
@@ -284,6 +365,68 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Slack channels",
currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist",
currentEntries: Object.entries(resolvedAccount.config.channels ?? {})
.filter(([, value]) => value?.allow !== false && value?.enabled !== false)
.map(([key]) => key),
placeholder: "#general, #private, C123",
updatePrompt: Boolean(resolvedAccount.config.channels),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setSlackGroupPolicy(next, slackAccountId, accessConfig.policy);
} else {
let keys = accessConfig.entries;
const accountWithTokens = resolveSlackAccount({
cfg: next,
accountId: slackAccountId,
});
if (accountWithTokens.botToken && accessConfig.entries.length > 0) {
try {
const resolved = await resolveSlackChannelAllowlist({
token: accountWithTokens.botToken,
entries: accessConfig.entries,
});
const resolvedKeys = resolved
.filter((entry) => entry.resolved && entry.id)
.map((entry) => entry.id as string);
const unresolved = resolved
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
keys = [
...resolvedKeys,
...unresolved.map((entry) => entry.trim()).filter(Boolean),
];
if (resolvedKeys.length > 0 || unresolved.length > 0) {
await prompter.note(
[
resolvedKeys.length > 0
? `Resolved: ${resolvedKeys.join(", ")}`
: undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Slack channels",
);
}
} catch (err) {
await prompter.note(
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
"Slack channels",
);
}
}
next = setSlackGroupPolicy(next, slackAccountId, "allowlist");
next = setSlackChannelAllowlist(next, slackAccountId, keys);
}
}
return { cfg: next, accountId: slackAccountId };
},
dmPolicy,

View File

@@ -108,8 +108,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- Signal groups: groupPolicy="open" allows any member to trigger the bot. Set channels.signal.groupPolicy="allowlist" + channels.signal.groupAllowFrom to restrict senders.`,

View File

@@ -9,6 +9,8 @@ import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../../slack/accounts.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js";
import { getChatChannelMeta } from "../registry.js";
@@ -32,6 +34,10 @@ import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./directory-config.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
const meta = getChatChannelMeta("slack");
@@ -138,9 +144,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
};
},
collectWarnings: ({ account }) => {
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const groupPolicy = account.config.groupPolicy ?? "allowlist";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const channelAllowlistConfigured =
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0;
@@ -190,6 +197,39 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
self: async () => null,
listPeers: async (params) => listSlackDirectoryPeersFromConfig(params),
listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params),
listPeersLive: async (params) => listSlackDirectoryPeersLive(params),
listGroupsLive: async (params) => listSlackDirectoryGroupsLive(params),
},
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const account = resolveSlackAccount({ cfg, accountId });
const token = account.config.userToken?.trim() || account.botToken?.trim();
if (!token) {
return inputs.map((input) => ({
input,
resolved: false,
note: "missing Slack token",
}));
}
if (kind === "group") {
const resolved = await resolveSlackChannelAllowlist({ token, entries: inputs });
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.archived ? "archived" : undefined,
}));
}
const resolved = await resolveSlackUserAllowlist({ token, entries: inputs });
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
},
},
actions: {
listActions: ({ cfg }) => {

View File

@@ -141,8 +141,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const groupAllowlistConfigured =
account.config.groups && Object.keys(account.config.groups).length > 0;

View File

@@ -263,6 +263,26 @@ export type ChannelDirectoryAdapter = {
}) => Promise<ChannelDirectoryEntry[]>;
};
export type ChannelResolveKind = "user" | "group";
export type ChannelResolveResult = {
input: string;
resolved: boolean;
id?: string;
name?: string;
note?: string;
};
export type ChannelResolverAdapter = {
resolveTargets: (params: {
cfg: ClawdbotConfig;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime: RuntimeEnv;
}) => Promise<ChannelResolveResult[]>;
};
export type ChannelElevatedAdapter = {
allowFromFallback?: (params: {
cfg: ClawdbotConfig;

View File

@@ -236,6 +236,7 @@ export type ChannelDirectoryEntry = {
name?: string;
handle?: string;
avatarUrl?: string;
rank?: number;
raw?: unknown;
};

View File

@@ -4,6 +4,7 @@ import type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelResolverAdapter,
ChannelElevatedAdapter,
ChannelGatewayAdapter,
ChannelGroupAdapter,
@@ -68,6 +69,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;
directory?: ChannelDirectoryAdapter;
resolver?: ChannelResolverAdapter;
actions?: ChannelMessageActionAdapter;
heartbeat?: ChannelHeartbeatAdapter;
// Channel-owned agent tools (login flows, etc.).

View File

@@ -9,6 +9,9 @@ export type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelResolverAdapter,
ChannelElevatedAdapter,
ChannelGatewayAdapter,
ChannelGatewayContext,

View File

@@ -149,8 +149,9 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
normalizeEntry: (raw) => normalizeE164(raw),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.groupPolicy ?? "allowlist";
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const groupAllowlistConfigured =
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0;

View File

@@ -6,6 +6,7 @@ import {
channelsListCommand,
channelsLogsCommand,
channelsRemoveCommand,
channelsResolveCommand,
channelsStatusCommand,
} from "../commands/channels.js";
import { danger } from "../globals.js";
@@ -105,6 +106,32 @@ export function registerChannelsCli(program: Command) {
}
});
channels
.command("resolve")
.description("Resolve channel/user names to IDs")
.argument("<entries...>", "Entries to resolve (names or ids)")
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (accountId)")
.option("--kind <kind>", "Target kind (auto|user|group)", "auto")
.option("--json", "Output JSON", false)
.action(async (entries, opts) => {
try {
await channelsResolveCommand(
{
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
kind: opts.kind as "auto" | "user" | "group",
json: Boolean(opts.json),
entries: Array.isArray(entries) ? entries : [String(entries)],
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
channels
.command("logs")
.description("Show recent channel logs from the gateway log file")

View File

@@ -8,5 +8,7 @@ export type { ChannelsLogsOptions } from "./channels/logs.js";
export { channelsLogsCommand } from "./channels/logs.js";
export type { ChannelsRemoveOptions } from "./channels/remove.js";
export { channelsRemoveCommand } from "./channels/remove.js";
export type { ChannelsResolveOptions } from "./channels/resolve.js";
export { channelsResolveCommand } from "./channels/resolve.js";
export type { ChannelsStatusOptions } from "./channels/status.js";
export { channelsStatusCommand, formatGatewayChannelsStatusLines } from "./channels/status.js";

View File

@@ -0,0 +1,131 @@
import { loadConfig } from "../../config/config.js";
import { danger } from "../../globals.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import type { RuntimeEnv } from "../../runtime.js";
export type ChannelsResolveOptions = {
channel?: string;
account?: string;
kind?: "auto" | "user" | "group" | "channel";
json?: boolean;
entries?: string[];
};
type ResolveResult = {
input: string;
resolved: boolean;
id?: string;
name?: string;
error?: string;
note?: string;
};
function resolvePreferredKind(kind?: ChannelsResolveOptions["kind"]): ChannelResolveKind | undefined {
if (!kind || kind === "auto") return undefined;
if (kind === "user") return "user";
return "group";
}
function detectAutoKind(input: string): ChannelResolveKind {
const trimmed = input.trim();
if (!trimmed) return "group";
if (trimmed.startsWith("@")) return "user";
if (/^<@!?/.test(trimmed)) return "user";
if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
return "user";
}
return "group";
}
function formatResolveResult(result: ResolveResult): string {
if (!result.resolved || !result.id) return `${result.input} -> unresolved`;
const name = result.name ? ` (${result.name})` : "";
const note = result.note ? ` [${result.note}]` : "";
return `${result.input} -> ${result.id}${name}${note}`;
}
export async function channelsResolveCommand(
opts: ChannelsResolveOptions,
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
const entries = (opts.entries ?? []).map((entry) => entry.trim()).filter(Boolean);
if (entries.length === 0) {
throw new Error("At least one entry is required.");
}
const selection = await resolveMessageChannelSelection({
cfg,
channel: opts.channel ?? null,
});
const plugin = getChannelPlugin(selection.channel);
if (!plugin?.resolver?.resolveTargets) {
throw new Error(`Channel ${selection.channel} does not support resolve.`);
}
const preferredKind = resolvePreferredKind(opts.kind);
let results: ResolveResult[] = [];
if (preferredKind) {
const resolved = await plugin.resolver.resolveTargets({
cfg,
accountId: opts.account ?? null,
inputs: entries,
kind: preferredKind,
runtime,
});
results = resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
} else {
const byKind = new Map<ChannelResolveKind, string[]>();
for (const entry of entries) {
const kind = detectAutoKind(entry);
byKind.set(kind, [...(byKind.get(kind) ?? []), entry]);
}
const resolved: ChannelResolveResult[] = [];
for (const [kind, inputs] of byKind.entries()) {
const batch = await plugin.resolver.resolveTargets({
cfg,
accountId: opts.account ?? null,
inputs,
kind,
runtime,
});
resolved.push(...batch);
}
const byInput = new Map(resolved.map((entry) => [entry.input, entry]));
results = entries.map((input) => {
const entry = byInput.get(input);
return {
input,
resolved: entry?.resolved ?? false,
id: entry?.id,
name: entry?.name,
note: entry?.note,
};
});
}
if (opts.json) {
runtime.log(JSON.stringify(results, null, 2));
return;
}
for (const result of results) {
if (result.resolved && result.id) {
runtime.log(formatResolveResult(result));
} else {
runtime.error(
danger(
`${result.input} -> unresolved${result.error ? ` (${result.error})` : result.note ? ` (${result.note})` : ""}`,
),
);
}
}
}

View File

@@ -5,8 +5,14 @@ import type { SignalConfig } from "./types.signal.js";
import type { SlackConfig } from "./types.slack.js";
import type { TelegramConfig } from "./types.telegram.js";
import type { WhatsAppConfig } from "./types.whatsapp.js";
import type { GroupPolicy } from "./types.base.js";
export type ChannelDefaultsConfig = {
groupPolicy?: GroupPolicy;
};
export type ChannelsConfig = {
defaults?: ChannelDefaultsConfig;
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;

View File

@@ -9,12 +9,18 @@ import {
TelegramConfigSchema,
} from "./zod-schema.providers-core.js";
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
import { GroupPolicySchema } from "./zod-schema.core.js";
export * from "./zod-schema.providers-core.js";
export * from "./zod-schema.providers-whatsapp.js";
export const ChannelsSchema = z
.object({
defaults: z
.object({
groupPolicy: GroupPolicySchema.optional(),
})
.optional(),
whatsapp: WhatsAppConfigSchema.optional(),
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),

View File

@@ -0,0 +1,104 @@
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { resolveDiscordAccount } from "./accounts.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordGuild = { id: string; name: string };
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
type DiscordMember = { user: DiscordUser; nick?: string | null };
type DiscordChannel = { id: string; name?: string | null };
async function fetchDiscord<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function buildUserRank(user: DiscordUser): number {
return user.bot ? 0 : 1;
}
export async function listDiscordDirectoryGroupsLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = normalizeDiscordToken(account.token);
if (!token) return [];
const query = normalizeQuery(params.query);
const guilds = await fetchDiscord<DiscordGuild[]>("/users/@me/guilds", token);
const rows: ChannelDirectoryEntry[] = [];
for (const guild of guilds) {
const channels = await fetchDiscord<DiscordChannel[]>(`/guilds/${guild.id}/channels`, token);
for (const channel of channels) {
const name = channel.name?.trim();
if (!name) continue;
if (query && !normalizeDiscordSlug(name).includes(normalizeDiscordSlug(query))) continue;
rows.push({
kind: "group",
id: `channel:${channel.id}`,
name,
handle: `#${name}`,
raw: channel,
});
if (typeof params.limit === "number" && params.limit > 0 && rows.length >= params.limit) {
return rows;
}
}
}
return rows;
}
export async function listDiscordDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = normalizeDiscordToken(account.token);
if (!token) return [];
const query = normalizeQuery(params.query);
if (!query) return [];
const guilds = await fetchDiscord<DiscordGuild[]>("/users/@me/guilds", token);
const rows: ChannelDirectoryEntry[] = [];
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 25;
for (const guild of guilds) {
const paramsObj = new URLSearchParams({
query,
limit: String(Math.min(limit, 100)),
});
const members = await fetchDiscord<DiscordMember[]>(
`/guilds/${guild.id}/members/search?${paramsObj.toString()}`,
token,
);
for (const member of members) {
const user = member.user;
if (!user?.id) continue;
const name = member.nick?.trim() || user.global_name?.trim() || user.username?.trim();
rows.push({
kind: "user",
id: `user:${user.id}`,
name: name || undefined,
handle: user.username ? `@${user.username}` : undefined,
rank: buildUserRank(user),
raw: member,
});
if (rows.length >= limit) return rows;
}
}
return rows;
}

View File

@@ -12,13 +12,15 @@ import {
} from "../../config/commands.js";
import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
import { createSubsystemLogger } from "../../logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveDiscordAccount } from "../accounts.js";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import { fetchDiscordApplicationId } from "../probe.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js";
import {
DiscordMessageListener,
@@ -58,6 +60,52 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
return `${sample.join(", ")}${suffix}`;
}
function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
const merged: string[] = [];
const push = (value: string) => {
const normalized = value.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
merged.push(normalized);
};
for (const entry of params.existing ?? []) {
push(String(entry));
}
for (const entry of params.additions) {
push(entry);
}
return merged;
}
function summarizeMapping(
label: string,
mapping: string[],
unresolved: string[],
runtime: RuntimeEnv,
) {
const lines: string[] = [];
if (mapping.length > 0) {
const sample = mapping.slice(0, 6);
const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : "";
lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`);
}
if (unresolved.length > 0) {
const sample = unresolved.slice(0, 6);
const suffix =
unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : "";
lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`);
}
if (lines.length > 0) {
runtime.log?.(lines.join("\n"));
}
}
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveDiscordAccount({
@@ -81,9 +129,22 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const discordCfg = account.config;
const dmConfig = discordCfg.dm;
const guildEntries = discordCfg.guilds;
const groupPolicy = discordCfg.groupPolicy ?? "open";
const allowFrom = dmConfig?.allowFrom;
let guildEntries = discordCfg.guilds;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = discordCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (
discordCfg.groupPolicy === undefined &&
discordCfg.guilds === undefined &&
defaultGroupPolicy === undefined &&
groupPolicy === "open"
) {
runtime.log?.(
warn(
'discord: groupPolicy defaults to "open" when channels.discord is missing; set channels.discord.groupPolicy (or channels.defaults.groupPolicy) or add channels.discord.guilds to restrict access.',
),
);
}
let allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, {
fallbackLimit: 2000,
@@ -115,6 +176,186 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const sessionPrefix = "discord:slash";
const ephemeralDefault = true;
if (token) {
if (guildEntries && Object.keys(guildEntries).length > 0) {
try {
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
if (guildKey === "*") continue;
const channels = guildCfg?.channels ?? {};
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
if (channelKeys.length === 0) {
entries.push({ input: guildKey, guildKey });
continue;
}
for (const channelKey of channelKeys) {
entries.push({
input: `${guildKey}/${channelKey}`,
guildKey,
channelKey,
});
}
}
if (entries.length > 0) {
const resolved = await resolveDiscordChannelAllowlist({
token,
entries: entries.map((entry) => entry.input),
});
const nextGuilds = { ...(guildEntries ?? {}) };
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of resolved) {
const source = entries.find((item) => item.input === entry.input);
if (!source) continue;
const sourceGuild = guildEntries?.[source.guildKey] ?? {};
if (!entry.resolved || !entry.guildId) {
unresolved.push(entry.input);
continue;
}
mapping.push(
entry.channelId
? `${entry.input}${entry.guildId}/${entry.channelId}`
: `${entry.input}${entry.guildId}`,
);
const existing = nextGuilds[entry.guildId] ?? {};
const mergedChannels = {
...(sourceGuild.channels ?? {}),
...(existing.channels ?? {}),
};
const mergedGuild = { ...sourceGuild, ...existing, channels: mergedChannels };
nextGuilds[entry.guildId] = mergedGuild;
if (source.channelKey && entry.channelId) {
const sourceChannel = sourceGuild.channels?.[source.channelKey];
if (sourceChannel) {
nextGuilds[entry.guildId] = {
...mergedGuild,
channels: {
...mergedChannels,
[entry.channelId]: {
...sourceChannel,
...(mergedChannels?.[entry.channelId] ?? {}),
},
},
};
}
}
}
guildEntries = nextGuilds;
summarizeMapping("discord channels", mapping, unresolved, runtime);
}
} catch (err) {
runtime.log?.(`discord channel resolve failed; using config entries. ${String(err)}`);
}
}
const allowEntries =
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
if (allowEntries.length > 0) {
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token,
entries: allowEntries.map((entry) => String(entry)),
});
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
for (const entry of resolvedUsers) {
if (entry.resolved && entry.id) {
mapping.push(`${entry.input}${entry.id}`);
additions.push(entry.id);
} else {
unresolved.push(entry.input);
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("discord users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(`discord user resolve failed; using config entries. ${String(err)}`);
}
}
if (guildEntries && Object.keys(guildEntries).length > 0) {
const userEntries = new Set<string>();
for (const guild of Object.values(guildEntries)) {
if (!guild || typeof guild !== "object") continue;
const users = (guild as { users?: Array<string | number> }).users;
if (Array.isArray(users)) {
for (const entry of users) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") userEntries.add(trimmed);
}
}
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
for (const channel of Object.values(channels)) {
if (!channel || typeof channel !== "object") continue;
const channelUsers = (channel as { users?: Array<string | number> }).users;
if (!Array.isArray(channelUsers)) continue;
for (const entry of channelUsers) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") userEntries.add(trimmed);
}
}
}
if (userEntries.size > 0) {
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token,
entries: Array.from(userEntries),
});
const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry]));
const mapping = resolvedUsers
.filter((entry) => entry.resolved && entry.id)
.map((entry) => `${entry.input}${entry.id}`);
const unresolved = resolvedUsers
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
const nextGuilds = { ...(guildEntries ?? {}) };
for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
if (!guildConfig || typeof guildConfig !== "object") continue;
const nextGuild = { ...guildConfig } as Record<string, unknown>;
const users = (guildConfig as { users?: Array<string | number> }).users;
if (Array.isArray(users) && users.length > 0) {
const additions: string[] = [];
for (const entry of users) {
const trimmed = String(entry).trim();
const resolved = resolvedMap.get(trimmed);
if (resolved?.resolved && resolved.id) additions.push(resolved.id);
}
nextGuild.users = mergeAllowlist({ existing: users, additions });
}
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
if (channels && typeof channels === "object") {
const nextChannels: Record<string, unknown> = { ...channels };
for (const [channelKey, channelConfig] of Object.entries(channels)) {
if (!channelConfig || typeof channelConfig !== "object") continue;
const channelUsers = (channelConfig as { users?: Array<string | number> }).users;
if (!Array.isArray(channelUsers) || channelUsers.length === 0) continue;
const additions: string[] = [];
for (const entry of channelUsers) {
const trimmed = String(entry).trim();
const resolved = resolvedMap.get(trimmed);
if (resolved?.resolved && resolved.id) additions.push(resolved.id);
}
nextChannels[channelKey] = {
...channelConfig,
users: mergeAllowlist({ existing: channelUsers, additions }),
};
}
nextGuild.channels = nextChannels;
}
nextGuilds[guildKey] = nextGuild;
}
guildEntries = nextGuilds;
summarizeMapping("discord channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(`discord channel user resolve failed; using config entries. ${String(err)}`);
}
}
}
}
if (shouldLogVerbose()) {
logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), { status: 200 });
}
describe("resolveDiscordChannelAllowlist", () => {
it("resolves guild/channel by name", async () => {
const fetcher = async (url: string) => {
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "g1", name: "My Guild" }]);
}
if (url.endsWith("/guilds/g1/channels")) {
return jsonResponse([
{ id: "c1", name: "general", guild_id: "g1", type: 0 },
{ id: "c2", name: "random", guild_id: "g1", type: 0 },
]);
}
return new Response("not found", { status: 404 });
};
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["My Guild/general"],
fetcher,
});
expect(res[0]?.resolved).toBe(true);
expect(res[0]?.guildId).toBe("g1");
expect(res[0]?.channelId).toBe("c1");
});
it("resolves channel id to guild", async () => {
const fetcher = async (url: string) => {
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "g1", name: "Guild One" }]);
}
if (url.endsWith("/channels/123")) {
return jsonResponse({ id: "123", name: "general", guild_id: "g1", type: 0 });
}
return new Response("not found", { status: 404 });
};
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["123"],
fetcher,
});
expect(res[0]?.resolved).toBe(true);
expect(res[0]?.guildId).toBe("g1");
expect(res[0]?.channelId).toBe("123");
});
});

View File

@@ -0,0 +1,317 @@
import type { RESTGetAPIChannelResult, RESTGetAPIGuildChannelsResult } from "discord-api-types/v10";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordGuildSummary = {
id: string;
name: string;
slug: string;
};
type DiscordChannelSummary = {
id: string;
name: string;
guildId: string;
type?: number;
archived?: boolean;
};
export type DiscordChannelResolution = {
input: string;
resolved: boolean;
guildId?: string;
guildName?: string;
channelId?: string;
channelName?: string;
archived?: boolean;
note?: string;
};
function parseDiscordChannelInput(raw: string): {
guild?: string;
channel?: string;
channelId?: string;
guildId?: string;
guildOnly?: boolean;
} {
const trimmed = raw.trim();
if (!trimmed) return {};
const mention = trimmed.match(/^<#(\d+)>$/);
if (mention) return { channelId: mention[1] };
const channelPrefix = trimmed.match(/^(?:channel:|discord:)?(\d+)$/i);
if (channelPrefix) return { channelId: channelPrefix[1] };
const guildPrefix = trimmed.match(/^(?:guild:|server:)?(\d+)$/i);
if (guildPrefix && !trimmed.includes("/") && !trimmed.includes("#")) {
return { guildId: guildPrefix[1], guildOnly: true };
}
const split = trimmed.includes("/") ? trimmed.split("/") : trimmed.split("#");
if (split.length >= 2) {
const guild = split[0]?.trim();
const channel = split.slice(1).join("#").trim();
if (!channel) {
return guild ? { guild: guild.trim(), guildOnly: true } : {};
}
if (guild && /^\d+$/.test(guild)) return { guildId: guild, channel };
return { guild, channel };
}
return { guild: trimmed, guildOnly: true };
}
async function fetchDiscord<T>(
path: string,
token: string,
fetcher: typeof fetch,
): Promise<T> {
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
async function listGuilds(token: string, fetcher: typeof fetch): Promise<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",
token,
fetcher,
);
return raw.map((guild) => ({
id: guild.id,
name: guild.name,
slug: normalizeDiscordSlug(guild.name),
}));
}
async function listGuildChannels(
token: string,
fetcher: typeof fetch,
guildId: string,
): Promise<DiscordChannelSummary[]> {
const raw = (await fetchDiscord(
`/guilds/${guildId}/channels`,
token,
fetcher,
)) as RESTGetAPIGuildChannelsResult;
return raw
.filter((channel) => Boolean(channel.id) && "name" in channel)
.map((channel) => ({
id: channel.id,
name: "name" in channel ? channel.name ?? "" : "",
guildId,
type: channel.type,
archived: "thread_metadata" in channel ? channel.thread_metadata?.archived : undefined,
}))
.filter((channel) => Boolean(channel.name));
}
async function fetchChannel(
token: string,
fetcher: typeof fetch,
channelId: string,
): Promise<DiscordChannelSummary | null> {
const raw = (await fetchDiscord(
`/channels/${channelId}`,
token,
fetcher,
)) as RESTGetAPIChannelResult;
if (!raw || !("guild_id" in raw)) return null;
return {
id: raw.id,
name: "name" in raw ? raw.name ?? "" : "",
guildId: raw.guild_id ?? "",
type: raw.type,
};
}
function preferActiveMatch(candidates: DiscordChannelSummary[]): DiscordChannelSummary | undefined {
if (candidates.length === 0) return undefined;
const scored = candidates.map((channel) => {
const isThread = channel.type === 11 || channel.type === 12;
const archived = Boolean(channel.archived);
const score = (archived ? 0 : 2) + (isThread ? 0 : 1);
return { channel, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.channel ?? candidates[0];
}
function resolveGuildByName(
guilds: DiscordGuildSummary[],
input: string,
): DiscordGuildSummary | undefined {
const slug = normalizeDiscordSlug(input);
if (!slug) return undefined;
return guilds.find((guild) => guild.slug === slug);
}
export async function resolveDiscordChannelAllowlist(params: {
token: string;
entries: string[];
fetcher?: typeof fetch;
}): Promise<DiscordChannelResolution[]> {
const token = normalizeDiscordToken(params.token);
if (!token)
return params.entries.map((input) => ({
input,
resolved: false,
}));
const fetcher = params.fetcher ?? fetch;
const guilds = await listGuilds(token, fetcher);
const channelsByGuild = new Map<string, Promise<DiscordChannelSummary[]>>();
const getChannels = (guildId: string) => {
const existing = channelsByGuild.get(guildId);
if (existing) return existing;
const promise = listGuildChannels(token, fetcher, guildId);
channelsByGuild.set(guildId, promise);
return promise;
};
const results: DiscordChannelResolution[] = [];
for (const input of params.entries) {
const parsed = parseDiscordChannelInput(input);
if (parsed.guildOnly) {
const guild =
parsed.guildId && guilds.find((entry) => entry.id === parsed.guildId)
? guilds.find((entry) => entry.id === parsed.guildId)
: parsed.guild
? resolveGuildByName(guilds, parsed.guild)
: undefined;
if (guild) {
results.push({
input,
resolved: true,
guildId: guild.id,
guildName: guild.name,
});
} else {
results.push({
input,
resolved: false,
guildId: parsed.guildId,
guildName: parsed.guild,
});
}
continue;
}
if (parsed.channelId) {
const channel = await fetchChannel(token, fetcher, parsed.channelId);
if (channel?.guildId) {
const guild = guilds.find((entry) => entry.id === channel.guildId);
results.push({
input,
resolved: true,
guildId: channel.guildId,
guildName: guild?.name,
channelId: channel.id,
channelName: channel.name,
archived: channel.archived,
});
} else {
results.push({
input,
resolved: false,
channelId: parsed.channelId,
});
}
continue;
}
if (parsed.guildId || parsed.guild) {
const guild =
parsed.guildId && guilds.find((entry) => entry.id === parsed.guildId)
? guilds.find((entry) => entry.id === parsed.guildId)
: parsed.guild
? resolveGuildByName(guilds, parsed.guild)
: undefined;
if (!guild || !parsed.channel) {
results.push({
input,
resolved: false,
guildId: parsed.guildId,
guildName: parsed.guild,
channelName: parsed.channel,
});
continue;
}
const channels = await getChannels(guild.id);
const matches = channels.filter(
(channel) => normalizeDiscordSlug(channel.name) === normalizeDiscordSlug(parsed.channel),
);
const match = preferActiveMatch(matches);
if (match) {
results.push({
input,
resolved: true,
guildId: guild.id,
guildName: guild.name,
channelId: match.id,
channelName: match.name,
archived: match.archived,
});
} else {
results.push({
input,
resolved: false,
guildId: guild.id,
guildName: guild.name,
channelName: parsed.channel,
note: `channel not found in guild ${guild.name}`,
});
}
continue;
}
const channelName = input.trim().replace(/^#/, "");
if (!channelName) {
results.push({
input,
resolved: false,
channelName: channelName,
});
continue;
}
const candidates: DiscordChannelSummary[] = [];
for (const guild of guilds) {
const channels = await getChannels(guild.id);
for (const channel of channels) {
if (normalizeDiscordSlug(channel.name) === normalizeDiscordSlug(channelName)) {
candidates.push(channel);
}
}
}
const match = preferActiveMatch(candidates);
if (match) {
const guild = guilds.find((entry) => entry.id === match.guildId);
results.push({
input,
resolved: true,
guildId: match.guildId,
guildName: guild?.name,
channelId: match.id,
channelName: match.name,
archived: match.archived,
note:
candidates.length > 1 && guild?.name
? `matched multiple; chose ${guild.name}`
: undefined,
});
continue;
}
results.push({
input,
resolved: false,
channelName: channelName,
});
}
return results;
}

View File

@@ -0,0 +1,178 @@
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordGuildSummary = {
id: string;
name: string;
slug: string;
};
type DiscordUser = {
id: string;
username: string;
discriminator?: string;
global_name?: string;
bot?: boolean;
};
type DiscordMember = {
user: DiscordUser;
nick?: string | null;
};
export type DiscordUserResolution = {
input: string;
resolved: boolean;
id?: string;
name?: string;
guildId?: string;
guildName?: string;
note?: string;
};
function parseDiscordUserInput(raw: string): {
userId?: string;
guildId?: string;
guildName?: string;
userName?: string;
} {
const trimmed = raw.trim();
if (!trimmed) return {};
const mention = trimmed.match(/^<@!?(\d+)>$/);
if (mention) return { userId: mention[1] };
const prefixed = trimmed.match(/^(?:user:|discord:)?(\d+)$/i);
if (prefixed) return { userId: prefixed[1] };
const split = trimmed.includes("/") ? trimmed.split("/") : trimmed.split("#");
if (split.length >= 2) {
const guild = split[0]?.trim();
const user = split.slice(1).join("#").trim();
if (guild && /^\d+$/.test(guild)) return { guildId: guild, userName: user };
return { guildName: guild, userName: user };
}
return { userName: trimmed.replace(/^@/, "") };
}
async function fetchDiscord<T>(path: string, token: string, fetcher: typeof fetch): Promise<T> {
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
async function listGuilds(token: string, fetcher: typeof fetch): Promise<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",
token,
fetcher,
);
return raw.map((guild) => ({
id: guild.id,
name: guild.name,
slug: normalizeDiscordSlug(guild.name),
}));
}
function scoreDiscordMember(member: DiscordMember, query: string): number {
const q = query.toLowerCase();
const user = member.user;
const candidates = [
user.username,
user.global_name,
member.nick ?? undefined,
]
.map((value) => value?.toLowerCase())
.filter(Boolean) as string[];
let score = 0;
if (candidates.some((value) => value === q)) score += 3;
if (candidates.some((value) => value?.includes(q))) score += 1;
if (!user.bot) score += 1;
return score;
}
export async function resolveDiscordUserAllowlist(params: {
token: string;
entries: string[];
fetcher?: typeof fetch;
}): Promise<DiscordUserResolution[]> {
const token = normalizeDiscordToken(params.token);
if (!token)
return params.entries.map((input) => ({
input,
resolved: false,
}));
const fetcher = params.fetcher ?? fetch;
const guilds = await listGuilds(token, fetcher);
const results: DiscordUserResolution[] = [];
for (const input of params.entries) {
const parsed = parseDiscordUserInput(input);
if (parsed.userId) {
results.push({
input,
resolved: true,
id: parsed.userId,
});
continue;
}
const query = parsed.userName?.trim();
if (!query) {
results.push({ input, resolved: false });
continue;
}
const guildList = parsed.guildId
? guilds.filter((g) => g.id === parsed.guildId)
: parsed.guildName
? guilds.filter((g) => g.slug === normalizeDiscordSlug(parsed.guildName))
: guilds;
let best: { member: DiscordMember; guild: DiscordGuildSummary; score: number } | null = null;
let matches = 0;
for (const guild of guildList) {
const paramsObj = new URLSearchParams({
query,
limit: "25",
});
const members = await fetchDiscord<DiscordMember[]>(
`/guilds/${guild.id}/members/search?${paramsObj.toString()}`,
token,
fetcher,
);
for (const member of members) {
const score = scoreDiscordMember(member, query);
if (score === 0) continue;
matches += 1;
if (!best || score > best.score) {
best = { member, guild, score };
}
}
}
if (best) {
const user = best.member.user;
const name =
best.member.nick?.trim() || user.global_name?.trim() || user.username?.trim() || undefined;
results.push({
input,
resolved: true,
id: user.id,
name,
guildId: best.guild.id,
guildName: best.guild.name,
note: matches > 1 ? "multiple matches; chose best" : undefined,
});
} else {
results.push({ input, resolved: false });
}
}
return results;
}

View File

@@ -105,7 +105,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
imessageCfg.groupAllowFrom ??
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []),
);
const groupPolicy = imessageCfg.groupPolicy ?? "open";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = imessageCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;

View File

@@ -16,6 +16,8 @@ import { ambiguousTargetError, unknownTargetError } from "./target-errors.js";
export type TargetResolveKind = ChannelDirectoryEntryKind | "channel";
export type ResolveAmbiguousMode = "error" | "best" | "first";
export type ResolvedMessagingTarget = {
to: string;
kind: TargetResolveKind;
@@ -249,6 +251,21 @@ async function getDirectoryEntries(params: {
return liveEntries;
}
function pickAmbiguousMatch(
entries: ChannelDirectoryEntry[],
mode: ResolveAmbiguousMode,
): ChannelDirectoryEntry | null {
if (entries.length === 0) return null;
if (mode === "first") return entries[0] ?? null;
const ranked = entries.map((entry) => ({
entry,
rank: typeof entry.rank === "number" ? entry.rank : 0,
}));
const bestRank = Math.max(...ranked.map((item) => item.rank));
const best = ranked.find((item) => item.rank === bestRank)?.entry;
return best ?? entries[0] ?? null;
}
export async function resolveMessagingTarget(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
@@ -256,6 +273,7 @@ export async function resolveMessagingTarget(params: {
accountId?: string | null;
preferredKind?: TargetResolveKind;
runtime?: RuntimeEnv;
resolveAmbiguous?: ResolveAmbiguousMode;
}): Promise<ResolveMessagingTargetResult> {
const raw = normalizeChannelTargetInput(params.input);
if (!raw) {
@@ -314,6 +332,21 @@ export async function resolveMessagingTarget(params: {
};
}
if (match.kind === "ambiguous") {
const mode = params.resolveAmbiguous ?? "error";
if (mode !== "error") {
const best = pickAmbiguousMatch(match.entries, mode);
if (best) {
return {
ok: true,
target: {
to: normalizeDirectoryEntryId(params.channel, best),
kind,
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
source: "directory",
},
};
}
}
return {
ok: false,
error: ambiguousTargetError(providerLabel, raw, hint),

View File

@@ -492,7 +492,9 @@ async function collectChannelSecurityFindings(params: {
});
const slashEnabled = nativeEnabled || nativeSkillsEnabled;
if (slashEnabled) {
const groupPolicy = (discordCfg.groupPolicy as string | undefined) ?? "allowlist";
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
@@ -652,7 +654,9 @@ async function collectChannelSecurityFindings(params: {
const telegramCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const groupPolicy = (telegramCfg.groupPolicy as string | undefined) ?? "allowlist";
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const groups = telegramCfg.groups as Record<string, unknown> | undefined;
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
const groupAccessPossible =

View File

@@ -273,7 +273,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
? accountInfo.config.allowFrom
: []),
);
const groupPolicy = accountInfo.config.groupPolicy ?? "allowlist";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = accountInfo.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const reactionMode = accountInfo.config.reactionNotifications ?? "own";
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;

163
src/slack/directory-live.ts Normal file
View File

@@ -0,0 +1,163 @@
import { WebClient } from "@slack/web-api";
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { resolveSlackAccount } from "./accounts.js";
type SlackUser = {
id?: string;
name?: string;
real_name?: string;
is_bot?: boolean;
is_app_user?: boolean;
deleted?: boolean;
profile?: {
display_name?: string;
real_name?: string;
email?: string;
};
};
type SlackChannel = {
id?: string;
name?: string;
is_archived?: boolean;
is_private?: boolean;
};
type SlackListUsersResponse = {
members?: SlackUser[];
response_metadata?: { next_cursor?: string };
};
type SlackListChannelsResponse = {
channels?: SlackChannel[];
response_metadata?: { next_cursor?: string };
};
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const userToken = account.config.userToken?.trim() || undefined;
return userToken ?? account.botToken?.trim();
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function buildUserRank(user: SlackUser): number {
let rank = 0;
if (!user.deleted) rank += 2;
if (!user.is_bot && !user.is_app_user) rank += 1;
return rank;
}
function buildChannelRank(channel: SlackChannel): number {
return channel.is_archived ? 0 : 1;
}
export async function listSlackDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) return [];
const client = new WebClient(token);
const query = normalizeQuery(params.query);
const members: SlackUser[] = [];
let cursor: string | undefined;
do {
const res = (await client.users.list({
limit: 200,
cursor,
})) as SlackListUsersResponse;
if (Array.isArray(res.members)) members.push(...res.members);
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = members.filter((member) => {
const name = member.profile?.display_name || member.profile?.real_name || member.real_name;
const handle = member.name;
const email = member.profile?.email;
const candidates = [name, handle, email].map((item) => item?.trim().toLowerCase()).filter(Boolean);
if (!query) return true;
return candidates.some((candidate) => candidate?.includes(query));
});
const rows = filtered
.map((member) => {
const id = member.id?.trim();
if (!id) return null;
const handle = member.name?.trim();
const display =
member.profile?.display_name?.trim() ||
member.profile?.real_name?.trim() ||
member.real_name?.trim() ||
handle;
return {
kind: "user",
id: `user:${id}`,
name: display || undefined,
handle: handle ? `@${handle}` : undefined,
rank: buildUserRank(member),
raw: member,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}
export async function listSlackDirectoryGroupsLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) return [];
const client = new WebClient(token);
const query = normalizeQuery(params.query);
const channels: SlackChannel[] = [];
let cursor: string | undefined;
do {
const res = (await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: false,
limit: 1000,
cursor,
})) as SlackListChannelsResponse;
if (Array.isArray(res.channels)) channels.push(...res.channels);
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = channels.filter((channel) => {
const name = channel.name?.trim().toLowerCase();
if (!query) return true;
return Boolean(name && name.includes(query));
});
const rows = filtered
.map((channel) => {
const id = channel.id?.trim();
const name = channel.name?.trim();
if (!id || !name) return null;
return {
kind: "group",
id: `channel:${id}`,
name,
handle: `#${name}`,
rank: buildChannelRank(channel),
raw: channel,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}

View File

@@ -5,10 +5,13 @@ import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
import { loadConfig } from "../../config/config.js";
import type { SessionScope } from "../../config/sessions.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import { warn } from "../../globals.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
@@ -25,10 +28,56 @@ function parseApiAppIdFromAppToken(raw?: string) {
return match?.[1]?.toUpperCase();
}
function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
const merged: string[] = [];
const push = (value: string) => {
const normalized = value.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
merged.push(normalized);
};
for (const entry of params.existing ?? []) {
push(String(entry));
}
for (const entry of params.additions) {
push(entry);
}
return merged;
}
function summarizeMapping(
label: string,
mapping: string[],
unresolved: string[],
runtime: RuntimeEnv,
) {
const lines: string[] = [];
if (mapping.length > 0) {
const sample = mapping.slice(0, 6);
const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : "";
lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`);
}
if (unresolved.length > 0) {
const sample = unresolved.slice(0, 6);
const suffix =
unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : "";
lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`);
}
if (lines.length > 0) {
runtime.log?.(lines.join("\n"));
}
}
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveSlackAccount({
let account = resolveSlackAccount({
cfg,
accountId: opts.accountId,
});
@@ -65,11 +114,128 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = (dmConfig?.policy ?? "pairing") as DmPolicy;
const allowFrom = dmConfig?.allowFrom;
let allowFrom = dmConfig?.allowFrom;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const channelsConfig = slackCfg.channels;
const groupPolicy = (slackCfg.groupPolicy ?? "open") as GroupPolicy;
let channelsConfig = slackCfg.channels;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = (slackCfg.groupPolicy ?? defaultGroupPolicy ?? "open") as GroupPolicy;
if (
slackCfg.groupPolicy === undefined &&
slackCfg.channels === undefined &&
defaultGroupPolicy === undefined &&
groupPolicy === "open"
) {
runtime.log?.(
warn(
'slack: groupPolicy defaults to "open" when channels.slack is missing; set channels.slack.groupPolicy (or channels.defaults.groupPolicy) or add channels.slack.channels to restrict access.',
),
);
}
const resolveToken = slackCfg.userToken?.trim() || botToken;
if (resolveToken) {
if (channelsConfig && Object.keys(channelsConfig).length > 0) {
try {
const entries = Object.keys(channelsConfig);
const resolved = await resolveSlackChannelAllowlist({
token: resolveToken,
entries,
});
const resolvedMap: string[] = [];
const unresolved: string[] = [];
const nextChannels = { ...channelsConfig };
for (const entry of resolved) {
if (entry.resolved && entry.id) {
resolvedMap.push(`${entry.input}${entry.id}`);
if (!nextChannels[entry.id] && channelsConfig[entry.input]) {
nextChannels[entry.id] = channelsConfig[entry.input];
}
} else {
unresolved.push(entry.input);
}
}
channelsConfig = nextChannels;
summarizeMapping("slack channels", resolvedMap, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack channel resolve failed; using config entries. ${String(err)}`);
}
}
const allowEntries =
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
if (allowEntries.length > 0) {
try {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: allowEntries.map((entry) => String(entry)),
});
const resolvedMap: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
for (const entry of resolvedUsers) {
if (entry.resolved && entry.id) {
resolvedMap.push(`${entry.input}${entry.id}`);
additions.push(entry.id);
} else {
unresolved.push(entry.input);
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("slack users", resolvedMap, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack user resolve failed; using config entries. ${String(err)}`);
}
}
if (channelsConfig && Object.keys(channelsConfig).length > 0) {
const userEntries = new Set<string>();
for (const channel of Object.values(channelsConfig)) {
if (!channel || typeof channel !== "object") continue;
const users = (channel as { users?: Array<string | number> }).users;
if (!Array.isArray(users)) continue;
for (const entry of users) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") userEntries.add(trimmed);
}
}
if (userEntries.size > 0) {
try {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: Array.from(userEntries),
});
const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry]));
const mapping = resolvedUsers
.filter((entry) => entry.resolved && entry.id)
.map((entry) => `${entry.input}${entry.id}`);
const unresolved = resolvedUsers
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
const nextChannels = { ...channelsConfig };
for (const [channelId, channelConfig] of Object.entries(channelsConfig)) {
if (!channelConfig || typeof channelConfig !== "object") continue;
const users = (channelConfig as { users?: Array<string | number> }).users;
if (!Array.isArray(users) || users.length === 0) continue;
const additions: string[] = [];
for (const entry of users) {
const trimmed = String(entry).trim();
const resolved = resolvedMap.get(trimmed);
if (resolved?.resolved && resolved.id) additions.push(resolved.id);
}
nextChannels[channelId] = {
...channelConfig,
users: mergeAllowlist({ existing: users, additions }),
};
}
channelsConfig = nextChannels;
summarizeMapping("slack channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack channel user resolve failed; using config entries. ${String(err)}`);
}
}
}
}
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { resolveSlackChannelAllowlist } from "./resolve-channels.js";
describe("resolveSlackChannelAllowlist", () => {
it("resolves by name and prefers active channels", async () => {
const client = {
conversations: {
list: vi.fn().mockResolvedValue({
channels: [
{ id: "C1", name: "general", is_archived: true },
{ id: "C2", name: "general", is_archived: false },
],
}),
},
};
const res = await resolveSlackChannelAllowlist({
token: "xoxb-test",
entries: ["#general"],
client: client as never,
});
expect(res[0]?.resolved).toBe(true);
expect(res[0]?.id).toBe("C2");
});
it("keeps unresolved entries", async () => {
const client = {
conversations: {
list: vi.fn().mockResolvedValue({ channels: [] }),
},
};
const res = await resolveSlackChannelAllowlist({
token: "xoxb-test",
entries: ["#does-not-exist"],
client: client as never,
});
expect(res[0]?.resolved).toBe(false);
});
});

View File

@@ -0,0 +1,121 @@
import { WebClient } from "@slack/web-api";
export type SlackChannelLookup = {
id: string;
name: string;
archived: boolean;
isPrivate: boolean;
};
export type SlackChannelResolution = {
input: string;
resolved: boolean;
id?: string;
name?: string;
archived?: boolean;
};
type SlackListResponse = {
channels?: Array<{
id?: string;
name?: string;
is_archived?: boolean;
is_private?: boolean;
}>;
response_metadata?: { next_cursor?: string };
};
function parseSlackChannelMention(raw: string): { id?: string; name?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const mention = trimmed.match(/^<#([A-Z0-9]+)(?:\|([^>]+))?>$/i);
if (mention) {
const id = mention[1]?.toUpperCase();
const name = mention[2]?.trim();
return { id, name };
}
const prefixed = trimmed.replace(/^(slack:|channel:)/i, "");
if (/^[CG][A-Z0-9]+$/i.test(prefixed)) return { id: prefixed.toUpperCase() };
const name = prefixed.replace(/^#/, "").trim();
return name ? { name } : {};
}
async function listSlackChannels(client: WebClient): Promise<SlackChannelLookup[]> {
const channels: SlackChannelLookup[] = [];
let cursor: string | undefined;
do {
const res = (await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: false,
limit: 1000,
cursor,
})) as SlackListResponse;
for (const channel of res.channels ?? []) {
const id = channel.id?.trim();
const name = channel.name?.trim();
if (!id || !name) continue;
channels.push({
id,
name,
archived: Boolean(channel.is_archived),
isPrivate: Boolean(channel.is_private),
});
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
return channels;
}
function resolveByName(
name: string,
channels: SlackChannelLookup[],
): SlackChannelLookup | undefined {
const target = name.trim().toLowerCase();
if (!target) return undefined;
const matches = channels.filter((channel) => channel.name.toLowerCase() === target);
if (matches.length === 0) return undefined;
const active = matches.find((channel) => !channel.archived);
return active ?? matches[0];
}
export async function resolveSlackChannelAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
}): Promise<SlackChannelResolution[]> {
const client = params.client ?? new WebClient(params.token);
const channels = await listSlackChannels(client);
const results: SlackChannelResolution[] = [];
for (const input of params.entries) {
const parsed = parseSlackChannelMention(input);
if (parsed.id) {
const match = channels.find((channel) => channel.id === parsed.id);
results.push({
input,
resolved: true,
id: parsed.id,
name: match?.name ?? parsed.name,
archived: match?.archived,
});
continue;
}
if (parsed.name) {
const match = resolveByName(parsed.name, channels);
if (match) {
results.push({
input,
resolved: true,
id: match.id,
name: match.name,
archived: match.archived,
});
continue;
}
}
results.push({ input, resolved: false });
}
return results;
}

182
src/slack/resolve-users.ts Normal file
View File

@@ -0,0 +1,182 @@
import { WebClient } from "@slack/web-api";
export type SlackUserLookup = {
id: string;
name: string;
displayName?: string;
realName?: string;
email?: string;
deleted: boolean;
isBot: boolean;
isAppUser: boolean;
};
export type SlackUserResolution = {
input: string;
resolved: boolean;
id?: string;
name?: string;
email?: string;
deleted?: boolean;
isBot?: boolean;
note?: string;
};
type SlackListUsersResponse = {
members?: Array<{
id?: string;
name?: string;
deleted?: boolean;
is_bot?: boolean;
is_app_user?: boolean;
real_name?: string;
profile?: {
display_name?: string;
real_name?: string;
email?: string;
};
}>;
response_metadata?: { next_cursor?: string };
};
function parseSlackUserInput(raw: string): { id?: string; name?: string; email?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mention) return { id: mention[1]?.toUpperCase() };
const prefixed = trimmed.replace(/^(slack:|user:)/i, "");
if (/^[A-Z][A-Z0-9]+$/i.test(prefixed)) return { id: prefixed.toUpperCase() };
if (trimmed.includes("@") && !trimmed.startsWith("@")) return { email: trimmed.toLowerCase() };
const name = trimmed.replace(/^@/, "").trim();
return name ? { name } : {};
}
async function listSlackUsers(client: WebClient): Promise<SlackUserLookup[]> {
const users: SlackUserLookup[] = [];
let cursor: string | undefined;
do {
const res = (await client.users.list({
limit: 200,
cursor,
})) as SlackListUsersResponse;
for (const member of res.members ?? []) {
const id = member.id?.trim();
const name = member.name?.trim();
if (!id || !name) continue;
const profile = member.profile ?? {};
users.push({
id,
name,
displayName: profile.display_name?.trim() || undefined,
realName: profile.real_name?.trim() || member.real_name?.trim() || undefined,
email: profile.email?.trim()?.toLowerCase() || undefined,
deleted: Boolean(member.deleted),
isBot: Boolean(member.is_bot),
isAppUser: Boolean(member.is_app_user),
});
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
return users;
}
function scoreSlackUser(user: SlackUserLookup, match: { name?: string; email?: string }): number {
let score = 0;
if (!user.deleted) score += 3;
if (!user.isBot && !user.isAppUser) score += 2;
if (match.email && user.email === match.email) score += 5;
if (match.name) {
const target = match.name.toLowerCase();
const candidates = [
user.name,
user.displayName,
user.realName,
]
.map((value) => value?.toLowerCase())
.filter(Boolean) as string[];
if (candidates.some((value) => value === target)) score += 2;
}
return score;
}
export async function resolveSlackUserAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
}): Promise<SlackUserResolution[]> {
const client = params.client ?? new WebClient(params.token);
const users = await listSlackUsers(client);
const results: SlackUserResolution[] = [];
for (const input of params.entries) {
const parsed = parseSlackUserInput(input);
if (parsed.id) {
const match = users.find((user) => user.id === parsed.id);
results.push({
input,
resolved: true,
id: parsed.id,
name: match?.displayName ?? match?.realName ?? match?.name,
email: match?.email,
deleted: match?.deleted,
isBot: match?.isBot,
});
continue;
}
if (parsed.email) {
const matches = users.filter((user) => user.email === parsed.email);
if (matches.length > 0) {
const scored = matches
.map((user) => ({ user, score: scoreSlackUser(user, parsed) }))
.sort((a, b) => b.score - a.score);
const best = scored[0]?.user ?? matches[0];
results.push({
input,
resolved: true,
id: best.id,
name: best.displayName ?? best.realName ?? best.name,
email: best.email,
deleted: best.deleted,
isBot: best.isBot,
note: matches.length > 1 ? "multiple matches; chose best" : undefined,
});
continue;
}
}
if (parsed.name) {
const target = parsed.name.toLowerCase();
const matches = users.filter((user) => {
const candidates = [
user.name,
user.displayName,
user.realName,
]
.map((value) => value?.toLowerCase())
.filter(Boolean) as string[];
return candidates.includes(target);
});
if (matches.length > 0) {
const scored = matches
.map((user) => ({ user, score: scoreSlackUser(user, parsed) }))
.sort((a, b) => b.score - a.score);
const best = scored[0]?.user ?? matches[0];
results.push({
input,
resolved: true,
id: best.id,
name: best.displayName ?? best.realName ?? best.name,
email: best.email,
deleted: best.deleted,
isBot: best.isBot,
note: matches.length > 1 ? "multiple matches; chose best" : undefined,
});
continue;
}
}
results.push({ input, resolved: false });
}
return results;
}

View File

@@ -243,7 +243,8 @@ export const registerTelegramHandlers = ({
return;
}
}
const groupPolicy = telegramCfg.groupPolicy ?? "open";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
return;
@@ -430,7 +431,8 @@ export const registerTelegramHandlers = ({
// - "open": groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = telegramCfg.groupPolicy ?? "open";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
return;

View File

@@ -163,7 +163,8 @@ export const registerTelegramNativeCommands = ({
}
if (isGroup && useAccessGroups) {
const groupPolicy = telegramCfg.groupPolicy ?? "open";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
return;

View File

@@ -78,7 +78,8 @@ export async function checkInboundAccessControl(params: {
// - "open": groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = account.groupPolicy ?? "open";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "open";
if (params.group && groupPolicy === "disabled") {
logVerbose("Blocked group message (groupPolicy: disabled)");
return {