Files
go-sip/internal/agent/recording_delivery.go
T

202 lines
7.2 KiB
Go

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
mu sync.Mutex
ended 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
}
// 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
}
result, err := emptyRecordingResult(recording.ResultPayload)
if err != nil {
return err
}
if !recording.Expected {
if len(recording.WAV) != 0 || recording.CaptureError != nil || recording.RecordingID != "" || recording.UploadID != "" || recording.DurationMS != 0 {
return errors.New("call without a recording cannot contain audio or upload identity")
}
return d.report(ctx, recording.ResultPayload, nil)
}
if recording.CaptureError != nil || len(recording.WAV) == 0 {
if len(recording.WAV) != 0 {
return 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 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 err
}
payload, err := json.Marshal(result)
if err != nil {
return err
}
return d.report(ctx, payload, nil)
}
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
}
// 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,
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,
}
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 (d *RecordingDelivery) report(ctx context.Context, payload []byte, proof *agentpb.UploadObservation) error {
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
}
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
}