// Binding scope helpers normalize route binding scope values. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeChatChannelId } from "../channels/ids.js"; import type { AgentRouteBinding } from "../config/types.agents.js"; import { normalizeAccountId, normalizeAgentId } from "./session-key.js"; // Route binding scopes constrain a configured agent/account binding to a guild, // team, group space, and optionally channel/platform role ids. type RouteBindingScopeConstraint = { guildId?: string | null; teamId?: string | null; roles?: string[] | null; }; type RouteBindingScope = { guildId?: string | null; teamId?: string | null; groupSpace?: string | null; memberRoleIds?: Iterable | null; }; type NormalizedRouteBindingMatch = { agentId: string; accountId: string; channelId: string; }; export function normalizeRouteBindingId(value: unknown): string { if (typeof value === "string") { return value.trim(); } if (typeof value === "number" || typeof value === "bigint") { return String(value).trim(); } return ""; } export function normalizeRouteBindingRoles(value: string[] | null | undefined): string[] | null { return Array.isArray(value) && value.length > 0 ? value : null; } export function normalizeRouteBindingChannelId(raw?: string | null): string | null { const normalized = normalizeChatChannelId(raw); if (normalized) { return normalized; } const fallback = normalizeLowercaseStringOrEmpty(raw); return fallback || null; } // Convert a binding match into the same canonical ids used by session routing. // Diagnostics include implicit defaults; outbound account selection requires explicit bindings. export function resolveNormalizedRouteBindingMatch( binding: AgentRouteBinding, options?: { includeImplicitDefaultAccount?: boolean }, ): NormalizedRouteBindingMatch | null { if (!binding || typeof binding !== "object") { return null; } const match = binding.match; if (!match || typeof match !== "object") { return null; } const channelId = normalizeRouteBindingChannelId(match.channel); if (!channelId) { return null; } if (match.accountId !== undefined && typeof match.accountId !== "string") { return null; } const accountId = match.accountId?.trim(); if (accountId === "*" || (!accountId && !options?.includeImplicitDefaultAccount)) { return null; } return { agentId: normalizeAgentId(binding.agentId), accountId: normalizeAccountId(accountId), channelId, }; } function scopeIdMatches(params: { constraint: string | null | undefined; exact: string; groupSpace: string; }): boolean { if (!params.constraint) { return true; } return params.constraint === params.exact || params.constraint === params.groupSpace; } function hasRoleLookup( memberRoleIds: Iterable, ): memberRoleIds is Iterable & { has(roleId: string): boolean } { return typeof (memberRoleIds as { has?: unknown }).has === "function"; } function hasAnyRouteBindingRole( roles: readonly string[], memberRoleIds: Iterable | null | undefined, ): boolean { if (!memberRoleIds) { return false; } if (hasRoleLookup(memberRoleIds)) { return roles.some((role) => memberRoleIds.has(role)); } // Most callers pass a Set, but arrays/iterables from channel adapters are // accepted to avoid forcing allocation at every routing call site. const memberRoleIdSet = new Set(memberRoleIds); return roles.some((role) => memberRoleIdSet.has(role)); } export function routeBindingScopeMatches( constraint: RouteBindingScopeConstraint, scope: RouteBindingScope, ): boolean { const guildId = normalizeRouteBindingId(scope.guildId); const teamId = normalizeRouteBindingId(scope.teamId); const groupSpace = normalizeRouteBindingId(scope.groupSpace); if (!scopeIdMatches({ constraint: constraint.guildId, exact: guildId, groupSpace })) { return false; } if (!scopeIdMatches({ constraint: constraint.teamId, exact: teamId, groupSpace })) { return false; } const roles = normalizeRouteBindingRoles(constraint.roles); if (!roles) { return true; } return hasAnyRouteBindingRole(roles, scope.memberRoleIds); }