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,126 @@
|
|
|
1
|
+
"""A version of an agent's settings on a share of its calls: set, cleared, which calls take it."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.scope import Scope
|
|
8
|
+
from pinecall.postgres.pool import Pool
|
|
9
|
+
|
|
10
|
+
# A share is a whole percent of the scope's calls.
|
|
11
|
+
ALL_CALLS = 100
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
STANDING = """
|
|
15
|
+
SELECT holder, version, share, author, note, set_at FROM agent_canaries
|
|
16
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND agent = %(agent)s
|
|
17
|
+
ORDER BY set_at DESC, id DESC
|
|
18
|
+
LIMIT 1
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Only ever added: a version and a share set it, a row with neither clears it.
|
|
23
|
+
# Stamped by the log's clock, as a call's head is, so which came first is one clock's word.
|
|
24
|
+
PUT = """
|
|
25
|
+
INSERT INTO agent_canaries (org, env, holder, agent, version, share, author, note, set_at)
|
|
26
|
+
VALUES (%(org)s, %(env)s, %(holder)s, %(agent)s, %(version)s, %(share)s, %(author)s, %(note)s,
|
|
27
|
+
to_timestamp(%(at)s))
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# The canary each level of the call's scope had when the call opened.
|
|
32
|
+
STOOD_AT = """
|
|
33
|
+
SELECT DISTINCT ON (holder) holder, version, share FROM agent_canaries
|
|
34
|
+
WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND agent = %(agent)s
|
|
35
|
+
AND set_at <= to_timestamp(%(at)s)
|
|
36
|
+
ORDER BY holder, set_at DESC, id DESC
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Canary:
|
|
42
|
+
"""Which version takes a share of the calls, and how many in a hundred."""
|
|
43
|
+
|
|
44
|
+
version: int
|
|
45
|
+
share: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class CanarySet:
|
|
50
|
+
"""A canary as written: the version and share, or None to clear it, who, when and why."""
|
|
51
|
+
|
|
52
|
+
canary: Canary | None
|
|
53
|
+
author: str
|
|
54
|
+
at: float
|
|
55
|
+
note: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Current:
|
|
60
|
+
"""The canary a scope stands on now: whose, which, who set it, why and when."""
|
|
61
|
+
|
|
62
|
+
holder: str
|
|
63
|
+
canary: Canary
|
|
64
|
+
author: str
|
|
65
|
+
note: str | None
|
|
66
|
+
set_at: datetime
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# sha256, not hash(): the same call lands in the same place in every process and every release,
|
|
70
|
+
# so a call asked for again (a gateway that restarted) keeps its version.
|
|
71
|
+
def bucket_of(call: str) -> int:
|
|
72
|
+
"""The call's place among a hundred; a canary of share N takes the places under N."""
|
|
73
|
+
return int(sha256(call.encode()).hexdigest()[:8], 16) % ALL_CALLS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def current(pool: Pool, scope: Scope, agent: str) -> Current | None:
|
|
77
|
+
"""The scope's own canary for the agent, or None when none stands or it was cleared."""
|
|
78
|
+
params = {"org": scope.org, "env": scope.env, "holder": scope.holder, "agent": agent}
|
|
79
|
+
async with pool.connection() as connection:
|
|
80
|
+
row = await (await connection.execute(STANDING, params)).fetchone()
|
|
81
|
+
if row is None or row["version"] is None:
|
|
82
|
+
return None
|
|
83
|
+
return Current(
|
|
84
|
+
holder=row["holder"],
|
|
85
|
+
canary=Canary(version=row["version"], share=row["share"]),
|
|
86
|
+
author=row["author"],
|
|
87
|
+
note=row["note"],
|
|
88
|
+
set_at=row["set_at"],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def put(pool: Pool, scope: Scope, agent: str, written: CanarySet) -> None:
|
|
93
|
+
"""Set the scope's canary for the agent, or clear it."""
|
|
94
|
+
wanted = written.canary
|
|
95
|
+
params = {
|
|
96
|
+
"org": scope.org,
|
|
97
|
+
"env": scope.env,
|
|
98
|
+
"holder": scope.holder,
|
|
99
|
+
"agent": agent,
|
|
100
|
+
"version": None if wanted is None else wanted.version,
|
|
101
|
+
"share": None if wanted is None else wanted.share,
|
|
102
|
+
"author": written.author,
|
|
103
|
+
"note": written.note,
|
|
104
|
+
"at": written.at,
|
|
105
|
+
}
|
|
106
|
+
async with pool.connection() as connection:
|
|
107
|
+
await connection.execute(PUT, params)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# A call runs the canary's version only when it was picked for it: the rest never run it.
|
|
111
|
+
async def ran_the_canary(
|
|
112
|
+
pool: Pool, scope: Scope, agent: str, version: int | None, at: float
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Whether a call that opened then on this version was one of a canary's share."""
|
|
115
|
+
if version is None:
|
|
116
|
+
return False
|
|
117
|
+
params = {
|
|
118
|
+
"org": scope.org,
|
|
119
|
+
"env": scope.env,
|
|
120
|
+
"holder": scope.holder,
|
|
121
|
+
"agent": agent,
|
|
122
|
+
"at": at,
|
|
123
|
+
}
|
|
124
|
+
async with pool.connection() as connection:
|
|
125
|
+
rows = await (await connection.execute(STOOD_AT, params)).fetchall()
|
|
126
|
+
return any(row["version"] == version and row["share"] for row in rows)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The networks an org asks the box to open 5060 to, and the operator's answer to each."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from ipaddress import IPv4Network, ip_network
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from psycopg.rows import DictRow
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.errors import DeclarationRefused, NotFound
|
|
11
|
+
from pinecall.postgres.pool import Pool
|
|
12
|
+
|
|
13
|
+
type NetworkState = Literal["waiting", "approved", "refused"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# A carrier's edge is a few addresses; anything wider is a network nobody vouches for.
|
|
17
|
+
WIDEST = 24
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
NOT_A_NETWORK = "{network} is not an IPv4 address or network, like 198.51.100.7/32"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
TOO_WIDE = "{network} is wider than a /{widest}: name the addresses your PBX or carrier calls from"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
NOT_PUBLIC = "{network} is not a public address: a carrier reaches the box from the internet"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
NO_SUCH_ASK = "no network was asked for with id {ask}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ASK = """
|
|
33
|
+
INSERT INTO carrier_networks (org, source, network) VALUES (%(org)s, %(source)s, %(network)s)
|
|
34
|
+
ON CONFLICT (org, source, network) DO NOTHING
|
|
35
|
+
"""
|
|
36
|
+
FORGET = """
|
|
37
|
+
DELETE FROM carrier_networks
|
|
38
|
+
WHERE org = %(org)s AND source = %(source)s AND NOT (network = ANY(%(networks)s::cidr[]))
|
|
39
|
+
"""
|
|
40
|
+
OF_SOURCE = """
|
|
41
|
+
SELECT id, org, source, network, state, asked_at, decided_by, decided_at FROM carrier_networks
|
|
42
|
+
WHERE org = %(org)s AND source = %(source)s ORDER BY asked_at, network
|
|
43
|
+
"""
|
|
44
|
+
OF_ORG = """
|
|
45
|
+
SELECT id, org, source, network, state, asked_at, decided_by, decided_at FROM carrier_networks
|
|
46
|
+
WHERE org = %(org)s ORDER BY asked_at, network
|
|
47
|
+
"""
|
|
48
|
+
LISTED = """
|
|
49
|
+
SELECT id, org, source, network, state, asked_at, decided_by, decided_at FROM carrier_networks
|
|
50
|
+
ORDER BY asked_at, id
|
|
51
|
+
"""
|
|
52
|
+
IN_STATE = """
|
|
53
|
+
SELECT id, org, source, network, state, asked_at, decided_by, decided_at FROM carrier_networks
|
|
54
|
+
WHERE state = %(state)s ORDER BY asked_at, id
|
|
55
|
+
"""
|
|
56
|
+
DECIDE = """
|
|
57
|
+
UPDATE carrier_networks SET state = %(state)s, decided_by = %(by)s, decided_at = now()
|
|
58
|
+
WHERE id = %(id)s RETURNING id, org, source, network, state, asked_at, decided_by, decided_at
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class NetworkAsk:
|
|
64
|
+
"""A network an org asked for: what asked, the network, and where the operator stands on it."""
|
|
65
|
+
|
|
66
|
+
id: int
|
|
67
|
+
org: str
|
|
68
|
+
source: str
|
|
69
|
+
network: str
|
|
70
|
+
state: NetworkState
|
|
71
|
+
asked_at: float
|
|
72
|
+
decided_by: str | None
|
|
73
|
+
decided_at: float | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def checked(network: str) -> str:
|
|
77
|
+
"""The network written the one way, or DeclarationRefused saying why it cannot be admitted."""
|
|
78
|
+
try:
|
|
79
|
+
parsed = ip_network(network.strip(), strict=False)
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise DeclarationRefused(NOT_A_NETWORK.format(network=network)) from None
|
|
82
|
+
if not isinstance(parsed, IPv4Network):
|
|
83
|
+
raise DeclarationRefused(NOT_A_NETWORK.format(network=network))
|
|
84
|
+
if parsed.prefixlen < WIDEST:
|
|
85
|
+
raise DeclarationRefused(TOO_WIDE.format(network=parsed, widest=WIDEST))
|
|
86
|
+
if not parsed.is_global:
|
|
87
|
+
raise DeclarationRefused(NOT_PUBLIC.format(network=parsed))
|
|
88
|
+
return str(parsed)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def written(network: str) -> str:
|
|
92
|
+
"""The network as the table writes it (an address is a /32), or as given when it is none."""
|
|
93
|
+
try:
|
|
94
|
+
return str(ip_network(network.strip(), strict=False))
|
|
95
|
+
except ValueError:
|
|
96
|
+
return network
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def ask(pool: Pool, org: str, source: str, networks: Sequence[str]) -> list[NetworkAsk]:
|
|
100
|
+
"""Ask for these networks for the source, forgetting the ones it no longer names; its rows."""
|
|
101
|
+
wanted = [checked(network) for network in networks]
|
|
102
|
+
params = {"org": org, "source": source, "networks": wanted}
|
|
103
|
+
async with pool.connection() as connection, connection.transaction():
|
|
104
|
+
for network in wanted:
|
|
105
|
+
await connection.execute(ASK, {"org": org, "source": source, "network": network})
|
|
106
|
+
await connection.execute(FORGET, params)
|
|
107
|
+
rows = await (await connection.execute(OF_SOURCE, params)).fetchall()
|
|
108
|
+
return [_ask(row) for row in rows]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def of_org(pool: Pool, org: str) -> list[NetworkAsk]:
|
|
112
|
+
"""Every network the org asked for, oldest first."""
|
|
113
|
+
async with pool.connection() as connection:
|
|
114
|
+
rows = await (await connection.execute(OF_ORG, {"org": org})).fetchall()
|
|
115
|
+
return [_ask(row) for row in rows]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def approved(pool: Pool, org: str) -> dict[str, tuple[str, ...]]:
|
|
119
|
+
"""The networks approved for each source of the org."""
|
|
120
|
+
found: dict[str, tuple[str, ...]] = {}
|
|
121
|
+
for row in await of_org(pool, org):
|
|
122
|
+
if row.state == "approved":
|
|
123
|
+
found[row.source] = (*found.get(row.source, ()), row.network)
|
|
124
|
+
return found
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def listed(pool: Pool, state: NetworkState | None = None) -> list[NetworkAsk]:
|
|
128
|
+
"""Every network asked for on the box, or those in one state, oldest first."""
|
|
129
|
+
query, params = (LISTED, {}) if state is None else (IN_STATE, {"state": state})
|
|
130
|
+
async with pool.connection() as connection:
|
|
131
|
+
rows = await (await connection.execute(query, params)).fetchall()
|
|
132
|
+
return [_ask(row) for row in rows]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def decide(pool: Pool, ask_id: int, state: NetworkState, by: str) -> NetworkAsk:
|
|
136
|
+
"""Approve or refuse a network asked for; NotFound for an id nobody asked with."""
|
|
137
|
+
async with pool.connection() as connection:
|
|
138
|
+
row = await (
|
|
139
|
+
await connection.execute(DECIDE, {"id": ask_id, "state": state, "by": by})
|
|
140
|
+
).fetchone()
|
|
141
|
+
if row is None:
|
|
142
|
+
raise NotFound(NO_SUCH_ASK.format(ask=ask_id))
|
|
143
|
+
return _ask(row)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ask(row: DictRow) -> NetworkAsk:
|
|
147
|
+
return NetworkAsk(
|
|
148
|
+
id=int(row["id"]),
|
|
149
|
+
org=str(row["org"]),
|
|
150
|
+
source=str(row["source"]),
|
|
151
|
+
network=str(row["network"]),
|
|
152
|
+
state=row["state"],
|
|
153
|
+
asked_at=row["asked_at"].timestamp(),
|
|
154
|
+
decided_by=row["decided_by"],
|
|
155
|
+
decided_at=None if row["decided_at"] is None else row["decided_at"].timestamp(),
|
|
156
|
+
)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""An org's carrier accounts (Twilio, a SIP peer, WhatsApp at Meta), sealed; the box's Twilio."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from ipaddress import ip_network
|
|
6
|
+
from typing import Annotated, Literal, Self
|
|
7
|
+
|
|
8
|
+
from cryptography.fernet import MultiFernet
|
|
9
|
+
from psycopg.rows import DictRow
|
|
10
|
+
from pydantic import (
|
|
11
|
+
BaseModel,
|
|
12
|
+
ConfigDict,
|
|
13
|
+
Field,
|
|
14
|
+
TypeAdapter,
|
|
15
|
+
ValidationError,
|
|
16
|
+
field_validator,
|
|
17
|
+
model_validator,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from pinecall.domain.errors import Conflict, NotAvailable, NotFound
|
|
21
|
+
from pinecall.postgres.pool import Pool
|
|
22
|
+
from pinecall.tenancy import vault
|
|
23
|
+
|
|
24
|
+
type Transport = Literal["auto", "udp", "tcp", "tls"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# The box's own Twilio account, sealed in box_settings as credentials/twilio.
|
|
31
|
+
BOX_ACCOUNT = "twilio"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NO_CARRIER = "the org has no carrier account: PUT /v1/carrier brings a Twilio account or a SIP peer"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
NO_SUCH_ACCOUNT = "the org has no carrier account {account}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
WHICH_ACCOUNT = "the org has {count} carrier accounts ({accounts}): name one of them"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
NO_BOX_TWILIO = (
|
|
44
|
+
"this box buys no numbers: it has no Twilio account of its own. Bring a carrier and import one"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
NOT_A_NETWORK = "{address} is not a network a peer calls from, like 203.0.113.0/24"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
HALF_A_PAIR = "outbound_username is given without outbound_password: a peer is dialled with both"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
A_SID = r"^(AC|SK)[0-9a-fA-F]{32}$"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
AN_ACCOUNT_SID = r"^AC[0-9a-fA-F]{32}$"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
CARRIERS = (
|
|
61
|
+
"SELECT kind, account, ciphertext FROM carriers WHERE org = %(org)s ORDER BY set_at, account"
|
|
62
|
+
)
|
|
63
|
+
CARRIER = (
|
|
64
|
+
"SELECT kind, account, ciphertext FROM carriers WHERE org = %(org)s AND account = %(account)s"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
PUT_CARRIER = """
|
|
69
|
+
INSERT INTO carriers (org, kind, account, label, ciphertext)
|
|
70
|
+
VALUES (%(org)s, %(kind)s, %(account)s, %(label)s, %(ciphertext)s)
|
|
71
|
+
ON CONFLICT (org, account) DO UPDATE SET kind = excluded.kind, label = excluded.label,
|
|
72
|
+
ciphertext = excluded.ciphertext, set_at = now()
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
DROP_CARRIER = (
|
|
77
|
+
"DELETE FROM carriers WHERE org = %(org)s AND account = %(account)s RETURNING account"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TwilioAccount(BaseModel):
|
|
82
|
+
"""A Twilio account: its SID, and an API key SID with its secret (or the SID and auth token)."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
85
|
+
|
|
86
|
+
kind: Literal["twilio"] = "twilio"
|
|
87
|
+
account_sid: Annotated[str, Field(pattern=AN_ACCOUNT_SID)]
|
|
88
|
+
user: Annotated[str, Field(pattern=A_SID)]
|
|
89
|
+
secret: Annotated[str, Field(min_length=1)]
|
|
90
|
+
label: str = ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SipPeer(BaseModel):
|
|
94
|
+
"""A PBX or carrier that speaks SIP: where it calls from, its pair, where it is dialled."""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
97
|
+
|
|
98
|
+
kind: Literal["sip"] = "sip"
|
|
99
|
+
username: Annotated[str, Field(min_length=1)]
|
|
100
|
+
password: Annotated[str, Field(min_length=1)]
|
|
101
|
+
addresses: Annotated[list[str], Field(min_length=1)]
|
|
102
|
+
outbound_host: str | None = None
|
|
103
|
+
outbound_transport: Transport = "auto"
|
|
104
|
+
outbound_username: str | None = None
|
|
105
|
+
outbound_password: str | None = None
|
|
106
|
+
label: str = ""
|
|
107
|
+
|
|
108
|
+
@field_validator("addresses")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _networks(cls, addresses: list[str]) -> list[str]:
|
|
111
|
+
for address in addresses:
|
|
112
|
+
try:
|
|
113
|
+
ip_network(address, strict=False)
|
|
114
|
+
except ValueError:
|
|
115
|
+
raise ValueError(NOT_A_NETWORK.format(address=address)) from None
|
|
116
|
+
return addresses
|
|
117
|
+
|
|
118
|
+
# One account in both directions is what most carriers sell: unsaid, the inbound pair dials.
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def _a_whole_pair(self) -> Self:
|
|
121
|
+
if self.outbound_username and not self.outbound_password:
|
|
122
|
+
raise ValueError(HALF_A_PAIR)
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WhatsappAccount(BaseModel):
|
|
127
|
+
"""A WhatsApp number at Meta: the id replies are sent from, and the org's token."""
|
|
128
|
+
|
|
129
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
130
|
+
|
|
131
|
+
kind: Literal["whatsapp"] = "whatsapp"
|
|
132
|
+
phone_number_id: Annotated[str, Field(min_length=1)]
|
|
133
|
+
access_token: Annotated[str, Field(min_length=1)]
|
|
134
|
+
label: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
type Account = Annotated[TwilioAccount | SipPeer | WhatsappAccount, Field(discriminator="kind")]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Termination(BaseModel):
|
|
141
|
+
"""How the box dials out through a Twilio account: the host and the credential it made."""
|
|
142
|
+
|
|
143
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
144
|
+
|
|
145
|
+
host: str
|
|
146
|
+
username: str
|
|
147
|
+
password: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _Sealed(BaseModel):
|
|
151
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
152
|
+
|
|
153
|
+
account: Account
|
|
154
|
+
outbound: Termination | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Carrier:
|
|
159
|
+
"""One account of the org at an outside party that holds its numbers."""
|
|
160
|
+
|
|
161
|
+
org: str
|
|
162
|
+
account: TwilioAccount | SipPeer | WhatsappAccount
|
|
163
|
+
# Twilio only, written by provisioning: Twilio shows a credential's password once.
|
|
164
|
+
outbound: Termination | None = None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def id(self) -> str:
|
|
168
|
+
"""The account's own id: Twilio's SID, the peer's username, Meta's phone number id."""
|
|
169
|
+
match self.account:
|
|
170
|
+
case TwilioAccount():
|
|
171
|
+
return self.account.account_sid
|
|
172
|
+
case SipPeer():
|
|
173
|
+
return self.account.username
|
|
174
|
+
case WhatsappAccount():
|
|
175
|
+
return self.account.phone_number_id
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
ACCOUNT: TypeAdapter[TwilioAccount | SipPeer | WhatsappAccount] = TypeAdapter(Account)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def carriers_of(pool: Pool, sealed: MultiFernet, org: str) -> list[Carrier]:
|
|
182
|
+
"""The org's carrier accounts, oldest first, opened; one sealed under a lost key is skipped."""
|
|
183
|
+
async with pool.connection() as connection:
|
|
184
|
+
rows = await (await connection.execute(CARRIERS, {"org": org})).fetchall()
|
|
185
|
+
opened = (_open_carrier(sealed, org, row) for row in rows)
|
|
186
|
+
return [carrier for carrier in opened if carrier is not None]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def carrier_named(
|
|
190
|
+
pool: Pool, sealed: MultiFernet, org: str, account: str | None = None
|
|
191
|
+
) -> Carrier:
|
|
192
|
+
"""The account named, or the org's only one; NotFound or Conflict otherwise."""
|
|
193
|
+
statement = CARRIERS if account is None else CARRIER
|
|
194
|
+
async with pool.connection() as connection:
|
|
195
|
+
rows = await (
|
|
196
|
+
await connection.execute(statement, {"org": org, "account": account})
|
|
197
|
+
).fetchall()
|
|
198
|
+
carriers = [carrier for carrier in (_open_carrier(sealed, org, row) for row in rows) if carrier]
|
|
199
|
+
if account is not None and not carriers:
|
|
200
|
+
raise NotFound(NO_SUCH_ACCOUNT.format(account=account))
|
|
201
|
+
if not carriers:
|
|
202
|
+
raise NotFound(NO_CARRIER)
|
|
203
|
+
if len(carriers) > 1:
|
|
204
|
+
named = ", ".join(carrier.id for carrier in carriers)
|
|
205
|
+
raise Conflict(WHICH_ACCOUNT.format(count=len(carriers), accounts=named))
|
|
206
|
+
return carriers[0]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def put_carrier(
|
|
210
|
+
pool: Pool, sealed: MultiFernet, org: str, account: TwilioAccount | SipPeer | WhatsappAccount
|
|
211
|
+
) -> Carrier:
|
|
212
|
+
"""Keep the account; bringing it again replaces its secret and keeps its termination."""
|
|
213
|
+
kept = Carrier(org=org, account=account)
|
|
214
|
+
was = next(
|
|
215
|
+
(carrier for carrier in await carriers_of(pool, sealed, org) if carrier.id == kept.id),
|
|
216
|
+
None,
|
|
217
|
+
)
|
|
218
|
+
# The termination stays with the account it was made on: only a Twilio account holds one.
|
|
219
|
+
if was is not None and was.account.kind == account.kind:
|
|
220
|
+
kept = replace(kept, outbound=was.outbound)
|
|
221
|
+
await seal_carrier(pool, sealed, kept)
|
|
222
|
+
return kept
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def drop_carrier(
|
|
226
|
+
pool: Pool, sealed: MultiFernet, org: str, account: str | None = None
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Forget the account; its numbers stay routed until each is let go."""
|
|
229
|
+
gone = await carrier_named(pool, sealed, org, account)
|
|
230
|
+
async with pool.connection() as connection:
|
|
231
|
+
await connection.execute(DROP_CARRIER, {"org": org, "account": gone.id})
|
|
232
|
+
return gone.id
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def box_twilio(pool: Pool, sealed: MultiFernet) -> TwilioAccount:
|
|
236
|
+
"""The box's own Twilio account, or NotAvailable when it holds none."""
|
|
237
|
+
carrier = (await vault.box_credentials(pool, sealed)).get(BOX_ACCOUNT)
|
|
238
|
+
try:
|
|
239
|
+
return TwilioAccount.model_validate(carrier)
|
|
240
|
+
except ValidationError:
|
|
241
|
+
raise NotAvailable(NO_BOX_TWILIO) from None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def seal_carrier(pool: Pool, sealed: MultiFernet, carrier: Carrier) -> None:
|
|
245
|
+
"""Write the account sealed, its termination with it."""
|
|
246
|
+
secret = _Sealed(account=carrier.account, outbound=carrier.outbound)
|
|
247
|
+
row = {
|
|
248
|
+
"org": carrier.org,
|
|
249
|
+
"kind": carrier.account.kind,
|
|
250
|
+
"account": carrier.id,
|
|
251
|
+
"label": carrier.account.label,
|
|
252
|
+
"ciphertext": vault.sealed(sealed, secret.model_dump(mode="json")),
|
|
253
|
+
}
|
|
254
|
+
async with pool.connection() as connection:
|
|
255
|
+
await connection.execute(PUT_CARRIER, row)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _open_carrier(sealed: MultiFernet, org: str, row: DictRow) -> Carrier | None:
|
|
259
|
+
secret = vault.opened(sealed, str(row["ciphertext"]))
|
|
260
|
+
try:
|
|
261
|
+
kept = _Sealed.model_validate(secret)
|
|
262
|
+
except ValidationError:
|
|
263
|
+
logger.warning("org %s's carrier account %s is not one this box reads", org, row["account"])
|
|
264
|
+
return None
|
|
265
|
+
return Carrier(org=org, account=kept.account, outbound=kept.outbound)
|