mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
332 lines
8.5 KiB
Go
332 lines
8.5 KiB
Go
package routing
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
// SessionPolicy describes how a routed message should be mapped to a session.
|
|
type SessionPolicy struct {
|
|
Dimensions []string
|
|
IdentityLinks map[string][]string
|
|
}
|
|
|
|
type RoutePeer struct {
|
|
Kind string
|
|
ID 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 from a normalized
|
|
// inbound context 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(inbound bus.InboundContext) ResolvedRoute {
|
|
channel := strings.ToLower(strings.TrimSpace(inbound.Channel))
|
|
accountID := NormalizeAccountID(inbound.Account)
|
|
peer := routePeerFromContext(inbound)
|
|
|
|
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 := parentPeerFromContext(inbound)
|
|
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 := routeGuildIDFromContext(inbound)
|
|
if guildID != "" {
|
|
if match := r.findGuildMatch(bindings, guildID); match != nil {
|
|
return choose(match.AgentID, "binding.guild")
|
|
}
|
|
}
|
|
|
|
// Priority 4: Team binding
|
|
teamID := routeTeamIDFromContext(inbound)
|
|
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 {
|
|
return SessionPolicy{
|
|
Dimensions: normalizeSessionDimensions(r.cfg.Session.Dimensions),
|
|
IdentityLinks: cloneIdentityLinks(r.cfg.Session.IdentityLinks),
|
|
}
|
|
}
|
|
|
|
func normalizeSessionDimensions(dimensions []string) []string {
|
|
if len(dimensions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
normalized := make([]string, 0, len(dimensions))
|
|
seen := make(map[string]struct{}, len(dimensions))
|
|
for _, dimension := range dimensions {
|
|
dimension = strings.ToLower(strings.TrimSpace(dimension))
|
|
switch dimension {
|
|
case "space", "chat", "topic", "sender":
|
|
default:
|
|
continue
|
|
}
|
|
if _, ok := seen[dimension]; ok {
|
|
continue
|
|
}
|
|
seen[dimension] = struct{}{}
|
|
normalized = append(normalized, dimension)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func routePeerFromContext(ctx bus.InboundContext) *RoutePeer {
|
|
peerKind := normalizeChannel(strings.TrimSpace(ctx.ChatType))
|
|
if peerKind == "" || peerKind == "unknown" {
|
|
return nil
|
|
}
|
|
|
|
peerID := strings.TrimSpace(ctx.ChatID)
|
|
if peerKind == "direct" && peerID == "" {
|
|
peerID = strings.TrimSpace(ctx.SenderID)
|
|
}
|
|
if peerID == "" {
|
|
return nil
|
|
}
|
|
|
|
return &RoutePeer{Kind: peerKind, ID: peerID}
|
|
}
|
|
|
|
func parentPeerFromContext(ctx bus.InboundContext) *RoutePeer {
|
|
if topicID := strings.TrimSpace(ctx.TopicID); topicID != "" {
|
|
return &RoutePeer{Kind: "topic", ID: topicID}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func routeGuildIDFromContext(ctx bus.InboundContext) string {
|
|
if strings.EqualFold(strings.TrimSpace(ctx.SpaceType), "guild") {
|
|
return strings.TrimSpace(ctx.SpaceID)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func routeTeamIDFromContext(ctx bus.InboundContext) string {
|
|
switch strings.ToLower(strings.TrimSpace(ctx.SpaceType)) {
|
|
case "team", "workspace":
|
|
return strings.TrimSpace(ctx.SpaceID)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func normalizeChannel(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|