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,448 @@
1
+ """The SFU's side of a number: on its world's LiveKit, a trunk per fence and the world's rule."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+
9
+ import httpx
10
+ from livekit import api
11
+ from livekit.protocol.agent_dispatch import RoomAgentDispatch
12
+ from livekit.protocol.room import RoomConfiguration
13
+ from livekit.protocol.sip import (
14
+ CreateSIPDispatchRuleRequest,
15
+ CreateSIPInboundTrunkRequest,
16
+ DeleteSIPDispatchRuleRequest,
17
+ DeleteSIPTrunkRequest,
18
+ ListSIPDispatchRuleRequest,
19
+ ListSIPInboundTrunkRequest,
20
+ SIPDispatchRule,
21
+ SIPDispatchRuleIndividual,
22
+ SIPDispatchRuleInfo,
23
+ SIPInboundTrunkInfo,
24
+ )
25
+
26
+ from pinecall.channels import rooms, routes
27
+ from pinecall.channels.rooms import Dispatch
28
+ from pinecall.channels.routes import RouteRecord
29
+ from pinecall.channels.telephony import hand_over
30
+ from pinecall.channels.telephony.carrier import Fence, declared_networks, fence_of, own_networks
31
+ from pinecall.domain.errors import NotAvailable
32
+ from pinecall.domain.names import ENVS, PRODUCTION, SANDBOX, Env, other_world
33
+ from pinecall.fleet import worlds
34
+ from pinecall.process.connections import NO_LIVEKIT, Connections
35
+ from pinecall.tenancy import carrier_networks
36
+ from pinecall.tenancy.carriers import Carrier, carriers_of
37
+ from pinecall.wire.rest.numbers import LegTrunk
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ ROOM_PREFIX = "call-"
43
+
44
+
45
+ NO_DOMAIN = "this box has no PINECALL_DOMAIN: a carrier has nowhere to send a call"
46
+
47
+
48
+ REBUILT = "SIP rebuilt from the tables: %d numbers stand, %d orgs refused, hand-overs admitted: %s"
49
+
50
+ NOT_WHOLE = "SIP not rebuilt whole (an org refused, or a LiveKit unreachable): again in %.0f s"
51
+
52
+ # A LiveKit still starting beside the gateway answers in seconds; one that is down, in minutes.
53
+ FIRST_WAIT_S = 2.0
54
+
55
+ LONGEST_WAIT_S = 60.0
56
+
57
+
58
+ NO_ADDRESS = "production's SIP name %s names no address: the sandbox admits no hand-over"
59
+
60
+
61
+ # How long the start waits for production's SIP name to say its address.
62
+ RESOLVED_WITHIN_S = 5.0
63
+
64
+
65
+ ROUTED_ORGS = """
66
+ SELECT DISTINCT org FROM routes WHERE channel = 'phone' AND number IS NOT NULL ORDER BY org
67
+ """
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class WorldRule:
72
+ """An org's world on the SFU: its rule, and the fleet the rule dispatches."""
73
+
74
+ org: str
75
+ env: Env
76
+ fleet: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Fencing:
81
+ """What fences an org's numbers now: its carriers by id, and the networks approved."""
82
+
83
+ carriers: dict[str, Carrier]
84
+ approved: dict[str, tuple[str, ...]]
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Rebuilt:
89
+ """What the reconcile found: numbers existing on the SFU, orgs it could not rebuild."""
90
+
91
+ numbers: int
92
+ refused: list[str]
93
+ # Whether the sandbox's own LiveKit admits the rings production hands over to it.
94
+ hand_over: bool = False
95
+
96
+
97
+ def domain_of(connections: Connections) -> str:
98
+ """The box's name, or NotAvailable: a carrier has nowhere to send a call."""
99
+ name = connections.settings.domain
100
+ if not name:
101
+ raise NotAvailable(NO_DOMAIN)
102
+ return name
103
+
104
+
105
+ def sip_domain_of(connections: Connections, world: Env) -> str:
106
+ """The name a carrier sends that world's calls to, or NotAvailable: it has none."""
107
+ name = connections.settings.sip_name_of(world)
108
+ if not name:
109
+ raise NotAvailable(NO_DOMAIN)
110
+ return name
111
+
112
+
113
+ # Both worlds hold the one client when the sandbox has no LiveKit of its own.
114
+ def elsewhere(connections: Connections, world: Env) -> api.LiveKitAPI | None:
115
+ """The other world's LiveKit when it is not this world's too; None where both share one."""
116
+ other = connections.servers[other_world(world)]
117
+ return None if other is connections.servers[world] else other
118
+
119
+
120
+ def rule_name(org: str, env: Env) -> str:
121
+ """The name of an org's dispatch rule in a world."""
122
+ return f"{org}:{env}"
123
+
124
+
125
+ # An org id holds no colon, so `{org}` and `{org}:…` can never be another org's.
126
+ def belongs_to(name: str, org: str) -> bool:
127
+ """Whether a trunk or rule name is the org's."""
128
+ return name == org or name.startswith(f"{org}:")
129
+
130
+
131
+ async def trunks_admitting(server: api.LiveKitAPI, number: str) -> list[SIPInboundTrunkInfo]:
132
+ """Every trunk on the SFU that lists the number."""
133
+ listed = await server.sip.list_inbound_trunk(ListSIPInboundTrunkRequest(numbers=[number]))
134
+ return [trunk for trunk in listed.items if number in trunk.numbers]
135
+
136
+
137
+ async def trunk_named(server: api.LiveKitAPI, name: str) -> SIPInboundTrunkInfo | None:
138
+ """The trunk of that name, or None."""
139
+ listed = await server.sip.list_inbound_trunk(ListSIPInboundTrunkRequest())
140
+ return next((trunk for trunk in listed.items if trunk.name == name), None)
141
+
142
+
143
+ async def rule_named(server: api.LiveKitAPI, name: str) -> SIPDispatchRuleInfo | None:
144
+ """The dispatch rule of that name, or None."""
145
+ listed = await server.sip.list_dispatch_rule(ListSIPDispatchRuleRequest())
146
+ return next((rule for rule in listed.items if rule.name == name), None)
147
+
148
+
149
+ async def admit(
150
+ server: api.LiveKitAPI, fence: Fence, existing: SIPInboundTrunkInfo | None, number: str
151
+ ) -> str:
152
+ """Make the fence's trunk list the number, made or updated; its id."""
153
+ if existing is None:
154
+ info = SIPInboundTrunkInfo(
155
+ name=fence.trunk,
156
+ numbers=[number],
157
+ allowed_addresses=list(fence.networks),
158
+ auth_username=fence.username,
159
+ auth_password=fence.password,
160
+ )
161
+ made = await server.sip.create_inbound_trunk(CreateSIPInboundTrunkRequest(trunk=info))
162
+ return made.sip_trunk_id
163
+ fenced = sorted(existing.allowed_addresses) == sorted(fence.networks)
164
+ if number in existing.numbers and fenced and existing.auth_username == fence.username:
165
+ return existing.sip_trunk_id
166
+ existing.numbers[:] = sorted({*existing.numbers, number})
167
+ existing.allowed_addresses[:] = list(fence.networks)
168
+ existing.auth_username, existing.auth_password = fence.username, fence.password
169
+ await server.sip.update_inbound_trunk(existing.sip_trunk_id, existing)
170
+ return existing.sip_trunk_id
171
+
172
+
173
+ # A trunk or a rule that lists no number takes every number: one emptied is deleted, and so is
174
+ # a rule left with no trunk.
175
+ async def renumber_trunk(
176
+ server: api.LiveKitAPI, org: str, trunk: SIPInboundTrunkInfo, numbers: list[str]
177
+ ) -> None:
178
+ """Leave the trunk these numbers; emptied, it goes, and so does a rule left with no trunk."""
179
+ if numbers:
180
+ await server.sip.update_inbound_trunk_fields(trunk.sip_trunk_id, numbers=numbers)
181
+ return
182
+ await server.sip.delete_trunk(DeleteSIPTrunkRequest(sip_trunk_id=trunk.sip_trunk_id))
183
+ listed = await server.sip.list_dispatch_rule(ListSIPDispatchRuleRequest())
184
+ for rule in listed.items:
185
+ if not belongs_to(rule.name, org) or trunk.sip_trunk_id not in rule.trunk_ids:
186
+ continue
187
+ kept = [trunk_id for trunk_id in rule.trunk_ids if trunk_id != trunk.sip_trunk_id]
188
+ if not kept:
189
+ await renumber_rule(server, rule, [])
190
+ continue
191
+ rule.trunk_ids[:] = kept
192
+ await server.sip.update_dispatch_rule(rule.sip_dispatch_rule_id, rule)
193
+
194
+
195
+ # One rule per world: its `numbers` are that world's, so a number changes world between two
196
+ # lists and never between trunks. livekit refuses two rules of one trunk listing one number.
197
+ async def rule_in(
198
+ server: api.LiveKitAPI, world: WorldRule, trunks: Sequence[str], number: str
199
+ ) -> None:
200
+ """Have the world's rule list the number and the trunks, made or updated."""
201
+ # A rule with no trunk dispatches every trunk's calls.
202
+ if not trunks:
203
+ return
204
+ name, fleet = rule_name(world.org, world.env), world.fleet
205
+ existing = await rule_named(server, name)
206
+ metadata = rooms.written(Dispatch(org=world.org, env=world.env))
207
+ agent_dispatch = RoomAgentDispatch(agent_name=fleet, metadata=metadata)
208
+ if existing is None:
209
+ info = SIPDispatchRuleInfo(
210
+ name=name,
211
+ trunk_ids=sorted(set(trunks)),
212
+ numbers=[number],
213
+ rule=SIPDispatchRule(
214
+ dispatch_rule_individual=SIPDispatchRuleIndividual(room_prefix=ROOM_PREFIX)
215
+ ),
216
+ room_config=RoomConfiguration(agents=[agent_dispatch]),
217
+ )
218
+ await server.sip.create_dispatch_rule(CreateSIPDispatchRuleRequest(dispatch_rule=info))
219
+ return
220
+ wanted_trunks = sorted({*existing.trunk_ids, *trunks})
221
+ fleets = [agent.agent_name for agent in existing.room_config.agents]
222
+ if (
223
+ number in existing.numbers
224
+ and list(existing.trunk_ids) == wanted_trunks
225
+ and fleets == [fleet]
226
+ ):
227
+ return
228
+ existing.trunk_ids[:] = wanted_trunks
229
+ existing.numbers[:] = sorted({*existing.numbers, number})
230
+ existing.room_config.CopyFrom(RoomConfiguration(agents=[agent_dispatch]))
231
+ await server.sip.update_dispatch_rule(existing.sip_dispatch_rule_id, existing)
232
+
233
+
234
+ async def renumber_rule(
235
+ server: api.LiveKitAPI, rule: SIPDispatchRuleInfo, numbers: list[str]
236
+ ) -> None:
237
+ """Leave the rule these numbers; emptied, it goes."""
238
+ if not numbers:
239
+ gone = DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule.sip_dispatch_rule_id)
240
+ await server.sip.delete_dispatch_rule(gone)
241
+ return
242
+ rule.numbers[:] = numbers
243
+ await server.sip.update_dispatch_rule(rule.sip_dispatch_rule_id, rule)
244
+
245
+
246
+ # The rules are listed after the trunks: a trunk emptied takes a rule left with none with it.
247
+ async def let_go(server: api.LiveKitAPI, org: str, number: str) -> None:
248
+ """Take the number off the org's trunks and off both of its worlds' rules on this LiveKit."""
249
+ await _unlisted(server, org, number)
250
+ names = {rule_name(org, env) for env in ENVS}
251
+ listed = await server.sip.list_dispatch_rule(ListSIPDispatchRuleRequest())
252
+ for rule in listed.items:
253
+ if rule.name in names and number in rule.numbers:
254
+ await renumber_rule(server, rule, [item for item in rule.numbers if item != number])
255
+
256
+
257
+ # LiveKit keeps its trunks and rules in Redis, which can be emptied; the tables are the truth.
258
+ # Nothing is deleted but a number the other world's LiveKit still lists, and the carrier's side is
259
+ # never touched.
260
+ async def rebuild(connections: Connections) -> Rebuilt:
261
+ """Admit every routed phone number again on the SFU, with its fence and its world's rule."""
262
+ async with connections.pool.connection() as connection:
263
+ rows = await (await connection.execute(ROUTED_ORGS)).fetchall()
264
+ readmitted, refused = 0, list[str]()
265
+ for org in (str(row["org"]) for row in rows):
266
+ try:
267
+ readmitted += await readmit(connections, org)
268
+ except (api.TwirpError, httpx.HTTPError):
269
+ logger.warning("the SFU refused org %s's numbers: the others go on", org, exc_info=True)
270
+ refused.append(org)
271
+ try:
272
+ handing = await _hand_overs_admitted(connections)
273
+ except (api.TwirpError, httpx.HTTPError):
274
+ logger.warning("the sandbox's SFU refused the hand-over trunk", exc_info=True)
275
+ handing = False
276
+ logger.info(REBUILT, readmitted, len(refused), handing)
277
+ return Rebuilt(numbers=readmitted, refused=refused, hand_over=handing)
278
+
279
+
280
+ # A gateway started beside a LiveKit that is still starting, or down, would otherwise leave every
281
+ # number of that world unadmitted until its own next start: it tries again until the rebuild is
282
+ # whole, waiting twice as long each time, a minute at most.
283
+ async def rebuilt_until_whole(connections: Connections, wait_s: float = FIRST_WAIT_S) -> Rebuilt:
284
+ """The rebuild, again after a wait while an org is refused or a LiveKit cannot be reached."""
285
+ while True:
286
+ try:
287
+ rebuilt = await rebuild(connections)
288
+ except (api.TwirpError, httpx.HTTPError, OSError, TimeoutError):
289
+ rebuilt = None
290
+ if rebuilt is not None and not rebuilt.refused:
291
+ return rebuilt
292
+ logger.warning(NOT_WHOLE, wait_s)
293
+ await asyncio.sleep(wait_s)
294
+ wait_s = min(wait_s * 2, LONGEST_WAIT_S)
295
+
296
+
297
+ # After the operator approves or refuses a network: the org's trunks follow what is approved now.
298
+ async def readmit(connections: Connections, org: str) -> int:
299
+ """Admit the org's phone numbers again with the fences they have now; how many stand."""
300
+ fleets = await worlds.fleets(connections.pool)
301
+ fencing = await fencing_of(connections, org)
302
+ admitted = 0
303
+ for record in await routes.phones_of(connections.pool, org):
304
+ admitted += await readmitted(connections, record, fencing, fleets)
305
+ return admitted
306
+
307
+
308
+ async def fencing_of(connections: Connections, org: str) -> Fencing:
309
+ """The org's carriers and approved networks, which fence its numbers now."""
310
+ carriers = await carriers_of(connections.pool, connections.vault, org)
311
+ approved = await carrier_networks.approved(connections.pool, org)
312
+ return Fencing(carriers={carrier.id: carrier for carrier in carriers}, approved=approved)
313
+
314
+
315
+ # A number lives on its world's LiveKit alone: the other world's, when it has its own, lets it go
316
+ # (a number moved, or one admitted there before the worlds had a LiveKit each). On its own LiveKit
317
+ # the other world's rule lets it go first: two rules of one trunk never list one number.
318
+ async def readmitted(
319
+ connections: Connections, record: RouteRecord, fencing: Fencing, fleets: worlds.Fleets
320
+ ) -> bool:
321
+ """Admit the number on its world's LiveKit with the fence it has now; whether it stands."""
322
+ org, number, env = record.route.org, str(record.route.number), record.route.env
323
+ server, other = connections.servers[env], elsewhere(connections, env)
324
+ if other is not None:
325
+ await let_go(other, org, number)
326
+ fence = fence_now(record, fencing.carriers.get(record.account or ""), fencing.approved)
327
+ if fence is None:
328
+ await _unlisted(server, org, number)
329
+ return False
330
+ trunk_id = await admit(server, fence, await trunk_named(server, fence.trunk), number)
331
+ left = await rule_named(server, rule_name(org, other_world(env)))
332
+ if left is not None and number in left.numbers:
333
+ await renumber_rule(server, left, [listed for listed in left.numbers if listed != number])
334
+ await rule_in(server, WorldRule(org, env, worlds.fleet_of(fleets, env)), [trunk_id], number)
335
+ return True
336
+
337
+
338
+ # Where each world has a LiveKit of its own, a developer's own phone at a production number is
339
+ # dialled from production's room to the sandbox's SIP (hand_over.py), with a pair drawn from
340
+ # LiveKit's secret; where both share one, the sandbox's fleet is sent into the room instead.
341
+ def hand_over_trunk(connections: Connections, caller: str) -> LegTrunk | None:
342
+ """The trunk a ring is handed to the sandbox's SIP through; None where the worlds share one."""
343
+ if elsewhere(connections, PRODUCTION) is None:
344
+ return None
345
+ return LegTrunk(
346
+ hostname=sip_domain_of(connections, SANDBOX),
347
+ transport="udp",
348
+ username=hand_over.USERNAME,
349
+ password=_hand_over_password(connections),
350
+ shown=caller,
351
+ )
352
+
353
+
354
+ def fence_now(
355
+ record: RouteRecord, carrier: Carrier | None, approved: dict[str, tuple[str, ...]]
356
+ ) -> Fence | None:
357
+ """The fence a routed number has with what is approved now; None while nothing fences it."""
358
+ number = str(record.route.number)
359
+ declared = (record.networks or None) if carrier is None else declared_networks(carrier)
360
+ source = number if carrier is None else carrier.id
361
+ own, _ = own_networks(declared, approved.get(source, ()))
362
+ return fence_of(record.route.org, number, carrier, record.via, own)
363
+
364
+
365
+ async def _unlisted(server: api.LiveKitAPI, org: str, number: str) -> None:
366
+ for trunk in await trunks_admitting(server, number):
367
+ if belongs_to(trunk.name, org):
368
+ await renumber_trunk(server, org, trunk, [n for n in trunk.numbers if n != number])
369
+
370
+
371
+ # The trunk lists no number: it admits whatever production dials, from production's media
372
+ # address alone (the one its SIP name names, and livekit-sip sends from) and with the pair. Its
373
+ # rule sends each ring to the sandbox's fleet as one diverted from production.
374
+ async def _hand_overs_admitted(connections: Connections) -> bool:
375
+ server = elsewhere(connections, PRODUCTION)
376
+ named = connections.settings.sip_domain
377
+ if server is None or not named:
378
+ return False
379
+ addresses = await _addresses_of(named)
380
+ if not addresses:
381
+ logger.warning(NO_ADDRESS, named)
382
+ return False
383
+ wanted = SIPInboundTrunkInfo(
384
+ name=hand_over.HAND_OVER,
385
+ allowed_addresses=addresses,
386
+ auth_username=hand_over.USERNAME,
387
+ auth_password=_hand_over_password(connections),
388
+ headers_to_attributes=hand_over.ATTRIBUTE_OF,
389
+ )
390
+ trunk_id = await _hand_over_trunk_kept(server, wanted)
391
+ fleet = worlds.fleet_of(await worlds.fleets(connections.pool), SANDBOX)
392
+ await _hand_over_rule_kept(server, trunk_id, fleet)
393
+ return True
394
+
395
+
396
+ def _hand_over_password(connections: Connections) -> str:
397
+ secret = connections.settings.livekit_api_secret
398
+ if not secret:
399
+ raise NotAvailable(NO_LIVEKIT)
400
+ return hand_over.password_of(secret)
401
+
402
+
403
+ async def _addresses_of(name: str) -> list[str]:
404
+ try:
405
+ async with asyncio.timeout(RESOLVED_WITHIN_S):
406
+ found = await asyncio.get_running_loop().getaddrinfo(
407
+ name, None, family=socket.AF_INET, type=socket.SOCK_DGRAM
408
+ )
409
+ except (OSError, TimeoutError):
410
+ return []
411
+ return sorted({str(address[4][0]) for address in found})
412
+
413
+
414
+ async def _hand_over_trunk_kept(server: api.LiveKitAPI, wanted: SIPInboundTrunkInfo) -> str:
415
+ existing = await trunk_named(server, wanted.name)
416
+ if existing is None:
417
+ made = await server.sip.create_inbound_trunk(CreateSIPInboundTrunkRequest(trunk=wanted))
418
+ return made.sip_trunk_id
419
+ if (
420
+ list(existing.allowed_addresses) == list(wanted.allowed_addresses)
421
+ and existing.auth_username == wanted.auth_username
422
+ and existing.auth_password == wanted.auth_password
423
+ and dict(existing.headers_to_attributes) == dict(wanted.headers_to_attributes)
424
+ ):
425
+ return existing.sip_trunk_id
426
+ await server.sip.update_inbound_trunk(existing.sip_trunk_id, wanted)
427
+ return existing.sip_trunk_id
428
+
429
+
430
+ async def _hand_over_rule_kept(server: api.LiveKitAPI, trunk_id: str, fleet: str) -> None:
431
+ metadata = rooms.written(Dispatch(env=SANDBOX, diverted_from=PRODUCTION))
432
+ wanted = SIPDispatchRuleInfo(
433
+ name=hand_over.HAND_OVER,
434
+ trunk_ids=[trunk_id],
435
+ rule=SIPDispatchRule(
436
+ dispatch_rule_individual=SIPDispatchRuleIndividual(room_prefix=ROOM_PREFIX)
437
+ ),
438
+ room_config=RoomConfiguration(
439
+ agents=[RoomAgentDispatch(agent_name=fleet, metadata=metadata)]
440
+ ),
441
+ )
442
+ existing = await rule_named(server, hand_over.HAND_OVER)
443
+ if existing is None:
444
+ await server.sip.create_dispatch_rule(CreateSIPDispatchRuleRequest(dispatch_rule=wanted))
445
+ return
446
+ if list(existing.trunk_ids) == [trunk_id] and existing.room_config == wanted.room_config:
447
+ return
448
+ await server.sip.update_dispatch_rule(existing.sip_dispatch_rule_id, wanted)