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,168 @@
1
+ """Sign-in with an org's identity provider: the redirect out, the callback in, a domain's orgs."""
2
+
3
+ from typing import Annotated
4
+
5
+ import httpx
6
+ from fastapi import APIRouter, Query, Request
7
+ from fastapi.responses import RedirectResponse
8
+
9
+ from pinecall.domain.errors import (
10
+ DeclarationRefused,
11
+ NotAllowed,
12
+ NotAvailable,
13
+ NotFound,
14
+ PinecallError,
15
+ )
16
+ from pinecall.domain.person import Member
17
+ from pinecall.gateway._deps import GatewayDep, check_knock, client_of, public_url
18
+ from pinecall.gateway._gateway import Gateway
19
+ from pinecall.tenancy import orgs, signin, sso, sso_domains
20
+ from pinecall.tenancy.signin import A_DOMAIN, Handshake
21
+ from pinecall.wire.rest.accounts import DiscoverSsoRequest, DiscoverSsoResponse, SsoOrgRow
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ # The page a sign-in lands on; the URL carries a one-use login code, never a key.
27
+ THE_CONSOLE = "/"
28
+
29
+
30
+ # Where a sign-in begun by `pinecall login` lands, to approve the terminal waiting.
31
+ THE_CARD = "/cli"
32
+
33
+
34
+ NO_SSO_HERE = "org {org} signs in with no identity provider"
35
+
36
+
37
+ NO_HANDSHAKE = (
38
+ "no sign-in answers to that: it was finished already, it expired, or it never started"
39
+ )
40
+
41
+
42
+ ANOTHER_DOMAIN = "{email} is not in a domain {org} signs in with"
43
+
44
+
45
+ # No address is known yet at the redirect, so it is counted by the client's address.
46
+ TOO_MANY_SIGN_INS = "too many sign-ins from here: try again in a minute"
47
+
48
+
49
+ THE_PROVIDER_SAID = "the sign-in was refused at the provider: {error}"
50
+
51
+
52
+ NO_CODE_BACK = "the provider sent no authorization code back"
53
+
54
+
55
+ NO_BOX_WIDE = (
56
+ "box-wide sign-in is not in this version: sign in with a password or the org's provider"
57
+ )
58
+
59
+
60
+ FOUND = 302
61
+
62
+
63
+ Pairing = Annotated[str | None, Query(description="the terminal waiting to be signed in")]
64
+
65
+
66
+ # The same 404 for an org unknown and one with no provider, so slugs are not enumerated.
67
+ @router.get("/v1/login/sso")
68
+ async def start_sso(
69
+ org: Annotated[str, Query(description="which org's provider, by id or slug")],
70
+ request: Request,
71
+ gateway: GatewayDep,
72
+ pairing: Pairing = None,
73
+ ) -> RedirectResponse:
74
+ """302 to the org's provider, with a state, a nonce and a PKCE challenge."""
75
+ connections = gateway.connections
76
+ owner = await orgs.find(connections.pool, org)
77
+ wired = (
78
+ None if owner is None else await sso.sso_of(connections.pool, connections.vault, owner.id)
79
+ )
80
+ if owner is None or wired is None:
81
+ raise NotFound(NO_SSO_HERE.format(org=org))
82
+ await check_knock(gateway, f"{client_of(request)} sso/{owner.slug}", TOO_MANY_SIGN_INS)
83
+ provider = await sso.discovered(connections.http, wired.client.issuer)
84
+ redirect_uri = f"{public_url(request, gateway)}{sso.CALLBACK}"
85
+ begun = await sso.handshake(
86
+ gateway.signins.handshakes, redirect_uri, org=owner.id, pairing=pairing
87
+ )
88
+ return RedirectResponse(sso.authorization_url(provider, wired.client, begun), FOUND)
89
+
90
+
91
+ # A refusal mid-redirect goes back to the sign-in page as `?refused=`: a JSON body there is a
92
+ # dead end. A state nobody began is the one refusal answered as JSON.
93
+ @router.get("/v1/login/sso/callback")
94
+ async def finish_sso(
95
+ state: str, gateway: GatewayDep, code: str | None = None, error: str | None = None
96
+ ) -> RedirectResponse:
97
+ """The code exchanged, the person seated, and 302 to the console with a login code."""
98
+ begun = await gateway.signins.handshakes.spend(state)
99
+ if begun is None:
100
+ raise DeclarationRefused(NO_HANDSHAKE)
101
+ if error is not None:
102
+ return _refused(THE_PROVIDER_SAID.format(error=error))
103
+ if not code:
104
+ return _refused(NO_CODE_BACK)
105
+ try:
106
+ member = await _seated(gateway, begun, code)
107
+ except PinecallError as refusal:
108
+ return _refused(str(refusal))
109
+ login, _ = await gateway.signins.codes.mint(member)
110
+ return RedirectResponse(_landing(begun.pairing, login), FOUND)
111
+
112
+
113
+ # Says which orgs a domain signs in with, never whether the address is anybody's.
114
+ @router.post("/v1/login/sso/discover")
115
+ async def discover_sso(
116
+ body: DiscoverSsoRequest, request: Request, gateway: GatewayDep
117
+ ) -> DiscoverSsoResponse:
118
+ """The orgs whose provider signs in this address's domain, oldest first."""
119
+ connections = gateway.connections
120
+ email = body.email.strip().lower()
121
+ await check_knock(
122
+ gateway, f"{client_of(request)} sso/{email}", signin.TOO_MANY.format(email=email)
123
+ )
124
+ domain = email.rpartition("@")[2]
125
+ if not A_DOMAIN.match(domain):
126
+ return DiscoverSsoResponse(orgs=[])
127
+ rows: list[SsoOrgRow] = []
128
+ for wired in await sso.sso_with_domain(connections.pool, connections.vault, domain):
129
+ org = await orgs.find(connections.pool, wired.org)
130
+ if org is not None:
131
+ rows.append(SsoOrgRow(org=org.id, slug=org.slug, name=org.name))
132
+ return DiscoverSsoResponse(orgs=rows)
133
+
134
+
135
+ @router.get("/v1/login/google")
136
+ async def google_sign_in() -> RedirectResponse:
137
+ """Box-wide "Continue with Google", which this version does not have."""
138
+ raise NotAvailable(NO_BOX_WIDE)
139
+
140
+
141
+ @router.get("/v1/login/google/callback")
142
+ async def google_callback() -> RedirectResponse:
143
+ """Box-wide Google's callback, which this version does not have."""
144
+ raise NotAvailable(NO_BOX_WIDE)
145
+
146
+
147
+ async def _seated(gateway: Gateway, begun: Handshake, code: str) -> Member:
148
+ connections = gateway.connections
149
+ named = begun.org or ""
150
+ wired = await sso.sso_of(connections.pool, connections.vault, named)
151
+ org = await orgs.find(connections.pool, named)
152
+ if wired is None or org is None:
153
+ raise NotFound(NO_SSO_HERE.format(org=named))
154
+ claims = await sso.vouched_for(connections.http, wired.client, begun, code)
155
+ proven = await sso_domains.verified_domains(connections.pool, org.id)
156
+ if not wired.admits(claims.email) or claims.email.rpartition("@")[2].lower() not in proven:
157
+ raise NotAllowed(ANOTHER_DOMAIN.format(email=claims.email, org=org.slug))
158
+ return await sso.seat_vouched(connections.pool, org, wired, claims)
159
+
160
+
161
+ def _landing(pairing: str | None, code: str) -> str:
162
+ if pairing is None:
163
+ return f"{THE_CONSOLE}?{httpx.QueryParams({'login': code})}"
164
+ return f"{THE_CARD}?{httpx.QueryParams({'c': pairing, 'login': code})}"
165
+
166
+
167
+ def _refused(sentence: str) -> RedirectResponse:
168
+ return RedirectResponse(f"{THE_CONSOLE}?{httpx.QueryParams({'refused': sentence})}", FOUND)
@@ -0,0 +1,187 @@
1
+ """An agent's WhatsApp inbox by contact: the threads, one thread, read, written to."""
2
+
3
+ import time
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Query
7
+ from pydantic import BaseModel, Field
8
+
9
+ from pinecall.channels.whatsapp import WINDOW_S
10
+ from pinecall.domain.errors import (
11
+ Conflict,
12
+ NotFound,
13
+ )
14
+ from pinecall.domain.names import THE_WIDGET, parse_channel
15
+ from pinecall.domain.scope import Scope
16
+ from pinecall.gateway import _deps
17
+ from pinecall.gateway._deps import Acting, CallsKey, GatewayDep, ScopeDep, TalkKey
18
+ from pinecall.gateway._gateway import Gateway
19
+ from pinecall.log import facts, inbox, queries
20
+ from pinecall.tenancy import keys
21
+ from pinecall.wire.commands import SayVerb, SupervisorVerb
22
+ from pinecall.wire.frames import Entry
23
+ from pinecall.wire.parts import Supervisor, ThreadKind
24
+ from pinecall.wire.rest.calls import (
25
+ ThreadLast,
26
+ ThreadList,
27
+ ThreadMessage,
28
+ ThreadMessageRequest,
29
+ ThreadMessageResponse,
30
+ ThreadResponse,
31
+ ThreadRow,
32
+ )
33
+
34
+ router = APIRouter()
35
+
36
+
37
+ THREADS_A_PAGE = 30
38
+
39
+ # The calls merged into one thread, newest first: each costs a log read.
40
+ CALLS_IN_A_THREAD = 20
41
+
42
+ NO_THREAD = "no thread with {contact} on agent {agent} in this corner"
43
+
44
+ ONLY_WHATSAPP = (
45
+ "a message is written on a WhatsApp thread, and {contact}'s newest call is {channel}"
46
+ )
47
+
48
+ WINDOW_CLOSED = (
49
+ "WhatsApp's window with {contact} closed 24 h after their last message: only a template "
50
+ "reaches them now"
51
+ )
52
+
53
+ NOTHING_OPEN = (
54
+ "{contact}'s conversation went quiet and is sealed: their next message opens a new one"
55
+ )
56
+
57
+
58
+ class ThreadQuery(BaseModel):
59
+ """One page of an inbox: after the cursor of the last one, so many contacts."""
60
+
61
+ after: str | None = None
62
+ limit: int = Field(THREADS_A_PAGE, ge=1, le=_deps.LONGEST_LIST)
63
+
64
+
65
+ @router.get("/v1/agents/{slug}/threads")
66
+ async def list_threads(
67
+ slug: str,
68
+ key: CallsKey,
69
+ where: ScopeDep,
70
+ gateway: GatewayDep,
71
+ page: Annotated[ThreadQuery, Query()],
72
+ ) -> ThreadList:
73
+ """The agent's contacts, the one that moved last first, with what this reader has not read."""
74
+ wanted = inbox.Inbox(where, slug, _reader_of(key))
75
+ found = await inbox.threads(
76
+ gateway.connections.pool, wanted, after=page.after, limit=page.limit
77
+ )
78
+ return ThreadList(threads=[_line_of(row) for row in found.rows], next=found.next)
79
+
80
+
81
+ @router.get("/v1/agents/{slug}/threads/{contact}")
82
+ async def get_thread(
83
+ slug: str, contact: str, _key: CallsKey, where: ScopeDep, gateway: GatewayDep
84
+ ) -> ThreadResponse:
85
+ """A contact's calls with the agent merged into one thread, oldest first."""
86
+ calls = await _thread_of(gateway, where, slug, contact)
87
+ facts = await queries.facts_of_calls(gateway.connections.pool, calls)
88
+ messages: list[ThreadMessage] = []
89
+ for call in reversed(calls):
90
+ if call in facts:
91
+ messages += _said_on(facts[call], await gateway.logs.store.whole(call))
92
+ name = next((facts[call].name for call in calls if call in facts and facts[call].name), None)
93
+ return ThreadResponse(contact=contact, name=name, messages=messages)
94
+
95
+
96
+ # The read cursor is the person's; a server's key keeps its own.
97
+ @router.post("/v1/agents/{slug}/threads/{contact}/read", status_code=204)
98
+ async def mark_thread_read(
99
+ slug: str, contact: str, key: CallsKey, where: ScopeDep, gateway: GatewayDep
100
+ ) -> None:
101
+ """This reader has read the thread up to now."""
102
+ await _thread_of(gateway, where, slug, contact)
103
+ await inbox.read(
104
+ gateway.connections.pool, inbox.Inbox(where, slug, _reader_of(key)), contact, time.time()
105
+ )
106
+
107
+
108
+ # A supervisor's `say` on the open conversation, sent to the contact by its reply tap. Meta lets
109
+ # a business write freely only within 24 h of the contact's last message.
110
+ @router.post("/v1/agents/{slug}/threads/{contact}/messages", status_code=202)
111
+ async def send_thread_message(
112
+ slug: str, contact: str, body: ThreadMessageRequest, key: TalkKey, gateway: GatewayDep
113
+ ) -> ThreadMessageResponse:
114
+ """Say something as the agent on the contact's open WhatsApp conversation."""
115
+ where = keys.scope_of(key.bearer, key.env)
116
+ newest = (await _thread_of(gateway, where, slug, contact))[0]
117
+ facts = (await queries.facts_of_calls(gateway.connections.pool, [newest])).get(newest)
118
+ channel = None if facts is None else facts.channel
119
+ if facts is None or channel != "whatsapp":
120
+ raise Conflict(ONLY_WHATSAPP.format(contact=contact, channel=channel))
121
+ # The log's own clock, which stamped when the contact last wrote.
122
+ if gateway.logs.store.clock() - max(facts.heard_at, default=0.0) > WINDOW_S:
123
+ raise Conflict(WINDOW_CLOSED.format(contact=contact))
124
+ served = gateway.live.calls.get(newest)
125
+ if served is None or served.session is None:
126
+ raise Conflict(NOTHING_OPEN.format(contact=contact))
127
+ member = key.bearer.member
128
+ by = Supervisor(id=_reader_of(key), name=None if member is None else member.name)
129
+ await served.session.supervise(SupervisorVerb(by=by, verb=SayVerb(text=body.text)))
130
+ return ThreadMessageResponse(contact=contact, call=newest)
131
+
132
+
133
+ async def _thread_of(gateway: Gateway, where: Scope, slug: str, contact: str) -> list[str]:
134
+ calls = await inbox.calls_with(
135
+ gateway.connections.pool, where, slug, contact, limit=CALLS_IN_A_THREAD
136
+ )
137
+ if not calls:
138
+ raise NotFound(NO_THREAD.format(contact=contact, agent=slug))
139
+ return calls
140
+
141
+
142
+ def _reader_of(key: Acting) -> str:
143
+ return key.bearer.key.subject or key.bearer.key.key_id
144
+
145
+
146
+ def _line_of(row: inbox.InboxRow) -> ThreadRow:
147
+ newest = row.newest
148
+ kind: ThreadKind = "call" if newest.spoken else ("in" if newest.last_in else "out")
149
+ data = newest.outcome if newest.spoken else newest.last_text
150
+ return ThreadRow(
151
+ contact=row.contact,
152
+ name=row.name,
153
+ channel_last=parse_channel(newest.channel or THE_WIDGET),
154
+ last=ThreadLast(text=data, at=row.moved_at, kind=kind),
155
+ unread=row.unread,
156
+ calls=row.calls,
157
+ )
158
+
159
+
160
+ # A written call is its turns; a spoken call is one pill with its length and whether it came up.
161
+ def _said_on(call_facts: facts.CallFacts, entries: list[Entry]) -> list[ThreadMessage]:
162
+ channel = parse_channel(call_facts.channel or THE_WIDGET)
163
+ if not call_facts.spoken:
164
+ return [
165
+ ThreadMessage(
166
+ kind="in" if entry.type == "turn.user" else "out",
167
+ text=str(entry.data.get("text") or ""),
168
+ at=entry.ts,
169
+ call=call_facts.call,
170
+ channel=channel,
171
+ )
172
+ for entry in entries
173
+ if entry.type in {"turn.user", "turn.agent"}
174
+ ]
175
+ ended = next((entry for entry in entries if entry.type == "call.ended"), None)
176
+ lasted = None if ended is None else ended.data.get("duration_s")
177
+ return [
178
+ ThreadMessage(
179
+ kind="call",
180
+ text=call_facts.outcome,
181
+ at=entries[0].ts if entries else 0.0,
182
+ call=call_facts.call,
183
+ channel=channel,
184
+ duration_s=float(lasted) if isinstance(lasted, int | float) else None,
185
+ answered=any(entry.type == "call.started" for entry in entries),
186
+ )
187
+ ]
@@ -0,0 +1,303 @@
1
+ """The org's meters: the usage feed, a window's insights, an agent's drift, what the org may use."""
2
+
3
+ import re
4
+ from datetime import UTC, date, datetime, timedelta
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Query
8
+ from pydantic import BaseModel
9
+
10
+ from pinecall.channels import routes
11
+ from pinecall.domain.errors import DeclarationRefused
12
+ from pinecall.domain.org import Quotas
13
+ from pinecall.gateway._deps import ActingDep, CallsKey, GatewayDep, ScopeDep, UsageKey
14
+ from pinecall.log import drift
15
+ from pinecall.log.drift import Side, Tally
16
+ from pinecall.log.reduce import Usage, UsageRow, totals_by_org
17
+ from pinecall.log.store import DEFAULT_LIMIT
18
+ from pinecall.log.usage import A_DAY_S, AgentWindow, WindowDay, counted_window, metered_page
19
+ from pinecall.tenancy import admission, people, scopes, usage
20
+ from pinecall.tenancy.keys import check_agent
21
+ from pinecall.wire.rest.usage import (
22
+ Drift,
23
+ DriftSide,
24
+ DriftVersion,
25
+ Insights,
26
+ InsightsAgent,
27
+ InsightsBudget,
28
+ InsightsChannels,
29
+ InsightsConversations,
30
+ InsightsDay,
31
+ InsightsEnding,
32
+ InsightsSpend,
33
+ Limit,
34
+ Limits,
35
+ UsagePage,
36
+ UsageTotals,
37
+ )
38
+ from pinecall.wire.rest.usage import UsageRow as UsageRowResponse
39
+
40
+ router = APIRouter()
41
+
42
+
43
+ # An org carries no timezone, so a day is cut in UTC and the answer says so.
44
+ TIMEZONE = "UTC"
45
+
46
+
47
+ # The windows the console offers, in whole UTC days: today, a week, a month.
48
+ WINDOW_DAYS = (1, 7, 30)
49
+
50
+
51
+ NOT_A_WINDOW = "days={days} is not a window: one of 1, 7 or 30"
52
+
53
+
54
+ A_VERSION = re.compile(r"v(\d+)")
55
+
56
+
57
+ NOT_A_SIDE = "{text!r} is neither a day (YYYY-MM-DD) nor a version (v3)"
58
+
59
+
60
+ BOTH_SIDES = "a drift between versions names both: before=v3&after=v4"
61
+
62
+
63
+ class InsightsQuery(BaseModel):
64
+ """What insights count: the window's last day (today), its days, one agent or every one."""
65
+
66
+ day: date | None = None
67
+ days: int = 1
68
+ agent: str | None = None
69
+
70
+
71
+ class DriftQuery(BaseModel):
72
+ """What a drift compares: an agent, two days (today and the day before) or two versions."""
73
+
74
+ agent: str
75
+ before: str | None = None
76
+ after: str | None = None
77
+
78
+
79
+ # The cursor is the store's position of the last row read: a billing consumer resumes from it
80
+ # and counts nothing twice.
81
+ @router.get("/v1/usage")
82
+ async def usage_feed(
83
+ key: UsageKey,
84
+ gateway: GatewayDep,
85
+ after: Annotated[int, Query(ge=0)] = 0,
86
+ limit: Annotated[int, Query(ge=1, le=DEFAULT_LIMIT)] = DEFAULT_LIMIT,
87
+ ) -> UsagePage:
88
+ """The org's metered rows after the cursor, their totals, and the next cursor."""
89
+ page = await metered_page(gateway.logs.store, after=after, limit=limit, org=key.org)
90
+ totals = totals_by_org(page.rows).get(key.org)
91
+ return UsagePage(
92
+ rows=[usage_row_response(row) for row in page.rows],
93
+ totals=None if totals is None else usage_totals(totals),
94
+ next=page.next,
95
+ )
96
+
97
+
98
+ # The window is the scope's, or one agent's of it; the budget is the org's across both worlds and
99
+ # its month is the window's last day's, since it is one bill.
100
+ @router.get("/v1/insights")
101
+ async def insights(
102
+ key: CallsKey,
103
+ scope: ScopeDep,
104
+ gateway: GatewayDep,
105
+ query: Annotated[InsightsQuery, Query()],
106
+ ) -> Insights:
107
+ """Whole UTC days of the key's world and scope at a glance, and the month's spend."""
108
+ if query.days not in WINDOW_DAYS:
109
+ raise DeclarationRefused(NOT_A_WINDOW.format(days=query.days))
110
+ if query.agent is not None:
111
+ check_agent(key.bearer, query.agent)
112
+ pool = gateway.connections.pool
113
+ last = query.day or datetime.now(UTC).date()
114
+ first = last - timedelta(days=query.days - 1)
115
+ start, end = _opening(first), _opening(last) + A_DAY_S
116
+ counted = await counted_window(pool, scope, start, end, query.agent)
117
+ spent = await usage.spent_in(pool, key.org, last)
118
+ quotas = await admission.quotas_of(pool, key.org, key.env)
119
+ return Insights(
120
+ day=last.isoformat(),
121
+ days=query.days,
122
+ timezone=TIMEZONE,
123
+ conversations=InsightsConversations(now=counted.calls, before=counted.before),
124
+ resolved_rate=counted.unescalated / counted.finished if counted.finished else None,
125
+ median_e2e_s=counted.median_e2e,
126
+ spend_usd=counted.spent,
127
+ channels=_channels(counted.channels),
128
+ judged=counted.judged,
129
+ passed=counted.passed,
130
+ escalated=counted.escalated,
131
+ mean_length_s=counted.mean_length,
132
+ endings=[InsightsEnding(reason=reason, count=count) for reason, count in counted.endings],
133
+ series=[_day(day) for day in counted.days],
134
+ sessions_total=counted.total,
135
+ live=counted.live,
136
+ agents=[
137
+ InsightsAgent(
138
+ slug=agent.slug, calls=agent.calls, score=agent.score, spend=_spend(agent)
139
+ )
140
+ for agent in counted.agents
141
+ ],
142
+ stages=await drift.stages_of_window(pool, scope, first, last, query.agent),
143
+ budget=InsightsBudget(limit_usd=quotas.budget_usd, spent_usd_month=spent),
144
+ )
145
+
146
+
147
+ # Folded when each call was sealed (log/drift.py): two days or two versions read a few rows each.
148
+ @router.get("/v1/insights/drift")
149
+ async def insights_drift(
150
+ key: CallsKey, scope: ScopeDep, gateway: GatewayDep, query: Annotated[DriftQuery, Query()]
151
+ ) -> Drift:
152
+ """What moved between two days or versions of an agent, and the versions run or set."""
153
+ check_agent(key.bearer, query.agent)
154
+ pool = gateway.connections.pool
155
+ before, after = _sides(query)
156
+ tallies = (
157
+ await drift.tally(pool, scope, query.agent, before),
158
+ await drift.tally(pool, scope, query.agent, after),
159
+ )
160
+ judges, stages = drift.compared(*tallies)
161
+ ran = tallies[0].versions | tallies[1].versions
162
+ days = [side.day for side in (before, after) if side.day is not None]
163
+ window = (_opening(min(days)), _opening(max(days)) + A_DAY_S) if days else (0.0, 0.0)
164
+ noted = await scopes.versions_noted(pool, scope, query.agent, ran, window)
165
+ return Drift(
166
+ agent=query.agent,
167
+ world=scope.env,
168
+ before=_side(before, tallies[0]),
169
+ after=_side(after, tallies[1]),
170
+ judges=judges,
171
+ stages=stages,
172
+ versions=[
173
+ DriftVersion(
174
+ version=row.version,
175
+ holder=row.holder,
176
+ author=row.author,
177
+ note=row.note,
178
+ set_at=row.set_at.timestamp(),
179
+ ran_before=row.version in tallies[0].versions,
180
+ ran_after=row.version in tallies[1].versions,
181
+ )
182
+ for row in noted
183
+ ],
184
+ )
185
+
186
+
187
+ # Any key of the org. `used` reads what admission reads, so this page and a refusal agree.
188
+ @router.get("/v1/limits")
189
+ async def limits(key: ActingDep, gateway: GatewayDep) -> Limits:
190
+ """Each quota of the key's world as {limit, used}, the lends, and where to buy more."""
191
+ pool, org, env = gateway.connections.pool, key.org, key.env
192
+ quotas = await admission.quotas_of(pool, org, env)
193
+ used = await usage.used(pool, org, env, usage.month_of(gateway.logs.store.clock()))
194
+ return Limits(
195
+ minutes=Limit(limit=quotas.minutes, used=used.minutes),
196
+ messages=Limit(limit=quotas.messages, used=used.messages),
197
+ llm_tokens=Limit(limit=quotas.llm_tokens, used=used.input_tokens + used.output_tokens),
198
+ concurrent_calls=Limit(limit=quotas.concurrent_calls, used=gateway.live.running(org, env)),
199
+ agents=Limit(limit=quotas.agents, used=len(gateway.sockets.slugs(org))),
200
+ seats=Limit(limit=quotas.seats, used=await people.seated(pool, org)),
201
+ numbers=Limit(limit=quotas.numbers, used=await routes.managed_in(pool, org, env)),
202
+ lends=_lends(quotas),
203
+ billing_url=gateway.connections.settings.billing_url,
204
+ world=env,
205
+ )
206
+
207
+
208
+ def usage_row_response(row: UsageRow) -> UsageRowResponse:
209
+ """A folded row as the feed sends it."""
210
+ return UsageRowResponse(
211
+ cursor=row.cursor,
212
+ org=row.org,
213
+ agent=row.agent,
214
+ call=row.call,
215
+ type=row.type,
216
+ at=row.at,
217
+ minutes=row.used.minutes,
218
+ messages=row.used.messages,
219
+ input_tokens=row.used.input_tokens,
220
+ output_tokens=row.used.output_tokens,
221
+ characters=row.used.characters,
222
+ judge_calls=row.used.judge_calls,
223
+ cost_usd=row.used.cost_usd,
224
+ )
225
+
226
+
227
+ def usage_totals(used: Usage) -> UsageTotals:
228
+ """A sum of usage as the feed sends it."""
229
+ return UsageTotals(
230
+ calls=used.calls,
231
+ minutes=used.minutes,
232
+ messages=used.messages,
233
+ input_tokens=used.input_tokens,
234
+ output_tokens=used.output_tokens,
235
+ characters=used.characters,
236
+ judge_calls=used.judge_calls,
237
+ cost_usd=used.cost_usd,
238
+ )
239
+
240
+
241
+ # Two days by default: today and the day before it, in UTC.
242
+ def _sides(query: DriftQuery) -> tuple[Side, Side]:
243
+ after = _side_of(query.after) if query.after is not None else Side(day=datetime.now(UTC).date())
244
+ if query.before is not None:
245
+ return _side_of(query.before), after
246
+ if after.day is None:
247
+ raise DeclarationRefused(BOTH_SIDES)
248
+ return Side(day=after.day - timedelta(days=1)), after
249
+
250
+
251
+ def _side_of(text: str) -> Side:
252
+ version = A_VERSION.fullmatch(text)
253
+ if version is not None:
254
+ return Side(version=int(version.group(1)))
255
+ try:
256
+ return Side(day=date.fromisoformat(text))
257
+ except ValueError as refused:
258
+ raise DeclarationRefused(NOT_A_SIDE.format(text=text)) from refused
259
+
260
+
261
+ def _side(side: Side, tallied: Tally) -> DriftSide:
262
+ return DriftSide(
263
+ day=None if side.day is None else side.day.isoformat(),
264
+ version=side.version,
265
+ versions=sorted(tallied.versions),
266
+ )
267
+
268
+
269
+ def _channels(counted: dict[str, int]) -> InsightsChannels:
270
+ return InsightsChannels(
271
+ phone=counted["phone"], web=counted["web"], whatsapp=counted["whatsapp"]
272
+ )
273
+
274
+
275
+ def _day(day: WindowDay) -> InsightsDay:
276
+ return InsightsDay(
277
+ day=day.day.isoformat(),
278
+ **day.channels,
279
+ spend_usd=day.spent,
280
+ judged=day.judged,
281
+ passed=day.passed,
282
+ )
283
+
284
+
285
+ def _spend(agent: AgentWindow) -> InsightsSpend:
286
+ total = sum(agent.spend.values())
287
+ return InsightsSpend(
288
+ llm_usd=agent.spend["llm"],
289
+ stt_usd=agent.spend["stt"],
290
+ tts_usd=agent.spend["tts"],
291
+ phone_usd=agent.spend["phone"],
292
+ platform_usd=agent.spend["platform"],
293
+ minutes=round(agent.minutes, 3),
294
+ per_minute_usd=round(total / agent.minutes, 6) if agent.minutes > 0 else None,
295
+ )
296
+
297
+
298
+ def _lends(quotas: Quotas) -> list[str] | None:
299
+ return None if quotas.lends is None else sorted(quotas.lends)
300
+
301
+
302
+ def _opening(day: date) -> float:
303
+ return datetime(day.year, day.month, day.day, tzinfo=UTC).timestamp()