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:
Peter Steinberger
2026-07-13 23:56:35 -07:00
committed by GitHub
parent e588f2c0ce
commit b34bba8e57
26 changed files with 1082 additions and 555 deletions

View 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"],
});
});
});

View File

@@ -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,
});
}

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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

View File

@@ -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"],
});
});
});

View File

@@ -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 });
}

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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";

View File

@@ -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"],
});
});
});
});

View File

@@ -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 = {

View File

@@ -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: {

View File

@@ -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 } =

View 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"] });
});
});

View File

@@ -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 });
}

View File

@@ -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"] });
});
});

View File

@@ -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,
});
}

View File

@@ -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"] });
});
});

View File

@@ -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",
});
}

View File

@@ -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({

View File

@@ -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);
});
});

View File

@@ -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 {