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,419 @@
1
+ """Dialling out: an account made dialable, the guards and their ledger, a call placed, a leg."""
2
+
3
+ import logging
4
+ import secrets
5
+ import time
6
+ from dataclasses import dataclass, replace
7
+ from datetime import date, datetime
8
+
9
+ from livekit import api
10
+ from livekit.protocol.sip import (
11
+ SIPOutboundConfig,
12
+ SIPTransport,
13
+ )
14
+ from psycopg.rows import DictRow
15
+
16
+ from pinecall.channels import routes
17
+ from pinecall.channels.offers import Offering
18
+ from pinecall.channels.rooms import Dialling, Dispatch
19
+ from pinecall.channels.telephony._twilio import (
20
+ TERMINATION_SUFFIX,
21
+ Twilio,
22
+ TwilioTrunk,
23
+ origination_uri,
24
+ termination_host,
25
+ )
26
+ from pinecall.channels.telephony.carrier import control_of, dialled_of, missing_to_dial
27
+ from pinecall.channels.telephony.sip import domain_of, sip_domain_of
28
+ from pinecall.domain.call import CallContext, new_call_id
29
+ from pinecall.domain.errors import (
30
+ Conflict,
31
+ DeclarationRefused,
32
+ NotFound,
33
+ UpstreamFailed,
34
+ )
35
+ from pinecall.domain.names import Env
36
+ from pinecall.domain.scope import Scope
37
+ from pinecall.fleet import worlds
38
+ from pinecall.log.logs import Logs, arrival_entry
39
+ from pinecall.log.store import Claim
40
+ from pinecall.postgres.pool import Pool
41
+ from pinecall.process.connections import Connections
42
+ from pinecall.tenancy import admission
43
+ from pinecall.tenancy.carriers import (
44
+ Carrier,
45
+ Termination,
46
+ Transport,
47
+ carrier_named,
48
+ seal_carrier,
49
+ )
50
+ from pinecall.tenancy.dial_policy import Dial, Guards, guard_dial, guard_second_leg, guards_of
51
+ from pinecall.wire.events import CallEnded
52
+ from pinecall.wire.rest.numbers import Consent, LegTrunk
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ NO_PHONE_DOOR = "agent {agent} answers at no phone number in the {env}: a call back shows one"
58
+
59
+
60
+ NOT_OUR_NUMBER = "{shown} is not a number agent {agent} answers at in the {env}"
61
+
62
+
63
+ DIALS_THROUGH_NOTHING = (
64
+ "{shown} was hooked by the org or bought by the box: it dials through no account of the org"
65
+ )
66
+
67
+
68
+ CREDENTIALS_LOST = (
69
+ "Twilio account {account} holds the credential {username} on list {name}, whose password "
70
+ "this box no longer has: delete that credential in Twilio's console and provision again"
71
+ )
72
+
73
+
74
+ DID_NOT_DIAL = "the media plane refused the call: {message}"
75
+
76
+
77
+ CALLING_FROM = """
78
+ SELECT number, account FROM routes
79
+ WHERE org = %(org)s AND env = %(env)s AND channel = 'phone' ORDER BY added_at, number
80
+ """
81
+
82
+
83
+ TRANSPORTS: dict[Transport, SIPTransport] = {
84
+ "auto": SIPTransport.SIP_TRANSPORT_AUTO,
85
+ "udp": SIPTransport.SIP_TRANSPORT_UDP,
86
+ "tcp": SIPTransport.SIP_TRANSPORT_TCP,
87
+ "tls": SIPTransport.SIP_TRANSPORT_TLS,
88
+ }
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class OutboundReadiness:
93
+ """Whether the org can place a call through an account, and what is still missing."""
94
+
95
+ ready: bool
96
+ kind: str | None
97
+ from_numbers: list[str]
98
+ steps_missing: list[str]
99
+ guards: Guards
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Provisioned:
104
+ """What provisioning did, or would do."""
105
+
106
+ steps: list[str]
107
+ dry_run: bool
108
+ ready: bool
109
+ trunk: str | None = None
110
+ address: str | None = None
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class TwilioSurvey:
115
+ """What the account holds today: the trunk, its termination host, the list, the credential."""
116
+
117
+ twilio: Twilio
118
+ trunk: TwilioTrunk | None
119
+ host: str
120
+ list_name: str
121
+ listed: str | None
122
+ username: str
123
+ # Whether the list already holds the org's credential; else one is made and sealed.
124
+ holds: bool
125
+ on_trunk: bool
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class OutboundSurvey:
130
+ """How an account stands before anything is written: the steps read, and Twilio's own."""
131
+
132
+ steps: list[str]
133
+ address: str | None = None
134
+ twilio: TwilioSurvey | None = None
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Placement:
139
+ """A call asked for: scope, agent, the far end, the number shown, who asked, when."""
140
+
141
+ scope: Scope
142
+ agent: str
143
+ to: str
144
+ shown: str | None
145
+ asked_by: str
146
+ today: date
147
+ at: datetime
148
+ # The far end is the asker's own verified phone: a test, held to no caller's hours.
149
+ own_phone: bool = False
150
+ consent: Consent | None = None
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class PlacedCall:
155
+ """The call a dial became, before the far end has heard anything ring."""
156
+
157
+ call: str
158
+ to: str
159
+ shown: str
160
+
161
+
162
+ async def outbound_readiness(
163
+ connections: Connections, scope: Scope, account: str | None = None
164
+ ) -> OutboundReadiness:
165
+ """Whether the org can dial out in the world, one sentence per thing missing, in order."""
166
+ guards = await guards_of(connections.pool, scope.org)
167
+ numbers = [str(row["number"]) for row in await _calling_from(connections.pool, scope)]
168
+ try:
169
+ carrier = await carrier_named(connections.pool, connections.vault, scope.org, account)
170
+ except NotFound as missing:
171
+ return OutboundReadiness(
172
+ ready=False,
173
+ kind=None,
174
+ from_numbers=numbers,
175
+ steps_missing=[str(missing)],
176
+ guards=guards,
177
+ )
178
+ missing = missing_to_dial(carrier)
179
+ if not numbers:
180
+ missing.append(f"the org answers at no phone number in the {scope.env}: a call shows one")
181
+ return OutboundReadiness(
182
+ ready=not missing,
183
+ kind=carrier.account.kind,
184
+ from_numbers=numbers,
185
+ steps_missing=missing,
186
+ guards=guards,
187
+ )
188
+
189
+
190
+ async def plan_outbound(
191
+ connections: Connections, org: str, world: Env, account: str | None = None
192
+ ) -> Provisioned:
193
+ """What provisioning would write, and nothing written."""
194
+ carrier = await carrier_named(connections.pool, connections.vault, org, account)
195
+ survey = await _surveyed(connections, carrier, world)
196
+ return Provisioned(
197
+ steps=survey.steps, dry_run=True, ready=survey.twilio is None, address=survey.address
198
+ )
199
+
200
+
201
+ async def provision_outbound(
202
+ connections: Connections, org: str, world: Env, account: str | None = None
203
+ ) -> Provisioned:
204
+ """Make the account dialable: Twilio's termination and a credential; a peer needs nothing."""
205
+ carrier = await carrier_named(connections.pool, connections.vault, org, account)
206
+ survey = await _surveyed(connections, carrier, world)
207
+ if survey.twilio is None:
208
+ return Provisioned(steps=survey.steps, dry_run=False, ready=True, address=survey.address)
209
+ trunk = await _twilio_provisioned(connections, carrier, survey.twilio, world)
210
+ return Provisioned(
211
+ steps=survey.steps, dry_run=False, ready=True, trunk=trunk, address=survey.address
212
+ )
213
+
214
+
215
+ async def place_call(
216
+ connections: Connections, logs: Logs, placement: Placement, offering: Offering, *, running: int
217
+ ) -> PlacedCall:
218
+ """Place the call: number shown, account, guards, minutes, the log, then the dispatch."""
219
+ scope = placement.scope
220
+ doors = [
221
+ route
222
+ for route in await routes.of_org(connections.pool, scope.org, scope.env)
223
+ if route.agent == placement.agent and route.channel == "phone"
224
+ ]
225
+ if not doors:
226
+ raise NotFound(NO_PHONE_DOOR.format(agent=placement.agent, env=scope.env))
227
+ shown = placement.shown or str(doors[0].number)
228
+ route = next((door for door in doors if door.number == shown), None)
229
+ if route is None:
230
+ raise DeclarationRefused(
231
+ NOT_OUR_NUMBER.format(shown=shown, agent=placement.agent, env=scope.env)
232
+ )
233
+ carrier = await _dials_through(connections, scope, shown)
234
+ call = new_call_id()
235
+ dial = Dial(
236
+ scope,
237
+ placement.agent,
238
+ placement.to,
239
+ shown,
240
+ placement.asked_by,
241
+ call,
242
+ placement.at,
243
+ own_phone=placement.own_phone,
244
+ consent=placement.consent,
245
+ )
246
+ guards = await guard_dial(connections.pool, dial)
247
+ await admission.admit_call(
248
+ connections.pool, scope.org, scope.env, running=running, at=placement.at.timestamp()
249
+ )
250
+ context = CallContext(
251
+ call=call,
252
+ channel="phone",
253
+ direction="outbound",
254
+ caller=placement.to,
255
+ route=route,
256
+ today=placement.today,
257
+ holder=scope.holder or None,
258
+ )
259
+ await logs.store.claim(call, placement.agent, scope.org, Claim(scope))
260
+ log = logs.writing(call, placement.agent)
261
+ kind, data = arrival_entry(context, shown, asked_by=placement.asked_by)
262
+ await log.append(kind, data)
263
+ fleets = await worlds.fleets(connections.pool)
264
+ carried = Dispatch(
265
+ agent=placement.agent,
266
+ org=scope.org,
267
+ env=scope.env,
268
+ holder=scope.holder or None,
269
+ direction="outbound",
270
+ caller=placement.to,
271
+ dial=Dialling(
272
+ trunk=carrier.id, to=placement.to, shown=shown, max_duration_s=guards.max_duration_s
273
+ ),
274
+ )
275
+ try:
276
+ await offering.offer(call, worlds.fleet_of(fleets, scope.env), carried)
277
+ except api.TwirpError as refused:
278
+ ended = CallEnded(
279
+ reason="dial_failed", ended_by="platform", ended_at=time.time(), duration_s=0.0
280
+ )
281
+ await log.append("call.ended", ended.written())
282
+ await log.seal()
283
+ logs.forget(call)
284
+ raise UpstreamFailed(DID_NOT_DIAL.format(message=refused.message)) from refused
285
+ return PlacedCall(call=call, to=placement.to, shown=shown)
286
+
287
+
288
+ async def leg_trunk(connections: Connections, dial: Dial) -> LegTrunk:
289
+ """How a leg of a live call is dialled, after its guards: the account of the number shown."""
290
+ await guard_second_leg(connections.pool, dial)
291
+ scope = dial.scope
292
+ shown = dial.shown
293
+ if shown is None:
294
+ doors = [
295
+ route
296
+ for route in await routes.of_org(connections.pool, scope.org, scope.env)
297
+ if route.agent == dial.agent and route.channel == "phone"
298
+ ]
299
+ if not doors:
300
+ raise NotFound(NO_PHONE_DOOR.format(agent=dial.agent, env=scope.env))
301
+ shown = str(doors[0].number)
302
+ return _leg_through(await _dials_through(connections, scope, shown), shown)
303
+
304
+
305
+ def sip_config(leg: LegTrunk) -> SIPOutboundConfig:
306
+ """The inline trunk livekit dials a leg through."""
307
+ return SIPOutboundConfig(
308
+ hostname=leg.hostname,
309
+ transport=TRANSPORTS[leg.transport],
310
+ auth_username=leg.username,
311
+ auth_password=leg.password,
312
+ )
313
+
314
+
315
+ # This box makes nothing on somebody else's switch: a peer is dialled where it said.
316
+ async def _surveyed(connections: Connections, carrier: Carrier, world: Env) -> OutboundSurvey:
317
+ control = control_of(connections.http, carrier)
318
+ if control is not None:
319
+ return await _twilio_surveyed(connections, carrier, control, world)
320
+ dialled = dialled_of(carrier)
321
+ where = f"{dialled.hostname} over {dialled.transport}"
322
+ return OutboundSurvey(steps=[f"SIP peer: dialled at {where}: stands"], address=dialled.hostname)
323
+
324
+
325
+ # Read before anything is written, so a plan and a provisioning see the same account.
326
+ async def _twilio_surveyed(
327
+ connections: Connections, carrier: Carrier, twilio: Twilio, world: Env
328
+ ) -> OutboundSurvey:
329
+ domain = domain_of(connections)
330
+ origination = origination_uri(sip_domain_of(connections, world))
331
+ trunk = await twilio.trunk_pointing_at(origination)
332
+ host = termination_host(domain, twilio.sid)
333
+ # One list per trunk, as the trunk is per account and box; one credential per org on it,
334
+ # so two orgs that brought the same account each dial with their own.
335
+ name = host.removesuffix(TERMINATION_SUFFIX)
336
+ username = f"pinecall-{carrier.org}".replace("_", "-")
337
+ listed = await twilio.credential_list(name)
338
+ holds = listed is not None and username in await twilio.usernames_in(listed)
339
+ if holds and carrier.outbound is None:
340
+ raise Conflict(CREDENTIALS_LOST.format(account=twilio.sid, username=username, name=name))
341
+ on_trunk = (
342
+ trunk is not None and listed is not None and listed in await twilio.lists_on(trunk.sid)
343
+ )
344
+ pointed = f"Twilio: a trunk sending calls to {origination}"
345
+ terminated = trunk is not None and trunk.domain_name == host
346
+ steps = [
347
+ f"{pointed}: {'stands' if trunk else 'to do'}",
348
+ f"Twilio: dialled at {host}: {'stands' if terminated else 'to do'}",
349
+ f"Twilio: credential list {name}: {'stands' if listed else 'to do'}",
350
+ f"Twilio: the org's credential {username} on it: {'stands' if holds else 'to do'}",
351
+ f"Twilio: the trunk asks for it: {'stands' if on_trunk else 'to do'}",
352
+ ]
353
+ at_twilio = TwilioSurvey(
354
+ twilio=twilio,
355
+ trunk=trunk,
356
+ host=host,
357
+ list_name=name,
358
+ listed=listed,
359
+ username=username,
360
+ holds=holds,
361
+ on_trunk=on_trunk,
362
+ )
363
+ return OutboundSurvey(steps=steps, address=host, twilio=at_twilio)
364
+
365
+
366
+ async def _twilio_provisioned(
367
+ connections: Connections, carrier: Carrier, survey: TwilioSurvey, world: Env
368
+ ) -> str:
369
+ domain = domain_of(connections)
370
+ twilio = survey.twilio
371
+ trunk = survey.trunk or await twilio.trunk_made(
372
+ domain, origination_uri(sip_domain_of(connections, world))
373
+ )
374
+ if trunk.domain_name != survey.host:
375
+ await twilio.terminated(trunk.sid, survey.host)
376
+ listed = survey.listed or await twilio.credential_list_made(survey.list_name)
377
+ outbound = carrier.outbound
378
+ if not survey.holds:
379
+ outbound = Termination(
380
+ host=survey.host, username=survey.username, password=secrets.token_urlsafe(24)
381
+ )
382
+ await twilio.credential_added(listed, outbound.username, outbound.password)
383
+ elif outbound is not None and outbound.host != survey.host:
384
+ # The trunk was given the box's host just now (its name changed): dial where it answers.
385
+ outbound = replace(outbound, host=survey.host)
386
+ if not survey.on_trunk:
387
+ await twilio.list_attached(trunk.sid, listed)
388
+ await seal_carrier(connections.pool, connections.vault, replace(carrier, outbound=outbound))
389
+ return trunk.sid
390
+
391
+
392
+ async def _dials_through(connections: Connections, scope: Scope, shown: str) -> Carrier:
393
+ rows = await _calling_from(connections.pool, scope)
394
+ account = next((row["account"] for row in rows if row["number"] == shown), None)
395
+ if account is None:
396
+ raise Conflict(DIALS_THROUGH_NOTHING.format(shown=shown))
397
+ carrier = await carrier_named(connections.pool, connections.vault, scope.org, str(account))
398
+ missing = missing_to_dial(carrier)
399
+ if missing:
400
+ raise Conflict(missing[0])
401
+ return carrier
402
+
403
+
404
+ def _leg_through(carrier: Carrier, shown: str) -> LegTrunk:
405
+ dialled = dialled_of(carrier)
406
+ return LegTrunk(
407
+ hostname=dialled.hostname,
408
+ transport=dialled.transport,
409
+ username=dialled.username,
410
+ password=dialled.password,
411
+ shown=shown,
412
+ )
413
+
414
+
415
+ async def _calling_from(pool: Pool, scope: Scope) -> list[DictRow]:
416
+ async with pool.connection() as connection:
417
+ return await (
418
+ await connection.execute(CALLING_FROM, {"org": scope.org, "env": scope.env})
419
+ ).fetchall()
@@ -0,0 +1,58 @@
1
+ """The networks 5060 opens to beyond Twilio's: the carriers admitted and the approved addresses."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from ipaddress import IPv4Network, ip_network
6
+
7
+ from pinecall.channels.telephony import carrier_catalog
8
+ from pinecall.channels.telephony.carrier_catalog import BOX_CARRIER, known
9
+ from pinecall.postgres.pool import Pool
10
+ from pinecall.tenancy import carrier_networks, orgs
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ # Checked again at the list's end: a row the gateway let through still opens nothing this wide.
16
+ WIDEST_FOR_A_CARRIER = 16
17
+
18
+
19
+ TOO_WIDE = "%s (%s) is not opened: wider than a /%d"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Opening:
24
+ """A network 5060 opens to, and why: a carrier's kind, or the org and what it asked for."""
25
+
26
+ network: str
27
+ reason: str
28
+
29
+
30
+ async def openings(pool: Pool) -> list[Opening]:
31
+ """Every network beyond Twilio's the fence opens to now, the catalog's first."""
32
+ admitted = await carrier_catalog.admitted(pool)
33
+ found = [
34
+ Opening(network=network, reason=carrier.kind)
35
+ for carrier in known().values()
36
+ if carrier.kind in admitted and carrier.kind != BOX_CARRIER
37
+ for network in carrier.networks
38
+ ]
39
+ slugs = {org.id: org.slug for org in await orgs.listed(pool)}
40
+ found += [
41
+ Opening(network=ask.network, reason=f"{slugs.get(ask.org, ask.org)}: {ask.source}")
42
+ for ask in await carrier_networks.listed(pool, "approved")
43
+ ]
44
+ return [opening for opening in found if _narrow_enough(opening)]
45
+
46
+
47
+ def every_network(found: list[Opening]) -> list[str]:
48
+ """Twilio's networks and every opening: what the cloud's firewall admits to 5060."""
49
+ return list(dict.fromkeys([*known()[BOX_CARRIER].networks, *(o.network for o in found)]))
50
+
51
+
52
+ def _narrow_enough(opening: Opening) -> bool:
53
+ parsed = ip_network(opening.network, strict=False)
54
+ widest = WIDEST_FOR_A_CARRIER if opening.reason in known() else carrier_networks.WIDEST
55
+ if not isinstance(parsed, IPv4Network) or parsed.prefixlen < widest:
56
+ logger.warning(TOO_WIDE, opening.network, opening.reason, widest)
57
+ return False
58
+ return True
@@ -0,0 +1,78 @@
1
+ """A developer's own phone at a production number, carried to the sandbox's LiveKit by SIP."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ from collections.abc import Mapping
6
+
7
+ from livekit import api
8
+ from livekit.protocol.webhook import WebhookEvent
9
+
10
+ from pinecall.channels import rooms
11
+ from pinecall.channels.rooms import Dispatch
12
+
13
+ # The sandbox's trunk and its rule that admit production's hand-overs are both named so.
14
+ HAND_OVER = "hand-over"
15
+
16
+ USERNAME = "pinecall-hand-over"
17
+
18
+ # What the trunk's password is drawn under, from the LiveKit secret both worlds' clusters share.
19
+ PAIR_OF = b"pinecall: a ring handed from production to the sandbox"
20
+
21
+ # The leg production's room dials, by its identity there.
22
+ LEG_PREFIX = "hand_over_"
23
+
24
+ LEFT = "participant_left"
25
+
26
+ # Whose sandbox copy the ring is for rides the leg's INVITE, a header each; the sandbox's trunk
27
+ # turns each into an attribute of its leg.
28
+ ORG = "pinecall.org"
29
+ AGENT = "pinecall.agent"
30
+ HOLDER = "pinecall.holder"
31
+
32
+ HEADER_OF: Mapping[str, str] = {
33
+ ORG: "X-Pinecall-Org",
34
+ AGENT: "X-Pinecall-Agent",
35
+ HOLDER: "X-Pinecall-Holder",
36
+ }
37
+
38
+ # The sandbox's trunk's map of the leg's headers to its attributes.
39
+ ATTRIBUTE_OF: Mapping[str, str] = {header: attribute for attribute, header in HEADER_OF.items()}
40
+
41
+
42
+ def password_of(secret: str) -> str:
43
+ """The hand-over trunk's password: whoever holds LiveKit's secret could rewrite the trunk."""
44
+ return hmac.new(secret.encode(), PAIR_OF, hashlib.sha256).hexdigest()
45
+
46
+
47
+ def headers_of(moved: Dispatch) -> dict[str, str]:
48
+ """The headers the hand-over leg carries: the org, the agent and the developer it is for."""
49
+ values = {ORG: moved.org, AGENT: moved.agent, HOLDER: moved.holder}
50
+ return {HEADER_OF[attribute]: value or "" for attribute, value in values.items()}
51
+
52
+
53
+ # The hand-over rule dispatches the same metadata for every ring; whose ring it is rides the leg.
54
+ def handed_in(dispatch: Dispatch, attributes: Mapping[str, str]) -> Dispatch:
55
+ """The hand-over rule's dispatch made the developer's call, from what its leg carries."""
56
+ if dispatch.diverted_from is None or dispatch.agent is not None or AGENT not in attributes:
57
+ return dispatch
58
+ carried = {
59
+ "org": attributes.get(ORG),
60
+ "agent": attributes.get(AGENT),
61
+ "holder": attributes.get(HOLDER),
62
+ }
63
+ return dispatch.model_copy(update=carried)
64
+
65
+
66
+ # LiveKit ends no SIP leg when another leaves: a caller who hung up would leave the developer's
67
+ # copy talking to nobody, and a copy that ended would leave the caller in silence.
68
+ async def unbridged(server: api.LiveKitAPI, event: WebhookEvent) -> bool:
69
+ """Close a room a hand-over bridges once either of its legs left; whether it did."""
70
+ if event.event != LEFT or event.participant.kind != api.ParticipantInfo.Kind.SIP:
71
+ return False
72
+ name = event.room.name
73
+ if not event.participant.identity.startswith(LEG_PREFIX):
74
+ seats = await rooms.seats_in(server, name)
75
+ if not any(seat.identity.startswith(LEG_PREFIX) for seat in seats):
76
+ return False
77
+ await rooms.room_closed(server, name)
78
+ return True