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,419 @@
|
|
|
1
|
+
"""Dialling out: an account made dialable, the guards and their ledger, a call placed, a leg."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
|
|
9
|
+
from livekit import api
|
|
10
|
+
from livekit.protocol.sip import (
|
|
11
|
+
SIPOutboundConfig,
|
|
12
|
+
SIPTransport,
|
|
13
|
+
)
|
|
14
|
+
from psycopg.rows import DictRow
|
|
15
|
+
|
|
16
|
+
from pinecall.channels import routes
|
|
17
|
+
from pinecall.channels.offers import Offering
|
|
18
|
+
from pinecall.channels.rooms import Dialling, Dispatch
|
|
19
|
+
from pinecall.channels.telephony._twilio import (
|
|
20
|
+
TERMINATION_SUFFIX,
|
|
21
|
+
Twilio,
|
|
22
|
+
TwilioTrunk,
|
|
23
|
+
origination_uri,
|
|
24
|
+
termination_host,
|
|
25
|
+
)
|
|
26
|
+
from pinecall.channels.telephony.carrier import control_of, dialled_of, missing_to_dial
|
|
27
|
+
from pinecall.channels.telephony.sip import domain_of, sip_domain_of
|
|
28
|
+
from pinecall.domain.call import CallContext, new_call_id
|
|
29
|
+
from pinecall.domain.errors import (
|
|
30
|
+
Conflict,
|
|
31
|
+
DeclarationRefused,
|
|
32
|
+
NotFound,
|
|
33
|
+
UpstreamFailed,
|
|
34
|
+
)
|
|
35
|
+
from pinecall.domain.names import Env
|
|
36
|
+
from pinecall.domain.scope import Scope
|
|
37
|
+
from pinecall.fleet import worlds
|
|
38
|
+
from pinecall.log.logs import Logs, arrival_entry
|
|
39
|
+
from pinecall.log.store import Claim
|
|
40
|
+
from pinecall.postgres.pool import Pool
|
|
41
|
+
from pinecall.process.connections import Connections
|
|
42
|
+
from pinecall.tenancy import admission
|
|
43
|
+
from pinecall.tenancy.carriers import (
|
|
44
|
+
Carrier,
|
|
45
|
+
Termination,
|
|
46
|
+
Transport,
|
|
47
|
+
carrier_named,
|
|
48
|
+
seal_carrier,
|
|
49
|
+
)
|
|
50
|
+
from pinecall.tenancy.dial_policy import Dial, Guards, guard_dial, guard_second_leg, guards_of
|
|
51
|
+
from pinecall.wire.events import CallEnded
|
|
52
|
+
from pinecall.wire.rest.numbers import Consent, LegTrunk
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
NO_PHONE_DOOR = "agent {agent} answers at no phone number in the {env}: a call back shows one"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
NOT_OUR_NUMBER = "{shown} is not a number agent {agent} answers at in the {env}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
DIALS_THROUGH_NOTHING = (
|
|
64
|
+
"{shown} was hooked by the org or bought by the box: it dials through no account of the org"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
CREDENTIALS_LOST = (
|
|
69
|
+
"Twilio account {account} holds the credential {username} on list {name}, whose password "
|
|
70
|
+
"this box no longer has: delete that credential in Twilio's console and provision again"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
DID_NOT_DIAL = "the media plane refused the call: {message}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
CALLING_FROM = """
|
|
78
|
+
SELECT number, account FROM routes
|
|
79
|
+
WHERE org = %(org)s AND env = %(env)s AND channel = 'phone' ORDER BY added_at, number
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
TRANSPORTS: dict[Transport, SIPTransport] = {
|
|
84
|
+
"auto": SIPTransport.SIP_TRANSPORT_AUTO,
|
|
85
|
+
"udp": SIPTransport.SIP_TRANSPORT_UDP,
|
|
86
|
+
"tcp": SIPTransport.SIP_TRANSPORT_TCP,
|
|
87
|
+
"tls": SIPTransport.SIP_TRANSPORT_TLS,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class OutboundReadiness:
|
|
93
|
+
"""Whether the org can place a call through an account, and what is still missing."""
|
|
94
|
+
|
|
95
|
+
ready: bool
|
|
96
|
+
kind: str | None
|
|
97
|
+
from_numbers: list[str]
|
|
98
|
+
steps_missing: list[str]
|
|
99
|
+
guards: Guards
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class Provisioned:
|
|
104
|
+
"""What provisioning did, or would do."""
|
|
105
|
+
|
|
106
|
+
steps: list[str]
|
|
107
|
+
dry_run: bool
|
|
108
|
+
ready: bool
|
|
109
|
+
trunk: str | None = None
|
|
110
|
+
address: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class TwilioSurvey:
|
|
115
|
+
"""What the account holds today: the trunk, its termination host, the list, the credential."""
|
|
116
|
+
|
|
117
|
+
twilio: Twilio
|
|
118
|
+
trunk: TwilioTrunk | None
|
|
119
|
+
host: str
|
|
120
|
+
list_name: str
|
|
121
|
+
listed: str | None
|
|
122
|
+
username: str
|
|
123
|
+
# Whether the list already holds the org's credential; else one is made and sealed.
|
|
124
|
+
holds: bool
|
|
125
|
+
on_trunk: bool
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class OutboundSurvey:
|
|
130
|
+
"""How an account stands before anything is written: the steps read, and Twilio's own."""
|
|
131
|
+
|
|
132
|
+
steps: list[str]
|
|
133
|
+
address: str | None = None
|
|
134
|
+
twilio: TwilioSurvey | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class Placement:
|
|
139
|
+
"""A call asked for: scope, agent, the far end, the number shown, who asked, when."""
|
|
140
|
+
|
|
141
|
+
scope: Scope
|
|
142
|
+
agent: str
|
|
143
|
+
to: str
|
|
144
|
+
shown: str | None
|
|
145
|
+
asked_by: str
|
|
146
|
+
today: date
|
|
147
|
+
at: datetime
|
|
148
|
+
# The far end is the asker's own verified phone: a test, held to no caller's hours.
|
|
149
|
+
own_phone: bool = False
|
|
150
|
+
consent: Consent | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class PlacedCall:
|
|
155
|
+
"""The call a dial became, before the far end has heard anything ring."""
|
|
156
|
+
|
|
157
|
+
call: str
|
|
158
|
+
to: str
|
|
159
|
+
shown: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def outbound_readiness(
|
|
163
|
+
connections: Connections, scope: Scope, account: str | None = None
|
|
164
|
+
) -> OutboundReadiness:
|
|
165
|
+
"""Whether the org can dial out in the world, one sentence per thing missing, in order."""
|
|
166
|
+
guards = await guards_of(connections.pool, scope.org)
|
|
167
|
+
numbers = [str(row["number"]) for row in await _calling_from(connections.pool, scope)]
|
|
168
|
+
try:
|
|
169
|
+
carrier = await carrier_named(connections.pool, connections.vault, scope.org, account)
|
|
170
|
+
except NotFound as missing:
|
|
171
|
+
return OutboundReadiness(
|
|
172
|
+
ready=False,
|
|
173
|
+
kind=None,
|
|
174
|
+
from_numbers=numbers,
|
|
175
|
+
steps_missing=[str(missing)],
|
|
176
|
+
guards=guards,
|
|
177
|
+
)
|
|
178
|
+
missing = missing_to_dial(carrier)
|
|
179
|
+
if not numbers:
|
|
180
|
+
missing.append(f"the org answers at no phone number in the {scope.env}: a call shows one")
|
|
181
|
+
return OutboundReadiness(
|
|
182
|
+
ready=not missing,
|
|
183
|
+
kind=carrier.account.kind,
|
|
184
|
+
from_numbers=numbers,
|
|
185
|
+
steps_missing=missing,
|
|
186
|
+
guards=guards,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def plan_outbound(
|
|
191
|
+
connections: Connections, org: str, world: Env, account: str | None = None
|
|
192
|
+
) -> Provisioned:
|
|
193
|
+
"""What provisioning would write, and nothing written."""
|
|
194
|
+
carrier = await carrier_named(connections.pool, connections.vault, org, account)
|
|
195
|
+
survey = await _surveyed(connections, carrier, world)
|
|
196
|
+
return Provisioned(
|
|
197
|
+
steps=survey.steps, dry_run=True, ready=survey.twilio is None, address=survey.address
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def provision_outbound(
|
|
202
|
+
connections: Connections, org: str, world: Env, account: str | None = None
|
|
203
|
+
) -> Provisioned:
|
|
204
|
+
"""Make the account dialable: Twilio's termination and a credential; a peer needs nothing."""
|
|
205
|
+
carrier = await carrier_named(connections.pool, connections.vault, org, account)
|
|
206
|
+
survey = await _surveyed(connections, carrier, world)
|
|
207
|
+
if survey.twilio is None:
|
|
208
|
+
return Provisioned(steps=survey.steps, dry_run=False, ready=True, address=survey.address)
|
|
209
|
+
trunk = await _twilio_provisioned(connections, carrier, survey.twilio, world)
|
|
210
|
+
return Provisioned(
|
|
211
|
+
steps=survey.steps, dry_run=False, ready=True, trunk=trunk, address=survey.address
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def place_call(
|
|
216
|
+
connections: Connections, logs: Logs, placement: Placement, offering: Offering, *, running: int
|
|
217
|
+
) -> PlacedCall:
|
|
218
|
+
"""Place the call: number shown, account, guards, minutes, the log, then the dispatch."""
|
|
219
|
+
scope = placement.scope
|
|
220
|
+
doors = [
|
|
221
|
+
route
|
|
222
|
+
for route in await routes.of_org(connections.pool, scope.org, scope.env)
|
|
223
|
+
if route.agent == placement.agent and route.channel == "phone"
|
|
224
|
+
]
|
|
225
|
+
if not doors:
|
|
226
|
+
raise NotFound(NO_PHONE_DOOR.format(agent=placement.agent, env=scope.env))
|
|
227
|
+
shown = placement.shown or str(doors[0].number)
|
|
228
|
+
route = next((door for door in doors if door.number == shown), None)
|
|
229
|
+
if route is None:
|
|
230
|
+
raise DeclarationRefused(
|
|
231
|
+
NOT_OUR_NUMBER.format(shown=shown, agent=placement.agent, env=scope.env)
|
|
232
|
+
)
|
|
233
|
+
carrier = await _dials_through(connections, scope, shown)
|
|
234
|
+
call = new_call_id()
|
|
235
|
+
dial = Dial(
|
|
236
|
+
scope,
|
|
237
|
+
placement.agent,
|
|
238
|
+
placement.to,
|
|
239
|
+
shown,
|
|
240
|
+
placement.asked_by,
|
|
241
|
+
call,
|
|
242
|
+
placement.at,
|
|
243
|
+
own_phone=placement.own_phone,
|
|
244
|
+
consent=placement.consent,
|
|
245
|
+
)
|
|
246
|
+
guards = await guard_dial(connections.pool, dial)
|
|
247
|
+
await admission.admit_call(
|
|
248
|
+
connections.pool, scope.org, scope.env, running=running, at=placement.at.timestamp()
|
|
249
|
+
)
|
|
250
|
+
context = CallContext(
|
|
251
|
+
call=call,
|
|
252
|
+
channel="phone",
|
|
253
|
+
direction="outbound",
|
|
254
|
+
caller=placement.to,
|
|
255
|
+
route=route,
|
|
256
|
+
today=placement.today,
|
|
257
|
+
holder=scope.holder or None,
|
|
258
|
+
)
|
|
259
|
+
await logs.store.claim(call, placement.agent, scope.org, Claim(scope))
|
|
260
|
+
log = logs.writing(call, placement.agent)
|
|
261
|
+
kind, data = arrival_entry(context, shown, asked_by=placement.asked_by)
|
|
262
|
+
await log.append(kind, data)
|
|
263
|
+
fleets = await worlds.fleets(connections.pool)
|
|
264
|
+
carried = Dispatch(
|
|
265
|
+
agent=placement.agent,
|
|
266
|
+
org=scope.org,
|
|
267
|
+
env=scope.env,
|
|
268
|
+
holder=scope.holder or None,
|
|
269
|
+
direction="outbound",
|
|
270
|
+
caller=placement.to,
|
|
271
|
+
dial=Dialling(
|
|
272
|
+
trunk=carrier.id, to=placement.to, shown=shown, max_duration_s=guards.max_duration_s
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
try:
|
|
276
|
+
await offering.offer(call, worlds.fleet_of(fleets, scope.env), carried)
|
|
277
|
+
except api.TwirpError as refused:
|
|
278
|
+
ended = CallEnded(
|
|
279
|
+
reason="dial_failed", ended_by="platform", ended_at=time.time(), duration_s=0.0
|
|
280
|
+
)
|
|
281
|
+
await log.append("call.ended", ended.written())
|
|
282
|
+
await log.seal()
|
|
283
|
+
logs.forget(call)
|
|
284
|
+
raise UpstreamFailed(DID_NOT_DIAL.format(message=refused.message)) from refused
|
|
285
|
+
return PlacedCall(call=call, to=placement.to, shown=shown)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def leg_trunk(connections: Connections, dial: Dial) -> LegTrunk:
|
|
289
|
+
"""How a leg of a live call is dialled, after its guards: the account of the number shown."""
|
|
290
|
+
await guard_second_leg(connections.pool, dial)
|
|
291
|
+
scope = dial.scope
|
|
292
|
+
shown = dial.shown
|
|
293
|
+
if shown is None:
|
|
294
|
+
doors = [
|
|
295
|
+
route
|
|
296
|
+
for route in await routes.of_org(connections.pool, scope.org, scope.env)
|
|
297
|
+
if route.agent == dial.agent and route.channel == "phone"
|
|
298
|
+
]
|
|
299
|
+
if not doors:
|
|
300
|
+
raise NotFound(NO_PHONE_DOOR.format(agent=dial.agent, env=scope.env))
|
|
301
|
+
shown = str(doors[0].number)
|
|
302
|
+
return _leg_through(await _dials_through(connections, scope, shown), shown)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def sip_config(leg: LegTrunk) -> SIPOutboundConfig:
|
|
306
|
+
"""The inline trunk livekit dials a leg through."""
|
|
307
|
+
return SIPOutboundConfig(
|
|
308
|
+
hostname=leg.hostname,
|
|
309
|
+
transport=TRANSPORTS[leg.transport],
|
|
310
|
+
auth_username=leg.username,
|
|
311
|
+
auth_password=leg.password,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# This box makes nothing on somebody else's switch: a peer is dialled where it said.
|
|
316
|
+
async def _surveyed(connections: Connections, carrier: Carrier, world: Env) -> OutboundSurvey:
|
|
317
|
+
control = control_of(connections.http, carrier)
|
|
318
|
+
if control is not None:
|
|
319
|
+
return await _twilio_surveyed(connections, carrier, control, world)
|
|
320
|
+
dialled = dialled_of(carrier)
|
|
321
|
+
where = f"{dialled.hostname} over {dialled.transport}"
|
|
322
|
+
return OutboundSurvey(steps=[f"SIP peer: dialled at {where}: stands"], address=dialled.hostname)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Read before anything is written, so a plan and a provisioning see the same account.
|
|
326
|
+
async def _twilio_surveyed(
|
|
327
|
+
connections: Connections, carrier: Carrier, twilio: Twilio, world: Env
|
|
328
|
+
) -> OutboundSurvey:
|
|
329
|
+
domain = domain_of(connections)
|
|
330
|
+
origination = origination_uri(sip_domain_of(connections, world))
|
|
331
|
+
trunk = await twilio.trunk_pointing_at(origination)
|
|
332
|
+
host = termination_host(domain, twilio.sid)
|
|
333
|
+
# One list per trunk, as the trunk is per account and box; one credential per org on it,
|
|
334
|
+
# so two orgs that brought the same account each dial with their own.
|
|
335
|
+
name = host.removesuffix(TERMINATION_SUFFIX)
|
|
336
|
+
username = f"pinecall-{carrier.org}".replace("_", "-")
|
|
337
|
+
listed = await twilio.credential_list(name)
|
|
338
|
+
holds = listed is not None and username in await twilio.usernames_in(listed)
|
|
339
|
+
if holds and carrier.outbound is None:
|
|
340
|
+
raise Conflict(CREDENTIALS_LOST.format(account=twilio.sid, username=username, name=name))
|
|
341
|
+
on_trunk = (
|
|
342
|
+
trunk is not None and listed is not None and listed in await twilio.lists_on(trunk.sid)
|
|
343
|
+
)
|
|
344
|
+
pointed = f"Twilio: a trunk sending calls to {origination}"
|
|
345
|
+
terminated = trunk is not None and trunk.domain_name == host
|
|
346
|
+
steps = [
|
|
347
|
+
f"{pointed}: {'stands' if trunk else 'to do'}",
|
|
348
|
+
f"Twilio: dialled at {host}: {'stands' if terminated else 'to do'}",
|
|
349
|
+
f"Twilio: credential list {name}: {'stands' if listed else 'to do'}",
|
|
350
|
+
f"Twilio: the org's credential {username} on it: {'stands' if holds else 'to do'}",
|
|
351
|
+
f"Twilio: the trunk asks for it: {'stands' if on_trunk else 'to do'}",
|
|
352
|
+
]
|
|
353
|
+
at_twilio = TwilioSurvey(
|
|
354
|
+
twilio=twilio,
|
|
355
|
+
trunk=trunk,
|
|
356
|
+
host=host,
|
|
357
|
+
list_name=name,
|
|
358
|
+
listed=listed,
|
|
359
|
+
username=username,
|
|
360
|
+
holds=holds,
|
|
361
|
+
on_trunk=on_trunk,
|
|
362
|
+
)
|
|
363
|
+
return OutboundSurvey(steps=steps, address=host, twilio=at_twilio)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def _twilio_provisioned(
|
|
367
|
+
connections: Connections, carrier: Carrier, survey: TwilioSurvey, world: Env
|
|
368
|
+
) -> str:
|
|
369
|
+
domain = domain_of(connections)
|
|
370
|
+
twilio = survey.twilio
|
|
371
|
+
trunk = survey.trunk or await twilio.trunk_made(
|
|
372
|
+
domain, origination_uri(sip_domain_of(connections, world))
|
|
373
|
+
)
|
|
374
|
+
if trunk.domain_name != survey.host:
|
|
375
|
+
await twilio.terminated(trunk.sid, survey.host)
|
|
376
|
+
listed = survey.listed or await twilio.credential_list_made(survey.list_name)
|
|
377
|
+
outbound = carrier.outbound
|
|
378
|
+
if not survey.holds:
|
|
379
|
+
outbound = Termination(
|
|
380
|
+
host=survey.host, username=survey.username, password=secrets.token_urlsafe(24)
|
|
381
|
+
)
|
|
382
|
+
await twilio.credential_added(listed, outbound.username, outbound.password)
|
|
383
|
+
elif outbound is not None and outbound.host != survey.host:
|
|
384
|
+
# The trunk was given the box's host just now (its name changed): dial where it answers.
|
|
385
|
+
outbound = replace(outbound, host=survey.host)
|
|
386
|
+
if not survey.on_trunk:
|
|
387
|
+
await twilio.list_attached(trunk.sid, listed)
|
|
388
|
+
await seal_carrier(connections.pool, connections.vault, replace(carrier, outbound=outbound))
|
|
389
|
+
return trunk.sid
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def _dials_through(connections: Connections, scope: Scope, shown: str) -> Carrier:
|
|
393
|
+
rows = await _calling_from(connections.pool, scope)
|
|
394
|
+
account = next((row["account"] for row in rows if row["number"] == shown), None)
|
|
395
|
+
if account is None:
|
|
396
|
+
raise Conflict(DIALS_THROUGH_NOTHING.format(shown=shown))
|
|
397
|
+
carrier = await carrier_named(connections.pool, connections.vault, scope.org, str(account))
|
|
398
|
+
missing = missing_to_dial(carrier)
|
|
399
|
+
if missing:
|
|
400
|
+
raise Conflict(missing[0])
|
|
401
|
+
return carrier
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _leg_through(carrier: Carrier, shown: str) -> LegTrunk:
|
|
405
|
+
dialled = dialled_of(carrier)
|
|
406
|
+
return LegTrunk(
|
|
407
|
+
hostname=dialled.hostname,
|
|
408
|
+
transport=dialled.transport,
|
|
409
|
+
username=dialled.username,
|
|
410
|
+
password=dialled.password,
|
|
411
|
+
shown=shown,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def _calling_from(pool: Pool, scope: Scope) -> list[DictRow]:
|
|
416
|
+
async with pool.connection() as connection:
|
|
417
|
+
return await (
|
|
418
|
+
await connection.execute(CALLING_FROM, {"org": scope.org, "env": scope.env})
|
|
419
|
+
).fetchall()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The networks 5060 opens to beyond Twilio's: the carriers admitted and the approved addresses."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from ipaddress import IPv4Network, ip_network
|
|
6
|
+
|
|
7
|
+
from pinecall.channels.telephony import carrier_catalog
|
|
8
|
+
from pinecall.channels.telephony.carrier_catalog import BOX_CARRIER, known
|
|
9
|
+
from pinecall.postgres.pool import Pool
|
|
10
|
+
from pinecall.tenancy import carrier_networks, orgs
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Checked again at the list's end: a row the gateway let through still opens nothing this wide.
|
|
16
|
+
WIDEST_FOR_A_CARRIER = 16
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TOO_WIDE = "%s (%s) is not opened: wider than a /%d"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Opening:
|
|
24
|
+
"""A network 5060 opens to, and why: a carrier's kind, or the org and what it asked for."""
|
|
25
|
+
|
|
26
|
+
network: str
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def openings(pool: Pool) -> list[Opening]:
|
|
31
|
+
"""Every network beyond Twilio's the fence opens to now, the catalog's first."""
|
|
32
|
+
admitted = await carrier_catalog.admitted(pool)
|
|
33
|
+
found = [
|
|
34
|
+
Opening(network=network, reason=carrier.kind)
|
|
35
|
+
for carrier in known().values()
|
|
36
|
+
if carrier.kind in admitted and carrier.kind != BOX_CARRIER
|
|
37
|
+
for network in carrier.networks
|
|
38
|
+
]
|
|
39
|
+
slugs = {org.id: org.slug for org in await orgs.listed(pool)}
|
|
40
|
+
found += [
|
|
41
|
+
Opening(network=ask.network, reason=f"{slugs.get(ask.org, ask.org)}: {ask.source}")
|
|
42
|
+
for ask in await carrier_networks.listed(pool, "approved")
|
|
43
|
+
]
|
|
44
|
+
return [opening for opening in found if _narrow_enough(opening)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def every_network(found: list[Opening]) -> list[str]:
|
|
48
|
+
"""Twilio's networks and every opening: what the cloud's firewall admits to 5060."""
|
|
49
|
+
return list(dict.fromkeys([*known()[BOX_CARRIER].networks, *(o.network for o in found)]))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _narrow_enough(opening: Opening) -> bool:
|
|
53
|
+
parsed = ip_network(opening.network, strict=False)
|
|
54
|
+
widest = WIDEST_FOR_A_CARRIER if opening.reason in known() else carrier_networks.WIDEST
|
|
55
|
+
if not isinstance(parsed, IPv4Network) or parsed.prefixlen < widest:
|
|
56
|
+
logger.warning(TOO_WIDE, opening.network, opening.reason, widest)
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""A developer's own phone at a production number, carried to the sandbox's LiveKit by SIP."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from livekit import api
|
|
8
|
+
from livekit.protocol.webhook import WebhookEvent
|
|
9
|
+
|
|
10
|
+
from pinecall.channels import rooms
|
|
11
|
+
from pinecall.channels.rooms import Dispatch
|
|
12
|
+
|
|
13
|
+
# The sandbox's trunk and its rule that admit production's hand-overs are both named so.
|
|
14
|
+
HAND_OVER = "hand-over"
|
|
15
|
+
|
|
16
|
+
USERNAME = "pinecall-hand-over"
|
|
17
|
+
|
|
18
|
+
# What the trunk's password is drawn under, from the LiveKit secret both worlds' clusters share.
|
|
19
|
+
PAIR_OF = b"pinecall: a ring handed from production to the sandbox"
|
|
20
|
+
|
|
21
|
+
# The leg production's room dials, by its identity there.
|
|
22
|
+
LEG_PREFIX = "hand_over_"
|
|
23
|
+
|
|
24
|
+
LEFT = "participant_left"
|
|
25
|
+
|
|
26
|
+
# Whose sandbox copy the ring is for rides the leg's INVITE, a header each; the sandbox's trunk
|
|
27
|
+
# turns each into an attribute of its leg.
|
|
28
|
+
ORG = "pinecall.org"
|
|
29
|
+
AGENT = "pinecall.agent"
|
|
30
|
+
HOLDER = "pinecall.holder"
|
|
31
|
+
|
|
32
|
+
HEADER_OF: Mapping[str, str] = {
|
|
33
|
+
ORG: "X-Pinecall-Org",
|
|
34
|
+
AGENT: "X-Pinecall-Agent",
|
|
35
|
+
HOLDER: "X-Pinecall-Holder",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# The sandbox's trunk's map of the leg's headers to its attributes.
|
|
39
|
+
ATTRIBUTE_OF: Mapping[str, str] = {header: attribute for attribute, header in HEADER_OF.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def password_of(secret: str) -> str:
|
|
43
|
+
"""The hand-over trunk's password: whoever holds LiveKit's secret could rewrite the trunk."""
|
|
44
|
+
return hmac.new(secret.encode(), PAIR_OF, hashlib.sha256).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def headers_of(moved: Dispatch) -> dict[str, str]:
|
|
48
|
+
"""The headers the hand-over leg carries: the org, the agent and the developer it is for."""
|
|
49
|
+
values = {ORG: moved.org, AGENT: moved.agent, HOLDER: moved.holder}
|
|
50
|
+
return {HEADER_OF[attribute]: value or "" for attribute, value in values.items()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# The hand-over rule dispatches the same metadata for every ring; whose ring it is rides the leg.
|
|
54
|
+
def handed_in(dispatch: Dispatch, attributes: Mapping[str, str]) -> Dispatch:
|
|
55
|
+
"""The hand-over rule's dispatch made the developer's call, from what its leg carries."""
|
|
56
|
+
if dispatch.diverted_from is None or dispatch.agent is not None or AGENT not in attributes:
|
|
57
|
+
return dispatch
|
|
58
|
+
carried = {
|
|
59
|
+
"org": attributes.get(ORG),
|
|
60
|
+
"agent": attributes.get(AGENT),
|
|
61
|
+
"holder": attributes.get(HOLDER),
|
|
62
|
+
}
|
|
63
|
+
return dispatch.model_copy(update=carried)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# LiveKit ends no SIP leg when another leaves: a caller who hung up would leave the developer's
|
|
67
|
+
# copy talking to nobody, and a copy that ended would leave the caller in silence.
|
|
68
|
+
async def unbridged(server: api.LiveKitAPI, event: WebhookEvent) -> bool:
|
|
69
|
+
"""Close a room a hand-over bridges once either of its legs left; whether it did."""
|
|
70
|
+
if event.event != LEFT or event.participant.kind != api.ParticipantInfo.Kind.SIP:
|
|
71
|
+
return False
|
|
72
|
+
name = event.room.name
|
|
73
|
+
if not event.participant.identity.startswith(LEG_PREFIX):
|
|
74
|
+
seats = await rooms.seats_in(server, name)
|
|
75
|
+
if not any(seat.identity.startswith(LEG_PREFIX) for seat in seats):
|
|
76
|
+
return False
|
|
77
|
+
await rooms.room_closed(server, name)
|
|
78
|
+
return True
|