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,530 @@
1
+ """A number imported, bought, released or moved: at the carrier, on the SFU, in the routes table."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field, replace
5
+
6
+ import httpx
7
+ from livekit.protocol.sip import (
8
+ SIPDispatchRuleInfo,
9
+ SIPInboundTrunkInfo,
10
+ )
11
+
12
+ from pinecall.channels import routes, whatsapp
13
+ from pinecall.channels.routes import RouteRecord, RouteWrite
14
+ from pinecall.channels.telephony import carrier_catalog, sip
15
+ from pinecall.channels.telephony._twilio import (
16
+ Twilio,
17
+ TwilioNumber,
18
+ TwilioTrunk,
19
+ origination_uri,
20
+ twilio_of,
21
+ )
22
+ from pinecall.channels.telephony.carrier import (
23
+ Fence,
24
+ control_of,
25
+ declared_networks,
26
+ fence_of,
27
+ meta_of,
28
+ own_networks,
29
+ )
30
+ from pinecall.channels.telephony.carrier_catalog import known_carrier
31
+ from pinecall.channels.telephony.sip import WorldRule, domain_of, rule_name, sip_domain_of
32
+ from pinecall.domain.call import Route
33
+ from pinecall.domain.errors import (
34
+ Conflict,
35
+ DeclarationRefused,
36
+ NotFound,
37
+ UpstreamFailed,
38
+ )
39
+ from pinecall.domain.names import Channel, Env, RouteOrigin, other_world, parse_e164
40
+ from pinecall.domain.scope import Scope
41
+ from pinecall.fleet import worlds
42
+ from pinecall.process.connections import Connections
43
+ from pinecall.tenancy import admission, carrier_networks
44
+ from pinecall.tenancy.carriers import (
45
+ NO_CARRIER,
46
+ WhatsappAccount,
47
+ box_twilio,
48
+ carrier_named,
49
+ carriers_of,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ NOT_ON_ACCOUNT = "{number} is not a number of Twilio account {account}"
56
+
57
+
58
+ ON_ANOTHER_TRUNK = (
59
+ "{number} is on Twilio trunk {trunk} of {account}, pointed at {where}: moving it here takes it "
60
+ "off there, so say move"
61
+ )
62
+
63
+
64
+ # Whose it is stays theirs: the sentence names no org and no trunk.
65
+ HELD_ELSEWHERE = (
66
+ "{number} is held by another org on this box, so nothing was written: if it is yours, ask the "
67
+ "box's operator"
68
+ )
69
+
70
+
71
+ NO_DOMAIN = "this box has no PINECALL_DOMAIN: a carrier has nowhere to send a call"
72
+
73
+
74
+ NOT_A_PHONE = "a number is imported to answer phone or whatsapp, not {channel}"
75
+
76
+
77
+ KEY_WITHOUT_PEER = "a {kind} account takes no networks: they are the fence of a number you hook"
78
+
79
+
80
+ NO_ROUTE = "the org answers no {number} in the {env}"
81
+
82
+
83
+ NONE_FOR_SALE = "Twilio has no local voice number for sale in {country}{area}"
84
+
85
+
86
+ VIA_ALONE = (
87
+ "a number comes via a carrier of the box's catalog when the org hooks it, on the phone, with "
88
+ "no networks of its own: that carrier's are its fence"
89
+ )
90
+
91
+
92
+ NOT_ADMITTED = "this box does not admit {name}: its operator turns it on under Carriers"
93
+
94
+
95
+ NOT_A_COUNTRY = "{number} starts with no country calling code E.164 assigns"
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class NumberPurchase:
100
+ """A number wanted from the box's own account."""
101
+
102
+ scope: Scope
103
+ agent: str
104
+ country: str
105
+ area_code: str | None = None
106
+ channel: Channel = "phone"
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class AtTwilio:
111
+ """The number at its Twilio account, and the trunk that points it here."""
112
+
113
+ twilio: Twilio
114
+ number: TwilioNumber | None
115
+ trunk: TwilioTrunk | None
116
+ # Bought in this very request: a dry run names it and buys nothing.
117
+ for_sale: str | None = None
118
+
119
+
120
+ @dataclass
121
+ class Survey:
122
+ """Everything a hook needs, read before anything is written."""
123
+
124
+ route: Route
125
+ origin: RouteOrigin
126
+ fence: Fence | None
127
+ account: str | None
128
+ networks: tuple[str, ...]
129
+ fleet: str
130
+ domain: str
131
+ # Where a trunk of the account sends the world's calls: its SIP name, else its name.
132
+ origination: str
133
+ at_twilio: AtTwilio | None = None
134
+ via: str | None = None
135
+ # The networks named for the number that the operator has not approved: no trunk until he does.
136
+ waiting: tuple[str, ...] = ()
137
+ # The org's own other trunks that list the number and must let it go.
138
+ leaving: list[SIPInboundTrunkInfo] = field(default_factory=list[SIPInboundTrunkInfo])
139
+ # The org's trunks on the other world's own LiveKit that list it: it lives on one LiveKit.
140
+ elsewhere: list[SIPInboundTrunkInfo] = field(default_factory=list[SIPInboundTrunkInfo])
141
+ trunk: SIPInboundTrunkInfo | None = None
142
+ rule: SIPDispatchRuleInfo | None = None
143
+ other_rule: SIPDispatchRuleInfo | None = None
144
+ routed: Route | None = None
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class Plan:
149
+ """What a hook does, one sentence a step, and the route it ends in."""
150
+
151
+ route: Route
152
+ steps: list[str]
153
+ dry_run: bool
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class OwnedNumber:
158
+ """A number one of the org's accounts owns, and whether this world already imported it."""
159
+
160
+ number: str
161
+ name: str
162
+ imported: bool
163
+ account: str
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class NumberImport:
168
+ """A number wanted: where it answers, and how it reaches the box."""
169
+
170
+ scope: Scope
171
+ agent: str
172
+ number: str
173
+ channel: Channel = "phone"
174
+ # The account it lives in; unsaid, the org's only one.
175
+ account: str | None = None
176
+ # The org points the number here itself: no account is touched.
177
+ hooked: bool = False
178
+ # A self-hooked number's own networks, which the operator approves; unsaid, its carrier's.
179
+ networks: tuple[str, ...] = ()
180
+ # The catalog carrier a self-hooked number comes through; unsaid, Twilio.
181
+ via: str | None = None
182
+ # Take the number off another trunk of its account first.
183
+ move: bool = False
184
+
185
+
186
+ async def plan_buy(connections: Connections, wanted: NumberPurchase) -> Plan:
187
+ """What buying would do: the number Twilio would sell, and nothing bought."""
188
+ survey = await _survey_purchase(connections, wanted)
189
+ return Plan(route=survey.route, steps=_steps(survey, done=False), dry_run=True)
190
+
191
+
192
+ async def buy_number(connections: Connections, wanted: NumberPurchase) -> Plan:
193
+ """Buy a number on the box's account and hook it as an import, counted as the box's."""
194
+ survey = await _survey_purchase(connections, wanted)
195
+ steps = _steps(survey, done=True)
196
+ at = survey.at_twilio
197
+ if at is not None and at.for_sale is not None:
198
+ survey.at_twilio = replace(at, number=await at.twilio.bought(at.for_sale), for_sale=None)
199
+ await _write_hook(connections, survey)
200
+ return Plan(route=survey.route, steps=steps, dry_run=False)
201
+
202
+
203
+ async def release(connections: Connections, org: str, number: str, env: Env) -> None:
204
+ """Let the number go: the route, its world's LiveKit's trunks and rule; the account keeps it."""
205
+ route = await routes.of_number(connections.pool, org, number)
206
+ if route is None or route.env != env:
207
+ raise NotFound(NO_ROUTE.format(number=number, env=env))
208
+ await routes.remove(connections.pool, org, number)
209
+ await sip.let_go(connections.servers[env], org, number)
210
+
211
+
212
+ # Where both worlds share a LiveKit the number keeps its trunk and changes rules; where each has
213
+ # its own, it leaves the old world's LiveKit and is admitted on the new one's.
214
+ async def move(connections: Connections, org: str, number: str, env: Env) -> RouteRecord:
215
+ """Move the number into the other world: its row, and its trunk and rule on that LiveKit."""
216
+ record = await routes.record_of(connections.pool, org, number)
217
+ if record is None:
218
+ raise NotFound(NO_ROUTE.format(number=number, env="either world"))
219
+ route = record.route
220
+ if route.env == env:
221
+ return record
222
+ moved = replace(record, route=replace(route, env=env))
223
+ # A WhatsApp number is on no trunk: its world is its row alone.
224
+ if route.channel != "phone":
225
+ await routes.moved(connections.pool, org, number, env)
226
+ return moved
227
+ fleets = await worlds.fleets(connections.pool)
228
+ await sip.readmitted(connections, moved, await sip.fencing_of(connections, org), fleets)
229
+ await routes.moved(connections.pool, org, number, env)
230
+ return moved
231
+
232
+
233
+ # A SIP peer owns what it owns and nobody here can list it: its import takes the number typed.
234
+ async def owned_numbers(
235
+ connections: Connections, scope: Scope, account: str | None = None
236
+ ) -> tuple[str, list[OwnedNumber]]:
237
+ """The kind of the org's accounts, and every number its Twilio accounts own."""
238
+ if account is None:
239
+ carriers = await carriers_of(connections.pool, connections.vault, scope.org)
240
+ else:
241
+ carriers = [await carrier_named(connections.pool, connections.vault, scope.org, account)]
242
+ if not carriers:
243
+ raise NotFound(NO_CARRIER)
244
+ answering = await routes.of_org(connections.pool, scope.org, scope.env)
245
+ imported = {route.number for route in answering if route.number is not None}
246
+ controlled = [
247
+ (carrier, control)
248
+ for carrier in carriers
249
+ if (control := control_of(connections.http, carrier)) is not None
250
+ ]
251
+ owned = [
252
+ OwnedNumber(
253
+ number=number.phone_number,
254
+ name=number.friendly_name or number.phone_number,
255
+ imported=number.phone_number in imported,
256
+ account=carrier.id,
257
+ )
258
+ for carrier, control in controlled
259
+ for number in await control.numbers()
260
+ ]
261
+ for carrier in carriers:
262
+ meta = meta_of(carrier)
263
+ if meta is not None:
264
+ owned += await _at_meta(connections.http, meta, imported)
265
+ lister = controlled[0][0] if controlled else carriers[0]
266
+ return lister.account.kind, owned
267
+
268
+
269
+ async def import_number(connections: Connections, wanted: NumberImport) -> Plan:
270
+ """Hook the number at its account, admit it on the SFU in its world, route it."""
271
+ survey = await _survey_import(connections, wanted)
272
+ steps = _steps(survey, done=True)
273
+ await _write_hook(connections, survey)
274
+ return Plan(route=survey.route, steps=steps, dry_run=False)
275
+
276
+
277
+ # The operator's row is admitted like a hooked number, at once, and says who wrote it.
278
+ async def type_route(connections: Connections, wanted: NumberImport) -> Plan:
279
+ """Route a number the box's operator names: on the SFU and in the table, no account touched."""
280
+ survey = await _survey_import(connections, replace(wanted, hooked=True, account=None))
281
+ survey.origin = "typed"
282
+ steps = _steps(survey, done=True)
283
+ await _write_hook(connections, survey)
284
+ return Plan(route=survey.route, steps=steps, dry_run=False)
285
+
286
+
287
+ async def plan_import(connections: Connections, wanted: NumberImport) -> Plan:
288
+ """What importing the number would do, writing nothing."""
289
+ survey = await _survey_import(connections, wanted)
290
+ return Plan(route=survey.route, steps=_steps(survey, done=False), dry_run=True)
291
+
292
+
293
+ async def _survey_import(connections: Connections, wanted: NumberImport) -> Survey:
294
+ number = parse_e164(wanted.number)
295
+ if wanted.channel not in {"phone", "whatsapp"}:
296
+ raise DeclarationRefused(NOT_A_PHONE.format(channel=wanted.channel))
297
+ org, env = wanted.scope.org, wanted.scope.env
298
+ domain = domain_of(connections)
299
+ route = Route(org=org, agent=wanted.agent, channel=wanted.channel, number=number, env=env)
300
+ if await routes.held_elsewhere(connections.pool, org, number):
301
+ raise Conflict(HELD_ELSEWHERE.format(number=number))
302
+ fleets = await worlds.fleets(connections.pool)
303
+ await _check_via(connections, wanted)
304
+ carrier = (
305
+ None
306
+ if wanted.hooked
307
+ else await carrier_named(connections.pool, connections.vault, org, wanted.account)
308
+ )
309
+ if carrier is not None and wanted.networks:
310
+ raise DeclarationRefused(KEY_WITHOUT_PEER.format(kind=carrier.account.kind))
311
+ networks = tuple(carrier_networks.checked(network) for network in wanted.networks)
312
+ approved = await carrier_networks.approved(connections.pool, org)
313
+ declared = (networks or None) if carrier is None else declared_networks(carrier)
314
+ source = number if carrier is None else carrier.id
315
+ own, waiting = own_networks(declared, approved.get(source, ()))
316
+ survey = Survey(
317
+ route=route,
318
+ origin="hooked" if carrier is None else "imported",
319
+ fence=fence_of(org, number, carrier, wanted.via, own)
320
+ if wanted.channel == "phone"
321
+ else None,
322
+ account=None if carrier is None else carrier.id,
323
+ networks=networks,
324
+ fleet=worlds.fleet_of(fleets, env),
325
+ domain=domain,
326
+ origination=origination_uri(sip_domain_of(connections, env)),
327
+ via=wanted.via,
328
+ waiting=waiting,
329
+ )
330
+ control = None if carrier is None else control_of(connections.http, carrier)
331
+ if control is not None and wanted.channel == "phone":
332
+ survey.at_twilio = await _at_twilio(connections, control, number, env, move=wanted.move)
333
+ return await _survey_sfu(connections, survey)
334
+
335
+
336
+ # A catalog carrier is the operator's to admit; its networks are the number's whole fence.
337
+ async def _check_via(connections: Connections, wanted: NumberImport) -> None:
338
+ if wanted.via is None:
339
+ return
340
+ if not wanted.hooked or wanted.networks or wanted.channel != "phone":
341
+ raise DeclarationRefused(VIA_ALONE)
342
+ carrier = known_carrier(wanted.via)
343
+ if wanted.via not in await carrier_catalog.admitted(connections.pool):
344
+ raise Conflict(NOT_ADMITTED.format(name=carrier.name))
345
+
346
+
347
+ async def _survey_purchase(connections: Connections, wanted: NumberPurchase) -> Survey:
348
+ org, env = wanted.scope.org, wanted.scope.env
349
+ domain = domain_of(connections)
350
+ origination = origination_uri(sip_domain_of(connections, env))
351
+ boxs = await box_twilio(connections.pool, connections.vault)
352
+ await admission.admit_number(
353
+ connections.pool, org, env, bought=await routes.managed_in(connections.pool, org, env)
354
+ )
355
+ twilio = twilio_of(connections.http, boxs)
356
+ for_sale = await twilio.for_sale(wanted.country, wanted.area_code)
357
+ if for_sale is None:
358
+ area = f", area code {wanted.area_code}" if wanted.area_code else ""
359
+ raise NotFound(NONE_FOR_SALE.format(country=wanted.country.upper(), area=area))
360
+ route = Route(
361
+ org=org, agent=wanted.agent, channel=wanted.channel, number=for_sale, env=env, managed=True
362
+ )
363
+ fleets = await worlds.fleets(connections.pool)
364
+ survey = Survey(
365
+ route=route,
366
+ origin="bought",
367
+ fence=fence_of(org, for_sale, None, None, None),
368
+ account=None,
369
+ networks=(),
370
+ fleet=worlds.fleet_of(fleets, env),
371
+ domain=domain,
372
+ origination=origination,
373
+ at_twilio=AtTwilio(
374
+ twilio=twilio,
375
+ number=None,
376
+ trunk=await twilio.trunk_pointing_at(origination),
377
+ for_sale=for_sale,
378
+ ),
379
+ )
380
+ return await _survey_sfu(connections, survey)
381
+
382
+
383
+ async def _at_twilio(
384
+ connections: Connections, twilio: Twilio, number: str, world: Env, *, move: bool
385
+ ) -> AtTwilio:
386
+ owned = await twilio.number(number)
387
+ if owned is None:
388
+ raise NotFound(NOT_ON_ACCOUNT.format(number=number, account=twilio.sid))
389
+ trunk = await twilio.trunk_pointing_at(origination_uri(sip_domain_of(connections, world)))
390
+ elsewhere = owned.trunk_sid is not None and (trunk is None or owned.trunk_sid != trunk.sid)
391
+ if elsewhere and owned.trunk_sid is not None and not move:
392
+ where = await twilio.originations(owned.trunk_sid)
393
+ other = await twilio.trunk(owned.trunk_sid)
394
+ raise Conflict(
395
+ ON_ANOTHER_TRUNK.format(
396
+ number=number,
397
+ trunk=other.friendly_name or other.sid,
398
+ account=twilio.sid,
399
+ where=", ".join(where) or "nowhere",
400
+ )
401
+ )
402
+ return AtTwilio(twilio=twilio, number=owned, trunk=trunk)
403
+
404
+
405
+ async def _survey_sfu(connections: Connections, survey: Survey) -> Survey:
406
+ route, fence = survey.route, survey.fence
407
+ number = str(route.number)
408
+ survey.routed = await routes.of_number(connections.pool, route.org, number)
409
+ if route.channel != "phone":
410
+ return survey
411
+ server, other = connections.servers[route.env], sip.elsewhere(connections, route.env)
412
+ listing = await sip.trunks_admitting(server, number)
413
+ stranger = next((trunk for trunk in listing if not sip.belongs_to(trunk.name, route.org)), None)
414
+ if stranger is not None:
415
+ raise Conflict(HELD_ELSEWHERE.format(number=number))
416
+ survey.leaving = [trunk for trunk in listing if fence is None or trunk.name != fence.trunk]
417
+ if other is not None:
418
+ survey.elsewhere = [
419
+ trunk
420
+ for trunk in await sip.trunks_admitting(other, number)
421
+ if sip.belongs_to(trunk.name, route.org)
422
+ ]
423
+ if fence is None:
424
+ return survey
425
+ survey.trunk = await sip.trunk_named(server, fence.trunk)
426
+ survey.rule = await sip.rule_named(server, rule_name(route.org, route.env))
427
+ survey.other_rule = await sip.rule_named(server, rule_name(route.org, other_world(route.env)))
428
+ return survey
429
+
430
+
431
+ async def _write_hook(connections: Connections, survey: Survey) -> None:
432
+ route, number = survey.route, str(survey.route.number)
433
+ at = survey.at_twilio
434
+ if at is not None and at.number is not None:
435
+ trunk = at.trunk or await at.twilio.trunk_made(survey.domain, survey.origination)
436
+ survey.at_twilio = replace(at, trunk=trunk)
437
+ if at.number.trunk_sid not in {None, trunk.sid}:
438
+ await at.twilio.detach(str(at.number.trunk_sid), at.number.sid)
439
+ if at.number.trunk_sid != trunk.sid:
440
+ await at.twilio.attach(trunk.sid, at.number.sid)
441
+ if survey.origin == "hooked" and survey.networks:
442
+ await carrier_networks.ask(connections.pool, route.org, number, survey.networks)
443
+ server, other = connections.servers[route.env], sip.elsewhere(connections, route.env)
444
+ if other is not None and route.channel == "phone":
445
+ await sip.let_go(other, route.org, number)
446
+ for leaving in survey.leaving:
447
+ kept = [listed for listed in leaving.numbers if listed != number]
448
+ await sip.renumber_trunk(server, route.org, leaving, kept)
449
+ if survey.fence is not None:
450
+ # Off the other world's rule first: two rules of one trunk never list one number.
451
+ if survey.other_rule is not None and number in survey.other_rule.numbers:
452
+ kept = [listed for listed in survey.other_rule.numbers if listed != number]
453
+ await sip.renumber_rule(server, survey.other_rule, kept)
454
+ trunk_id = await sip.admit(server, survey.fence, survey.trunk, number)
455
+ world = WorldRule(route.org, route.env, survey.fleet)
456
+ await sip.rule_in(server, world, [trunk_id], number)
457
+ # A hooked number waits for the operator only where the box asks (PINECALL_APPROVE_HOOKED).
458
+ waits = survey.origin == "hooked" and connections.settings.approve_hooked
459
+ written = RouteWrite(
460
+ origin=survey.origin,
461
+ account=survey.account,
462
+ networks=survey.networks,
463
+ via=survey.via,
464
+ waits=waits,
465
+ )
466
+ await routes.put(connections.pool, route, written)
467
+
468
+
469
+ def _steps(survey: Survey, *, done: bool) -> list[str]:
470
+ route, number = survey.route, str(survey.route.number)
471
+ made = "done" if done else "to do"
472
+ steps: list[str] = []
473
+ at = survey.at_twilio
474
+ if at is not None:
475
+ if at.for_sale is not None:
476
+ steps.append(f"Twilio: buy {at.for_sale} on the box's account: {made}")
477
+ pointed = f"Twilio: a trunk sending calls to {survey.origination}"
478
+ steps.append(f"{pointed}: {'stands' if at.trunk else made}")
479
+ on_it = (
480
+ at.number is not None and at.trunk is not None and at.number.trunk_sid == at.trunk.sid
481
+ )
482
+ steps.append(f"Twilio: {number} attached to it: {'stands' if on_it else made}")
483
+ fence = survey.fence
484
+ other = other_world(route.env)
485
+ steps += [
486
+ f"LiveKit of the {other}: {number} off trunk {trunk.name}: {made}"
487
+ for trunk in survey.elsewhere
488
+ ]
489
+ steps += [f"LiveKit: {number} off trunk {trunk.name}: {made}" for trunk in survey.leaving]
490
+ if fence is None and survey.waiting and route.channel == "phone":
491
+ waiting = ", ".join(survey.waiting)
492
+ steps.append(f"LiveKit: {number} waits for the box's operator to approve {waiting}: {made}")
493
+ if fence is not None:
494
+ admits = survey.trunk is not None and number in survey.trunk.numbers
495
+ steps.append(
496
+ f"LiveKit: trunk {fence.trunk} admits {number}: {'stands' if admits else made}"
497
+ )
498
+ rule = rule_name(route.org, route.env)
499
+ ruled = survey.rule is not None and number in survey.rule.numbers
500
+ steps.append(
501
+ f"LiveKit: rule {rule} sends it to fleet {survey.fleet}: {'stands' if ruled else made}"
502
+ )
503
+ routed = survey.routed == route
504
+ steps.append(
505
+ f"route: {number} to {route.agent} in the {route.env}: {'stands' if routed else made}"
506
+ )
507
+ return steps
508
+
509
+
510
+ # A token Meta refuses (they expire) leaves the account listed with no number, said in the log.
511
+ async def _at_meta(
512
+ http: httpx.AsyncClient, account: WhatsappAccount, imported: set[str]
513
+ ) -> list[OwnedNumber]:
514
+ try:
515
+ number, name = await whatsapp.display_number(
516
+ http, account.access_token, account.phone_number_id
517
+ )
518
+ except UpstreamFailed:
519
+ logger.warning(
520
+ "Meta did not say which number %s is", account.phone_number_id, exc_info=True
521
+ )
522
+ return []
523
+ return [
524
+ OwnedNumber(
525
+ number=number,
526
+ name=name or number,
527
+ imported=number in imported,
528
+ account=account.phone_number_id,
529
+ )
530
+ ]
@@ -0,0 +1,77 @@
1
+ """Where the Twilio trunks send a world's calls: to its SIP name, from its name or a former one."""
2
+
3
+ import contextlib
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from pinecall.channels.telephony._twilio import origination_uri, twilio_of
8
+ from pinecall.domain.errors import NotAvailable
9
+ from pinecall.domain.names import ENVS, Env
10
+ from pinecall.process.connections import Connections
11
+ from pinecall.tenancy import orgs
12
+ from pinecall.tenancy.carriers import TwilioAccount, box_twilio, carriers_of
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Repointed:
17
+ """A trunk of an account that sent a world's calls to one URI and now sends them to another."""
18
+
19
+ account: str
20
+ trunk: str
21
+ world: Env
22
+ was: str
23
+ now: str
24
+
25
+
26
+ # Once, when a box's SIP moves off its names (a cluster: the names are the HTTPS load balancer's) or
27
+ # a name is retired for another (`former`, the names a world was reached at before): the runtime
28
+ # never touches a carrier on its own, so the operator asks for it (operator-api.md).
29
+ async def repointed(
30
+ connections: Connections, former: Mapping[Env, Sequence[str]] | None = None
31
+ ) -> list[Repointed]:
32
+ """Each trunk of the box's and the orgs' Twilio accounts moved to its world's SIP name."""
33
+ moves = _moves(connections, former or {})
34
+ if not moves:
35
+ return []
36
+ done: list[Repointed] = []
37
+ for account in await _twilio_accounts(connections):
38
+ twilio = twilio_of(connections.http, account)
39
+ for world, was, now in moves:
40
+ for trunk in await twilio.trunks_pointing_at(was):
41
+ await twilio.repointed(trunk.sid, was, now)
42
+ done.append(Repointed(account.account_sid, trunk.sid, world, was, now))
43
+ return done
44
+
45
+
46
+ # From the world's own name when SIP has a name of its own, and from every former name given.
47
+ def _moves(
48
+ connections: Connections, former: Mapping[Env, Sequence[str]]
49
+ ) -> list[tuple[Env, str, str]]:
50
+ settings = connections.settings
51
+ moves: list[tuple[Env, str, str]] = []
52
+ for world in ENVS:
53
+ sip = settings.sip_name_of(world)
54
+ if not sip:
55
+ continue
56
+ names = dict.fromkeys([settings.domain or "", *former.get(world, ())])
57
+ moves.extend(_moved_from(world, [name for name in names if name and name != sip], sip))
58
+ return moves
59
+
60
+
61
+ def _moved_from(world: Env, names: Sequence[str], sip: str) -> list[tuple[Env, str, str]]:
62
+ return [(world, origination_uri(name), origination_uri(sip)) for name in names]
63
+
64
+
65
+ # The box's own account, then each org's, an account two orgs brought counted once.
66
+ async def _twilio_accounts(connections: Connections) -> list[TwilioAccount]:
67
+ pool, vault = connections.pool, connections.vault
68
+ found: dict[str, TwilioAccount] = {}
69
+ # A box with no Twilio account of its own has only the orgs'.
70
+ with contextlib.suppress(NotAvailable):
71
+ boxs = await box_twilio(pool, vault)
72
+ found[boxs.account_sid] = boxs
73
+ for org in await orgs.listed(pool):
74
+ for carrier in await carriers_of(pool, vault, org.id):
75
+ if isinstance(carrier.account, TwilioAccount):
76
+ found.setdefault(carrier.account.account_sid, carrier.account)
77
+ return list(found.values())