119 lines
4.7 KiB
Go
119 lines
4.7 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/agent"
|
|
"git.ipao.vip/rogee/go-sip/internal/ai"
|
|
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
|
)
|
|
|
|
var ErrApprovedMockDeliveryUnavailable = errors.New("approved Mock call requires a per-call recording delivery")
|
|
|
|
// ApprovedRecordedMockCall joins the approved callflow with bounded in-memory
|
|
// capture and one final delivery. The synthetic input and script are explicit
|
|
// test fixtures, not SaaS AI settings or evidence of a real SIP/provider call.
|
|
// A new instance and RecordingDelivery belong to each call.
|
|
type ApprovedRecordedMockCall struct {
|
|
InboundPCM16 []byte
|
|
Script ai.ApprovedMockScript
|
|
MaxWAVBytes int64
|
|
ExpectedRecording bool
|
|
Outcome string
|
|
ReasonMessage string
|
|
ReportTimeout time.Duration
|
|
Delivery *agent.RecordingDelivery
|
|
}
|
|
|
|
// Prepare validates the per-call synthetic media, approved AI settings and
|
|
// recording destination before the Agent can acknowledge an originate. It has
|
|
// no outbound or durable side effects. The returned function stays synchronous
|
|
// until the Mock media flow has ended and hangup has completed.
|
|
func (r *ApprovedRecordedMockCall) Prepare(approved ApprovedExecution) (func(context.Context) error, error) {
|
|
if r == nil || r.Delivery == nil || r.ReportTimeout <= 0 {
|
|
return nil, ErrApprovedMockDeliveryUnavailable
|
|
}
|
|
if approved.DispatcherID == "" || approved.TenantID <= 0 || approved.SourceEventID == "" || approved.CallID != approved.SourceEventID ||
|
|
approved.TaskID == "" || approved.Callee == "" || approved.SelectedTrunkID == "" || r.MaxWAVBytes <= 44 || strings.TrimSpace(r.ReasonMessage) == "" {
|
|
return nil, errors.New("approved Mock call identity, media limit or observed reason is incomplete")
|
|
}
|
|
if r.Outcome != "answered" && r.Outcome != "no_answer" && r.Outcome != "failed" {
|
|
return nil, errors.New("approved Mock call outcome is invalid")
|
|
}
|
|
if r.Delivery.Call.Client == nil || r.Delivery.Call.Session == nil || r.Delivery.Call.DispatcherID != approved.DispatcherID ||
|
|
r.Delivery.Call.TenantID != approved.TenantID || r.Delivery.Call.SourceEventID != approved.SourceEventID {
|
|
return nil, ErrApprovedMockDeliveryUnavailable
|
|
}
|
|
if r.ExpectedRecording {
|
|
if r.Delivery.Recovery == nil || strings.TrimSpace(r.Delivery.Recovery.Root) == "" {
|
|
return nil, ErrApprovedMockDeliveryUnavailable
|
|
}
|
|
info, err := os.Stat(r.Delivery.Recovery.Root)
|
|
if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 {
|
|
return nil, ErrApprovedMockDeliveryUnavailable
|
|
}
|
|
}
|
|
media, err := callflow.NewRecordingSession(callflow.NewMemorySession(r.InboundPCM16), r.MaxWAVBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The isolated memory session has no external SIP channel to hang up.
|
|
hangup := func(context.Context) error { return nil }
|
|
pipeline, err := ai.NewApprovedMockPipeline(approved.AI, r.Script, hangup)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outcome, reason, expected := r.Outcome, r.ReasonMessage, r.ExpectedRecording
|
|
delivery, timeout := r.Delivery, r.ReportTimeout
|
|
return func(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("approved Mock call requires a context")
|
|
}
|
|
startedAt := time.Now().UTC()
|
|
observed, runErr := RunApprovedCall(ctx, approved, media, hangup, pipeline)
|
|
endedAt := time.Now().UTC()
|
|
resultOutcome, resultReason := outcome, reason
|
|
if runErr != nil {
|
|
resultOutcome, resultReason = "failed", "isolated Mock media did not complete"
|
|
}
|
|
payload, err := callflow.FinalResultPayload(callflow.FinalCallFacts{
|
|
TaskID: approved.TaskID,
|
|
Callee: approved.Callee, TrunkID: approved.SelectedTrunkID,
|
|
StartedAt: startedAt, EndedAt: endedAt, Outcome: resultOutcome, ReasonMessage: resultReason,
|
|
}, observed)
|
|
if err != nil {
|
|
return errors.Join(runErr, err)
|
|
}
|
|
completed := agent.CompletedRecording{ResultPayload: payload, Expected: expected}
|
|
if expected {
|
|
wav, durationMS, captureErr := media.WAV()
|
|
completed.CaptureError = captureErr
|
|
if captureErr == nil {
|
|
completed.RecordingID = "recording-" + approved.SourceEventID
|
|
completed.UploadID = "upload-" + approved.SourceEventID
|
|
completed.WAV, completed.DurationMS = wav, durationMS
|
|
}
|
|
}
|
|
reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
|
defer cancel()
|
|
return errors.Join(runErr, delivery.Complete(reportCtx, completed))
|
|
}, nil
|
|
}
|
|
|
|
// Run is a direct local Mock entry. Agent dispatch uses Prepare so invalid
|
|
// per-call configuration is rejected before the execute acknowledgment.
|
|
func (r *ApprovedRecordedMockCall) Run(ctx context.Context, approved ApprovedExecution) error {
|
|
if ctx == nil {
|
|
return errors.New("approved Mock call requires a context")
|
|
}
|
|
prepared, err := r.Prepare(approved)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return prepared(ctx)
|
|
}
|