Files
shirobana-bot/bot-utility/agent/executor.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

827 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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],
)