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:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+22
View File
@@ -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",
]
+361
View File
@@ -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
+96
View File
@@ -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)
+826
View File
@@ -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],
)
+44
View File
@@ -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
+143
View File
@@ -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)
+19
View File
@@ -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",
]