pinecall-runtime 0.1.6__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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,626 @@
1
+ """A voice call's room: the caller's leg, transfers, tones, the melody, and the room's facts."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections import deque
9
+ from collections.abc import Awaitable, Callable
10
+ from dataclasses import dataclass
11
+ from datetime import timedelta
12
+
13
+ from livekit import api, rtc
14
+ from livekit.agents import utils
15
+ from livekit.agents.voice import AgentSession
16
+ from livekit.protocol.room import MuteRoomTrackRequest, RoomParticipantIdentity
17
+ from livekit.protocol.sip import (
18
+ CreateSIPParticipantRequest,
19
+ SIPOutboundConfig,
20
+ SIPTransferReason,
21
+ SIPTransferStatus,
22
+ TransferSIPParticipantRequest,
23
+ TransferSIPParticipantResponse,
24
+ )
25
+ from livekit.rtc.participant import PublishDataError, PublishDTMFError
26
+ from pydantic import ValidationError
27
+
28
+ from pinecall.domain.names import Channel, JsonObject
29
+ from pinecall.domain.scope import SCOPE_ATTRIBUTE
30
+ from pinecall.session.call import Call
31
+ from pinecall.wire import events as wire
32
+ from pinecall.wire.commands import (
33
+ CallDtmf,
34
+ CallTransfer,
35
+ ParticipantMute,
36
+ ParticipantRemove,
37
+ RoomInvite,
38
+ RoomSend,
39
+ )
40
+ from pinecall.wire.frames import WireModel
41
+ from pinecall.wire.parts import ParticipantKind, TrackKind, TrackSource, TransferMode
42
+ from pinecall.wire.room import (
43
+ ParticipantJoined,
44
+ ParticipantLeft,
45
+ ParticipantSpeaking,
46
+ RoomOpened,
47
+ RoomSent,
48
+ TrackPublished,
49
+ TrackUnpublished,
50
+ )
51
+
52
+ # A code the caller keyed, claimed with the gateway, which decides whether it is one.
53
+ type ClaimCode = Callable[[str], Awaitable[None]]
54
+
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+
59
+ # The caller's leg can join a moment after the agent, and livekit's own wait never times out.
60
+ WAIT_FOR_THE_LEG_S = 5.0
61
+
62
+
63
+ # livekit's attribute names for the caller's number and the one dialled.
64
+ SIP_PREFIX = "sip."
65
+
66
+
67
+ # livekit's convention for a dialled leg's identity, which every dialled leg follows.
68
+ LEG_PREFIX = "sip_"
69
+
70
+
71
+ KIND_OF_SCOPE: dict[str, ParticipantKind] = {"supervise": "supervisor", "observe": "listener"}
72
+
73
+
74
+ # livekit's session closes when the caller leaves only for three reasons (they hung up, the room
75
+ # was deleted, they were rejected); one whose connection dropped is waited for, and the room ends
76
+ # 20 s after its last person left, LiveKit's departure timeout. A supervisor watching keeps the
77
+ # room up, so the call is ended here: the caller gone as long as LiveKit waits for a last person.
78
+ CALLER_RETURNS_S = 20.0
79
+
80
+ CALLER_GONE = "call %s: the caller left (%s) and did not come back in %.0f s: the call ends"
81
+
82
+ # A warm transfer rings for less than livekit's 30 s; a tone plays meanwhile, so silence is not
83
+ # taken for a dropped call.
84
+ RINGING_S = 25.0
85
+
86
+
87
+ # The longest a transfer asked outside a tool waits for the agent to finish its sentence.
88
+ THE_ANNOUNCEMENT_S = 12.0
89
+
90
+
91
+ # RFC 4733 codes, as publish_dtmf takes them. A comma pauses, as in a dialler's string; any
92
+ # other character refuses the whole sequence, since half a card number is worse than none. Some
93
+ # carriers merge tones sent back to back into one long press.
94
+ CODES: dict[str, int] = {**{str(digit): digit for digit in range(10)}, "*": 10, "#": 11}
95
+
96
+
97
+ PAUSE = ","
98
+
99
+
100
+ PAUSE_S = 0.5
101
+
102
+
103
+ BETWEEN_TONES_S = 0.12
104
+
105
+
106
+ # A code is four digits keyed within eight seconds (docs/protocol/codes.md); answers to a menu
107
+ # are slower.
108
+ CODE_LENGTH = 4
109
+
110
+
111
+ CODE_WITHIN_S = 8.0
112
+
113
+
114
+ ROOM_VERB_FAILED = "room_verb_failed"
115
+
116
+
117
+ NO_LEG = "{verb}: this call has no phone leg"
118
+
119
+
120
+ NO_TRUNK = "{verb}: no outbound SIP trunk is configured, so {to} cannot be dialled"
121
+
122
+
123
+ NOT_A_TONE = "call.dtmf: {digit!r} is not a touch tone (0-9, * or #), and nothing was sent"
124
+
125
+
126
+ NOT_DIALLED = "room.invite: a participant joins with a token; the room dials only kind sip"
127
+
128
+
129
+ NOT_IN_THE_ROOM = "participant.mute: {identity} is not in the room"
130
+
131
+
132
+ NO_MICROPHONE = "participant.mute: {identity} has no microphone in the room"
133
+
134
+
135
+ TRACK_KINDS: dict[int, TrackKind] = {
136
+ rtc.TrackKind.KIND_AUDIO: "audio",
137
+ rtc.TrackKind.KIND_VIDEO: "video",
138
+ }
139
+
140
+
141
+ TRACK_SOURCES: dict[int, TrackSource] = {
142
+ rtc.TrackSource.SOURCE_MICROPHONE: "microphone",
143
+ rtc.TrackSource.SOURCE_CAMERA: "camera",
144
+ rtc.TrackSource.SOURCE_SCREENSHARE: "screen_share",
145
+ rtc.TrackSource.SOURCE_SCREENSHARE_AUDIO: "screen_share_audio",
146
+ }
147
+
148
+
149
+ # The SFU keeps no outbound trunk: each leg carries its own, so a restarted SFU dials on.
150
+ # Asked each time a verb dials: the gateway checks the org's dial guards and its limits first,
151
+ # so a refusal (NotAllowed) carries their reason into the log. Never cached.
152
+ type Trunks = Callable[[str], Awaitable[Trunk]]
153
+
154
+
155
+ # Asked before a cold transfer: the same guards, and no trunk, since a REFER dials nothing of ours.
156
+ type SentOn = Callable[[str], Awaitable[None]]
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class Trunk:
161
+ """How a number is dialled: the trunk inline, and the org's number shown."""
162
+
163
+ config: SIPOutboundConfig
164
+ shown: str | None = None
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class Legs:
169
+ """How a second leg of the call is judged: the guards and the trunk, or the guards alone."""
170
+
171
+ trunk: Trunks
172
+ sent_on: SentOn
173
+
174
+
175
+ class CallRoom:
176
+ """One voice call's room, the server's API over it, and what it writes about both."""
177
+
178
+ def __init__(
179
+ self,
180
+ call: Call,
181
+ room: rtc.Room,
182
+ server: api.LiveKitAPI,
183
+ *,
184
+ legs: Legs,
185
+ claim: ClaimCode,
186
+ ) -> None:
187
+ """The room of a call not yet opened."""
188
+ self.call = call
189
+ self.room = room
190
+ self.server = server
191
+ self.legs = legs
192
+ self.claim = claim
193
+ self.speaking: set[str] = set()
194
+ self.tones: deque[tuple[str, float]] = deque(maxlen=CODE_LENGTH)
195
+ self.claimed: set[str] = set()
196
+ self.tasks: set[asyncio.Task[None]] = set()
197
+ self.bridged: str | None = None
198
+ self.caller_gone: Callable[[], None] | None = None
199
+ self.returning: asyncio.Task[None] | None = None
200
+
201
+ # ── the caller's leg ──
202
+
203
+ async def leg(self) -> rtc.RemoteParticipant | None:
204
+ """The caller's SIP leg, waited for a moment; none on a call that is not a phone's."""
205
+ return await caller_leg(self.room, self.call.context.channel)
206
+
207
+ # ── transfers ──
208
+
209
+ async def mode_of(self, wanted: CallTransfer) -> TransferMode:
210
+ """The mode asked for, else cold on a phone leg and warm on a browser's call."""
211
+ if wanted.mode is not None:
212
+ return wanted.mode
213
+ return "cold" if await self.leg() is not None else "warm"
214
+
215
+ # A failed transfer is call.transferred with ok false, and the call goes on.
216
+ async def transfer(
217
+ self, wanted: CallTransfer, live: AgentSession[None]
218
+ ) -> wire.CallTransferred:
219
+ """Send the caller on (cold, by REFER of their leg) or dial the person in (warm)."""
220
+ mode = await self.mode_of(wanted)
221
+ await _after_the_announcement(live)
222
+ if mode == "cold":
223
+ return await self._sent_on(wanted)
224
+ return await self._dialled_in(wanted)
225
+
226
+ # The person is dialled into this room. The caller leaving closes the session by itself; the
227
+ # person leaving does not, so the call ends then.
228
+ def when_the_person_leaves(self, identity: str, hang_up: Callable[[], None]) -> None:
229
+ """Call `hang_up` once the transferred leg leaves the room."""
230
+ self.bridged = identity
231
+
232
+ def left(participant: rtc.RemoteParticipant) -> None:
233
+ if participant.identity == self.bridged:
234
+ self.bridged = None
235
+ hang_up()
236
+
237
+ self.room.on("participant_disconnected", left) # pyright: ignore[reportUnknownMemberType]
238
+
239
+ def when_the_caller_is_gone(self, hang_up: Callable[[], None]) -> None:
240
+ """Call `hang_up` once the caller left, whatever the reason, and did not come back."""
241
+ self.caller_gone = hang_up
242
+
243
+ # The caller is sent on only past the org's guards for a second leg (shape, pace, the
244
+ # ledger's row), as a warm transfer is: the REFER is a leg the carrier dials on the org's bill.
245
+ async def _sent_on(self, wanted: CallTransfer) -> wire.CallTransferred:
246
+ leg = await self.leg()
247
+ if leg is None:
248
+ return _stayed(wanted, "cold", NO_LEG.format(verb="call.transfer"))
249
+ try:
250
+ await self.legs.sent_on(wanted.to)
251
+ except Exception as refused:
252
+ logger.warning("call.transfer: the guards refused the leg", exc_info=True)
253
+ return _stayed(
254
+ wanted, "cold", str(refused) or NO_TRUNK.format(verb="call.transfer", to=wanted.to)
255
+ )
256
+ request = TransferSIPParticipantRequest(
257
+ participant_identity=leg.identity,
258
+ room_name=self.room.name,
259
+ transfer_to=wanted.to,
260
+ play_dialtone=True,
261
+ )
262
+ try:
263
+ answer = await self.server.sip.transfer_sip_participant(request)
264
+ except api.TwirpError as refused:
265
+ return _stayed(wanted, "cold", f"call.transfer: {refused}")
266
+ # A transfer can fail without raising.
267
+ if answer.status != SIPTransferStatus.STS_TRANSFER_SUCCESSFUL:
268
+ return _stayed(wanted, "cold", _did_not_take(answer))
269
+ return wire.CallTransferred(to=wanted.to, mode="cold", ok=True)
270
+
271
+ # Waiting until answered is what makes busy and no answer seen; without it the request
272
+ # returns as soon as the INVITE went out.
273
+ async def _dialled_in(self, wanted: CallTransfer) -> wire.CallTransferred:
274
+ request = await self._leg_to(wanted.to, verb="call.transfer")
275
+ if isinstance(request, str):
276
+ return _stayed(wanted, "warm", request)
277
+ request.wait_until_answered = True
278
+ request.play_dialtone = True
279
+ request.ringing_timeout.FromTimedelta(timedelta(seconds=RINGING_S))
280
+ try:
281
+ await self.server.sip.create_sip_participant(request)
282
+ except api.TwirpError as refused:
283
+ return _stayed(wanted, "warm", f"call.transfer: {refused}")
284
+ return wire.CallTransferred(to=wanted.to, mode="warm", ok=True)
285
+
286
+ # ── the other room verbs ──
287
+
288
+ async def apply(self, command: WireModel) -> None:
289
+ """A room verb of the app's; the server's refusal is an error entry, the call goes on."""
290
+ match command:
291
+ case RoomInvite():
292
+ await self._invite(command)
293
+ case RoomSend():
294
+ await self._send(command)
295
+ case ParticipantMute():
296
+ await self._mute(command)
297
+ case ParticipantRemove():
298
+ request = RoomParticipantIdentity(room=self.room.name, identity=command.identity)
299
+ await self._completed(
300
+ "participant.remove", self.server.room.remove_participant(request)
301
+ )
302
+ case CallDtmf():
303
+ await self._tones(command)
304
+ case _:
305
+ self._failed(
306
+ type(command).__name__,
307
+ f"{type(command).__name__} is not something this call does",
308
+ )
309
+
310
+ # Success is written by the room itself, as participant.joined of kind sip.
311
+ async def _invite(self, wanted: RoomInvite) -> None:
312
+ if wanted.kind != "sip":
313
+ self._failed("room.invite", NOT_DIALLED)
314
+ return
315
+ request = await self._leg_to(wanted.to, verb="room.invite")
316
+ if isinstance(request, str):
317
+ self._failed("room.invite", request)
318
+ return
319
+ await self._completed("room.invite", self.server.sip.create_sip_participant(request))
320
+
321
+ # The payload stays out of the log: room.sent says the topic, whom, and the size.
322
+ async def _send(self, wanted: RoomSend) -> None:
323
+ packed = json.dumps(wanted.data, separators=(",", ":")).encode()
324
+ to = [] if wanted.to is None else [wanted.to]
325
+ sent = self.room.local_participant.publish_data(
326
+ packed, topic=wanted.topic, destination_identities=to
327
+ )
328
+ if await self._completed("room.send", sent):
329
+ self.call.writing.write(
330
+ "room.sent", RoomSent(topic=wanted.topic, to=wanted.to, bytes=len(packed))
331
+ )
332
+
333
+ # livekit mutes in place and says nothing, so the verb writes track.unpublished itself.
334
+ async def _mute(self, wanted: ParticipantMute) -> None:
335
+ seat = self.room.remote_participants.get(wanted.identity)
336
+ if seat is None:
337
+ self._failed("participant.mute", NOT_IN_THE_ROOM.format(identity=wanted.identity))
338
+ return
339
+ microphone = next(
340
+ (
341
+ value
342
+ for value in seat.track_publications.values()
343
+ if value.source == rtc.TrackSource.SOURCE_MICROPHONE
344
+ ),
345
+ None,
346
+ )
347
+ if microphone is None:
348
+ self._failed("participant.mute", NO_MICROPHONE.format(identity=wanted.identity))
349
+ return
350
+ request = MuteRoomTrackRequest(
351
+ room=self.room.name, identity=wanted.identity, track_sid=microphone.sid, muted=True
352
+ )
353
+ if await self._completed(
354
+ "participant.mute", self.server.room.mute_published_track(request)
355
+ ):
356
+ unpublished = TrackUnpublished(
357
+ identity=wanted.identity, kind="audio", source="microphone"
358
+ )
359
+ self.call.writing.write("track.unpublished", unpublished)
360
+
361
+ async def _tones(self, wanted: CallDtmf) -> None:
362
+ wrong = next(
363
+ (digit for digit in wanted.digits if digit != PAUSE and digit not in CODES), None
364
+ )
365
+ if wrong is not None:
366
+ self._failed("call.dtmf", NOT_A_TONE.format(digit=wrong))
367
+ return
368
+ if await self.leg() is None:
369
+ self._failed("call.dtmf", NO_LEG.format(verb="call.dtmf"))
370
+ return
371
+ for digit in wanted.digits:
372
+ if digit == PAUSE:
373
+ await asyncio.sleep(PAUSE_S)
374
+ continue
375
+ if not await self._completed(
376
+ "call.dtmf",
377
+ self.room.local_participant.publish_dtmf(code=CODES[digit], digit=digit),
378
+ ):
379
+ return
380
+ await asyncio.sleep(BETWEEN_TONES_S)
381
+
382
+ async def _leg_to(self, to: str, *, verb: str) -> CreateSIPParticipantRequest | str:
383
+ try:
384
+ trunk = await self.legs.trunk(to)
385
+ except Exception as refused:
386
+ logger.warning("%s: no trunk to dial through", verb, exc_info=True)
387
+ return str(refused) or NO_TRUNK.format(verb=verb, to=to)
388
+ request = CreateSIPParticipantRequest(
389
+ trunk=trunk.config,
390
+ sip_call_to=to,
391
+ room_name=self.room.name,
392
+ participant_identity=f"{LEG_PREFIX}{to}",
393
+ )
394
+ if trunk.shown is not None:
395
+ request.sip_number = trunk.shown
396
+ return request
397
+
398
+ # The server's own words, as it said them.
399
+ async def _completed(self, verb: str, done: Awaitable[object]) -> bool:
400
+ try:
401
+ await done
402
+ except (api.TwirpError, PublishDataError, PublishDTMFError, ConnectionError) as refused:
403
+ self._failed(verb, str(refused))
404
+ return False
405
+ return True
406
+
407
+ def _failed(self, verb: str, why: str) -> None:
408
+ failed = wire.ErrorEvent(code=ROOM_VERB_FAILED, message=why, command=verb, recoverable=True)
409
+ self.call.writing.write("error", failed)
410
+
411
+ # ── the room's own facts ──
412
+
413
+ # The job usually connects before the session exists, so the connection was already missed.
414
+ def watch(self) -> None:
415
+ """Write what happens in the room, room.opened first."""
416
+ for name, listener in self._listeners().items():
417
+ self.room.on(name, listener) # pyright: ignore[reportUnknownMemberType]
418
+ if self.room.isconnected():
419
+ self._connection(rtc.ConnectionState.CONN_CONNECTED)
420
+
421
+ def stop(self) -> None:
422
+ """Let go of the room and of every claim still out."""
423
+ for name, listener in self._listeners().items():
424
+ self.room.off(name, listener) # pyright: ignore[reportUnknownMemberType]
425
+ for task in self.tasks:
426
+ task.cancel()
427
+
428
+ def _listeners(self) -> dict[rtc.room.EventTypes, Callable[..., None]]:
429
+ return {
430
+ "connection_state_changed": self._connection,
431
+ "participant_connected": self._joined,
432
+ "participant_disconnected": self._left,
433
+ "track_published": self._published,
434
+ "track_unpublished": self._unpublished,
435
+ "active_speakers_changed": self._speakers,
436
+ "sip_dtmf_received": self._tone,
437
+ }
438
+
439
+ def _connection(self, state: int) -> None:
440
+ if state == rtc.ConnectionState.CONN_CONNECTED:
441
+ self._spawn(self._opened())
442
+
443
+ # room.sid is awaited, so the opening runs as a task: room.opened, the agent's seat, then the
444
+ # seats taken before the agent arrived and their tracks.
445
+ async def _opened(self) -> None:
446
+ opened = RoomOpened(
447
+ name=self.room.name, sid=await self.room.sid, channel=self.call.context.channel
448
+ )
449
+ await self.call.writing.write("room.opened", opened)
450
+ self._joined(self.room.local_participant)
451
+ for seat in self.room.remote_participants.values():
452
+ self._joined(seat)
453
+ for publication in seat.track_publications.values():
454
+ self._published(publication, seat)
455
+
456
+ def _joined(self, seat: rtc.Participant) -> None:
457
+ attributes: dict[str, str] = dict(seat.attributes)
458
+ data: JsonObject = dict(attributes)
459
+ joined = ParticipantJoined(
460
+ identity=seat.identity,
461
+ kind=self.kind_of(seat, attributes),
462
+ name=seat.name or None,
463
+ attributes=data,
464
+ )
465
+ self.call.writing.write("participant.joined", joined)
466
+ if joined.kind == "caller" and self.returning is not None:
467
+ self.returning.cancel()
468
+ self.returning = None
469
+
470
+ def _left(self, seat: rtc.RemoteParticipant) -> None:
471
+ reason = seat.disconnect_reason
472
+ text = "unknown" if reason is None else rtc.DisconnectReason.Name(reason).lower()
473
+ self.call.writing.write(
474
+ "participant.left", ParticipantLeft(identity=seat.identity, reason=text)
475
+ )
476
+ self.speaking.discard(seat.identity)
477
+ gone = self.caller_gone
478
+ if gone is not None and self.kind_of(seat, dict(seat.attributes)) == "caller":
479
+ if self.returning is not None:
480
+ self.returning.cancel()
481
+ self.returning = self._spawn(self._waited_for(gone, text))
482
+
483
+ async def _waited_for(self, hang_up: Callable[[], None], reason: str) -> None:
484
+ await asyncio.sleep(CALLER_RETURNS_S)
485
+ self.returning = None
486
+ logger.info(CALLER_GONE, self.room.name, reason, CALLER_RETURNS_S)
487
+ hang_up()
488
+
489
+ # A SIP seat is the caller only if its number is the call's caller; any other was dialled in.
490
+ def kind_of(self, seat: rtc.Participant, attributes: dict[str, str]) -> ParticipantKind:
491
+ """Who a seat of the room is to this call: caller, agent, supervisor or a far end."""
492
+ if seat.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT:
493
+ return "agent"
494
+ by_scope = KIND_OF_SCOPE.get(attributes.get(SCOPE_ATTRIBUTE, ""))
495
+ if by_scope is not None:
496
+ return by_scope
497
+ a_phone = any(key.startswith(SIP_PREFIX) for key in attributes)
498
+ if a_phone and attributes.get(CALLER_NUMBER) != self.call.context.caller:
499
+ return "sip"
500
+ return "caller"
501
+
502
+ def _published(self, publication: rtc.TrackPublication, seat: rtc.Participant) -> None:
503
+ track = _track_of(publication)
504
+ if track is not None:
505
+ published = TrackPublished(identity=seat.identity, kind=track[0], source=track[1])
506
+ self.call.writing.write("track.published", published)
507
+
508
+ def _unpublished(self, publication: rtc.TrackPublication, seat: rtc.Participant) -> None:
509
+ track = _track_of(publication)
510
+ if track is not None:
511
+ unpublished = TrackUnpublished(identity=seat.identity, kind=track[0], source=track[1])
512
+ self.call.writing.write("track.unpublished", unpublished)
513
+
514
+ # Written on the change, never on every tick.
515
+ def _speakers(self, speakers: list[rtc.Participant]) -> None:
516
+ now = {speaker.identity for speaker in speakers}
517
+ for identity in sorted(now - self.speaking):
518
+ self.call.writing.write(
519
+ "participant.speaking", ParticipantSpeaking(identity=identity, speaking=True)
520
+ )
521
+ for identity in sorted(self.speaking - now):
522
+ self.call.writing.write(
523
+ "participant.speaking", ParticipantSpeaking(identity=identity, speaking=False)
524
+ )
525
+ self.speaking = now
526
+
527
+ # The caller's tones only: a server's have no seat, another leg is not the caller.
528
+ def _tone(self, tone: rtc.SipDTMF) -> None:
529
+ seat = tone.participant
530
+ if seat is None or self.kind_of(seat, dict(seat.attributes)) != "caller":
531
+ return
532
+ try:
533
+ claim = wire.DtmfReceived.model_validate({"digit": tone.digit, "code": tone.code})
534
+ except ValidationError:
535
+ return
536
+ self.call.writing.write("dtmf.received", claim)
537
+ self._tone_pressed(claim.digit, time.monotonic())
538
+
539
+ # The worker cannot tell a code from an extension, so every candidate is claimed and the
540
+ # gateway decides; each code once a call. `*` or `#` starts over.
541
+ def _tone_pressed(self, digit: str, at: float) -> None:
542
+ if not digit.isdigit():
543
+ self.tones.clear()
544
+ return
545
+ self.tones.append((digit, at))
546
+ if len(self.tones) < CODE_LENGTH or at - self.tones[0][1] > CODE_WITHIN_S:
547
+ return
548
+ code = "".join(digit for digit, _ in self.tones)
549
+ self.tones.clear()
550
+ if code not in self.claimed:
551
+ self.claimed.add(code)
552
+ self._spawn(self._claimed(code))
553
+
554
+ async def _claimed(self, code: str) -> None:
555
+ try:
556
+ await self.claim(code)
557
+ except Exception:
558
+ logger.warning(
559
+ "call %s: the claim of a keyed code was refused",
560
+ self.call.context.call,
561
+ exc_info=True,
562
+ )
563
+
564
+ # A task of a synchronous listener is kept, or it may be collected mid-run.
565
+ def _spawn(self, work: Awaitable[None]) -> asyncio.Task[None]:
566
+ task = asyncio.ensure_future(work)
567
+ self.tasks.add(task)
568
+ task.add_done_callback(self.tasks.discard)
569
+ return task
570
+
571
+
572
+ CALLER_NUMBER = f"{SIP_PREFIX}phoneNumber"
573
+
574
+
575
+ DIALLED_NUMBER = f"{SIP_PREFIX}trunkPhoneNumber"
576
+
577
+
578
+ # The Twilio account a leg Twilio sent came from: livekit-sip reads X-Twilio-AccountSid into it.
579
+ TWILIO_ACCOUNT = f"{SIP_PREFIX}twilio.accountSid"
580
+
581
+
582
+ # livekit's wait never times out; a room that never connected raises, and has no leg either.
583
+ # The first SIP seat is the caller; later ones came from room.invite or a warm transfer.
584
+ async def caller_leg(room: rtc.Room, channel: Channel) -> rtc.RemoteParticipant | None:
585
+ """The caller's SIP seat, waited for a moment on a phone call; none on any other."""
586
+ if channel == "phone" and _phone_in(room) is None:
587
+ with contextlib.suppress(TimeoutError, RuntimeError):
588
+ async with asyncio.timeout(WAIT_FOR_THE_LEG_S):
589
+ await utils.wait_for_participant(
590
+ room, kind=rtc.ParticipantKind.PARTICIPANT_KIND_SIP
591
+ )
592
+ return _phone_in(room)
593
+
594
+
595
+ def _phone_in(room: rtc.Room) -> rtc.RemoteParticipant | None:
596
+ return next(
597
+ (
598
+ seat
599
+ for seat in room.remote_participants.values()
600
+ if seat.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP
601
+ ),
602
+ None,
603
+ )
604
+
605
+
606
+ async def _after_the_announcement(live: AgentSession[None]) -> None:
607
+ speech = live.current_speech
608
+ if speech is not None:
609
+ with contextlib.suppress(TimeoutError):
610
+ await asyncio.wait_for(speech.wait_for_playout(), THE_ANNOUNCEMENT_S)
611
+
612
+
613
+ def _stayed(wanted: CallTransfer, mode: TransferMode, why: str) -> wire.CallTransferred:
614
+ return wire.CallTransferred(to=wanted.to, mode=mode, ok=False, error=why)
615
+
616
+
617
+ def _did_not_take(answer: TransferSIPParticipantResponse) -> str:
618
+ payload = SIPTransferReason.Name(answer.reason).removeprefix("STR_").lower()
619
+ return (
620
+ f"call.transfer: {payload}, SIP {answer.sip_status.code} {answer.sip_status.status}".strip()
621
+ )
622
+
623
+
624
+ def _track_of(publication: rtc.TrackPublication) -> tuple[TrackKind, TrackSource] | None:
625
+ kind = TRACK_KINDS.get(publication.kind)
626
+ return None if kind is None else (kind, TRACK_SOURCES.get(publication.source, "unknown"))