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,275 @@
1
+ """Which agent answers a number or a channel, per org and world: the routes table."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from psycopg.rows import DictRow
6
+
7
+ from pinecall.domain.call import Route
8
+ from pinecall.domain.names import Channel, Env, RouteOrigin
9
+ from pinecall.postgres.pool import Pool, box_wide
10
+
11
+ OF_ORG = """
12
+ SELECT org, number, agent, channel, env, managed FROM routes
13
+ WHERE org = %(org)s AND env = %(env)s
14
+ ORDER BY added_at, number
15
+ """
16
+ # One org holds a number (routes_number_held_once), so a number reads one row at most.
17
+ AT = """
18
+ SELECT org, number, agent, channel, env, managed FROM routes
19
+ WHERE channel = %(channel)s AND number = %(number)s
20
+ """
21
+ OF_NUMBER = """
22
+ SELECT org, number, agent, channel, env, managed FROM routes
23
+ WHERE org = %(org)s AND number = %(number)s
24
+ """
25
+ # A number added again moves: its agent, channel, world and how it came are the newest said. One
26
+ # written to wait (a hooked number, where the box asks for approval) is unapproved until the
27
+ # operator approves it; one approved once stays approved however it comes again.
28
+ PUT = """
29
+ INSERT INTO routes (org, number, agent, channel, env, managed, account, networks, origin, via,
30
+ approved_at)
31
+ VALUES (%(org)s, %(number)s, %(agent)s, %(channel)s, %(env)s, %(managed)s, %(account)s,
32
+ %(networks)s, %(origin)s, %(via)s,
33
+ CASE WHEN %(waits)s THEN NULL ELSE now() END)
34
+ ON CONFLICT (org, number) DO UPDATE SET agent = excluded.agent, channel = excluded.channel,
35
+ env = excluded.env, managed = excluded.managed, account = excluded.account,
36
+ networks = excluded.networks, origin = excluded.origin, via = excluded.via,
37
+ approved_at = COALESCE(routes.approved_at, excluded.approved_at)
38
+ """
39
+ APPROVE = """
40
+ UPDATE routes SET approved_at = now(), approved_by = %(by)s
41
+ WHERE org = %(org)s AND number = %(number)s AND approved_at IS NULL
42
+ RETURNING number
43
+ """
44
+ WAITING = """
45
+ SELECT 1 FROM routes WHERE org = %(org)s AND number = %(number)s AND approved_at IS NULL
46
+ """
47
+ ELSEWHERE = "SELECT 1 FROM routes WHERE number = %(number)s AND org <> %(org)s"
48
+ REMOVE = "DELETE FROM routes WHERE org = %(org)s AND number = %(number)s RETURNING number"
49
+ MOVE = "UPDATE routes SET env = %(env)s WHERE org = %(org)s AND number = %(number)s RETURNING env"
50
+ MANAGED = "SELECT count(*) AS bought FROM routes WHERE org = %(org)s AND env = %(env)s AND managed"
51
+ # A number the target org already typed stays where it was: one row per number per org.
52
+ WITH_AGENT = """
53
+ UPDATE routes SET org = %(org)s
54
+ WHERE agent = %(agent)s AND org <> %(org)s
55
+ AND NOT EXISTS (SELECT 1 FROM routes AS theirs WHERE theirs.org = %(org)s
56
+ AND theirs.number = routes.number)
57
+ RETURNING number
58
+ """
59
+ STAYED = "SELECT number FROM routes WHERE agent = %(agent)s AND org <> %(org)s ORDER BY number"
60
+ # A number rung all day is written once a minute, never once a call.
61
+ CALLED = """
62
+ UPDATE routes SET last_call_at = now()
63
+ WHERE org = %(org)s AND number = %(number)s
64
+ AND (last_call_at IS NULL OR last_call_at < now() - interval '1 minute')
65
+ """
66
+ # A row with what the table keeps beside it: how it was written, the kind of the account its
67
+ # number lives in, and whether the operator approved it.
68
+ RECORDS = """
69
+ SELECT routes.org, routes.number, routes.agent, routes.channel, routes.env, routes.managed,
70
+ routes.origin, routes.via, routes.last_call_at, routes.account, routes.networks,
71
+ routes.approved_at IS NOT NULL AS approved,
72
+ carriers.kind AS carrier
73
+ FROM routes LEFT JOIN carriers
74
+ ON carriers.org = routes.org AND carriers.account = routes.account
75
+ """
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class RouteWrite:
80
+ """What a row keeps beside its route: who wrote it, the account, the networks, the carrier."""
81
+
82
+ origin: RouteOrigin
83
+ account: str | None = None
84
+ # A number hooked from networks of the org's own, which the operator approves.
85
+ networks: tuple[str, ...] = ()
86
+ # The catalog carrier a number with no account comes through.
87
+ via: str | None = None
88
+ # Written unapproved: no call to it opens until the operator approves it.
89
+ waits: bool = False
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class RouteRecord:
94
+ """A route with how it was written, its account's kind, and whether it was approved."""
95
+
96
+ route: Route
97
+ origin: RouteOrigin
98
+ # None for a number no account of the org holds: bought by the box, hooked, typed, or an
99
+ # account since forgotten.
100
+ carrier: str | None
101
+ via: str | None = None
102
+ # When a call to the number last reached the box; None when none ever did.
103
+ last_call_at: float | None = None
104
+ # The org's account the number lives in, and a hooked number's own networks.
105
+ account: str | None = None
106
+ networks: tuple[str, ...] = ()
107
+ # False for a number the org hooked that the operator has not approved: no call to it opens.
108
+ approved: bool = True
109
+
110
+
111
+ ON_THE_BOX = RECORDS + "ORDER BY routes.number, routes.channel, routes.added_at"
112
+ OF_NUMBER_RECORD = RECORDS + "WHERE routes.org = %(org)s AND routes.number = %(number)s"
113
+ PHONES_OF_ORG = (
114
+ RECORDS
115
+ + "WHERE routes.org = %(org)s AND routes.channel = 'phone' AND routes.number IS NOT NULL "
116
+ + "ORDER BY routes.added_at, routes.number"
117
+ )
118
+ OF_ORG_RECORDS = (
119
+ RECORDS
120
+ + "WHERE routes.org = %(org)s AND routes.env = %(env)s ORDER BY routes.added_at, routes.number"
121
+ )
122
+
123
+
124
+ async def of_org(pool: Pool, org: str, env: Env) -> list[Route]:
125
+ """The org's numbers in one world, oldest first."""
126
+ async with pool.connection() as connection:
127
+ rows = await (await connection.execute(OF_ORG, {"org": org, "env": env})).fetchall()
128
+ return [_route(row) for row in rows]
129
+
130
+
131
+ async def at(pool: Pool, channel: Channel, number: str) -> Route | None:
132
+ """The route a call dialled to this number takes, whatever org holds it."""
133
+ params = {"channel": channel, "number": number}
134
+ with box_wide():
135
+ async with pool.connection() as connection:
136
+ row = await (await connection.execute(AT, params)).fetchone()
137
+ return None if row is None else _route(row)
138
+
139
+
140
+ async def on_the_box(pool: Pool) -> list[RouteRecord]:
141
+ """Every route of every org and world, by number, and which of them answers each number."""
142
+ async with pool.connection() as connection:
143
+ rows = await (await connection.execute(ON_THE_BOX)).fetchall()
144
+ return [_record(row) for row in rows]
145
+
146
+
147
+ async def records_of(pool: Pool, org: str, env: Env) -> list[RouteRecord]:
148
+ """The org's routes in one world, oldest first, each with what the table keeps beside it."""
149
+ async with pool.connection() as connection:
150
+ rows = await (await connection.execute(OF_ORG_RECORDS, {"org": org, "env": env})).fetchall()
151
+ return [_record(row) for row in rows]
152
+
153
+
154
+ async def phones_of(pool: Pool, org: str) -> list[RouteRecord]:
155
+ """The org's phone numbers in both worlds, oldest first, with what the table keeps."""
156
+ async with pool.connection() as connection:
157
+ rows = await (await connection.execute(PHONES_OF_ORG, {"org": org})).fetchall()
158
+ return [_record(row) for row in rows]
159
+
160
+
161
+ async def record_of(pool: Pool, org: str, number: str) -> RouteRecord | None:
162
+ """The org's route at this number, whatever world it is in, with what the table keeps."""
163
+ params = {"org": org, "number": number}
164
+ async with pool.connection() as connection:
165
+ row = await (await connection.execute(OF_NUMBER_RECORD, params)).fetchone()
166
+ return None if row is None else _record(row)
167
+
168
+
169
+ async def of_number(pool: Pool, org: str, number: str) -> Route | None:
170
+ """The org's route at this number, whatever world it is in."""
171
+ async with pool.connection() as connection:
172
+ row = await (await connection.execute(OF_NUMBER, {"org": org, "number": number})).fetchone()
173
+ return None if row is None else _route(row)
174
+
175
+
176
+ async def put(pool: Pool, route: Route, written: RouteWrite) -> None:
177
+ """Keep the route, moving the number if the org had it, with how it was written."""
178
+ row = {
179
+ "org": route.org,
180
+ "number": route.number,
181
+ "agent": route.agent,
182
+ "channel": route.channel,
183
+ "env": route.env,
184
+ "managed": route.managed,
185
+ "account": written.account,
186
+ "networks": list(written.networks),
187
+ "origin": written.origin,
188
+ "via": written.via,
189
+ "waits": written.waits,
190
+ }
191
+ async with pool.connection() as connection:
192
+ await connection.execute(PUT, row)
193
+
194
+
195
+ async def called(pool: Pool, org: str, number: str) -> None:
196
+ """Note that a call to the org's number reached the box now."""
197
+ async with pool.connection() as connection:
198
+ await connection.execute(CALLED, {"org": org, "number": number})
199
+
200
+
201
+ async def approve(pool: Pool, org: str, number: str, by: str) -> bool:
202
+ """The operator's word that the org's hooked number is the org's; whether one waited."""
203
+ async with pool.connection() as connection:
204
+ done = await connection.execute(APPROVE, {"org": org, "number": number, "by": by})
205
+ return await done.fetchone() is not None
206
+
207
+
208
+ async def is_waiting(pool: Pool, org: str, number: str) -> bool:
209
+ """Whether the org's number is one it hooked that the operator has not approved."""
210
+ async with pool.connection() as connection:
211
+ row = await (await connection.execute(WAITING, {"org": org, "number": number})).fetchone()
212
+ return row is not None
213
+
214
+
215
+ async def held_elsewhere(pool: Pool, org: str, number: str) -> bool:
216
+ """Whether another org holds the number on this box."""
217
+ with box_wide():
218
+ async with pool.connection() as connection:
219
+ found = await connection.execute(ELSEWHERE, {"org": org, "number": number})
220
+ return await found.fetchone() is not None
221
+
222
+
223
+ async def remove(pool: Pool, org: str, number: str) -> bool:
224
+ """Forget the org's route at the number; whether there was one."""
225
+ async with pool.connection() as connection:
226
+ gone = await connection.execute(REMOVE, {"org": org, "number": number})
227
+ return await gone.fetchone() is not None
228
+
229
+
230
+ async def moved(pool: Pool, org: str, number: str, env: Env) -> bool:
231
+ """Move the org's number into the world; whether it had it."""
232
+ async with pool.connection() as connection:
233
+ done = await connection.execute(MOVE, {"org": org, "number": number, "env": env})
234
+ return await done.fetchone() is not None
235
+
236
+
237
+ async def moved_with_agent(pool: Pool, agent: str, org: str) -> tuple[list[str], list[str]]:
238
+ """Move the agent's numbers into the org; the numbers moved, and the ones that stayed."""
239
+ async with pool.connection() as connection, connection.transaction():
240
+ moved = await (
241
+ await connection.execute(WITH_AGENT, {"agent": agent, "org": org})
242
+ ).fetchall()
243
+ stayed = await (await connection.execute(STAYED, {"agent": agent, "org": org})).fetchall()
244
+ return sorted(str(row["number"]) for row in moved), [str(row["number"]) for row in stayed]
245
+
246
+
247
+ async def managed_in(pool: Pool, org: str, env: Env) -> int:
248
+ """How many of the org's numbers in the world the box bought."""
249
+ async with pool.connection() as connection:
250
+ row = await (await connection.execute(MANAGED, {"org": org, "env": env})).fetchone()
251
+ return 0 if row is None else int(row["bought"])
252
+
253
+
254
+ def _route(row: DictRow) -> Route:
255
+ return Route(
256
+ org=row["org"],
257
+ agent=row["agent"],
258
+ channel=row["channel"],
259
+ number=row["number"],
260
+ env=row["env"],
261
+ managed=row["managed"],
262
+ )
263
+
264
+
265
+ def _record(row: DictRow) -> RouteRecord:
266
+ return RouteRecord(
267
+ route=_route(row),
268
+ origin=row["origin"],
269
+ carrier=row["carrier"],
270
+ via=row["via"],
271
+ last_call_at=None if row["last_call_at"] is None else row["last_call_at"].timestamp(),
272
+ account=row["account"],
273
+ networks=tuple(str(network) for network in row["networks"] or ()),
274
+ approved=bool(row["approved"]),
275
+ )
File without changes
@@ -0,0 +1,349 @@
1
+ """Twilio's REST API, written by hand: numbers, trunks, credential lists, the shop."""
2
+
3
+ import logging
4
+
5
+ import httpx
6
+ from pydantic import (
7
+ BaseModel,
8
+ ConfigDict,
9
+ TypeAdapter,
10
+ ValidationError,
11
+ )
12
+
13
+ from pinecall.domain.errors import (
14
+ DeclarationRefused,
15
+ UpstreamFailed,
16
+ )
17
+ from pinecall.domain.names import Json
18
+ from pinecall.tenancy.carriers import TwilioAccount
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ TRUNKING = "https://trunking.twilio.com/v1"
24
+
25
+
26
+ ACCOUNTS = "https://api.twilio.com/2010-04-01"
27
+
28
+
29
+ # Never on a call's path: an import or a purchase waits for Twilio, a call never does.
30
+ TIMEOUT_S = 30.0
31
+
32
+
33
+ # livekit-sip listens here, and the fence opens it to the carrier alone.
34
+ SIP_PORT = 5060
35
+
36
+
37
+ # Termination hosts are global across Twilio; the label is `[a-z0-9-]`. Twilio wants the whole
38
+ # host in DomainName: a bare label is its error 21245.
39
+ TWILIO_SAID = "Twilio: {message}"
40
+
41
+
42
+ TERMINATION_SUFFIX = ".pstn.twilio.com"
43
+
44
+
45
+ ROOM_PREFIX = "call-"
46
+
47
+
48
+ _OBJECT: TypeAdapter[dict[str, Json]] = TypeAdapter(dict[str, Json])
49
+
50
+
51
+ TWILIO_REFUSED = "Twilio refused these credentials for {account}"
52
+
53
+
54
+ class TwilioNumber(BaseModel):
55
+ """A number an account owns, and the trunk it is attached to, if any."""
56
+
57
+ model_config = ConfigDict(frozen=True, extra="ignore")
58
+
59
+ sid: str
60
+ phone_number: str
61
+ friendly_name: str = ""
62
+ trunk_sid: str | None = None
63
+
64
+
65
+ class TwilioTrunk(BaseModel):
66
+ """A SIP trunk of an account."""
67
+
68
+ model_config = ConfigDict(frozen=True, extra="ignore")
69
+
70
+ sid: str
71
+ friendly_name: str = ""
72
+ domain_name: str | None = None
73
+
74
+
75
+ class _Origination(BaseModel):
76
+ model_config = ConfigDict(frozen=True, extra="ignore")
77
+
78
+ sid: str
79
+ sip_url: str
80
+
81
+
82
+ class _Listed(BaseModel):
83
+ model_config = ConfigDict(frozen=True, extra="ignore")
84
+
85
+ sid: str
86
+ friendly_name: str = ""
87
+
88
+
89
+ class _Credential(BaseModel):
90
+ model_config = ConfigDict(frozen=True, extra="ignore")
91
+
92
+ username: str
93
+
94
+
95
+ class _ForSale(BaseModel):
96
+ model_config = ConfigDict(frozen=True, extra="ignore")
97
+
98
+ phone_number: str
99
+
100
+
101
+ class _Meta(BaseModel):
102
+ model_config = ConfigDict(frozen=True, extra="ignore")
103
+
104
+ next_page_url: str | None = None
105
+
106
+
107
+ # The Accounts API names the next page by a path, the Trunking API by a whole URL in `meta`.
108
+ class _Page(BaseModel):
109
+ model_config = ConfigDict(frozen=True, extra="allow")
110
+
111
+ next_page_uri: str | None = None
112
+ meta: _Meta | None = None
113
+
114
+
115
+ class _Refusal(BaseModel):
116
+ model_config = ConfigDict(frozen=True, extra="ignore")
117
+
118
+ message: str = ""
119
+
120
+
121
+ class Twilio:
122
+ """One Twilio account's REST API, over the box's HTTP client."""
123
+
124
+ def __init__(self, http: httpx.AsyncClient, account_sid: str, user: str, secret: str) -> None:
125
+ """The account and the pair it is asked with; nothing is asked yet."""
126
+ self.http = http
127
+ self.sid = account_sid
128
+ self.auth = httpx.BasicAuth(user, secret)
129
+ self.home = f"{ACCOUNTS}/Accounts/{account_sid}"
130
+
131
+ async def verified(self) -> bool:
132
+ """Whether the pair opens the account."""
133
+ answer = await self.http.get(f"{self.home}.json", auth=self.auth, timeout=TIMEOUT_S)
134
+ if answer.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
135
+ return False
136
+ _answer_of(answer)
137
+ return True
138
+
139
+ async def numbers(self) -> list[TwilioNumber]:
140
+ """Every number the account owns, every page."""
141
+ listed = await self._every(
142
+ f"{self.home}/IncomingPhoneNumbers.json?PageSize=200", "incoming_phone_numbers"
143
+ )
144
+ return _NUMBERS.validate_python(listed)
145
+
146
+ async def number(self, number: str) -> TwilioNumber | None:
147
+ """The account's number, or None when it owns no such number."""
148
+ answer = await self._get(f"{self.home}/IncomingPhoneNumbers.json", {"PhoneNumber": number})
149
+ found = _NUMBERS.validate_python(answer.get("incoming_phone_numbers", []))
150
+ return found[0] if found else None
151
+
152
+ async def trunk_pointing_at(self, uri: str) -> TwilioTrunk | None:
153
+ """The account's trunk whose origination is this URI, whatever it is named."""
154
+ found = await self.trunks_pointing_at(uri)
155
+ return found[0] if found else None
156
+
157
+ async def trunks_pointing_at(self, uri: str) -> list[TwilioTrunk]:
158
+ """Every trunk of the account whose origination is this URI."""
159
+ trunks = _TRUNKS.validate_python(
160
+ await self._every(f"{TRUNKING}/Trunks?PageSize=50", "trunks")
161
+ )
162
+ return [
163
+ trunk
164
+ for trunk in trunks
165
+ if uri in {origination.sip_url for origination in await self._originations(trunk.sid)}
166
+ ]
167
+
168
+ async def repointed(self, trunk: str, was: str, now: str) -> None:
169
+ """The trunk's origination that is `was` sent to `now`, its weight and order kept."""
170
+ for origination in await self._originations(trunk):
171
+ if origination.sip_url == was:
172
+ url = f"{TRUNKING}/Trunks/{trunk}/OriginationUrls/{origination.sid}"
173
+ await self._post(url, {"SipUrl": now})
174
+
175
+ async def trunk(self, sid: str) -> TwilioTrunk:
176
+ """One trunk of the account."""
177
+ return TwilioTrunk.model_validate(await self._get(f"{TRUNKING}/Trunks/{sid}"))
178
+
179
+ async def originations(self, sid: str) -> list[str]:
180
+ """Where the trunk sends a call."""
181
+ return [origination.sip_url for origination in await self._originations(sid)]
182
+
183
+ async def trunk_made(self, name: str, uri: str) -> TwilioTrunk:
184
+ """A new trunk, sending its calls to the URI."""
185
+ made = TwilioTrunk.model_validate(
186
+ await self._post(f"{TRUNKING}/Trunks", {"FriendlyName": name})
187
+ )
188
+ form = {
189
+ "FriendlyName": name,
190
+ "SipUrl": uri,
191
+ "Weight": "10",
192
+ "Priority": "10",
193
+ "Enabled": "true",
194
+ }
195
+ await self._post(f"{TRUNKING}/Trunks/{made.sid}/OriginationUrls", form)
196
+ return made
197
+
198
+ async def attach(self, trunk: str, number: str) -> None:
199
+ """Attach the number to the trunk: its voice URL is then ignored."""
200
+ await self._post(f"{TRUNKING}/Trunks/{trunk}/PhoneNumbers", {"PhoneNumberSid": number})
201
+
202
+ async def detach(self, trunk: str, number: str) -> None:
203
+ """Take the number off a trunk."""
204
+ answer = await self.http.delete(
205
+ f"{TRUNKING}/Trunks/{trunk}/PhoneNumbers/{number}", auth=self.auth, timeout=TIMEOUT_S
206
+ )
207
+ if answer.is_error:
208
+ _answer_of(answer)
209
+
210
+ async def for_sale(self, country: str, area_code: str | None) -> str | None:
211
+ """One local voice number on sale in the country, or None."""
212
+ params = {"VoiceEnabled": "true", "PageSize": "1"} | (
213
+ {"AreaCode": area_code} if area_code else {}
214
+ )
215
+ answer = await self._get(
216
+ f"{self.home}/AvailablePhoneNumbers/{country.upper()}/Local.json", params
217
+ )
218
+ found = _FOR_SALE.validate_python(answer.get("available_phone_numbers", []))
219
+ return found[0].phone_number if found else None
220
+
221
+ async def bought(self, number: str) -> TwilioNumber:
222
+ """Buy the number onto the account: this costs money."""
223
+ answer = await self._post(f"{self.home}/IncomingPhoneNumbers.json", {"PhoneNumber": number})
224
+ return TwilioNumber.model_validate(answer)
225
+
226
+ async def terminated(self, trunk: str, host: str) -> None:
227
+ """Set where the box dials the trunk: <label>.pstn.twilio.com, the whole host."""
228
+ await self._post(f"{TRUNKING}/Trunks/{trunk}", {"DomainName": host})
229
+
230
+ async def credential_list(self, name: str) -> str | None:
231
+ """The account's credential list by name, or None."""
232
+ listed = _LISTS.validate_python(
233
+ await self._every(
234
+ f"{self.home}/SIP/CredentialLists.json?PageSize=50", "credential_lists"
235
+ )
236
+ )
237
+ return next((found.sid for found in listed if found.friendly_name == name), None)
238
+
239
+ async def credential_list_made(self, name: str) -> str:
240
+ """A new, empty credential list."""
241
+ made = _Listed.model_validate(
242
+ await self._post(f"{self.home}/SIP/CredentialLists.json", {"FriendlyName": name})
243
+ )
244
+ return made.sid
245
+
246
+ async def usernames_in(self, credential_list: str) -> list[str]:
247
+ """The usernames a credential list holds; never a password, which Twilio never shows."""
248
+ listed = await self._every(
249
+ f"{self.home}/SIP/CredentialLists/{credential_list}/Credentials.json", "credentials"
250
+ )
251
+ return [credential.username for credential in _CREDENTIALS.validate_python(listed)]
252
+
253
+ async def credential_added(self, credential_list: str, username: str, password: str) -> None:
254
+ """One more credential on the list; Twilio shows its password this once."""
255
+ form = {"Username": username, "Password": password}
256
+ await self._post(
257
+ f"{self.home}/SIP/CredentialLists/{credential_list}/Credentials.json", form
258
+ )
259
+
260
+ async def lists_on(self, trunk: str) -> list[str]:
261
+ """The credential lists the trunk asks the box for."""
262
+ listed = await self._every(f"{TRUNKING}/Trunks/{trunk}/CredentialLists", "credential_lists")
263
+ return [found.sid for found in _LISTS.validate_python(listed)]
264
+
265
+ async def list_attached(self, trunk: str, credential_list: str) -> None:
266
+ """Have the trunk ask the box for this credential."""
267
+ form = {"CredentialListSid": credential_list}
268
+ await self._post(f"{TRUNKING}/Trunks/{trunk}/CredentialLists", form)
269
+
270
+ async def _originations(self, trunk: str) -> list[_Origination]:
271
+ listed = await self._every(f"{TRUNKING}/Trunks/{trunk}/OriginationUrls", "origination_urls")
272
+ return _ORIGINATIONS.validate_python(listed)
273
+
274
+ # A listing past its first page silently lost the rest in v1; every page is read.
275
+ async def _every(self, url: str, key: str) -> list[Json]:
276
+ found: list[Json] = []
277
+ following: str | None = url
278
+ while following is not None:
279
+ answer = await self._get(following)
280
+ listed = answer.get(key)
281
+ found += listed if isinstance(listed, list) else []
282
+ page = _Page.model_validate(answer)
283
+ path = page.next_page_uri
284
+ following = (
285
+ f"https://{httpx.URL(url).host}{path}"
286
+ if path
287
+ else (page.meta.next_page_url if page.meta else None)
288
+ )
289
+ return found
290
+
291
+ async def _get(self, url: str, params: dict[str, str] | None = None) -> dict[str, Json]:
292
+ answer = await self.http.get(url, params=params, auth=self.auth, timeout=TIMEOUT_S)
293
+ return _answer_of(answer)
294
+
295
+ async def _post(self, url: str, form: dict[str, str]) -> dict[str, Json]:
296
+ answer = await self.http.post(url, data=form, auth=self.auth, timeout=TIMEOUT_S)
297
+ return _answer_of(answer)
298
+
299
+
300
+ _NUMBERS: TypeAdapter[list[TwilioNumber]] = TypeAdapter(list[TwilioNumber])
301
+
302
+
303
+ _TRUNKS: TypeAdapter[list[TwilioTrunk]] = TypeAdapter(list[TwilioTrunk])
304
+
305
+
306
+ _ORIGINATIONS: TypeAdapter[list[_Origination]] = TypeAdapter(list[_Origination])
307
+
308
+
309
+ _LISTS: TypeAdapter[list[_Listed]] = TypeAdapter(list[_Listed])
310
+
311
+
312
+ _CREDENTIALS: TypeAdapter[list[_Credential]] = TypeAdapter(list[_Credential])
313
+
314
+
315
+ _FOR_SALE: TypeAdapter[list[_ForSale]] = TypeAdapter(list[_ForSale])
316
+
317
+
318
+ def origination_uri(domain: str) -> str:
319
+ """The SIP URI a Twilio trunk sends this box's calls to."""
320
+ return f"sip:{domain}:{SIP_PORT};transport=udp"
321
+
322
+
323
+ def termination_host(domain: str, account: str) -> str:
324
+ """The host the box dials an account through: one per account per box, `[a-z0-9-]`."""
325
+ named = f"{domain}-{account[-8:]}".lower()
326
+ label = "".join(letter if letter.isalnum() else "-" for letter in named).strip("-")
327
+ return f"{label}{TERMINATION_SUFFIX}"
328
+
329
+
330
+ def twilio_of(http: httpx.AsyncClient, account: TwilioAccount) -> Twilio:
331
+ """The account's REST API, asked with its pair."""
332
+ return Twilio(http, account.account_sid, account.user, account.secret)
333
+
334
+
335
+ async def verify(http: httpx.AsyncClient, account: TwilioAccount) -> None:
336
+ """Refuse a pair Twilio does not open the account with, in our words."""
337
+ if not await twilio_of(http, account).verified():
338
+ raise DeclarationRefused(TWILIO_REFUSED.format(account=account.account_sid))
339
+
340
+
341
+ # Twilio's own sentence reaches the person; the pair never does.
342
+ def _answer_of(answer: httpx.Response) -> dict[str, Json]:
343
+ if answer.is_error:
344
+ try:
345
+ message = _Refusal.model_validate_json(answer.content).message
346
+ except ValidationError:
347
+ message = f"{answer.status_code}"
348
+ raise UpstreamFailed(TWILIO_SAID.format(message=message or answer.status_code))
349
+ return _OBJECT.validate_json(answer.content) if answer.content else {}