fix recording timing and sync call contracts
management-images / build-and-publish (push) Successful in 34s

This commit is contained in:
2026-09-17 17:43:37 +08:00
parent fa6925010b
commit d17bbe15d9
6 changed files with 773 additions and 19 deletions
+11 -2
View File
@@ -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():
+12 -12
View File
@@ -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}
+561
View File
@@ -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}
+1 -1
View File
@@ -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
View File
@@ -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")