mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
feat(discord): allow DM access groups from channel audiences
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -449,6 +449,58 @@ Example:
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="DM access groups">
|
||||
Discord DMs can use dynamic `accessGroup:<name>` 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.
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Guild policy">
|
||||
Guild handling is controlled by `channels.discord.groupPolicy`:
|
||||
|
||||
|
||||
70
extensions/discord/src/monitor/access-groups.ts
Normal file
70
extensions/discord/src/monitor/access-groups.ts
Normal file
@@ -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<string[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof import("../send.permissions.js")>();
|
||||
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",
|
||||
|
||||
@@ -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<string[]>;
|
||||
}): Promise<DiscordDmCommandAccess> {
|
||||
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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -271,6 +271,8 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: {
|
||||
},
|
||||
allowNameMatching,
|
||||
useAccessGroups,
|
||||
cfg,
|
||||
rest: interaction.client.rest,
|
||||
});
|
||||
if (dmAccess.decision !== "allow") {
|
||||
return false;
|
||||
|
||||
@@ -384,6 +384,8 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
},
|
||||
allowNameMatching,
|
||||
useAccessGroups,
|
||||
cfg,
|
||||
rest: interaction.client.rest,
|
||||
});
|
||||
commandAuthorized = dmAccess.commandAuthorized;
|
||||
if (dmAccess.decision !== "allow") {
|
||||
|
||||
@@ -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<APIGuild, "id" | "roles">;
|
||||
member: Pick<APIGuildMember, "roles">;
|
||||
}) {
|
||||
const rolesById = new Map<string, APIRole>(
|
||||
(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<APIGuild, "id" | "roles">;
|
||||
member: Pick<APIGuildMember, "roles">;
|
||||
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<string, APIRole>((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<boolean> {
|
||||
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<string, APIRole>((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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
17
src/config/types.access-groups.ts
Normal file
17
src/config/types.access-groups.ts
Normal file
@@ -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<string, AccessGroupConfig>;
|
||||
@@ -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'`). */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user