mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:21:23 +00:00
refactor: unify channel config matching and gating
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./channel-config.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
} from "./channel-config.js";
|
||||
|
||||
describe("buildChannelKeyCandidates", () => {
|
||||
it("dedupes and trims keys", () => {
|
||||
@@ -22,3 +26,44 @@ describe("resolveChannelEntryMatch", () => {
|
||||
expect(match.wildcardKey).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChannelEntryMatchWithFallback", () => {
|
||||
it("prefers direct matches over parent and wildcard", () => {
|
||||
const entries = { a: { allow: true }, parent: { allow: false }, "*": { allow: false } };
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries,
|
||||
keys: ["a"],
|
||||
parentKeys: ["parent"],
|
||||
wildcardKey: "*",
|
||||
});
|
||||
expect(match.entry).toBe(entries.a);
|
||||
expect(match.matchSource).toBe("direct");
|
||||
expect(match.matchKey).toBe("a");
|
||||
});
|
||||
|
||||
it("falls back to parent when direct misses", () => {
|
||||
const entries = { parent: { allow: false }, "*": { allow: true } };
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries,
|
||||
keys: ["missing"],
|
||||
parentKeys: ["parent"],
|
||||
wildcardKey: "*",
|
||||
});
|
||||
expect(match.entry).toBe(entries.parent);
|
||||
expect(match.matchSource).toBe("parent");
|
||||
expect(match.matchKey).toBe("parent");
|
||||
});
|
||||
|
||||
it("falls back to wildcard when no direct or parent match", () => {
|
||||
const entries = { "*": { allow: true } };
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries,
|
||||
keys: ["missing"],
|
||||
parentKeys: ["still-missing"],
|
||||
wildcardKey: "*",
|
||||
});
|
||||
expect(match.entry).toBe(entries["*"]);
|
||||
expect(match.matchSource).toBe("wildcard");
|
||||
expect(match.matchKey).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
export type ChannelMatchSource = "direct" | "parent" | "wildcard";
|
||||
|
||||
export function buildChannelKeyCandidates(
|
||||
...keys: Array<string | undefined | null>
|
||||
): string[] {
|
||||
export type ChannelEntryMatch<T> = {
|
||||
entry?: T;
|
||||
key?: string;
|
||||
wildcardEntry?: T;
|
||||
wildcardKey?: string;
|
||||
parentEntry?: T;
|
||||
parentKey?: string;
|
||||
matchKey?: string;
|
||||
matchSource?: ChannelMatchSource;
|
||||
};
|
||||
|
||||
export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] {
|
||||
export function buildChannelKeyCandidates(
|
||||
...keys: Array<string | undefined | null>
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const candidates: string[] = [];
|
||||
for (const key of keys) {
|
||||
@@ -37,3 +48,48 @@ export function resolveChannelEntryMatch<T>(params: {
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
export function resolveChannelEntryMatchWithFallback<T>(params: {
|
||||
entries?: Record<string, T>;
|
||||
keys: string[];
|
||||
parentKeys?: string[];
|
||||
wildcardKey?: string;
|
||||
}): ChannelEntryMatch<T> {
|
||||
const direct = resolveChannelEntryMatch({
|
||||
entries: params.entries,
|
||||
keys: params.keys,
|
||||
wildcardKey: params.wildcardKey,
|
||||
});
|
||||
|
||||
if (direct.entry && direct.key) {
|
||||
return { ...direct, matchKey: direct.key, matchSource: "direct" };
|
||||
}
|
||||
|
||||
const parentKeys = params.parentKeys ?? [];
|
||||
if (parentKeys.length > 0) {
|
||||
const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys });
|
||||
if (parent.entry && parent.key) {
|
||||
return {
|
||||
...direct,
|
||||
entry: parent.entry,
|
||||
key: parent.key,
|
||||
parentEntry: parent.entry,
|
||||
parentKey: parent.key,
|
||||
matchKey: parent.key,
|
||||
matchSource: "parent",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (direct.wildcardEntry && direct.wildcardKey) {
|
||||
return {
|
||||
...direct,
|
||||
entry: direct.wildcardEntry,
|
||||
key: direct.wildcardKey,
|
||||
matchKey: direct.wildcardKey,
|
||||
matchSource: "wildcard",
|
||||
};
|
||||
}
|
||||
|
||||
return direct;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "./command-gating.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers, resolveControlCommandGate } from "./command-gating.js";
|
||||
|
||||
describe("resolveCommandAuthorizedFromAuthorizers", () => {
|
||||
it("denies when useAccessGroups is enabled and no authorizer is configured", () => {
|
||||
@@ -70,3 +70,26 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveControlCommandGate", () => {
|
||||
it("blocks control commands when unauthorized", () => {
|
||||
const result = resolveControlCommandGate({
|
||||
useAccessGroups: true,
|
||||
authorizers: [{ configured: true, allowed: false }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
expect(result.commandAuthorized).toBe(false);
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("does not block when control commands are disabled", () => {
|
||||
const result = resolveControlCommandGate({
|
||||
useAccessGroups: true,
|
||||
authorizers: [{ configured: true, allowed: false }],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,3 +21,19 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
|
||||
}
|
||||
return authorizers.some((entry) => entry.configured && entry.allowed);
|
||||
}
|
||||
|
||||
export function resolveControlCommandGate(params: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: CommandAuthorizer[];
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
|
||||
}): { commandAuthorized: boolean; shouldBlock: boolean } {
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
authorizers: params.authorizers,
|
||||
modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff,
|
||||
});
|
||||
const shouldBlock = params.allowTextCommands && params.hasControlCommand && !commandAuthorized;
|
||||
return { commandAuthorized, shouldBlock };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMentionGating } from "./mention-gating.js";
|
||||
import { resolveMentionGating, resolveMentionGatingWithBypass } from "./mention-gating.js";
|
||||
|
||||
describe("resolveMentionGating", () => {
|
||||
it("combines explicit, implicit, and bypass mentions", () => {
|
||||
@@ -36,3 +36,35 @@ describe("resolveMentionGating", () => {
|
||||
expect(res.shouldSkip).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMentionGatingWithBypass", () => {
|
||||
it("enables bypass when control commands are authorized", () => {
|
||||
const res = resolveMentionGatingWithBypass({
|
||||
isGroup: true,
|
||||
requireMention: true,
|
||||
canDetectMention: true,
|
||||
wasMentioned: false,
|
||||
hasAnyMention: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(res.shouldBypassMention).toBe(true);
|
||||
expect(res.shouldSkip).toBe(false);
|
||||
});
|
||||
|
||||
it("does not bypass when control commands are not authorized", () => {
|
||||
const res = resolveMentionGatingWithBypass({
|
||||
isGroup: true,
|
||||
requireMention: true,
|
||||
canDetectMention: true,
|
||||
wasMentioned: false,
|
||||
hasAnyMention: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
commandAuthorized: false,
|
||||
});
|
||||
expect(res.shouldBypassMention).toBe(false);
|
||||
expect(res.shouldSkip).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,22 @@ export type MentionGateResult = {
|
||||
shouldSkip: boolean;
|
||||
};
|
||||
|
||||
export type MentionGateWithBypassParams = {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
canDetectMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
implicitMention?: boolean;
|
||||
hasAnyMention?: boolean;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
};
|
||||
|
||||
export type MentionGateWithBypassResult = MentionGateResult & {
|
||||
shouldBypassMention: boolean;
|
||||
};
|
||||
|
||||
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
|
||||
const implicit = params.implicitMention === true;
|
||||
const bypass = params.shouldBypassMention === true;
|
||||
@@ -18,3 +34,26 @@ export function resolveMentionGating(params: MentionGateParams): MentionGateResu
|
||||
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
|
||||
return { effectiveWasMentioned, shouldSkip };
|
||||
}
|
||||
|
||||
export function resolveMentionGatingWithBypass(
|
||||
params: MentionGateWithBypassParams,
|
||||
): MentionGateWithBypassResult {
|
||||
const shouldBypassMention =
|
||||
params.isGroup &&
|
||||
params.requireMention &&
|
||||
!params.wasMentioned &&
|
||||
!(params.hasAnyMention ?? false) &&
|
||||
params.allowTextCommands &&
|
||||
params.commandAuthorized &&
|
||||
params.hasControlCommand;
|
||||
return {
|
||||
...resolveMentionGating({
|
||||
requireMention: params.requireMention,
|
||||
canDetectMention: params.canDetectMention,
|
||||
wasMentioned: params.wasMentioned,
|
||||
implicitMention: params.implicitMention,
|
||||
shouldBypassMention,
|
||||
}),
|
||||
shouldBypassMention,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export type { ChannelEntryMatch } from "../channel-config.js";
|
||||
export { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js";
|
||||
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
|
||||
export {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
} from "../channel-config.js";
|
||||
|
||||
@@ -87,6 +87,8 @@ export {
|
||||
export {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
type ChannelEntryMatch,
|
||||
type ChannelMatchSource,
|
||||
} from "./channel-config.js";
|
||||
export type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
|
||||
import { asString, isRecord } from "./shared.js";
|
||||
import { appendMatchMetadata, asString, isRecord } from "./shared.js";
|
||||
|
||||
type DiscordIntentSummary = {
|
||||
messageContent?: "enabled" | "limited" | "disabled";
|
||||
@@ -128,15 +128,15 @@ export function collectDiscordStatusIssues(
|
||||
if (channel.ok === true) continue;
|
||||
const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : "";
|
||||
const error = channel.error ? `: ${channel.error}` : "";
|
||||
const matchMeta =
|
||||
channel.matchKey || channel.matchSource
|
||||
? ` (matchKey=${channel.matchKey ?? "none"} matchSource=${channel.matchSource ?? "none"})`
|
||||
: "";
|
||||
const baseMessage = `Channel ${channel.channelId} permission check failed.${missing}${error}`;
|
||||
issues.push({
|
||||
channel: "discord",
|
||||
accountId,
|
||||
kind: "permissions",
|
||||
message: `Channel ${channel.channelId} permission check failed.${missing}${error}${matchMeta}`,
|
||||
message: appendMatchMetadata(baseMessage, {
|
||||
matchKey: channel.matchKey,
|
||||
matchSource: channel.matchSource,
|
||||
}),
|
||||
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,3 +5,27 @@ export function asString(value: unknown): string | undefined {
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function formatMatchMetadata(params: {
|
||||
matchKey?: unknown;
|
||||
matchSource?: unknown;
|
||||
}): string | undefined {
|
||||
const matchKey =
|
||||
typeof params.matchKey === "string"
|
||||
? params.matchKey
|
||||
: typeof params.matchKey === "number"
|
||||
? String(params.matchKey)
|
||||
: undefined;
|
||||
const matchSource = asString(params.matchSource);
|
||||
const parts = [matchKey ? `matchKey=${matchKey}` : null, matchSource ? `matchSource=${matchSource}` : null]
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
export function appendMatchMetadata(
|
||||
message: string,
|
||||
params: { matchKey?: unknown; matchSource?: unknown },
|
||||
): string {
|
||||
const meta = formatMatchMetadata(params);
|
||||
return meta ? `${message} (${meta})` : message;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
|
||||
import { asString, isRecord } from "./shared.js";
|
||||
import { appendMatchMetadata, asString, isRecord } from "./shared.js";
|
||||
|
||||
type TelegramAccountStatus = {
|
||||
accountId?: unknown;
|
||||
@@ -111,15 +111,15 @@ export function collectTelegramStatusIssues(
|
||||
if (group.ok === true) continue;
|
||||
const status = group.status ? ` status=${group.status}` : "";
|
||||
const err = group.error ? `: ${group.error}` : "";
|
||||
const matchMeta =
|
||||
group.matchKey || group.matchSource
|
||||
? ` (matchKey=${group.matchKey ?? "none"} matchSource=${group.matchSource ?? "none"})`
|
||||
: "";
|
||||
const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
|
||||
issues.push({
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: `Group ${group.chatId} not reachable by bot.${status}${err}${matchMeta}`,
|
||||
message: appendMatchMetadata(baseMessage, {
|
||||
matchKey: group.matchKey,
|
||||
matchSource: group.matchSource,
|
||||
}),
|
||||
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user