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