Files
go-sip/internal/dispatcher/dial_policy.go
T

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)
}