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,448 @@
|
|
|
1
|
+
"""The SFU's side of a number: on its world's LiveKit, a trunk per fence and the world's rule."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from livekit import api
|
|
11
|
+
from livekit.protocol.agent_dispatch import RoomAgentDispatch
|
|
12
|
+
from livekit.protocol.room import RoomConfiguration
|
|
13
|
+
from livekit.protocol.sip import (
|
|
14
|
+
CreateSIPDispatchRuleRequest,
|
|
15
|
+
CreateSIPInboundTrunkRequest,
|
|
16
|
+
DeleteSIPDispatchRuleRequest,
|
|
17
|
+
DeleteSIPTrunkRequest,
|
|
18
|
+
ListSIPDispatchRuleRequest,
|
|
19
|
+
ListSIPInboundTrunkRequest,
|
|
20
|
+
SIPDispatchRule,
|
|
21
|
+
SIPDispatchRuleIndividual,
|
|
22
|
+
SIPDispatchRuleInfo,
|
|
23
|
+
SIPInboundTrunkInfo,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from pinecall.channels import rooms, routes
|
|
27
|
+
from pinecall.channels.rooms import Dispatch
|
|
28
|
+
from pinecall.channels.routes import RouteRecord
|
|
29
|
+
from pinecall.channels.telephony import hand_over
|
|
30
|
+
from pinecall.channels.telephony.carrier import Fence, declared_networks, fence_of, own_networks
|
|
31
|
+
from pinecall.domain.errors import NotAvailable
|
|
32
|
+
from pinecall.domain.names import ENVS, PRODUCTION, SANDBOX, Env, other_world
|
|
33
|
+
from pinecall.fleet import worlds
|
|
34
|
+
from pinecall.process.connections import NO_LIVEKIT, Connections
|
|
35
|
+
from pinecall.tenancy import carrier_networks
|
|
36
|
+
from pinecall.tenancy.carriers import Carrier, carriers_of
|
|
37
|
+
from pinecall.wire.rest.numbers import LegTrunk
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ROOM_PREFIX = "call-"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
NO_DOMAIN = "this box has no PINECALL_DOMAIN: a carrier has nowhere to send a call"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
REBUILT = "SIP rebuilt from the tables: %d numbers stand, %d orgs refused, hand-overs admitted: %s"
|
|
49
|
+
|
|
50
|
+
NOT_WHOLE = "SIP not rebuilt whole (an org refused, or a LiveKit unreachable): again in %.0f s"
|
|
51
|
+
|
|
52
|
+
# A LiveKit still starting beside the gateway answers in seconds; one that is down, in minutes.
|
|
53
|
+
FIRST_WAIT_S = 2.0
|
|
54
|
+
|
|
55
|
+
LONGEST_WAIT_S = 60.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
NO_ADDRESS = "production's SIP name %s names no address: the sandbox admits no hand-over"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# How long the start waits for production's SIP name to say its address.
|
|
62
|
+
RESOLVED_WITHIN_S = 5.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
ROUTED_ORGS = """
|
|
66
|
+
SELECT DISTINCT org FROM routes WHERE channel = 'phone' AND number IS NOT NULL ORDER BY org
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class WorldRule:
|
|
72
|
+
"""An org's world on the SFU: its rule, and the fleet the rule dispatches."""
|
|
73
|
+
|
|
74
|
+
org: str
|
|
75
|
+
env: Env
|
|
76
|
+
fleet: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Fencing:
|
|
81
|
+
"""What fences an org's numbers now: its carriers by id, and the networks approved."""
|
|
82
|
+
|
|
83
|
+
carriers: dict[str, Carrier]
|
|
84
|
+
approved: dict[str, tuple[str, ...]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Rebuilt:
|
|
89
|
+
"""What the reconcile found: numbers existing on the SFU, orgs it could not rebuild."""
|
|
90
|
+
|
|
91
|
+
numbers: int
|
|
92
|
+
refused: list[str]
|
|
93
|
+
# Whether the sandbox's own LiveKit admits the rings production hands over to it.
|
|
94
|
+
hand_over: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def domain_of(connections: Connections) -> str:
|
|
98
|
+
"""The box's name, or NotAvailable: a carrier has nowhere to send a call."""
|
|
99
|
+
name = connections.settings.domain
|
|
100
|
+
if not name:
|
|
101
|
+
raise NotAvailable(NO_DOMAIN)
|
|
102
|
+
return name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sip_domain_of(connections: Connections, world: Env) -> str:
|
|
106
|
+
"""The name a carrier sends that world's calls to, or NotAvailable: it has none."""
|
|
107
|
+
name = connections.settings.sip_name_of(world)
|
|
108
|
+
if not name:
|
|
109
|
+
raise NotAvailable(NO_DOMAIN)
|
|
110
|
+
return name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Both worlds hold the one client when the sandbox has no LiveKit of its own.
|
|
114
|
+
def elsewhere(connections: Connections, world: Env) -> api.LiveKitAPI | None:
|
|
115
|
+
"""The other world's LiveKit when it is not this world's too; None where both share one."""
|
|
116
|
+
other = connections.servers[other_world(world)]
|
|
117
|
+
return None if other is connections.servers[world] else other
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def rule_name(org: str, env: Env) -> str:
|
|
121
|
+
"""The name of an org's dispatch rule in a world."""
|
|
122
|
+
return f"{org}:{env}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# An org id holds no colon, so `{org}` and `{org}:…` can never be another org's.
|
|
126
|
+
def belongs_to(name: str, org: str) -> bool:
|
|
127
|
+
"""Whether a trunk or rule name is the org's."""
|
|
128
|
+
return name == org or name.startswith(f"{org}:")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def trunks_admitting(server: api.LiveKitAPI, number: str) -> list[SIPInboundTrunkInfo]:
|
|
132
|
+
"""Every trunk on the SFU that lists the number."""
|
|
133
|
+
listed = await server.sip.list_inbound_trunk(ListSIPInboundTrunkRequest(numbers=[number]))
|
|
134
|
+
return [trunk for trunk in listed.items if number in trunk.numbers]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def trunk_named(server: api.LiveKitAPI, name: str) -> SIPInboundTrunkInfo | None:
|
|
138
|
+
"""The trunk of that name, or None."""
|
|
139
|
+
listed = await server.sip.list_inbound_trunk(ListSIPInboundTrunkRequest())
|
|
140
|
+
return next((trunk for trunk in listed.items if trunk.name == name), None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def rule_named(server: api.LiveKitAPI, name: str) -> SIPDispatchRuleInfo | None:
|
|
144
|
+
"""The dispatch rule of that name, or None."""
|
|
145
|
+
listed = await server.sip.list_dispatch_rule(ListSIPDispatchRuleRequest())
|
|
146
|
+
return next((rule for rule in listed.items if rule.name == name), None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def admit(
|
|
150
|
+
server: api.LiveKitAPI, fence: Fence, existing: SIPInboundTrunkInfo | None, number: str
|
|
151
|
+
) -> str:
|
|
152
|
+
"""Make the fence's trunk list the number, made or updated; its id."""
|
|
153
|
+
if existing is None:
|
|
154
|
+
info = SIPInboundTrunkInfo(
|
|
155
|
+
name=fence.trunk,
|
|
156
|
+
numbers=[number],
|
|
157
|
+
allowed_addresses=list(fence.networks),
|
|
158
|
+
auth_username=fence.username,
|
|
159
|
+
auth_password=fence.password,
|
|
160
|
+
)
|
|
161
|
+
made = await server.sip.create_inbound_trunk(CreateSIPInboundTrunkRequest(trunk=info))
|
|
162
|
+
return made.sip_trunk_id
|
|
163
|
+
fenced = sorted(existing.allowed_addresses) == sorted(fence.networks)
|
|
164
|
+
if number in existing.numbers and fenced and existing.auth_username == fence.username:
|
|
165
|
+
return existing.sip_trunk_id
|
|
166
|
+
existing.numbers[:] = sorted({*existing.numbers, number})
|
|
167
|
+
existing.allowed_addresses[:] = list(fence.networks)
|
|
168
|
+
existing.auth_username, existing.auth_password = fence.username, fence.password
|
|
169
|
+
await server.sip.update_inbound_trunk(existing.sip_trunk_id, existing)
|
|
170
|
+
return existing.sip_trunk_id
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# A trunk or a rule that lists no number takes every number: one emptied is deleted, and so is
|
|
174
|
+
# a rule left with no trunk.
|
|
175
|
+
async def renumber_trunk(
|
|
176
|
+
server: api.LiveKitAPI, org: str, trunk: SIPInboundTrunkInfo, numbers: list[str]
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Leave the trunk these numbers; emptied, it goes, and so does a rule left with no trunk."""
|
|
179
|
+
if numbers:
|
|
180
|
+
await server.sip.update_inbound_trunk_fields(trunk.sip_trunk_id, numbers=numbers)
|
|
181
|
+
return
|
|
182
|
+
await server.sip.delete_trunk(DeleteSIPTrunkRequest(sip_trunk_id=trunk.sip_trunk_id))
|
|
183
|
+
listed = await server.sip.list_dispatch_rule(ListSIPDispatchRuleRequest())
|
|
184
|
+
for rule in listed.items:
|
|
185
|
+
if not belongs_to(rule.name, org) or trunk.sip_trunk_id not in rule.trunk_ids:
|
|
186
|
+
continue
|
|
187
|
+
kept = [trunk_id for trunk_id in rule.trunk_ids if trunk_id != trunk.sip_trunk_id]
|
|
188
|
+
if not kept:
|
|
189
|
+
await renumber_rule(server, rule, [])
|
|
190
|
+
continue
|
|
191
|
+
rule.trunk_ids[:] = kept
|
|
192
|
+
await server.sip.update_dispatch_rule(rule.sip_dispatch_rule_id, rule)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# One rule per world: its `numbers` are that world's, so a number changes world between two
|
|
196
|
+
# lists and never between trunks. livekit refuses two rules of one trunk listing one number.
|
|
197
|
+
async def rule_in(
|
|
198
|
+
server: api.LiveKitAPI, world: WorldRule, trunks: Sequence[str], number: str
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Have the world's rule list the number and the trunks, made or updated."""
|
|
201
|
+
# A rule with no trunk dispatches every trunk's calls.
|
|
202
|
+
if not trunks:
|
|
203
|
+
return
|
|
204
|
+
name, fleet = rule_name(world.org, world.env), world.fleet
|
|
205
|
+
existing = await rule_named(server, name)
|
|
206
|
+
metadata = rooms.written(Dispatch(org=world.org, env=world.env))
|
|
207
|
+
agent_dispatch = RoomAgentDispatch(agent_name=fleet, metadata=metadata)
|
|
208
|
+
if existing is None:
|
|
209
|
+
info = SIPDispatchRuleInfo(
|
|
210
|
+
name=name,
|
|
211
|
+
trunk_ids=sorted(set(trunks)),
|
|
212
|
+
numbers=[number],
|
|
213
|
+
rule=SIPDispatchRule(
|
|
214
|
+
dispatch_rule_individual=SIPDispatchRuleIndividual(room_prefix=ROOM_PREFIX)
|
|
215
|
+
),
|
|
216
|
+
room_config=RoomConfiguration(agents=[agent_dispatch]),
|
|
217
|
+
)
|
|
218
|
+
await server.sip.create_dispatch_rule(CreateSIPDispatchRuleRequest(dispatch_rule=info))
|
|
219
|
+
return
|
|
220
|
+
wanted_trunks = sorted({*existing.trunk_ids, *trunks})
|
|
221
|
+
fleets = [agent.agent_name for agent in existing.room_config.agents]
|
|
222
|
+
if (
|
|
223
|
+
number in existing.numbers
|
|
224
|
+
and list(existing.trunk_ids) == wanted_trunks
|
|
225
|
+
and fleets == [fleet]
|
|
226
|
+
):
|
|
227
|
+
return
|
|
228
|
+
existing.trunk_ids[:] = wanted_trunks
|
|
229
|
+
existing.numbers[:] = sorted({*existing.numbers, number})
|
|
230
|
+
existing.room_config.CopyFrom(RoomConfiguration(agents=[agent_dispatch]))
|
|
231
|
+
await server.sip.update_dispatch_rule(existing.sip_dispatch_rule_id, existing)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def renumber_rule(
|
|
235
|
+
server: api.LiveKitAPI, rule: SIPDispatchRuleInfo, numbers: list[str]
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Leave the rule these numbers; emptied, it goes."""
|
|
238
|
+
if not numbers:
|
|
239
|
+
gone = DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule.sip_dispatch_rule_id)
|
|
240
|
+
await server.sip.delete_dispatch_rule(gone)
|
|
241
|
+
return
|
|
242
|
+
rule.numbers[:] = numbers
|
|
243
|
+
await server.sip.update_dispatch_rule(rule.sip_dispatch_rule_id, rule)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# The rules are listed after the trunks: a trunk emptied takes a rule left with none with it.
|
|
247
|
+
async def let_go(server: api.LiveKitAPI, org: str, number: str) -> None:
|
|
248
|
+
"""Take the number off the org's trunks and off both of its worlds' rules on this LiveKit."""
|
|
249
|
+
await _unlisted(server, org, number)
|
|
250
|
+
names = {rule_name(org, env) for env in ENVS}
|
|
251
|
+
listed = await server.sip.list_dispatch_rule(ListSIPDispatchRuleRequest())
|
|
252
|
+
for rule in listed.items:
|
|
253
|
+
if rule.name in names and number in rule.numbers:
|
|
254
|
+
await renumber_rule(server, rule, [item for item in rule.numbers if item != number])
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# LiveKit keeps its trunks and rules in Redis, which can be emptied; the tables are the truth.
|
|
258
|
+
# Nothing is deleted but a number the other world's LiveKit still lists, and the carrier's side is
|
|
259
|
+
# never touched.
|
|
260
|
+
async def rebuild(connections: Connections) -> Rebuilt:
|
|
261
|
+
"""Admit every routed phone number again on the SFU, with its fence and its world's rule."""
|
|
262
|
+
async with connections.pool.connection() as connection:
|
|
263
|
+
rows = await (await connection.execute(ROUTED_ORGS)).fetchall()
|
|
264
|
+
readmitted, refused = 0, list[str]()
|
|
265
|
+
for org in (str(row["org"]) for row in rows):
|
|
266
|
+
try:
|
|
267
|
+
readmitted += await readmit(connections, org)
|
|
268
|
+
except (api.TwirpError, httpx.HTTPError):
|
|
269
|
+
logger.warning("the SFU refused org %s's numbers: the others go on", org, exc_info=True)
|
|
270
|
+
refused.append(org)
|
|
271
|
+
try:
|
|
272
|
+
handing = await _hand_overs_admitted(connections)
|
|
273
|
+
except (api.TwirpError, httpx.HTTPError):
|
|
274
|
+
logger.warning("the sandbox's SFU refused the hand-over trunk", exc_info=True)
|
|
275
|
+
handing = False
|
|
276
|
+
logger.info(REBUILT, readmitted, len(refused), handing)
|
|
277
|
+
return Rebuilt(numbers=readmitted, refused=refused, hand_over=handing)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# A gateway started beside a LiveKit that is still starting, or down, would otherwise leave every
|
|
281
|
+
# number of that world unadmitted until its own next start: it tries again until the rebuild is
|
|
282
|
+
# whole, waiting twice as long each time, a minute at most.
|
|
283
|
+
async def rebuilt_until_whole(connections: Connections, wait_s: float = FIRST_WAIT_S) -> Rebuilt:
|
|
284
|
+
"""The rebuild, again after a wait while an org is refused or a LiveKit cannot be reached."""
|
|
285
|
+
while True:
|
|
286
|
+
try:
|
|
287
|
+
rebuilt = await rebuild(connections)
|
|
288
|
+
except (api.TwirpError, httpx.HTTPError, OSError, TimeoutError):
|
|
289
|
+
rebuilt = None
|
|
290
|
+
if rebuilt is not None and not rebuilt.refused:
|
|
291
|
+
return rebuilt
|
|
292
|
+
logger.warning(NOT_WHOLE, wait_s)
|
|
293
|
+
await asyncio.sleep(wait_s)
|
|
294
|
+
wait_s = min(wait_s * 2, LONGEST_WAIT_S)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# After the operator approves or refuses a network: the org's trunks follow what is approved now.
|
|
298
|
+
async def readmit(connections: Connections, org: str) -> int:
|
|
299
|
+
"""Admit the org's phone numbers again with the fences they have now; how many stand."""
|
|
300
|
+
fleets = await worlds.fleets(connections.pool)
|
|
301
|
+
fencing = await fencing_of(connections, org)
|
|
302
|
+
admitted = 0
|
|
303
|
+
for record in await routes.phones_of(connections.pool, org):
|
|
304
|
+
admitted += await readmitted(connections, record, fencing, fleets)
|
|
305
|
+
return admitted
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def fencing_of(connections: Connections, org: str) -> Fencing:
|
|
309
|
+
"""The org's carriers and approved networks, which fence its numbers now."""
|
|
310
|
+
carriers = await carriers_of(connections.pool, connections.vault, org)
|
|
311
|
+
approved = await carrier_networks.approved(connections.pool, org)
|
|
312
|
+
return Fencing(carriers={carrier.id: carrier for carrier in carriers}, approved=approved)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# A number lives on its world's LiveKit alone: the other world's, when it has its own, lets it go
|
|
316
|
+
# (a number moved, or one admitted there before the worlds had a LiveKit each). On its own LiveKit
|
|
317
|
+
# the other world's rule lets it go first: two rules of one trunk never list one number.
|
|
318
|
+
async def readmitted(
|
|
319
|
+
connections: Connections, record: RouteRecord, fencing: Fencing, fleets: worlds.Fleets
|
|
320
|
+
) -> bool:
|
|
321
|
+
"""Admit the number on its world's LiveKit with the fence it has now; whether it stands."""
|
|
322
|
+
org, number, env = record.route.org, str(record.route.number), record.route.env
|
|
323
|
+
server, other = connections.servers[env], elsewhere(connections, env)
|
|
324
|
+
if other is not None:
|
|
325
|
+
await let_go(other, org, number)
|
|
326
|
+
fence = fence_now(record, fencing.carriers.get(record.account or ""), fencing.approved)
|
|
327
|
+
if fence is None:
|
|
328
|
+
await _unlisted(server, org, number)
|
|
329
|
+
return False
|
|
330
|
+
trunk_id = await admit(server, fence, await trunk_named(server, fence.trunk), number)
|
|
331
|
+
left = await rule_named(server, rule_name(org, other_world(env)))
|
|
332
|
+
if left is not None and number in left.numbers:
|
|
333
|
+
await renumber_rule(server, left, [listed for listed in left.numbers if listed != number])
|
|
334
|
+
await rule_in(server, WorldRule(org, env, worlds.fleet_of(fleets, env)), [trunk_id], number)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# Where each world has a LiveKit of its own, a developer's own phone at a production number is
|
|
339
|
+
# dialled from production's room to the sandbox's SIP (hand_over.py), with a pair drawn from
|
|
340
|
+
# LiveKit's secret; where both share one, the sandbox's fleet is sent into the room instead.
|
|
341
|
+
def hand_over_trunk(connections: Connections, caller: str) -> LegTrunk | None:
|
|
342
|
+
"""The trunk a ring is handed to the sandbox's SIP through; None where the worlds share one."""
|
|
343
|
+
if elsewhere(connections, PRODUCTION) is None:
|
|
344
|
+
return None
|
|
345
|
+
return LegTrunk(
|
|
346
|
+
hostname=sip_domain_of(connections, SANDBOX),
|
|
347
|
+
transport="udp",
|
|
348
|
+
username=hand_over.USERNAME,
|
|
349
|
+
password=_hand_over_password(connections),
|
|
350
|
+
shown=caller,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def fence_now(
|
|
355
|
+
record: RouteRecord, carrier: Carrier | None, approved: dict[str, tuple[str, ...]]
|
|
356
|
+
) -> Fence | None:
|
|
357
|
+
"""The fence a routed number has with what is approved now; None while nothing fences it."""
|
|
358
|
+
number = str(record.route.number)
|
|
359
|
+
declared = (record.networks or None) if carrier is None else declared_networks(carrier)
|
|
360
|
+
source = number if carrier is None else carrier.id
|
|
361
|
+
own, _ = own_networks(declared, approved.get(source, ()))
|
|
362
|
+
return fence_of(record.route.org, number, carrier, record.via, own)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def _unlisted(server: api.LiveKitAPI, org: str, number: str) -> None:
|
|
366
|
+
for trunk in await trunks_admitting(server, number):
|
|
367
|
+
if belongs_to(trunk.name, org):
|
|
368
|
+
await renumber_trunk(server, org, trunk, [n for n in trunk.numbers if n != number])
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# The trunk lists no number: it admits whatever production dials, from production's media
|
|
372
|
+
# address alone (the one its SIP name names, and livekit-sip sends from) and with the pair. Its
|
|
373
|
+
# rule sends each ring to the sandbox's fleet as one diverted from production.
|
|
374
|
+
async def _hand_overs_admitted(connections: Connections) -> bool:
|
|
375
|
+
server = elsewhere(connections, PRODUCTION)
|
|
376
|
+
named = connections.settings.sip_domain
|
|
377
|
+
if server is None or not named:
|
|
378
|
+
return False
|
|
379
|
+
addresses = await _addresses_of(named)
|
|
380
|
+
if not addresses:
|
|
381
|
+
logger.warning(NO_ADDRESS, named)
|
|
382
|
+
return False
|
|
383
|
+
wanted = SIPInboundTrunkInfo(
|
|
384
|
+
name=hand_over.HAND_OVER,
|
|
385
|
+
allowed_addresses=addresses,
|
|
386
|
+
auth_username=hand_over.USERNAME,
|
|
387
|
+
auth_password=_hand_over_password(connections),
|
|
388
|
+
headers_to_attributes=hand_over.ATTRIBUTE_OF,
|
|
389
|
+
)
|
|
390
|
+
trunk_id = await _hand_over_trunk_kept(server, wanted)
|
|
391
|
+
fleet = worlds.fleet_of(await worlds.fleets(connections.pool), SANDBOX)
|
|
392
|
+
await _hand_over_rule_kept(server, trunk_id, fleet)
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _hand_over_password(connections: Connections) -> str:
|
|
397
|
+
secret = connections.settings.livekit_api_secret
|
|
398
|
+
if not secret:
|
|
399
|
+
raise NotAvailable(NO_LIVEKIT)
|
|
400
|
+
return hand_over.password_of(secret)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def _addresses_of(name: str) -> list[str]:
|
|
404
|
+
try:
|
|
405
|
+
async with asyncio.timeout(RESOLVED_WITHIN_S):
|
|
406
|
+
found = await asyncio.get_running_loop().getaddrinfo(
|
|
407
|
+
name, None, family=socket.AF_INET, type=socket.SOCK_DGRAM
|
|
408
|
+
)
|
|
409
|
+
except (OSError, TimeoutError):
|
|
410
|
+
return []
|
|
411
|
+
return sorted({str(address[4][0]) for address in found})
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def _hand_over_trunk_kept(server: api.LiveKitAPI, wanted: SIPInboundTrunkInfo) -> str:
|
|
415
|
+
existing = await trunk_named(server, wanted.name)
|
|
416
|
+
if existing is None:
|
|
417
|
+
made = await server.sip.create_inbound_trunk(CreateSIPInboundTrunkRequest(trunk=wanted))
|
|
418
|
+
return made.sip_trunk_id
|
|
419
|
+
if (
|
|
420
|
+
list(existing.allowed_addresses) == list(wanted.allowed_addresses)
|
|
421
|
+
and existing.auth_username == wanted.auth_username
|
|
422
|
+
and existing.auth_password == wanted.auth_password
|
|
423
|
+
and dict(existing.headers_to_attributes) == dict(wanted.headers_to_attributes)
|
|
424
|
+
):
|
|
425
|
+
return existing.sip_trunk_id
|
|
426
|
+
await server.sip.update_inbound_trunk(existing.sip_trunk_id, wanted)
|
|
427
|
+
return existing.sip_trunk_id
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def _hand_over_rule_kept(server: api.LiveKitAPI, trunk_id: str, fleet: str) -> None:
|
|
431
|
+
metadata = rooms.written(Dispatch(env=SANDBOX, diverted_from=PRODUCTION))
|
|
432
|
+
wanted = SIPDispatchRuleInfo(
|
|
433
|
+
name=hand_over.HAND_OVER,
|
|
434
|
+
trunk_ids=[trunk_id],
|
|
435
|
+
rule=SIPDispatchRule(
|
|
436
|
+
dispatch_rule_individual=SIPDispatchRuleIndividual(room_prefix=ROOM_PREFIX)
|
|
437
|
+
),
|
|
438
|
+
room_config=RoomConfiguration(
|
|
439
|
+
agents=[RoomAgentDispatch(agent_name=fleet, metadata=metadata)]
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
existing = await rule_named(server, hand_over.HAND_OVER)
|
|
443
|
+
if existing is None:
|
|
444
|
+
await server.sip.create_dispatch_rule(CreateSIPDispatchRuleRequest(dispatch_rule=wanted))
|
|
445
|
+
return
|
|
446
|
+
if list(existing.trunk_ids) == [trunk_id] and existing.room_config == wanted.room_config:
|
|
447
|
+
return
|
|
448
|
+
await server.sip.update_dispatch_rule(existing.sip_dispatch_rule_id, wanted)
|