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.
Files changed (119) hide show
  1. jambonz_python_sdk-0.2.0.dist-info/METADATA +179 -0
  2. jambonz_python_sdk-0.2.0.dist-info/RECORD +119 -0
  3. jambonz_python_sdk-0.2.0.dist-info/WHEEL +4 -0
  4. jambonz_sdk/__init__.py +52 -0
  5. jambonz_sdk/_signature.py +73 -0
  6. jambonz_sdk/client/__init__.py +15 -0
  7. jambonz_sdk/client/api.py +241 -0
  8. jambonz_sdk/schema/callbacks/amd.schema.json +50 -0
  9. jambonz_sdk/schema/callbacks/base.schema.json +29 -0
  10. jambonz_sdk/schema/callbacks/call-status.schema.json +22 -0
  11. jambonz_sdk/schema/callbacks/conference-status.schema.json +24 -0
  12. jambonz_sdk/schema/callbacks/conference-wait.schema.json +11 -0
  13. jambonz_sdk/schema/callbacks/conference.schema.json +11 -0
  14. jambonz_sdk/schema/callbacks/dequeue.schema.json +19 -0
  15. jambonz_sdk/schema/callbacks/dial-dtmf.schema.json +18 -0
  16. jambonz_sdk/schema/callbacks/dial-hold.schema.json +22 -0
  17. jambonz_sdk/schema/callbacks/dial-refer.schema.json +28 -0
  18. jambonz_sdk/schema/callbacks/dial.schema.json +31 -0
  19. jambonz_sdk/schema/callbacks/enqueue-wait.schema.json +17 -0
  20. jambonz_sdk/schema/callbacks/enqueue.schema.json +27 -0
  21. jambonz_sdk/schema/callbacks/gather-partial.schema.json +54 -0
  22. jambonz_sdk/schema/callbacks/gather.schema.json +60 -0
  23. jambonz_sdk/schema/callbacks/listen.schema.json +21 -0
  24. jambonz_sdk/schema/callbacks/llm.schema.json +30 -0
  25. jambonz_sdk/schema/callbacks/message.schema.json +35 -0
  26. jambonz_sdk/schema/callbacks/pipeline-turn.schema.json +109 -0
  27. jambonz_sdk/schema/callbacks/play.schema.json +36 -0
  28. jambonz_sdk/schema/callbacks/session-new.schema.json +143 -0
  29. jambonz_sdk/schema/callbacks/session-reconnect.schema.json +9 -0
  30. jambonz_sdk/schema/callbacks/session-redirect.schema.json +38 -0
  31. jambonz_sdk/schema/callbacks/sip-refer-event.schema.json +20 -0
  32. jambonz_sdk/schema/callbacks/sip-refer.schema.json +22 -0
  33. jambonz_sdk/schema/callbacks/sip-request.schema.json +27 -0
  34. jambonz_sdk/schema/callbacks/transcribe-translation.schema.json +24 -0
  35. jambonz_sdk/schema/callbacks/transcribe.schema.json +46 -0
  36. jambonz_sdk/schema/callbacks/tts-streaming-event.schema.json +77 -0
  37. jambonz_sdk/schema/callbacks/verb-status.schema.json +57 -0
  38. jambonz_sdk/schema/components/actionHook.schema.json +36 -0
  39. jambonz_sdk/schema/components/actionHookDelayAction.schema.json +37 -0
  40. jambonz_sdk/schema/components/amd.schema.json +68 -0
  41. jambonz_sdk/schema/components/auth.schema.json +18 -0
  42. jambonz_sdk/schema/components/bidirectionalAudio.schema.json +22 -0
  43. jambonz_sdk/schema/components/fillerNoise.schema.json +25 -0
  44. jambonz_sdk/schema/components/llm-base.schema.json +94 -0
  45. jambonz_sdk/schema/components/recognizer-assemblyAiOptions.schema.json +66 -0
  46. jambonz_sdk/schema/components/recognizer-awsOptions.schema.json +52 -0
  47. jambonz_sdk/schema/components/recognizer-azureOptions.schema.json +32 -0
  48. jambonz_sdk/schema/components/recognizer-cobaltOptions.schema.json +34 -0
  49. jambonz_sdk/schema/components/recognizer-customOptions.schema.json +27 -0
  50. jambonz_sdk/schema/components/recognizer-deepgramOptions.schema.json +147 -0
  51. jambonz_sdk/schema/components/recognizer-elevenlabsOptions.schema.json +39 -0
  52. jambonz_sdk/schema/components/recognizer-gladiaOptions.schema.json +8 -0
  53. jambonz_sdk/schema/components/recognizer-googleOptions.schema.json +35 -0
  54. jambonz_sdk/schema/components/recognizer-houndifyOptions.schema.json +53 -0
  55. jambonz_sdk/schema/components/recognizer-ibmOptions.schema.json +54 -0
  56. jambonz_sdk/schema/components/recognizer-nuanceOptions.schema.json +150 -0
  57. jambonz_sdk/schema/components/recognizer-nvidiaOptions.schema.json +39 -0
  58. jambonz_sdk/schema/components/recognizer-openaiOptions.schema.json +59 -0
  59. jambonz_sdk/schema/components/recognizer-sonioxOptions.schema.json +46 -0
  60. jambonz_sdk/schema/components/recognizer-speechmaticsOptions.schema.json +100 -0
  61. jambonz_sdk/schema/components/recognizer-verbioOptions.schema.json +46 -0
  62. jambonz_sdk/schema/components/recognizer.schema.json +216 -0
  63. jambonz_sdk/schema/components/synthesizer.schema.json +82 -0
  64. jambonz_sdk/schema/components/target.schema.json +105 -0
  65. jambonz_sdk/schema/components/vad.schema.json +48 -0
  66. jambonz_sdk/schema/jambonz-app.schema.json +113 -0
  67. jambonz_sdk/schema/verbs/alert.schema.json +34 -0
  68. jambonz_sdk/schema/verbs/answer.schema.json +22 -0
  69. jambonz_sdk/schema/verbs/conference.schema.json +107 -0
  70. jambonz_sdk/schema/verbs/config.schema.json +221 -0
  71. jambonz_sdk/schema/verbs/deepgram_s2s.schema.json +81 -0
  72. jambonz_sdk/schema/verbs/dequeue.schema.json +51 -0
  73. jambonz_sdk/schema/verbs/dial.schema.json +200 -0
  74. jambonz_sdk/schema/verbs/dialogflow.schema.json +148 -0
  75. jambonz_sdk/schema/verbs/dtmf.schema.json +49 -0
  76. jambonz_sdk/schema/verbs/dub.schema.json +103 -0
  77. jambonz_sdk/schema/verbs/elevenlabs_s2s.schema.json +81 -0
  78. jambonz_sdk/schema/verbs/enqueue.schema.json +53 -0
  79. jambonz_sdk/schema/verbs/gather.schema.json +190 -0
  80. jambonz_sdk/schema/verbs/google_s2s.schema.json +42 -0
  81. jambonz_sdk/schema/verbs/hangup.schema.json +36 -0
  82. jambonz_sdk/schema/verbs/leave.schema.json +22 -0
  83. jambonz_sdk/schema/verbs/listen.schema.json +127 -0
  84. jambonz_sdk/schema/verbs/llm.schema.json +44 -0
  85. jambonz_sdk/schema/verbs/message.schema.json +82 -0
  86. jambonz_sdk/schema/verbs/openai_s2s.schema.json +42 -0
  87. jambonz_sdk/schema/verbs/pause.schema.json +36 -0
  88. jambonz_sdk/schema/verbs/pipeline.schema.json +240 -0
  89. jambonz_sdk/schema/verbs/play.schema.json +96 -0
  90. jambonz_sdk/schema/verbs/redirect.schema.json +34 -0
  91. jambonz_sdk/schema/verbs/rest_dial.schema.json +113 -0
  92. jambonz_sdk/schema/verbs/s2s.schema.json +39 -0
  93. jambonz_sdk/schema/verbs/say.schema.json +107 -0
  94. jambonz_sdk/schema/verbs/sip-decline.schema.json +58 -0
  95. jambonz_sdk/schema/verbs/sip-refer.schema.json +58 -0
  96. jambonz_sdk/schema/verbs/sip-request.schema.json +54 -0
  97. jambonz_sdk/schema/verbs/stream.schema.json +103 -0
  98. jambonz_sdk/schema/verbs/tag.schema.json +41 -0
  99. jambonz_sdk/schema/verbs/transcribe.schema.json +57 -0
  100. jambonz_sdk/schema/verbs/ultravox_s2s.schema.json +41 -0
  101. jambonz_sdk/types/__init__.py +139 -0
  102. jambonz_sdk/types/components.py +250 -0
  103. jambonz_sdk/types/rest.py +59 -0
  104. jambonz_sdk/types/session.py +55 -0
  105. jambonz_sdk/types/verbs.py +572 -0
  106. jambonz_sdk/validator.py +107 -0
  107. jambonz_sdk/verb_builder.py +316 -0
  108. jambonz_sdk/verb_builder.pyi +1133 -0
  109. jambonz_sdk/verb_registry.py +102 -0
  110. jambonz_sdk/webhook/__init__.py +10 -0
  111. jambonz_sdk/webhook/middleware.py +63 -0
  112. jambonz_sdk/webhook/response.py +43 -0
  113. jambonz_sdk/websocket/__init__.py +15 -0
  114. jambonz_sdk/websocket/audio_client.py +11 -0
  115. jambonz_sdk/websocket/audio_stream.py +151 -0
  116. jambonz_sdk/websocket/client.py +165 -0
  117. jambonz_sdk/websocket/endpoint.py +193 -0
  118. jambonz_sdk/websocket/router.py +87 -0
  119. 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))