refactor(config): add canonical group-policy scope-tree resolver and migrate the first six channels (#106846)

* feat(config): add canonical group-policy scope-tree resolver

* chore(plugin-sdk): refresh API baseline hash for scope-tree exports

* refactor(channels): migrate googlechat, imessage, and whatsapp group policy onto the scope tree

* chore(plugin-sdk): refresh API baseline hash for groups scope-tree builder

* refactor(channels): migrate line, qqbot, and mattermost group policy onto the scope tree

* chore(plugin-sdk): refresh API baseline hash for case-insensitive scope key helper

* chore(plugin-sdk): refresh API baseline hash after rebase onto current main

* chore(plugin-sdk): refresh API baseline hash after rebase

* chore(plugin-sdk): refresh API baseline hash after rebase

* chore(plugin-sdk): refresh API baseline hash after rebase
This commit is contained in:
Peter Steinberger
2026-07-13 18:24:52 -07:00
committed by GitHub
parent 8ac4a52a4e
commit b9c4815403
19 changed files with 806 additions and 115 deletions
@@ -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
@@ -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);
});
});
+12 -13
View File
@@ -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),
});
}
@@ -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"],
});
});
});
+15 -16
View File
@@ -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",
});
}
+32 -15
View File
@@ -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", () => {
+2 -3
View File
@@ -52,11 +52,10 @@ export function resolveLineGroupsConfig(
}
export function resolveExactLineGroupConfigKey(params: {
cfg: OpenClawConfig;
accountId?: string | null;
groups: Record<string, unknown> | undefined;
groupId?: string | null;
}): string | undefined {
const groups = resolveLineGroupsConfig(params.cfg, params.accountId);
const { groups } = params;
if (!groups) {
return undefined;
}
+11 -14
View File
@@ -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] : [],
});
}
@@ -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", () => {
+9 -11
View File
@@ -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",
});
}
+27 -4
View File
@@ -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: {
+10 -11
View File
@@ -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",
});
}
+62 -1
View File
@@ -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"] });
});
});
+15 -16
View File
@@ -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",
});
}
+8 -5
View File
@@ -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(
+1 -1
View File
@@ -330,7 +330,7 @@ export function resolveToolsBySender(
return matchToolsBySenderPolicy(compiled, params);
}
function resolveChannelGroups(
export function resolveChannelGroups(
cfg: OpenClawConfig,
channel: GroupPolicyChannel,
accountId?: string | null,
+296
View File
@@ -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 },
"<chat>": { requireMention: true },
},
};
expect(
resolveScopeRequireMention({
tree,
path: ["*", "<chat>", "*#topic:7", "<chat>#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");
});
});
+143
View File
@@ -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<string, ScopeNode>;
};
export type ScopePath = string[];
type ScopeToolPolicySender = Omit<Parameters<typeof resolveToolsBySender>[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<Value>(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,
});
}
+10
View File
@@ -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,