Discord: move group policy behind plugin boundary

This commit is contained in:
Gustavo Madeira Santana
2026-03-18 03:30:02 +00:00
parent 9350cb19dd
commit 0bfaa36126
9 changed files with 237 additions and 192 deletions

View File

@@ -209,3 +209,42 @@ describe("discordPlugin outbound", () => {
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
});
});
describe("discordPlugin groups", () => {
it("uses plugin-owned group policy resolvers", () => {
const cfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
},
},
},
},
},
},
} as OpenClawConfig;
expect(
discordPlugin.groups?.resolveRequireMention?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toBe(true);
expect(
discordPlugin.groups?.resolveToolPolicy?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toEqual({ allow: ["message.channel"] });
});
});

View File

@@ -21,8 +21,6 @@ import {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type OpenClawConfig,
@@ -38,6 +36,10 @@ import {
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
} from "./exec-approvals.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import { monitorDiscordProvider } from "./monitor.js";
import {
looksLikeDiscordTargetId,

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
describe("discord group policy", () => {
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
const discordCfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
toolsBySender: {
"id:user:guild-admin": { allow: ["sessions.list"] },
},
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
toolsBySender: {
"id:user:channel-admin": { deny: ["exec"] },
},
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
).toBe(true);
expect(
resolveDiscordGroupRequireMention({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
}),
).toBe(false);
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:channel-admin",
}),
).toEqual({ deny: ["exec"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.channel"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:guild-admin",
}),
).toEqual({ allow: ["sessions.list"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.guild"] });
});
});

View File

@@ -0,0 +1,111 @@
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core";
import type { DiscordConfig } from "openclaw/plugin-sdk/discord";
function normalizeDiscordSlug(value?: string | null) {
return normalizeAtHashSlug(value);
}
type SenderScopedToolsEntry = {
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
requireMention?: boolean;
};
function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) {
if (!guilds || Object.keys(guilds).length === 0) {
return null;
}
const space = groupSpace?.trim() ?? "";
if (space && guilds[space]) {
return guilds[space];
}
const normalized = normalizeDiscordSlug(space);
if (normalized && guilds[normalized]) {
return guilds[normalized];
}
if (normalized) {
const match = Object.values(guilds).find(
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
);
if (match) {
return match;
}
}
return guilds["*"] ?? null;
}
function resolveDiscordChannelEntry<TEntry extends SenderScopedToolsEntry>(
channelEntries: Record<string, TEntry> | undefined,
params: { groupId?: string | null; groupChannel?: string | null },
): TEntry | undefined {
if (!channelEntries || Object.keys(channelEntries).length === 0) {
return undefined;
}
const groupChannel = params.groupChannel;
const channelSlug = normalizeDiscordSlug(groupChannel);
return (
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined)
);
}
function resolveSenderToolsEntry(
entry: SenderScopedToolsEntry | undefined | null,
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
if (!entry) {
return undefined;
}
const senderPolicy = resolveToolsBySender({
toolsBySender: entry.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return senderPolicy ?? entry.tools;
}
function resolveDiscordPolicyContext(params: ChannelGroupContext) {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.channels?.discord?.guilds,
params.groupSpace,
);
const channelEntries = guildEntry?.channels;
const channelEntry =
channelEntries && Object.keys(channelEntries).length > 0
? resolveDiscordChannelEntry(channelEntries, params)
: undefined;
return { guildEntry, channelEntry };
}
export function resolveDiscordGroupRequireMention(params: ChannelGroupContext): boolean {
const context = resolveDiscordPolicyContext(params);
if (typeof context.channelEntry?.requireMention === "boolean") {
return context.channelEntry.requireMention;
}
if (typeof context.guildEntry?.requireMention === "boolean") {
return context.guildEntry.requireMention;
}
return true;
}
export function resolveDiscordGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const context = resolveDiscordPolicyContext(params);
const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params);
if (channelPolicy) {
return channelPolicy;
}
return resolveSenderToolsEntry(context.guildEntry, params);
}