Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Agent tool runtime for the utility bot.
|
||||
|
||||
The package deliberately does not import legacy handlers at module import time.
|
||||
This keeps mode storage and artifact management usable without optional bot
|
||||
dependencies and avoids starting background services during tool discovery.
|
||||
"""
|
||||
|
||||
from .artifacts import ArtifactError, ArtifactStore
|
||||
from .capture import CaptureChannel
|
||||
from .executor import ToolExecutor
|
||||
from .models import ToolContext, ToolResult
|
||||
from .modes import ModeStore
|
||||
|
||||
__all__ = [
|
||||
"ArtifactError",
|
||||
"ArtifactStore",
|
||||
"CaptureChannel",
|
||||
"ModeStore",
|
||||
"ToolContext",
|
||||
"ToolExecutor",
|
||||
"ToolResult",
|
||||
]
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Randomly addressed, path-confined artifacts captured from OneBot media."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import threading
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
|
||||
DEFAULT_ARTIFACT_ROOT = Path(__file__).resolve().parents[1] / "data" / "artifacts"
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
class ArtifactError(ValueError):
|
||||
"""An artifact identifier, source, or confined path is invalid."""
|
||||
|
||||
|
||||
class ArtifactStore:
|
||||
"""Register captured media and materialize it for mail or explicit delivery.
|
||||
|
||||
Local files and base64 payloads are copied beneath ``root`` immediately.
|
||||
HTTP(S) sources are stored as references and downloaded on first
|
||||
``materialize`` call. Public artifact IDs are random URL-safe tokens; the
|
||||
caller can never provide a destination path. Every materialized path is
|
||||
resolved and checked against this store's root before it is returned.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Optional[Union[str, os.PathLike]] = None,
|
||||
*,
|
||||
max_bytes: int = 50 * 1024 * 1024,
|
||||
download_timeout: float = 30.0,
|
||||
) -> None:
|
||||
self.root = Path(root) if root is not None else DEFAULT_ARTIFACT_ROOT
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.root = self.root.resolve()
|
||||
self.max_bytes = int(max_bytes)
|
||||
self.download_timeout = float(download_timeout)
|
||||
if self.max_bytes < 1:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _filename(value: Optional[str], fallback: str = "artifact.bin") -> str:
|
||||
name = Path(value or fallback).name
|
||||
name = _SAFE_NAME_RE.sub("_", name).strip("._")
|
||||
return (name or fallback)[:160]
|
||||
|
||||
def _new_id(self) -> str:
|
||||
for _ in range(20):
|
||||
artifact_id = secrets.token_urlsafe(24)
|
||||
if not (self.root / artifact_id).exists():
|
||||
return artifact_id
|
||||
raise ArtifactError("could not allocate an artifact id")
|
||||
|
||||
@staticmethod
|
||||
def _public(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Exclude local paths and remote source URLs from model-facing metadata."""
|
||||
return {
|
||||
"artifact_id": record["artifact_id"],
|
||||
"media_type": record["media_type"],
|
||||
"filename": record["filename"],
|
||||
"size": record.get("size"),
|
||||
"remote": record.get("source_kind") == "url",
|
||||
}
|
||||
|
||||
def _directory(self, artifact_id: str) -> Path:
|
||||
if not isinstance(artifact_id, str) or not _ID_RE.fullmatch(artifact_id):
|
||||
raise ArtifactError("invalid artifact id")
|
||||
path = (self.root / artifact_id).resolve()
|
||||
if path.parent != self.root:
|
||||
raise ArtifactError("artifact path escapes store")
|
||||
return path
|
||||
|
||||
def _write_record(self, record: Dict[str, Any]) -> None:
|
||||
directory = self._directory(record["artifact_id"])
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
target = directory / "artifact.json"
|
||||
temporary = directory / ".artifact.json.tmp"
|
||||
temporary.write_text(json.dumps(record, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(temporary, target)
|
||||
|
||||
def _record(self, artifact_id: str) -> Dict[str, Any]:
|
||||
directory = self._directory(artifact_id)
|
||||
try:
|
||||
record = json.loads((directory / "artifact.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ArtifactError("unknown artifact id") from exc
|
||||
if record.get("artifact_id") != artifact_id:
|
||||
raise ArtifactError("invalid artifact metadata")
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _context_metadata(ctx: Any) -> Dict[str, Any]:
|
||||
"""Return the stable owner and conversation scope for an artifact."""
|
||||
channel = getattr(getattr(ctx, "channel", None), "name", None)
|
||||
msg_type = getattr(ctx, "msg_type", None)
|
||||
owner_id = getattr(ctx, "user_id", None)
|
||||
target_id = getattr(ctx, "target_id", None)
|
||||
if (not isinstance(channel, str) or not channel
|
||||
or msg_type not in ("private", "group")
|
||||
or owner_id is None or target_id is None):
|
||||
raise ArtifactError("invalid artifact context")
|
||||
return {
|
||||
"owner_id": str(owner_id),
|
||||
"session": {
|
||||
"channel": channel,
|
||||
"msg_type": msg_type,
|
||||
"target_id": str(target_id),
|
||||
},
|
||||
}
|
||||
|
||||
def _authorize(self, record: Dict[str, Any], ctx: Any = None) -> None:
|
||||
"""Require a matching context for scoped artifacts.
|
||||
|
||||
Legacy records have no trustworthy owner/scope. They remain usable by
|
||||
old trusted local callers that omit ``ctx``, but are never exposed to a
|
||||
context-aware agent request. Scoped records always require ``ctx`` so
|
||||
dropping the context cannot bypass the check.
|
||||
"""
|
||||
has_owner = "owner_id" in record
|
||||
has_session = "session" in record
|
||||
if not has_owner and not has_session:
|
||||
if ctx is not None:
|
||||
raise ArtifactError("artifact is not available in this context")
|
||||
return
|
||||
if ctx is None:
|
||||
raise ArtifactError("artifact context required")
|
||||
expected = self._context_metadata(ctx)
|
||||
if (not has_owner or not has_session
|
||||
or record.get("owner_id") != expected["owner_id"]
|
||||
or record.get("session") != expected["session"]):
|
||||
raise ArtifactError("artifact is not available in this context")
|
||||
|
||||
def get(self, artifact_id: str, ctx: Any = None) -> Dict[str, Any]:
|
||||
"""Return public metadata after enforcing artifact context."""
|
||||
record = self._record(artifact_id)
|
||||
self._authorize(record, ctx)
|
||||
return self._public(record)
|
||||
|
||||
def _register_bytes(
|
||||
self,
|
||||
payload: bytes,
|
||||
*,
|
||||
media_type: str,
|
||||
filename: Optional[str],
|
||||
source_kind: str,
|
||||
source_url: Optional[str] = None,
|
||||
ctx: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
if len(payload) > self.max_bytes:
|
||||
raise ArtifactError("artifact exceeds size limit")
|
||||
artifact_id = self._new_id()
|
||||
directory = self._directory(artifact_id)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
name = self._filename(filename)
|
||||
relative = str(Path(artifact_id) / name)
|
||||
record: Dict[str, Any] = {
|
||||
"artifact_id": artifact_id,
|
||||
"media_type": media_type,
|
||||
"filename": name,
|
||||
"size": len(payload),
|
||||
"source_kind": source_kind,
|
||||
"path": relative,
|
||||
}
|
||||
if source_url is not None:
|
||||
record["source_url"] = source_url
|
||||
if ctx is not None:
|
||||
record.update(self._context_metadata(ctx))
|
||||
try:
|
||||
(self.root / relative).write_bytes(payload)
|
||||
self._write_record(record)
|
||||
except Exception:
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
raise
|
||||
return self._public(record)
|
||||
|
||||
def register(
|
||||
self,
|
||||
source: Union[str, os.PathLike],
|
||||
media_type: str = "file",
|
||||
filename: Optional[str] = None,
|
||||
*,
|
||||
ctx: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Register a local path/file URI, HTTP(S) URL, or base64 media payload.
|
||||
|
||||
Raw base64 and ``base64://`` values are accepted because OneBot uses
|
||||
both forms. Data URLs retain their optional MIME-derived filename only
|
||||
when a filename is explicitly supplied; no payload is logged.
|
||||
"""
|
||||
if not isinstance(media_type, str) or not media_type:
|
||||
raise ValueError("media_type must be a non-empty string")
|
||||
raw = str(source)
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
if not parsed.hostname:
|
||||
raise ArtifactError("invalid media URL")
|
||||
artifact_id = self._new_id()
|
||||
inferred = Path(urllib.parse.unquote(parsed.path)).name
|
||||
record = {
|
||||
"artifact_id": artifact_id,
|
||||
"media_type": media_type,
|
||||
"filename": self._filename(filename or inferred),
|
||||
"size": None,
|
||||
"source_kind": "url",
|
||||
"source_url": raw,
|
||||
"path": None,
|
||||
}
|
||||
if ctx is not None:
|
||||
record.update(self._context_metadata(ctx))
|
||||
self._write_record(record)
|
||||
return self._public(record)
|
||||
if parsed.scheme == "file":
|
||||
if parsed.netloc not in ("", "localhost"):
|
||||
raise ArtifactError("remote file URI is not allowed")
|
||||
local = Path(urllib.request.url2pathname(urllib.parse.unquote(parsed.path)))
|
||||
return self._register_local(local, media_type, filename, ctx)
|
||||
if parsed.scheme and len(parsed.scheme) > 1 and not isinstance(source, os.PathLike):
|
||||
if parsed.scheme == "base64":
|
||||
return self._register_base64(raw[len("base64://"):], media_type, filename, ctx)
|
||||
if parsed.scheme == "data":
|
||||
return self._register_data_url(raw, media_type, filename, ctx)
|
||||
raise ArtifactError("unsupported artifact source")
|
||||
path = Path(source)
|
||||
try:
|
||||
exists = path.exists()
|
||||
except OSError:
|
||||
# Long raw base64 strings are not valid filesystem names.
|
||||
exists = False
|
||||
if exists:
|
||||
return self._register_local(path, media_type, filename, ctx)
|
||||
if isinstance(source, os.PathLike):
|
||||
raise ArtifactError("local media file does not exist")
|
||||
return self._register_base64(raw, media_type, filename, ctx)
|
||||
|
||||
def _register_local(
|
||||
self,
|
||||
path: Path,
|
||||
media_type: str,
|
||||
filename: Optional[str],
|
||||
ctx: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
if not path.is_file():
|
||||
raise ArtifactError("local media is not a regular file")
|
||||
size = path.stat().st_size
|
||||
if size > self.max_bytes:
|
||||
raise ArtifactError("artifact exceeds size limit")
|
||||
with path.open("rb") as handle:
|
||||
payload = handle.read(self.max_bytes + 1)
|
||||
except ArtifactError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ArtifactError("cannot read local media") from exc
|
||||
return self._register_bytes(
|
||||
payload,
|
||||
media_type=media_type,
|
||||
filename=filename or path.name,
|
||||
source_kind="local",
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
def _register_base64(
|
||||
self,
|
||||
raw: str,
|
||||
media_type: str,
|
||||
filename: Optional[str],
|
||||
ctx: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
compact = "".join(raw.split())
|
||||
if len(compact) > (self.max_bytes * 4 // 3 + 16):
|
||||
raise ArtifactError("artifact exceeds size limit")
|
||||
try:
|
||||
payload = base64.b64decode(compact, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ArtifactError("invalid or nonexistent media source") from exc
|
||||
return self._register_bytes(
|
||||
payload,
|
||||
media_type=media_type,
|
||||
filename=filename,
|
||||
source_kind="base64",
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
def _register_data_url(
|
||||
self,
|
||||
raw: str,
|
||||
media_type: str,
|
||||
filename: Optional[str],
|
||||
ctx: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
header, separator, payload = raw.partition(",")
|
||||
if not separator or ";base64" not in header.lower():
|
||||
raise ArtifactError("only base64 data URLs are supported")
|
||||
return self._register_base64(payload, media_type, filename, ctx)
|
||||
|
||||
def _confined_file(self, record: Dict[str, Any]) -> Path:
|
||||
relative = record.get("path")
|
||||
if not isinstance(relative, str) or not relative:
|
||||
raise ArtifactError("artifact has not been materialized")
|
||||
candidate = (self.root / relative).resolve()
|
||||
try:
|
||||
candidate.relative_to(self.root)
|
||||
except ValueError as exc:
|
||||
raise ArtifactError("artifact path escapes store") from exc
|
||||
if candidate.parent != self._directory(record["artifact_id"]):
|
||||
raise ArtifactError("invalid artifact path")
|
||||
if not candidate.is_file():
|
||||
raise ArtifactError("artifact file is missing")
|
||||
return candidate
|
||||
|
||||
def materialize(self, artifact_id: str, ctx: Any = None) -> Path:
|
||||
"""Return a readable local path confined to this store and context.
|
||||
|
||||
URL artifacts are downloaded with a byte limit. Existing local/base64
|
||||
artifacts return their stored copy, never the original arbitrary path.
|
||||
"""
|
||||
with self._lock:
|
||||
record = self._record(artifact_id)
|
||||
self._authorize(record, ctx)
|
||||
if record.get("path"):
|
||||
return self._confined_file(record)
|
||||
if record.get("source_kind") != "url":
|
||||
raise ArtifactError("artifact has no local representation")
|
||||
url = record.get("source_url", "")
|
||||
if urllib.parse.urlparse(url).scheme not in ("http", "https"):
|
||||
raise ArtifactError("invalid artifact URL")
|
||||
directory = self._directory(artifact_id)
|
||||
destination = directory / self._filename(record.get("filename"))
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=self.download_timeout) as response:
|
||||
final_scheme = urllib.parse.urlparse(response.geturl()).scheme
|
||||
if final_scheme not in ("http", "https"):
|
||||
raise ArtifactError("artifact redirected to a non-HTTP source")
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > self.max_bytes:
|
||||
raise ArtifactError("artifact exceeds size limit")
|
||||
payload = response.read(self.max_bytes + 1)
|
||||
if len(payload) > self.max_bytes:
|
||||
raise ArtifactError("artifact exceeds size limit")
|
||||
destination.write_bytes(payload)
|
||||
record["path"] = str(Path(artifact_id) / destination.name)
|
||||
record["size"] = len(payload)
|
||||
self._write_record(record)
|
||||
return self._confined_file(record)
|
||||
except ArtifactError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ArtifactError("failed to materialize remote artifact") from exc
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Non-delivering channel adapter used around legacy command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .artifacts import ArtifactError, ArtifactStore
|
||||
|
||||
|
||||
MEDIA_TYPES = frozenset(("image", "file", "record", "video"))
|
||||
|
||||
|
||||
class CaptureChannel:
|
||||
"""Capture text and OneBot media segments instead of sending them.
|
||||
|
||||
Legacy modules call ``channel.send_message(CaptureChannel, ...)``. Returning
|
||||
``False`` from ``send`` is intentional: the shared sender then skips its
|
||||
private-message mirror fan-out, preventing a second real reply. Tool media
|
||||
delivery is performed separately and recorded by ``ToolExecutor``.
|
||||
"""
|
||||
|
||||
def __init__(self, context: Any, artifacts: ArtifactStore) -> None:
|
||||
self._context = context
|
||||
self._real_channel = context.channel
|
||||
self._artifacts = artifacts
|
||||
self.name = getattr(context.channel, "name", "unknown")
|
||||
self.texts: List[str] = []
|
||||
self.media: List[Dict[str, Any]] = []
|
||||
self.messages: List[Any] = []
|
||||
self.capture_errors: List[str] = []
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Preserve channel metadata used by legacy modules (for example TG flags)."""
|
||||
return getattr(self._real_channel, name)
|
||||
|
||||
def is_admin(self, user_id: Any) -> bool:
|
||||
"""Use the already validated execution context, never mutable handler state."""
|
||||
return bool(self._context.is_admin)
|
||||
|
||||
async def send(self, msg_type: str, target_id: int, content: Any) -> bool:
|
||||
"""Record one legacy send operation; never deliver it to the real channel."""
|
||||
self.messages.append(content)
|
||||
if isinstance(content, str):
|
||||
self.texts.append(content)
|
||||
return False
|
||||
if not isinstance(content, (list, tuple)):
|
||||
self.texts.append(str(content))
|
||||
return False
|
||||
for segment in content:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
segment_type = segment.get("type")
|
||||
data = segment.get("data")
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
if segment_type == "text":
|
||||
text = data.get("text")
|
||||
if text is not None:
|
||||
self.texts.append(str(text))
|
||||
continue
|
||||
if segment_type not in MEDIA_TYPES:
|
||||
continue
|
||||
source = data.get("file") or data.get("url")
|
||||
if not source:
|
||||
self.capture_errors.append("media segment has no source")
|
||||
continue
|
||||
try:
|
||||
artifact = self._artifacts.register(
|
||||
source,
|
||||
media_type=segment_type,
|
||||
filename=data.get("name"),
|
||||
ctx=self._context,
|
||||
)
|
||||
except (ArtifactError, OSError, ValueError):
|
||||
self.capture_errors.append("media could not be registered")
|
||||
continue
|
||||
self.media.append({
|
||||
"type": segment_type,
|
||||
"artifact_id": artifact["artifact_id"],
|
||||
"data": dict(data),
|
||||
})
|
||||
# Suppress channel._mirror_private; this is capture, not a failed tool.
|
||||
return False
|
||||
|
||||
@property
|
||||
def artifact_ids(self) -> List[str]:
|
||||
"""Return IDs in capture order, de-duplicated for compact result metadata."""
|
||||
return list(dict.fromkeys(item["artifact_id"] for item in self.media))
|
||||
|
||||
def public_artifacts(self) -> List[Dict[str, Any]]:
|
||||
"""Return safe metadata for all successfully captured media."""
|
||||
return [self._artifacts.get(item["artifact_id"], self._context) for item in self.media]
|
||||
|
||||
def safe_text(self) -> str:
|
||||
"""Join captured text for subsequent model rewriting."""
|
||||
return "\n".join(text for text in self.texts if text)
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Validated asynchronous tool execution on top of legacy bot handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
|
||||
|
||||
from .artifacts import ArtifactError, ArtifactStore
|
||||
from .capture import CaptureChannel
|
||||
from .models import ToolContext, ToolResult
|
||||
|
||||
|
||||
_MODULES = {
|
||||
"setu": "setu",
|
||||
"pixiv": "pixiv",
|
||||
"jm": "jm",
|
||||
"mail": "mail",
|
||||
"akaros_mail": "for-bot_mail",
|
||||
"ginkgo": "ginkgostu",
|
||||
"live": "live_monitor",
|
||||
"schedule": "scheduler",
|
||||
"memo": "notebook",
|
||||
"todo": "notebook",
|
||||
"wardrobe": "wardrobe",
|
||||
"my": "identity_cmd",
|
||||
"server": "for_admin_komari_agent",
|
||||
"deepseek": "deepseek.deepseek_balance",
|
||||
"log": "for_admin_catch_log",
|
||||
}
|
||||
_ADMIN_TOOLS = frozenset(("server", "deepseek", "log"))
|
||||
_ADMIN_ACTIONS = {
|
||||
"setu": frozenset(("del-cache",)),
|
||||
"pixiv": frozenset(("del-cache",)),
|
||||
"jm": frozenset(("del-cache",)),
|
||||
}
|
||||
_MEDIA_AUTOSEND = frozenset(("setu", "pixiv", "jm"))
|
||||
_OUTBOUND = frozenset(("send_image", "send_file", "send_sticker"))
|
||||
_ACTIONS = {
|
||||
"setu": ("help", "get", "del-cache"),
|
||||
"pixiv": ("help", "get", "del-cache"),
|
||||
"jm": ("help", "get", "search", "del-cache"),
|
||||
"mail": ("help", "bind", "unbind", "account", "default", "list", "info", "push", "new", "send"),
|
||||
"akaros_mail": ("send", "list", "info"),
|
||||
"ginkgo": ("help", "rule", "bind", "unbind", "status", "sign", "notice", "auto-sign-in"),
|
||||
"live": ("help", "status", "follow", "unfollow"),
|
||||
"schedule": ("create", "list", "cancel"),
|
||||
"memo": ("add", "list", "delete"),
|
||||
"todo": ("add", "list", "complete", "delete"),
|
||||
"wardrobe": ("add", "list", "update", "delete"),
|
||||
"my": ("help", "account", "bind", "unbind", "push"),
|
||||
"server": ("help", "status", "list", "load", "polling"),
|
||||
"deepseek": ("help", "balance", "threshold", "polling", "alert"),
|
||||
"log": ("help", "tail"),
|
||||
"send_image": ("send",),
|
||||
"send_file": ("send",),
|
||||
"send_sticker": ("send",),
|
||||
}
|
||||
_DESCRIPTIONS = {
|
||||
"setu": "获取涩图;params: tags(字符串数组), r18/ai(布尔)。媒体直接投递,文本交给模型。",
|
||||
"pixiv": "按 pid 获取 Pixiv 图片;params: pid。媒体直接投递,文本交给模型。",
|
||||
"jm": "JM 下载或搜索;get params: jm_id, range;search params: keyword, page。",
|
||||
"mail": "邮件绑定、查询和发送。new/send 支持 to_address, subject, body_text, from_address, artifact_ids。",
|
||||
"akaros_mail": "使用 shirobana@akaros.si 收发 Akaros 邮件。send params: to(地址或数组), subject, text, html/cc/bcc(可选);list params: folder,limit,query,direction;info params: id。发件地址不可改写。",
|
||||
"ginkgo": "白果云账户绑定、签到、通知和自动签到;敏感参数不会写入结果 data。",
|
||||
"live": "直播关注;follow/unfollow params: room(0..100), site(1/2,可省)。",
|
||||
"schedule": "创建、查看和取消持久化定时提醒。create params: message, kind(once/daily/weekly), run_at(once所需ISO8601时间), time_of_day(daily/weekly所需HH:MM), weekdays(weekly所需1..7数组);list无参数;cancel params: id。只管理当前会话的任务。",
|
||||
"memo": "用户显式维护的长期备忘录,不是自动聊天记忆。add params: content;list params: query(可选关键词);delete params: id。",
|
||||
"todo": "基于时间的个人约定,与主动提醒不同。add params: content, due_at(ISO8601), expires_at(可选,默认到期后24小时);list params: include_completed(可选布尔);complete/delete params: id。过期事项自动删除。",
|
||||
"wardrobe": "仅私聊:白花在当前用户设定中的独立衣柜;支持任何衣物,包括内衣、内裤、袜子。日常提到已给白花买了/送给白花/白花已获得的衣物时主动add,无需保存指令或额外确认;排除用户自己的衣物、计划、假设、否定和没买成,勿凭空或仅凭图片猜测赠予。add params: name,description(可选详细外观),category(可选);list params: query,limit(1..20默认10),offset(可选);update params: id及name/description/category;delete params: id。衣物外观仅作背景理解,不每次复述完整描述,不代表实际购买或当前穿着。",
|
||||
"my": "统一身份查询、绑定和平台推送设置。",
|
||||
"server": "管理员:Komari 节点状态、负载和轮询设置。",
|
||||
"deepseek": "管理员:DeepSeek 余额查询和监控设置。",
|
||||
"log": "管理员:读取最近日志;tail params: lines(1..100)。",
|
||||
"send_image": "只按 artifact_id 发送已捕获图片,不接受路径、URL 或 base64。",
|
||||
"send_file": "只按 artifact_id 发送已捕获文件,严格禁止任意路径。",
|
||||
"send_sticker": "只按 artifact_id 将已捕获图片作为贴纸发送。",
|
||||
}
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
"""Validate tool calls and directly await existing business handlers.
|
||||
|
||||
No call passes through ``dispatch`` and no background ``create_task`` is
|
||||
used. Legacy output is captured first. Only media from setu/pixiv/jm is
|
||||
automatically delivered; all other captured text remains model-facing.
|
||||
``modules`` and ``send_message`` are injectable solely to keep tests fully
|
||||
isolated from network and real user delivery.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
artifacts: Optional[ArtifactStore] = None,
|
||||
*,
|
||||
modules: Optional[Mapping[str, Any]] = None,
|
||||
send_message: Optional[Callable[..., Awaitable[Any]]] = None,
|
||||
) -> None:
|
||||
self.artifacts = artifacts or ArtifactStore()
|
||||
self._modules = dict(modules or {})
|
||||
self._send_message = send_message
|
||||
|
||||
def prompt_schema(self, ctx: ToolContext) -> list:
|
||||
"""Return model-facing JSON schemas, filtered by execution privileges."""
|
||||
schemas = []
|
||||
for name, actions in _ACTIONS.items():
|
||||
if name == "wardrobe" and ctx.msg_type != "private":
|
||||
continue
|
||||
if name in _ADMIN_TOOLS and not ctx.is_admin:
|
||||
continue
|
||||
visible_actions = [
|
||||
action for action in actions
|
||||
if ctx.is_admin or action not in _ADMIN_ACTIONS.get(name, ())
|
||||
]
|
||||
schemas.append({
|
||||
"name": name,
|
||||
"description": _DESCRIPTIONS[name],
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": visible_actions},
|
||||
"params": {"type": "object", "additionalProperties": True},
|
||||
},
|
||||
"required": ["action"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
})
|
||||
return schemas
|
||||
|
||||
def _module(self, name: str) -> Any:
|
||||
if name not in self._modules:
|
||||
self._modules[name] = importlib.import_module(_MODULES[name])
|
||||
return self._modules[name]
|
||||
|
||||
@staticmethod
|
||||
def _error(code: str, message: str, retryable: bool = False) -> ToolResult:
|
||||
return ToolResult(False, code, message, retryable=retryable)
|
||||
|
||||
@staticmethod
|
||||
def _check_params(
|
||||
params: Dict[str, Any],
|
||||
allowed: tuple = (),
|
||||
required: tuple = (),
|
||||
) -> None:
|
||||
unknown = set(params) - set(allowed)
|
||||
missing = [key for key in required if key not in params]
|
||||
if unknown or missing:
|
||||
details = []
|
||||
if missing:
|
||||
details.append("缺少参数 " + ", ".join(missing))
|
||||
if unknown:
|
||||
details.append("不支持的参数 " + ", ".join(sorted(unknown)))
|
||||
raise ValueError(";".join(details) or "工具参数无效")
|
||||
|
||||
@staticmethod
|
||||
def _string(
|
||||
value: Any,
|
||||
field: str,
|
||||
*,
|
||||
max_length: int = 200,
|
||||
no_whitespace: bool = False,
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > max_length:
|
||||
raise ValueError("invalid %s" % field)
|
||||
value = value.strip()
|
||||
if "\x00" in value or "\r" in value or "\n" in value:
|
||||
raise ValueError("invalid %s" % field)
|
||||
if no_whitespace and any(char.isspace() for char in value):
|
||||
raise ValueError("invalid %s" % field)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _integer(value: Any, field: str, minimum: int, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError("invalid %s" % field)
|
||||
if not minimum <= value <= maximum:
|
||||
raise ValueError("invalid %s" % field)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _enabled(params: Dict[str, Any], key: str = "enabled") -> str:
|
||||
value = params.get(key)
|
||||
if isinstance(value, bool):
|
||||
return "on" if value else "off"
|
||||
if value in ("on", "off"):
|
||||
return value
|
||||
raise ValueError("invalid enabled flag")
|
||||
|
||||
@staticmethod
|
||||
def _address(value: Any, field: str = "address") -> str:
|
||||
address = ToolExecutor._string(value, field, max_length=320, no_whitespace=True).lower()
|
||||
if not re.fullmatch(r"[^@\s]+@[^@\s]+", address):
|
||||
raise ValueError("invalid %s" % field)
|
||||
return address
|
||||
|
||||
@staticmethod
|
||||
def _raw(prefix: str, action: str, *values: Any) -> str:
|
||||
"""Construct a whitespace-parsed legacy command without invoking a shell."""
|
||||
tail = [str(value) for value in values if value is not None and value != ""]
|
||||
return "." + prefix + (" " + " ".join([action, *tail]) if action else "")
|
||||
|
||||
@staticmethod
|
||||
def _mail_raw(action: str, *values: Any) -> str:
|
||||
"""Mail alone uses ``shlex.split``; quote its subject/body accordingly."""
|
||||
tail = [str(value) for value in values if value is not None and value != ""]
|
||||
return ".mail " + shlex.join([action, *tail])
|
||||
|
||||
async def execute(self, name: str, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
||||
"""Validate and execute one tool, returning errors instead of tracebacks."""
|
||||
if name not in _ACTIONS:
|
||||
return self._error("unknown_tool", "未知工具")
|
||||
if not isinstance(args, dict):
|
||||
return self._error("invalid_args", "工具参数必须是对象")
|
||||
# Outbound tools also accept the convenient direct {artifact_id: ...} form.
|
||||
if name in _OUTBOUND and "action" not in args:
|
||||
action, params = "send", args
|
||||
else:
|
||||
if set(args) - {"action", "params"}:
|
||||
return self._error("invalid_args", "工具调用只允许 action 和 params")
|
||||
action = args.get("action")
|
||||
params = args.get("params", {})
|
||||
if not isinstance(action, str) or action not in _ACTIONS[name] or not isinstance(params, dict):
|
||||
return self._error("invalid_args", "工具 action 或 params 无效")
|
||||
if name in _ADMIN_TOOLS and not ctx.is_admin:
|
||||
return self._error("permission_denied", "该工具仅管理员可用")
|
||||
if action in _ADMIN_ACTIONS.get(name, ()) and not ctx.is_admin:
|
||||
return self._error("permission_denied", "该操作仅管理员可用")
|
||||
if name == "wardrobe" and ctx.msg_type != "private":
|
||||
return self._error("permission_denied", "衣柜仅在所属用户的私聊中使用")
|
||||
try:
|
||||
if name == "wardrobe":
|
||||
return await self._execute_wardrobe(action, params, ctx)
|
||||
if name == "akaros_mail":
|
||||
return await self._execute_akaros_mail(action, params)
|
||||
if name in ("memo", "todo"):
|
||||
return await self._execute_notebook(name, action, params, ctx)
|
||||
if name == "schedule":
|
||||
return await self._execute_schedule(action, params, ctx)
|
||||
if name in _OUTBOUND:
|
||||
return await self._execute_outbound(name, params, ctx)
|
||||
if name == "mail" and action in ("new", "send") and "artifact_ids" in params:
|
||||
return await self._mail_with_attachments(params, ctx)
|
||||
capture = CaptureChannel(ctx, self.artifacts)
|
||||
await self._dispatch(name, action, params, ctx, capture)
|
||||
return await self._captured_result(name, capture, ctx)
|
||||
except ArtifactError as exc:
|
||||
return self._error("invalid_artifact", str(exc))
|
||||
except ValueError as exc:
|
||||
return self._error("invalid_args", str(exc) or "工具参数无效")
|
||||
except Exception:
|
||||
# Do not expose credentials, local paths or tracebacks to the model.
|
||||
return self._error(
|
||||
"handler_error",
|
||||
"工具执行失败,请稍后重试",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wardrobe_item(item: dict) -> dict:
|
||||
# Model needs stable IDs for later edits, never owner IDs or DB timestamps.
|
||||
return {key: item[key] for key in ("id", "name", "category", "description") if key in item}
|
||||
|
||||
async def _execute_wardrobe(self, action: str, params: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
||||
module = self._module("wardrobe")
|
||||
store = module.wardrobe
|
||||
if action == "add":
|
||||
self._check_params(params, ("name", "description", "category"), ("name",))
|
||||
item = await asyncio.to_thread(store.add, ctx, params["name"],
|
||||
params.get("description", ""), params.get("category", "其他"))
|
||||
return ToolResult(True, "ok", f"已保存到衣柜:{item['name']}。",
|
||||
data={"action": "add", "item": self._wardrobe_item(item)})
|
||||
if action == "list":
|
||||
self._check_params(params, ("query", "limit", "offset"))
|
||||
limit = self._integer(params.get("limit", 10), "limit", 1, 20)
|
||||
offset = self._integer(params.get("offset", 0), "offset", 0, 200)
|
||||
items = await asyncio.to_thread(store.list, ctx, params.get("query"))
|
||||
selected, parts, size = [], [], 0
|
||||
for item in items[offset:offset + limit]:
|
||||
formatted = f"{item['name']}({item['category']})" + (f":{item['description']}" if item['description'] else "")
|
||||
if size + len(formatted) > 12000:
|
||||
break
|
||||
selected.append(self._wardrobe_item(item)); parts.append(formatted); size += len(formatted)
|
||||
next_offset = offset + len(selected)
|
||||
text = "衣柜为空" if not items else "衣柜记录:\n" + "\n".join(parts)
|
||||
if next_offset < len(items):
|
||||
text += "\n还有未展示的衣物,可以继续查询;这不是全部库存。"
|
||||
return ToolResult(True, "ok", text, data={"items": selected, "total": len(items),
|
||||
"next_offset": next_offset if next_offset < len(items) else None})
|
||||
if action == "update":
|
||||
self._check_params(params, ("id", "name", "description", "category"), ("id",))
|
||||
changes = {k: params[k] for k in ("name", "description", "category") if k in params}
|
||||
if not changes:
|
||||
raise ValueError("至少提供一项衣物修改")
|
||||
item = await asyncio.to_thread(store.update, ctx, params["id"], **changes)
|
||||
return ToolResult(True, "ok", f"衣物细节已经改好:{item['name']}。",
|
||||
data={"action": "update", "item": self._wardrobe_item(item)})
|
||||
self._check_params(params, ("id",), ("id",))
|
||||
item = await asyncio.to_thread(store.delete, ctx, params["id"])
|
||||
return ToolResult(True, "ok", f"已从衣柜移除:{item['name']}",
|
||||
data={"action": "delete", "item": self._wardrobe_item(item)})
|
||||
|
||||
async def _execute_akaros_mail(self, action: str, params: Dict[str, Any]) -> ToolResult:
|
||||
module = self._module("akaros_mail")
|
||||
if action == "send":
|
||||
self._check_params(params, ("to", "subject", "text", "html", "cc", "bcc"),
|
||||
("to", "subject", "text"))
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
module.send_email, params["to"], params["subject"], params["text"],
|
||||
params.get("html"), params.get("cc"), params.get("bcc"))
|
||||
except Exception as exc:
|
||||
return self._error("send_unknown", f"Akaros 发件结果未知:{str(exc)[:300]}")
|
||||
return ToolResult(True, "ok", "Akaros 邮件已接受发送", data=result)
|
||||
if action == "list":
|
||||
self._check_params(params, ("folder", "limit", "query", "direction"))
|
||||
result = await asyncio.to_thread(
|
||||
module.list_messages, params.get("folder", "inbox"),
|
||||
params.get("limit", 10), params.get("query"), params.get("direction"))
|
||||
return ToolResult(True, "ok", module.format_messages(result), data=result)
|
||||
self._check_params(params, ("id",), ("id",))
|
||||
result = await asyncio.to_thread(module.get_message, params["id"])
|
||||
return ToolResult(True, "ok", module.format_message(result), data=result)
|
||||
|
||||
async def _execute_notebook(self, name: str, action: str, params: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
||||
module = self._module(name)
|
||||
store = module.notebook
|
||||
if name == "memo":
|
||||
if action == "add":
|
||||
self._check_params(params, ("content",), ("content",))
|
||||
item = await asyncio.to_thread(store.add_memo, ctx, params["content"])
|
||||
return ToolResult(True, "ok", "备忘录已保存:" + module.format_memo(item), data={"item": item})
|
||||
if action == "list":
|
||||
self._check_params(params, ("query",))
|
||||
query = params.get("query")
|
||||
if query is not None:
|
||||
query = self._string(query, "query", max_length=200)
|
||||
items = await asyncio.to_thread(store.list_memos, ctx, query)
|
||||
text = "备忘录为空" if not items else "备忘录:\n" + "\n".join(module.format_memo(x) for x in items)
|
||||
return ToolResult(True, "ok", text, data={"items": items})
|
||||
self._check_params(params, ("id",), ("id",))
|
||||
item_id = self._string(params["id"], "id", max_length=32, no_whitespace=True)
|
||||
item = await asyncio.to_thread(store.delete_memo, ctx, item_id)
|
||||
return ToolResult(True, "ok", f"已删除备忘录 {item['id']}:{item['content']}", data={"item": item})
|
||||
|
||||
if action == "add":
|
||||
self._check_params(params, ("content", "due_at", "expires_at"), ("content", "due_at"))
|
||||
item = await asyncio.to_thread(
|
||||
store.add_todo, ctx, params["content"], params["due_at"], params.get("expires_at"))
|
||||
return ToolResult(True, "ok", "待办已保存:" + module.format_todo(item), data={"item": item})
|
||||
if action == "list":
|
||||
self._check_params(params, ("include_completed",))
|
||||
include = params.get("include_completed", False)
|
||||
if not isinstance(include, bool):
|
||||
raise ValueError("include_completed 必须是布尔值")
|
||||
items = await asyncio.to_thread(store.list_todos, ctx, include)
|
||||
text = "没有待办事项" if not items else "待办事项:\n" + "\n".join(module.format_todo(x) for x in items)
|
||||
return ToolResult(True, "ok", text, data={"items": items})
|
||||
self._check_params(params, ("id",), ("id",))
|
||||
item_id = self._string(params["id"], "id", max_length=32, no_whitespace=True)
|
||||
if action == "complete":
|
||||
item = await asyncio.to_thread(store.complete_todo, ctx, item_id)
|
||||
return ToolResult(True, "ok", f"已完成待办 {item['id']}:{item['content']}", data={"item": item})
|
||||
item = await asyncio.to_thread(store.delete_todo, ctx, item_id)
|
||||
return ToolResult(True, "ok", f"已删除待办 {item['id']}:{item['content']}", data={"item": item})
|
||||
|
||||
async def _execute_schedule(self, action: str, params: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
||||
module = self._module("schedule")
|
||||
if action == "create":
|
||||
task = await asyncio.to_thread(module.scheduler.create_from_params, ctx, params)
|
||||
return ToolResult(
|
||||
True, "ok", "定时任务已创建:" + module.format_task(task),
|
||||
data={"task": task},
|
||||
)
|
||||
if action == "list":
|
||||
self._check_params(params)
|
||||
tasks = await asyncio.to_thread(module.scheduler.list_for_context, ctx)
|
||||
if not tasks:
|
||||
return ToolResult(True, "ok", "当前会话没有进行中的定时任务", data={"tasks": []})
|
||||
text = "当前定时任务:\n" + "\n".join(module.format_task(task) for task in tasks)
|
||||
return ToolResult(True, "ok", text, data={"tasks": tasks})
|
||||
self._check_params(params, ("id",), ("id",))
|
||||
task_id = self._string(params["id"], "id", max_length=32, no_whitespace=True)
|
||||
task = await asyncio.to_thread(module.scheduler.cancel_for_context, ctx, task_id)
|
||||
return ToolResult(
|
||||
True, "ok", f"已取消定时任务 {task['id']}:{task['message']}",
|
||||
data={"task": task},
|
||||
)
|
||||
|
||||
async def _dispatch(
|
||||
self,
|
||||
name: str,
|
||||
action: str,
|
||||
params: Dict[str, Any],
|
||||
ctx: ToolContext,
|
||||
capture: CaptureChannel,
|
||||
) -> None:
|
||||
"""Route to a typed builder and directly await its original handler."""
|
||||
module = self._module(name)
|
||||
if name == "setu":
|
||||
await self._setu(module, action, params, ctx, capture)
|
||||
elif name == "live":
|
||||
await self._live(module, action, params, ctx, capture)
|
||||
elif name == "my":
|
||||
raw = self._my_command(action, params)
|
||||
await module.handle_my(capture, ctx.msg_type, ctx.target_id, ctx.user_id, ctx.user_id, raw[3:].strip())
|
||||
else:
|
||||
raw = self._legacy_command(name, action, params)
|
||||
handler_name = {
|
||||
"pixiv": "handle_pixiv",
|
||||
"jm": "handle_jm",
|
||||
"mail": "handle_mail",
|
||||
"ginkgo": "handle_ginkgo",
|
||||
}.get(name, "handle_command")
|
||||
handler = getattr(module, handler_name)
|
||||
if name in ("pixiv", "jm", "mail", "ginkgo"):
|
||||
await handler(capture, ctx.msg_type, ctx.target_id, ctx.user_id, raw)
|
||||
else:
|
||||
await handler(capture, ctx.msg_type, ctx.target_id, raw)
|
||||
|
||||
async def _setu(
|
||||
self,
|
||||
module: Any,
|
||||
action: str,
|
||||
params: Dict[str, Any],
|
||||
ctx: ToolContext,
|
||||
capture: CaptureChannel,
|
||||
) -> None:
|
||||
if action == "help":
|
||||
self._check_params(params)
|
||||
await capture.send(ctx.msg_type, ctx.target_id, module.HELP_TEXT)
|
||||
return
|
||||
if action == "del-cache":
|
||||
self._check_params(params)
|
||||
await module.handle_del_cache(capture, ctx.msg_type, ctx.target_id, ctx.user_id)
|
||||
return
|
||||
self._check_params(params, ("tags", "r18", "ai"))
|
||||
tags = params.get("tags", [])
|
||||
if not isinstance(tags, list) or len(tags) > 3:
|
||||
raise ValueError("tags must be an array of at most three items")
|
||||
clean_tags = []
|
||||
for tag in tags:
|
||||
tag = self._string(tag, "tag", max_length=80, no_whitespace=True)
|
||||
if not re.fullmatch(r"[\w一-鿿]+", tag):
|
||||
raise ValueError("invalid tag")
|
||||
clean_tags.append(tag)
|
||||
flags = []
|
||||
for key, positive, negative in (("r18", "允许r18", "不要r18"), ("ai", "允许ai", "不要ai")):
|
||||
value = params.get(key)
|
||||
if value is not None and not isinstance(value, bool):
|
||||
raise ValueError("invalid %s" % key)
|
||||
flags.append(positive if value is True else negative if value is False else None)
|
||||
await module.handle_setu(
|
||||
clean_tags, flags[0], flags[1], ctx.msg_type, ctx.target_id, capture,
|
||||
)
|
||||
|
||||
def _legacy_command(self, name: str, action: str, params: Dict[str, Any]) -> str:
|
||||
if name == "pixiv":
|
||||
if action == "get":
|
||||
self._check_params(params, ("pid",), ("pid",))
|
||||
pid = str(params["pid"])
|
||||
if not pid.isdigit() or len(pid) > 20:
|
||||
raise ValueError("invalid pid")
|
||||
return self._raw(name, pid)
|
||||
self._check_params(params)
|
||||
return self._raw(name, action)
|
||||
if name == "jm":
|
||||
if action == "get":
|
||||
self._check_params(params, ("jm_id", "range"), ("jm_id",))
|
||||
jm_id = str(params["jm_id"])
|
||||
chapter_range = params.get("range")
|
||||
if not jm_id.isdigit() or len(jm_id) > 20:
|
||||
raise ValueError("invalid jm_id")
|
||||
if chapter_range is not None and (
|
||||
not isinstance(chapter_range, str)
|
||||
or not re.fullmatch(r"\d{1,4}-\d{1,4}", chapter_range)
|
||||
):
|
||||
raise ValueError("invalid chapter range")
|
||||
return self._raw(name, "zip", jm_id, chapter_range)
|
||||
if action == "search":
|
||||
self._check_params(params, ("keyword", "page"), ("keyword",))
|
||||
keyword = self._string(params["keyword"], "keyword", max_length=100)
|
||||
page = self._integer(params.get("page", 1), "page", 1, 10000)
|
||||
return self._raw(name, "ss", keyword, page)
|
||||
self._check_params(params)
|
||||
return self._raw(name, action)
|
||||
if name == "mail":
|
||||
return self._mail_command(action, params)
|
||||
if name == "ginkgo":
|
||||
return self._ginkgo_command(action, params)
|
||||
if name == "server":
|
||||
return self._server_command(action, params)
|
||||
if name == "deepseek":
|
||||
return self._deepseek_command(action, params)
|
||||
if name == "log":
|
||||
if action == "tail":
|
||||
self._check_params(params, ("lines",))
|
||||
lines = self._integer(params.get("lines", 5), "lines", 1, 100)
|
||||
return self._raw(name, str(lines))
|
||||
self._check_params(params)
|
||||
return self._raw(name, "help")
|
||||
raise ValueError("unsupported tool")
|
||||
|
||||
def _mail_command(self, action: str, params: Dict[str, Any]) -> str:
|
||||
if action in ("new", "send"):
|
||||
self._check_params(
|
||||
params,
|
||||
("to_address", "subject", "body_text", "from_address"),
|
||||
("to_address", "subject", "body_text"),
|
||||
)
|
||||
to_address = self._address(params["to_address"], "to_address")
|
||||
subject = self._string(params["subject"], "subject", max_length=500)
|
||||
body = params["body_text"]
|
||||
if not isinstance(body, str) or len(body) > 100000 or "\x00" in body:
|
||||
raise ValueError("invalid body_text")
|
||||
from_address = params.get("from_address")
|
||||
if from_address is not None:
|
||||
from_address = self._address(from_address, "from_address")
|
||||
return self._mail_raw("new", to_address, subject, body, from_address)
|
||||
if action in ("bind", "unbind", "default"):
|
||||
self._check_params(params, ("address",), ("address",))
|
||||
return self._mail_raw(action, self._address(params["address"]))
|
||||
if action == "list":
|
||||
self._check_params(params, ("window", "address", "limit", "count"))
|
||||
window = params.get("window")
|
||||
limit = params.get("limit", params.get("count"))
|
||||
if window is not None and limit is not None:
|
||||
raise ValueError("window 与 limit/count 只能选择一个")
|
||||
if limit is not None:
|
||||
window = str(self._integer(limit, "limit", 1, 10))
|
||||
if window is not None and (
|
||||
not isinstance(window, str) or not re.fullmatch(r"\d{1,6}[dhm]?", window)
|
||||
):
|
||||
raise ValueError("invalid window")
|
||||
address = params.get("address")
|
||||
return self._mail_raw("list", window, self._address(address) if address else None)
|
||||
if action == "info":
|
||||
self._check_params(params, ("message_id", "mid", "id"))
|
||||
value = params.get("message_id", params.get("mid", params.get("id")))
|
||||
if value is None:
|
||||
raise ValueError("缺少邮件编号 message_id/mid/id")
|
||||
return self._mail_raw("info", self._integer(value, "message_id", 0, 2**63 - 1))
|
||||
if action == "push":
|
||||
self._check_params(params, ("enabled", "interval"), ("enabled",))
|
||||
enabled = self._enabled(params)
|
||||
interval = params.get("interval")
|
||||
if interval is not None:
|
||||
interval = self._integer(interval, "interval", 1, 10080)
|
||||
return self._mail_raw("push", enabled, interval)
|
||||
self._check_params(params)
|
||||
return self._mail_raw(action)
|
||||
|
||||
def _ginkgo_command(self, action: str, params: Dict[str, Any]) -> str:
|
||||
if action == "bind":
|
||||
self._check_params(params, ("account", "password"), ("account", "password"))
|
||||
account = self._string(params["account"], "account", no_whitespace=True)
|
||||
password = self._string(params["password"], "password", no_whitespace=True)
|
||||
return self._raw("ginkgo", "bind", account, password)
|
||||
if action == "unbind":
|
||||
self._check_params(params, ("account",))
|
||||
account = params.get("account")
|
||||
return self._raw("ginkgo", "unbind", self._string(account, "account", no_whitespace=True) if account else None)
|
||||
if action == "notice":
|
||||
self._check_params(params, ("count", "enabled"))
|
||||
if "enabled" in params:
|
||||
return self._raw("ginkgo", "notice", self._enabled(params))
|
||||
count = self._integer(params.get("count", 5), "count", 1, 20)
|
||||
return self._raw("ginkgo", "notice", count)
|
||||
if action == "auto-sign-in":
|
||||
self._check_params(params, ("enabled",), ("enabled",))
|
||||
return self._raw("ginkgo", action, self._enabled(params))
|
||||
self._check_params(params)
|
||||
return self._raw("ginkgo", action)
|
||||
|
||||
def _my_command(self, action: str, params: Dict[str, Any]) -> str:
|
||||
if action == "account":
|
||||
self._check_params(params)
|
||||
return ".my account"
|
||||
if action == "bind":
|
||||
self._check_params(params, ("value",), ("value",))
|
||||
value = self._string(params["value"], "value", max_length=30, no_whitespace=True)
|
||||
if not value.isdigit():
|
||||
raise ValueError("bind value must be numeric")
|
||||
return self._raw("my account", "bind", value)
|
||||
if action == "unbind":
|
||||
self._check_params(params, ("qq",))
|
||||
qq = params.get("qq")
|
||||
if qq is not None and (not str(qq).isdigit()):
|
||||
raise ValueError("invalid qq")
|
||||
return self._raw("my account", "unbind", qq)
|
||||
if action == "push":
|
||||
self._check_params(params, ("platform", "enabled"), ("platform", "enabled"))
|
||||
if params["platform"] not in ("qq", "tg"):
|
||||
raise ValueError("invalid platform")
|
||||
return self._raw("my", params["platform"] + "-push", self._enabled(params))
|
||||
self._check_params(params)
|
||||
return ".my help"
|
||||
|
||||
def _server_command(self, action: str, params: Dict[str, Any]) -> str:
|
||||
if action == "status":
|
||||
self._check_params(params, ("seconds", "node"))
|
||||
seconds = params.get("seconds")
|
||||
node = params.get("node")
|
||||
if seconds is not None:
|
||||
seconds = self._integer(seconds, "seconds", 1, 30)
|
||||
if node is not None:
|
||||
node = self._string(str(node), "node", max_length=100, no_whitespace=True)
|
||||
return self._raw("server", "status", seconds, node)
|
||||
if action == "load":
|
||||
self._check_params(params, ("node", "hours", "metric"), ("node",))
|
||||
node = self._string(params["node"], "node", max_length=100, no_whitespace=True)
|
||||
hours = self._integer(params.get("hours", 1), "hours", 1, 720)
|
||||
metric = params.get("metric", "all")
|
||||
allowed = ("cpu", "ram", "swap", "load", "temp", "disk", "network", "process", "connections", "all")
|
||||
if metric not in allowed:
|
||||
raise ValueError("invalid metric")
|
||||
return self._raw("server", "load", node, hours, metric)
|
||||
if action == "polling":
|
||||
self._check_params(params, ("enabled", "minutes"), ("enabled",))
|
||||
minutes = params.get("minutes")
|
||||
if minutes is not None:
|
||||
minutes = self._integer(minutes, "minutes", 1, 1440)
|
||||
return self._raw("server", "polling", self._enabled(params), minutes)
|
||||
self._check_params(params)
|
||||
return self._raw("server", action)
|
||||
|
||||
def _deepseek_command(self, action: str, params: Dict[str, Any]) -> str:
|
||||
if action in ("threshold", "polling"):
|
||||
self._check_params(params, ("value",), ("value",))
|
||||
minimum = 0 if action == "threshold" else 1
|
||||
return self._raw("deepseek", action, self._integer(params["value"], "value", minimum, 100000))
|
||||
if action == "alert":
|
||||
self._check_params(params, ("enabled",), ("enabled",))
|
||||
return self._raw("deepseek", action, self._enabled(params))
|
||||
self._check_params(params)
|
||||
return self._raw("deepseek", action)
|
||||
|
||||
async def _live(
|
||||
self,
|
||||
module: Any,
|
||||
action: str,
|
||||
params: Dict[str, Any],
|
||||
ctx: ToolContext,
|
||||
capture: CaptureChannel,
|
||||
) -> None:
|
||||
"""Live has no legacy async handler, so use its public storage functions."""
|
||||
if action == "help":
|
||||
self._check_params(params)
|
||||
await capture.send(ctx.msg_type, ctx.target_id, module.HELP_TEXT)
|
||||
return
|
||||
if action == "status":
|
||||
self._check_params(params)
|
||||
text = module.status_text(ctx.msg_type, ctx.target_id, self._channel_name(ctx))
|
||||
await capture.send(ctx.msg_type, ctx.target_id, text)
|
||||
return
|
||||
self._check_params(params, ("room", "site"), ("room",))
|
||||
room = self._integer(params["room"], "room", 0, 100)
|
||||
site = params.get("site")
|
||||
if site is not None:
|
||||
site = self._integer(site, "site", 1, 2)
|
||||
site_value = str(site or "")
|
||||
if ctx.msg_type == "group":
|
||||
follow_channel, follow_id = self._channel_name(ctx), ctx.target_id
|
||||
else:
|
||||
identity = importlib.import_module("identity")
|
||||
if not identity.push_targets(ctx.user_id):
|
||||
await capture.send(ctx.msg_type, ctx.target_id, "身份异常:找不到可用的推送目标,请联系管理员")
|
||||
return
|
||||
follow_channel, follow_id = "tg", ctx.user_id
|
||||
function = module.add_follow if action == "follow" else module.remove_follow
|
||||
await asyncio.to_thread(function, str(room), ctx.msg_type, follow_id, site_value, follow_channel)
|
||||
label = {"1": "主站", "2": "副站", "": "全部"}[site_value]
|
||||
verb = "已经添加" if action == "follow" else "已经取消"
|
||||
await capture.send(ctx.msg_type, ctx.target_id, f"{verb}对{label}直播间{room}的关注!")
|
||||
|
||||
@staticmethod
|
||||
def _channel_name(ctx: ToolContext) -> str:
|
||||
return getattr(ctx.channel, "name", "unknown")
|
||||
|
||||
async def _sender(self) -> Callable[..., Awaitable[Any]]:
|
||||
if self._send_message is None:
|
||||
self._send_message = importlib.import_module("channel").send_message
|
||||
return self._send_message
|
||||
|
||||
async def _captured_result(
|
||||
self,
|
||||
name: str,
|
||||
capture: CaptureChannel,
|
||||
ctx: ToolContext,
|
||||
) -> ToolResult:
|
||||
artifacts = capture.public_artifacts()
|
||||
deliveries = []
|
||||
if name in _MEDIA_AUTOSEND:
|
||||
for media in capture.media:
|
||||
deliveries.append(await self._deliver_captured(media, ctx))
|
||||
data = {
|
||||
"request_id": ctx.request_id,
|
||||
"task_id": ctx.task_id,
|
||||
"captured_text": list(capture.texts),
|
||||
"deliveries": deliveries,
|
||||
}
|
||||
if capture.capture_errors:
|
||||
data["capture_errors"] = list(capture.capture_errors)
|
||||
failed = any(not item["ok"] for item in deliveries)
|
||||
text = capture.safe_text()
|
||||
legacy_failure = any(marker in text for marker in (
|
||||
"发送失败", "多次尝试", "请手动访问", "未绑定", "参数错误", "参数解析失败",
|
||||
"仅管理员", "无法", "不存在", "暂时不可用", "请求图片失败", "获取图片信息失败",
|
||||
"读取邮件失败", "未找到", "没有找到",
|
||||
))
|
||||
mail_send_unknown = name == "mail" and "发送失败" in text
|
||||
code = ("delivery_failed" if failed else "send_unknown" if mail_send_unknown
|
||||
else "legacy_failure" if legacy_failure
|
||||
else "media_capture_failed" if capture.capture_errors else "ok")
|
||||
return ToolResult(
|
||||
ok=not failed and not legacy_failure and not capture.capture_errors,
|
||||
code=code,
|
||||
safe_message=text,
|
||||
data=data,
|
||||
artifacts=artifacts,
|
||||
retryable=(not mail_send_unknown and (
|
||||
failed or any(marker in text for marker in ("失败", "不可用", "超时"))
|
||||
)),
|
||||
)
|
||||
|
||||
async def _deliver_captured(self, media: Dict[str, Any], ctx: ToolContext) -> Dict[str, Any]:
|
||||
artifact_id = media["artifact_id"]
|
||||
record = {"artifact_id": artifact_id, "media_type": media["type"], "ok": False}
|
||||
try:
|
||||
path = await asyncio.to_thread(self.artifacts.materialize, artifact_id, ctx)
|
||||
data = dict(media["data"])
|
||||
data["file"] = path.as_uri()
|
||||
sender = await self._sender()
|
||||
result = await sender(ctx.channel, ctx.msg_type, ctx.target_id, [{"type": media["type"], "data": data}])
|
||||
record["ok"] = result is not False
|
||||
except Exception:
|
||||
record["ok"] = False
|
||||
return record
|
||||
|
||||
async def _mail_with_attachments(self, params: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
||||
"""Call the attachment-aware mail API without routing through ``.mail new``."""
|
||||
self._check_params(
|
||||
params,
|
||||
("to_address", "subject", "body_text", "from_address", "artifact_ids"),
|
||||
("to_address", "subject", "body_text", "artifact_ids"),
|
||||
)
|
||||
to_address = self._address(params["to_address"], "to_address")
|
||||
subject = self._string(params["subject"], "subject", max_length=500)
|
||||
body = params["body_text"]
|
||||
if not isinstance(body, str) or len(body) > 100000 or "\x00" in body:
|
||||
raise ValueError("invalid body_text")
|
||||
artifact_ids = params["artifact_ids"]
|
||||
if not isinstance(artifact_ids, list) or not artifact_ids or len(artifact_ids) > 10:
|
||||
raise ValueError("artifact_ids must contain 1..10 ids")
|
||||
if any(not isinstance(value, str) for value in artifact_ids):
|
||||
raise ValueError("invalid artifact id")
|
||||
module = self._module("mail")
|
||||
data = await asyncio.to_thread(module._load_user, ctx.user_id)
|
||||
from_address = params.get("from_address") or data.get("default")
|
||||
if not from_address:
|
||||
return self._error("mail_identity_required", "尚未绑定邮箱或未设默认地址")
|
||||
from_address = self._address(from_address, "from_address")
|
||||
if from_address not in data.get("addresses", []):
|
||||
return self._error("mail_identity_required", "发件地址未绑定")
|
||||
paths = [
|
||||
await asyncio.to_thread(self.artifacts.materialize, artifact_id, ctx)
|
||||
for artifact_id in artifact_ids
|
||||
]
|
||||
send = getattr(module, "send_with_attachments")
|
||||
result = send(
|
||||
ctx.user_id, from_address, to_address, subject, body,
|
||||
[str(path) for path in paths],
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not isinstance(result, dict) or not result.get("ok"):
|
||||
error_code = str(result.get("error_code") or "send_failed") if isinstance(result, dict) else "send_failed"
|
||||
error = str(result.get("error") or "附件邮件发送失败") if isinstance(result, dict) else "附件邮件发送失败"
|
||||
return ToolResult(
|
||||
False, error_code, error,
|
||||
data={"request_id": ctx.request_id, "task_id": ctx.task_id},
|
||||
artifacts=[self.artifacts.get(artifact_id, ctx) for artifact_id in artifact_ids],
|
||||
retryable=error_code in ("send_failed", "upstream_unavailable"),
|
||||
)
|
||||
return ToolResult(
|
||||
True,
|
||||
"sent",
|
||||
"邮件及附件已发送",
|
||||
data={"request_id": ctx.request_id, "task_id": ctx.task_id,
|
||||
"attachment_count": len(paths), "mail": result},
|
||||
artifacts=[self.artifacts.get(artifact_id, ctx) for artifact_id in artifact_ids],
|
||||
)
|
||||
|
||||
async def _execute_outbound(self, name: str, params: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
||||
"""Deliver only a registered artifact; no arbitrary filesystem input exists."""
|
||||
self._check_params(params, ("artifact_id",), ("artifact_id",))
|
||||
artifact_id = params["artifact_id"]
|
||||
if not isinstance(artifact_id, str):
|
||||
raise ValueError("invalid artifact id")
|
||||
public = self.artifacts.get(artifact_id, ctx)
|
||||
if name in ("send_image", "send_sticker") and public["media_type"] != "image":
|
||||
return self._error("invalid_artifact", "该 artifact 不是图片")
|
||||
path = await asyncio.to_thread(self.artifacts.materialize, artifact_id, ctx)
|
||||
segment_type = "file" if name == "send_file" else "image"
|
||||
data: Dict[str, Any] = {"file": path.as_uri()}
|
||||
if name == "send_file":
|
||||
data["name"] = public["filename"]
|
||||
if name == "send_sticker":
|
||||
data["sub_type"] = 1
|
||||
delivery = {"artifact_id": artifact_id, "media_type": segment_type, "ok": False}
|
||||
try:
|
||||
sender = await self._sender()
|
||||
result = await sender(ctx.channel, ctx.msg_type, ctx.target_id, [{"type": segment_type, "data": data}])
|
||||
delivery["ok"] = result is not False
|
||||
except Exception:
|
||||
delivery["ok"] = False
|
||||
if not delivery["ok"]:
|
||||
return ToolResult(
|
||||
False, "delivery_failed", "媒体发送失败,请稍后重试",
|
||||
data={"delivery": delivery}, artifacts=[public], retryable=True,
|
||||
)
|
||||
return ToolResult(
|
||||
True, "sent", "媒体已发送",
|
||||
data={"delivery": delivery}, artifacts=[public],
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared data types for agent tool execution."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolContext:
|
||||
"""Normalized request context supplied by the notification/router layer.
|
||||
|
||||
``user_id`` is the unified identity used by legacy business handlers;
|
||||
``target_id`` remains the actual private-chat or group delivery target.
|
||||
``request_id`` and ``task_id`` are correlation values only and are never
|
||||
interpolated into legacy command strings.
|
||||
"""
|
||||
|
||||
channel: Any
|
||||
msg_type: str
|
||||
user_id: int
|
||||
target_id: int
|
||||
is_admin: bool = False
|
||||
request_id: Optional[str] = None
|
||||
task_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.msg_type not in ("private", "group"):
|
||||
raise ValueError("msg_type must be 'private' or 'group'")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""Safe, serializable result returned to the model orchestration layer.
|
||||
|
||||
``safe_message`` contains captured legacy text (or a generic error), never
|
||||
a traceback. ``artifacts`` contains public metadata only; local paths,
|
||||
base64 payloads and source URLs are kept inside ``ArtifactStore``.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
code: str
|
||||
safe_message: str = ""
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
artifacts: List[Dict[str, Any]] = field(default_factory=list)
|
||||
retryable: bool = False
|
||||
@@ -0,0 +1,143 @@
|
||||
"""SQLite-backed command/agent mode storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = Path(__file__).resolve().parents[1] / "data" / "agent.db"
|
||||
_VALID_MODES = frozenset(("command", "agent", "chat"))
|
||||
|
||||
|
||||
class ModeStore:
|
||||
"""Persist conversation mode with private and group scopes kept separate.
|
||||
|
||||
A private conversation is keyed by ``(channel, uid)``. A group conversation
|
||||
is keyed by ``(channel, group_id)`` and therefore shared by its members.
|
||||
Every operation uses a short-lived SQLite connection, making one instance
|
||||
safe to share between the event loop and worker threads.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[Union[str, os.PathLike]] = None) -> None:
|
||||
self.db_path = Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_modes (
|
||||
channel TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK(mode IN ('command', 'agent', 'chat')),
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (channel, scope, subject_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self.db_path), timeout=10)
|
||||
conn.execute("PRAGMA busy_timeout = 10000")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _key(channel: str, uid: int, group_id: Optional[int]) -> tuple:
|
||||
if not isinstance(channel, str) or not channel.strip():
|
||||
raise ValueError("channel must be a non-empty string")
|
||||
if group_id is not None:
|
||||
return channel.strip(), "group", str(group_id)
|
||||
return channel.strip(), "private", str(uid)
|
||||
|
||||
def _configured_default(self, channel: str, uid: int, group_id: Optional[int]) -> str:
|
||||
"""Return the settings.json default without overriding saved preferences.
|
||||
|
||||
Example: {"chat_mode": {"default": "command", "admin": "chat",
|
||||
"group": "command"}}. Administrator chat is intended for private
|
||||
conversations; groups stay in command unless explicitly configured.
|
||||
"""
|
||||
if group_id is not None:
|
||||
scope = "group"
|
||||
else:
|
||||
try:
|
||||
import identity
|
||||
scope = "admin" if identity.is_admin(uid) else "default"
|
||||
except Exception:
|
||||
scope = "default"
|
||||
try:
|
||||
settings_path = Path(__file__).resolve().parents[1] / "settings.json"
|
||||
with settings_path.open("r", encoding="utf-8") as fh:
|
||||
config = json.load(fh).get("chat_mode", {})
|
||||
mode = config.get(scope, config.get("default", "command"))
|
||||
return mode if mode in ("chat", "agent") else "command"
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return "command"
|
||||
|
||||
def get_mode(self, channel: str, uid: int, group_id: Optional[int] = None) -> str:
|
||||
"""Return saved mode, then the config default; ultimate fallback is command."""
|
||||
key = self._key(channel, uid, group_id)
|
||||
with self._lock, self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT mode FROM agent_modes WHERE channel=? AND scope=? AND subject_id=?",
|
||||
key,
|
||||
).fetchone()
|
||||
if row and row[0] in _VALID_MODES:
|
||||
return row[0]
|
||||
return self._configured_default(channel, uid, group_id)
|
||||
|
||||
def set_mode(
|
||||
self,
|
||||
channel: str,
|
||||
uid: int,
|
||||
mode: str,
|
||||
group_id: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Set and return the mode for a private user or an entire group."""
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError("mode must be 'command' or 'chat'")
|
||||
key = self._key(channel, uid, group_id)
|
||||
with self._lock, self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_modes(channel, scope, subject_id, mode, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(channel, scope, subject_id) DO UPDATE SET
|
||||
mode=excluded.mode, updated_at=excluded.updated_at
|
||||
""",
|
||||
(*key, mode, time.time()),
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
_default_store: Optional[ModeStore] = None
|
||||
_default_lock = threading.Lock()
|
||||
|
||||
|
||||
def _default() -> ModeStore:
|
||||
global _default_store
|
||||
if _default_store is None:
|
||||
with _default_lock:
|
||||
if _default_store is None:
|
||||
_default_store = ModeStore()
|
||||
return _default_store
|
||||
|
||||
|
||||
def get_mode(channel: str, uid: int, group_id: Optional[int] = None) -> str:
|
||||
"""Module-level convenience wrapper using ``bot-utility/data/agent.db``."""
|
||||
return _default().get_mode(channel, uid, group_id)
|
||||
|
||||
|
||||
def set_mode(
|
||||
channel: str,
|
||||
uid: int,
|
||||
mode: str,
|
||||
group_id: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Module-level convenience wrapper using ``bot-utility/data/agent.db``."""
|
||||
return _default().set_mode(channel, uid, mode, group_id)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Compatibility import surface for the agent notification layer."""
|
||||
|
||||
from .artifacts import ArtifactError, ArtifactStore
|
||||
from .capture import CaptureChannel
|
||||
from .executor import ToolExecutor
|
||||
from .models import ToolContext, ToolResult
|
||||
from .modes import ModeStore, get_mode, set_mode
|
||||
|
||||
__all__ = [
|
||||
"ArtifactError",
|
||||
"ArtifactStore",
|
||||
"CaptureChannel",
|
||||
"ModeStore",
|
||||
"ToolContext",
|
||||
"ToolExecutor",
|
||||
"ToolResult",
|
||||
"get_mode",
|
||||
"set_mode",
|
||||
]
|
||||
Reference in New Issue
Block a user