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.
- guava/__init__.py +9 -0
- guava/agent.py +592 -0
- guava/call.py +227 -0
- guava/call_controller.py +538 -0
- guava/campaigns.py +139 -0
- guava/client.py +437 -0
- guava/commands.py +125 -0
- guava/devaudio/sounddevice.py +183 -0
- guava/events.py +162 -0
- guava/examples/agent/help_desk.py +77 -0
- guava/examples/agent/polling_campaign.py +116 -0
- guava/examples/agent/property_insurance.py +28 -0
- guava/examples/agent/restaurant_waitlist.py +74 -0
- guava/examples/agent/scheduling_outbound.py +68 -0
- guava/examples/credit_card_activation.py +155 -0
- guava/examples/example_data.py +804 -0
- guava/examples/inbound_sip.py +21 -0
- guava/examples/inbound_webrtc.py +25 -0
- guava/examples/mock_appointments.py +35 -0
- guava/examples/polling.py +140 -0
- guava/examples/property_insurance.py +44 -0
- guava/examples/scheduling_inbound.py +51 -0
- guava/examples/scheduling_outbound.py +77 -0
- guava/examples/thai_palace.py +76 -0
- guava/guavadialer_events.py +42 -0
- guava/helpers/beta.py +10 -0
- guava/helpers/chromadb.py +89 -0
- guava/helpers/fastapi.py +82 -0
- guava/helpers/genai.py +190 -0
- guava/helpers/lancedb.py +106 -0
- guava/helpers/openai.py +259 -0
- guava/helpers/pgvector.py +103 -0
- guava/helpers/pinecone.py +135 -0
- guava/helpers/rag.py +422 -0
- guava/helpers/server_rag.py +180 -0
- guava/helpers/vertexai.py +97 -0
- guava/listen_inbound.py +51 -0
- guava/logging_utils.py +76 -0
- guava/py.typed +0 -0
- guava/socket/client.py +349 -0
- guava/socket/protocol.py +66 -0
- guava/socket/utils.py +20 -0
- guava/telemetry.py +159 -0
- guava/terminal_call.py +111 -0
- guava/threading_utils.py +17 -0
- guava/tools/__init__.py +0 -0
- guava/tools/create_sip_agent.py +5 -0
- guava/tools/create_webrtc_agent.py +5 -0
- guava/types/__init__.py +115 -0
- guava/types/call_info.py +33 -0
- guava/types/incoming_call_action.py +16 -0
- guava/utils.py +93 -0
- guava_sdk-0.20.0.dist-info/METADATA +63 -0
- guava_sdk-0.20.0.dist-info/RECORD +55 -0
- 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
|