release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-sdk"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = []
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Public extension API for JWeBot."""
|
||||
|
||||
from .context import (
|
||||
ApprovedHttpClient,
|
||||
ApprovedServiceBroker,
|
||||
CommandRegistry,
|
||||
FactWriter,
|
||||
PluginLogger,
|
||||
PluginStorage,
|
||||
Scheduler,
|
||||
SingleGroupContext,
|
||||
)
|
||||
from .contracts import (
|
||||
API_VERSION,
|
||||
ContractViolation,
|
||||
Event,
|
||||
ExtensionResult,
|
||||
Fact,
|
||||
PlanKind,
|
||||
ReplyPlan,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"API_VERSION",
|
||||
"ApprovedHttpClient",
|
||||
"ApprovedServiceBroker",
|
||||
"CommandRegistry",
|
||||
"ContractViolation",
|
||||
"Event",
|
||||
"ExtensionResult",
|
||||
"Fact",
|
||||
"FactWriter",
|
||||
"PlanKind",
|
||||
"PluginLogger",
|
||||
"PluginStorage",
|
||||
"ReplyPlan",
|
||||
"Scheduler",
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Capability-limited protocols exposed to extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Protocol
|
||||
|
||||
from .contracts import Event, ExtensionResult, Fact
|
||||
|
||||
|
||||
CommandHandler = Callable[[Event, str], ExtensionResult]
|
||||
|
||||
|
||||
class CommandRegistry(Protocol):
|
||||
def register(self, command: str, handler: CommandHandler) -> None: ...
|
||||
|
||||
|
||||
class PluginStorage(Protocol):
|
||||
def get(self, key: str) -> str | None: ...
|
||||
|
||||
def set(self, key: str, value: str) -> None: ...
|
||||
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class ApprovedHttpClient(Protocol):
|
||||
def request(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
query: Mapping[str, str] | None = None,
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
class ApprovedServiceBroker(Protocol):
|
||||
def call(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
class FactWriter(Protocol):
|
||||
def write(self, fact: Fact) -> None: ...
|
||||
|
||||
|
||||
class Scheduler(Protocol):
|
||||
def schedule(self, key: str, *, run_at: str, payload: Mapping[str, str]) -> None: ...
|
||||
|
||||
|
||||
class PluginLogger(Protocol):
|
||||
def info(self, message: str, **fields: object) -> None: ...
|
||||
|
||||
def warning(self, message: str, **fields: object) -> None: ...
|
||||
|
||||
|
||||
class SingleGroupContext(Protocol):
|
||||
"""No group selector, GUI sender, raw ledger, environment, or shell access."""
|
||||
|
||||
commands: CommandRegistry
|
||||
storage: PluginStorage
|
||||
http: ApprovedHttpClient
|
||||
services: ApprovedServiceBroker
|
||||
facts: FactWriter
|
||||
scheduler: Scheduler
|
||||
logger: PluginLogger
|
||||
@@ -0,0 +1,291 @@
|
||||
"""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"
|
||||
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 = ""
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
elif self.message:
|
||||
raise ContractViolation("no_reply plan cannot contain text")
|
||||
|
||||
@classmethod
|
||||
def text(cls, message: str) -> "ReplyPlan":
|
||||
return cls(kind=PlanKind.TEXT, message=message)
|
||||
|
||||
@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 value.get("text"):
|
||||
raise ContractViolation("no_reply plan cannot contain text")
|
||||
return cls.no_reply()
|
||||
|
||||
def to_mapping(self) -> dict[str, str]:
|
||||
value = {"kind": self.kind.value}
|
||||
if self.kind is PlanKind.TEXT:
|
||||
value["text"] = self.message
|
||||
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()
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user