package callmemory import ( "context" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/identity" "errors" "net/url" "regexp" "sort" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/volcengine/ve-tos-golang-sdk/v2/tos" "github.com/volcengine/ve-tos-golang-sdk/v2/tos/enum" ) // Only metadata and locally signed URLs pass through this service. There is // deliberately no GetObject operation or audio proxy in this interface. type RecordingStore interface { List(context.Context, string, string) ([]CloudRecording, error) Sign(context.Context, string, string, int64) (string, error) PlaybackReady() bool } type CloudRecording struct { ID string `json:"id"` Speaker string `json:"speaker"` Duration int64 `json:"duration_seconds"` Bytes int64 `json:"bytes"` Key string `json:"-"` } type TOSRecordings struct { client *tos.ClientV2 bucket string playbackEndpoint string } var recordingDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) var recordingSuffix = regexp.MustCompile(`^[0-9]{13}_([0-9]{6})_[A-Za-z0-9]+\.(?:mp4|mp3)$`) func NewTOSRecordings(bucket, region, ak, sk, token string) (*TOSRecordings, error) { if !regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`).MatchString(bucket) || !regexp.MustCompile(`^[a-z]{2}-[a-z]+[0-9]*$`).MatchString(region) || ak == "" || sk == "" { return nil, errors.New("invalid TOS recording configuration") } credentials := tos.NewStaticCredentials(ak, sk) if token != "" { credentials.WithSecurityToken(token) } client, err := tos.NewClientV2("https://tos-"+region+".volces.com", tos.WithRegion(region), tos.WithCredentials(credentials), tos.WithMaxRetryCount(0)) if err != nil { return nil, err } return &TOSRecordings{client: client, bucket: bucket}, nil } func (s *Service) ConfigureRecordings(store RecordingStore) { s.recordings = store } // TOS default domains force attachment responses. Playback requires a bound // HTTPS custom domain; signing still uses the same private bucket credentials. func (s *TOSRecordings) ConfigurePlayback(endpoint string) error { if endpoint == "" { return nil } u, e := url.Parse(endpoint) if e != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Port() != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { return errors.New("invalid recording playback domain") } s.playbackEndpoint = "https://" + u.Host return nil } func (s *TOSRecordings) PlaybackReady() bool { return s.playbackEndpoint != "" } func recordingPrefixes(device, bot string) ([]string, error) { if !recordingDevice.MatchString(device) || !isRTCAgentUser(bot) { return nil, errors.New("unverified recording identity") } // Hardware gateway identity, corroborated by the authenticated subtitle UID. room := "aibotrtc_OPUS_" + device + "_" + strings.TrimPrefix(bot, "bot_") return []string{device + "_" + room + "_" + device + "_C_", bot + "_" + room + "_" + bot + "_C_"}, nil } func parseRecording(key, prefix, speaker string, size int64) (CloudRecording, bool) { if !strings.HasPrefix(key, prefix) { return CloudRecording{}, false } parts := recordingSuffix.FindStringSubmatch(strings.TrimPrefix(key, prefix)) if len(parts) != 2 || size <= 0 { return CloudRecording{}, false } duration, _ := strconv.ParseInt(parts[1], 10, 64) return CloudRecording{ID: hash([]byte(key)), Key: key, Speaker: speaker, Duration: duration, Bytes: size}, true } func (s *TOSRecordings) List(ctx context.Context, device, bot string) ([]CloudRecording, error) { prefixes, err := recordingPrefixes(device, bot) if err != nil { return nil, err } items := []CloudRecording{} room := "aibotrtc_OPUS_" + device + "_" + strings.TrimPrefix(bot, "bot_") prefixes = append([]string{mixedPrefix(room)}, prefixes...) for i, prefix := range prefixes { cursor := "" for page := 0; ; page++ { if page >= 20 { return nil, errors.New("recording listing limit exceeded") } out, e := s.client.ListObjectsType2(ctx, &tos.ListObjectsType2Input{Bucket: s.bucket, Prefix: prefix, MaxKeys: 100, ContinuationToken: cursor, ListOnlyOnce: true}) if e != nil { return nil, e } for _, object := range out.Contents { if item, ok := parseRecording(object.Key, prefix, []string{"DIALOG", "USER", "ASSISTANT"}[i], object.Size); ok { items = append(items, item) } } if !out.IsTruncated { break } if out.NextContinuationToken == "" || out.NextContinuationToken == cursor { return nil, errors.New("invalid recording pagination") } cursor = out.NextContinuationToken } if i == 0 && len(items) > 0 { break } // Prefer the complete conversation over separate tracks. } sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) return items, nil } func (s *TOSRecordings) Sign(ctx context.Context, key, action string, seconds int64) (string, error) { if action != "PLAY" && action != "DOWNLOAD" { return "", errors.New("invalid recording action") } disposition := "inline" if action == "DOWNLOAD" { disposition = "attachment" } mimeType, filename := "audio/mp4", "conversation.mp4" if strings.HasSuffix(key, ".mp3") { mimeType = "audio/mpeg" filename = "conversation.mp3" } in := &tos.PreSignedURLInput{HTTPMethod: enum.HttpMethodGet, Bucket: s.bucket, Key: key, Expires: seconds, Query: map[string]string{"response-content-type": mimeType, "response-content-disposition": disposition + `; filename="` + filename + `"`}} if action == "PLAY" { if !s.PlaybackReady() { return "", errors.New("recording playback domain required") } custom := true in.IsCustomDomain = &custom in.AlternativeEndpoint = s.playbackEndpoint } out, e := s.client.PreSignedURL(in) if e != nil { return "", e } return out.SignedUrl, nil } type recordingInput struct { ElderID string `json:"elder_id"` SessionID string `json:"business_session_id"` RecordingID string `json:"recording_id,omitempty"` Action string `json:"action,omitempty"` } func (s *Service) callRecordings(ctx context.Context, tx pgx.Tx, p identity.Principal, op string, raw []byte) (any, error) { var in recordingInput if e := StrictJSON(raw, &in); e != nil { return nil, e } if !validUUID(in.SessionID) { return nil, fault(400, "INVALID_CALL_ID") } if op == "calls/recording-access" && (len(in.RecordingID) != 64 || !oneOf(in.Action, "PLAY", "DOWNLOAD")) { return nil, fault(400, "INVALID_RECORDING_REQUEST") } a, e := ResolveAccess(ctx, tx, p, in.ElderID, false) if e != nil { return nil, e } if !a.Sensitive { return nil, fault(403, "RECORDING_ACCESS_DENIED") } var device, bot, publicUser string var consent, play, download, mixed bool var playUntil, downloadUntil *time.Time e = tx.QueryRow(ctx, `SELECT COALESCE(c.budget_snapshot->>'device_name',''),COALESCE(c.budget_snapshot->>'rtc_agent_user_id',''),ua.public_id::text,COALESCE(c.budget_snapshot->>'mixed_recording','false')='true', EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=c.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='raw_audio_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())) AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.subject_id=c.subject_id AND pr.request_type='DELETE'), (SELECT max(expires_at) FROM recording_access_grant WHERE tenant_id=c.tenant_id AND subject_id=c.subject_id AND grantee_user_id=ua.id AND permission='PLAY' AND revoked_at IS NULL AND expires_at>now()), (SELECT max(expires_at) FROM recording_access_grant WHERE tenant_id=c.tenant_id AND subject_id=c.subject_id AND grantee_user_id=ua.id AND permission='DOWNLOAD' AND revoked_at IS NULL AND expires_at>now()) FROM call_session c JOIN user_account ua ON ua.id=$4::uuid AND ua.tenant_id=c.tenant_id WHERE c.id=$1::uuid AND c.subject_id=$2::uuid AND c.tenant_id=$3::uuid`, in.SessionID, a.Subject, a.Tenant, a.User).Scan(&device, &bot, &publicUser, &mixed, &consent, &playUntil, &downloadUntil) if errors.Is(e, pgx.ErrNoRows) { return nil, fault(404, "CALL_NOT_FOUND") } if e != nil { return nil, e } play = consent && playUntil != nil download = consent && downloadUntil != nil playbackReady := s.recordings != nil && s.recordings.PlaybackReady() result := map[string]any{"items": []CloudRecording{}, "can_play": play, "can_download": download, "can_manage": a.Manage, "requester_id": publicUser, "consent": consent, "state": "READY", "playback_ready": playbackReady} result["mixed"] = mixed if op == "calls/recording-access" && in.Action == "PLAY" && !playbackReady { return nil, fault(409, "RECORDING_PLAYBACK_NOT_CONFIGURED") } if op == "calls/recording-access" && ((!play && in.Action == "PLAY") || (!download && in.Action == "DOWNLOAD")) { return nil, fault(403, "RECORDING_ACCESS_DENIED") } if !consent { result["state"] = "CONSENT_REQUIRED" return result, nil } if s.recordings == nil { result["state"] = "NOT_CONFIGURED" if op == "calls/recording-access" { return nil, fault(503, "RECORDING_NOT_CONFIGURED") } return result, nil } if _, e = recordingPrefixes(device, bot); e != nil { result["state"] = "IDENTITY_PENDING" if op == "calls/recording-access" { return nil, fault(404, "RECORDING_NOT_FOUND") } return result, nil } items, e := s.recordings.List(ctx, device, bot) if e != nil { return nil, fault(503, "RECORDING_STORAGE_UNAVAILABLE") } if mixed { complete := []CloudRecording{} for _, item := range items { if item.Speaker == "DIALOG" { complete = append(complete, item) } } items = complete } if op == "calls/recordings" { result["items"] = items return result, nil } for _, item := range items { if item.ID == in.RecordingID { until := playUntil if in.Action == "DOWNLOAD" { until = downloadUntil } seconds := int64(300) if remaining := int64(until.Sub(s.now()).Seconds()); remaining < seconds { seconds = remaining } // Never issue an address that outlives its consent or recording grant. var consentUntil *time.Time e = tx.QueryRow(ctx, `SELECT CASE WHEN bool_or(expires_at IS NULL) THEN NULL ELSE max(expires_at) END FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='raw_audio_storage' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now())`, a.Tenant, a.Subject).Scan(&consentUntil) if e != nil { return nil, e } if consentUntil != nil { if remaining := int64(consentUntil.Sub(s.now()).Seconds()); remaining < seconds { seconds = remaining } } if seconds < 1 { return nil, fault(403, "RECORDING_ACCESS_DENIED") } url, e := s.recordings.Sign(ctx, item.Key, in.Action, seconds) if e != nil { return nil, fault(503, "RECORDING_STORAGE_UNAVAILABLE") } e = audit.NewRepository().Record(ctx, tx, audit.Event{TenantID: &a.Tenant, ActorType: "user", ActorPublicID: &publicUser, Action: "call.recording." + strings.ToLower(in.Action), ResourceType: "call_session", ResourceID: &in.SessionID, Result: "success", Purpose: "customer conversation review", Detail: map[string]any{"recording_id": item.ID, "expires_seconds": seconds}}) if e != nil { return nil, e } return map[string]any{"access_url": url, "access_until": s.now().Add(time.Duration(seconds) * time.Second)}, nil } } return nil, fault(404, "RECORDING_NOT_FOUND") }