"""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()) def _appointment_id(value: Any) -> int: if value is None: return 0 try: parsed = int(value) except (TypeError, ValueError) as exc: raise VideoTicketError("appointment_id must be a nonnegative integer") from exc if isinstance(value, bool) or parsed < 0 or str(value) != str(parsed): raise VideoTicketError("appointment_id must be a nonnegative integer") return parsed _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", "callRecordId": "call_record_id", } adapted = { json_name: getattr(ticket, attribute_name) for json_name, attribute_name in attribute_aliases.items() if hasattr(ticket, attribute_name) } if adapted: # Preserve server policy and identities; model attributes alone omit them. return {**(dict(raw) if isinstance(raw, Mapping) else {}), **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 call_record_id: Identifier | None = None backend_mode: BackendMode = BackendMode.EMBEDDED appointment_id: int = 0 appointment_type: Any = None appointment_type_desc: str = "" can_video_call: bool = False can_audio_call: bool = False call_disabled_reason: str = "" def __post_init__(self) -> None: object.__setattr__(self, "appointment_id", _appointment_id(self.appointment_id)) 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"), ) if self.call_record_id is not None: object.__setattr__( self, "call_record_id", _identifier(self.call_record_id, "callRecordId"), ) 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, "patientId": self.patient_id, "appointmentId": self.appointment_id, "appointment_type": self.appointment_type, "appointment_type_desc": self.appointment_type_desc, "can_video_call": self.can_video_call is True, "can_audio_call": self.can_audio_call is True, "call_disabled_reason": self.call_disabled_reason, } 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, "call_record_id": self.call_record_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, ) payload_call_record = _read_aliases( payload, ("callRecordId", "call_record_id"), "callRecordId", _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, call_record_id=payload_call_record, appointment_id=_appointment_id(payload.get("appointment_id", 0)), appointment_type=payload.get("appointment_type"), appointment_type_desc=str(payload.get("appointment_type_desc") or ""), can_video_call=payload.get("can_video_call") is True, can_audio_call=payload.get("can_audio_call") is True, call_disabled_reason=str(payload.get("call_disabled_reason") or ""), 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 = "患者", patient_case: Mapping[str, Any] | None = None, on_open_diagnosis: Callable[[], None] | None = 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, open_im=open_im, patient_name=patient_name, patient_case=patient_case, on_open_diagnosis=on_open_diagnosis, ) 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 = "患者", patient_case: Mapping[str, Any] | None = None, on_open_diagnosis: Callable[[], None] | 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, open_im=open_im, patient_name=patient_name, patient_case=patient_case, on_open_diagnosis=on_open_diagnosis, )