From 9e556f75f57ad0089d0112af3596746a63a1f9de Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:26:21 +0000 Subject: [PATCH] Slack: move group policy behind plugin boundary --- extensions/slack/api.ts | 1 + extensions/slack/src/channel.ts | 3 +- extensions/slack/src/group-policy.test.ts | 55 +++++++++++++++ extensions/slack/src/group-policy.ts | 74 +++++++++++++++++++++ extensions/slack/src/index.ts | 1 + src/channels/plugins/group-mentions.test.ts | 55 --------------- src/channels/plugins/group-mentions.ts | 53 +-------------- src/plugin-sdk/channel-policy.ts | 6 +- src/plugin-sdk/core.ts | 1 + src/plugin-sdk/slack.ts | 2 +- 10 files changed, 140 insertions(+), 111 deletions(-) create mode 100644 extensions/slack/src/group-policy.test.ts create mode 100644 extensions/slack/src/group-policy.ts diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts index 37aaf02b027..70ae694652d 100644 --- a/extensions/slack/api.ts +++ b/extensions/slack/api.ts @@ -6,6 +6,7 @@ export * from "./src/blocks-render.js"; export * from "./src/http/index.js"; export * from "./src/interactive-replies.js"; export * from "./src/message-actions.js"; +export * from "./src/group-policy.js"; export * from "./src/sent-thread-cache.js"; export * from "./src/targets.js"; export * from "./src/threading-tool-context.js"; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index b6f82f19afd..d2c59c25468 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -20,8 +20,6 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromRequiredCredentialStatuses, - resolveSlackGroupRequireMention, - resolveSlackGroupToolPolicy, type ChannelPlugin, type OpenClawConfig, type SlackActionContext, @@ -36,6 +34,7 @@ import { import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackActions } from "./channel-actions.js"; import { createSlackWebClient } from "./client.js"; +import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; diff --git a/extensions/slack/src/group-policy.test.ts b/extensions/slack/src/group-policy.test.ts new file mode 100644 index 00000000000..8606a9da674 --- /dev/null +++ b/extensions/slack/src/group-policy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; + +const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + channels: { + alerts: { + requireMention: false, + tools: { allow: ["message.send"] }, + toolsBySender: { + "id:user:alice": { allow: ["sessions.list"] }, + }, + }, + "*": { + requireMention: true, + tools: { deny: ["exec"] }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any +} as any; + +describe("slack group policy", () => { + it("uses matched channel requireMention and wildcard fallback", () => { + expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false); + expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true); + }); + + it("resolves sender override, then channel tools, then wildcard tools", () => { + const senderOverride = resolveSlackGroupToolPolicy({ + cfg, + groupChannel: "#alerts", + senderId: "user:alice", + }); + expect(senderOverride).toEqual({ allow: ["sessions.list"] }); + + const channelTools = resolveSlackGroupToolPolicy({ + cfg, + groupChannel: "#alerts", + senderId: "user:bob", + }); + expect(channelTools).toEqual({ allow: ["message.send"] }); + + const wildcardTools = resolveSlackGroupToolPolicy({ + cfg, + groupChannel: "#missing", + senderId: "user:bob", + }); + expect(wildcardTools).toEqual({ deny: ["exec"] }); + }); +}); diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts new file mode 100644 index 00000000000..d49138fb5f8 --- /dev/null +++ b/extensions/slack/src/group-policy.ts @@ -0,0 +1,74 @@ +import { + resolveToolsBySender, + type GroupToolPolicyBySenderConfig, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; +import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime"; +import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core"; +import { inspectSlackAccount } from "./account-inspect.js"; + +type SlackChannelPolicyEntry = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; +}; + +function resolveSlackChannelPolicyEntry( + params: ChannelGroupContext, +): SlackChannelPolicyEntry | undefined { + const account = inspectSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const channels = (account.channels ?? {}) as Record; + if (Object.keys(channels).length === 0) { + return undefined; + } + const channelId = params.groupId?.trim(); + const groupChannel = params.groupChannel; + const channelName = groupChannel?.replace(/^#/, ""); + const normalizedName = normalizeHyphenSlug(channelName); + const candidates = [ + channelId ?? "", + channelName ? `#${channelName}` : "", + channelName ?? "", + normalizedName, + ].filter(Boolean); + for (const candidate of candidates) { + if (candidate && channels[candidate]) { + return channels[candidate]; + } + } + return channels["*"]; +} + +function resolveSenderToolsEntry( + entry: SlackChannelPolicyEntry | undefined, + 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; +} + +export function resolveSlackGroupRequireMention(params: ChannelGroupContext): boolean { + const resolved = resolveSlackChannelPolicyEntry(params); + if (typeof resolved?.requireMention === "boolean") { + return resolved.requireMention; + } + return true; +} + +export function resolveSlackGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + return resolveSenderToolsEntry(resolveSlackChannelPolicyEntry(params), params); +} diff --git a/extensions/slack/src/index.ts b/extensions/slack/src/index.ts index 7798ea9c605..f7b5f436fc4 100644 --- a/extensions/slack/src/index.ts +++ b/extensions/slack/src/index.ts @@ -22,4 +22,5 @@ export { export { monitorSlackProvider } from "./monitor.js"; export { probeSlack } from "./probe.js"; export { sendMessageSlack } from "./send.js"; +export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index 5f8e4ed43e9..5bcedcf4d8f 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -6,65 +6,10 @@ import { resolveDiscordGroupToolPolicy, resolveLineGroupRequireMention, resolveLineGroupToolPolicy, - resolveSlackGroupRequireMention, - resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "./group-mentions.js"; -const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - channels: { - alerts: { - requireMention: false, - tools: { allow: ["message.send"] }, - toolsBySender: { - "id:user:alice": { allow: ["sessions.list"] }, - }, - }, - "*": { - requireMention: true, - tools: { deny: ["exec"] }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any -} as any; - -describe("group mentions (slack)", () => { - it("uses matched channel requireMention and wildcard fallback", () => { - expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false); - expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true); - }); - - it("resolves sender override, then channel tools, then wildcard tools", () => { - const senderOverride = resolveSlackGroupToolPolicy({ - cfg, - groupChannel: "#alerts", - senderId: "user:alice", - }); - expect(senderOverride).toEqual({ allow: ["sessions.list"] }); - - const channelTools = resolveSlackGroupToolPolicy({ - cfg, - groupChannel: "#alerts", - senderId: "user:bob", - }); - expect(channelTools).toEqual({ allow: ["message.send"] }); - - const wildcardTools = resolveSlackGroupToolPolicy({ - cfg, - groupChannel: "#missing", - senderId: "user:bob", - }); - expect(wildcardTools).toEqual({ deny: ["exec"] }); - }); -}); - describe("group mentions (telegram)", () => { it("resolves topic-level requireMention and chat-level tools for topic ids", () => { const telegramCfg = { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index f825fc73fe5..215c22e2942 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -10,8 +10,7 @@ import type { GroupToolPolicyConfig, } from "../../config/types.tools.js"; import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js"; -import { inspectSlackAccount } from "../../plugin-sdk/slack.js"; -import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js"; +import { normalizeAtHashSlug } from "../../shared/string-normalization.js"; import type { ChannelGroupContext } from "./types.js"; type GroupMentionParams = ChannelGroupContext; @@ -110,12 +109,6 @@ function resolveDiscordChannelEntry( ); } -type SlackChannelPolicyEntry = { - requireMention?: boolean; - tools?: GroupToolPolicyConfig; - toolsBySender?: GroupToolPolicyBySenderConfig; -}; - type SenderScopedToolsEntry = { tools?: GroupToolPolicyConfig; toolsBySender?: GroupToolPolicyBySenderConfig; @@ -129,35 +122,6 @@ type ChannelGroupPolicyChannel = | "bluebubbles" | "line"; -function resolveSlackChannelPolicyEntry( - params: GroupMentionParams, -): SlackChannelPolicyEntry | undefined { - const account = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - const channels = (account.channels ?? {}) as Record; - if (Object.keys(channels).length === 0) { - return undefined; - } - const channelId = params.groupId?.trim(); - const groupChannel = params.groupChannel; - const channelName = groupChannel?.replace(/^#/, ""); - const normalizedName = normalizeHyphenSlug(channelName); - const candidates = [ - channelId ?? "", - channelName ? `#${channelName}` : "", - channelName ?? "", - normalizedName, - ].filter(Boolean); - for (const candidate of candidates) { - if (candidate && channels[candidate]) { - return channels[candidate]; - } - } - return channels["*"]; -} - function resolveChannelRequireMention( params: GroupMentionParams, channel: ChannelGroupPolicyChannel, @@ -270,14 +234,6 @@ export function resolveGoogleChatGroupToolPolicy( return resolveChannelToolPolicyForSender(params, "googlechat"); } -export function resolveSlackGroupRequireMention(params: GroupMentionParams): boolean { - const resolved = resolveSlackChannelPolicyEntry(params); - if (typeof resolved?.requireMention === "boolean") { - return resolved.requireMention; - } - return true; -} - export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean { return resolveChannelRequireMention(params, "bluebubbles"); } @@ -312,13 +268,6 @@ export function resolveDiscordGroupToolPolicy( return resolveSenderToolsEntry(context.guildEntry, params); } -export function resolveSlackGroupToolPolicy( - params: GroupMentionParams, -): GroupToolPolicyConfig | undefined { - const resolved = resolveSlackChannelPolicyEntry(params); - return resolveSenderToolsEntry(resolved, params); -} - export function resolveBlueBubblesGroupToolPolicy( params: GroupMentionParams, ): GroupToolPolicyConfig | undefined { diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index 62538b68dd6..b7166262eb6 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -1,4 +1,8 @@ /** Shared policy warnings and DM/group policy helpers for channel plugins. */ +export type { + GroupToolPolicyBySenderConfig, + GroupToolPolicyConfig, +} from "../config/types.tools.js"; export { buildOpenGroupPolicyConfigureRouteAllowlistWarning, buildOpenGroupPolicyRestrictSendersWarning, @@ -10,7 +14,7 @@ export { collectOpenProviderGroupPolicyWarnings, } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; -export { resolveChannelGroupRequireMention } from "../config/group-policy.js"; +export { resolveChannelGroupRequireMention, resolveToolsBySender } from "../config/group-policy.js"; export { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 1628506a055..8fef540da68 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -89,6 +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 { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type { diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 31b857c0d2a..80b49010142 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -43,7 +43,7 @@ export { export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, -} from "../channels/plugins/group-mentions.js"; +} from "../../extensions/slack/src/group-policy.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildComputedAccountStatusSnapshot } from "./status-helpers.js";