mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-10 15:00:15 +08:00
Compare commits
10 Commits
fix/7452
...
perf/stdio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264e3eedd8 | ||
|
|
03cafea1ce | ||
|
|
7c827e4ec8 | ||
|
|
70657907f2 | ||
|
|
dbafa07b52 | ||
|
|
68a195e12b | ||
|
|
2274e0efc9 | ||
|
|
f1f1720c58 | ||
|
|
6691411550 | ||
|
|
8d28693e32 |
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Generic
|
||||
|
||||
from tenacity import (
|
||||
@@ -21,6 +23,75 @@ from astrbot.core.utils.log_pipe import LogPipe
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
|
||||
{
|
||||
"python",
|
||||
"python3",
|
||||
"py",
|
||||
"node",
|
||||
"npx",
|
||||
"npm",
|
||||
"pnpm",
|
||||
"yarn",
|
||||
"bun",
|
||||
"bunx",
|
||||
"deno",
|
||||
"uv",
|
||||
"uvx",
|
||||
}
|
||||
)
|
||||
_DENIED_STDIO_COMMANDS = frozenset(
|
||||
{
|
||||
"bash",
|
||||
"sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
"cmd",
|
||||
"cmd.exe",
|
||||
"powershell",
|
||||
"powershell.exe",
|
||||
"pwsh",
|
||||
"pwsh.exe",
|
||||
"osascript",
|
||||
"open",
|
||||
"curl",
|
||||
"wget",
|
||||
"nc",
|
||||
"netcat",
|
||||
"telnet",
|
||||
"ssh",
|
||||
"scp",
|
||||
"rm",
|
||||
"mv",
|
||||
"cp",
|
||||
"dd",
|
||||
"mkfs",
|
||||
"sudo",
|
||||
"su",
|
||||
"chmod",
|
||||
"chown",
|
||||
"kill",
|
||||
"killall",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"halt",
|
||||
}
|
||||
)
|
||||
_SHELL_META_RE = re.compile(r"[\r\n\x00;&|<>`$]")
|
||||
_PYTHON_INLINE_CODE_FLAGS = frozenset({"-c"})
|
||||
_JS_INLINE_CODE_FLAGS = frozenset({"-e", "--eval", "-p", "--print"})
|
||||
_DENIED_DOCKER_ARGS = frozenset(
|
||||
{
|
||||
"--privileged",
|
||||
"--pid=host",
|
||||
"--network=host",
|
||||
"--net=host",
|
||||
"--ipc=host",
|
||||
}
|
||||
)
|
||||
_STDIO_ALLOWLIST_ENV = "ASTRBOT_MCP_STDIO_ALLOWED_COMMANDS"
|
||||
|
||||
try:
|
||||
import anyio
|
||||
import mcp
|
||||
@@ -42,11 +113,129 @@ def _prepare_config(config: dict) -> dict:
|
||||
"""Prepare configuration, handle nested format"""
|
||||
if config.get("mcpServers"):
|
||||
first_key = next(iter(config["mcpServers"]))
|
||||
config = config["mcpServers"][first_key]
|
||||
config = dict(config["mcpServers"][first_key])
|
||||
else:
|
||||
config = dict(config)
|
||||
config.pop("active", None)
|
||||
return config
|
||||
|
||||
|
||||
def _normalize_stdio_command_name(command: str) -> str:
|
||||
command = command.strip()
|
||||
if "\\" in command:
|
||||
command_name = PureWindowsPath(command).name
|
||||
else:
|
||||
command_name = Path(command).name
|
||||
command_name = command_name.lower()
|
||||
for suffix in (".exe", ".cmd", ".bat"):
|
||||
if command_name.endswith(suffix):
|
||||
return command_name[: -len(suffix)]
|
||||
return command_name
|
||||
|
||||
|
||||
def _get_stdio_command_allowlist() -> set[str]:
|
||||
allowed = set(_DEFAULT_STDIO_COMMAND_ALLOWLIST)
|
||||
configured = os.environ.get(_STDIO_ALLOWLIST_ENV, "")
|
||||
if configured.strip():
|
||||
allowed = {
|
||||
_normalize_stdio_command_name(item)
|
||||
for item in configured.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return allowed
|
||||
|
||||
|
||||
def _is_stdio_config(config: dict) -> bool:
|
||||
cfg = _prepare_config(config.copy())
|
||||
return "url" not in cfg
|
||||
|
||||
|
||||
def _validate_stdio_args(command_name: str, args: object) -> None:
|
||||
if args is None:
|
||||
return
|
||||
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
||||
raise ValueError("MCP stdio args must be a list of strings.")
|
||||
|
||||
for arg in args:
|
||||
if "\x00" in arg or "\r" in arg or "\n" in arg:
|
||||
raise ValueError("MCP stdio args cannot contain control characters.")
|
||||
|
||||
if command_name.startswith("python") or command_name == "py":
|
||||
if any(
|
||||
arg == "-c"
|
||||
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg)
|
||||
for arg in args
|
||||
):
|
||||
raise ValueError(
|
||||
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed."
|
||||
)
|
||||
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"):
|
||||
if any(
|
||||
arg in _JS_INLINE_CODE_FLAGS
|
||||
or arg == "eval"
|
||||
or (
|
||||
arg.startswith("-")
|
||||
and not arg.startswith("--")
|
||||
and any(c in arg for c in "ep")
|
||||
)
|
||||
for arg in args
|
||||
):
|
||||
raise ValueError(
|
||||
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed."
|
||||
)
|
||||
elif command_name == "docker":
|
||||
denied = []
|
||||
for i, arg in enumerate(args):
|
||||
if arg in _DENIED_DOCKER_ARGS:
|
||||
denied.append(arg)
|
||||
elif (
|
||||
arg in {"--network", "--net", "--pid", "--ipc"}
|
||||
and i + 1 < len(args)
|
||||
and args[i + 1] == "host"
|
||||
):
|
||||
denied.append(f"{arg} {args[i + 1]}")
|
||||
if denied:
|
||||
raise ValueError(
|
||||
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}."
|
||||
)
|
||||
|
||||
|
||||
def validate_mcp_stdio_config(config: dict) -> None:
|
||||
"""Validate stdio MCP config before any subprocess can be spawned."""
|
||||
cfg = _prepare_config(config.copy())
|
||||
if "url" in cfg:
|
||||
return
|
||||
|
||||
command = cfg.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("MCP stdio server requires a non-empty command.")
|
||||
if _SHELL_META_RE.search(command):
|
||||
raise ValueError("MCP stdio command contains unsafe shell metacharacters.")
|
||||
|
||||
command_name = _normalize_stdio_command_name(command)
|
||||
if command_name in _DENIED_STDIO_COMMANDS:
|
||||
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.")
|
||||
|
||||
allowed = _get_stdio_command_allowlist()
|
||||
if command_name not in allowed:
|
||||
allowed_display = ", ".join(sorted(allowed))
|
||||
raise ValueError(
|
||||
f"MCP stdio command `{command_name}` is not allowed. "
|
||||
f"Allowed commands: {allowed_display}. "
|
||||
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher."
|
||||
)
|
||||
|
||||
_validate_stdio_args(command_name, cfg.get("args"))
|
||||
|
||||
env = cfg.get("env")
|
||||
if env is not None and not isinstance(env, dict):
|
||||
raise ValueError("MCP stdio env must be an object.")
|
||||
if isinstance(env, dict) and not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in env.items()
|
||||
):
|
||||
raise ValueError("MCP stdio env keys and values must be strings.")
|
||||
|
||||
|
||||
def _prepare_stdio_env(config: dict) -> dict:
|
||||
"""Preserve Windows executable resolution for stdio subprocesses."""
|
||||
if sys.platform != "win32":
|
||||
@@ -243,6 +432,7 @@ class MCPClient:
|
||||
)
|
||||
|
||||
else:
|
||||
validate_mcp_stdio_config(cfg)
|
||||
cfg = _prepare_stdio_env(cfg)
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
|
||||
@@ -436,6 +436,21 @@ class DiscordPlatformAdapter(Platform):
|
||||
async def dynamic_callback(
|
||||
ctx: discord.ApplicationContext, params: str | None = None
|
||||
) -> None:
|
||||
# 1. 嘗試立即响应,防止超时 (移到最前面)
|
||||
followup_webhook = None
|
||||
try:
|
||||
# 設定 2.5 秒超時,避免卡死整個 event loop
|
||||
await asyncio.wait_for(ctx.defer(), timeout=2.5)
|
||||
followup_webhook = ctx.followup
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[Discord] Defer command '{cmd_name}' timeout. Network might be too slow."
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
|
||||
return
|
||||
|
||||
# 将平台特定的前缀'/'剥离,以适配通用的CommandFilter
|
||||
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
|
||||
logger.debug(f"[Discord] Callback context: {ctx}")
|
||||
@@ -450,14 +465,6 @@ class DiscordPlatformAdapter(Platform):
|
||||
f"Built command string: '{message_str_for_filter}'",
|
||||
)
|
||||
|
||||
# 尝试立即响应,防止超时
|
||||
followup_webhook = None
|
||||
try:
|
||||
await ctx.defer()
|
||||
followup_webhook = ctx.followup
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
|
||||
|
||||
# 2. 构建 AstrBotMessage
|
||||
channel = ctx.channel
|
||||
abm = AstrBotMessage()
|
||||
|
||||
@@ -6,6 +6,7 @@ import hashlib
|
||||
import io
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -15,7 +16,7 @@ import qrcode as qrcode_lib
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import File, Image, Plain, Record, Video
|
||||
from astrbot.api.message_components import File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -60,6 +61,34 @@ class TypingSessionState:
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeixinOCRecentMessage:
|
||||
message_id: str
|
||||
sender_id: str
|
||||
sender_nickname: str
|
||||
timestamp: int
|
||||
timestamp_ms: int
|
||||
components: list[Any]
|
||||
message_str: str
|
||||
message_kind: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeixinOCRecentSessionCache:
|
||||
messages: deque[WeixinOCRecentMessage]
|
||||
updated_at: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeixinOCReplyMeta:
|
||||
is_reply: bool = False
|
||||
ref_msg: dict[str, Any] | None = None
|
||||
reply_kind: str | None = None
|
||||
quoted_item_type: int | None = None
|
||||
quoted_text: str | None = None
|
||||
reply_to: dict[str, Any] = field(default_factory=lambda: {"matched": False})
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"weixin_oc",
|
||||
"个人微信",
|
||||
@@ -73,6 +102,10 @@ class WeixinOCAdapter(Platform):
|
||||
IMAGE_UPLOAD_TYPE = 1
|
||||
VIDEO_UPLOAD_TYPE = 2
|
||||
FILE_UPLOAD_TYPE = 3
|
||||
RECENT_MESSAGE_CACHE_SIZE = 100
|
||||
REPLY_MATCH_WINDOW_MS = 60_000
|
||||
RECENT_SESSION_CACHE_TTL_S = 1_800
|
||||
MAX_RECENT_MESSAGE_SESSIONS = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -118,6 +151,22 @@ class WeixinOCAdapter(Platform):
|
||||
self._context_tokens: dict[str, str] = {}
|
||||
self._typing_states: dict[str, TypingSessionState] = {}
|
||||
self._last_inbound_error = ""
|
||||
self._recent_message_cache_size = self._get_int_config(
|
||||
"weixin_oc_recent_message_cache_size",
|
||||
self.RECENT_MESSAGE_CACHE_SIZE,
|
||||
1,
|
||||
)
|
||||
self._recent_session_cache_ttl_s = self._get_int_config(
|
||||
"weixin_oc_recent_session_cache_ttl_s",
|
||||
self.RECENT_SESSION_CACHE_TTL_S,
|
||||
60,
|
||||
)
|
||||
self._max_recent_message_sessions = self._get_int_config(
|
||||
"weixin_oc_max_recent_message_sessions",
|
||||
self.MAX_RECENT_MESSAGE_SESSIONS,
|
||||
1,
|
||||
)
|
||||
self._recent_messages: dict[str, WeixinOCRecentSessionCache] = {}
|
||||
self._typing_keepalive_interval_s = max(
|
||||
1,
|
||||
int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)),
|
||||
@@ -152,6 +201,18 @@ class WeixinOCAdapter(Platform):
|
||||
self.client.api_timeout_ms = self.api_timeout_ms
|
||||
self.client.token = self.token
|
||||
|
||||
def _get_int_config(
|
||||
self,
|
||||
key: str,
|
||||
default: int,
|
||||
minimum: int,
|
||||
) -> int:
|
||||
try:
|
||||
value = int(self.config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
return max(minimum, value)
|
||||
|
||||
def _get_typing_state(self, user_id: str) -> TypingSessionState:
|
||||
state = self._typing_states.get(user_id)
|
||||
if state is None:
|
||||
@@ -201,12 +262,12 @@ class WeixinOCAdapter(Platform):
|
||||
return state.ticket
|
||||
|
||||
payload = await self.client.get_typing_config(user_id, context_token)
|
||||
if int(payload.get("ret") or 0) != 0:
|
||||
if not self._is_successful_api_payload(payload):
|
||||
logger.warning(
|
||||
"weixin_oc(%s): getconfig failed for %s: %s",
|
||||
self.meta().id,
|
||||
user_id,
|
||||
payload.get("errmsg", ""),
|
||||
self._format_api_error(payload),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -227,9 +288,9 @@ class WeixinOCAdapter(Platform):
|
||||
cancel: bool,
|
||||
) -> None:
|
||||
payload = await self.client.send_typing_state(user_id, ticket, cancel=cancel)
|
||||
if int(payload.get("ret") or 0) != 0:
|
||||
if not self._is_successful_api_payload(payload):
|
||||
raise RuntimeError(
|
||||
f"sendtyping failed for {user_id}: {payload.get('errmsg', '')}"
|
||||
f"sendtyping failed for {user_id}: {self._format_api_error(payload)}"
|
||||
)
|
||||
|
||||
async def _run_typing_keepalive(self, user_id: str) -> None:
|
||||
@@ -769,6 +830,9 @@ class WeixinOCAdapter(Platform):
|
||||
self,
|
||||
user_id: str,
|
||||
item_list: list[dict[str, Any]],
|
||||
*,
|
||||
cache_components: list[Any] | None = None,
|
||||
cache_message_str: str | None = None,
|
||||
) -> bool:
|
||||
if not self.token:
|
||||
logger.warning("weixin_oc(%s): missing token, skip send", self.meta().id)
|
||||
@@ -787,7 +851,7 @@ class WeixinOCAdapter(Platform):
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
await self.client.request_json(
|
||||
payload = await self.client.request_json(
|
||||
"POST",
|
||||
"ilink/bot/sendmessage",
|
||||
payload={
|
||||
@@ -807,8 +871,65 @@ class WeixinOCAdapter(Platform):
|
||||
token_required=True,
|
||||
headers={},
|
||||
)
|
||||
if not self._is_successful_api_payload(payload):
|
||||
logger.warning(
|
||||
"weixin_oc(%s): sendmessage failed for %s: %s",
|
||||
self.meta().id,
|
||||
user_id,
|
||||
self._format_api_error(payload),
|
||||
)
|
||||
return False
|
||||
resolved_cache_components = (
|
||||
list(cache_components)
|
||||
if cache_components is not None
|
||||
else self._build_cache_components_from_items(item_list)
|
||||
)
|
||||
sender_id = str(self.account_id or self.meta().id)
|
||||
sent_at_ms = time.time_ns() // 1_000_000
|
||||
self._cache_recent_message(
|
||||
user_id,
|
||||
message_id=uuid.uuid4().hex,
|
||||
sender_id=sender_id,
|
||||
sender_nickname=sender_id,
|
||||
timestamp=sent_at_ms // 1000,
|
||||
timestamp_ms=sent_at_ms,
|
||||
components=resolved_cache_components,
|
||||
message_str=cache_message_str
|
||||
if cache_message_str is not None
|
||||
else self._message_text_from_item_list(
|
||||
item_list,
|
||||
include_ref_text=False,
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
def _build_cache_components_from_items(
|
||||
self,
|
||||
item_list: list[dict[str, Any]],
|
||||
) -> list[Any]:
|
||||
components: list[Any] = []
|
||||
for item in item_list:
|
||||
item_type = int(item.get("type") or 0)
|
||||
if item_type != 1:
|
||||
continue
|
||||
text = str(item.get("text_item", {}).get("text", "")).strip()
|
||||
if text:
|
||||
components.append(Plain(text))
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
def _is_successful_api_payload(payload: dict[str, Any]) -> bool:
|
||||
ret = payload.get("ret", 0)
|
||||
errcode = payload.get("errcode", 0)
|
||||
return int(ret or 0) == 0 and int(errcode or 0) == 0
|
||||
|
||||
@staticmethod
|
||||
def _format_api_error(payload: dict[str, Any]) -> str:
|
||||
ret = int(payload.get("ret") or 0)
|
||||
errcode = int(payload.get("errcode") or 0)
|
||||
errmsg = str(payload.get("errmsg", ""))
|
||||
return f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
|
||||
|
||||
async def _send_media_segment(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -858,8 +979,18 @@ class WeixinOCAdapter(Platform):
|
||||
await self._send_items_to_session(
|
||||
user_id,
|
||||
[self._build_plain_text_item(text)],
|
||||
cache_components=[Plain(text)],
|
||||
cache_message_str=text,
|
||||
)
|
||||
return await self._send_items_to_session(user_id, [media_item])
|
||||
return await self._send_items_to_session(
|
||||
user_id,
|
||||
[media_item],
|
||||
cache_components=[segment],
|
||||
cache_message_str=self._message_text_from_item_list(
|
||||
[media_item],
|
||||
include_ref_text=False,
|
||||
),
|
||||
)
|
||||
|
||||
async def _start_login_session(self) -> OpenClawLoginSession:
|
||||
endpoint = "ilink/bot/get_bot_qrcode"
|
||||
@@ -961,7 +1092,10 @@ class WeixinOCAdapter(Platform):
|
||||
await self._save_account_state()
|
||||
|
||||
def _message_text_from_item_list(
|
||||
self, item_list: list[dict[str, Any]] | None
|
||||
self,
|
||||
item_list: list[dict[str, Any]] | None,
|
||||
*,
|
||||
include_ref_text: bool = False,
|
||||
) -> str:
|
||||
if not item_list:
|
||||
return ""
|
||||
@@ -985,15 +1119,298 @@ class WeixinOCAdapter(Platform):
|
||||
elif item_type == 5:
|
||||
text_parts.append("[视频]")
|
||||
else:
|
||||
ref = item.get("ref_msg")
|
||||
if isinstance(ref, dict):
|
||||
ref_item = ref.get("message_item")
|
||||
if isinstance(ref_item, dict):
|
||||
ref_text = str(self._message_text_from_item_list([ref_item]))
|
||||
if ref_text:
|
||||
text_parts.append(f"[引用:{ref_text}]")
|
||||
if include_ref_text:
|
||||
ref = item.get("ref_msg")
|
||||
if isinstance(ref, dict):
|
||||
ref_item = ref.get("message_item")
|
||||
if isinstance(ref_item, dict):
|
||||
ref_text = str(
|
||||
self._message_text_from_item_list(
|
||||
[ref_item],
|
||||
include_ref_text=True,
|
||||
)
|
||||
)
|
||||
if ref_text:
|
||||
text_parts.append(f"[引用:{ref_text}]")
|
||||
return "\n".join(text_parts).strip()
|
||||
|
||||
def _item_type_to_kind(self, item_type: int | None) -> str:
|
||||
match int(item_type or 0):
|
||||
case 1:
|
||||
return "text"
|
||||
case self.IMAGE_ITEM_TYPE:
|
||||
return "image"
|
||||
case self.VOICE_ITEM_TYPE:
|
||||
return "voice"
|
||||
case self.FILE_ITEM_TYPE:
|
||||
return "file"
|
||||
case self.VIDEO_ITEM_TYPE:
|
||||
return "video"
|
||||
case _:
|
||||
return "unknown"
|
||||
|
||||
def _get_recent_message_cache(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> deque[WeixinOCRecentMessage]:
|
||||
now = time.monotonic()
|
||||
self._prune_recent_message_caches(now=now)
|
||||
|
||||
cache_entry = self._recent_messages.get(session_id)
|
||||
if cache_entry is None:
|
||||
cache_entry = WeixinOCRecentSessionCache(
|
||||
messages=deque(maxlen=self._recent_message_cache_size),
|
||||
updated_at=now,
|
||||
)
|
||||
self._recent_messages[session_id] = cache_entry
|
||||
else:
|
||||
cache_entry.updated_at = now
|
||||
return cache_entry.messages
|
||||
|
||||
def _prune_recent_message_caches(self, *, now: float | None = None) -> None:
|
||||
if not self._recent_messages:
|
||||
return
|
||||
|
||||
current = now if now is not None else time.monotonic()
|
||||
expired_session_ids = [
|
||||
session_id
|
||||
for session_id, cache_entry in self._recent_messages.items()
|
||||
if current - cache_entry.updated_at > self._recent_session_cache_ttl_s
|
||||
]
|
||||
for session_id in expired_session_ids:
|
||||
self._recent_messages.pop(session_id, None)
|
||||
|
||||
overflow = len(self._recent_messages) - self._max_recent_message_sessions
|
||||
if overflow <= 0:
|
||||
return
|
||||
|
||||
oldest_session_ids = sorted(
|
||||
self._recent_messages,
|
||||
key=lambda session_id: self._recent_messages[session_id].updated_at,
|
||||
)[:overflow]
|
||||
for session_id in oldest_session_ids:
|
||||
self._recent_messages.pop(session_id, None)
|
||||
|
||||
def _infer_message_kind_from_components(self, components: list[Any]) -> str:
|
||||
if not components:
|
||||
return "unknown"
|
||||
for component in components:
|
||||
if isinstance(component, Plain) and component.text.strip():
|
||||
return "text"
|
||||
if isinstance(component, Image):
|
||||
return "image"
|
||||
if isinstance(component, Record):
|
||||
return "voice"
|
||||
if isinstance(component, File):
|
||||
return "file"
|
||||
if isinstance(component, Video):
|
||||
return "video"
|
||||
return "unknown"
|
||||
|
||||
def _cache_recent_message(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
message_id: str,
|
||||
sender_id: str,
|
||||
sender_nickname: str,
|
||||
timestamp: int,
|
||||
timestamp_ms: int | None = None,
|
||||
components: list[Any],
|
||||
message_str: str,
|
||||
message_kind: str | None = None,
|
||||
) -> None:
|
||||
if not session_id or not message_id:
|
||||
return
|
||||
resolved_timestamp_ms = (
|
||||
timestamp_ms if timestamp_ms is not None else timestamp * 1000
|
||||
)
|
||||
cache = self._get_recent_message_cache(session_id)
|
||||
cache.append(
|
||||
WeixinOCRecentMessage(
|
||||
message_id=message_id,
|
||||
sender_id=sender_id,
|
||||
sender_nickname=sender_nickname,
|
||||
timestamp=timestamp,
|
||||
timestamp_ms=resolved_timestamp_ms,
|
||||
components=list(components),
|
||||
message_str=message_str,
|
||||
message_kind=message_kind
|
||||
or self._infer_message_kind_from_components(components),
|
||||
)
|
||||
)
|
||||
|
||||
def _match_recent_reply(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
ref_create_time_ms: int | None,
|
||||
) -> tuple[WeixinOCRecentMessage | None, dict[str, Any] | None]:
|
||||
if not session_id or ref_create_time_ms is None:
|
||||
return None, None
|
||||
|
||||
best_match: WeixinOCRecentMessage | None = None
|
||||
best_distance: int | None = None
|
||||
self._prune_recent_message_caches()
|
||||
cache_entry = self._recent_messages.get(session_id)
|
||||
if cache_entry is None:
|
||||
return None, None
|
||||
|
||||
for candidate in cache_entry.messages:
|
||||
distance = abs(candidate.timestamp_ms - ref_create_time_ms)
|
||||
if distance > self.REPLY_MATCH_WINDOW_MS:
|
||||
continue
|
||||
if best_distance is None or distance < best_distance:
|
||||
best_match = candidate
|
||||
best_distance = distance
|
||||
|
||||
if best_match is None or best_distance is None:
|
||||
return None, None
|
||||
|
||||
confidence = max(
|
||||
0.0,
|
||||
min(1.0, 1.0 - (best_distance / self.REPLY_MATCH_WINDOW_MS)),
|
||||
)
|
||||
return best_match, {
|
||||
"matched": True,
|
||||
"strategy": "nearest-message-by-timestamp",
|
||||
"ref_create_time_ms": ref_create_time_ms,
|
||||
"matched_message_id": best_match.message_id,
|
||||
"matched_kind": best_match.message_kind,
|
||||
"distance_ms": best_distance,
|
||||
"confidence": round(confidence, 4),
|
||||
}
|
||||
|
||||
async def _build_reply_component_from_ref(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
ref_msg: dict[str, Any],
|
||||
) -> tuple[Reply | None, WeixinOCReplyMeta]:
|
||||
metadata = WeixinOCReplyMeta(ref_msg=ref_msg)
|
||||
message_item = ref_msg.get("message_item")
|
||||
if not isinstance(message_item, dict):
|
||||
return None, metadata
|
||||
|
||||
quoted_item_type_raw = message_item.get("type")
|
||||
try:
|
||||
quoted_item_type = (
|
||||
int(quoted_item_type_raw)
|
||||
if quoted_item_type_raw not in (None, "")
|
||||
else None
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
quoted_item_type = None
|
||||
metadata.quoted_item_type = quoted_item_type
|
||||
metadata.reply_kind = self._item_type_to_kind(quoted_item_type)
|
||||
|
||||
ref_create_time_ms_raw = message_item.get("create_time_ms")
|
||||
try:
|
||||
ref_create_time_ms = (
|
||||
int(ref_create_time_ms_raw)
|
||||
if ref_create_time_ms_raw not in (None, "")
|
||||
else None
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
ref_create_time_ms = None
|
||||
|
||||
quoted_components: list[Any] = []
|
||||
quoted_text = ""
|
||||
if quoted_item_type is not None:
|
||||
quoted_components = await self._item_list_to_components([message_item])
|
||||
quoted_text = self._message_text_from_item_list(
|
||||
[message_item],
|
||||
include_ref_text=False,
|
||||
)
|
||||
|
||||
if quoted_text:
|
||||
metadata.quoted_text = quoted_text
|
||||
metadata.reply_to = {
|
||||
"matched": True,
|
||||
"strategy": "direct-ref-msg",
|
||||
"matched_kind": metadata.reply_kind,
|
||||
"matched_text": quoted_text,
|
||||
"confidence": 1.0,
|
||||
}
|
||||
|
||||
matched_message = None
|
||||
matched_reply_to = None
|
||||
if not quoted_text or not quoted_components:
|
||||
matched_message, matched_reply_to = self._match_recent_reply(
|
||||
session_id,
|
||||
ref_create_time_ms=ref_create_time_ms,
|
||||
)
|
||||
if matched_message is not None:
|
||||
quoted_components = list(matched_message.components)
|
||||
quoted_text = matched_message.message_str
|
||||
metadata.quoted_text = quoted_text or None
|
||||
metadata.reply_kind = matched_message.message_kind
|
||||
metadata.reply_to = matched_reply_to or {"matched": True}
|
||||
|
||||
if not quoted_text and not quoted_components:
|
||||
return None, metadata
|
||||
|
||||
metadata.is_reply = True
|
||||
|
||||
reply_message_id = (
|
||||
matched_message.message_id
|
||||
if matched_message is not None
|
||||
else str(
|
||||
message_item.get("message_id")
|
||||
or message_item.get("msg_id")
|
||||
or f"weixin_oc_ref_{ref_create_time_ms or uuid.uuid4().hex}"
|
||||
)
|
||||
)
|
||||
quoted_sender_id_raw = str(message_item.get("from_user_id") or "unknown")
|
||||
reply_sender_id_raw = (
|
||||
matched_message.sender_id
|
||||
if matched_message is not None
|
||||
else quoted_sender_id_raw
|
||||
)
|
||||
normalized_reply_sender_id = self._normalize_reply_sender_id(
|
||||
reply_sender_id_raw
|
||||
)
|
||||
reply_sender_id = (
|
||||
normalized_reply_sender_id
|
||||
if normalized_reply_sender_id
|
||||
else reply_sender_id_raw
|
||||
)
|
||||
reply_sender_nickname = (
|
||||
matched_message.sender_nickname
|
||||
if matched_message is not None
|
||||
else quoted_sender_id_raw
|
||||
)
|
||||
reply_time = (
|
||||
matched_message.timestamp
|
||||
if matched_message is not None
|
||||
else (
|
||||
int(ref_create_time_ms / 1000)
|
||||
if isinstance(ref_create_time_ms, int)
|
||||
else int(time.time())
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
Reply(
|
||||
id=reply_message_id,
|
||||
chain=quoted_components,
|
||||
sender_id=reply_sender_id,
|
||||
sender_nickname=reply_sender_nickname,
|
||||
time=reply_time,
|
||||
message_str=quoted_text,
|
||||
text=quoted_text,
|
||||
),
|
||||
metadata,
|
||||
)
|
||||
|
||||
def _normalize_reply_sender_id(self, sender_id: str) -> str:
|
||||
normalized_sender_id = sender_id.strip()
|
||||
if not normalized_sender_id:
|
||||
return normalized_sender_id
|
||||
if self.account_id and normalized_sender_id == str(self.account_id):
|
||||
return self.meta().id
|
||||
return normalized_sender_id
|
||||
|
||||
async def _item_list_to_components(
|
||||
self, item_list: list[dict[str, Any]] | None
|
||||
) -> list[Any]:
|
||||
@@ -1031,16 +1448,36 @@ class WeixinOCAdapter(Platform):
|
||||
self._context_tokens[from_user_id] = context_token
|
||||
|
||||
item_list = cast(list[dict[str, Any]], msg.get("item_list", []))
|
||||
components = await self._item_list_to_components(item_list)
|
||||
text = self._message_text_from_item_list(item_list)
|
||||
reply_component = None
|
||||
reply_metadata = WeixinOCReplyMeta()
|
||||
for item in item_list:
|
||||
ref_msg = item.get("ref_msg")
|
||||
if isinstance(ref_msg, dict):
|
||||
(
|
||||
reply_component,
|
||||
reply_metadata,
|
||||
) = await self._build_reply_component_from_ref(
|
||||
session_id=from_user_id,
|
||||
ref_msg=ref_msg,
|
||||
)
|
||||
break
|
||||
cached_components = await self._item_list_to_components(item_list)
|
||||
components = list(cached_components)
|
||||
if reply_component is not None:
|
||||
components.insert(0, reply_component)
|
||||
text = self._message_text_from_item_list(item_list, include_ref_text=False)
|
||||
message_id = str(msg.get("message_id") or msg.get("msg_id") or uuid.uuid4().hex)
|
||||
create_time = msg.get("create_time_ms") or msg.get("create_time")
|
||||
create_time_ms: int | None = None
|
||||
if isinstance(create_time, (int, float)) and create_time > 1_000_000_000_000:
|
||||
create_time_ms = int(create_time)
|
||||
ts = int(float(create_time) / 1000)
|
||||
elif isinstance(create_time, (int, float)):
|
||||
ts = int(create_time)
|
||||
create_time_ms = ts * 1000
|
||||
else:
|
||||
ts = int(time.time())
|
||||
create_time_ms = ts * 1000
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self.meta().id
|
||||
@@ -1052,6 +1489,23 @@ class WeixinOCAdapter(Platform):
|
||||
abm.message_str = text
|
||||
abm.timestamp = ts
|
||||
abm.raw_message = msg
|
||||
abm.is_reply = reply_metadata.is_reply
|
||||
abm.ref_msg = reply_metadata.ref_msg
|
||||
abm.reply_kind = reply_metadata.reply_kind
|
||||
abm.quoted_item_type = reply_metadata.quoted_item_type
|
||||
abm.quoted_text = reply_metadata.quoted_text
|
||||
abm.reply_to = reply_metadata.reply_to
|
||||
|
||||
self._cache_recent_message(
|
||||
from_user_id,
|
||||
message_id=message_id,
|
||||
sender_id=from_user_id,
|
||||
sender_nickname=from_user_id,
|
||||
timestamp=ts,
|
||||
timestamp_ms=create_time_ms,
|
||||
components=cached_components,
|
||||
message_str=text,
|
||||
)
|
||||
|
||||
self.commit_event(
|
||||
WeixinOCMessageEvent(
|
||||
@@ -1076,20 +1530,8 @@ class WeixinOCAdapter(Platform):
|
||||
token_required=True,
|
||||
timeout_ms=self.long_poll_timeout_ms,
|
||||
)
|
||||
ret = int(data.get("ret") or 0)
|
||||
errcode = data.get("errcode", 0)
|
||||
if ret != 0 and ret is not None:
|
||||
errmsg = str(data.get("errmsg", ""))
|
||||
self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
|
||||
logger.warning(
|
||||
"weixin_oc(%s): getupdates error: %s",
|
||||
self.meta().id,
|
||||
self._last_inbound_error,
|
||||
)
|
||||
return
|
||||
if errcode and int(errcode) != 0:
|
||||
errmsg = str(data.get("errmsg", ""))
|
||||
self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
|
||||
if not self._is_successful_api_payload(data):
|
||||
self._last_inbound_error = self._format_api_error(data)
|
||||
logger.warning(
|
||||
"weixin_oc(%s): getupdates error: %s",
|
||||
self.meta().id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -88,8 +89,6 @@ class BailianRerankProvider(RerankProvider):
|
||||
normalized_model = self.model.strip().lower()
|
||||
normalized_top_n = top_n if top_n is not None and top_n > 0 else None
|
||||
|
||||
# qwen3-rerank follows a model-specific payload:
|
||||
# query/documents/top_n/instruct should be at the top level.
|
||||
if normalized_model == self.QWEN3_RERANK_MODEL:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
@@ -107,8 +106,7 @@ class BailianRerankProvider(RerankProvider):
|
||||
)
|
||||
return payload
|
||||
|
||||
base = {"model": self.model, "input": {"query": query, "documents": documents}}
|
||||
|
||||
payload_input = {"query": query, "documents": documents}
|
||||
params = {
|
||||
k: v
|
||||
for k, v in [
|
||||
@@ -118,6 +116,7 @@ class BailianRerankProvider(RerankProvider):
|
||||
if v is not None
|
||||
}
|
||||
|
||||
base: dict[str, Any] = {"model": self.model, "input": payload_input}
|
||||
if params:
|
||||
base["parameters"] = params
|
||||
|
||||
@@ -136,14 +135,23 @@ class BailianRerankProvider(RerankProvider):
|
||||
BailianAPIError: API返回错误
|
||||
KeyError: 结果缺少必要字段
|
||||
"""
|
||||
# 检查响应状态
|
||||
if data.get("code", "200") != "200":
|
||||
raise BailianAPIError(
|
||||
f"百炼 API 错误: {data.get('code')} – {data.get('message', '')}"
|
||||
)
|
||||
is_compatible_api = "compatible-api" in self.base_url
|
||||
|
||||
if is_compatible_api:
|
||||
code = data.get("code")
|
||||
if code:
|
||||
raise BailianAPIError(
|
||||
f"百炼 API 错误: {code} – {data.get('message', '')}"
|
||||
)
|
||||
results = data.get("results", [])
|
||||
else:
|
||||
code = data.get("code", "200")
|
||||
if code != "200":
|
||||
raise BailianAPIError(
|
||||
f"百炼 API 错误: {code} – {data.get('message', '')}"
|
||||
)
|
||||
results = data.get("output", {}).get("results", [])
|
||||
|
||||
# 兼容旧版 API (output.results) 和新版 compatible API (results)
|
||||
results = (data.get("output") or {}).get("results") or data.get("results") or []
|
||||
if not results:
|
||||
logger.warning(f"百炼 Rerank 返回空结果: {data}")
|
||||
return []
|
||||
|
||||
@@ -73,6 +73,6 @@ class ExecuteShellTool(FunctionTool):
|
||||
background=background,
|
||||
env=env,
|
||||
)
|
||||
return json.dumps(result)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
|
||||
@@ -3,7 +3,7 @@ import traceback
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.star import star_map
|
||||
from astrbot.core.tools.registry import get_builtin_tool_config_statuses
|
||||
@@ -153,6 +153,11 @@ class ToolsRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
validate_mcp_stdio_config(server_config)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if name in config["mcpServers"]:
|
||||
@@ -256,6 +261,11 @@ class ToolsRoute(Route):
|
||||
if key != "active": # 除了active之外的所有字段都保留
|
||||
server_config[key] = value
|
||||
|
||||
try:
|
||||
validate_mcp_stdio_config(server_config)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
|
||||
# config["mcpServers"][name] = server_config
|
||||
if is_rename:
|
||||
config["mcpServers"].pop(old_name)
|
||||
@@ -415,6 +425,11 @@ class ToolsRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
validate_mcp_stdio_config(config)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
|
||||
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
|
||||
return (
|
||||
Response()
|
||||
|
||||
14
compose.yml
14
compose.yml
@@ -1,15 +1,17 @@
|
||||
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
||||
version: '3.8'
|
||||
|
||||
# When connecting to OneBot v11 Napcat, please use this compose file for one-click deployment: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
||||
|
||||
services:
|
||||
astrbot:
|
||||
image: soulter/astrbot:latest
|
||||
container_name: astrbot
|
||||
restart: always
|
||||
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
|
||||
- "6185:6185" # 必选,AstrBot WebUI 端口
|
||||
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
|
||||
# - "6195:6195" # 可选, 企业微信 Webhook 端口
|
||||
# - "6196:6196" # 可选, QQ 官方接口 Webhook 端口
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "6185:6185" # AstrBot WebUI
|
||||
- "6199:6199" # Optional. OneBot v11 Napcat Websocket Port
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
|
||||
@@ -110,6 +110,10 @@
|
||||
|
||||
<small style="color: grey">*{{ tm('dialogs.addServer.tips.timeoutConfig') }}</small>
|
||||
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mt-3">
|
||||
{{ tm('dialogs.addServer.tips.transportRecommendation') }}
|
||||
</v-alert>
|
||||
|
||||
<div class="monaco-container" style="margin-top: 16px;">
|
||||
<VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{
|
||||
minimap: {
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
"sync": "Sync"
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "Please configure tool call timeout separately in the configuration page"
|
||||
"timeoutConfig": "Please configure tool call timeout separately in the configuration page",
|
||||
"transportRecommendation": "Prefer Streamable HTTP or SSE mode. Stdio mode starts a local process on the AstrBot host and should only be used for trusted MCP servers."
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
"sync": "Синхронизировать"
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
|
||||
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации",
|
||||
"transportRecommendation": "Рекомендуется сначала использовать режим Streamable HTTP или SSE. Режим stdio запускает локальный процесс на хосте AstrBot и подходит только для доверенных MCP-серверов."
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
"sync": "同步"
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "工具调用的超时时间请前往配置页面单独配置"
|
||||
"timeoutConfig": "工具调用的超时时间请前往配置页面单独配置",
|
||||
"transportRecommendation": "建议优先使用 Streamable HTTP 或 SSE 模式。stdio 模式会在 AstrBot 主机上启动本地进程,仅适合可信 MCP 服务器。"
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
|
||||
Reference in New Issue
Block a user