The input peer.kind from channel plugins was used as-is without normalization via normalizeChatType(), while the binding side correctly normalized. This caused "dm" !== "direct" mismatches in matchesBindingScope, making plugins that use "dm" as peerKind fail to match bindings configured with "direct". Normalize both peer.kind and parentPeer.kind through normalizeChatType() so that "dm" and "direct" are treated equivalently on both sides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit b0c96702f5531287f857410303b2c3cc698a1441)
444 lines
13 KiB
TypeScript
444 lines
13 KiB
TypeScript
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import type { ChatType } from "../channels/chat-type.js";
|
|
import { normalizeChatType } from "../channels/chat-type.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { shouldLogVerbose } from "../globals.js";
|
|
import { logDebug } from "../logger.js";
|
|
import { listBindings } from "./bindings.js";
|
|
import {
|
|
buildAgentMainSessionKey,
|
|
buildAgentPeerSessionKey,
|
|
DEFAULT_ACCOUNT_ID,
|
|
DEFAULT_MAIN_KEY,
|
|
normalizeAccountId,
|
|
normalizeAgentId,
|
|
sanitizeAgentId,
|
|
} from "./session-key.js";
|
|
|
|
/** @deprecated Use ChatType from channels/chat-type.js */
|
|
export type RoutePeerKind = ChatType;
|
|
|
|
export type RoutePeer = {
|
|
kind: ChatType;
|
|
id: string;
|
|
};
|
|
|
|
export type ResolveAgentRouteInput = {
|
|
cfg: OpenClawConfig;
|
|
channel: string;
|
|
accountId?: string | null;
|
|
peer?: RoutePeer | null;
|
|
/** Parent peer for threads — used for binding inheritance when peer doesn't match directly. */
|
|
parentPeer?: RoutePeer | null;
|
|
guildId?: string | null;
|
|
teamId?: string | null;
|
|
/** Discord member role IDs — used for role-based agent routing. */
|
|
memberRoleIds?: string[];
|
|
};
|
|
|
|
export type ResolvedAgentRoute = {
|
|
agentId: string;
|
|
channel: string;
|
|
accountId: string;
|
|
/** Internal session key used for persistence + concurrency. */
|
|
sessionKey: string;
|
|
/** Convenience alias for direct-chat collapse. */
|
|
mainSessionKey: string;
|
|
/** Match description for debugging/logging. */
|
|
matchedBy:
|
|
| "binding.peer"
|
|
| "binding.peer.parent"
|
|
| "binding.guild+roles"
|
|
| "binding.guild"
|
|
| "binding.team"
|
|
| "binding.account"
|
|
| "binding.channel"
|
|
| "default";
|
|
};
|
|
|
|
export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js";
|
|
|
|
function normalizeToken(value: string | undefined | null): string {
|
|
return (value ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeId(value: unknown): string {
|
|
if (typeof value === "string") {
|
|
return value.trim();
|
|
}
|
|
if (typeof value === "number" || typeof value === "bigint") {
|
|
return String(value).trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function matchesAccountId(match: string | undefined, actual: string): boolean {
|
|
const trimmed = (match ?? "").trim();
|
|
if (!trimmed) {
|
|
return actual === DEFAULT_ACCOUNT_ID;
|
|
}
|
|
if (trimmed === "*") {
|
|
return true;
|
|
}
|
|
return normalizeAccountId(trimmed) === actual;
|
|
}
|
|
|
|
export function buildAgentSessionKey(params: {
|
|
agentId: string;
|
|
channel: string;
|
|
accountId?: string | null;
|
|
peer?: RoutePeer | null;
|
|
/** DM session scope. */
|
|
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
|
|
identityLinks?: Record<string, string[]>;
|
|
}): string {
|
|
const channel = normalizeToken(params.channel) || "unknown";
|
|
const peer = params.peer;
|
|
return buildAgentPeerSessionKey({
|
|
agentId: params.agentId,
|
|
mainKey: DEFAULT_MAIN_KEY,
|
|
channel,
|
|
accountId: params.accountId,
|
|
peerKind: peer?.kind ?? "direct",
|
|
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
|
|
dmScope: params.dmScope,
|
|
identityLinks: params.identityLinks,
|
|
});
|
|
}
|
|
|
|
function listAgents(cfg: OpenClawConfig) {
|
|
const agents = cfg.agents?.list;
|
|
return Array.isArray(agents) ? agents : [];
|
|
}
|
|
|
|
function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): string {
|
|
const trimmed = (agentId ?? "").trim();
|
|
if (!trimmed) {
|
|
return sanitizeAgentId(resolveDefaultAgentId(cfg));
|
|
}
|
|
const normalized = normalizeAgentId(trimmed);
|
|
const agents = listAgents(cfg);
|
|
if (agents.length === 0) {
|
|
return sanitizeAgentId(trimmed);
|
|
}
|
|
const match = agents.find((agent) => normalizeAgentId(agent.id) === normalized);
|
|
if (match?.id?.trim()) {
|
|
return sanitizeAgentId(match.id.trim());
|
|
}
|
|
return sanitizeAgentId(resolveDefaultAgentId(cfg));
|
|
}
|
|
|
|
function matchesChannel(
|
|
match: { channel?: string | undefined } | undefined,
|
|
channel: string,
|
|
): boolean {
|
|
const key = normalizeToken(match?.channel);
|
|
if (!key) {
|
|
return false;
|
|
}
|
|
return key === channel;
|
|
}
|
|
|
|
type NormalizedPeerConstraint =
|
|
| { state: "none" }
|
|
| { state: "invalid" }
|
|
| { state: "valid"; kind: ChatType; id: string };
|
|
|
|
type NormalizedBindingMatch = {
|
|
accountPattern: string;
|
|
peer: NormalizedPeerConstraint;
|
|
guildId: string | null;
|
|
teamId: string | null;
|
|
roles: string[] | null;
|
|
};
|
|
|
|
type EvaluatedBinding = {
|
|
binding: ReturnType<typeof listBindings>[number];
|
|
match: NormalizedBindingMatch;
|
|
};
|
|
|
|
type BindingScope = {
|
|
peer: RoutePeer | null;
|
|
guildId: string;
|
|
teamId: string;
|
|
memberRoleIds: Set<string>;
|
|
};
|
|
|
|
type EvaluatedBindingsCache = {
|
|
bindingsRef: OpenClawConfig["bindings"];
|
|
byChannelAccount: Map<string, EvaluatedBinding[]>;
|
|
};
|
|
|
|
const evaluatedBindingsCacheByCfg = new WeakMap<OpenClawConfig, EvaluatedBindingsCache>();
|
|
const MAX_EVALUATED_BINDINGS_CACHE_KEYS = 2000;
|
|
|
|
function getEvaluatedBindingsForChannelAccount(
|
|
cfg: OpenClawConfig,
|
|
channel: string,
|
|
accountId: string,
|
|
): EvaluatedBinding[] {
|
|
const bindingsRef = cfg.bindings;
|
|
const existing = evaluatedBindingsCacheByCfg.get(cfg);
|
|
const cache =
|
|
existing && existing.bindingsRef === bindingsRef
|
|
? existing
|
|
: { bindingsRef, byChannelAccount: new Map<string, EvaluatedBinding[]>() };
|
|
if (cache !== existing) {
|
|
evaluatedBindingsCacheByCfg.set(cfg, cache);
|
|
}
|
|
|
|
const cacheKey = `${channel}\t${accountId}`;
|
|
const hit = cache.byChannelAccount.get(cacheKey);
|
|
if (hit) {
|
|
return hit;
|
|
}
|
|
|
|
const evaluated: EvaluatedBinding[] = listBindings(cfg).flatMap((binding) => {
|
|
if (!binding || typeof binding !== "object") {
|
|
return [];
|
|
}
|
|
if (!matchesChannel(binding.match, channel)) {
|
|
return [];
|
|
}
|
|
if (!matchesAccountId(binding.match?.accountId, accountId)) {
|
|
return [];
|
|
}
|
|
return [{ binding, match: normalizeBindingMatch(binding.match) }];
|
|
});
|
|
|
|
cache.byChannelAccount.set(cacheKey, evaluated);
|
|
if (cache.byChannelAccount.size > MAX_EVALUATED_BINDINGS_CACHE_KEYS) {
|
|
cache.byChannelAccount.clear();
|
|
cache.byChannelAccount.set(cacheKey, evaluated);
|
|
}
|
|
|
|
return evaluated;
|
|
}
|
|
|
|
function normalizePeerConstraint(
|
|
peer: { kind?: string; id?: string } | undefined,
|
|
): NormalizedPeerConstraint {
|
|
if (!peer) {
|
|
return { state: "none" };
|
|
}
|
|
const kind = normalizeChatType(peer.kind);
|
|
const id = normalizeId(peer.id);
|
|
if (!kind || !id) {
|
|
return { state: "invalid" };
|
|
}
|
|
return { state: "valid", kind, id };
|
|
}
|
|
|
|
function normalizeBindingMatch(
|
|
match:
|
|
| {
|
|
accountId?: string | undefined;
|
|
peer?: { kind?: string; id?: string } | undefined;
|
|
guildId?: string | undefined;
|
|
teamId?: string | undefined;
|
|
roles?: string[] | undefined;
|
|
}
|
|
| undefined,
|
|
): NormalizedBindingMatch {
|
|
const rawRoles = match?.roles;
|
|
return {
|
|
accountPattern: (match?.accountId ?? "").trim(),
|
|
peer: normalizePeerConstraint(match?.peer),
|
|
guildId: normalizeId(match?.guildId) || null,
|
|
teamId: normalizeId(match?.teamId) || null,
|
|
roles: Array.isArray(rawRoles) && rawRoles.length > 0 ? rawRoles : null,
|
|
};
|
|
}
|
|
|
|
function hasGuildConstraint(match: NormalizedBindingMatch): boolean {
|
|
return Boolean(match.guildId);
|
|
}
|
|
|
|
function hasTeamConstraint(match: NormalizedBindingMatch): boolean {
|
|
return Boolean(match.teamId);
|
|
}
|
|
|
|
function hasRolesConstraint(match: NormalizedBindingMatch): boolean {
|
|
return Boolean(match.roles);
|
|
}
|
|
|
|
function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope): boolean {
|
|
if (match.peer.state === "invalid") {
|
|
return false;
|
|
}
|
|
if (match.peer.state === "valid") {
|
|
if (!scope.peer || scope.peer.kind !== match.peer.kind || scope.peer.id !== match.peer.id) {
|
|
return false;
|
|
}
|
|
}
|
|
if (match.guildId && match.guildId !== scope.guildId) {
|
|
return false;
|
|
}
|
|
if (match.teamId && match.teamId !== scope.teamId) {
|
|
return false;
|
|
}
|
|
if (match.roles) {
|
|
for (const role of match.roles) {
|
|
if (scope.memberRoleIds.has(role)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {
|
|
const channel = normalizeToken(input.channel);
|
|
const accountId = normalizeAccountId(input.accountId);
|
|
const peer = input.peer
|
|
? {
|
|
kind: normalizeChatType(input.peer.kind) ?? input.peer.kind,
|
|
id: normalizeId(input.peer.id),
|
|
}
|
|
: null;
|
|
const guildId = normalizeId(input.guildId);
|
|
const teamId = normalizeId(input.teamId);
|
|
const memberRoleIds = input.memberRoleIds ?? [];
|
|
const memberRoleIdSet = new Set(memberRoleIds);
|
|
|
|
const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId);
|
|
|
|
const dmScope = input.cfg.session?.dmScope ?? "main";
|
|
const identityLinks = input.cfg.session?.identityLinks;
|
|
|
|
const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => {
|
|
const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId);
|
|
const sessionKey = buildAgentSessionKey({
|
|
agentId: resolvedAgentId,
|
|
channel,
|
|
accountId,
|
|
peer,
|
|
dmScope,
|
|
identityLinks,
|
|
}).toLowerCase();
|
|
const mainSessionKey = buildAgentMainSessionKey({
|
|
agentId: resolvedAgentId,
|
|
mainKey: DEFAULT_MAIN_KEY,
|
|
}).toLowerCase();
|
|
return {
|
|
agentId: resolvedAgentId,
|
|
channel,
|
|
accountId,
|
|
sessionKey,
|
|
mainSessionKey,
|
|
matchedBy,
|
|
};
|
|
};
|
|
|
|
const shouldLogDebug = shouldLogVerbose();
|
|
const formatPeer = (value?: RoutePeer | null) =>
|
|
value?.kind && value?.id ? `${value.kind}:${value.id}` : "none";
|
|
const formatNormalizedPeer = (value: NormalizedPeerConstraint) => {
|
|
if (value.state === "none") {
|
|
return "none";
|
|
}
|
|
if (value.state === "invalid") {
|
|
return "invalid";
|
|
}
|
|
return `${value.kind}:${value.id}`;
|
|
};
|
|
|
|
if (shouldLogDebug) {
|
|
logDebug(
|
|
`[routing] resolveAgentRoute: channel=${channel} accountId=${accountId} peer=${formatPeer(peer)} guildId=${guildId || "none"} teamId=${teamId || "none"} bindings=${bindings.length}`,
|
|
);
|
|
for (const entry of bindings) {
|
|
logDebug(
|
|
`[routing] binding: agentId=${entry.binding.agentId} accountPattern=${entry.match.accountPattern || "default"} peer=${formatNormalizedPeer(entry.match.peer)} guildId=${entry.match.guildId ?? "none"} teamId=${entry.match.teamId ?? "none"} roles=${entry.match.roles?.length ?? 0}`,
|
|
);
|
|
}
|
|
}
|
|
// Thread parent inheritance: if peer (thread) didn't match, check parent peer binding
|
|
const parentPeer = input.parentPeer
|
|
? {
|
|
kind: normalizeChatType(input.parentPeer.kind) ?? input.parentPeer.kind,
|
|
id: normalizeId(input.parentPeer.id),
|
|
}
|
|
: null;
|
|
const baseScope = {
|
|
guildId,
|
|
teamId,
|
|
memberRoleIds: memberRoleIdSet,
|
|
};
|
|
|
|
const tiers: Array<{
|
|
matchedBy: Exclude<ResolvedAgentRoute["matchedBy"], "default">;
|
|
enabled: boolean;
|
|
scopePeer: RoutePeer | null;
|
|
predicate: (candidate: EvaluatedBinding) => boolean;
|
|
}> = [
|
|
{
|
|
matchedBy: "binding.peer",
|
|
enabled: Boolean(peer),
|
|
scopePeer: peer,
|
|
predicate: (candidate) => candidate.match.peer.state === "valid",
|
|
},
|
|
{
|
|
matchedBy: "binding.peer.parent",
|
|
enabled: Boolean(parentPeer && parentPeer.id),
|
|
scopePeer: parentPeer && parentPeer.id ? parentPeer : null,
|
|
predicate: (candidate) => candidate.match.peer.state === "valid",
|
|
},
|
|
{
|
|
matchedBy: "binding.guild+roles",
|
|
enabled: Boolean(guildId && memberRoleIds.length > 0),
|
|
scopePeer: peer,
|
|
predicate: (candidate) =>
|
|
hasGuildConstraint(candidate.match) && hasRolesConstraint(candidate.match),
|
|
},
|
|
{
|
|
matchedBy: "binding.guild",
|
|
enabled: Boolean(guildId),
|
|
scopePeer: peer,
|
|
predicate: (candidate) =>
|
|
hasGuildConstraint(candidate.match) && !hasRolesConstraint(candidate.match),
|
|
},
|
|
{
|
|
matchedBy: "binding.team",
|
|
enabled: Boolean(teamId),
|
|
scopePeer: peer,
|
|
predicate: (candidate) => hasTeamConstraint(candidate.match),
|
|
},
|
|
{
|
|
matchedBy: "binding.account",
|
|
enabled: true,
|
|
scopePeer: peer,
|
|
predicate: (candidate) => candidate.match.accountPattern !== "*",
|
|
},
|
|
{
|
|
matchedBy: "binding.channel",
|
|
enabled: true,
|
|
scopePeer: peer,
|
|
predicate: (candidate) => candidate.match.accountPattern === "*",
|
|
},
|
|
];
|
|
|
|
for (const tier of tiers) {
|
|
if (!tier.enabled) {
|
|
continue;
|
|
}
|
|
const matched = bindings.find(
|
|
(candidate) =>
|
|
tier.predicate(candidate) &&
|
|
matchesBindingScope(candidate.match, {
|
|
...baseScope,
|
|
peer: tier.scopePeer,
|
|
}),
|
|
);
|
|
if (matched) {
|
|
if (shouldLogDebug) {
|
|
logDebug(`[routing] match: matchedBy=${tier.matchedBy} agentId=${matched.binding.agentId}`);
|
|
}
|
|
return choose(matched.binding.agentId, tier.matchedBy);
|
|
}
|
|
}
|
|
|
|
return choose(resolveDefaultAgentId(input.cfg), "default");
|
|
}
|