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

239 lines
8.7 KiB
Go

package dispatcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/store"
)
// CurrentCallSpec is a frozen instruction handed to one Agent. Its Snapshot
// includes immutable AI parameters and provider credentials; never log it.
type CurrentCallSpec struct {
DispatcherID string
EventID string
TenantID int64
TaskID string
CallerProfileID string
CallerID string
Callee string
DialedCallee string
TrunkID string
RingTimeoutMS int64
MaxCallDurationMS int64
Deadline time.Time
Snapshot configread.CurrentSnapshot
}
type CurrentOriginator interface {
LoadedTrunks(context.Context) (map[string]int64, error)
Originate(context.Context, CurrentCallSpec) error
}
type CurrentPublisher interface {
Publish(context.Context, string, string, []byte) error
}
type CurrentExecuteController struct {
DispatcherID string
Store *store.CurrentStore
Originator CurrentOriginator
Publisher CurrentPublisher
Now func() time.Time
}
func (c *CurrentExecuteController) validate() error {
if c == nil || c.DispatcherID == "" || c.Store == nil || c.Originator == nil || c.Publisher == nil || c.Now == nil {
return errors.New("current execution requires Dispatcher identity, durable store, originator, publisher, and clock")
}
return nil
}
// ProcessExecute persists the inbound identity before the transport ACK.
// Its business acknowledgment is a separate durable outbox event, written
// only after originate was actually dispatched or an individual number was
// definitively rejected.
func (c *CurrentExecuteController) ProcessExecute(ctx context.Context, body []byte) error {
if err := c.validate(); err != nil {
return err
}
if err := contract.ValidateCurrent("mq", body); err != nil {
return fmt.Errorf("invalid incoming call.execute: %w", err)
}
var event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
IssuedAt string `json:"issued_at"`
Payload struct {
TaskID string `json:"task_id"`
Callee string `json:"callee"`
} `json:"payload"`
}
if err := json.Unmarshal(body, &event); err != nil {
return fmt.Errorf("decode call.execute: %w", err)
}
if event.EventType != "call.execute" || event.Payload.TaskID == "" || event.Payload.Callee == "" || event.DispatcherID != c.DispatcherID {
return errors.New("call instruction event type, task, or Dispatcher owner mismatch")
}
cmd := store.CurrentExecuteCommand{
DispatcherID: event.DispatcherID, EventID: event.EventID,
TenantID: event.TenantID, TaskID: event.Payload.TaskID,
Callee: event.Payload.Callee, IssuedAt: event.IssuedAt,
}
stored, _, err := c.Store.RecordExecute(cmd)
if errors.Is(err, store.ErrCurrentStopped) {
// The stopped task never accepted this old queued command. RabbitMQ
// may ACK it without creating an execution or a per-call result.
return nil
}
if err != nil {
return fmt.Errorf("durable inbound call %q: %w", cmd.EventID, err)
}
if stored.Status != "pending" {
return nil
}
if _, whitelisted := currentCalleeWhitelist[cmd.Callee]; !whitelisted {
if err := c.Store.RejectExecute(cmd.DispatcherID, cmd.EventID, ErrCurrentCalleeRejected.Error()); err != nil {
return fmt.Errorf("persist rejected call %q: %w", cmd.EventID, err)
}
return nil
}
return c.dispatchPending(ctx, cmd)
}
// ProcessPending reevaluates only durable, never-originated commands. This is
// used after rule/config changes; dispatching and unknown calls are excluded.
func (c *CurrentExecuteController) ProcessPending(ctx context.Context) error {
if err := c.validate(); err != nil {
return err
}
commands, err := c.Store.ListPendingExecute(c.DispatcherID)
if err != nil {
return err
}
var failures []error
for _, cmd := range commands {
if err := c.dispatchPending(ctx, cmd); err != nil {
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
func (c *CurrentExecuteController) dispatchPending(ctx context.Context, cmd store.CurrentExecuteCommand) error {
at := c.Now()
issued, err := time.Parse(time.RFC3339Nano, cmd.IssuedAt)
if err != nil {
return fmt.Errorf("persisted command %q has invalid issued_at: %w", cmd.EventID, err)
}
if issued.After(at) {
return nil
}
snapshot, err := c.Store.ReadSnapshot(cmd.DispatcherID, cmd.TenantID, cmd.TaskID)
if err != nil {
return fmt.Errorf("load authorized task %q: %w", cmd.TaskID, err)
}
if err := validateCurrentAISnapshot(snapshot); err != nil {
return errors.Join(err, c.Store.CloseAdmission(c.DispatcherID))
}
loaded, err := c.Originator.LoadedTrunks(ctx)
if err != nil {
return fmt.Errorf("read applied Agent/Asterisk SIP revisions: %w", err)
}
occupied, err := c.Store.TrunkOccupancy(c.DispatcherID)
if err != nil {
return err
}
choice, err := SelectCurrentTrunk(snapshot, cmd.Callee, at, occupied, loaded)
if errors.Is(err, ErrCurrentRuleWait) {
return nil
}
if err != nil {
return fmt.Errorf("task %q rule validation: %w", cmd.TaskID, err)
}
reservation := store.CurrentCallReservation{
TrunkID: choice.TrunkID, SIPRevision: snapshot.SIP.Revision,
CallerID: choice.CallerID, DialedCallee: choice.DialedCallee,
Deadline: choice.Deadline,
}
if err := c.Store.ReserveExecute(cmd.DispatcherID, cmd.EventID, reservation, at); err != nil {
if errors.Is(err, store.ErrCurrentCapacity) || errors.Is(err, store.ErrCurrentNotReady) || errors.Is(err, store.ErrCurrentAlreadyStarted) {
return nil
}
return fmt.Errorf("reserve call %q: %w", cmd.EventID, err)
}
// A reservation is not permission to dial after a clock/window/loaded SIP
// change. Recheck the fixed selection immediately before the only RPC.
actualAt := c.Now()
freshLoaded, err := c.Originator.LoadedTrunks(ctx)
if err != nil {
cancelErr := c.Store.CancelReservationBeforeOrigin(cmd.DispatcherID, cmd.EventID)
return errors.Join(fmt.Errorf("recheck applied SIP before originate: %w", err), cancelErr)
}
fresh, ruleErr := SelectCurrentTrunk(snapshot, cmd.Callee, actualAt, occupied, freshLoaded)
if !actualAt.Before(choice.Deadline) || ruleErr != nil || fresh.TrunkID != choice.TrunkID || fresh.CallerID != choice.CallerID {
cancelErr := c.Store.CancelReservationBeforeOrigin(cmd.DispatcherID, cmd.EventID)
if cancelErr != nil {
return fmt.Errorf("cancel expired/changed reservation: %w", cancelErr)
}
if ruleErr != nil && !errors.Is(ruleErr, ErrCurrentRuleWait) {
return fmt.Errorf("pre-dial task %q rule: %w", cmd.TaskID, ruleErr)
}
return nil
}
admitted, err := c.Store.CanAdmit(cmd.DispatcherID, cmd.TenantID, cmd.TaskID)
if err != nil || !admitted {
cancelErr := c.Store.CancelReservationBeforeOrigin(cmd.DispatcherID, cmd.EventID)
if err != nil {
return errors.Join(fmt.Errorf("recheck task admission before originate: %w", err), cancelErr)
}
return cancelErr
}
spec := CurrentCallSpec{
DispatcherID: cmd.DispatcherID, EventID: cmd.EventID, TenantID: cmd.TenantID,
TaskID: cmd.TaskID, CallerProfileID: snapshot.Task.CallerProfileID,
CallerID: choice.CallerID, Callee: cmd.Callee, DialedCallee: choice.DialedCallee,
TrunkID: choice.TrunkID, RingTimeoutMS: snapshot.Task.RingTimeoutMS,
MaxCallDurationMS: choice.MaxCallDurationMS,
Deadline: choice.Deadline, Snapshot: snapshot,
}
if err := c.Originator.Originate(ctx, spec); err != nil {
unknownErr := c.Store.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID)
return errors.Join(fmt.Errorf("originator outcome unknown for call %q: %w", cmd.EventID, err), unknownErr)
}
if err := c.Store.MarkExecuteDispatched(cmd.DispatcherID, cmd.EventID); err != nil {
return fmt.Errorf("originated call %q has no durable acknowledgment: %w", cmd.EventID, err)
}
return nil
}
// FlushOutbox uses the same event identity/body on every retry. Confirming a
// bound queue is not a SaaS application receipt; confirmed rows remain stored.
func (c *CurrentExecuteController) FlushOutbox(ctx context.Context) error {
if err := c.validate(); err != nil {
return err
}
pending, err := c.Store.ListPendingOutbox(c.DispatcherID)
if err != nil {
return err
}
var failures []error
for _, event := range pending {
if err := c.Publisher.Publish(ctx, "agent-call.saas.v1", event.RoutingKey, event.Body); err != nil {
failures = append(failures, fmt.Errorf("publish event %q to SaaS queue: %w", event.EventID, err))
continue
}
if err := c.Store.MarkOutboxConfirmed(c.DispatcherID, event.EventID); err != nil {
failures = append(failures, fmt.Errorf("persist queue confirmation for %q: %w", event.EventID, err))
}
}
return errors.Join(failures...)
}