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
pinecall/session/room.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""A voice call's room: the caller's leg, transfers, tones, the melody, and the room's facts."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
|
|
13
|
+
from livekit import api, rtc
|
|
14
|
+
from livekit.agents import utils
|
|
15
|
+
from livekit.agents.voice import AgentSession
|
|
16
|
+
from livekit.protocol.room import MuteRoomTrackRequest, RoomParticipantIdentity
|
|
17
|
+
from livekit.protocol.sip import (
|
|
18
|
+
CreateSIPParticipantRequest,
|
|
19
|
+
SIPOutboundConfig,
|
|
20
|
+
SIPTransferReason,
|
|
21
|
+
SIPTransferStatus,
|
|
22
|
+
TransferSIPParticipantRequest,
|
|
23
|
+
TransferSIPParticipantResponse,
|
|
24
|
+
)
|
|
25
|
+
from livekit.rtc.participant import PublishDataError, PublishDTMFError
|
|
26
|
+
from pydantic import ValidationError
|
|
27
|
+
|
|
28
|
+
from pinecall.domain.names import Channel, JsonObject
|
|
29
|
+
from pinecall.domain.scope import SCOPE_ATTRIBUTE
|
|
30
|
+
from pinecall.session.call import Call
|
|
31
|
+
from pinecall.wire import events as wire
|
|
32
|
+
from pinecall.wire.commands import (
|
|
33
|
+
CallDtmf,
|
|
34
|
+
CallTransfer,
|
|
35
|
+
ParticipantMute,
|
|
36
|
+
ParticipantRemove,
|
|
37
|
+
RoomInvite,
|
|
38
|
+
RoomSend,
|
|
39
|
+
)
|
|
40
|
+
from pinecall.wire.frames import WireModel
|
|
41
|
+
from pinecall.wire.parts import ParticipantKind, TrackKind, TrackSource, TransferMode
|
|
42
|
+
from pinecall.wire.room import (
|
|
43
|
+
ParticipantJoined,
|
|
44
|
+
ParticipantLeft,
|
|
45
|
+
ParticipantSpeaking,
|
|
46
|
+
RoomOpened,
|
|
47
|
+
RoomSent,
|
|
48
|
+
TrackPublished,
|
|
49
|
+
TrackUnpublished,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# A code the caller keyed, claimed with the gateway, which decides whether it is one.
|
|
53
|
+
type ClaimCode = Callable[[str], Awaitable[None]]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# The caller's leg can join a moment after the agent, and livekit's own wait never times out.
|
|
60
|
+
WAIT_FOR_THE_LEG_S = 5.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# livekit's attribute names for the caller's number and the one dialled.
|
|
64
|
+
SIP_PREFIX = "sip."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# livekit's convention for a dialled leg's identity, which every dialled leg follows.
|
|
68
|
+
LEG_PREFIX = "sip_"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
KIND_OF_SCOPE: dict[str, ParticipantKind] = {"supervise": "supervisor", "observe": "listener"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# livekit's session closes when the caller leaves only for three reasons (they hung up, the room
|
|
75
|
+
# was deleted, they were rejected); one whose connection dropped is waited for, and the room ends
|
|
76
|
+
# 20 s after its last person left, LiveKit's departure timeout. A supervisor watching keeps the
|
|
77
|
+
# room up, so the call is ended here: the caller gone as long as LiveKit waits for a last person.
|
|
78
|
+
CALLER_RETURNS_S = 20.0
|
|
79
|
+
|
|
80
|
+
CALLER_GONE = "call %s: the caller left (%s) and did not come back in %.0f s: the call ends"
|
|
81
|
+
|
|
82
|
+
# A warm transfer rings for less than livekit's 30 s; a tone plays meanwhile, so silence is not
|
|
83
|
+
# taken for a dropped call.
|
|
84
|
+
RINGING_S = 25.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# The longest a transfer asked outside a tool waits for the agent to finish its sentence.
|
|
88
|
+
THE_ANNOUNCEMENT_S = 12.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# RFC 4733 codes, as publish_dtmf takes them. A comma pauses, as in a dialler's string; any
|
|
92
|
+
# other character refuses the whole sequence, since half a card number is worse than none. Some
|
|
93
|
+
# carriers merge tones sent back to back into one long press.
|
|
94
|
+
CODES: dict[str, int] = {**{str(digit): digit for digit in range(10)}, "*": 10, "#": 11}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
PAUSE = ","
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
PAUSE_S = 0.5
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
BETWEEN_TONES_S = 0.12
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# A code is four digits keyed within eight seconds (docs/protocol/codes.md); answers to a menu
|
|
107
|
+
# are slower.
|
|
108
|
+
CODE_LENGTH = 4
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
CODE_WITHIN_S = 8.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
ROOM_VERB_FAILED = "room_verb_failed"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
NO_LEG = "{verb}: this call has no phone leg"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
NO_TRUNK = "{verb}: no outbound SIP trunk is configured, so {to} cannot be dialled"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
NOT_A_TONE = "call.dtmf: {digit!r} is not a touch tone (0-9, * or #), and nothing was sent"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
NOT_DIALLED = "room.invite: a participant joins with a token; the room dials only kind sip"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
NOT_IN_THE_ROOM = "participant.mute: {identity} is not in the room"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
NO_MICROPHONE = "participant.mute: {identity} has no microphone in the room"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
TRACK_KINDS: dict[int, TrackKind] = {
|
|
136
|
+
rtc.TrackKind.KIND_AUDIO: "audio",
|
|
137
|
+
rtc.TrackKind.KIND_VIDEO: "video",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
TRACK_SOURCES: dict[int, TrackSource] = {
|
|
142
|
+
rtc.TrackSource.SOURCE_MICROPHONE: "microphone",
|
|
143
|
+
rtc.TrackSource.SOURCE_CAMERA: "camera",
|
|
144
|
+
rtc.TrackSource.SOURCE_SCREENSHARE: "screen_share",
|
|
145
|
+
rtc.TrackSource.SOURCE_SCREENSHARE_AUDIO: "screen_share_audio",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# The SFU keeps no outbound trunk: each leg carries its own, so a restarted SFU dials on.
|
|
150
|
+
# Asked each time a verb dials: the gateway checks the org's dial guards and its limits first,
|
|
151
|
+
# so a refusal (NotAllowed) carries their reason into the log. Never cached.
|
|
152
|
+
type Trunks = Callable[[str], Awaitable[Trunk]]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Asked before a cold transfer: the same guards, and no trunk, since a REFER dials nothing of ours.
|
|
156
|
+
type SentOn = Callable[[str], Awaitable[None]]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Trunk:
|
|
161
|
+
"""How a number is dialled: the trunk inline, and the org's number shown."""
|
|
162
|
+
|
|
163
|
+
config: SIPOutboundConfig
|
|
164
|
+
shown: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class Legs:
|
|
169
|
+
"""How a second leg of the call is judged: the guards and the trunk, or the guards alone."""
|
|
170
|
+
|
|
171
|
+
trunk: Trunks
|
|
172
|
+
sent_on: SentOn
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CallRoom:
|
|
176
|
+
"""One voice call's room, the server's API over it, and what it writes about both."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
call: Call,
|
|
181
|
+
room: rtc.Room,
|
|
182
|
+
server: api.LiveKitAPI,
|
|
183
|
+
*,
|
|
184
|
+
legs: Legs,
|
|
185
|
+
claim: ClaimCode,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""The room of a call not yet opened."""
|
|
188
|
+
self.call = call
|
|
189
|
+
self.room = room
|
|
190
|
+
self.server = server
|
|
191
|
+
self.legs = legs
|
|
192
|
+
self.claim = claim
|
|
193
|
+
self.speaking: set[str] = set()
|
|
194
|
+
self.tones: deque[tuple[str, float]] = deque(maxlen=CODE_LENGTH)
|
|
195
|
+
self.claimed: set[str] = set()
|
|
196
|
+
self.tasks: set[asyncio.Task[None]] = set()
|
|
197
|
+
self.bridged: str | None = None
|
|
198
|
+
self.caller_gone: Callable[[], None] | None = None
|
|
199
|
+
self.returning: asyncio.Task[None] | None = None
|
|
200
|
+
|
|
201
|
+
# ── the caller's leg ──
|
|
202
|
+
|
|
203
|
+
async def leg(self) -> rtc.RemoteParticipant | None:
|
|
204
|
+
"""The caller's SIP leg, waited for a moment; none on a call that is not a phone's."""
|
|
205
|
+
return await caller_leg(self.room, self.call.context.channel)
|
|
206
|
+
|
|
207
|
+
# ── transfers ──
|
|
208
|
+
|
|
209
|
+
async def mode_of(self, wanted: CallTransfer) -> TransferMode:
|
|
210
|
+
"""The mode asked for, else cold on a phone leg and warm on a browser's call."""
|
|
211
|
+
if wanted.mode is not None:
|
|
212
|
+
return wanted.mode
|
|
213
|
+
return "cold" if await self.leg() is not None else "warm"
|
|
214
|
+
|
|
215
|
+
# A failed transfer is call.transferred with ok false, and the call goes on.
|
|
216
|
+
async def transfer(
|
|
217
|
+
self, wanted: CallTransfer, live: AgentSession[None]
|
|
218
|
+
) -> wire.CallTransferred:
|
|
219
|
+
"""Send the caller on (cold, by REFER of their leg) or dial the person in (warm)."""
|
|
220
|
+
mode = await self.mode_of(wanted)
|
|
221
|
+
await _after_the_announcement(live)
|
|
222
|
+
if mode == "cold":
|
|
223
|
+
return await self._sent_on(wanted)
|
|
224
|
+
return await self._dialled_in(wanted)
|
|
225
|
+
|
|
226
|
+
# The person is dialled into this room. The caller leaving closes the session by itself; the
|
|
227
|
+
# person leaving does not, so the call ends then.
|
|
228
|
+
def when_the_person_leaves(self, identity: str, hang_up: Callable[[], None]) -> None:
|
|
229
|
+
"""Call `hang_up` once the transferred leg leaves the room."""
|
|
230
|
+
self.bridged = identity
|
|
231
|
+
|
|
232
|
+
def left(participant: rtc.RemoteParticipant) -> None:
|
|
233
|
+
if participant.identity == self.bridged:
|
|
234
|
+
self.bridged = None
|
|
235
|
+
hang_up()
|
|
236
|
+
|
|
237
|
+
self.room.on("participant_disconnected", left) # pyright: ignore[reportUnknownMemberType]
|
|
238
|
+
|
|
239
|
+
def when_the_caller_is_gone(self, hang_up: Callable[[], None]) -> None:
|
|
240
|
+
"""Call `hang_up` once the caller left, whatever the reason, and did not come back."""
|
|
241
|
+
self.caller_gone = hang_up
|
|
242
|
+
|
|
243
|
+
# The caller is sent on only past the org's guards for a second leg (shape, pace, the
|
|
244
|
+
# ledger's row), as a warm transfer is: the REFER is a leg the carrier dials on the org's bill.
|
|
245
|
+
async def _sent_on(self, wanted: CallTransfer) -> wire.CallTransferred:
|
|
246
|
+
leg = await self.leg()
|
|
247
|
+
if leg is None:
|
|
248
|
+
return _stayed(wanted, "cold", NO_LEG.format(verb="call.transfer"))
|
|
249
|
+
try:
|
|
250
|
+
await self.legs.sent_on(wanted.to)
|
|
251
|
+
except Exception as refused:
|
|
252
|
+
logger.warning("call.transfer: the guards refused the leg", exc_info=True)
|
|
253
|
+
return _stayed(
|
|
254
|
+
wanted, "cold", str(refused) or NO_TRUNK.format(verb="call.transfer", to=wanted.to)
|
|
255
|
+
)
|
|
256
|
+
request = TransferSIPParticipantRequest(
|
|
257
|
+
participant_identity=leg.identity,
|
|
258
|
+
room_name=self.room.name,
|
|
259
|
+
transfer_to=wanted.to,
|
|
260
|
+
play_dialtone=True,
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
answer = await self.server.sip.transfer_sip_participant(request)
|
|
264
|
+
except api.TwirpError as refused:
|
|
265
|
+
return _stayed(wanted, "cold", f"call.transfer: {refused}")
|
|
266
|
+
# A transfer can fail without raising.
|
|
267
|
+
if answer.status != SIPTransferStatus.STS_TRANSFER_SUCCESSFUL:
|
|
268
|
+
return _stayed(wanted, "cold", _did_not_take(answer))
|
|
269
|
+
return wire.CallTransferred(to=wanted.to, mode="cold", ok=True)
|
|
270
|
+
|
|
271
|
+
# Waiting until answered is what makes busy and no answer seen; without it the request
|
|
272
|
+
# returns as soon as the INVITE went out.
|
|
273
|
+
async def _dialled_in(self, wanted: CallTransfer) -> wire.CallTransferred:
|
|
274
|
+
request = await self._leg_to(wanted.to, verb="call.transfer")
|
|
275
|
+
if isinstance(request, str):
|
|
276
|
+
return _stayed(wanted, "warm", request)
|
|
277
|
+
request.wait_until_answered = True
|
|
278
|
+
request.play_dialtone = True
|
|
279
|
+
request.ringing_timeout.FromTimedelta(timedelta(seconds=RINGING_S))
|
|
280
|
+
try:
|
|
281
|
+
await self.server.sip.create_sip_participant(request)
|
|
282
|
+
except api.TwirpError as refused:
|
|
283
|
+
return _stayed(wanted, "warm", f"call.transfer: {refused}")
|
|
284
|
+
return wire.CallTransferred(to=wanted.to, mode="warm", ok=True)
|
|
285
|
+
|
|
286
|
+
# ── the other room verbs ──
|
|
287
|
+
|
|
288
|
+
async def apply(self, command: WireModel) -> None:
|
|
289
|
+
"""A room verb of the app's; the server's refusal is an error entry, the call goes on."""
|
|
290
|
+
match command:
|
|
291
|
+
case RoomInvite():
|
|
292
|
+
await self._invite(command)
|
|
293
|
+
case RoomSend():
|
|
294
|
+
await self._send(command)
|
|
295
|
+
case ParticipantMute():
|
|
296
|
+
await self._mute(command)
|
|
297
|
+
case ParticipantRemove():
|
|
298
|
+
request = RoomParticipantIdentity(room=self.room.name, identity=command.identity)
|
|
299
|
+
await self._completed(
|
|
300
|
+
"participant.remove", self.server.room.remove_participant(request)
|
|
301
|
+
)
|
|
302
|
+
case CallDtmf():
|
|
303
|
+
await self._tones(command)
|
|
304
|
+
case _:
|
|
305
|
+
self._failed(
|
|
306
|
+
type(command).__name__,
|
|
307
|
+
f"{type(command).__name__} is not something this call does",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Success is written by the room itself, as participant.joined of kind sip.
|
|
311
|
+
async def _invite(self, wanted: RoomInvite) -> None:
|
|
312
|
+
if wanted.kind != "sip":
|
|
313
|
+
self._failed("room.invite", NOT_DIALLED)
|
|
314
|
+
return
|
|
315
|
+
request = await self._leg_to(wanted.to, verb="room.invite")
|
|
316
|
+
if isinstance(request, str):
|
|
317
|
+
self._failed("room.invite", request)
|
|
318
|
+
return
|
|
319
|
+
await self._completed("room.invite", self.server.sip.create_sip_participant(request))
|
|
320
|
+
|
|
321
|
+
# The payload stays out of the log: room.sent says the topic, whom, and the size.
|
|
322
|
+
async def _send(self, wanted: RoomSend) -> None:
|
|
323
|
+
packed = json.dumps(wanted.data, separators=(",", ":")).encode()
|
|
324
|
+
to = [] if wanted.to is None else [wanted.to]
|
|
325
|
+
sent = self.room.local_participant.publish_data(
|
|
326
|
+
packed, topic=wanted.topic, destination_identities=to
|
|
327
|
+
)
|
|
328
|
+
if await self._completed("room.send", sent):
|
|
329
|
+
self.call.writing.write(
|
|
330
|
+
"room.sent", RoomSent(topic=wanted.topic, to=wanted.to, bytes=len(packed))
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# livekit mutes in place and says nothing, so the verb writes track.unpublished itself.
|
|
334
|
+
async def _mute(self, wanted: ParticipantMute) -> None:
|
|
335
|
+
seat = self.room.remote_participants.get(wanted.identity)
|
|
336
|
+
if seat is None:
|
|
337
|
+
self._failed("participant.mute", NOT_IN_THE_ROOM.format(identity=wanted.identity))
|
|
338
|
+
return
|
|
339
|
+
microphone = next(
|
|
340
|
+
(
|
|
341
|
+
value
|
|
342
|
+
for value in seat.track_publications.values()
|
|
343
|
+
if value.source == rtc.TrackSource.SOURCE_MICROPHONE
|
|
344
|
+
),
|
|
345
|
+
None,
|
|
346
|
+
)
|
|
347
|
+
if microphone is None:
|
|
348
|
+
self._failed("participant.mute", NO_MICROPHONE.format(identity=wanted.identity))
|
|
349
|
+
return
|
|
350
|
+
request = MuteRoomTrackRequest(
|
|
351
|
+
room=self.room.name, identity=wanted.identity, track_sid=microphone.sid, muted=True
|
|
352
|
+
)
|
|
353
|
+
if await self._completed(
|
|
354
|
+
"participant.mute", self.server.room.mute_published_track(request)
|
|
355
|
+
):
|
|
356
|
+
unpublished = TrackUnpublished(
|
|
357
|
+
identity=wanted.identity, kind="audio", source="microphone"
|
|
358
|
+
)
|
|
359
|
+
self.call.writing.write("track.unpublished", unpublished)
|
|
360
|
+
|
|
361
|
+
async def _tones(self, wanted: CallDtmf) -> None:
|
|
362
|
+
wrong = next(
|
|
363
|
+
(digit for digit in wanted.digits if digit != PAUSE and digit not in CODES), None
|
|
364
|
+
)
|
|
365
|
+
if wrong is not None:
|
|
366
|
+
self._failed("call.dtmf", NOT_A_TONE.format(digit=wrong))
|
|
367
|
+
return
|
|
368
|
+
if await self.leg() is None:
|
|
369
|
+
self._failed("call.dtmf", NO_LEG.format(verb="call.dtmf"))
|
|
370
|
+
return
|
|
371
|
+
for digit in wanted.digits:
|
|
372
|
+
if digit == PAUSE:
|
|
373
|
+
await asyncio.sleep(PAUSE_S)
|
|
374
|
+
continue
|
|
375
|
+
if not await self._completed(
|
|
376
|
+
"call.dtmf",
|
|
377
|
+
self.room.local_participant.publish_dtmf(code=CODES[digit], digit=digit),
|
|
378
|
+
):
|
|
379
|
+
return
|
|
380
|
+
await asyncio.sleep(BETWEEN_TONES_S)
|
|
381
|
+
|
|
382
|
+
async def _leg_to(self, to: str, *, verb: str) -> CreateSIPParticipantRequest | str:
|
|
383
|
+
try:
|
|
384
|
+
trunk = await self.legs.trunk(to)
|
|
385
|
+
except Exception as refused:
|
|
386
|
+
logger.warning("%s: no trunk to dial through", verb, exc_info=True)
|
|
387
|
+
return str(refused) or NO_TRUNK.format(verb=verb, to=to)
|
|
388
|
+
request = CreateSIPParticipantRequest(
|
|
389
|
+
trunk=trunk.config,
|
|
390
|
+
sip_call_to=to,
|
|
391
|
+
room_name=self.room.name,
|
|
392
|
+
participant_identity=f"{LEG_PREFIX}{to}",
|
|
393
|
+
)
|
|
394
|
+
if trunk.shown is not None:
|
|
395
|
+
request.sip_number = trunk.shown
|
|
396
|
+
return request
|
|
397
|
+
|
|
398
|
+
# The server's own words, as it said them.
|
|
399
|
+
async def _completed(self, verb: str, done: Awaitable[object]) -> bool:
|
|
400
|
+
try:
|
|
401
|
+
await done
|
|
402
|
+
except (api.TwirpError, PublishDataError, PublishDTMFError, ConnectionError) as refused:
|
|
403
|
+
self._failed(verb, str(refused))
|
|
404
|
+
return False
|
|
405
|
+
return True
|
|
406
|
+
|
|
407
|
+
def _failed(self, verb: str, why: str) -> None:
|
|
408
|
+
failed = wire.ErrorEvent(code=ROOM_VERB_FAILED, message=why, command=verb, recoverable=True)
|
|
409
|
+
self.call.writing.write("error", failed)
|
|
410
|
+
|
|
411
|
+
# ── the room's own facts ──
|
|
412
|
+
|
|
413
|
+
# The job usually connects before the session exists, so the connection was already missed.
|
|
414
|
+
def watch(self) -> None:
|
|
415
|
+
"""Write what happens in the room, room.opened first."""
|
|
416
|
+
for name, listener in self._listeners().items():
|
|
417
|
+
self.room.on(name, listener) # pyright: ignore[reportUnknownMemberType]
|
|
418
|
+
if self.room.isconnected():
|
|
419
|
+
self._connection(rtc.ConnectionState.CONN_CONNECTED)
|
|
420
|
+
|
|
421
|
+
def stop(self) -> None:
|
|
422
|
+
"""Let go of the room and of every claim still out."""
|
|
423
|
+
for name, listener in self._listeners().items():
|
|
424
|
+
self.room.off(name, listener) # pyright: ignore[reportUnknownMemberType]
|
|
425
|
+
for task in self.tasks:
|
|
426
|
+
task.cancel()
|
|
427
|
+
|
|
428
|
+
def _listeners(self) -> dict[rtc.room.EventTypes, Callable[..., None]]:
|
|
429
|
+
return {
|
|
430
|
+
"connection_state_changed": self._connection,
|
|
431
|
+
"participant_connected": self._joined,
|
|
432
|
+
"participant_disconnected": self._left,
|
|
433
|
+
"track_published": self._published,
|
|
434
|
+
"track_unpublished": self._unpublished,
|
|
435
|
+
"active_speakers_changed": self._speakers,
|
|
436
|
+
"sip_dtmf_received": self._tone,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
def _connection(self, state: int) -> None:
|
|
440
|
+
if state == rtc.ConnectionState.CONN_CONNECTED:
|
|
441
|
+
self._spawn(self._opened())
|
|
442
|
+
|
|
443
|
+
# room.sid is awaited, so the opening runs as a task: room.opened, the agent's seat, then the
|
|
444
|
+
# seats taken before the agent arrived and their tracks.
|
|
445
|
+
async def _opened(self) -> None:
|
|
446
|
+
opened = RoomOpened(
|
|
447
|
+
name=self.room.name, sid=await self.room.sid, channel=self.call.context.channel
|
|
448
|
+
)
|
|
449
|
+
await self.call.writing.write("room.opened", opened)
|
|
450
|
+
self._joined(self.room.local_participant)
|
|
451
|
+
for seat in self.room.remote_participants.values():
|
|
452
|
+
self._joined(seat)
|
|
453
|
+
for publication in seat.track_publications.values():
|
|
454
|
+
self._published(publication, seat)
|
|
455
|
+
|
|
456
|
+
def _joined(self, seat: rtc.Participant) -> None:
|
|
457
|
+
attributes: dict[str, str] = dict(seat.attributes)
|
|
458
|
+
data: JsonObject = dict(attributes)
|
|
459
|
+
joined = ParticipantJoined(
|
|
460
|
+
identity=seat.identity,
|
|
461
|
+
kind=self.kind_of(seat, attributes),
|
|
462
|
+
name=seat.name or None,
|
|
463
|
+
attributes=data,
|
|
464
|
+
)
|
|
465
|
+
self.call.writing.write("participant.joined", joined)
|
|
466
|
+
if joined.kind == "caller" and self.returning is not None:
|
|
467
|
+
self.returning.cancel()
|
|
468
|
+
self.returning = None
|
|
469
|
+
|
|
470
|
+
def _left(self, seat: rtc.RemoteParticipant) -> None:
|
|
471
|
+
reason = seat.disconnect_reason
|
|
472
|
+
text = "unknown" if reason is None else rtc.DisconnectReason.Name(reason).lower()
|
|
473
|
+
self.call.writing.write(
|
|
474
|
+
"participant.left", ParticipantLeft(identity=seat.identity, reason=text)
|
|
475
|
+
)
|
|
476
|
+
self.speaking.discard(seat.identity)
|
|
477
|
+
gone = self.caller_gone
|
|
478
|
+
if gone is not None and self.kind_of(seat, dict(seat.attributes)) == "caller":
|
|
479
|
+
if self.returning is not None:
|
|
480
|
+
self.returning.cancel()
|
|
481
|
+
self.returning = self._spawn(self._waited_for(gone, text))
|
|
482
|
+
|
|
483
|
+
async def _waited_for(self, hang_up: Callable[[], None], reason: str) -> None:
|
|
484
|
+
await asyncio.sleep(CALLER_RETURNS_S)
|
|
485
|
+
self.returning = None
|
|
486
|
+
logger.info(CALLER_GONE, self.room.name, reason, CALLER_RETURNS_S)
|
|
487
|
+
hang_up()
|
|
488
|
+
|
|
489
|
+
# A SIP seat is the caller only if its number is the call's caller; any other was dialled in.
|
|
490
|
+
def kind_of(self, seat: rtc.Participant, attributes: dict[str, str]) -> ParticipantKind:
|
|
491
|
+
"""Who a seat of the room is to this call: caller, agent, supervisor or a far end."""
|
|
492
|
+
if seat.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT:
|
|
493
|
+
return "agent"
|
|
494
|
+
by_scope = KIND_OF_SCOPE.get(attributes.get(SCOPE_ATTRIBUTE, ""))
|
|
495
|
+
if by_scope is not None:
|
|
496
|
+
return by_scope
|
|
497
|
+
a_phone = any(key.startswith(SIP_PREFIX) for key in attributes)
|
|
498
|
+
if a_phone and attributes.get(CALLER_NUMBER) != self.call.context.caller:
|
|
499
|
+
return "sip"
|
|
500
|
+
return "caller"
|
|
501
|
+
|
|
502
|
+
def _published(self, publication: rtc.TrackPublication, seat: rtc.Participant) -> None:
|
|
503
|
+
track = _track_of(publication)
|
|
504
|
+
if track is not None:
|
|
505
|
+
published = TrackPublished(identity=seat.identity, kind=track[0], source=track[1])
|
|
506
|
+
self.call.writing.write("track.published", published)
|
|
507
|
+
|
|
508
|
+
def _unpublished(self, publication: rtc.TrackPublication, seat: rtc.Participant) -> None:
|
|
509
|
+
track = _track_of(publication)
|
|
510
|
+
if track is not None:
|
|
511
|
+
unpublished = TrackUnpublished(identity=seat.identity, kind=track[0], source=track[1])
|
|
512
|
+
self.call.writing.write("track.unpublished", unpublished)
|
|
513
|
+
|
|
514
|
+
# Written on the change, never on every tick.
|
|
515
|
+
def _speakers(self, speakers: list[rtc.Participant]) -> None:
|
|
516
|
+
now = {speaker.identity for speaker in speakers}
|
|
517
|
+
for identity in sorted(now - self.speaking):
|
|
518
|
+
self.call.writing.write(
|
|
519
|
+
"participant.speaking", ParticipantSpeaking(identity=identity, speaking=True)
|
|
520
|
+
)
|
|
521
|
+
for identity in sorted(self.speaking - now):
|
|
522
|
+
self.call.writing.write(
|
|
523
|
+
"participant.speaking", ParticipantSpeaking(identity=identity, speaking=False)
|
|
524
|
+
)
|
|
525
|
+
self.speaking = now
|
|
526
|
+
|
|
527
|
+
# The caller's tones only: a server's have no seat, another leg is not the caller.
|
|
528
|
+
def _tone(self, tone: rtc.SipDTMF) -> None:
|
|
529
|
+
seat = tone.participant
|
|
530
|
+
if seat is None or self.kind_of(seat, dict(seat.attributes)) != "caller":
|
|
531
|
+
return
|
|
532
|
+
try:
|
|
533
|
+
claim = wire.DtmfReceived.model_validate({"digit": tone.digit, "code": tone.code})
|
|
534
|
+
except ValidationError:
|
|
535
|
+
return
|
|
536
|
+
self.call.writing.write("dtmf.received", claim)
|
|
537
|
+
self._tone_pressed(claim.digit, time.monotonic())
|
|
538
|
+
|
|
539
|
+
# The worker cannot tell a code from an extension, so every candidate is claimed and the
|
|
540
|
+
# gateway decides; each code once a call. `*` or `#` starts over.
|
|
541
|
+
def _tone_pressed(self, digit: str, at: float) -> None:
|
|
542
|
+
if not digit.isdigit():
|
|
543
|
+
self.tones.clear()
|
|
544
|
+
return
|
|
545
|
+
self.tones.append((digit, at))
|
|
546
|
+
if len(self.tones) < CODE_LENGTH or at - self.tones[0][1] > CODE_WITHIN_S:
|
|
547
|
+
return
|
|
548
|
+
code = "".join(digit for digit, _ in self.tones)
|
|
549
|
+
self.tones.clear()
|
|
550
|
+
if code not in self.claimed:
|
|
551
|
+
self.claimed.add(code)
|
|
552
|
+
self._spawn(self._claimed(code))
|
|
553
|
+
|
|
554
|
+
async def _claimed(self, code: str) -> None:
|
|
555
|
+
try:
|
|
556
|
+
await self.claim(code)
|
|
557
|
+
except Exception:
|
|
558
|
+
logger.warning(
|
|
559
|
+
"call %s: the claim of a keyed code was refused",
|
|
560
|
+
self.call.context.call,
|
|
561
|
+
exc_info=True,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# A task of a synchronous listener is kept, or it may be collected mid-run.
|
|
565
|
+
def _spawn(self, work: Awaitable[None]) -> asyncio.Task[None]:
|
|
566
|
+
task = asyncio.ensure_future(work)
|
|
567
|
+
self.tasks.add(task)
|
|
568
|
+
task.add_done_callback(self.tasks.discard)
|
|
569
|
+
return task
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
CALLER_NUMBER = f"{SIP_PREFIX}phoneNumber"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
DIALLED_NUMBER = f"{SIP_PREFIX}trunkPhoneNumber"
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# The Twilio account a leg Twilio sent came from: livekit-sip reads X-Twilio-AccountSid into it.
|
|
579
|
+
TWILIO_ACCOUNT = f"{SIP_PREFIX}twilio.accountSid"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# livekit's wait never times out; a room that never connected raises, and has no leg either.
|
|
583
|
+
# The first SIP seat is the caller; later ones came from room.invite or a warm transfer.
|
|
584
|
+
async def caller_leg(room: rtc.Room, channel: Channel) -> rtc.RemoteParticipant | None:
|
|
585
|
+
"""The caller's SIP seat, waited for a moment on a phone call; none on any other."""
|
|
586
|
+
if channel == "phone" and _phone_in(room) is None:
|
|
587
|
+
with contextlib.suppress(TimeoutError, RuntimeError):
|
|
588
|
+
async with asyncio.timeout(WAIT_FOR_THE_LEG_S):
|
|
589
|
+
await utils.wait_for_participant(
|
|
590
|
+
room, kind=rtc.ParticipantKind.PARTICIPANT_KIND_SIP
|
|
591
|
+
)
|
|
592
|
+
return _phone_in(room)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _phone_in(room: rtc.Room) -> rtc.RemoteParticipant | None:
|
|
596
|
+
return next(
|
|
597
|
+
(
|
|
598
|
+
seat
|
|
599
|
+
for seat in room.remote_participants.values()
|
|
600
|
+
if seat.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP
|
|
601
|
+
),
|
|
602
|
+
None,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
async def _after_the_announcement(live: AgentSession[None]) -> None:
|
|
607
|
+
speech = live.current_speech
|
|
608
|
+
if speech is not None:
|
|
609
|
+
with contextlib.suppress(TimeoutError):
|
|
610
|
+
await asyncio.wait_for(speech.wait_for_playout(), THE_ANNOUNCEMENT_S)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _stayed(wanted: CallTransfer, mode: TransferMode, why: str) -> wire.CallTransferred:
|
|
614
|
+
return wire.CallTransferred(to=wanted.to, mode=mode, ok=False, error=why)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _did_not_take(answer: TransferSIPParticipantResponse) -> str:
|
|
618
|
+
payload = SIPTransferReason.Name(answer.reason).removeprefix("STR_").lower()
|
|
619
|
+
return (
|
|
620
|
+
f"call.transfer: {payload}, SIP {answer.sip_status.code} {answer.sip_status.status}".strip()
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _track_of(publication: rtc.TrackPublication) -> tuple[TrackKind, TrackSource] | None:
|
|
625
|
+
kind = TRACK_KINDS.get(publication.kind)
|
|
626
|
+
return None if kind is None else (kind, TRACK_SOURCES.get(publication.source, "unknown"))
|