fix recording timing and sync call contracts
management-images / build-and-publish (push) Successful in 34s
management-images / build-and-publish (push) Successful in 34s
This commit is contained in:
+11
-2
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import contextlib
|
||||
import http.client
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
@@ -23,6 +24,7 @@ import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import wave
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -618,6 +620,8 @@ class RealCellCall:
|
||||
)
|
||||
self.target_channel_id = self._channel_id(target)
|
||||
self._add_when_ready(self.target_channel_id)
|
||||
self._wait_answer(result)
|
||||
result.connected = True
|
||||
self.ari.request(
|
||||
"POST",
|
||||
f"bridges/{self.bridge_id}/record",
|
||||
@@ -628,8 +632,6 @@ class RealCellCall:
|
||||
"beepEnabled": False,
|
||||
},
|
||||
)
|
||||
self._wait_answer(result)
|
||||
result.connected = True
|
||||
opening = str(self.engine.config["conversation"].get("opening", ""))
|
||||
if opening:
|
||||
opening_result = self.engine.speak(opening, f"opening_{self.call_id}")
|
||||
@@ -1016,6 +1018,13 @@ class RealCellCall:
|
||||
return None
|
||||
if not isinstance(raw, bytes) or not raw:
|
||||
return None
|
||||
try:
|
||||
with wave.open(io.BytesIO(raw), "rb") as source:
|
||||
frames = source.getnframes()
|
||||
except (EOFError, OSError, wave.Error):
|
||||
return None
|
||||
if not frames > 0:
|
||||
return None
|
||||
path = Path(self.config.recording_dir) / f"{self.recording_name}.wav"
|
||||
try:
|
||||
if path.is_symlink():
|
||||
|
||||
@@ -263,6 +263,18 @@ components:
|
||||
recordings: {type: array, items: {type: object}}
|
||||
delivery: {type: object}
|
||||
snapshot_at: {type: string, format: date-time}
|
||||
Problem:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [type, title, status, code, detail, request_id, retryable]
|
||||
properties:
|
||||
type: {type: string, format: uri-reference}
|
||||
title: {type: string}
|
||||
status: {type: integer}
|
||||
code: {type: string}
|
||||
detail: {type: string}
|
||||
request_id: {type: string}
|
||||
retryable: {type: boolean}
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Unauthorized
|
||||
@@ -289,15 +301,3 @@ components:
|
||||
content:
|
||||
application/problem+json:
|
||||
schema: {$ref: '#/components/schemas/Problem'}
|
||||
Problem:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [type, title, status, code, detail, request_id, retryable]
|
||||
properties:
|
||||
type: {type: string, format: uri-reference}
|
||||
title: {type: string}
|
||||
status: {type: integer}
|
||||
code: {type: string}
|
||||
detail: {type: string}
|
||||
request_id: {type: string}
|
||||
retryable: {type: boolean}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
@@ -16,8 +17,12 @@ servers:
|
||||
description: SaaS read-only service network
|
||||
tags:
|
||||
- name: health
|
||||
- name: admin-providers
|
||||
- name: admin-trunks
|
||||
- name: admin-cells
|
||||
- name: admin-status
|
||||
- name: admin-statistics
|
||||
- name: admin-audit
|
||||
- name: saas-readonly
|
||||
paths:
|
||||
/healthz/live:
|
||||
@@ -31,11 +36,69 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Health'
|
||||
/admin/v1/providers:
|
||||
get:
|
||||
tags: [admin-providers]
|
||||
operationId: listProviders
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Provider directory
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ProviderList'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
/admin/v1/providers/{provider_id}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ProviderId'}
|
||||
get:
|
||||
tags: [admin-providers]
|
||||
operationId: getProvider
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Provider
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ProviderResponse'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
put:
|
||||
tags: [admin-providers]
|
||||
operationId: upsertProvider
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/IfMatch'}
|
||||
- {$ref: '#/components/parameters/RequestId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ProviderInput'}
|
||||
responses:
|
||||
'200':
|
||||
description: Updated provider
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ProviderResponse'}
|
||||
'201':
|
||||
description: Created provider
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ProviderResponse'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/admin/v1/trunks:
|
||||
get:
|
||||
tags: [admin-trunks]
|
||||
operationId: listAdminTrunks
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkStatusFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: All Trunks, including unpublished revisions
|
||||
@@ -88,6 +151,54 @@ paths:
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/admin/v1/trunks/{trunk_id}/validate:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TrunkId'}
|
||||
post:
|
||||
tags: [admin-trunks]
|
||||
operationId: validateTrunk
|
||||
security: [{SipAdminBearer: []}]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
revision: {type: integer, minimum: 1}
|
||||
responses:
|
||||
'200':
|
||||
description: Validation issues, compatible Cells, and impact preview
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/admin/v1/trunks/{trunk_id}/verifications:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TrunkId'}
|
||||
post:
|
||||
tags: [admin-trunks]
|
||||
operationId: addTrunkVerification
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/IfMatch'}
|
||||
- {$ref: '#/components/parameters/RequestId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Versioned verification record
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/admin/v1/trunks/{trunk_id}/publish:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TrunkId'}
|
||||
@@ -220,6 +331,18 @@ paths:
|
||||
/admin/v1/cells/{cell_id}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CellId'}
|
||||
get:
|
||||
tags: [admin-cells]
|
||||
operationId: getCell
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Registered Cell
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Cell'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
put:
|
||||
tags: [admin-cells]
|
||||
operationId: registerCell
|
||||
@@ -245,6 +368,206 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/Cell'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/admin/v1/cells/{cell_id}/observations:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CellId'}
|
||||
post:
|
||||
tags: [admin-cells]
|
||||
operationId: ingestCellObservation
|
||||
security: [{SipAdminBearer: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ObservationInput'}
|
||||
responses:
|
||||
'201':
|
||||
description: >-
|
||||
Observation accepted for the current boot and monotonic sequence
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/admin/v1/egress-pools:
|
||||
get:
|
||||
tags: [admin-cells]
|
||||
operationId: listEgressPools
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Fixed egress pool directory
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
/admin/v1/sip-status:
|
||||
get:
|
||||
tags: [admin-status]
|
||||
operationId: getSipStatus
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CellIdsFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: >-
|
||||
Cell/trunk status matrix with freshness and missing sources
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/StatusResponse'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
/admin/v1/cells/{cell_id}/sip-status:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CellId'}
|
||||
get:
|
||||
tags: [admin-status]
|
||||
operationId: getCellSipStatus
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: One Cell status
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/StatusItem'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/admin/v1/providers/{provider_id}/status:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ProviderId'}
|
||||
get:
|
||||
tags: [admin-status]
|
||||
operationId: getProviderStatus
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Provider status matrix
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/StatusResponse'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/admin/v1/statistics/outbound/summary:
|
||||
get:
|
||||
tags: [admin-statistics]
|
||||
operationId: getOutboundSummary
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/From'}
|
||||
- {$ref: '#/components/parameters/To'}
|
||||
- {$ref: '#/components/parameters/StatsMode'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/EgressFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: Cohort and interval metrics with completeness metadata
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/StatisticsSummary'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'422': {$ref: '#/components/responses/BadRequest'}
|
||||
/admin/v1/statistics/outbound/timeseries:
|
||||
get:
|
||||
tags: [admin-statistics]
|
||||
operationId: getOutboundTimeseries
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/From'}
|
||||
- {$ref: '#/components/parameters/To'}
|
||||
- {$ref: '#/components/parameters/StatsMode'}
|
||||
- {$ref: '#/components/parameters/Granularity'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/EgressFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: Bounded UTC time series
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TimeseriesResponse'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'422': {$ref: '#/components/responses/BadRequest'}
|
||||
/admin/v1/call-attempts:
|
||||
get:
|
||||
tags: [admin-statistics]
|
||||
operationId: listCallAttempts
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/From'}
|
||||
- {$ref: '#/components/parameters/To'}
|
||||
- {$ref: '#/components/parameters/StatsMode'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/Limit'}
|
||||
- {$ref: '#/components/parameters/Cursor'}
|
||||
- {$ref: '#/components/parameters/EgressFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: Redacted raw attempt facts
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
/admin/v1/call-attempts/{attempt_id}:
|
||||
parameters:
|
||||
- name: attempt_id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string}
|
||||
get:
|
||||
tags: [admin-statistics]
|
||||
operationId: getCallAttempt
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: One redacted attempt fact
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/admin/v1/audit:
|
||||
get:
|
||||
tags: [admin-audit]
|
||||
operationId: listAudit
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Limit'}
|
||||
- {$ref: '#/components/parameters/ResourceFilter'}
|
||||
- {$ref: '#/components/parameters/RequestFilter'}
|
||||
- {$ref: '#/components/parameters/ActorFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: Immutable audit entries
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
/admin/v1/operations/{operation_id}:
|
||||
parameters:
|
||||
- name: operation_id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string}
|
||||
get:
|
||||
tags: [admin-audit]
|
||||
operationId: getOperation
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Durable operation state for retry/recovery
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: object}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/readonly/v1/sip/trunks:
|
||||
get:
|
||||
tags: [saas-readonly]
|
||||
@@ -290,6 +613,11 @@ components:
|
||||
Dedicated SaaS read-only credential. It cannot publish, modify, disable,
|
||||
rollback, or access Cell management.
|
||||
parameters:
|
||||
ProviderId:
|
||||
name: provider_id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'}
|
||||
TrunkId:
|
||||
name: trunk_id
|
||||
in: path
|
||||
@@ -306,6 +634,81 @@ components:
|
||||
required: true
|
||||
description: Exact latest revision required for CAS; quotes are accepted.
|
||||
schema: {type: integer, minimum: 0}
|
||||
CellIdsFilter:
|
||||
name: cell_ids
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
CellIdFilter:
|
||||
name: cell_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
ProviderFilter:
|
||||
name: provider_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
TrunkFilter:
|
||||
name: trunk_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
TrunkStatusFilter:
|
||||
name: status
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
ResourceFilter:
|
||||
name: resource_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
ActorFilter:
|
||||
name: actor
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
From:
|
||||
name: from
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string, format: date-time}
|
||||
To:
|
||||
name: to
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string, format: date-time}
|
||||
StatsMode:
|
||||
name: mode
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string, enum: [mock, mixed, real]}
|
||||
EgressFilter:
|
||||
name: egress_pool_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
Granularity:
|
||||
name: granularity
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string, enum: [minute, hour, day]}
|
||||
Limit:
|
||||
name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: integer, minimum: 1, maximum: 200}
|
||||
Cursor:
|
||||
name: cursor
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
RequestFilter:
|
||||
name: request_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
RequestId:
|
||||
name: X-Request-ID
|
||||
in: header
|
||||
@@ -341,6 +744,9 @@ components:
|
||||
Mode:
|
||||
type: string
|
||||
enum: [mock, real]
|
||||
StatsMode:
|
||||
type: string
|
||||
enum: [mock, mixed, real]
|
||||
Health:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -374,10 +780,31 @@ components:
|
||||
writeOnly: true
|
||||
description: >-
|
||||
Secret-store reference only; plaintext credentials are forbidden.
|
||||
VerificationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [revision, check_name, result]
|
||||
properties:
|
||||
revision: {type: integer, minimum: 1}
|
||||
check_name:
|
||||
type: string
|
||||
enum:
|
||||
- transport
|
||||
- registration_auth
|
||||
- caller_id_rules
|
||||
- codec
|
||||
- capacity
|
||||
- whitelist
|
||||
result:
|
||||
type: string
|
||||
enum: [confirmed, failed, unknown, not_applicable]
|
||||
evidence_ref: {type: string, maxLength: 512}
|
||||
checked_by: {type: string, maxLength: 128}
|
||||
TrunkConfig:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- provider_id
|
||||
- display_name
|
||||
- enabled
|
||||
- sip
|
||||
@@ -388,6 +815,9 @@ components:
|
||||
- max_concurrency
|
||||
- max_cps
|
||||
properties:
|
||||
provider_id:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||
display_name: {type: string, minLength: 1, maxLength: 256}
|
||||
enabled: {type: boolean}
|
||||
sip: {$ref: '#/components/schemas/SipConfig'}
|
||||
@@ -432,6 +862,7 @@ components:
|
||||
properties:
|
||||
revision: {type: integer, minimum: 1}
|
||||
state: {type: string, enum: [draft, publishing, published, superseded]}
|
||||
config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||
created_at: {type: string, format: date-time}
|
||||
created_by: {type: string}
|
||||
AdminTrunk:
|
||||
@@ -439,8 +870,10 @@ components:
|
||||
required:
|
||||
- mode
|
||||
- trunk_id
|
||||
- provider_id
|
||||
- latest_revision
|
||||
- active_revision
|
||||
- active_status
|
||||
- status
|
||||
- compatible_cell_ids
|
||||
- latest
|
||||
@@ -449,8 +882,10 @@ components:
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
trunk_id: {type: string}
|
||||
provider_id: {type: string}
|
||||
latest_revision: {type: integer, minimum: 1}
|
||||
active_revision: {type: integer, minimum: 0}
|
||||
active_status: {type: string, enum: [draft, published, disabled]}
|
||||
status: {type: string, enum: [draft, published, disabled]}
|
||||
updated_at: {type: string, format: date-time}
|
||||
compatible_cell_ids: {type: array, items: {type: string}}
|
||||
@@ -470,17 +905,20 @@ components:
|
||||
asterisk_allow:
|
||||
type: array
|
||||
items: {type: string, enum: [alaw, ulaw]}
|
||||
config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||
ReadonlyTrunk:
|
||||
type: object
|
||||
required:
|
||||
- mode
|
||||
- trunk_id
|
||||
- provider_id
|
||||
- revision
|
||||
- status
|
||||
- config
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
trunk_id: {type: string}
|
||||
provider_id: {type: string}
|
||||
revision: {type: integer, minimum: 1}
|
||||
status: {type: string, const: published}
|
||||
updated_at: {type: string, format: date-time}
|
||||
@@ -508,6 +946,10 @@ components:
|
||||
cell_id: {type: string}
|
||||
revision: {type: integer, minimum: 1}
|
||||
config: {$ref: '#/components/schemas/CellConfig'}
|
||||
cloud_instance_id: {type: string}
|
||||
instance_name: {type: string}
|
||||
region: {type: string}
|
||||
boot_id: {type: string}
|
||||
updated_at: {type: string, format: date-time}
|
||||
updated_by: {type: string}
|
||||
Publication:
|
||||
@@ -519,6 +961,11 @@ components:
|
||||
cell_id: {type: string}
|
||||
status: {type: string, enum: [pending, applied, failed]}
|
||||
error_code: {type: [string, 'null']}
|
||||
applied_at: {type: [string, 'null'], format: date-time}
|
||||
target_digest: {type: string}
|
||||
local_revision: {type: integer, minimum: 0}
|
||||
local_digest: {type: string}
|
||||
operation_id: {type: string}
|
||||
updated_at: {type: string, format: date-time}
|
||||
AuditEntry:
|
||||
type: object
|
||||
@@ -552,6 +999,118 @@ components:
|
||||
request_id: {type: [string, 'null']}
|
||||
details_json: {type: string}
|
||||
created_at: {type: string, format: date-time}
|
||||
ProviderInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [display_name, lifecycle]
|
||||
properties:
|
||||
display_name: {type: string, minLength: 1, maxLength: 256}
|
||||
notes: {type: string, maxLength: 2000}
|
||||
lifecycle: {type: string, enum: [active, archived]}
|
||||
Provider:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ProviderInput'}
|
||||
- type: object
|
||||
required: [provider_id, revision, trunk_count, created_at, updated_at]
|
||||
properties:
|
||||
provider_id: {type: string}
|
||||
revision: {type: integer, minimum: 1}
|
||||
trunk_count: {type: integer, minimum: 0}
|
||||
created_at: {type: string, format: date-time}
|
||||
updated_at: {type: string, format: date-time}
|
||||
updated_by: {type: string}
|
||||
ProviderList:
|
||||
type: object
|
||||
required: [mode, providers]
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
providers: {type: array, items: {$ref: '#/components/schemas/Provider'}}
|
||||
ProviderResponse:
|
||||
type: object
|
||||
required: [mode, provider]
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
provider: {$ref: '#/components/schemas/Provider'}
|
||||
ObservationInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [cell_id, boot_id, sequence, observed_at, source, states]
|
||||
properties:
|
||||
observation_id: {type: string}
|
||||
cell_id: {type: string}
|
||||
trunk_id: {type: string}
|
||||
boot_id: {type: string, minLength: 1}
|
||||
sequence: {type: integer, minimum: 1}
|
||||
observed_at: {type: string, format: date-time}
|
||||
source: {type: string}
|
||||
config_revision: {type: integer, minimum: 0}
|
||||
states: {type: object}
|
||||
occupancy: {type: object}
|
||||
sample_id: {type: string}
|
||||
StatusItem:
|
||||
type: object
|
||||
required: [mode, cell_id, availability, complete, missing_sources]
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
cell_id: {type: string}
|
||||
availability: {type: string, enum: [healthy, stale, unknown, disabled]}
|
||||
complete: {type: boolean}
|
||||
observed_at: {type: [string, 'null'], format: date-time}
|
||||
received_at: {type: [string, 'null'], format: date-time}
|
||||
observation_age_seconds: {type: [integer, 'null'], minimum: 0}
|
||||
clock_skew: {type: boolean}
|
||||
reason: {type: string}
|
||||
boot_id: {type: string}
|
||||
sequence: {type: integer, minimum: 1}
|
||||
config_revision: {type: integer, minimum: 1}
|
||||
config_status: {type: string}
|
||||
egress_pool_id: {type: string}
|
||||
eligibility: {type: string}
|
||||
missing_sources: {type: array, items: {type: string}}
|
||||
states: {type: object}
|
||||
occupancy: {type: object}
|
||||
trunks: {type: array, items: {type: object}}
|
||||
publication: {type: object}
|
||||
StatusResponse:
|
||||
type: object
|
||||
required: [mode, complete, cells]
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
complete: {type: boolean}
|
||||
generated_at: {type: string, format: date-time}
|
||||
data_as_of: {type: [string, 'null'], format: date-time}
|
||||
coverage: {type: object}
|
||||
cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}}
|
||||
StatisticsSummary:
|
||||
type: object
|
||||
required: [mode, from, to, complete, metrics]
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/StatsMode'}
|
||||
from: {type: string, format: date-time}
|
||||
to: {type: string, format: date-time}
|
||||
timezone: {type: string}
|
||||
definition_version: {type: string}
|
||||
filters: {type: object}
|
||||
complete: {type: boolean}
|
||||
missing_sources: {type: array, items: {type: string}}
|
||||
unresolved_count: {type: integer, minimum: 0}
|
||||
data_as_of: {type: [string, 'null'], format: date-time}
|
||||
metrics: {type: object}
|
||||
failure_reasons: {type: array, items: {type: object}}
|
||||
realtime: {type: object}
|
||||
TimeseriesResponse:
|
||||
type: object
|
||||
required: [mode, from, to, granularity, complete, series]
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/StatsMode'}
|
||||
from: {type: string, format: date-time}
|
||||
to: {type: string, format: date-time}
|
||||
timezone: {type: string}
|
||||
definition_version: {type: string}
|
||||
filters: {type: object}
|
||||
granularity: {type: string, enum: [minute, hour, day]}
|
||||
complete: {type: boolean}
|
||||
series: {type: array, items: {type: object}}
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [error]
|
||||
@@ -562,3 +1121,5 @@ components:
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
fields: {type: object}
|
||||
request_id: {type: string}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"files": {
|
||||
"sip-management.openapi.yaml": {
|
||||
"source_path": "docs/contracts/sip-management.openapi.yaml",
|
||||
"sha256": "2a4974e241fd55b4f72514c69bf75e18244bc1dca93797d8f9bb5ae00c9bd9aa"
|
||||
"sha256": "5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281"
|
||||
},
|
||||
"cell-agent.openapi.yaml": {
|
||||
"source_path": "docs/contracts/cell-agent.openapi.yaml",
|
||||
|
||||
@@ -95,6 +95,10 @@ paths:
|
||||
tags: [admin-trunks]
|
||||
operationId: listAdminTrunks
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkStatusFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: All Trunks, including unpublished revisions
|
||||
@@ -327,6 +331,18 @@ paths:
|
||||
/admin/v1/cells/{cell_id}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CellId'}
|
||||
get:
|
||||
tags: [admin-cells]
|
||||
operationId: getCell
|
||||
security: [{SipAdminBearer: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Registered Cell
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Cell'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
put:
|
||||
tags: [admin-cells]
|
||||
operationId: registerCell
|
||||
@@ -391,7 +407,8 @@ paths:
|
||||
operationId: getSipStatus
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CellFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdsFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
responses:
|
||||
'200':
|
||||
@@ -408,6 +425,8 @@ paths:
|
||||
tags: [admin-status]
|
||||
operationId: getCellSipStatus
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: One Cell status
|
||||
@@ -442,7 +461,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/StatsMode'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
- {$ref: '#/components/parameters/CellFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/EgressFilter'}
|
||||
responses:
|
||||
'200':
|
||||
@@ -462,6 +481,9 @@ paths:
|
||||
- {$ref: '#/components/parameters/To'}
|
||||
- {$ref: '#/components/parameters/StatsMode'}
|
||||
- {$ref: '#/components/parameters/Granularity'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/EgressFilter'}
|
||||
responses:
|
||||
'200':
|
||||
@@ -480,6 +502,9 @@ paths:
|
||||
- {$ref: '#/components/parameters/From'}
|
||||
- {$ref: '#/components/parameters/To'}
|
||||
- {$ref: '#/components/parameters/StatsMode'}
|
||||
- {$ref: '#/components/parameters/ProviderFilter'}
|
||||
- {$ref: '#/components/parameters/TrunkFilter'}
|
||||
- {$ref: '#/components/parameters/CellIdFilter'}
|
||||
- {$ref: '#/components/parameters/Limit'}
|
||||
- {$ref: '#/components/parameters/Cursor'}
|
||||
- {$ref: '#/components/parameters/EgressFilter'}
|
||||
@@ -515,7 +540,9 @@ paths:
|
||||
security: [{SipAdminBearer: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Limit'}
|
||||
- {$ref: '#/components/parameters/ResourceFilter'}
|
||||
- {$ref: '#/components/parameters/RequestFilter'}
|
||||
- {$ref: '#/components/parameters/ActorFilter'}
|
||||
responses:
|
||||
'200':
|
||||
description: Immutable audit entries
|
||||
@@ -607,11 +634,16 @@ components:
|
||||
required: true
|
||||
description: Exact latest revision required for CAS; quotes are accepted.
|
||||
schema: {type: integer, minimum: 0}
|
||||
CellFilter:
|
||||
CellIdsFilter:
|
||||
name: cell_ids
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
CellIdFilter:
|
||||
name: cell_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
ProviderFilter:
|
||||
name: provider_id
|
||||
in: query
|
||||
@@ -622,6 +654,21 @@ components:
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
TrunkStatusFilter:
|
||||
name: status
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
ResourceFilter:
|
||||
name: resource_id
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
ActorFilter:
|
||||
name: actor
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string}
|
||||
From:
|
||||
name: from
|
||||
in: query
|
||||
@@ -823,6 +870,7 @@ components:
|
||||
required:
|
||||
- mode
|
||||
- trunk_id
|
||||
- provider_id
|
||||
- latest_revision
|
||||
- active_revision
|
||||
- active_status
|
||||
@@ -834,6 +882,7 @@ components:
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
trunk_id: {type: string}
|
||||
provider_id: {type: string}
|
||||
latest_revision: {type: integer, minimum: 1}
|
||||
active_revision: {type: integer, minimum: 0}
|
||||
active_status: {type: string, enum: [draft, published, disabled]}
|
||||
@@ -862,12 +911,14 @@ components:
|
||||
required:
|
||||
- mode
|
||||
- trunk_id
|
||||
- provider_id
|
||||
- revision
|
||||
- status
|
||||
- config
|
||||
properties:
|
||||
mode: {$ref: '#/components/schemas/Mode'}
|
||||
trunk_id: {type: string}
|
||||
provider_id: {type: string}
|
||||
revision: {type: integer, minimum: 1}
|
||||
status: {type: string, const: published}
|
||||
updated_at: {type: string, format: date-time}
|
||||
@@ -895,6 +946,10 @@ components:
|
||||
cell_id: {type: string}
|
||||
revision: {type: integer, minimum: 1}
|
||||
config: {$ref: '#/components/schemas/CellConfig'}
|
||||
cloud_instance_id: {type: string}
|
||||
instance_name: {type: string}
|
||||
region: {type: string}
|
||||
boot_id: {type: string}
|
||||
updated_at: {type: string, format: date-time}
|
||||
updated_by: {type: string}
|
||||
Publication:
|
||||
@@ -906,6 +961,11 @@ components:
|
||||
cell_id: {type: string}
|
||||
status: {type: string, enum: [pending, applied, failed]}
|
||||
error_code: {type: [string, 'null']}
|
||||
applied_at: {type: [string, 'null'], format: date-time}
|
||||
target_digest: {type: string}
|
||||
local_revision: {type: integer, minimum: 0}
|
||||
local_digest: {type: string}
|
||||
operation_id: {type: string}
|
||||
updated_at: {type: string, format: date-time}
|
||||
AuditEntry:
|
||||
type: object
|
||||
|
||||
+125
-1
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
@@ -52,7 +54,106 @@ class FakeBroker:
|
||||
self.published.append(body)
|
||||
|
||||
|
||||
def wav_bytes(pcm: bytes) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with wave.open(output, "wb") as source:
|
||||
source.setnchannels(1)
|
||||
source.setsampwidth(2)
|
||||
source.setframerate(8000)
|
||||
source.writeframes(pcm)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class RealCellTests(unittest.TestCase):
|
||||
def _build_lifecycle_call(
|
||||
self, order: list[str], answer_error: CellCallError | None = None
|
||||
) -> tuple[RealCellCall, Any]:
|
||||
config = CellCallConfig(
|
||||
"http://127.0.0.1:8088", "u", "p", event_timeout_s=0.1
|
||||
)
|
||||
engine = SimpleNamespace(
|
||||
config={
|
||||
"agent_version_id": "agent_v1",
|
||||
"conversation": {
|
||||
"opening": "hello",
|
||||
"max_turns": 1,
|
||||
"allow_interrupt": False,
|
||||
},
|
||||
},
|
||||
history=[],
|
||||
)
|
||||
engine.speak = lambda _text, _turn_id: (
|
||||
order.append("opening") or {"audio": b""}
|
||||
)
|
||||
call = RealCellCall(config, cast(Any, engine), cast(Any, SimpleNamespace()))
|
||||
|
||||
class FakeARI:
|
||||
def __init__(self) -> None:
|
||||
self.record_requests = 0
|
||||
|
||||
def events(self, _app: str) -> Any:
|
||||
return SimpleNamespace(close=lambda: None)
|
||||
|
||||
def request(
|
||||
self, method: str, resource: str, _params=None, **_kwargs: object
|
||||
) -> dict[str, str] | dict:
|
||||
if method == "POST" and resource.endswith("/record"):
|
||||
self.record_requests += 1
|
||||
order.append("record")
|
||||
if resource == "channels/externalMedia":
|
||||
return {"id": "external"}
|
||||
if resource == "channels":
|
||||
return {"id": "target"}
|
||||
return {}
|
||||
|
||||
fake_ari = FakeARI()
|
||||
call_any = cast(Any, call)
|
||||
call_any.ari = fake_ari
|
||||
call_any._start_event_reader = lambda: None
|
||||
call_any._try_set_external_media_peer = lambda: False
|
||||
call_any._add_when_ready = lambda _channel_id: True
|
||||
|
||||
def wait_answer(_result: Any) -> None:
|
||||
order.append("answer")
|
||||
if answer_error is not None:
|
||||
raise answer_error
|
||||
|
||||
call_any._wait_answer = wait_answer
|
||||
call_any._play = lambda _audio: order.append("play")
|
||||
call_any._capture_turn = lambda _timeout: (
|
||||
order.append("capture") or b""
|
||||
)
|
||||
call_any._finish_recording = lambda: None
|
||||
call_any._cleanup = lambda: None
|
||||
return call, fake_ari
|
||||
|
||||
def test_recording_starts_after_answer_before_opening_tts(self) -> None:
|
||||
order: list[str] = []
|
||||
call, fake_ari = self._build_lifecycle_call(order)
|
||||
try:
|
||||
result = call.start_authorized_call("123")
|
||||
finally:
|
||||
if call.media is not None:
|
||||
call.media.close()
|
||||
self.assertTrue(result.connected)
|
||||
self.assertEqual(fake_ari.record_requests, 1)
|
||||
self.assertLess(order.index("answer"), order.index("record"))
|
||||
self.assertLess(order.index("record"), order.index("opening"))
|
||||
|
||||
def test_answer_failure_does_not_start_recording(self) -> None:
|
||||
order: list[str] = []
|
||||
call, fake_ari = self._build_lifecycle_call(
|
||||
order, CellCallError("CALL_NOT_ANSWERED", "not answered")
|
||||
)
|
||||
try:
|
||||
result = call.start_authorized_call("123")
|
||||
finally:
|
||||
if call.media is not None:
|
||||
call.media.close()
|
||||
self.assertFalse(result.connected)
|
||||
self.assertEqual(fake_ari.record_requests, 0)
|
||||
self.assertNotIn("opening", order)
|
||||
|
||||
def test_pcma_decode_and_rtp_payload(self) -> None:
|
||||
media = RTPMedia("127.0.0.1", 0)
|
||||
receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -202,7 +303,7 @@ class RealCellTests(unittest.TestCase):
|
||||
|
||||
def request(self, method: str, resource: str, **_kwargs):
|
||||
self.calls.append((method, resource))
|
||||
return {} if method == "POST" else b"RIFFtest"
|
||||
return {} if method == "POST" else wav_bytes(b"\x00\x00")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = CellCallConfig(
|
||||
@@ -226,6 +327,29 @@ class RealCellTests(unittest.TestCase):
|
||||
("DELETE", fake_ari.calls[0][1].rsplit("/stop", 1)[0]), fake_ari.calls
|
||||
)
|
||||
|
||||
def test_finish_recording_rejects_zero_frame_wav(self) -> None:
|
||||
class FakeARI:
|
||||
def request(self, method: str, resource: str, **_kwargs):
|
||||
return {} if method == "POST" else wav_bytes(b"")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = CellCallConfig(
|
||||
"http://127.0.0.1:8088", "u", "p", recording_dir=directory
|
||||
)
|
||||
call = cast(
|
||||
Any,
|
||||
RealCellCall(
|
||||
config,
|
||||
cast(Any, SimpleNamespace(config={})),
|
||||
cast(Any, SimpleNamespace()),
|
||||
),
|
||||
)
|
||||
call.ari = FakeARI()
|
||||
self.assertIsNone(call._finish_recording())
|
||||
self.assertFalse(
|
||||
(Path(directory) / f"{call.recording_name}.wav").exists()
|
||||
)
|
||||
|
||||
def test_cell_config_rejects_non_pcma_bad_port_and_prefix(self) -> None:
|
||||
with self.assertRaises(CellCallError):
|
||||
CellCallConfig("http://127.0.0.1:8088", "u", "p", rtp_format="ulaw")
|
||||
|
||||
Reference in New Issue
Block a user