feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user