788 lines
22 KiB
TypeScript
788 lines
22 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 "";
|
|
}
|
|
|
|
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 : [];
|
|
}
|
|
|
|
type AgentLookupCache = {
|
|
agentsRef: OpenClawConfig["agents"] | undefined;
|
|
byNormalizedId: Map<string, string>;
|
|
fallbackDefaultAgentId: string;
|
|
};
|
|
|
|
const agentLookupCacheByCfg = new WeakMap<OpenClawConfig, AgentLookupCache>();
|
|
|
|
function resolveAgentLookupCache(cfg: OpenClawConfig): AgentLookupCache {
|
|
const agentsRef = cfg.agents;
|
|
const existing = agentLookupCacheByCfg.get(cfg);
|
|
if (existing && existing.agentsRef === agentsRef) {
|
|
return existing;
|
|
}
|
|
|
|
const byNormalizedId = new Map<string, string>();
|
|
for (const agent of listAgents(cfg)) {
|
|
const rawId = agent.id?.trim();
|
|
if (!rawId) {
|
|
continue;
|
|
}
|
|
byNormalizedId.set(normalizeAgentId(rawId), sanitizeAgentId(rawId));
|
|
}
|
|
const next: AgentLookupCache = {
|
|
agentsRef,
|
|
byNormalizedId,
|
|
fallbackDefaultAgentId: sanitizeAgentId(resolveDefaultAgentId(cfg)),
|
|
};
|
|
agentLookupCacheByCfg.set(cfg, next);
|
|
return next;
|
|
}
|
|
|
|
export function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): string {
|
|
const lookup = resolveAgentLookupCache(cfg);
|
|
const trimmed = (agentId ?? "").trim();
|
|
if (!trimmed) {
|
|
return lookup.fallbackDefaultAgentId;
|
|
}
|
|
const normalized = normalizeAgentId(trimmed);
|
|
if (lookup.byNormalizedId.size === 0) {
|
|
return sanitizeAgentId(trimmed);
|
|
}
|
|
const resolved = lookup.byNormalizedId.get(normalized);
|
|
if (resolved) {
|
|
return resolved;
|
|
}
|
|
return lookup.fallbackDefaultAgentId;
|
|
}
|
|
|
|
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;
|
|
order: number;
|
|
};
|
|
|
|
type BindingScope = {
|
|
peer: RoutePeer | null;
|
|
guildId: string;
|
|
teamId: string;
|
|
memberRoleIds: Set<string>;
|
|
};
|
|
|
|
type EvaluatedBindingsCache = {
|
|
bindingsRef: OpenClawConfig["bindings"];
|
|
byChannel: Map<string, EvaluatedBindingsByChannel>;
|
|
byChannelAccount: Map<string, EvaluatedBinding[]>;
|
|
byChannelAccountIndex: Map<string, EvaluatedBindingsIndex>;
|
|
};
|
|
|
|
const evaluatedBindingsCacheByCfg = new WeakMap<OpenClawConfig, EvaluatedBindingsCache>();
|
|
const MAX_EVALUATED_BINDINGS_CACHE_KEYS = 2000;
|
|
const resolvedRouteCacheByCfg = new WeakMap<
|
|
OpenClawConfig,
|
|
{
|
|
bindingsRef: OpenClawConfig["bindings"];
|
|
agentsRef: OpenClawConfig["agents"];
|
|
sessionRef: OpenClawConfig["session"];
|
|
byKey: Map<string, ResolvedAgentRoute>;
|
|
}
|
|
>();
|
|
const MAX_RESOLVED_ROUTE_CACHE_KEYS = 4000;
|
|
|
|
type EvaluatedBindingsIndex = {
|
|
byPeer: Map<string, EvaluatedBinding[]>;
|
|
byGuildWithRoles: Map<string, EvaluatedBinding[]>;
|
|
byGuild: Map<string, EvaluatedBinding[]>;
|
|
byTeam: Map<string, EvaluatedBinding[]>;
|
|
byAccount: EvaluatedBinding[];
|
|
byChannel: EvaluatedBinding[];
|
|
};
|
|
|
|
type EvaluatedBindingsByChannel = {
|
|
byAccount: Map<string, EvaluatedBinding[]>;
|
|
byAnyAccount: EvaluatedBinding[];
|
|
};
|
|
|
|
function resolveAccountPatternKey(accountPattern: string): string {
|
|
if (!accountPattern.trim()) {
|
|
return DEFAULT_ACCOUNT_ID;
|
|
}
|
|
return normalizeAccountId(accountPattern);
|
|
}
|
|
|
|
function buildEvaluatedBindingsByChannel(
|
|
cfg: OpenClawConfig,
|
|
): Map<string, EvaluatedBindingsByChannel> {
|
|
const byChannel = new Map<string, EvaluatedBindingsByChannel>();
|
|
let order = 0;
|
|
for (const binding of listBindings(cfg)) {
|
|
if (!binding || typeof binding !== "object") {
|
|
continue;
|
|
}
|
|
const channel = normalizeToken(binding.match?.channel);
|
|
if (!channel) {
|
|
continue;
|
|
}
|
|
const match = normalizeBindingMatch(binding.match);
|
|
const evaluated: EvaluatedBinding = {
|
|
binding,
|
|
match,
|
|
order,
|
|
};
|
|
order += 1;
|
|
let bucket = byChannel.get(channel);
|
|
if (!bucket) {
|
|
bucket = {
|
|
byAccount: new Map<string, EvaluatedBinding[]>(),
|
|
byAnyAccount: [],
|
|
};
|
|
byChannel.set(channel, bucket);
|
|
}
|
|
if (match.accountPattern === "*") {
|
|
bucket.byAnyAccount.push(evaluated);
|
|
continue;
|
|
}
|
|
const accountKey = resolveAccountPatternKey(match.accountPattern);
|
|
const existing = bucket.byAccount.get(accountKey);
|
|
if (existing) {
|
|
existing.push(evaluated);
|
|
continue;
|
|
}
|
|
bucket.byAccount.set(accountKey, [evaluated]);
|
|
}
|
|
return byChannel;
|
|
}
|
|
|
|
function mergeEvaluatedBindingsInSourceOrder(
|
|
accountScoped: EvaluatedBinding[],
|
|
anyAccount: EvaluatedBinding[],
|
|
): EvaluatedBinding[] {
|
|
if (accountScoped.length === 0) {
|
|
return anyAccount;
|
|
}
|
|
if (anyAccount.length === 0) {
|
|
return accountScoped;
|
|
}
|
|
const merged: EvaluatedBinding[] = [];
|
|
let accountIdx = 0;
|
|
let anyIdx = 0;
|
|
while (accountIdx < accountScoped.length && anyIdx < anyAccount.length) {
|
|
const accountBinding = accountScoped[accountIdx];
|
|
const anyBinding = anyAccount[anyIdx];
|
|
if (
|
|
(accountBinding?.order ?? Number.MAX_SAFE_INTEGER) <=
|
|
(anyBinding?.order ?? Number.MAX_SAFE_INTEGER)
|
|
) {
|
|
if (accountBinding) {
|
|
merged.push(accountBinding);
|
|
}
|
|
accountIdx += 1;
|
|
continue;
|
|
}
|
|
if (anyBinding) {
|
|
merged.push(anyBinding);
|
|
}
|
|
anyIdx += 1;
|
|
}
|
|
if (accountIdx < accountScoped.length) {
|
|
merged.push(...accountScoped.slice(accountIdx));
|
|
}
|
|
if (anyIdx < anyAccount.length) {
|
|
merged.push(...anyAccount.slice(anyIdx));
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function pushToIndexMap(
|
|
map: Map<string, EvaluatedBinding[]>,
|
|
key: string | null,
|
|
binding: EvaluatedBinding,
|
|
): void {
|
|
if (!key) {
|
|
return;
|
|
}
|
|
const existing = map.get(key);
|
|
if (existing) {
|
|
existing.push(binding);
|
|
return;
|
|
}
|
|
map.set(key, [binding]);
|
|
}
|
|
|
|
function peerLookupKeys(kind: ChatType, id: string): string[] {
|
|
if (kind === "group") {
|
|
return [`group:${id}`, `channel:${id}`];
|
|
}
|
|
if (kind === "channel") {
|
|
return [`channel:${id}`, `group:${id}`];
|
|
}
|
|
return [`${kind}:${id}`];
|
|
}
|
|
|
|
function collectPeerIndexedBindings(
|
|
index: EvaluatedBindingsIndex,
|
|
peer: RoutePeer | null,
|
|
): EvaluatedBinding[] {
|
|
if (!peer) {
|
|
return [];
|
|
}
|
|
const out: EvaluatedBinding[] = [];
|
|
const seen = new Set<EvaluatedBinding>();
|
|
for (const key of peerLookupKeys(peer.kind, peer.id)) {
|
|
const matches = index.byPeer.get(key);
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
for (const match of matches) {
|
|
if (seen.has(match)) {
|
|
continue;
|
|
}
|
|
seen.add(match);
|
|
out.push(match);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildEvaluatedBindingsIndex(bindings: EvaluatedBinding[]): EvaluatedBindingsIndex {
|
|
const byPeer = new Map<string, EvaluatedBinding[]>();
|
|
const byGuildWithRoles = new Map<string, EvaluatedBinding[]>();
|
|
const byGuild = new Map<string, EvaluatedBinding[]>();
|
|
const byTeam = new Map<string, EvaluatedBinding[]>();
|
|
const byAccount: EvaluatedBinding[] = [];
|
|
const byChannel: EvaluatedBinding[] = [];
|
|
|
|
for (const binding of bindings) {
|
|
if (binding.match.peer.state === "valid") {
|
|
for (const key of peerLookupKeys(binding.match.peer.kind, binding.match.peer.id)) {
|
|
pushToIndexMap(byPeer, key, binding);
|
|
}
|
|
continue;
|
|
}
|
|
if (binding.match.guildId && binding.match.roles) {
|
|
pushToIndexMap(byGuildWithRoles, binding.match.guildId, binding);
|
|
continue;
|
|
}
|
|
if (binding.match.guildId && !binding.match.roles) {
|
|
pushToIndexMap(byGuild, binding.match.guildId, binding);
|
|
continue;
|
|
}
|
|
if (binding.match.teamId) {
|
|
pushToIndexMap(byTeam, binding.match.teamId, binding);
|
|
continue;
|
|
}
|
|
if (binding.match.accountPattern !== "*") {
|
|
byAccount.push(binding);
|
|
continue;
|
|
}
|
|
byChannel.push(binding);
|
|
}
|
|
|
|
return {
|
|
byPeer,
|
|
byGuildWithRoles,
|
|
byGuild,
|
|
byTeam,
|
|
byAccount,
|
|
byChannel,
|
|
};
|
|
}
|
|
|
|
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,
|
|
byChannel: buildEvaluatedBindingsByChannel(cfg),
|
|
byChannelAccount: new Map<string, EvaluatedBinding[]>(),
|
|
byChannelAccountIndex: new Map<string, EvaluatedBindingsIndex>(),
|
|
};
|
|
if (cache !== existing) {
|
|
evaluatedBindingsCacheByCfg.set(cfg, cache);
|
|
}
|
|
|
|
const cacheKey = `${channel}\t${accountId}`;
|
|
const hit = cache.byChannelAccount.get(cacheKey);
|
|
if (hit) {
|
|
return hit;
|
|
}
|
|
|
|
const channelBindings = cache.byChannel.get(channel);
|
|
const accountScoped = channelBindings?.byAccount.get(accountId) ?? [];
|
|
const anyAccount = channelBindings?.byAnyAccount ?? [];
|
|
const evaluated = mergeEvaluatedBindingsInSourceOrder(accountScoped, anyAccount);
|
|
|
|
cache.byChannelAccount.set(cacheKey, evaluated);
|
|
cache.byChannelAccountIndex.set(cacheKey, buildEvaluatedBindingsIndex(evaluated));
|
|
if (cache.byChannelAccount.size > MAX_EVALUATED_BINDINGS_CACHE_KEYS) {
|
|
cache.byChannelAccount.clear();
|
|
cache.byChannelAccountIndex.clear();
|
|
cache.byChannelAccount.set(cacheKey, evaluated);
|
|
cache.byChannelAccountIndex.set(cacheKey, buildEvaluatedBindingsIndex(evaluated));
|
|
}
|
|
|
|
return evaluated;
|
|
}
|
|
|
|
function getEvaluatedBindingIndexForChannelAccount(
|
|
cfg: OpenClawConfig,
|
|
channel: string,
|
|
accountId: string,
|
|
): EvaluatedBindingsIndex {
|
|
const bindings = getEvaluatedBindingsForChannelAccount(cfg, channel, accountId);
|
|
const existing = evaluatedBindingsCacheByCfg.get(cfg);
|
|
const cacheKey = `${channel}\t${accountId}`;
|
|
const indexed = existing?.byChannelAccountIndex.get(cacheKey);
|
|
if (indexed) {
|
|
return indexed;
|
|
}
|
|
const built = buildEvaluatedBindingsIndex(bindings);
|
|
existing?.byChannelAccountIndex.set(cacheKey, built);
|
|
return built;
|
|
}
|
|
|
|
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 resolveRouteCacheForConfig(cfg: OpenClawConfig): Map<string, ResolvedAgentRoute> {
|
|
const existing = resolvedRouteCacheByCfg.get(cfg);
|
|
if (
|
|
existing &&
|
|
existing.bindingsRef === cfg.bindings &&
|
|
existing.agentsRef === cfg.agents &&
|
|
existing.sessionRef === cfg.session
|
|
) {
|
|
return existing.byKey;
|
|
}
|
|
const byKey = new Map<string, ResolvedAgentRoute>();
|
|
resolvedRouteCacheByCfg.set(cfg, {
|
|
bindingsRef: cfg.bindings,
|
|
agentsRef: cfg.agents,
|
|
sessionRef: cfg.session,
|
|
byKey,
|
|
});
|
|
return byKey;
|
|
}
|
|
|
|
function formatRouteCachePeer(peer: RoutePeer | null): string {
|
|
if (!peer || !peer.id) {
|
|
return "-";
|
|
}
|
|
return `${peer.kind}:${peer.id}`;
|
|
}
|
|
|
|
function formatRoleIdsCacheKey(roleIds: string[]): string {
|
|
const count = roleIds.length;
|
|
if (count === 0) {
|
|
return "-";
|
|
}
|
|
if (count === 1) {
|
|
return roleIds[0] ?? "-";
|
|
}
|
|
if (count === 2) {
|
|
const first = roleIds[0] ?? "";
|
|
const second = roleIds[1] ?? "";
|
|
return first <= second ? `${first},${second}` : `${second},${first}`;
|
|
}
|
|
return roleIds.toSorted().join(",");
|
|
}
|
|
|
|
function buildResolvedRouteCacheKey(params: {
|
|
channel: string;
|
|
accountId: string;
|
|
peer: RoutePeer | null;
|
|
parentPeer: RoutePeer | null;
|
|
guildId: string;
|
|
teamId: string;
|
|
memberRoleIds: string[];
|
|
dmScope: string;
|
|
}): string {
|
|
return `${params.channel}\t${params.accountId}\t${formatRouteCachePeer(params.peer)}\t${formatRouteCachePeer(params.parentPeer)}\t${params.guildId || "-"}\t${params.teamId || "-"}\t${formatRoleIdsCacheKey(params.memberRoleIds)}\t${params.dmScope}`;
|
|
}
|
|
|
|
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 peerKindMatches(bindingKind: ChatType, scopeKind: ChatType): boolean {
|
|
if (bindingKind === scopeKind) {
|
|
return true;
|
|
}
|
|
const both = new Set([bindingKind, scopeKind]);
|
|
return both.has("group") && both.has("channel");
|
|
}
|
|
|
|
function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope): boolean {
|
|
if (match.peer.state === "invalid") {
|
|
return false;
|
|
}
|
|
if (match.peer.state === "valid") {
|
|
if (
|
|
!scope.peer ||
|
|
!peerKindMatches(match.peer.kind, scope.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 dmScope = input.cfg.session?.dmScope ?? "main";
|
|
const identityLinks = input.cfg.session?.identityLinks;
|
|
const shouldLogDebug = shouldLogVerbose();
|
|
const parentPeer = input.parentPeer
|
|
? {
|
|
kind: normalizeChatType(input.parentPeer.kind) ?? input.parentPeer.kind,
|
|
id: normalizeId(input.parentPeer.id),
|
|
}
|
|
: null;
|
|
|
|
const routeCache =
|
|
!shouldLogDebug && !identityLinks ? resolveRouteCacheForConfig(input.cfg) : null;
|
|
const routeCacheKey = routeCache
|
|
? buildResolvedRouteCacheKey({
|
|
channel,
|
|
accountId,
|
|
peer,
|
|
parentPeer,
|
|
guildId,
|
|
teamId,
|
|
memberRoleIds,
|
|
dmScope,
|
|
})
|
|
: "";
|
|
if (routeCache && routeCacheKey) {
|
|
const cachedRoute = routeCache.get(routeCacheKey);
|
|
if (cachedRoute) {
|
|
return { ...cachedRoute };
|
|
}
|
|
}
|
|
|
|
const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId);
|
|
const bindingsIndex = getEvaluatedBindingIndexForChannelAccount(input.cfg, channel, accountId);
|
|
|
|
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();
|
|
const route = {
|
|
agentId: resolvedAgentId,
|
|
channel,
|
|
accountId,
|
|
sessionKey,
|
|
mainSessionKey,
|
|
matchedBy,
|
|
};
|
|
if (routeCache && routeCacheKey) {
|
|
routeCache.set(routeCacheKey, route);
|
|
if (routeCache.size > MAX_RESOLVED_ROUTE_CACHE_KEYS) {
|
|
routeCache.clear();
|
|
routeCache.set(routeCacheKey, route);
|
|
}
|
|
}
|
|
return route;
|
|
};
|
|
|
|
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 baseScope = {
|
|
guildId,
|
|
teamId,
|
|
memberRoleIds: memberRoleIdSet,
|
|
};
|
|
|
|
const tiers: Array<{
|
|
matchedBy: Exclude<ResolvedAgentRoute["matchedBy"], "default">;
|
|
enabled: boolean;
|
|
scopePeer: RoutePeer | null;
|
|
candidates: EvaluatedBinding[];
|
|
predicate: (candidate: EvaluatedBinding) => boolean;
|
|
}> = [
|
|
{
|
|
matchedBy: "binding.peer",
|
|
enabled: Boolean(peer),
|
|
scopePeer: peer,
|
|
candidates: collectPeerIndexedBindings(bindingsIndex, peer),
|
|
predicate: (candidate) => candidate.match.peer.state === "valid",
|
|
},
|
|
{
|
|
matchedBy: "binding.peer.parent",
|
|
enabled: Boolean(parentPeer && parentPeer.id),
|
|
scopePeer: parentPeer && parentPeer.id ? parentPeer : null,
|
|
candidates: collectPeerIndexedBindings(bindingsIndex, parentPeer),
|
|
predicate: (candidate) => candidate.match.peer.state === "valid",
|
|
},
|
|
{
|
|
matchedBy: "binding.guild+roles",
|
|
enabled: Boolean(guildId && memberRoleIds.length > 0),
|
|
scopePeer: peer,
|
|
candidates: guildId ? (bindingsIndex.byGuildWithRoles.get(guildId) ?? []) : [],
|
|
predicate: (candidate) =>
|
|
hasGuildConstraint(candidate.match) && hasRolesConstraint(candidate.match),
|
|
},
|
|
{
|
|
matchedBy: "binding.guild",
|
|
enabled: Boolean(guildId),
|
|
scopePeer: peer,
|
|
candidates: guildId ? (bindingsIndex.byGuild.get(guildId) ?? []) : [],
|
|
predicate: (candidate) =>
|
|
hasGuildConstraint(candidate.match) && !hasRolesConstraint(candidate.match),
|
|
},
|
|
{
|
|
matchedBy: "binding.team",
|
|
enabled: Boolean(teamId),
|
|
scopePeer: peer,
|
|
candidates: teamId ? (bindingsIndex.byTeam.get(teamId) ?? []) : [],
|
|
predicate: (candidate) => hasTeamConstraint(candidate.match),
|
|
},
|
|
{
|
|
matchedBy: "binding.account",
|
|
enabled: true,
|
|
scopePeer: peer,
|
|
candidates: bindingsIndex.byAccount,
|
|
predicate: (candidate) => candidate.match.accountPattern !== "*",
|
|
},
|
|
{
|
|
matchedBy: "binding.channel",
|
|
enabled: true,
|
|
scopePeer: peer,
|
|
candidates: bindingsIndex.byChannel,
|
|
predicate: (candidate) => candidate.match.accountPattern === "*",
|
|
},
|
|
];
|
|
|
|
for (const tier of tiers) {
|
|
if (!tier.enabled) {
|
|
continue;
|
|
}
|
|
const matched = tier.candidates.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");
|
|
}
|