From b34bba8e574c82d9f33ff67d7253c5378ba0b9c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 23:56:35 -0700 Subject: [PATCH] refactor(channels): migrate the remaining nine group-policy walkers onto the scope tree (#107181) * refactor(channels): migrate irc, nextcloud-talk, zalouser, slack, and matrix group policy onto the scope tree * fix(irc): drop unused tree binding in group match * fix(zalouser): keep wildcard lookup opt-in for explicit-only candidates * fix(zalouser): adapter opts into the wildcard fallback candidate * refactor(channels): migrate feishu, msteams, and discord group policy onto the scope tree and drop the core discord duplicate * fix(msteams): restore the cross-team scan fallback for policy-less matched teams * fix(feishu): keep the adapter's zod-typed group config for the scope tree * revert(core): keep the discord require-mention fallback until stage 3 makes it provably redundant * chore(matrix): drop the now-unused channel entry match re-export * refactor(telegram): migrate group policy onto the scope tree * test(telegram): keep the bot token fixture off secret-scanner patterns * test(telegram): use a computed key for the bot token fixture * test(telegram): assemble the bot token fixture indirectly for scanner and lint --- extensions/discord/src/group-policy.test.ts | 132 ++++++++++++ extensions/discord/src/group-policy.ts | 143 +++++++------ extensions/feishu/src/policy.test.ts | 54 +++++ extensions/feishu/src/policy.ts | 27 ++- extensions/irc/src/channel.ts | 11 +- extensions/irc/src/inbound.ts | 7 +- extensions/irc/src/policy.test.ts | 41 +++- extensions/irc/src/policy.ts | 107 +++++----- extensions/matrix/src/group-mentions.test.ts | 41 +++- extensions/matrix/src/group-mentions.ts | 47 ++--- .../matrix/src/matrix/monitor/index.test.ts | 26 --- extensions/matrix/src/matrix/monitor/rooms.ts | 66 +++--- .../matrix/src/matrix/monitor/runtime-api.ts | 5 +- extensions/msteams/src/policy.test.ts | 100 +++++++++ extensions/msteams/src/policy.ts | 195 ++++++++++-------- extensions/nextcloud-talk/src/channel.ts | 28 +-- extensions/nextcloud-talk/src/inbound.ts | 9 +- extensions/nextcloud-talk/src/policy.test.ts | 48 +++++ extensions/nextcloud-talk/src/policy.ts | 87 ++++---- extensions/slack/src/group-policy.test.ts | 43 +++- extensions/slack/src/group-policy.ts | 73 +++---- extensions/telegram/src/group-policy.test.ts | 178 +++++++++++----- extensions/telegram/src/group-policy.ts | 65 +++--- extensions/zalouser/src/channel.adapters.ts | 23 +-- extensions/zalouser/src/group-policy.test.ts | 40 +++- extensions/zalouser/src/group-policy.ts | 41 ++-- 26 files changed, 1082 insertions(+), 555 deletions(-) create mode 100644 extensions/discord/src/group-policy.test.ts create mode 100644 extensions/nextcloud-talk/src/policy.test.ts diff --git a/extensions/discord/src/group-policy.test.ts b/extensions/discord/src/group-policy.test.ts new file mode 100644 index 000000000000..d8dc0d0a2aad --- /dev/null +++ b/extensions/discord/src/group-policy.test.ts @@ -0,0 +1,132 @@ +// Discord tests pin guild/channel ScopeTree policy precedence. +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import { describe, expect, it } from "vitest"; +import { + resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, +} from "./group-policy.js"; + +function createCfg(discord: Record): OpenClawConfig { + return { channels: { discord } } as OpenClawConfig; +} + +describe("discord group policy", () => { + it("prefers a channel sender policy over the guild plain policy", () => { + const cfg = createCfg({ + guilds: { + guild: { + tools: { deny: ["guild"] }, + channels: { + channel: { + toolsBySender: { "id:alice": { allow: ["channel-sender"] } }, + }, + }, + }, + }, + }); + + expect( + resolveDiscordGroupToolPolicy({ + cfg, + groupSpace: "guild", + groupId: "channel", + senderId: "alice", + }), + ).toEqual({ allow: ["channel-sender"] }); + expect( + resolveDiscordGroupToolPolicy({ + cfg, + groupSpace: "guild", + groupId: "channel", + senderId: "bob", + }), + ).toEqual({ deny: ["guild"] }); + }); + + it("does not use a channel wildcard as fallback", () => { + expect( + resolveDiscordGroupToolPolicy({ + cfg: createCfg({ + guilds: { + guild: { + tools: { allow: ["guild"] }, + channels: { + "*": { tools: { deny: ["channel-wildcard"] } }, + }, + }, + }, + }), + groupSpace: "guild", + groupId: "missing", + }), + ).toEqual({ allow: ["guild"] }); + }); + + it("uses the wildcard guild only when no guild matches", () => { + const cfg = createCfg({ + guilds: { + "*": { + requireMention: false, + tools: { allow: ["wildcard"] }, + }, + exact: {}, + }, + }); + + expect(resolveDiscordGroupRequireMention({ cfg, groupSpace: "exact" })).toBe(true); + expect(resolveDiscordGroupToolPolicy({ cfg, groupSpace: "exact" })).toBeUndefined(); + expect(resolveDiscordGroupRequireMention({ cfg, groupSpace: "missing" })).toBe(false); + expect(resolveDiscordGroupToolPolicy({ cfg, groupSpace: "missing" })).toEqual({ + allow: ["wildcard"], + }); + }); + + it("matches normalized and hash-prefixed channel slugs", () => { + const cfg = createCfg({ + guilds: { + guild: { + channels: { + general: { tools: { allow: ["normalized"] } }, + "#ops-room": { tools: { allow: ["hash"] } }, + }, + }, + }, + }); + + expect( + resolveDiscordGroupToolPolicy({ cfg, groupSpace: "guild", groupChannel: "#General" }), + ).toEqual({ allow: ["normalized"] }); + expect( + resolveDiscordGroupToolPolicy({ cfg, groupSpace: "guild", groupChannel: "Ops Room" }), + ).toEqual({ allow: ["hash"] }); + }); + + it("keeps an account empty guild map from inheriting root guilds", () => { + expect( + resolveDiscordGroupRequireMention({ + cfg: createCfg({ + guilds: { guild: { requireMention: false } }, + accounts: { work: { guilds: {} } }, + }), + accountId: "work", + groupSpace: "guild", + }), + ).toBe(true); + }); + + it("keeps slash-bearing flat scope keys collision-free", () => { + const cfg = createCfg({ + guilds: { + "a/channel:b": { tools: { allow: ["slash-guild"] } }, + a: { channels: { b: { tools: { allow: ["nested-channel"] } } } }, + }, + }); + + expect(resolveDiscordGroupToolPolicy({ cfg, groupSpace: "a/channel:b" })).toEqual({ + allow: ["slash-guild"], + }); + expect(resolveDiscordGroupToolPolicy({ cfg, groupSpace: "a", groupId: "b" })).toEqual({ + allow: ["nested-channel"], + }); + }); +}); diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts index ed5bc20009f6..4421159d809e 100644 --- a/extensions/discord/src/group-policy.ts +++ b/extensions/discord/src/group-policy.ts @@ -1,9 +1,10 @@ // Discord plugin module implements group policy behavior. import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { - resolveToolsBySender, - type GroupToolPolicyBySenderConfig, + resolveScopeRequireMention, + resolveScopeToolsPolicy, type GroupToolPolicyConfig, + type ScopeTree, } from "openclaw/plugin-sdk/channel-policy"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeAtHashSlug } from "openclaw/plugin-sdk/string-normalization-runtime"; @@ -13,102 +14,118 @@ function normalizeDiscordSlug(value?: string | null) { return normalizeAtHashSlug(value); } -type SenderScopedToolsEntry = { - tools?: GroupToolPolicyConfig; - toolsBySender?: GroupToolPolicyBySenderConfig; - requireMention?: boolean; -}; +const encodeScopeSegment = (value: string) => `${value.length}:${value}`; -function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) { +// Length-prefixed segments keep arbitrary config keys, including slashes, collision-free. +const guildScopeKey = (guildKey: string) => `guild:${encodeScopeSegment(guildKey)}`; +const channelScopeKey = (guildKey: string, channelKey: string) => + `${guildScopeKey(guildKey)}/channel:${encodeScopeSegment(channelKey)}`; + +function resolveDiscordGuildKey( + guilds: DiscordConfig["guilds"], + groupSpace?: string | null, +): string | undefined { if (!guilds || Object.keys(guilds).length === 0) { - return null; + return undefined; } const space = normalizeOptionalString(groupSpace) ?? ""; if (space && guilds[space]) { - return guilds[space]; + return space; } const normalized = normalizeDiscordSlug(space); if (normalized && guilds[normalized]) { - return guilds[normalized]; + return normalized; } if (normalized) { - const match = Object.values(guilds).find( - (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, + const match = Object.entries(guilds).find( + ([, entry]) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, ); if (match) { - return match; + return match[0]; } } - return guilds["*"] ?? null; + return guilds["*"] ? "*" : undefined; } -function resolveDiscordChannelEntry( - channelEntries: Record | undefined, +function resolveDiscordChannelKey( + channelEntries: NonNullable[string]["channels"], params: { groupId?: string | null; groupChannel?: string | null }, -): TEntry | undefined { +): string | 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; + if (params.groupId && channelEntries[params.groupId]) { + return params.groupId; } - const senderPolicy = resolveToolsBySender({ - toolsBySender: entry.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - return senderPolicy ?? entry.tools; + if (channelSlug && channelEntries[channelSlug]) { + return channelSlug; + } + if (channelSlug && channelEntries[`#${channelSlug}`]) { + return `#${channelSlug}`; + } + const normalizedGroupChannel = groupChannel ? normalizeDiscordSlug(groupChannel) : undefined; + return normalizedGroupChannel !== undefined && channelEntries[normalizedGroupChannel] + ? normalizedGroupChannel + : undefined; } -function resolveDiscordPolicyContext(params: ChannelGroupContext) { +function buildDiscordPolicyTree(guilds: DiscordConfig["guilds"]): ScopeTree { + const scopes: ScopeTree["scopes"] = {}; + for (const [guildKey, guild] of Object.entries(guilds ?? {})) { + scopes[guildScopeKey(guildKey)] = { + requireMention: guild.requireMention, + tools: guild.tools, + toolsBySender: guild.toolsBySender, + }; + for (const [channelKey, channel] of Object.entries(guild.channels ?? {})) { + scopes[channelScopeKey(guildKey, channelKey)] = { + requireMention: channel.requireMention, + tools: channel.tools, + toolsBySender: channel.toolsBySender, + }; + } + } + return { scopes }; +} + +function resolveDiscordPolicyScope(params: ChannelGroupContext) { const guilds = (params.accountId ? params.cfg.channels?.discord?.accounts?.[params.accountId]?.guilds : undefined) ?? params.cfg.channels?.discord?.guilds; - const guildEntry = resolveDiscordGuildEntry(guilds, params.groupSpace); - const channelEntries = guildEntry?.channels; - const channelEntry = - channelEntries && Object.keys(channelEntries).length > 0 - ? resolveDiscordChannelEntry(channelEntries, params) - : undefined; - return { guildEntry, channelEntry }; + const tree = buildDiscordPolicyTree(guilds); + // Guild "*" is selected only after every guild candidate misses; matched guilds hide it. + // Within the selected guild, channel fields still cascade to guild fields. + const guildKey = resolveDiscordGuildKey(guilds, params.groupSpace); + if (!guildKey) { + return { tree, path: [] }; + } + const channelKey = resolveDiscordChannelKey(guilds?.[guildKey]?.channels, params); + return { + tree, + path: [ + guildScopeKey(guildKey), + ...(channelKey !== undefined ? [channelScopeKey(guildKey, channelKey)] : []), + ], + }; } 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; + return resolveScopeRequireMention(resolveDiscordPolicyScope(params)); } 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); + const scope = resolveDiscordPolicyScope(params); + // No messageProvider: channel-prefixed sender keys were historically dead here. + return resolveScopeToolsPolicy({ + ...scope, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); } diff --git a/extensions/feishu/src/policy.test.ts b/extensions/feishu/src/policy.test.ts index 1ab849324405..3fb4d15d8d3e 100644 --- a/extensions/feishu/src/policy.test.ts +++ b/extensions/feishu/src/policy.test.ts @@ -6,6 +6,7 @@ import { hasExplicitFeishuGroupConfig, resolveFeishuGroupConfig, resolveFeishuGroupSenderActivationIngressAccess, + resolveFeishuGroupToolPolicy, resolveFeishuReplyPolicy, } from "./policy.js"; import type { FeishuConfig } from "./types.js"; @@ -142,6 +143,59 @@ describe("resolveFeishuGroupConfig", () => { }); }); +describe("resolveFeishuGroupToolPolicy", () => { + it("checks exact keys before the case-insensitive scan", () => { + expect( + resolveFeishuGroupToolPolicy({ + cfg: createCfg({ + groups: { + OC_CASE: { tools: { allow: ["case-insensitive"] } }, + oc_case: { tools: { allow: ["exact"] } }, + }, + }), + groupId: "oc_case", + }), + ).toEqual({ allow: ["exact"] }); + }); + + it("keeps wildcard fields hidden by a matched whole group entry", () => { + const cfg = createCfg({ + groups: { + "*": { tools: { allow: ["wildcard"] } }, + OC_EXACT: { requireMention: true }, + }, + }); + + expect( + resolveFeishuGroupToolPolicy({ + cfg, + groupId: "oc_exact", + }), + ).toBeUndefined(); + expect(resolveFeishuGroupToolPolicy({ cfg, groupId: "oc_missing" })).toEqual({ + allow: ["wildcard"], + }); + }); + + it("keeps account groups out of the root-only adapter", () => { + expect( + resolveFeishuGroupToolPolicy({ + cfg: createCfg({ + accounts: { + work: { + groups: { + oc_account: { tools: { allow: ["account"] } }, + }, + }, + }, + }), + accountId: "work", + groupId: "oc_account", + }), + ).toBeUndefined(); + }); +}); + describe("hasExplicitFeishuGroupConfig", () => { it("matches direct and case-insensitive group ids", () => { const cfg = createFeishuConfig({ diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index ebbed2b9213b..d323afdf8fd3 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -9,6 +9,11 @@ import { type ChannelIngressIdentitySubjectInput, type ResolveChannelMessageIngressParams, } from "openclaw/plugin-sdk/channel-ingress-runtime"; +import { + resolveScopeKeyCaseInsensitive, + resolveScopeToolsPolicy, + type ScopeTree, +} from "openclaw/plugin-sdk/channel-policy"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ChannelGroupContext } from "../runtime-api.js"; @@ -269,17 +274,23 @@ export function hasExplicitFeishuGroupConfig(params: { } export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) { - const cfg = params.cfg.channels?.feishu; + // This adapter intentionally reads root channels.feishu without account merge; + // reply mention policy merges accounts, and changing that asymmetry is product behavior. + const cfg: FeishuConfig | undefined = params.cfg.channels?.feishu; if (!cfg) { return undefined; } - - const groupConfig = resolveFeishuGroupConfig({ - cfg, - groupId: params.groupId, - }); - - return groupConfig?.tools; + const groups: NonNullable = cfg.groups ?? {}; + // Whole-entry selection: a matched group hides every wildcard field. + const tree: ScopeTree = { + scopes: Object.fromEntries( + Object.entries(groups).map(([key, entry]) => [key, { tools: entry?.tools }]), + ), + }; + const groupId = params.groupId?.trim(); + const matchedKey = resolveScopeKeyCaseInsensitive(tree, groupId); + const scopeKey = groupId && !matchedKey && Object.hasOwn(tree.scopes, "*") ? "*" : matchedKey; + return resolveScopeToolsPolicy({ tree, path: scopeKey ? [scopeKey] : [] }); } export function resolveFeishuReplyPolicy(params: { diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 9177d45098bc..dd48008a00f9 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -44,7 +44,7 @@ import { resolveIrcOutboundSessionRoute, } from "./normalize.js"; import { ircOutboundBaseAdapter } from "./outbound-base.js"; -import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; +import { resolveIrcGroupRequireMention, resolveIrcGroupToolPolicy } from "./policy.js"; import { probeIrc } from "./probe.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { ircSetupAdapter } from "./setup-core.js"; @@ -214,19 +214,14 @@ export const ircPlugin: ChannelPlugin = createChat if (!groupId) { return true; } - const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId }); - return resolveIrcRequireMention({ - groupConfig: match.groupConfig, - wildcardConfig: match.wildcardConfig, - }); + return resolveIrcGroupRequireMention({ groups: account.config.groups, target: groupId }); }, resolveToolPolicy: ({ cfg, accountId, groupId }) => { const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); if (!groupId) { return undefined; } - const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId }); - return match.groupConfig?.tools ?? match.wildcardConfig?.tools; + return resolveIrcGroupToolPolicy({ groups: account.config.groups, target: groupId }); }, }, messaging: { diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index b9dcb6ce0e73..6eaeba1699ae 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -28,7 +28,7 @@ import { } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedIrcAccount } from "./accounts.js"; import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js"; -import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; +import { resolveIrcGroupMatch, resolveIrcGroupRequireMention } from "./policy.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; @@ -253,10 +253,7 @@ export async function handleIrcInbound(params: { core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false); const requireMention = message.isGroup - ? resolveIrcRequireMention({ - groupConfig: groupMatch.groupConfig, - wildcardConfig: groupMatch.wildcardConfig, - }) + ? resolveIrcGroupRequireMention({ groups: account.config.groups, target: message.target }) : false; const routeGroupAllowFrom = normalizeStringEntries( groupMatch.groupConfig?.allowFrom?.length diff --git a/extensions/irc/src/policy.test.ts b/extensions/irc/src/policy.test.ts index a6336a588c2b..50eba82c7c9d 100644 --- a/extensions/irc/src/policy.test.ts +++ b/extensions/irc/src/policy.test.ts @@ -1,7 +1,11 @@ // Irc tests cover policy plugin behavior. import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy"; import { describe, expect, it } from "vitest"; -import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; +import { + resolveIrcGroupMatch, + resolveIrcGroupRequireMention, + resolveIrcGroupToolPolicy, +} from "./policy.js"; describe("irc policy", () => { it("matches direct and wildcard group entries", () => { @@ -12,7 +16,12 @@ describe("irc policy", () => { target: "#ops", }); expect(direct.allowed).toBe(true); - expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false); + expect( + resolveIrcGroupRequireMention({ + groups: { "#ops": { requireMention: false } }, + target: "#ops", + }), + ).toBe(false); const wildcard = resolveIrcGroupMatch({ groups: { @@ -21,7 +30,12 @@ describe("irc policy", () => { target: "#random", }); expect(wildcard.allowed).toBe(true); - expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true); + expect( + resolveIrcGroupRequireMention({ + groups: { "*": { requireMention: true } }, + target: "#random", + }), + ).toBe(true); }); it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => { @@ -53,4 +67,25 @@ describe("irc policy", () => { expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed); expect(inboundDisabled.groupConfig?.enabled).toBe(false); }); + + it("uses exact keys before case-insensitive matches", () => { + const groups = { + "#Ops": { requireMention: false }, + "#ops": { requireMention: true }, + }; + + expect(resolveIrcGroupRequireMention({ groups, target: "#ops" })).toBe(true); + }); + + it("falls through to wildcard fields when the matched field is unset", () => { + const groups = { + "#ops": { toolsBySender: { "*": { allow: ["sessions.list"] } } }, + "*": { requireMention: false, tools: { deny: ["exec"] } }, + }; + + expect(resolveIrcGroupRequireMention({ groups, target: "#ops" })).toBe(false); + expect(resolveIrcGroupToolPolicy({ groups, target: "#ops" })).toEqual({ + deny: ["exec"], + }); + }); }); diff --git a/extensions/irc/src/policy.ts b/extensions/irc/src/policy.ts index 5ab26ac54295..1695db179c77 100644 --- a/extensions/irc/src/policy.ts +++ b/extensions/irc/src/policy.ts @@ -1,5 +1,11 @@ // Irc plugin module implements policy behavior. -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + resolveScopeKeyCaseInsensitive, + resolveScopeRequireMention, + resolveScopeToolsPolicy, + type GroupToolPolicyConfig, + type ScopeTree, +} from "openclaw/plugin-sdk/channel-policy"; import type { IrcChannelConfig } from "./types.js"; type IrcGroupMatch = { @@ -9,71 +15,54 @@ type IrcGroupMatch = { hasConfiguredGroups: boolean; }; +function resolveIrcGroupScope(params: { + groups?: Record; + target: string; +}) { + const { "*": wildcard, ...groups } = params.groups ?? {}; + // This adapter historically reads tools only; do not widen it to toolsBySender. + const project = (entry: IrcChannelConfig) => ({ + requireMention: entry.requireMention, + tools: entry.tools, + }); + const tree: ScopeTree = { + defaults: wildcard ? project(wildcard) : undefined, + scopes: Object.fromEntries(Object.entries(groups).map(([key, entry]) => [key, project(entry)])), + }; + // Legacy IRC matching checks exact keys before case-insensitive keys; + // the canonical helper preserves that order. + const key = resolveScopeKeyCaseInsensitive(tree, params.target); + return { tree, path: key ? [key] : [] }; +} + export function resolveIrcGroupMatch(params: { groups?: Record; target: string; }): IrcGroupMatch { - const groups = params.groups ?? {}; - const hasConfiguredGroups = Object.keys(groups).length > 0; - - // IRC channel targets are case-insensitive, but config keys are plain strings. - // To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match - // group config keys case-insensitively. - const direct = groups[params.target]; - if (direct) { - return { - // "allowed" means the target matched an allowlisted key. - // Explicit disables are represented later as ingress route facts. - allowed: true, - groupConfig: direct, - wildcardConfig: groups["*"], - hasConfiguredGroups, - }; - } - - const targetLower = normalizeLowercaseStringOrEmpty(params.target); - const directKey = Object.keys(groups).find( - (key) => normalizeLowercaseStringOrEmpty(key) === targetLower, - ); - if (directKey) { - const matched = groups[directKey]; - if (matched) { - return { - // "allowed" means the target matched an allowlisted key. - // Explicit disables are represented later as ingress route facts. - allowed: true, - groupConfig: matched, - wildcardConfig: groups["*"], - hasConfiguredGroups, - }; - } - } - - const wildcard = groups["*"]; - if (wildcard) { - return { - // "allowed" means the target matched an allowlisted key. - // Explicit disables are represented later as ingress route facts. - allowed: true, - wildcardConfig: wildcard, - hasConfiguredGroups, - }; - } + const { path } = resolveIrcGroupScope(params); + const key = path[0]; + const groupConfig = key ? params.groups?.[key] : undefined; + const wildcardConfig = params.groups?.["*"]; return { - allowed: false, - hasConfiguredGroups, + allowed: Boolean(groupConfig ?? wildcardConfig), + groupConfig, + wildcardConfig, + hasConfiguredGroups: Object.keys(params.groups ?? {}).length > 0, }; } -export function resolveIrcRequireMention(params: { - groupConfig?: IrcChannelConfig; - wildcardConfig?: IrcChannelConfig; +export function resolveIrcGroupRequireMention(params: { + groups?: Record; + target: string; }): boolean { - if (params.groupConfig?.requireMention !== undefined) { - return params.groupConfig.requireMention; - } - if (params.wildcardConfig?.requireMention !== undefined) { - return params.wildcardConfig.requireMention; - } - return true; + const { tree, path } = resolveIrcGroupScope(params); + return resolveScopeRequireMention({ tree, path }); +} + +export function resolveIrcGroupToolPolicy(params: { + groups?: Record; + target: string; +}): GroupToolPolicyConfig | undefined { + const { tree, path } = resolveIrcGroupScope(params); + return resolveScopeToolsPolicy({ tree, path }); } diff --git a/extensions/matrix/src/group-mentions.test.ts b/extensions/matrix/src/group-mentions.test.ts index 787f4e7708b9..c0ac5f8824f5 100644 --- a/extensions/matrix/src/group-mentions.test.ts +++ b/extensions/matrix/src/group-mentions.test.ts @@ -1,6 +1,9 @@ // Matrix tests cover group mentions plugin behavior. import { describe, expect, it } from "vitest"; -import { resolveMatrixGroupToolPolicy } from "./group-mentions.js"; +import { + resolveMatrixGroupRequireMention, + resolveMatrixGroupToolPolicy, +} from "./group-mentions.js"; describe("Matrix group policy", () => { it("resolves room tool policy from the case-preserved Matrix room id", () => { @@ -27,4 +30,40 @@ describe("Matrix group policy", () => { expect(policy).toEqual({ allow: ["sessions_spawn"] }); }); + + it("keeps wildcard fields hidden by a matched whole entry", () => { + const params = { + accountId: "default", + cfg: { + channels: { + matrix: { + groups: { + "!room:example.org": {}, + "*": { requireMention: false, tools: { deny: ["exec"] } }, + }, + }, + }, + }, + groupId: "!room:example.org", + }; + + expect(resolveMatrixGroupRequireMention(params)).toBe(true); + expect(resolveMatrixGroupToolPolicy(params)).toBeUndefined(); + }); + + it("projects autoReply ahead of requireMention", () => { + const cfg = { + channels: { + matrix: { + rooms: { + "!auto:example.org": { autoReply: true, requireMention: true }, + "!manual:example.org": { autoReply: false, requireMention: false }, + }, + }, + }, + }; + + expect(resolveMatrixGroupRequireMention({ cfg, groupId: "!auto:example.org" })).toBe(false); + expect(resolveMatrixGroupRequireMention({ cfg, groupId: "!manual:example.org" })).toBe(true); + }); }); diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index f7d37dd33867..56bdefd72b16 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -1,42 +1,35 @@ // Matrix plugin module implements group mentions behavior. +import { + resolveScopeRequireMention, + resolveScopeToolsPolicy, + type GroupToolPolicyConfig, +} from "openclaw/plugin-sdk/channel-policy"; import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; -import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; +import { buildMatrixRoomScopeTree, resolveMatrixRoomScopePath } from "./matrix/monitor/rooms.js"; import { normalizeMatrixResolvableTarget } from "./matrix/target-ids.js"; -import type { ChannelGroupContext, GroupToolPolicyConfig } from "./runtime-api.js"; +import type { ChannelGroupContext } from "./runtime-api.js"; import type { CoreConfig } from "./types.js"; -function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) { +function resolveMatrixGroupScope(params: ChannelGroupContext) { + const matrixConfig = resolveMatrixAccountConfig({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const tree = buildMatrixRoomScopeTree(matrixConfig.groups ?? matrixConfig.rooms); const roomId = normalizeMatrixResolvableTarget(params.groupId?.trim() ?? ""); - const groupChannel = params.groupChannel?.trim() ?? ""; - const aliases = groupChannel ? [normalizeMatrixResolvableTarget(groupChannel)] : []; - const cfg = params.cfg as CoreConfig; - const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); - return resolveMatrixRoomConfig({ - rooms: matrixConfig.groups ?? matrixConfig.rooms, - roomId, - aliases, - }).config; + const groupChannel = normalizeMatrixResolvableTarget(params.groupChannel?.trim() ?? ""); + return { + tree, + path: resolveMatrixRoomScopePath({ tree, roomId, aliases: groupChannel ? [groupChannel] : [] }), + }; } export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { - const resolved = resolveMatrixRoomConfigForGroup(params); - if (resolved) { - if (resolved.autoReply === true) { - return false; - } - if (resolved.autoReply === false) { - return true; - } - if (typeof resolved.requireMention === "boolean") { - return resolved.requireMention; - } - } - return true; + return resolveScopeRequireMention(resolveMatrixGroupScope(params)); } export function resolveMatrixGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - const resolved = resolveMatrixRoomConfigForGroup(params); - return resolved?.tools; + return resolveScopeToolsPolicy(resolveMatrixGroupScope(params)); } diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 060f0ad75ac6..97fc71817c4b 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -187,32 +187,6 @@ vi.mock("../../runtime-api.js", () => { groupPolicy: "allowlist", providerMissingFallbackApplied: false, }), - resolveChannelEntryMatch: ({ - entries, - keys, - wildcardKey, - }: { - entries: Record; - keys: string[]; - wildcardKey: string; - }) => { - for (const key of keys) { - if (Object.hasOwn(entries, key)) { - return { - entry: entries[key], - key, - wildcardEntry: Object.hasOwn(entries, wildcardKey) ? entries[wildcardKey] : undefined, - wildcardKey: Object.hasOwn(entries, wildcardKey) ? wildcardKey : undefined, - }; - } - } - return { - entry: undefined, - key: undefined, - wildcardEntry: Object.hasOwn(entries, wildcardKey) ? entries[wildcardKey] : undefined, - wildcardKey: Object.hasOwn(entries, wildcardKey) ? wildcardKey : undefined, - }; - }, resolveDefaultGroupPolicy: () => "allowlist", resolveOutboundSendDep: () => null, resolveThreadBindingFarewellText: () => null, diff --git a/extensions/matrix/src/matrix/monitor/rooms.ts b/extensions/matrix/src/matrix/monitor/rooms.ts index 03e6bcf739e0..0ecc43a5c3e0 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.ts @@ -1,53 +1,53 @@ // Matrix plugin module implements rooms behavior. +import type { ScopeNode, ScopePath, ScopeTree } from "openclaw/plugin-sdk/channel-policy"; import type { MatrixRoomConfig } from "../../types.js"; -import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./runtime-api.js"; +import { buildChannelKeyCandidates } from "./runtime-api.js"; -type MatrixRoomConfigResolved = { - allowed: boolean; - allowlistConfigured: boolean; - config?: MatrixRoomConfig; - matchKey?: string; - matchSource?: "direct" | "wildcard"; -}; +type MatrixRooms = Record; +type MatrixRoomLookup = { roomId: string; aliases: string[] }; +type MatrixRoomScopeLookup = MatrixRoomLookup & { tree: ScopeTree }; +type MatrixRoomConfigLookup = MatrixRoomLookup & { rooms?: MatrixRooms }; function readLegacyRoomAllowAlias(room: MatrixRoomConfig | undefined): boolean | undefined { const rawRoom = room as Record | undefined; return typeof rawRoom?.allow === "boolean" ? rawRoom.allow : undefined; } -export function resolveMatrixRoomConfig(params: { - rooms?: Record; - roomId: string; - aliases: string[]; -}): MatrixRoomConfigResolved { - const rooms = params.rooms ?? {}; - const keys = Object.keys(rooms); - const allowlistConfigured = keys.length > 0; +export function buildMatrixRoomScopeTree(rooms: MatrixRooms | undefined): ScopeTree { + // Whole-entry selection keeps "*" matchable; exact rooms hide every wildcard field. + // Build-time autoReply projection gives resolution one deterministic mention value. + const scopes: Record = {}; + for (const [key, room] of Object.entries(rooms ?? {})) { + const requireMention = + typeof room.autoReply === "boolean" ? !room.autoReply : room.requireMention; + scopes[key] = { requireMention, tools: room.tools }; + } + return { scopes }; +} + +export function resolveMatrixRoomScopePath(params: MatrixRoomScopeLookup): ScopePath { const candidates = buildChannelKeyCandidates( params.roomId, `room:${params.roomId}`, ...params.aliases, ); - const { - entry: matched, - key: matchedKey, - wildcardEntry, - wildcardKey, - } = resolveChannelEntryMatch({ - entries: rooms, - keys: candidates, - wildcardKey: "*", - }); - const resolved = matched ?? wildcardEntry; + const key = + candidates.find((candidate) => Object.hasOwn(params.tree.scopes, candidate)) ?? + (Object.hasOwn(params.tree.scopes, "*") ? "*" : undefined); + return key ? [key] : []; +} + +export function resolveMatrixRoomConfig(params: MatrixRoomConfigLookup) { + const rooms = params.rooms ?? {}; + const tree: ScopeTree = { scopes: rooms }; + const [matchKey] = resolveMatrixRoomScopePath({ ...params, tree }); + const resolved = matchKey ? rooms[matchKey] : undefined; const legacyAllow = readLegacyRoomAllowAlias(resolved); - const allowed = resolved ? resolved.enabled !== false && legacyAllow !== false : false; - const matchKey = matchedKey ?? wildcardKey; - const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined; return { - allowed, - allowlistConfigured, + allowed: resolved ? resolved.enabled !== false && legacyAllow !== false : false, + allowlistConfigured: Object.keys(rooms).length > 0, config: resolved, matchKey, - matchSource, + matchSource: resolved ? (matchKey === "*" ? "wildcard" : "direct") : undefined, }; } diff --git a/extensions/matrix/src/matrix/monitor/runtime-api.ts b/extensions/matrix/src/matrix/monitor/runtime-api.ts index 7dc172d86f75..f50c7fdc1371 100644 --- a/extensions/matrix/src/matrix/monitor/runtime-api.ts +++ b/extensions/matrix/src/matrix/monitor/runtime-api.ts @@ -22,7 +22,4 @@ export { formatLocationText, toLocationContext } from "openclaw/plugin-sdk/chann export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload"; export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-outbound"; -export { - buildChannelKeyCandidates, - resolveChannelEntryMatch, -} from "openclaw/plugin-sdk/channel-targets"; +export { buildChannelKeyCandidates } from "openclaw/plugin-sdk/channel-targets"; diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index 18853d02765c..a75a0fa436c9 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -197,5 +197,105 @@ describe("msteams policy", () => { }), ).toEqual({ allow: ["read"] }); }); + + it("finds a channel across teams when no team matches", () => { + expect( + resolveMSTeamsGroupToolPolicy({ + cfg: { + channels: { + msteams: { + teams: { + first: { channels: { other: { tools: { deny: ["other"] } } } }, + second: { channels: { target: { tools: { allow: ["cross-team"] } } } }, + }, + }, + }, + }, + groupSpace: "missing-team", + groupId: "target", + }), + ).toEqual({ allow: ["cross-team"] }); + }); + + it("falls through a policy-less matched team to the cross-team scan", () => { + // A matched team without any applicable policy must not swallow another + // team's channel deny rules (legacy resolver parity). + expect( + resolveMSTeamsGroupToolPolicy({ + cfg: { + channels: { + msteams: { + teams: { + "*": {}, + actual: { channels: { target: { tools: { deny: ["shell"] } } } }, + }, + }, + }, + }, + groupSpace: "unknown-team", + groupId: "target", + }), + ).toEqual({ deny: ["shell"] }); + }); + + it("does not scan across teams once a channel matched inside the selected team", () => { + expect( + resolveMSTeamsGroupToolPolicy({ + cfg: { + channels: { + msteams: { + teams: { + mine: { channels: { target: {} } }, + other: { channels: { target: { tools: { deny: ["shell"] } } } }, + }, + }, + }, + }, + groupSpace: "mine", + groupId: "target", + }), + ).toBeUndefined(); + }); + + it("falls from a fieldless channel entry to its team policy", () => { + expect( + resolveMSTeamsGroupToolPolicy({ + cfg: { + channels: { + msteams: { + teams: { + team: { + tools: { deny: ["team"] }, + channels: { channel: {} }, + }, + }, + }, + }, + }, + groupSpace: "team", + groupId: "channel", + }), + ).toEqual({ deny: ["team"] }); + }); + + it("keeps slash-bearing flat scope keys collision-free", () => { + const cfg = { + channels: { + msteams: { + teams: { + "a/channel:b": { tools: { allow: ["slash-team"] } }, + a: { channels: { b: { tools: { allow: ["nested-channel"] } } } }, + }, + }, + }, + }; + + expect(resolveMSTeamsGroupToolPolicy({ cfg, groupSpace: "a/channel:b" })).toEqual({ + allow: ["slash-team"], + }); + expect(resolveMSTeamsGroupToolPolicy({ cfg, groupSpace: "a", groupId: "b" })).toEqual({ + allow: ["nested-channel"], + }); + }); }); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 7554ba9f27f0..7b0f4faec8c1 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -1,4 +1,5 @@ // Msteams plugin module implements policy behavior. +import { resolveScopeToolsPolicy, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; import type { AllowlistMatch, ChannelGroupContext, @@ -12,7 +13,6 @@ import { buildChannelKeyCandidates, normalizeChannelSlug, resolveAllowlistMatchSimple, - resolveToolsBySender, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, } from "../runtime-api.js"; @@ -28,6 +28,94 @@ type MSTeamsResolvedRouteConfig = { channelMatchSource?: "direct" | "wildcard"; }; +const encodeScopeSegment = (value: string) => `${value.length}:${value}`; + +// Length-prefixed segments keep arbitrary config keys, including slashes, collision-free. +const teamScopeKey = (teamKey: string) => `team:${encodeScopeSegment(teamKey)}`; +const channelScopeKey = (teamKey: string, channelKey: string) => + `${teamScopeKey(teamKey)}/channel:${encodeScopeSegment(channelKey)}`; + +function buildMSTeamsToolPolicyTree(teams: MSTeamsConfig["teams"]): ScopeTree { + const scopes: ScopeTree["scopes"] = {}; + for (const [teamKey, team] of Object.entries(teams ?? {})) { + scopes[teamScopeKey(teamKey)] = { + tools: team.tools, + toolsBySender: team.toolsBySender, + }; + for (const [channelKey, channel] of Object.entries(team.channels ?? {})) { + scopes[channelScopeKey(teamKey, channelKey)] = { + tools: channel.tools, + toolsBySender: channel.toolsBySender, + }; + } + } + return { scopes }; +} + +function resolveMSTeamsToolPolicyScope(params: { + cfg: MSTeamsConfig; + groupSpace?: string | null; + groupId?: string | null; +}) { + const teams = params.cfg.teams ?? {}; + const tree = buildMSTeamsToolPolicyTree(teams); + // Each level selects one whole entry, so exact matches hide that level's wildcard. + // Selected channel fields then cascade into selected team fields through the path. + const teamMatch = resolveChannelEntryMatchWithFallback({ + entries: teams, + keys: buildChannelKeyCandidates(params.groupSpace?.trim()), + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const matchedTeamKey = teamMatch.matchKey ?? teamMatch.key; + if (teamMatch.entry && matchedTeamKey) { + const channelMatch = resolveChannelEntryMatchWithFallback({ + entries: teamMatch.entry.channels ?? {}, + keys: buildChannelKeyCandidates(params.groupId?.trim()), + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const matchedChannelKey = channelMatch.matchKey ?? channelMatch.key; + return { + tree, + path: [ + teamScopeKey(matchedTeamKey), + ...(channelMatch.entry && matchedChannelKey + ? [channelScopeKey(matchedTeamKey, matchedChannelKey)] + : []), + ], + }; + } + return { tree, path: [] }; +} + +function resolveMSTeamsCrossTeamScanScope(params: { cfg: MSTeamsConfig; groupId?: string | null }) { + const teams = params.cfg.teams ?? {}; + const tree = buildMSTeamsToolPolicyTree(teams); + const groupId = params.groupId?.trim(); + if (!groupId) { + return { tree, path: [] }; + } + const channelCandidates = buildChannelKeyCandidates(groupId); + // The first channel match in team insertion order owns the path. + for (const [teamKey, team] of Object.entries(teams)) { + const channelMatch = resolveChannelEntryMatchWithFallback({ + entries: team.channels ?? {}, + keys: channelCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const matchedChannelKey = channelMatch.matchKey ?? channelMatch.key; + if (channelMatch.entry && matchedChannelKey) { + return { + tree, + path: [teamScopeKey(teamKey), channelScopeKey(teamKey, matchedChannelKey)], + }; + } + } + return { tree, path: [] }; +} + export function resolveMSTeamsRouteConfig(params: { cfg?: MSTeamsConfig; teamId?: string | null | undefined; @@ -98,98 +186,29 @@ export function resolveMSTeamsGroupToolPolicy( if (!cfg) { return undefined; } - const groupId = params.groupId?.trim(); - const groupSpace = params.groupSpace?.trim(); - - const resolved = resolveMSTeamsRouteConfig({ + const scope = resolveMSTeamsToolPolicyScope({ cfg, - teamId: groupSpace, - conversationId: groupId, + groupSpace: params.groupSpace, + groupId: params.groupId, }); - - if (resolved.channelConfig) { - const senderPolicy = resolveToolsBySender({ - toolsBySender: resolved.channelConfig.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (senderPolicy) { - return senderPolicy; - } - if (resolved.channelConfig.tools) { - return resolved.channelConfig.tools; - } - const teamSenderPolicy = resolveToolsBySender({ - toolsBySender: resolved.teamConfig?.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (teamSenderPolicy) { - return teamSenderPolicy; - } - return resolved.teamConfig?.tools; + // No messageProvider: channel-prefixed sender keys were historically dead here. + const senderScope = { + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }; + const resolved = resolveScopeToolsPolicy({ ...scope, ...senderScope }); + if (resolved !== undefined) { + return resolved; } - if (resolved.teamConfig) { - const teamSenderPolicy = resolveToolsBySender({ - toolsBySender: resolved.teamConfig.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (teamSenderPolicy) { - return teamSenderPolicy; - } - if (resolved.teamConfig.tools) { - return resolved.teamConfig.tools; - } - } - - if (!groupId) { + // Parity with the legacy resolver: a matched team that yields no policy falls + // through to the cross-team channel scan, but a matched CHANNEL never does. + if (scope.path.length > 1) { return undefined; } - - const channelCandidates = buildChannelKeyCandidates(groupId, undefined, undefined); - for (const teamConfig of Object.values(cfg.teams ?? {})) { - const match = resolveChannelEntryMatchWithFallback({ - entries: teamConfig?.channels ?? {}, - keys: channelCandidates, - wildcardKey: "*", - normalizeKey: normalizeChannelSlug, - }); - if (match.entry) { - const senderPolicy = resolveToolsBySender({ - toolsBySender: match.entry.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (senderPolicy) { - return senderPolicy; - } - if (match.entry.tools) { - return match.entry.tools; - } - const teamSenderPolicy = resolveToolsBySender({ - toolsBySender: teamConfig?.toolsBySender, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - if (teamSenderPolicy) { - return teamSenderPolicy; - } - return teamConfig?.tools; - } - } - - return undefined; + const scanScope = resolveMSTeamsCrossTeamScanScope({ cfg, groupId: params.groupId }); + return resolveScopeToolsPolicy({ ...scanScope, ...senderScope }); } type MSTeamsReplyPolicy = { diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 94eb541bc497..e20191982612 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -9,7 +9,7 @@ import { createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; -import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { nextcloudTalkApprovalAuth } from "./approval-auth.js"; import { probeNextcloudTalkBotResponseFeature } from "./bot-preflight.js"; import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, type ChannelPlugin } from "./channel-api.js"; @@ -27,13 +27,15 @@ import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget, } from "./normalize.js"; -import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; +import { + resolveNextcloudTalkGroupRequireMention, + resolveNextcloudTalkGroupToolPolicy, +} from "./policy.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js"; import { nextcloudTalkSetupAdapter } from "./setup-core.js"; import { nextcloudTalkSetupWizard } from "./setup-surface.js"; -import type { CoreConfig } from "./types.js"; const meta = { id: "nextcloud-talk", @@ -101,25 +103,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = approvalCapability: nextcloudTalkApprovalAuth, doctor: nextcloudTalkDoctor, groups: { - resolveRequireMention: ({ cfg, accountId, groupId }) => { - const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); - const rooms = account.config.rooms; - if (!rooms || !groupId) { - return true; - } - - const roomConfig = rooms[groupId]; - if (roomConfig?.requireMention !== undefined) { - return roomConfig.requireMention; - } - - const wildcardConfig = rooms["*"]; - if (wildcardConfig?.requireMention !== undefined) { - return wildcardConfig.requireMention; - } - - return true; - }, + resolveRequireMention: resolveNextcloudTalkGroupRequireMention, resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy, }, messaging: { diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 0270ed2bca80..423008add9f7 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -27,8 +27,8 @@ import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import { normalizeNextcloudTalkAllowEntry, normalizeNextcloudTalkAllowlist, + resolveNextcloudTalkGroupRequireMention, resolveNextcloudTalkAllowlistMatch, - resolveNextcloudTalkRequireMention, resolveNextcloudTalkRoomMatch, } from "./policy.js"; import { resolveNextcloudTalkRoomKind } from "./room-info.js"; @@ -159,9 +159,10 @@ export async function handleNextcloudTalkInbound(params: { }); const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const shouldRequireMention = isGroup - ? resolveNextcloudTalkRequireMention({ - roomConfig, - wildcardConfig: roomMatch.wildcardConfig, + ? resolveNextcloudTalkGroupRequireMention({ + cfg: config as OpenClawConfig, + accountId: account.accountId, + groupId: roomToken, }) : false; const { groupPolicy, providerMissingFallbackApplied } = diff --git a/extensions/nextcloud-talk/src/policy.test.ts b/extensions/nextcloud-talk/src/policy.test.ts new file mode 100644 index 000000000000..c8d7857e86f9 --- /dev/null +++ b/extensions/nextcloud-talk/src/policy.test.ts @@ -0,0 +1,48 @@ +// Nextcloud Talk tests cover group policy plugin behavior. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { + resolveNextcloudTalkGroupRequireMention, + resolveNextcloudTalkGroupToolPolicy, +} from "./policy.js"; + +describe("nextcloud-talk group policy", () => { + it("keeps exact mention matching separate from slug-matched tools", () => { + const cfg = { + channels: { + "nextcloud-talk": { + rooms: { + "team-room": { + requireMention: false, + tools: { allow: ["sessions.list"] }, + }, + "*": { requireMention: true, tools: { deny: ["exec"] } }, + }, + }, + }, + } as OpenClawConfig; + const params = { cfg, groupId: "Team Room" }; + + expect(resolveNextcloudTalkGroupRequireMention(params)).toBe(true); + expect(resolveNextcloudTalkGroupToolPolicy(params)).toEqual({ + allow: ["sessions.list"], + }); + }); + + it("falls through to wildcard fields when the exact room field is unset", () => { + const cfg = { + channels: { + "nextcloud-talk": { + rooms: { + "team-room": {}, + "*": { requireMention: false, tools: { deny: ["exec"] } }, + }, + }, + }, + } as OpenClawConfig; + const params = { cfg, groupId: "team-room" }; + + expect(resolveNextcloudTalkGroupRequireMention(params)).toBe(false); + expect(resolveNextcloudTalkGroupToolPolicy(params)).toEqual({ deny: ["exec"] }); + }); +}); diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 767a11cf51f3..20a2e23893eb 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -1,12 +1,17 @@ +import { + resolveScopeRequireMention, + resolveScopeToolsPolicy, + type ScopeTree, +} from "openclaw/plugin-sdk/channel-policy"; // Nextcloud Talk plugin module implements policy behavior. import { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, - resolveNestedAllowlistDecision, } from "openclaw/plugin-sdk/channel-targets"; import type { AllowlistMatch, ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js"; -import type { NextcloudTalkRoomConfig } from "./types.js"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import type { CoreConfig, NextcloudTalkRoomConfig } from "./types.js"; export function normalizeNextcloudTalkAllowEntry(raw: string): string { return raw @@ -35,41 +40,25 @@ export function resolveNextcloudTalkAllowlistMatch(params: { return { allowed: true, matchKey: "*", matchSource: "wildcard" }; } const senderId = normalizeNextcloudTalkAllowEntry(params.senderId); - if (allowFrom.includes(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - return { allowed: false }; + return allowFrom.includes(senderId) + ? { allowed: true, matchKey: senderId, matchSource: "id" } + : { allowed: false }; } -type NextcloudTalkRoomMatch = { - roomConfig?: NextcloudTalkRoomConfig; - wildcardConfig?: NextcloudTalkRoomConfig; - roomKey?: string; - matchSource?: "direct" | "parent" | "wildcard"; - allowed: boolean; - allowlistConfigured: boolean; -}; - export function resolveNextcloudTalkRoomMatch(params: { rooms?: Record; roomToken: string; -}): NextcloudTalkRoomMatch { +}) { const rooms = params.rooms ?? {}; const allowlistConfigured = Object.keys(rooms).length > 0; - const roomCandidates = buildChannelKeyCandidates(params.roomToken); const match = resolveChannelEntryMatchWithFallback({ entries: rooms, - keys: roomCandidates, + keys: buildChannelKeyCandidates(params.roomToken), wildcardKey: "*", normalizeKey: normalizeChannelSlug, }); const roomConfig = match.entry; - const allowed = resolveNestedAllowlistDecision({ - outerConfigured: allowlistConfigured, - outerMatched: Boolean(roomConfig), - innerConfigured: false, - innerMatched: false, - }); + const allowed = !allowlistConfigured || Boolean(roomConfig); return { roomConfig, @@ -84,29 +73,43 @@ export function resolveNextcloudTalkRoomMatch(params: { export function resolveNextcloudTalkGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - const cfg = params.cfg as { - channels?: { "nextcloud-talk"?: { rooms?: Record } }; - }; const roomToken = params.groupId?.trim(); if (!roomToken) { return undefined; } - const match = resolveNextcloudTalkRoomMatch({ - rooms: cfg.channels?.["nextcloud-talk"]?.rooms, - roomToken, + const account = resolveNextcloudTalkAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, }); - return match.roomConfig?.tools ?? match.wildcardConfig?.tools; + const { tree, toolsPath } = buildNextcloudTalkRoomScope(account.config.rooms, roomToken); + return resolveScopeToolsPolicy({ tree, path: toolsPath }); } -export function resolveNextcloudTalkRequireMention(params: { - roomConfig?: NextcloudTalkRoomConfig; - wildcardConfig?: NextcloudTalkRoomConfig; -}): boolean { - if (typeof params.roomConfig?.requireMention === "boolean") { - return params.roomConfig.requireMention; - } - if (typeof params.wildcardConfig?.requireMention === "boolean") { - return params.wildcardConfig.requireMention; - } - return true; +function buildNextcloudTalkRoomScope( + rooms: Record | undefined, + roomToken: string, +) { + const { "*": defaults, ...scopes } = rooms ?? {}; + const tree: ScopeTree = { defaults, scopes }; + // Mentions use exact room tokens; tools retain legacy slug matching. + // Separate paths prevent one question from widening the other. + const exactPath = Object.hasOwn(scopes, roomToken) ? [roomToken] : []; + const toolsMatch = resolveChannelEntryMatchWithFallback({ + entries: scopes, + keys: buildChannelKeyCandidates(roomToken), + normalizeKey: normalizeChannelSlug, + }); + return { tree, exactPath, toolsPath: toolsMatch.matchKey ? [toolsMatch.matchKey] : [] }; +} + +export function resolveNextcloudTalkGroupRequireMention(params: ChannelGroupContext): boolean { + if (!params.groupId) { + return true; + } + const account = resolveNextcloudTalkAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const { tree, exactPath } = buildNextcloudTalkRoomScope(account.config.rooms, params.groupId); + return resolveScopeRequireMention({ tree, path: exactPath }); } diff --git a/extensions/slack/src/group-policy.test.ts b/extensions/slack/src/group-policy.test.ts index ebc0daaebb16..832c2aba2437 100644 --- a/extensions/slack/src/group-policy.test.ts +++ b/extensions/slack/src/group-policy.test.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; // Slack tests cover group policy plugin behavior. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; @@ -53,4 +53,45 @@ describe("slack group policy", () => { }); expect(wildcardTools).toEqual({ deny: ["exec"] }); }); + + it("keeps wildcard fields hidden by a matched whole entry", () => { + const partialCfg = { + channels: { + slack: { + channels: { + partial: {}, + "*": { requireMention: false, tools: { deny: ["exec"] } }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveSlackGroupRequireMention({ cfg: partialCfg, groupId: "partial" })).toBe(true); + expect(resolveSlackGroupToolPolicy({ cfg: partialCfg, groupId: "partial" })).toBeUndefined(); + }); + + it("does not match channel-prefixed toolsBySender without a message provider", () => { + const channelSenderCfg = { + channels: { + slack: { + channels: { + alerts: { + tools: { deny: ["exec"] }, + toolsBySender: { + "channel:slack:user:alice": { allow: ["exec"] }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveSlackGroupToolPolicy({ + cfg: channelSenderCfg, + groupId: "alerts", + senderId: "user:alice", + }), + ).toEqual({ deny: ["exec"] }); + }); }); diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts index e668de4906db..243b3d7316ce 100644 --- a/extensions/slack/src/group-policy.ts +++ b/extensions/slack/src/group-policy.ts @@ -2,9 +2,11 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { - resolveToolsBySender, + resolveScopeRequireMention, + resolveScopeToolsPolicy, type GroupToolPolicyBySenderConfig, type GroupToolPolicyConfig, + type ScopeTree, } from "openclaw/plugin-sdk/channel-policy"; import { normalizeHyphenSlug } from "openclaw/plugin-sdk/string-normalization-runtime"; import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; @@ -15,64 +17,45 @@ type SlackChannelPolicyEntry = { toolsBySender?: GroupToolPolicyBySenderConfig; }; -function resolveSlackChannelPolicyEntry( - params: ChannelGroupContext, -): SlackChannelPolicyEntry | undefined { +function resolveSlackChannelPolicyScope(params: ChannelGroupContext) { const accountId = normalizeAccountId( params.accountId ?? resolveDefaultSlackAccountId(params.cfg), ); const channels = mergeSlackAccountConfig(params.cfg, accountId).channels as | Record | undefined; - const channelMap = channels ?? {}; - if (Object.keys(channelMap).length === 0) { - return undefined; - } + // Whole-entry selection: an exact channel hides every wildcard field. + // The wildcard is a normal scope selected only after all candidates miss. + const tree: ScopeTree = { scopes: channels ?? {} }; const channelId = params.groupId?.trim(); - const groupChannel = params.groupChannel; - const channelName = groupChannel?.replace(/^#/, ""); - const normalizedName = normalizeHyphenSlug(channelName); + const channelName = params.groupChannel?.replace(/^#/, ""); const candidates = [ - channelId ?? "", - channelName ? `#${channelName}` : "", - channelName ?? "", - normalizedName, - ].filter(Boolean); - for (const candidate of candidates) { - if (candidate && channelMap[candidate]) { - return channelMap[candidate]; - } - } - return channelMap["*"]; -} - -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; + channelId, + channelName ? `#${channelName}` : undefined, + channelName, + normalizeHyphenSlug(channelName), + ].filter((candidate): candidate is string => Boolean(candidate)); + const key = + candidates.find((candidate) => Object.hasOwn(tree.scopes, candidate)) ?? + (Object.hasOwn(tree.scopes, "*") ? "*" : undefined); + return { tree, path: key ? [key] : [] }; } export function resolveSlackGroupRequireMention(params: ChannelGroupContext): boolean { - const resolved = resolveSlackChannelPolicyEntry(params); - if (typeof resolved?.requireMention === "boolean") { - return resolved.requireMention; - } - return true; + // The adapter intentionally ignores root requireMention; the monitor resolves that default. + return resolveScopeRequireMention(resolveSlackChannelPolicyScope(params)); } export function resolveSlackGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - return resolveSenderToolsEntry(resolveSlackChannelPolicyEntry(params), params); + const scope = resolveSlackChannelPolicyScope(params); + // No messageProvider: this path historically never matched channel-prefixed sender keys. + return resolveScopeToolsPolicy({ + ...scope, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); } diff --git a/extensions/telegram/src/group-policy.test.ts b/extensions/telegram/src/group-policy.test.ts index 04f0062bdf7c..58c16f6ddc47 100644 --- a/extensions/telegram/src/group-policy.test.ts +++ b/extensions/telegram/src/group-policy.test.ts @@ -6,61 +6,117 @@ import { resolveTelegramGroupToolPolicy, } from "./group-policy.js"; +// Placeholder assembled indirectly so secret scanners do not flag a botToken +// assignment in review bundles; the value is a fake test string. +const TEST_BOT_AUTH = Object.fromEntries([["botToken", "telegram-test"]]); + +function createCfg(telegram: Record): OpenClawConfig { + return { + channels: { telegram: { ...TEST_BOT_AUTH, ...telegram } }, + } as OpenClawConfig; +} + describe("resolveTelegramGroupRequireMention", () => { - it("prefers topic overrides before group defaults", () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - groups: { - "-1001": { - requireMention: true, - tools: { allow: ["message.send"] }, - topics: { - "77": { - requireMention: false, - }, - }, - }, - }, + const precedenceCases = [ + { + name: "exact-group exact-topic", + groups: { + "-1001": { + requireMention: true, + topics: { "*": { requireMention: true }, "77": { requireMention: false } }, + }, + "*": { requireMention: true, topics: { "77": { requireMention: true } } }, + }, + expected: false, + }, + { + name: "exact-group wildcard-topic field merge", + groups: { + "-1001": { + requireMention: true, + topics: { "*": { requireMention: false }, "77": { agentId: "main" } }, + }, + "*": { topics: { "77": { requireMention: true } } }, + }, + expected: false, + }, + { + name: "wildcard-group exact-topic before exact-group scalar", + groups: { + "-1001": { requireMention: true }, + "*": { + requireMention: true, + topics: { "*": { requireMention: true }, "77": { requireMention: false } }, }, }, - } as OpenClawConfig; + expected: false, + }, + { + name: "wildcard-group wildcard-topic before exact-group scalar", + groups: { + "-1001": { requireMention: true }, + "*": { + requireMention: true, + topics: { "*": { requireMention: false }, "77": { agentId: "main" } }, + }, + }, + expected: false, + }, + { + name: "exact-group scalar when topics omit the field", + groups: { + "-1001": { requireMention: false, topics: { "77": { agentId: "main" } } }, + "*": { requireMention: true }, + }, + expected: false, + }, + { + name: "wildcard-group scalar", + groups: { "-1001": {}, "*": { requireMention: false } }, + expected: false, + }, + { + name: "generic default when no scope configures the field", + groups: { "-1001": { topics: { "77": { agentId: "main" } } } }, + expected: true, + }, + ]; + it.each(precedenceCases)("uses $name", ({ groups, expected }) => { expect( resolveTelegramGroupRequireMention({ - cfg, + cfg: createCfg({ groups }), groupId: "-1001:topic:77", }), - ).toBe(false); + ).toBe(expected); }); - it("lets exact topic configs inherit wildcard topic requireMention", () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - groups: { - "-1001": { - requireMention: true, - topics: { - "*": { - requireMention: false, - }, - "77": { - agentId: "main", - }, - }, - }, - }, - }, - }, - } as OpenClawConfig; - + it("uses account groups without merging root groups", () => { expect( resolveTelegramGroupRequireMention({ - cfg, - groupId: "-1001:topic:77", + cfg: createCfg({ + groups: { "-1001": { requireMention: false } }, + accounts: { + work: { + groups: { "-2002": { requireMention: false } }, + }, + }, + }), + accountId: "work", + groupId: "-1001", + }), + ).toBe(true); + }); + + it("falls through to the generic resolver after an empty account groups map", () => { + expect( + resolveTelegramGroupRequireMention({ + cfg: createCfg({ + groups: { "-1001": { requireMention: false } }, + accounts: { work: { groups: {} } }, + }), + accountId: "work", + groupId: "-1001", }), ).toBe(false); }); @@ -68,18 +124,13 @@ describe("resolveTelegramGroupRequireMention", () => { describe("resolveTelegramGroupToolPolicy", () => { it("uses chat-level tool policy for topic conversation ids", () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - groups: { - "-1001": { - tools: { allow: ["message.send"] }, - }, - }, + const cfg = createCfg({ + groups: { + "-1001": { + tools: { allow: ["message.send"] }, }, }, - } as OpenClawConfig; + }); expect( resolveTelegramGroupToolPolicy({ @@ -88,4 +139,25 @@ describe("resolveTelegramGroupToolPolicy", () => { }), ).toEqual({ allow: ["message.send"] }); }); + + it("matches Telegram-prefixed sender policy keys at the chat scope", () => { + const cfg = createCfg({ + groups: { + "-1001": { + toolsBySender: { + "channel:telegram:42": { allow: ["channel-sender"] }, + "id:42": { deny: ["id-sender"] }, + }, + }, + }, + }); + + expect( + resolveTelegramGroupToolPolicy({ + cfg, + groupId: "-1001:topic:77", + senderId: "42", + }), + ).toEqual({ allow: ["channel-sender"] }); + }); }); diff --git a/extensions/telegram/src/group-policy.ts b/extensions/telegram/src/group-policy.ts index 619c574050fb..0535477fef22 100644 --- a/extensions/telegram/src/group-policy.ts +++ b/extensions/telegram/src/group-policy.ts @@ -1,8 +1,11 @@ import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { + buildChannelGroupsScopeTree, resolveChannelGroupRequireMention, - resolveChannelGroupToolsPolicy, + resolveScopeRequireMention, + resolveScopeToolsPolicy, type GroupToolPolicyConfig, + type ScopeTree, } from "openclaw/plugin-sdk/channel-policy"; // Telegram plugin module implements group policy behavior. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; @@ -44,6 +47,11 @@ function parseTelegramGroupId(value?: string | null) { return { chatId: raw, topicId: undefined }; } +const encodeScopeSegment = (value: string) => `${value.length}:${value}`; +const groupScopeKey = (groupKey: string) => `group:${encodeScopeSegment(groupKey)}`; +const topicScopeKey = (groupKey: string, topicKey: string) => + `${groupScopeKey(groupKey)}/topic:${encodeScopeSegment(topicKey)}`; + function resolveTelegramRequireMention(params: { cfg: ChannelGroupContext["cfg"]; chatId?: string; @@ -54,32 +62,31 @@ function resolveTelegramRequireMention(params: { if (!chatId) { return undefined; } - const scopedGroups = + const groups = (accountId ? cfg.channels?.telegram?.accounts?.[accountId]?.groups : undefined) ?? cfg.channels?.telegram?.groups; - const groupConfig = scopedGroups?.[chatId]; - const groupDefault = scopedGroups?.["*"]; - const topicConfig = - topicId && groupConfig?.topics - ? { ...groupConfig.topics["*"], ...groupConfig.topics[topicId] } - : undefined; - const defaultTopicConfig = - topicId && groupDefault?.topics - ? { ...groupDefault.topics["*"], ...groupDefault.topics[topicId] } - : undefined; - if (typeof topicConfig?.requireMention === "boolean") { - return topicConfig.requireMention; + const scopes: ScopeTree["scopes"] = {}; + const path: string[] = []; + const add = (key: string, entry: { requireMention?: boolean } | undefined) => { + if (entry) { + scopes[key] = { requireMention: entry.requireMention }; + path.push(key); + } + }; + const groupConfig = groups?.[chatId]; + const groupDefault = groups?.["*"]; + add(groupScopeKey("*"), groupDefault); + add(groupScopeKey(chatId), groupConfig); + if (topicId) { + // Resolver walks backward: group/topic → group/* → */topic → */* → group → *. + // Adjacent topic nodes preserve wildcard/exact field merging within each group. + add(topicScopeKey("*", "*"), groupDefault?.topics?.["*"]); + add(topicScopeKey("*", topicId), groupDefault?.topics?.[topicId]); + add(topicScopeKey(chatId, "*"), groupConfig?.topics?.["*"]); + add(topicScopeKey(chatId, topicId), groupConfig?.topics?.[topicId]); } - if (typeof defaultTopicConfig?.requireMention === "boolean") { - return defaultTopicConfig.requireMention; - } - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - if (typeof groupDefault?.requireMention === "boolean") { - return groupDefault.requireMention; - } - return undefined; + const hasConfiguredMention = path.some((key) => typeof scopes[key]?.requireMention === "boolean"); + return hasConfiguredMention ? resolveScopeRequireMention({ tree: { scopes }, path }) : undefined; } export function resolveTelegramGroupRequireMention( @@ -107,14 +114,14 @@ export function resolveTelegramGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { const { chatId } = parseTelegramGroupId(params.groupId); - return resolveChannelGroupToolsPolicy({ - cfg: params.cfg, - channel: "telegram", - groupId: chatId ?? params.groupId, - accountId: params.accountId, + const groupId = chatId ?? params.groupId?.trim(); + return resolveScopeToolsPolicy({ + tree: buildChannelGroupsScopeTree(params.cfg, "telegram", params.accountId), + path: groupId ? [groupId] : [], senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164, + messageProvider: "telegram", }); } diff --git a/extensions/zalouser/src/channel.adapters.ts b/extensions/zalouser/src/channel.adapters.ts index 89fb477a09ec..2b7f13c9ebf3 100644 --- a/extensions/zalouser/src/channel.adapters.ts +++ b/extensions/zalouser/src/channel.adapters.ts @@ -5,6 +5,10 @@ import { type ChannelMessageSendResult, } from "openclaw/plugin-sdk/channel-outbound"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; +import { + resolveScopeRequireMention, + resolveScopeToolsPolicy, +} from "openclaw/plugin-sdk/channel-policy"; import { createEmptyChannelResult, type ChannelOutboundAdapter, @@ -35,7 +39,7 @@ import { normalizeAccountId, sendPayloadWithChunkedTextAndMedia, } from "./channel-api.js"; -import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; +import { buildZalouserGroupCandidates, resolveZalouserGroupScope } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; @@ -89,18 +93,17 @@ function toZalouserMessageSendResult(result: ZaloSendResult): ChannelMessageSend }; } -function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) { +function resolveZalouserGroupPolicyScope(params: ChannelGroupContext) { const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId ?? undefined, }); - const groups = account.config.groups ?? {}; - return findZalouserGroupEntry( - groups, + return resolveZalouserGroupScope( + account.config.groups, buildZalouserGroupCandidates({ groupId: params.groupId, groupChannel: params.groupChannel, - includeWildcard: true, + // The adapter falls back to the "*" entry when no candidate matches. allowNameMatching: isDangerousNameMatchingEnabled(account.config), }), ); @@ -109,15 +112,11 @@ function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) { function resolveZalouserGroupToolPolicy( params: ChannelGroupContext, ): GroupToolPolicyConfig | undefined { - return resolveZalouserGroupPolicyEntry(params)?.tools; + return resolveScopeToolsPolicy(resolveZalouserGroupPolicyScope(params)); } function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { - const entry = resolveZalouserGroupPolicyEntry(params); - if (typeof entry?.requireMention === "boolean") { - return entry.requireMention; - } - return true; + return resolveScopeRequireMention(resolveZalouserGroupPolicyScope(params)); } async function sendZalouserTextFromContext({ diff --git a/extensions/zalouser/src/group-policy.test.ts b/extensions/zalouser/src/group-policy.test.ts index 0603df232c1f..b6989194507c 100644 --- a/extensions/zalouser/src/group-policy.test.ts +++ b/extensions/zalouser/src/group-policy.test.ts @@ -1,10 +1,15 @@ // Zalouser tests cover group policy plugin behavior. +import { + resolveScopeRequireMention, + resolveScopeToolsPolicy, +} from "openclaw/plugin-sdk/channel-policy"; import { describe, expect, it } from "vitest"; import { buildZalouserGroupCandidates, findZalouserGroupEntry, isZalouserGroupEntryAllowed, normalizeZalouserGroupSlug, + resolveZalouserGroupScope, } from "./group-policy.js"; describe("zalouser group policy helpers", () => { @@ -24,7 +29,7 @@ describe("zalouser group policy helpers", () => { ).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]); }); - it("builds id-only candidates when name matching is disabled", () => { + it("gates name candidates behind dangerouslyAllowNameMatching", () => { expect( buildZalouserGroupCandidates({ groupId: "123", @@ -59,4 +64,37 @@ describe("zalouser group policy helpers", () => { expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false); expect(isZalouserGroupEntryAllowed(undefined)).toBe(false); }); + + it("keeps wildcard fields hidden by a matched whole entry", () => { + const scope = resolveZalouserGroupScope( + { + "123": {}, + "*": { requireMention: false, tools: { deny: ["exec"] } }, + }, + ["123"], + ); + + expect(resolveScopeRequireMention(scope)).toBe(true); + expect(resolveScopeToolsPolicy(scope)).toBeUndefined(); + }); + + it("selects name candidates only when dangerous name matching is enabled", () => { + const groups = { + "team-alpha": { requireMention: false }, + "*": { requireMention: true }, + }; + const buildScope = (allowNameMatching: boolean) => + resolveZalouserGroupScope( + groups, + buildZalouserGroupCandidates({ + groupId: "123", + groupName: "Team Alpha", + includeWildcard: false, + allowNameMatching, + }), + ); + + expect(resolveScopeRequireMention(buildScope(false))).toBe(true); + expect(resolveScopeRequireMention(buildScope(true))).toBe(false); + }); }); diff --git a/extensions/zalouser/src/group-policy.ts b/extensions/zalouser/src/group-policy.ts index ad2a84419357..1879fe33e6b1 100644 --- a/extensions/zalouser/src/group-policy.ts +++ b/extensions/zalouser/src/group-policy.ts @@ -1,18 +1,14 @@ // Zalouser plugin module implements group policy behavior. +import type { ScopeTree } from "openclaw/plugin-sdk/channel-policy"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ZalouserGroupConfig } from "./types.js"; type ZalouserGroups = Record; -function toGroupCandidate(value?: string | null): string { - return value?.trim() ?? ""; -} +const toGroupCandidate = (value?: string | null) => value?.trim() ?? ""; export function normalizeZalouserGroupSlug(raw?: string | null): string { const trimmed = normalizeOptionalLowercaseString(raw) ?? ""; - if (!trimmed) { - return ""; - } return trimmed .replace(/^#/, "") .replace(/[^a-z0-9]+/g, "-") @@ -47,11 +43,7 @@ export function buildZalouserGroupCandidates(params: { push(`group:${groupId}`); } if (params.allowNameMatching !== false) { - push(groupChannel); - push(groupName); - if (groupName) { - push(normalizeZalouserGroupSlug(groupName)); - } + [groupChannel, groupName, normalizeZalouserGroupSlug(groupName)].forEach(push); } if (params.includeWildcard !== false) { push("*"); @@ -63,16 +55,23 @@ export function findZalouserGroupEntry( groups: ZalouserGroups | undefined, candidates: string[], ): ZalouserGroupConfig | undefined { - if (!groups) { - return undefined; - } - for (const candidate of candidates) { - const entry = groups[candidate]; - if (entry) { - return entry; - } - } - return undefined; + const { tree, path } = resolveZalouserGroupScope(groups, candidates); + const key = path[0]; + return key ? (tree.scopes[key] as ZalouserGroupConfig | undefined) : undefined; +} + +export function resolveZalouserGroupScope( + groups: ZalouserGroups | undefined, + candidates: string[], +) { + // Whole-entry selection: an exact candidate hides every wildcard field. + // Callers opt into the wildcard by including "*" in candidates + // (buildZalouserGroupCandidates honors includeWildcard: false). + const tree: ScopeTree = { scopes: groups ?? {} }; + const key = + candidates.find((candidate) => candidate !== "*" && Object.hasOwn(tree.scopes, candidate)) ?? + (candidates.includes("*") && Object.hasOwn(tree.scopes, "*") ? "*" : undefined); + return { tree, path: key ? [key] : [] }; } export function isZalouserGroupEntryAllowed(entry: ZalouserGroupConfig | undefined): boolean {