package dispatcher import ( "encoding/json" "errors" "fmt" "math" "time" "git.ipao.vip/rogee/go-sip/internal/callwindow" "git.ipao.vip/rogee/go-sip/internal/configread" ) var ( ErrCalleeRejected = errors.New("SaaS event callee is not a valid original numeric dial route") ErrRuleWait = errors.New("task rules are temporarily not satisfied") ErrRuleInvalid = errors.New("task execution rules are invalid or unknown") ) type SelectedTrunk struct { TrunkID string CallerID string Callee string DialedCallee string Deadline time.Time MaxCallDurationMS int64 } type trunkConfig struct { TrunkID string `json:"trunk_id"` Codec string `json:"codec"` DialPrefix string `json:"dial_prefix"` Enabled bool `json:"enabled"` Transport *string `json:"transport"` AuthMode *string `json:"auth_mode"` RegistrationRequired *bool `json:"registration_required"` MaxConcurrentCalls *int64 `json:"max_concurrent_calls"` CallerID string `json:"caller_id"` Schedule callwindow.WeeklySchedule `json:"schedule"` } // validRawCallee checks only what the native dial route can safely represent; // the SaaS call.execute event, not a local list, chooses the business number. func validRawCallee(callee string) bool { if len(callee) == 0 || len(callee) > 32 { return false } for i := 0; i < len(callee); i++ { if callee[i] < '0' || callee[i] > '9' { return false } } return true } // SelectTrunk makes one ordered choice before originate. Its answer is // frozen with the accepted command; callers never silently reselect on an // unknown execution or after a failed originate. func SelectTrunk(snapshot configread.Snapshot, callee string, at time.Time, trunkOccupancy, taskTrunkOccupancy, loadedRevisions map[string]int64) (SelectedTrunk, error) { if !validRawCallee(callee) { return SelectedTrunk{}, ErrCalleeRejected } if snapshot.Task.MaxCallDurationMS <= 0 || snapshot.Task.MaxCallDurationMS > math.MaxInt64/int64(time.Millisecond) { return SelectedTrunk{}, fmt.Errorf("%w: invalid task call duration", ErrRuleInvalid) } var taskSchedule callwindow.TaskSchedule if err := json.Unmarshal(snapshot.Task.Schedule, &taskSchedule); err != nil { return SelectedTrunk{}, fmt.Errorf("%w: task schedule: %v", ErrRuleInvalid, err) } var trunks []trunkConfig if err := json.Unmarshal(snapshot.SIP.Trunks, &trunks); err != nil { return SelectedTrunk{}, fmt.Errorf("%w: SIP trunks: %v", ErrRuleInvalid, err) } byID := make(map[string]trunkConfig, len(trunks)) for _, trunk := range trunks { if trunk.TrunkID == "" { return SelectedTrunk{}, fmt.Errorf("%w: trunk identity missing", ErrRuleInvalid) } if _, exists := byID[trunk.TrunkID]; exists { return SelectedTrunk{}, fmt.Errorf("%w: duplicate trunk identity", ErrRuleInvalid) } byID[trunk.TrunkID] = trunk } limits, err := snapshot.Task.TrunkLimits() if err != nil { return SelectedTrunk{}, fmt.Errorf("%w: %v", ErrRuleInvalid, err) } maxDurationMS := snapshot.Task.MaxCallDurationMS if snapshot.Task.Agent.Mode == "full_ai" { var ai struct { Conversation struct { MaxDurationMS *int64 `json:"max_duration_ms"` } `json:"conversation"` } if err := json.Unmarshal(snapshot.Task.Agent.Raw, &ai); err != nil { return SelectedTrunk{}, fmt.Errorf("%w: AI call duration: %v", ErrRuleInvalid, err) } if ai.Conversation.MaxDurationMS != nil { if *ai.Conversation.MaxDurationMS <= 0 { return SelectedTrunk{}, fmt.Errorf("%w: AI call duration must be positive", ErrRuleInvalid) } if *ai.Conversation.MaxDurationMS < maxDurationMS { maxDurationMS = *ai.Conversation.MaxDurationMS } } } if maxDurationMS > math.MaxInt64/int64(time.Millisecond) { return SelectedTrunk{}, fmt.Errorf("%w: call duration exceeds time range", ErrRuleInvalid) } var invalid error for _, allowed := range snapshot.Task.AllowedTrunks { id := allowed.TrunkID trunk, found := byID[id] if !found { invalid = fmt.Errorf("%w: allowed trunk %q is missing", ErrRuleInvalid, id) continue } if trunk.Transport == nil || *trunk.Transport == "" || trunk.AuthMode == nil || *trunk.AuthMode == "" || trunk.RegistrationRequired == nil || trunk.MaxConcurrentCalls == nil || *trunk.MaxConcurrentCalls <= 0 || trunk.Codec != "PCMA" { invalid = fmt.Errorf("%w: SIP trunk %q has unknown required execution fields", ErrRuleInvalid, id) continue } if trunk.CallerID == "" { invalid = fmt.Errorf("%w: caller ID is missing on trunk %q", ErrRuleInvalid, id) continue } end, err := callwindow.Evaluate(taskSchedule, trunk.Schedule, at) if err != nil { if errors.Is(err, callwindow.ErrWindowClosed) { continue } invalid = fmt.Errorf("%w: schedule of trunk %q: %v", ErrRuleInvalid, id, err) continue } if !trunk.Enabled || limits[id] == 0 || taskTrunkOccupancy[id] >= limits[id] || loadedRevisions[id] != snapshot.SIP.Revision || trunkOccupancy[id] >= *trunk.MaxConcurrentCalls { continue } deadline := at.Add(time.Duration(maxDurationMS) * time.Millisecond) if end.Before(deadline) { deadline = end } if !deadline.After(at) { continue } return SelectedTrunk{ TrunkID: id, CallerID: trunk.CallerID, Callee: callee, DialedCallee: trunk.DialPrefix + callee, Deadline: deadline, MaxCallDurationMS: maxDurationMS, }, nil } if invalid != nil { return SelectedTrunk{}, invalid } return SelectedTrunk{}, ErrRuleWait }