139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL.
|
|
|
|
The connector hands one to the gateway's ``RelayAdapter`` at handshake: which
|
|
platform it fronts and which capabilities to advertise to the stream consumer
|
|
(char limit, draft streaming, edit/threading, markdown dialect, length unit).
|
|
One gateway adapter thus serves every platform without per-platform branching.
|
|
|
|
Schema evolution is additive-only, gated by ``contract_version``
|
|
(docs/relay-connector-contract.md). Most fields are a wire projection of
|
|
``PlatformEntry`` (``max_message_length``, ``emoji``, ``platform_hint``,
|
|
``pii_safe``, ``label``) plus the adapter capability probes
|
|
(``supports_draft_streaming``, ``supports_edit``, ``supports_threads``,
|
|
``len_unit`` → which ``message_len_fn`` the adapter installs).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
|
|
# Bump additively (never reinterpret an existing field); a breaking change
|
|
# requires updating both repos in lockstep.
|
|
CONTRACT_VERSION = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CapabilityDescriptor:
|
|
"""Immutable capability profile negotiated at relay handshake (frozen: fixed for 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" # 🔌 (matches PlatformEntry default)
|
|
platform_hint: str = ""
|
|
pii_safe: bool = False
|
|
# All optional bits below default False/empty so an older connector that
|
|
# never sends them is treated as "not supported" — additive within
|
|
# contract_version 1 (from_json also drops unknown keys, so newer connectors
|
|
# are safe against older gateways).
|
|
# Connector can supply surrounding channel/group CONTEXT for an addressed turn.
|
|
supports_context: bool = False
|
|
# Platform can host a FLAT continuable cron surface (native Slack's
|
|
# ``cron_continuable_surface: in_channel``); the scheduler fails safe to
|
|
# thread mode when False (D6 gate).
|
|
supports_inchannel_continuable: bool = False
|
|
# Platform sender renders block-level formatting from raw markdown; when
|
|
# True AND the operator enables rich_blocks/markdown_blocks, the gateway
|
|
# stamps ``format_hints`` on outbound send/edit metadata.
|
|
supports_block_formatting: bool = False
|
|
# Outbound op names the connector implements for this platform. Empty =
|
|
# the connector predates the field; callers MUST treat that as LEGACY_OPS,
|
|
# not "nothing supported". Tuple keeps the frozen dataclass hashable.
|
|
supported_ops: tuple = ()
|
|
|
|
# Assumed capability set when a legacy connector sends no supported_ops.
|
|
LEGACY_OPS = ("send", "edit", "typing", "follow_up")
|
|
|
|
def supports_op(self, op: str) -> bool:
|
|
"""Whether the connector advertises ``op`` (legacy set when none advertised).
|
|
|
|
A NEW op is therefore only True when explicitly advertised — capability
|
|
can be probed without trying the op and parsing an error.
|
|
"""
|
|
if not self.supported_ops:
|
|
return op in self.LEGACY_OPS
|
|
return op in self.supported_ops
|
|
|
|
def to_json(self) -> str:
|
|
"""Compact, stable JSON for the handshake frame."""
|
|
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
|
|
|
|
@classmethod
|
|
def from_json(cls, data: str) -> "CapabilityDescriptor":
|
|
"""Deserialize a handshake JSON string; unknown keys ignored, missing keys default.
|
|
|
|
Trust-boundary normalization (malformed input never breaks the handshake):
|
|
a non-positive/garbage ``max_message_length`` ("no limit", or hostile)
|
|
maps to the documented 4096 default so the adapter can always chunk;
|
|
``supported_ops`` becomes a tuple of non-empty strings, or () (legacy
|
|
fallback) when malformed.
|
|
"""
|
|
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}
|
|
if "max_message_length" in filtered:
|
|
try:
|
|
if int(filtered["max_message_length"]) <= 0:
|
|
filtered["max_message_length"] = 4096
|
|
except (TypeError, ValueError):
|
|
filtered["max_message_length"] = 4096
|
|
if "supported_ops" in filtered:
|
|
raw_ops = filtered["supported_ops"]
|
|
if isinstance(raw_ops, (list, tuple)):
|
|
filtered["supported_ops"] = tuple(
|
|
str(op) for op in raw_ops if isinstance(op, str) and op
|
|
)
|
|
else:
|
|
filtered["supported_ops"] = ()
|
|
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.
|
|
|
|
Runtime bits the entry does not encode (length unit, draft/edit/thread/
|
|
markdown) come from the caller. A ``max_message_length`` of 0 ("no
|
|
limit") maps to the stream_consumer default of 4096.
|
|
"""
|
|
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),
|
|
)
|