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,413 @@
1
+ """WhatsApp's Cloud API: the webhook's handshake and signature, the messages in a body, a reply."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ import httpx
10
+ from cryptography.fernet import MultiFernet
11
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
12
+
13
+ from pinecall.domain.call import Route
14
+ from pinecall.domain.errors import NotAllowed, NotAvailable, UpstreamFailed
15
+ from pinecall.domain.names import Env
16
+ from pinecall.log.logs import Logs
17
+ from pinecall.log.store import Store
18
+ from pinecall.postgres.pool import Pool
19
+ from pinecall.tenancy import vault
20
+ from pinecall.tenancy.carriers import WhatsappAccount, carriers_of
21
+ from pinecall.wire.events import MessageTaken, MessageWaiting
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ GRAPH = "https://graph.facebook.com/v21.0"
27
+
28
+
29
+ SIGNED_WITH = "sha256="
30
+
31
+
32
+ SIGNATURE_HEADER = "x-hub-signature-256"
33
+
34
+
35
+ TIMEOUT_S = 10.0
36
+
37
+
38
+ # A conversation quiet this long is over: the next message opens a new call. It is under
39
+ # Meta's 24 h customer-service window, which only the idle close keeps.
40
+ IDLE_S = 2 * 60 * 60
41
+
42
+
43
+ # Meta lets a business write freely only this long after the contact's last message
44
+ # (developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages).
45
+ WINDOW_S = 24 * 60 * 60
46
+
47
+
48
+ # The box's Meta app, sealed in box_settings as credentials/whatsapp.
49
+ BOX_ACCOUNT = "whatsapp"
50
+
51
+
52
+ WAITING = "message.waiting"
53
+
54
+
55
+ TAKEN = "message.taken"
56
+
57
+
58
+ NO_META = "this box has no Meta app (credentials/whatsapp): its WhatsApp door is closed"
59
+
60
+
61
+ NOT_THE_WORD = "the verify token is not this box's"
62
+
63
+
64
+ META_SAID = "WhatsApp: {message}"
65
+
66
+
67
+ # Meta retries a webhook not answered 200 with decreasing frequency for up to 7 days
68
+ # (developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks): an id is kept as long.
69
+ SEEN_KEPT_S = 7 * 24 * 60 * 60
70
+
71
+
72
+ # Longer than a delivery is ever read in (the route, and opening the conversation at worst): a
73
+ # claim still unread past it is a process that died reading it, and the next delivery reads it.
74
+ READING_S = 10 * 60
75
+
76
+
77
+ # The insert is the check: of two deliveries at once, one inserts and the other finds the row.
78
+ CLAIM = """
79
+ INSERT INTO whatsapp_seen (org, message_id, claimed_at)
80
+ VALUES (%(org)s, %(message)s, to_timestamp(%(now)s))
81
+ ON CONFLICT (org, message_id) DO UPDATE SET claimed_at = excluded.claimed_at
82
+ WHERE whatsapp_seen.read_at IS NULL AND whatsapp_seen.claimed_at < to_timestamp(%(stale)s)
83
+ RETURNING org
84
+ """
85
+
86
+
87
+ SEEN = "SELECT read_at FROM whatsapp_seen WHERE org = %(org)s AND message_id = %(message)s"
88
+
89
+
90
+ READ = """
91
+ UPDATE whatsapp_seen SET read_at = to_timestamp(%(now)s)
92
+ WHERE org = %(org)s AND message_id = %(message)s
93
+ """
94
+
95
+
96
+ RELEASE = "DELETE FROM whatsapp_seen WHERE org = %(org)s AND message_id = %(message)s"
97
+
98
+
99
+ FORGET_SEEN = "DELETE FROM whatsapp_seen WHERE claimed_at < to_timestamp(%(before)s)"
100
+
101
+
102
+ # `new`: this delivery claimed it and reads it; `seen`: another read it; `reading`: another
103
+ # delivery is reading it now, and Meta must be told to come back.
104
+ type Claim = Literal["new", "seen", "reading"]
105
+
106
+
107
+ class Meta(BaseModel):
108
+ """The box's Meta app: the secret every body is signed with, the handshake word, a token."""
109
+
110
+ model_config = ConfigDict(frozen=True, extra="forbid")
111
+
112
+ app_secret: str = Field(min_length=1)
113
+ verify_token: str = Field(min_length=1)
114
+ # Replies for an org that brought no token of its own.
115
+ access_token: str | None = None
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Inbound:
120
+ """One message to one of the box's WhatsApp numbers."""
121
+
122
+ number: str
123
+ phone_number_id: str
124
+ wa_id: str
125
+ name: str | None
126
+ message_id: str
127
+ kind: str
128
+ text: str | None
129
+
130
+ @property
131
+ def caller(self) -> str:
132
+ """The sender in E.164: WhatsApp names them by digits alone."""
133
+ return f"+{self.wa_id}"
134
+
135
+
136
+ # Meta adds fields without notice, and a webhook answered 4xx for long is disabled: every
137
+ # model ignores what it does not know.
138
+ class _Lenient(BaseModel):
139
+ model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
140
+
141
+
142
+ class _Text(_Lenient):
143
+ body: str | None = None
144
+
145
+
146
+ class _Message(_Lenient):
147
+ id: str = ""
148
+ sender: str = Field("", alias="from")
149
+ type: str = ""
150
+ text: _Text | None = None
151
+
152
+
153
+ class _Profile(_Lenient):
154
+ name: str | None = None
155
+
156
+
157
+ class _Contact(_Lenient):
158
+ wa_id: str = ""
159
+ profile: _Profile | None = None
160
+
161
+
162
+ class _Metadata(_Lenient):
163
+ display_phone_number: str = ""
164
+ phone_number_id: str = ""
165
+
166
+
167
+ # A delivery receipt arrives in this shape, with `statuses` and no messages.
168
+ class _Value(_Lenient):
169
+ metadata: _Metadata | None = None
170
+ contacts: list[_Contact] = Field(default_factory=list[_Contact])
171
+ messages: list[_Message] = Field(default_factory=list[_Message])
172
+
173
+
174
+ class _Change(_Lenient):
175
+ value: _Value | None = None
176
+
177
+
178
+ class _Entry(_Lenient):
179
+ changes: list[_Change] = Field(default_factory=list[_Change])
180
+
181
+
182
+ class _Body(_Lenient):
183
+ entry: list[_Entry] = Field(default_factory=list[_Entry])
184
+
185
+
186
+ class _Refused(BaseModel):
187
+ model_config = ConfigDict(frozen=True, extra="ignore")
188
+
189
+ message: str = ""
190
+
191
+
192
+ class _Refusal(BaseModel):
193
+ model_config = ConfigDict(frozen=True, extra="ignore")
194
+
195
+ error: _Refused | None = None
196
+
197
+
198
+ class _Number(BaseModel):
199
+ model_config = ConfigDict(frozen=True, extra="ignore")
200
+
201
+ display_phone_number: str
202
+ verified_name: str = ""
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Waiting:
207
+ """A message kept for an agent nobody held when it arrived."""
208
+
209
+ agent: str
210
+ env: Env
211
+ inbound: Inbound
212
+ received_at: float
213
+
214
+
215
+ async def box_account(pool: Pool, sealed: MultiFernet) -> Meta:
216
+ """The box's Meta app; NotAvailable when it has none."""
217
+ credentials = (await vault.box_credentials(pool, sealed)).get(BOX_ACCOUNT)
218
+ try:
219
+ return Meta.model_validate(credentials)
220
+ except ValidationError:
221
+ raise NotAvailable(NO_META) from None
222
+
223
+
224
+ # Meta keeps the subscription only when the challenge comes back as it was sent, as plain text.
225
+ def handshake(meta: Meta, mode: str | None, word: str | None, challenge: str | None) -> str:
226
+ """The challenge to echo back, when Meta subscribes with this box's word."""
227
+ if mode != "subscribe" or word is None or challenge is None:
228
+ raise NotAllowed(NOT_THE_WORD)
229
+ if not hmac.compare_digest(word.encode(), meta.verify_token.encode()):
230
+ raise NotAllowed(NOT_THE_WORD)
231
+ return challenge
232
+
233
+
234
+ # The raw bytes, before anything parses them: a body re-serialised signs differently.
235
+ def is_signed(app_secret: str, body: bytes, header: str | None) -> bool:
236
+ """Whether the body carries the app's HMAC-SHA256 signature."""
237
+ if header is None or not header.startswith(SIGNED_WITH):
238
+ return False
239
+ expected = hmac.new(app_secret.encode(), body, hashlib.sha256).hexdigest()
240
+ return hmac.compare_digest(header.removeprefix(SIGNED_WITH), expected)
241
+
242
+
243
+ def messages_in(body: bytes) -> list[Inbound]:
244
+ """Every message in Meta's body, in Meta's order; none in a receipt or a stranger's body."""
245
+ try:
246
+ parsed = _Body.model_validate_json(body)
247
+ except ValidationError:
248
+ return []
249
+ found: list[Inbound] = []
250
+ for change in (change for entry in parsed.entry for change in entry.changes):
251
+ value = change.value
252
+ if value is None or value.metadata is None:
253
+ continue
254
+ names = {
255
+ contact.wa_id: contact.profile.name for contact in value.contacts if contact.profile
256
+ }
257
+ found += [
258
+ Inbound(
259
+ number=f"+{value.metadata.display_phone_number.lstrip('+')}",
260
+ phone_number_id=value.metadata.phone_number_id,
261
+ wa_id=message.sender,
262
+ name=names.get(message.sender),
263
+ message_id=message.id,
264
+ kind=message.type,
265
+ text=None if message.text is None else message.text.body,
266
+ )
267
+ for message in value.messages
268
+ ]
269
+ return found
270
+
271
+
272
+ async def display_number(
273
+ http: httpx.AsyncClient, token: str, phone_number_id: str
274
+ ) -> tuple[str, str]:
275
+ """The number a WhatsApp account answers at, in E.164, and its name at Meta."""
276
+ answer = await http.get(
277
+ f"{GRAPH}/{phone_number_id}",
278
+ params={"fields": "display_phone_number,verified_name"},
279
+ headers={"authorization": f"Bearer {token}"},
280
+ timeout=TIMEOUT_S,
281
+ )
282
+ if answer.is_error:
283
+ raise UpstreamFailed(META_SAID.format(message=_refused_by(answer)))
284
+ answer = _Number.model_validate_json(answer.content)
285
+ number = "+" + "".join(digit for digit in answer.display_phone_number if digit.isdigit())
286
+ return number, answer.verified_name
287
+
288
+
289
+ async def send_text(http: httpx.AsyncClient, token: str, inbound: Inbound, text: str) -> None:
290
+ """Send the text to the contact from the number they wrote to; Meta's refusal in its words."""
291
+ body = {
292
+ "messaging_product": "whatsapp",
293
+ "recipient_type": "individual",
294
+ "to": inbound.wa_id,
295
+ "type": "text",
296
+ "text": {"body": text, "preview_url": False},
297
+ }
298
+ answer = await http.post(
299
+ f"{GRAPH}/{inbound.phone_number_id}/messages",
300
+ json=body,
301
+ headers={"authorization": f"Bearer {token}"},
302
+ timeout=TIMEOUT_S,
303
+ )
304
+ if answer.is_error:
305
+ raise UpstreamFailed(META_SAID.format(message=_refused_by(answer)))
306
+
307
+
308
+ async def kept(logs: Logs, route: Route, inbound: Inbound) -> Waiting:
309
+ """Keep the message on the agent's log until somebody holds the agent."""
310
+ now = logs.store.clock()
311
+ message = MessageWaiting.model_validate(
312
+ {
313
+ "channel": "whatsapp",
314
+ "env": route.env,
315
+ "number": inbound.number,
316
+ "phone_number_id": inbound.phone_number_id,
317
+ "from": inbound.wa_id,
318
+ "name": inbound.name,
319
+ "message_id": inbound.message_id,
320
+ "text": inbound.text or "",
321
+ "received_at": now,
322
+ }
323
+ )
324
+ await logs.agent(route.agent).append(WAITING, message.written())
325
+ return Waiting(route.agent, route.env, inbound, now)
326
+
327
+
328
+ async def taken(logs: Logs, waiting: Waiting, call: str | None) -> None:
329
+ """The message off the queue: the call it went on, or none when its window closed."""
330
+ taken = MessageTaken(message_id=waiting.inbound.message_id, call=call)
331
+ await logs.agent(waiting.agent).append(TAKEN, taken.written())
332
+
333
+
334
+ async def waiting_in(store: Store) -> list[Waiting]:
335
+ """Every message still waiting, oldest first, read off every agent's log."""
336
+ waiting: dict[str, Waiting] = {}
337
+ after = 0
338
+ while page := await store.across([WAITING, TAKEN], after=after):
339
+ for row in page:
340
+ entry = row.entry
341
+ if entry.type == TAKEN:
342
+ waiting.pop(str(entry.data.get("message_id")), None)
343
+ continue
344
+ message = MessageWaiting.model_validate(entry.data)
345
+ inbound = Inbound(
346
+ number=message.number,
347
+ phone_number_id=message.phone_number_id,
348
+ wa_id=message.from_,
349
+ name=message.name,
350
+ message_id=message.message_id,
351
+ kind="text",
352
+ text=message.text,
353
+ )
354
+ waiting[message.message_id] = Waiting(
355
+ entry.agent, message.env, inbound, message.received_at
356
+ )
357
+ after = page[-1].position
358
+ return list(waiting.values())
359
+
360
+
361
+ async def claimed(pool: Pool, org: str, message_id: str, now: float) -> Claim:
362
+ """Claim the org's message for this delivery, unless another has read it or is reading it."""
363
+ params = {"org": org, "message": message_id, "now": now, "stale": now - READING_S}
364
+ # independent: the look after a claim refused reads what the other delivery left
365
+ async with pool.connection() as connection:
366
+ if await (await connection.execute(CLAIM, params)).fetchone() is not None:
367
+ return "new"
368
+ row = await (await connection.execute(SEEN, params)).fetchone()
369
+ # A row gone between the two is a claim released: the delivery that held it failed.
370
+ return "seen" if row is not None and row["read_at"] is not None else "reading"
371
+
372
+
373
+ async def read(pool: Pool, org: str, message_id: str, now: float) -> None:
374
+ """The claimed message is read: every later delivery of it is dropped."""
375
+ async with pool.connection() as connection:
376
+ await connection.execute(READ, {"org": org, "message": message_id, "now": now})
377
+
378
+
379
+ async def released(pool: Pool, org: str, message_id: str) -> None:
380
+ """Give up the claim of a message whose reading failed, so Meta's next delivery reads it."""
381
+ async with pool.connection() as connection:
382
+ await connection.execute(RELEASE, {"org": org, "message": message_id})
383
+
384
+
385
+ async def forget_seen(pool: Pool, now: float) -> int:
386
+ """Forget the message ids claimed longer ago than Meta retries; how many."""
387
+ async with pool.connection() as connection:
388
+ done = await connection.execute(FORGET_SEEN, {"before": now - SEEN_KEPT_S})
389
+ return done.rowcount
390
+
391
+
392
+ async def meta_token_for(
393
+ pool: Pool, sealed: MultiFernet, org: str, phone_number_id: str
394
+ ) -> str | None:
395
+ """The org's own Meta token for the number, else the box's, else None."""
396
+ for carrier in await carriers_of(pool, sealed, org):
397
+ account = carrier.account
398
+ if isinstance(account, WhatsappAccount) and account.phone_number_id == phone_number_id:
399
+ return account.access_token
400
+ try:
401
+ return (await box_account(pool, sealed)).access_token
402
+ except NotAvailable:
403
+ return None
404
+
405
+
406
+ def _refused_by(answer: httpx.Response) -> str:
407
+ try:
408
+ refused = _Refusal.model_validate_json(answer.content).error
409
+ except ValidationError:
410
+ refused = None
411
+ if refused is not None and refused.message:
412
+ return refused.message
413
+ return str(answer.status_code)
File without changes
@@ -0,0 +1,85 @@
1
+ """The doctor's line on the WAL archive: Postgres's archiver and the spool the box ships from."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from pinecall.postgres.pool import Pool
7
+
8
+ # The timer ships every 10 s: five minutes of segments waiting is a bucket that does not answer.
9
+ SHIPPED_WITHIN_S = 300
10
+
11
+ OFF = "off: no restore to a minute; the postgres chart's backups section turns the archiver on"
12
+
13
+ NOT_SPOOLED = (
14
+ "segment {wal} could not be spooled at {at:%Y-%m-%d %H:%M:%S}Z: Postgres keeps it in pg_wal "
15
+ "and retries, and pg_wal grows until it can"
16
+ )
17
+
18
+ NOT_SHIPPED = (
19
+ "{waiting} segments waiting since {at:%Y-%m-%d %H:%M:%S}Z: the bucket has not taken them "
20
+ "(the postgres pod's logs say why); they stay on this disk until it does"
21
+ )
22
+
23
+ ON = "on, {waiting} waiting to ship, the last spooled {ago}"
24
+
25
+ ARCHIVER = """
26
+ SELECT current_setting('archive_mode') AS mode, last_archived_time, last_failed_time,
27
+ last_failed_wal
28
+ FROM pg_stat_archiver
29
+ """
30
+
31
+ # Read through Postgres, which has the spool mounted: the doctor's own user reads no WAL.
32
+ SPOOLED = """
33
+ SELECT count(*) AS waiting, min(file.modification) AS oldest
34
+ FROM pg_ls_dir(%(spool)s, true, false) AS name,
35
+ LATERAL pg_stat_file(%(spool)s || '/' || name, true) AS file
36
+ WHERE NOT file.isdir AND name NOT LIKE '%%.part'
37
+ """
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Archive:
42
+ """What Postgres says of its archiving, and what waits in the spool to leave the box."""
43
+
44
+ mode: str
45
+ last_archived: datetime | None
46
+ last_failed: datetime | None
47
+ failed_wal: str | None
48
+ waiting: int
49
+ oldest: datetime | None
50
+
51
+
52
+ # A box ships its WAL through a spool (PINECALL_WAL_SPOOL), read here through Postgres; a cluster's
53
+ # operator archives with none, and its spool is unset: the archiver's state alone is read.
54
+ async def archive_of(pool: Pool, spool: str | None) -> Archive:
55
+ """Read the archiver's state and, when it is on and a spool is named, the spool's backlog."""
56
+ # independent: two reads of the archiver's state, each its own snapshot
57
+ async with pool.connection() as connection:
58
+ row = await (await connection.execute(ARCHIVER)).fetchone()
59
+ if row is None or row["mode"] == "off":
60
+ return Archive("off", None, None, None, 0, None)
61
+ spooled = None
62
+ if spool:
63
+ spooled = await (await connection.execute(SPOOLED, {"spool": spool})).fetchone()
64
+ return Archive(
65
+ mode=row["mode"],
66
+ last_archived=row["last_archived_time"],
67
+ last_failed=row["last_failed_time"],
68
+ failed_wal=row["last_failed_wal"],
69
+ waiting=0 if spooled is None else spooled["waiting"],
70
+ oldest=None if spooled is None else spooled["oldest"],
71
+ )
72
+
73
+
74
+ def archive_finding(archive: Archive, now: datetime) -> tuple[str | None, str]:
75
+ """The trouble with the archive, or None, and the state an ok line says."""
76
+ if archive.mode == "off":
77
+ return None, OFF
78
+ failed, archived = archive.last_failed, archive.last_archived
79
+ if failed is not None and (archived is None or failed > archived):
80
+ return NOT_SPOOLED.format(wal=archive.failed_wal, at=failed), ""
81
+ oldest = archive.oldest
82
+ if oldest is not None and (now - oldest).total_seconds() > SHIPPED_WITHIN_S:
83
+ return NOT_SHIPPED.format(waiting=archive.waiting, at=oldest), ""
84
+ ago = "never" if archived is None else f"{int((now - archived).total_seconds())} s ago"
85
+ return None, ON.format(waiting=archive.waiting, ago=ago)
pinecall/cli/_drift.py ADDED
@@ -0,0 +1,38 @@
1
+ """`drift rebuild`: each sealed call's stages and verdicts counted into its day again."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import sys
6
+
7
+ from pinecall.cli._traceback import day_of
8
+ from pinecall.log import drift
9
+ from pinecall.log.drift import Rebuilt
10
+ from pinecall.postgres.pool import open_pool
11
+ from pinecall.process.settings import Settings
12
+
13
+ REBUILT = "{calls} sealed calls read, {folded} counted into their day's drift\n"
14
+
15
+
16
+ def drift_group(group: argparse.ArgumentParser) -> None:
17
+ """`drift rebuild [--org <org id>] [--since YYYY-MM-DD]`."""
18
+ verbs = group.add_subparsers(required=True)
19
+ rebuilt = verbs.add_parser("rebuild", help="count the drift again from the log")
20
+ rebuilt.add_argument("--org", default=None, help="an org's calls, by its id")
21
+ rebuilt.add_argument("--since", default=None, help="the days since one, YYYY-MM-DD")
22
+ rebuilt.set_defaults(run=rebuild)
23
+
24
+
25
+ def rebuild(settings: Settings, args: argparse.Namespace) -> int:
26
+ """Forget the drift the flags name, every org's and day's when none, and count it again."""
27
+ since = day_of(args.since)
28
+ done = asyncio.run(_rebuilt(settings, args.org, since))
29
+ sys.stdout.write(REBUILT.format(calls=done.calls, folded=done.folded))
30
+ return 0
31
+
32
+
33
+ async def _rebuilt(settings: Settings, org: str | None, since: float | None) -> Rebuilt:
34
+ pool = await open_pool(settings.database_url)
35
+ try:
36
+ return await drift.rebuild(pool, org=org, since=since)
37
+ finally:
38
+ await pool.close()
pinecall/cli/_facts.py ADDED
@@ -0,0 +1,67 @@
1
+ """`facts rebuild`: each call's facts folded again from its log; doctor's question of the facts."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import sys
6
+
7
+ from pinecall.cli._traceback import day_of
8
+ from pinecall.domain.errors import PinecallError
9
+ from pinecall.log import refold
10
+ from pinecall.log.refold import Rebuilt, Refolding
11
+ from pinecall.postgres.pool import open_pool
12
+ from pinecall.process.settings import Settings
13
+
14
+ REBUILT = (
15
+ "{calls} calls refolded, {rewritten} facts rows rewritten, {left} left as they were:"
16
+ " their log holds an entry this release cannot read\n"
17
+ )
18
+
19
+ BEHIND = "{logs}: rows past their head's seq, of {examined} heads examined"
20
+
21
+ APART = "facts not what their log folds to, of {sample} sampled: {calls}"
22
+
23
+
24
+ def facts_group(group: argparse.ArgumentParser) -> None:
25
+ """`facts rebuild [--call <call>] [--org <org id>] [--since YYYY-MM-DD]`."""
26
+ verbs = group.add_subparsers(required=True)
27
+ rebuilt = verbs.add_parser("rebuild", help="fold the facts again from the log")
28
+ rebuilt.add_argument("--call", default=None, help="one call")
29
+ rebuilt.add_argument("--org", default=None, help="an org's calls, by its id")
30
+ rebuilt.add_argument("--since", default=None, help="the calls started since a day, YYYY-MM-DD")
31
+ rebuilt.set_defaults(run=rebuild)
32
+
33
+
34
+ def rebuild(settings: Settings, args: argparse.Namespace) -> int:
35
+ """Refold the facts of the calls the flags name, every call's when none; say what changed."""
36
+ refolding = Refolding(call=args.call, org=args.org, since=day_of(args.since))
37
+ done = asyncio.run(_rebuilt(settings, refolding))
38
+ sys.stdout.write(REBUILT.format(calls=done.calls, rewritten=done.rewritten, left=done.left))
39
+ return 0
40
+
41
+
42
+ async def examined(settings: Settings) -> str | None:
43
+ """What is wrong between the log and its facts: heads behind their rows, a sample refolded."""
44
+ try:
45
+ pool = await open_pool(settings.database_url)
46
+ except PinecallError as refused:
47
+ return str(refused)
48
+ try:
49
+ behind = await refold.heads_behind(pool)
50
+ apart = await refold.differing(pool, sample=refold.A_SAMPLE)
51
+ finally:
52
+ await pool.close()
53
+ troubles: list[str] = []
54
+ if behind.behind:
55
+ troubles.append(BEHIND.format(logs=", ".join(behind.behind), examined=behind.examined))
56
+ if apart:
57
+ calls = ", ".join(f"{item.call} ({', '.join(item.columns)})" for item in apart)
58
+ troubles.append(APART.format(sample=refold.A_SAMPLE, calls=calls))
59
+ return "; ".join(troubles) or None
60
+
61
+
62
+ async def _rebuilt(settings: Settings, refolding: Refolding) -> Rebuilt:
63
+ pool = await open_pool(settings.database_url)
64
+ try:
65
+ return await refold.rebuild(pool, refolding)
66
+ finally:
67
+ await pool.close()