mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 00:11:11 +00:00
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
This commit is contained in:
committed by
GitHub
parent
e588f2c0ce
commit
b34bba8e57
132
extensions/discord/src/group-policy.test.ts
Normal file
132
extensions/discord/src/group-policy.test.ts
Normal file
@@ -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<string, unknown>): 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TEntry extends SenderScopedToolsEntry>(
|
||||
channelEntries: Record<string, TEntry> | undefined,
|
||||
function resolveDiscordChannelKey(
|
||||
channelEntries: NonNullable<DiscordConfig["guilds"]>[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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<FeishuConfig["groups"]> = 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: {
|
||||
|
||||
@@ -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<ResolvedIrcAccount, IrcProbe> = 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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, IrcChannelConfig>;
|
||||
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<string, IrcChannelConfig>;
|
||||
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<string, IrcChannelConfig>;
|
||||
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<string, IrcChannelConfig>;
|
||||
target: string;
|
||||
}): GroupToolPolicyConfig | undefined {
|
||||
const { tree, path } = resolveIrcGroupScope(params);
|
||||
return resolveScopeToolsPolicy({ tree, path });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -187,32 +187,6 @@ vi.mock("../../runtime-api.js", () => {
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: false,
|
||||
}),
|
||||
resolveChannelEntryMatch: ({
|
||||
entries,
|
||||
keys,
|
||||
wildcardKey,
|
||||
}: {
|
||||
entries: Record<string, unknown>;
|
||||
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,
|
||||
|
||||
@@ -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<string, MatrixRoomConfig>;
|
||||
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<string, unknown> | undefined;
|
||||
return typeof rawRoom?.allow === "boolean" ? rawRoom.allow : undefined;
|
||||
}
|
||||
|
||||
export function resolveMatrixRoomConfig(params: {
|
||||
rooms?: Record<string, MatrixRoomConfig>;
|
||||
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<string, ScopeNode> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<ResolvedNextcloudTalkAccount> =
|
||||
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: {
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
48
extensions/nextcloud-talk/src/policy.test.ts
Normal file
48
extensions/nextcloud-talk/src/policy.test.ts
Normal file
@@ -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"] });
|
||||
});
|
||||
});
|
||||
@@ -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<string, NextcloudTalkRoomConfig>;
|
||||
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<string, NextcloudTalkRoomConfig> } };
|
||||
};
|
||||
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<string, NextcloudTalkRoomConfig> | 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 });
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, SlackChannelPolicyEntry>
|
||||
| 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): 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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, ZalouserGroupConfig>;
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user