151 lines
5.8 KiB
Go
151 lines
5.8 KiB
Go
package dispatcher
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/callwindow"
|
|
"git.ipao.vip/rogee/go-sip/internal/configread"
|
|
)
|
|
|
|
type dialDecision struct {
|
|
TrunkID string
|
|
CallerID string
|
|
RingTimeoutMS int64
|
|
MaxCallDurationMS int64
|
|
TrunkCapacity int64
|
|
AllowedUntil time.Time
|
|
}
|
|
|
|
type dialTaskConfig struct {
|
|
Status string `json:"status"`
|
|
Schedule callwindow.TaskSchedule `json:"schedule"`
|
|
AllowedTrunkIDs []string `json:"allowed_trunk_ids"`
|
|
CallerProfileID string `json:"caller_profile_id"`
|
|
RoutePolicyID string `json:"route_policy_id"`
|
|
RingTimeoutMS int64 `json:"ring_timeout_ms"`
|
|
MaxCallDurationMS int64 `json:"max_call_duration_ms"`
|
|
Agent struct {
|
|
AgentVersionID string `json:"agent_version_id"`
|
|
AuthorizationExpiresAt string `json:"authorization_expires_at"`
|
|
Config struct {
|
|
Conversation struct {
|
|
MaxDurationMS int64 `json:"max_duration_ms"`
|
|
} `json:"conversation"`
|
|
} `json:"config"`
|
|
} `json:"agent"`
|
|
}
|
|
|
|
type dialSIPConfig struct {
|
|
Trunks []struct {
|
|
Enabled bool `json:"enabled"`
|
|
TrunkID string `json:"trunk_id"`
|
|
MaxConcurrentCalls *int64 `json:"max_concurrent_calls"`
|
|
CallerProfiles []struct {
|
|
CallerProfileID string `json:"caller_profile_id"`
|
|
CallerID string `json:"caller_id"`
|
|
} `json:"caller_profiles"`
|
|
Schedule callwindow.WeeklySchedule `json:"schedule"`
|
|
} `json:"trunks"`
|
|
}
|
|
|
|
// A line is chosen once at admission, in the task's listed order. The second
|
|
// check before origination must use the bound line, never select a replacement.
|
|
func selectDialPolicy(snapshot configread.Snapshot, callee string, at time.Time) (dialDecision, error) {
|
|
return evaluateDialPolicy(snapshot, callee, "", at, true)
|
|
}
|
|
|
|
func checkSelectedDialPolicy(snapshot configread.Snapshot, callee, trunkID string, at time.Time) (dialDecision, error) {
|
|
if trunkID == "" {
|
|
return dialDecision{}, errors.New("accepted execution has no selected trunk")
|
|
}
|
|
return evaluateDialPolicy(snapshot, callee, trunkID, at, false)
|
|
}
|
|
|
|
func evaluateDialPolicy(snapshot configread.Snapshot, callee, selectedTrunk string, at time.Time, newAdmission bool) (dialDecision, error) {
|
|
// The project-wide allowlist is local, never inferred from SaaS or
|
|
// rewritten using a provider prefix.
|
|
if callee != "15003164745" && callee != "15830461047" {
|
|
return dialDecision{}, errors.New("callee is not on the approved outbound whitelist")
|
|
}
|
|
if newAdmission && (snapshot.ExpiresAt.IsZero() || !at.Before(snapshot.ExpiresAt) || snapshot.QuotaValidUntil.IsZero() || !at.Before(snapshot.QuotaValidUntil)) {
|
|
return dialDecision{}, errors.New("configuration or quota is expired or unavailable")
|
|
}
|
|
if len(snapshot.Task) == 0 || len(snapshot.SIP) == 0 {
|
|
return dialDecision{}, errors.New("approved task and SIP snapshots are required")
|
|
}
|
|
var task dialTaskConfig
|
|
if err := json.Unmarshal(snapshot.Task, &task); err != nil {
|
|
return dialDecision{}, fmt.Errorf("decode bound task config: %w", err)
|
|
}
|
|
var sip dialSIPConfig
|
|
if err := json.Unmarshal(snapshot.SIP, &sip); err != nil {
|
|
return dialDecision{}, fmt.Errorf("decode bound SIP config: %w", err)
|
|
}
|
|
if task.Status != "running" || len(task.AllowedTrunkIDs) == 0 || task.CallerProfileID == "" {
|
|
return dialDecision{}, errors.New("task is not authorized for dialing")
|
|
}
|
|
if task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 || task.Agent.Config.Conversation.MaxDurationMS <= 0 {
|
|
return dialDecision{}, errors.New("task or AI call duration is missing")
|
|
}
|
|
authorizationExpiry, err := time.Parse(time.RFC3339, task.Agent.AuthorizationExpiresAt)
|
|
if err != nil || !at.Before(authorizationExpiry) {
|
|
return dialDecision{}, errors.New("Agent authorization expired or invalid")
|
|
}
|
|
if !snapshot.AgentAuthorizationExpiresAt.IsZero() && !snapshot.AgentAuthorizationExpiresAt.Equal(authorizationExpiry) {
|
|
return dialDecision{}, errors.New("Agent authorization does not match the bound task snapshot")
|
|
}
|
|
maxDuration := min(task.MaxCallDurationMS, task.Agent.Config.Conversation.MaxDurationMS)
|
|
if selectedTrunk != "" && !slices.Contains(task.AllowedTrunkIDs, selectedTrunk) {
|
|
return dialDecision{}, errors.New("selected trunk is not permitted by the bound task")
|
|
}
|
|
var lastReason error
|
|
for _, candidate := range task.AllowedTrunkIDs {
|
|
if selectedTrunk != "" && candidate != selectedTrunk {
|
|
continue
|
|
}
|
|
for _, detail := range sip.Trunks {
|
|
if detail.TrunkID != candidate {
|
|
continue
|
|
}
|
|
if !detail.Enabled {
|
|
lastReason = fmt.Errorf("trunk %s is disabled", candidate)
|
|
break
|
|
}
|
|
if detail.MaxConcurrentCalls == nil || *detail.MaxConcurrentCalls <= 0 {
|
|
lastReason = fmt.Errorf("trunk %s has no authorized capacity", candidate)
|
|
break
|
|
}
|
|
var callerID string
|
|
for _, profile := range detail.CallerProfiles {
|
|
if profile.CallerProfileID == task.CallerProfileID {
|
|
callerID = profile.CallerID
|
|
break
|
|
}
|
|
}
|
|
if callerID == "" {
|
|
lastReason = fmt.Errorf("trunk %s has no matching caller identity", candidate)
|
|
break
|
|
}
|
|
until, err := callwindow.Evaluate(task.Schedule, detail.Schedule, at)
|
|
if err != nil {
|
|
lastReason = fmt.Errorf("trunk %s calling window: %w", candidate, err)
|
|
break
|
|
}
|
|
if authorizationExpiry.Before(until) {
|
|
until = authorizationExpiry
|
|
}
|
|
return dialDecision{TrunkID: candidate, CallerID: callerID,
|
|
RingTimeoutMS: task.RingTimeoutMS, MaxCallDurationMS: maxDuration,
|
|
TrunkCapacity: *detail.MaxConcurrentCalls, AllowedUntil: until}, nil
|
|
}
|
|
}
|
|
if lastReason == nil {
|
|
lastReason = errors.New("no authorized task trunk is present in the applied SIP snapshot")
|
|
}
|
|
return dialDecision{}, fmt.Errorf("no eligible trunk: %w", lastReason)
|
|
}
|