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,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],
|
||||
)
|
||||
Reference in New Issue
Block a user