package main import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/url" "path/filepath" "strings" "time" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/agent" "git.ipao.vip/rogee/go-sip/internal/callruntime" "git.ipao.vip/rogee/go-sip/internal/config" "git.ipao.vip/rogee/go-sip/internal/rpc" ) func uploadCallRecordings(ctx context.Context, cfg config.Config, result callruntime.Result) ([]map[string]any, error) { if strings.TrimSpace(cfg.DispatcherGRPCEndpoint) == "" { return nil, nil } for name, value := range map[string]string{ "AGENT_CALL_TENANT_ID": cfg.CallTenantID, "AGENT_CALL_TENANT_KEY": cfg.CallTenantKey, "AGENT_CALL_TASK_ID": cfg.CallTaskID, "AGENT_CALL_TASK_ITEM_ID": cfg.CallTaskItemID, } { if strings.TrimSpace(value) == "" { return nil, fmt.Errorf("%s is required when Dispatcher OSS upload is enabled", name) } } if result.ChannelID == "" { return nil, errors.New("call result has no channel ID for upload binding") } executionID := cfg.CallExecutionID if executionID == "" { executionID = result.ChannelID } binding := &agentv1.ExecutionBinding{ TenantId: cfg.CallTenantID, TenantKey: cfg.CallTenantKey, ExecutionId: executionID, TaskId: cfg.CallTaskID, TaskItemId: cfg.CallTaskItemID, TaskRevision: 1, CallId: result.ChannelID, AttemptId: result.ChannelID, AgentVersionId: cfg.Version, } client, err := rpc.DialFromFiles(cfg.DispatcherGRPCEndpoint, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, cfg.DispatcherGRPCServerName) if err != nil { return nil, fmt.Errorf("dial Dispatcher gRPC service: %w", err) } defer client.Close() uploader := agent.UploadClient{Now: time.Now} facts := append(append([]callruntime.RecordingFact(nil), result.InboundRecordings...), result.OutboundRecordings...) if len(facts) == 0 { return nil, errors.New("call produced no recordings for OSS upload") } uploaded := make([]map[string]any, 0, len(facts)) for index, recording := range facts { if recording.Path == "" || recording.Bytes <= 0 || recording.SHA256 == "" { return nil, fmt.Errorf("recording %d is missing path, size or checksum", index+1) } assetID := recordingAssetID(recording.Segment, recording.Path, index+1) asset := &agentv1.AssetDescriptor{ Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: assetID, CallId: result.ChannelID, ExecutionId: executionID, Format: "wav", SizeBytes: int64(recording.Bytes), ChecksumSha256: recording.SHA256, Channels: 1, SampleRateHz: 16000, } uploadID := stableUploadID(binding, asset) meta := uploadMeta(cfg, "request", uploadID) grantResponse, err := client.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: uploadID}) if err != nil { return nil, fmt.Errorf("request upload %s: %w", uploadID, err) } if grantResponse == nil || grantResponse.Grant == nil || grantResponse.Receipt == nil || grantResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED { return nil, fmt.Errorf("request upload %s was rejected", uploadID) } parsed, err := url.Parse(grantResponse.Grant.TargetUrl) if err != nil || parsed.Host == "" { return nil, fmt.Errorf("upload %s returned invalid target URL", uploadID) } uploader.AllowedHosts = map[string]struct{}{strings.ToLower(parsed.Host): {}} uploadResult, err := uploader.UploadFile(ctx, grantResponse.Grant, recording.Path) if err != nil { return nil, fmt.Errorf("upload %s data plane: %w", uploadID, err) } if uploadResult.SizeBytes != asset.SizeBytes || !strings.EqualFold(uploadResult.SHA256, asset.ChecksumSha256) { return nil, fmt.Errorf("upload %s local result does not match asset", uploadID) } completeResponse, err := client.CompleteUpload(ctx, &agentv1.CompleteUploadRequest{ Meta: uploadMeta(cfg, "complete", uploadID), Binding: binding, Asset: asset, UploadId: uploadID, UploadedSizeBytes: uploadResult.SizeBytes, UploadedChecksumSha256: uploadResult.SHA256, }) if err != nil { return nil, fmt.Errorf("complete upload %s: %w", uploadID, err) } if completeResponse == nil || completeResponse.Receipt == nil || completeResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || completeResponse.OssId == "" { return nil, fmt.Errorf("complete upload %s was not verified", uploadID) } uploaded = append(uploaded, map[string]any{ "upload_id": uploadID, "asset_id": asset.AssetId, "object_key": grantResponse.Grant.ObjectKey, "oss_id": completeResponse.OssId, "bytes": uploadResult.SizeBytes, "sha256": uploadResult.SHA256, "status_code": uploadResult.StatusCode, }) } return uploaded, nil } func recordingAssetID(segment, path string, index int) string { raw := segment if raw == "" { raw = fmt.Sprintf("segment-%d", index) } raw += "-" + filepath.Base(path) var builder strings.Builder for _, r := range raw { if r == '/' || r == '\\' || r == ' ' || r == '\t' || r == '\n' || r == '\r' { builder.WriteByte('-') continue } builder.WriteRune(r) } assetID := strings.Trim(builder.String(), "-") if assetID == "" { assetID = fmt.Sprintf("segment-%d", index) } if len([]byte(assetID)) > 120 { digest := sha256.Sum256([]byte(assetID)) assetID = "recording-" + hex.EncodeToString(digest[:16]) } return assetID } func stableUploadID(binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor) string { value := binding.ExecutionId + "\x00" + asset.AssetId + "\x00" + asset.ChecksumSha256 digest := sha256.Sum256([]byte(value)) return "upload-" + hex.EncodeToString(digest[:16]) } func uploadMeta(cfg config.Config, phase, uploadID string) *agentv1.RequestMeta { operationID := "recording-" + phase + "-" + uploadID return &agentv1.RequestMeta{ ProtocolVersion: "agent.v1", RequestId: operationID, TraceId: operationID, OperationId: operationID, IdempotencyKey: operationID, AgentId: cfg.AgentID, CellId: cfg.CellID, } }