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