"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL. The connector hands a ``CapabilityDescriptor`` to the gateway's ``RelayAdapter`` at handshake time; it tells the adapter which platform it is fronting and which capabilities to advertise to the ``GatewayStreamConsumer`` (char limit, draft-streaming, edit/threading support, markdown dialect, length unit). It is the linchpin of the generalization: one gateway adapter serves Discord, Telegram, Matrix, Signal, ... without per-platform branching. EXPERIMENTAL: this schema MAY CHANGE without a deprecation cycle until at least two real Class-1 platforms have validated it. Evolution during the experimental phase is additive-only, gated by ``contract_version`` (see docs/relay-connector-contract.md). Field origins (most are a wire-serializable projection of ``PlatformEntry`` plus the per-instance capability methods on ``BasePlatformAdapter``): - ``max_message_length`` -> ``PlatformEntry.max_message_length`` / adapter ``MAX_MESSAGE_LENGTH`` attribute (read by stream_consumer). - ``len_unit`` -> selects which ``message_len_fn`` the adapter installs ("chars" = builtin len; "utf16" = Telegram-style UTF-16 code-unit counting). - ``supports_draft_streaming`` -> adapter ``supports_draft_streaming()`` probe. - ``supports_edit`` -> whether edit-based streaming is possible (Discord/ Telegram yes; Signal/SMS no -> consumer degrades to one-message-per-segment). - ``supports_threads`` -> ``create_handoff_thread`` capability flag. - ``markdown_dialect`` -> presentation hint (e.g. "markdown_v2", "discord"). - ``emoji`` / ``platform_hint`` / ``pii_safe`` -> ``PlatformEntry`` fields of the same name. """ from __future__ import annotations import json from dataclasses import asdict, dataclass # Bump additively (never reinterpret an existing field) during the experimental # phase; a breaking change requires updating both repos in lockstep. CONTRACT_VERSION = 1 @dataclass(frozen=True) class CapabilityDescriptor: """Immutable capability descriptor negotiated at relay handshake. Frozen so a descriptor cannot be mutated after handshake — the adapter advertises a fixed capability profile for the life of the connection. """ contract_version: int platform: str label: str max_message_length: int supports_draft_streaming: bool supports_edit: bool supports_threads: bool markdown_dialect: str len_unit: str # "chars" | "utf16" emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default) platform_hint: str = "" pii_safe: bool = False def to_json(self) -> str: """Serialize to a compact, stable JSON string for the handshake frame.""" return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False) @classmethod def from_json(cls, data: str) -> "CapabilityDescriptor": """Deserialize from a handshake JSON string. Unknown keys are ignored (forward-compat: a newer connector may send fields this gateway does not know yet); missing optional keys fall back to dataclass defaults. """ raw = json.loads(data) known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined] filtered = {k: v for k, v in raw.items() if k in known} return cls(**filtered) @classmethod def from_platform_entry( cls, entry, *, len_unit: str = "chars", supports_draft_streaming: bool = False, supports_edit: bool = True, supports_threads: bool = False, markdown_dialect: str = "plain", ) -> "CapabilityDescriptor": """Project a ``gateway.platform_registry.PlatformEntry`` into a descriptor. Demonstrates the descriptor is a *subset/projection* of what ``PlatformEntry`` already encodes, not a parallel concept: ``label``, ``max_message_length``, ``emoji``, ``platform_hint``, ``pii_safe`` and the platform name come straight off the entry. The runtime capability bits that ``PlatformEntry`` does NOT encode (length unit, draft/edit/ thread/markdown behavior) are supplied by the caller — in production the connector fills these from the live adapter's capability methods. ``max_message_length`` of 0 on a ``PlatformEntry`` means "no limit"; we map that to the stream_consumer default of 4096 so the descriptor always carries a concrete chunking bound. """ max_len = getattr(entry, "max_message_length", 0) or 4096 return cls( contract_version=CONTRACT_VERSION, platform=entry.name, label=entry.label, max_message_length=max_len, supports_draft_streaming=supports_draft_streaming, supports_edit=supports_edit, supports_threads=supports_threads, markdown_dialect=markdown_dialect, len_unit=len_unit, emoji=getattr(entry, "emoji", "\U0001f50c"), platform_hint=getattr(entry, "platform_hint", ""), pii_safe=getattr(entry, "pii_safe", False), )