diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 07604b285098..70c01391b946 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -d43b631bf84fdef65d44514e34f77dc97de14a3e5016d728e59585a1c47d1b2b plugin-sdk-api-baseline.json -5c272b15c12c2f7ff0756c57f33706662702564882b5c9fbdd46c58cf8dcf314 plugin-sdk-api-baseline.jsonl +2828a432f8c8fcec39fed6a2f612907aef001e1e5bb7bc295aa3f6d05aa053e5 plugin-sdk-api-baseline.json +c847fbaf67e4159a9305f8f985b00696d3d830a84082ef54df222ef627ea22a5 plugin-sdk-api-baseline.jsonl diff --git a/extensions/googlechat/src/group-policy.test.ts b/extensions/googlechat/src/group-policy.test.ts new file mode 100644 index 000000000000..c530da20663d --- /dev/null +++ b/extensions/googlechat/src/group-policy.test.ts @@ -0,0 +1,55 @@ +// Googlechat tests cover group policy plugin behavior. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; + +describe("googlechat group policy", () => { + it("resolves exact, wildcard, and unconfigured mention policies", () => { + const cfg = { + channels: { + googlechat: { + groups: { + "spaces/exact": { requireMention: false }, + "*": { requireMention: true }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/exact" })).toBe(false); + expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/other" })).toBe(true); + expect(resolveGoogleChatGroupRequireMention({ cfg: {}, groupId: "spaces/other" })).toBe(true); + }); + + it("uses account groups instead of root groups", () => { + const cfg = { + channels: { + googlechat: { + groups: { "spaces/exact": { requireMention: false } }, + accounts: { + work: { groups: { "spaces/exact": { requireMention: true } } }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveGoogleChatGroupRequireMention({ cfg, accountId: "work", groupId: "spaces/exact" }), + ).toBe(true); + }); + + it("falls back to root groups for one account with an empty groups map", () => { + const cfg = { + channels: { + googlechat: { + groups: { "spaces/exact": { requireMention: false } }, + accounts: { work: { groups: {} } }, + }, + }, + } as OpenClawConfig; + + expect( + resolveGoogleChatGroupRequireMention({ cfg, accountId: "work", groupId: "spaces/exact" }), + ).toBe(false); + }); +}); diff --git a/extensions/googlechat/src/group-policy.ts b/extensions/googlechat/src/group-policy.ts index 646e979ff66c..bf7e51e5986f 100644 --- a/extensions/googlechat/src/group-policy.ts +++ b/extensions/googlechat/src/group-policy.ts @@ -1,18 +1,17 @@ -// Googlechat plugin module implements group policy behavior. -import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; +import { + buildChannelGroupsScopeTree, + resolveScopeRequireMention, +} from "openclaw/plugin-sdk/channel-policy"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; -type GoogleChatGroupContext = { - cfg: OpenClawConfig; - accountId?: string | null; - groupId?: string | null; -}; +type GroupContext = { cfg: OpenClawConfig; accountId?: string | null; groupId?: string | null }; +function resolveScopePath(params: GroupContext) { + return params.groupId ? [params.groupId] : []; +} -export function resolveGoogleChatGroupRequireMention(params: GoogleChatGroupContext): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "googlechat", - groupId: params.groupId, - accountId: params.accountId, +export function resolveGoogleChatGroupRequireMention(params: GroupContext): boolean { + return resolveScopeRequireMention({ + tree: buildChannelGroupsScopeTree(params.cfg, "googlechat", params.accountId), + path: resolveScopePath(params), }); } diff --git a/extensions/imessage/src/group-policy.test.ts b/extensions/imessage/src/group-policy.test.ts new file mode 100644 index 000000000000..495c9ca5dacf --- /dev/null +++ b/extensions/imessage/src/group-policy.test.ts @@ -0,0 +1,86 @@ +// Imessage tests cover group policy plugin behavior. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, +} from "./group-policy.js"; + +describe("imessage group policy", () => { + it("resolves exact, wildcard, and unconfigured policies", () => { + const cfg = { + channels: { + imessage: { + groups: { + exact: { requireMention: false, tools: { deny: ["exec"] } }, + "*": { requireMention: true, tools: { allow: ["message.send"] } }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveIMessageGroupRequireMention({ cfg, groupId: "exact" })).toBe(false); + expect(resolveIMessageGroupRequireMention({ cfg, groupId: "other" })).toBe(true); + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "exact" })).toEqual({ + deny: ["exec"], + }); + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "other" })).toEqual({ + allow: ["message.send"], + }); + expect(resolveIMessageGroupRequireMention({ cfg: {}, groupId: "other" })).toBe(true); + expect(resolveIMessageGroupToolPolicy({ cfg: {}, groupId: "other" })).toBeUndefined(); + }); + + it("uses account groups and preserves the single-account empty fallback", () => { + const overrideCfg = { + channels: { + imessage: { + groups: { exact: { requireMention: false } }, + accounts: { work: { groups: { exact: { requireMention: true } } } }, + }, + }, + } as OpenClawConfig; + const fallbackCfg = { + channels: { + imessage: { + groups: { exact: { requireMention: false } }, + accounts: { work: { groups: {} } }, + }, + }, + } as OpenClawConfig; + + expect( + resolveIMessageGroupRequireMention({ + cfg: overrideCfg, + accountId: "work", + groupId: "exact", + }), + ).toBe(true); + expect( + resolveIMessageGroupRequireMention({ + cfg: fallbackCfg, + accountId: "work", + groupId: "exact", + }), + ).toBe(false); + }); + + it("prefers sender-scoped tools", () => { + const cfg = { + channels: { + imessage: { + groups: { + exact: { + tools: { deny: ["exec"] }, + toolsBySender: { "channel:imessage:alice": { allow: ["message.send"] } }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "exact", senderId: "alice" })).toEqual({ + allow: ["message.send"], + }); + }); +}); diff --git a/extensions/imessage/src/group-policy.ts b/extensions/imessage/src/group-policy.ts index f8058d50a1e9..b59977db9232 100644 --- a/extensions/imessage/src/group-policy.ts +++ b/extensions/imessage/src/group-policy.ts @@ -1,7 +1,8 @@ // Imessage plugin module implements group policy behavior. import { - resolveChannelGroupRequireMention, - resolveChannelGroupToolsPolicy, + buildChannelGroupsScopeTree, + resolveScopeRequireMention, + resolveScopeToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; @@ -16,26 +17,24 @@ type IMessageGroupContext = { senderE164?: string | null; }; +function resolveScopePath(params: IMessageGroupContext) { + return params.groupId ? [params.groupId] : []; +} + export function resolveIMessageGroupRequireMention(params: IMessageGroupContext): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "imessage", - groupId: params.groupId, - accountId: params.accountId, + return resolveScopeRequireMention({ + tree: buildChannelGroupsScopeTree(params.cfg, "imessage", params.accountId), + path: resolveScopePath(params), }); } export function resolveIMessageGroupToolPolicy( params: IMessageGroupContext, ): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "imessage", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, + return resolveScopeToolsPolicy({ + ...params, + tree: buildChannelGroupsScopeTree(params.cfg, "imessage", params.accountId), + path: resolveScopePath(params), + messageProvider: "imessage", }); } diff --git a/extensions/line/src/group-keys.test.ts b/extensions/line/src/group-keys.test.ts index 6e0126a61f91..b23325b8f27f 100644 --- a/extensions/line/src/group-keys.test.ts +++ b/extensions/line/src/group-keys.test.ts @@ -62,40 +62,57 @@ describe("account-scoped LINE groups", () => { expect(resolveLineGroupsConfig(cfg, "work")).toEqual({ "group:g1": { requireMention: false }, }); - expect(resolveExactLineGroupConfigKey({ cfg, accountId: "work", groupId: "g1" })).toBe( - "group:g1", - ); - expect(resolveExactLineGroupConfigKey({ cfg, accountId: "default", groupId: "g1" })).toBe( - undefined, - ); + expect( + resolveExactLineGroupConfigKey({ + groups: resolveLineGroupsConfig(cfg, "work"), + groupId: "g1", + }), + ).toBe("group:g1"); + expect( + resolveExactLineGroupConfigKey({ + groups: resolveLineGroupsConfig(cfg, "default"), + groupId: "g1", + }), + ).toBe(undefined); }); }); describe("line group policy", () => { - it("matches raw and prefixed LINE group keys for requireMention", () => { + it("preserves candidate precedence and falls back to wildcard", () => { const cfg = { channels: { line: { groups: { - "room:r123": { + same: { requireMention: false, }, - "group:g123": { + "group:same": { + requireMention: true, + }, + "room:same": { + requireMention: true, + }, + "group:typed": { requireMention: false, }, + "room:typed": { + requireMention: true, + }, "*": { - requireMention: true, + requireMention: false, }, }, }, }, } as OpenClawConfig; - expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false); - expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false); - expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false); - expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false); - expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true); + expect(resolveLineGroupRequireMention({ cfg, groupId: "same" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg, groupId: "room:same" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg, groupId: "group:same" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg, groupId: "typed" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg, groupId: "group:typed" })).toBe(false); + expect(resolveLineGroupRequireMention({ cfg, groupId: "room:typed" })).toBe(true); + expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(false); }); it("uses account-scoped prefixed LINE group config for requireMention", () => { diff --git a/extensions/line/src/group-keys.ts b/extensions/line/src/group-keys.ts index 5ef352879dc1..8459e9c43fd9 100644 --- a/extensions/line/src/group-keys.ts +++ b/extensions/line/src/group-keys.ts @@ -52,11 +52,10 @@ export function resolveLineGroupsConfig( } export function resolveExactLineGroupConfigKey(params: { - cfg: OpenClawConfig; - accountId?: string | null; + groups: Record | undefined; groupId?: string | null; }): string | undefined { - const groups = resolveLineGroupsConfig(params.cfg, params.accountId); + const { groups } = params; if (!groups) { return undefined; } diff --git a/extensions/line/src/group-policy.ts b/extensions/line/src/group-policy.ts index cad5aa48afe1..8aebe7e5db30 100644 --- a/extensions/line/src/group-policy.ts +++ b/extensions/line/src/group-policy.ts @@ -1,23 +1,20 @@ // Line plugin module implements group policy behavior. -import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; +import { + buildChannelGroupsScopeTree, + resolveScopeRequireMention, +} from "openclaw/plugin-sdk/channel-policy"; import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "./channel-api.js"; -type LineGroupContext = { - cfg: OpenClawConfig; - accountId?: string | null; - groupId?: string | null; -}; +type LineGroupContext = { cfg: OpenClawConfig; accountId?: string | null; groupId?: string | null }; export function resolveLineGroupRequireMention(params: LineGroupContext): boolean { - const exactGroupId = resolveExactLineGroupConfigKey({ - cfg: params.cfg, - accountId: params.accountId, + const tree = buildChannelGroupsScopeTree(params.cfg, "line", params.accountId); + const matchedKey = resolveExactLineGroupConfigKey({ + groups: tree.scopes, groupId: params.groupId, }); - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "line", - groupId: exactGroupId ?? params.groupId, - accountId: params.accountId, + return resolveScopeRequireMention({ + tree, + path: matchedKey ? [matchedKey] : [], }); } diff --git a/extensions/mattermost/src/group-mentions.test.ts b/extensions/mattermost/src/group-mentions.test.ts index 3df17ca4fc01..e8e5c53bde1a 100644 --- a/extensions/mattermost/src/group-mentions.test.ts +++ b/extensions/mattermost/src/group-mentions.test.ts @@ -15,17 +15,24 @@ describe("resolveMattermostGroupRequireMention", () => { expect(requireMention).toBe(true); }); - it("respects chatmode-derived account override", () => { + it("lets groups config beat chatmode and chatmode beat the final default", () => { const cfg: OpenClawConfig = { channels: { mattermost: { chatmode: "onmessage", + groups: { + calls: { requireMention: true }, + }, }, }, }; - const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" }); - expect(requireMention).toBe(false); + expect( + resolveMattermostGroupRequireMention({ cfg, accountId: "default", groupId: "calls" }), + ).toBe(true); + expect( + resolveMattermostGroupRequireMention({ cfg, accountId: "default", groupId: "other" }), + ).toBe(false); }); it("prefers an explicit runtime override when provided", () => { diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 0d8a19784b14..1b4dca9cdfe4 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -1,5 +1,8 @@ // Mattermost plugin module implements group mentions behavior. -import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy"; +import { + buildChannelGroupsScopeTree, + resolveScopeRequireMention, +} from "openclaw/plugin-sdk/channel-policy"; import { resolveMattermostAccount } from "./mattermost/accounts.js"; import type { ChannelGroupContext } from "./runtime-api.js"; @@ -10,15 +13,10 @@ export function resolveMattermostGroupRequireMention( cfg: params.cfg, accountId: params.accountId, }); - const requireMentionOverride = - typeof params.requireMentionOverride === "boolean" - ? params.requireMentionOverride - : account.requireMention; - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "mattermost", - groupId: params.groupId, - accountId: params.accountId, - requireMentionOverride, + return resolveScopeRequireMention({ + tree: buildChannelGroupsScopeTree(params.cfg, "mattermost", params.accountId), + path: params.groupId ? [params.groupId] : [], + requireMentionOverride: params.requireMentionOverride ?? account.requireMention, + overrideOrder: "after-config", }); } diff --git a/extensions/qqbot/src/group-policy.test.ts b/extensions/qqbot/src/group-policy.test.ts index 49c3a0c8e4e0..9b46a490ef6a 100644 --- a/extensions/qqbot/src/group-policy.test.ts +++ b/extensions/qqbot/src/group-policy.test.ts @@ -1,3 +1,7 @@ +import { + buildChannelGroupsScopeTree, + resolveScopeKeyCaseInsensitive, +} from "openclaw/plugin-sdk/channel-policy"; // Qqbot tests cover shared group tool policy behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; @@ -5,19 +9,20 @@ import { qqbotPlugin } from "./channel.js"; import { resolveQQBotGroupToolPolicy } from "./group-policy.js"; describe("qqbot group tool policy", () => { - it("resolves canonical per-group tools config", () => { + it("prefers an exact group key over a case-insensitive match", () => { const cfg = { channels: { qqbot: { groups: { - G1: { tools: { deny: ["*"] } }, + g1: { tools: { allow: ["case-insensitive"] } }, + G1: { tools: { deny: ["exact"] } }, }, }, }, } as OpenClawConfig; expect(resolveQQBotGroupToolPolicy({ cfg, groupId: "G1" })).toStrictEqual({ - deny: ["*"], + deny: ["exact"], }); }); @@ -46,7 +51,7 @@ describe("qqbot group tool policy", () => { ).toStrictEqual({ deny: ["*"] }); }); - it("matches mixed-case group ids after session-key normalization", () => { + it("uses a case-insensitive group key when no exact key exists", () => { const cfg = { channels: { qqbot: { @@ -71,6 +76,24 @@ describe("qqbot group tool policy", () => { ).toStrictEqual({ deny: ["*"] }); }); + it("keeps wildcard defaults out of case-insensitive scope matching", () => { + const cfg = { + channels: { + qqbot: { + groups: { + "*": { tools: { deny: ["default"] } }, + }, + }, + }, + } as OpenClawConfig; + const tree = buildChannelGroupsScopeTree(cfg, "qqbot"); + + expect(resolveScopeKeyCaseInsensitive(tree, "*")).toBeUndefined(); + expect(resolveQQBotGroupToolPolicy({ cfg, groupId: "*" })).toStrictEqual({ + deny: ["default"], + }); + }); + it("registers the resolver on the channel plugin", () => { const cfg = { channels: { diff --git a/extensions/qqbot/src/group-policy.ts b/extensions/qqbot/src/group-policy.ts index 3c4f0b63cc5c..754a381b34bc 100644 --- a/extensions/qqbot/src/group-policy.ts +++ b/extensions/qqbot/src/group-policy.ts @@ -1,22 +1,21 @@ // Qqbot plugin module implements group tool policy behavior. import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { - resolveChannelGroupToolsPolicy, + buildChannelGroupsScopeTree, + resolveScopeKeyCaseInsensitive, + resolveScopeToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; export function resolveQQBotGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "qqbot", - groupId: params.groupId, - groupIdCaseInsensitive: true, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, + const tree = buildChannelGroupsScopeTree(params.cfg, "qqbot", params.accountId); + const scopeKey = resolveScopeKeyCaseInsensitive(tree, params.groupId); + return resolveScopeToolsPolicy({ + ...params, + tree, + path: scopeKey ? [scopeKey] : [], + messageProvider: "qqbot", }); } diff --git a/extensions/whatsapp/src/group-policy.test.ts b/extensions/whatsapp/src/group-policy.test.ts index aa51c48fd933..379c0368691c 100644 --- a/extensions/whatsapp/src/group-policy.test.ts +++ b/extensions/whatsapp/src/group-policy.test.ts @@ -7,7 +7,7 @@ import { import type { OpenClawConfig } from "./runtime-api.js"; describe("whatsapp group policy", () => { - it("uses generic channel group policy helpers", () => { + it("resolves exact, wildcard, and unconfigured policies", () => { const cfg = { channels: { whatsapp: { @@ -33,5 +33,66 @@ describe("whatsapp group policy", () => { expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "other@g.us" })).toEqual({ allow: ["message.send"], }); + expect(resolveWhatsAppGroupRequireMention({ cfg: {}, groupId: "other@g.us" })).toBe(true); + expect(resolveWhatsAppGroupToolPolicy({ cfg: {}, groupId: "other@g.us" })).toBeUndefined(); + }); + + it("uses account groups and preserves the single-account empty fallback", () => { + const overrideCfg = { + channels: { + whatsapp: { + groups: { "1203630@g.us": { requireMention: false } }, + accounts: { + work: { groups: { "1203630@g.us": { requireMention: true } } }, + }, + }, + }, + } as OpenClawConfig; + const fallbackCfg = { + channels: { + whatsapp: { + groups: { "1203630@g.us": { requireMention: false } }, + accounts: { work: { groups: {} } }, + }, + }, + } as OpenClawConfig; + + expect( + resolveWhatsAppGroupRequireMention({ + cfg: overrideCfg, + accountId: "work", + groupId: "1203630@g.us", + }), + ).toBe(true); + expect( + resolveWhatsAppGroupRequireMention({ + cfg: fallbackCfg, + accountId: "work", + groupId: "1203630@g.us", + }), + ).toBe(false); + }); + + it("prefers sender-scoped tools", () => { + const cfg = { + channels: { + whatsapp: { + groups: { + "1203630@g.us": { + tools: { deny: ["exec"] }, + toolsBySender: { "channel:whatsapp:alice": { allow: ["message.send"] } }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveWhatsAppGroupToolPolicy({ + cfg, + groupId: "1203630@g.us", + senderId: "alice", + }), + ).toEqual({ allow: ["message.send"] }); }); }); diff --git a/extensions/whatsapp/src/group-policy.ts b/extensions/whatsapp/src/group-policy.ts index 286ea5ac4403..6974966bc5f0 100644 --- a/extensions/whatsapp/src/group-policy.ts +++ b/extensions/whatsapp/src/group-policy.ts @@ -1,7 +1,8 @@ // Whatsapp plugin module implements group policy behavior. import { - resolveChannelGroupRequireMention, - resolveChannelGroupToolsPolicy, + buildChannelGroupsScopeTree, + resolveScopeRequireMention, + resolveScopeToolsPolicy, type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -16,26 +17,24 @@ type WhatsAppGroupContext = { senderE164?: string | null; }; +function resolveScopePath(params: WhatsAppGroupContext) { + return params.groupId ? [params.groupId] : []; +} + export function resolveWhatsAppGroupRequireMention(params: WhatsAppGroupContext): boolean { - return resolveChannelGroupRequireMention({ - cfg: params.cfg, - channel: "whatsapp", - groupId: params.groupId, - accountId: params.accountId, + return resolveScopeRequireMention({ + tree: buildChannelGroupsScopeTree(params.cfg, "whatsapp", params.accountId), + path: resolveScopePath(params), }); } export function resolveWhatsAppGroupToolPolicy( params: WhatsAppGroupContext, ): GroupToolPolicyConfig | undefined { - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "whatsapp", - groupId: params.groupId, - accountId: params.accountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, + return resolveScopeToolsPolicy({ + ...params, + tree: buildChannelGroupsScopeTree(params.cfg, "whatsapp", params.accountId), + path: resolveScopePath(params), + messageProvider: "whatsapp", }); } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 5bdeb6090f8f..c599785e4200 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -135,7 +135,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ types: 6, "agent-config-primitives": 2, "command-auth": 81, - compat: 152, + compat: 160, "direct-dm": 9, "direct-dm-access": 5, discord: 48, @@ -159,7 +159,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "channel-pairing": 1, "conversation-runtime": 4, "channel-send-result": 1, - "channel-policy": 8, + "channel-policy": 15, "channel-route": 5, "session-store-runtime": 4, "session-transcript-runtime": 2, @@ -202,19 +202,22 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { 327, env, ), + // ScopeTree adds six channel-policy exports, mirrored by compat, including three functions. + // Its flat channel-groups builder adds one function, also mirrored by compat. + // Its case-insensitive scope-key resolver adds one function, also mirrored by compat. publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10649, + 10665, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5361, + 5371, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", - 3283, + 3292, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 1a6f5b017bd2..721fed31fc42 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -330,7 +330,7 @@ export function resolveToolsBySender( return matchToolsBySenderPolicy(compiled, params); } -function resolveChannelGroups( +export function resolveChannelGroups( cfg: OpenClawConfig, channel: GroupPolicyChannel, accountId?: string | null, diff --git a/src/config/group-scope-tree.test.ts b/src/config/group-scope-tree.test.ts new file mode 100644 index 000000000000..f752dc3adf5a --- /dev/null +++ b/src/config/group-scope-tree.test.ts @@ -0,0 +1,296 @@ +// Verifies canonical group scope precedence and sender policy resolution. +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "./config.js"; +import { resolveChannelGroupRequireMention, resolveToolsBySender } from "./group-policy.js"; +import { + resolveScopeIntroHint, + resolveScopeRequireMention, + resolveScopeToolsPolicy, + type ScopeTree, +} from "./group-scope-tree.js"; + +describe("resolveScopeRequireMention", () => { + const scalarCases: Array<{ + name: string; + tree: ScopeTree; + path: string[]; + expected: boolean; + }> = [ + { + name: "uses the most specific configured scope", + tree: { + defaults: { requireMention: false }, + scopes: { + broad: { requireMention: false }, + narrow: { requireMention: true }, + }, + }, + path: ["broad", "narrow"], + expected: true, + }, + { + name: "skips missing scope keys", + tree: { scopes: { broad: { requireMention: false } } }, + path: ["broad", "missing"], + expected: false, + }, + { + name: "uses defaults after path scopes", + tree: { defaults: { requireMention: false }, scopes: { room: {} } }, + path: ["room"], + expected: false, + }, + { + name: "defaults to requiring a mention", + tree: { scopes: {} }, + path: ["missing"], + expected: true, + }, + ]; + + it.each(scalarCases)("$name", ({ tree, path, expected }) => { + expect(resolveScopeRequireMention({ tree, path })).toBe(expected); + }); + + it("honors Telegram wildcard-topic precedence encoded by the path", () => { + const tree: ScopeTree = { + scopes: { + "*#topic:7": { requireMention: false }, + "": { requireMention: true }, + }, + }; + + expect( + resolveScopeRequireMention({ + tree, + path: ["*", "", "*#topic:7", "#topic:7"], + }), + ).toBe(false); + }); + + it.each([ + { + name: "no override", + configured: false, + override: undefined, + overrideOrder: undefined, + }, + { + name: "before-config override", + configured: false, + override: true, + overrideOrder: "before-config" as const, + }, + { + name: "after-config override", + configured: false, + override: true, + overrideOrder: "after-config" as const, + }, + { + name: "after-config fallback override", + configured: undefined, + override: false, + overrideOrder: "after-config" as const, + }, + ])("matches flat group-policy behavior: $name", ({ configured, override, overrideOrder }) => { + const node = typeof configured === "boolean" ? { requireMention: configured } : {}; + const tree: ScopeTree = { scopes: { room: node } }; + const cfg = { + channels: { whatsapp: { groups: { room: node } } }, + } as OpenClawConfig; + + expect( + resolveScopeRequireMention({ + tree, + path: ["room"], + requireMentionOverride: override, + overrideOrder, + }), + ).toBe( + resolveChannelGroupRequireMention({ + cfg, + channel: "whatsapp", + groupId: "room", + requireMentionOverride: override, + overrideOrder, + }), + ); + }); + + it("defaults configured-but-unset scopes to no mention only when requested", () => { + const tree: ScopeTree = { scopes: { configured: {} } }; + + expect( + resolveScopeRequireMention({ + tree, + path: ["configured"], + configuredScopeDefaultsToNoMention: true, + }), + ).toBe(false); + expect( + resolveScopeRequireMention({ + tree, + path: ["missing"], + configuredScopeDefaultsToNoMention: true, + }), + ).toBe(true); + }); +}); + +describe("resolveScopeToolsPolicy", () => { + const cascadeCases: Array<{ + name: string; + tree: ScopeTree; + senderId: string; + expected: { allow?: string[]; deny?: string[] }; + }> = [ + { + name: "channel sender policy", + tree: { + scopes: { + team: { tools: { deny: ["team"] } }, + channel: { + toolsBySender: { "id:alice": { allow: ["channel-sender"] } }, + tools: { allow: ["channel"] }, + }, + }, + }, + senderId: "alice", + expected: { allow: ["channel-sender"] }, + }, + { + name: "channel policy", + tree: { + scopes: { + team: { tools: { deny: ["team"] } }, + channel: { + toolsBySender: { "id:alice": { allow: ["channel-sender"] } }, + tools: { allow: ["channel"] }, + }, + }, + }, + senderId: "bob", + expected: { allow: ["channel"] }, + }, + { + name: "team sender policy", + tree: { + scopes: { + team: { + toolsBySender: { "id:bob": { allow: ["team-sender"] } }, + tools: { deny: ["team"] }, + }, + channel: {}, + }, + }, + senderId: "bob", + expected: { allow: ["team-sender"] }, + }, + { + name: "team policy", + tree: { + scopes: { + team: { + toolsBySender: { "id:bob": { allow: ["team-sender"] } }, + tools: { deny: ["team"] }, + }, + channel: {}, + }, + }, + senderId: "carol", + expected: { deny: ["team"] }, + }, + ]; + + it.each(cascadeCases)( + "resolves the MSTeams cascade through $name", + ({ tree, senderId, expected }) => { + expect(resolveScopeToolsPolicy({ tree, path: ["team", "channel"], senderId })).toEqual( + expected, + ); + }, + ); + + it.each([ + { name: "id", sender: { senderId: "user:alice" }, expected: { allow: ["id"] } }, + { + name: "username", + sender: { senderUsername: "@Alice" }, + expected: { allow: ["username"] }, + }, + { + name: "channel", + sender: { senderId: "user:alice", messageProvider: "discord" }, + expected: { allow: ["channel"] }, + }, + { + name: "channel without provider", + sender: { senderId: "user:alice" }, + expected: { allow: ["id"] }, + }, + ])("matches resolveToolsBySender for typed $name keys", ({ sender, expected }) => { + const toolsBySender = { + "id:user:alice": { allow: ["id"] }, + "username:alice": { allow: ["username"] }, + "channel:discord:user:alice": { allow: ["channel"] }, + "*": { deny: ["fallback"] }, + }; + const tree: ScopeTree = { scopes: { room: { toolsBySender } } }; + + const directPolicy = resolveToolsBySender({ toolsBySender, ...sender }); + expect(resolveScopeToolsPolicy({ tree, path: ["room"], ...sender })).toEqual(directPolicy); + expect(directPolicy).toEqual(expected); + }); + + it("uses sender and plain policies from defaults after path scopes", () => { + const senderTree: ScopeTree = { + defaults: { + toolsBySender: { "id:alice": { allow: ["default-sender"] } }, + tools: { deny: ["default"] }, + }, + scopes: { channel: {} }, + }; + + expect( + resolveScopeToolsPolicy({ tree: senderTree, path: ["channel"], senderId: "alice" }), + ).toEqual({ allow: ["default-sender"] }); + expect( + resolveScopeToolsPolicy({ tree: senderTree, path: ["channel"], senderId: "bob" }), + ).toEqual({ deny: ["default"] }); + expect(resolveScopeToolsPolicy({ tree: { scopes: {} }, path: [] })).toBeUndefined(); + }); + + it("keeps a narrower plain policy ahead of a broader sender match", () => { + const tree: ScopeTree = { + scopes: { + team: { toolsBySender: { "id:alice": { allow: ["team-sender"] } } }, + channel: { tools: { deny: ["channel"] } }, + }, + }; + + expect( + resolveScopeToolsPolicy({ + tree, + path: ["team", "channel"], + senderId: "alice", + }), + ).toEqual({ deny: ["channel"] }); + }); +}); + +describe("resolveScopeIntroHint", () => { + it("uses the first defined hint from narrowest scope through defaults", () => { + const tree: ScopeTree = { + defaults: { introHint: "default" }, + scopes: { + team: { introHint: "team" }, + channel: {}, + thread: { introHint: "thread" }, + }, + }; + + expect(resolveScopeIntroHint({ tree, path: ["team", "channel", "thread"] })).toBe("thread"); + expect(resolveScopeIntroHint({ tree, path: ["missing"] })).toBe("default"); + }); +}); diff --git a/src/config/group-scope-tree.ts b/src/config/group-scope-tree.ts new file mode 100644 index 000000000000..641b75858569 --- /dev/null +++ b/src/config/group-scope-tree.ts @@ -0,0 +1,143 @@ +// Resolves canonical group policy scopes prepared by channel plugins. +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import type { ChannelId } from "../channels/plugins/channel-id.types.js"; +import { resolveChannelGroups, resolveToolsBySender } from "./group-policy.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; + +export type ScopeNode = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + introHint?: string; +}; + +export type ScopeTree = { + defaults?: ScopeNode; + // Flat keys preserve channel-defined precedence: channels emit broad-to-narrow paths. + // Nested trees cannot model Telegram, where a wildcard-group topic outranks + // an exact-group scalar requireMention. + scopes: Record; +}; + +export type ScopePath = string[]; + +type ScopeToolPolicySender = Omit[0], "toolsBySender">; + +export function buildChannelGroupsScopeTree( + cfg: OpenClawConfig, + channel: ChannelId, + accountId?: string | null, +): ScopeTree { + // 13+ channels share the flat `groups` config shape; richer channels such as + // Discord, Telegram, and Microsoft Teams ship their own builders. + const groups = resolveChannelGroups(cfg, channel, accountId) ?? {}; + // The wildcard config is the fallback node, not a matchable exact scope. + const { "*": defaults, ...scopes } = groups; + return { defaults, scopes }; +} + +export function resolveScopeKeyCaseInsensitive( + tree: ScopeTree, + key: string | null | undefined, +): string | undefined { + if (!key) { + return undefined; + } + // Exact scope identity wins; keys are matched untrimmed for parity with the + // legacy resolver. Wildcard never matches: builders move it to `defaults`. + if (Object.hasOwn(tree.scopes, key)) { + return key; + } + const target = normalizeLowercaseStringOrEmpty(key); + return Object.keys(tree.scopes).find( + (scopeKey) => normalizeLowercaseStringOrEmpty(scopeKey) === target, + ); +} + +function resolveFromScopes(params: { + tree: ScopeTree; + path: ScopePath; + resolveNode: (node: ScopeNode) => Value | undefined; +}): Value | undefined { + for (let index = params.path.length - 1; index >= 0; index -= 1) { + const key = params.path[index]; + if (key === undefined || !Object.hasOwn(params.tree.scopes, key)) { + continue; + } + const node = params.tree.scopes[key]; + if (!node) { + continue; + } + const value = params.resolveNode(node); + if (value !== undefined) { + return value; + } + } + return params.tree.defaults ? params.resolveNode(params.tree.defaults) : undefined; +} + +export function resolveScopeRequireMention(params: { + tree: ScopeTree; + path: ScopePath; + requireMentionOverride?: boolean; + overrideOrder?: "before-config" | "after-config"; + configuredScopeDefaultsToNoMention?: boolean; +}): boolean { + // Runtime overrides stay in resolver parameters because channels derive them per message. + const { requireMentionOverride, overrideOrder = "after-config" } = params; + const configuredMention = resolveFromScopes({ + tree: params.tree, + path: params.path, + resolveNode: (node) => node.requireMention, + }); + + if (overrideOrder === "before-config" && typeof requireMentionOverride === "boolean") { + return requireMentionOverride; + } + if (typeof configuredMention === "boolean") { + return configuredMention; + } + if (overrideOrder !== "before-config" && typeof requireMentionOverride === "boolean") { + return requireMentionOverride; + } + if ( + params.configuredScopeDefaultsToNoMention && + params.path.some((key) => Object.hasOwn(params.tree.scopes, key)) + ) { + return false; + } + return true; +} + +export function resolveScopeToolsPolicy( + params: { + tree: ScopeTree; + path: ScopePath; + } & ScopeToolPolicySender, +): GroupToolPolicyConfig | undefined { + return resolveFromScopes({ + tree: params.tree, + path: params.path, + resolveNode: (node) => + resolveToolsBySender({ + toolsBySender: node.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + messageProvider: params.messageProvider, + }) ?? node.tools, + }); +} + +export function resolveScopeIntroHint(params: { + tree: ScopeTree; + path: ScopePath; +}): string | undefined { + return resolveFromScopes({ + tree: params.tree, + path: params.path, + resolveNode: (node) => node.introHint, + }); +} diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index ca0e7370965f..fee4f80a2cfd 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -48,6 +48,16 @@ export { resolveToolsBySender, type ChannelGroupPolicy, } from "../config/group-policy.js"; +export { + buildChannelGroupsScopeTree, + resolveScopeIntroHint, + resolveScopeKeyCaseInsensitive, + resolveScopeRequireMention, + resolveScopeToolsPolicy, + type ScopeNode, + type ScopePath, + type ScopeTree, +} from "../config/group-scope-tree.js"; export { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy,