release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-config"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Profile-based configuration compiler for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jwebot-config = "jwebot_config.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Profile-based configuration generation and activation for JWeBot."""
|
||||
|
||||
from .profile import (
|
||||
CONFIG_FORMAT_VERSION,
|
||||
DEFAULT_PROFILE_FILES,
|
||||
ConfigError,
|
||||
ConfigLayout,
|
||||
activate_profile,
|
||||
archive_legacy_material,
|
||||
compile_profile,
|
||||
export_profile,
|
||||
import_profile,
|
||||
load_active_environment,
|
||||
load_private_context,
|
||||
migrate_legacy_context,
|
||||
migrate_legacy_environment,
|
||||
validate_profile,
|
||||
write_default_profile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CONFIG_FORMAT_VERSION",
|
||||
"DEFAULT_PROFILE_FILES",
|
||||
"ConfigError",
|
||||
"ConfigLayout",
|
||||
"activate_profile",
|
||||
"archive_legacy_material",
|
||||
"compile_profile",
|
||||
"export_profile",
|
||||
"import_profile",
|
||||
"load_active_environment",
|
||||
"load_private_context",
|
||||
"migrate_legacy_context",
|
||||
"migrate_legacy_environment",
|
||||
"validate_profile",
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Command-line interface for JWeBot configuration profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .profile import (
|
||||
ConfigError,
|
||||
ConfigLayout,
|
||||
activate_profile,
|
||||
archive_legacy_material,
|
||||
compile_profile,
|
||||
export_profile,
|
||||
import_profile,
|
||||
migrate_legacy_context,
|
||||
migrate_legacy_environment,
|
||||
validate_profile,
|
||||
write_default_profile,
|
||||
)
|
||||
|
||||
|
||||
def progress(step: int, total: int, label: str) -> None:
|
||||
width = 20
|
||||
filled = round(width * step / total)
|
||||
bar = "#" * filled + "-" * (width - filled)
|
||||
percent = round(100 * step / total)
|
||||
print(f"[{bar}] {percent:3d}% {label}")
|
||||
|
||||
|
||||
def layout_from_args(args: argparse.Namespace) -> ConfigLayout:
|
||||
return ConfigLayout.from_environment(
|
||||
config_root=args.config_root,
|
||||
state_root=args.state_root,
|
||||
)
|
||||
|
||||
|
||||
def add_layout_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--config-root", type=Path)
|
||||
parser.add_argument("--state-root", type=Path)
|
||||
|
||||
|
||||
def command_init(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
progress(1, 3, "create profile")
|
||||
profile = write_default_profile(args.profile, layout, force=args.force)
|
||||
progress(2, 3, "compile profile")
|
||||
compile_profile(profile, layout)
|
||||
if args.activate:
|
||||
activate_profile(args.profile, layout)
|
||||
progress(3, 3, "ready")
|
||||
print(f"profile={args.profile}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_validate(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
path = args.path or layout.profile(args.profile)
|
||||
profile_id = validate_profile(path)
|
||||
print(f"valid={profile_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_compile(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
progress(1, 2, "validate profile")
|
||||
profile = layout.profile(args.profile)
|
||||
validate_profile(profile)
|
||||
progress(2, 2, "compile generated files")
|
||||
generated = compile_profile(profile, layout)
|
||||
print(f"generated={generated}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_activate(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
progress(1, 3, "validate profile")
|
||||
validate_profile(layout.profile(args.profile))
|
||||
progress(2, 3, "compile atomically")
|
||||
activate_profile(args.profile, layout)
|
||||
progress(3, 3, "activate profile")
|
||||
print(f"active={args.profile}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_import(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
progress(1, 3, "inspect package")
|
||||
profile_id = import_profile(args.source, layout, replace=args.replace)
|
||||
progress(2, 3, "compile package")
|
||||
compile_profile(layout.profile(profile_id), layout)
|
||||
if args.activate:
|
||||
activate_profile(profile_id, layout)
|
||||
progress(3, 3, "import ready")
|
||||
print(f"imported={profile_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_export(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
progress(1, 2, "validate profile")
|
||||
validate_profile(layout.profile(args.profile))
|
||||
progress(2, 2, "build private export" if args.include_private else "build redacted export")
|
||||
output = export_profile(
|
||||
args.profile,
|
||||
args.output,
|
||||
layout,
|
||||
include_private=args.include_private,
|
||||
)
|
||||
print(f"export={output}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_migrate_legacy(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
profile = layout.profile(args.profile)
|
||||
if not profile.exists():
|
||||
progress(1, 6, "create profile")
|
||||
write_default_profile(args.profile, layout)
|
||||
else:
|
||||
progress(1, 6, "reuse profile")
|
||||
progress(2, 6, "archive incidents and debug material")
|
||||
archive = archive_legacy_material(args.repository, args.profile, layout)
|
||||
progress(3, 6, "import private context")
|
||||
imported_context = migrate_legacy_context(
|
||||
args.repository,
|
||||
args.profile,
|
||||
layout,
|
||||
environment=args.environment,
|
||||
)
|
||||
progress(4, 6, "import legacy environment")
|
||||
imported_environment = migrate_legacy_environment(
|
||||
args.environment,
|
||||
args.profile,
|
||||
layout,
|
||||
)
|
||||
progress(5, 6, "compile profile")
|
||||
compile_profile(profile, layout)
|
||||
if args.activate:
|
||||
activate_profile(args.profile, layout)
|
||||
progress(6, 6, "migration ready")
|
||||
print(f"archive={archive}")
|
||||
print(f"context_imported={str(imported_context).lower()}")
|
||||
print(f"environment_imported={str(imported_environment).lower()}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_status(args: argparse.Namespace) -> int:
|
||||
layout = layout_from_args(args)
|
||||
active = layout.active_profile()
|
||||
profiles = sorted(
|
||||
path.name
|
||||
for path in layout.profiles_root.glob("*")
|
||||
if path.is_dir() and not path.name.startswith(".")
|
||||
)
|
||||
value = {
|
||||
"config_root": str(layout.config_root),
|
||||
"state_root": str(layout.state_root),
|
||||
"active": active.name if active else None,
|
||||
"profiles": profiles,
|
||||
}
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate, import, validate, and activate JWeBot config profiles"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init = subparsers.add_parser("init")
|
||||
init.add_argument("profile")
|
||||
init.add_argument("--activate", action="store_true")
|
||||
init.add_argument("--force", action="store_true")
|
||||
add_layout_arguments(init)
|
||||
init.set_defaults(handler=command_init)
|
||||
|
||||
validate = subparsers.add_parser("validate")
|
||||
validate.add_argument("profile", nargs="?", default="default")
|
||||
validate.add_argument("--path", type=Path)
|
||||
add_layout_arguments(validate)
|
||||
validate.set_defaults(handler=command_validate)
|
||||
|
||||
compile_parser = subparsers.add_parser("compile")
|
||||
compile_parser.add_argument("profile")
|
||||
add_layout_arguments(compile_parser)
|
||||
compile_parser.set_defaults(handler=command_compile)
|
||||
|
||||
activate = subparsers.add_parser("activate")
|
||||
activate.add_argument("profile")
|
||||
add_layout_arguments(activate)
|
||||
activate.set_defaults(handler=command_activate)
|
||||
|
||||
import_parser = subparsers.add_parser("import")
|
||||
import_parser.add_argument("source", type=Path)
|
||||
import_parser.add_argument("--replace", action="store_true")
|
||||
import_parser.add_argument("--activate", action="store_true")
|
||||
add_layout_arguments(import_parser)
|
||||
import_parser.set_defaults(handler=command_import)
|
||||
|
||||
export = subparsers.add_parser("export")
|
||||
export.add_argument("profile")
|
||||
export.add_argument("--output", type=Path, required=True)
|
||||
export.add_argument("--include-private", action="store_true")
|
||||
add_layout_arguments(export)
|
||||
export.set_defaults(handler=command_export)
|
||||
|
||||
migrate = subparsers.add_parser("migrate-legacy")
|
||||
migrate.add_argument("profile")
|
||||
migrate.add_argument("--repository", type=Path, default=Path.cwd())
|
||||
migrate.add_argument(
|
||||
"--environment",
|
||||
type=Path,
|
||||
default=Path.home() / ".config" / "wechat-health-watch" / "environment",
|
||||
)
|
||||
migrate.add_argument("--activate", action="store_true")
|
||||
add_layout_arguments(migrate)
|
||||
migrate.set_defaults(handler=command_migrate_legacy)
|
||||
|
||||
status = subparsers.add_parser("status")
|
||||
add_layout_arguments(status)
|
||||
status.set_defaults(handler=command_status)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return int(args.handler(args))
|
||||
except ConfigError as error:
|
||||
print(f"jwebot-config: {error}", file=sys.stderr)
|
||||
return 2
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-kernel==0.6.0a0+1015",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jwebot-extension-host = "jwebot_extension_host.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Single-release process host for reviewed JWeBot extensions."""
|
||||
|
||||
from .runtime import (
|
||||
CapabilityUnavailable,
|
||||
ExtensionExecutionError,
|
||||
ExtensionRuntime,
|
||||
HostError,
|
||||
process_request,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CapabilityUnavailable",
|
||||
"ExtensionExecutionError",
|
||||
"ExtensionRuntime",
|
||||
"HostError",
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Single-request JSON-line entrypoint for the JWeBot extension host."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from jwebot_kernel import RuntimeTelemetry
|
||||
|
||||
from .runtime import (
|
||||
CapabilityUnavailable,
|
||||
ExtensionRuntime,
|
||||
HostError,
|
||||
process_request,
|
||||
)
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 256 * 1024
|
||||
MAX_SERVICE_CALLS = 8
|
||||
|
||||
|
||||
def _scrub_environment() -> None:
|
||||
retained = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if key in {"LANG", "LC_ALL", "TZ"}
|
||||
}
|
||||
os.environ.clear()
|
||||
os.environ.update(retained)
|
||||
|
||||
|
||||
def _read_message(stream: object) -> dict[str, object]:
|
||||
raw = stream.readline(MAX_REQUEST_BYTES + 1)
|
||||
if not raw or len(raw) > MAX_REQUEST_BYTES:
|
||||
raise HostError("request payload is empty or too large")
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise HostError("request payload is invalid") from error
|
||||
if not isinstance(value, dict):
|
||||
raise HostError("request payload must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _write_message(
|
||||
value: Mapping[str, object],
|
||||
stream: object,
|
||||
) -> None:
|
||||
payload = (
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
encoded = payload.encode("utf-8")
|
||||
if len(encoded) > MAX_REQUEST_BYTES:
|
||||
raise HostError("response payload is too large")
|
||||
stream.write(encoded)
|
||||
stream.flush()
|
||||
|
||||
|
||||
class _ProtocolServiceBroker:
|
||||
def __init__(
|
||||
self,
|
||||
input_stream: object,
|
||||
output_stream: object,
|
||||
telemetry: RuntimeTelemetry,
|
||||
) -> None:
|
||||
self.calls = 0
|
||||
self.input_stream = input_stream
|
||||
self.output_stream = output_stream
|
||||
self.telemetry = telemetry
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
profile: str,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
started = time.monotonic()
|
||||
self.calls += 1
|
||||
if self.calls > MAX_SERVICE_CALLS:
|
||||
raise CapabilityUnavailable("service call limit exceeded")
|
||||
call_id = str(self.calls)
|
||||
_write_message(
|
||||
{
|
||||
"status": "service_request",
|
||||
"call_id": call_id,
|
||||
"profile": profile,
|
||||
"operation": operation,
|
||||
"payload": dict(payload),
|
||||
},
|
||||
self.output_stream,
|
||||
)
|
||||
response = _read_message(self.input_stream)
|
||||
if response.get("call_id") != call_id:
|
||||
raise CapabilityUnavailable("service response id mismatch")
|
||||
if response.get("op") == "service_error":
|
||||
self.telemetry.elapsed(
|
||||
"host.service.wait",
|
||||
started,
|
||||
status="error",
|
||||
plugin_id=profile,
|
||||
operation=operation,
|
||||
error_type=str(response.get("error", "ServiceError")),
|
||||
)
|
||||
raise CapabilityUnavailable("kernel service call failed")
|
||||
if response.get("op") != "service_result":
|
||||
raise CapabilityUnavailable("kernel service response is invalid")
|
||||
result = response.get("result")
|
||||
if not isinstance(result, Mapping):
|
||||
raise CapabilityUnavailable("kernel service result is invalid")
|
||||
self.telemetry.elapsed(
|
||||
"host.service.wait",
|
||||
started,
|
||||
plugin_id=profile,
|
||||
operation=operation,
|
||||
)
|
||||
return dict(result)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--release-dir", type=Path, required=True)
|
||||
parser.add_argument("--allowed-signers", type=Path)
|
||||
parser.add_argument("--publisher")
|
||||
parser.add_argument("--allow-unsigned-local", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.allow_unsigned_local:
|
||||
if args.allowed_signers is not None or args.publisher is not None:
|
||||
parser.error(
|
||||
"--allow-unsigned-local cannot include publisher trust options"
|
||||
)
|
||||
elif args.allowed_signers is None:
|
||||
parser.error("signed host requires --allowed-signers")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
_scrub_environment()
|
||||
telemetry = RuntimeTelemetry(component="extension-host")
|
||||
process_started = time.monotonic()
|
||||
input_stream = sys.stdin.buffer
|
||||
output_stream = sys.stdout.buffer
|
||||
try:
|
||||
service_broker = _ProtocolServiceBroker(
|
||||
input_stream,
|
||||
output_stream,
|
||||
telemetry,
|
||||
)
|
||||
load_started = time.monotonic()
|
||||
runtime = ExtensionRuntime.load(
|
||||
args.release_dir,
|
||||
allowed_signers=args.allowed_signers,
|
||||
expected_publisher=args.publisher,
|
||||
allow_unsigned=args.allow_unsigned_local,
|
||||
service_broker=service_broker,
|
||||
)
|
||||
telemetry.elapsed(
|
||||
"host.runtime.load",
|
||||
load_started,
|
||||
plugin_id=runtime.manifest.plugin_id,
|
||||
process="extension-host",
|
||||
)
|
||||
request = _read_message(input_stream)
|
||||
event_value = request.get("event")
|
||||
event_id = (
|
||||
event_value.get("event_id", "")
|
||||
if isinstance(event_value, Mapping)
|
||||
else ""
|
||||
)
|
||||
telemetry.emit(
|
||||
"host.request.received",
|
||||
event_id=event_id,
|
||||
plugin_id=runtime.manifest.plugin_id,
|
||||
operation=str(request.get("op", "")),
|
||||
process="extension-host",
|
||||
)
|
||||
response = process_request(runtime, request)
|
||||
except HostError as error:
|
||||
logging.warning("extension host request failed error_type=%s", type(error).__name__)
|
||||
telemetry.elapsed(
|
||||
"host.process.complete",
|
||||
process_started,
|
||||
status="error",
|
||||
process="extension-host",
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
response = {"status": "error", "error": type(error).__name__}
|
||||
except Exception as error:
|
||||
logging.error("extension host failed error_type=%s", type(error).__name__)
|
||||
telemetry.elapsed(
|
||||
"host.process.complete",
|
||||
process_started,
|
||||
status="error",
|
||||
process="extension-host",
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
response = {"status": "error", "error": "HostUnavailable"}
|
||||
else:
|
||||
telemetry.elapsed(
|
||||
"host.process.complete",
|
||||
process_started,
|
||||
plugin_id=str(response.get("plugin_id", "")),
|
||||
operation=str(request.get("op", "")),
|
||||
process="extension-host",
|
||||
)
|
||||
_write_message(response, output_stream)
|
||||
return 0 if response.get("status") == "ok" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Load one verified release and execute bounded command requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jwebot_kernel import (
|
||||
ActivationError,
|
||||
PluginManifest,
|
||||
ReleaseError,
|
||||
load_manifest,
|
||||
validate_source_tree,
|
||||
verify_installed_release,
|
||||
verify_installed_release_signature,
|
||||
)
|
||||
from jwebot_sdk import (
|
||||
ContractViolation,
|
||||
Event,
|
||||
ExtensionResult,
|
||||
Fact,
|
||||
)
|
||||
|
||||
|
||||
MAX_ARGUMENT_CHARS = 1_000
|
||||
MAX_PLUGIN_OUTPUT_CHARS = 64 * 1024
|
||||
MAX_SERVICE_OPERATION_CHARS = 80
|
||||
|
||||
|
||||
class HostError(ValueError):
|
||||
"""Controlled extension-host setup or request failure."""
|
||||
|
||||
|
||||
class CapabilityUnavailable(HostError):
|
||||
"""Requested capability has no configured kernel broker."""
|
||||
|
||||
|
||||
class ExtensionExecutionError(HostError):
|
||||
"""Extension code failed without exposing private exception details."""
|
||||
|
||||
|
||||
class _BoundedTextSink:
|
||||
def __init__(self, target: Any, limit: int = MAX_PLUGIN_OUTPUT_CHARS) -> None:
|
||||
self.target = target
|
||||
self.remaining = limit
|
||||
|
||||
def write(self, value: object) -> int:
|
||||
text = str(value)
|
||||
accepted = text[: self.remaining]
|
||||
self.remaining -= len(accepted)
|
||||
if accepted:
|
||||
self.target.write(accepted)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.target.flush()
|
||||
|
||||
|
||||
class _CommandRegistry:
|
||||
def __init__(self, manifest: PluginManifest) -> None:
|
||||
self._declared = {
|
||||
command.casefold(): command
|
||||
for command in manifest.capabilities.commands
|
||||
}
|
||||
self._handlers: dict[str, Callable[[Event, str], ExtensionResult]] = {}
|
||||
self._frozen = False
|
||||
|
||||
def register(
|
||||
self,
|
||||
command: str,
|
||||
handler: Callable[[Event, str], ExtensionResult],
|
||||
) -> None:
|
||||
if self._frozen:
|
||||
raise HostError("command registry is frozen")
|
||||
key = command.casefold()
|
||||
if key not in self._declared:
|
||||
raise HostError(f"extension registered undeclared command: {command}")
|
||||
if key in self._handlers:
|
||||
raise HostError(f"extension registered command twice: {command}")
|
||||
if not callable(handler):
|
||||
raise HostError("command handler must be callable")
|
||||
self._handlers[key] = handler
|
||||
|
||||
def freeze(self) -> None:
|
||||
missing = set(self._declared) - set(self._handlers)
|
||||
if missing:
|
||||
names = ", ".join(self._declared[key] for key in sorted(missing))
|
||||
raise HostError(f"extension did not register declared commands: {names}")
|
||||
self._frozen = True
|
||||
|
||||
def handler(self, command: str) -> Callable[[Event, str], ExtensionResult]:
|
||||
if not self._frozen:
|
||||
raise HostError("command registry is not frozen")
|
||||
try:
|
||||
return self._handlers[command.casefold()]
|
||||
except KeyError as error:
|
||||
raise HostError("command is not registered by this extension") from error
|
||||
|
||||
|
||||
class _UnavailableStorage:
|
||||
def get(self, key: str) -> str | None:
|
||||
del key
|
||||
raise CapabilityUnavailable("storage broker is not configured")
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
del key, value
|
||||
raise CapabilityUnavailable("storage broker is not configured")
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
del key
|
||||
raise CapabilityUnavailable("storage broker is not configured")
|
||||
|
||||
|
||||
class _UnavailableHttp:
|
||||
def request(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
query: Mapping[str, str] | None = None,
|
||||
) -> bytes:
|
||||
del profile, method, path, query
|
||||
raise CapabilityUnavailable("HTTP broker is not configured")
|
||||
|
||||
|
||||
class _Services:
|
||||
def __init__(
|
||||
self,
|
||||
manifest: PluginManifest,
|
||||
broker: Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
| None,
|
||||
) -> None:
|
||||
self._profiles = frozenset(manifest.capabilities.service_profiles)
|
||||
self._broker = broker
|
||||
|
||||
def call(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
if profile not in self._profiles:
|
||||
raise CapabilityUnavailable("service profile is not declared")
|
||||
if (
|
||||
not isinstance(operation, str)
|
||||
or not operation
|
||||
or len(operation) > MAX_SERVICE_OPERATION_CHARS
|
||||
):
|
||||
raise HostError("service operation is invalid")
|
||||
if not isinstance(payload, Mapping):
|
||||
raise HostError("service payload must be an object")
|
||||
if self._broker is None:
|
||||
raise CapabilityUnavailable("service broker is not configured")
|
||||
result = self._broker(profile, operation, payload)
|
||||
if not isinstance(result, Mapping):
|
||||
raise HostError("service broker returned an invalid result")
|
||||
return dict(result)
|
||||
|
||||
|
||||
class _FactCollector:
|
||||
def __init__(self) -> None:
|
||||
self._facts: list[Fact] = []
|
||||
|
||||
def write(self, fact: Fact) -> None:
|
||||
if not isinstance(fact, Fact):
|
||||
raise HostError("fact writer accepts Fact objects only")
|
||||
self._facts.append(fact)
|
||||
if len(self._facts) > 12:
|
||||
raise HostError("extension emitted more than 12 facts")
|
||||
|
||||
def reset(self) -> None:
|
||||
self._facts.clear()
|
||||
|
||||
def values(self) -> tuple[Fact, ...]:
|
||||
return tuple(self._facts)
|
||||
|
||||
|
||||
class _UnavailableScheduler:
|
||||
def schedule(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
run_at: str,
|
||||
payload: Mapping[str, str],
|
||||
) -> None:
|
||||
del key, run_at, payload
|
||||
raise CapabilityUnavailable("scheduler broker is not configured")
|
||||
|
||||
|
||||
class _SanitizedLogger:
|
||||
def __init__(self, plugin_id: str) -> None:
|
||||
self.plugin_id = plugin_id
|
||||
|
||||
def _write(self, level: int, message: str, fields: Mapping[str, object]) -> None:
|
||||
digest = hashlib.sha256(str(message).encode("utf-8", "replace")).hexdigest()[:12]
|
||||
field_names = "\n".join(sorted(str(key) for key in fields))
|
||||
fields_digest = hashlib.sha256(
|
||||
field_names.encode("utf-8", "replace")
|
||||
).hexdigest()[:12]
|
||||
logging.log(
|
||||
level,
|
||||
"extension log plugin=%s message_hash=%s field_count=%d fields_hash=%s",
|
||||
self.plugin_id,
|
||||
digest,
|
||||
len(fields),
|
||||
fields_digest,
|
||||
)
|
||||
|
||||
def info(self, message: str, **fields: object) -> None:
|
||||
self._write(logging.INFO, message, fields)
|
||||
|
||||
def warning(self, message: str, **fields: object) -> None:
|
||||
self._write(logging.WARNING, message, fields)
|
||||
|
||||
|
||||
class _HostContext:
|
||||
def __init__(
|
||||
self,
|
||||
manifest: PluginManifest,
|
||||
service_broker: Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self.commands = _CommandRegistry(manifest)
|
||||
self.storage = _UnavailableStorage()
|
||||
self.http = _UnavailableHttp()
|
||||
self.services = _Services(manifest, service_broker)
|
||||
self.facts = _FactCollector()
|
||||
self.scheduler = _UnavailableScheduler()
|
||||
self.logger = _SanitizedLogger(manifest.plugin_id)
|
||||
|
||||
|
||||
class ExtensionRuntime:
|
||||
def __init__(
|
||||
self,
|
||||
release_dir: Path,
|
||||
manifest: PluginManifest,
|
||||
context: _HostContext,
|
||||
) -> None:
|
||||
self.release_dir = release_dir
|
||||
self.manifest = manifest
|
||||
self.context = context
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
release_dir: Path,
|
||||
*,
|
||||
manifest: PluginManifest | None = None,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned: bool = False,
|
||||
service_broker: Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
| None = None,
|
||||
) -> "ExtensionRuntime":
|
||||
if allow_unsigned:
|
||||
if allowed_signers is not None or expected_publisher is not None:
|
||||
raise HostError(
|
||||
"unsigned local host cannot include trusted publishers"
|
||||
)
|
||||
try:
|
||||
record = verify_installed_release(release_dir)
|
||||
except (ActivationError, ReleaseError) as error:
|
||||
raise HostError("installed release verification failed") from error
|
||||
else:
|
||||
if allowed_signers is None:
|
||||
raise HostError("signed extension release is required")
|
||||
try:
|
||||
record, _ = verify_installed_release_signature(
|
||||
release_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
except (ActivationError, ReleaseError) as error:
|
||||
raise HostError("installed release verification failed") from error
|
||||
manifest = manifest or load_manifest(release_dir / "plugin.toml")
|
||||
if (
|
||||
record.plugin_id != manifest.plugin_id
|
||||
or record.author != manifest.author
|
||||
or record.plugin_version != manifest.version
|
||||
or record.api_version != manifest.api_version
|
||||
):
|
||||
raise HostError("installed release and plugin manifest disagree")
|
||||
validate_source_tree(release_dir, manifest)
|
||||
|
||||
module_name, _, attribute = manifest.entrypoint.partition(":")
|
||||
source_root = release_dir / "src"
|
||||
module_file = source_root.joinpath(*module_name.split(".")).with_suffix(".py")
|
||||
package_file = source_root.joinpath(*module_name.split(".")) / "__init__.py"
|
||||
if not module_file.is_file() and not package_file.is_file():
|
||||
raise HostError("extension entrypoint module is unavailable")
|
||||
|
||||
for loaded_name in tuple(sys.modules):
|
||||
if loaded_name == module_name or loaded_name.startswith(module_name + "."):
|
||||
del sys.modules[loaded_name]
|
||||
original_path = list(sys.path)
|
||||
original_dont_write_bytecode = sys.dont_write_bytecode
|
||||
sys.path.insert(0, str(source_root))
|
||||
sys.dont_write_bytecode = True
|
||||
sink = _BoundedTextSink(sys.stderr)
|
||||
try:
|
||||
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
||||
module = importlib.import_module(module_name)
|
||||
entrypoint = getattr(module, attribute, None)
|
||||
if not callable(entrypoint):
|
||||
raise HostError("extension entrypoint is not callable")
|
||||
context = _HostContext(manifest, service_broker)
|
||||
entrypoint(context)
|
||||
context.commands.freeze()
|
||||
except HostError:
|
||||
raise
|
||||
except BaseException as error:
|
||||
raise ExtensionExecutionError("extension setup failed") from error
|
||||
finally:
|
||||
sys.path[:] = original_path
|
||||
sys.dont_write_bytecode = original_dont_write_bytecode
|
||||
return cls(release_dir, manifest, context)
|
||||
|
||||
def invoke_command(
|
||||
self,
|
||||
command: str,
|
||||
event: Event,
|
||||
arguments: str,
|
||||
) -> ExtensionResult:
|
||||
if len(arguments) > MAX_ARGUMENT_CHARS:
|
||||
raise HostError("command arguments are too long")
|
||||
handler = self.context.commands.handler(command)
|
||||
self.context.facts.reset()
|
||||
sink = _BoundedTextSink(sys.stderr)
|
||||
try:
|
||||
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
||||
result = handler(event, arguments)
|
||||
except BaseException as error:
|
||||
raise ExtensionExecutionError("extension command failed") from error
|
||||
if not isinstance(result, ExtensionResult):
|
||||
raise ExtensionExecutionError(
|
||||
"extension command returned an invalid result"
|
||||
)
|
||||
collected = self.context.facts.values()
|
||||
if not collected:
|
||||
return result
|
||||
try:
|
||||
return ExtensionResult(
|
||||
reply=result.reply,
|
||||
facts=result.facts + collected,
|
||||
metadata=result.metadata,
|
||||
)
|
||||
except ContractViolation as error:
|
||||
raise ExtensionExecutionError(
|
||||
"extension command returned an invalid result"
|
||||
) from error
|
||||
|
||||
|
||||
def process_request(
|
||||
runtime: ExtensionRuntime,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, object]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise HostError("request must be an object")
|
||||
operation = request.get("op")
|
||||
if operation == "health":
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": runtime.manifest.api_version,
|
||||
"plugin_id": runtime.manifest.plugin_id,
|
||||
"author": runtime.manifest.author,
|
||||
"description": runtime.manifest.description,
|
||||
"help": list(runtime.manifest.help_entries),
|
||||
"commands": list(runtime.manifest.capabilities.commands),
|
||||
"service_profiles": list(
|
||||
runtime.manifest.capabilities.service_profiles
|
||||
),
|
||||
}
|
||||
if operation != "invoke_command":
|
||||
raise HostError("unsupported extension host operation")
|
||||
command = request.get("command")
|
||||
arguments = request.get("arguments", "")
|
||||
event_value = request.get("event")
|
||||
if not isinstance(command, str) or not command:
|
||||
raise HostError("command is required")
|
||||
if not isinstance(arguments, str):
|
||||
raise HostError("arguments must be a string")
|
||||
if not isinstance(event_value, Mapping):
|
||||
raise HostError("event must be an object")
|
||||
try:
|
||||
event = Event.from_mapping(event_value)
|
||||
except ContractViolation as error:
|
||||
raise HostError("event violates the SDK contract") from error
|
||||
result = runtime.invoke_command(command, event, arguments)
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": runtime.manifest.api_version,
|
||||
"plugin_id": runtime.manifest.plugin_id,
|
||||
"result": result.to_mapping(),
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==0.6.0a0+1015",
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
"jwebot-official-extensions==0.6.0a0+1015",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Stable kernel contracts for JWeBot."""
|
||||
|
||||
from .activation import (
|
||||
ActivationError,
|
||||
install_release,
|
||||
rollback_release,
|
||||
verify_installed_release,
|
||||
verify_installed_release_signature,
|
||||
)
|
||||
from .catalog import CatalogError, CommandMatch, ExtensionCatalog
|
||||
from .extension_dispatch import (
|
||||
ExpectedExtension,
|
||||
HostedExtensionError,
|
||||
HostedExtensionRuntime,
|
||||
HostedRelease,
|
||||
SignedExtensionInventory,
|
||||
)
|
||||
from .host_client import (
|
||||
ExtensionHostClient,
|
||||
HostClientError,
|
||||
HostTimeout,
|
||||
UnixExtensionHostClient,
|
||||
)
|
||||
from .manifest import (
|
||||
CapabilityRequest,
|
||||
ManifestError,
|
||||
PluginManifest,
|
||||
PluginType,
|
||||
load_manifest,
|
||||
parse_manifest,
|
||||
)
|
||||
from .routing import (
|
||||
ExecutionPath,
|
||||
RouteDecision,
|
||||
RouteKind,
|
||||
classify_question,
|
||||
parse_local_command,
|
||||
)
|
||||
from .release import (
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
build_release,
|
||||
load_release_record,
|
||||
release_record_bytes,
|
||||
verify_release,
|
||||
)
|
||||
from .signing import (
|
||||
ReleaseSignature,
|
||||
SignatureError,
|
||||
load_release_signature,
|
||||
release_signature_bytes,
|
||||
sign_release_record,
|
||||
verify_release_signature,
|
||||
)
|
||||
from .source_policy import (
|
||||
PolicyFinding,
|
||||
SourcePolicyError,
|
||||
validate_source_tree,
|
||||
)
|
||||
from .telemetry import RuntimeTelemetry, hash_identifier
|
||||
|
||||
__all__ = [
|
||||
"ActivationError",
|
||||
"CapabilityRequest",
|
||||
"CatalogError",
|
||||
"CommandMatch",
|
||||
"ExtensionCatalog",
|
||||
"ExpectedExtension",
|
||||
"ExecutionPath",
|
||||
"ExtensionHostClient",
|
||||
"HostClientError",
|
||||
"HostTimeout",
|
||||
"HostedExtensionError",
|
||||
"HostedExtensionRuntime",
|
||||
"HostedRelease",
|
||||
"UnixExtensionHostClient",
|
||||
"ManifestError",
|
||||
"PluginManifest",
|
||||
"PluginType",
|
||||
"PolicyFinding",
|
||||
"RouteDecision",
|
||||
"RouteKind",
|
||||
"RuntimeTelemetry",
|
||||
"ReleaseError",
|
||||
"ReleaseRecord",
|
||||
"ReleaseSignature",
|
||||
"SignatureError",
|
||||
"SignedExtensionInventory",
|
||||
"SourcePolicyError",
|
||||
"classify_question",
|
||||
"build_release",
|
||||
"hash_identifier",
|
||||
"load_manifest",
|
||||
"load_release_record",
|
||||
"load_release_signature",
|
||||
"parse_local_command",
|
||||
"parse_manifest",
|
||||
"release_record_bytes",
|
||||
"release_signature_bytes",
|
||||
"install_release",
|
||||
"rollback_release",
|
||||
"sign_release_record",
|
||||
"validate_source_tree",
|
||||
"verify_release",
|
||||
"verify_release_signature",
|
||||
"verify_installed_release",
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Verified release installation with atomic activation and rollback pointers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, Iterator
|
||||
|
||||
from .release import (
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
load_release_record,
|
||||
source_digest_for_files,
|
||||
verify_release,
|
||||
)
|
||||
from .signing import (
|
||||
ReleaseSignature,
|
||||
load_release_signature,
|
||||
release_signature_bytes,
|
||||
verify_release_signature,
|
||||
)
|
||||
|
||||
|
||||
class ActivationError(ValueError):
|
||||
"""Raised when a verified release cannot be installed or activated safely."""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _activation_lock(state_root: Path) -> Iterator[None]:
|
||||
state_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if state_root.is_symlink():
|
||||
raise ActivationError("activation state root cannot be a symlink")
|
||||
lock_path = state_root / ".activation.lock"
|
||||
descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _release_target(digest: str) -> Path:
|
||||
if len(digest) != 64 or any(value not in "0123456789abcdef" for value in digest):
|
||||
raise ActivationError("invalid release digest")
|
||||
return Path("releases") / digest
|
||||
|
||||
|
||||
def _read_pointer(state_root: Path, name: str) -> Path | None:
|
||||
pointer = state_root / name
|
||||
if not pointer.exists() and not pointer.is_symlink():
|
||||
return None
|
||||
if not pointer.is_symlink():
|
||||
raise ActivationError(f"{name} pointer is not a symlink")
|
||||
target = Path(os.readlink(pointer))
|
||||
if target.is_absolute() or len(target.parts) != 2 or target.parts[0] != "releases":
|
||||
raise ActivationError(f"{name} pointer has an unsafe target")
|
||||
if not (state_root / target).is_dir():
|
||||
raise ActivationError(f"{name} pointer target is unavailable")
|
||||
return target
|
||||
|
||||
|
||||
def _replace_pointer(state_root: Path, name: str, target: Path) -> None:
|
||||
temporary = state_root / f".{name}.{os.getpid()}.tmp"
|
||||
temporary.unlink(missing_ok=True)
|
||||
temporary.symlink_to(target)
|
||||
try:
|
||||
os.replace(temporary, state_root / name)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _installed_files(
|
||||
release_dir: Path,
|
||||
record: ReleaseRecord,
|
||||
) -> tuple[tuple[str, bytes], ...]:
|
||||
values: list[tuple[str, bytes]] = []
|
||||
for path in sorted(release_dir.rglob("*")):
|
||||
relative = path.relative_to(release_dir).as_posix()
|
||||
if relative in {".release.json", ".release.sig.json"}:
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise ActivationError(f"installed release contains a symlink: {relative}")
|
||||
if path.is_file():
|
||||
values.append((relative, path.read_bytes()))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _verify_installed(
|
||||
release_dir: Path,
|
||||
record: ReleaseRecord,
|
||||
signature: ReleaseSignature | None = None,
|
||||
) -> None:
|
||||
installed = _installed_files(release_dir, record)
|
||||
if tuple(path for path, _ in installed) != record.files:
|
||||
raise ActivationError("installed release file list mismatch")
|
||||
if source_digest_for_files(installed) != record.source_digest:
|
||||
raise ActivationError("installed release source digest mismatch")
|
||||
stored_record = release_dir / ".release.json"
|
||||
try:
|
||||
stored = ReleaseRecord.from_mapping(
|
||||
json.loads(stored_record.read_text(encoding="utf-8"))
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ReleaseError) as error:
|
||||
raise ActivationError("installed release record is invalid") from error
|
||||
if stored != record:
|
||||
raise ActivationError("installed release record mismatch")
|
||||
if signature is not None:
|
||||
stored_signature_path = release_dir / ".release.sig.json"
|
||||
try:
|
||||
stored_signature = load_release_signature(stored_signature_path)
|
||||
except ReleaseError as error:
|
||||
raise ActivationError(
|
||||
"installed release signature is invalid"
|
||||
) from error
|
||||
if stored_signature != signature:
|
||||
raise ActivationError("installed release signature mismatch")
|
||||
|
||||
|
||||
def _extract_verified(
|
||||
artifact: Path,
|
||||
record: ReleaseRecord,
|
||||
destination: Path,
|
||||
signature: ReleaseSignature | None = None,
|
||||
) -> None:
|
||||
with tarfile.open(artifact, mode="r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
relative = PurePosixPath(*path.parts[1:])
|
||||
target = destination.joinpath(*relative.parts)
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ActivationError("verified artifact member cannot be read")
|
||||
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
shutil.copyfileobj(stream, output)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
(destination / ".release.json").write_text(
|
||||
json.dumps(
|
||||
record.to_mapping(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(destination / ".release.json", 0o600)
|
||||
if signature is not None:
|
||||
(destination / ".release.sig.json").write_bytes(
|
||||
release_signature_bytes(signature)
|
||||
)
|
||||
os.chmod(destination / ".release.sig.json", 0o600)
|
||||
|
||||
|
||||
def install_release(
|
||||
artifact: Path,
|
||||
release_record: Path,
|
||||
state_root: Path,
|
||||
*,
|
||||
signature: Path | None = None,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned: bool = False,
|
||||
health_check: Callable[[Path], None] | None = None,
|
||||
allow_unhealthy_local: bool = False,
|
||||
) -> Path:
|
||||
record = load_release_record(release_record)
|
||||
if allow_unsigned:
|
||||
if (
|
||||
signature is not None
|
||||
or allowed_signers is not None
|
||||
or expected_publisher is not None
|
||||
):
|
||||
raise ActivationError(
|
||||
"unsigned local activation cannot include signature inputs"
|
||||
)
|
||||
signature_record = None
|
||||
else:
|
||||
if signature is None or allowed_signers is None:
|
||||
raise ActivationError("signed release activation is required")
|
||||
signature_record = verify_release_signature(
|
||||
release_record,
|
||||
signature,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
if health_check is None and not allow_unhealthy_local:
|
||||
raise ActivationError("signed release health check is required")
|
||||
verify_release(artifact, record)
|
||||
relative_target = _release_target(record.source_digest)
|
||||
releases = state_root / "releases"
|
||||
release_dir = state_root / relative_target
|
||||
|
||||
with _activation_lock(state_root):
|
||||
created = False
|
||||
releases.mkdir(mode=0o700, exist_ok=True)
|
||||
if releases.is_symlink():
|
||||
raise ActivationError("releases directory cannot be a symlink")
|
||||
if release_dir.is_symlink():
|
||||
raise ActivationError("release destination is unsafe")
|
||||
if release_dir.exists():
|
||||
if not release_dir.is_dir():
|
||||
raise ActivationError("release destination is unsafe")
|
||||
_verify_installed(release_dir, record, signature_record)
|
||||
else:
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{record.source_digest}.",
|
||||
dir=releases,
|
||||
)
|
||||
)
|
||||
os.chmod(staging, 0o700)
|
||||
try:
|
||||
_extract_verified(
|
||||
artifact,
|
||||
record,
|
||||
staging,
|
||||
signature_record,
|
||||
)
|
||||
_verify_installed(staging, record, signature_record)
|
||||
staging.replace(release_dir)
|
||||
created = True
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if health_check is not None:
|
||||
try:
|
||||
health_check(release_dir)
|
||||
except Exception as error:
|
||||
if created:
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
raise ActivationError("release health check failed") from error
|
||||
|
||||
current = _read_pointer(state_root, "current")
|
||||
if current == relative_target:
|
||||
return release_dir
|
||||
if current is not None:
|
||||
_replace_pointer(state_root, "previous", current)
|
||||
_replace_pointer(state_root, "current", relative_target)
|
||||
return release_dir
|
||||
|
||||
|
||||
def verify_installed_release(release_dir: Path) -> ReleaseRecord:
|
||||
if not release_dir.is_dir() or release_dir.is_symlink():
|
||||
raise ActivationError("installed release directory is unsafe")
|
||||
record_path = release_dir / ".release.json"
|
||||
try:
|
||||
record = ReleaseRecord.from_mapping(
|
||||
json.loads(record_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ReleaseError) as error:
|
||||
raise ActivationError("installed release record is invalid") from error
|
||||
if release_dir.name != record.source_digest:
|
||||
raise ActivationError("installed release directory digest mismatch")
|
||||
_verify_installed(release_dir, record)
|
||||
return record
|
||||
|
||||
|
||||
def verify_installed_release_signature(
|
||||
release_dir: Path,
|
||||
allowed_signers: Path,
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
) -> tuple[ReleaseRecord, ReleaseSignature]:
|
||||
record = verify_installed_release(release_dir)
|
||||
signature = verify_release_signature(
|
||||
release_dir / ".release.json",
|
||||
release_dir / ".release.sig.json",
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
_verify_installed(release_dir, record, signature)
|
||||
return record, signature
|
||||
|
||||
|
||||
def rollback_release(
|
||||
state_root: Path,
|
||||
*,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned: bool = False,
|
||||
) -> Path:
|
||||
with _activation_lock(state_root):
|
||||
current = _read_pointer(state_root, "current")
|
||||
previous = _read_pointer(state_root, "previous")
|
||||
if current is None or previous is None:
|
||||
raise ActivationError("no verified previous release is available")
|
||||
previous_dir = state_root / previous
|
||||
if allow_unsigned:
|
||||
if allowed_signers is not None or expected_publisher is not None:
|
||||
raise ActivationError(
|
||||
"unsigned local rollback cannot include trusted publishers"
|
||||
)
|
||||
verify_installed_release(previous_dir)
|
||||
else:
|
||||
if allowed_signers is None:
|
||||
raise ActivationError("signed release rollback is required")
|
||||
verify_installed_release_signature(
|
||||
previous_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
_replace_pointer(state_root, "current", previous)
|
||||
_replace_pointer(state_root, "previous", current)
|
||||
return previous_dir
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Deterministic startup registration and runtime-frozen extension catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Mapping
|
||||
|
||||
from .manifest import PluginManifest
|
||||
|
||||
|
||||
class CatalogError(ValueError):
|
||||
"""Raised when approved manifests cannot form one deterministic catalog."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandMatch:
|
||||
plugin_id: str
|
||||
command: str
|
||||
arguments: str
|
||||
|
||||
|
||||
class ExtensionCatalog:
|
||||
"""Mutable during startup, immutable after the kernel freezes registration."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._manifests: dict[str, PluginManifest] = {}
|
||||
self._commands: dict[str, str] = {}
|
||||
self._frozen = False
|
||||
|
||||
@property
|
||||
def frozen(self) -> bool:
|
||||
return self._frozen
|
||||
|
||||
@property
|
||||
def manifests(self) -> Mapping[str, PluginManifest]:
|
||||
return MappingProxyType(self._manifests)
|
||||
|
||||
@property
|
||||
def commands(self) -> Mapping[str, str]:
|
||||
return MappingProxyType(self._commands)
|
||||
|
||||
def register(self, manifest: PluginManifest) -> None:
|
||||
if self._frozen:
|
||||
raise CatalogError("extension catalog is frozen")
|
||||
if manifest.plugin_id in self._manifests:
|
||||
raise CatalogError(f"duplicate plugin id: {manifest.plugin_id}")
|
||||
for command in manifest.capabilities.commands:
|
||||
owner = self._commands.get(command.casefold())
|
||||
if owner is not None:
|
||||
raise CatalogError(
|
||||
f"command {command} already belongs to {owner}"
|
||||
)
|
||||
self._manifests[manifest.plugin_id] = manifest
|
||||
for command in manifest.capabilities.commands:
|
||||
self._commands[command.casefold()] = manifest.plugin_id
|
||||
|
||||
def freeze(self) -> None:
|
||||
self._frozen = True
|
||||
|
||||
def resolve_command(self, question: str) -> CommandMatch | None:
|
||||
if not self._frozen:
|
||||
raise CatalogError("extension catalog must be frozen before routing")
|
||||
normalized = question.lstrip()
|
||||
folded = normalized.casefold()
|
||||
for command_key, plugin_id in sorted(
|
||||
self._commands.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
if folded == command_key:
|
||||
return CommandMatch(
|
||||
plugin_id=plugin_id,
|
||||
command=normalized[: len(command_key)],
|
||||
arguments="",
|
||||
)
|
||||
prefix = command_key + " "
|
||||
if folded.startswith(prefix):
|
||||
return CommandMatch(
|
||||
plugin_id=plugin_id,
|
||||
command=normalized[: len(command_key)],
|
||||
arguments=normalized[len(prefix) :].strip(),
|
||||
)
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
"""Signed startup catalog and isolated extension-host dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol
|
||||
|
||||
from jwebot_sdk import API_VERSION, ContractViolation, ExtensionResult
|
||||
|
||||
from .activation import verify_installed_release_signature
|
||||
from .catalog import ExtensionCatalog
|
||||
from .host_client import ServiceHandler
|
||||
from .manifest import PluginManifest, load_manifest
|
||||
|
||||
|
||||
class HostedExtensionError(ValueError):
|
||||
"""Controlled signed-catalog, health, or invocation failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpectedExtension:
|
||||
plugin_id: str
|
||||
author: str
|
||||
commands: tuple[str, ...]
|
||||
service_profiles: tuple[str, ...]
|
||||
description: str = ""
|
||||
help_entries: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostedRelease:
|
||||
manifest: PluginManifest
|
||||
release_dir: Path
|
||||
|
||||
|
||||
class HostInvoker(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
service_handler: ServiceHandler | None,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
def _active_release_dir(plugin_root: Path) -> Path | None:
|
||||
current = plugin_root / "current"
|
||||
if not current.exists() and not current.is_symlink():
|
||||
return None
|
||||
if not current.is_symlink():
|
||||
raise HostedExtensionError("extension current pointer is not a symlink")
|
||||
target = Path(os.readlink(current))
|
||||
if (
|
||||
target.is_absolute()
|
||||
or len(target.parts) != 2
|
||||
or target.parts[0] != "releases"
|
||||
):
|
||||
raise HostedExtensionError("extension current pointer is unsafe")
|
||||
release_dir = plugin_root / target
|
||||
if not release_dir.is_dir() or release_dir.is_symlink():
|
||||
raise HostedExtensionError("extension current release is unavailable")
|
||||
return release_dir
|
||||
|
||||
|
||||
class SignedExtensionInventory:
|
||||
"""Load reviewed signed manifests during startup, then freeze routing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
catalog: ExtensionCatalog,
|
||||
releases: Mapping[str, HostedRelease],
|
||||
) -> None:
|
||||
if not catalog.frozen:
|
||||
raise HostedExtensionError("signed extension catalog must be frozen")
|
||||
self.catalog = catalog
|
||||
self._releases = MappingProxyType(dict(releases))
|
||||
|
||||
@property
|
||||
def releases(self) -> Mapping[str, HostedRelease]:
|
||||
return self._releases
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
state_root: Path,
|
||||
allowed_signers: Path,
|
||||
expected: Sequence[ExpectedExtension],
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
require_all: bool = False,
|
||||
) -> "SignedExtensionInventory":
|
||||
catalog = ExtensionCatalog()
|
||||
releases: dict[str, HostedRelease] = {}
|
||||
missing: list[str] = []
|
||||
for descriptor in sorted(expected, key=lambda item: item.plugin_id):
|
||||
plugin_root = state_root / descriptor.plugin_id
|
||||
release_dir = _active_release_dir(plugin_root)
|
||||
if release_dir is None:
|
||||
missing.append(descriptor.plugin_id)
|
||||
continue
|
||||
try:
|
||||
record, _ = verify_installed_release_signature(
|
||||
release_dir,
|
||||
allowed_signers,
|
||||
expected_publisher=expected_publisher,
|
||||
)
|
||||
manifest = load_manifest(release_dir / "plugin.toml")
|
||||
except Exception as error:
|
||||
raise HostedExtensionError(
|
||||
f"signed extension verification failed: {descriptor.plugin_id}"
|
||||
) from error
|
||||
if (
|
||||
record.plugin_id != descriptor.plugin_id
|
||||
or record.author != descriptor.author
|
||||
or manifest.plugin_id != descriptor.plugin_id
|
||||
or manifest.author != descriptor.author
|
||||
or (
|
||||
descriptor.description
|
||||
and manifest.description != descriptor.description
|
||||
)
|
||||
or (
|
||||
descriptor.help_entries
|
||||
and manifest.help_entries != descriptor.help_entries
|
||||
)
|
||||
or manifest.capabilities.commands != descriptor.commands
|
||||
or manifest.capabilities.service_profiles
|
||||
!= descriptor.service_profiles
|
||||
):
|
||||
raise HostedExtensionError(
|
||||
f"signed extension inventory mismatch: {descriptor.plugin_id}"
|
||||
)
|
||||
catalog.register(manifest)
|
||||
releases[descriptor.plugin_id] = HostedRelease(
|
||||
manifest=manifest,
|
||||
release_dir=release_dir,
|
||||
)
|
||||
if require_all and missing:
|
||||
raise HostedExtensionError(
|
||||
"required signed extensions are missing: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
catalog.freeze()
|
||||
return cls(catalog, releases)
|
||||
|
||||
|
||||
class HostedExtensionRuntime:
|
||||
"""Health-gated dispatch over signed, frozen Extension Host releases."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inventory: SignedExtensionInventory,
|
||||
invokers: Mapping[str, HostInvoker],
|
||||
service_handler: ServiceHandler,
|
||||
*,
|
||||
require_all_healthy: bool = False,
|
||||
) -> None:
|
||||
self.inventory = inventory
|
||||
self.service_handler = service_handler
|
||||
active: dict[str, HostInvoker] = {}
|
||||
failures: dict[str, str] = {}
|
||||
for plugin_id, release in inventory.releases.items():
|
||||
invoker = invokers.get(plugin_id)
|
||||
if invoker is None:
|
||||
failures[plugin_id] = "HostInvokerMissing"
|
||||
continue
|
||||
try:
|
||||
response = invoker({"op": "health"}, None)
|
||||
self._validate_health(plugin_id, release.manifest, response)
|
||||
except Exception as error:
|
||||
failures[plugin_id] = type(error).__name__
|
||||
continue
|
||||
active[plugin_id] = invoker
|
||||
if require_all_healthy and len(active) != len(inventory.releases):
|
||||
unavailable = sorted(set(inventory.releases) - set(active))
|
||||
raise HostedExtensionError(
|
||||
"required extension hosts are unhealthy: "
|
||||
+ ", ".join(unavailable)
|
||||
)
|
||||
self._active = MappingProxyType(active)
|
||||
self._failures = MappingProxyType(failures)
|
||||
|
||||
@staticmethod
|
||||
def _validate_health(
|
||||
plugin_id: str,
|
||||
manifest: PluginManifest,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
if (
|
||||
response.get("status") != "ok"
|
||||
or response.get("api_version") != API_VERSION
|
||||
or response.get("plugin_id") != plugin_id
|
||||
or response.get("author") != manifest.author
|
||||
or response.get("description") != manifest.description
|
||||
or tuple(response.get("help", ())) != manifest.help_entries
|
||||
or tuple(response.get("commands", ()))
|
||||
!= manifest.capabilities.commands
|
||||
or tuple(response.get("service_profiles", ()))
|
||||
!= manifest.capabilities.service_profiles
|
||||
):
|
||||
raise HostedExtensionError("extension host health response mismatch")
|
||||
|
||||
@property
|
||||
def active_plugin_ids(self) -> frozenset[str]:
|
||||
return frozenset(self._active)
|
||||
|
||||
@property
|
||||
def failures(self) -> Mapping[str, str]:
|
||||
return self._failures
|
||||
|
||||
def available(self, plugin_id: str) -> bool:
|
||||
return plugin_id in self._active
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
command: str,
|
||||
arguments: str,
|
||||
event: Mapping[str, Any],
|
||||
) -> ExtensionResult:
|
||||
invoker = self._active.get(plugin_id)
|
||||
if invoker is None:
|
||||
raise HostedExtensionError("extension host is not active")
|
||||
release = self.inventory.releases.get(plugin_id)
|
||||
if release is None:
|
||||
raise HostedExtensionError("extension release is not registered")
|
||||
if command.casefold() not in {
|
||||
value.casefold()
|
||||
for value in release.manifest.capabilities.commands
|
||||
}:
|
||||
raise HostedExtensionError("extension command is not declared")
|
||||
try:
|
||||
response = invoker(
|
||||
{
|
||||
"op": "invoke_command",
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": dict(event),
|
||||
},
|
||||
self.service_handler,
|
||||
)
|
||||
except Exception as error:
|
||||
raise HostedExtensionError("extension host invocation failed") from error
|
||||
if (
|
||||
response.get("status") != "ok"
|
||||
or response.get("api_version") != API_VERSION
|
||||
or response.get("plugin_id") != plugin_id
|
||||
or not isinstance(response.get("result"), Mapping)
|
||||
):
|
||||
raise HostedExtensionError("extension host response mismatch")
|
||||
try:
|
||||
return ExtensionResult.from_mapping(response["result"])
|
||||
except ContractViolation as error:
|
||||
raise HostedExtensionError(
|
||||
"extension host result violates the SDK contract"
|
||||
) from error
|
||||
@@ -0,0 +1,579 @@
|
||||
"""Parent-side clients for one isolated extension-host invocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .telemetry import RuntimeTelemetry
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 256 * 1024
|
||||
MAX_RESPONSE_BYTES = 1024 * 1024
|
||||
MAX_STDERR_BYTES = 64 * 1024
|
||||
MAX_SERVICE_CALLS = 8
|
||||
ServiceHandler = Callable[
|
||||
[str, str, Mapping[str, object]],
|
||||
Mapping[str, object],
|
||||
]
|
||||
|
||||
|
||||
class HostClientError(ValueError):
|
||||
"""Controlled parent-side host transport failure."""
|
||||
|
||||
|
||||
class HostTimeout(HostClientError):
|
||||
"""Extension host exceeded its parent-enforced wall-time deadline."""
|
||||
|
||||
|
||||
def _message_payload(value: Mapping[str, Any]) -> bytes:
|
||||
try:
|
||||
payload = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as error:
|
||||
raise HostClientError("extension message is not JSON serializable") from error
|
||||
if not payload or len(payload) > MAX_REQUEST_BYTES:
|
||||
raise HostClientError("extension request exceeds the size limit")
|
||||
return payload + b"\n"
|
||||
|
||||
|
||||
def _decode_message(payload: bytes) -> dict[str, Any]:
|
||||
if not payload or len(payload) > MAX_RESPONSE_BYTES:
|
||||
raise HostClientError("extension host output exceeds the size limit")
|
||||
try:
|
||||
response = json.loads(payload)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise HostClientError("extension host returned invalid JSON") from error
|
||||
if not isinstance(response, dict):
|
||||
raise HostClientError("extension host returned an invalid response")
|
||||
return response
|
||||
|
||||
|
||||
def _final_response(response: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if response.get("status") == "error":
|
||||
raise HostClientError("extension host returned an error")
|
||||
if response.get("status") != "ok":
|
||||
raise HostClientError("extension host returned an invalid response")
|
||||
return dict(response)
|
||||
|
||||
|
||||
def _service_request(
|
||||
response: Mapping[str, Any],
|
||||
) -> tuple[str, str, str, Mapping[str, object]]:
|
||||
call_id = response.get("call_id")
|
||||
profile = response.get("profile")
|
||||
operation = response.get("operation")
|
||||
payload = response.get("payload")
|
||||
if (
|
||||
not isinstance(call_id, str)
|
||||
or not call_id
|
||||
or len(call_id) > 40
|
||||
or not isinstance(profile, str)
|
||||
or not profile
|
||||
or len(profile) > 80
|
||||
or not isinstance(operation, str)
|
||||
or not operation
|
||||
or len(operation) > 80
|
||||
or not isinstance(payload, Mapping)
|
||||
):
|
||||
raise HostClientError("extension host service request is invalid")
|
||||
return call_id, profile, operation, payload
|
||||
|
||||
|
||||
class _MessageReader:
|
||||
def __init__(
|
||||
self,
|
||||
read_chunk: Callable[[float], bytes],
|
||||
*,
|
||||
deadline: float,
|
||||
) -> None:
|
||||
self.read_chunk = read_chunk
|
||||
self.deadline = deadline
|
||||
self.buffer = bytearray()
|
||||
self.total = 0
|
||||
|
||||
def read(self) -> dict[str, Any]:
|
||||
while True:
|
||||
newline = self.buffer.find(b"\n")
|
||||
if newline >= 0:
|
||||
payload = bytes(self.buffer[:newline])
|
||||
del self.buffer[: newline + 1]
|
||||
return _decode_message(payload)
|
||||
remaining = self.deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise HostTimeout("extension host timed out")
|
||||
chunk = self.read_chunk(remaining)
|
||||
if not chunk:
|
||||
if self.buffer:
|
||||
payload = bytes(self.buffer)
|
||||
self.buffer.clear()
|
||||
return _decode_message(payload)
|
||||
raise HostClientError("extension host closed without a response")
|
||||
self.total += len(chunk)
|
||||
if self.total > MAX_RESPONSE_BYTES:
|
||||
raise HostClientError(
|
||||
"extension host output exceeds the size limit"
|
||||
)
|
||||
self.buffer.extend(chunk)
|
||||
|
||||
|
||||
def _protocol_exchange(
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
send: Callable[[bytes], None],
|
||||
read_chunk: Callable[[float], bytes],
|
||||
timeout_seconds: float,
|
||||
service_handler: ServiceHandler | None,
|
||||
telemetry: RuntimeTelemetry | None = None,
|
||||
plugin_id: str = "",
|
||||
transport: str = "",
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
reader = _MessageReader(read_chunk, deadline=deadline)
|
||||
send(_message_payload(request))
|
||||
service_calls = 0
|
||||
while True:
|
||||
response = reader.read()
|
||||
if response.get("status") != "service_request":
|
||||
return _final_response(response)
|
||||
service_calls += 1
|
||||
if service_calls > MAX_SERVICE_CALLS:
|
||||
raise HostClientError("extension host exceeded the service call limit")
|
||||
call_id, profile, operation, payload = _service_request(response)
|
||||
service_started = time.monotonic()
|
||||
if telemetry is not None:
|
||||
telemetry.emit(
|
||||
"host.service.request",
|
||||
plugin_id=plugin_id,
|
||||
transport=transport,
|
||||
operation=operation,
|
||||
)
|
||||
if service_handler is None:
|
||||
send(
|
||||
_message_payload(
|
||||
{
|
||||
"op": "service_error",
|
||||
"call_id": call_id,
|
||||
"error": "ServiceUnavailable",
|
||||
}
|
||||
)
|
||||
)
|
||||
if telemetry is not None:
|
||||
telemetry.elapsed(
|
||||
"host.service.complete",
|
||||
service_started,
|
||||
status="error",
|
||||
plugin_id=plugin_id,
|
||||
transport=transport,
|
||||
operation=operation,
|
||||
error_type="ServiceUnavailable",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
result = service_handler(profile, operation, payload)
|
||||
if not isinstance(result, Mapping):
|
||||
raise HostClientError(
|
||||
"kernel service handler returned an invalid result"
|
||||
)
|
||||
reply = {
|
||||
"op": "service_result",
|
||||
"call_id": call_id,
|
||||
"result": dict(result),
|
||||
}
|
||||
except Exception as error:
|
||||
reply = {
|
||||
"op": "service_error",
|
||||
"call_id": call_id,
|
||||
"error": type(error).__name__[:80],
|
||||
}
|
||||
if time.monotonic() > deadline:
|
||||
raise HostTimeout("kernel service exceeded the host deadline")
|
||||
if telemetry is not None:
|
||||
telemetry.elapsed(
|
||||
"host.service.complete",
|
||||
service_started,
|
||||
status=(
|
||||
"ok"
|
||||
if reply["op"] == "service_result"
|
||||
else "error"
|
||||
),
|
||||
plugin_id=plugin_id,
|
||||
transport=transport,
|
||||
operation=operation,
|
||||
error_type=(
|
||||
""
|
||||
if reply["op"] == "service_result"
|
||||
else str(reply["error"])
|
||||
),
|
||||
)
|
||||
send(_message_payload(reply))
|
||||
|
||||
|
||||
class _StderrCollector(threading.Thread):
|
||||
def __init__(self, stream: Any) -> None:
|
||||
super().__init__(name="jwebot-extension-stderr", daemon=True)
|
||||
self.stream = stream
|
||||
self.content = bytearray()
|
||||
self.overflow = False
|
||||
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
chunk = self.stream.read(16 * 1024)
|
||||
if not chunk:
|
||||
return
|
||||
remaining = MAX_STDERR_BYTES + 1 - len(self.content)
|
||||
if remaining > 0:
|
||||
self.content.extend(chunk[:remaining])
|
||||
if len(self.content) > MAX_STDERR_BYTES:
|
||||
self.overflow = True
|
||||
|
||||
|
||||
def _kill_process_group(process: subprocess.Popen[bytes]) -> None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
process.kill()
|
||||
|
||||
|
||||
class ExtensionHostClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
command: Sequence[str] | None = None,
|
||||
timeout_seconds: float = 8.0,
|
||||
allowed_signers: Path | None = None,
|
||||
expected_publisher: str | None = None,
|
||||
allow_unsigned_local: bool = False,
|
||||
plugin_id: str = "",
|
||||
telemetry: RuntimeTelemetry | None = None,
|
||||
) -> None:
|
||||
self.command = tuple(
|
||||
command
|
||||
or (
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-m",
|
||||
"jwebot_extension_host",
|
||||
)
|
||||
)
|
||||
if (
|
||||
not self.command
|
||||
or timeout_seconds <= 0
|
||||
or (
|
||||
allow_unsigned_local
|
||||
and (
|
||||
allowed_signers is not None
|
||||
or expected_publisher is not None
|
||||
)
|
||||
)
|
||||
):
|
||||
raise HostClientError("invalid extension host client configuration")
|
||||
if command is None and allowed_signers is None and not allow_unsigned_local:
|
||||
raise HostClientError("default extension host requires publisher trust")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.allowed_signers = allowed_signers
|
||||
self.expected_publisher = expected_publisher
|
||||
self.allow_unsigned_local = allow_unsigned_local
|
||||
self.plugin_id = plugin_id[:64]
|
||||
self.telemetry = telemetry
|
||||
|
||||
def _command(self, release_dir: Path) -> list[str]:
|
||||
command = [
|
||||
*self.command,
|
||||
"--release-dir",
|
||||
str(release_dir),
|
||||
]
|
||||
if self.allow_unsigned_local:
|
||||
command.append("--allow-unsigned-local")
|
||||
elif self.allowed_signers is not None:
|
||||
command.extend(
|
||||
[
|
||||
"--allowed-signers",
|
||||
str(self.allowed_signers),
|
||||
]
|
||||
)
|
||||
if self.expected_publisher is not None:
|
||||
command.extend(["--publisher", self.expected_publisher])
|
||||
return command
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
release_dir: Path,
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
service_handler: ServiceHandler | None = None,
|
||||
) -> dict[str, Any]:
|
||||
invocation_started = time.monotonic()
|
||||
try:
|
||||
resolved_release = release_dir.resolve(strict=True)
|
||||
except OSError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise HostClientError(
|
||||
"extension release directory is unavailable"
|
||||
) from error
|
||||
if not resolved_release.is_dir():
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type="ReleaseUnavailable",
|
||||
)
|
||||
raise HostClientError("extension release path is not a directory")
|
||||
|
||||
environment = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if key in {"LANG", "LC_ALL", "TZ"}
|
||||
}
|
||||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
self._command(resolved_release),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd="/",
|
||||
env=environment,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
bufsize=0,
|
||||
)
|
||||
except OSError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise HostClientError("cannot start extension host") from error
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.process.spawn",
|
||||
invocation_started,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
process="extension-host",
|
||||
)
|
||||
assert process.stdin is not None
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stderr = _StderrCollector(process.stderr)
|
||||
stderr.start()
|
||||
deadline = time.monotonic() + self.timeout_seconds
|
||||
|
||||
def send(payload: bytes) -> None:
|
||||
try:
|
||||
process.stdin.write(payload)
|
||||
except (BrokenPipeError, OSError) as error:
|
||||
raise HostClientError(
|
||||
"extension host closed its input"
|
||||
) from error
|
||||
|
||||
def read_chunk(timeout: float) -> bytes:
|
||||
ready, _, _ = select.select(
|
||||
[process.stdout.fileno()],
|
||||
[],
|
||||
[],
|
||||
timeout,
|
||||
)
|
||||
if not ready:
|
||||
raise HostTimeout("extension host timed out")
|
||||
return os.read(process.stdout.fileno(), 64 * 1024)
|
||||
|
||||
try:
|
||||
response = _protocol_exchange(
|
||||
request,
|
||||
send=send,
|
||||
read_chunk=read_chunk,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
service_handler=service_handler,
|
||||
telemetry=self.telemetry,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
)
|
||||
process.stdin.close()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise HostTimeout("extension host timed out")
|
||||
try:
|
||||
return_code = process.wait(timeout=remaining)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise HostTimeout("extension host timed out") from error
|
||||
stderr.join(timeout=1)
|
||||
if stderr.overflow:
|
||||
raise HostClientError(
|
||||
"extension host output exceeds the size limit"
|
||||
)
|
||||
if return_code != 0:
|
||||
raise HostClientError("extension host returned an error")
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
)
|
||||
return response
|
||||
except Exception as error:
|
||||
if process.poll() is None:
|
||||
_kill_process_group(process)
|
||||
process.wait()
|
||||
stderr.join(timeout=1)
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="process",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
if not stream.closed:
|
||||
stream.close()
|
||||
|
||||
|
||||
class UnixExtensionHostClient:
|
||||
"""Client for a systemd-activated AF_UNIX extension-host socket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 8.0,
|
||||
plugin_id: str = "",
|
||||
telemetry: RuntimeTelemetry | None = None,
|
||||
) -> None:
|
||||
if not socket_path.is_absolute() or timeout_seconds <= 0:
|
||||
raise HostClientError("invalid Unix host client configuration")
|
||||
self.socket_path = socket_path
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.plugin_id = plugin_id[:64]
|
||||
self.telemetry = telemetry
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
service_handler: ServiceHandler | None = None,
|
||||
) -> dict[str, Any]:
|
||||
invocation_started = time.monotonic()
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(self.timeout_seconds)
|
||||
connection.connect(str(self.socket_path))
|
||||
|
||||
def send(payload: bytes) -> None:
|
||||
connection.sendall(payload)
|
||||
|
||||
def read_chunk(timeout: float) -> bytes:
|
||||
connection.settimeout(timeout)
|
||||
return connection.recv(64 * 1024)
|
||||
|
||||
response = _protocol_exchange(
|
||||
request,
|
||||
send=send,
|
||||
read_chunk=read_chunk,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
service_handler=service_handler,
|
||||
telemetry=self.telemetry,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
)
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
except OSError:
|
||||
pass
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
)
|
||||
return response
|
||||
except HostTimeout as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise
|
||||
except HostClientError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise
|
||||
except socket.timeout as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type="HostTimeout",
|
||||
)
|
||||
raise HostTimeout("extension host socket timed out") from error
|
||||
except OSError as error:
|
||||
if self.telemetry is not None:
|
||||
self.telemetry.elapsed(
|
||||
"host.invoke",
|
||||
invocation_started,
|
||||
status="error",
|
||||
plugin_id=self.plugin_id,
|
||||
transport="unix",
|
||||
operation=str(request.get("op", "")),
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
raise HostClientError(
|
||||
"extension host socket is unavailable"
|
||||
) from error
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Strict parser for reviewed extension manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from jwebot_sdk import API_VERSION
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ModuleNotFoundError:
|
||||
tomllib = None # type: ignore[assignment]
|
||||
|
||||
|
||||
PLUGIN_ID = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
SEMVER = re.compile(r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$")
|
||||
AUTHOR = re.compile(r"^[A-Za-z][A-Za-z0-9._ -]{0,63}$")
|
||||
ENTRYPOINT = re.compile(
|
||||
r"^[A-Za-z_][A-Za-z0-9_.]*:[A-Za-z_][A-Za-z0-9_]*$"
|
||||
)
|
||||
COMMAND = re.compile(r"^/[\w.-]{1,40}$")
|
||||
NETWORK_PROFILE = re.compile(r"^[a-z][a-z0-9-]{1,39}$")
|
||||
ALLOWED_TOP_LEVEL = {
|
||||
"id",
|
||||
"author",
|
||||
"description",
|
||||
"help",
|
||||
"version",
|
||||
"api_version",
|
||||
"entrypoint",
|
||||
"type",
|
||||
"capabilities",
|
||||
}
|
||||
ALLOWED_CAPABILITIES = {
|
||||
"commands",
|
||||
"storage",
|
||||
"network_profiles",
|
||||
"service_profiles",
|
||||
}
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""Raised when an extension manifest violates the kernel policy."""
|
||||
|
||||
|
||||
class PluginType(str, Enum):
|
||||
COMMAND = "command"
|
||||
ENRICHMENT = "enrichment"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
def _required_string(
|
||||
value: Mapping[str, Any],
|
||||
key: str,
|
||||
*,
|
||||
pattern: re.Pattern[str] | None = None,
|
||||
) -> str:
|
||||
raw = value.get(key)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise ManifestError(f"{key} must be a non-empty string")
|
||||
if pattern is not None and not pattern.fullmatch(raw):
|
||||
raise ManifestError(f"{key} has an invalid format")
|
||||
return raw
|
||||
|
||||
|
||||
def _string_list(
|
||||
value: object,
|
||||
*,
|
||||
key: str,
|
||||
pattern: re.Pattern[str],
|
||||
limit: int,
|
||||
) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise ManifestError(f"{key} must be a list of strings")
|
||||
if len(value) > limit:
|
||||
raise ManifestError(f"{key} exceeds {limit} entries")
|
||||
if any(not pattern.fullmatch(item) for item in value):
|
||||
raise ManifestError(f"{key} contains an invalid value")
|
||||
if len(set(value)) != len(value):
|
||||
raise ManifestError(f"{key} contains duplicates")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _optional_description(value: Mapping[str, Any]) -> str:
|
||||
raw = value.get("description", "")
|
||||
if not isinstance(raw, str):
|
||||
raise ManifestError("description must be a string")
|
||||
description = raw.strip()
|
||||
if len(description) > 120 or "\n" in description or "\r" in description:
|
||||
raise ManifestError("description exceeds the display boundary")
|
||||
return description
|
||||
|
||||
|
||||
def _help_entries(value: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
raw = value.get("help", [])
|
||||
if not isinstance(raw, list) or not all(
|
||||
isinstance(item, str)
|
||||
for item in raw
|
||||
):
|
||||
raise ManifestError("help must be a list of strings")
|
||||
if len(raw) > 8:
|
||||
raise ManifestError("help exceeds 8 entries")
|
||||
entries = tuple(item.strip() for item in raw)
|
||||
if any(
|
||||
not item
|
||||
or not item.startswith("/")
|
||||
or len(item) > 160
|
||||
or "\n" in item
|
||||
or "\r" in item
|
||||
for item in entries
|
||||
):
|
||||
raise ManifestError("help contains an invalid display entry")
|
||||
if len(set(entries)) != len(entries):
|
||||
raise ManifestError("help contains duplicates")
|
||||
return entries
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityRequest:
|
||||
commands: tuple[str, ...] = ()
|
||||
storage: bool = False
|
||||
network_profiles: tuple[str, ...] = ()
|
||||
service_profiles: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
plugin_id: str
|
||||
author: str
|
||||
description: str
|
||||
help_entries: tuple[str, ...]
|
||||
version: str
|
||||
api_version: str
|
||||
entrypoint: str
|
||||
plugin_type: PluginType
|
||||
capabilities: CapabilityRequest
|
||||
|
||||
|
||||
def parse_manifest(value: Mapping[str, Any]) -> PluginManifest:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ManifestError("manifest must be an object")
|
||||
unknown = set(value) - ALLOWED_TOP_LEVEL
|
||||
if unknown:
|
||||
raise ManifestError(f"unknown manifest keys: {', '.join(sorted(unknown))}")
|
||||
|
||||
plugin_id = _required_string(value, "id", pattern=PLUGIN_ID)
|
||||
author = _required_string(value, "author", pattern=AUTHOR)
|
||||
description = _optional_description(value)
|
||||
help_entries = _help_entries(value)
|
||||
if bool(description) != bool(help_entries):
|
||||
raise ManifestError(
|
||||
"description and help must be declared together"
|
||||
)
|
||||
version = _required_string(value, "version", pattern=SEMVER)
|
||||
api_version = _required_string(value, "api_version")
|
||||
if api_version != API_VERSION:
|
||||
raise ManifestError(f"unsupported api_version: {api_version}")
|
||||
entrypoint = _required_string(value, "entrypoint", pattern=ENTRYPOINT)
|
||||
entry_module = entrypoint.partition(":")[0].partition(".")[0]
|
||||
if entry_module != plugin_id.replace("-", "_"):
|
||||
raise ManifestError("entrypoint must use the plugin module namespace")
|
||||
try:
|
||||
plugin_type = PluginType(_required_string(value, "type"))
|
||||
except ValueError as error:
|
||||
raise ManifestError("type must be command, enrichment, or scheduled") from error
|
||||
|
||||
raw_capabilities = value.get("capabilities", {})
|
||||
if not isinstance(raw_capabilities, Mapping):
|
||||
raise ManifestError("capabilities must be an object")
|
||||
unknown_capabilities = set(raw_capabilities) - ALLOWED_CAPABILITIES
|
||||
if unknown_capabilities:
|
||||
raise ManifestError(
|
||||
"unknown capabilities: " + ", ".join(sorted(unknown_capabilities))
|
||||
)
|
||||
storage = raw_capabilities.get("storage", False)
|
||||
if not isinstance(storage, bool):
|
||||
raise ManifestError("capabilities.storage must be boolean")
|
||||
commands = _string_list(
|
||||
raw_capabilities.get("commands"),
|
||||
key="capabilities.commands",
|
||||
pattern=COMMAND,
|
||||
limit=16,
|
||||
)
|
||||
if len({command.casefold() for command in commands}) != len(commands):
|
||||
raise ManifestError("capabilities.commands contains case-insensitive duplicates")
|
||||
capabilities = CapabilityRequest(
|
||||
commands=commands,
|
||||
storage=storage,
|
||||
network_profiles=_string_list(
|
||||
raw_capabilities.get("network_profiles"),
|
||||
key="capabilities.network_profiles",
|
||||
pattern=NETWORK_PROFILE,
|
||||
limit=8,
|
||||
),
|
||||
service_profiles=_string_list(
|
||||
raw_capabilities.get("service_profiles"),
|
||||
key="capabilities.service_profiles",
|
||||
pattern=NETWORK_PROFILE,
|
||||
limit=8,
|
||||
),
|
||||
)
|
||||
|
||||
if plugin_type is PluginType.COMMAND and not capabilities.commands:
|
||||
raise ManifestError("command extension must declare at least one command")
|
||||
if plugin_type is PluginType.COMMAND and not description:
|
||||
raise ManifestError(
|
||||
"command extension must declare description and help"
|
||||
)
|
||||
if plugin_type is not PluginType.COMMAND and capabilities.commands:
|
||||
raise ManifestError("only command extensions may declare commands")
|
||||
if plugin_type is not PluginType.COMMAND and help_entries:
|
||||
raise ManifestError("only command extensions may declare help")
|
||||
|
||||
return PluginManifest(
|
||||
plugin_id=plugin_id,
|
||||
author=author,
|
||||
description=description,
|
||||
help_entries=help_entries,
|
||||
version=version,
|
||||
api_version=api_version,
|
||||
entrypoint=entrypoint,
|
||||
plugin_type=plugin_type,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> PluginManifest:
|
||||
if tomllib is None:
|
||||
raise ManifestError("TOML parser unavailable; install tomli on Python < 3.11")
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
value = tomllib.load(stream)
|
||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
||||
raise ManifestError("cannot read plugin manifest") from error
|
||||
return parse_manifest(value)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Deterministic extension release artifacts and digest verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .manifest import AUTHOR, PluginManifest, load_manifest
|
||||
from .source_policy import validate_source_tree
|
||||
|
||||
|
||||
MAX_RELEASE_FILES = 66
|
||||
MAX_RELEASE_BYTES = 1024 * 1024
|
||||
MAX_ARTIFACT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
class ReleaseError(ValueError):
|
||||
"""Raised when a release tree or artifact is not immutable and bounded."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def release_record_bytes(record: "ReleaseRecord") -> bytes:
|
||||
return _canonical_json(record.to_mapping()) + b"\n"
|
||||
|
||||
|
||||
def source_digest_for_files(files: tuple[tuple[str, bytes], ...]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for relative, content in files:
|
||||
path_bytes = relative.encode("utf-8")
|
||||
digest.update(len(path_bytes).to_bytes(4, "big"))
|
||||
digest.update(path_bytes)
|
||||
digest.update(len(content).to_bytes(8, "big"))
|
||||
digest.update(content)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(128 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def collect_release_files(
|
||||
root: Path,
|
||||
manifest: PluginManifest,
|
||||
) -> tuple[tuple[str, bytes], ...]:
|
||||
if not root.is_dir() or root.is_symlink():
|
||||
raise ReleaseError("extension root must be a real directory")
|
||||
validate_source_tree(root, manifest)
|
||||
|
||||
allowed: list[Path] = [root / "plugin.toml"]
|
||||
readme = root / "README.md"
|
||||
if readme.exists():
|
||||
allowed.append(readme)
|
||||
|
||||
source_root = root / "src"
|
||||
for path in sorted(source_root.rglob("*")):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink():
|
||||
raise ReleaseError(f"release symlink is forbidden: {relative}")
|
||||
if path.is_dir():
|
||||
continue
|
||||
if (
|
||||
"__pycache__" in path.relative_to(source_root).parts
|
||||
and path.suffix == ".pyc"
|
||||
):
|
||||
continue
|
||||
if not path.is_file() or path.suffix != ".py":
|
||||
raise ReleaseError(f"unsupported release file: {relative}")
|
||||
allowed.append(path)
|
||||
|
||||
values: list[tuple[str, bytes]] = []
|
||||
total_bytes = 0
|
||||
for path in sorted(set(allowed), key=lambda item: item.relative_to(root).as_posix()):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseError(f"release file is unavailable: {relative}")
|
||||
if PurePosixPath(relative).is_absolute() or ".." in PurePosixPath(relative).parts:
|
||||
raise ReleaseError(f"unsafe release path: {relative}")
|
||||
content = path.read_bytes()
|
||||
total_bytes += len(content)
|
||||
values.append((relative, content))
|
||||
|
||||
if len(values) > MAX_RELEASE_FILES:
|
||||
raise ReleaseError(f"release exceeds {MAX_RELEASE_FILES} files")
|
||||
if total_bytes > MAX_RELEASE_BYTES:
|
||||
raise ReleaseError(f"release exceeds {MAX_RELEASE_BYTES} bytes")
|
||||
return tuple(values)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseRecord:
|
||||
plugin_id: str
|
||||
author: str
|
||||
plugin_version: str
|
||||
api_version: str
|
||||
source_digest: str
|
||||
artifact_name: str
|
||||
artifact_sha256: str
|
||||
artifact_size: int
|
||||
files: tuple[str, ...]
|
||||
|
||||
def to_mapping(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"plugin_id": self.plugin_id,
|
||||
"author": self.author,
|
||||
"plugin_version": self.plugin_version,
|
||||
"api_version": self.api_version,
|
||||
"source_digest": self.source_digest,
|
||||
"artifact_name": self.artifact_name,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"artifact_size": self.artifact_size,
|
||||
"files": list(self.files),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ReleaseRecord":
|
||||
try:
|
||||
if value.get("schema_version") != 2:
|
||||
raise ReleaseError("unsupported release schema")
|
||||
files = value["files"]
|
||||
if not isinstance(files, list) or not all(
|
||||
isinstance(item, str) for item in files
|
||||
):
|
||||
raise ReleaseError("release files must be a string list")
|
||||
author = value["author"]
|
||||
if not isinstance(author, str) or not AUTHOR.fullmatch(author):
|
||||
raise ReleaseError("release author has an invalid format")
|
||||
record = cls(
|
||||
plugin_id=str(value["plugin_id"]),
|
||||
author=author,
|
||||
plugin_version=str(value["plugin_version"]),
|
||||
api_version=str(value["api_version"]),
|
||||
source_digest=str(value["source_digest"]),
|
||||
artifact_name=str(value["artifact_name"]),
|
||||
artifact_sha256=str(value["artifact_sha256"]),
|
||||
artifact_size=int(value["artifact_size"]),
|
||||
files=tuple(files),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ReleaseError("invalid release record") from error
|
||||
for digest in (record.source_digest, record.artifact_sha256):
|
||||
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||
raise ReleaseError("invalid release digest")
|
||||
if record.artifact_size < 1 or record.artifact_size > MAX_ARTIFACT_BYTES:
|
||||
raise ReleaseError("invalid artifact size")
|
||||
if len(record.files) > MAX_RELEASE_FILES or len(set(record.files)) != len(record.files):
|
||||
raise ReleaseError("invalid release file list")
|
||||
return record
|
||||
|
||||
|
||||
def _write_artifact(
|
||||
path: Path,
|
||||
files: tuple[tuple[str, bytes], ...],
|
||||
) -> None:
|
||||
with path.open("wb") as raw:
|
||||
with gzip.GzipFile(
|
||||
filename="",
|
||||
mode="wb",
|
||||
compresslevel=9,
|
||||
fileobj=raw,
|
||||
mtime=0,
|
||||
) as compressed:
|
||||
with tarfile.open(
|
||||
fileobj=compressed,
|
||||
mode="w",
|
||||
format=tarfile.USTAR_FORMAT,
|
||||
) as archive:
|
||||
for relative, content in files:
|
||||
info = tarfile.TarInfo(name=f"plugin/{relative}")
|
||||
info.size = len(content)
|
||||
info.mode = 0o644
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = ""
|
||||
info.gname = ""
|
||||
info.mtime = 0
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
|
||||
|
||||
def _publish_immutable(temporary: Path, destination: Path) -> None:
|
||||
if destination.exists():
|
||||
if destination.read_bytes() != temporary.read_bytes():
|
||||
raise ReleaseError(f"immutable release already exists: {destination.name}")
|
||||
temporary.unlink()
|
||||
return
|
||||
temporary.replace(destination)
|
||||
|
||||
|
||||
def build_release(
|
||||
root: Path,
|
||||
output: Path,
|
||||
*,
|
||||
manifest: PluginManifest | None = None,
|
||||
) -> tuple[Path, Path, ReleaseRecord]:
|
||||
manifest = manifest or load_manifest(root / "plugin.toml")
|
||||
files = collect_release_files(root, manifest)
|
||||
source_digest = source_digest_for_files(files)
|
||||
stem = f"{manifest.plugin_id}-{manifest.version}-{source_digest[:16]}"
|
||||
artifact = output / f"{stem}.tar.gz"
|
||||
record_path = output / f"{stem}.release.json"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{stem}.",
|
||||
suffix=".tmp",
|
||||
dir=output,
|
||||
)
|
||||
os.close(descriptor)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
_write_artifact(temporary, files)
|
||||
artifact_size = temporary.stat().st_size
|
||||
if artifact_size > MAX_ARTIFACT_BYTES:
|
||||
raise ReleaseError(f"artifact exceeds {MAX_ARTIFACT_BYTES} bytes")
|
||||
record = ReleaseRecord(
|
||||
plugin_id=manifest.plugin_id,
|
||||
author=manifest.author,
|
||||
plugin_version=manifest.version,
|
||||
api_version=manifest.api_version,
|
||||
source_digest=source_digest,
|
||||
artifact_name=artifact.name,
|
||||
artifact_sha256=_sha256_file(temporary),
|
||||
artifact_size=artifact_size,
|
||||
files=tuple(relative for relative, _ in files),
|
||||
)
|
||||
_publish_immutable(temporary, artifact)
|
||||
record_bytes = release_record_bytes(record)
|
||||
record_temporary = record_path.with_name(f".{record_path.name}.{os.getpid()}.tmp")
|
||||
record_temporary.write_bytes(record_bytes)
|
||||
try:
|
||||
_publish_immutable(record_temporary, record_path)
|
||||
finally:
|
||||
record_temporary.unlink(missing_ok=True)
|
||||
return artifact, record_path, record
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def load_release_record(path: Path) -> ReleaseRecord:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ReleaseError("cannot read release record") from error
|
||||
if not isinstance(value, Mapping):
|
||||
raise ReleaseError("release record must be an object")
|
||||
return ReleaseRecord.from_mapping(value)
|
||||
|
||||
|
||||
def verify_release(artifact: Path, record: ReleaseRecord) -> None:
|
||||
if artifact.name != record.artifact_name:
|
||||
raise ReleaseError("artifact name does not match release record")
|
||||
try:
|
||||
size = artifact.stat().st_size
|
||||
except OSError as error:
|
||||
raise ReleaseError("artifact is unavailable") from error
|
||||
if size != record.artifact_size:
|
||||
raise ReleaseError("artifact size mismatch")
|
||||
if _sha256_file(artifact) != record.artifact_sha256:
|
||||
raise ReleaseError("artifact digest mismatch")
|
||||
|
||||
files: list[tuple[str, bytes]] = []
|
||||
try:
|
||||
with tarfile.open(artifact, mode="r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
if len(members) > MAX_RELEASE_FILES:
|
||||
raise ReleaseError("artifact contains too many files")
|
||||
total_bytes = 0
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
if (
|
||||
not member.isfile()
|
||||
or path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or not path.parts
|
||||
or path.parts[0] != "plugin"
|
||||
):
|
||||
raise ReleaseError("artifact contains an unsafe member")
|
||||
relative = PurePosixPath(*path.parts[1:]).as_posix()
|
||||
if not relative or relative.startswith("/"):
|
||||
raise ReleaseError("artifact contains an unsafe path")
|
||||
total_bytes += member.size
|
||||
if total_bytes > MAX_RELEASE_BYTES:
|
||||
raise ReleaseError("artifact expands beyond the size limit")
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ReleaseError("artifact member cannot be read")
|
||||
files.append((relative, stream.read()))
|
||||
except (OSError, tarfile.TarError, EOFError) as error:
|
||||
raise ReleaseError("cannot read release artifact") from error
|
||||
|
||||
normalized = tuple(sorted(files))
|
||||
if tuple(path for path, _ in normalized) != record.files:
|
||||
raise ReleaseError("artifact file list mismatch")
|
||||
if source_digest_for_files(normalized) != record.source_digest:
|
||||
raise ReleaseError("artifact source digest mismatch")
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pure routing primitives with no model, storage, or GUI dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class RouteKind(str, Enum):
|
||||
LOCAL = "local"
|
||||
CHAT = "chat"
|
||||
CLOUD = "cloud"
|
||||
|
||||
|
||||
class ExecutionPath(str, Enum):
|
||||
LOCAL_COMMAND = "local-command"
|
||||
DEEPSEEK = "deepseek"
|
||||
OPENCLAW = "openclaw"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteDecision:
|
||||
route: RouteKind
|
||||
reason: str
|
||||
command_kind: str = ""
|
||||
arguments: str = ""
|
||||
|
||||
@property
|
||||
def execution_path(self) -> ExecutionPath:
|
||||
return {
|
||||
RouteKind.LOCAL: ExecutionPath.LOCAL_COMMAND,
|
||||
RouteKind.CHAT: ExecutionPath.DEEPSEEK,
|
||||
RouteKind.CLOUD: ExecutionPath.OPENCLAW,
|
||||
}[self.route]
|
||||
|
||||
|
||||
def parse_local_command(
|
||||
question: str,
|
||||
aliases: Mapping[str, str],
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve the longest registered slash-command prefix."""
|
||||
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
return "", ""
|
||||
raw = normalized[1:].lstrip()
|
||||
folded = raw.casefold()
|
||||
for command, kind in sorted(
|
||||
aliases.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
command_key = command.casefold()
|
||||
if folded == command_key:
|
||||
return kind, ""
|
||||
if folded.startswith(command_key):
|
||||
return kind, raw[len(command):].strip()
|
||||
command, _, arguments = raw.partition(" ")
|
||||
return aliases.get(command.casefold(), ""), arguments.strip()
|
||||
|
||||
|
||||
def classify_question(
|
||||
question: str,
|
||||
aliases: Mapping[str, str],
|
||||
) -> RouteDecision:
|
||||
normalized = question.lstrip()
|
||||
command_kind, arguments = parse_local_command(normalized, aliases)
|
||||
if command_kind:
|
||||
return RouteDecision(
|
||||
route=RouteKind.LOCAL,
|
||||
reason=f"local_{command_kind}",
|
||||
command_kind=command_kind,
|
||||
arguments=arguments,
|
||||
)
|
||||
if normalized.startswith("/"):
|
||||
return RouteDecision(route=RouteKind.LOCAL, reason="local_unknown")
|
||||
if normalized.startswith(("#", "#")):
|
||||
return RouteDecision(route=RouteKind.CLOUD, reason="hash_command")
|
||||
return RouteDecision(route=RouteKind.CHAT, reason="direct_chat")
|
||||
@@ -0,0 +1,396 @@
|
||||
"""OpenSSH publisher signatures for canonical extension release records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .release import (
|
||||
ReleaseError,
|
||||
ReleaseRecord,
|
||||
load_release_record,
|
||||
release_record_bytes,
|
||||
verify_release,
|
||||
)
|
||||
|
||||
|
||||
SIGNATURE_SCHEMA_VERSION = 1
|
||||
SIGNATURE_FORMAT = "sshsig"
|
||||
SIGNATURE_NAMESPACE = "jwebot-release-v1"
|
||||
MAX_RELEASE_RECORD_BYTES = 128 * 1024
|
||||
MAX_SIGNATURE_BYTES = 16 * 1024
|
||||
MAX_ALLOWED_SIGNERS_BYTES = 64 * 1024
|
||||
PUBLISHER_ID = re.compile(r"^[a-z][a-z0-9._-]{1,63}$")
|
||||
RELEASE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,159}\.release\.json$")
|
||||
SSH_SIGNATURE_HEADER = b"-----BEGIN SSH SIGNATURE-----"
|
||||
SSH_SIGNATURE_FOOTER = b"-----END SSH SIGNATURE-----"
|
||||
SIGNATURE_PAYLOAD_PREFIX = b"JWEBOT-RELEASE-SIGNATURE-V1\0"
|
||||
|
||||
|
||||
class SignatureError(ReleaseError):
|
||||
"""Raised when a release signature or publisher trust check fails."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _signature_payload(
|
||||
publisher: str,
|
||||
release_name: str,
|
||||
release_bytes: bytes,
|
||||
) -> bytes:
|
||||
publisher_bytes = publisher.encode("ascii")
|
||||
name_bytes = release_name.encode("ascii")
|
||||
return b"".join(
|
||||
(
|
||||
SIGNATURE_PAYLOAD_PREFIX,
|
||||
len(publisher_bytes).to_bytes(2, "big"),
|
||||
publisher_bytes,
|
||||
len(name_bytes).to_bytes(2, "big"),
|
||||
name_bytes,
|
||||
len(release_bytes).to_bytes(8, "big"),
|
||||
release_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _release_name(record: ReleaseRecord) -> str:
|
||||
suffix = ".tar.gz"
|
||||
if not record.artifact_name.endswith(suffix):
|
||||
raise SignatureError("release artifact name is invalid")
|
||||
name = f"{record.artifact_name[: -len(suffix)]}.release.json"
|
||||
if not RELEASE_NAME.fullmatch(name):
|
||||
raise SignatureError("release record name is invalid")
|
||||
return name
|
||||
|
||||
|
||||
def _read_canonical_release(path: Path) -> tuple[ReleaseRecord, bytes]:
|
||||
record = load_release_record(path)
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise SignatureError("release record is unavailable") from error
|
||||
if len(content) > MAX_RELEASE_RECORD_BYTES:
|
||||
raise SignatureError("release record exceeds the size limit")
|
||||
if content != release_record_bytes(record):
|
||||
raise SignatureError("release record is not canonical")
|
||||
return record, content
|
||||
|
||||
|
||||
def _publish_immutable(content: bytes, destination: Path) -> None:
|
||||
if destination.exists():
|
||||
try:
|
||||
current = destination.read_bytes()
|
||||
except OSError as error:
|
||||
raise SignatureError("signature destination is unavailable") from error
|
||||
if current != content:
|
||||
raise SignatureError(
|
||||
f"immutable signature already exists: {destination.name}"
|
||||
)
|
||||
return
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".tmp",
|
||||
dir=destination.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _signature_path(release_path: Path) -> Path:
|
||||
suffix = ".release.json"
|
||||
if not release_path.name.endswith(suffix):
|
||||
raise SignatureError("release record name is invalid")
|
||||
stem = release_path.name[: -len(suffix)]
|
||||
return release_path.with_name(f"{stem}.release.sig.json")
|
||||
|
||||
|
||||
def _run_ssh_keygen(
|
||||
arguments: list[str],
|
||||
*,
|
||||
input_bytes: bytes,
|
||||
ssh_keygen: Path,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(ssh_keygen), *arguments],
|
||||
input=input_bytes,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
env={
|
||||
"LANG": "C",
|
||||
"LC_ALL": "C",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise SignatureError("ssh-keygen execution failed") from error
|
||||
if len(result.stdout) > MAX_SIGNATURE_BYTES or len(result.stderr) > 16 * 1024:
|
||||
raise SignatureError("ssh-keygen output exceeds the size limit")
|
||||
return result
|
||||
|
||||
|
||||
def _validate_allowed_signers(path: Path) -> None:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise SignatureError("trusted publisher file is unavailable") from error
|
||||
if not 1 <= len(content) <= MAX_ALLOWED_SIGNERS_BYTES:
|
||||
raise SignatureError("trusted publisher file is invalid")
|
||||
try:
|
||||
text = content.decode("ascii")
|
||||
except UnicodeDecodeError as error:
|
||||
raise SignatureError("trusted publisher file must be ASCII") from error
|
||||
key_count = 0
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
fields = stripped.split()
|
||||
if len(fields) < 3 or fields[1] != "ssh-ed25519":
|
||||
raise SignatureError(
|
||||
"trusted publishers must use simple Ed25519 entries"
|
||||
)
|
||||
principals = fields[0].split(",")
|
||||
if not principals or any(
|
||||
not PUBLISHER_ID.fullmatch(principal)
|
||||
for principal in principals
|
||||
):
|
||||
raise SignatureError("trusted publisher principal is invalid")
|
||||
try:
|
||||
key_blob = base64.b64decode(fields[2], validate=True)
|
||||
except (ValueError, binascii.Error) as error:
|
||||
raise SignatureError("trusted publisher key is invalid") from error
|
||||
if not 32 <= len(key_blob) <= 256:
|
||||
raise SignatureError("trusted publisher key is invalid")
|
||||
key_count += 1
|
||||
if key_count > 64:
|
||||
raise SignatureError("trusted publisher file has too many keys")
|
||||
if key_count == 0:
|
||||
raise SignatureError("trusted publisher file contains no keys")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseSignature:
|
||||
publisher: str
|
||||
release_name: str
|
||||
release_sha256: str
|
||||
signature: bytes
|
||||
|
||||
def to_mapping(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SIGNATURE_SCHEMA_VERSION,
|
||||
"format": SIGNATURE_FORMAT,
|
||||
"namespace": SIGNATURE_NAMESPACE,
|
||||
"publisher": self.publisher,
|
||||
"release_name": self.release_name,
|
||||
"release_sha256": self.release_sha256,
|
||||
"signature_base64": base64.b64encode(self.signature).decode("ascii"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ReleaseSignature":
|
||||
expected = {
|
||||
"schema_version",
|
||||
"format",
|
||||
"namespace",
|
||||
"publisher",
|
||||
"release_name",
|
||||
"release_sha256",
|
||||
"signature_base64",
|
||||
}
|
||||
if set(value) != expected:
|
||||
raise SignatureError("invalid release signature fields")
|
||||
if value.get("schema_version") != SIGNATURE_SCHEMA_VERSION:
|
||||
raise SignatureError("unsupported release signature schema")
|
||||
if value.get("format") != SIGNATURE_FORMAT:
|
||||
raise SignatureError("unsupported release signature format")
|
||||
if value.get("namespace") != SIGNATURE_NAMESPACE:
|
||||
raise SignatureError("invalid release signature namespace")
|
||||
publisher = value.get("publisher")
|
||||
release_name = value.get("release_name")
|
||||
release_sha256 = value.get("release_sha256")
|
||||
encoded = value.get("signature_base64")
|
||||
if not isinstance(publisher, str) or not PUBLISHER_ID.fullmatch(publisher):
|
||||
raise SignatureError("publisher has an invalid format")
|
||||
if not isinstance(release_name, str) or not RELEASE_NAME.fullmatch(
|
||||
release_name
|
||||
):
|
||||
raise SignatureError("signed release name has an invalid format")
|
||||
if (
|
||||
not isinstance(release_sha256, str)
|
||||
or len(release_sha256) != 64
|
||||
or any(value not in "0123456789abcdef" for value in release_sha256)
|
||||
):
|
||||
raise SignatureError("signed release digest is invalid")
|
||||
if not isinstance(encoded, str) or len(encoded) > MAX_SIGNATURE_BYTES * 2:
|
||||
raise SignatureError("encoded release signature is invalid")
|
||||
try:
|
||||
signature = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, binascii.Error) as error:
|
||||
raise SignatureError("encoded release signature is invalid") from error
|
||||
if (
|
||||
not signature.startswith(SSH_SIGNATURE_HEADER)
|
||||
or SSH_SIGNATURE_FOOTER not in signature
|
||||
or len(signature) > MAX_SIGNATURE_BYTES
|
||||
):
|
||||
raise SignatureError("OpenSSH release signature is invalid")
|
||||
return cls(
|
||||
publisher=publisher,
|
||||
release_name=release_name,
|
||||
release_sha256=release_sha256,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
|
||||
def release_signature_bytes(signature: ReleaseSignature) -> bytes:
|
||||
return _canonical_json(signature.to_mapping()) + b"\n"
|
||||
|
||||
|
||||
def load_release_signature(path: Path) -> ReleaseSignature:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
value = json.loads(content.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SignatureError("cannot read release signature") from error
|
||||
if not isinstance(value, Mapping):
|
||||
raise SignatureError("release signature must be an object")
|
||||
signature = ReleaseSignature.from_mapping(value)
|
||||
if content != release_signature_bytes(signature):
|
||||
raise SignatureError("release signature record is not canonical")
|
||||
return signature
|
||||
|
||||
|
||||
def sign_release_record(
|
||||
release_path: Path,
|
||||
private_key: Path,
|
||||
publisher: str,
|
||||
*,
|
||||
artifact: Path | None = None,
|
||||
output: Path | None = None,
|
||||
ssh_keygen: Path = Path("/usr/bin/ssh-keygen"),
|
||||
) -> tuple[Path, ReleaseSignature]:
|
||||
if not PUBLISHER_ID.fullmatch(publisher):
|
||||
raise SignatureError("publisher has an invalid format")
|
||||
if not private_key.is_file():
|
||||
raise SignatureError("publisher private key is unavailable")
|
||||
record, release_bytes = _read_canonical_release(release_path)
|
||||
expected_release_name = _release_name(record)
|
||||
if release_path.name != expected_release_name:
|
||||
raise SignatureError("release record name does not match its artifact")
|
||||
verify_release(
|
||||
artifact or release_path.with_name(record.artifact_name),
|
||||
record,
|
||||
)
|
||||
result = _run_ssh_keygen(
|
||||
[
|
||||
"-Y",
|
||||
"sign",
|
||||
"-q",
|
||||
"-f",
|
||||
str(private_key),
|
||||
"-n",
|
||||
SIGNATURE_NAMESPACE,
|
||||
"-",
|
||||
],
|
||||
input_bytes=_signature_payload(
|
||||
publisher,
|
||||
expected_release_name,
|
||||
release_bytes,
|
||||
),
|
||||
ssh_keygen=ssh_keygen,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SignatureError("release signing failed")
|
||||
signature = ReleaseSignature(
|
||||
publisher=publisher,
|
||||
release_name=expected_release_name,
|
||||
release_sha256=_sha256(release_bytes),
|
||||
signature=result.stdout,
|
||||
)
|
||||
signature = ReleaseSignature.from_mapping(signature.to_mapping())
|
||||
output = output or _signature_path(release_path)
|
||||
_publish_immutable(release_signature_bytes(signature), output)
|
||||
return output, signature
|
||||
|
||||
|
||||
def verify_release_signature(
|
||||
release_path: Path,
|
||||
signature_path: Path,
|
||||
allowed_signers: Path,
|
||||
*,
|
||||
expected_publisher: str | None = None,
|
||||
ssh_keygen: Path = Path("/usr/bin/ssh-keygen"),
|
||||
) -> ReleaseSignature:
|
||||
record, release_bytes = _read_canonical_release(release_path)
|
||||
signature = load_release_signature(signature_path)
|
||||
if expected_publisher is not None and signature.publisher != expected_publisher:
|
||||
raise SignatureError("release publisher does not match policy")
|
||||
if signature.release_name != _release_name(record):
|
||||
raise SignatureError("signature references a different release record")
|
||||
if signature.release_sha256 != _sha256(release_bytes):
|
||||
raise SignatureError("signed release digest mismatch")
|
||||
if not allowed_signers.is_file():
|
||||
raise SignatureError("trusted publisher file is invalid")
|
||||
_validate_allowed_signers(allowed_signers)
|
||||
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".jwebot-release-signature.",
|
||||
suffix=".sshsig",
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(signature.signature)
|
||||
result = _run_ssh_keygen(
|
||||
[
|
||||
"-Y",
|
||||
"verify",
|
||||
"-q",
|
||||
"-f",
|
||||
str(allowed_signers),
|
||||
"-I",
|
||||
signature.publisher,
|
||||
"-n",
|
||||
SIGNATURE_NAMESPACE,
|
||||
"-s",
|
||||
str(temporary),
|
||||
],
|
||||
input_bytes=_signature_payload(
|
||||
signature.publisher,
|
||||
signature.release_name,
|
||||
release_bytes,
|
||||
),
|
||||
ssh_keygen=ssh_keygen,
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
raise SignatureError("release signature verification failed")
|
||||
return signature
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Static preflight policy for reviewed extension source trees."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .manifest import PluginManifest
|
||||
|
||||
|
||||
MAX_SOURCE_FILES = 64
|
||||
MAX_SOURCE_BYTES = 512 * 1024
|
||||
ALLOWED_STDLIB_IMPORTS = frozenset(
|
||||
{
|
||||
"collections",
|
||||
"dataclasses",
|
||||
"datetime",
|
||||
"decimal",
|
||||
"enum",
|
||||
"functools",
|
||||
"itertools",
|
||||
"math",
|
||||
"operator",
|
||||
"re",
|
||||
"statistics",
|
||||
"string",
|
||||
"typing",
|
||||
}
|
||||
)
|
||||
FORBIDDEN_CALLS = frozenset(
|
||||
{
|
||||
"__import__",
|
||||
"breakpoint",
|
||||
"compile",
|
||||
"delattr",
|
||||
"eval",
|
||||
"exec",
|
||||
"getattr",
|
||||
"globals",
|
||||
"input",
|
||||
"locals",
|
||||
"open",
|
||||
"setattr",
|
||||
"vars",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyFinding:
|
||||
path: str
|
||||
line: int
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class SourcePolicyError(ValueError):
|
||||
def __init__(self, findings: tuple[PolicyFinding, ...]) -> None:
|
||||
self.findings = findings
|
||||
summary = "; ".join(
|
||||
f"{finding.path}:{finding.line} {finding.code} {finding.message}"
|
||||
for finding in findings[:5]
|
||||
)
|
||||
super().__init__(summary or "extension source policy failed")
|
||||
|
||||
|
||||
class _PolicyVisitor(ast.NodeVisitor):
|
||||
def __init__(self, path: str, local_module: str) -> None:
|
||||
self.path = path
|
||||
self.local_module = local_module
|
||||
self.findings: list[PolicyFinding] = []
|
||||
|
||||
def add(self, node: ast.AST, code: str, message: str) -> None:
|
||||
self.findings.append(
|
||||
PolicyFinding(
|
||||
path=self.path,
|
||||
line=getattr(node, "lineno", 1),
|
||||
code=code,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
def import_allowed(self, module: str) -> bool:
|
||||
top_level = module.partition(".")[0]
|
||||
return (
|
||||
top_level == "jwebot_sdk"
|
||||
or top_level == self.local_module
|
||||
or top_level in ALLOWED_STDLIB_IMPORTS
|
||||
)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
if not self.import_allowed(alias.name):
|
||||
self.add(
|
||||
node,
|
||||
"JWP001",
|
||||
f"import is not allowed: {alias.name}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
if node.level == 0 and not self.import_allowed(node.module or ""):
|
||||
self.add(
|
||||
node,
|
||||
"JWP001",
|
||||
f"import is not allowed: {node.module or '<unknown>'}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_CALLS:
|
||||
self.add(
|
||||
node,
|
||||
"JWP002",
|
||||
f"dynamic or privileged builtin is not allowed: {node.func.id}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Attribute(self, node: ast.Attribute) -> None:
|
||||
if node.attr.startswith("__") and node.attr.endswith("__"):
|
||||
self.add(
|
||||
node,
|
||||
"JWP003",
|
||||
f"dunder attribute access is not allowed: {node.attr}",
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def validate_source_tree(
|
||||
root: Path,
|
||||
manifest: PluginManifest,
|
||||
) -> tuple[Path, ...]:
|
||||
source_root = root / "src"
|
||||
if not source_root.is_dir() or source_root.is_symlink():
|
||||
raise SourcePolicyError(
|
||||
(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP004",
|
||||
message="src must be a real directory",
|
||||
),
|
||||
)
|
||||
)
|
||||
paths = sorted(source_root.rglob("*.py"))
|
||||
findings: list[PolicyFinding] = []
|
||||
if not paths:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP004",
|
||||
message="extension contains no Python source",
|
||||
)
|
||||
)
|
||||
if len(paths) > MAX_SOURCE_FILES:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP005",
|
||||
message=f"extension exceeds {MAX_SOURCE_FILES} source files",
|
||||
)
|
||||
)
|
||||
|
||||
local_module = manifest.entrypoint.partition(":")[0].partition(".")[0]
|
||||
total_bytes = 0
|
||||
for path in paths:
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path=relative,
|
||||
line=1,
|
||||
code="JWP006",
|
||||
message="source symlinks and non-regular files are forbidden",
|
||||
)
|
||||
)
|
||||
continue
|
||||
raw = path.read_bytes()
|
||||
total_bytes += len(raw)
|
||||
try:
|
||||
source = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path=relative,
|
||||
line=1,
|
||||
code="JWP007",
|
||||
message="source must be UTF-8",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(source, filename=relative)
|
||||
except SyntaxError as error:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path=relative,
|
||||
line=error.lineno or 1,
|
||||
code="JWP008",
|
||||
message="source contains invalid Python syntax",
|
||||
)
|
||||
)
|
||||
continue
|
||||
visitor = _PolicyVisitor(relative, local_module)
|
||||
visitor.visit(tree)
|
||||
findings.extend(visitor.findings)
|
||||
|
||||
if total_bytes > MAX_SOURCE_BYTES:
|
||||
findings.append(
|
||||
PolicyFinding(
|
||||
path="src",
|
||||
line=1,
|
||||
code="JWP009",
|
||||
message=f"source exceeds {MAX_SOURCE_BYTES} bytes",
|
||||
)
|
||||
)
|
||||
if findings:
|
||||
raise SourcePolicyError(tuple(findings))
|
||||
return tuple(paths)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Privacy-safe structured runtime telemetry for latency and failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Union
|
||||
|
||||
|
||||
TELEMETRY_SCHEMA_VERSION = 1
|
||||
EVENT_NAME = re.compile(r"^[a-z][a-z0-9_.-]{2,79}$")
|
||||
FIELD_NAME = re.compile(r"^[a-z][a-z0-9_]{1,39}$")
|
||||
ALLOWED_FIELDS = frozenset(
|
||||
{
|
||||
"ack_kind",
|
||||
"attempts",
|
||||
"bytes",
|
||||
"chars",
|
||||
"count",
|
||||
"confirmed_by",
|
||||
"delivered",
|
||||
"depth",
|
||||
"duplicate",
|
||||
"error_type",
|
||||
"execution_path",
|
||||
"fallback",
|
||||
"flag",
|
||||
"flag_wait_ms",
|
||||
"height",
|
||||
"mode",
|
||||
"operation",
|
||||
"outcome",
|
||||
"phase",
|
||||
"plugin_id",
|
||||
"pixels",
|
||||
"polls",
|
||||
"process",
|
||||
"queue",
|
||||
"queue_state",
|
||||
"reason",
|
||||
"roi",
|
||||
"route",
|
||||
"source",
|
||||
"threshold",
|
||||
"timeout_ms",
|
||||
"transport",
|
||||
"worker",
|
||||
"width",
|
||||
"x",
|
||||
"y",
|
||||
}
|
||||
)
|
||||
Scalar = Union[str, int, float, bool, None]
|
||||
|
||||
|
||||
def hash_identifier(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if not text:
|
||||
return ""
|
||||
return hashlib.sha256(
|
||||
text.encode("utf-8", "replace")
|
||||
).hexdigest()[:12]
|
||||
|
||||
|
||||
class RuntimeTelemetry:
|
||||
"""Emit bounded JSON logs and retain low-cardinality process aggregates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
component: str,
|
||||
enabled: bool = True,
|
||||
observer: Callable[[Mapping[str, Scalar]], None] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.component = component[:40]
|
||||
self.enabled = enabled
|
||||
self.observer = observer
|
||||
self.logger = logger or logging.getLogger("jwebot.telemetry")
|
||||
self.monotonic = monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._counts: dict[str, int] = {}
|
||||
self._latencies: dict[str, dict[str, float]] = {}
|
||||
|
||||
def emit(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
status: str = "ok",
|
||||
elapsed_ms: float | None = None,
|
||||
event_id: object = "",
|
||||
group_id: object = "",
|
||||
**fields: Scalar,
|
||||
) -> Mapping[str, Scalar]:
|
||||
if not self.enabled:
|
||||
return MappingProxyType({})
|
||||
safe_name = name if EVENT_NAME.fullmatch(name) else "telemetry.invalid"
|
||||
safe_status = (
|
||||
status[:24]
|
||||
if isinstance(status, str) and status
|
||||
else "unknown"
|
||||
)
|
||||
value: dict[str, Scalar] = {
|
||||
"schema_version": TELEMETRY_SCHEMA_VERSION,
|
||||
"at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"name": safe_name,
|
||||
"status": safe_status,
|
||||
"component": self.component,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
event_hash = hash_identifier(event_id)
|
||||
group_hash = hash_identifier(group_id)
|
||||
if event_hash:
|
||||
value["event_hash"] = event_hash
|
||||
if group_hash:
|
||||
value["group_hash"] = group_hash
|
||||
if elapsed_ms is not None:
|
||||
value["elapsed_ms"] = round(max(0.0, float(elapsed_ms)), 3)
|
||||
for key, item in fields.items():
|
||||
if (
|
||||
key in ALLOWED_FIELDS
|
||||
and FIELD_NAME.fullmatch(key)
|
||||
and isinstance(item, (str, int, float, bool, type(None)))
|
||||
):
|
||||
value[key] = item[:120] if isinstance(item, str) else item
|
||||
metric_key = f"{safe_name}:{safe_status}"
|
||||
with self._lock:
|
||||
self._counts[metric_key] = self._counts.get(metric_key, 0) + 1
|
||||
if elapsed_ms is not None:
|
||||
latency = self._latencies.setdefault(
|
||||
safe_name,
|
||||
{"count": 0.0, "total_ms": 0.0, "max_ms": 0.0},
|
||||
)
|
||||
latency["count"] += 1
|
||||
latency["total_ms"] += max(0.0, float(elapsed_ms))
|
||||
latency["max_ms"] = max(
|
||||
latency["max_ms"],
|
||||
max(0.0, float(elapsed_ms)),
|
||||
)
|
||||
try:
|
||||
self.logger.info(
|
||||
"telemetry %s",
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if self.observer is not None:
|
||||
try:
|
||||
self.observer(MappingProxyType(dict(value)))
|
||||
except Exception:
|
||||
pass
|
||||
return MappingProxyType(value)
|
||||
|
||||
def elapsed(
|
||||
self,
|
||||
name: str,
|
||||
started: float,
|
||||
**kwargs: Any,
|
||||
) -> Mapping[str, Scalar]:
|
||||
return self.emit(
|
||||
name,
|
||||
elapsed_ms=(self.monotonic() - started) * 1000,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def span(
|
||||
self,
|
||||
name: str,
|
||||
**fields: Scalar,
|
||||
) -> Iterator[None]:
|
||||
started = self.monotonic()
|
||||
try:
|
||||
yield
|
||||
except BaseException as error:
|
||||
self.elapsed(
|
||||
name,
|
||||
started,
|
||||
status="error",
|
||||
error_type=type(error).__name__,
|
||||
**fields,
|
||||
)
|
||||
raise
|
||||
else:
|
||||
self.elapsed(name, started, **fields)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
counts = dict(self._counts)
|
||||
latencies = {
|
||||
name: {
|
||||
"count": int(value["count"]),
|
||||
"average_ms": round(
|
||||
value["total_ms"] / value["count"],
|
||||
3,
|
||||
),
|
||||
"max_ms": round(value["max_ms"], 3),
|
||||
}
|
||||
for name, value in self._latencies.items()
|
||||
if value["count"]
|
||||
}
|
||||
return {
|
||||
"schema_version": TELEMETRY_SCHEMA_VERSION,
|
||||
"counts": counts,
|
||||
"latencies": latencies,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-official-extensions"
|
||||
version = "0.6.0a0+1015"
|
||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.0a0+1015",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Reviewed JWeBot local command adapters and broker implementation."""
|
||||
|
||||
from .descriptors import (
|
||||
DIRECT_KERNEL_COMMANDS,
|
||||
DIRECT_KERNEL_COMMAND_KINDS,
|
||||
JWEBOT_AUTHOR,
|
||||
LOCAL_COMMAND_ALIASES,
|
||||
LOCAL_EXTENSIONS,
|
||||
OFFICIAL_AUTHOR,
|
||||
KernelCommandDescriptor,
|
||||
LocalExtensionDescriptor,
|
||||
extension_for_kind,
|
||||
render_feature_list,
|
||||
)
|
||||
from .local_commands import (
|
||||
DICE_CHALLENGE_TIMEOUT_SECONDS,
|
||||
LOCAL_GAME_TIMEOUT_SECONDS,
|
||||
MAX_RPS_PLAYERS,
|
||||
LocalCommandEngine,
|
||||
LocalCommandError,
|
||||
deterministic_int,
|
||||
local_command_parts,
|
||||
)
|
||||
from .runtime import (
|
||||
LocalExtensionError,
|
||||
LocalExtensionRuntime,
|
||||
LocalServiceBroker,
|
||||
local_extension_command_token,
|
||||
)
|
||||
from .steam import (
|
||||
DEFAULT_STEAM_MIHOMO_PROXY_URL,
|
||||
STEAM_API_BASE_URL,
|
||||
SteamClient,
|
||||
SteamUnavailable,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DICE_CHALLENGE_TIMEOUT_SECONDS",
|
||||
"DEFAULT_STEAM_MIHOMO_PROXY_URL",
|
||||
"DIRECT_KERNEL_COMMANDS",
|
||||
"DIRECT_KERNEL_COMMAND_KINDS",
|
||||
"JWEBOT_AUTHOR",
|
||||
"LOCAL_COMMAND_ALIASES",
|
||||
"LOCAL_EXTENSIONS",
|
||||
"LOCAL_GAME_TIMEOUT_SECONDS",
|
||||
"MAX_RPS_PLAYERS",
|
||||
"OFFICIAL_AUTHOR",
|
||||
"KernelCommandDescriptor",
|
||||
"LocalExtensionDescriptor",
|
||||
"LocalExtensionError",
|
||||
"LocalExtensionRuntime",
|
||||
"LocalServiceBroker",
|
||||
"STEAM_API_BASE_URL",
|
||||
"SteamClient",
|
||||
"SteamUnavailable",
|
||||
"LocalCommandEngine",
|
||||
"LocalCommandError",
|
||||
"deterministic_int",
|
||||
"local_command_parts",
|
||||
"local_extension_command_token",
|
||||
"extension_for_kind",
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "0.6.0a0+1015"
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
"""Generated official SDK command adapters."""
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for coin."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'coin'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '抛一枚硬币'
|
||||
HELP_ENTRIES = ('/翻硬币',)
|
||||
COMMANDS = ('/翻硬币',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for daily-fortune."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'daily-fortune'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '生成当天稳定一致的运势'
|
||||
HELP_ENTRIES = ('/今日运势',)
|
||||
COMMANDS = ('/今日运势',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for debug-telemetry."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'debug-telemetry'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '埋点 p50/p95'
|
||||
HELP_ENTRIES = ('/debug',)
|
||||
COMMANDS = ('/debug',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for dice."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'dice'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '骰子和多人骰子挑战'
|
||||
HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]')
|
||||
COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for draw-lot."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'draw-lot'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '抽取一条本地签文'
|
||||
HELP_ENTRIES = ('/抽签',)
|
||||
COMMANDS = ('/抽签',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for guess-number."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'guess-number'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '群内持久化猜数字游戏'
|
||||
HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',)
|
||||
COMMANDS = ('/猜数字', '/猜')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for history-search."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'history-search'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '搜索本地群聊文字记录'
|
||||
HELP_ENTRIES = ('/查记录 关键词',)
|
||||
COMMANDS = ('/查记录', '/历史搜索')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for jwebot-version."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'jwebot-version'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = 'JWeBot 版本'
|
||||
HELP_ENTRIES = ('/version',)
|
||||
COMMANDS = ('/version',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for person-memory."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'person-memory'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '管理个人和同群人物记忆'
|
||||
HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',)
|
||||
COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for random-number."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'random-number'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '在指定整数范围内取随机数'
|
||||
HELP_ENTRIES = ('/随机数 最小值 最大值',)
|
||||
COMMANDS = ('/随机数',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for repeat-ranking."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'repeat-ranking'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '查看北京时间今日复读排行'
|
||||
HELP_ENTRIES = ('/复读榜',)
|
||||
COMMANDS = ('/复读榜', '/复读')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for rock-paper-scissors."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'rock-paper-scissors'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '多人石头剪刀布游戏'
|
||||
HELP_ENTRIES = ('/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]',)
|
||||
COMMANDS = ('/石头剪刀布',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for scan-ranking."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'scan-ranking'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = '查看北京时间今日扫码排行'
|
||||
HELP_ENTRIES = ('/扫码排行',)
|
||||
COMMANDS = ('/扫码排行', '/扫码榜', '/今日扫码')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for server-status."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'server-status'
|
||||
AUTHOR = 'official'
|
||||
DESCRIPTION = '服务器状态'
|
||||
HELP_ENTRIES = ('/状态;/status',)
|
||||
COMMANDS = ('/状态', '/status')
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""Generated reviewed adapter for steam-presence."""
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||
|
||||
|
||||
PROFILE = 'steam-presence'
|
||||
AUTHOR = 'jwebot'
|
||||
DESCRIPTION = 'Steam 状态与时长'
|
||||
HELP_ENTRIES = ('/steam;/steam时长 游戏[、玩家]',)
|
||||
COMMANDS = ('/steam',)
|
||||
|
||||
|
||||
def _handler(context: SingleGroupContext, command: str):
|
||||
def handle(event: Event, arguments: str) -> ExtensionResult:
|
||||
response = context.services.call(
|
||||
PROFILE,
|
||||
operation="execute",
|
||||
payload={
|
||||
"command": command,
|
||||
"arguments": arguments,
|
||||
"event": event.to_mapping(),
|
||||
},
|
||||
)
|
||||
text = response.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("local service returned invalid text")
|
||||
return ExtensionResult(
|
||||
reply=ReplyPlan.text(text),
|
||||
metadata={"local_extension": PROFILE, "author": AUTHOR},
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def plugin(context: SingleGroupContext) -> None:
|
||||
for command in COMMANDS:
|
||||
context.commands.register(command, _handler(context, command))
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Ownership map for Kernel commands and reviewed local extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
LOCAL_COMMAND_ALIASES = {
|
||||
"功能列表": "feature_list",
|
||||
"帮助": "feature_list",
|
||||
"help": "feature_list",
|
||||
"丢骰子": "dice",
|
||||
"骰子": "dice",
|
||||
"丢骰子挑战": "dice_challenge",
|
||||
"骰子挑战": "dice_challenge",
|
||||
"翻硬币": "coin",
|
||||
"随机数": "random_number",
|
||||
"抽签": "draw_lot",
|
||||
"今日运势": "daily_fortune",
|
||||
"猜数字": "guess_number",
|
||||
"猜": "guess_number",
|
||||
"石头剪刀布": "rps",
|
||||
"记住": "remember",
|
||||
"我的记忆": "my_memory",
|
||||
"忘记": "forget_memory",
|
||||
"人物记忆": "person_memory",
|
||||
"记忆帮助": "memory_help",
|
||||
"扫码排行": "scan_ranking",
|
||||
"扫码榜": "scan_ranking",
|
||||
"今日扫码": "scan_ranking",
|
||||
"复读榜": "repeat_ranking",
|
||||
"复读": "repeat_ranking",
|
||||
"查记录": "history_search",
|
||||
"历史搜索": "history_search",
|
||||
"状态": "local_status",
|
||||
"status": "server_status",
|
||||
"version": "jwebot_version",
|
||||
"debug": "telemetry_debug",
|
||||
"余额": "deepseek_balance",
|
||||
"steam": "steam_friends",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalExtensionDescriptor:
|
||||
extension_id: str
|
||||
author: str
|
||||
description: str
|
||||
help_entries: tuple[str, ...]
|
||||
command_kinds: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
return self.author.casefold()
|
||||
|
||||
@property
|
||||
def commands(self) -> tuple[str, ...]:
|
||||
owned = set(self.command_kinds)
|
||||
return tuple(
|
||||
f"/{alias}"
|
||||
for alias, kind in LOCAL_COMMAND_ALIASES.items()
|
||||
if kind in owned
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KernelCommandDescriptor:
|
||||
description: str
|
||||
help_entries: tuple[str, ...]
|
||||
command_kinds: tuple[str, ...]
|
||||
|
||||
|
||||
OFFICIAL_AUTHOR = "official"
|
||||
JWEBOT_AUTHOR = "jwebot"
|
||||
DIRECT_KERNEL_COMMANDS = (
|
||||
KernelCommandDescriptor(
|
||||
description="查看由插件元数据自动生成的功能列表",
|
||||
help_entries=("/功能列表(别名 /帮助、/help)",),
|
||||
command_kinds=("feature_list",),
|
||||
),
|
||||
KernelCommandDescriptor(
|
||||
description="查看 DeepSeek 账户余额",
|
||||
help_entries=("/余额",),
|
||||
command_kinds=("deepseek_balance",),
|
||||
),
|
||||
)
|
||||
DIRECT_KERNEL_COMMAND_KINDS = frozenset(
|
||||
kind
|
||||
for descriptor in DIRECT_KERNEL_COMMANDS
|
||||
for kind in descriptor.command_kinds
|
||||
)
|
||||
|
||||
LOCAL_EXTENSIONS = (
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="person-memory",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="管理个人和同群人物记忆",
|
||||
help_entries=(
|
||||
"/记住 内容;/我的记忆;/忘记 关键词或全部;"
|
||||
"/人物记忆 [显示名]",
|
||||
),
|
||||
command_kinds=(
|
||||
"remember",
|
||||
"my_memory",
|
||||
"forget_memory",
|
||||
"person_memory",
|
||||
"memory_help",
|
||||
),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="server-status",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="服务器状态",
|
||||
help_entries=("/状态;/status",),
|
||||
command_kinds=("local_status", "server_status"),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="jwebot-version",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="JWeBot 版本",
|
||||
help_entries=("/version",),
|
||||
command_kinds=("jwebot_version",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="debug-telemetry",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
description="埋点 p50/p95",
|
||||
help_entries=("/debug",),
|
||||
command_kinds=("telemetry_debug",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="dice",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="骰子和多人骰子挑战",
|
||||
help_entries=(
|
||||
"/丢骰子 [NdM]",
|
||||
"/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]",
|
||||
),
|
||||
command_kinds=("dice", "dice_challenge"),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="coin",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="抛一枚硬币",
|
||||
help_entries=("/翻硬币",),
|
||||
command_kinds=("coin",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="random-number",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="在指定整数范围内取随机数",
|
||||
help_entries=("/随机数 最小值 最大值",),
|
||||
command_kinds=("random_number",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="draw-lot",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="抽取一条本地签文",
|
||||
help_entries=("/抽签",),
|
||||
command_kinds=("draw_lot",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="daily-fortune",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="生成当天稳定一致的运势",
|
||||
help_entries=("/今日运势",),
|
||||
command_kinds=("daily_fortune",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="guess-number",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="群内持久化猜数字游戏",
|
||||
help_entries=(
|
||||
"/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]",
|
||||
),
|
||||
command_kinds=("guess_number",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="rock-paper-scissors",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="多人石头剪刀布游戏",
|
||||
help_entries=(
|
||||
"/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]",
|
||||
),
|
||||
command_kinds=("rps",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="scan-ranking",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="查看北京时间今日扫码排行",
|
||||
help_entries=("/扫码排行",),
|
||||
command_kinds=("scan_ranking",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="repeat-ranking",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="查看北京时间今日复读排行",
|
||||
help_entries=("/复读榜",),
|
||||
command_kinds=("repeat_ranking",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="history-search",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="搜索本地群聊文字记录",
|
||||
help_entries=("/查记录 关键词",),
|
||||
command_kinds=("history_search",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="steam-presence",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="Steam 状态与时长",
|
||||
help_entries=(
|
||||
"/steam;/steam时长 游戏[、玩家]",
|
||||
),
|
||||
command_kinds=("steam_friends",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def extension_for_kind(kind: str) -> LocalExtensionDescriptor | None:
|
||||
return next(
|
||||
(
|
||||
extension
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
if kind in extension.command_kinds
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def render_feature_list() -> str:
|
||||
lines = ["功能列表(由内核和插件描述自动生成)"]
|
||||
for descriptor in DIRECT_KERNEL_COMMANDS:
|
||||
lines.append(
|
||||
f"[内核] {descriptor.description}:"
|
||||
+ ";".join(descriptor.help_entries)
|
||||
)
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
lines.append(
|
||||
f"[{descriptor.author}/{descriptor.extension_id}] "
|
||||
f"{descriptor.description}:"
|
||||
+ ";".join(descriptor.help_entries)
|
||||
)
|
||||
lines.extend(
|
||||
(
|
||||
"[DeepSeek] 普通 @JWeBot 消息走本地直连 DeepSeek",
|
||||
"[OpenClaw] @JWeBot #任务进入 OpenClaw",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _validate_inventory() -> None:
|
||||
extension_kinds = [
|
||||
kind
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
for kind in extension.command_kinds
|
||||
]
|
||||
if len(extension_kinds) != len(set(extension_kinds)):
|
||||
raise RuntimeError("local extension command ownership overlaps")
|
||||
known_kinds = set(LOCAL_COMMAND_ALIASES.values())
|
||||
if set(extension_kinds) | DIRECT_KERNEL_COMMAND_KINDS != known_kinds:
|
||||
raise RuntimeError("local command inventory is incomplete")
|
||||
if set(extension_kinds) & DIRECT_KERNEL_COMMAND_KINDS:
|
||||
raise RuntimeError("Kernel commands cannot be extension-owned")
|
||||
if any(
|
||||
not extension.description or not extension.help_entries
|
||||
for extension in LOCAL_EXTENSIONS
|
||||
):
|
||||
raise RuntimeError("local extensions must provide help metadata")
|
||||
|
||||
|
||||
_validate_inventory()
|
||||
@@ -0,0 +1,953 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Official local command implementations for the packaged Edge runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
||||
|
||||
|
||||
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
|
||||
DICE_CHALLENGE_TIMEOUT_SECONDS = 60 * 60
|
||||
MAX_RPS_PLAYERS = 8
|
||||
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
||||
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
||||
|
||||
|
||||
class LocalCommandError(RuntimeError):
|
||||
"""Controlled local-command failure safe for the Edge response boundary."""
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def deterministic_int(seed: str, lower: int, upper: int) -> int:
|
||||
if lower > upper:
|
||||
raise LocalCommandError("invalid deterministic range")
|
||||
value = int.from_bytes(
|
||||
hashlib.sha256(seed.encode("utf-8", "replace")).digest()[:8],
|
||||
"big",
|
||||
)
|
||||
return lower + value % (upper - lower + 1)
|
||||
|
||||
|
||||
def local_command_parts(question: str) -> tuple[str, str]:
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
return "", ""
|
||||
raw = normalized[1:].lstrip()
|
||||
folded = raw.casefold()
|
||||
for command, kind in sorted(
|
||||
LOCAL_COMMAND_ALIASES.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
command_key = command.casefold()
|
||||
if folded == command_key:
|
||||
return kind, ""
|
||||
if folded.startswith(command_key):
|
||||
return kind, raw[len(command):].strip()
|
||||
command, _, arguments = raw.partition(" ")
|
||||
return LOCAL_COMMAND_ALIASES.get(command.casefold(), ""), arguments.strip()
|
||||
|
||||
|
||||
class LocalCommandEngine:
|
||||
"""Deterministic group mini-games backed by the local edge ledger."""
|
||||
|
||||
LOTS = (
|
||||
"今天适合开一局,别在大厅里演了喵",
|
||||
"抽到普通签,稳住节奏比整活更重要喵",
|
||||
"小吉,喊人失败就先自己排一把喵",
|
||||
"中吉,适合清理积压的小任务喵",
|
||||
"大吉,今天的手感看起来不太像会背刺你喵",
|
||||
"末吉,先喝口水,别急着和队友对线喵",
|
||||
"吉,能做完一点就是赚到一点喵",
|
||||
"平签,今天适合安静发育,少折腾喵",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ledger: Any,
|
||||
config: Any | None = None,
|
||||
*,
|
||||
steam: Any | None = None,
|
||||
balance_provider: Callable[[], list[dict[str, str]]] | None = None,
|
||||
reply_cleaner: Callable[[object], str],
|
||||
person_display_label: Callable[[object], str],
|
||||
profile_fact_is_safe: Callable[[object], bool],
|
||||
profile_expiry: Callable[..., float],
|
||||
) -> None:
|
||||
self.ledger = ledger
|
||||
self.config = config
|
||||
self.steam = steam
|
||||
self.balance_provider = balance_provider
|
||||
self.reply_cleaner = reply_cleaner
|
||||
self.person_display_label = person_display_label
|
||||
self.profile_fact_is_safe = profile_fact_is_safe
|
||||
self.profile_expiry = profile_expiry
|
||||
|
||||
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
question = str(row["question"])
|
||||
kind, arguments = local_command_parts(question)
|
||||
if not kind:
|
||||
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
|
||||
if kind == "feature_list":
|
||||
return self.reply_cleaner(self.feature_list())
|
||||
if kind == "dice":
|
||||
return self.reply_cleaner(self.dice(row, arguments))
|
||||
if kind == "dice_challenge":
|
||||
return self.reply_cleaner(self.dice_challenge(row, arguments))
|
||||
if kind == "coin":
|
||||
return self.reply_cleaner(self.coin(row))
|
||||
if kind == "random_number":
|
||||
return self.reply_cleaner(self.random_number(row, arguments))
|
||||
if kind == "draw_lot":
|
||||
return self.reply_cleaner(self.draw_lot(row))
|
||||
if kind == "daily_fortune":
|
||||
return self.reply_cleaner(self.daily_fortune(row, event))
|
||||
if kind == "guess_number":
|
||||
return self.reply_cleaner(self.guess_number(row, arguments))
|
||||
if kind == "rps":
|
||||
return self.reply_cleaner(self.rps(row, arguments))
|
||||
if kind == "remember":
|
||||
return self.reply_cleaner(self.remember(row, arguments))
|
||||
if kind == "my_memory":
|
||||
return self.reply_cleaner(self.show_memory(row, ""))
|
||||
if kind == "forget_memory":
|
||||
return self.reply_cleaner(self.forget_memory(row, arguments))
|
||||
if kind == "person_memory":
|
||||
return self.reply_cleaner(self.show_memory(row, arguments))
|
||||
if kind == "memory_help":
|
||||
return self.reply_cleaner(self.memory_help())
|
||||
if kind == "scan_ranking":
|
||||
return self.reply_cleaner(self.scan_ranking(row))
|
||||
if kind == "repeat_ranking":
|
||||
return self.reply_cleaner(self.repeat_ranking(row))
|
||||
if kind == "history_search":
|
||||
return self.reply_cleaner(self.history_search(row, arguments))
|
||||
if kind in {"local_status", "server_status"}:
|
||||
return self.reply_cleaner(self.server_status())
|
||||
if kind == "jwebot_version":
|
||||
return self.reply_cleaner(self.jwebot_version())
|
||||
if kind == "telemetry_debug":
|
||||
return self.reply_cleaner(self.telemetry_debug())
|
||||
if kind == "deepseek_balance":
|
||||
return self.reply_cleaner(self.deepseek_balance())
|
||||
if kind == "steam_friends":
|
||||
return self.reply_cleaner(self.steam_friends(arguments))
|
||||
raise LocalCommandError("unsupported local command")
|
||||
|
||||
@staticmethod
|
||||
def feature_list() -> str:
|
||||
return render_feature_list()
|
||||
|
||||
@staticmethod
|
||||
def seed(row: dict[str, Any], label: str) -> str:
|
||||
return f"{row['group_fingerprint']}|{row['event_id']}|{label}"
|
||||
|
||||
@staticmethod
|
||||
def player_key(row: dict[str, Any]) -> str:
|
||||
return sha256(str(row["sender"]))[:16]
|
||||
|
||||
@staticmethod
|
||||
def memory_help() -> str:
|
||||
return (
|
||||
"记忆命令:/记住 内容 会立刻保存,/我的记忆 查看自己,"
|
||||
"/人物记忆 显示名 查看同群非敏感摘要,"
|
||||
"/忘记 关键词 或 /忘记 全部 可以清理自己的记忆"
|
||||
)
|
||||
|
||||
def scan_ranking(self, row: dict[str, Any]) -> str:
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.clock_scan_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
local_date,
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有已确认送达的扫码记录"
|
||||
listing = ";".join(
|
||||
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
||||
for index, entry in enumerate(ranking, start=1)
|
||||
)
|
||||
return f"北京时间今日扫码排行:{listing}"
|
||||
|
||||
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.repeat_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
local_date,
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有可统计的复读"
|
||||
lines = []
|
||||
for index, entry in enumerate(ranking, start=1):
|
||||
repeaters = "、".join(
|
||||
f"{name}×{count}"
|
||||
for name, count in sorted(entry["repeaters"].items())
|
||||
)
|
||||
lines.append(
|
||||
f"{index}. “{entry['text'][:48]}” 首发 {entry['origin']},"
|
||||
f"后续复读 {entry['repeat_count']} 次({repeaters})"
|
||||
)
|
||||
return "北京时间今日复读榜:" + ";".join(lines)
|
||||
|
||||
def history_search(self, row: dict[str, Any], arguments: str) -> str:
|
||||
query = normalize_text(arguments)
|
||||
if len(query) < 2:
|
||||
return "用法:/查记录 关键词,至少两个字"
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
records = self.ledger.search_repeat_history(
|
||||
str(row["group_fingerprint"]),
|
||||
query,
|
||||
)
|
||||
if not records:
|
||||
return f"当前群没有找到“{query}”的本地历史记录"
|
||||
listing = ";".join(
|
||||
f"{record['observed_at'][5:16]} {record['display_name']}:"
|
||||
f"{record['text'][:80]}"
|
||||
for record in records
|
||||
)
|
||||
return f"本地记录命中“{query}”:{listing}"
|
||||
|
||||
@staticmethod
|
||||
def service_state(service_name: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "--user", "is-active", service_name],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unknown"
|
||||
return "active" if result.returncode == 0 else "inactive"
|
||||
|
||||
def local_status(self) -> str:
|
||||
state = self.ledger.status()
|
||||
edge_service = self.service_state("jwebot-edge.service")
|
||||
ocr_service = self.service_state("wechat-ocr-adaptive-recorder.service")
|
||||
direct_api = "已配置" if self.config and self.config.deepseek_key else "未配置"
|
||||
return (
|
||||
f"本地状态:Edge {edge_service},OCR {ocr_service},"
|
||||
f"Flash {direct_api};直聊待处理 {state['direct_chat_pending']},"
|
||||
f"云端待处理 {state['outbox_pending']},"
|
||||
f"本地游戏 {state['local_games']}"
|
||||
)
|
||||
|
||||
def server_status(self) -> str:
|
||||
return self.local_status()
|
||||
|
||||
@staticmethod
|
||||
def package_version(module_name: str) -> str:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return str(getattr(module, "__version__", "unknown"))
|
||||
|
||||
def jwebot_version(self) -> str:
|
||||
return (
|
||||
"JWeBot 版本:"
|
||||
f"kernel {self.package_version('jwebot_kernel')},"
|
||||
f"sdk {self.package_version('jwebot_sdk')},"
|
||||
"official "
|
||||
f"{self.package_version('jwebot_official_extensions')}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def percentile(values: list[float], percent: float) -> float:
|
||||
if not values:
|
||||
raise LocalCommandError("missing telemetry samples")
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
position = (len(ordered) - 1) * percent / 100
|
||||
lower = int(position)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
if lower == upper:
|
||||
return ordered[lower]
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (
|
||||
position - lower
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def telemetry_rows_from_text(text: str) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
if "telemetry " in line:
|
||||
line = line.split("telemetry ", 1)[1]
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
try:
|
||||
elapsed = float(value["elapsed_ms"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
name = str(value.get("name", ""))[:80]
|
||||
if not name:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"elapsed_ms": elapsed,
|
||||
"status": str(value.get("status", "unknown"))[:24],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def format_duration(milliseconds: float) -> str:
|
||||
if milliseconds >= 1000:
|
||||
return f"{milliseconds / 1000:.2f}s"
|
||||
return f"{milliseconds:.1f}ms"
|
||||
|
||||
@classmethod
|
||||
def render_telemetry_summary(cls, rows: list[dict[str, Any]]) -> str:
|
||||
values_by_name: dict[str, list[float]] = {}
|
||||
for row in rows:
|
||||
if row.get("status") != "ok":
|
||||
continue
|
||||
values_by_name.setdefault(str(row["name"]), []).append(
|
||||
float(row["elapsed_ms"])
|
||||
)
|
||||
if not values_by_name:
|
||||
return "调试埋点:最近没有可汇总的耗时样本"
|
||||
ranked = sorted(
|
||||
values_by_name.items(),
|
||||
key=lambda item: cls.percentile(item[1], 95),
|
||||
reverse=True,
|
||||
)[:8]
|
||||
lines = ["调试埋点 p50/p95(最近2小时)"]
|
||||
for name, values in ranked:
|
||||
lines.append(
|
||||
f"- {name}: n={len(values)},"
|
||||
f"p50={cls.format_duration(cls.percentile(values, 50))},"
|
||||
f"p95={cls.format_duration(cls.percentile(values, 95))},"
|
||||
f"max={cls.format_duration(max(values))}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def telemetry_debug(self) -> str:
|
||||
command = [
|
||||
"/usr/bin/journalctl",
|
||||
"--user",
|
||||
"-u",
|
||||
"jwebot-edge.service",
|
||||
"-u",
|
||||
"wechat-ocr-adaptive-recorder.service",
|
||||
"--since",
|
||||
"2 hours ago",
|
||||
"--no-pager",
|
||||
"-o",
|
||||
"cat",
|
||||
"-n",
|
||||
"2000",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=4,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "调试埋点:journal 当前不可用"
|
||||
if result.returncode != 0:
|
||||
return "调试埋点:journal 当前不可用"
|
||||
return self.render_telemetry_summary(
|
||||
self.telemetry_rows_from_text(result.stdout)
|
||||
)
|
||||
|
||||
def deepseek_balance(self) -> str:
|
||||
if self.config is None or self.balance_provider is None:
|
||||
return "余额查询当前不可用"
|
||||
balances = self.balance_provider()
|
||||
if not balances:
|
||||
return "DeepSeek 当前没有可用余额"
|
||||
listing = ";".join(
|
||||
f"{item['currency']} 总 {item['total_balance']},"
|
||||
f"充值 {item['topped_up_balance']},赠金 {item['granted_balance']}"
|
||||
for item in balances
|
||||
)
|
||||
return f"DeepSeek 余额:{listing}"
|
||||
|
||||
def steam_friends(self, arguments: str) -> str:
|
||||
if self.steam is None:
|
||||
return "Steam 查询当前不可用"
|
||||
return self.steam.render(arguments)
|
||||
|
||||
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
person = self.ledger.resolve_person(
|
||||
str(row["group_fingerprint"]),
|
||||
str(row["sender"]),
|
||||
)
|
||||
return self.ledger.person_profile(
|
||||
str(row["group_fingerprint"]),
|
||||
str(person["person_key"]),
|
||||
)
|
||||
|
||||
def remember(self, row: dict[str, Any], arguments: str) -> str:
|
||||
text = normalize_text(arguments)
|
||||
if not text:
|
||||
return "要记什么直接写在后面,例如 /记住 我更喜欢技术细节"
|
||||
if not self.profile_fact_is_safe(text):
|
||||
return "这类内容不适合做个人记忆,换成偏好、话题或未完结事项吧"
|
||||
group = str(row["group_fingerprint"])
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
profile = self.ledger.person_profile(group, person_key)
|
||||
facts = list(profile["facts"]) if profile else []
|
||||
normalized = text.casefold()
|
||||
matched = next(
|
||||
(fact for fact in facts if str(fact["text"]).casefold() == normalized),
|
||||
None,
|
||||
)
|
||||
now = utc_now()
|
||||
if matched is None:
|
||||
facts.append(
|
||||
{
|
||||
"text": text,
|
||||
"kind": "preference",
|
||||
"source": "explicit",
|
||||
"evidence_count": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"expires_at": self.profile_expiry("preference", explicit=True),
|
||||
}
|
||||
)
|
||||
else:
|
||||
matched["source"] = "explicit"
|
||||
matched["updated_at"] = now
|
||||
matched["expires_at"] = self.profile_expiry("preference", explicit=True)
|
||||
self.ledger.save_person_profile(
|
||||
group,
|
||||
person_key,
|
||||
str(person["display_name"]),
|
||||
facts,
|
||||
)
|
||||
return "记下了,这条只在当前群的同一身份作用域里生效"
|
||||
|
||||
def show_memory(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
requested = normalize_text(arguments)
|
||||
if requested:
|
||||
person = self.ledger.resolve_person(group, requested)
|
||||
profile = self.ledger.person_profile(
|
||||
group,
|
||||
str(person["person_key"]),
|
||||
)
|
||||
if profile is None:
|
||||
return "当前群没有这位显示名的可用记忆"
|
||||
else:
|
||||
profile = self.profile_for_row(row)
|
||||
if not profile or not profile["facts"]:
|
||||
return "暂时没有可用记忆,/记住 内容 可以直接补一条"
|
||||
facts = ";".join(
|
||||
f"[{fact.get('kind', 'topic')}] {fact['text']}"
|
||||
for fact in profile["facts"]
|
||||
)
|
||||
label = profile["display_name"]
|
||||
return f"{label} 的群内记忆:{facts}"
|
||||
|
||||
def forget_memory(self, row: dict[str, Any], arguments: str) -> str:
|
||||
query = normalize_text(arguments)
|
||||
if not query:
|
||||
return "用法:/忘记 关键词,或 /忘记 全部"
|
||||
group = str(row["group_fingerprint"])
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
profile = self.ledger.person_profile(group, person_key)
|
||||
if profile is None:
|
||||
return "没有可清理的个人记忆"
|
||||
if query == "全部":
|
||||
self.ledger.clear_person_profile(group, person_key)
|
||||
return "当前群内的个人记忆已清空"
|
||||
kept = [
|
||||
fact
|
||||
for fact in profile["facts"]
|
||||
if query.casefold() not in str(fact["text"]).casefold()
|
||||
]
|
||||
if len(kept) == len(profile["facts"]):
|
||||
return "没有找到匹配的记忆"
|
||||
if kept:
|
||||
self.ledger.save_person_profile(
|
||||
group,
|
||||
person_key,
|
||||
str(person["display_name"]),
|
||||
kept,
|
||||
)
|
||||
else:
|
||||
self.ledger.clear_person_profile(group, person_key)
|
||||
return "匹配的个人记忆已忘掉"
|
||||
|
||||
def dice(self, row: dict[str, Any], arguments: str) -> str:
|
||||
match = DICE_PATTERN.fullmatch(arguments)
|
||||
if not match:
|
||||
return "骰子格式不对,试试 /丢骰子 或 /丢骰子 2d6"
|
||||
count = int(match.group(1) or 1)
|
||||
faces = int(match.group(2) or 6)
|
||||
if count > 10 or not 2 <= faces <= 1_000:
|
||||
return "一次最多 10 个骰子,点数范围 2 到 1000,别拿骰盅砸服务器喵"
|
||||
rolls = [
|
||||
deterministic_int(self.seed(row, f"dice:{index}"), 1, faces)
|
||||
for index in range(count)
|
||||
]
|
||||
label = f"{count}d{faces}"
|
||||
if count == 1:
|
||||
return f"骰子 {label} 掷出了 {rolls[0]}"
|
||||
return f"骰子 {label}:{'、'.join(map(str, rolls))},合计 {sum(rolls)}"
|
||||
|
||||
@staticmethod
|
||||
def dice_challenge_spec(value: str) -> tuple[int, int] | None:
|
||||
match = DICE_PATTERN.fullmatch(value)
|
||||
if not match:
|
||||
return None
|
||||
count = int(match.group(1) or 1)
|
||||
faces = int(match.group(2) or 6)
|
||||
if count != 5 or not 6 <= faces <= 1_000:
|
||||
return None
|
||||
return count, faces
|
||||
|
||||
@staticmethod
|
||||
def is_five_straight(rolls: list[int]) -> bool:
|
||||
values = sorted(set(rolls))
|
||||
return len(values) == 5 and all(
|
||||
values[index] + 1 == values[index + 1]
|
||||
for index in range(4)
|
||||
)
|
||||
|
||||
def dice_challenge(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
state, expired = self.active_game(row, "dice_challenge")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
|
||||
if action == "状态":
|
||||
if state is None:
|
||||
return "现在没有丢骰子挑战,直接 /丢骰子挑战 开一局 5d6"
|
||||
entries = state.get("entries", {})
|
||||
if not isinstance(entries, dict) or not entries:
|
||||
return f"本局是 {state['dice_count']}d{state['faces']},还没人掷骰"
|
||||
ranking = sorted(
|
||||
(
|
||||
entry
|
||||
for entry in entries.values()
|
||||
if isinstance(entry, dict)
|
||||
),
|
||||
key=lambda entry: (-int(entry.get("total", 0)), str(entry.get("display_name", ""))),
|
||||
)
|
||||
listing = ";".join(
|
||||
f"{entry.get('display_name', 'unknown')} {entry.get('total', 0)}"
|
||||
for entry in ranking
|
||||
)
|
||||
return (
|
||||
f"本局 {state['dice_count']}d{state['faces']}:{listing}。"
|
||||
"继续 /丢骰子挑战 掷骰,或 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
if action == "战绩":
|
||||
achievements = self.ledger.dice_challenge_achievements(group)
|
||||
if not achievements:
|
||||
return "本群还没有丢骰子挑战达成记录"
|
||||
listing = ";".join(
|
||||
f"{entry['display_name']} [{entry['achievement']}] {entry['details']}"
|
||||
for entry in achievements
|
||||
)
|
||||
return f"丢骰子挑战战绩:{listing}"
|
||||
|
||||
if action in {"结算", "结束"}:
|
||||
if state is None:
|
||||
if expired:
|
||||
return "本局丢骰子挑战已超时,未结算点数不计战绩,重新开一局吧"
|
||||
return "没有进行中的丢骰子挑战"
|
||||
if action == "结束":
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
return "本局丢骰子挑战已结束,未结算的点数不计战绩"
|
||||
entries = state.get("entries", {})
|
||||
valid_entries = [
|
||||
entry
|
||||
for entry in entries.values()
|
||||
if isinstance(entry, dict)
|
||||
] if isinstance(entries, dict) else []
|
||||
if not valid_entries:
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
return "本局没人掷骰,挑战取消"
|
||||
highest = max(int(entry.get("total", 0)) for entry in valid_entries)
|
||||
winners = [
|
||||
entry
|
||||
for entry in valid_entries
|
||||
if int(entry.get("total", 0)) == highest
|
||||
]
|
||||
for winner in winners:
|
||||
self.ledger.record_dice_challenge_achievement(
|
||||
group_fingerprint=group,
|
||||
event_id=str(row["event_id"]),
|
||||
person_key=str(winner["person_key"]),
|
||||
display_name=str(winner["display_name"]),
|
||||
achievement="总点冠军",
|
||||
details=(
|
||||
f"{state['dice_count']}d{state['faces']} 合计 {highest}"
|
||||
),
|
||||
)
|
||||
ranking = sorted(
|
||||
valid_entries,
|
||||
key=lambda entry: (
|
||||
-int(entry.get("total", 0)),
|
||||
str(entry.get("display_name", "")),
|
||||
),
|
||||
)
|
||||
full_results = ";".join(
|
||||
(
|
||||
f"{index}. {entry['display_name']} {entry['total']} "
|
||||
f"({'、'.join(map(str, entry['rolls']))})"
|
||||
+ (" 五连号" if entry.get("five_straight") else "")
|
||||
)
|
||||
for index, entry in enumerate(ranking, start=1)
|
||||
)
|
||||
winner_names = "、".join(str(winner["display_name"]) for winner in winners)
|
||||
straights = [
|
||||
entry
|
||||
for entry in valid_entries
|
||||
if bool(entry.get("five_straight"))
|
||||
]
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
straight_note = (
|
||||
";五连号达成:" + "、".join(
|
||||
str(entry["display_name"]) for entry in straights
|
||||
)
|
||||
if straights
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"本局 {state['dice_count']}d{state['faces']} 结算:"
|
||||
f"{full_results}。冠军 {winner_names},{highest} 点{straight_note}"
|
||||
)
|
||||
|
||||
requested_spec = ""
|
||||
if action == "开始":
|
||||
requested_spec = parts[1] if len(parts) == 2 else "5d6"
|
||||
if state is not None:
|
||||
return (
|
||||
f"本局已经是 {state['dice_count']}d{state['faces']},"
|
||||
"直接 /丢骰子挑战 掷骰"
|
||||
)
|
||||
spec = self.dice_challenge_spec(requested_spec)
|
||||
if spec is None:
|
||||
return "挑战只支持 5d6 到 5d1000,例如 /丢骰子挑战5d20"
|
||||
state = self.touch_challenge(
|
||||
{
|
||||
"dice_count": spec[0],
|
||||
"faces": spec[1],
|
||||
"entries": {},
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "dice_challenge", state)
|
||||
return (
|
||||
f"丢骰子挑战开局:{state['dice_count']}d{state['faces']}。"
|
||||
"每人直接 /丢骰子挑战 掷骰,最后 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
if action in {"", "掷", "骰"}:
|
||||
requested_spec = ""
|
||||
else:
|
||||
requested_spec = action
|
||||
if state is None:
|
||||
spec = self.dice_challenge_spec(requested_spec or "5d6")
|
||||
if spec is None:
|
||||
if expired:
|
||||
return "上一局挑战已超时,直接 /丢骰子挑战 或 /丢骰子挑战5d20 重开"
|
||||
return "挑战只支持 5d6 到 5d1000,例如 /丢骰子挑战5d20"
|
||||
state = self.touch_challenge(
|
||||
{
|
||||
"dice_count": spec[0],
|
||||
"faces": spec[1],
|
||||
"entries": {},
|
||||
}
|
||||
)
|
||||
elif requested_spec:
|
||||
spec = self.dice_challenge_spec(requested_spec)
|
||||
if spec is None or spec != (int(state["dice_count"]), int(state["faces"])):
|
||||
return (
|
||||
f"本局固定为 {state['dice_count']}d{state['faces']},"
|
||||
"先结算或结束再换规则"
|
||||
)
|
||||
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
entries = state.setdefault("entries", {})
|
||||
if person_key in entries:
|
||||
return "你这局已经掷过了,等大家掷完后 /丢骰子挑战 结算"
|
||||
rolls = [
|
||||
deterministic_int(
|
||||
self.seed(row, f"dice-challenge:{index}"),
|
||||
1,
|
||||
int(state["faces"]),
|
||||
)
|
||||
for index in range(int(state["dice_count"]))
|
||||
]
|
||||
total = sum(rolls)
|
||||
straight = self.is_five_straight(rolls)
|
||||
entries[person_key] = {
|
||||
"person_key": person_key,
|
||||
"display_name": self.person_display_label(person["display_name"]),
|
||||
"rolls": rolls,
|
||||
"total": total,
|
||||
"five_straight": straight,
|
||||
}
|
||||
self.ledger.save_local_game(
|
||||
group,
|
||||
"dice_challenge",
|
||||
self.touch_challenge(state),
|
||||
)
|
||||
if straight:
|
||||
self.ledger.record_dice_challenge_achievement(
|
||||
group_fingerprint=group,
|
||||
event_id=str(row["event_id"]),
|
||||
person_key=person_key,
|
||||
display_name=self.person_display_label(person["display_name"]),
|
||||
achievement="五连号",
|
||||
details=(
|
||||
f"{state['dice_count']}d{state['faces']} "
|
||||
f"{'-'.join(map(str, sorted(rolls)))}"
|
||||
),
|
||||
)
|
||||
straight_note = ",五连号达成" if straight else ""
|
||||
return (
|
||||
f"{self.person_display_label(person['display_name'])} 挑战掷出 "
|
||||
f"{'、'.join(map(str, rolls))},合计 {total}{straight_note}。"
|
||||
"其他人继续 /丢骰子挑战,最后 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
def coin(self, row: dict[str, Any]) -> str:
|
||||
side = ("正面", "反面")[
|
||||
deterministic_int(self.seed(row, "coin"), 0, 1)
|
||||
]
|
||||
return f"硬币落地:{side}"
|
||||
|
||||
def random_number(self, row: dict[str, Any], arguments: str) -> str:
|
||||
match = RANDOM_NUMBER_PATTERN.fullmatch(arguments)
|
||||
if not match:
|
||||
return "用法:/随机数 最小值 最大值,例如 /随机数 1 100"
|
||||
lower, upper = int(match.group(1)), int(match.group(2))
|
||||
if lower > upper or upper - lower > 1_000_000:
|
||||
return "范围不对,最大跨度是 1000000,别让随机数跑马拉松喵"
|
||||
value = deterministic_int(self.seed(row, "random"), lower, upper)
|
||||
return f"随机数:{value}(范围 {lower} 到 {upper})"
|
||||
|
||||
def draw_lot(self, row: dict[str, Any]) -> str:
|
||||
value = self.LOTS[
|
||||
deterministic_int(self.seed(row, "draw-lot"), 0, len(self.LOTS) - 1)
|
||||
]
|
||||
return f"抽签结果:{value}"
|
||||
|
||||
def daily_fortune(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
observed_at = str(event.get("observed_at") or row["created_at"])
|
||||
day = observed_at[:10]
|
||||
score = deterministic_int(
|
||||
f"{row['group_fingerprint']}|{self.player_key(row)}|{day}|fortune",
|
||||
1,
|
||||
100,
|
||||
)
|
||||
if score >= 90:
|
||||
note = "今天手感在线,想做的事可以大胆开局"
|
||||
elif score >= 70:
|
||||
note = "节奏不错,别被小事带偏"
|
||||
elif score >= 40:
|
||||
note = "平稳发育就行,少和概率较劲"
|
||||
else:
|
||||
note = "先补水再开工,别硬扛"
|
||||
return f"今日运势 {score} 分:{note}"
|
||||
|
||||
def active_game(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
game_key: str,
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
state = self.ledger.local_game(str(row["group_fingerprint"]), game_key)
|
||||
if state is None:
|
||||
return None, False
|
||||
if float(state.get("expires_at", 0)) <= time.time():
|
||||
self.ledger.clear_local_game(str(row["group_fingerprint"]), game_key)
|
||||
return None, True
|
||||
return state, False
|
||||
|
||||
def touch(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state["expires_at"] = time.time() + LOCAL_GAME_TIMEOUT_SECONDS
|
||||
return state
|
||||
|
||||
def touch_challenge(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state["expires_at"] = time.time() + DICE_CHALLENGE_TIMEOUT_SECONDS
|
||||
return state
|
||||
|
||||
def guess_number(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
state, expired = self.active_game(row, "guess_number")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
if action == "开始":
|
||||
if state is not None:
|
||||
return (
|
||||
f"本群已经有一局猜数字,范围 1 到 {state['upper']},"
|
||||
"直接 /猜 数字 就行"
|
||||
)
|
||||
upper = 100
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
upper = int(parts[1])
|
||||
if not 10 <= upper <= 1_000:
|
||||
return "上限请设在 10 到 1000,别把群友扔进无尽数字海喵"
|
||||
state = self.touch(
|
||||
{
|
||||
"target": deterministic_int(
|
||||
self.seed(row, "guess-target"),
|
||||
1,
|
||||
upper,
|
||||
),
|
||||
"upper": upper,
|
||||
"attempts": 0,
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "guess_number", state)
|
||||
return f"猜数字开局,范围 1 到 {upper},用 /猜 数字 试试"
|
||||
if action == "状态":
|
||||
if state is None:
|
||||
return "现在没有猜数字局,先 /猜数字 开始"
|
||||
return f"猜数字还在继续,范围 1 到 {state['upper']},目前猜了 {state['attempts']} 次"
|
||||
if action == "结束":
|
||||
if state is None:
|
||||
return "没有进行中的猜数字局"
|
||||
self.ledger.clear_local_game(group, "guess_number")
|
||||
return f"本局结束,答案是 {state['target']},下次别被数字拿捏喵"
|
||||
|
||||
guess_text = ""
|
||||
if action == "猜" and len(parts) == 2:
|
||||
guess_text = parts[1]
|
||||
elif action.isdigit() and len(parts) == 1:
|
||||
guess_text = action
|
||||
if not guess_text:
|
||||
if expired:
|
||||
return "上一局猜数字已超时,先 /猜数字 开始"
|
||||
return "用 /猜 数字 下注,或者 /猜数字 开始 新开一局"
|
||||
if state is None:
|
||||
return "没有进行中的猜数字局,先 /猜数字 开始"
|
||||
guess = int(guess_text)
|
||||
upper = int(state["upper"])
|
||||
if not 1 <= guess <= upper:
|
||||
return f"只能猜 1 到 {upper},数字别离家出走喵"
|
||||
state["attempts"] = int(state["attempts"]) + 1
|
||||
if guess == int(state["target"]):
|
||||
attempts = state["attempts"]
|
||||
self.ledger.clear_local_game(group, "guess_number")
|
||||
return f"猜中了,答案就是 {guess},你用了 {attempts} 次,确实有点东西喵"
|
||||
self.ledger.save_local_game(group, "guess_number", self.touch(state))
|
||||
direction = "大了" if guess > int(state["target"]) else "小了"
|
||||
return f"{guess} {direction},继续猜"
|
||||
|
||||
def rps(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
player = self.player_key(row)
|
||||
state, expired = self.active_game(row, "rps")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
if action == "开始":
|
||||
if state is not None:
|
||||
return "石头剪刀布已经开着了,/石头剪刀布 加入 后再出招"
|
||||
state = self.touch(
|
||||
{
|
||||
"round": 1,
|
||||
"players": {player: True},
|
||||
"choices": {},
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "rps", state)
|
||||
return "石头剪刀布开局了,你已加入,还差至少一名玩家,/石头剪刀布 加入"
|
||||
if action == "结束":
|
||||
if state is None:
|
||||
return "没有进行中的石头剪刀布"
|
||||
self.ledger.clear_local_game(group, "rps")
|
||||
return "石头剪刀布已收摊,下局再来喵"
|
||||
if state is None:
|
||||
if expired:
|
||||
return "上一局石头剪刀布已超时,重新 /石头剪刀布 开始"
|
||||
return "先 /石头剪刀布 开始 开局"
|
||||
players = state.setdefault("players", {})
|
||||
choices = state.setdefault("choices", {})
|
||||
if action == "加入":
|
||||
if player in players:
|
||||
return "你已经在这局里了,别想卡双号喵"
|
||||
if len(players) >= MAX_RPS_PLAYERS:
|
||||
return f"本局最多 {MAX_RPS_PLAYERS} 人,观战席先坐会儿喵"
|
||||
players[player] = True
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return f"加入成功,目前 {len(players)} 人,/石头剪刀布 出 石头"
|
||||
if action == "状态":
|
||||
missing = max(0, len(players) - len(choices))
|
||||
return (
|
||||
f"第 {state['round']} 回合:{len(players)} 人参战,"
|
||||
f"{len(choices)} 人已出招,还差 {missing} 人"
|
||||
)
|
||||
if action == "结算":
|
||||
self.ledger.clear_local_game(group, "rps")
|
||||
return "这局手动结算为取消,下一局记得别挂机喵"
|
||||
|
||||
choice = ""
|
||||
if action == "出" and len(parts) == 2:
|
||||
choice = parts[1]
|
||||
elif action in {"石头", "剪刀", "布"}:
|
||||
choice = action
|
||||
if choice not in {"石头", "剪刀", "布"}:
|
||||
return "用 /石头剪刀布 出 石头、剪刀或布"
|
||||
if player not in players:
|
||||
return "先 /石头剪刀布 加入,再偷偷出招喵"
|
||||
choices[player] = choice
|
||||
if len(players) < 2:
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return "出招记下了,但还差一名玩家加入"
|
||||
if len(choices) < len(players):
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return f"出招记下了,还差 {len(players) - len(choices)} 人"
|
||||
outcome = self.resolve_rps(choices)
|
||||
state["round"] = int(state["round"]) + 1
|
||||
state["choices"] = {}
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def resolve_rps(choices: dict[str, str]) -> str:
|
||||
values = set(choices.values())
|
||||
if len(values) in {1, 3}:
|
||||
return "这一轮平局,大家默契得有点过头了,下一轮继续"
|
||||
winning_choice = next(
|
||||
winner
|
||||
for winner, loser in (("石头", "剪刀"), ("剪刀", "布"), ("布", "石头"))
|
||||
if winner in values and loser in values
|
||||
)
|
||||
winners = sum(choice == winning_choice for choice in choices.values())
|
||||
return f"这一轮 {winning_choice} 赢了,{winners} 名玩家拿分,下一轮继续"
|
||||
@@ -0,0 +1,167 @@
|
||||
"""In-process compatibility runtime for generated reviewed adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from jwebot_sdk import Event, ExtensionResult, PlanKind
|
||||
|
||||
from .descriptors import LOCAL_EXTENSIONS, extension_for_kind
|
||||
from .local_commands import (
|
||||
LOCAL_COMMAND_ALIASES,
|
||||
LocalCommandEngine,
|
||||
local_command_parts,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
class LocalExtensionError(ValueError):
|
||||
"""Controlled local adapter or broker contract failure."""
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self) -> None:
|
||||
self.handlers: dict[str, Callable[[Event, str], ExtensionResult]] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
command: str,
|
||||
handler: Callable[[Event, str], ExtensionResult],
|
||||
) -> None:
|
||||
key = command.casefold()
|
||||
if key in self.handlers:
|
||||
raise LocalExtensionError(f"duplicate local command: {command}")
|
||||
self.handlers[key] = handler
|
||||
|
||||
|
||||
class LocalServiceBroker:
|
||||
"""Kernel-side implementation of reviewed local service profiles."""
|
||||
|
||||
def __init__(self, engine: LocalCommandEngine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
def call(
|
||||
self,
|
||||
profile: str,
|
||||
*,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
if operation != "execute":
|
||||
raise LocalExtensionError("unsupported local service operation")
|
||||
command = payload.get("command")
|
||||
arguments = payload.get("arguments", "")
|
||||
event_value = payload.get("event")
|
||||
if (
|
||||
not isinstance(command, str)
|
||||
or not isinstance(arguments, str)
|
||||
or not isinstance(event_value, Mapping)
|
||||
):
|
||||
raise LocalExtensionError("invalid local service payload")
|
||||
kind, _ = local_command_parts(
|
||||
f"{command} {arguments}".rstrip()
|
||||
)
|
||||
owner = extension_for_kind(kind)
|
||||
if owner is None or owner.extension_id != profile:
|
||||
raise LocalExtensionError("local service profile mismatch")
|
||||
event = dict(event_value)
|
||||
row = {
|
||||
"event_id": str(event.get("event_id", "")),
|
||||
"group_fingerprint": str(event.get("group_fingerprint", "")),
|
||||
"sender": str(event.get("sender", "")),
|
||||
"question": f"{command} {arguments}".rstrip(),
|
||||
"created_at": str(event.get("observed_at") or utc_now()),
|
||||
}
|
||||
return {"text": self.engine.handle(row, event)}
|
||||
|
||||
|
||||
class _Context:
|
||||
def __init__(self, engine: LocalCommandEngine) -> None:
|
||||
self.commands = _Registry()
|
||||
self.services = LocalServiceBroker(engine)
|
||||
|
||||
|
||||
def local_extension_command_token(question: str) -> str:
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
raise LocalExtensionError("local command must start with slash")
|
||||
raw = normalized[1:]
|
||||
folded = raw.casefold()
|
||||
for alias in sorted(LOCAL_COMMAND_ALIASES, key=len, reverse=True):
|
||||
if (
|
||||
extension_for_kind(LOCAL_COMMAND_ALIASES[alias]) is not None
|
||||
and folded.startswith(alias.casefold())
|
||||
):
|
||||
return "/" + raw[: len(alias)]
|
||||
raise LocalExtensionError("extension command alias is unknown")
|
||||
|
||||
|
||||
class LocalExtensionRuntime:
|
||||
"""Load generated adapters once, then dispatch without mutable call state."""
|
||||
|
||||
def __init__(self, engine: LocalCommandEngine) -> None:
|
||||
context = _Context(engine)
|
||||
for descriptor in LOCAL_EXTENSIONS:
|
||||
module_name = descriptor.extension_id.replace("-", "_")
|
||||
module = importlib.import_module(
|
||||
f".adapters.{module_name}",
|
||||
package=__package__,
|
||||
)
|
||||
entrypoint = getattr(module, "plugin", None)
|
||||
if not callable(entrypoint):
|
||||
raise LocalExtensionError(
|
||||
f"local adapter has no entrypoint: {descriptor.extension_id}"
|
||||
)
|
||||
entrypoint(context)
|
||||
expected = {
|
||||
command.casefold()
|
||||
for descriptor in LOCAL_EXTENSIONS
|
||||
for command in descriptor.commands
|
||||
}
|
||||
if set(context.commands.handlers) != expected:
|
||||
raise LocalExtensionError(
|
||||
"local adapter registration does not match ownership inventory"
|
||||
)
|
||||
self._handlers = dict(context.commands.handlers)
|
||||
|
||||
def handle(
|
||||
self,
|
||||
row: Mapping[str, Any],
|
||||
event: Mapping[str, Any],
|
||||
) -> str:
|
||||
question = str(row.get("question", ""))
|
||||
_, arguments = local_command_parts(question)
|
||||
command = local_extension_command_token(question)
|
||||
try:
|
||||
handler = self._handlers[command.casefold()]
|
||||
except KeyError as error:
|
||||
raise LocalExtensionError("local command is not registered") from error
|
||||
observed_at = str(
|
||||
event.get("observed_at")
|
||||
or row.get("created_at")
|
||||
or utc_now()
|
||||
)
|
||||
sdk_event = Event(
|
||||
event_id=str(row.get("event_id") or event.get("event_id") or ""),
|
||||
observed_at=observed_at,
|
||||
sender=str(row.get("sender") or event.get("sender") or ""),
|
||||
text=str(event.get("text") or question),
|
||||
group_fingerprint=str(
|
||||
row.get("group_fingerprint")
|
||||
or event.get("group_fingerprint")
|
||||
or ""
|
||||
),
|
||||
mention_bot=bool(event.get("mention_bot", True)),
|
||||
confidence=float(event.get("confidence", 0.0)),
|
||||
sender_confidence=float(event.get("sender_confidence", 0.0)),
|
||||
body_confidence=float(event.get("body_confidence", 0.0)),
|
||||
source=str(event.get("source", "jwebot-edge")),
|
||||
)
|
||||
result = handler(sdk_event, arguments)
|
||||
if not isinstance(result, ExtensionResult):
|
||||
raise LocalExtensionError("local adapter returned invalid result")
|
||||
if result.reply.kind is not PlanKind.TEXT:
|
||||
raise LocalExtensionError("local command returned no visible reply")
|
||||
return result.reply.message
|
||||
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Official Steam status and playtime provider with mandatory proxy egress."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
STEAM_API_BASE_URL = "https://api.steampowered.com"
|
||||
STEAM_CACHE_SECONDS = 60
|
||||
STEAM_FRIEND_CACHE_SECONDS = 5 * 60
|
||||
STEAM_PLAYTIME_CACHE_SECONDS = 5 * 60
|
||||
STEAM_REQUEST_TIMEOUT_SECONDS = 8
|
||||
STEAM_SUMMARY_BATCH_SIZE = 100
|
||||
STEAM_PLAYTIME_WORKERS = 8
|
||||
STEAM_MAX_RESPONSE_BYTES = 4_000_000
|
||||
STEAM_RESULT_LIMIT = 8
|
||||
STEAM_SCOPED_RESULT_LIMIT = 16
|
||||
DEFAULT_STEAM_MIHOMO_PROXY_URL = "http://127.0.0.1:7890"
|
||||
STEAM_ID64_PATTERN = re.compile(r"^\d{17}$")
|
||||
STEAM_API_KEY_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
|
||||
SAFE_STEAM_PROXY_URL = re.compile(
|
||||
r"^http://(?:127\.0\.0\.1|localhost):(?:[1-9]\d{0,3}|[1-5]\d{4})$"
|
||||
)
|
||||
PLAYTIME_PERSON_FIELD = re.compile(
|
||||
r"(?:玩家|用户|person|人)\s*[:=:]\s*(?P<selector>.+)$",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Public AppIDs make common OCR-shortened names deterministic. Other games are
|
||||
# resolved against the configured account's library, then public friend data.
|
||||
STEAM_GAME_ALIASES = {
|
||||
"cs2": (730, "Counter-Strike 2"),
|
||||
"csgo": (730, "Counter-Strike 2"),
|
||||
"dota": (570, "Dota 2"),
|
||||
"dota2": (570, "Dota 2"),
|
||||
"pubg": (578080, "PUBG: BATTLEGROUNDS"),
|
||||
"gta5": (271590, "Grand Theft Auto V"),
|
||||
"gtav": (271590, "Grand Theft Auto V"),
|
||||
"apex": (1172470, "Apex Legends"),
|
||||
}
|
||||
|
||||
|
||||
class SteamUnavailable(RuntimeError):
|
||||
"""Steam cannot be reached through the mandatory approved proxy."""
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def bounded_text(value: object, *, limit: int) -> str:
|
||||
return normalize_text(value)[:limit]
|
||||
|
||||
|
||||
def match_key(value: object) -> str:
|
||||
normalized = unicodedata.normalize("NFKC", str(value or "")).casefold()
|
||||
return re.sub(r"[\W_]+", "", normalized, flags=re.UNICODE)
|
||||
|
||||
|
||||
class SteamClient:
|
||||
"""Read public Steam data through Mihomo without any direct fallback."""
|
||||
|
||||
PERSONA_STATES = {
|
||||
0: "离线",
|
||||
1: "在线",
|
||||
2: "忙碌",
|
||||
3: "离开",
|
||||
4: "打盹",
|
||||
5: "想交易",
|
||||
6: "想玩",
|
||||
}
|
||||
|
||||
def __init__(self, config: Any) -> None:
|
||||
self.config = config
|
||||
self.lock = threading.Lock()
|
||||
self.friend_cache: tuple[float, list[str]] | None = None
|
||||
self.presence_cache: tuple[float, list[dict[str, Any]]] | None = None
|
||||
self.playtime_cache: dict[tuple[int, tuple[str, ...]], tuple[float, str]] = {}
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(
|
||||
STEAM_API_KEY_PATTERN.fullmatch(self.config.steam_api_key)
|
||||
and STEAM_ID64_PATTERN.fullmatch(self.config.steam_id64)
|
||||
and SAFE_STEAM_PROXY_URL.fullmatch(self.config.steam_proxy_url)
|
||||
)
|
||||
|
||||
def configured_roster(self) -> list[dict[str, Any]]:
|
||||
values = []
|
||||
seen: set[str] = set()
|
||||
for raw in getattr(self.config, "steam_roster", ()):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
steam_id = str(raw.get("steam_id", ""))
|
||||
label = bounded_text(raw.get("label", ""), limit=40)
|
||||
if (
|
||||
not STEAM_ID64_PATTERN.fullmatch(steam_id)
|
||||
or not label
|
||||
or steam_id in seen
|
||||
):
|
||||
continue
|
||||
selectors = tuple(
|
||||
dict.fromkeys(
|
||||
bounded_text(selector, limit=120)
|
||||
for selector in raw.get("selectors", ())
|
||||
if bounded_text(selector, limit=120)
|
||||
)
|
||||
)
|
||||
values.append(
|
||||
{
|
||||
"steam_id": steam_id,
|
||||
"label": label,
|
||||
"selectors": selectors,
|
||||
}
|
||||
)
|
||||
seen.add(steam_id)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def usage() -> str:
|
||||
return (
|
||||
"用法:/steam [在线|游戏|全部|刷新];"
|
||||
"/steam时长 游戏名[、玩家名](可省略空格)"
|
||||
)
|
||||
|
||||
def render(self, arguments: str) -> str:
|
||||
if not self.configured():
|
||||
return "Steam 尚未配置"
|
||||
mode, value, force_refresh = self.parse_arguments(arguments)
|
||||
if mode == "help":
|
||||
return self.usage()
|
||||
if mode == "playtime":
|
||||
if not value:
|
||||
return "请在 /steam时长 后填写游戏名"
|
||||
game_query, person_selector = self.parse_playtime_query(value)
|
||||
if not game_query:
|
||||
return "请在 /steam时长 后填写游戏名"
|
||||
return self.render_playtime(
|
||||
game_query,
|
||||
person_selector=person_selector,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return self.render_presence(
|
||||
mode=mode,
|
||||
selector=value,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
def render_group_presence(self, arguments: str) -> str:
|
||||
"""Compatibility entrypoint retained for the reviewed service profile."""
|
||||
|
||||
return self.render(arguments)
|
||||
|
||||
@staticmethod
|
||||
def parse_arguments(arguments: str) -> tuple[str, str, bool]:
|
||||
value = normalize_text(arguments)
|
||||
folded = value.casefold()
|
||||
force_refresh = False
|
||||
for refresh in ("刷新", "refresh"):
|
||||
if folded == refresh:
|
||||
return "online", "", True
|
||||
suffix = f" {refresh}"
|
||||
if folded.endswith(suffix):
|
||||
value = value[: -len(suffix)].strip()
|
||||
folded = value.casefold()
|
||||
force_refresh = True
|
||||
|
||||
for prefix in ("时长", "playtime", "hours"):
|
||||
if folded.startswith(prefix):
|
||||
return "playtime", value[len(prefix):].strip(), force_refresh
|
||||
|
||||
modes = {
|
||||
"": "online",
|
||||
"在线": "online",
|
||||
"online": "online",
|
||||
"游戏": "game",
|
||||
"game": "game",
|
||||
"全部": "all",
|
||||
"all": "all",
|
||||
"帮助": "help",
|
||||
"help": "help",
|
||||
}
|
||||
if folded in modes:
|
||||
return modes[folded], "", force_refresh
|
||||
return "online", value, force_refresh
|
||||
|
||||
@staticmethod
|
||||
def parse_playtime_query(query: str) -> tuple[str, str]:
|
||||
value = normalize_text(query)
|
||||
field_matches = list(PLAYTIME_PERSON_FIELD.finditer(value))
|
||||
if field_matches:
|
||||
match = field_matches[-1]
|
||||
game = value[:match.start()].rstrip(" ,,、;;")
|
||||
selector = normalize_text(match.group("selector"))
|
||||
if game and selector:
|
||||
return game, selector
|
||||
|
||||
game, separator, selector = value.rpartition("@")
|
||||
if separator and game.strip() and selector.strip():
|
||||
return game.strip(), selector.strip()
|
||||
|
||||
delimiter_index = max(value.rfind(mark) for mark in ("、", ",", ","))
|
||||
if delimiter_index > 0:
|
||||
game = value[:delimiter_index].strip()
|
||||
selector = value[delimiter_index + 1:].strip()
|
||||
if game and selector:
|
||||
return game, selector
|
||||
return value, ""
|
||||
|
||||
def friend_ids(self, *, force_refresh: bool = False) -> list[str]:
|
||||
roster = self.configured_roster()
|
||||
if roster:
|
||||
return [str(record["steam_id"]) for record in roster]
|
||||
now = time.monotonic()
|
||||
with self.lock:
|
||||
if (
|
||||
not force_refresh
|
||||
and self.friend_cache is not None
|
||||
and now - self.friend_cache[0] < STEAM_FRIEND_CACHE_SECONDS
|
||||
):
|
||||
return list(self.friend_cache[1])
|
||||
payload = self.request_json(
|
||||
"/ISteamUser/GetFriendList/v1/",
|
||||
{
|
||||
"steamid": self.config.steam_id64,
|
||||
"relationship": "friend",
|
||||
},
|
||||
)
|
||||
friendslist = payload.get("friendslist")
|
||||
friends = friendslist.get("friends") if isinstance(friendslist, dict) else None
|
||||
if not isinstance(friends, list):
|
||||
raise SteamUnavailable("Steam 列表不可见")
|
||||
values = [self.config.steam_id64]
|
||||
values.extend(
|
||||
str(friend.get("steamid", ""))
|
||||
for friend in friends
|
||||
if isinstance(friend, dict)
|
||||
)
|
||||
unique = list(dict.fromkeys(
|
||||
value for value in values if STEAM_ID64_PATTERN.fullmatch(value)
|
||||
))
|
||||
with self.lock:
|
||||
self.friend_cache = (time.monotonic(), list(unique))
|
||||
return unique
|
||||
|
||||
def presence(self, *, force_refresh: bool = False) -> list[dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
with self.lock:
|
||||
if (
|
||||
not force_refresh
|
||||
and self.presence_cache is not None
|
||||
and now - self.presence_cache[0] < STEAM_CACHE_SECONDS
|
||||
):
|
||||
return [dict(record) for record in self.presence_cache[1]]
|
||||
roster = self.configured_roster()
|
||||
steam_ids = (
|
||||
[str(record["steam_id"]) for record in roster]
|
||||
if roster
|
||||
else self.friend_ids(force_refresh=force_refresh)
|
||||
)
|
||||
roster_by_id = {
|
||||
str(record["steam_id"]): record
|
||||
for record in roster
|
||||
}
|
||||
summaries = self.fetch_summaries(steam_ids)
|
||||
records = []
|
||||
for index, steam_id in enumerate(steam_ids, start=1):
|
||||
summary = summaries.get(steam_id)
|
||||
roster_record = roster_by_id.get(steam_id)
|
||||
try:
|
||||
state = int(summary.get("personastate", 0)) if summary else -1
|
||||
except (TypeError, ValueError):
|
||||
state = -1
|
||||
label = (
|
||||
str(roster_record["label"])
|
||||
if roster_record is not None
|
||||
else bounded_text(
|
||||
summary.get("personaname", "") if summary else "",
|
||||
limit=40,
|
||||
) or f"用户{index}"
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"steam_id": steam_id,
|
||||
"label": label,
|
||||
"selectors": (
|
||||
tuple(roster_record["selectors"])
|
||||
if roster_record is not None
|
||||
else (label,)
|
||||
),
|
||||
"scoped": roster_record is not None,
|
||||
"state": state,
|
||||
"game": bounded_text(
|
||||
summary.get("gameextrainfo", "") if summary else "",
|
||||
limit=120,
|
||||
),
|
||||
}
|
||||
)
|
||||
with self.lock:
|
||||
self.presence_cache = (
|
||||
time.monotonic(),
|
||||
[dict(record) for record in records],
|
||||
)
|
||||
return records
|
||||
|
||||
def render_presence(
|
||||
self,
|
||||
*,
|
||||
mode: str,
|
||||
selector: str,
|
||||
force_refresh: bool,
|
||||
) -> str:
|
||||
records = self.presence(force_refresh=force_refresh)
|
||||
if selector:
|
||||
record = self.select_record(records, selector)
|
||||
if record is None:
|
||||
return self.usage()
|
||||
return f"Steam:{self.render_record(record)}"
|
||||
online_count = sum(record["state"] > 0 for record in records)
|
||||
game_count = sum(bool(record["game"]) for record in records)
|
||||
if mode == "game":
|
||||
visible = [record for record in records if record["game"]]
|
||||
elif mode == "all":
|
||||
visible = records
|
||||
else:
|
||||
visible = [record for record in records if record["state"] > 0]
|
||||
visible.sort(
|
||||
key=lambda record: (
|
||||
not bool(record["game"]),
|
||||
-int(record["state"]),
|
||||
str(record["label"]).casefold(),
|
||||
)
|
||||
)
|
||||
if not visible:
|
||||
return (
|
||||
f"Steam:在线 {online_count}/{len(records)},"
|
||||
f"游戏中 {game_count};当前没有匹配状态"
|
||||
)
|
||||
result_limit = (
|
||||
STEAM_SCOPED_RESULT_LIMIT
|
||||
if any(bool(record.get("scoped")) for record in records)
|
||||
else STEAM_RESULT_LIMIT
|
||||
)
|
||||
shown = visible[:result_limit]
|
||||
listing = ";".join(self.render_record(record) for record in shown)
|
||||
omitted = len(visible) - len(shown)
|
||||
suffix = f";另有 {omitted} 个账号未展开" if omitted else ""
|
||||
return (
|
||||
f"Steam:在线 {online_count}/{len(records)},"
|
||||
f"游戏中 {game_count};{listing}{suffix}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def select_record(
|
||||
records: list[dict[str, Any]],
|
||||
selector: str,
|
||||
) -> dict[str, Any] | None:
|
||||
matches = SteamClient.matching_records(records, selector)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
@staticmethod
|
||||
def matching_records(
|
||||
records: list[dict[str, Any]],
|
||||
selector: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
query = match_key(selector)
|
||||
if not query:
|
||||
return []
|
||||
|
||||
def keys(record: dict[str, Any]) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
key
|
||||
for key in (
|
||||
match_key(record.get("label", "")),
|
||||
*(
|
||||
match_key(value)
|
||||
for value in record.get("selectors", ())
|
||||
),
|
||||
)
|
||||
if key
|
||||
)
|
||||
)
|
||||
|
||||
exact = [record for record in records if query in keys(record)]
|
||||
if exact:
|
||||
return exact
|
||||
partial = [
|
||||
record
|
||||
for record in records
|
||||
if any(query in key for key in keys(record))
|
||||
]
|
||||
if partial:
|
||||
return partial
|
||||
scored = [
|
||||
(
|
||||
difflib.SequenceMatcher(
|
||||
None,
|
||||
query,
|
||||
key,
|
||||
).ratio(),
|
||||
record,
|
||||
)
|
||||
for record in records
|
||||
for key in [max(keys(record), key=lambda value: difflib.SequenceMatcher(
|
||||
None,
|
||||
query,
|
||||
value,
|
||||
).ratio(), default="")]
|
||||
if key
|
||||
]
|
||||
if not scored:
|
||||
return []
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
best_score = scored[0][0]
|
||||
if best_score < 0.78:
|
||||
return []
|
||||
return [
|
||||
record
|
||||
for score, record in scored
|
||||
if score >= 0.78 and best_score - score < 0.05
|
||||
]
|
||||
|
||||
def render_playtime(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
person_selector: str = "",
|
||||
force_refresh: bool,
|
||||
) -> str:
|
||||
records = self.presence(force_refresh=force_refresh)
|
||||
if person_selector:
|
||||
matches = self.matching_records(records, person_selector)
|
||||
safe_selector = bounded_text(person_selector, limit=40)
|
||||
if not matches:
|
||||
return f"Steam:没有匹配到玩家“{safe_selector}”"
|
||||
if len(matches) > 1:
|
||||
return f"Steam:玩家“{safe_selector}”匹配不唯一,请写完整名称"
|
||||
records = matches
|
||||
steam_ids = [str(record["steam_id"]) for record in records]
|
||||
seed_id = (
|
||||
steam_ids[0]
|
||||
if person_selector
|
||||
else self.config.steam_id64
|
||||
)
|
||||
seed_games = self.fetch_owned_games(seed_id)
|
||||
game = self.resolve_game(query, seed_games or [])
|
||||
prefetched: dict[str, list[dict[str, Any]] | None] = {
|
||||
seed_id: seed_games,
|
||||
}
|
||||
if game is None:
|
||||
prefetched.update(
|
||||
self.fetch_owned_games_parallel(
|
||||
[
|
||||
steam_id
|
||||
for steam_id in steam_ids
|
||||
if steam_id != seed_id
|
||||
],
|
||||
app_id=None,
|
||||
)
|
||||
)
|
||||
candidates = [
|
||||
entry
|
||||
for games in prefetched.values()
|
||||
if games
|
||||
for entry in games
|
||||
]
|
||||
game = self.resolve_game(query, candidates)
|
||||
if game is None:
|
||||
return f"Steam:没有匹配到“{bounded_text(query, limit=40)}”"
|
||||
|
||||
app_id, game_name = game
|
||||
cache_key = (app_id, tuple(steam_ids))
|
||||
with self.lock:
|
||||
cached = self.playtime_cache.get(cache_key)
|
||||
if (
|
||||
not force_refresh
|
||||
and cached is not None
|
||||
and time.monotonic() - cached[0] < STEAM_PLAYTIME_CACHE_SECONDS
|
||||
):
|
||||
return cached[1]
|
||||
|
||||
missing = [steam_id for steam_id in steam_ids if steam_id not in prefetched]
|
||||
prefetched.update(
|
||||
self.fetch_owned_games_parallel(missing, app_id=app_id)
|
||||
)
|
||||
entries = []
|
||||
visible_count = 0
|
||||
hidden_count = 0
|
||||
record_by_id = {str(record["steam_id"]): record for record in records}
|
||||
for steam_id in steam_ids:
|
||||
games = prefetched.get(steam_id)
|
||||
if games is None:
|
||||
hidden_count += 1
|
||||
continue
|
||||
visible_count += 1
|
||||
minutes = 0
|
||||
for owned in games:
|
||||
if int(owned.get("appid", 0)) == app_id:
|
||||
try:
|
||||
minutes = max(0, int(owned.get("playtime_forever", 0)))
|
||||
except (TypeError, ValueError):
|
||||
minutes = 0
|
||||
break
|
||||
if minutes > 0:
|
||||
entries.append(
|
||||
(
|
||||
minutes,
|
||||
bounded_text(record_by_id[steam_id]["label"], limit=40),
|
||||
)
|
||||
)
|
||||
entries.sort(key=lambda item: (-item[0], item[1].casefold()))
|
||||
result_limit = (
|
||||
STEAM_SCOPED_RESULT_LIMIT
|
||||
if any(bool(record.get("scoped")) for record in records)
|
||||
else STEAM_RESULT_LIMIT
|
||||
)
|
||||
listing = ";".join(
|
||||
f"{label} {self.format_hours(minutes)}"
|
||||
for minutes, label in entries[:result_limit]
|
||||
)
|
||||
omitted = len(entries) - min(len(entries), result_limit)
|
||||
if listing:
|
||||
details = listing
|
||||
elif person_selector:
|
||||
label = bounded_text(records[0]["label"], limit=40)
|
||||
details = (
|
||||
f"{label} 资料不可见"
|
||||
if hidden_count
|
||||
else f"{label} 暂无公开游玩时长"
|
||||
)
|
||||
else:
|
||||
details = "暂无公开游玩时长"
|
||||
if omitted:
|
||||
details += f";另有 {omitted} 个有记录账号未展开"
|
||||
result = (
|
||||
f"Steam [■■■] {game_name}:已查询 {len(steam_ids)} 个账号,"
|
||||
f"有时长 {len(entries)},公开但未玩 {visible_count - len(entries)},"
|
||||
f"不可见 {hidden_count};{details}"
|
||||
)
|
||||
with self.lock:
|
||||
self.playtime_cache[cache_key] = (time.monotonic(), result)
|
||||
return result
|
||||
|
||||
def fetch_owned_games_parallel(
|
||||
self,
|
||||
steam_ids: list[str],
|
||||
*,
|
||||
app_id: int | None,
|
||||
) -> dict[str, list[dict[str, Any]] | None]:
|
||||
if not steam_ids:
|
||||
return {}
|
||||
result: dict[str, list[dict[str, Any]] | None] = {}
|
||||
errors: list[SteamUnavailable] = []
|
||||
workers = min(STEAM_PLAYTIME_WORKERS, len(steam_ids))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {
|
||||
executor.submit(self.fetch_owned_games, steam_id, app_id=app_id):
|
||||
steam_id
|
||||
for steam_id in steam_ids
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
steam_id = futures[future]
|
||||
try:
|
||||
result[steam_id] = future.result()
|
||||
except SteamUnavailable as error:
|
||||
errors.append(error)
|
||||
result[steam_id] = None
|
||||
if errors and len(errors) == len(steam_ids):
|
||||
raise errors[0]
|
||||
return result
|
||||
|
||||
def fetch_owned_games(
|
||||
self,
|
||||
steam_id: str,
|
||||
*,
|
||||
app_id: int | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
input_value: dict[str, Any] = {
|
||||
"steamid": steam_id,
|
||||
"include_appinfo": True,
|
||||
"include_played_free_games": True,
|
||||
}
|
||||
if app_id is not None:
|
||||
input_value["appids_filter"] = [app_id]
|
||||
payload = self.request_json(
|
||||
"/IPlayerService/GetOwnedGames/v1/",
|
||||
{
|
||||
"input_json": json.dumps(
|
||||
input_value,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
},
|
||||
)
|
||||
response = payload.get("response")
|
||||
if not isinstance(response, dict):
|
||||
raise SteamUnavailable("Steam API returned invalid game data")
|
||||
games = response.get("games")
|
||||
if games is None and "game_count" not in response:
|
||||
return None
|
||||
if games is None:
|
||||
return []
|
||||
if not isinstance(games, list):
|
||||
raise SteamUnavailable("Steam API returned invalid game data")
|
||||
return [game for game in games if isinstance(game, dict)]
|
||||
|
||||
@staticmethod
|
||||
def resolve_game(
|
||||
query: str,
|
||||
games: list[dict[str, Any]],
|
||||
) -> tuple[int, str] | None:
|
||||
query_key = match_key(query)
|
||||
if not query_key:
|
||||
return None
|
||||
alias = STEAM_GAME_ALIASES.get(query_key)
|
||||
if alias is not None:
|
||||
return alias
|
||||
candidates: dict[int, str] = {}
|
||||
for game in games:
|
||||
try:
|
||||
app_id = int(game.get("appid", 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
name = bounded_text(game.get("name", ""), limit=120)
|
||||
if app_id > 0 and name:
|
||||
candidates.setdefault(app_id, name)
|
||||
exact = [
|
||||
(app_id, name)
|
||||
for app_id, name in candidates.items()
|
||||
if match_key(name) == query_key
|
||||
]
|
||||
if exact:
|
||||
return exact[0]
|
||||
|
||||
scored: list[tuple[float, int, str]] = []
|
||||
for app_id, name in candidates.items():
|
||||
name_key = match_key(name)
|
||||
ratio = difflib.SequenceMatcher(None, query_key, name_key).ratio()
|
||||
if query_key in name_key:
|
||||
ratio = max(ratio, 0.9 + min(0.08, len(query_key) / 100))
|
||||
scored.append((ratio, app_id, name))
|
||||
if not scored:
|
||||
return None
|
||||
score, app_id, name = max(scored)
|
||||
threshold = 0.78 if len(query_key) <= 3 else 0.68
|
||||
return (app_id, name) if score >= threshold else None
|
||||
|
||||
def fetch_summaries(self, steam_ids: list[str]) -> dict[str, dict[str, Any]]:
|
||||
summaries: dict[str, dict[str, Any]] = {}
|
||||
for offset in range(0, len(steam_ids), STEAM_SUMMARY_BATCH_SIZE):
|
||||
batch = steam_ids[offset:offset + STEAM_SUMMARY_BATCH_SIZE]
|
||||
payload = self.request_json(
|
||||
"/ISteamUser/GetPlayerSummaries/v2/",
|
||||
{"steamids": ",".join(batch)},
|
||||
)
|
||||
response = payload.get("response")
|
||||
players = response.get("players") if isinstance(response, dict) else None
|
||||
if not isinstance(players, list):
|
||||
raise SteamUnavailable("Steam API returned invalid player data")
|
||||
for player in players:
|
||||
steam_id = (
|
||||
str(player.get("steamid", ""))
|
||||
if isinstance(player, dict)
|
||||
else ""
|
||||
)
|
||||
if steam_id in batch and isinstance(player, dict):
|
||||
summaries[steam_id] = player
|
||||
return summaries
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
path: str,
|
||||
parameters: dict[str, object],
|
||||
) -> dict[str, Any]:
|
||||
query = urllib.parse.urlencode(
|
||||
{"key": self.config.steam_api_key, **parameters},
|
||||
doseq=True,
|
||||
safe=",",
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
f"{STEAM_API_BASE_URL}{path}?{query}",
|
||||
headers={"Accept": "application/json", "User-Agent": "jwebot-edge/1"},
|
||||
method="GET",
|
||||
)
|
||||
proxy_handler = urllib.request.ProxyHandler(
|
||||
{"https": self.config.steam_proxy_url}
|
||||
)
|
||||
opener = urllib.request.build_opener(proxy_handler)
|
||||
try:
|
||||
with opener.open(
|
||||
request,
|
||||
timeout=STEAM_REQUEST_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
raw = response.read(STEAM_MAX_RESPONSE_BYTES + 1)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in {401, 403}:
|
||||
raise SteamUnavailable("Steam API key 无效或资料不可见") from error
|
||||
if error.code == 429:
|
||||
raise SteamUnavailable("Steam API 请求过快,请稍后重试") from error
|
||||
raise SteamUnavailable(f"Steam API HTTP {error.code}") from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
raise SteamUnavailable("Steam Mihomo 代理不可用") from error
|
||||
if len(raw) > STEAM_MAX_RESPONSE_BYTES:
|
||||
raise SteamUnavailable("Steam API response exceeded limit")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SteamUnavailable("Steam API returned invalid JSON") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SteamUnavailable("Steam API returned invalid data")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def format_hours(minutes: int) -> str:
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f}h" if minutes % 60 else f"{int(hours)}h"
|
||||
|
||||
def render_record(self, record: dict[str, Any]) -> str:
|
||||
label = str(record["label"])
|
||||
game = str(record["game"])
|
||||
state = int(record["state"])
|
||||
if game:
|
||||
return f"{label} 正在玩 {game}"
|
||||
if state < 0:
|
||||
return f"{label} 状态不可见"
|
||||
return f"{label} {self.PERSONA_STATES.get(state, '未知')}"
|
||||
@@ -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