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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The four-digit codes a caller keys to tie their call to a browser page, issued per agent."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from psycopg.errors import UniqueViolation
|
|
10
|
+
from psycopg.rows import DictRow
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.errors import NotAvailable, QuotaExhausted
|
|
13
|
+
from pinecall.domain.names import Env
|
|
14
|
+
from pinecall.log.logs import Logs
|
|
15
|
+
from pinecall.postgres.pool import Pool
|
|
16
|
+
from pinecall.wire.events import CodeClaimed, CodeIssued
|
|
17
|
+
from pinecall.wire.parts import Projection
|
|
18
|
+
|
|
19
|
+
CLAIMED = "code.claimed"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TOO_MANY = (
|
|
23
|
+
"agent {agent} already has {live} codes waiting for a call: let one be claimed or expire "
|
|
24
|
+
"before issuing another"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Four digits to key on a phone; the cap per agent stops a page minting them by the thousand.
|
|
29
|
+
DIGITS = 4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ISSUED = "code.issued"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
LIVE_PER_AGENT = 50
|
|
36
|
+
|
|
37
|
+
# A call keys three codes at most: past them a code it keys ties it to no page, answered as one
|
|
38
|
+
# nobody issued, so a caller keying at random learns nothing and has three chances in all.
|
|
39
|
+
TRIES_PER_CALL = 3
|
|
40
|
+
|
|
41
|
+
A_DAY_S = 24 * 60 * 60.0
|
|
42
|
+
|
|
43
|
+
# Where a claim wakes the page waiting on the code, whichever gateway it waits on.
|
|
44
|
+
TAKEN_CHANNEL = "code:{agent}:{code}"
|
|
45
|
+
|
|
46
|
+
# Expired codes leave with the one statement that finds them: two gateways never close one twice.
|
|
47
|
+
EXPIRED = """
|
|
48
|
+
delete from caller_codes where agent = %(agent)s and expires_at <= %(now)s
|
|
49
|
+
returning code, claimed
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
LIVE = "select count(*) as live from caller_codes where agent = %(agent)s"
|
|
53
|
+
|
|
54
|
+
KEPT = """
|
|
55
|
+
insert into caller_codes (agent, code, env, log, expires_at)
|
|
56
|
+
values (%(agent)s, %(code)s, %(env)s, %(log)s, %(expires_at)s)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
FOUND = """
|
|
60
|
+
select agent, code, env, log, expires_at, claimed from caller_codes
|
|
61
|
+
where agent = %(agent)s and code = %(code)s
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# Each try counted in one statement, so two gateways serving one call count both.
|
|
65
|
+
TRIED = """
|
|
66
|
+
insert into code_tries (call, tries, first_at) values (%(call)s, 1, %(now)s)
|
|
67
|
+
on conflict (call) do update set tries = code_tries.tries + 1
|
|
68
|
+
returning tries
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
TRIES_GONE = "delete from code_tries where first_at <= %(since)s"
|
|
72
|
+
|
|
73
|
+
# Taken by one call: the second finds it claimed and gets nothing.
|
|
74
|
+
TAKEN = """
|
|
75
|
+
update caller_codes set claimed = %(call)s
|
|
76
|
+
where agent = %(agent)s and code = %(code)s and env = %(env)s and claimed is null
|
|
77
|
+
and expires_at > %(now)s
|
|
78
|
+
returning agent, code, env, log, expires_at, claimed
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class IssuedCode:
|
|
84
|
+
"""A code a page shows, waiting for the call that keys it, and the call once one did."""
|
|
85
|
+
|
|
86
|
+
code: str
|
|
87
|
+
env: Env
|
|
88
|
+
agent: str
|
|
89
|
+
expires_at: float
|
|
90
|
+
log: Projection
|
|
91
|
+
claimed: str | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Kept in Postgres, so any gateway issues, claims and answers a code; written on each agent's log
|
|
95
|
+
# as code.issued and code.claimed (no call: it expired). A code is the agent's, whatever the world.
|
|
96
|
+
class Codes:
|
|
97
|
+
"""Every agent's live caller codes, and a waiting page's wake-up for each."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, logs: Logs) -> None:
|
|
100
|
+
"""Codes kept in the logs' store, their wake-ups on the logs' signal."""
|
|
101
|
+
self.logs = logs
|
|
102
|
+
self.pool: Pool = logs.store.pool
|
|
103
|
+
|
|
104
|
+
async def issue(self, env: Env, agent: str, ttl_s: float, log: Projection) -> IssuedCode:
|
|
105
|
+
"""A new code for the agent, written on its log; at most fifty live at once."""
|
|
106
|
+
await self._swept(agent, time.time())
|
|
107
|
+
async with self.pool.connection() as connection:
|
|
108
|
+
row = await (await connection.execute(LIVE, {"agent": agent})).fetchone()
|
|
109
|
+
live = 0 if row is None else int(row["live"])
|
|
110
|
+
if live >= LIVE_PER_AGENT:
|
|
111
|
+
raise QuotaExhausted(TOO_MANY.format(agent=agent, live=live))
|
|
112
|
+
issued = await self._kept(env, agent, time.time() + ttl_s, log)
|
|
113
|
+
data = CodeIssued(code=issued.code, env=env, expires_at=issued.expires_at, log=log)
|
|
114
|
+
await self.logs.agent(agent).append(ISSUED, data.written())
|
|
115
|
+
return issued
|
|
116
|
+
|
|
117
|
+
async def status_of(self, env: Env, agent: str, code: str) -> IssuedCode | None:
|
|
118
|
+
"""The code as it stands, once more after it expired; None for one nobody issued."""
|
|
119
|
+
issued = await self._found(agent, code)
|
|
120
|
+
await self._swept(agent, time.time())
|
|
121
|
+
return issued if issued is not None and issued.env == env else None
|
|
122
|
+
|
|
123
|
+
# Listened for before the code is read again, so a claim between the two still wakes it.
|
|
124
|
+
async def waited(self, issued: IssuedCode, within_s: float) -> IssuedCode:
|
|
125
|
+
"""Wait up to that long for a call to key the code, and say how it stands."""
|
|
126
|
+
channel = TAKEN_CHANNEL.format(agent=issued.agent, code=issued.code)
|
|
127
|
+
if within_s > 0:
|
|
128
|
+
with contextlib.suppress(NotAvailable):
|
|
129
|
+
taken = await self.logs.relay.signal.subscribe(channel)
|
|
130
|
+
try:
|
|
131
|
+
now = await self._found(issued.agent, issued.code)
|
|
132
|
+
if now is not None and now.claimed is None:
|
|
133
|
+
with contextlib.suppress(TimeoutError, StopAsyncIteration):
|
|
134
|
+
await asyncio.wait_for(anext(taken), within_s)
|
|
135
|
+
finally:
|
|
136
|
+
taken.close()
|
|
137
|
+
return await self._found(issued.agent, issued.code) or issued
|
|
138
|
+
|
|
139
|
+
async def claim(self, env: Env, agent: str, code: str, call: str) -> IssuedCode | None:
|
|
140
|
+
"""Tie the code to the call that keyed it; None if nobody's, dead, taken, or a 4th try."""
|
|
141
|
+
await self._swept(agent, time.time())
|
|
142
|
+
now = time.time()
|
|
143
|
+
wanted = {"agent": agent, "code": code, "env": env, "call": call, "now": now}
|
|
144
|
+
# independent: a sweep of old counts, a try counted (a try refused stays counted), the take
|
|
145
|
+
async with self.pool.connection() as connection:
|
|
146
|
+
await connection.execute(TRIES_GONE, {"since": now - A_DAY_S})
|
|
147
|
+
tried = await (await connection.execute(TRIED, wanted)).fetchone()
|
|
148
|
+
if tried is None or int(tried["tries"]) > TRIES_PER_CALL:
|
|
149
|
+
return None
|
|
150
|
+
row = await (await connection.execute(TAKEN, wanted)).fetchone()
|
|
151
|
+
if row is None:
|
|
152
|
+
return None
|
|
153
|
+
await self.logs.agent(agent).append(CLAIMED, CodeClaimed(code=code, call=call).written())
|
|
154
|
+
self.logs.relay.signal.publish(TAKEN_CHANNEL.format(agent=agent, code=code), b"taken")
|
|
155
|
+
return _issued(row)
|
|
156
|
+
|
|
157
|
+
async def _kept(self, env: Env, agent: str, expires_at: float, log: Projection) -> IssuedCode:
|
|
158
|
+
while True:
|
|
159
|
+
issued = IssuedCode(_drawn(), env, agent, expires_at, log)
|
|
160
|
+
row = {"agent": agent, "code": issued.code, "env": env, "log": log}
|
|
161
|
+
try:
|
|
162
|
+
async with self.pool.connection() as connection:
|
|
163
|
+
await connection.execute(KEPT, {**row, "expires_at": expires_at})
|
|
164
|
+
except UniqueViolation:
|
|
165
|
+
continue
|
|
166
|
+
return issued
|
|
167
|
+
|
|
168
|
+
async def _found(self, agent: str, code: str) -> IssuedCode | None:
|
|
169
|
+
async with self.pool.connection() as connection:
|
|
170
|
+
row = await (await connection.execute(FOUND, {"agent": agent, "code": code})).fetchone()
|
|
171
|
+
return None if row is None else _issued(row)
|
|
172
|
+
|
|
173
|
+
# Swept as codes are asked for, not by a loop of its own.
|
|
174
|
+
async def _swept(self, agent: str, now: float) -> None:
|
|
175
|
+
async with self.pool.connection() as connection:
|
|
176
|
+
expired = await connection.execute(EXPIRED, {"agent": agent, "now": now})
|
|
177
|
+
rows = await expired.fetchall()
|
|
178
|
+
for row in rows:
|
|
179
|
+
if row["claimed"] is None:
|
|
180
|
+
closed = CodeClaimed(code=str(row["code"]), call=None).written()
|
|
181
|
+
await self.logs.agent(agent).append(CLAIMED, closed)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _issued(row: DictRow) -> IssuedCode:
|
|
185
|
+
return IssuedCode(
|
|
186
|
+
code=str(row["code"]),
|
|
187
|
+
env="sandbox" if row["env"] == "sandbox" else "production",
|
|
188
|
+
agent=str(row["agent"]),
|
|
189
|
+
expires_at=float(row["expires_at"]),
|
|
190
|
+
log="tenant" if row["log"] == "tenant" else "public",
|
|
191
|
+
claimed=row["claimed"],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _drawn() -> str:
|
|
196
|
+
return f"{secrets.randbelow(10**DIGITS):0{DIGITS}d}"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Consent and the do-not-call list: facts about a number, never changed, the newest standing."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from psycopg.rows import DictRow
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
10
|
+
from pinecall.domain.names import parse_e164
|
|
11
|
+
from pinecall.domain.scope import Scope
|
|
12
|
+
from pinecall.postgres.pool import Pool
|
|
13
|
+
from pinecall.wire.rest.numbers import ConsentHistory, ConsentKind, ConsentRow, DoNotCall, OptedOut
|
|
14
|
+
|
|
15
|
+
type Standing = Literal["consented", "opted_out", "unknown"]
|
|
16
|
+
|
|
17
|
+
GIVE = """
|
|
18
|
+
INSERT INTO contact_consents (org, env, number, kind, source, text, evidence, given_by, call)
|
|
19
|
+
VALUES (%(org)s, %(env)s, %(number)s, %(kind)s, %(source)s, %(text)s, %(evidence)s,
|
|
20
|
+
%(given_by)s, %(call)s)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
HISTORY = """
|
|
24
|
+
SELECT kind, source, text, evidence, given_by, call, given_at FROM contact_consents
|
|
25
|
+
WHERE org = %(org)s AND env = %(env)s AND number = %(number)s
|
|
26
|
+
ORDER BY given_at DESC, id DESC
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
NEWEST = """
|
|
30
|
+
SELECT kind FROM contact_consents
|
|
31
|
+
WHERE org = %(org)s AND env = %(env)s AND number = %(number)s
|
|
32
|
+
ORDER BY given_at DESC, id DESC LIMIT 1
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# The numbers whose newest row is an opt-out, newest row first, after the row id a page ended on.
|
|
36
|
+
OPTED_OUT = """
|
|
37
|
+
SELECT id, number, given_at, source, given_by FROM (
|
|
38
|
+
SELECT DISTINCT ON (number) number, kind, given_at, source, given_by, id
|
|
39
|
+
FROM contact_consents WHERE org = %(org)s AND env = %(env)s
|
|
40
|
+
ORDER BY number, given_at DESC, id DESC
|
|
41
|
+
) newest
|
|
42
|
+
WHERE kind = 'opt_out' AND (%(after)s::bigint IS NULL OR id < %(after)s)
|
|
43
|
+
ORDER BY id DESC
|
|
44
|
+
LIMIT %(limit)s
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
OPTED_OUT_ON = """
|
|
48
|
+
SELECT EXISTS (SELECT 1 FROM contact_consents WHERE call = %(call)s AND kind = 'opt_out') AS opted
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
NOT_A_CURSOR = "{after} is no cursor of this list: the last page said it"
|
|
52
|
+
|
|
53
|
+
A_PAGE = 200
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Given:
|
|
58
|
+
"""A fact about a number, as it is written down."""
|
|
59
|
+
|
|
60
|
+
kind: ConsentKind
|
|
61
|
+
source: str
|
|
62
|
+
given_by: str
|
|
63
|
+
text: str | None = None
|
|
64
|
+
evidence: str | None = None
|
|
65
|
+
call: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def give(pool: Pool, scope: Scope, number: str, given: Given) -> None:
|
|
69
|
+
"""Write one fact about the number in the scope's world."""
|
|
70
|
+
params = {"org": scope.org, "env": scope.env, "number": parse_e164(number), **vars(given)}
|
|
71
|
+
async with pool.connection() as connection:
|
|
72
|
+
await connection.execute(GIVE, params)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def standing_of(pool: Pool, scope: Scope, number: str) -> Standing:
|
|
76
|
+
"""What stands for the number: its newest row's, or unknown when there is none."""
|
|
77
|
+
params = {"org": scope.org, "env": scope.env, "number": number.strip()}
|
|
78
|
+
async with pool.connection() as connection:
|
|
79
|
+
row = await (await connection.execute(NEWEST, params)).fetchone()
|
|
80
|
+
return _as_standing(None if row is None else str(row["kind"]))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def history(pool: Pool, scope: Scope, number: str) -> ConsentHistory:
|
|
84
|
+
"""Every fact about the number, newest first, and what stands."""
|
|
85
|
+
kept = parse_e164(number)
|
|
86
|
+
params = {"org": scope.org, "env": scope.env, "number": kept}
|
|
87
|
+
async with pool.connection() as connection:
|
|
88
|
+
rows = await (await connection.execute(HISTORY, params)).fetchall()
|
|
89
|
+
listed = [_row(row) for row in rows]
|
|
90
|
+
return ConsentHistory(
|
|
91
|
+
number=kept,
|
|
92
|
+
standing=_as_standing(listed[0].kind if listed else None),
|
|
93
|
+
rows=listed,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def do_not_call(
|
|
98
|
+
pool: Pool, scope: Scope, *, after: str | None = None, limit: int = A_PAGE
|
|
99
|
+
) -> DoNotCall:
|
|
100
|
+
"""The world's do-not-call list, newest first, a page after the cursor."""
|
|
101
|
+
params = {"org": scope.org, "env": scope.env, "after": _cursor(after), "limit": limit}
|
|
102
|
+
async with pool.connection() as connection:
|
|
103
|
+
rows = await (await connection.execute(OPTED_OUT, params)).fetchall()
|
|
104
|
+
numbers = [
|
|
105
|
+
OptedOut(
|
|
106
|
+
number=str(row["number"]),
|
|
107
|
+
since=_epoch(row["given_at"]),
|
|
108
|
+
source=str(row["source"]),
|
|
109
|
+
given_by=str(row["given_by"]),
|
|
110
|
+
)
|
|
111
|
+
for row in rows
|
|
112
|
+
]
|
|
113
|
+
last = rows[-1] if len(rows) == limit else None
|
|
114
|
+
return DoNotCall(numbers=numbers, next=None if last is None else str(last["id"]))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def opted_out_on(pool: Pool, call: str) -> bool:
|
|
118
|
+
"""Whether a number went on the do-not-call list on this call."""
|
|
119
|
+
async with pool.connection() as connection:
|
|
120
|
+
row = await (await connection.execute(OPTED_OUT_ON, {"call": call})).fetchone()
|
|
121
|
+
return row is not None and bool(row["opted"])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def opt_out_many(
|
|
125
|
+
pool: Pool, scope: Scope, numbers: list[str], given: Given
|
|
126
|
+
) -> tuple[int, list[str]]:
|
|
127
|
+
"""Put every number that is one on the list, in one transaction; how many, and the refused."""
|
|
128
|
+
kept: list[str] = []
|
|
129
|
+
refused: list[str] = []
|
|
130
|
+
for number in numbers:
|
|
131
|
+
try:
|
|
132
|
+
kept.append(parse_e164(number))
|
|
133
|
+
except DeclarationRefused:
|
|
134
|
+
refused.append(number)
|
|
135
|
+
rows = [
|
|
136
|
+
{"org": scope.org, "env": scope.env, "number": number, **vars(given)} for number in kept
|
|
137
|
+
]
|
|
138
|
+
async with (
|
|
139
|
+
pool.connection() as connection,
|
|
140
|
+
connection.transaction(),
|
|
141
|
+
connection.cursor() as cursor,
|
|
142
|
+
):
|
|
143
|
+
await cursor.executemany(GIVE, rows)
|
|
144
|
+
return len(kept), refused
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _as_standing(kind: str | None) -> Standing:
|
|
148
|
+
if kind is None:
|
|
149
|
+
return "unknown"
|
|
150
|
+
return "opted_out" if kind == "opt_out" else "consented"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _row(row: DictRow) -> ConsentRow:
|
|
154
|
+
return ConsentRow.model_validate({**row, "given_at": _epoch(row["given_at"])})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _epoch(at: datetime) -> float:
|
|
158
|
+
return at.timestamp()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cursor(after: str | None) -> int | None:
|
|
162
|
+
if after is None:
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
return int(after)
|
|
166
|
+
except ValueError:
|
|
167
|
+
raise DeclarationRefused(NOT_A_CURSOR.format(after=after)) from None
|