mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
314 lines
8.3 KiB
Go
314 lines
8.3 KiB
Go
package routing
|
|
|
|
import (
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
// ResolvedRoute is the result of agent routing.
|
|
type ResolvedRoute struct {
|
|
AgentID string
|
|
Channel string
|
|
AccountID string
|
|
SessionPolicy SessionPolicy
|
|
MatchedBy string
|
|
}
|
|
|
|
// RouteResolver determines which agent handles a message.
|
|
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.
|
|
func (r *RouteResolver) ResolveRoute(inbound bus.InboundContext) ResolvedRoute {
|
|
channel := strings.ToLower(strings.TrimSpace(inbound.Channel))
|
|
accountID := NormalizeAccountID(inbound.Account)
|
|
identityLinks := cloneIdentityLinks(r.cfg.Session.IdentityLinks)
|
|
view := buildDispatchView(inbound, identityLinks)
|
|
|
|
if rule := r.matchDispatchRule(view); rule != nil {
|
|
return ResolvedRoute{
|
|
AgentID: r.pickAgentID(rule.Agent),
|
|
Channel: channel,
|
|
AccountID: accountID,
|
|
SessionPolicy: r.sessionPolicy(rule),
|
|
MatchedBy: matchedByForRule(rule),
|
|
}
|
|
}
|
|
|
|
return ResolvedRoute{
|
|
AgentID: r.pickAgentID(r.resolveDefaultAgentID()),
|
|
Channel: channel,
|
|
AccountID: accountID,
|
|
SessionPolicy: r.sessionPolicy(nil),
|
|
MatchedBy: "default",
|
|
}
|
|
}
|
|
|
|
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(rule *config.DispatchRule) SessionPolicy {
|
|
dimensions := r.cfg.Session.Dimensions
|
|
if rule != nil && len(rule.SessionDimensions) > 0 {
|
|
dimensions = rule.SessionDimensions
|
|
}
|
|
return SessionPolicy{
|
|
Dimensions: normalizeSessionDimensions(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 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
|
|
}
|
|
|
|
type dispatchView struct {
|
|
Channel string
|
|
Account string
|
|
Space string
|
|
Chat string
|
|
Topic string
|
|
Sender string
|
|
Mentioned bool
|
|
}
|
|
|
|
func (r *RouteResolver) matchDispatchRule(view dispatchView) *config.DispatchRule {
|
|
if r.cfg == nil || r.cfg.Agents.Dispatch == nil || len(r.cfg.Agents.Dispatch.Rules) == 0 {
|
|
return nil
|
|
}
|
|
|
|
for i := range r.cfg.Agents.Dispatch.Rules {
|
|
rule := &r.cfg.Agents.Dispatch.Rules[i]
|
|
if !selectorHasAnyConstraint(rule.When) {
|
|
continue
|
|
}
|
|
if ruleMatchesView(*rule, view) {
|
|
return rule
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ruleMatchesView(rule config.DispatchRule, view dispatchView) bool {
|
|
when := normalizeDispatchSelector(rule.When)
|
|
if when.Channel != "" && when.Channel != view.Channel {
|
|
return false
|
|
}
|
|
if when.Account != "" && when.Account != view.Account {
|
|
return false
|
|
}
|
|
if when.Space != "" && when.Space != view.Space {
|
|
return false
|
|
}
|
|
if when.Chat != "" && when.Chat != view.Chat {
|
|
return false
|
|
}
|
|
if when.Topic != "" && when.Topic != view.Topic {
|
|
return false
|
|
}
|
|
if when.Sender != "" && when.Sender != view.Sender {
|
|
return false
|
|
}
|
|
if when.Mentioned != nil && *when.Mentioned != view.Mentioned {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func matchedByForRule(rule *config.DispatchRule) string {
|
|
if rule == nil {
|
|
return "default"
|
|
}
|
|
name := strings.TrimSpace(rule.Name)
|
|
if name == "" {
|
|
return "dispatch.rule"
|
|
}
|
|
return "dispatch.rule:" + strings.ToLower(name)
|
|
}
|
|
|
|
func buildDispatchView(inbound bus.InboundContext, identityLinks map[string][]string) dispatchView {
|
|
view := dispatchView{
|
|
Channel: strings.ToLower(strings.TrimSpace(inbound.Channel)),
|
|
Account: NormalizeAccountID(inbound.Account),
|
|
Mentioned: inbound.Mentioned,
|
|
}
|
|
|
|
if spaceID := strings.TrimSpace(inbound.SpaceID); spaceID != "" {
|
|
spaceType := strings.ToLower(strings.TrimSpace(inbound.SpaceType))
|
|
if spaceType == "" {
|
|
spaceType = "space"
|
|
}
|
|
view.Space = fmt.Sprintf("%s:%s", spaceType, strings.ToLower(spaceID))
|
|
}
|
|
|
|
if chatID := strings.TrimSpace(inbound.ChatID); chatID != "" {
|
|
chatType := strings.ToLower(strings.TrimSpace(inbound.ChatType))
|
|
if chatType == "" {
|
|
chatType = "direct"
|
|
}
|
|
view.Chat = fmt.Sprintf("%s:%s", chatType, strings.ToLower(chatID))
|
|
}
|
|
|
|
if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
|
|
view.Topic = "topic:" + strings.ToLower(topicID)
|
|
}
|
|
|
|
view.Sender = canonicalDispatchSenderID(inbound.Channel, inbound.SenderID, identityLinks)
|
|
|
|
return view
|
|
}
|
|
|
|
func normalizeDispatchSelector(selector config.DispatchSelector) config.DispatchSelector {
|
|
selector.Channel = strings.ToLower(strings.TrimSpace(selector.Channel))
|
|
selector.Account = NormalizeAccountID(selector.Account)
|
|
selector.Space = strings.ToLower(strings.TrimSpace(selector.Space))
|
|
selector.Chat = strings.ToLower(strings.TrimSpace(selector.Chat))
|
|
selector.Topic = strings.ToLower(strings.TrimSpace(selector.Topic))
|
|
selector.Sender = strings.ToLower(strings.TrimSpace(selector.Sender))
|
|
return selector
|
|
}
|
|
|
|
func selectorHasAnyConstraint(selector config.DispatchSelector) bool {
|
|
return strings.TrimSpace(selector.Channel) != "" ||
|
|
strings.TrimSpace(selector.Account) != "" ||
|
|
strings.TrimSpace(selector.Space) != "" ||
|
|
strings.TrimSpace(selector.Chat) != "" ||
|
|
strings.TrimSpace(selector.Topic) != "" ||
|
|
strings.TrimSpace(selector.Sender) != "" ||
|
|
selector.Mentioned != nil
|
|
}
|
|
|
|
func canonicalDispatchSenderID(channel, rawID string, identityLinks map[string][]string) string {
|
|
normalizedID := strings.TrimSpace(rawID)
|
|
if normalizedID == "" {
|
|
return ""
|
|
}
|
|
if linked := resolveLinkedDispatchID(identityLinks, channel, normalizedID); linked != "" {
|
|
normalizedID = linked
|
|
}
|
|
return strings.ToLower(normalizedID)
|
|
}
|
|
|
|
func resolveLinkedDispatchID(identityLinks map[string][]string, channel, peerID string) string {
|
|
if len(identityLinks) == 0 {
|
|
return ""
|
|
}
|
|
peerID = strings.TrimSpace(peerID)
|
|
if peerID == "" {
|
|
return ""
|
|
}
|
|
|
|
candidates := make(map[string]bool)
|
|
rawCandidate := strings.ToLower(peerID)
|
|
if rawCandidate != "" {
|
|
candidates[rawCandidate] = true
|
|
}
|
|
channel = strings.ToLower(strings.TrimSpace(channel))
|
|
if channel != "" {
|
|
candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true
|
|
}
|
|
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
|
|
candidates[rawCandidate[idx+1:]] = true
|
|
}
|
|
|
|
for canonical, ids := range identityLinks {
|
|
canonicalName := strings.TrimSpace(canonical)
|
|
if canonicalName == "" {
|
|
continue
|
|
}
|
|
for _, id := range ids {
|
|
normalized := strings.ToLower(strings.TrimSpace(id))
|
|
if normalized != "" && candidates[normalized] {
|
|
return canonicalName
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|