239 lines
8.7 KiB
Go
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...)
|
|
}
|