release: public alpha 0.6.0 baseline

This commit is contained in:
JWeBot Contributors
2026-10-15 03:58:31 +08:00
commit d4242ae07c
238 changed files with 37724 additions and 0 deletions
+13
View File
@@ -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()
},
)