153 lines
7.6 KiB
Go
153 lines
7.6 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|