package callmemory import ( "context" "data-terminal-cloud/internal/deviceauth" "errors" "github.com/jackc/pgx/v5" rtc "github.com/volcengine/volc-sdk-golang/service/rtc/v20231101" "strings" "time" ) type MixedRecorder interface { Start(context.Context, string, string, int32) error Stop(context.Context, string, string) error } type VolcanoMixedRecorder struct { client *rtc.Rtc account, bucket string } func NewMixedRecorder(ak, sk, token, account, bucket string) *VolcanoMixedRecorder { c := rtc.NewInstance() c.Client.SetAccessKey(ak) c.Client.SetSecretKey(sk) if token != "" { c.Client.SetSessionToken(token) } return &VolcanoMixedRecorder{client: c, account: account, bucket: bucket} } func (s *Service) ConfigureMixedRecorder(r MixedRecorder) { s.mixedRecorder = r } func mixedTask(room string) string { return "dialog_" + hash([]byte(room))[:24] } func mixedPrefix(room string) string { return mixedTask(room) + "_" + room + "_" } func (r *VolcanoMixedRecorder) Start(ctx context.Context, app, room string, maxSeconds int32) error { zero, one, idle := int32(0), int32(1), int32(30) format := "MP3" out, status, e := r.client.StartRecord(ctx, &rtc.StartRecordBody{AppID: app, RoomID: room, TaskID: mixedTask(room), RecordMode: &zero, Control: &rtc.StartRecordBodyControl{MediaType: &one, MaxIdleTime: &idle, MaxRecordTime: &maxSeconds}, FileFormatConfig: &rtc.StartRecordBodyFileFormatConfig{FileFormat: []*string{&format}}, StorageConfig: rtc.StartRecordBodyStorageConfig{Type: &zero, TosConfig: &rtc.StartRecordBodyStorageConfigTosConfig{AccountID: r.account, Bucket: r.bucket, Region: &zero}}}) if e != nil || status != 200 || out == nil || out.ResponseMetadata.Error != nil || out.Result == nil { return errors.New("cloud mixed recording start failed") } // Volcano's same-App/Room/Task retry is idempotent, including a lost response. // StartRecord is asynchronous: don't tell the device to join/publish until // the cloud task reports RUNNING, otherwise its opening words may be missed. for { state, status, err := r.client.GetRecordTask(ctx, &rtc.GetRecordTaskQuery{AppID: app, RoomID: room, TaskID: mixedTask(room)}) if err == nil && status == 200 && state != nil && state.ResponseMetadata.Error == nil && state.Result != nil && state.Result.RecordTask != nil && state.Result.RecordTask.Status != nil { switch *state.Result.RecordTask.Status { case 2: return nil case 3, 4: return errors.New("cloud recording stopped before media") } } select { case <-ctx.Done(): return errors.New("cloud recording readiness timeout") case <-time.After(150 * time.Millisecond): } } } func (r *VolcanoMixedRecorder) Stop(ctx context.Context, app, room string) error { out, status, e := r.client.StopRecord(ctx, &rtc.StopRecordBody{AppID: app, RoomID: room, TaskID: mixedTask(room)}) if e != nil || status != 200 || out == nil || out.ResponseMetadata.Error != nil { return errors.New("cloud mixed recording stop failed") } return nil } func (s *Service) prepareMixedRecording(ctx context.Context, tx pgx.Tx, d deviceauth.Identity, in DirectInput) (any, error) { if s.mixedRecorder == nil { return map[string]any{"recording": "DISABLED"}, nil } var device, app, previous string var created, expires time.Time var consentExpires *time.Time var allowed bool e := tx.QueryRow(ctx, `SELECT c.budget_snapshot->>'device_name',j.provider_rtc_app_id,COALESCE(c.budget_snapshot->>'rtc_room_id',''),c.created_at,c.expires_at, EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.tenant_id=c.tenant_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 p WHERE p.subject_id=c.subject_id AND p.request_type='DELETE'), (SELECT CASE WHEN bool_or(cr.expires_at IS NULL) THEN NULL ELSE max(cr.expires_at) END FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.tenant_id=c.tenant_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())) FROM call_session c JOIN device dev ON dev.id=c.device_id AND dev.binding_generation=c.binding_generation AND dev.status='registered' JOIN device_binding b ON b.device_id=c.device_id AND b.subject_id=c.subject_id AND b.generation=c.binding_generation AND b.unbound_at IS NULL JOIN subject_profile sp ON sp.id=c.subject_id AND sp.status='active' JOIN device_activation_job j ON j.device_id=c.device_id AND j.binding_generation=c.binding_generation WHERE c.id=$1::uuid AND c.device_id=$2::uuid AND c.tenant_id=$3::uuid AND c.binding_generation=$4 AND c.ended_at IS NULL AND c.expires_at>now() AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1' FOR UPDATE OF c`, in.SessionID, d.ID, d.TenantID, d.BindingGeneration).Scan(&device, &app, &previous, &created, &expires, &allowed, &consentExpires) if errors.Is(e, pgx.ErrNoRows) { return nil, fault(409, "CHAT_ENDED") } if e != nil { return nil, e } if !allowed { return map[string]any{"recording": "NOT_AUTHORIZED"}, nil } prefix := "aibotrtc_OPUS_" + device + "_" stamp := strings.TrimPrefix(in.RoomID, prefix) if app == "" || in.RTCAppID != app || in.RTCUserID != device || !strings.HasPrefix(in.RoomID, prefix) || !isRTCAgentUser("bot_"+stamp) { return nil, fault(400, "RECORDING_ROOM_MISMATCH") } if previous != "" && previous != in.RoomID { return nil, fault(409, "RECORDING_IDENTITY_CONFLICT") } // This hardware gateway uses China local wall-clock timestamps in room IDs. at, err := time.ParseInLocation("20060102150405.000", stamp[:14]+"."+stamp[14:], time.FixedZone("CST", 8*3600)) if err != nil || at.Before(created.Add(-time.Minute)) || at.After(s.now().Add(time.Minute)) { return nil, fault(400, "RECORDING_ROOM_MISMATCH") } var reused bool if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE device_id=$1::uuid AND id<>$2::uuid AND (budget_snapshot->>'rtc_room_id'=$3 OR budget_snapshot->>'rtc_agent_user_id'=$4))`, d.ID, in.SessionID, in.RoomID, "bot_"+stamp).Scan(&reused); e != nil { return nil, e } if reused { return nil, fault(409, "RECORDING_IDENTITY_CONFLICT") } timeout := int32(expires.Sub(s.now()).Seconds()) if consentExpires != nil && consentExpires.Before(expires) { timeout = int32(consentExpires.Sub(s.now()).Seconds()) } if timeout < 10 { return nil, fault(409, "CHAT_ENDED") } if timeout > 3600 { timeout = 3600 } providerCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if e = s.mixedRecorder.Start(providerCtx, app, in.RoomID, timeout); e != nil { return nil, fault(503, "RECORDING_START_UNAVAILABLE") } _, e = tx.Exec(ctx, `UPDATE call_session SET budget_snapshot=budget_snapshot||jsonb_build_object('rtc_room_id',$2::text,'rtc_agent_user_id',$3::text,'recording_app_id',$4::text,'mixed_recording',true) WHERE id=$1::uuid`, in.SessionID, in.RoomID, "bot_"+stamp, app) if e != nil { return nil, e } return map[string]any{"recording": "STARTED"}, nil } func (s *Service) stopMixedRecording(ctx context.Context, tx pgx.Tx, session, device string) { if s.mixedRecorder == nil { return } var app, room string if e := tx.QueryRow(ctx, `SELECT budget_snapshot->>'recording_app_id',budget_snapshot->>'rtc_room_id' FROM call_session WHERE id=$1::uuid AND device_id=$2::uuid AND budget_snapshot->>'mixed_recording'='true'`, session, device).Scan(&app, &room); e != nil { return } c, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() if e := s.mixedRecorder.Stop(c, app, room); e != nil { s.logger.Warn("mixed recording stop deferred to cloud idle timeout", "session_id", session) } }