diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 858255c0495..19a5b926ff0 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -3,6 +3,7 @@ export * from "./src/accounts.js"; export * from "./src/actions/handle-action.guild-admin.js"; export * from "./src/actions/handle-action.js"; export * from "./src/components.js"; +export * from "./src/group-policy.js"; export * from "./src/normalize.js"; export * from "./src/pluralkit.js"; export * from "./src/session-key-normalization.js"; diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 5e47dda6334..b5f2224b1dd 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -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"] }); + }); +}); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 21348036a46..30a5535a6d9 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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, diff --git a/extensions/discord/src/group-policy.test.ts b/extensions/discord/src/group-policy.test.ts new file mode 100644 index 00000000000..249df3fa8a7 --- /dev/null +++ b/extensions/discord/src/group-policy.test.ts @@ -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"] }); + }); +}); diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts new file mode 100644 index 00000000000..f327a761ea0 --- /dev/null +++ b/extensions/discord/src/group-policy.ts @@ -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( + channelEntries: Record | 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); +} diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index 5bcedcf4d8f..b942cf5a63b 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, - resolveDiscordGroupRequireMention, - resolveDiscordGroupToolPolicy, resolveLineGroupRequireMention, resolveLineGroupToolPolicy, resolveTelegramGroupRequireMention, @@ -45,80 +43,6 @@ describe("group mentions (telegram)", () => { }); }); -describe("group mentions (discord)", () => { - 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"] }); - }); -}); - describe("group mentions (bluebubbles)", () => { it("uses generic channel group policy helpers", () => { const blueBubblesCfg = { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 215c22e2942..ed432d8deb6 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -2,23 +2,13 @@ import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, - resolveToolsBySender, } from "../../config/group-policy.js"; -import type { DiscordConfig } from "../../config/types.js"; -import type { - GroupToolPolicyBySenderConfig, - GroupToolPolicyConfig, -} from "../../config/types.tools.js"; +import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; -import { normalizeAtHashSlug } from "../../shared/string-normalization.js"; import type { ChannelGroupContext } from "./types.js"; type GroupMentionParams = ChannelGroupContext; -function normalizeDiscordSlug(value?: string | null) { - return normalizeAtHashSlug(value); -} - function parseTelegramGroupId(value?: string | null) { const raw = value?.trim() ?? ""; if (!raw) { @@ -68,52 +58,6 @@ function resolveTelegramRequireMention(params: { return undefined; } -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( - channelEntries: Record | 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) - ); -} - -type SenderScopedToolsEntry = { - tools?: GroupToolPolicyConfig; - toolsBySender?: GroupToolPolicyBySenderConfig; -}; - type ChannelGroupPolicyChannel = | "telegram" | "whatsapp" @@ -152,39 +96,6 @@ function resolveChannelToolPolicyForSender( }); } -function resolveSenderToolsEntry( - entry: SenderScopedToolsEntry | undefined | null, - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - if (!entry) { - return undefined; - } - const senderPolicy = resolveToolsBySender({ - toolsBySender: entry.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (senderPolicy) { - return senderPolicy; - } - return entry.tools; -} - -function resolveDiscordPolicyContext(params: GroupMentionParams) { - 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 resolveTelegramGroupRequireMention( params: GroupMentionParams, ): boolean | undefined { @@ -213,17 +124,6 @@ export function resolveIMessageGroupRequireMention(params: GroupMentionParams): return resolveChannelRequireMention(params, "imessage"); } -export function resolveDiscordGroupRequireMention(params: GroupMentionParams): 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 resolveGoogleChatGroupRequireMention(params: GroupMentionParams): boolean { return resolveChannelRequireMention(params, "googlechat"); } @@ -257,17 +157,6 @@ export function resolveIMessageGroupToolPolicy( return resolveChannelToolPolicyForSender(params, "imessage"); } -export function resolveDiscordGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - const context = resolveDiscordPolicyContext(params); - const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params); - if (channelPolicy) { - return channelPolicy; - } - return resolveSenderToolsEntry(context.guildEntry, params); -} - export function resolveBlueBubblesGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 8fef540da68..7bec25a02cf 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -89,7 +89,7 @@ export type { SecretFileReadOptions, SecretFileReadResult } from "../infra/secre export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; -export { normalizeHyphenSlug } from "../shared/string-normalization.js"; +export { normalizeAtHashSlug, normalizeHyphenSlug } from "../shared/string-normalization.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 91bde97a5aa..2078382a2da 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -56,7 +56,7 @@ export { export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/discord/src/group-policy.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export {