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(),