更新
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
"""Pure-Python contract and launcher for the optional video companion.
|
||||
|
||||
The backend is the only authority that may issue ``userSig``. This module
|
||||
normalizes that short-lived ticket and deliberately keeps Qt imports out of the
|
||||
contract layer so it remains importable in core-only installations and tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class VideoTicketError(ValueError):
|
||||
"""Raised when a backend video ticket is incomplete or unsafe."""
|
||||
|
||||
|
||||
class BackendMode(StrEnum):
|
||||
"""Supported rendering backends for a video call."""
|
||||
|
||||
EMBEDDED = "embedded"
|
||||
BROWSER = "browser"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: BackendMode | str) -> BackendMode:
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
try:
|
||||
return cls(str(value).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise VideoTicketError("backend mode must be 'embedded' or 'browser'") from exc
|
||||
|
||||
|
||||
def require_supported_backend(value: BackendMode | str) -> BackendMode:
|
||||
"""Reject browser launch until a server-issued one-time handoff exists."""
|
||||
|
||||
mode = BackendMode.parse(value)
|
||||
if mode is BackendMode.BROWSER:
|
||||
raise VideoTicketError(
|
||||
"browser video mode is disabled until a server-issued one-time handoff is available"
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
Identifier = int | str
|
||||
|
||||
|
||||
def _identifier(value: Any, field_name: str) -> Identifier:
|
||||
if isinstance(value, bool) or value is None:
|
||||
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
|
||||
if isinstance(value, int):
|
||||
if value <= 0:
|
||||
raise VideoTicketError(f"{field_name} must be a positive identifier")
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
raise VideoTicketError(f"{field_name} must be a non-empty identifier")
|
||||
return cleaned
|
||||
raise VideoTicketError(f"{field_name} must be a string or integer")
|
||||
|
||||
|
||||
def _non_empty_string(value: Any, field_name: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise VideoTicketError(f"{field_name} must be a non-empty string")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _sdk_app_id(value: Any, field_name: str = "SDKAppID") -> int:
|
||||
if isinstance(value, bool):
|
||||
raise VideoTicketError(f"{field_name} must be a positive integer")
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise VideoTicketError(f"{field_name} must be a positive integer") from exc
|
||||
if parsed <= 0 or str(value).strip() != str(parsed):
|
||||
raise VideoTicketError(f"{field_name} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
def _normalized_key(key: Any) -> str:
|
||||
return "".join(character for character in str(key).lower() if character.isalnum())
|
||||
|
||||
|
||||
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
|
||||
|
||||
|
||||
def _reject_server_secrets(payload: Mapping[str, Any]) -> None:
|
||||
for key in payload:
|
||||
if _normalized_key(key) in _FORBIDDEN_SECRET_KEYS:
|
||||
raise VideoTicketError("backend ticket contains forbidden server-side secret material")
|
||||
|
||||
|
||||
def _contains_ticket_fields(payload: Mapping[str, Any]) -> bool:
|
||||
keys = {_normalized_key(key) for key in payload}
|
||||
return bool(keys & {"sdkappid", "userid", "usersig", "targetuserid", "patientuserid"})
|
||||
|
||||
|
||||
def _ticket_payload(ticket: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
_reject_server_secrets(ticket)
|
||||
if _contains_ticket_fields(ticket):
|
||||
return ticket
|
||||
|
||||
for envelope_key in ("data", "result", "ticket"):
|
||||
nested = ticket.get(envelope_key)
|
||||
if isinstance(nested, Mapping):
|
||||
_reject_server_secrets(nested)
|
||||
if _contains_ticket_fields(nested):
|
||||
return nested
|
||||
return ticket
|
||||
|
||||
|
||||
def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
|
||||
"""Adapt the repository's CallTicket model without importing core models."""
|
||||
|
||||
if isinstance(ticket, Mapping):
|
||||
return ticket
|
||||
|
||||
raw = getattr(ticket, "raw", None)
|
||||
if isinstance(raw, Mapping):
|
||||
_reject_server_secrets(raw)
|
||||
|
||||
attribute_aliases = {
|
||||
"sdkAppId": "sdk_app_id",
|
||||
"userId": "user_id",
|
||||
"userSig": "user_sig",
|
||||
"patientUserId": "patient_user_id",
|
||||
"diagnosisId": "diagnosis_id",
|
||||
"patientId": "patient_id",
|
||||
}
|
||||
adapted = {
|
||||
json_name: getattr(ticket, attribute_name)
|
||||
for json_name, attribute_name in attribute_aliases.items()
|
||||
if hasattr(ticket, attribute_name)
|
||||
}
|
||||
if adapted:
|
||||
return adapted
|
||||
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
|
||||
|
||||
|
||||
def _read_aliases(
|
||||
payload: Mapping[str, Any],
|
||||
aliases: tuple[str, ...],
|
||||
field_name: str,
|
||||
converter: Callable[[Any, str], Any],
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> Any:
|
||||
converted: list[Any] = []
|
||||
for alias in aliases:
|
||||
if alias in payload and payload[alias] is not None:
|
||||
converted.append(converter(payload[alias], field_name))
|
||||
|
||||
if not converted:
|
||||
if required:
|
||||
raise VideoTicketError(f"backend ticket is missing {field_name}")
|
||||
return None
|
||||
if any(value != converted[0] and str(value) != str(converted[0]) for value in converted[1:]):
|
||||
raise VideoTicketError(f"backend ticket has conflicting {field_name} aliases")
|
||||
return converted[0]
|
||||
|
||||
|
||||
def _merge_identifier(
|
||||
payload_value: Identifier | None,
|
||||
explicit_value: Any,
|
||||
field_name: str,
|
||||
) -> Identifier:
|
||||
if explicit_value is None:
|
||||
if payload_value is None:
|
||||
raise VideoTicketError(f"backend ticket is missing {field_name}")
|
||||
return payload_value
|
||||
normalized = _identifier(explicit_value, field_name)
|
||||
if (
|
||||
payload_value is not None
|
||||
and payload_value != normalized
|
||||
and str(payload_value) != str(normalized)
|
||||
):
|
||||
raise VideoTicketError(f"backend ticket conflicts with requested {field_name}")
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VideoCallRequest:
|
||||
"""Validated data required to start one doctor-to-patient video call.
|
||||
|
||||
``user_sig`` is excluded from ``repr``. Use :meth:`safe_log_context` for
|
||||
structured logs; never serialize the dataclass itself into diagnostics.
|
||||
"""
|
||||
|
||||
sdk_app_id: int
|
||||
user_id: str
|
||||
user_sig: str = field(repr=False)
|
||||
target_user_id: str
|
||||
diagnosis_id: Identifier
|
||||
patient_id: Identifier | None = None
|
||||
backend_mode: BackendMode = BackendMode.EMBEDDED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
|
||||
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
|
||||
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"target_user_id",
|
||||
_non_empty_string(self.target_user_id, "targetUserId"),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"diagnosis_id",
|
||||
_identifier(self.diagnosis_id, "diagnosisId"),
|
||||
)
|
||||
if self.patient_id is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"patient_id",
|
||||
_identifier(self.patient_id, "patientId"),
|
||||
)
|
||||
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
|
||||
|
||||
@classmethod
|
||||
def from_backend_ticket(
|
||||
cls,
|
||||
ticket: Any,
|
||||
*,
|
||||
diagnosis_id: Any = None,
|
||||
patient_id: Any = None,
|
||||
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
|
||||
) -> VideoCallRequest:
|
||||
return normalize_backend_ticket(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
backend_mode=backend_mode,
|
||||
)
|
||||
|
||||
def to_web_config(self) -> dict[str, Any]:
|
||||
"""Return the canonical JavaScript bridge payload.
|
||||
|
||||
The returned mapping contains the short-lived credential and therefore
|
||||
must only be passed in memory to the trusted companion page.
|
||||
"""
|
||||
|
||||
return {
|
||||
"SDKAppID": self.sdk_app_id,
|
||||
"userID": self.user_id,
|
||||
"userSig": self.user_sig,
|
||||
"targetUserId": self.target_user_id,
|
||||
"diagnosisId": self.diagnosis_id,
|
||||
}
|
||||
|
||||
def safe_log_context(self) -> dict[str, Any]:
|
||||
"""Return non-secret call metadata suitable for structured logging."""
|
||||
|
||||
return {
|
||||
"diagnosis_id": self.diagnosis_id,
|
||||
"patient_id": self.patient_id,
|
||||
"backend_mode": self.backend_mode.value,
|
||||
}
|
||||
|
||||
|
||||
def normalize_backend_ticket(
|
||||
ticket: Any,
|
||||
*,
|
||||
diagnosis_id: Any = None,
|
||||
patient_id: Any = None,
|
||||
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
|
||||
) -> VideoCallRequest:
|
||||
"""Normalize backend camel-case aliases into a validated call request."""
|
||||
|
||||
payload = _ticket_payload(_ticket_mapping(ticket))
|
||||
payload_diagnosis = _read_aliases(
|
||||
payload,
|
||||
("diagnosisId", "diagnosis_id"),
|
||||
"diagnosisId",
|
||||
_identifier,
|
||||
required=False,
|
||||
)
|
||||
payload_patient = _read_aliases(
|
||||
payload,
|
||||
("patientId", "patient_id"),
|
||||
"patientId",
|
||||
_identifier,
|
||||
required=False,
|
||||
)
|
||||
|
||||
normalized_diagnosis = _merge_identifier(
|
||||
payload_diagnosis,
|
||||
diagnosis_id,
|
||||
"diagnosisId",
|
||||
)
|
||||
if patient_id is not None:
|
||||
normalized_patient = _merge_identifier(payload_patient, patient_id, "patientId")
|
||||
else:
|
||||
normalized_patient = payload_patient
|
||||
|
||||
return VideoCallRequest(
|
||||
sdk_app_id=_read_aliases(
|
||||
payload,
|
||||
("SDKAppID", "sdkAppId", "sdkAppID"),
|
||||
"SDKAppID",
|
||||
_sdk_app_id,
|
||||
),
|
||||
user_id=_read_aliases(
|
||||
payload,
|
||||
("userID", "userId"),
|
||||
"userID",
|
||||
_non_empty_string,
|
||||
),
|
||||
user_sig=_read_aliases(
|
||||
payload,
|
||||
("userSig", "user_sig"),
|
||||
"userSig",
|
||||
_non_empty_string,
|
||||
),
|
||||
target_user_id=_read_aliases(
|
||||
payload,
|
||||
("targetUserId", "patientUserId"),
|
||||
"targetUserId",
|
||||
_non_empty_string,
|
||||
),
|
||||
diagnosis_id=normalized_diagnosis,
|
||||
patient_id=normalized_patient,
|
||||
backend_mode=BackendMode.parse(backend_mode),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VideoCallLauncher:
|
||||
"""Small composition root that defers the optional Qt import until launch."""
|
||||
|
||||
repository: Any
|
||||
backend_mode: BackendMode | str = BackendMode.EMBEDDED
|
||||
local_dist: str | Path | None = None
|
||||
remote_url: str | None = None
|
||||
logger: Any = None
|
||||
browser_opener: Callable[[str], bool] | None = None
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
ticket: Any,
|
||||
*,
|
||||
diagnosis_id: Any = None,
|
||||
patient_id: Any = None,
|
||||
) -> VideoCallRequest:
|
||||
require_supported_backend(self.backend_mode)
|
||||
return normalize_backend_ticket(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
backend_mode=self.backend_mode,
|
||||
)
|
||||
|
||||
def launch(
|
||||
self,
|
||||
ticket: Any,
|
||||
*,
|
||||
diagnosis_id: Any = None,
|
||||
patient_id: Any = None,
|
||||
) -> Any:
|
||||
request = self.prepare(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
)
|
||||
from .window import open_video_call
|
||||
|
||||
return open_video_call(
|
||||
request,
|
||||
repository=self.repository,
|
||||
local_dist=self.local_dist,
|
||||
remote_url=self.remote_url,
|
||||
logger=self.logger,
|
||||
browser_opener=self.browser_opener,
|
||||
)
|
||||
|
||||
|
||||
def launch_video_call(
|
||||
ticket: Any,
|
||||
*,
|
||||
repository: Any,
|
||||
diagnosis_id: Any = None,
|
||||
patient_id: Any = None,
|
||||
backend_mode: BackendMode | str = BackendMode.EMBEDDED,
|
||||
local_dist: str | Path | None = None,
|
||||
remote_url: str | None = None,
|
||||
logger: Any = None,
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
) -> Any:
|
||||
"""Normalize a ticket and open a call with the requested backend."""
|
||||
|
||||
return VideoCallLauncher(
|
||||
repository=repository,
|
||||
backend_mode=backend_mode,
|
||||
local_dist=local_dist,
|
||||
remote_url=remote_url,
|
||||
logger=logger,
|
||||
browser_opener=browser_opener,
|
||||
).launch(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
)
|
||||
Reference in New Issue
Block a user