From b217cd09726f6c793dddac58b6d7a283b90840aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 22:00:55 +0100 Subject: [PATCH] feat(discord): allow DM access groups from channel audiences --- docs/.generated/config-baseline.sha256 | 4 +- docs/channels/discord.md | 52 ++++++ .../discord/src/monitor/access-groups.ts | 70 ++++++++ .../src/monitor/agent-components-dm-auth.ts | 24 ++- .../src/monitor/dm-command-auth.test.ts | 75 ++++++++- .../discord/src/monitor/dm-command-auth.ts | 43 ++++- .../monitor/message-handler.dm-preflight.ts | 3 + .../src/monitor/native-command-auth.ts | 2 + .../discord/src/monitor/native-command.ts | 2 + extensions/discord/src/send.permissions.ts | 159 +++++++++++------- .../send.sends-basic-channel-messages.test.ts | 56 ++++++ extensions/discord/src/send.ts | 1 + src/config/config-misc.test.ts | 38 +++++ src/config/schema.base.generated.ts | 34 ++++ src/config/types.access-groups.ts | 17 ++ src/config/types.openclaw.ts | 2 + src/config/types.ts | 1 + src/config/zod-schema.ts | 17 ++ 18 files changed, 534 insertions(+), 66 deletions(-) create mode 100644 extensions/discord/src/monitor/access-groups.ts create mode 100644 src/config/types.access-groups.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 0d551d0f68b..d1f18f40238 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -9a22f3ea41864de9cc754d7237a5e1403582f21eeec421463e3faa80a243e7de config-baseline.json -7fa5e58fa863a13f2b9e06ea0fc536304f21e4e8929068f96f93968de91d1aae config-baseline.core.json +e14ddc6b9859128d4c5561cf80f322b7b24e0f87dac5bff170afbf2d6a9c3711 config-baseline.json +2b1eac57f1b08b461e4cb9931a766f472c668e18aedd78e2af89541d8b476933 config-baseline.core.json c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json 7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 4c1929d6876..8d68daf8f3d 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -449,6 +449,58 @@ Example: + + Discord DMs can use dynamic `accessGroup:` entries in `channels.discord.allowFrom`. + + A Discord text channel has no separate member list. `type: "discord.channelAudience"` models membership as: the DM sender is a member of the configured guild and currently has effective `ViewChannel` permission on the configured channel after role and channel overwrites are applied. + + Example: allow anyone who can see `#maintainers` to DM the bot, while keeping DMs closed to everyone else. + +```json5 +{ + accessGroups: { + maintainers: { + type: "discord.channelAudience", + guildId: "1456350064065904867", + channelId: "1456744319972282449", + membership: "canViewChannel", + }, + }, + channels: { + discord: { + dmPolicy: "allowlist", + allowFrom: ["accessGroup:maintainers"], + }, + }, +} +``` + + You can mix dynamic and static entries: + +```json5 +{ + accessGroups: { + maintainers: { + type: "discord.channelAudience", + guildId: "1456350064065904867", + channelId: "1456744319972282449", + }, + }, + channels: { + discord: { + dmPolicy: "allowlist", + allowFrom: ["accessGroup:maintainers", "discord:123456789012345678"], + }, + }, +} +``` + + Lookups fail closed. If Discord returns `Missing Access`, the member lookup fails, or the channel belongs to a different guild, the DM sender is treated as unauthorized. + + Enable the Discord Developer Portal **Server Members Intent** for the bot when using channel-audience access groups. DMs do not include guild member state, so OpenClaw resolves the member through Discord REST at authorization time. + + + Guild handling is controlled by `channels.discord.groupPolicy`: diff --git a/extensions/discord/src/monitor/access-groups.ts b/extensions/discord/src/monitor/access-groups.ts new file mode 100644 index 00000000000..d2af85ae925 --- /dev/null +++ b/extensions/discord/src/monitor/access-groups.ts @@ -0,0 +1,70 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { RequestClient } from "../internal/discord.js"; +import { canViewDiscordGuildChannel } from "../send.permissions.js"; + +export const DISCORD_ACCESS_GROUP_PREFIX = "accessGroup:"; + +export function parseDiscordAccessGroupEntry(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed.startsWith(DISCORD_ACCESS_GROUP_PREFIX)) { + return null; + } + const name = trimmed.slice(DISCORD_ACCESS_GROUP_PREFIX.length).trim(); + return name.length > 0 ? name : null; +} + +export async function resolveDiscordDmAccessGroupEntries(params: { + cfg?: OpenClawConfig; + allowFrom: string[]; + sender: { id: string }; + accountId: string; + token?: string; + rest?: RequestClient; +}): Promise { + const names = Array.from( + new Set( + params.allowFrom + .map((entry) => parseDiscordAccessGroupEntry(entry)) + .filter((entry): entry is string => entry != null), + ), + ); + if (names.length === 0 || !params.cfg?.accessGroups) { + return []; + } + + const matched: string[] = []; + for (const name of names) { + const group = params.cfg.accessGroups[name]; + if (!group) { + continue; + } + if (group.type !== "discord.channelAudience") { + continue; + } + const membership = group.membership ?? "canViewChannel"; + if (membership !== "canViewChannel") { + continue; + } + const allowed = await canViewDiscordGuildChannel( + group.guildId, + group.channelId, + params.sender.id, + { + cfg: params.cfg, + accountId: params.accountId, + token: params.token, + rest: params.rest, + }, + ).catch((err) => { + logVerbose( + `discord: accessGroup:${name} lookup failed for user ${params.sender.id}: ${String(err)}`, + ); + return false; + }); + if (allowed) { + matched.push(`${DISCORD_ACCESS_GROUP_PREFIX}${name}`); + } + } + return matched; +} diff --git a/extensions/discord/src/monitor/agent-components-dm-auth.ts b/extensions/discord/src/monitor/agent-components-dm-auth.ts index 4a45669cbc6..4e36769b371 100644 --- a/extensions/discord/src/monitor/agent-components-dm-auth.ts +++ b/extensions/discord/src/monitor/agent-components-dm-auth.ts @@ -1,6 +1,7 @@ import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js"; import { resolveComponentInteractionContext, resolveDiscordChannelContext, @@ -45,6 +46,22 @@ async function ensureDmComponentAuthorized(params: { }) : { allowed: false }; }; + const resolveAllowMatchWithAccessGroups = async (entries: string[]) => { + const staticMatch = resolveAllowMatch(entries); + if (staticMatch.allowed) { + return staticMatch; + } + const matchedGroups = await resolveDiscordDmAccessGroupEntries({ + cfg: ctx.cfg, + allowFrom: entries, + sender: { id: user.id }, + accountId: ctx.accountId, + token: ctx.token, + }); + return matchedGroups.length > 0 + ? resolveAllowMatch([...entries, `discord:${user.id}`]) + : staticMatch; + }; const dmPolicy = ctx.dmPolicy ?? "pairing"; if (dmPolicy === "disabled") { logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`); @@ -52,7 +69,7 @@ async function ensureDmComponentAuthorized(params: { return false; } if (dmPolicy === "allowlist") { - const allowMatch = resolveAllowMatch(ctx.allowFrom ?? []); + const allowMatch = await resolveAllowMatchWithAccessGroups(ctx.allowFrom ?? []); if (allowMatch.allowed) { return true; } @@ -73,7 +90,10 @@ async function ensureDmComponentAuthorized(params: { dmPolicy, }); const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]); - if (allowMatch.allowed) { + const dynamicAllowMatch = allowMatch.allowed + ? allowMatch + : await resolveAllowMatchWithAccessGroups([...(ctx.allowFrom ?? []), ...storeAllowFrom]); + if (dynamicAllowMatch.allowed) { return true; } diff --git a/extensions/discord/src/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts index a588d9dc9f2..0c9711153dc 100644 --- a/extensions/discord/src/monitor/dm-command-auth.test.ts +++ b/extensions/discord/src/monitor/dm-command-auth.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; +const canViewDiscordGuildChannelMock = vi.hoisted(() => vi.fn()); + +vi.mock("../send.permissions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + canViewDiscordGuildChannel: canViewDiscordGuildChannelMock, + }; +}); + describe("resolveDiscordDmCommandAccess", () => { const sender = { id: "123", @@ -8,6 +18,10 @@ describe("resolveDiscordDmCommandAccess", () => { tag: "alice#0001", }; + beforeEach(() => { + canViewDiscordGuildChannelMock.mockReset(); + }); + async function resolveOpenDmAccess(configuredAllowFrom: string[]) { return await resolveDiscordDmCommandAccess({ accountId: "default", @@ -80,6 +94,65 @@ describe("resolveDiscordDmCommandAccess", () => { expect(result.commandAuthorized).toBe(true); }); + it("authorizes allowlist DMs from a Discord channel audience access group", async () => { + canViewDiscordGuildChannelMock.mockResolvedValueOnce(true); + + const result = await resolveDiscordDmCommandAccess({ + accountId: "default", + dmPolicy: "allowlist", + configuredAllowFrom: ["accessGroup:maintainers"], + sender, + allowNameMatching: false, + useAccessGroups: true, + cfg: { + accessGroups: { + maintainers: { + type: "discord.channelAudience", + guildId: "guild-1", + channelId: "channel-1", + }, + }, + }, + token: "token", + readStoreAllowFrom: async () => [], + }); + + expect(canViewDiscordGuildChannelMock).toHaveBeenCalledWith( + "guild-1", + "channel-1", + "123", + expect.objectContaining({ accountId: "default", token: "token" }), + ); + expect(result.decision).toBe("allow"); + expect(result.commandAuthorized).toBe(true); + }); + + it("fails closed when a Discord channel audience access group lookup rejects", async () => { + canViewDiscordGuildChannelMock.mockRejectedValueOnce(new Error("missing intent")); + + const result = await resolveDiscordDmCommandAccess({ + accountId: "default", + dmPolicy: "allowlist", + configuredAllowFrom: ["accessGroup:maintainers"], + sender, + allowNameMatching: false, + useAccessGroups: true, + cfg: { + accessGroups: { + maintainers: { + type: "discord.channelAudience", + guildId: "guild-1", + channelId: "channel-1", + }, + }, + }, + readStoreAllowFrom: async () => [], + }); + + expect(result.decision).toBe("block"); + expect(result.commandAuthorized).toBe(false); + }); + it("keeps open DM blocked without wildcard even when access groups are disabled", async () => { const result = await resolveDiscordDmCommandAccess({ accountId: "default", diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 2b39e2bbdca..189ebc6683c 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,9 +1,12 @@ import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, type DmGroupAccessDecision, } from "openclaw/plugin-sdk/security-runtime"; +import type { RequestClient } from "../internal/discord.js"; +import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js"; import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; @@ -39,6 +42,21 @@ function resolveDmPolicyCommandAuthorization(params: { return params.commandAuthorized; } +async function expandAllowFromWithDiscordAccessGroups(params: { + cfg?: OpenClawConfig; + allowFrom: string[]; + sender: { id: string }; + accountId: string; + token?: string; + rest?: RequestClient; +}) { + const matchedGroups = await resolveDiscordDmAccessGroupEntries(params); + if (matchedGroups.length === 0) { + return params.allowFrom; + } + return [...params.allowFrom, `discord:${params.sender.id}`]; +} + export async function resolveDiscordDmCommandAccess(params: { accountId: string; dmPolicy: DiscordDmPolicy; @@ -46,6 +64,9 @@ export async function resolveDiscordDmCommandAccess(params: { sender: { id: string; name?: string; tag?: string }; allowNameMatching: boolean; useAccessGroups: boolean; + cfg?: OpenClawConfig; + token?: string; + rest?: RequestClient; readStoreAllowFrom?: () => Promise; }): Promise { const storeAllowFrom = params.readStoreAllowFrom @@ -58,13 +79,31 @@ export async function resolveDiscordDmCommandAccess(params: { dmPolicy: params.dmPolicy, shouldRead: params.dmPolicy !== "open", }); + const [configuredAllowFrom, effectiveStoreAllowFrom] = await Promise.all([ + expandAllowFromWithDiscordAccessGroups({ + cfg: params.cfg, + allowFrom: params.configuredAllowFrom, + sender: params.sender, + accountId: params.accountId, + token: params.token, + rest: params.rest, + }), + expandAllowFromWithDiscordAccessGroups({ + cfg: params.cfg, + allowFrom: storeAllowFrom, + sender: params.sender, + accountId: params.accountId, + token: params.token, + rest: params.rest, + }), + ]); const access = resolveDmGroupAccessWithLists({ isGroup: false, dmPolicy: params.dmPolicy, - allowFrom: params.configuredAllowFrom, + allowFrom: configuredAllowFrom, groupAllowFrom: [], - storeAllowFrom, + storeAllowFrom: effectiveStoreAllowFrom, isSenderAllowed: (allowEntries) => resolveSenderAllowMatch({ allowEntries, diff --git a/extensions/discord/src/monitor/message-handler.dm-preflight.ts b/extensions/discord/src/monitor/message-handler.dm-preflight.ts index 14dd34eb512..39237178aad 100644 --- a/extensions/discord/src/monitor/message-handler.dm-preflight.ts +++ b/extensions/discord/src/monitor/message-handler.dm-preflight.ts @@ -62,6 +62,9 @@ export async function resolveDiscordDmPreflightAccess(params: { }, allowNameMatching: params.allowNameMatching, useAccessGroups: params.useAccessGroups, + cfg: params.preflight.cfg, + token: params.preflight.token, + rest: params.preflight.client.rest, }); const commandAuthorized = dmAccess.commandAuthorized || directBindingRecord != null; if (dmAccess.decision === "allow") { diff --git a/extensions/discord/src/monitor/native-command-auth.ts b/extensions/discord/src/monitor/native-command-auth.ts index 41ad79a71e6..1ed9bd185d1 100644 --- a/extensions/discord/src/monitor/native-command-auth.ts +++ b/extensions/discord/src/monitor/native-command-auth.ts @@ -271,6 +271,8 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { }, allowNameMatching, useAccessGroups, + cfg, + rest: interaction.client.rest, }); if (dmAccess.decision !== "allow") { return false; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 9d0230350f9..b1591cabd64 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -384,6 +384,8 @@ async function dispatchDiscordCommandInteraction(params: { }, allowNameMatching, useAccessGroups, + cfg, + rest: interaction.client.rest, }); commandAuthorized = dmAccess.commandAuthorized; if (dmAccess.decision !== "allow") { diff --git a/extensions/discord/src/send.permissions.ts b/extensions/discord/src/send.permissions.ts index bc992c15608..a6ab93ca87f 100644 --- a/extensions/discord/src/send.permissions.ts +++ b/extensions/discord/src/send.permissions.ts @@ -1,4 +1,4 @@ -import type { APIRole } from "discord-api-types/v10"; +import type { APIChannel, APIGuild, APIGuildMember, APIRole } from "discord-api-types/v10"; import { ChannelType, PermissionFlagsBits } from "discord-api-types/v10"; import { resolveDiscordRest } from "./client.js"; import { @@ -60,6 +60,67 @@ async function fetchBotUserId(rest: RequestClient) { return me.id; } +function resolveMemberGuildPermissionBits(params: { + guild: Pick; + member: Pick; +}) { + const rolesById = new Map( + (params.guild.roles ?? []).map((role) => [role.id, role]), + ); + const everyoneRole = rolesById.get(params.guild.id); + let permissions = 0n; + if (everyoneRole?.permissions) { + permissions = addPermissionBits(permissions, everyoneRole.permissions); + } + for (const roleId of params.member.roles ?? []) { + const role = rolesById.get(roleId); + if (role?.permissions) { + permissions = addPermissionBits(permissions, role.permissions); + } + } + return permissions; +} + +function resolveMemberChannelPermissionBits(params: { + guildId: string; + userId: string; + guild: Pick; + member: Pick; + channel: APIChannel; +}) { + let permissions = resolveMemberGuildPermissionBits({ + guild: params.guild, + member: params.member, + }); + + if (hasAdministrator(permissions)) { + return ALL_PERMISSIONS; + } + + const overwrites = + "permission_overwrites" in params.channel ? (params.channel.permission_overwrites ?? []) : []; + for (const overwrite of overwrites) { + if (overwrite.id === params.guildId) { + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); + } + } + for (const overwrite of overwrites) { + if (params.member.roles?.includes(overwrite.id)) { + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); + } + } + for (const overwrite of overwrites) { + if (overwrite.id === params.userId) { + permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); + permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); + } + } + + return permissions; +} + /** * Fetch guild-level permissions for a user. This does not include channel-specific overwrites. */ @@ -74,25 +135,43 @@ export async function fetchMemberGuildPermissionsDiscord( getGuild(rest, guildId), getGuildMember(rest, guildId, userId), ]); - const rolesById = new Map((guild.roles ?? []).map((role) => [role.id, role])); - const everyoneRole = rolesById.get(guildId); - let permissions = 0n; - if (everyoneRole?.permissions) { - permissions = addPermissionBits(permissions, everyoneRole.permissions); - } - for (const roleId of member.roles ?? []) { - const role = rolesById.get(roleId); - if (role?.permissions) { - permissions = addPermissionBits(permissions, role.permissions); - } - } - return permissions; + return resolveMemberGuildPermissionBits({ guild, member }); } catch { // Not a guild member, guild not found, or API failure. return null; } } +export async function canViewDiscordGuildChannel( + guildId: string, + channelId: string, + userId: string, + opts: DiscordReactOpts, +): Promise { + const rest = resolveDiscordRest(opts); + try { + const channel = await getChannel(rest, channelId); + const channelGuildId = "guild_id" in channel ? channel.guild_id : undefined; + if (channelGuildId !== guildId) { + return false; + } + const [guild, member] = await Promise.all([ + getGuild(rest, guildId), + getGuildMember(rest, guildId, userId), + ]); + const permissions = resolveMemberChannelPermissionBits({ + guildId, + userId, + guild, + member, + channel, + }); + return hasPermissionBit(permissions, PermissionFlagsBits.ViewChannel); + } catch { + return false; + } +} + /** * Returns true when the user has ADMINISTRATOR or required permission bits * matching the provided predicate. @@ -181,51 +260,13 @@ export async function fetchChannelPermissionsDiscord( getGuildMember(rest, guildId, botId), ]); - const rolesById = new Map((guild.roles ?? []).map((role) => [role.id, role])); - const everyoneRole = rolesById.get(guildId); - let base = 0n; - if (everyoneRole?.permissions) { - base = addPermissionBits(base, everyoneRole.permissions); - } - for (const roleId of member.roles ?? []) { - const role = rolesById.get(roleId); - if (role?.permissions) { - base = addPermissionBits(base, role.permissions); - } - } - - if (hasAdministrator(base)) { - return { - channelId, - guildId, - permissions: bitfieldToPermissions(ALL_PERMISSIONS), - raw: ALL_PERMISSIONS.toString(), - isDm: false, - channelType, - }; - } - - let permissions = base; - const overwrites = - "permission_overwrites" in channel ? (channel.permission_overwrites ?? []) : []; - for (const overwrite of overwrites) { - if (overwrite.id === guildId) { - permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); - permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); - } - } - for (const overwrite of overwrites) { - if (member.roles?.includes(overwrite.id)) { - permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); - permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); - } - } - for (const overwrite of overwrites) { - if (overwrite.id === botId) { - permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); - permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); - } - } + const permissions = resolveMemberChannelPermissionBits({ + guildId, + userId: botId, + guild, + member, + channel, + }); return { channelId, diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index b534ad80855..77efc7e6e3a 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -6,6 +6,7 @@ vi.mock("openclaw/plugin-sdk/web-media", () => discordWebMediaMockFactory()); let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord; let editMessageDiscord: typeof import("./send.js").editMessageDiscord; +let canViewDiscordGuildChannel: typeof import("./send.js").canViewDiscordGuildChannel; let fetchChannelPermissionsDiscord: typeof import("./send.js").fetchChannelPermissionsDiscord; let fetchReactionsDiscord: typeof import("./send.js").fetchReactionsDiscord; let pinMessageDiscord: typeof import("./send.js").pinMessageDiscord; @@ -29,6 +30,7 @@ beforeAll(async () => { ({ deleteMessageDiscord, editMessageDiscord, + canViewDiscordGuildChannel, fetchChannelPermissionsDiscord, fetchReactionsDiscord, pinMessageDiscord, @@ -695,6 +697,60 @@ describe("fetchChannelPermissionsDiscord", () => { expect(res.permissions).toContain("Administrator"); expect(res.permissions).toContain("ViewChannel"); }); + + it("checks whether an arbitrary member can view a guild channel", async () => { + const { rest, getMock } = makeDiscordRest(); + getMock + .mockResolvedValueOnce({ + id: "chan1", + guild_id: "guild1", + permission_overwrites: [ + { + id: "guild1", + deny: PermissionFlagsBits.ViewChannel.toString(), + allow: "0", + }, + { + id: "role2", + deny: "0", + allow: PermissionFlagsBits.ViewChannel.toString(), + }, + ], + }) + .mockResolvedValueOnce({ + id: "guild1", + roles: [ + { id: "guild1", permissions: "0" }, + { id: "role2", permissions: "0" }, + ], + }) + .mockResolvedValueOnce({ roles: ["role2"] }); + + await expect( + canViewDiscordGuildChannel("guild1", "chan1", "user1", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), + ).resolves.toBe(true); + }); + + it("fails closed when the channel belongs to a different guild", async () => { + const { rest, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ + id: "chan1", + guild_id: "guild2", + permission_overwrites: [], + }); + + await expect( + canViewDiscordGuildChannel("guild1", "chan1", "user1", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), + ).resolves.toBe(false); + }); }); describe("readMessagesDiscord", () => { diff --git a/extensions/discord/src/send.ts b/extensions/discord/src/send.ts index 82f4b0202fd..0483f9fd655 100644 --- a/extensions/discord/src/send.ts +++ b/extensions/discord/src/send.ts @@ -43,6 +43,7 @@ export { sendWebhookMessageDiscord } from "./send.webhook.js"; export { sendVoiceMessageDiscord } from "./send.voice.js"; export { sendTypingDiscord } from "./send.typing.js"; export { + canViewDiscordGuildChannel, fetchChannelPermissionsDiscord, hasAllGuildPermissionsDiscord, hasAnyGuildPermissionDiscord, diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 7e1f1797f59..e3554faf37a 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -52,6 +52,44 @@ describe("$schema key in config (#14998)", () => { }); }); +describe("accessGroups config", () => { + it("accepts Discord channel audience access groups", () => { + const result = OpenClawSchema.safeParse({ + accessGroups: { + maintainers: { + type: "discord.channelAudience", + guildId: "1456350064065904867", + channelId: "1456744319972282449", + membership: "canViewChannel", + }, + }, + channels: { + discord: { + dmPolicy: "allowlist", + allowFrom: ["accessGroup:maintainers"], + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects unknown access group membership modes", () => { + const result = OpenClawSchema.safeParse({ + accessGroups: { + maintainers: { + type: "discord.channelAudience", + guildId: "guild", + channelId: "channel", + membership: "roleMember", + }, + }, + }); + + expect(result.success).toBe(false); + }); +}); + describe("plugins.slots.contextEngine", () => { it("accepts a contextEngine slot id", () => { const result = OpenClawSchema.safeParse({ diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index c5018dd699f..e436c30b8cd 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -1279,6 +1279,40 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.", }, + accessGroups: { + type: "object", + propertyNames: { + type: "string", + minLength: 1, + }, + additionalProperties: { + oneOf: [ + { + type: "object", + properties: { + type: { + type: "string", + const: "discord.channelAudience", + }, + guildId: { + type: "string", + minLength: 1, + }, + channelId: { + type: "string", + minLength: 1, + }, + membership: { + type: "string", + const: "canViewChannel", + }, + }, + required: ["type", "guildId", "channelId"], + additionalProperties: false, + }, + ], + }, + }, acp: { type: "object", properties: { diff --git a/src/config/types.access-groups.ts b/src/config/types.access-groups.ts new file mode 100644 index 00000000000..c0ec64331e3 --- /dev/null +++ b/src/config/types.access-groups.ts @@ -0,0 +1,17 @@ +export type DiscordChannelAudienceAccessGroup = { + /** + * Discord dynamic audience backed by the users who can currently view a guild + * channel. + */ + type: "discord.channelAudience"; + /** Guild ID that owns the channel. */ + guildId: string; + /** Channel ID whose effective ViewChannel permission defines the audience. */ + channelId: string; + /** Audience predicate. Defaults to canViewChannel. */ + membership?: "canViewChannel"; +}; + +export type AccessGroupConfig = DiscordChannelAudienceAccessGroup; + +export type AccessGroupsConfig = Record; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index f6852855b32..94ae1fd1d1a 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -2,6 +2,7 @@ import type { SilentReplyPolicyShape, SilentReplyRewriteShape, } from "../shared/silent-reply-policy.js"; +import type { AccessGroupsConfig } from "./types.access-groups.js"; import type { AcpConfig } from "./types.acp.js"; import type { AgentBinding, AgentsConfig } from "./types.agents.js"; import type { ApprovalsConfig } from "./types.approvals.js"; @@ -50,6 +51,7 @@ export type OpenClawConfig = { lastTouchedAt?: string; }; auth?: AuthConfig; + accessGroups?: AccessGroupsConfig; acp?: AcpConfig; env?: { /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ diff --git a/src/config/types.ts b/src/config/types.ts index e508d7998fc..ff903d398e1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -3,6 +3,7 @@ export * from "./types.agent-defaults.js"; export * from "./types.agents.js"; export * from "./types.acp.js"; +export * from "./types.access-groups.js"; export * from "./types.approvals.js"; export * from "./types.auth.js"; export * from "./types.base.js"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index cc1cbf7074f..25e2e1b908b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -49,6 +49,22 @@ const NodeHostSchema = z .strict() .optional(); +const AccessGroupsSchema = z + .record( + z.string().min(1), + z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("discord.channelAudience"), + guildId: z.string().min(1), + channelId: z.string().min(1), + membership: z.literal("canViewChannel").optional(), + }) + .strict(), + ]), + ) + .optional(); + const MemoryQmdPathSchema = z .object({ path: z.string(), @@ -522,6 +538,7 @@ export const OpenClawSchema = z }) .strict() .optional(), + accessGroups: AccessGroupsSchema, acp: z .object({ enabled: z.boolean().optional(),