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,508 @@
|
||||
package callmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/billing"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
now func() time.Time
|
||||
logger *slog.Logger
|
||||
projection ProjectionProvider
|
||||
extractor IntentExtractor
|
||||
direct DirectOptions
|
||||
managedSync func(context.Context) error
|
||||
recordings RecordingStore
|
||||
mixedRecorder MixedRecorder
|
||||
vikingConsole VikingConsoleProvider
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, logger *slog.Logger) *Service {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Service{pool: pool, now: time.Now, logger: logger}
|
||||
}
|
||||
|
||||
type DeviceAuth func(*http.Request) (deviceauth.Identity, []byte, []byte, error)
|
||||
|
||||
func (s *Service) RegisterDeviceRoutes(mux *http.ServeMux, auth DeviceAuth) {
|
||||
for _, op := range []string{"warmup", "prepare", "events", "heartbeat", "end", "status", "command-acks"} {
|
||||
mux.HandleFunc("POST /api/v1/device/calls/"+op, func(w http.ResponseWriter, r *http.Request) {
|
||||
id, key, raw, e := auth(r)
|
||||
if e != nil {
|
||||
var typed interface {
|
||||
error
|
||||
HTTPStatus() int
|
||||
}
|
||||
if errors.As(e, &typed) && typed.HTTPStatus() >= 400 && typed.HTTPStatus() < 600 {
|
||||
s.write(w, r, nil, fault(typed.HTTPStatus(), typed.Error()))
|
||||
} else {
|
||||
s.write(w, r, nil, fault(503, "AUTHENTICATION_UNAVAILABLE"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(raw) > MaxBody {
|
||||
s.write(w, r, nil, fault(413, "REQUEST_TOO_LARGE"))
|
||||
return
|
||||
}
|
||||
result, e := s.DeviceRequest(r.Context(), id, key, r.Header.Get("X-Request-ID"), op, raw)
|
||||
s.write(w, r, result, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
func (s *Service) RegisterMemoryRoutes(mux *http.ServeMux, wrap func(http.Handler) http.Handler) {
|
||||
for _, op := range []string{"viking/query", "viking/delete", "query", "mutations", "mutations/status", "sync/status", "mutations/resolve", "mutations/confirm", "access/query", "access/grant", "calls/query", "calls/turns", "calls/recordings", "calls/recording-access"} {
|
||||
mux.Handle("POST /api/v1/memories/"+op, wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := identity.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
s.write(w, r, nil, fault(401, "AUTHENTICATION_REQUIRED"))
|
||||
return
|
||||
}
|
||||
mediaType, _, mediaErr := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if mediaErr != nil || mediaType != "application/json" || r.URL.RawQuery != "" {
|
||||
s.write(w, r, nil, fault(400, "INVALID_REQUEST_FORMAT"))
|
||||
return
|
||||
}
|
||||
raw, e := io.ReadAll(io.LimitReader(r.Body, MaxMemoryBody+1))
|
||||
if e != nil || len(raw) > MaxMemoryBody {
|
||||
s.write(w, r, nil, fault(413, "REQUEST_TOO_LARGE"))
|
||||
return
|
||||
}
|
||||
out, e := s.MemoryRequest(r.Context(), p, op, raw)
|
||||
s.write(w, r, out, e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
func (s *Service) write(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
out := map[string]any{"ok": err == nil, "code": "OK", "request_id": id, "server_time_ms": s.now().UnixMilli()}
|
||||
status := 200
|
||||
if err == nil {
|
||||
out["data"] = data
|
||||
} else {
|
||||
e := &Error{}
|
||||
if !errors.As(err, &e) {
|
||||
e = fault(503, "SERVICE_UNAVAILABLE")
|
||||
s.logger.Error("call memory request failed", "request_id", id, "path", r.URL.Path)
|
||||
}
|
||||
status = e.Status
|
||||
out["code"] = e.Code
|
||||
out["message"] = e.Message
|
||||
out["recovery_action"] = e.Recovery
|
||||
if status == 503 {
|
||||
out["retry_after_ms"] = 2000
|
||||
}
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
func validUUID(s string) bool { u, e := uuid.Parse(s); return e == nil && u.String() == s }
|
||||
func short(s string) bool {
|
||||
return len(s) > 0 && len(s) <= 128 && strings.IndexFunc(s, func(r rune) bool { return r < 33 || r > 126 }) < 0
|
||||
}
|
||||
|
||||
type CallKey struct {
|
||||
SessionID *string `json:"business_session_id,omitempty"`
|
||||
BootID string `json:"boot_id"`
|
||||
HookID string `json:"hook_event_id,omitempty"`
|
||||
Generation int64 `json:"call_generation"`
|
||||
Binding int64 `json:"binding_generation"`
|
||||
}
|
||||
type StatusInput struct {
|
||||
SessionID *string `json:"business_session_id"`
|
||||
BootID string `json:"boot_id"`
|
||||
HookID string `json:"hook_event_id"`
|
||||
}
|
||||
type WarmupInput struct {
|
||||
BootID string `json:"boot_id"`
|
||||
Binding int64 `json:"binding_generation"`
|
||||
Firmware string `json:"firmware_version"`
|
||||
SDK string `json:"sdk_version"`
|
||||
AudioID string `json:"audio_profile_id"`
|
||||
KnownVersion int64 `json:"known_config_version"`
|
||||
Contract string `json:"contract_version,omitempty"`
|
||||
}
|
||||
type PrepareInput struct {
|
||||
CallKey
|
||||
AudioID string `json:"audio_profile_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
type HeartbeatInput struct {
|
||||
CallKey
|
||||
ManifestID string `json:"manifest_id"`
|
||||
AttemptID string `json:"media_attempt_id"`
|
||||
Epoch int64 `json:"observed_privacy_epoch"`
|
||||
Runtime string `json:"runtime_state"`
|
||||
LastSeq int64 `json:"last_event_seq"`
|
||||
Uptime int64 `json:"uptime_ms"`
|
||||
}
|
||||
type EndInput struct {
|
||||
CallKey
|
||||
Reason string `json:"reason"`
|
||||
Elapsed int64 `json:"elapsed_ms"`
|
||||
LastSeq int64 `json:"last_event_seq"`
|
||||
AudioStopped bool `json:"local_audio_stopped"`
|
||||
SDKStatus string `json:"sdk_stop_status"`
|
||||
}
|
||||
type AckInput struct {
|
||||
SessionID string `json:"business_session_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
Generation int64 `json:"call_generation"`
|
||||
Phase string `json:"phase"`
|
||||
ErrorCode *string `json:"error_code"`
|
||||
MutationID *string `json:"mutation_id,omitempty"`
|
||||
MutationRevision *int64 `json:"mutation_revision,omitempty"`
|
||||
}
|
||||
type session struct {
|
||||
ID, Subject, State, Remote, Transcript, ManifestID, ManifestStatus, B64, MAC, Boot, Hook string
|
||||
Generation, Binding, Epoch, Revision, LastSeq int64
|
||||
Expires, Lease *time.Time
|
||||
AudioOff, SDKStopped bool
|
||||
}
|
||||
|
||||
func readSession(ctx context.Context, tx pgx.Tx, device string, k CallKey) (session, error) {
|
||||
var c session
|
||||
sid := ""
|
||||
if k.SessionID != nil {
|
||||
sid = *k.SessionID
|
||||
if !validUUID(sid) {
|
||||
return c, fault(400, "INVALID_SESSION")
|
||||
}
|
||||
}
|
||||
e := tx.QueryRow(ctx, `SELECT id::text,COALESCE(subject_id::text,''),call_state,remote_state,transcript_state,COALESCE(manifest_id::text,''),manifest_status,COALESCE(manifest_b64,''),COALESCE(manifest_mac,''),boot_id::text,hook_event_id,call_generation,binding_generation,privacy_epoch,memory_revision,highest_contiguous_seq,expires_at,lease_expires_at,local_audio_off,sdk_stopped FROM call_session WHERE device_id=$1::uuid AND (($2<>'' AND id=NULLIF($2,'')::uuid) OR ($2='' AND boot_id=NULLIF($3,'')::uuid AND hook_event_id=$4)) FOR UPDATE`, device, sid, k.BootID, k.HookID).Scan(&c.ID, &c.Subject, &c.State, &c.Remote, &c.Transcript, &c.ManifestID, &c.ManifestStatus, &c.B64, &c.MAC, &c.Boot, &c.Hook, &c.Generation, &c.Binding, &c.Epoch, &c.Revision, &c.LastSeq, &c.Expires, &c.Lease, &c.AudioOff, &c.SDKStopped)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return c, fault(404, "CALL_NOT_FOUND")
|
||||
}
|
||||
return c, e
|
||||
}
|
||||
func (s *Service) DeviceRequest(ctx context.Context, d deviceauth.Identity, key []byte, requestID, op string, raw []byte) (any, error) {
|
||||
if !validUUID(requestID) || len(raw) > MaxBody || d.EnrollmentMode != "physical_confirm" {
|
||||
return nil, fault(400, "CALL_PROTOCOL_REQUIRED")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
tx, e := s.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
// Serializes end-before-prepare and duplicate request IDs for this device.
|
||||
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,11))`, d.ID); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var cached []byte
|
||||
var oldPath, oldHash string
|
||||
e = tx.QueryRow(ctx, `SELECT path,body_digest,response FROM call_request_result WHERE actor=$1 AND request_id=$2::uuid`, "device:"+d.ID, requestID).Scan(&oldPath, &oldHash, &cached)
|
||||
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil, e
|
||||
}
|
||||
if len(cached) > 0 && (oldPath != op || oldHash != hash(raw)) {
|
||||
return nil, fault(409, "IDEMPOTENCY_CONFLICT")
|
||||
}
|
||||
var result any
|
||||
switch op {
|
||||
case "warmup":
|
||||
var in WarmupInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
result, e = s.warmup(ctx, tx, d, in)
|
||||
}
|
||||
case "prepare":
|
||||
var in PrepareInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
result, e = s.prepare(ctx, tx, d, key, in, hash(raw))
|
||||
}
|
||||
case "heartbeat":
|
||||
var in HeartbeatInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
result, e = s.heartbeat(ctx, tx, d, in, cached)
|
||||
}
|
||||
case "events":
|
||||
var in EventsInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
result, e = s.events(ctx, tx, d, in)
|
||||
}
|
||||
case "end":
|
||||
var in EndInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
result, e = s.end(ctx, tx, d, in)
|
||||
}
|
||||
case "status":
|
||||
var in StatusInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
var c session
|
||||
c, e = readSession(ctx, tx, d.ID, CallKey{SessionID: in.SessionID, BootID: in.BootID, HookID: in.HookID})
|
||||
if e == nil && (c.Boot != in.BootID || c.Hook != in.HookID) {
|
||||
e = fault(409, "CALL_IDENTITY_MISMATCH")
|
||||
}
|
||||
if e == nil {
|
||||
result, e = s.status(ctx, tx, c)
|
||||
}
|
||||
}
|
||||
case "command-acks":
|
||||
var in AckInput
|
||||
if e = StrictJSON(raw, &in); e == nil {
|
||||
result, e = s.ack(ctx, tx, d, in)
|
||||
}
|
||||
default:
|
||||
e = fault(404, "NOT_FOUND")
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(cached) == 0 {
|
||||
_, e = tx.Exec(ctx, `INSERT INTO call_request_result(actor,request_id,path,body_digest,response) VALUES($1,$2::uuid,$3,$4,$5::jsonb)`, "device:"+d.ID, requestID, op, hash(raw), jsonBytes(result))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if e = tx.Commit(ctx); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) warmup(ctx context.Context, tx pgx.Tx, d deviceauth.Identity, in WarmupInput) (any, error) {
|
||||
if !validUUID(in.BootID) || in.Binding != d.BindingGeneration || !short(in.SDK) || !short(in.Firmware) || !short(in.AudioID) || in.KnownVersion < 0 || (in.Contract != "" && in.Contract != ContractVersion) {
|
||||
return nil, fault(409, "CALL_PROTOCOL_REQUIRED")
|
||||
}
|
||||
var profile string
|
||||
e := tx.QueryRow(ctx, `SELECT id FROM call_provider_profile WHERE published AND sdk_version=$1 AND audio_profile->>'id'=$2 ORDER BY created_at DESC LIMIT 1`, in.SDK, in.AudioID).Scan(&profile)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil, fault(409, "PROVIDER_PROFILE_UNVERIFIED")
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var allowed bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL WHERE d.id=$1::uuid AND d.onboarding_boot_id=$2::uuid AND d.binding_generation=$3 AND b.generation=$3 AND d.status='registered')`, d.ID, in.BootID, in.Binding).Scan(&allowed)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !allowed {
|
||||
return nil, fault(409, "BINDING_GENERATION_CONFLICT")
|
||||
}
|
||||
var configVersion int64
|
||||
var lifecycle string
|
||||
e = tx.QueryRow(ctx, `SELECT config_version,status FROM device_activation_job WHERE device_id=$1::uuid AND binding_generation=$2 AND config_expires_at>now() AND status NOT IN ('REVOKED','FAILED') ORDER BY created_at DESC LIMIT 1`, d.ID, in.Binding).Scan(&configVersion, &lifecycle)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil, fault(409, "DEVICE_NOT_READY")
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO call_device_ready(device_id,boot_id,binding_generation,provider_profile_id,firmware_version,expires_at) VALUES($1::uuid,$2::uuid,$3,$4,$5,now()+interval '5 minutes') ON CONFLICT(device_id) DO UPDATE SET boot_id=excluded.boot_id,binding_generation=excluded.binding_generation,provider_profile_id=excluded.provider_profile_id,firmware_version=excluded.firmware_version,expires_at=excluded.expires_at`, d.ID, in.BootID, in.Binding, profile, in.Firmware)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var subject string
|
||||
e = tx.QueryRow(ctx, `SELECT subject_id::text FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL`, d.ID, in.Binding).Scan(&subject)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
transcriptAllowed, e := transcriptPermission(ctx, tx, subject)
|
||||
return map[string]any{"device_state": map[string]any{"activation_state": d.ActivationState, "binding_generation": in.Binding, "lifecycle_state": lifecycle}, "binding_generation": in.Binding, "config_version": configVersion, "provider_profile_id": profile, "engine_prepare_allowed": true, "transcript_upload_allowed": transcriptAllowed, "poll_after_ms": 30000, "contract_version": ContractVersion}, e
|
||||
}
|
||||
func (s *Service) prepare(ctx context.Context, tx pgx.Tx, d deviceauth.Identity, key []byte, in PrepareInput, bodyHash string) (any, error) {
|
||||
if !validUUID(in.BootID) || !short(in.HookID) || in.Generation < 0 || in.Binding != d.BindingGeneration || !short(in.AudioID) || (in.Purpose != "NORMAL" && in.Purpose != "FIRST_TEST") {
|
||||
return nil, fault(400, "INVALID_PREPARE")
|
||||
}
|
||||
var subject, tenant, boot, lifecycle string
|
||||
var binding int64
|
||||
e := tx.QueryRow(ctx, `SELECT b.subject_id::text,b.tenant_id::text,d.onboarding_boot_id::text,d.binding_generation,j.status FROM device d JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL AND b.generation=d.binding_generation JOIN subject_profile sp ON sp.id=b.subject_id AND sp.status='active' JOIN household h ON h.id=b.household_id AND h.status='active' JOIN device_activation_job j ON j.device_id=d.id AND j.binding_generation=d.binding_generation WHERE d.id=$1::uuid AND d.status='registered' AND j.config_ack_at IS NOT NULL AND j.engine_ready_at IS NOT NULL AND j.sdk_registered_at IS NOT NULL AND j.config_expires_at>now() AND d.activation_state NOT IN ('REVOKED','LOST') AND (j.status<>'READY' OR j.ready_binding_generation=d.binding_generation)`, d.ID).Scan(&subject, &tenant, &boot, &binding, &lifecycle)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil, fault(409, "DEVICE_NOT_READY")
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if boot != in.BootID || binding != in.Binding {
|
||||
return nil, fault(409, "BINDING_GENERATION_CONFLICT")
|
||||
}
|
||||
var voiceAllowed bool
|
||||
e = tx.QueryRow(ctx, `SELECT COALESCE((SELECT effective_consents @> '{"voice_chat":true}'::jsonb FROM consent_snapshot WHERE subject_id=$1::uuid ORDER BY snapshot_version DESC LIMIT 1),false) AND NOT EXISTS(SELECT 1 FROM privacy_request WHERE subject_id=$1::uuid AND request_type='DELETE')`, subject).Scan(&voiceAllowed)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !voiceAllowed {
|
||||
return nil, fault(403, "VOICE_CHAT_CONSENT_REQUIRED")
|
||||
}
|
||||
if in.Purpose == "NORMAL" && lifecycle != "READY" {
|
||||
return nil, fault(409, "DEVICE_NOT_READY")
|
||||
}
|
||||
if in.Purpose == "FIRST_TEST" {
|
||||
var permit bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt a JOIN device_activation_job j ON j.id=a.activation_id WHERE j.device_id=$1::uuid AND a.binding_generation=$2 AND a.stage='CONVERSATION_TEST' AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE' AND a.permit_expires_at>now())`, d.ID, in.Binding).Scan(&permit)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !permit {
|
||||
return nil, fault(403, "FIRST_TEST_NOT_AUTHORIZED")
|
||||
}
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO memory_user_state(subject_id,tenant_id) VALUES($1::uuid,$2::uuid) ON CONFLICT DO NOTHING`, subject, tenant)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var revision, visible, epoch int64
|
||||
var legacy bool
|
||||
e = tx.QueryRow(ctx, `SELECT memory_revision,viking_visible_revision,privacy_epoch,legacy_projection_pending FROM memory_user_state WHERE subject_id=$1::uuid FOR UPDATE`, subject).Scan(&revision, &visible, &epoch, &legacy)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
old, findErr := readSession(ctx, tx, d.ID, in.CallKey)
|
||||
if findErr == nil {
|
||||
if old.State == "CANCELLED" {
|
||||
return nil, fault(409, "CALL_CANCELLED")
|
||||
}
|
||||
var digest string
|
||||
if e = tx.QueryRow(ctx, `SELECT prepare_digest FROM call_session WHERE id=$1::uuid`, old.ID).Scan(&digest); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if digest != bodyHash {
|
||||
return nil, fault(409, "IDEMPOTENCY_CONFLICT")
|
||||
}
|
||||
if old.ManifestStatus != "ISSUED" || old.Epoch != epoch || old.Binding != binding {
|
||||
return nil, fault(409, "MANIFEST_REVOKED")
|
||||
}
|
||||
if old.Expires == nil || !old.Expires.After(s.now()) {
|
||||
return nil, fault(410, "PREPARE_EXPIRED")
|
||||
}
|
||||
return map[string]any{"manifest_b64": old.B64, "manifest_mac": old.MAC}, nil
|
||||
}
|
||||
var fe *Error
|
||||
if !errors.As(findErr, &fe) || fe.Status != 404 {
|
||||
return nil, findErr
|
||||
}
|
||||
var active bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE device_id=$1::uuid AND manifest_status IN ('ISSUED','REVOKED') AND NOT(local_audio_off AND sdk_stopped AND remote_state IN ('NOT_STARTED','STOPPED_OBSERVED'))) OR EXISTS(SELECT 1 FROM conversation WHERE device_id=$1::uuid AND status IN ('ROOM_ISSUED','STARTING_AGENT','ACTIVE','STOPPING'))`, d.ID).Scan(&active)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if active {
|
||||
return nil, fault(409, "MEDIA_CLEANUP_PENDING")
|
||||
}
|
||||
budget, e := billing.CheckQuotaTx(ctx, tx, d, s.now())
|
||||
if errors.Is(e, billing.ErrQuotaExceeded) {
|
||||
return nil, fault(429, "CALL_QUOTA_EXCEEDED")
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p, e := loadProfile(ctx, tx, d.ID, in.BootID, in.Binding)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if p.Audio["id"] != in.AudioID {
|
||||
return nil, fault(409, "AUDIO_PROFILE_MISMATCH")
|
||||
}
|
||||
var critical, consent bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM memory_projection_job WHERE subject_id=$1::uuid AND critical AND state<>'VISIBLE'),EXISTS(SELECT 1 FROM consent_record WHERE subject_id=$1::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, subject).Scan(&critical, &consent)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
mode, e := SelectMemoryMode(revision, visible, critical || legacy, consent, p.Capabilities["H05"] && p.Evidence["H05"] != "")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
contextText := ""
|
||||
if mode != "OFF" {
|
||||
contextText, e = loadContext(ctx, tx, subject)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
sid := uuid.NewString()
|
||||
params, e := p.Compile(mode, contextText, "你好,今天想聊些什么?", "u_"+hash([]byte(tenant+":"+subject)), "platform_companion_v2", sid)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
now := s.now()
|
||||
maxCall := int64(3600000)
|
||||
if v, ok := p.Audio["max_call_ms"].(float64); ok && v > 0 && int64(v) < maxCall {
|
||||
maxCall = int64(v)
|
||||
}
|
||||
if budget != nil && budget.MaxCallDurationSec > 0 && int64(budget.MaxCallDurationSec)*1000 < maxCall {
|
||||
maxCall = int64(budget.MaxCallDurationSec) * 1000
|
||||
}
|
||||
if in.Purpose == "FIRST_TEST" && maxCall > 60000 {
|
||||
maxCall = 60000
|
||||
}
|
||||
m := Manifest{ContractVersion: ContractVersion, ManifestID: uuid.NewString(), SessionID: sid, DeviceID: d.PublicID, BootID: in.BootID, HookID: in.HookID, CallGeneration: in.Generation, BindingGeneration: in.Binding, IssuedAt: now.UnixMilli(), ExpiresAt: now.UnixMilli() + ManifestMS, Mode: "RTC", BotID: p.BotID, ProfileID: p.ID, Purpose: in.Purpose, FirstTurnMode: "AI_WELCOME", MemoryMode: mode, MemoryRevision: revision, VisibleRevision: visible, PrivacyEpoch: epoch, ContextVersion: revision, AudioProfileID: in.AudioID, VendorParams: params, LeaseMS: LeaseMS, LeaseExpiresAt: now.UnixMilli() + LeaseMS, MaxCallMS: maxCall}
|
||||
// Physical factory identity is carried by the authentication adapter.
|
||||
if strings.HasPrefix(d.CredentialRef, "onboarding://") {
|
||||
m.DeviceID = strings.TrimPrefix(d.CredentialRef, "onboarding://")
|
||||
}
|
||||
b64, mac, e := EncodeManifest(key, m)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,prepare_digest,purpose,manifest_id,manifest_b64,manifest_mac,manifest_status,provider_profile_id,privacy_epoch,memory_revision,expires_at,lease_expires_at,memory_mode) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,$7,$8,$9,$10,$11::uuid,$12,$13,'ISSUED',$14,$15,$16,$17,$18,$19)`, sid, d.ID, tenant, subject, in.BootID, in.HookID, in.Generation, in.Binding, bodyHash, in.Purpose, m.ManifestID, b64, mac, p.ID, epoch, revision, time.UnixMilli(m.ExpiresAt), time.UnixMilli(m.LeaseExpiresAt), mode)
|
||||
if e == nil && budget != nil {
|
||||
_, e = tx.Exec(ctx, `UPDATE call_session SET budget_snapshot=$2::jsonb WHERE id=$1::uuid`, sid, jsonBytes(budget))
|
||||
}
|
||||
if e == nil && in.Purpose == "FIRST_TEST" {
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt a SET call_session_id=$1::uuid FROM device_activation_job j WHERE a.activation_id=j.id AND j.device_id=$2::uuid AND a.id=j.current_attempt_id AND a.stage='CONVERSATION_TEST' AND a.status='CONTACT_POSSIBLE' AND a.call_session_id IS NULL AND a.conversation_id IS NULL AND a.permit_expires_at>now()`, sid, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return nil, fault(409, "FIRST_TEST_ALREADY_CONSUMED")
|
||||
}
|
||||
}
|
||||
return map[string]any{"manifest_b64": b64, "manifest_mac": mac}, e
|
||||
}
|
||||
func loadProfile(ctx context.Context, tx pgx.Tx, device, boot string, binding int64) (Profile, error) {
|
||||
var p Profile
|
||||
var audio, template, mapping, caps, evidence []byte
|
||||
e := tx.QueryRow(ctx, `SELECT p.id,p.sdk_version,p.bot_id,p.model_version,p.transport,p.audio_profile,p.vendor_template,p.field_mapping,p.capabilities,p.evidence FROM call_provider_profile p JOIN call_device_ready r ON r.provider_profile_id=p.id WHERE r.device_id=$1::uuid AND r.boot_id=$2::uuid AND r.binding_generation=$3 AND r.expires_at>now() AND p.published`, device, boot, binding).Scan(&p.ID, &p.SDKVersion, &p.BotID, &p.ModelVersion, &p.Transport, &audio, &template, &mapping, &caps, &evidence)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return p, fault(409, "WARMUP_REQUIRED")
|
||||
}
|
||||
if e != nil {
|
||||
return p, e
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
b []byte
|
||||
v any
|
||||
}{{audio, &p.Audio}, {template, &p.Template}, {mapping, &p.Mapping}, {caps, &p.Capabilities}, {evidence, &p.Evidence}} {
|
||||
if e = json.Unmarshal(pair.b, pair.v); e != nil {
|
||||
return p, e
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func loadContext(ctx context.Context, tx pgx.Tx, subject string) (string, error) {
|
||||
rows, e := tx.Query(ctx, `SELECT cm.public_id::text,cm.entity_key,mv.content,cm.memory_type,COALESCE(cm.item_status,''),cm.proactive_policy,cm.version,COALESCE(cm.source_time,cm.updated_at) FROM curated_memory cm JOIN memory_version mv ON mv.id=cm.current_version_id WHERE cm.subject_id=$1::uuid AND cm.status='ACTIVE' AND cm.sensitivity='normal' AND (cm.valid_from IS NULL OR cm.valid_from<=now()) AND (cm.valid_to IS NULL OR cm.valid_to>now()) ORDER BY cm.updated_at DESC LIMIT 200`, subject)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
defer rows.Close()
|
||||
facts := []ContextFact{}
|
||||
for rows.Next() {
|
||||
var f ContextFact
|
||||
if e = rows.Scan(&f.ID, &f.EntityKey, &f.Text, &f.Type, &f.ItemStatus, &f.Policy, &f.Version, &f.SourceTime); e != nil {
|
||||
return "", e
|
||||
}
|
||||
facts = append(facts, f)
|
||||
}
|
||||
return BuildContext(facts, 1000), rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user