Files
zyt/app/src/doctor_workstation/video/launcher.py
T
2026-08-12 11:03:28 +08:00

414 lines
13 KiB
Python

"""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,
open_im: bool = False,
patient_name: str = "患者",
) -> 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,
open_im=open_im,
patient_name=patient_name,
)
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,
open_im: bool = False,
patient_name: str = "患者",
) -> 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,
open_im=open_im,
patient_name=patient_name,
)