mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
271 lines
7.1 KiB
Go
271 lines
7.1 KiB
Go
package routing
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
// RouteInput contains the routing context from an inbound message.
|
|
type RouteInput struct {
|
|
Channel string
|
|
AccountID string
|
|
Peer *RoutePeer
|
|
ParentPeer *RoutePeer
|
|
GuildID string
|
|
TeamID string
|
|
}
|
|
|
|
// SessionPolicy describes how a routed message should be mapped to a session.
|
|
// The current implementation preserves the legacy dm_scope and identity_link
|
|
// semantics while moving session-key construction out of the router.
|
|
type SessionPolicy struct {
|
|
DMScope DMScope
|
|
IdentityLinks map[string][]string
|
|
}
|
|
|
|
// ResolvedRoute is the result of agent routing.
|
|
type ResolvedRoute struct {
|
|
AgentID string
|
|
Channel string
|
|
AccountID string
|
|
SessionPolicy SessionPolicy
|
|
MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default"
|
|
}
|
|
|
|
// RouteResolver determines which agent handles a message based on config bindings.
|
|
type RouteResolver struct {
|
|
cfg *config.Config
|
|
}
|
|
|
|
// NewRouteResolver creates a new route resolver.
|
|
func NewRouteResolver(cfg *config.Config) *RouteResolver {
|
|
return &RouteResolver{cfg: cfg}
|
|
}
|
|
|
|
// ResolveRoute determines which agent handles the message and returns the
|
|
// session policy that should be used to allocate session state.
|
|
// Implements the 7-level priority cascade:
|
|
// peer > parent_peer > guild > team > account > channel_wildcard > default
|
|
func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
|
channel := strings.ToLower(strings.TrimSpace(input.Channel))
|
|
accountID := NormalizeAccountID(input.AccountID)
|
|
peer := input.Peer
|
|
|
|
sessionPolicy := r.sessionPolicy()
|
|
|
|
bindings := r.filterBindings(channel, accountID)
|
|
|
|
choose := func(agentID string, matchedBy string) ResolvedRoute {
|
|
resolvedAgentID := r.pickAgentID(agentID)
|
|
return ResolvedRoute{
|
|
AgentID: resolvedAgentID,
|
|
Channel: channel,
|
|
AccountID: accountID,
|
|
SessionPolicy: sessionPolicy,
|
|
MatchedBy: matchedBy,
|
|
}
|
|
}
|
|
|
|
// Priority 1: Peer binding
|
|
if peer != nil && strings.TrimSpace(peer.ID) != "" {
|
|
if match := r.findPeerMatch(bindings, peer); match != nil {
|
|
return choose(match.AgentID, "binding.peer")
|
|
}
|
|
}
|
|
|
|
// Priority 2: Parent peer binding
|
|
parentPeer := input.ParentPeer
|
|
if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
|
|
if match := r.findPeerMatch(bindings, parentPeer); match != nil {
|
|
return choose(match.AgentID, "binding.peer.parent")
|
|
}
|
|
}
|
|
|
|
// Priority 3: Guild binding
|
|
guildID := strings.TrimSpace(input.GuildID)
|
|
if guildID != "" {
|
|
if match := r.findGuildMatch(bindings, guildID); match != nil {
|
|
return choose(match.AgentID, "binding.guild")
|
|
}
|
|
}
|
|
|
|
// Priority 4: Team binding
|
|
teamID := strings.TrimSpace(input.TeamID)
|
|
if teamID != "" {
|
|
if match := r.findTeamMatch(bindings, teamID); match != nil {
|
|
return choose(match.AgentID, "binding.team")
|
|
}
|
|
}
|
|
|
|
// Priority 5: Account binding
|
|
if match := r.findAccountMatch(bindings); match != nil {
|
|
return choose(match.AgentID, "binding.account")
|
|
}
|
|
|
|
// Priority 6: Channel wildcard binding
|
|
if match := r.findChannelWildcardMatch(bindings); match != nil {
|
|
return choose(match.AgentID, "binding.channel")
|
|
}
|
|
|
|
// Priority 7: Default agent
|
|
return choose(r.resolveDefaultAgentID(), "default")
|
|
}
|
|
|
|
func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding {
|
|
var filtered []config.AgentBinding
|
|
for _, b := range r.cfg.Bindings {
|
|
matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel))
|
|
if matchChannel == "" || matchChannel != channel {
|
|
continue
|
|
}
|
|
if !matchesAccountID(b.Match.AccountID, accountID) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, b)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func matchesAccountID(matchAccountID, actual string) bool {
|
|
trimmed := strings.TrimSpace(matchAccountID)
|
|
if trimmed == "" {
|
|
return actual == DefaultAccountID
|
|
}
|
|
if trimmed == "*" {
|
|
return true
|
|
}
|
|
return strings.ToLower(trimmed) == strings.ToLower(actual)
|
|
}
|
|
|
|
func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding {
|
|
for i := range bindings {
|
|
b := &bindings[i]
|
|
if b.Match.Peer == nil {
|
|
continue
|
|
}
|
|
peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind))
|
|
peerID := strings.TrimSpace(b.Match.Peer.ID)
|
|
if peerKind == "" || peerID == "" {
|
|
continue
|
|
}
|
|
if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID {
|
|
return b
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding {
|
|
for i := range bindings {
|
|
b := &bindings[i]
|
|
matchGuild := strings.TrimSpace(b.Match.GuildID)
|
|
if matchGuild != "" && matchGuild == guildID {
|
|
return &bindings[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding {
|
|
for i := range bindings {
|
|
b := &bindings[i]
|
|
matchTeam := strings.TrimSpace(b.Match.TeamID)
|
|
if matchTeam != "" && matchTeam == teamID {
|
|
return &bindings[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding {
|
|
for i := range bindings {
|
|
b := &bindings[i]
|
|
accountID := strings.TrimSpace(b.Match.AccountID)
|
|
if accountID == "*" {
|
|
continue
|
|
}
|
|
if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
|
|
continue
|
|
}
|
|
return &bindings[i]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding {
|
|
for i := range bindings {
|
|
b := &bindings[i]
|
|
accountID := strings.TrimSpace(b.Match.AccountID)
|
|
if accountID != "*" {
|
|
continue
|
|
}
|
|
if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
|
|
continue
|
|
}
|
|
return &bindings[i]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *RouteResolver) pickAgentID(agentID string) string {
|
|
trimmed := strings.TrimSpace(agentID)
|
|
if trimmed == "" {
|
|
return NormalizeAgentID(r.resolveDefaultAgentID())
|
|
}
|
|
normalized := NormalizeAgentID(trimmed)
|
|
agents := r.cfg.Agents.List
|
|
if len(agents) == 0 {
|
|
return normalized
|
|
}
|
|
for _, a := range agents {
|
|
if NormalizeAgentID(a.ID) == normalized {
|
|
return normalized
|
|
}
|
|
}
|
|
return NormalizeAgentID(r.resolveDefaultAgentID())
|
|
}
|
|
|
|
func (r *RouteResolver) resolveDefaultAgentID() string {
|
|
agents := r.cfg.Agents.List
|
|
if len(agents) == 0 {
|
|
return DefaultAgentID
|
|
}
|
|
for _, a := range agents {
|
|
if a.Default {
|
|
id := strings.TrimSpace(a.ID)
|
|
if id != "" {
|
|
return NormalizeAgentID(id)
|
|
}
|
|
}
|
|
}
|
|
if id := strings.TrimSpace(agents[0].ID); id != "" {
|
|
return NormalizeAgentID(id)
|
|
}
|
|
return DefaultAgentID
|
|
}
|
|
|
|
func (r *RouteResolver) sessionPolicy() SessionPolicy {
|
|
dmScope := DMScope(r.cfg.Session.DMScope)
|
|
if dmScope == "" {
|
|
dmScope = DMScopeMain
|
|
}
|
|
return SessionPolicy{
|
|
DMScope: dmScope,
|
|
IdentityLinks: cloneIdentityLinks(r.cfg.Session.IdentityLinks),
|
|
}
|
|
}
|
|
|
|
func cloneIdentityLinks(src map[string][]string) map[string][]string {
|
|
if len(src) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make(map[string][]string, len(src))
|
|
for canonical, ids := range src {
|
|
dup := make([]string, len(ids))
|
|
copy(dup, ids)
|
|
cloned[canonical] = dup
|
|
}
|
|
return cloned
|
|
}
|