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,23 @@
|
|
|
1
|
+
"""A call a gateway learns of from another: served here from what was kept when it opened."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from pinecall.gateway._served import Served, Serving
|
|
6
|
+
from pinecall.gateway.calls.serving import first_seen
|
|
7
|
+
from pinecall.log import openings, queries
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# None for a call nobody claimed, one sealed, or one opened by a release that kept no opening.
|
|
11
|
+
async def known_here(serving: Serving, call: str) -> Served | None:
|
|
12
|
+
"""The call as this gateway serves it, first seen here from its opening when need be."""
|
|
13
|
+
served = serving.live.calls.get(call)
|
|
14
|
+
if served is not None:
|
|
15
|
+
return served
|
|
16
|
+
pool = serving.connections.pool
|
|
17
|
+
kept = await queries.scope_of_call(pool, call)
|
|
18
|
+
if kept is None or kept.scope is None or kept.sealed:
|
|
19
|
+
return None
|
|
20
|
+
opening = await openings.opening_of(pool, call)
|
|
21
|
+
if opening is None:
|
|
22
|
+
return None
|
|
23
|
+
return first_seen(serving, opening.context, opening.config, kept, time.monotonic())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Who runs each written call and holds each WhatsApp thread, across the gateways of a box."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
|
|
7
|
+
from pinecall.process.shared import Shared
|
|
8
|
+
from pinecall.process.signal import Signal
|
|
9
|
+
|
|
10
|
+
# Each gateway says the written calls it runs and the threads it holds here, merged by each.
|
|
11
|
+
OWNED_CHANNEL = "owned"
|
|
12
|
+
|
|
13
|
+
# Where a gateway hands a WhatsApp message on to the one holding its contact's thread.
|
|
14
|
+
THREAD_CHANNEL = "thread:{gateway}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Owned:
|
|
19
|
+
"""One gateway's share: the written calls whose sessions run there, the threads it holds."""
|
|
20
|
+
|
|
21
|
+
texts: tuple[str, ...] = ()
|
|
22
|
+
threads: tuple[str, ...] = ()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# A session and a thread live where they were opened: another gateway takes one up only once its
|
|
26
|
+
# owner fell silent, when the owner is forgotten (process/shared.py).
|
|
27
|
+
class Owners:
|
|
28
|
+
"""This gateway's written calls and threads, and every other gateway's as it says them."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, signal: Signal) -> None:
|
|
31
|
+
"""Nothing run and nothing held yet."""
|
|
32
|
+
self.shared = Shared(signal, OWNED_CHANNEL, _OWNED, Owned(), self._merged)
|
|
33
|
+
self.texts: set[str] = set()
|
|
34
|
+
self.threads: set[str] = set()
|
|
35
|
+
self._texts_elsewhere: set[str] = set()
|
|
36
|
+
# Each thread another live gateway holds, and that gateway's name on the signal.
|
|
37
|
+
self.threads_elsewhere: dict[str, str] = {}
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def id(self) -> str:
|
|
41
|
+
"""This gateway's name on the signal."""
|
|
42
|
+
return self.shared.id
|
|
43
|
+
|
|
44
|
+
async def start(self) -> None:
|
|
45
|
+
"""Hear the other gateways, and say what runs here."""
|
|
46
|
+
await self.shared.start()
|
|
47
|
+
|
|
48
|
+
async def close(self) -> None:
|
|
49
|
+
"""Stop hearing and saying."""
|
|
50
|
+
await self.shared.close()
|
|
51
|
+
|
|
52
|
+
def running(self, call: str, *, here: bool) -> None:
|
|
53
|
+
"""A written call's session started here, or ended."""
|
|
54
|
+
if (call in self.texts) == here:
|
|
55
|
+
return
|
|
56
|
+
if here:
|
|
57
|
+
self.texts.add(call)
|
|
58
|
+
else:
|
|
59
|
+
self.texts.discard(call)
|
|
60
|
+
self._say()
|
|
61
|
+
|
|
62
|
+
def holding(self, thread: str, *, here: bool) -> None:
|
|
63
|
+
"""A contact's thread opened here, or closed."""
|
|
64
|
+
if (thread in self.threads) == here:
|
|
65
|
+
return
|
|
66
|
+
if here:
|
|
67
|
+
self.threads.add(thread)
|
|
68
|
+
else:
|
|
69
|
+
self.threads.discard(thread)
|
|
70
|
+
self._say()
|
|
71
|
+
|
|
72
|
+
def text_elsewhere(self, call: str) -> bool:
|
|
73
|
+
"""Whether a live gateway other than this one runs the written call."""
|
|
74
|
+
return call in self._texts_elsewhere
|
|
75
|
+
|
|
76
|
+
def _say(self) -> None:
|
|
77
|
+
self.shared.put(Owned(texts=tuple(sorted(self.texts)), threads=tuple(sorted(self.threads))))
|
|
78
|
+
|
|
79
|
+
def _merged(self) -> None:
|
|
80
|
+
heard = self.shared.theirs.items()
|
|
81
|
+
self._texts_elsewhere = {call for _, share in heard for call in share.share.texts}
|
|
82
|
+
self.threads_elsewhere = {
|
|
83
|
+
thread: gateway for gateway, share in heard for thread in share.share.threads
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_OWNED: TypeAdapter[Owned] = TypeAdapter(Owned)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A bound call's pump: what any gateway writes to it, sent down its app socket in seq order."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
5
|
+
|
|
6
|
+
from pinecall.log.logs import Log
|
|
7
|
+
from pinecall.wire.frames import Entry
|
|
8
|
+
|
|
9
|
+
type Send = Callable[[Entry], Awaitable[None]]
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# What a stream says of itself, never sent to an app.
|
|
14
|
+
MARKERS = frozenset({"log.caught_up", "log.gap"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# The app runs the call's tools and holds its state: it is sent them as they were written, from
|
|
18
|
+
# `after`; `then` goes down right after the first entry (the tools still waiting, after
|
|
19
|
+
# call.attached). The stream follows the log through the relay, so an entry another gateway
|
|
20
|
+
# wrote reaches the socket here, and it ends when the call does.
|
|
21
|
+
async def pumped(log: Log, after: int, send: Send, then: Sequence[Entry]) -> None:
|
|
22
|
+
"""Send the call's entries to its socket until the call ends or the socket stops taking them."""
|
|
23
|
+
waiting = list(then)
|
|
24
|
+
try:
|
|
25
|
+
async for entry in log.stream(after=after):
|
|
26
|
+
if entry.type in MARKERS:
|
|
27
|
+
continue
|
|
28
|
+
await send(log.opened(entry))
|
|
29
|
+
for tool_call in waiting:
|
|
30
|
+
await send(log.opened(tool_call))
|
|
31
|
+
waiting = []
|
|
32
|
+
except (OSError, RuntimeError):
|
|
33
|
+
logger.warning("an app socket stopped taking its call's entries", exc_info=True)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""A call served here, as a door reaches it: its socket, set up, first seen, looked up, claimed."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.agent import AgentConfig
|
|
10
|
+
from pinecall.domain.call import CallContext
|
|
11
|
+
from pinecall.domain.names import CHANNELS_WITH_A_NUMBER, JsonObject
|
|
12
|
+
from pinecall.domain.scope import Scope
|
|
13
|
+
from pinecall.gateway._served import Served, Serving
|
|
14
|
+
from pinecall.gateway._sockets import Registration, SocketId, Sockets, orgs_own
|
|
15
|
+
from pinecall.log import queries
|
|
16
|
+
from pinecall.log.logs import Log, arrival_entry
|
|
17
|
+
from pinecall.log.private import Privacy
|
|
18
|
+
from pinecall.log.queries import CallScope
|
|
19
|
+
from pinecall.retrieval import lookups
|
|
20
|
+
from pinecall.retrieval.lookups import OnTheCall
|
|
21
|
+
from pinecall.session.tools import ToolCalls
|
|
22
|
+
from pinecall.tenancy import admission
|
|
23
|
+
from pinecall.tenancy.codes import Codes
|
|
24
|
+
from pinecall.wire.events import CallClaimed
|
|
25
|
+
from pinecall.wire.rest.calls import LookupRequest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# A call opened with a key goes to that key's scope; one that rang, to its phone owner, or the line.
|
|
29
|
+
def serving_agent(
|
|
30
|
+
sockets: Sockets, scope: Scope, agent: str, app: SocketId | None, context: CallContext
|
|
31
|
+
) -> Registration | None:
|
|
32
|
+
"""The socket a new call of the agent reaches."""
|
|
33
|
+
if app is None and context.route.channel in CHANNELS_WITH_A_NUMBER:
|
|
34
|
+
return sockets.taking(orgs_own(scope), agent, context.caller)
|
|
35
|
+
return sockets.serving(scope, agent, app)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def served_call(
|
|
39
|
+
serving: Serving,
|
|
40
|
+
owner: SocketId | None,
|
|
41
|
+
context: CallContext,
|
|
42
|
+
config: AgentConfig,
|
|
43
|
+
scope: Scope,
|
|
44
|
+
) -> Served:
|
|
45
|
+
"""Serve the call to its socket, or park it for the next one; before its first entry."""
|
|
46
|
+
log = serving.logs.writing(context.call, config.slug)
|
|
47
|
+
log.privacy = Privacy(serving.connections.vault, config)
|
|
48
|
+
served = Served(
|
|
49
|
+
agent=config.slug,
|
|
50
|
+
scope=scope,
|
|
51
|
+
app=owner,
|
|
52
|
+
log=log,
|
|
53
|
+
commands=asyncio.Queue(),
|
|
54
|
+
context=context,
|
|
55
|
+
config=config,
|
|
56
|
+
tools=ToolCalls(
|
|
57
|
+
config,
|
|
58
|
+
log,
|
|
59
|
+
partial(queries.tool_answered, serving.connections.pool, context.call),
|
|
60
|
+
partial(queries.tool_called, serving.connections.pool, context.call),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
serving.live.serve(served)
|
|
64
|
+
return serving.live.calls[context.call]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Parked and not counted: the socket holding its agent and the quota are its opener's.
|
|
68
|
+
def first_seen(
|
|
69
|
+
serving: Serving, context: CallContext, config: AgentConfig, kept: CallScope, now: float
|
|
70
|
+
) -> Served:
|
|
71
|
+
"""Serve a call another gateway opened, from what was kept when it opened."""
|
|
72
|
+
serving.live.idle(now)
|
|
73
|
+
served = served_call(
|
|
74
|
+
serving, None, context, config, kept.scope or Scope(context.route.org, context.env)
|
|
75
|
+
)
|
|
76
|
+
served = replace(served, opened_here=False, opened_by=kept.opened_by)
|
|
77
|
+
serving.live.calls[served.call] = served
|
|
78
|
+
serving.live.seen[served.call] = now
|
|
79
|
+
return served
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def opened(log: Log, context: CallContext, agent: str) -> None:
|
|
83
|
+
"""call.ringing for an inbound call; an outbound one has its call.dialing already."""
|
|
84
|
+
if context.direction == "outbound":
|
|
85
|
+
return
|
|
86
|
+
kind, data = arrival_entry(context, context.route.number or agent)
|
|
87
|
+
await log.append(kind, data)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# The quotas are read per lookup, so a plan changed mid-call applies from the next turn.
|
|
91
|
+
async def looked_up(serving: Serving, served: Served, request: LookupRequest) -> JsonObject:
|
|
92
|
+
"""Recall or search for a call served here, written on its log, answered as the model reads."""
|
|
93
|
+
pool = serving.connections.pool
|
|
94
|
+
quotas = await admission.quotas_of(pool, served.scope.org, served.scope.env)
|
|
95
|
+
on_the_call = OnTheCall(
|
|
96
|
+
scope=served.scope,
|
|
97
|
+
context=served.context,
|
|
98
|
+
config=served.config,
|
|
99
|
+
log=served.log,
|
|
100
|
+
now=now_of(serving),
|
|
101
|
+
)
|
|
102
|
+
return await lookups.lookup(pool, serving.embedder, on_the_call, request, quotas=quotas)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def claim_code(
|
|
106
|
+
codes: Codes, served: Served, code: str, *, via: Literal["keypad", "agent"]
|
|
107
|
+
) -> bool:
|
|
108
|
+
"""Tie the call to the page showing the code; False when no page waits on it."""
|
|
109
|
+
issued = await codes.claim(served.scope.env, served.agent, code, served.call)
|
|
110
|
+
if issued is None:
|
|
111
|
+
return False
|
|
112
|
+
await served.log.append("call.claimed", CallClaimed(code=code, via=via).written())
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def now_of(serving: Serving) -> datetime:
|
|
117
|
+
"""This moment by the store's clock, the one every entry of a call is stamped with."""
|
|
118
|
+
return datetime.fromtimestamp(serving.logs.store.clock(), UTC)
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""The WhatsApp threads this gateway keeps open, one per contact; their doors are api/threads.py."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import TypeAdapter
|
|
9
|
+
|
|
10
|
+
from pinecall.channels import routes, whatsapp
|
|
11
|
+
from pinecall.channels.whatsapp import IDLE_S, WINDOW_S, Inbound, Waiting
|
|
12
|
+
from pinecall.domain.call import CallContext, Contact, Route, new_call_id, today_in
|
|
13
|
+
from pinecall.domain.errors import NotAvailable, PinecallError, QuotaExhausted, UpstreamFailed
|
|
14
|
+
from pinecall.domain.scope import Scope
|
|
15
|
+
from pinecall.gateway._call_setup import exhausted
|
|
16
|
+
from pinecall.gateway._served import Serving
|
|
17
|
+
from pinecall.gateway._sockets import Registration, Sockets
|
|
18
|
+
from pinecall.gateway._text_calls import open_text, resume_text, tokens_of
|
|
19
|
+
from pinecall.gateway.calls.owners import THREAD_CHANNEL
|
|
20
|
+
from pinecall.log import inbox
|
|
21
|
+
from pinecall.postgres.pool import Pool, box_task
|
|
22
|
+
from pinecall.session import text
|
|
23
|
+
from pinecall.session.session import Session
|
|
24
|
+
from pinecall.tenancy import admission
|
|
25
|
+
from pinecall.wire.events import ErrorEvent
|
|
26
|
+
from pinecall.wire.frames import Entry
|
|
27
|
+
|
|
28
|
+
# One conversation: the number written to, and who wrote.
|
|
29
|
+
type Door = tuple[str, str, str, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
NOT_TEXT = "whatsapp: a %s to %s was dropped: only text is read"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
NOBODY_AT = "whatsapp: nobody answers %s: `pinecall numbers import %s --channel whatsapp` routes it"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
NOT_ANSWERED = "whatsapp: agent %s cannot answer at %s: %s"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
KEPT = "whatsapp: nobody holds agent %s, so a message to %s waits for somebody"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
EXPIRED = "whatsapp: a message to agent %s outlived Meta's window unanswered"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
AGAIN = "whatsapp: message %s delivered again, dropped: %s"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
TURN_FAILED = "whatsapp: a turn of call %s failed and was not answered"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
NOT_SENT = "whatsapp_not_sent"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
HANDED = "whatsapp: a message for %s was handed to the gateway holding its thread"
|
|
60
|
+
|
|
61
|
+
NOT_HANDED = "whatsapp: a message handed on by another gateway was not read: %s"
|
|
62
|
+
|
|
63
|
+
# How long the listener for messages handed on waits for the signal before listening again.
|
|
64
|
+
HANDED_RETRY_S = 1.0
|
|
65
|
+
|
|
66
|
+
_INBOUND: TypeAdapter[Inbound] = TypeAdapter(Inbound)
|
|
67
|
+
|
|
68
|
+
# However the call ends, the conversation is over with it: a written call a supervisor ends
|
|
69
|
+
# only says so, and its session is closed here.
|
|
70
|
+
OVER = {"call.ended", "supervisor.ended"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class OpenThread:
|
|
75
|
+
"""One contact's open conversation: its session, the token it replies with, what is unread."""
|
|
76
|
+
|
|
77
|
+
session: Session
|
|
78
|
+
inbound: Inbound
|
|
79
|
+
http: httpx.AsyncClient
|
|
80
|
+
token: str
|
|
81
|
+
# A None is the call over: the desk ended it, or it was hung up some other way.
|
|
82
|
+
heard: asyncio.Queue[str | None] = field(default_factory=asyncio.Queue[str | None])
|
|
83
|
+
served: asyncio.Task[None] | None = None
|
|
84
|
+
|
|
85
|
+
# The model's turns, the agent's `say` and a supervisor's reach the contact on one path.
|
|
86
|
+
async def replied(self, entry: Entry) -> None:
|
|
87
|
+
"""Send a durable turn.agent to the contact; Meta's refusal is an error on the call."""
|
|
88
|
+
if entry.type in OVER:
|
|
89
|
+
self.heard.put_nowait(None)
|
|
90
|
+
return
|
|
91
|
+
answer = entry.data.get("text")
|
|
92
|
+
if (
|
|
93
|
+
entry.type != "turn.agent"
|
|
94
|
+
or entry.ephemeral
|
|
95
|
+
or not isinstance(answer, str)
|
|
96
|
+
or not answer
|
|
97
|
+
):
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
await whatsapp.send_text(self.http, self.token, self.inbound, answer)
|
|
101
|
+
except (UpstreamFailed, httpx.HTTPError) as refused:
|
|
102
|
+
error = ErrorEvent(code=NOT_SENT, message=str(refused), recoverable=True)
|
|
103
|
+
# Queued, not awaited: the queue is the one appending the entry this tap runs on.
|
|
104
|
+
self.session.call.writing.write("error", error)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Threads:
|
|
108
|
+
"""The open WhatsApp threads of this process, keyed by org, world, number and contact."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, serving: Serving, sockets: Sockets) -> None:
|
|
111
|
+
"""No conversation open and nothing waiting until `loaded` reads the logs."""
|
|
112
|
+
self.serving = serving
|
|
113
|
+
self.sockets = sockets
|
|
114
|
+
self.idle_s: float = IDLE_S
|
|
115
|
+
self.open: dict[Door, OpenThread] = {}
|
|
116
|
+
self.waiting: list[Waiting] = []
|
|
117
|
+
self.answering_now: set[asyncio.Task[None]] = set()
|
|
118
|
+
self.handed: asyncio.Task[None] | None = None
|
|
119
|
+
|
|
120
|
+
async def start(self) -> None:
|
|
121
|
+
"""Take the messages other gateways hand on for the threads held here."""
|
|
122
|
+
self.handed = box_task(self._handed_here())
|
|
123
|
+
|
|
124
|
+
# Meta delivers a message again when it thinks it unanswered. The row is claimed before the
|
|
125
|
+
# message is read, so of two deliveries at once only one reads it; it is marked read once the
|
|
126
|
+
# message is queued or kept, and a reading that fails gives the claim back, so Meta's next
|
|
127
|
+
# delivery reads it again. A process that dies mid-reading leaves a claim its lease expires.
|
|
128
|
+
async def received(self, inbound: Inbound) -> bool:
|
|
129
|
+
"""Read the message once, onto its contact's conversation; False while another reads it."""
|
|
130
|
+
route = await _route_of(self.serving.connections.pool, inbound)
|
|
131
|
+
if route is None:
|
|
132
|
+
return True
|
|
133
|
+
pool = self.serving.connections.pool
|
|
134
|
+
now = self.serving.logs.store.clock()
|
|
135
|
+
claim = await whatsapp.claimed(pool, route.org, inbound.message_id, now)
|
|
136
|
+
if claim != "new":
|
|
137
|
+
logger.info(AGAIN, inbound.message_id, claim)
|
|
138
|
+
return claim == "seen"
|
|
139
|
+
try:
|
|
140
|
+
await self._heard(route, inbound)
|
|
141
|
+
except Exception:
|
|
142
|
+
await whatsapp.released(pool, route.org, inbound.message_id)
|
|
143
|
+
raise
|
|
144
|
+
await whatsapp.read(pool, route.org, inbound.message_id, now)
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
# Returns once the message is queued or kept: Meta sends a slow webhook again.
|
|
148
|
+
# A contact's thread is held by one gateway: a message landing on another is handed to it,
|
|
149
|
+
# and taken here only when nobody holds it, or its holder no longer listens.
|
|
150
|
+
async def _heard(self, route: Route, inbound: Inbound) -> None:
|
|
151
|
+
scope = Scope(route.org, route.env)
|
|
152
|
+
registration = self.sockets.taking(scope, route.agent, inbound.caller)
|
|
153
|
+
door = _door(registration.scope if registration else scope, inbound)
|
|
154
|
+
thread = self.open.get(door)
|
|
155
|
+
holder = self.serving.live.owners.threads_elsewhere.get("|".join(door))
|
|
156
|
+
if thread is None and holder is not None and await self._handed_to(holder, inbound):
|
|
157
|
+
logger.info(HANDED, inbound.number)
|
|
158
|
+
return
|
|
159
|
+
if thread is None and registration is None:
|
|
160
|
+
self.waiting.append(await whatsapp.kept(self.serving.logs, route, inbound))
|
|
161
|
+
logger.warning(KEPT, route.agent, inbound.number)
|
|
162
|
+
return
|
|
163
|
+
if thread is None and registration is not None:
|
|
164
|
+
thread = await self._opened(registration, route, inbound)
|
|
165
|
+
if thread is not None:
|
|
166
|
+
thread.heard.put_nowait(inbound.text)
|
|
167
|
+
|
|
168
|
+
# Off the app socket's own loop: opening a conversation may wait on the model.
|
|
169
|
+
def answering(self, registration: Registration) -> None:
|
|
170
|
+
"""Answer the messages that waited for this agent, now that somebody holds it."""
|
|
171
|
+
if not any(waiter.agent == registration.slug for waiter in self.waiting):
|
|
172
|
+
return
|
|
173
|
+
task = asyncio.create_task(self._answered(registration))
|
|
174
|
+
self.answering_now.add(task)
|
|
175
|
+
task.add_done_callback(self.answering_now.discard)
|
|
176
|
+
|
|
177
|
+
# Oldest first, so one contact's messages reach their conversation in order.
|
|
178
|
+
async def _answered(self, registration: Registration) -> None:
|
|
179
|
+
now = self.serving.logs.store.clock()
|
|
180
|
+
for waiting in [waiter for waiter in self.waiting if waiter.agent == registration.slug]:
|
|
181
|
+
if waiting.env != registration.scope.env:
|
|
182
|
+
continue
|
|
183
|
+
self.waiting.remove(waiting)
|
|
184
|
+
if now - waiting.received_at > WINDOW_S:
|
|
185
|
+
logger.warning(EXPIRED, waiting.agent)
|
|
186
|
+
await whatsapp.taken(self.serving.logs, waiting, None)
|
|
187
|
+
continue
|
|
188
|
+
# Claimed and read when it was kept: it is heard, not received again.
|
|
189
|
+
route = await _route_of(self.serving.connections.pool, waiting.inbound)
|
|
190
|
+
if route is not None:
|
|
191
|
+
await self._heard(route, waiting.inbound)
|
|
192
|
+
opened = self.open.get(_door(registration.scope, waiting.inbound))
|
|
193
|
+
await whatsapp.taken(
|
|
194
|
+
self.serving.logs,
|
|
195
|
+
waiting,
|
|
196
|
+
None if opened is None else opened.session.call.context.call,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def loaded(self) -> None:
|
|
200
|
+
"""What was waiting when this process started, read back off the agents' logs."""
|
|
201
|
+
self.waiting = await whatsapp.waiting_in(self.serving.logs.store)
|
|
202
|
+
|
|
203
|
+
async def closed(self) -> None:
|
|
204
|
+
"""Stop answering: the threads stay open in their logs for the next process."""
|
|
205
|
+
serving = [value.served for value in self.open.values() if value.served is not None]
|
|
206
|
+
serving += list(self.answering_now)
|
|
207
|
+
serving += [] if self.handed is None else [self.handed]
|
|
208
|
+
for task in serving:
|
|
209
|
+
task.cancel()
|
|
210
|
+
await asyncio.gather(*serving, return_exceptions=True)
|
|
211
|
+
self.open.clear()
|
|
212
|
+
|
|
213
|
+
async def _opened(
|
|
214
|
+
self, registration: Registration, route: Route, inbound: Inbound
|
|
215
|
+
) -> OpenThread | None:
|
|
216
|
+
token = await whatsapp.meta_token_for(
|
|
217
|
+
self.serving.connections.pool,
|
|
218
|
+
self.serving.connections.vault,
|
|
219
|
+
route.org,
|
|
220
|
+
inbound.phone_number_id,
|
|
221
|
+
)
|
|
222
|
+
if token is None:
|
|
223
|
+
logger.warning(NOT_ANSWERED, route.agent, inbound.number, "no Meta token here")
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
taken = await self._taken_up(registration, inbound)
|
|
227
|
+
session = taken or await open_text(
|
|
228
|
+
self.serving, registration, self._context(registration, route, inbound)
|
|
229
|
+
)
|
|
230
|
+
except PinecallError as refused:
|
|
231
|
+
logger.warning(NOT_ANSWERED, route.agent, inbound.number, refused)
|
|
232
|
+
return None
|
|
233
|
+
thread = OpenThread(
|
|
234
|
+
session=session, inbound=inbound, http=self.serving.connections.http, token=token
|
|
235
|
+
)
|
|
236
|
+
# Before the session starts, so every turn the contact is owed reaches them.
|
|
237
|
+
served = self.serving.live.calls[session.call.context.call]
|
|
238
|
+
served.log.tapped(thread.replied)
|
|
239
|
+
if taken is None:
|
|
240
|
+
await session.start()
|
|
241
|
+
door = _door(registration.scope, inbound)
|
|
242
|
+
self.open[door] = thread
|
|
243
|
+
self.serving.live.owners.holding("|".join(door), here=True)
|
|
244
|
+
thread.served = asyncio.create_task(self._served(door, thread))
|
|
245
|
+
return thread
|
|
246
|
+
|
|
247
|
+
async def _handed_to(self, holder: str, inbound: Inbound) -> bool:
|
|
248
|
+
channel = THREAD_CHANNEL.format(gateway=holder)
|
|
249
|
+
try:
|
|
250
|
+
return (
|
|
251
|
+
await self.serving.live.signal.published(channel, _INBOUND.dump_json(inbound)) > 0
|
|
252
|
+
)
|
|
253
|
+
except NotAvailable:
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
# Claimed and read by the gateway it landed on: here it is only heard.
|
|
257
|
+
async def _handed_here(self) -> None:
|
|
258
|
+
signal = self.serving.live.signal
|
|
259
|
+
channel = THREAD_CHANNEL.format(gateway=self.serving.live.owners.id)
|
|
260
|
+
while True:
|
|
261
|
+
try:
|
|
262
|
+
listening = await signal.subscribe(channel)
|
|
263
|
+
except NotAvailable:
|
|
264
|
+
await asyncio.sleep(HANDED_RETRY_S)
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
async for data in listening:
|
|
268
|
+
await self._taken_on(_INBOUND.validate_json(data))
|
|
269
|
+
finally:
|
|
270
|
+
listening.close()
|
|
271
|
+
await asyncio.sleep(HANDED_RETRY_S)
|
|
272
|
+
|
|
273
|
+
async def _taken_on(self, inbound: Inbound) -> None:
|
|
274
|
+
route = await _route_of(self.serving.connections.pool, inbound)
|
|
275
|
+
if route is None:
|
|
276
|
+
return
|
|
277
|
+
try:
|
|
278
|
+
await self._heard(route, inbound)
|
|
279
|
+
except PinecallError as refused:
|
|
280
|
+
logger.warning(NOT_HANDED, refused)
|
|
281
|
+
|
|
282
|
+
# A conversation this process never saw is taken up from its log while it has idle time left.
|
|
283
|
+
async def _taken_up(self, registration: Registration, inbound: Inbound) -> Session | None:
|
|
284
|
+
newest = await inbox.calls_with(
|
|
285
|
+
self.serving.connections.pool,
|
|
286
|
+
registration.scope,
|
|
287
|
+
registration.slug,
|
|
288
|
+
inbound.caller,
|
|
289
|
+
limit=1,
|
|
290
|
+
)
|
|
291
|
+
if not newest:
|
|
292
|
+
return None
|
|
293
|
+
entries = await self.serving.logs.store.whole(newest[0])
|
|
294
|
+
if not entries or self.serving.logs.store.clock() - entries[-1].ts > self.idle_s:
|
|
295
|
+
return None
|
|
296
|
+
return await resume_text(
|
|
297
|
+
self.serving, registration, newest[0], self.serving.connections.settings.timezone
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _context(self, registration: Registration, route: Route, inbound: Inbound) -> CallContext:
|
|
301
|
+
answering = Route(
|
|
302
|
+
org=route.org,
|
|
303
|
+
agent=registration.slug,
|
|
304
|
+
channel="whatsapp",
|
|
305
|
+
number=route.number,
|
|
306
|
+
env=route.env,
|
|
307
|
+
)
|
|
308
|
+
return CallContext(
|
|
309
|
+
call=new_call_id(),
|
|
310
|
+
channel="whatsapp",
|
|
311
|
+
direction="inbound",
|
|
312
|
+
caller=inbound.caller,
|
|
313
|
+
route=answering,
|
|
314
|
+
today=today_in(self.serving.connections.settings.timezone),
|
|
315
|
+
contact=Contact(phone=inbound.caller, name=inbound.name),
|
|
316
|
+
holder=registration.scope.holder or None,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# The idle period starts once every message is answered: a slow turn is never cut off.
|
|
320
|
+
async def _served(self, door: Door, thread: OpenThread) -> None:
|
|
321
|
+
session = thread.session
|
|
322
|
+
scope = session.call.context.route
|
|
323
|
+
try:
|
|
324
|
+
while True:
|
|
325
|
+
try:
|
|
326
|
+
answer = await asyncio.wait_for(thread.heard.get(), timeout=self.idle_s)
|
|
327
|
+
except TimeoutError:
|
|
328
|
+
await text.end(session, "timeout", "platform")
|
|
329
|
+
return
|
|
330
|
+
if answer is None:
|
|
331
|
+
await session.close()
|
|
332
|
+
return
|
|
333
|
+
try:
|
|
334
|
+
await admission.admit_turn(
|
|
335
|
+
self.serving.connections.pool,
|
|
336
|
+
Scope(scope.org, scope.env),
|
|
337
|
+
turns=session.call.turns,
|
|
338
|
+
tokens=tokens_of(session.usage),
|
|
339
|
+
at=self.serving.logs.store.clock(),
|
|
340
|
+
)
|
|
341
|
+
except QuotaExhausted as refused:
|
|
342
|
+
await exhausted(self.serving.logs, scope.org, session.call.config.slug, refused)
|
|
343
|
+
await text.end(session, "timeout", "platform")
|
|
344
|
+
return
|
|
345
|
+
try:
|
|
346
|
+
await text.hears(session, answer)
|
|
347
|
+
except PinecallError:
|
|
348
|
+
logger.warning(TURN_FAILED, session.call.context.call, exc_info=True)
|
|
349
|
+
finally:
|
|
350
|
+
self.open.pop(door, None)
|
|
351
|
+
self.serving.live.owners.holding("|".join(door), here=False)
|
|
352
|
+
self.serving.live.close(session.call.context.call)
|
|
353
|
+
self.serving.logs.forget(session.call.context.call)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _door(scope: Scope, inbound: Inbound) -> Door:
|
|
357
|
+
return (scope.org, scope.env, inbound.number, inbound.wa_id)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _route_of(pool: Pool, inbound: Inbound) -> Route | None:
|
|
361
|
+
if inbound.kind != "text" or not inbound.text:
|
|
362
|
+
logger.warning(NOT_TEXT, inbound.kind or "message", inbound.number)
|
|
363
|
+
return None
|
|
364
|
+
route = await routes.at(pool, "whatsapp", inbound.number)
|
|
365
|
+
if route is None:
|
|
366
|
+
logger.warning(NOBODY_AT, inbound.number, inbound.number)
|
|
367
|
+
return route
|
|
File without changes
|