package rpc import ( "context" "crypto/sha256" "errors" "fmt" "log" "path" "strconv" "time" agentpb "git.ipao.vip/rogee/go-sip/gen/agent" "git.ipao.vip/rogee/go-sip/internal/oss" "git.ipao.vip/rogee/go-sip/internal/store" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // RecordingServer is the Dispatcher-only endpoint for the approved recording // lifecycle. It does not accept the old upload binding or provide a fallback // when the active Agent session, mTLS peer or original execution is unknown. type RecordingServer struct { agentpb.UnimplementedAgentControlServiceServer Store *store.CurrentStore OSS *oss.Client DispatcherID string TrustedFingerprints map[string]struct{} AuthorizeSession func(*agentpb.RequestMeta) error Now func() time.Time } func (s *RecordingServer) clock() time.Time { if s.Now != nil { return s.Now().UTC() } return time.Now().UTC() } func (s *RecordingServer) authorize(ctx context.Context, meta *agentpb.RequestMeta, dispatcherID string, tenantID int64, sourceEventID string) error { if s == nil || s.Store == nil || s.OSS == nil || s.DispatcherID == "" || len(s.TrustedFingerprints) == 0 || s.AuthorizeSession == nil { return status.Error(codes.FailedPrecondition, "Dispatcher recording authority is not configured") } if dispatcherID == "" || sourceEventID == "" || tenantID <= 0 { return status.Error(codes.InvalidArgument, "recording fact requires original Dispatcher, tenant and call identity") } if dispatcherID != s.DispatcherID { return status.Error(codes.PermissionDenied, "recording fact targets another Dispatcher") } if err := validateDispatcherPeer(ctx, meta, true, s.TrustedFingerprints, nil); err != nil { return err } if err := s.AuthorizeSession(meta); err != nil { log.Printf("recording RPC denied dispatcher=%s event=%s reason=inactive_agent_session", dispatcherID, sourceEventID) return status.Error(codes.PermissionDenied, "Agent session is not active for this Dispatcher") } if err := s.Store.RequireReservedCall(dispatcherID, sourceEventID, tenantID); err != nil { log.Printf("recording RPC denied dispatcher=%s event=%s reason=unbound_tenant_execution", dispatcherID, sourceEventID) return status.Error(codes.FailedPrecondition, "recording source is not the approved tenant execution") } return nil } func recordingObjectKey(prefix, dispatcherID string, tenantID int64, sourceEventID, recordingID string) string { identity := sha256.Sum256([]byte(dispatcherID + "\x00" + strconv.FormatInt(tenantID, 10) + "\x00" + sourceEventID + "\x00" + recordingID)) return path.Join(prefix, "recordings", fmt.Sprintf("%x.wav", identity)) } func (s *RecordingServer) RequestRecordingUpload(ctx context.Context, req *agentpb.RequestRecordingUploadRequest) (*agentpb.RequestRecordingUploadResponse, error) { if err := s.authorize(ctx, req.GetMeta(), req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId()); err != nil { return nil, err } asset := req.GetAsset() if asset == nil || req.GetUploadId() == "" || asset.GetKind() != agentpb.AssetKind_ASSET_KIND_RECORDING || asset.GetExecutionId() != req.GetSourceEventId() || asset.GetCallId() != req.GetSourceEventId() || asset.GetAssetId() == "" || asset.GetFormat() != "wav" || asset.GetChannels() < 1 || asset.GetChannels() > 2 || asset.GetSampleRateHz() != 16000 || asset.GetDurationMs() < 0 || asset.GetSizeBytes() <= 0 || asset.GetChecksumSha256() == "" { return nil, status.Error(codes.InvalidArgument, "recording asset does not match the approved call and media profile") } config := s.OSS.Config() objectKey := recordingObjectKey(config.KeyPrefix, req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId(), asset.GetAssetId()) grant, err := s.OSS.Grant(ctx, req.GetUploadId(), objectKey, asset.GetChecksumSha256(), asset.GetSizeBytes(), s.clock()) if err != nil { log.Printf("recording grant failed dispatcher=%s event=%s stage=presign", req.GetDispatcherId(), req.GetSourceEventId()) return nil, status.Error(codes.FailedPrecondition, "bounded OSS recording grant unavailable") } if grant.GetBucket() != config.Bucket || grant.GetObjectKey() != objectKey || grant.GetUploadId() != req.GetUploadId() || grant.GetRequiredChecksumSha256() != asset.GetChecksumSha256() || grant.GetMaxBytes() != asset.GetSizeBytes() { log.Printf("recording grant failed dispatcher=%s event=%s stage=signed_target_mismatch", req.GetDispatcherId(), req.GetSourceEventId()) return nil, status.Error(codes.Internal, "OSS grant differs from approved original asset") } binding := store.CurrentRecordingGrant{ DispatcherID: req.GetDispatcherId(), SourceEventID: req.GetSourceEventId(), UploadID: req.GetUploadId(), RecordingID: asset.GetAssetId(), Bucket: grant.GetBucket(), ObjectKey: grant.GetObjectKey(), ChecksumSHA256: asset.GetChecksumSha256(), SizeBytes: asset.GetSizeBytes(), Format: asset.GetFormat(), Channels: int(asset.GetChannels()), SampleRateHz: int(asset.GetSampleRateHz()), DurationMS: asset.GetDurationMs(), } _, created, err := s.Store.BindRecordingUpload(binding) if err != nil { log.Printf("recording grant failed dispatcher=%s event=%s stage=bind error_class=%T", req.GetDispatcherId(), req.GetSourceEventId(), err) switch { case errors.Is(err, store.ErrCurrentUploadConflict), errors.Is(err, store.ErrCurrentResultConflict): return nil, status.Error(codes.AlreadyExists, "original recording target cannot be changed") case errors.Is(err, store.ErrCurrentUploadAlreadyConfirmed): return nil, status.Error(codes.FailedPrecondition, "recording is already confirmed; another PUT is forbidden") default: return nil, status.Error(codes.Internal, "original recording target could not be persisted") } } log.Printf("recording grant bound dispatcher=%s event=%s new=%t", req.GetDispatcherId(), req.GetSourceEventId(), created) return &agentpb.RequestRecordingUploadResponse{Grant: grant}, nil } func (s *RecordingServer) ReportCallEnded(ctx context.Context, req *agentpb.ReportCallEndedRequest) (*agentpb.ReportCallEndedResponse, error) { if err := s.authorize(ctx, req.GetMeta(), req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId()); err != nil { return nil, err } if err := s.Store.FinishExecute(req.GetDispatcherId(), req.GetSourceEventId()); err != nil { log.Printf("call end not committed dispatcher=%s event=%s stage=ack_and_release error_class=%T", req.GetDispatcherId(), req.GetSourceEventId(), err) return nil, status.Error(codes.FailedPrecondition, "confirmed call end could not be persisted with its acknowledgment") } log.Printf("call end committed dispatcher=%s event=%s", req.GetDispatcherId(), req.GetSourceEventId()) return &agentpb.ReportCallEndedResponse{Receipt: &agentpb.OperationReceipt{FactId: req.GetSourceEventId(), Result: agentpb.ResultCode_RESULT_CODE_APPLIED, AcceptedAtUnixMs: s.clock().UnixMilli()}}, nil } func (s *RecordingServer) ReportCallResult(ctx context.Context, req *agentpb.ReportCallResultRequest) (*agentpb.ReportCallResultResponse, error) { if err := s.authorize(ctx, req.GetMeta(), req.GetDispatcherId(), req.GetTenantId(), req.GetSourceEventId()); err != nil { return nil, err } if len(req.GetResultPayloadJson()) == 0 { return nil, status.Error(codes.InvalidArgument, "final result payload is required") } var event store.CurrentOutboxEvent var created bool var err error if observation := req.GetUpload(); observation != nil { event, created, err = s.Store.RecordUploadedCallResult(req.GetDispatcherId(), req.GetSourceEventId(), req.GetResultPayloadJson(), store.CurrentUploadProof{ UploadID: observation.GetUploadId(), RecordingID: observation.GetRecordingId(), StatusCode: int(observation.GetPutStatusCode()), SizeBytes: observation.GetSizeBytes(), SHA256: observation.GetChecksumSha256(), }) } else { event, created, err = s.Store.RecordCallResult(req.GetDispatcherId(), req.GetSourceEventId(), req.GetResultPayloadJson()) } if err != nil { log.Printf("final result not committed dispatcher=%s event=%s stage=outbox error_class=%T", req.GetDispatcherId(), req.GetSourceEventId(), err) switch { case errors.Is(err, store.ErrCurrentResultInvalid): return nil, status.Error(codes.InvalidArgument, "final result violates the approved MQ contract") case errors.Is(err, store.ErrCurrentUploadUnverified), errors.Is(err, store.ErrCurrentEndUnconfirmed): return nil, status.Error(codes.FailedPrecondition, "final result requires confirmed call end and original upload outcome") case errors.Is(err, store.ErrCurrentResultConflict): return nil, status.Error(codes.AlreadyExists, "call already has a different final result") default: return nil, status.Error(codes.Internal, "final result could not be persisted") } } checksum := sha256.Sum256(event.Body) log.Printf("final result committed dispatcher=%s event=%s outbox=%s new=%t", req.GetDispatcherId(), req.GetSourceEventId(), event.EventID, created) return &agentpb.ReportCallResultResponse{Receipt: &agentpb.OperationReceipt{FactId: event.EventID, Result: agentpb.ResultCode_RESULT_CODE_ACCEPTED, ContentSha256: fmt.Sprintf("%x", checksum), AcceptedAtUnixMs: s.clock().UnixMilli()}}, nil }