callva-livekit 0.1.0__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.
- callva/livekit/config/__init__.py +21 -0
- callva/livekit/config/models.py +104 -0
- callva/livekit/config/resolver.py +163 -0
- callva/livekit/config/template.py +67 -0
- callva/livekit/core/__init__.py +40 -0
- callva/livekit/core/env.py +35 -0
- callva/livekit/core/envelope.py +77 -0
- callva/livekit/core/identity.py +146 -0
- callva/livekit/core/log.py +10 -0
- callva/livekit/core/state.py +101 -0
- callva/livekit/core/transport.py +278 -0
- callva/livekit/core/version.py +1 -0
- callva/livekit/webhook/__init__.py +22 -0
- callva/livekit/webhook/payload.py +113 -0
- callva/livekit/webhook/service.py +276 -0
- callva/livekit/webhook/storage.py +95 -0
- callva_livekit-0.1.0.dist-info/METADATA +223 -0
- callva_livekit-0.1.0.dist-info/RECORD +19 -0
- callva_livekit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Per-call configuration resolution.
|
|
2
|
+
|
|
3
|
+
Configuration reaches the agent through agent dispatch metadata — ``ctx.job.metadata`` —
|
|
4
|
+
either as a body or as a pointer to an endpoint, or from an endpoint named in the
|
|
5
|
+
environment. Room metadata is deliberately not used: it is broadcast to every participant
|
|
6
|
+
in the room, so a prompt placed there is readable by any connected client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .models import CallConfig, Variables
|
|
10
|
+
from .resolver import ConfigError, as_path, load, request_payload
|
|
11
|
+
from .template import render
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CallConfig",
|
|
15
|
+
"ConfigError",
|
|
16
|
+
"Variables",
|
|
17
|
+
"as_path",
|
|
18
|
+
"load",
|
|
19
|
+
"render",
|
|
20
|
+
"request_payload",
|
|
21
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..core.transport import WebhookTarget
|
|
7
|
+
from .template import render
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Variables(dict):
|
|
11
|
+
"""The call's variables, with their JSON types intact.
|
|
12
|
+
|
|
13
|
+
A number stays a number and a boolean stays a boolean; only substitution into a
|
|
14
|
+
prompt takes the string form. The typed accessors are for reading a variable in code
|
|
15
|
+
without re-checking what the producer sent.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def get_str(self, name: str, default: str | None = None) -> str | None:
|
|
19
|
+
value = self.get(name)
|
|
20
|
+
return default if value is None else str(value)
|
|
21
|
+
|
|
22
|
+
def get_int(self, name: str, default: int | None = None) -> int | None:
|
|
23
|
+
value = self.get(name)
|
|
24
|
+
if isinstance(value, bool) or value is None:
|
|
25
|
+
return default
|
|
26
|
+
try:
|
|
27
|
+
return int(value)
|
|
28
|
+
except (TypeError, ValueError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
def get_float(self, name: str, default: float | None = None) -> float | None:
|
|
32
|
+
value = self.get(name)
|
|
33
|
+
if isinstance(value, bool) or value is None:
|
|
34
|
+
return default
|
|
35
|
+
try:
|
|
36
|
+
return float(value)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return default
|
|
39
|
+
|
|
40
|
+
def get_bool(self, name: str, default: bool | None = None) -> bool | None:
|
|
41
|
+
value = self.get(name)
|
|
42
|
+
if value is None:
|
|
43
|
+
return default
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
return value
|
|
46
|
+
if isinstance(value, (int, float)):
|
|
47
|
+
return bool(value)
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
lowered = value.strip().lower()
|
|
50
|
+
if lowered in ("1", "true", "yes", "on"):
|
|
51
|
+
return True
|
|
52
|
+
if lowered in ("0", "false", "no", "off"):
|
|
53
|
+
return False
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class CallConfig:
|
|
59
|
+
"""The configuration resolved for one call.
|
|
60
|
+
|
|
61
|
+
``prompt`` and ``greeting`` come back rendered. The unrendered forms are kept beside
|
|
62
|
+
them for callers that need the original.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
prompt: str | None = None
|
|
66
|
+
greeting: str | None = None
|
|
67
|
+
raw_prompt: str | None = None
|
|
68
|
+
raw_greeting: str | None = None
|
|
69
|
+
variables: Variables = field(default_factory=Variables)
|
|
70
|
+
webhook: WebhookTarget | None = None
|
|
71
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
source: str = "none"
|
|
73
|
+
"""Where this came from: ``metadata``, ``file``, ``url``, or ``none``."""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def empty(self) -> bool:
|
|
77
|
+
return not any((self.prompt, self.greeting, self.variables, self.extra))
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def parse(cls, payload: Any, *, source: str) -> CallConfig:
|
|
81
|
+
"""Build a config from a decoded response body. Never raises."""
|
|
82
|
+
if not isinstance(payload, dict):
|
|
83
|
+
return cls(source=source)
|
|
84
|
+
|
|
85
|
+
variables = payload.get("variables")
|
|
86
|
+
variables = Variables(variables) if isinstance(variables, dict) else Variables()
|
|
87
|
+
|
|
88
|
+
raw_prompt = payload.get("prompt")
|
|
89
|
+
raw_prompt = raw_prompt if isinstance(raw_prompt, str) else None
|
|
90
|
+
raw_greeting = payload.get("greeting")
|
|
91
|
+
raw_greeting = raw_greeting if isinstance(raw_greeting, str) else None
|
|
92
|
+
|
|
93
|
+
extra = payload.get("extra")
|
|
94
|
+
|
|
95
|
+
return cls(
|
|
96
|
+
prompt=render(raw_prompt, variables),
|
|
97
|
+
greeting=render(raw_greeting, variables),
|
|
98
|
+
raw_prompt=raw_prompt,
|
|
99
|
+
raw_greeting=raw_greeting,
|
|
100
|
+
variables=variables,
|
|
101
|
+
webhook=WebhookTarget.from_dict(payload.get("webhook")),
|
|
102
|
+
extra=extra if isinstance(extra, dict) else {},
|
|
103
|
+
source=source,
|
|
104
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
from urllib.request import url2pathname
|
|
9
|
+
|
|
10
|
+
from ..core import env, transport
|
|
11
|
+
from ..core import state as _state
|
|
12
|
+
from ..core.log import logger
|
|
13
|
+
from .models import CallConfig
|
|
14
|
+
|
|
15
|
+
OnError = Literal["terminate", "continue"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(RuntimeError):
|
|
19
|
+
"""Configuration for this call could not be resolved."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def as_path(endpoint: str) -> Path | None:
|
|
23
|
+
"""A local file, or ``None`` if this names a remote endpoint.
|
|
24
|
+
|
|
25
|
+
``file:///etc/agent.json`` and a bare ``./agent.json`` both mean the same thing. A
|
|
26
|
+
file answers instantly and needs no participant, which makes it the shortest possible
|
|
27
|
+
development loop; it cannot answer per caller, which is why it is not the production
|
|
28
|
+
channel.
|
|
29
|
+
"""
|
|
30
|
+
if endpoint.startswith("file://"):
|
|
31
|
+
return Path(url2pathname(urlparse(endpoint).path))
|
|
32
|
+
if "://" in endpoint:
|
|
33
|
+
return None
|
|
34
|
+
return Path(endpoint).expanduser()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _read_file(path: Path) -> Any:
|
|
38
|
+
text = await asyncio.get_running_loop().run_in_executor(None, path.read_text)
|
|
39
|
+
return json.loads(text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def load(
|
|
43
|
+
*,
|
|
44
|
+
url: str | None = None,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
direction: str | None = None,
|
|
47
|
+
on_error: OnError = "terminate",
|
|
48
|
+
timeout: float | None = None,
|
|
49
|
+
) -> CallConfig:
|
|
50
|
+
"""Resolve the configuration for this call.
|
|
51
|
+
|
|
52
|
+
Resolution order:
|
|
53
|
+
|
|
54
|
+
1. a body inside ``ctx.job.metadata`` — returns immediately, before anyone has joined
|
|
55
|
+
2. a pointer inside ``ctx.job.metadata`` — followed
|
|
56
|
+
3. ``CONFIG_URL`` (or the ``url`` argument) — followed
|
|
57
|
+
|
|
58
|
+
A pointer is either an endpoint, asked with the call's own context as the request body
|
|
59
|
+
so the responder can answer "who called which number", or a local file — ``file://…``
|
|
60
|
+
or a plain path — read as it is.
|
|
61
|
+
|
|
62
|
+
Only the endpoint path needs the SIP envelope to build its request, so only it waits
|
|
63
|
+
for the participant to join. A body in metadata and a file both answer immediately.
|
|
64
|
+
|
|
65
|
+
When configuration cannot be resolved the call is terminated and the reason logged: an
|
|
66
|
+
agent without its prompt is a broken call either way, and failing quietly hides it.
|
|
67
|
+
Pass ``on_error="continue"`` to receive an empty config instead.
|
|
68
|
+
"""
|
|
69
|
+
st = _state.state()
|
|
70
|
+
|
|
71
|
+
if st.config is not None:
|
|
72
|
+
return st.config
|
|
73
|
+
|
|
74
|
+
envelope = st.envelope
|
|
75
|
+
|
|
76
|
+
if envelope.config is not None:
|
|
77
|
+
return _store(st, CallConfig.parse(envelope.config, source="metadata"))
|
|
78
|
+
|
|
79
|
+
endpoint = envelope.config_url or url or env.get("CONFIG_URL")
|
|
80
|
+
if not endpoint:
|
|
81
|
+
logger.debug("no configuration source: job metadata carries none and no URL is set")
|
|
82
|
+
return _store(st, CallConfig(source="none"))
|
|
83
|
+
|
|
84
|
+
path = as_path(endpoint)
|
|
85
|
+
if path is not None:
|
|
86
|
+
try:
|
|
87
|
+
body = await _read_file(path)
|
|
88
|
+
except (OSError, ValueError) as exc:
|
|
89
|
+
return _fail(st, on_error, f"could not read configuration from {path}: {exc}")
|
|
90
|
+
|
|
91
|
+
config = CallConfig.parse(body, source="file")
|
|
92
|
+
if config.empty:
|
|
93
|
+
return _fail(st, on_error, f"configuration file {path} carried nothing usable")
|
|
94
|
+
|
|
95
|
+
logger.debug("resolved configuration from %s", path)
|
|
96
|
+
return _store(st, config)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
participant = await st.ctx.wait_for_participant()
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
return _fail(st, on_error, f"no participant joined, cannot request configuration: {exc}")
|
|
102
|
+
|
|
103
|
+
identity = _state.ensure_identity(st, participant=participant, direction=direction)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
body = await transport.fetch_json(
|
|
107
|
+
endpoint,
|
|
108
|
+
payload=request_payload(st, participant),
|
|
109
|
+
api_key=api_key or env.get("CONFIG_API_KEY"),
|
|
110
|
+
timeout=timeout,
|
|
111
|
+
)
|
|
112
|
+
except transport.FetchError as exc:
|
|
113
|
+
return _fail(st, on_error, f"configuration request to {endpoint} failed: {exc}")
|
|
114
|
+
|
|
115
|
+
config = CallConfig.parse(body, source="url")
|
|
116
|
+
if config.empty:
|
|
117
|
+
return _fail(st, on_error, f"configuration response from {endpoint} carried nothing usable")
|
|
118
|
+
|
|
119
|
+
logger.debug("resolved configuration for call %s from %s", identity.id, endpoint)
|
|
120
|
+
return _store(st, config)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def request_payload(st: _state.CallState, participant: Any) -> dict[str, Any]:
|
|
124
|
+
"""The body of a configuration request.
|
|
125
|
+
|
|
126
|
+
The request is the question: it carries who is calling, which number they reached and
|
|
127
|
+
how the call arrived, so the responder can decide what to send back.
|
|
128
|
+
"""
|
|
129
|
+
identity = _state.ensure_identity(st, participant=participant)
|
|
130
|
+
job = st.ctx.job
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"room": getattr(job.room, "name", None),
|
|
134
|
+
"job_id": job.id,
|
|
135
|
+
"dispatch_id": getattr(job, "dispatch_id", None) or None,
|
|
136
|
+
"agent_name": getattr(job, "agent_name", None) or None,
|
|
137
|
+
"direction": identity.direction,
|
|
138
|
+
"from": identity.from_party.to_dict(),
|
|
139
|
+
"to": identity.to_party.to_dict(),
|
|
140
|
+
"sip": identity.sip,
|
|
141
|
+
"participant_identity": getattr(participant, "identity", None),
|
|
142
|
+
"metadata": st.envelope.raw,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _store(st: _state.CallState, config: CallConfig) -> CallConfig:
|
|
147
|
+
st.config = config
|
|
148
|
+
if config.webhook is not None:
|
|
149
|
+
st.webhook = config.webhook
|
|
150
|
+
return config
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _fail(st: _state.CallState, on_error: OnError, reason: str) -> CallConfig:
|
|
154
|
+
if on_error == "continue":
|
|
155
|
+
logger.error("%s; continuing without configuration", reason)
|
|
156
|
+
return _store(st, CallConfig(source="none"))
|
|
157
|
+
|
|
158
|
+
logger.error("%s; terminating the call", reason)
|
|
159
|
+
try:
|
|
160
|
+
st.ctx.shutdown(reason="callva: configuration unavailable")
|
|
161
|
+
except Exception:
|
|
162
|
+
logger.debug("could not request shutdown", exc_info=True)
|
|
163
|
+
raise ConfigError(reason)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..core.log import logger
|
|
8
|
+
|
|
9
|
+
PLACEHOLDER = re.compile(
|
|
10
|
+
r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*\}\}"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_MISSING = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _lookup(variables: Mapping[str, Any], path: str) -> Any:
|
|
17
|
+
cursor: Any = variables
|
|
18
|
+
for part in path.split("."):
|
|
19
|
+
if not isinstance(cursor, Mapping) or part not in cursor:
|
|
20
|
+
return _MISSING
|
|
21
|
+
cursor = cursor[part]
|
|
22
|
+
return cursor
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _stringify(value: Any) -> str:
|
|
26
|
+
"""Render a value the way its producer wrote it.
|
|
27
|
+
|
|
28
|
+
Booleans come back as ``true``/``false`` rather than Python's capitalised form, and a
|
|
29
|
+
null renders as nothing at all, because both end up inside a prompt a model reads.
|
|
30
|
+
"""
|
|
31
|
+
if value is None:
|
|
32
|
+
return ""
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
return "true" if value else "false"
|
|
35
|
+
return str(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render(text: str | None, variables: Mapping[str, Any] | None) -> str | None:
|
|
39
|
+
"""Substitute ``{{ name }}`` placeholders from ``variables``.
|
|
40
|
+
|
|
41
|
+
A placeholder with no matching variable is left exactly as it was and logged at
|
|
42
|
+
warning level. Rendering never raises and never evaluates anything: one missing key
|
|
43
|
+
must not take down a call that is already ringing.
|
|
44
|
+
"""
|
|
45
|
+
if not text or not variables:
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
missing: list[str] = []
|
|
49
|
+
|
|
50
|
+
def substitute(match: re.Match[str]) -> str:
|
|
51
|
+
path = match.group(1)
|
|
52
|
+
value = _lookup(variables, path)
|
|
53
|
+
if value is _MISSING:
|
|
54
|
+
missing.append(path)
|
|
55
|
+
return match.group(0)
|
|
56
|
+
return _stringify(value)
|
|
57
|
+
|
|
58
|
+
rendered = PLACEHOLDER.sub(substitute, text)
|
|
59
|
+
|
|
60
|
+
if missing:
|
|
61
|
+
logger.warning(
|
|
62
|
+
"left %s unresolved placeholder(s) in place: %s",
|
|
63
|
+
len(missing),
|
|
64
|
+
", ".join(sorted(set(missing))),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return rendered
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Shared call identity, per-job state, transport and logging.
|
|
2
|
+
|
|
3
|
+
Modules in this package never import one another. They read and write the
|
|
4
|
+
:class:`CallState` that lives here, keyed off the ambient LiveKit job, which is how
|
|
5
|
+
configuration resolved by one module reaches another.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .envelope import ENVELOPE_KEY, DispatchEnvelope, parse
|
|
9
|
+
from .identity import INBOUND, OUTBOUND, CallIdentity, Party, resolve, resolve_direction
|
|
10
|
+
from .log import logger
|
|
11
|
+
from .state import CallState, NoJobContext, context, ensure_identity
|
|
12
|
+
from .state import state as call_state
|
|
13
|
+
from .transport import WebhookTarget, post_file, post_json
|
|
14
|
+
from .version import __version__
|
|
15
|
+
|
|
16
|
+
# `state` deliberately stays bound to the submodule: re-exporting the function under that
|
|
17
|
+
# name would shadow it, and every internal `from ..core import state` would silently get a
|
|
18
|
+
# function instead of the module.
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ENVELOPE_KEY",
|
|
22
|
+
"INBOUND",
|
|
23
|
+
"OUTBOUND",
|
|
24
|
+
"CallIdentity",
|
|
25
|
+
"CallState",
|
|
26
|
+
"DispatchEnvelope",
|
|
27
|
+
"NoJobContext",
|
|
28
|
+
"Party",
|
|
29
|
+
"WebhookTarget",
|
|
30
|
+
"__version__",
|
|
31
|
+
"call_state",
|
|
32
|
+
"context",
|
|
33
|
+
"ensure_identity",
|
|
34
|
+
"logger",
|
|
35
|
+
"parse",
|
|
36
|
+
"post_file",
|
|
37
|
+
"post_json",
|
|
38
|
+
"resolve",
|
|
39
|
+
"resolve_direction",
|
|
40
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Environment variables.
|
|
2
|
+
|
|
3
|
+
Names describe the job, not the vendor. This is an extension to the LiveKit Agents SDK;
|
|
4
|
+
that a URL happens to point at CallVA is configuration, not identity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get(name: str, default: str | None = None) -> str | None:
|
|
13
|
+
"""Read an environment variable, treating a blank value as unset."""
|
|
14
|
+
value = os.environ.get(name)
|
|
15
|
+
if value is None:
|
|
16
|
+
return default
|
|
17
|
+
value = value.strip()
|
|
18
|
+
return value or default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_bool(name: str, default: bool = False) -> bool:
|
|
22
|
+
value = get(name)
|
|
23
|
+
if value is None:
|
|
24
|
+
return default
|
|
25
|
+
return value.lower() in ("1", "true", "yes", "on")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_float(name: str, default: float | None = None) -> float | None:
|
|
29
|
+
value = get(name)
|
|
30
|
+
if value is None:
|
|
31
|
+
return default
|
|
32
|
+
try:
|
|
33
|
+
return float(value)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return default
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .log import logger
|
|
8
|
+
|
|
9
|
+
ENVELOPE_KEY = "callva"
|
|
10
|
+
|
|
11
|
+
_OWN_KEYS = frozenset({"call_id", "direction", "config", "config_url", "webhook"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DispatchEnvelope:
|
|
16
|
+
"""What the dispatcher said about this call, read from ``ctx.job.metadata``.
|
|
17
|
+
|
|
18
|
+
Job metadata is a free-form string that the host application may already be using for
|
|
19
|
+
its own purposes, so the envelope is looked for under a ``callva`` key first. A
|
|
20
|
+
top-level object is only claimed when it carries keys that are unambiguously ours.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
call_id: str | None = None
|
|
24
|
+
direction: str | None = None
|
|
25
|
+
config: dict[str, Any] | None = None
|
|
26
|
+
config_url: str | None = None
|
|
27
|
+
webhook: dict[str, Any] | None = None
|
|
28
|
+
raw: str | None = None
|
|
29
|
+
"""The original metadata string, forwarded to a config endpoint unchanged."""
|
|
30
|
+
|
|
31
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
"""Everything else found alongside our keys."""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def empty(self) -> bool:
|
|
36
|
+
return not any((self.call_id, self.direction, self.config, self.config_url, self.webhook))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse(metadata: str | None) -> DispatchEnvelope:
|
|
40
|
+
"""Parse job metadata into an envelope. Never raises."""
|
|
41
|
+
if not metadata or not metadata.strip():
|
|
42
|
+
return DispatchEnvelope()
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
decoded = json.loads(metadata)
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
logger.debug("job metadata is not JSON, no dispatch envelope taken from it")
|
|
48
|
+
return DispatchEnvelope(raw=metadata)
|
|
49
|
+
|
|
50
|
+
if not isinstance(decoded, dict):
|
|
51
|
+
return DispatchEnvelope(raw=metadata)
|
|
52
|
+
|
|
53
|
+
scoped = decoded.get(ENVELOPE_KEY)
|
|
54
|
+
if isinstance(scoped, dict):
|
|
55
|
+
body = scoped
|
|
56
|
+
elif _OWN_KEYS & decoded.keys():
|
|
57
|
+
body = decoded
|
|
58
|
+
else:
|
|
59
|
+
return DispatchEnvelope(raw=metadata)
|
|
60
|
+
|
|
61
|
+
def _dict(key: str) -> dict[str, Any] | None:
|
|
62
|
+
value = body.get(key)
|
|
63
|
+
return value if isinstance(value, dict) else None
|
|
64
|
+
|
|
65
|
+
def _str(key: str) -> str | None:
|
|
66
|
+
value = body.get(key)
|
|
67
|
+
return value.strip() or None if isinstance(value, str) else None
|
|
68
|
+
|
|
69
|
+
return DispatchEnvelope(
|
|
70
|
+
call_id=_str("call_id"),
|
|
71
|
+
direction=_str("direction"),
|
|
72
|
+
config=_dict("config"),
|
|
73
|
+
config_url=_str("config_url"),
|
|
74
|
+
webhook=_dict("webhook"),
|
|
75
|
+
raw=metadata,
|
|
76
|
+
extra={k: v for k, v in body.items() if k not in _OWN_KEYS},
|
|
77
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import env
|
|
10
|
+
from .envelope import DispatchEnvelope
|
|
11
|
+
from .log import logger
|
|
12
|
+
|
|
13
|
+
INBOUND = "inbound"
|
|
14
|
+
OUTBOUND = "outbound"
|
|
15
|
+
|
|
16
|
+
SIP_PREFIX = "sip."
|
|
17
|
+
|
|
18
|
+
_REMOTE_NUMBER_KEYS = ("phoneNumber", "phone_number")
|
|
19
|
+
_LOCAL_NUMBER_KEYS = ("trunkPhoneNumber", "trunk_phone_number")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Party:
|
|
24
|
+
"""One end of the call."""
|
|
25
|
+
|
|
26
|
+
number: str | None = None
|
|
27
|
+
identity: str | None = None
|
|
28
|
+
name: str | None = None
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {"number": self.number, "identity": self.identity, "name": self.name}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CallIdentity:
|
|
36
|
+
"""Who is on this call, which way it goes, and what to call it.
|
|
37
|
+
|
|
38
|
+
Derived by the package. Nothing here is taken from the call's configuration: an
|
|
39
|
+
inbound call is described by the SIP envelope it arrived in, and a dispatched call by
|
|
40
|
+
what the dispatcher declared.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
direction: str
|
|
45
|
+
from_party: Party
|
|
46
|
+
to_party: Party
|
|
47
|
+
sip: dict[str, Any] | None = None
|
|
48
|
+
started_at: float = 0.0
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"id": self.id,
|
|
53
|
+
"direction": self.direction,
|
|
54
|
+
"from": self.from_party.to_dict(),
|
|
55
|
+
"to": self.to_party.to_dict(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sip_attributes(attributes: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
60
|
+
"""Collect the ``sip.*`` participant attributes into a nested dict, unchanged.
|
|
61
|
+
|
|
62
|
+
``sip.twilio.callSid`` becomes ``{"twilio": {"callSid": ...}}``. Keys are never
|
|
63
|
+
renamed and nothing is dropped, so attributes LiveKit adds later flow through without
|
|
64
|
+
a release here.
|
|
65
|
+
"""
|
|
66
|
+
if not attributes:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
tree: dict[str, Any] = {}
|
|
70
|
+
for key, value in attributes.items():
|
|
71
|
+
if not key.startswith(SIP_PREFIX):
|
|
72
|
+
continue
|
|
73
|
+
parts = key[len(SIP_PREFIX) :].split(".")
|
|
74
|
+
cursor = tree
|
|
75
|
+
for part in parts[:-1]:
|
|
76
|
+
existing = cursor.get(part)
|
|
77
|
+
if not isinstance(existing, dict):
|
|
78
|
+
existing = {}
|
|
79
|
+
cursor[part] = existing
|
|
80
|
+
cursor = existing
|
|
81
|
+
cursor[parts[-1]] = value
|
|
82
|
+
|
|
83
|
+
return tree or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _first(source: Mapping[str, Any], keys: tuple[str, ...]) -> str | None:
|
|
87
|
+
for key in keys:
|
|
88
|
+
value = source.get(key)
|
|
89
|
+
if isinstance(value, str) and value.strip():
|
|
90
|
+
return value.strip()
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_direction(envelope: DispatchEnvelope, override: str | None = None) -> str:
|
|
95
|
+
"""Resolve the call direction. Never inferred from participant state.
|
|
96
|
+
|
|
97
|
+
The dispatcher's declaration wins, then an explicit override or ``CALL_DIRECTION``,
|
|
98
|
+
then inbound. The default is sound rather than a guess: an outbound call is always
|
|
99
|
+
placed by someone, so it always arrives with dispatch metadata.
|
|
100
|
+
"""
|
|
101
|
+
for candidate in (envelope.direction, override, env.get("CALL_DIRECTION")):
|
|
102
|
+
if not candidate:
|
|
103
|
+
continue
|
|
104
|
+
value = candidate.strip().lower()
|
|
105
|
+
if value in (INBOUND, OUTBOUND):
|
|
106
|
+
return value
|
|
107
|
+
logger.warning(
|
|
108
|
+
"ignoring unknown call direction %r, expected inbound or outbound", candidate
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return INBOUND
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve(
|
|
115
|
+
*,
|
|
116
|
+
envelope: DispatchEnvelope,
|
|
117
|
+
participant: Any | None = None,
|
|
118
|
+
direction: str | None = None,
|
|
119
|
+
started_at: float | None = None,
|
|
120
|
+
) -> CallIdentity:
|
|
121
|
+
"""Build the call identity from the dispatch envelope and the SIP envelope."""
|
|
122
|
+
resolved_direction = resolve_direction(envelope, direction)
|
|
123
|
+
|
|
124
|
+
attributes = getattr(participant, "attributes", None) or {}
|
|
125
|
+
sip = sip_attributes(attributes)
|
|
126
|
+
|
|
127
|
+
remote = Party(
|
|
128
|
+
number=_first(sip, _REMOTE_NUMBER_KEYS) if sip else None,
|
|
129
|
+
identity=getattr(participant, "identity", None) or None,
|
|
130
|
+
name=getattr(participant, "name", None) or None,
|
|
131
|
+
)
|
|
132
|
+
local = Party(number=_first(sip, _LOCAL_NUMBER_KEYS) if sip else None)
|
|
133
|
+
|
|
134
|
+
if resolved_direction == OUTBOUND:
|
|
135
|
+
from_party, to_party = local, remote
|
|
136
|
+
else:
|
|
137
|
+
from_party, to_party = remote, local
|
|
138
|
+
|
|
139
|
+
return CallIdentity(
|
|
140
|
+
id=envelope.call_id or uuid.uuid4().hex,
|
|
141
|
+
direction=resolved_direction,
|
|
142
|
+
from_party=from_party,
|
|
143
|
+
to_party=to_party,
|
|
144
|
+
sip=sip,
|
|
145
|
+
started_at=started_at if started_at is not None else time.time(),
|
|
146
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("callva.livekit")
|
|
6
|
+
"""The package logger.
|
|
7
|
+
|
|
8
|
+
The package never sets a level, never attaches a handler and never touches the root
|
|
9
|
+
logger. Whatever the host application has configured is what applies.
|
|
10
|
+
"""
|