"""Versioned data contracts shared by extensions and the kernel.""" from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from types import MappingProxyType from typing import Any, Mapping API_VERSION = "1" MAX_EVENT_TEXT = 5_000 MAX_REPLY_CHARS = 900 MAX_FACT_CHARS = 120 class ContractViolation(ValueError): """Raised when data crosses the extension boundary with an invalid shape.""" class PlanKind(str, Enum): TEXT = "text" MULTI_TEXT = "multi_text" NO_REPLY = "no_reply" def _required_text(value: object, *, name: str, limit: int) -> str: text = str(value or "").strip() if not text: raise ContractViolation(f"{name} is required") if len(text) > limit: raise ContractViolation(f"{name} exceeds {limit} characters") return text def _confidence(value: object, *, name: str) -> float: try: number = float(value) except (TypeError, ValueError) as error: raise ContractViolation(f"{name} must be numeric") from error if not 0.0 <= number <= 1.0: raise ContractViolation(f"{name} must be between 0 and 1") return number @dataclass(frozen=True) class Event: """Normalized, single-group incoming event exposed to extensions.""" event_id: str observed_at: str sender: str text: str group_fingerprint: str mention_bot: bool confidence: float = 0.0 sender_confidence: float = 0.0 body_confidence: float = 0.0 source: str = "jwebot" version: int = 1 side: str = "incoming" def __post_init__(self) -> None: object.__setattr__( self, "event_id", _required_text(self.event_id, name="event_id", limit=256), ) object.__setattr__( self, "observed_at", _required_text(self.observed_at, name="observed_at", limit=64), ) object.__setattr__( self, "sender", _required_text(self.sender, name="sender", limit=120), ) object.__setattr__( self, "text", _required_text(self.text, name="text", limit=MAX_EVENT_TEXT), ) object.__setattr__( self, "group_fingerprint", _required_text( self.group_fingerprint, name="group_fingerprint", limit=256, ), ) object.__setattr__( self, "source", _required_text(self.source, name="source", limit=80), ) object.__setattr__( self, "confidence", _confidence(self.confidence, name="confidence"), ) object.__setattr__( self, "sender_confidence", _confidence(self.sender_confidence, name="sender_confidence"), ) object.__setattr__( self, "body_confidence", _confidence(self.body_confidence, name="body_confidence"), ) if self.version != 1: raise ContractViolation("unsupported event version") if self.side != "incoming": raise ContractViolation("extensions only receive incoming events") @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "Event": if not isinstance(value, Mapping): raise ContractViolation("event must be an object") return cls( event_id=value.get("event_id", ""), observed_at=value.get("observed_at", ""), sender=value.get("sender", ""), text=value.get("text", ""), group_fingerprint=value.get("group_fingerprint", ""), mention_bot=bool(value.get("mention_bot")), confidence=value.get("confidence", 0.0), sender_confidence=value.get("sender_confidence", 0.0), body_confidence=value.get("body_confidence", 0.0), source=value.get("source", "jwebot"), version=value.get("version", 1), side=value.get("side", "incoming"), ) def to_mapping(self) -> dict[str, Any]: return { "version": self.version, "event_id": self.event_id, "observed_at": self.observed_at, "sender": self.sender, "text": self.text, "confidence": self.confidence, "sender_confidence": self.sender_confidence, "body_confidence": self.body_confidence, "side": self.side, "mention_bot": self.mention_bot, "group_fingerprint": self.group_fingerprint, "source": self.source, } @dataclass(frozen=True) class ReplyPlan: """A structured request; only the kernel may turn it into a WeChat send.""" kind: PlanKind message: str = "" messages: tuple[str, ...] = () def __post_init__(self) -> None: if self.kind is PlanKind.TEXT: object.__setattr__( self, "message", _required_text( self.message, name="reply message", limit=MAX_REPLY_CHARS, ), ) if self.messages: raise ContractViolation("text plan cannot contain multiple messages") elif self.kind is PlanKind.MULTI_TEXT: if self.message: raise ContractViolation("multi_text plan cannot contain text") if not self.messages: raise ContractViolation("multi_text plan requires messages") if len(self.messages) > 8: raise ContractViolation("multi_text plan exceeds 8 messages") object.__setattr__( self, "messages", tuple( _required_text( message, name="reply message", limit=MAX_REPLY_CHARS, ) for message in self.messages ), ) elif self.message: raise ContractViolation("no_reply plan cannot contain text") elif self.messages: raise ContractViolation("no_reply plan cannot contain messages") @classmethod def text(cls, message: str) -> "ReplyPlan": return cls(kind=PlanKind.TEXT, message=message) @classmethod def multi_text(cls, messages: tuple[str, ...] | list[str]) -> "ReplyPlan": return cls(kind=PlanKind.MULTI_TEXT, messages=tuple(messages)) @classmethod def no_reply(cls) -> "ReplyPlan": return cls(kind=PlanKind.NO_REPLY) @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "ReplyPlan": if not isinstance(value, Mapping): raise ContractViolation("reply plan must be an object") try: kind = PlanKind(value.get("kind")) except ValueError as error: raise ContractViolation("reply plan kind is invalid") from error if kind is PlanKind.TEXT: return cls.text(str(value.get("text", ""))) if kind is PlanKind.MULTI_TEXT: messages = value.get("texts", ()) if not isinstance(messages, list): raise ContractViolation("multi_text plan messages must be a list") return cls.multi_text([str(message) for message in messages]) if value.get("text"): raise ContractViolation("no_reply plan cannot contain text") if value.get("texts"): raise ContractViolation("no_reply plan cannot contain messages") return cls.no_reply() def to_mapping(self) -> dict[str, Any]: value = {"kind": self.kind.value} if self.kind is PlanKind.TEXT: value["text"] = self.message if self.kind is PlanKind.MULTI_TEXT: value["texts"] = list(self.messages) return value @dataclass(frozen=True) class Fact: """A bounded, non-sensitive fact candidate emitted by an extension.""" kind: str text: str confidence: float = 1.0 def __post_init__(self) -> None: object.__setattr__( self, "kind", _required_text(self.kind, name="fact kind", limit=40), ) object.__setattr__( self, "text", _required_text(self.text, name="fact text", limit=MAX_FACT_CHARS), ) object.__setattr__( self, "confidence", _confidence(self.confidence, name="fact confidence"), ) def to_mapping(self) -> dict[str, object]: return { "kind": self.kind, "text": self.text, "confidence": self.confidence, } @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "Fact": if not isinstance(value, Mapping): raise ContractViolation("fact must be an object") return cls( kind=value.get("kind", ""), text=value.get("text", ""), confidence=value.get("confidence", 1.0), ) @dataclass(frozen=True) class ExtensionResult: """Bounded result returned by one extension invocation.""" reply: ReplyPlan = field(default_factory=ReplyPlan.no_reply) facts: tuple[Fact, ...] = () metadata: Mapping[str, str] = field(default_factory=dict) def __post_init__(self) -> None: if len(self.facts) > 12: raise ContractViolation("extension result exceeds 12 facts") metadata = {str(key): str(value) for key, value in self.metadata.items()} if len(metadata) > 16: raise ContractViolation("extension metadata exceeds 16 entries") if any(len(key) > 40 or len(value) > 120 for key, value in metadata.items()): raise ContractViolation("extension metadata entry is too long") object.__setattr__(self, "metadata", MappingProxyType(metadata)) def to_mapping(self) -> dict[str, object]: return { "api_version": API_VERSION, "reply": self.reply.to_mapping(), "facts": [fact.to_mapping() for fact in self.facts], "metadata": dict(self.metadata), } @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "ExtensionResult": if not isinstance(value, Mapping): raise ContractViolation("extension result must be an object") if value.get("api_version") != API_VERSION: raise ContractViolation("extension result API version is invalid") facts = value.get("facts", []) metadata = value.get("metadata", {}) if not isinstance(facts, list): raise ContractViolation("extension facts must be a list") if not isinstance(metadata, Mapping): raise ContractViolation("extension metadata must be an object") return cls( reply=ReplyPlan.from_mapping(value.get("reply", {})), facts=tuple(Fact.from_mapping(fact) for fact in facts), metadata={ str(key): str(item) for key, item in metadata.items() }, )