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,196 @@
1
+ """The four-digit codes a caller keys to tie their call to a browser page, issued per agent."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import secrets
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ from psycopg.errors import UniqueViolation
10
+ from psycopg.rows import DictRow
11
+
12
+ from pinecall.domain.errors import NotAvailable, QuotaExhausted
13
+ from pinecall.domain.names import Env
14
+ from pinecall.log.logs import Logs
15
+ from pinecall.postgres.pool import Pool
16
+ from pinecall.wire.events import CodeClaimed, CodeIssued
17
+ from pinecall.wire.parts import Projection
18
+
19
+ CLAIMED = "code.claimed"
20
+
21
+
22
+ TOO_MANY = (
23
+ "agent {agent} already has {live} codes waiting for a call: let one be claimed or expire "
24
+ "before issuing another"
25
+ )
26
+
27
+
28
+ # Four digits to key on a phone; the cap per agent stops a page minting them by the thousand.
29
+ DIGITS = 4
30
+
31
+
32
+ ISSUED = "code.issued"
33
+
34
+
35
+ LIVE_PER_AGENT = 50
36
+
37
+ # A call keys three codes at most: past them a code it keys ties it to no page, answered as one
38
+ # nobody issued, so a caller keying at random learns nothing and has three chances in all.
39
+ TRIES_PER_CALL = 3
40
+
41
+ A_DAY_S = 24 * 60 * 60.0
42
+
43
+ # Where a claim wakes the page waiting on the code, whichever gateway it waits on.
44
+ TAKEN_CHANNEL = "code:{agent}:{code}"
45
+
46
+ # Expired codes leave with the one statement that finds them: two gateways never close one twice.
47
+ EXPIRED = """
48
+ delete from caller_codes where agent = %(agent)s and expires_at <= %(now)s
49
+ returning code, claimed
50
+ """
51
+
52
+ LIVE = "select count(*) as live from caller_codes where agent = %(agent)s"
53
+
54
+ KEPT = """
55
+ insert into caller_codes (agent, code, env, log, expires_at)
56
+ values (%(agent)s, %(code)s, %(env)s, %(log)s, %(expires_at)s)
57
+ """
58
+
59
+ FOUND = """
60
+ select agent, code, env, log, expires_at, claimed from caller_codes
61
+ where agent = %(agent)s and code = %(code)s
62
+ """
63
+
64
+ # Each try counted in one statement, so two gateways serving one call count both.
65
+ TRIED = """
66
+ insert into code_tries (call, tries, first_at) values (%(call)s, 1, %(now)s)
67
+ on conflict (call) do update set tries = code_tries.tries + 1
68
+ returning tries
69
+ """
70
+
71
+ TRIES_GONE = "delete from code_tries where first_at <= %(since)s"
72
+
73
+ # Taken by one call: the second finds it claimed and gets nothing.
74
+ TAKEN = """
75
+ update caller_codes set claimed = %(call)s
76
+ where agent = %(agent)s and code = %(code)s and env = %(env)s and claimed is null
77
+ and expires_at > %(now)s
78
+ returning agent, code, env, log, expires_at, claimed
79
+ """
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class IssuedCode:
84
+ """A code a page shows, waiting for the call that keys it, and the call once one did."""
85
+
86
+ code: str
87
+ env: Env
88
+ agent: str
89
+ expires_at: float
90
+ log: Projection
91
+ claimed: str | None = None
92
+
93
+
94
+ # Kept in Postgres, so any gateway issues, claims and answers a code; written on each agent's log
95
+ # as code.issued and code.claimed (no call: it expired). A code is the agent's, whatever the world.
96
+ class Codes:
97
+ """Every agent's live caller codes, and a waiting page's wake-up for each."""
98
+
99
+ def __init__(self, logs: Logs) -> None:
100
+ """Codes kept in the logs' store, their wake-ups on the logs' signal."""
101
+ self.logs = logs
102
+ self.pool: Pool = logs.store.pool
103
+
104
+ async def issue(self, env: Env, agent: str, ttl_s: float, log: Projection) -> IssuedCode:
105
+ """A new code for the agent, written on its log; at most fifty live at once."""
106
+ await self._swept(agent, time.time())
107
+ async with self.pool.connection() as connection:
108
+ row = await (await connection.execute(LIVE, {"agent": agent})).fetchone()
109
+ live = 0 if row is None else int(row["live"])
110
+ if live >= LIVE_PER_AGENT:
111
+ raise QuotaExhausted(TOO_MANY.format(agent=agent, live=live))
112
+ issued = await self._kept(env, agent, time.time() + ttl_s, log)
113
+ data = CodeIssued(code=issued.code, env=env, expires_at=issued.expires_at, log=log)
114
+ await self.logs.agent(agent).append(ISSUED, data.written())
115
+ return issued
116
+
117
+ async def status_of(self, env: Env, agent: str, code: str) -> IssuedCode | None:
118
+ """The code as it stands, once more after it expired; None for one nobody issued."""
119
+ issued = await self._found(agent, code)
120
+ await self._swept(agent, time.time())
121
+ return issued if issued is not None and issued.env == env else None
122
+
123
+ # Listened for before the code is read again, so a claim between the two still wakes it.
124
+ async def waited(self, issued: IssuedCode, within_s: float) -> IssuedCode:
125
+ """Wait up to that long for a call to key the code, and say how it stands."""
126
+ channel = TAKEN_CHANNEL.format(agent=issued.agent, code=issued.code)
127
+ if within_s > 0:
128
+ with contextlib.suppress(NotAvailable):
129
+ taken = await self.logs.relay.signal.subscribe(channel)
130
+ try:
131
+ now = await self._found(issued.agent, issued.code)
132
+ if now is not None and now.claimed is None:
133
+ with contextlib.suppress(TimeoutError, StopAsyncIteration):
134
+ await asyncio.wait_for(anext(taken), within_s)
135
+ finally:
136
+ taken.close()
137
+ return await self._found(issued.agent, issued.code) or issued
138
+
139
+ async def claim(self, env: Env, agent: str, code: str, call: str) -> IssuedCode | None:
140
+ """Tie the code to the call that keyed it; None if nobody's, dead, taken, or a 4th try."""
141
+ await self._swept(agent, time.time())
142
+ now = time.time()
143
+ wanted = {"agent": agent, "code": code, "env": env, "call": call, "now": now}
144
+ # independent: a sweep of old counts, a try counted (a try refused stays counted), the take
145
+ async with self.pool.connection() as connection:
146
+ await connection.execute(TRIES_GONE, {"since": now - A_DAY_S})
147
+ tried = await (await connection.execute(TRIED, wanted)).fetchone()
148
+ if tried is None or int(tried["tries"]) > TRIES_PER_CALL:
149
+ return None
150
+ row = await (await connection.execute(TAKEN, wanted)).fetchone()
151
+ if row is None:
152
+ return None
153
+ await self.logs.agent(agent).append(CLAIMED, CodeClaimed(code=code, call=call).written())
154
+ self.logs.relay.signal.publish(TAKEN_CHANNEL.format(agent=agent, code=code), b"taken")
155
+ return _issued(row)
156
+
157
+ async def _kept(self, env: Env, agent: str, expires_at: float, log: Projection) -> IssuedCode:
158
+ while True:
159
+ issued = IssuedCode(_drawn(), env, agent, expires_at, log)
160
+ row = {"agent": agent, "code": issued.code, "env": env, "log": log}
161
+ try:
162
+ async with self.pool.connection() as connection:
163
+ await connection.execute(KEPT, {**row, "expires_at": expires_at})
164
+ except UniqueViolation:
165
+ continue
166
+ return issued
167
+
168
+ async def _found(self, agent: str, code: str) -> IssuedCode | None:
169
+ async with self.pool.connection() as connection:
170
+ row = await (await connection.execute(FOUND, {"agent": agent, "code": code})).fetchone()
171
+ return None if row is None else _issued(row)
172
+
173
+ # Swept as codes are asked for, not by a loop of its own.
174
+ async def _swept(self, agent: str, now: float) -> None:
175
+ async with self.pool.connection() as connection:
176
+ expired = await connection.execute(EXPIRED, {"agent": agent, "now": now})
177
+ rows = await expired.fetchall()
178
+ for row in rows:
179
+ if row["claimed"] is None:
180
+ closed = CodeClaimed(code=str(row["code"]), call=None).written()
181
+ await self.logs.agent(agent).append(CLAIMED, closed)
182
+
183
+
184
+ def _issued(row: DictRow) -> IssuedCode:
185
+ return IssuedCode(
186
+ code=str(row["code"]),
187
+ env="sandbox" if row["env"] == "sandbox" else "production",
188
+ agent=str(row["agent"]),
189
+ expires_at=float(row["expires_at"]),
190
+ log="tenant" if row["log"] == "tenant" else "public",
191
+ claimed=row["claimed"],
192
+ )
193
+
194
+
195
+ def _drawn() -> str:
196
+ return f"{secrets.randbelow(10**DIGITS):0{DIGITS}d}"
@@ -0,0 +1,167 @@
1
+ """Consent and the do-not-call list: facts about a number, never changed, the newest standing."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Literal
6
+
7
+ from psycopg.rows import DictRow
8
+
9
+ from pinecall.domain.errors import DeclarationRefused
10
+ from pinecall.domain.names import parse_e164
11
+ from pinecall.domain.scope import Scope
12
+ from pinecall.postgres.pool import Pool
13
+ from pinecall.wire.rest.numbers import ConsentHistory, ConsentKind, ConsentRow, DoNotCall, OptedOut
14
+
15
+ type Standing = Literal["consented", "opted_out", "unknown"]
16
+
17
+ GIVE = """
18
+ INSERT INTO contact_consents (org, env, number, kind, source, text, evidence, given_by, call)
19
+ VALUES (%(org)s, %(env)s, %(number)s, %(kind)s, %(source)s, %(text)s, %(evidence)s,
20
+ %(given_by)s, %(call)s)
21
+ """
22
+
23
+ HISTORY = """
24
+ SELECT kind, source, text, evidence, given_by, call, given_at FROM contact_consents
25
+ WHERE org = %(org)s AND env = %(env)s AND number = %(number)s
26
+ ORDER BY given_at DESC, id DESC
27
+ """
28
+
29
+ NEWEST = """
30
+ SELECT kind FROM contact_consents
31
+ WHERE org = %(org)s AND env = %(env)s AND number = %(number)s
32
+ ORDER BY given_at DESC, id DESC LIMIT 1
33
+ """
34
+
35
+ # The numbers whose newest row is an opt-out, newest row first, after the row id a page ended on.
36
+ OPTED_OUT = """
37
+ SELECT id, number, given_at, source, given_by FROM (
38
+ SELECT DISTINCT ON (number) number, kind, given_at, source, given_by, id
39
+ FROM contact_consents WHERE org = %(org)s AND env = %(env)s
40
+ ORDER BY number, given_at DESC, id DESC
41
+ ) newest
42
+ WHERE kind = 'opt_out' AND (%(after)s::bigint IS NULL OR id < %(after)s)
43
+ ORDER BY id DESC
44
+ LIMIT %(limit)s
45
+ """
46
+
47
+ OPTED_OUT_ON = """
48
+ SELECT EXISTS (SELECT 1 FROM contact_consents WHERE call = %(call)s AND kind = 'opt_out') AS opted
49
+ """
50
+
51
+ NOT_A_CURSOR = "{after} is no cursor of this list: the last page said it"
52
+
53
+ A_PAGE = 200
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Given:
58
+ """A fact about a number, as it is written down."""
59
+
60
+ kind: ConsentKind
61
+ source: str
62
+ given_by: str
63
+ text: str | None = None
64
+ evidence: str | None = None
65
+ call: str | None = None
66
+
67
+
68
+ async def give(pool: Pool, scope: Scope, number: str, given: Given) -> None:
69
+ """Write one fact about the number in the scope's world."""
70
+ params = {"org": scope.org, "env": scope.env, "number": parse_e164(number), **vars(given)}
71
+ async with pool.connection() as connection:
72
+ await connection.execute(GIVE, params)
73
+
74
+
75
+ async def standing_of(pool: Pool, scope: Scope, number: str) -> Standing:
76
+ """What stands for the number: its newest row's, or unknown when there is none."""
77
+ params = {"org": scope.org, "env": scope.env, "number": number.strip()}
78
+ async with pool.connection() as connection:
79
+ row = await (await connection.execute(NEWEST, params)).fetchone()
80
+ return _as_standing(None if row is None else str(row["kind"]))
81
+
82
+
83
+ async def history(pool: Pool, scope: Scope, number: str) -> ConsentHistory:
84
+ """Every fact about the number, newest first, and what stands."""
85
+ kept = parse_e164(number)
86
+ params = {"org": scope.org, "env": scope.env, "number": kept}
87
+ async with pool.connection() as connection:
88
+ rows = await (await connection.execute(HISTORY, params)).fetchall()
89
+ listed = [_row(row) for row in rows]
90
+ return ConsentHistory(
91
+ number=kept,
92
+ standing=_as_standing(listed[0].kind if listed else None),
93
+ rows=listed,
94
+ )
95
+
96
+
97
+ async def do_not_call(
98
+ pool: Pool, scope: Scope, *, after: str | None = None, limit: int = A_PAGE
99
+ ) -> DoNotCall:
100
+ """The world's do-not-call list, newest first, a page after the cursor."""
101
+ params = {"org": scope.org, "env": scope.env, "after": _cursor(after), "limit": limit}
102
+ async with pool.connection() as connection:
103
+ rows = await (await connection.execute(OPTED_OUT, params)).fetchall()
104
+ numbers = [
105
+ OptedOut(
106
+ number=str(row["number"]),
107
+ since=_epoch(row["given_at"]),
108
+ source=str(row["source"]),
109
+ given_by=str(row["given_by"]),
110
+ )
111
+ for row in rows
112
+ ]
113
+ last = rows[-1] if len(rows) == limit else None
114
+ return DoNotCall(numbers=numbers, next=None if last is None else str(last["id"]))
115
+
116
+
117
+ async def opted_out_on(pool: Pool, call: str) -> bool:
118
+ """Whether a number went on the do-not-call list on this call."""
119
+ async with pool.connection() as connection:
120
+ row = await (await connection.execute(OPTED_OUT_ON, {"call": call})).fetchone()
121
+ return row is not None and bool(row["opted"])
122
+
123
+
124
+ async def opt_out_many(
125
+ pool: Pool, scope: Scope, numbers: list[str], given: Given
126
+ ) -> tuple[int, list[str]]:
127
+ """Put every number that is one on the list, in one transaction; how many, and the refused."""
128
+ kept: list[str] = []
129
+ refused: list[str] = []
130
+ for number in numbers:
131
+ try:
132
+ kept.append(parse_e164(number))
133
+ except DeclarationRefused:
134
+ refused.append(number)
135
+ rows = [
136
+ {"org": scope.org, "env": scope.env, "number": number, **vars(given)} for number in kept
137
+ ]
138
+ async with (
139
+ pool.connection() as connection,
140
+ connection.transaction(),
141
+ connection.cursor() as cursor,
142
+ ):
143
+ await cursor.executemany(GIVE, rows)
144
+ return len(kept), refused
145
+
146
+
147
+ def _as_standing(kind: str | None) -> Standing:
148
+ if kind is None:
149
+ return "unknown"
150
+ return "opted_out" if kind == "opt_out" else "consented"
151
+
152
+
153
+ def _row(row: DictRow) -> ConsentRow:
154
+ return ConsentRow.model_validate({**row, "given_at": _epoch(row["given_at"])})
155
+
156
+
157
+ def _epoch(at: datetime) -> float:
158
+ return at.timestamp()
159
+
160
+
161
+ def _cursor(after: str | None) -> int | None:
162
+ if after is None:
163
+ return None
164
+ try:
165
+ return int(after)
166
+ except ValueError:
167
+ raise DeclarationRefused(NOT_A_CURSOR.format(after=after)) from None