diff --git a/extensions/discord/src/audit-core.ts b/extensions/discord/src/audit-core.ts new file mode 100644 index 00000000000..49951747cac --- /dev/null +++ b/extensions/discord/src/audit-core.ts @@ -0,0 +1,138 @@ +import type { + DiscordGuildChannelConfig, + DiscordGuildEntry, +} from "openclaw/plugin-sdk/config-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { isRecord } from "openclaw/plugin-sdk/text-runtime"; + +export type DiscordChannelPermissionsAuditEntry = { + channelId: string; + ok: boolean; + missing?: string[]; + error?: string | null; + matchKey?: string; + matchSource?: "id"; +}; + +export type DiscordChannelPermissionsAudit = { + ok: boolean; + checkedChannels: number; + unresolvedChannels: number; + channels: DiscordChannelPermissionsAuditEntry[]; + elapsedMs: number; +}; + +const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; + +function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) { + if (!config) { + return true; + } + if (config.enabled === false) { + return false; + } + return true; +} + +export function listConfiguredGuildChannelKeys( + guilds: Record | undefined, +): string[] { + if (!guilds) { + return []; + } + const ids = new Set(); + for (const entry of Object.values(guilds)) { + if (!entry || typeof entry !== "object") { + continue; + } + const channelsRaw = (entry as { channels?: unknown }).channels; + if (!isRecord(channelsRaw)) { + continue; + } + for (const [key, value] of Object.entries(channelsRaw)) { + const channelId = String(key).trim(); + if (!channelId) { + continue; + } + if (channelId === "*") { + continue; + } + if (!shouldAuditChannelConfig(value as DiscordGuildChannelConfig | undefined)) { + continue; + } + ids.add(channelId); + } + } + return [...ids].toSorted((a, b) => a.localeCompare(b)); +} + +export function collectDiscordAuditChannelIdsForGuilds( + guilds: Record | undefined, +) { + const keys = listConfiguredGuildChannelKeys(guilds); + const channelIds = keys.filter((key) => /^\d+$/.test(key)); + const unresolvedChannels = keys.length - channelIds.length; + return { channelIds, unresolvedChannels }; +} + +export async function auditDiscordChannelPermissionsWithFetcher(params: { + token: string; + accountId?: string | null; + channelIds: string[]; + timeoutMs: number; + fetchChannelPermissions: ( + channelId: string, + params: { token: string; accountId?: string }, + ) => Promise<{ + permissions: string[]; + }>; +}): Promise { + const started = Date.now(); + const token = params.token?.trim() ?? ""; + if (!token || params.channelIds.length === 0) { + return { + ok: true, + checkedChannels: 0, + unresolvedChannels: 0, + channels: [], + elapsedMs: Date.now() - started, + }; + } + + const required = [...REQUIRED_CHANNEL_PERMISSIONS]; + const channels: DiscordChannelPermissionsAuditEntry[] = []; + + for (const channelId of params.channelIds) { + try { + const perms = await params.fetchChannelPermissions(channelId, { + token, + accountId: params.accountId ?? undefined, + }); + const missing = required.filter((p) => !perms.permissions.includes(p)); + channels.push({ + channelId, + ok: missing.length === 0, + missing: missing.length ? missing : undefined, + error: null, + matchKey: channelId, + matchSource: "id", + }); + } catch (err) { + channels.push({ + channelId, + ok: false, + error: formatErrorMessage(err), + matchKey: channelId, + matchSource: "id", + }); + } + } + + return { + ok: channels.every((c) => c.ok), + checkedChannels: channels.length, + unresolvedChannels: 0, + channels, + elapsedMs: Date.now() - started, + }; +} diff --git a/extensions/discord/src/audit.test.ts b/extensions/discord/src/audit.test.ts index 20812889324..6bab1c08866 100644 --- a/extensions/discord/src/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -1,20 +1,12 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + auditDiscordChannelPermissionsWithFetcher, + collectDiscordAuditChannelIdsForGuilds, +} from "./audit-core.js"; -const sendModule = await import("./send.js"); const fetchChannelPermissionsDiscordMock = vi.fn(); -vi.spyOn(sendModule, "fetchChannelPermissionsDiscord").mockImplementation( - fetchChannelPermissionsDiscordMock, -); - -let auditDiscordChannelPermissions: typeof import("./audit.js").auditDiscordChannelPermissions; -let collectDiscordAuditChannelIds: typeof import("./audit.js").collectDiscordAuditChannelIds; describe("discord audit", () => { - beforeAll(async () => { - ({ collectDiscordAuditChannelIds, auditDiscordChannelPermissions } = - await import("./audit.js")); - }); - beforeEach(() => { fetchChannelPermissionsDiscordMock.mockReset(); }); @@ -39,10 +31,7 @@ describe("discord audit", () => { }, } as unknown as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; - const collected = collectDiscordAuditChannelIds({ - cfg, - accountId: "default", - }); + const collected = collectDiscordAuditChannelIdsForGuilds(cfg.channels.discord.guilds); expect(collected.channelIds).toEqual(["111", "222"]); expect(collected.unresolvedChannels).toBe(1); @@ -59,11 +48,12 @@ describe("discord audit", () => { isDm: false, }); - const audit = await auditDiscordChannelPermissions({ + const audit = await auditDiscordChannelPermissionsWithFetcher({ token: "t", accountId: "default", channelIds: collected.channelIds, timeoutMs: 1000, + fetchChannelPermissions: fetchChannelPermissionsDiscordMock, }); expect(audit.ok).toBe(false); expect(audit.channels).toHaveLength(2); @@ -90,7 +80,7 @@ describe("discord audit", () => { }, } as unknown as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; - const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); + const collected = collectDiscordAuditChannelIdsForGuilds(cfg.channels.discord.guilds); expect(collected.channelIds).toEqual(["111"]); expect(collected.unresolvedChannels).toBe(0); }); @@ -113,7 +103,7 @@ describe("discord audit", () => { }, } as unknown as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; - const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); + const collected = collectDiscordAuditChannelIdsForGuilds(cfg.channels.discord.guilds); expect(collected.channelIds).toEqual([]); expect(collected.unresolvedChannels).toBe(0); }); @@ -140,7 +130,7 @@ describe("discord audit", () => { }, } as unknown as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; - const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); + const collected = collectDiscordAuditChannelIdsForGuilds(cfg.channels.discord.guilds); expect(collected.channelIds).toEqual(["111"]); expect(collected.unresolvedChannels).toBe(1); }); diff --git a/extensions/discord/src/audit.ts b/extensions/discord/src/audit.ts index 32b6790179e..b206e2b6405 100644 --- a/extensions/discord/src/audit.ts +++ b/extensions/discord/src/audit.ts @@ -1,76 +1,12 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { - DiscordGuildChannelConfig, - DiscordGuildEntry, -} from "openclaw/plugin-sdk/config-runtime"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isRecord } from "openclaw/plugin-sdk/text-runtime"; import { inspectDiscordAccount } from "./account-inspect.js"; +import { + auditDiscordChannelPermissionsWithFetcher, + collectDiscordAuditChannelIdsForGuilds, + type DiscordChannelPermissionsAudit, +} from "./audit-core.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; -export type DiscordChannelPermissionsAuditEntry = { - channelId: string; - ok: boolean; - missing?: string[]; - error?: string | null; - matchKey?: string; - matchSource?: "id"; -}; - -export type DiscordChannelPermissionsAudit = { - ok: boolean; - checkedChannels: number; - unresolvedChannels: number; - channels: DiscordChannelPermissionsAuditEntry[]; - elapsedMs: number; -}; - -const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; - -function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) { - if (!config) { - return true; - } - if (config.enabled === false) { - return false; - } - return true; -} - -function listConfiguredGuildChannelKeys( - guilds: Record | undefined, -): string[] { - if (!guilds) { - return []; - } - const ids = new Set(); - for (const entry of Object.values(guilds)) { - if (!entry || typeof entry !== "object") { - continue; - } - const channelsRaw = (entry as { channels?: unknown }).channels; - if (!isRecord(channelsRaw)) { - continue; - } - for (const [key, value] of Object.entries(channelsRaw)) { - const channelId = String(key).trim(); - if (!channelId) { - continue; - } - // Skip wildcard keys (e.g. "*" meaning "all channels") — they are valid - // config but are not real channel IDs and should not be audited. - if (channelId === "*") { - continue; - } - if (!shouldAuditChannelConfig(value as DiscordGuildChannelConfig | undefined)) { - continue; - } - ids.add(channelId); - } - } - return [...ids].toSorted((a, b) => a.localeCompare(b)); -} - export function collectDiscordAuditChannelIds(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -79,10 +15,7 @@ export function collectDiscordAuditChannelIds(params: { cfg: params.cfg, accountId: params.accountId, }); - const keys = listConfiguredGuildChannelKeys(account.config.guilds); - const channelIds = keys.filter((key) => /^\d+$/.test(key)); - const unresolvedChannels = keys.length - channelIds.length; - return { channelIds, unresolvedChannels }; + return collectDiscordAuditChannelIdsForGuilds(account.config.guilds); } export async function auditDiscordChannelPermissions(params: { @@ -91,52 +24,8 @@ export async function auditDiscordChannelPermissions(params: { channelIds: string[]; timeoutMs: number; }): Promise { - const started = Date.now(); - const token = params.token?.trim() ?? ""; - if (!token || params.channelIds.length === 0) { - return { - ok: true, - checkedChannels: 0, - unresolvedChannels: 0, - channels: [], - elapsedMs: Date.now() - started, - }; - } - - const required = [...REQUIRED_CHANNEL_PERMISSIONS]; - const channels: DiscordChannelPermissionsAuditEntry[] = []; - - for (const channelId of params.channelIds) { - try { - const perms = await fetchChannelPermissionsDiscord(channelId, { - token, - accountId: params.accountId ?? undefined, - }); - const missing = required.filter((p) => !perms.permissions.includes(p)); - channels.push({ - channelId, - ok: missing.length === 0, - missing: missing.length ? missing : undefined, - error: null, - matchKey: channelId, - matchSource: "id", - }); - } catch (err) { - channels.push({ - channelId, - ok: false, - error: formatErrorMessage(err), - matchKey: channelId, - matchSource: "id", - }); - } - } - - return { - ok: channels.every((c) => c.ok), - checkedChannels: channels.length, - unresolvedChannels: 0, - channels, - elapsedMs: Date.now() - started, - }; + return await auditDiscordChannelPermissionsWithFetcher({ + ...params, + fetchChannelPermissions: fetchChannelPermissionsDiscord, + }); }