Files
story/internal/callmemory/mixed_recording.go
T

153 lines
7.6 KiB
Go
Raw Normal View History

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)
}
}