414 lines
13 KiB
Python
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,
|
|
)
|