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,530 @@
|
|
|
1
|
+
"""A number imported, bought, released or moved: at the carrier, on the SFU, in the routes table."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from livekit.protocol.sip import (
|
|
8
|
+
SIPDispatchRuleInfo,
|
|
9
|
+
SIPInboundTrunkInfo,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from pinecall.channels import routes, whatsapp
|
|
13
|
+
from pinecall.channels.routes import RouteRecord, RouteWrite
|
|
14
|
+
from pinecall.channels.telephony import carrier_catalog, sip
|
|
15
|
+
from pinecall.channels.telephony._twilio import (
|
|
16
|
+
Twilio,
|
|
17
|
+
TwilioNumber,
|
|
18
|
+
TwilioTrunk,
|
|
19
|
+
origination_uri,
|
|
20
|
+
twilio_of,
|
|
21
|
+
)
|
|
22
|
+
from pinecall.channels.telephony.carrier import (
|
|
23
|
+
Fence,
|
|
24
|
+
control_of,
|
|
25
|
+
declared_networks,
|
|
26
|
+
fence_of,
|
|
27
|
+
meta_of,
|
|
28
|
+
own_networks,
|
|
29
|
+
)
|
|
30
|
+
from pinecall.channels.telephony.carrier_catalog import known_carrier
|
|
31
|
+
from pinecall.channels.telephony.sip import WorldRule, domain_of, rule_name, sip_domain_of
|
|
32
|
+
from pinecall.domain.call import Route
|
|
33
|
+
from pinecall.domain.errors import (
|
|
34
|
+
Conflict,
|
|
35
|
+
DeclarationRefused,
|
|
36
|
+
NotFound,
|
|
37
|
+
UpstreamFailed,
|
|
38
|
+
)
|
|
39
|
+
from pinecall.domain.names import Channel, Env, RouteOrigin, other_world, parse_e164
|
|
40
|
+
from pinecall.domain.scope import Scope
|
|
41
|
+
from pinecall.fleet import worlds
|
|
42
|
+
from pinecall.process.connections import Connections
|
|
43
|
+
from pinecall.tenancy import admission, carrier_networks
|
|
44
|
+
from pinecall.tenancy.carriers import (
|
|
45
|
+
NO_CARRIER,
|
|
46
|
+
WhatsappAccount,
|
|
47
|
+
box_twilio,
|
|
48
|
+
carrier_named,
|
|
49
|
+
carriers_of,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
NOT_ON_ACCOUNT = "{number} is not a number of Twilio account {account}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
ON_ANOTHER_TRUNK = (
|
|
59
|
+
"{number} is on Twilio trunk {trunk} of {account}, pointed at {where}: moving it here takes it "
|
|
60
|
+
"off there, so say move"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Whose it is stays theirs: the sentence names no org and no trunk.
|
|
65
|
+
HELD_ELSEWHERE = (
|
|
66
|
+
"{number} is held by another org on this box, so nothing was written: if it is yours, ask the "
|
|
67
|
+
"box's operator"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
NO_DOMAIN = "this box has no PINECALL_DOMAIN: a carrier has nowhere to send a call"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
NOT_A_PHONE = "a number is imported to answer phone or whatsapp, not {channel}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
KEY_WITHOUT_PEER = "a {kind} account takes no networks: they are the fence of a number you hook"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
NO_ROUTE = "the org answers no {number} in the {env}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
NONE_FOR_SALE = "Twilio has no local voice number for sale in {country}{area}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
VIA_ALONE = (
|
|
87
|
+
"a number comes via a carrier of the box's catalog when the org hooks it, on the phone, with "
|
|
88
|
+
"no networks of its own: that carrier's are its fence"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
NOT_ADMITTED = "this box does not admit {name}: its operator turns it on under Carriers"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
NOT_A_COUNTRY = "{number} starts with no country calling code E.164 assigns"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class NumberPurchase:
|
|
100
|
+
"""A number wanted from the box's own account."""
|
|
101
|
+
|
|
102
|
+
scope: Scope
|
|
103
|
+
agent: str
|
|
104
|
+
country: str
|
|
105
|
+
area_code: str | None = None
|
|
106
|
+
channel: Channel = "phone"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class AtTwilio:
|
|
111
|
+
"""The number at its Twilio account, and the trunk that points it here."""
|
|
112
|
+
|
|
113
|
+
twilio: Twilio
|
|
114
|
+
number: TwilioNumber | None
|
|
115
|
+
trunk: TwilioTrunk | None
|
|
116
|
+
# Bought in this very request: a dry run names it and buys nothing.
|
|
117
|
+
for_sale: str | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Survey:
|
|
122
|
+
"""Everything a hook needs, read before anything is written."""
|
|
123
|
+
|
|
124
|
+
route: Route
|
|
125
|
+
origin: RouteOrigin
|
|
126
|
+
fence: Fence | None
|
|
127
|
+
account: str | None
|
|
128
|
+
networks: tuple[str, ...]
|
|
129
|
+
fleet: str
|
|
130
|
+
domain: str
|
|
131
|
+
# Where a trunk of the account sends the world's calls: its SIP name, else its name.
|
|
132
|
+
origination: str
|
|
133
|
+
at_twilio: AtTwilio | None = None
|
|
134
|
+
via: str | None = None
|
|
135
|
+
# The networks named for the number that the operator has not approved: no trunk until he does.
|
|
136
|
+
waiting: tuple[str, ...] = ()
|
|
137
|
+
# The org's own other trunks that list the number and must let it go.
|
|
138
|
+
leaving: list[SIPInboundTrunkInfo] = field(default_factory=list[SIPInboundTrunkInfo])
|
|
139
|
+
# The org's trunks on the other world's own LiveKit that list it: it lives on one LiveKit.
|
|
140
|
+
elsewhere: list[SIPInboundTrunkInfo] = field(default_factory=list[SIPInboundTrunkInfo])
|
|
141
|
+
trunk: SIPInboundTrunkInfo | None = None
|
|
142
|
+
rule: SIPDispatchRuleInfo | None = None
|
|
143
|
+
other_rule: SIPDispatchRuleInfo | None = None
|
|
144
|
+
routed: Route | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Plan:
|
|
149
|
+
"""What a hook does, one sentence a step, and the route it ends in."""
|
|
150
|
+
|
|
151
|
+
route: Route
|
|
152
|
+
steps: list[str]
|
|
153
|
+
dry_run: bool
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class OwnedNumber:
|
|
158
|
+
"""A number one of the org's accounts owns, and whether this world already imported it."""
|
|
159
|
+
|
|
160
|
+
number: str
|
|
161
|
+
name: str
|
|
162
|
+
imported: bool
|
|
163
|
+
account: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class NumberImport:
|
|
168
|
+
"""A number wanted: where it answers, and how it reaches the box."""
|
|
169
|
+
|
|
170
|
+
scope: Scope
|
|
171
|
+
agent: str
|
|
172
|
+
number: str
|
|
173
|
+
channel: Channel = "phone"
|
|
174
|
+
# The account it lives in; unsaid, the org's only one.
|
|
175
|
+
account: str | None = None
|
|
176
|
+
# The org points the number here itself: no account is touched.
|
|
177
|
+
hooked: bool = False
|
|
178
|
+
# A self-hooked number's own networks, which the operator approves; unsaid, its carrier's.
|
|
179
|
+
networks: tuple[str, ...] = ()
|
|
180
|
+
# The catalog carrier a self-hooked number comes through; unsaid, Twilio.
|
|
181
|
+
via: str | None = None
|
|
182
|
+
# Take the number off another trunk of its account first.
|
|
183
|
+
move: bool = False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def plan_buy(connections: Connections, wanted: NumberPurchase) -> Plan:
|
|
187
|
+
"""What buying would do: the number Twilio would sell, and nothing bought."""
|
|
188
|
+
survey = await _survey_purchase(connections, wanted)
|
|
189
|
+
return Plan(route=survey.route, steps=_steps(survey, done=False), dry_run=True)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def buy_number(connections: Connections, wanted: NumberPurchase) -> Plan:
|
|
193
|
+
"""Buy a number on the box's account and hook it as an import, counted as the box's."""
|
|
194
|
+
survey = await _survey_purchase(connections, wanted)
|
|
195
|
+
steps = _steps(survey, done=True)
|
|
196
|
+
at = survey.at_twilio
|
|
197
|
+
if at is not None and at.for_sale is not None:
|
|
198
|
+
survey.at_twilio = replace(at, number=await at.twilio.bought(at.for_sale), for_sale=None)
|
|
199
|
+
await _write_hook(connections, survey)
|
|
200
|
+
return Plan(route=survey.route, steps=steps, dry_run=False)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def release(connections: Connections, org: str, number: str, env: Env) -> None:
|
|
204
|
+
"""Let the number go: the route, its world's LiveKit's trunks and rule; the account keeps it."""
|
|
205
|
+
route = await routes.of_number(connections.pool, org, number)
|
|
206
|
+
if route is None or route.env != env:
|
|
207
|
+
raise NotFound(NO_ROUTE.format(number=number, env=env))
|
|
208
|
+
await routes.remove(connections.pool, org, number)
|
|
209
|
+
await sip.let_go(connections.servers[env], org, number)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Where both worlds share a LiveKit the number keeps its trunk and changes rules; where each has
|
|
213
|
+
# its own, it leaves the old world's LiveKit and is admitted on the new one's.
|
|
214
|
+
async def move(connections: Connections, org: str, number: str, env: Env) -> RouteRecord:
|
|
215
|
+
"""Move the number into the other world: its row, and its trunk and rule on that LiveKit."""
|
|
216
|
+
record = await routes.record_of(connections.pool, org, number)
|
|
217
|
+
if record is None:
|
|
218
|
+
raise NotFound(NO_ROUTE.format(number=number, env="either world"))
|
|
219
|
+
route = record.route
|
|
220
|
+
if route.env == env:
|
|
221
|
+
return record
|
|
222
|
+
moved = replace(record, route=replace(route, env=env))
|
|
223
|
+
# A WhatsApp number is on no trunk: its world is its row alone.
|
|
224
|
+
if route.channel != "phone":
|
|
225
|
+
await routes.moved(connections.pool, org, number, env)
|
|
226
|
+
return moved
|
|
227
|
+
fleets = await worlds.fleets(connections.pool)
|
|
228
|
+
await sip.readmitted(connections, moved, await sip.fencing_of(connections, org), fleets)
|
|
229
|
+
await routes.moved(connections.pool, org, number, env)
|
|
230
|
+
return moved
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# A SIP peer owns what it owns and nobody here can list it: its import takes the number typed.
|
|
234
|
+
async def owned_numbers(
|
|
235
|
+
connections: Connections, scope: Scope, account: str | None = None
|
|
236
|
+
) -> tuple[str, list[OwnedNumber]]:
|
|
237
|
+
"""The kind of the org's accounts, and every number its Twilio accounts own."""
|
|
238
|
+
if account is None:
|
|
239
|
+
carriers = await carriers_of(connections.pool, connections.vault, scope.org)
|
|
240
|
+
else:
|
|
241
|
+
carriers = [await carrier_named(connections.pool, connections.vault, scope.org, account)]
|
|
242
|
+
if not carriers:
|
|
243
|
+
raise NotFound(NO_CARRIER)
|
|
244
|
+
answering = await routes.of_org(connections.pool, scope.org, scope.env)
|
|
245
|
+
imported = {route.number for route in answering if route.number is not None}
|
|
246
|
+
controlled = [
|
|
247
|
+
(carrier, control)
|
|
248
|
+
for carrier in carriers
|
|
249
|
+
if (control := control_of(connections.http, carrier)) is not None
|
|
250
|
+
]
|
|
251
|
+
owned = [
|
|
252
|
+
OwnedNumber(
|
|
253
|
+
number=number.phone_number,
|
|
254
|
+
name=number.friendly_name or number.phone_number,
|
|
255
|
+
imported=number.phone_number in imported,
|
|
256
|
+
account=carrier.id,
|
|
257
|
+
)
|
|
258
|
+
for carrier, control in controlled
|
|
259
|
+
for number in await control.numbers()
|
|
260
|
+
]
|
|
261
|
+
for carrier in carriers:
|
|
262
|
+
meta = meta_of(carrier)
|
|
263
|
+
if meta is not None:
|
|
264
|
+
owned += await _at_meta(connections.http, meta, imported)
|
|
265
|
+
lister = controlled[0][0] if controlled else carriers[0]
|
|
266
|
+
return lister.account.kind, owned
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def import_number(connections: Connections, wanted: NumberImport) -> Plan:
|
|
270
|
+
"""Hook the number at its account, admit it on the SFU in its world, route it."""
|
|
271
|
+
survey = await _survey_import(connections, wanted)
|
|
272
|
+
steps = _steps(survey, done=True)
|
|
273
|
+
await _write_hook(connections, survey)
|
|
274
|
+
return Plan(route=survey.route, steps=steps, dry_run=False)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# The operator's row is admitted like a hooked number, at once, and says who wrote it.
|
|
278
|
+
async def type_route(connections: Connections, wanted: NumberImport) -> Plan:
|
|
279
|
+
"""Route a number the box's operator names: on the SFU and in the table, no account touched."""
|
|
280
|
+
survey = await _survey_import(connections, replace(wanted, hooked=True, account=None))
|
|
281
|
+
survey.origin = "typed"
|
|
282
|
+
steps = _steps(survey, done=True)
|
|
283
|
+
await _write_hook(connections, survey)
|
|
284
|
+
return Plan(route=survey.route, steps=steps, dry_run=False)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def plan_import(connections: Connections, wanted: NumberImport) -> Plan:
|
|
288
|
+
"""What importing the number would do, writing nothing."""
|
|
289
|
+
survey = await _survey_import(connections, wanted)
|
|
290
|
+
return Plan(route=survey.route, steps=_steps(survey, done=False), dry_run=True)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _survey_import(connections: Connections, wanted: NumberImport) -> Survey:
|
|
294
|
+
number = parse_e164(wanted.number)
|
|
295
|
+
if wanted.channel not in {"phone", "whatsapp"}:
|
|
296
|
+
raise DeclarationRefused(NOT_A_PHONE.format(channel=wanted.channel))
|
|
297
|
+
org, env = wanted.scope.org, wanted.scope.env
|
|
298
|
+
domain = domain_of(connections)
|
|
299
|
+
route = Route(org=org, agent=wanted.agent, channel=wanted.channel, number=number, env=env)
|
|
300
|
+
if await routes.held_elsewhere(connections.pool, org, number):
|
|
301
|
+
raise Conflict(HELD_ELSEWHERE.format(number=number))
|
|
302
|
+
fleets = await worlds.fleets(connections.pool)
|
|
303
|
+
await _check_via(connections, wanted)
|
|
304
|
+
carrier = (
|
|
305
|
+
None
|
|
306
|
+
if wanted.hooked
|
|
307
|
+
else await carrier_named(connections.pool, connections.vault, org, wanted.account)
|
|
308
|
+
)
|
|
309
|
+
if carrier is not None and wanted.networks:
|
|
310
|
+
raise DeclarationRefused(KEY_WITHOUT_PEER.format(kind=carrier.account.kind))
|
|
311
|
+
networks = tuple(carrier_networks.checked(network) for network in wanted.networks)
|
|
312
|
+
approved = await carrier_networks.approved(connections.pool, org)
|
|
313
|
+
declared = (networks or None) if carrier is None else declared_networks(carrier)
|
|
314
|
+
source = number if carrier is None else carrier.id
|
|
315
|
+
own, waiting = own_networks(declared, approved.get(source, ()))
|
|
316
|
+
survey = Survey(
|
|
317
|
+
route=route,
|
|
318
|
+
origin="hooked" if carrier is None else "imported",
|
|
319
|
+
fence=fence_of(org, number, carrier, wanted.via, own)
|
|
320
|
+
if wanted.channel == "phone"
|
|
321
|
+
else None,
|
|
322
|
+
account=None if carrier is None else carrier.id,
|
|
323
|
+
networks=networks,
|
|
324
|
+
fleet=worlds.fleet_of(fleets, env),
|
|
325
|
+
domain=domain,
|
|
326
|
+
origination=origination_uri(sip_domain_of(connections, env)),
|
|
327
|
+
via=wanted.via,
|
|
328
|
+
waiting=waiting,
|
|
329
|
+
)
|
|
330
|
+
control = None if carrier is None else control_of(connections.http, carrier)
|
|
331
|
+
if control is not None and wanted.channel == "phone":
|
|
332
|
+
survey.at_twilio = await _at_twilio(connections, control, number, env, move=wanted.move)
|
|
333
|
+
return await _survey_sfu(connections, survey)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# A catalog carrier is the operator's to admit; its networks are the number's whole fence.
|
|
337
|
+
async def _check_via(connections: Connections, wanted: NumberImport) -> None:
|
|
338
|
+
if wanted.via is None:
|
|
339
|
+
return
|
|
340
|
+
if not wanted.hooked or wanted.networks or wanted.channel != "phone":
|
|
341
|
+
raise DeclarationRefused(VIA_ALONE)
|
|
342
|
+
carrier = known_carrier(wanted.via)
|
|
343
|
+
if wanted.via not in await carrier_catalog.admitted(connections.pool):
|
|
344
|
+
raise Conflict(NOT_ADMITTED.format(name=carrier.name))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def _survey_purchase(connections: Connections, wanted: NumberPurchase) -> Survey:
|
|
348
|
+
org, env = wanted.scope.org, wanted.scope.env
|
|
349
|
+
domain = domain_of(connections)
|
|
350
|
+
origination = origination_uri(sip_domain_of(connections, env))
|
|
351
|
+
boxs = await box_twilio(connections.pool, connections.vault)
|
|
352
|
+
await admission.admit_number(
|
|
353
|
+
connections.pool, org, env, bought=await routes.managed_in(connections.pool, org, env)
|
|
354
|
+
)
|
|
355
|
+
twilio = twilio_of(connections.http, boxs)
|
|
356
|
+
for_sale = await twilio.for_sale(wanted.country, wanted.area_code)
|
|
357
|
+
if for_sale is None:
|
|
358
|
+
area = f", area code {wanted.area_code}" if wanted.area_code else ""
|
|
359
|
+
raise NotFound(NONE_FOR_SALE.format(country=wanted.country.upper(), area=area))
|
|
360
|
+
route = Route(
|
|
361
|
+
org=org, agent=wanted.agent, channel=wanted.channel, number=for_sale, env=env, managed=True
|
|
362
|
+
)
|
|
363
|
+
fleets = await worlds.fleets(connections.pool)
|
|
364
|
+
survey = Survey(
|
|
365
|
+
route=route,
|
|
366
|
+
origin="bought",
|
|
367
|
+
fence=fence_of(org, for_sale, None, None, None),
|
|
368
|
+
account=None,
|
|
369
|
+
networks=(),
|
|
370
|
+
fleet=worlds.fleet_of(fleets, env),
|
|
371
|
+
domain=domain,
|
|
372
|
+
origination=origination,
|
|
373
|
+
at_twilio=AtTwilio(
|
|
374
|
+
twilio=twilio,
|
|
375
|
+
number=None,
|
|
376
|
+
trunk=await twilio.trunk_pointing_at(origination),
|
|
377
|
+
for_sale=for_sale,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
return await _survey_sfu(connections, survey)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def _at_twilio(
|
|
384
|
+
connections: Connections, twilio: Twilio, number: str, world: Env, *, move: bool
|
|
385
|
+
) -> AtTwilio:
|
|
386
|
+
owned = await twilio.number(number)
|
|
387
|
+
if owned is None:
|
|
388
|
+
raise NotFound(NOT_ON_ACCOUNT.format(number=number, account=twilio.sid))
|
|
389
|
+
trunk = await twilio.trunk_pointing_at(origination_uri(sip_domain_of(connections, world)))
|
|
390
|
+
elsewhere = owned.trunk_sid is not None and (trunk is None or owned.trunk_sid != trunk.sid)
|
|
391
|
+
if elsewhere and owned.trunk_sid is not None and not move:
|
|
392
|
+
where = await twilio.originations(owned.trunk_sid)
|
|
393
|
+
other = await twilio.trunk(owned.trunk_sid)
|
|
394
|
+
raise Conflict(
|
|
395
|
+
ON_ANOTHER_TRUNK.format(
|
|
396
|
+
number=number,
|
|
397
|
+
trunk=other.friendly_name or other.sid,
|
|
398
|
+
account=twilio.sid,
|
|
399
|
+
where=", ".join(where) or "nowhere",
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
return AtTwilio(twilio=twilio, number=owned, trunk=trunk)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def _survey_sfu(connections: Connections, survey: Survey) -> Survey:
|
|
406
|
+
route, fence = survey.route, survey.fence
|
|
407
|
+
number = str(route.number)
|
|
408
|
+
survey.routed = await routes.of_number(connections.pool, route.org, number)
|
|
409
|
+
if route.channel != "phone":
|
|
410
|
+
return survey
|
|
411
|
+
server, other = connections.servers[route.env], sip.elsewhere(connections, route.env)
|
|
412
|
+
listing = await sip.trunks_admitting(server, number)
|
|
413
|
+
stranger = next((trunk for trunk in listing if not sip.belongs_to(trunk.name, route.org)), None)
|
|
414
|
+
if stranger is not None:
|
|
415
|
+
raise Conflict(HELD_ELSEWHERE.format(number=number))
|
|
416
|
+
survey.leaving = [trunk for trunk in listing if fence is None or trunk.name != fence.trunk]
|
|
417
|
+
if other is not None:
|
|
418
|
+
survey.elsewhere = [
|
|
419
|
+
trunk
|
|
420
|
+
for trunk in await sip.trunks_admitting(other, number)
|
|
421
|
+
if sip.belongs_to(trunk.name, route.org)
|
|
422
|
+
]
|
|
423
|
+
if fence is None:
|
|
424
|
+
return survey
|
|
425
|
+
survey.trunk = await sip.trunk_named(server, fence.trunk)
|
|
426
|
+
survey.rule = await sip.rule_named(server, rule_name(route.org, route.env))
|
|
427
|
+
survey.other_rule = await sip.rule_named(server, rule_name(route.org, other_world(route.env)))
|
|
428
|
+
return survey
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def _write_hook(connections: Connections, survey: Survey) -> None:
|
|
432
|
+
route, number = survey.route, str(survey.route.number)
|
|
433
|
+
at = survey.at_twilio
|
|
434
|
+
if at is not None and at.number is not None:
|
|
435
|
+
trunk = at.trunk or await at.twilio.trunk_made(survey.domain, survey.origination)
|
|
436
|
+
survey.at_twilio = replace(at, trunk=trunk)
|
|
437
|
+
if at.number.trunk_sid not in {None, trunk.sid}:
|
|
438
|
+
await at.twilio.detach(str(at.number.trunk_sid), at.number.sid)
|
|
439
|
+
if at.number.trunk_sid != trunk.sid:
|
|
440
|
+
await at.twilio.attach(trunk.sid, at.number.sid)
|
|
441
|
+
if survey.origin == "hooked" and survey.networks:
|
|
442
|
+
await carrier_networks.ask(connections.pool, route.org, number, survey.networks)
|
|
443
|
+
server, other = connections.servers[route.env], sip.elsewhere(connections, route.env)
|
|
444
|
+
if other is not None and route.channel == "phone":
|
|
445
|
+
await sip.let_go(other, route.org, number)
|
|
446
|
+
for leaving in survey.leaving:
|
|
447
|
+
kept = [listed for listed in leaving.numbers if listed != number]
|
|
448
|
+
await sip.renumber_trunk(server, route.org, leaving, kept)
|
|
449
|
+
if survey.fence is not None:
|
|
450
|
+
# Off the other world's rule first: two rules of one trunk never list one number.
|
|
451
|
+
if survey.other_rule is not None and number in survey.other_rule.numbers:
|
|
452
|
+
kept = [listed for listed in survey.other_rule.numbers if listed != number]
|
|
453
|
+
await sip.renumber_rule(server, survey.other_rule, kept)
|
|
454
|
+
trunk_id = await sip.admit(server, survey.fence, survey.trunk, number)
|
|
455
|
+
world = WorldRule(route.org, route.env, survey.fleet)
|
|
456
|
+
await sip.rule_in(server, world, [trunk_id], number)
|
|
457
|
+
# A hooked number waits for the operator only where the box asks (PINECALL_APPROVE_HOOKED).
|
|
458
|
+
waits = survey.origin == "hooked" and connections.settings.approve_hooked
|
|
459
|
+
written = RouteWrite(
|
|
460
|
+
origin=survey.origin,
|
|
461
|
+
account=survey.account,
|
|
462
|
+
networks=survey.networks,
|
|
463
|
+
via=survey.via,
|
|
464
|
+
waits=waits,
|
|
465
|
+
)
|
|
466
|
+
await routes.put(connections.pool, route, written)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _steps(survey: Survey, *, done: bool) -> list[str]:
|
|
470
|
+
route, number = survey.route, str(survey.route.number)
|
|
471
|
+
made = "done" if done else "to do"
|
|
472
|
+
steps: list[str] = []
|
|
473
|
+
at = survey.at_twilio
|
|
474
|
+
if at is not None:
|
|
475
|
+
if at.for_sale is not None:
|
|
476
|
+
steps.append(f"Twilio: buy {at.for_sale} on the box's account: {made}")
|
|
477
|
+
pointed = f"Twilio: a trunk sending calls to {survey.origination}"
|
|
478
|
+
steps.append(f"{pointed}: {'stands' if at.trunk else made}")
|
|
479
|
+
on_it = (
|
|
480
|
+
at.number is not None and at.trunk is not None and at.number.trunk_sid == at.trunk.sid
|
|
481
|
+
)
|
|
482
|
+
steps.append(f"Twilio: {number} attached to it: {'stands' if on_it else made}")
|
|
483
|
+
fence = survey.fence
|
|
484
|
+
other = other_world(route.env)
|
|
485
|
+
steps += [
|
|
486
|
+
f"LiveKit of the {other}: {number} off trunk {trunk.name}: {made}"
|
|
487
|
+
for trunk in survey.elsewhere
|
|
488
|
+
]
|
|
489
|
+
steps += [f"LiveKit: {number} off trunk {trunk.name}: {made}" for trunk in survey.leaving]
|
|
490
|
+
if fence is None and survey.waiting and route.channel == "phone":
|
|
491
|
+
waiting = ", ".join(survey.waiting)
|
|
492
|
+
steps.append(f"LiveKit: {number} waits for the box's operator to approve {waiting}: {made}")
|
|
493
|
+
if fence is not None:
|
|
494
|
+
admits = survey.trunk is not None and number in survey.trunk.numbers
|
|
495
|
+
steps.append(
|
|
496
|
+
f"LiveKit: trunk {fence.trunk} admits {number}: {'stands' if admits else made}"
|
|
497
|
+
)
|
|
498
|
+
rule = rule_name(route.org, route.env)
|
|
499
|
+
ruled = survey.rule is not None and number in survey.rule.numbers
|
|
500
|
+
steps.append(
|
|
501
|
+
f"LiveKit: rule {rule} sends it to fleet {survey.fleet}: {'stands' if ruled else made}"
|
|
502
|
+
)
|
|
503
|
+
routed = survey.routed == route
|
|
504
|
+
steps.append(
|
|
505
|
+
f"route: {number} to {route.agent} in the {route.env}: {'stands' if routed else made}"
|
|
506
|
+
)
|
|
507
|
+
return steps
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# A token Meta refuses (they expire) leaves the account listed with no number, said in the log.
|
|
511
|
+
async def _at_meta(
|
|
512
|
+
http: httpx.AsyncClient, account: WhatsappAccount, imported: set[str]
|
|
513
|
+
) -> list[OwnedNumber]:
|
|
514
|
+
try:
|
|
515
|
+
number, name = await whatsapp.display_number(
|
|
516
|
+
http, account.access_token, account.phone_number_id
|
|
517
|
+
)
|
|
518
|
+
except UpstreamFailed:
|
|
519
|
+
logger.warning(
|
|
520
|
+
"Meta did not say which number %s is", account.phone_number_id, exc_info=True
|
|
521
|
+
)
|
|
522
|
+
return []
|
|
523
|
+
return [
|
|
524
|
+
OwnedNumber(
|
|
525
|
+
number=number,
|
|
526
|
+
name=name or number,
|
|
527
|
+
imported=number in imported,
|
|
528
|
+
account=account.phone_number_id,
|
|
529
|
+
)
|
|
530
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Where the Twilio trunks send a world's calls: to its SIP name, from its name or a former one."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from pinecall.channels.telephony._twilio import origination_uri, twilio_of
|
|
8
|
+
from pinecall.domain.errors import NotAvailable
|
|
9
|
+
from pinecall.domain.names import ENVS, Env
|
|
10
|
+
from pinecall.process.connections import Connections
|
|
11
|
+
from pinecall.tenancy import orgs
|
|
12
|
+
from pinecall.tenancy.carriers import TwilioAccount, box_twilio, carriers_of
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Repointed:
|
|
17
|
+
"""A trunk of an account that sent a world's calls to one URI and now sends them to another."""
|
|
18
|
+
|
|
19
|
+
account: str
|
|
20
|
+
trunk: str
|
|
21
|
+
world: Env
|
|
22
|
+
was: str
|
|
23
|
+
now: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Once, when a box's SIP moves off its names (a cluster: the names are the HTTPS load balancer's) or
|
|
27
|
+
# a name is retired for another (`former`, the names a world was reached at before): the runtime
|
|
28
|
+
# never touches a carrier on its own, so the operator asks for it (operator-api.md).
|
|
29
|
+
async def repointed(
|
|
30
|
+
connections: Connections, former: Mapping[Env, Sequence[str]] | None = None
|
|
31
|
+
) -> list[Repointed]:
|
|
32
|
+
"""Each trunk of the box's and the orgs' Twilio accounts moved to its world's SIP name."""
|
|
33
|
+
moves = _moves(connections, former or {})
|
|
34
|
+
if not moves:
|
|
35
|
+
return []
|
|
36
|
+
done: list[Repointed] = []
|
|
37
|
+
for account in await _twilio_accounts(connections):
|
|
38
|
+
twilio = twilio_of(connections.http, account)
|
|
39
|
+
for world, was, now in moves:
|
|
40
|
+
for trunk in await twilio.trunks_pointing_at(was):
|
|
41
|
+
await twilio.repointed(trunk.sid, was, now)
|
|
42
|
+
done.append(Repointed(account.account_sid, trunk.sid, world, was, now))
|
|
43
|
+
return done
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# From the world's own name when SIP has a name of its own, and from every former name given.
|
|
47
|
+
def _moves(
|
|
48
|
+
connections: Connections, former: Mapping[Env, Sequence[str]]
|
|
49
|
+
) -> list[tuple[Env, str, str]]:
|
|
50
|
+
settings = connections.settings
|
|
51
|
+
moves: list[tuple[Env, str, str]] = []
|
|
52
|
+
for world in ENVS:
|
|
53
|
+
sip = settings.sip_name_of(world)
|
|
54
|
+
if not sip:
|
|
55
|
+
continue
|
|
56
|
+
names = dict.fromkeys([settings.domain or "", *former.get(world, ())])
|
|
57
|
+
moves.extend(_moved_from(world, [name for name in names if name and name != sip], sip))
|
|
58
|
+
return moves
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _moved_from(world: Env, names: Sequence[str], sip: str) -> list[tuple[Env, str, str]]:
|
|
62
|
+
return [(world, origination_uri(name), origination_uri(sip)) for name in names]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# The box's own account, then each org's, an account two orgs brought counted once.
|
|
66
|
+
async def _twilio_accounts(connections: Connections) -> list[TwilioAccount]:
|
|
67
|
+
pool, vault = connections.pool, connections.vault
|
|
68
|
+
found: dict[str, TwilioAccount] = {}
|
|
69
|
+
# A box with no Twilio account of its own has only the orgs'.
|
|
70
|
+
with contextlib.suppress(NotAvailable):
|
|
71
|
+
boxs = await box_twilio(pool, vault)
|
|
72
|
+
found[boxs.account_sid] = boxs
|
|
73
|
+
for org in await orgs.listed(pool):
|
|
74
|
+
for carrier in await carriers_of(pool, vault, org.id):
|
|
75
|
+
if isinstance(carrier.account, TwilioAccount):
|
|
76
|
+
found.setdefault(carrier.account.account_sid, carrier.account)
|
|
77
|
+
return list(found.values())
|