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,229 @@
1
+ """What an account is to telephony: who may call the box with it, where it is dialled, its API."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import httpx
6
+
7
+ from pinecall.channels import routes, whatsapp
8
+ from pinecall.channels.telephony._twilio import Twilio, twilio_of, verify
9
+ from pinecall.channels.telephony.carrier_catalog import BOX_CARRIER, known_carrier
10
+ from pinecall.domain.errors import (
11
+ Conflict,
12
+ DeclarationRefused,
13
+ NotAvailable,
14
+ NotFound,
15
+ UpstreamFailed,
16
+ )
17
+ from pinecall.process.connections import Connections
18
+ from pinecall.tenancy import carrier_networks
19
+ from pinecall.tenancy.carriers import (
20
+ Account,
21
+ Carrier,
22
+ SipPeer,
23
+ Transport,
24
+ TwilioAccount,
25
+ WhatsappAccount,
26
+ box_twilio,
27
+ carrier_named,
28
+ )
29
+
30
+ # Twilio's signalling edges, from the catalog: the fence of every Twilio number on the SFU, and the
31
+ # set the cloud firewall opens 5060 to. A test holds them equal.
32
+ TWILIO_SIGNALLING: tuple[str, ...] = known_carrier(BOX_CARRIER).networks
33
+
34
+
35
+ NOT_PROVISIONED = (
36
+ "the org's account {account} cannot place a call yet: POST /v1/carrier/outbound provisions it"
37
+ )
38
+
39
+
40
+ NO_OUTBOUND_HOST = (
41
+ "SIP peer {account} declared no outbound_host: the networks a peer calls FROM are not an "
42
+ "address it takes calls AT"
43
+ )
44
+
45
+
46
+ PLACES_NO_CALL = "{account} is a WhatsApp number: it places no call"
47
+
48
+
49
+ ON_NO_TRUNK = "{account} is a WhatsApp number: it is on no SIP trunk"
50
+
51
+
52
+ META_REFUSED = "Meta does not open WhatsApp number {number} with this token: {why}"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Fence:
57
+ """Who the SFU admits a number's INVITE from: one trunk per fence."""
58
+
59
+ trunk: str
60
+ networks: tuple[str, ...]
61
+ username: str = ""
62
+ password: str = ""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Dialled:
67
+ """Where a call through the account is sent: the host, its transport and the pair."""
68
+
69
+ hostname: str
70
+ transport: Transport
71
+ username: str
72
+ password: str
73
+
74
+
75
+ # A trunk that lists no address admits every source: a fence with none approved is no trunk at all.
76
+ # `via` is the catalog carrier a number with no account comes through; `own` the approved networks
77
+ # of the peer, or of a number hooked with networks of its own (None: it named none).
78
+ def fence_of(
79
+ org: str,
80
+ number: str,
81
+ carrier: Carrier | None,
82
+ via: str | None,
83
+ own: tuple[str, ...] | None,
84
+ ) -> Fence | None:
85
+ """Who the SFU admits the number from, or None until the operator approves a network of it."""
86
+ if carrier is None:
87
+ if own is not None:
88
+ return Fence(trunk=f"{org}:{number}", networks=own) if own else None
89
+ kind = via or BOX_CARRIER
90
+ trunk = org if kind == BOX_CARRIER else f"{org}:{kind}"
91
+ return Fence(trunk=trunk, networks=known_carrier(kind).networks)
92
+ match carrier.account:
93
+ case TwilioAccount():
94
+ return Fence(trunk=org, networks=TWILIO_SIGNALLING)
95
+ case SipPeer():
96
+ peer = carrier.account
97
+ if not own:
98
+ return None
99
+ return Fence(
100
+ trunk=f"{org}:{peer.username}",
101
+ networks=own,
102
+ username=peer.username,
103
+ password=peer.password,
104
+ )
105
+ case WhatsappAccount():
106
+ raise DeclarationRefused(ON_NO_TRUNK.format(account=carrier.id))
107
+
108
+
109
+ def declared_networks(carrier: Carrier) -> tuple[str, ...] | None:
110
+ """The networks a peer says it calls from, for the operator to approve; None for the others."""
111
+ match carrier.account:
112
+ case SipPeer():
113
+ return tuple(carrier.account.addresses)
114
+ case TwilioAccount() | WhatsappAccount():
115
+ return None
116
+
117
+
118
+ def own_networks(
119
+ declared: tuple[str, ...] | None, approved: tuple[str, ...]
120
+ ) -> tuple[tuple[str, ...] | None, tuple[str, ...]]:
121
+ """Of the networks named for a fence, the approved ones (None: it named none) and the rest."""
122
+ if declared is None:
123
+ return None, ()
124
+ named = tuple(carrier_networks.written(network) for network in declared)
125
+ return (
126
+ tuple(network for network in named if network in approved),
127
+ tuple(network for network in named if network not in approved),
128
+ )
129
+
130
+
131
+ def missing_to_dial(carrier: Carrier) -> list[str]:
132
+ """What the account lacks before a call can go out through it, one sentence each."""
133
+ match carrier.account:
134
+ case TwilioAccount():
135
+ return [] if carrier.outbound else [NOT_PROVISIONED.format(account=carrier.id)]
136
+ case SipPeer():
137
+ peer = carrier.account
138
+ return [] if peer.outbound_host else [NO_OUTBOUND_HOST.format(account=carrier.id)]
139
+ case WhatsappAccount():
140
+ return [PLACES_NO_CALL.format(account=carrier.id)]
141
+
142
+
143
+ def dialled_of(carrier: Carrier) -> Dialled:
144
+ """Where a call through the account goes; Conflict naming what is missing."""
145
+ missing = missing_to_dial(carrier)
146
+ if missing:
147
+ raise Conflict(missing[0])
148
+ match carrier.account:
149
+ case TwilioAccount():
150
+ termination = carrier.outbound
151
+ if termination is None:
152
+ raise Conflict(NOT_PROVISIONED.format(account=carrier.id))
153
+ return Dialled(
154
+ hostname=termination.host,
155
+ transport="auto",
156
+ username=termination.username,
157
+ password=termination.password,
158
+ )
159
+ case SipPeer():
160
+ peer = carrier.account
161
+ return Dialled(
162
+ hostname=str(peer.outbound_host),
163
+ transport=peer.outbound_transport,
164
+ username=peer.outbound_username or peer.username,
165
+ password=peer.outbound_password or peer.password,
166
+ )
167
+ case WhatsappAccount():
168
+ raise Conflict(PLACES_NO_CALL.format(account=carrier.id))
169
+
170
+
171
+ # Twilio is the one carrier with an API the box drives; a peer is SIP terms and nothing more.
172
+ def control_of(http: httpx.AsyncClient, carrier: Carrier) -> Twilio | None:
173
+ """The account's API the box can list, point and provision numbers through, if it has one."""
174
+ match carrier.account:
175
+ case TwilioAccount():
176
+ return twilio_of(http, carrier.account)
177
+ case SipPeer() | WhatsappAccount():
178
+ return None
179
+
180
+
181
+ def meta_of(carrier: Carrier) -> WhatsappAccount | None:
182
+ """The WhatsApp number at Meta the account is, if it is one."""
183
+ match carrier.account:
184
+ case WhatsappAccount():
185
+ return carrier.account
186
+ case TwilioAccount() | SipPeer():
187
+ return None
188
+
189
+
190
+ async def verify_account(http: httpx.AsyncClient, account: Account) -> None:
191
+ """Refuse an account its carrier does not open with what was given; a peer is not asked."""
192
+ match account:
193
+ case TwilioAccount():
194
+ await verify(http, account)
195
+ case WhatsappAccount():
196
+ await _whatsapp_opens(http, account)
197
+ case SipPeer():
198
+ return
199
+
200
+
201
+ # Twilio's networks are every Twilio customer's, so the firewall says only that a call came from
202
+ # Twilio; the account Twilio stamps on it says whose. A number the box bought is the box's account,
203
+ # one imported is the org's; a number hooked or typed has no account known here, and goes unasked.
204
+ async def twilio_account_of(connections: Connections, org: str, number: str) -> str | None:
205
+ """The Twilio account a call to the org's number must come from; None where none is known."""
206
+ pool, vault = connections.pool, connections.vault
207
+ record = await routes.record_of(pool, org, number)
208
+ if record is None:
209
+ return None
210
+ try:
211
+ if record.route.managed:
212
+ return (await box_twilio(pool, vault)).account_sid
213
+ if record.account is None:
214
+ return None
215
+ found = await carrier_named(pool, vault, org, record.account)
216
+ except (NotAvailable, NotFound):
217
+ return None
218
+ return found.account.account_sid if isinstance(found.account, TwilioAccount) else None
219
+
220
+
221
+ # Meta is asked for the number the id names, as the inbox asks it: a token that does not open it
222
+ # is refused here, in Meta's words, and not on the first message.
223
+ async def _whatsapp_opens(http: httpx.AsyncClient, account: WhatsappAccount) -> None:
224
+ try:
225
+ await whatsapp.display_number(http, account.access_token, account.phone_number_id)
226
+ except UpstreamFailed as refused:
227
+ raise DeclarationRefused(
228
+ META_REFUSED.format(number=account.phone_number_id, why=refused)
229
+ ) from refused
@@ -0,0 +1,92 @@
1
+ """The carriers a box knows (carriers.csv) and which of them its operator admits."""
2
+
3
+ import csv
4
+ from dataclasses import dataclass
5
+ from datetime import date
6
+ from functools import cache
7
+ from importlib import resources
8
+
9
+ from pinecall.domain.errors import Conflict, NotFound
10
+ from pinecall.postgres.pool import Pool
11
+ from pinecall.process import box_settings
12
+
13
+ # Each carrier's networks are read from its own page, with the page and the day beside them.
14
+ FILE = "carriers.csv"
15
+
16
+
17
+ ADMITTED = "carriers/admitted"
18
+
19
+
20
+ # The box's own carrier: always admitted, its networks are the cloud firewall's own set.
21
+ BOX_CARRIER = "twilio"
22
+
23
+
24
+ NO_SUCH_CARRIER = "this box knows no carrier {kind}: one of {known}"
25
+
26
+
27
+ ALWAYS_ADMITTED = "{name} is the box's own carrier: it is always admitted"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class KnownCarrier:
32
+ """A carrier of the catalog: its published signalling networks, and whether it has an API."""
33
+
34
+ kind: str
35
+ name: str
36
+ # The box has a client for its API (lists, points, buys numbers); the rest is SIP terms.
37
+ control: bool
38
+ networks: tuple[str, ...]
39
+ source: str
40
+ read_on: date
41
+
42
+
43
+ @cache
44
+ def known() -> dict[str, KnownCarrier]:
45
+ """Every carrier of the shipped catalog, by kind, in the file's order."""
46
+ text = resources.files(__package__).joinpath(FILE).read_text(encoding="utf-8")
47
+ rows = list(csv.DictReader(text.splitlines()))
48
+ kinds = dict.fromkeys(row["kind"] for row in rows)
49
+ return {
50
+ kind: KnownCarrier(
51
+ kind=kind,
52
+ name=first["name"],
53
+ control=first["control"] == "yes",
54
+ networks=tuple(row["network"] for row in rows if row["kind"] == kind),
55
+ source=first["source"],
56
+ read_on=date.fromisoformat(first["read_on"]),
57
+ )
58
+ for kind in kinds
59
+ for first in [next(row for row in rows if row["kind"] == kind)]
60
+ }
61
+
62
+
63
+ def known_carrier(kind: str) -> KnownCarrier:
64
+ """The catalog's carrier of that kind; NotFound naming the ones there are."""
65
+ found = known().get(kind)
66
+ if found is None:
67
+ raise NotFound(NO_SUCH_CARRIER.format(kind=kind, known=", ".join(known())))
68
+ return found
69
+
70
+
71
+ async def admitted(pool: Pool) -> frozenset[str]:
72
+ """The kinds the operator admits, the box's own carrier always among them."""
73
+ async with pool.connection() as connection:
74
+ value = await box_settings.read(connection, ADMITTED)
75
+ kinds = value.get("kinds") if value is not None else None
76
+ chosen = {BOX_CARRIER}
77
+ for kind in kinds if isinstance(kinds, list) else []:
78
+ if isinstance(kind, str) and kind in known():
79
+ chosen.add(kind)
80
+ return frozenset(chosen)
81
+
82
+
83
+ async def admit(pool: Pool, kind: str, *, on: bool) -> frozenset[str]:
84
+ """Admit the carrier or stop admitting it; the kinds admitted after."""
85
+ carrier = known_carrier(kind)
86
+ if kind == BOX_CARRIER and not on:
87
+ raise Conflict(ALWAYS_ADMITTED.format(name=carrier.name))
88
+ kinds = await admitted(pool)
89
+ after = sorted(kinds | {kind} if on else kinds - {kind})
90
+ async with pool.connection() as connection:
91
+ await box_settings.write(connection, ADMITTED, {"kinds": list(after)})
92
+ return frozenset(after)
@@ -0,0 +1,41 @@
1
+ kind,name,control,network,source,read_on
2
+ twilio,Twilio,yes,54.172.60.0/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
3
+ twilio,Twilio,yes,54.244.51.0/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
4
+ twilio,Twilio,yes,54.171.127.192/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
5
+ twilio,Twilio,yes,35.156.191.128/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
6
+ twilio,Twilio,yes,54.65.63.192/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
7
+ twilio,Twilio,yes,54.169.127.128/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
8
+ twilio,Twilio,yes,54.252.254.64/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
9
+ twilio,Twilio,yes,177.71.206.192/30,https://www.twilio.com/docs/sip-trunking/ip-addresses,2026-10-03
10
+ telnyx,Telnyx,no,192.76.120.10/32,https://sip.telnyx.com/,2026-10-03
11
+ telnyx,Telnyx,no,64.16.250.10/32,https://sip.telnyx.com/,2026-10-03
12
+ telnyx,Telnyx,no,185.246.41.140/32,https://sip.telnyx.com/,2026-10-03
13
+ telnyx,Telnyx,no,185.246.41.141/32,https://sip.telnyx.com/,2026-10-03
14
+ telnyx,Telnyx,no,103.115.244.145/32,https://sip.telnyx.com/,2026-10-03
15
+ telnyx,Telnyx,no,103.115.244.146/32,https://sip.telnyx.com/,2026-10-03
16
+ telnyx,Telnyx,no,192.76.120.31/32,https://sip.telnyx.com/,2026-10-03
17
+ telnyx,Telnyx,no,64.16.250.13/32,https://sip.telnyx.com/,2026-10-03
18
+ telnyx,Telnyx,no,185.246.42.128/32,https://sip.telnyx.com/,2026-10-03
19
+ telnyx,Telnyx,no,185.246.42.129/32,https://sip.telnyx.com/,2026-10-03
20
+ telnyx,Telnyx,no,103.115.244.158/32,https://sip.telnyx.com/,2026-10-03
21
+ telnyx,Telnyx,no,103.115.244.159/32,https://sip.telnyx.com/,2026-10-03
22
+ vonage,Vonage,no,216.147.0.1/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
23
+ vonage,Vonage,no,216.147.0.2/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
24
+ vonage,Vonage,no,216.147.1.1/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
25
+ vonage,Vonage,no,216.147.1.2/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
26
+ vonage,Vonage,no,216.147.2.1/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
27
+ vonage,Vonage,no,216.147.2.2/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
28
+ vonage,Vonage,no,216.147.3.1/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
29
+ vonage,Vonage,no,216.147.3.2/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
30
+ vonage,Vonage,no,216.147.4.1/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
31
+ vonage,Vonage,no,216.147.4.2/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
32
+ vonage,Vonage,no,216.147.5.1/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
33
+ vonage,Vonage,no,216.147.5.2/32,https://api.support.vonage.com/hc/en-us/articles/360035471331,2026-10-03
34
+ plivo,Plivo,no,13.52.9.0/25,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
35
+ plivo,Plivo,no,216.120.187.128/26,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
36
+ plivo,Plivo,no,18.214.109.128/25,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
37
+ plivo,Plivo,no,18.215.142.0/26,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
38
+ plivo,Plivo,no,204.89.148.128/26,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
39
+ plivo,Plivo,no,3.120.121.128/26,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
40
+ plivo,Plivo,no,18.228.70.64/26,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03
41
+ plivo,Plivo,no,13.238.202.192/26,https://www.plivo.com/docs/sip-trunking/concepts/technical-specifications,2026-10-03