guava-sdk 0.20.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 (55) hide show
  1. guava/__init__.py +9 -0
  2. guava/agent.py +592 -0
  3. guava/call.py +227 -0
  4. guava/call_controller.py +538 -0
  5. guava/campaigns.py +139 -0
  6. guava/client.py +437 -0
  7. guava/commands.py +125 -0
  8. guava/devaudio/sounddevice.py +183 -0
  9. guava/events.py +162 -0
  10. guava/examples/agent/help_desk.py +77 -0
  11. guava/examples/agent/polling_campaign.py +116 -0
  12. guava/examples/agent/property_insurance.py +28 -0
  13. guava/examples/agent/restaurant_waitlist.py +74 -0
  14. guava/examples/agent/scheduling_outbound.py +68 -0
  15. guava/examples/credit_card_activation.py +155 -0
  16. guava/examples/example_data.py +804 -0
  17. guava/examples/inbound_sip.py +21 -0
  18. guava/examples/inbound_webrtc.py +25 -0
  19. guava/examples/mock_appointments.py +35 -0
  20. guava/examples/polling.py +140 -0
  21. guava/examples/property_insurance.py +44 -0
  22. guava/examples/scheduling_inbound.py +51 -0
  23. guava/examples/scheduling_outbound.py +77 -0
  24. guava/examples/thai_palace.py +76 -0
  25. guava/guavadialer_events.py +42 -0
  26. guava/helpers/beta.py +10 -0
  27. guava/helpers/chromadb.py +89 -0
  28. guava/helpers/fastapi.py +82 -0
  29. guava/helpers/genai.py +190 -0
  30. guava/helpers/lancedb.py +106 -0
  31. guava/helpers/openai.py +259 -0
  32. guava/helpers/pgvector.py +103 -0
  33. guava/helpers/pinecone.py +135 -0
  34. guava/helpers/rag.py +422 -0
  35. guava/helpers/server_rag.py +180 -0
  36. guava/helpers/vertexai.py +97 -0
  37. guava/listen_inbound.py +51 -0
  38. guava/logging_utils.py +76 -0
  39. guava/py.typed +0 -0
  40. guava/socket/client.py +349 -0
  41. guava/socket/protocol.py +66 -0
  42. guava/socket/utils.py +20 -0
  43. guava/telemetry.py +159 -0
  44. guava/terminal_call.py +111 -0
  45. guava/threading_utils.py +17 -0
  46. guava/tools/__init__.py +0 -0
  47. guava/tools/create_sip_agent.py +5 -0
  48. guava/tools/create_webrtc_agent.py +5 -0
  49. guava/types/__init__.py +115 -0
  50. guava/types/call_info.py +33 -0
  51. guava/types/incoming_call_action.py +16 -0
  52. guava/utils.py +93 -0
  53. guava_sdk-0.20.0.dist-info/METADATA +63 -0
  54. guava_sdk-0.20.0.dist-info/RECORD +55 -0
  55. guava_sdk-0.20.0.dist-info/WHEEL +4 -0
