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,323 @@
1
+ """Where a call's audio is kept: the disk it was recorded on, or a bucket, under its org."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import shutil
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass
8
+ from http import HTTPStatus
9
+ from pathlib import Path, PurePath
10
+
11
+ import httpx
12
+
13
+ from pinecall.domain.errors import DeclarationRefused, UpstreamFailed
14
+ from pinecall.process._objects import ObjectStore, object_store_of
15
+ from pinecall.process.sealed_audio import (
16
+ BROKEN,
17
+ HEADER,
18
+ MAGIC,
19
+ Sealed,
20
+ Span,
21
+ opened,
22
+ recorded_size,
23
+ span_of,
24
+ )
25
+ from pinecall.process.settings import Settings
26
+
27
+ # The recorder's file in the call's own directory: `<root>/<call>/audio.ogg` on the disk,
28
+ # `<org>/<call>/audio.ogg` in the bucket. Sealed under the call's own key, it is `audio.sealed`
29
+ # beside it (sealed_audio.py): the name says which a stored recording is.
30
+ AUDIO_FILE = "audio.ogg"
31
+
32
+ SEALED_FILE = "audio.sealed"
33
+
34
+ # An hour of a call is tens of megabytes: the default five seconds is a small file's. Under what
35
+ # a job is given to seal (worker/main.py SEALING_S): an upload that hangs leaves the file on the
36
+ # disk and the call is still sealed.
37
+ UPLOAD_TIMEOUT_S = 30.0
38
+
39
+ # What an upload reads of the file at a time: hashed first, then sent, and never held whole.
40
+ READ_BYTES = 256 * 1024
41
+
42
+ # What a player needs from the bucket's answer to seek.
43
+ PASSED_ON = ("content-length", "content-range", "accept-ranges")
44
+
45
+ # The answers a read passes on: the whole file, a range of it, a range past its end.
46
+ SERVED = frozenset({200, 206, 416})
47
+
48
+ # An org's erasure deletes its calls' objects this many at a time.
49
+ DELETED_AT_ONCE = 16
50
+
51
+ REFUSED = "the recordings bucket {bucket} answered {status} to {what} {name}: {text}"
52
+
53
+ UNREACHABLE = "the recordings bucket {bucket} did not answer {what} {name}: {why}"
54
+
55
+
56
+ NOT_UNDER_THE_ROOT = "call {call!r} names no directory under the recordings root"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Fetched:
61
+ """A recording read from the bucket: the status and headers to pass on, and its bytes."""
62
+
63
+ status: int
64
+ headers: dict[str, str]
65
+ body: AsyncIterator[bytes]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Disk:
70
+ """Recordings kept on the disk they were recorded on: a box with no bucket, as always."""
71
+
72
+ root: Path
73
+
74
+ async def store(self, org: str, call: str, audio: Path) -> None:
75
+ """Nothing to do: the recorder's file is where it is kept."""
76
+
77
+ async def fetch(self, org: str, call: str, byte_range: str | None) -> Fetched | None:
78
+ """None: the file on the disk is the recording, and the door serves it as a file."""
79
+
80
+ async def sealed(self, org: str, call: str) -> Sealed | None:
81
+ """None: a sealed file on the disk is read from where the call's summary says it is."""
82
+
83
+ async def erase(self, org: str, calls: list[str]) -> int:
84
+ """Remove each call's recording directory; how many calls had one."""
85
+ return len(await asyncio.to_thread(_removed, self.root, calls))
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Bucket:
90
+ """Recordings moved to a bucket of the object store once written, the disk holding one until."""
91
+
92
+ root: Path
93
+ name: str
94
+ objects: ObjectStore
95
+
96
+ async def store(self, org: str, call: str, audio: Path) -> None:
97
+ """Upload the file under the org, then remove it from the disk; raise if it stayed."""
98
+ what = _object_name(org, call, audio.name)
99
+ size, digest = await asyncio.to_thread(_measured, audio)
100
+ url = self.objects.url_of(self.name, what)
101
+ kind = "audio/ogg" if audio.name == AUDIO_FILE else "application/octet-stream"
102
+ wanted = {"content-type": kind, "content-length": str(size)}
103
+ request = self.objects.http.build_request(
104
+ "PUT",
105
+ url,
106
+ headers=self.objects.signed("PUT", url, wanted, digest),
107
+ content=_read(audio),
108
+ timeout=UPLOAD_TIMEOUT_S,
109
+ )
110
+ answer = await self._sent(request, "an upload of", what)
111
+ await answer.aread()
112
+ await answer.aclose()
113
+ if answer.status_code != HTTPStatus.OK:
114
+ raise self._refused("an upload of", what, answer)
115
+ await asyncio.to_thread(_removed, self.root, [call])
116
+
117
+ async def fetch(self, org: str, call: str, byte_range: str | None) -> Fetched | None:
118
+ """The recording's bytes, the range asked for when one is; None while it is not there."""
119
+ return await self._fetched(_object_name(org, call, AUDIO_FILE), byte_range)
120
+
121
+ async def sealed(self, org: str, call: str) -> Sealed | None:
122
+ """The call's sealed object, read by ranges; None while it is not in the bucket."""
123
+ what = _object_name(org, call, SEALED_FILE)
124
+ first = await self._fetched(what, f"bytes=0-{HEADER - 1}")
125
+ if first is None:
126
+ return None
127
+ header = b"".join([piece async for piece in first.body])
128
+ size = first.headers.get("content-range", "").rpartition("/")[2]
129
+ if not header.startswith(MAGIC) or not size.isdigit():
130
+ raise UpstreamFailed(BROKEN.format(call=call, why=f"{what} is not a sealed recording"))
131
+
132
+ async def read(offset: int, length: int) -> AsyncIterator[bytes]:
133
+ part = await self._fetched(what, f"bytes={offset}-{offset + length - 1}")
134
+ if part is None:
135
+ raise UpstreamFailed(BROKEN.format(call=call, why=f"{what} went while it was read"))
136
+ async for piece in part.body:
137
+ yield piece
138
+
139
+ return Sealed(call, int(size), header[len(MAGIC) :], read)
140
+
141
+ async def _fetched(self, what: str, byte_range: str | None) -> Fetched | None:
142
+ url = self.objects.url_of(self.name, what)
143
+ wanted = {} if byte_range is None else {"range": byte_range}
144
+ # The bytes as stored, so the length and the range passed on are the file's own.
145
+ headers = {**self.objects.signed("GET", url, wanted), "accept-encoding": "identity"}
146
+ answer = await self._sent(
147
+ self.objects.http.build_request("GET", url, headers=headers), "a read of", what
148
+ )
149
+ if answer.status_code not in SERVED:
150
+ await answer.aread()
151
+ await answer.aclose()
152
+ if answer.status_code == HTTPStatus.NOT_FOUND:
153
+ return None
154
+ raise self._refused("a read of", what, answer)
155
+ kept = {name: answer.headers[name] for name in PASSED_ON if name in answer.headers}
156
+ return Fetched(answer.status_code, kept, _streamed(answer))
157
+
158
+ async def erase(self, org: str, calls: list[str]) -> int:
159
+ """Delete each call's objects, and its file where one stayed; how many calls had either."""
160
+ deleted: set[str] = set()
161
+ for start in range(0, len(calls), DELETED_AT_ONCE):
162
+ chunk = calls[start : start + DELETED_AT_ONCE]
163
+ gone = await asyncio.gather(*(self._deleted(org, call) for call in chunk))
164
+ deleted |= {call for call, was_there in zip(chunk, gone, strict=True) if was_there}
165
+ return len(deleted | await asyncio.to_thread(_removed, self.root, calls))
166
+
167
+ # S3 answers a delete of nothing as it answers a delete: each object is looked for first, so
168
+ # the trail counts the recordings there were.
169
+ async def _deleted(self, org: str, call: str) -> bool:
170
+ kept = [await self._deleted_named(_object_name(org, call, name)) for name in NAMES]
171
+ return any(kept)
172
+
173
+ async def _deleted_named(self, what: str) -> bool:
174
+ url = self.objects.url_of(self.name, what)
175
+ existing = await self._response_to("HEAD", url, what)
176
+ if existing.status_code == HTTPStatus.NOT_FOUND:
177
+ return False
178
+ if existing.status_code != HTTPStatus.OK:
179
+ raise self._refused("a delete of", what, existing)
180
+ answer = await self._response_to("DELETE", url, what)
181
+ if answer.status_code not in {HTTPStatus.OK, HTTPStatus.NO_CONTENT}:
182
+ raise self._refused("a delete of", what, answer)
183
+ return True
184
+
185
+ async def _response_to(self, method: str, url: str, what: str) -> httpx.Response:
186
+ request = self.objects.http.build_request(
187
+ method, url, headers=self.objects.signed(method, url, {})
188
+ )
189
+ answer = await self._sent(request, "a delete of", what)
190
+ await answer.aread()
191
+ await answer.aclose()
192
+ return answer
193
+
194
+ async def _sent(self, request: httpx.Request, what: str, name: str) -> httpx.Response:
195
+ try:
196
+ return await self.objects.http.send(request, stream=True)
197
+ except httpx.HTTPError as unreachable:
198
+ raise UpstreamFailed(
199
+ UNREACHABLE.format(
200
+ bucket=self.name,
201
+ what=what,
202
+ name=name,
203
+ why=str(unreachable) or type(unreachable).__name__,
204
+ )
205
+ ) from unreachable
206
+
207
+ def _refused(self, what: str, name: str, answer: httpx.Response) -> UpstreamFailed:
208
+ return UpstreamFailed(
209
+ REFUSED.format(
210
+ bucket=self.name,
211
+ status=answer.status_code,
212
+ what=what,
213
+ name=name,
214
+ text=answer.text[:200],
215
+ )
216
+ )
217
+
218
+
219
+ # Every object a call's recording may be kept as.
220
+ NAMES = (AUDIO_FILE, SEALED_FILE)
221
+
222
+
223
+ type Recordings = Disk | Bucket
224
+
225
+
226
+ def recordings_of(settings: Settings, http: httpx.AsyncClient) -> Recordings:
227
+ """The bucket PINECALL_RECORDINGS_BUCKET names in the object store, else the disk."""
228
+ root = Path(settings.recordings_root)
229
+ objects = object_store_of(settings, http)
230
+ if settings.recordings_bucket is None or objects is None:
231
+ return Disk(root)
232
+ return Bucket(root, settings.recordings_bucket, objects)
233
+
234
+
235
+ # The worker's pointer says which file it kept, never where: a summary that named any path would
236
+ # have the gateway serve any file its own disk holds.
237
+ def call_directory(root: Path, call: str) -> Path | None:
238
+ """The call's directory under the root; None for an id that would name a path outside it."""
239
+ directory = (root / call).resolve()
240
+ if directory == root.resolve() or not directory.is_relative_to(root.resolve()):
241
+ return None
242
+ return directory
243
+
244
+
245
+ # Where the worker's session writes the call's audio: the one place a call's file is named.
246
+ def recording_path(root: Path, call: str) -> Path:
247
+ """The call's audio file, in a directory of its own under the root; refused outside it."""
248
+ directory = call_directory(root, call)
249
+ if directory is None:
250
+ raise DeclarationRefused(NOT_UNDER_THE_ROOT.format(call=call))
251
+ directory.mkdir(parents=True, exist_ok=True)
252
+ return directory / AUDIO_FILE
253
+
254
+
255
+ def kept_on_disk(root: Path, call: str, pointer: str) -> Path | None:
256
+ """The call's recording under the root, by the name its summary gave; None for any other."""
257
+ name = PurePath(pointer).name
258
+ path = (root / call / name).resolve()
259
+ if name not in NAMES or not path.is_relative_to(root.resolve()):
260
+ return None
261
+ return path
262
+
263
+
264
+ def served_sealed(sealed: Sealed, key: bytes, byte_range: str | None) -> Fetched:
265
+ """A sealed recording as a player reads it: the range it asked for, or the whole, opened."""
266
+ size = recorded_size(sealed.size)
267
+ span = span_of(byte_range, size)
268
+ if span is not None and span.first >= size:
269
+ return Fetched(
270
+ HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
271
+ {"content-range": f"bytes */{size}"},
272
+ _nothing(),
273
+ )
274
+ whole = span or Span(0, size - 1)
275
+ body = opened(sealed, key, whole) if size else _nothing()
276
+ headers = {"accept-ranges": "bytes", "content-length": str(whole.length)}
277
+ if span is None:
278
+ return Fetched(HTTPStatus.OK, headers, body)
279
+ shown = {**headers, "content-range": f"bytes {span.first}-{span.last}/{size}"}
280
+ return Fetched(HTTPStatus.PARTIAL_CONTENT, shown, body)
281
+
282
+
283
+ def _object_name(org: str, call: str, name: str) -> str:
284
+ return f"{org}/{call}/{name}"
285
+
286
+
287
+ def _measured(audio: Path) -> tuple[int, str]:
288
+ digest = hashlib.sha256()
289
+ with audio.open("rb") as source:
290
+ while chunk := source.read(READ_BYTES):
291
+ digest.update(chunk)
292
+ return audio.stat().st_size, digest.hexdigest()
293
+
294
+
295
+ async def _read(audio: Path) -> AsyncIterator[bytes]:
296
+ with audio.open("rb") as source:
297
+ while chunk := await asyncio.to_thread(source.read, READ_BYTES):
298
+ yield chunk
299
+
300
+
301
+ async def _nothing() -> AsyncIterator[bytes]:
302
+ for piece in ():
303
+ yield piece
304
+
305
+
306
+ async def _streamed(answer: httpx.Response) -> AsyncIterator[bytes]:
307
+ try:
308
+ async for chunk in answer.aiter_bytes():
309
+ yield chunk
310
+ finally:
311
+ await answer.aclose()
312
+
313
+
314
+ # A recording is a directory named for its call (recording_path): one under
315
+ # the root, whatever the id says, or nothing is removed.
316
+ def _removed(root: Path, calls: list[str]) -> set[str]:
317
+ removed: set[str] = set()
318
+ for call in calls:
319
+ directory = call_directory(root, call)
320
+ if directory is not None and directory.is_dir():
321
+ shutil.rmtree(directory)
322
+ removed.add(call)
323
+ return removed
@@ -0,0 +1,41 @@
1
+ """What a name resolves to: every address, so a caller can hold them all to the public internet."""
2
+
3
+ import ipaddress
4
+ import socket
5
+
6
+ import dns.exception
7
+ import dns.rdatatype
8
+ import dns.rdtypes.ANY.TXT
9
+ import dns.resolver
10
+
11
+ type Address = ipaddress.IPv4Address | ipaddress.IPv6Address
12
+
13
+
14
+ # A TXT lookup waits this long in all, over every nameserver tried.
15
+ TXT_WITHIN_S = 5.0
16
+
17
+
18
+ def addresses_of(host: str) -> list[Address]:
19
+ """Every address the name resolves to; OSError when it resolves to none."""
20
+ found = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
21
+ return [ipaddress.ip_address(str(item[4][0]).partition("%")[0]) for item in found]
22
+
23
+
24
+ def all_public(addresses: list[Address]) -> bool:
25
+ """Whether there is an address and every one is on the public internet."""
26
+ return bool(addresses) and all(address.is_global for address in addresses)
27
+
28
+
29
+ # A record of several strings is one text, as the RFC reads it; a name with no TXT, or none at all,
30
+ # is an empty list, like one whose nameservers did not answer in time.
31
+ def txt_of(name: str) -> list[str]:
32
+ """Every TXT record at the name, each as one string; none when there is none or no answer."""
33
+ try:
34
+ answer = dns.resolver.resolve(name, dns.rdatatype.TXT, lifetime=TXT_WITHIN_S)
35
+ except dns.exception.DNSException:
36
+ return []
37
+ return [
38
+ b"".join(rdata.strings).decode("utf-8", errors="replace")
39
+ for rdata in answer
40
+ if isinstance(rdata, dns.rdtypes.ANY.TXT.TXT)
41
+ ]
@@ -0,0 +1,173 @@
1
+ """A recording sealed under its own key: AES-GCM a chunk at a time, so a range opens on its own."""
2
+
3
+ import asyncio
4
+ import os
5
+ from collections.abc import AsyncIterator, Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from cryptography.exceptions import InvalidTag
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+
12
+ from pinecall.domain.errors import UpstreamFailed
13
+
14
+ # What a sealed file starts with, then the 8 random bytes every chunk's nonce begins with.
15
+ MAGIC = b"PCSEAL1\n"
16
+
17
+ PREFIX_BYTES = 8
18
+
19
+ # A chunk of the recording as recorded: a range opens the chunks it covers and no other, and a
20
+ # read holds one at a time. 64 KiB is some seconds of a call's audio.
21
+ CHUNK = 64 * 1024
22
+
23
+ TAG = 16
24
+
25
+ # The last chunk is sealed as the last, so a file cut short never opens as a shorter recording.
26
+ LAST, MORE = b"\x01", b"\x00"
27
+
28
+ KEY_BITS = 256
29
+
30
+ BROKEN = "the sealed recording of {call} does not open: {why}"
31
+
32
+ # Reads `length` bytes of the sealed object from `offset`, in whatever pieces its source yields.
33
+ type Read = Callable[[int, int], AsyncIterator[bytes]]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Sealed:
38
+ """A sealed recording where it is kept: its size as kept, its nonces' prefix, its reader."""
39
+
40
+ call: str
41
+ size: int
42
+ prefix: bytes
43
+ read: Read
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Span:
48
+ """The bytes of the recording as recorded that a reader asked for, first and last included."""
49
+
50
+ first: int
51
+ last: int
52
+
53
+ @property
54
+ def length(self) -> int:
55
+ """How many bytes the span holds."""
56
+ return self.last - self.first + 1
57
+
58
+
59
+ # The format's name and the nonces' prefix, then each chunk and its tag.
60
+ HEADER = len(MAGIC) + PREFIX_BYTES
61
+
62
+ SEALED_CHUNK = CHUNK + TAG
63
+
64
+
65
+ def new_key() -> bytes:
66
+ """A recording's own key."""
67
+ return AESGCM.generate_key(bit_length=KEY_BITS)
68
+
69
+
70
+ # Blocking, for a thread: the file is read and written a chunk at a time, never whole.
71
+ def seal_file(plain: Path, sealed: Path, key: bytes) -> None:
72
+ """Write `sealed` from `plain` under the key, then remove `plain`."""
73
+ cipher = AESGCM(key)
74
+ prefix = os.urandom(PREFIX_BYTES)
75
+ part = sealed.with_name(f"{sealed.name}.part")
76
+ with plain.open("rb") as source, part.open("wb") as target:
77
+ target.write(MAGIC + prefix)
78
+ chunk, index = source.read(CHUNK), 0
79
+ while True:
80
+ following = source.read(CHUNK)
81
+ flag = MORE if following else LAST
82
+ target.write(cipher.encrypt(_nonce(prefix, index), chunk, flag))
83
+ if not following:
84
+ break
85
+ chunk, index = following, index + 1
86
+ target.flush()
87
+ os.fsync(target.fileno())
88
+ part.rename(sealed)
89
+ plain.unlink()
90
+
91
+
92
+ def recorded_size(sealed_size: int) -> int:
93
+ """How many bytes the recording had, from its sealed object's size."""
94
+ full, rest = divmod(sealed_size - HEADER, SEALED_CHUNK)
95
+ return full * CHUNK + max(rest - TAG, 0)
96
+
97
+
98
+ def span_of(byte_range: str | None, size: int) -> Span | None:
99
+ """The one range a player asked for within the recording; None for the whole of it."""
100
+ if byte_range is None or not byte_range.startswith("bytes=") or "," in byte_range:
101
+ return None
102
+ first, _, last = byte_range.removeprefix("bytes=").strip().partition("-")
103
+ if not (first or last) or not all(bound.isdigit() for bound in (first, last) if bound):
104
+ return None
105
+ if not first:
106
+ return Span(max(size - int(last), 0), size - 1)
107
+ # A range that ends before it starts is no range: the whole is answered, as HTTP says.
108
+ if last and int(last) < int(first):
109
+ return None
110
+ return Span(int(first), min(int(last), size - 1) if last else size - 1)
111
+
112
+
113
+ async def opened(sealed: Sealed, key: bytes, span: Span) -> AsyncIterator[bytes]:
114
+ """The span's bytes as recorded, opening only the chunks it covers, one at a time."""
115
+ chunks = -(-(sealed.size - HEADER) // SEALED_CHUNK)
116
+ first, last = span.first // CHUNK, span.last // CHUNK
117
+ start = HEADER + first * SEALED_CHUNK
118
+ end = min(HEADER + (last + 1) * SEALED_CHUNK, sealed.size)
119
+ cipher = AESGCM(key)
120
+ index = first
121
+ async for piece in _in_chunks(sealed.read(start, end - start)):
122
+ flag = LAST if index == chunks - 1 else MORE
123
+ try:
124
+ plain = cipher.decrypt(_nonce(sealed.prefix, index), piece, flag)
125
+ except InvalidTag:
126
+ raise UpstreamFailed(BROKEN.format(call=sealed.call, why=f"chunk {index}")) from None
127
+ low = span.first - index * CHUNK if index == first else 0
128
+ high = span.last - index * CHUNK + 1 if index == last else len(plain)
129
+ yield plain[low:high]
130
+ index += 1
131
+
132
+
133
+ async def on_disk(call: str, path: Path) -> Sealed | None:
134
+ """The sealed file at the path, read a chunk at a time off the loop; None when absent."""
135
+ try:
136
+ header, size = await asyncio.to_thread(_head_of, path)
137
+ except FileNotFoundError:
138
+ return None
139
+ if not header.startswith(MAGIC):
140
+ raise UpstreamFailed(BROKEN.format(call=call, why=f"{path} is not a sealed recording"))
141
+
142
+ async def read(offset: int, length: int) -> AsyncIterator[bytes]:
143
+ with await asyncio.to_thread(path.open, "rb") as file:
144
+ await asyncio.to_thread(file.seek, offset)
145
+ left = length
146
+ while left > 0:
147
+ piece = await asyncio.to_thread(file.read, min(SEALED_CHUNK, left))
148
+ if not piece:
149
+ return
150
+ left -= len(piece)
151
+ yield piece
152
+
153
+ return Sealed(call, size, header[len(MAGIC) :], read)
154
+
155
+
156
+ def _head_of(path: Path) -> tuple[bytes, int]:
157
+ with path.open("rb") as file:
158
+ return file.read(HEADER), path.stat().st_size
159
+
160
+
161
+ def _nonce(prefix: bytes, index: int) -> bytes:
162
+ return prefix + index.to_bytes(4, "big")
163
+
164
+
165
+ async def _in_chunks(pieces: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
166
+ waiting = bytearray()
167
+ async for piece in pieces:
168
+ waiting += piece
169
+ while len(waiting) >= SEALED_CHUNK:
170
+ yield bytes(waiting[:SEALED_CHUNK])
171
+ del waiting[:SEALED_CHUNK]
172
+ if waiting:
173
+ yield bytes(waiting)