package agent import ( "bytes" "context" "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "sync" agentpb "git.ipao.vip/rogee/go-sip/gen/agent" ) var ErrRecordingRetryManaged = errors.New("recording delivery already attempted; retry the original result or persisted recovery instead") // CompletedRecording describes one confirmed call and, when expected, the // bounded in-memory WAV created from its actual media. Recording IDs are stable // across retries; ResultPayload has the approved call identity and recording:{}. type CompletedRecording struct { ResultPayload []byte Expected bool RecordingID string UploadID string WAV []byte DurationMS int64 CaptureError error } // RecordingDelivery owns one approved call. In-process retries may report a // confirmed PUT again, but can never repeat that PUT. Failed or uncertain PUTs // are handed to RecordingRecovery, not retried through Complete. type RecordingDelivery struct { Call RecordingClient Recovery *RecordingRecovery Journal *ResultJournal mu sync.Mutex ended bool endJournaled bool attempted bool pending []byte pendingProof *agentpb.UploadObservation } func (d *RecordingDelivery) Complete(ctx context.Context, recording CompletedRecording) error { d.mu.Lock() defer d.mu.Unlock() if d.attempted { return ErrRecordingRetryManaged } // Preserve a valid no-recording result before sending a call-end report // whose response could be lost. Invalid payloads are still reported as // ended, then rejected by the original validation below. if !d.ended && d.Journal != nil && (!recording.Expected || recording.CaptureError != nil || len(recording.WAV) == 0) { if payload, err := noUploadPayload(recording); err == nil { if err := d.Journal.SaveEndPending(d.resultEntry(payload)); err != nil { return err } d.endJournaled = true } } // Release the confirmed call independently of OSS and final-result delivery. if !d.ended { if err := d.Call.ReportEnded(ctx); err != nil { return err } d.ended = true if d.endJournaled { if err := d.Journal.MarkEnded(d.Call.SourceEventID); err != nil { return err } } } if !recording.Expected || recording.CaptureError != nil || len(recording.WAV) == 0 { payload, err := noUploadPayload(recording) if err != nil { return err } return d.report(ctx, payload, nil) } result, err := emptyRecordingResult(recording.ResultPayload) if err != nil { return err } if recording.RecordingID == "" || recording.UploadID == "" || !validMonoWAV(recording.WAV, recording.DurationMS) { return errors.New("recording identity or 16-kHz mono WAV is invalid") } if d.Recovery == nil || strings.TrimSpace(d.Recovery.Root) == "" { return errors.New("private recording recovery directory and upload client are required before PUT") } sum := sha256.Sum256(recording.WAV) digest := hex.EncodeToString(sum[:]) asset := &agentpb.AssetDescriptor{ Kind: agentpb.AssetKind_ASSET_KIND_RECORDING, AssetId: recording.RecordingID, CallId: d.Call.SourceEventID, ExecutionId: d.Call.SourceEventID, Format: "wav", Channels: 1, SampleRateHz: 16000, DurationMs: recording.DurationMS, SizeBytes: int64(len(recording.WAV)), ChecksumSha256: digest, } grant, err := d.Call.RequestUpload(ctx, asset, recording.UploadID) if err != nil { return err } result["recording"], err = json.Marshal(struct { Status string `json:"status"` Bucket string `json:"bucket"` ObjectKey string `json:"object_key"` Format string `json:"format"` Channels int `json:"channels"` SampleRateHz int `json:"sample_rate_hz"` DurationMS int64 `json:"duration_ms"` SizeBytes int64 `json:"size_bytes"` ChecksumSHA256 string `json:"checksum_sha256"` }{"uploaded", grant.Bucket, grant.ObjectKey, "wav", 1, 16000, recording.DurationMS, int64(len(recording.WAV)), digest}) if err != nil { return err } payload, err := json.Marshal(result) if err != nil { return err } // The result is durable before PUT. A crash before its success is // observed leaves this entry blocked, never presumed uploaded. if d.Journal != nil { if err := d.Journal.SavePrepared(d.resultEntry(payload)); err != nil { return err } } // A returned OSS failure needs the original exact result and bytes; no // failure in this path is allowed to fabricate an uploaded result. d.attempted = true uploaded, err := d.Recovery.Upload.UploadBytes(ctx, grant, recording.WAV) if err != nil { var rejected *UploadHTTPError if errors.As(err, &rejected) || errors.Is(err, ErrUploadOutcomeUnknown) { entry := RecordingRecoveryEntry{ CallID: d.Call.SourceEventID, SourceEventID: d.Call.SourceEventID, DispatcherID: d.Call.DispatcherID, TenantID: d.Call.TenantID, RecordingID: recording.RecordingID, UploadID: recording.UploadID, Bucket: grant.Bucket, ObjectKey: grant.ObjectKey, SHA256: digest, SizeBytes: int64(len(recording.WAV)), ResultPayload: payload, } if _, saveErr := d.Recovery.SaveFailure(recording.WAV, entry, err); saveErr != nil { return errors.Join(err, fmt.Errorf("persist failed recording for recovery: %w", saveErr)) } } return err } proof := &agentpb.UploadObservation{ UploadId: recording.UploadID, RecordingId: recording.RecordingID, PutStatusCode: int32(uploaded.StatusCode), SizeBytes: uploaded.SizeBytes, ChecksumSha256: uploaded.SHA256, } if d.Journal != nil { if err := d.Journal.MarkUploaded(d.Call.SourceEventID, proof); err != nil { return fmt.Errorf("persist confirmed upload before reporting result: %w", err) } } return d.report(ctx, payload, proof) } // RetryResult reports only the original result after a confirmed PUT (or a // no-recording result); it never requests another upload or sends audio. func (d *RecordingDelivery) RetryResult(ctx context.Context) error { d.mu.Lock() defer d.mu.Unlock() if len(d.pending) == 0 { return ErrRecordingRetryManaged } return d.report(ctx, d.pending, d.pendingProof) } func noUploadPayload(recording CompletedRecording) ([]byte, error) { result, err := emptyRecordingResult(recording.ResultPayload) if err != nil { return nil, err } if !recording.Expected { if len(recording.WAV) != 0 || recording.CaptureError != nil || recording.RecordingID != "" || recording.UploadID != "" || recording.DurationMS != 0 { return nil, errors.New("call without a recording cannot contain audio or upload identity") } return recording.ResultPayload, nil } if len(recording.WAV) != 0 { return nil, errors.New("recording has both an error and apparently uploadable audio") } var reason string if err := json.Unmarshal(result["reason_message"], &reason); err != nil { return nil, errors.New("failed recording needs the original call reason") } if reason != "" { reason += "; " } reason += "recording generation failed" result["reason_message"], err = json.Marshal(reason) if err != nil { return nil, err } return json.Marshal(result) } func (d *RecordingDelivery) resultEntry(payload []byte) ResultEntry { return ResultEntry{SourceEventID: d.Call.SourceEventID, DispatcherID: d.Call.DispatcherID, TenantID: d.Call.TenantID, Payload: bytes.Clone(payload)} } func (d *RecordingDelivery) report(ctx context.Context, payload []byte, proof *agentpb.UploadObservation) error { if d.Journal != nil && !d.attempted && !d.endJournaled { if err := d.Journal.SaveReady(d.resultEntry(payload)); err != nil { return err } } d.attempted = true d.pending = bytes.Clone(payload) d.pendingProof = proof if _, err := d.Call.ReportFinal(ctx, d.pending, d.pendingProof); err != nil { return err } if d.Journal != nil { if err := d.Journal.Ack(d.Call.SourceEventID); err != nil { return fmt.Errorf("clear accepted final result journal: %w", err) } } d.pending = nil d.pendingProof = nil return nil } func emptyRecordingResult(payload []byte) (map[string]json.RawMessage, error) { var result map[string]json.RawMessage if err := json.Unmarshal(payload, &result); err != nil || result == nil { return nil, errors.New("final call result must be a JSON object") } var recording map[string]json.RawMessage if err := json.Unmarshal(result["recording"], &recording); err != nil || recording == nil || len(recording) != 0 { return nil, errors.New("final call result must start with an empty recording object") } return result, nil } func validMonoWAV(wav []byte, durationMS int64) bool { if len(wav) <= 44 || len(wav)%2 != 0 || uint64(len(wav)) > uint64(^uint32(0))+8 || durationMS < 0 { return false } return string(wav[:4]) == "RIFF" && binary.LittleEndian.Uint32(wav[4:8]) == uint32(len(wav)-8) && string(wav[8:16]) == "WAVEfmt " && binary.LittleEndian.Uint32(wav[16:20]) == 16 && binary.LittleEndian.Uint16(wav[20:22]) == 1 && binary.LittleEndian.Uint16(wav[22:24]) == 1 && binary.LittleEndian.Uint32(wav[24:28]) == 16000 && binary.LittleEndian.Uint32(wav[28:32]) == 32000 && binary.LittleEndian.Uint16(wav[32:34]) == 2 && binary.LittleEndian.Uint16(wav[34:36]) == 16 && string(wav[36:40]) == "data" && binary.LittleEndian.Uint32(wav[40:44]) == uint32(len(wav)-44) && int64(len(wav)-44)*1000/32000 == durationMS }