202 lines
7.2 KiB
Go
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
|
|
}
|