jambonz-python-sdk 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jambonz_python_sdk-0.2.0.dist-info/METADATA +179 -0
- jambonz_python_sdk-0.2.0.dist-info/RECORD +119 -0
- jambonz_python_sdk-0.2.0.dist-info/WHEEL +4 -0
- jambonz_sdk/__init__.py +52 -0
- jambonz_sdk/_signature.py +73 -0
- jambonz_sdk/client/__init__.py +15 -0
- jambonz_sdk/client/api.py +241 -0
- jambonz_sdk/schema/callbacks/amd.schema.json +50 -0
- jambonz_sdk/schema/callbacks/base.schema.json +29 -0
- jambonz_sdk/schema/callbacks/call-status.schema.json +22 -0
- jambonz_sdk/schema/callbacks/conference-status.schema.json +24 -0
- jambonz_sdk/schema/callbacks/conference-wait.schema.json +11 -0
- jambonz_sdk/schema/callbacks/conference.schema.json +11 -0
- jambonz_sdk/schema/callbacks/dequeue.schema.json +19 -0
- jambonz_sdk/schema/callbacks/dial-dtmf.schema.json +18 -0
- jambonz_sdk/schema/callbacks/dial-hold.schema.json +22 -0
- jambonz_sdk/schema/callbacks/dial-refer.schema.json +28 -0
- jambonz_sdk/schema/callbacks/dial.schema.json +31 -0
- jambonz_sdk/schema/callbacks/enqueue-wait.schema.json +17 -0
- jambonz_sdk/schema/callbacks/enqueue.schema.json +27 -0
- jambonz_sdk/schema/callbacks/gather-partial.schema.json +54 -0
- jambonz_sdk/schema/callbacks/gather.schema.json +60 -0
- jambonz_sdk/schema/callbacks/listen.schema.json +21 -0
- jambonz_sdk/schema/callbacks/llm.schema.json +30 -0
- jambonz_sdk/schema/callbacks/message.schema.json +35 -0
- jambonz_sdk/schema/callbacks/pipeline-turn.schema.json +109 -0
- jambonz_sdk/schema/callbacks/play.schema.json +36 -0
- jambonz_sdk/schema/callbacks/session-new.schema.json +143 -0
- jambonz_sdk/schema/callbacks/session-reconnect.schema.json +9 -0
- jambonz_sdk/schema/callbacks/session-redirect.schema.json +38 -0
- jambonz_sdk/schema/callbacks/sip-refer-event.schema.json +20 -0
- jambonz_sdk/schema/callbacks/sip-refer.schema.json +22 -0
- jambonz_sdk/schema/callbacks/sip-request.schema.json +27 -0
- jambonz_sdk/schema/callbacks/transcribe-translation.schema.json +24 -0
- jambonz_sdk/schema/callbacks/transcribe.schema.json +46 -0
- jambonz_sdk/schema/callbacks/tts-streaming-event.schema.json +77 -0
- jambonz_sdk/schema/callbacks/verb-status.schema.json +57 -0
- jambonz_sdk/schema/components/actionHook.schema.json +36 -0
- jambonz_sdk/schema/components/actionHookDelayAction.schema.json +37 -0
- jambonz_sdk/schema/components/amd.schema.json +68 -0
- jambonz_sdk/schema/components/auth.schema.json +18 -0
- jambonz_sdk/schema/components/bidirectionalAudio.schema.json +22 -0
- jambonz_sdk/schema/components/fillerNoise.schema.json +25 -0
- jambonz_sdk/schema/components/llm-base.schema.json +94 -0
- jambonz_sdk/schema/components/recognizer-assemblyAiOptions.schema.json +66 -0
- jambonz_sdk/schema/components/recognizer-awsOptions.schema.json +52 -0
- jambonz_sdk/schema/components/recognizer-azureOptions.schema.json +32 -0
- jambonz_sdk/schema/components/recognizer-cobaltOptions.schema.json +34 -0
- jambonz_sdk/schema/components/recognizer-customOptions.schema.json +27 -0
- jambonz_sdk/schema/components/recognizer-deepgramOptions.schema.json +147 -0
- jambonz_sdk/schema/components/recognizer-elevenlabsOptions.schema.json +39 -0
- jambonz_sdk/schema/components/recognizer-gladiaOptions.schema.json +8 -0
- jambonz_sdk/schema/components/recognizer-googleOptions.schema.json +35 -0
- jambonz_sdk/schema/components/recognizer-houndifyOptions.schema.json +53 -0
- jambonz_sdk/schema/components/recognizer-ibmOptions.schema.json +54 -0
- jambonz_sdk/schema/components/recognizer-nuanceOptions.schema.json +150 -0
- jambonz_sdk/schema/components/recognizer-nvidiaOptions.schema.json +39 -0
- jambonz_sdk/schema/components/recognizer-openaiOptions.schema.json +59 -0
- jambonz_sdk/schema/components/recognizer-sonioxOptions.schema.json +46 -0
- jambonz_sdk/schema/components/recognizer-speechmaticsOptions.schema.json +100 -0
- jambonz_sdk/schema/components/recognizer-verbioOptions.schema.json +46 -0
- jambonz_sdk/schema/components/recognizer.schema.json +216 -0
- jambonz_sdk/schema/components/synthesizer.schema.json +82 -0
- jambonz_sdk/schema/components/target.schema.json +105 -0
- jambonz_sdk/schema/components/vad.schema.json +48 -0
- jambonz_sdk/schema/jambonz-app.schema.json +113 -0
- jambonz_sdk/schema/verbs/alert.schema.json +34 -0
- jambonz_sdk/schema/verbs/answer.schema.json +22 -0
- jambonz_sdk/schema/verbs/conference.schema.json +107 -0
- jambonz_sdk/schema/verbs/config.schema.json +221 -0
- jambonz_sdk/schema/verbs/deepgram_s2s.schema.json +81 -0
- jambonz_sdk/schema/verbs/dequeue.schema.json +51 -0
- jambonz_sdk/schema/verbs/dial.schema.json +200 -0
- jambonz_sdk/schema/verbs/dialogflow.schema.json +148 -0
- jambonz_sdk/schema/verbs/dtmf.schema.json +49 -0
- jambonz_sdk/schema/verbs/dub.schema.json +103 -0
- jambonz_sdk/schema/verbs/elevenlabs_s2s.schema.json +81 -0
- jambonz_sdk/schema/verbs/enqueue.schema.json +53 -0
- jambonz_sdk/schema/verbs/gather.schema.json +190 -0
- jambonz_sdk/schema/verbs/google_s2s.schema.json +42 -0
- jambonz_sdk/schema/verbs/hangup.schema.json +36 -0
- jambonz_sdk/schema/verbs/leave.schema.json +22 -0
- jambonz_sdk/schema/verbs/listen.schema.json +127 -0
- jambonz_sdk/schema/verbs/llm.schema.json +44 -0
- jambonz_sdk/schema/verbs/message.schema.json +82 -0
- jambonz_sdk/schema/verbs/openai_s2s.schema.json +42 -0
- jambonz_sdk/schema/verbs/pause.schema.json +36 -0
- jambonz_sdk/schema/verbs/pipeline.schema.json +240 -0
- jambonz_sdk/schema/verbs/play.schema.json +96 -0
- jambonz_sdk/schema/verbs/redirect.schema.json +34 -0
- jambonz_sdk/schema/verbs/rest_dial.schema.json +113 -0
- jambonz_sdk/schema/verbs/s2s.schema.json +39 -0
- jambonz_sdk/schema/verbs/say.schema.json +107 -0
- jambonz_sdk/schema/verbs/sip-decline.schema.json +58 -0
- jambonz_sdk/schema/verbs/sip-refer.schema.json +58 -0
- jambonz_sdk/schema/verbs/sip-request.schema.json +54 -0
- jambonz_sdk/schema/verbs/stream.schema.json +103 -0
- jambonz_sdk/schema/verbs/tag.schema.json +41 -0
- jambonz_sdk/schema/verbs/transcribe.schema.json +57 -0
- jambonz_sdk/schema/verbs/ultravox_s2s.schema.json +41 -0
- jambonz_sdk/types/__init__.py +139 -0
- jambonz_sdk/types/components.py +250 -0
- jambonz_sdk/types/rest.py +59 -0
- jambonz_sdk/types/session.py +55 -0
- jambonz_sdk/types/verbs.py +572 -0
- jambonz_sdk/validator.py +107 -0
- jambonz_sdk/verb_builder.py +316 -0
- jambonz_sdk/verb_builder.pyi +1133 -0
- jambonz_sdk/verb_registry.py +102 -0
- jambonz_sdk/webhook/__init__.py +10 -0
- jambonz_sdk/webhook/middleware.py +63 -0
- jambonz_sdk/webhook/response.py +43 -0
- jambonz_sdk/websocket/__init__.py +15 -0
- jambonz_sdk/websocket/audio_client.py +11 -0
- jambonz_sdk/websocket/audio_stream.py +151 -0
- jambonz_sdk/websocket/client.py +165 -0
- jambonz_sdk/websocket/endpoint.py +193 -0
- jambonz_sdk/websocket/router.py +87 -0
- jambonz_sdk/websocket/session.py +259 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""create_endpoint factory for WebSocket-based jambonz applications.
|
|
2
|
+
|
|
3
|
+
Sets up a WebSocket server that handles both control (ws.jambonz.org)
|
|
4
|
+
and audio (audio.drachtio.org) subprotocols.
|
|
5
|
+
|
|
6
|
+
For env vars discovery (OPTIONS), a lightweight HTTP server runs alongside
|
|
7
|
+
the WebSocket server on the same port using aiohttp, which hands off
|
|
8
|
+
WebSocket upgrade requests to the websockets library.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from aiohttp import web
|
|
17
|
+
|
|
18
|
+
from jambonz_sdk.websocket.client import WsClient
|
|
19
|
+
from jambonz_sdk.websocket.router import WsRouter, _AudioService
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("jambonz_sdk.websocket.endpoint")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MakeService:
|
|
25
|
+
"""Factory returned by ``create_endpoint`` for registering services.
|
|
26
|
+
|
|
27
|
+
Call this as a function to register control services, or use
|
|
28
|
+
``.audio()`` to register audio services.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, router: WsRouter) -> None:
|
|
32
|
+
self._router = router
|
|
33
|
+
|
|
34
|
+
def __call__(self, *, path: str = "/") -> WsClient:
|
|
35
|
+
"""Register a control WebSocket service at the given path.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
path: URL path to listen on.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A WsClient that emits ``'session:new'`` events.
|
|
42
|
+
"""
|
|
43
|
+
client = WsClient(path)
|
|
44
|
+
self._router.use(path, client)
|
|
45
|
+
return client
|
|
46
|
+
|
|
47
|
+
def audio(self, *, path: str) -> _AudioService:
|
|
48
|
+
"""Register an audio WebSocket service at the given path.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path: URL path for the audio WebSocket.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
An audio service that emits ``'connection'`` events with AudioStream.
|
|
55
|
+
"""
|
|
56
|
+
return self._router.use_audio(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def create_endpoint(
|
|
60
|
+
*,
|
|
61
|
+
host: str = "0.0.0.0",
|
|
62
|
+
port: int = 3000,
|
|
63
|
+
env_vars: dict[str, dict[str, Any]] | None = None,
|
|
64
|
+
logger_: logging.Logger | None = None,
|
|
65
|
+
) -> tuple[MakeService, web.AppRunner]:
|
|
66
|
+
"""Create a WebSocket endpoint for jambonz applications.
|
|
67
|
+
|
|
68
|
+
Starts an HTTP + WebSocket server that handles:
|
|
69
|
+
- OPTIONS requests for env vars discovery
|
|
70
|
+
- WebSocket upgrades for jambonz control (ws.jambonz.org) and audio
|
|
71
|
+
(audio.drachtio.org) subprotocols
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
host: Bind address (default ``"0.0.0.0"``).
|
|
75
|
+
port: Bind port (default ``3000``).
|
|
76
|
+
env_vars: Application environment variable schema for portal discovery.
|
|
77
|
+
logger_: Optional logger instance.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A tuple of ``(make_service, runner)`` where ``make_service`` is used
|
|
81
|
+
to register services and ``runner`` is the aiohttp AppRunner.
|
|
82
|
+
|
|
83
|
+
Example::
|
|
84
|
+
|
|
85
|
+
make_service, runner = await create_endpoint(port=3000)
|
|
86
|
+
svc = make_service(path="/")
|
|
87
|
+
|
|
88
|
+
svc.on("session:new", handle_session)
|
|
89
|
+
|
|
90
|
+
# Server is running; to stop:
|
|
91
|
+
await runner.cleanup()
|
|
92
|
+
"""
|
|
93
|
+
log = logger_ or logger
|
|
94
|
+
router = WsRouter()
|
|
95
|
+
make_service = MakeService(router)
|
|
96
|
+
|
|
97
|
+
app = web.Application()
|
|
98
|
+
|
|
99
|
+
async def handle_options(request: web.Request) -> web.Response:
|
|
100
|
+
"""Respond to OPTIONS with env_vars schema for portal discovery."""
|
|
101
|
+
if env_vars is not None:
|
|
102
|
+
return web.json_response({"env": env_vars})
|
|
103
|
+
return web.Response(status=200)
|
|
104
|
+
|
|
105
|
+
async def handle_websocket(request: web.Request) -> web.WebSocketResponse:
|
|
106
|
+
"""Handle WebSocket upgrade and route to the appropriate service."""
|
|
107
|
+
# Determine subprotocol from the Sec-WebSocket-Protocol header
|
|
108
|
+
requested_protocols = request.headers.get("Sec-WebSocket-Protocol", "")
|
|
109
|
+
protocols = [p.strip() for p in requested_protocols.split(",") if p.strip()]
|
|
110
|
+
|
|
111
|
+
# Pick the best matching subprotocol
|
|
112
|
+
selected_protocol = None
|
|
113
|
+
for proto in ["audio.drachtio.org", "ws.jambonz.org"]:
|
|
114
|
+
if proto in protocols:
|
|
115
|
+
selected_protocol = proto
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
ws = web.WebSocketResponse(protocols=[selected_protocol] if selected_protocol else [])
|
|
119
|
+
await ws.prepare(request)
|
|
120
|
+
|
|
121
|
+
# Wrap aiohttp WS into an adapter that the router can use
|
|
122
|
+
adapter = _AiohttpWsAdapter(ws, request.path, selected_protocol)
|
|
123
|
+
await router.route(adapter)
|
|
124
|
+
|
|
125
|
+
return ws
|
|
126
|
+
|
|
127
|
+
# Register routes: OPTIONS on all paths, and WebSocket GET on all paths
|
|
128
|
+
app.router.add_route("OPTIONS", "/{path:.*}", handle_options)
|
|
129
|
+
app.router.add_route("GET", "/{path:.*}", handle_websocket)
|
|
130
|
+
|
|
131
|
+
runner = web.AppRunner(app)
|
|
132
|
+
await runner.setup()
|
|
133
|
+
site = web.TCPSite(runner, host, port)
|
|
134
|
+
await site.start()
|
|
135
|
+
|
|
136
|
+
log.info("jambonz WebSocket endpoint listening on %s:%d", host, port)
|
|
137
|
+
|
|
138
|
+
return make_service, runner
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _AiohttpWsAdapter:
|
|
142
|
+
"""Adapts an aiohttp WebSocketResponse to the interface expected by WsClient/WsRouter.
|
|
143
|
+
|
|
144
|
+
The router and session code call ``ws.send()``, ``async for msg in ws:``,
|
|
145
|
+
``ws.close()``, and access ``ws.request.path`` and ``ws.subprotocol``.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, ws: web.WebSocketResponse, path: str, subprotocol: str | None) -> None:
|
|
149
|
+
self._ws = ws
|
|
150
|
+
self._path = path
|
|
151
|
+
self._subprotocol = subprotocol
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def subprotocol(self) -> str | None:
|
|
155
|
+
return self._subprotocol
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def request(self) -> Any:
|
|
159
|
+
"""Mimic websockets ServerConnection.request with a .path attribute."""
|
|
160
|
+
return _FakeRequest(self._path)
|
|
161
|
+
|
|
162
|
+
async def send(self, data: str | bytes) -> None:
|
|
163
|
+
if isinstance(data, bytes):
|
|
164
|
+
await self._ws.send_bytes(data)
|
|
165
|
+
else:
|
|
166
|
+
await self._ws.send_str(data)
|
|
167
|
+
|
|
168
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
169
|
+
await self._ws.close(code=code, message=reason.encode() if reason else b"")
|
|
170
|
+
|
|
171
|
+
def __aiter__(self):
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
async def __anext__(self) -> str | bytes:
|
|
175
|
+
from aiohttp import WSMsgType
|
|
176
|
+
|
|
177
|
+
msg = await self._ws.receive()
|
|
178
|
+
if msg.type == WSMsgType.TEXT:
|
|
179
|
+
return msg.data
|
|
180
|
+
elif msg.type == WSMsgType.BINARY:
|
|
181
|
+
return msg.data
|
|
182
|
+
elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
|
|
183
|
+
raise StopAsyncIteration
|
|
184
|
+
elif msg.type == WSMsgType.ERROR:
|
|
185
|
+
raise self._ws.exception() or ConnectionError("WebSocket error")
|
|
186
|
+
raise StopAsyncIteration
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _FakeRequest:
|
|
190
|
+
"""Minimal object with a .path attribute to satisfy router.route()."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, path: str) -> None:
|
|
193
|
+
self.path = path
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""WsRouter - path-based routing for multiple WebSocket services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from jambonz_sdk.websocket.audio_stream import AudioStream
|
|
10
|
+
from jambonz_sdk.websocket.client import WsClient
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("jambonz_sdk.websocket.router")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _AudioService:
|
|
16
|
+
"""Manages audio WebSocket connections for a specific path."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: str) -> None:
|
|
19
|
+
self.path = path
|
|
20
|
+
self._handlers: dict[str, list] = {}
|
|
21
|
+
|
|
22
|
+
def on(self, event: str, handler: object) -> _AudioService:
|
|
23
|
+
if event not in self._handlers:
|
|
24
|
+
self._handlers[event] = []
|
|
25
|
+
self._handlers[event].append(handler)
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
async def handle_connection(self, ws: Any) -> None:
|
|
29
|
+
stream = AudioStream(ws)
|
|
30
|
+
handlers = self._handlers.get("connection", [])
|
|
31
|
+
for handler in handlers:
|
|
32
|
+
result = handler(stream)
|
|
33
|
+
if asyncio.iscoroutine(result):
|
|
34
|
+
await result
|
|
35
|
+
await stream._run()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WsRouter:
|
|
39
|
+
"""Routes incoming WebSocket connections to the appropriate service by path.
|
|
40
|
+
|
|
41
|
+
Supports both control (``ws.jambonz.org``) and audio (``audio.drachtio.org``)
|
|
42
|
+
WebSocket subprotocols.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._services: dict[str, WsClient] = {}
|
|
47
|
+
self._audio_services: dict[str, _AudioService] = {}
|
|
48
|
+
|
|
49
|
+
def use(self, path: str, client: WsClient) -> None:
|
|
50
|
+
"""Register a control service for a path."""
|
|
51
|
+
self._services[path] = client
|
|
52
|
+
|
|
53
|
+
def use_audio(self, path: str) -> _AudioService:
|
|
54
|
+
"""Register an audio service for a path.
|
|
55
|
+
|
|
56
|
+
Returns an _AudioService that emits ``'connection'`` events with
|
|
57
|
+
an AudioStream instance.
|
|
58
|
+
"""
|
|
59
|
+
svc = _AudioService(path)
|
|
60
|
+
self._audio_services[path] = svc
|
|
61
|
+
return svc
|
|
62
|
+
|
|
63
|
+
async def route(self, ws: Any) -> None:
|
|
64
|
+
"""Route a WebSocket connection to the appropriate service.
|
|
65
|
+
|
|
66
|
+
Called by the WebSocket server for each new connection.
|
|
67
|
+
"""
|
|
68
|
+
path = ws.request.path if ws.request else "/"
|
|
69
|
+
|
|
70
|
+
# Check subprotocol to determine if this is audio or control
|
|
71
|
+
subprotocol = ws.subprotocol
|
|
72
|
+
|
|
73
|
+
if subprotocol == "audio.drachtio.org":
|
|
74
|
+
svc = self._audio_services.get(path)
|
|
75
|
+
if svc:
|
|
76
|
+
await svc.handle_connection(ws)
|
|
77
|
+
else:
|
|
78
|
+
logger.warning("No audio service for path: %s", path)
|
|
79
|
+
await ws.close(4004, "No audio service for path")
|
|
80
|
+
else:
|
|
81
|
+
# Default to control protocol
|
|
82
|
+
svc_ctrl = self._services.get(path)
|
|
83
|
+
if svc_ctrl:
|
|
84
|
+
await svc_ctrl.handle_connection(ws)
|
|
85
|
+
else:
|
|
86
|
+
logger.warning("No service for path: %s", path)
|
|
87
|
+
await ws.close(4004, "No service for path")
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""WebSocket Session class representing a single call.
|
|
2
|
+
|
|
3
|
+
A Session is created for each incoming call over a persistent WebSocket
|
|
4
|
+
connection. It extends VerbBuilder with send/reply semantics and event
|
|
5
|
+
handling for actionHook callbacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jambonz_sdk.verb_builder import VerbBuilder
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("jambonz_sdk.websocket.session")
|
|
18
|
+
|
|
19
|
+
EventHandler = Callable[..., Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Session(VerbBuilder):
|
|
23
|
+
"""Represents a single call session over WebSocket.
|
|
24
|
+
|
|
25
|
+
Provides verb building (inherited from VerbBuilder), message sending,
|
|
26
|
+
and event-based actionHook handling.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
call_sid: Unique call identifier.
|
|
30
|
+
account_sid: Account identifier.
|
|
31
|
+
application_sid: Application identifier.
|
|
32
|
+
direction: Call direction ("inbound" or "outbound").
|
|
33
|
+
from_: Caller phone number or SIP URI.
|
|
34
|
+
to: Called phone number or SIP URI.
|
|
35
|
+
call_id: SIP Call-ID.
|
|
36
|
+
data: Full session data from session:new message.
|
|
37
|
+
locals: Application-local storage dict for the session.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
ws: Any,
|
|
43
|
+
data: dict[str, Any],
|
|
44
|
+
msgid: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self._ws = ws
|
|
48
|
+
self._msgid = msgid
|
|
49
|
+
self._handlers: dict[str, list[EventHandler]] = {}
|
|
50
|
+
|
|
51
|
+
# Extract call properties from session data
|
|
52
|
+
self.data = data
|
|
53
|
+
self.call_sid: str = data.get("call_sid", "")
|
|
54
|
+
self.account_sid: str = data.get("account_sid", "")
|
|
55
|
+
self.application_sid: str = data.get("application_sid", "")
|
|
56
|
+
self.direction: str = data.get("direction", "")
|
|
57
|
+
self.from_: str = data.get("from", "")
|
|
58
|
+
self.to: str = data.get("to", "")
|
|
59
|
+
self.call_id: str = data.get("call_id", "")
|
|
60
|
+
self.b3: str = data.get("b3", "")
|
|
61
|
+
self.locals: dict[str, Any] = {}
|
|
62
|
+
|
|
63
|
+
def on(self, event: str, handler: EventHandler) -> Session:
|
|
64
|
+
"""Register an event handler for an actionHook or lifecycle event.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
event: Event name (e.g., ``'/gather-result'``, ``'close'``, ``'error'``).
|
|
68
|
+
handler: Callback function to invoke when the event fires.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
self for chaining.
|
|
72
|
+
"""
|
|
73
|
+
if event not in self._handlers:
|
|
74
|
+
self._handlers[event] = []
|
|
75
|
+
self._handlers[event].append(handler)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def _emit(self, event: str, *args: Any) -> bool:
|
|
79
|
+
"""Emit an event to registered handlers.
|
|
80
|
+
|
|
81
|
+
Returns True if any handler was called.
|
|
82
|
+
"""
|
|
83
|
+
handlers = self._handlers.get(event, [])
|
|
84
|
+
for handler in handlers:
|
|
85
|
+
handler(*args)
|
|
86
|
+
return bool(handlers)
|
|
87
|
+
|
|
88
|
+
async def _emit_async(self, event: str, *args: Any) -> bool:
|
|
89
|
+
"""Emit an event, awaiting async handlers.
|
|
90
|
+
|
|
91
|
+
Returns True if any handler was called.
|
|
92
|
+
"""
|
|
93
|
+
import asyncio
|
|
94
|
+
|
|
95
|
+
handlers = self._handlers.get(event, [])
|
|
96
|
+
for handler in handlers:
|
|
97
|
+
result = handler(*args)
|
|
98
|
+
if asyncio.iscoroutine(result):
|
|
99
|
+
await result
|
|
100
|
+
return bool(handlers)
|
|
101
|
+
|
|
102
|
+
async def send(self, **opts: Any) -> None:
|
|
103
|
+
"""Send the initial verb array (response to session:new).
|
|
104
|
+
|
|
105
|
+
This should be called exactly once per session, after building
|
|
106
|
+
the initial verb chain. Use ``.reply()`` for all subsequent
|
|
107
|
+
responses (actionHook events).
|
|
108
|
+
"""
|
|
109
|
+
verbs = self.to_list()
|
|
110
|
+
msg = {
|
|
111
|
+
"type": "ack",
|
|
112
|
+
"msgid": self._msgid,
|
|
113
|
+
"data": verbs,
|
|
114
|
+
}
|
|
115
|
+
if opts:
|
|
116
|
+
msg.update(opts)
|
|
117
|
+
await self._ws.send(json.dumps(msg))
|
|
118
|
+
|
|
119
|
+
async def reply(self, **opts: Any) -> None:
|
|
120
|
+
"""Reply to an actionHook event with the next verb array.
|
|
121
|
+
|
|
122
|
+
Must be called after receiving a verb:hook event. Uses the
|
|
123
|
+
most recently received message ID for correlation.
|
|
124
|
+
"""
|
|
125
|
+
verbs = self.to_list()
|
|
126
|
+
msg = {
|
|
127
|
+
"type": "ack",
|
|
128
|
+
"msgid": self._msgid,
|
|
129
|
+
"data": verbs,
|
|
130
|
+
}
|
|
131
|
+
if opts:
|
|
132
|
+
msg.update(opts)
|
|
133
|
+
await self._ws.send(json.dumps(msg))
|
|
134
|
+
|
|
135
|
+
def _update_msgid(self, msgid: str) -> None:
|
|
136
|
+
"""Update the current message ID for reply correlation."""
|
|
137
|
+
self._msgid = msgid
|
|
138
|
+
|
|
139
|
+
# ── Inject Commands ─────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async def inject_command(self, command: str, data: dict[str, Any] | None = None) -> None:
|
|
142
|
+
"""Send an immediate command (bypasses verb queue)."""
|
|
143
|
+
msg: dict[str, Any] = {"type": "command", "command": command}
|
|
144
|
+
if data:
|
|
145
|
+
msg["data"] = data
|
|
146
|
+
await self._ws.send(json.dumps(msg))
|
|
147
|
+
|
|
148
|
+
async def inject_record(self, action: str, data: dict[str, Any] | None = None) -> None:
|
|
149
|
+
"""Control call recording.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
action: One of 'startCallRecording', 'stopCallRecording',
|
|
153
|
+
'pauseCallRecording', 'resumeCallRecording'.
|
|
154
|
+
data: Additional recording options (e.g., siprecServerURL).
|
|
155
|
+
"""
|
|
156
|
+
cmd_data: dict[str, Any] = {"action": action}
|
|
157
|
+
if data:
|
|
158
|
+
cmd_data.update(data)
|
|
159
|
+
await self.inject_command("record", cmd_data)
|
|
160
|
+
|
|
161
|
+
async def inject_whisper(self, verb: dict[str, Any]) -> None:
|
|
162
|
+
"""Inject a whisper verb (say/play) to one party."""
|
|
163
|
+
await self.inject_command("whisper", {"whisper": verb})
|
|
164
|
+
|
|
165
|
+
async def inject_mute(self, status: str) -> None:
|
|
166
|
+
"""Mute or unmute the call.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
status: ``'mute'`` or ``'unmute'``.
|
|
170
|
+
"""
|
|
171
|
+
await self.inject_command("mute", {"mute_status": status})
|
|
172
|
+
|
|
173
|
+
async def inject_listen_status(self, status: str) -> None:
|
|
174
|
+
"""Pause or resume audio streaming.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
status: ``'pause'`` or ``'resume'``.
|
|
178
|
+
"""
|
|
179
|
+
await self.inject_command("listen:status", {"listen_status": status})
|
|
180
|
+
|
|
181
|
+
async def inject_noise_isolation(
|
|
182
|
+
self, status: str, opts: dict[str, Any] | None = None
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Enable or disable noise isolation.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
status: ``'on'`` or ``'off'``.
|
|
188
|
+
opts: Additional options (vendor, level, etc.).
|
|
189
|
+
"""
|
|
190
|
+
data: dict[str, Any] = {"noiseIsolation_status": status}
|
|
191
|
+
if opts:
|
|
192
|
+
data.update(opts)
|
|
193
|
+
await self.inject_command("noiseIsolation:status", data)
|
|
194
|
+
|
|
195
|
+
async def inject_dtmf(self, dtmf: str) -> None:
|
|
196
|
+
"""Send DTMF digits."""
|
|
197
|
+
await self.inject_command("dtmf", {"dtmf": dtmf})
|
|
198
|
+
|
|
199
|
+
async def inject_tag(self, data: dict[str, Any]) -> None:
|
|
200
|
+
"""Attach metadata to the call."""
|
|
201
|
+
await self.inject_command("tag", data)
|
|
202
|
+
|
|
203
|
+
async def inject_redirect(self, hook: str) -> None:
|
|
204
|
+
"""Redirect the call to a new webhook."""
|
|
205
|
+
await self.inject_command("redirect", {"call_hook": hook})
|
|
206
|
+
|
|
207
|
+
# ── TTS Token Streaming ─────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async def send_tts_tokens(self, text: str, **opts: Any) -> None:
|
|
210
|
+
"""Stream TTS text tokens for incremental synthesis."""
|
|
211
|
+
msg: dict[str, Any] = {"type": "tts:tokens", "data": {"tokens": text}}
|
|
212
|
+
if opts:
|
|
213
|
+
msg["data"].update(opts)
|
|
214
|
+
await self._ws.send(json.dumps(msg))
|
|
215
|
+
|
|
216
|
+
async def flush_tts_tokens(self, **opts: Any) -> None:
|
|
217
|
+
"""Signal end of a TTS token stream."""
|
|
218
|
+
msg: dict[str, Any] = {"type": "tts:flush", "data": {}}
|
|
219
|
+
if opts:
|
|
220
|
+
msg["data"].update(opts)
|
|
221
|
+
await self._ws.send(json.dumps(msg))
|
|
222
|
+
|
|
223
|
+
async def clear_tts_tokens(self) -> None:
|
|
224
|
+
"""Clear pending TTS tokens."""
|
|
225
|
+
await self._ws.send(json.dumps({"type": "tts:clear", "data": {}}))
|
|
226
|
+
|
|
227
|
+
# ── LLM Tool Output ────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
async def tool_output(self, tool_call_id: str, result: Any) -> Session:
|
|
230
|
+
"""Return a tool call result to the pipeline LLM.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
tool_call_id: The tool_call_id from the llm:tool-call event.
|
|
234
|
+
result: The tool result (will be JSON-serialized).
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
self for chaining with .reply().
|
|
238
|
+
"""
|
|
239
|
+
msg = {
|
|
240
|
+
"type": "llm:tool-output",
|
|
241
|
+
"data": {
|
|
242
|
+
"tool_call_id": tool_call_id,
|
|
243
|
+
"output": result,
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
await self._ws.send(json.dumps(msg))
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
# ── Pipeline Updates ────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async def update_pipeline(self, data: dict[str, Any]) -> None:
|
|
252
|
+
"""Send a mid-conversation pipeline update.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
data: Update payload with ``type`` key (e.g., 'update_instructions',
|
|
256
|
+
'inject_context', 'update_tools', 'generate_reply').
|
|
257
|
+
"""
|
|
258
|
+
msg = {"type": "pipeline:update", "data": data}
|
|
259
|
+
await self._ws.send(json.dumps(msg))
|