Files
go-sip/internal/rpc/approved_recorded_mock.go
T

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