guava/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .client import Client
2
+ from .types import Field, Say, Todo
3
+ from .call_controller import CallController
4
+ from .types.call_info import CallInfo
5
+ from .types.incoming_call_action import IncomingCallAction, AcceptCall, DeclineCall
6
+ from .agent import Agent, SuggestedAction
7
+ from .call import Call
8
+
9
+ __all__ = ["CallController", "Client", "Field", "Say", "Todo", "CallInfo", "IncomingCallAction", "AcceptCall", "DeclineCall", "Agent", "Call", "SuggestedAction"]
guava/agent.py ADDED
@@ -0,0 +1,592 @@
1
+ from datetime import timedelta
2
+ import logging
3
+ import threading
4
+ import httpx
5
+ import time
6
+
7
+ from typing import Callable, overload, Optional, Any
8
+ from typing_extensions import Self
9
+ from .telemetry import telemetry_client
10
+ from guava.types.call_info import CallInfo
11
+ from guava.types.incoming_call_action import IncomingCallAction, AcceptCall, DeclineCall
12
+ from guava import Client
13
+ from guava.utils import check_exactly_one
14
+ from urllib.parse import urlencode
15
+ from guava.socket.client import GuavaSocket, GuavaSocketClosedError
16
+ from . import listen_inbound
17
+ from guava.call import Call
18
+ from .utils import check_response, is_jsonable
19
+ from guava import campaigns, guavadialer_events
20
+ from pydantic import BaseModel
21
+
22
+ from .events import (
23
+ Event,
24
+ CallerSpeechEvent,
25
+ AgentSpeechEvent,
26
+ TaskCompletedEvent,
27
+ ActionItemCompletedEvent,
28
+ AgentQuestionEvent,
29
+ ActionRequestEvent,
30
+ ChoiceQueryEvent,
31
+ ExecuteActionEvent,
32
+ ErrorEvent,
33
+ WarningEvent,
34
+ BotSessionEnded,
35
+ OutboundCallFailed,
36
+ OutboundCallConnected,
37
+ decode_event_dict,
38
+ )
39
+ from .commands import (
40
+ Command,
41
+ RegisteredHooksCommand,
42
+ AnswerQuestionCommand,
43
+ ChoiceResultCommand,
44
+ ActionSuggestionCommand,
45
+ )
46
+ from guava.call_controller import CommandQueueEnd
47
+
48
+ logger = logging.getLogger("guava.agent")
49
+
50
+ class SuggestedAction(BaseModel):
51
+ key: str
52
+ description: str | None = None
53
+
54
+ @telemetry_client.track_class()
55
+ class Agent:
56
+ def __init__(self, name: Optional[str] = None, organization: Optional[str] = None, purpose: Optional[str] = None):
57
+ self._name: Optional[str] = name
58
+ self._organization: Optional[str] = organization
59
+ self._purpose: Optional[str] = purpose
60
+
61
+ self._client = Client()
62
+
63
+ self._on_call_received: Callable[[CallInfo], IncomingCallAction] = self.default_on_call_received
64
+ self._on_call_start: Optional[Callable[[Call], None]] = None
65
+
66
+ self._on_caller_speech: Optional[Callable[[Call, CallerSpeechEvent], None]] = None
67
+ self._on_agent_speech: Optional[Callable[[Call, AgentSpeechEvent], None]] = None
68
+
69
+ self._on_task_complete_generic: Optional[Callable[[Call, str], None]] = None
70
+ self._on_task_complete_handlers: dict[str, Callable[[Call], None]] = {}
71
+
72
+ self._on_question: Optional[Callable[[Call, str], str]] = None
73
+ self._search_query_handlers: dict[str, Callable[[Call, str], tuple]] = {}
74
+
75
+ self._on_action_requested: Optional[Callable[[Call, str], SuggestedAction | None]] = None
76
+
77
+ self._on_action_generic: Optional[Callable[[Call, str], None]] = None
78
+ self._on_action_handlers: dict[str, Callable[[Call], None]] = {}
79
+
80
+ self._on_session_end: Optional[Callable[[Call], None]] = None
81
+ self._on_outbound_failed: Optional[Callable[[OutboundCallFailed], None]] = None
82
+
83
+ self._threads: list[threading.Thread] = []
84
+
85
+ @overload
86
+ def on_call_received(self, fn: Callable[[CallInfo], IncomingCallAction], /) -> Callable[[CallInfo], IncomingCallAction]: ...
87
+ @overload
88
+ def on_call_received(self) -> Callable[[Callable[[CallInfo], IncomingCallAction]], Callable[[CallInfo], IncomingCallAction]]: ...
89
+
90
+ def _register(self, attr: str, fn):
91
+ """Shared logic for un-keyed decorators."""
92
+ if fn is not None and callable(fn):
93
+ # This handles the bare decorator case.
94
+ setattr(self, attr, fn)
95
+ return fn
96
+
97
+ def decorator(fn):
98
+ # This handles the decorator with parens case.
99
+ setattr(self, attr, fn)
100
+ return fn
101
+
102
+ return decorator
103
+
104
+ def on_call_received(self, fn=None):
105
+ return self._register("_on_call_received", fn)
106
+
107
+ def default_on_call_received(self, call_info: CallInfo) -> IncomingCallAction:
108
+ return AcceptCall()
109
+
110
+ @overload
111
+ def on_call_start(self, fn: Callable[[Call], None], /) -> Callable[[Call], None]: ...
112
+ @overload
113
+ def on_call_start(self) -> Callable[[Callable[[Call], None]], Callable[[Call], None]]: ...
114
+
115
+ def on_call_start(self, fn=None):
116
+ return self._register("_on_call_start", fn)
117
+
118
+ @overload
119
+ def on_task_complete(self, fn: Callable[[Call, str], None], /) -> Callable[[Call, str], None]: ...
120
+ @overload
121
+ def on_task_complete(self) -> Callable[[Callable[[Call, str], None]], Callable[[Call, str], None]]: ...
122
+ @overload
123
+ def on_task_complete(self, task_name: str) -> Callable[[Callable[[Call], None]], Callable[[Call], None]]: ...
124
+
125
+ @overload
126
+ def on_question(self, fn: Callable[[Call, str], str], /) -> Callable[[Call, str], str]: ...
127
+ @overload
128
+ def on_question(self) -> Callable[[Callable[[Call, str], str]], Callable[[Call, str], str]]: ...
129
+
130
+ def on_question(self, fn=None):
131
+ return self._register("_on_question", fn)
132
+
133
+ @overload
134
+ def on_action_requested(self, fn: Callable[[Call, str], SuggestedAction | None], /) -> Callable[[Call, str], SuggestedAction | None]: ...
135
+ @overload
136
+ def on_action_requested(self) -> Callable[[Callable[[Call, str], SuggestedAction | None]], Callable[[Call, str], SuggestedAction | None]]: ...
137
+
138
+ def on_action_requested(self, fn=None):
139
+ return self._register("_on_action_requested", fn)
140
+
141
+ @overload
142
+ def on_session_end(self, fn: Callable[[Call], None], /) -> Callable[[Call], None]: ...
143
+ @overload
144
+ def on_session_end(self) -> Callable[[Callable[[Call], None]], Callable[[Call], None]]: ...
145
+
146
+ def on_session_end(self, fn=None):
147
+ return self._register("_on_session_end", fn)
148
+
149
+ @overload
150
+ def on_outbound_failed(self, fn: Callable[[OutboundCallFailed], None], /) -> Callable[[OutboundCallFailed], None]: ...
151
+ @overload
152
+ def on_outbound_failed(self) -> Callable[[Callable[[OutboundCallFailed], None]], Callable[[OutboundCallFailed], None]]: ...
153
+
154
+ def on_outbound_failed(self, fn=None):
155
+ return self._register("_on_outbound_failed", fn)
156
+
157
+ def on_search_query(self, field_key: str) -> Callable[[Callable[[Call, str], tuple]], Callable[[Call, str], tuple]]:
158
+ def decorator(fn: Callable[[Call, str], tuple]):
159
+ self._search_query_handlers[field_key] = fn
160
+ return fn
161
+ return decorator
162
+
163
+ @overload
164
+ def on_reach_person(self, fn: Callable[[Call, str], None], /) -> Callable[[Call, str], None]: ...
165
+ @overload
166
+ def on_reach_person(self) -> Callable[[Callable[[Call, str], None]], Callable[[Call, str], None]]: ...
167
+
168
+ def on_reach_person(self, fn=None):
169
+ def register(fn):
170
+ def handler(call: Call):
171
+ fn(call, call.get_field("contact_availability"))
172
+ self.on_task_complete("reach_person")(handler)
173
+ return fn
174
+
175
+ if fn is not None and callable(fn):
176
+ return register(fn)
177
+ return register
178
+
179
+ def on_task_complete(self, fn_or_task_name=None):
180
+ _mix_err = "Cannot mix a generic on_task_complete handler with per-task handlers."
181
+ if fn_or_task_name is None:
182
+ # @agent.on_task_complete()
183
+ if self._on_task_complete_handlers:
184
+ raise TypeError(_mix_err)
185
+ def decorator(fn):
186
+ self._on_task_complete_generic = fn
187
+ return fn
188
+ return decorator
189
+ elif callable(fn_or_task_name):
190
+ # @agent.on_task_complete (bare)
191
+ if self._on_task_complete_handlers:
192
+ raise TypeError(_mix_err)
193
+ self._on_task_complete_generic = fn_or_task_name
194
+ return fn_or_task_name
195
+ else:
196
+ # @agent.on_task_complete("task_name")
197
+ task_name = fn_or_task_name
198
+ if self._on_task_complete_generic is not None:
199
+ raise TypeError(_mix_err)
200
+ def decorator(fn):
201
+ self._on_task_complete_handlers[task_name] = fn
202
+ return fn
203
+ return decorator
204
+
205
+ @overload
206
+ def on_action(self, fn: Callable[[Call, str], None], /) -> Callable[[Call, str], None]: ...
207
+ @overload
208
+ def on_action(self) -> Callable[[Callable[[Call, str], None]], Callable[[Call, str], None]]: ...
209
+ @overload
210
+ def on_action(self, action_key: str) -> Callable[[Callable[[Call], None]], Callable[[Call], None]]: ...
211
+
212
+ def on_action(self, fn_or_action_key=None):
213
+ _mix_err = "Cannot mix a generic on_action handler with per-action handlers."
214
+ if fn_or_action_key is None:
215
+ # @agent.on_action()
216
+ if self._on_action_handlers:
217
+ raise TypeError(_mix_err)
218
+ def decorator(fn):
219
+ self._on_action_generic = fn
220
+ return fn
221
+ return decorator
222
+ elif callable(fn_or_action_key):
223
+ # @agent.on_action (bare)
224
+ if self._on_action_handlers:
225
+ raise TypeError(_mix_err)
226
+ self._on_action_generic = fn_or_action_key
227
+ return fn_or_action_key
228
+ else:
229
+ # @agent.on_action("action_key")
230
+ action_key = fn_or_action_key
231
+ if self._on_action_generic is not None:
232
+ raise TypeError(_mix_err)
233
+ def decorator(fn):
234
+ self._on_action_handlers[action_key] = fn
235
+ return fn
236
+ return decorator
237
+
238
+
239
+ def _attach_to_call(self, call_id: str, initial_variables: dict = {}, route="v2/connect-call"):
240
+ """Attach a call controller to a given call ID."""
241
+ try:
242
+ command_thread = None
243
+
244
+ call = Call()
245
+ call.set_persona(
246
+ agent_name=self._name,
247
+ agent_purpose=self._purpose,
248
+ organization_name=self._organization
249
+ )
250
+ call.send_command(
251
+ RegisteredHooksCommand(
252
+ has_on_question=self._on_question is not None,
253
+ has_on_intent=False,
254
+ has_on_action_requested=self._on_action_requested is not None,
255
+ )
256
+ )
257
+ for key, value in initial_variables.items():
258
+ call.set_variable(key, value)
259
+
260
+ if self._on_call_start is not None:
261
+ self._on_call_start(call)
262
+
263
+ with GuavaSocket[Command, Event | None](
264
+ f"call-connection-{call_id}",
265
+ self._client.get_websocket_url(f"{route}/{call_id}"),
266
+ headers=self._client._get_headers(),
267
+ serializer=lambda command: command.model_dump(),
268
+ deserializer=lambda e: decode_event_dict(e),
269
+ max_age_seconds=18000 # Conservatively kill the connection after 5 hours.
270
+ ) as gs:
271
+
272
+ def drain_commands():
273
+ while gs.is_open():
274
+ command: Command | CommandQueueEnd = call._command_queue.get(block=True)
275
+ if isinstance(command, CommandQueueEnd):
276
+ break
277
+
278
+ logger.debug("Sending command: %r for call ID: %s", command, call_id)
279
+ gs.send(command)
280
+
281
+ # On a background thread, drain commands to the websocket.
282
+ command_thread = threading.Thread(target=drain_commands, daemon=True)
283
+ command_thread.start()
284
+
285
+ # Receive and dispatch events on the main thread.
286
+ while gs.is_open():
287
+ event = gs.recv()
288
+
289
+ if event is None:
290
+ continue
291
+
292
+ match event:
293
+ case CallerSpeechEvent():
294
+ if self._on_caller_speech:
295
+ self._on_caller_speech(call, event)
296
+ case AgentSpeechEvent():
297
+ if self._on_agent_speech:
298
+ self._on_agent_speech(call, event)
299
+ case TaskCompletedEvent():
300
+ logger.info("Task %s completed.", event.task_id)
301
+ if self._on_task_complete_generic is not None:
302
+ self._on_task_complete_generic(call, event.task_id)
303
+ elif event.task_id in self._on_task_complete_handlers:
304
+ self._on_task_complete_handlers[event.task_id](call)
305
+ else:
306
+ logger.warning("No handler registered for completion of task '%s'", event.task_id)
307
+ case AgentQuestionEvent():
308
+ if self._on_question is not None:
309
+ try:
310
+ logger.info("Received question from bot: %s", event.question)
311
+ answer = self._on_question(call, event.question)
312
+ call.send_command(AnswerQuestionCommand(question_id=event.question_id, answer=answer))
313
+ except Exception:
314
+ logger.exception("Error occurred while answering question.")
315
+ call.send_command(AnswerQuestionCommand(question_id=event.question_id, answer="An error occurred and the question could not be answered."))
316
+ else:
317
+ logger.warning("Received question but no on_question handler is registered: %s", event.question)
318
+ call.send_command(AnswerQuestionCommand(question_id=event.question_id, answer="I don't have an answer to that question."))
319
+ case ActionRequestEvent():
320
+ logger.info("Received action request %s: %s", event.intent_id, event.intent_summary)
321
+ if self._on_action_requested is not None:
322
+ suggestion = self._on_action_requested(call, event.intent_summary)
323
+ if suggestion is not None:
324
+ call.send_command(ActionSuggestionCommand(
325
+ intent_id=event.intent_id,
326
+ action_key=suggestion.key,
327
+ action_description=suggestion.description or "",
328
+ ))
329
+ else:
330
+ call.send_command(ActionSuggestionCommand(intent_id=event.intent_id, action_key=None))
331
+ else:
332
+ call.send_command(ActionSuggestionCommand(intent_id=event.intent_id, action_key=None))
333
+ case ActionItemCompletedEvent():
334
+ call._field_values[event.key] = event.payload
335
+ if event.key and event.payload:
336
+ logger.info("Field %s updated with value: %r", event.key, event.payload)
337
+ case ExecuteActionEvent():
338
+ logger.info("Executing action '%s'", event.action_key)
339
+ if self._on_action_generic is not None:
340
+ self._on_action_generic(call, event.action_key)
341
+ elif event.action_key in self._on_action_handlers:
342
+ self._on_action_handlers[event.action_key](call)
343
+ else:
344
+ logger.warning("No handler registered for action '%s'", event.action_key)
345
+ case BotSessionEnded():
346
+ logger.info("Session ended: %s", event.termination_reason)
347
+ if self._on_session_end is not None:
348
+ self._on_session_end(call)
349
+ break
350
+ case OutboundCallFailed():
351
+ logger.error("Outbound call failed: %s", event.error_reason)
352
+ if self._on_outbound_failed is not None:
353
+ self._on_outbound_failed(event)
354
+ break
355
+ case ErrorEvent():
356
+ logger.error("Received error event: %s", event.content)
357
+ case WarningEvent():
358
+ logger.warning("Received warning event: %s", event.content)
359
+ case OutboundCallConnected():
360
+ # No handler for this yet.
361
+ pass
362
+ case ChoiceQueryEvent():
363
+ logger.info("Received search query for field '%s': %s", event.field_key, event.query)
364
+ handler = self._search_query_handlers.get(event.field_key)
365
+ if handler is None:
366
+ logger.warning("Search query arrived for field '%s' with no handler attached.", event.field_key)
367
+ else:
368
+ choices, other_choices = handler(call, event.query)
369
+ call.send_command(ChoiceResultCommand(
370
+ field_key=event.field_key,
371
+ query_id=event.query_id,
372
+ matched_choices=choices,
373
+ other_choices=other_choices,
374
+ ))
375
+ case _:
376
+ logger.warning("Received unexpected event: %r", event)
377
+ finally:
378
+ call._shutdown_queue()
379
+ if command_thread:
380
+ command_thread.join()
381
+
382
+ def inbound_phone(self, agent_number: str) -> Self:
383
+ self._threads.append(
384
+ threading.Thread(target=self._listen_inbound, kwargs={
385
+ "agent_number": agent_number
386
+ }, daemon=True)
387
+ )
388
+ return self
389
+
390
+ def inbound_webrtc(self, webrtc_code: str | None = None) -> Self:
391
+ if not webrtc_code:
392
+ logger.info("No WebRTC code provided. Creating a temporary one.")
393
+ webrtc_code = self._client.create_webrtc_agent(ttl=timedelta(hours=1))
394
+
395
+ self._threads.append(
396
+ threading.Thread(target=self._listen_inbound, kwargs={
397
+ "webrtc_code": webrtc_code
398
+ }, daemon=True)
399
+ )
400
+ return self
401
+
402
+ def inbound_sip(self, sip_code: str) -> Self:
403
+ self._threads.append(
404
+ threading.Thread(target=self._listen_inbound, kwargs={
405
+ "sip_code": sip_code
406
+ }, daemon=True)
407
+ )
408
+ return self
409
+
410
+ def local_call(self) -> Self:
411
+ import sys
412
+ import importlib.util
413
+
414
+ # First check that required deps are available.
415
+ required_packages = ["aiortc", "sounddevice", "numpy"]
416
+ needed_packages = [pkg for pkg in required_packages if importlib.util.find_spec(pkg) is None]
417
+
418
+ if needed_packages:
419
+ print("Local calling requires the following additional dependencies to be installed:", needed_packages)
420
+ print("- To install using pip, run: pip install " + ' '.join(needed_packages))
421
+ print("- To install using uv, run: uv add " + ' '.join(needed_packages))
422
+ sys.exit(1)
423
+
424
+ import asyncio
425
+ from .terminal_call import TerminalCall
426
+ webrtc_code = self._client.create_webrtc_agent(ttl=timedelta(minutes=5))
427
+
428
+ self._threads.append(
429
+ threading.Thread(target=self._listen_inbound, kwargs={
430
+ "webrtc_code": webrtc_code
431
+ }, daemon=True)
432
+ )
433
+ self._threads.append(
434
+ threading.Thread(target=lambda: asyncio.run(TerminalCall(self._client, webrtc_code).start()), daemon=True)
435
+ )
436
+ return self
437
+
438
+ def run(self) -> None:
439
+ for t in self._threads:
440
+ t.start()
441
+
442
+ for t in self._threads:
443
+ t.join()
444
+
445
+ def _listen_inbound(self, agent_number: str | None = None, webrtc_code: str | None = None, sip_code: str | None = None):
446
+ if not check_exactly_one(agent_number, webrtc_code, sip_code):
447
+ raise TypeError("One of agent_number, webrtc_code, or sip_code must be provided.")
448
+
449
+
450
+ query = {}
451
+ if agent_number:
452
+ query["phone_number"] = agent_number
453
+ elif webrtc_code:
454
+ query["webrtc_code"] = webrtc_code
455
+ elif sip_code:
456
+ query["sip_code"] = sip_code
457
+ query_string = urlencode(query)
458
+ with GuavaSocket[listen_inbound.ClientMessage, listen_inbound.ServerMessage](
459
+ "listen-inbound",
460
+ self._client.get_websocket_url(f"v2/listen-inbound?{query_string}"),
461
+ headers=self._client._get_headers(),
462
+ serializer=lambda msg: msg.model_dump(),
463
+ deserializer=listen_inbound.decode_server_message,
464
+ ) as gs:
465
+
466
+ # Start listening and get the response.
467
+ while gs.is_open():
468
+ server_message = gs.recv()
469
+ match server_message:
470
+ case listen_inbound.ListenStarted():
471
+ if agent_number:
472
+ logger.info("Started listing on phone number %s. %d other listeners registered.", agent_number, server_message.other_listeners)
473
+ elif webrtc_code:
474
+ logger.info("Started listing on WebRTC code %s. %d other listeners registered.", webrtc_code, server_message.other_listeners)
475
+ logger.info("WebRTC URL: %s?webrtc_code=%s", self._client.get_http_url('debug-webrtc'), webrtc_code)
476
+ elif sip_code:
477
+ logger.info("Started listening on SIP code %s. %d other listeners registered.", sip_code, server_message.other_listeners)
478
+ case listen_inbound.IncomingCall():
479
+ gs.send(listen_inbound.ClaimCall(call_id=server_message.call_id))
480
+ case listen_inbound.AssignCall():
481
+ logger.info("Received call (session ID: %s), info: %r", server_message.call_id, server_message.call_info)
482
+ try:
483
+ call_action = self._on_call_received(server_message.call_info)
484
+ if isinstance(call_action, DeclineCall):
485
+ logger.info("Declining call...")
486
+ gs.send(listen_inbound.DeclineCall(call_id=server_message.call_id))
487
+ elif isinstance(call_action, AcceptCall):
488
+ logger.info("Accepting call...")
489
+ gs.send(listen_inbound.AnswerCall(call_id=server_message.call_id))
490
+
491
+ threading.Thread(target=self._attach_to_call, args=(server_message.call_id, ), daemon=True).start()
492
+ else:
493
+ logger.error("Unknown action for incoming call: %r", call_action)
494
+ except Exception:
495
+ logger.exception("Failed to initialize call controller.")
496
+
497
+ def outbound_phone(self, from_number, to_number, variables: dict[str, Any] = {}) -> Self:
498
+ for key, val in variables.items():
499
+ if not is_jsonable(val):
500
+ raise ValueError(f"Variable '{key}' value is not JSON serializable: {val!r}")
501
+
502
+ def start_call():
503
+ response = check_response(httpx.post(
504
+ self._client.get_http_url("v2/create-outbound"),
505
+ headers=self._client._get_headers(),
506
+ params={
507
+ "from_number": from_number,
508
+ "to_number": to_number
509
+ }
510
+ ))
511
+ call_id = response.json()["call_id"]
512
+ logger.info("Outbound call created with session ID: %s", call_id)
513
+ self._attach_to_call(call_id, variables)
514
+
515
+ self._threads.append(
516
+ threading.Thread(target=start_call, daemon=True)
517
+ )
518
+ return self
519
+
520
+ def _serve_campaign(
521
+ self,
522
+ campaign: "campaigns.OutboundCampaign",
523
+ ):
524
+ def initiate_call(call_id: str, contact_data: Any):
525
+ data = contact_data.get('data', {})
526
+ gs.send(guavadialer_events.ControllerReady(call_id=call_id))
527
+ self._attach_to_call(call_id, initial_variables=data, route="v2/connect-campaign-call")
528
+
529
+ logger.info("Connecting to campaign '%s' (id: %s).", campaign.name, campaign.id)
530
+ try:
531
+ with GuavaSocket[guavadialer_events.ClientMessage, guavadialer_events.ServerMessage](
532
+ "serve-campaign",
533
+ self._client.get_websocket_url(f"v1/serve-campaign/{campaign.id}"),
534
+ headers=self._client._get_headers(),
535
+ serializer=lambda msg: msg.model_dump(),
536
+ deserializer=guavadialer_events.decode_server_message,
537
+ ) as gs:
538
+
539
+ active_call_threads: list[threading.Thread] = []
540
+
541
+ def poll_campaign_completion():
542
+ """Poll campaign status and close the socket when no callable contacts remain and no local calls are active."""
543
+ while gs.is_open():
544
+ time.sleep(5)
545
+ try:
546
+ r = httpx.get(
547
+ self._client.get_http_url(f"v1/campaigns/{campaign.id}/has-callable-contacts"),
548
+ headers=self._client._get_headers(),
549
+ )
550
+ check_response(r)
551
+ if not r.json().get("has_callable_contacts", True):
552
+ # Wait for any local call threads to finish before closing.
553
+ alive = [t for t in active_call_threads if t.is_alive()]
554
+ if alive:
555
+ logger.info("Campaign '%s' has no more callable contacts, but %d call(s) still active locally. Waiting.", campaign.name, len(alive))
556
+ continue
557
+ logger.info("Campaign '%s' has no more callable contacts and no active calls. Closing.", campaign.name)
558
+ gs.close()
559
+ return
560
+ except Exception:
561
+ logger.debug("Failed to poll campaign status, will retry.", exc_info=True)
562
+
563
+ threading.Thread(target=poll_campaign_completion, daemon=True).start()
564
+
565
+ while gs.is_open():
566
+ server_message = gs.recv()
567
+ match server_message:
568
+ case guavadialer_events.ListenStarted():
569
+ logger.info("Listening for calls on campaign '%s' (controller mode). Ready.", campaign.name)
570
+ case guavadialer_events.InitiateAndAssignCall():
571
+ # Only used in controller mode. In headless mode the server handles calls directly.
572
+ log_phone = server_message.contact_data.get('phone_number') if server_message.contact_data else '?'
573
+ logger.info("Ready to make call, id %s — running precall for contact %s.", server_message.call_id, log_phone)
574
+ t = threading.Thread(
575
+ target=initiate_call,
576
+ args=(server_message.call_id, server_message.contact_data),
577
+ daemon=True,
578
+ )
579
+ active_call_threads.append(t)
580
+ t.start()
581
+ except GuavaSocketClosedError:
582
+ logger.info("Campaign '%s' disconnected.", campaign.name)
583
+
584
+ def outbound_campaign(
585
+ self,
586
+ *,
587
+ campaign: campaigns.OutboundCampaign,
588
+ ) -> Self:
589
+ self._threads.append(
590
+ threading.Thread(target=self._serve_campaign, args=(campaign,), daemon=True)
591
+ )
592
+ return self