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,275 @@
|
|
|
1
|
+
"""Which agent answers a number or a channel, per org and world: the routes table."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from psycopg.rows import DictRow
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.call import Route
|
|
8
|
+
from pinecall.domain.names import Channel, Env, RouteOrigin
|
|
9
|
+
from pinecall.postgres.pool import Pool, box_wide
|
|
10
|
+
|
|
11
|
+
OF_ORG = """
|
|
12
|
+
SELECT org, number, agent, channel, env, managed FROM routes
|
|
13
|
+
WHERE org = %(org)s AND env = %(env)s
|
|
14
|
+
ORDER BY added_at, number
|
|
15
|
+
"""
|
|
16
|
+
# One org holds a number (routes_number_held_once), so a number reads one row at most.
|
|
17
|
+
AT = """
|
|
18
|
+
SELECT org, number, agent, channel, env, managed FROM routes
|
|
19
|
+
WHERE channel = %(channel)s AND number = %(number)s
|
|
20
|
+
"""
|
|
21
|
+
OF_NUMBER = """
|
|
22
|
+
SELECT org, number, agent, channel, env, managed FROM routes
|
|
23
|
+
WHERE org = %(org)s AND number = %(number)s
|
|
24
|
+
"""
|
|
25
|
+
# A number added again moves: its agent, channel, world and how it came are the newest said. One
|
|
26
|
+
# written to wait (a hooked number, where the box asks for approval) is unapproved until the
|
|
27
|
+
# operator approves it; one approved once stays approved however it comes again.
|
|
28
|
+
PUT = """
|
|
29
|
+
INSERT INTO routes (org, number, agent, channel, env, managed, account, networks, origin, via,
|
|
30
|
+
approved_at)
|
|
31
|
+
VALUES (%(org)s, %(number)s, %(agent)s, %(channel)s, %(env)s, %(managed)s, %(account)s,
|
|
32
|
+
%(networks)s, %(origin)s, %(via)s,
|
|
33
|
+
CASE WHEN %(waits)s THEN NULL ELSE now() END)
|
|
34
|
+
ON CONFLICT (org, number) DO UPDATE SET agent = excluded.agent, channel = excluded.channel,
|
|
35
|
+
env = excluded.env, managed = excluded.managed, account = excluded.account,
|
|
36
|
+
networks = excluded.networks, origin = excluded.origin, via = excluded.via,
|
|
37
|
+
approved_at = COALESCE(routes.approved_at, excluded.approved_at)
|
|
38
|
+
"""
|
|
39
|
+
APPROVE = """
|
|
40
|
+
UPDATE routes SET approved_at = now(), approved_by = %(by)s
|
|
41
|
+
WHERE org = %(org)s AND number = %(number)s AND approved_at IS NULL
|
|
42
|
+
RETURNING number
|
|
43
|
+
"""
|
|
44
|
+
WAITING = """
|
|
45
|
+
SELECT 1 FROM routes WHERE org = %(org)s AND number = %(number)s AND approved_at IS NULL
|
|
46
|
+
"""
|
|
47
|
+
ELSEWHERE = "SELECT 1 FROM routes WHERE number = %(number)s AND org <> %(org)s"
|
|
48
|
+
REMOVE = "DELETE FROM routes WHERE org = %(org)s AND number = %(number)s RETURNING number"
|
|
49
|
+
MOVE = "UPDATE routes SET env = %(env)s WHERE org = %(org)s AND number = %(number)s RETURNING env"
|
|
50
|
+
MANAGED = "SELECT count(*) AS bought FROM routes WHERE org = %(org)s AND env = %(env)s AND managed"
|
|
51
|
+
# A number the target org already typed stays where it was: one row per number per org.
|
|
52
|
+
WITH_AGENT = """
|
|
53
|
+
UPDATE routes SET org = %(org)s
|
|
54
|
+
WHERE agent = %(agent)s AND org <> %(org)s
|
|
55
|
+
AND NOT EXISTS (SELECT 1 FROM routes AS theirs WHERE theirs.org = %(org)s
|
|
56
|
+
AND theirs.number = routes.number)
|
|
57
|
+
RETURNING number
|
|
58
|
+
"""
|
|
59
|
+
STAYED = "SELECT number FROM routes WHERE agent = %(agent)s AND org <> %(org)s ORDER BY number"
|
|
60
|
+
# A number rung all day is written once a minute, never once a call.
|
|
61
|
+
CALLED = """
|
|
62
|
+
UPDATE routes SET last_call_at = now()
|
|
63
|
+
WHERE org = %(org)s AND number = %(number)s
|
|
64
|
+
AND (last_call_at IS NULL OR last_call_at < now() - interval '1 minute')
|
|
65
|
+
"""
|
|
66
|
+
# A row with what the table keeps beside it: how it was written, the kind of the account its
|
|
67
|
+
# number lives in, and whether the operator approved it.
|
|
68
|
+
RECORDS = """
|
|
69
|
+
SELECT routes.org, routes.number, routes.agent, routes.channel, routes.env, routes.managed,
|
|
70
|
+
routes.origin, routes.via, routes.last_call_at, routes.account, routes.networks,
|
|
71
|
+
routes.approved_at IS NOT NULL AS approved,
|
|
72
|
+
carriers.kind AS carrier
|
|
73
|
+
FROM routes LEFT JOIN carriers
|
|
74
|
+
ON carriers.org = routes.org AND carriers.account = routes.account
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class RouteWrite:
|
|
80
|
+
"""What a row keeps beside its route: who wrote it, the account, the networks, the carrier."""
|
|
81
|
+
|
|
82
|
+
origin: RouteOrigin
|
|
83
|
+
account: str | None = None
|
|
84
|
+
# A number hooked from networks of the org's own, which the operator approves.
|
|
85
|
+
networks: tuple[str, ...] = ()
|
|
86
|
+
# The catalog carrier a number with no account comes through.
|
|
87
|
+
via: str | None = None
|
|
88
|
+
# Written unapproved: no call to it opens until the operator approves it.
|
|
89
|
+
waits: bool = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class RouteRecord:
|
|
94
|
+
"""A route with how it was written, its account's kind, and whether it was approved."""
|
|
95
|
+
|
|
96
|
+
route: Route
|
|
97
|
+
origin: RouteOrigin
|
|
98
|
+
# None for a number no account of the org holds: bought by the box, hooked, typed, or an
|
|
99
|
+
# account since forgotten.
|
|
100
|
+
carrier: str | None
|
|
101
|
+
via: str | None = None
|
|
102
|
+
# When a call to the number last reached the box; None when none ever did.
|
|
103
|
+
last_call_at: float | None = None
|
|
104
|
+
# The org's account the number lives in, and a hooked number's own networks.
|
|
105
|
+
account: str | None = None
|
|
106
|
+
networks: tuple[str, ...] = ()
|
|
107
|
+
# False for a number the org hooked that the operator has not approved: no call to it opens.
|
|
108
|
+
approved: bool = True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
ON_THE_BOX = RECORDS + "ORDER BY routes.number, routes.channel, routes.added_at"
|
|
112
|
+
OF_NUMBER_RECORD = RECORDS + "WHERE routes.org = %(org)s AND routes.number = %(number)s"
|
|
113
|
+
PHONES_OF_ORG = (
|
|
114
|
+
RECORDS
|
|
115
|
+
+ "WHERE routes.org = %(org)s AND routes.channel = 'phone' AND routes.number IS NOT NULL "
|
|
116
|
+
+ "ORDER BY routes.added_at, routes.number"
|
|
117
|
+
)
|
|
118
|
+
OF_ORG_RECORDS = (
|
|
119
|
+
RECORDS
|
|
120
|
+
+ "WHERE routes.org = %(org)s AND routes.env = %(env)s ORDER BY routes.added_at, routes.number"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def of_org(pool: Pool, org: str, env: Env) -> list[Route]:
|
|
125
|
+
"""The org's numbers in one world, oldest first."""
|
|
126
|
+
async with pool.connection() as connection:
|
|
127
|
+
rows = await (await connection.execute(OF_ORG, {"org": org, "env": env})).fetchall()
|
|
128
|
+
return [_route(row) for row in rows]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def at(pool: Pool, channel: Channel, number: str) -> Route | None:
|
|
132
|
+
"""The route a call dialled to this number takes, whatever org holds it."""
|
|
133
|
+
params = {"channel": channel, "number": number}
|
|
134
|
+
with box_wide():
|
|
135
|
+
async with pool.connection() as connection:
|
|
136
|
+
row = await (await connection.execute(AT, params)).fetchone()
|
|
137
|
+
return None if row is None else _route(row)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def on_the_box(pool: Pool) -> list[RouteRecord]:
|
|
141
|
+
"""Every route of every org and world, by number, and which of them answers each number."""
|
|
142
|
+
async with pool.connection() as connection:
|
|
143
|
+
rows = await (await connection.execute(ON_THE_BOX)).fetchall()
|
|
144
|
+
return [_record(row) for row in rows]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def records_of(pool: Pool, org: str, env: Env) -> list[RouteRecord]:
|
|
148
|
+
"""The org's routes in one world, oldest first, each with what the table keeps beside it."""
|
|
149
|
+
async with pool.connection() as connection:
|
|
150
|
+
rows = await (await connection.execute(OF_ORG_RECORDS, {"org": org, "env": env})).fetchall()
|
|
151
|
+
return [_record(row) for row in rows]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def phones_of(pool: Pool, org: str) -> list[RouteRecord]:
|
|
155
|
+
"""The org's phone numbers in both worlds, oldest first, with what the table keeps."""
|
|
156
|
+
async with pool.connection() as connection:
|
|
157
|
+
rows = await (await connection.execute(PHONES_OF_ORG, {"org": org})).fetchall()
|
|
158
|
+
return [_record(row) for row in rows]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def record_of(pool: Pool, org: str, number: str) -> RouteRecord | None:
|
|
162
|
+
"""The org's route at this number, whatever world it is in, with what the table keeps."""
|
|
163
|
+
params = {"org": org, "number": number}
|
|
164
|
+
async with pool.connection() as connection:
|
|
165
|
+
row = await (await connection.execute(OF_NUMBER_RECORD, params)).fetchone()
|
|
166
|
+
return None if row is None else _record(row)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def of_number(pool: Pool, org: str, number: str) -> Route | None:
|
|
170
|
+
"""The org's route at this number, whatever world it is in."""
|
|
171
|
+
async with pool.connection() as connection:
|
|
172
|
+
row = await (await connection.execute(OF_NUMBER, {"org": org, "number": number})).fetchone()
|
|
173
|
+
return None if row is None else _route(row)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def put(pool: Pool, route: Route, written: RouteWrite) -> None:
|
|
177
|
+
"""Keep the route, moving the number if the org had it, with how it was written."""
|
|
178
|
+
row = {
|
|
179
|
+
"org": route.org,
|
|
180
|
+
"number": route.number,
|
|
181
|
+
"agent": route.agent,
|
|
182
|
+
"channel": route.channel,
|
|
183
|
+
"env": route.env,
|
|
184
|
+
"managed": route.managed,
|
|
185
|
+
"account": written.account,
|
|
186
|
+
"networks": list(written.networks),
|
|
187
|
+
"origin": written.origin,
|
|
188
|
+
"via": written.via,
|
|
189
|
+
"waits": written.waits,
|
|
190
|
+
}
|
|
191
|
+
async with pool.connection() as connection:
|
|
192
|
+
await connection.execute(PUT, row)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def called(pool: Pool, org: str, number: str) -> None:
|
|
196
|
+
"""Note that a call to the org's number reached the box now."""
|
|
197
|
+
async with pool.connection() as connection:
|
|
198
|
+
await connection.execute(CALLED, {"org": org, "number": number})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def approve(pool: Pool, org: str, number: str, by: str) -> bool:
|
|
202
|
+
"""The operator's word that the org's hooked number is the org's; whether one waited."""
|
|
203
|
+
async with pool.connection() as connection:
|
|
204
|
+
done = await connection.execute(APPROVE, {"org": org, "number": number, "by": by})
|
|
205
|
+
return await done.fetchone() is not None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def is_waiting(pool: Pool, org: str, number: str) -> bool:
|
|
209
|
+
"""Whether the org's number is one it hooked that the operator has not approved."""
|
|
210
|
+
async with pool.connection() as connection:
|
|
211
|
+
row = await (await connection.execute(WAITING, {"org": org, "number": number})).fetchone()
|
|
212
|
+
return row is not None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def held_elsewhere(pool: Pool, org: str, number: str) -> bool:
|
|
216
|
+
"""Whether another org holds the number on this box."""
|
|
217
|
+
with box_wide():
|
|
218
|
+
async with pool.connection() as connection:
|
|
219
|
+
found = await connection.execute(ELSEWHERE, {"org": org, "number": number})
|
|
220
|
+
return await found.fetchone() is not None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def remove(pool: Pool, org: str, number: str) -> bool:
|
|
224
|
+
"""Forget the org's route at the number; whether there was one."""
|
|
225
|
+
async with pool.connection() as connection:
|
|
226
|
+
gone = await connection.execute(REMOVE, {"org": org, "number": number})
|
|
227
|
+
return await gone.fetchone() is not None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def moved(pool: Pool, org: str, number: str, env: Env) -> bool:
|
|
231
|
+
"""Move the org's number into the world; whether it had it."""
|
|
232
|
+
async with pool.connection() as connection:
|
|
233
|
+
done = await connection.execute(MOVE, {"org": org, "number": number, "env": env})
|
|
234
|
+
return await done.fetchone() is not None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def moved_with_agent(pool: Pool, agent: str, org: str) -> tuple[list[str], list[str]]:
|
|
238
|
+
"""Move the agent's numbers into the org; the numbers moved, and the ones that stayed."""
|
|
239
|
+
async with pool.connection() as connection, connection.transaction():
|
|
240
|
+
moved = await (
|
|
241
|
+
await connection.execute(WITH_AGENT, {"agent": agent, "org": org})
|
|
242
|
+
).fetchall()
|
|
243
|
+
stayed = await (await connection.execute(STAYED, {"agent": agent, "org": org})).fetchall()
|
|
244
|
+
return sorted(str(row["number"]) for row in moved), [str(row["number"]) for row in stayed]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def managed_in(pool: Pool, org: str, env: Env) -> int:
|
|
248
|
+
"""How many of the org's numbers in the world the box bought."""
|
|
249
|
+
async with pool.connection() as connection:
|
|
250
|
+
row = await (await connection.execute(MANAGED, {"org": org, "env": env})).fetchone()
|
|
251
|
+
return 0 if row is None else int(row["bought"])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _route(row: DictRow) -> Route:
|
|
255
|
+
return Route(
|
|
256
|
+
org=row["org"],
|
|
257
|
+
agent=row["agent"],
|
|
258
|
+
channel=row["channel"],
|
|
259
|
+
number=row["number"],
|
|
260
|
+
env=row["env"],
|
|
261
|
+
managed=row["managed"],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _record(row: DictRow) -> RouteRecord:
|
|
266
|
+
return RouteRecord(
|
|
267
|
+
route=_route(row),
|
|
268
|
+
origin=row["origin"],
|
|
269
|
+
carrier=row["carrier"],
|
|
270
|
+
via=row["via"],
|
|
271
|
+
last_call_at=None if row["last_call_at"] is None else row["last_call_at"].timestamp(),
|
|
272
|
+
account=row["account"],
|
|
273
|
+
networks=tuple(str(network) for network in row["networks"] or ()),
|
|
274
|
+
approved=bool(row["approved"]),
|
|
275
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Twilio's REST API, written by hand: numbers, trunks, credential lists, the shop."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import (
|
|
7
|
+
BaseModel,
|
|
8
|
+
ConfigDict,
|
|
9
|
+
TypeAdapter,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.errors import (
|
|
14
|
+
DeclarationRefused,
|
|
15
|
+
UpstreamFailed,
|
|
16
|
+
)
|
|
17
|
+
from pinecall.domain.names import Json
|
|
18
|
+
from pinecall.tenancy.carriers import TwilioAccount
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
TRUNKING = "https://trunking.twilio.com/v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ACCOUNTS = "https://api.twilio.com/2010-04-01"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Never on a call's path: an import or a purchase waits for Twilio, a call never does.
|
|
30
|
+
TIMEOUT_S = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# livekit-sip listens here, and the fence opens it to the carrier alone.
|
|
34
|
+
SIP_PORT = 5060
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Termination hosts are global across Twilio; the label is `[a-z0-9-]`. Twilio wants the whole
|
|
38
|
+
# host in DomainName: a bare label is its error 21245.
|
|
39
|
+
TWILIO_SAID = "Twilio: {message}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
TERMINATION_SUFFIX = ".pstn.twilio.com"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ROOM_PREFIX = "call-"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_OBJECT: TypeAdapter[dict[str, Json]] = TypeAdapter(dict[str, Json])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TWILIO_REFUSED = "Twilio refused these credentials for {account}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TwilioNumber(BaseModel):
|
|
55
|
+
"""A number an account owns, and the trunk it is attached to, if any."""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
58
|
+
|
|
59
|
+
sid: str
|
|
60
|
+
phone_number: str
|
|
61
|
+
friendly_name: str = ""
|
|
62
|
+
trunk_sid: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TwilioTrunk(BaseModel):
|
|
66
|
+
"""A SIP trunk of an account."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
69
|
+
|
|
70
|
+
sid: str
|
|
71
|
+
friendly_name: str = ""
|
|
72
|
+
domain_name: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Origination(BaseModel):
|
|
76
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
77
|
+
|
|
78
|
+
sid: str
|
|
79
|
+
sip_url: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Listed(BaseModel):
|
|
83
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
84
|
+
|
|
85
|
+
sid: str
|
|
86
|
+
friendly_name: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Credential(BaseModel):
|
|
90
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
91
|
+
|
|
92
|
+
username: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _ForSale(BaseModel):
|
|
96
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
97
|
+
|
|
98
|
+
phone_number: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Meta(BaseModel):
|
|
102
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
103
|
+
|
|
104
|
+
next_page_url: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# The Accounts API names the next page by a path, the Trunking API by a whole URL in `meta`.
|
|
108
|
+
class _Page(BaseModel):
|
|
109
|
+
model_config = ConfigDict(frozen=True, extra="allow")
|
|
110
|
+
|
|
111
|
+
next_page_uri: str | None = None
|
|
112
|
+
meta: _Meta | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Refusal(BaseModel):
|
|
116
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
117
|
+
|
|
118
|
+
message: str = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Twilio:
|
|
122
|
+
"""One Twilio account's REST API, over the box's HTTP client."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, http: httpx.AsyncClient, account_sid: str, user: str, secret: str) -> None:
|
|
125
|
+
"""The account and the pair it is asked with; nothing is asked yet."""
|
|
126
|
+
self.http = http
|
|
127
|
+
self.sid = account_sid
|
|
128
|
+
self.auth = httpx.BasicAuth(user, secret)
|
|
129
|
+
self.home = f"{ACCOUNTS}/Accounts/{account_sid}"
|
|
130
|
+
|
|
131
|
+
async def verified(self) -> bool:
|
|
132
|
+
"""Whether the pair opens the account."""
|
|
133
|
+
answer = await self.http.get(f"{self.home}.json", auth=self.auth, timeout=TIMEOUT_S)
|
|
134
|
+
if answer.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
|
|
135
|
+
return False
|
|
136
|
+
_answer_of(answer)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
async def numbers(self) -> list[TwilioNumber]:
|
|
140
|
+
"""Every number the account owns, every page."""
|
|
141
|
+
listed = await self._every(
|
|
142
|
+
f"{self.home}/IncomingPhoneNumbers.json?PageSize=200", "incoming_phone_numbers"
|
|
143
|
+
)
|
|
144
|
+
return _NUMBERS.validate_python(listed)
|
|
145
|
+
|
|
146
|
+
async def number(self, number: str) -> TwilioNumber | None:
|
|
147
|
+
"""The account's number, or None when it owns no such number."""
|
|
148
|
+
answer = await self._get(f"{self.home}/IncomingPhoneNumbers.json", {"PhoneNumber": number})
|
|
149
|
+
found = _NUMBERS.validate_python(answer.get("incoming_phone_numbers", []))
|
|
150
|
+
return found[0] if found else None
|
|
151
|
+
|
|
152
|
+
async def trunk_pointing_at(self, uri: str) -> TwilioTrunk | None:
|
|
153
|
+
"""The account's trunk whose origination is this URI, whatever it is named."""
|
|
154
|
+
found = await self.trunks_pointing_at(uri)
|
|
155
|
+
return found[0] if found else None
|
|
156
|
+
|
|
157
|
+
async def trunks_pointing_at(self, uri: str) -> list[TwilioTrunk]:
|
|
158
|
+
"""Every trunk of the account whose origination is this URI."""
|
|
159
|
+
trunks = _TRUNKS.validate_python(
|
|
160
|
+
await self._every(f"{TRUNKING}/Trunks?PageSize=50", "trunks")
|
|
161
|
+
)
|
|
162
|
+
return [
|
|
163
|
+
trunk
|
|
164
|
+
for trunk in trunks
|
|
165
|
+
if uri in {origination.sip_url for origination in await self._originations(trunk.sid)}
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
async def repointed(self, trunk: str, was: str, now: str) -> None:
|
|
169
|
+
"""The trunk's origination that is `was` sent to `now`, its weight and order kept."""
|
|
170
|
+
for origination in await self._originations(trunk):
|
|
171
|
+
if origination.sip_url == was:
|
|
172
|
+
url = f"{TRUNKING}/Trunks/{trunk}/OriginationUrls/{origination.sid}"
|
|
173
|
+
await self._post(url, {"SipUrl": now})
|
|
174
|
+
|
|
175
|
+
async def trunk(self, sid: str) -> TwilioTrunk:
|
|
176
|
+
"""One trunk of the account."""
|
|
177
|
+
return TwilioTrunk.model_validate(await self._get(f"{TRUNKING}/Trunks/{sid}"))
|
|
178
|
+
|
|
179
|
+
async def originations(self, sid: str) -> list[str]:
|
|
180
|
+
"""Where the trunk sends a call."""
|
|
181
|
+
return [origination.sip_url for origination in await self._originations(sid)]
|
|
182
|
+
|
|
183
|
+
async def trunk_made(self, name: str, uri: str) -> TwilioTrunk:
|
|
184
|
+
"""A new trunk, sending its calls to the URI."""
|
|
185
|
+
made = TwilioTrunk.model_validate(
|
|
186
|
+
await self._post(f"{TRUNKING}/Trunks", {"FriendlyName": name})
|
|
187
|
+
)
|
|
188
|
+
form = {
|
|
189
|
+
"FriendlyName": name,
|
|
190
|
+
"SipUrl": uri,
|
|
191
|
+
"Weight": "10",
|
|
192
|
+
"Priority": "10",
|
|
193
|
+
"Enabled": "true",
|
|
194
|
+
}
|
|
195
|
+
await self._post(f"{TRUNKING}/Trunks/{made.sid}/OriginationUrls", form)
|
|
196
|
+
return made
|
|
197
|
+
|
|
198
|
+
async def attach(self, trunk: str, number: str) -> None:
|
|
199
|
+
"""Attach the number to the trunk: its voice URL is then ignored."""
|
|
200
|
+
await self._post(f"{TRUNKING}/Trunks/{trunk}/PhoneNumbers", {"PhoneNumberSid": number})
|
|
201
|
+
|
|
202
|
+
async def detach(self, trunk: str, number: str) -> None:
|
|
203
|
+
"""Take the number off a trunk."""
|
|
204
|
+
answer = await self.http.delete(
|
|
205
|
+
f"{TRUNKING}/Trunks/{trunk}/PhoneNumbers/{number}", auth=self.auth, timeout=TIMEOUT_S
|
|
206
|
+
)
|
|
207
|
+
if answer.is_error:
|
|
208
|
+
_answer_of(answer)
|
|
209
|
+
|
|
210
|
+
async def for_sale(self, country: str, area_code: str | None) -> str | None:
|
|
211
|
+
"""One local voice number on sale in the country, or None."""
|
|
212
|
+
params = {"VoiceEnabled": "true", "PageSize": "1"} | (
|
|
213
|
+
{"AreaCode": area_code} if area_code else {}
|
|
214
|
+
)
|
|
215
|
+
answer = await self._get(
|
|
216
|
+
f"{self.home}/AvailablePhoneNumbers/{country.upper()}/Local.json", params
|
|
217
|
+
)
|
|
218
|
+
found = _FOR_SALE.validate_python(answer.get("available_phone_numbers", []))
|
|
219
|
+
return found[0].phone_number if found else None
|
|
220
|
+
|
|
221
|
+
async def bought(self, number: str) -> TwilioNumber:
|
|
222
|
+
"""Buy the number onto the account: this costs money."""
|
|
223
|
+
answer = await self._post(f"{self.home}/IncomingPhoneNumbers.json", {"PhoneNumber": number})
|
|
224
|
+
return TwilioNumber.model_validate(answer)
|
|
225
|
+
|
|
226
|
+
async def terminated(self, trunk: str, host: str) -> None:
|
|
227
|
+
"""Set where the box dials the trunk: <label>.pstn.twilio.com, the whole host."""
|
|
228
|
+
await self._post(f"{TRUNKING}/Trunks/{trunk}", {"DomainName": host})
|
|
229
|
+
|
|
230
|
+
async def credential_list(self, name: str) -> str | None:
|
|
231
|
+
"""The account's credential list by name, or None."""
|
|
232
|
+
listed = _LISTS.validate_python(
|
|
233
|
+
await self._every(
|
|
234
|
+
f"{self.home}/SIP/CredentialLists.json?PageSize=50", "credential_lists"
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return next((found.sid for found in listed if found.friendly_name == name), None)
|
|
238
|
+
|
|
239
|
+
async def credential_list_made(self, name: str) -> str:
|
|
240
|
+
"""A new, empty credential list."""
|
|
241
|
+
made = _Listed.model_validate(
|
|
242
|
+
await self._post(f"{self.home}/SIP/CredentialLists.json", {"FriendlyName": name})
|
|
243
|
+
)
|
|
244
|
+
return made.sid
|
|
245
|
+
|
|
246
|
+
async def usernames_in(self, credential_list: str) -> list[str]:
|
|
247
|
+
"""The usernames a credential list holds; never a password, which Twilio never shows."""
|
|
248
|
+
listed = await self._every(
|
|
249
|
+
f"{self.home}/SIP/CredentialLists/{credential_list}/Credentials.json", "credentials"
|
|
250
|
+
)
|
|
251
|
+
return [credential.username for credential in _CREDENTIALS.validate_python(listed)]
|
|
252
|
+
|
|
253
|
+
async def credential_added(self, credential_list: str, username: str, password: str) -> None:
|
|
254
|
+
"""One more credential on the list; Twilio shows its password this once."""
|
|
255
|
+
form = {"Username": username, "Password": password}
|
|
256
|
+
await self._post(
|
|
257
|
+
f"{self.home}/SIP/CredentialLists/{credential_list}/Credentials.json", form
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def lists_on(self, trunk: str) -> list[str]:
|
|
261
|
+
"""The credential lists the trunk asks the box for."""
|
|
262
|
+
listed = await self._every(f"{TRUNKING}/Trunks/{trunk}/CredentialLists", "credential_lists")
|
|
263
|
+
return [found.sid for found in _LISTS.validate_python(listed)]
|
|
264
|
+
|
|
265
|
+
async def list_attached(self, trunk: str, credential_list: str) -> None:
|
|
266
|
+
"""Have the trunk ask the box for this credential."""
|
|
267
|
+
form = {"CredentialListSid": credential_list}
|
|
268
|
+
await self._post(f"{TRUNKING}/Trunks/{trunk}/CredentialLists", form)
|
|
269
|
+
|
|
270
|
+
async def _originations(self, trunk: str) -> list[_Origination]:
|
|
271
|
+
listed = await self._every(f"{TRUNKING}/Trunks/{trunk}/OriginationUrls", "origination_urls")
|
|
272
|
+
return _ORIGINATIONS.validate_python(listed)
|
|
273
|
+
|
|
274
|
+
# A listing past its first page silently lost the rest in v1; every page is read.
|
|
275
|
+
async def _every(self, url: str, key: str) -> list[Json]:
|
|
276
|
+
found: list[Json] = []
|
|
277
|
+
following: str | None = url
|
|
278
|
+
while following is not None:
|
|
279
|
+
answer = await self._get(following)
|
|
280
|
+
listed = answer.get(key)
|
|
281
|
+
found += listed if isinstance(listed, list) else []
|
|
282
|
+
page = _Page.model_validate(answer)
|
|
283
|
+
path = page.next_page_uri
|
|
284
|
+
following = (
|
|
285
|
+
f"https://{httpx.URL(url).host}{path}"
|
|
286
|
+
if path
|
|
287
|
+
else (page.meta.next_page_url if page.meta else None)
|
|
288
|
+
)
|
|
289
|
+
return found
|
|
290
|
+
|
|
291
|
+
async def _get(self, url: str, params: dict[str, str] | None = None) -> dict[str, Json]:
|
|
292
|
+
answer = await self.http.get(url, params=params, auth=self.auth, timeout=TIMEOUT_S)
|
|
293
|
+
return _answer_of(answer)
|
|
294
|
+
|
|
295
|
+
async def _post(self, url: str, form: dict[str, str]) -> dict[str, Json]:
|
|
296
|
+
answer = await self.http.post(url, data=form, auth=self.auth, timeout=TIMEOUT_S)
|
|
297
|
+
return _answer_of(answer)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
_NUMBERS: TypeAdapter[list[TwilioNumber]] = TypeAdapter(list[TwilioNumber])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
_TRUNKS: TypeAdapter[list[TwilioTrunk]] = TypeAdapter(list[TwilioTrunk])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_ORIGINATIONS: TypeAdapter[list[_Origination]] = TypeAdapter(list[_Origination])
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
_LISTS: TypeAdapter[list[_Listed]] = TypeAdapter(list[_Listed])
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_CREDENTIALS: TypeAdapter[list[_Credential]] = TypeAdapter(list[_Credential])
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
_FOR_SALE: TypeAdapter[list[_ForSale]] = TypeAdapter(list[_ForSale])
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def origination_uri(domain: str) -> str:
|
|
319
|
+
"""The SIP URI a Twilio trunk sends this box's calls to."""
|
|
320
|
+
return f"sip:{domain}:{SIP_PORT};transport=udp"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def termination_host(domain: str, account: str) -> str:
|
|
324
|
+
"""The host the box dials an account through: one per account per box, `[a-z0-9-]`."""
|
|
325
|
+
named = f"{domain}-{account[-8:]}".lower()
|
|
326
|
+
label = "".join(letter if letter.isalnum() else "-" for letter in named).strip("-")
|
|
327
|
+
return f"{label}{TERMINATION_SUFFIX}"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def twilio_of(http: httpx.AsyncClient, account: TwilioAccount) -> Twilio:
|
|
331
|
+
"""The account's REST API, asked with its pair."""
|
|
332
|
+
return Twilio(http, account.account_sid, account.user, account.secret)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def verify(http: httpx.AsyncClient, account: TwilioAccount) -> None:
|
|
336
|
+
"""Refuse a pair Twilio does not open the account with, in our words."""
|
|
337
|
+
if not await twilio_of(http, account).verified():
|
|
338
|
+
raise DeclarationRefused(TWILIO_REFUSED.format(account=account.account_sid))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Twilio's own sentence reaches the person; the pair never does.
|
|
342
|
+
def _answer_of(answer: httpx.Response) -> dict[str, Json]:
|
|
343
|
+
if answer.is_error:
|
|
344
|
+
try:
|
|
345
|
+
message = _Refusal.model_validate_json(answer.content).message
|
|
346
|
+
except ValidationError:
|
|
347
|
+
message = f"{answer.status_code}"
|
|
348
|
+
raise UpstreamFailed(TWILIO_SAID.format(message=message or answer.status_code))
|
|
349
|
+
return _OBJECT.validate_json(answer.content) if answer.content else {}
|