release: public alpha 0.6.0 baseline

This commit is contained in:
JWeBot Contributors
2026-10-15 03:58:31 +08:00
commit d4242ae07c
238 changed files with 37724 additions and 0 deletions
+18
View File
@@ -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(),
}
+18
View File
@@ -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"
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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))
@@ -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, '未知')}"
+13
View File
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "0.6.0a0+1015"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,41 @@
"""Public extension API for JWeBot."""
from .context import (
ApprovedHttpClient,
ApprovedServiceBroker,
CommandRegistry,
FactWriter,
PluginLogger,
PluginStorage,
Scheduler,
SingleGroupContext,
)
from .contracts import (
API_VERSION,
ContractViolation,
Event,
ExtensionResult,
Fact,
PlanKind,
ReplyPlan,
)
__all__ = [
"API_VERSION",
"ApprovedHttpClient",
"ApprovedServiceBroker",
"CommandRegistry",
"ContractViolation",
"Event",
"ExtensionResult",
"Fact",
"FactWriter",
"PlanKind",
"PluginLogger",
"PluginStorage",
"ReplyPlan",
"Scheduler",
"SingleGroupContext",
]
__version__ = "0.6.0a0+1015"
@@ -0,0 +1,70 @@
"""Capability-limited protocols exposed to extensions."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Protocol
from .contracts import Event, ExtensionResult, Fact
CommandHandler = Callable[[Event, str], ExtensionResult]
class CommandRegistry(Protocol):
def register(self, command: str, handler: CommandHandler) -> None: ...
class PluginStorage(Protocol):
def get(self, key: str) -> str | None: ...
def set(self, key: str, value: str) -> None: ...
def delete(self, key: str) -> None: ...
class ApprovedHttpClient(Protocol):
def request(
self,
profile: str,
*,
method: str,
path: str,
query: Mapping[str, str] | None = None,
) -> bytes: ...
class ApprovedServiceBroker(Protocol):
def call(
self,
profile: str,
*,
operation: str,
payload: Mapping[str, object],
) -> Mapping[str, object]: ...
class FactWriter(Protocol):
def write(self, fact: Fact) -> None: ...
class Scheduler(Protocol):
def schedule(self, key: str, *, run_at: str, payload: Mapping[str, str]) -> None: ...
class PluginLogger(Protocol):
def info(self, message: str, **fields: object) -> None: ...
def warning(self, message: str, **fields: object) -> None: ...
class SingleGroupContext(Protocol):
"""No group selector, GUI sender, raw ledger, environment, or shell access."""
commands: CommandRegistry
storage: PluginStorage
http: ApprovedHttpClient
services: ApprovedServiceBroker
facts: FactWriter
scheduler: Scheduler
logger: PluginLogger
@@ -0,0 +1,291 @@
"""Versioned data contracts shared by extensions and the kernel."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from types import MappingProxyType
from typing import Any, Mapping
API_VERSION = "1"
MAX_EVENT_TEXT = 5_000
MAX_REPLY_CHARS = 900
MAX_FACT_CHARS = 120
class ContractViolation(ValueError):
"""Raised when data crosses the extension boundary with an invalid shape."""
class PlanKind(str, Enum):
TEXT = "text"
NO_REPLY = "no_reply"
def _required_text(value: object, *, name: str, limit: int) -> str:
text = str(value or "").strip()
if not text:
raise ContractViolation(f"{name} is required")
if len(text) > limit:
raise ContractViolation(f"{name} exceeds {limit} characters")
return text
def _confidence(value: object, *, name: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as error:
raise ContractViolation(f"{name} must be numeric") from error
if not 0.0 <= number <= 1.0:
raise ContractViolation(f"{name} must be between 0 and 1")
return number
@dataclass(frozen=True)
class Event:
"""Normalized, single-group incoming event exposed to extensions."""
event_id: str
observed_at: str
sender: str
text: str
group_fingerprint: str
mention_bot: bool
confidence: float = 0.0
sender_confidence: float = 0.0
body_confidence: float = 0.0
source: str = "jwebot"
version: int = 1
side: str = "incoming"
def __post_init__(self) -> None:
object.__setattr__(
self,
"event_id",
_required_text(self.event_id, name="event_id", limit=256),
)
object.__setattr__(
self,
"observed_at",
_required_text(self.observed_at, name="observed_at", limit=64),
)
object.__setattr__(
self,
"sender",
_required_text(self.sender, name="sender", limit=120),
)
object.__setattr__(
self,
"text",
_required_text(self.text, name="text", limit=MAX_EVENT_TEXT),
)
object.__setattr__(
self,
"group_fingerprint",
_required_text(
self.group_fingerprint,
name="group_fingerprint",
limit=256,
),
)
object.__setattr__(
self,
"source",
_required_text(self.source, name="source", limit=80),
)
object.__setattr__(
self,
"confidence",
_confidence(self.confidence, name="confidence"),
)
object.__setattr__(
self,
"sender_confidence",
_confidence(self.sender_confidence, name="sender_confidence"),
)
object.__setattr__(
self,
"body_confidence",
_confidence(self.body_confidence, name="body_confidence"),
)
if self.version != 1:
raise ContractViolation("unsupported event version")
if self.side != "incoming":
raise ContractViolation("extensions only receive incoming events")
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "Event":
if not isinstance(value, Mapping):
raise ContractViolation("event must be an object")
return cls(
event_id=value.get("event_id", ""),
observed_at=value.get("observed_at", ""),
sender=value.get("sender", ""),
text=value.get("text", ""),
group_fingerprint=value.get("group_fingerprint", ""),
mention_bot=bool(value.get("mention_bot")),
confidence=value.get("confidence", 0.0),
sender_confidence=value.get("sender_confidence", 0.0),
body_confidence=value.get("body_confidence", 0.0),
source=value.get("source", "jwebot"),
version=value.get("version", 1),
side=value.get("side", "incoming"),
)
def to_mapping(self) -> dict[str, Any]:
return {
"version": self.version,
"event_id": self.event_id,
"observed_at": self.observed_at,
"sender": self.sender,
"text": self.text,
"confidence": self.confidence,
"sender_confidence": self.sender_confidence,
"body_confidence": self.body_confidence,
"side": self.side,
"mention_bot": self.mention_bot,
"group_fingerprint": self.group_fingerprint,
"source": self.source,
}
@dataclass(frozen=True)
class ReplyPlan:
"""A structured request; only the kernel may turn it into a WeChat send."""
kind: PlanKind
message: str = ""
def __post_init__(self) -> None:
if self.kind is PlanKind.TEXT:
object.__setattr__(
self,
"message",
_required_text(
self.message,
name="reply message",
limit=MAX_REPLY_CHARS,
),
)
elif self.message:
raise ContractViolation("no_reply plan cannot contain text")
@classmethod
def text(cls, message: str) -> "ReplyPlan":
return cls(kind=PlanKind.TEXT, message=message)
@classmethod
def no_reply(cls) -> "ReplyPlan":
return cls(kind=PlanKind.NO_REPLY)
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ReplyPlan":
if not isinstance(value, Mapping):
raise ContractViolation("reply plan must be an object")
try:
kind = PlanKind(value.get("kind"))
except ValueError as error:
raise ContractViolation("reply plan kind is invalid") from error
if kind is PlanKind.TEXT:
return cls.text(str(value.get("text", "")))
if value.get("text"):
raise ContractViolation("no_reply plan cannot contain text")
return cls.no_reply()
def to_mapping(self) -> dict[str, str]:
value = {"kind": self.kind.value}
if self.kind is PlanKind.TEXT:
value["text"] = self.message
return value
@dataclass(frozen=True)
class Fact:
"""A bounded, non-sensitive fact candidate emitted by an extension."""
kind: str
text: str
confidence: float = 1.0
def __post_init__(self) -> None:
object.__setattr__(
self,
"kind",
_required_text(self.kind, name="fact kind", limit=40),
)
object.__setattr__(
self,
"text",
_required_text(self.text, name="fact text", limit=MAX_FACT_CHARS),
)
object.__setattr__(
self,
"confidence",
_confidence(self.confidence, name="fact confidence"),
)
def to_mapping(self) -> dict[str, object]:
return {
"kind": self.kind,
"text": self.text,
"confidence": self.confidence,
}
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "Fact":
if not isinstance(value, Mapping):
raise ContractViolation("fact must be an object")
return cls(
kind=value.get("kind", ""),
text=value.get("text", ""),
confidence=value.get("confidence", 1.0),
)
@dataclass(frozen=True)
class ExtensionResult:
"""Bounded result returned by one extension invocation."""
reply: ReplyPlan = field(default_factory=ReplyPlan.no_reply)
facts: tuple[Fact, ...] = ()
metadata: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if len(self.facts) > 12:
raise ContractViolation("extension result exceeds 12 facts")
metadata = {str(key): str(value) for key, value in self.metadata.items()}
if len(metadata) > 16:
raise ContractViolation("extension metadata exceeds 16 entries")
if any(len(key) > 40 or len(value) > 120 for key, value in metadata.items()):
raise ContractViolation("extension metadata entry is too long")
object.__setattr__(self, "metadata", MappingProxyType(metadata))
def to_mapping(self) -> dict[str, object]:
return {
"api_version": API_VERSION,
"reply": self.reply.to_mapping(),
"facts": [fact.to_mapping() for fact in self.facts],
"metadata": dict(self.metadata),
}
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ExtensionResult":
if not isinstance(value, Mapping):
raise ContractViolation("extension result must be an object")
if value.get("api_version") != API_VERSION:
raise ContractViolation("extension result API version is invalid")
facts = value.get("facts", [])
metadata = value.get("metadata", {})
if not isinstance(facts, list):
raise ContractViolation("extension facts must be a list")
if not isinstance(metadata, Mapping):
raise ContractViolation("extension metadata must be an object")
return cls(
reply=ReplyPlan.from_mapping(value.get("reply", {})),
facts=tuple(Fact.from_mapping(fact) for fact in facts),
metadata={
str(key): str(item)
for key, item in metadata.items()
},
)