diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml new file mode 100644 index 000000000..246469680 --- /dev/null +++ b/.github/workflows/build-docs.yml @@ -0,0 +1,43 @@ +name: release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest # 运行环境 + steps: + - name: checkout + uses: actions/checkout@master + - name: nodejs installation + uses: actions/setup-node@v6 + with: + node-version: "18" + - name: npm install + run: npm add -D vitepress + working-directory: './docs' # working-directory 指定 shell 命令运行目录 + - name: npm run build + run: npm run docs:build + working-directory: './docs' + - name: scp + uses: appleboy/scp-action@master + with: + host: ${{ secrets.HOST_NEKO }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORDNEKO }} + source: 'docs/.vitepress/dist/*' + target: '/tmp/' + - name: script + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST_NEKO }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORDNEKO }} + script: | + mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/ + rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/* + mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/ + rm -rf /tmp/docs/ diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 18c8d4926..d79d628c3 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -64,20 +64,20 @@ jobs: echo "build_date=$build_date" >> $GITHUB_OUTPUT - name: Set QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry if: env.HAS_GHCR_TOKEN == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} @@ -98,7 +98,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Build and Push Nightly Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 @@ -163,27 +163,27 @@ jobs: cp -r dashboard/dist data/ - name: Set QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry if: env.HAS_GHCR_TOKEN == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} password: ${{ secrets.GHCR_GITHUB_TOKEN }} - name: Build and Push Release Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml new file mode 100644 index 000000000..2fe0d3153 --- /dev/null +++ b/.github/workflows/sync-wiki.yml @@ -0,0 +1,68 @@ +name: sync wiki + +on: + workflow_dispatch: + push: + branches: + - master + paths: + - '.github/workflows/sync-wiki.yml' + - 'docs/scripts/sync_docs_to_wiki.py' + - 'docs/tests/test_sync_docs_to_wiki.py' + - 'docs/zh/**' + - 'docs/en/**' + +concurrency: + group: sync-wiki-${{ github.ref }} + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Validate manual ref + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master' + run: | + echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch." + exit 1 + + - name: Check out docs repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run sync unit tests + working-directory: docs + run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v + + - name: Validate internal doc links + run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only + + - name: Clone AstrBot wiki + env: + WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }} + run: | + test -n "$WIKI_TOKEN" + git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki + + - name: Generate wiki pages + run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki + + - name: Commit and push wiki changes + working-directory: wiki + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add . + if git diff --cached --quiet; then + echo "No wiki changes to push" + exit 0 + fi + git commit -m "docs: sync wiki from AstrBot-1/docs" + git push diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 2bcafbace..593f1c94e 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.19.2" +__version__ = "4.19.5" diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 3bdff35c3..d673a7b2d 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -144,10 +144,14 @@ class MCPClient: cfg = _prepare_config(mcp_server_config.copy()) - def logging_callback(msg: str) -> None: + def logging_callback( + msg: str | mcp.types.LoggingMessageNotificationParams, + ) -> None: # Handle MCP service error logs - print(f"MCP Server {name} Error: {msg}") - self.server_errlogs.append(msg) + if isinstance(msg, mcp.types.LoggingMessageNotificationParams): + if msg.level in ("warning", "error", "critical", "alert", "emergency"): + log_msg = f"[{msg.level.upper()}] {str(msg.data)}" + self.server_errlogs.append(log_msg) if "url" in cfg: success, error_msg = await _quick_test_mcp_connection(cfg) @@ -214,15 +218,24 @@ class MCPClient: **cfg, ) - def callback(msg: str) -> None: + def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None: # Handle MCP service error logs - self.server_errlogs.append(msg) + if isinstance(msg, mcp.types.LoggingMessageNotificationParams): + if msg.level in ( + "warning", + "error", + "critical", + "alert", + "emergency", + ): + log_msg = f"[{msg.level.upper()}] {str(msg.data)}" + self.server_errlogs.append(log_msg) stdio_transport = await self.exit_stack.enter_async_context( mcp.stdio_client( server_params, errlog=LogPipe( - level=logging.ERROR, + level=logging.INFO, logger=logger, identifier=f"MCPServer-{name}", callback=callback, diff --git a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py index 1aaf6e3b9..8169a678c 100644 --- a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +++ b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py @@ -302,7 +302,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]): while True: try: - item_type, item_data = await asyncio.get_event_loop().run_in_executor( + item_type, item_data = await asyncio.get_running_loop().run_in_executor( None, response_queue.get, True, 1 ) except queue.Empty: @@ -388,7 +388,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]): # 发起请求 partial = functools.partial(Application.call, **payload) - response = await asyncio.get_event_loop().run_in_executor(None, partial) + response = await asyncio.get_running_loop().run_in_executor(None, partial) async for resp in self._handle_streaming_response(response, session_id): yield resp diff --git a/astrbot/core/computer/booters/bay_manager.py b/astrbot/core/computer/booters/bay_manager.py index 24fa379e8..61ccc1b3a 100644 --- a/astrbot/core/computer/booters/bay_manager.py +++ b/astrbot/core/computer/booters/bay_manager.py @@ -121,11 +121,12 @@ class BayContainerManager: async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None: """Block until Bay's ``/health`` endpoint returns 200.""" url = f"http://127.0.0.1:{self._host_port}/health" - deadline = asyncio.get_event_loop().time() + timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout last_error: str = "" async with aiohttp.ClientSession() as session: - while asyncio.get_event_loop().time() < deadline: + while loop.time() < deadline: try: async with session.get( url, timeout=aiohttp.ClientTimeout(total=3) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 71608b7ad..2e32073a9 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.19.2" +VERSION = "4.19.5" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") WEBHOOK_SUPPORTED_PLATFORMS = [ @@ -342,14 +342,20 @@ CONFIG_METADATA_2 = { "企业微信智能机器人": { "id": "wecom_ai_bot", "type": "wecom_ai_bot", + "hint": "如果发现字段有异常,请重新创建", "enable": True, + "wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook + "wecom_ai_bot_name": "", + "wecomaibot_ws_bot_id": "", + "wecomaibot_ws_secret": "", + "wecomaibot_token": "", + "wecomaibot_encoding_aes_key": "", "wecomaibot_init_respond_text": "", "wecomaibot_friend_message_welcome_text": "", - "wecom_ai_bot_name": "", "msg_push_webhook_url": "", "only_use_webhook_url_to_send": False, - "token": "", - "encoding_aes_key": "", + "wecomaibot_ws_url": "wss://openws.work.weixin.qq.com", + "wecomaibot_heartbeat_interval": 30, "unified_webhook_mode": True, "webhook_uuid": "", "callback_server_host": "0.0.0.0", @@ -732,6 +738,13 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "请务必填写正确,否则无法使用一些指令。", }, + "wecom_ai_bot_connection_mode": { + "description": "企业微信智能机器人连接模式", + "type": "string", + "options": ["webhook", "long_connection"], + "labels": ["Webhook 回调", "长连接"], + "hint": "Webhook 回调模式需要配置 Token/EncodingAESKey。长连接模式需要配置 BotID/Secret。", + }, "wecomaibot_init_respond_text": { "description": "企业微信智能机器人初始响应文本", "type": "string", @@ -742,6 +755,22 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。", }, + "wecomaibot_token": { + "description": "企业微信智能机器人 Token", + "type": "string", + "hint": "用于 Webhook 回调模式的身份验证。", + "condition": { + "wecom_ai_bot_connection_mode": "webhook", + }, + }, + "wecomaibot_encoding_aes_key": { + "description": "企业微信智能机器人 EncodingAESKey", + "type": "string", + "hint": "用于 Webhook 回调模式的消息加密解密。", + "condition": { + "wecom_ai_bot_connection_mode": "webhook", + }, + }, "msg_push_webhook_url": { "description": "企业微信消息推送 Webhook URL", "type": "string", @@ -752,6 +781,40 @@ CONFIG_METADATA_2 = { "type": "bool", "hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。", }, + "wecomaibot_ws_bot_id": { + "description": "长连接 BotID", + "type": "string", + "hint": "企业微信智能机器人长连接模式凭证 BotID。", + "condition": { + "wecom_ai_bot_connection_mode": "long_connection", + }, + }, + "wecomaibot_ws_secret": { + "description": "长连接 Secret", + "type": "string", + "hint": "企业微信智能机器人长连接模式凭证 Secret。", + "condition": { + "wecom_ai_bot_connection_mode": "long_connection", + }, + }, + "wecomaibot_ws_url": { + "description": "长连接 WebSocket 地址", + "type": "string", + "invisible": True, + "hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。", + "condition": { + "wecom_ai_bot_connection_mode": "long_connection", + }, + }, + "wecomaibot_heartbeat_interval": { + "description": "长连接心跳间隔", + "type": "int", + "invisible": True, + "hint": "长连接模式心跳间隔(秒),建议 30 秒。", + "condition": { + "wecom_ai_bot_connection_mode": "long_connection", + }, + }, "lark_bot_name": { "description": "飞书机器人的名字", "type": "string", @@ -796,7 +859,7 @@ CONFIG_METADATA_2 = { "unified_webhook_mode": { "description": "统一 Webhook 模式", "type": "bool", - "hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。", + "hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。", }, "webhook_uuid": { "invisible": True, @@ -1123,7 +1186,7 @@ CONFIG_METADATA_2 = { "enable": True, "key": [], "timeout": 120, - "api_base": "https://openrouter.ai/v1", + "api_base": "https://openrouter.ai/api/v1", "proxy": "", "custom_headers": {}, }, diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 6dbe78ae4..d9ea6aa26 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -699,21 +699,24 @@ class File(BaseMessageComponent): if self.url: try: - loop = asyncio.get_event_loop() - if loop.is_running(): - logger.warning( - "不可以在异步上下文中同步等待下载! " - "这个警告通常发生于某些逻辑试图通过 .file 获取文件消息段的文件内容。" - "请使用 await get_file() 代替直接获取 .file 字段", - ) - return "" - # 等待下载完成 - loop.run_until_complete(self._download_file()) + # 检查是否有正在运行的 event loop + asyncio.get_running_loop() + logger.warning( + "不可以在异步上下文中同步等待下载! " + "这个警告通常发生于某些逻辑试图通过 .file 获取文件消息段的文件内容。" + "请使用 await get_file() 代替直接获取 .file 字段", + ) + return "" + except RuntimeError: + # 没有运行中的 event loop,可以同步执行 + try: + # 使用 asyncio.run 安全地创建和关闭事件循环 + asyncio.run(self._download_file()) + except Exception: + logger.exception("文件下载失败") if self.file_ and os.path.exists(self.file_): return os.path.abspath(self.file_) - except Exception as e: - logger.error(f"文件下载失败: {e}") return "" diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 2d9b45cc1..37c3b09ab 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, Image, Plain, Record, Video +from astrbot.api.message_components import At, File, Image, Plain, Record, Video from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -178,29 +178,110 @@ class DingtalkPlatformAdapter(Platform): abm.session_id = abm.sender.user_id message_type: str = cast(str, message.message_type) + robot_code = cast(str, message.robot_code or "") + raw_content = cast(dict, message.extensions.get("content") or {}) + if not isinstance(raw_content, dict): + raw_content = {} match message_type: case "text": abm.message_str = message.text.content.strip() abm.message.append(Plain(abm.message_str)) + case "picture": + if not robot_code: + logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode") + await self._remember_sender_binding(message, abm) + return abm + image_content = cast( + dingtalk_stream.ImageContent | None, + message.image_content, + ) + download_code = cast( + str, (image_content.download_code if image_content else "") or "" + ) + if not download_code: + logger.warning("钉钉图片消息缺少 downloadCode,已跳过") + else: + f_path = await self.download_ding_file( + download_code, + robot_code, + "jpg", + ) + if f_path: + abm.message.append(Image.fromFileSystem(f_path)) + else: + logger.warning("钉钉图片消息下载失败,无法解析为图片") case "richText": rtc: dingtalk_stream.RichTextContent = cast( dingtalk_stream.RichTextContent, message.rich_text_content ) contents: list[dict] = cast(list[dict], rtc.rich_text_list) + plain_parts: list[str] = [] for content in contents: - plains = "" if "text" in content: - plains += content["text"] - abm.message.append(Plain(plains)) + plain_text = cast(str, content.get("text") or "") + if plain_text: + plain_parts.append(plain_text) + abm.message.append(Plain(plain_text)) elif "type" in content and content["type"] == "picture": + download_code = cast(str, content.get("downloadCode") or "") + if not download_code: + logger.warning( + "钉钉富文本图片消息缺少 downloadCode,已跳过" + ) + continue + if not robot_code: + logger.error( + "钉钉富文本图片消息解析失败: 回调中缺少 robotCode" + ) + continue f_path = await self.download_ding_file( - content["downloadCode"], - cast(str, message.robot_code), + download_code, + robot_code, "jpg", ) - abm.message.append(Image.fromFileSystem(f_path)) - case "audio": - pass + if f_path: + abm.message.append(Image.fromFileSystem(f_path)) + abm.message_str = "".join(plain_parts).strip() + case "audio" | "voice": + download_code = cast(str, raw_content.get("downloadCode") or "") + if not download_code: + logger.warning("钉钉语音消息缺少 downloadCode,已跳过") + elif not robot_code: + logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode") + else: + voice_ext = cast(str, raw_content.get("fileExtension") or "") + if not voice_ext: + voice_ext = "amr" + voice_ext = voice_ext.lstrip(".") + f_path = await self.download_ding_file( + download_code, + robot_code, + voice_ext, + ) + if f_path: + abm.message.append(Record.fromFileSystem(f_path)) + case "file": + download_code = cast(str, raw_content.get("downloadCode") or "") + if not download_code: + logger.warning("钉钉文件消息缺少 downloadCode,已跳过") + elif not robot_code: + logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode") + else: + file_name = cast(str, raw_content.get("fileName") or "") + file_ext = Path(file_name).suffix.lstrip(".") if file_name else "" + if not file_ext: + file_ext = cast(str, raw_content.get("fileExtension") or "") + if not file_ext: + file_ext = "file" + f_path = await self.download_ding_file( + download_code, + robot_code, + file_ext, + ) + if f_path: + if not file_name: + file_name = Path(f_path).name + abm.message.append(File(name=file_name, file=f_path)) await self._remember_sender_binding(message, abm) return abm # 别忘了返回转换后的消息对象 @@ -270,13 +351,23 @@ class DingtalkPlatformAdapter(Platform): ) return "" resp_data = await resp.json() - download_url = resp_data["data"]["downloadUrl"] + download_url = cast( + str, + ( + resp_data.get("downloadUrl") + or resp_data.get("data", {}).get("downloadUrl") + or "" + ), + ) + if not download_url: + logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}") + return "" await download_file(download_url, str(f_path)) return str(f_path) async def get_access_token(self) -> str: try: - access_token = await asyncio.get_event_loop().run_in_executor( + access_token = await asyncio.get_running_loop().run_in_executor( None, self.client_.get_access_token, ) @@ -541,6 +632,28 @@ class DingtalkPlatformAdapter(Platform): self._safe_remove_file(cover_path) if converted_video: self._safe_remove_file(video_path) + elif isinstance(segment, File): + try: + file_path = await segment.get_file() + if not file_path: + logger.warning("钉钉文件发送失败: 无法解析文件路径") + continue + media_id = await self.upload_media(file_path, "file") + if not media_id: + continue + file_name = segment.name or Path(file_path).name + file_type = Path(file_name).suffix.lstrip(".") + await send_message( + msg_key="sampleFile", + msg_param={ + "mediaId": media_id, + "fileName": file_name, + "fileType": file_type, + }, + ) + except Exception as e: + logger.warning(f"钉钉文件发送失败: {e}") + continue async def send_message_chain_to_group( self, @@ -647,7 +760,7 @@ class DingtalkPlatformAdapter(Platform): return logger.error(f"钉钉机器人启动失败: {e}") - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() await loop.run_in_executor(None, start_client, loop) async def terminate(self) -> None: diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index be1c81c26..60e8e0d93 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -34,7 +34,7 @@ from .server import LarkWebhookServer @register_platform_adapter( - "lark", "飞书机器人官方 API 适配器", support_streaming_message=False + "lark", "飞书机器人官方 API 适配器", support_streaming_message=True ) class LarkPlatformAdapter(Platform): def __init__( @@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform): name="lark", description="飞书机器人官方 API 适配器", id=cast(str, self.config.get("id")), - support_streaming_message=False, + support_streaming_message=True, ) async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None: diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 92e3a32b9..0959f63df 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -1,3 +1,4 @@ +import asyncio import base64 import json import os @@ -5,6 +6,14 @@ import uuid from io import BytesIO import lark_oapi as lark +from lark_oapi.api.cardkit.v1 import ( + ContentCardElementRequest, + ContentCardElementRequestBody, + CreateCardRequest, + CreateCardRequestBody, + SettingsCardRequest, + SettingsCardRequestBody, +) from lark_oapi.api.im.v1 import ( CreateFileRequest, CreateFileRequestBody, @@ -28,6 +37,7 @@ from astrbot.core.utils.media_utils import ( convert_video_format, get_media_duration, ) +from astrbot.core.utils.metrics import Metric class LarkMessageEvent(AstrMessageEvent): @@ -555,15 +565,257 @@ class LarkMessageEvent(AstrMessageEvent): logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}") return - async def send_streaming(self, generator, use_fallback: bool = False): + async def _create_streaming_card(self) -> str | None: + """创建一个开启流式更新模式的卡片实体,返回 card_id。""" + if self.bot.cardkit is None: + logger.error("[Lark] API Client cardkit 模块未初始化") + return None + + card_json = { + "schema": "2.0", + "header": { + "title": {"content": "", "tag": "plain_text"}, + }, + "config": { + "streaming_mode": True, + "summary": {"content": ""}, + "streaming_config": { + "print_frequency_ms": {"default": 50}, + "print_step": {"default": 2}, + "print_strategy": "fast", + }, + }, + "body": { + "elements": [ + { + "tag": "markdown", + "content": "", + "element_id": "markdown_1", + } + ] + }, + } + + request = ( + CreateCardRequest.builder() + .request_body( + CreateCardRequestBody.builder() + .type("card_json") + .data(json.dumps(card_json, ensure_ascii=False)) + .build() + ) + .build() + ) + + try: + response = await self.bot.cardkit.v1.card.acreate(request) + except Exception as e: + logger.error(f"[Lark] 创建流式卡片实体失败: {e}") + return None + + if not response.success(): + logger.error( + f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}" + ) + return None + + if response.data is None or not response.data.card_id: + logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id") + return None + + card_id = response.data.card_id + logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}") + return card_id + + async def _send_card_message( + self, + card_id: str, + reply_message_id: str | None = None, + receive_id: str | None = None, + receive_id_type: str | None = None, + ) -> bool: + """将卡片实体作为 interactive 消息发送。""" + content = json.dumps( + {"type": "card", "data": {"card_id": card_id}}, + ensure_ascii=False, + ) + return await self._send_im_message( + self.bot, + content=content, + msg_type="interactive", + reply_message_id=reply_message_id, + receive_id=receive_id, + receive_id_type=receive_id_type, + ) + + async def _update_streaming_text( + self, + card_id: str, + content: str, + sequence: int, + ) -> bool: + """调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。""" + if self.bot.cardkit is None: + logger.error("[Lark] API Client cardkit 模块未初始化") + return False + + request = ( + ContentCardElementRequest.builder() + .card_id(card_id) + .element_id("markdown_1") + .request_body( + ContentCardElementRequestBody.builder() + .content(content) + .sequence(sequence) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + try: + response = await self.bot.cardkit.v1.card_element.acontent(request) + except Exception as e: + logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}") + return False + + if not response.success(): + logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}") + return False + + return True + + async def _close_streaming_mode( + self, + card_id: str, + sequence: int, + ) -> None: + """关闭卡片的流式更新模式,使其可正常转发、摘要恢复。""" + if self.bot.cardkit is None: + logger.error("[Lark] API Client cardkit 模块未初始化") + return + + settings_json = json.dumps( + {"config": {"streaming_mode": False}}, + ensure_ascii=False, + ) + + request = ( + SettingsCardRequest.builder() + .card_id(card_id) + .request_body( + SettingsCardRequestBody.builder() + .settings(settings_json) + .sequence(sequence) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + try: + response = await self.bot.cardkit.v1.card.asettings(request) + except Exception as e: + logger.error(f"[Lark] 关闭流式模式失败: {e}") + return + + if not response.success(): + logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}") + else: + logger.debug(f"[Lark] 流式模式已关闭: {card_id}") + + async def _fallback_send_streaming(self, generator, use_fallback: bool = False): + """回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。""" buffer = None async for chain in generator: if not buffer: buffer = chain else: buffer.chain.extend(chain.chain) - if not buffer: - return None - buffer.squash_plain() - await self.send(buffer) - return await super().send_streaming(generator, use_fallback) + + if buffer: + buffer.squash_plain() + await self.send(buffer) + + await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name) + self._has_send_oper = True + + async def send_streaming(self, generator, use_fallback: bool = False): + """使用 CardKit 流式卡片实现打字机效果。 + + 流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。 + 使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程, + 发送频率由网络 RTT 自然限流。 + """ + # Step 1: 创建流式卡片实体 + card_id = await self._create_streaming_card() + if not card_id: + logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送") + await self._fallback_send_streaming(generator, use_fallback) + return + + # Step 2: 发送卡片消息 + sent = await self._send_card_message( + card_id, + reply_message_id=self.message_obj.message_id, + ) + if not sent: + logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送") + await self._fallback_send_streaming(generator, use_fallback) + return + + logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片") + + # Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径) + sequence = 0 + delta = "" + last_sent = "" + done = False + text_changed = asyncio.Event() + + async def _sender_loop() -> None: + """信号驱动的文本发送循环,有新内容就发,RTT 自然限流。""" + nonlocal sequence, last_sent + while not done: + await text_changed.wait() + text_changed.clear() + snapshot = delta + if snapshot and snapshot != last_sent: + sequence += 1 + ok = await self._update_streaming_text(card_id, snapshot, sequence) + if ok: + last_sent = snapshot + if delta != snapshot: + text_changed.set() + + sender_task = asyncio.create_task(_sender_loop()) + + try: + async for chain in generator: + if not isinstance(chain, MessageChain): + continue + + if chain.type == "break": + # 飞书卡片不支持分段,忽略 break + continue + + for comp in chain.chain: + if isinstance(comp, Plain): + delta += comp.text + text_changed.set() + finally: + done = True + text_changed.set() + await sender_task + + # Step 4: 必要时补发最终文本 + 关闭流式模式 + if delta and delta != last_sent: + sequence += 1 + await self._update_streaming_text(card_id, delta, sequence) + + sequence += 1 + await self._close_streaming_mode(card_id, sequence) + + # Step 5: 内联父类 send_streaming 的副作用 + await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name) + self._has_send_oper = True diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 868ec8a65..2b417f45f 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -80,7 +80,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): if isinstance(source, botpy.message.C2CMessage): # 真流式传输 - current_time = asyncio.get_event_loop().time() + current_time = asyncio.get_running_loop().time() time_since_last_edit = current_time - last_edit_time if time_since_last_edit >= throttle_interval: @@ -90,7 +90,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) stream_payload["index"] += 1 stream_payload["id"] = ret["id"] - last_edit_time = asyncio.get_event_loop().time() + last_edit_time = asyncio.get_running_loop().time() if isinstance(source, botpy.message.C2CMessage): # 结束流式对话,并且传输 buffer 中剩余的消息 diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 5f35471ee..bcd05faf1 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -55,7 +55,7 @@ class QQOfficialWebhook: max_async=1, connect=bot_connect, dispatch=self.client.ws_dispatch, - loop=asyncio.get_event_loop(), + loop=asyncio.get_running_loop(), api=self.api, ) diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 96c7c5568..43e58960e 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -626,7 +626,7 @@ class TelegramPlatformEvent(AstrMessageEvent): # 发送初始 typing 状态 await self._ensure_typing(user_name, message_thread_id) - last_chat_action_time = asyncio.get_event_loop().time() + last_chat_action_time = asyncio.get_running_loop().time() def _append_text(t: str) -> None: nonlocal delta @@ -657,11 +657,11 @@ class TelegramPlatformEvent(AstrMessageEvent): # 编辑或发送消息 if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH: - current_time = asyncio.get_event_loop().time() + current_time = asyncio.get_running_loop().time() time_since_last_edit = current_time - last_edit_time if time_since_last_edit >= throttle_interval: - current_time = asyncio.get_event_loop().time() + current_time = asyncio.get_running_loop().time() if current_time - last_chat_action_time >= chat_action_interval: await self._ensure_typing(user_name, message_thread_id) last_chat_action_time = current_time @@ -674,9 +674,9 @@ class TelegramPlatformEvent(AstrMessageEvent): current_content = delta except Exception as e: logger.warning(f"编辑消息失败(streaming): {e!s}") - last_edit_time = asyncio.get_event_loop().time() + last_edit_time = asyncio.get_running_loop().time() else: - current_time = asyncio.get_event_loop().time() + current_time = asyncio.get_running_loop().time() if current_time - last_chat_action_time >= chat_action_interval: await self._ensure_typing(user_name, message_thread_id) last_chat_action_time = current_time @@ -688,7 +688,7 @@ class TelegramPlatformEvent(AstrMessageEvent): except Exception as e: logger.warning(f"发送消息失败(streaming): {e!s}") message_id = msg.message_id - last_edit_time = asyncio.get_event_loop().time() + last_edit_time = asyncio.get_running_loop().time() try: if delta and current_content != delta: diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 6647db89f..410b30eea 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -200,7 +200,7 @@ class WecomPlatformAdapter(Platform): return msg_list[-1] return None - msg_new = await asyncio.get_event_loop().run_in_executor( + msg_new = await asyncio.get_running_loop().run_in_executor( None, get_latest_msg_item, ) @@ -261,7 +261,7 @@ class WecomPlatformAdapter(Platform): @override async def run(self) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() if self.kf_name: try: acc_list = ( @@ -339,7 +339,7 @@ class WecomPlatformAdapter(Platform): abm.session_id = abm.sender.user_id abm.raw_message = msg elif isinstance(msg, VoiceMessage): - resp: Response = await asyncio.get_event_loop().run_in_executor( + resp: Response = await asyncio.get_running_loop().run_in_executor( None, self.client.media.download, msg.media_id, @@ -395,7 +395,7 @@ class WecomPlatformAdapter(Platform): abm.message_str = text elif msgtype == "image": media_id = msg.get("image", {}).get("media_id", "") - resp: Response = await asyncio.get_event_loop().run_in_executor( + resp: Response = await asyncio.get_running_loop().run_in_executor( None, self.client.media.download, media_id, @@ -407,7 +407,7 @@ class WecomPlatformAdapter(Platform): abm.message = [Image(file=path, url=path)] elif msgtype == "voice": media_id = msg.get("voice", {}).get("media_id", "") - resp: Response = await asyncio.get_event_loop().run_in_executor( + resp: Response = await asyncio.get_running_loop().run_in_executor( None, self.client.media.download, media_id, diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index aba60e06c..62f236b57 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -1,5 +1,5 @@ """企业微信智能机器人平台适配器 -基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调 +基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调与长连接 参考webchat_adapter.py的队列机制,实现异步消息处理和流式响应 """ @@ -31,6 +31,7 @@ from .wecomai_api import ( WecomAIBotStreamMessageBuilder, ) from .wecomai_event import WecomAIBotMessageEvent +from .wecomai_long_connection import WecomAIBotLongConnectionClient from .wecomai_queue_mgr import WecomAIQueueMgr from .wecomai_server import WecomAIBotServer from .wecomai_utils import ( @@ -78,8 +79,13 @@ class WecomAIBotAdapter(Platform): self.settings = platform_settings # 初始化配置参数 - self.token = self.config["token"] - self.encoding_aes_key = self.config["encoding_aes_key"] + self.connection_mode = self.config.get( + "wecom_ai_bot_connection_mode", "webhook" + ) + self.token = self.config.get("token", self.config.get("wecomaibot_token", "")) + self.encoding_aes_key = self.config.get( + "encoding_aes_key", self.config.get("wecomaibot_encoding_aes_key", "") + ) self.port = int(self.config["port"]) self.host = self.config.get("callback_server_host", "0.0.0.0") self.bot_name = self.config.get("wecom_ai_bot_name", "") @@ -96,25 +102,52 @@ class WecomAIBotAdapter(Platform): self.only_use_webhook_url_to_send = bool( self.config.get("only_use_webhook_url_to_send", False), ) + self.long_connection_bot_id = self.config.get( + "wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "") + ) + self.long_connection_secret = self.config.get( + "wecomaibot_ws_secret", self.config.get("long_connection_secret", "") + ) + self.long_connection_ws_url = self.config.get( + "wecomaibot_ws_url", + "wss://openws.work.weixin.qq.com", + ) + self.long_connection_heartbeat_interval = int( + self.config.get("wecomaibot_heartbeat_interval", 30), + ) # 平台元数据 self.metadata = PlatformMetadata( name="wecom_ai_bot", - description="企业微信智能机器人适配器,支持 HTTP 回调接收消息", + description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式", id=self.config.get("id", "wecom_ai_bot"), support_proactive_message=bool(self.msg_push_webhook_url), ) - # 初始化 API 客户端 - self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key) + self.api_client: WecomAIBotAPIClient | None = None + self.server: WecomAIBotServer | None = None + self.long_connection_client: WecomAIBotLongConnectionClient | None = None - # 初始化 HTTP 服务器 - self.server = WecomAIBotServer( - host=self.host, - port=self.port, - api_client=self.api_client, - message_handler=self._process_message, - ) + if self.connection_mode == "long_connection": + if not self.long_connection_bot_id or not self.long_connection_secret: + logger.warning( + "企业微信智能机器人长连接模式缺少 BotID 或 Secret,连接可能失败" + ) + self.long_connection_client = WecomAIBotLongConnectionClient( + bot_id=self.long_connection_bot_id, + secret=self.long_connection_secret, + ws_url=self.long_connection_ws_url, + heartbeat_interval=self.long_connection_heartbeat_interval, + message_handler=self._process_long_connection_payload, + ) + else: + self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key) + self.server = WecomAIBotServer( + host=self.host, + port=self.port, + api_client=self.api_client, + message_handler=self._process_message, + ) # 事件循环和关闭信号 self.shutdown_event = asyncio.Event() @@ -161,6 +194,9 @@ class WecomAIBotAdapter(Platform): 加密后的响应消息,无需响应时返回 None """ + if not self.api_client: + logger.error("Webhook 消息处理失败: API 客户端未初始化") + return None msgtype = message_data.get("msgtype") if not msgtype: logger.warning(f"消息类型未知,忽略: {message_data}") @@ -320,6 +356,89 @@ class WecomAIBotAdapter(Platform): logger.error("处理欢迎消息时发生异常: %s", e) return None + async def _process_long_connection_payload( + self, + payload: dict[str, Any], + ) -> None: + """处理长连接回调消息。""" + cmd = payload.get("cmd") + headers = payload.get("headers") or {} + body = payload.get("body") or {} + req_id = headers.get("req_id") + if not isinstance(body, dict): + return + + if cmd == "aibot_msg_callback": + session_id = self._extract_session_id(body) + stream_id = f"{session_id}_{generate_random_string(10)}" + await self._enqueue_message( + body, {"req_id": req_id or ""}, stream_id, session_id + ) + self.queue_mgr.set_pending_response( + stream_id, + { + "req_id": req_id or "", + "connection_mode": "long_connection", + }, + ) + + if self.initial_respond_text and req_id: + await self._send_long_connection_respond_msg( + req_id=req_id, + body={ + "msgtype": "stream", + "stream": { + "id": stream_id, + "finish": False, + "content": self.initial_respond_text, + }, + }, + ) + return + + if cmd == "aibot_event_callback": + event = body.get("event") or {} + event_type = event.get("eventtype") + if ( + event_type == "enter_chat" + and self.friend_message_welcome_text + and req_id + ): + await self._send_long_connection_respond_welcome(req_id) + elif event_type == "disconnected_event": + logger.warning( + "[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭" + ) + + async def _send_long_connection_respond_welcome(self, req_id: str) -> bool: + client = self.long_connection_client + if not client: + return False + return await client.send_command( + cmd="aibot_respond_welcome_msg", + req_id=req_id, + body={ + "msgtype": "text", + "text": { + "content": self.friend_message_welcome_text, + }, + }, + ) + + async def _send_long_connection_respond_msg( + self, + req_id: str, + body: dict[str, Any], + ) -> bool: + client = self.long_connection_client + if not client: + return False + return await client.send_command( + cmd="aibot_respond_msg", + req_id=req_id, + body=body, + ) + def _extract_session_id(self, message_data: dict[str, Any]) -> str: """从消息数据中提取会话ID""" user_id = message_data.get("from", {}).get("userid", "default_user") @@ -355,15 +474,16 @@ class WecomAIBotAdapter(Platform): content = "" image_base64 = [] - _img_url_to_process = [] + _img_url_to_process: list[tuple[str, str | None]] = [] msg_items = [] if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT: content = WecomAIBotMessageParser.parse_text_message(message_data) elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE: - _img_url_to_process.append( - WecomAIBotMessageParser.parse_image_message(message_data), - ) + image_payload = message_data.get("image", {}) + image_url = image_payload.get("url", "") + if image_url: + _img_url_to_process.append((image_url, image_payload.get("aeskey"))) elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED: # 提取混合消息中的文本内容 msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data) @@ -374,9 +494,12 @@ class WecomAIBotAdapter(Platform): if text_content: text_parts.append(text_content) elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE: - image_url = item.get("image", {}).get("url", "") + image_payload = item.get("image", {}) + image_url = image_payload.get("url", "") if image_url: - _img_url_to_process.append(image_url) + _img_url_to_process.append( + (image_url, image_payload.get("aeskey")) + ) content = " ".join(text_parts) if text_parts else "" else: content = f"[{msgtype}消息]" @@ -384,8 +507,8 @@ class WecomAIBotAdapter(Platform): # 并行处理图片下载和解密 if _img_url_to_process: tasks = [ - process_encrypted_image(url, self.encoding_aes_key) - for url in _img_url_to_process + process_encrypted_image(url, aes_key or self.encoding_aes_key) + for url, aes_key in _img_url_to_process ] results = await asyncio.gather(*tasks) for success, result in results: @@ -459,26 +582,43 @@ class WecomAIBotAdapter(Platform): """运行适配器,同时启动HTTP服务器和队列监听器""" async def run_both() -> None: - # 如果启用统一 webhook 模式,则不启动独立服务器 - webhook_uuid = self.config.get("webhook_uuid") - if self.unified_webhook_mode and webhook_uuid: - log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid) - # 只运行队列监听器 - await self.queue_listener.run() - else: + if self.connection_mode == "long_connection": + if not self.long_connection_client: + raise RuntimeError("长连接客户端未初始化") logger.info( - "启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port + "启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url ) - # 同时运行HTTP服务器和队列监听器 await asyncio.gather( - self.server.start_server(), + self.long_connection_client.start(), self.queue_listener.run(), ) + else: + # 如果启用统一 webhook 模式,则不启动独立服务器 + webhook_uuid = self.config.get("webhook_uuid") + if self.unified_webhook_mode and webhook_uuid: + log_webhook_info( + f"{self.meta().id}(企业微信智能机器人)", webhook_uuid + ) + # 只运行队列监听器 + await self.queue_listener.run() + else: + if not self.server: + raise RuntimeError("Webhook 服务器未初始化") + logger.info( + "启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port + ) + # 同时运行HTTP服务器和队列监听器 + await asyncio.gather( + self.server.start_server(), + self.queue_listener.run(), + ) return run_both() async def webhook_callback(self, request: Any) -> Any: """统一 Webhook 回调入口""" + if self.connection_mode == "long_connection" or not self.server: + return "long_connection mode does not accept webhook callbacks", 400 # 根据请求方法分发到不同的处理函数 if request.method == "GET": return await self.server.handle_verify(request) @@ -489,7 +629,10 @@ class WecomAIBotAdapter(Platform): """终止适配器""" logger.info("企业微信智能机器人适配器正在关闭...") self.shutdown_event.set() - await self.server.shutdown() + if self.long_connection_client: + await self.long_connection_client.shutdown() + if self.server: + await self.server.shutdown() def meta(self) -> PlatformMetadata: """获取平台元数据""" @@ -507,17 +650,22 @@ class WecomAIBotAdapter(Platform): queue_mgr=self.queue_mgr, webhook_client=self.webhook_client, only_use_webhook_url_to_send=self.only_use_webhook_url_to_send, + long_connection_sender=self._send_long_connection_respond_msg, ) + message_event.is_at_or_wake_command = ( + True # 企业微信智能机器人默认消息都是 at 或唤醒命令 + ) + message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理 self.commit_event(message_event) except Exception as e: logger.error("处理消息时发生异常: %s", e) - def get_client(self) -> WecomAIBotAPIClient: + def get_client(self) -> WecomAIBotAPIClient | None: """获取 API 客户端""" return self.api_client - def get_server(self) -> WecomAIBotServer: + def get_server(self) -> WecomAIBotServer | None: """获取 HTTP 服务器实例""" return self.server diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py index 0369a82af..b7cf189e1 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py @@ -1,5 +1,7 @@ """企业微信智能机器人事件处理模块,处理消息事件的发送和接收""" +from collections.abc import Awaitable, Callable + from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import At, Image, Plain @@ -18,10 +20,11 @@ class WecomAIBotMessageEvent(AstrMessageEvent): message_obj, platform_meta, session_id: str, - api_client: WecomAIBotAPIClient, + api_client: WecomAIBotAPIClient | None, queue_mgr: WecomAIQueueMgr, webhook_client: WecomAIBotWebhookClient | None = None, only_use_webhook_url_to_send: bool = False, + long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None, ) -> None: """初始化消息事件 @@ -38,6 +41,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent): self.queue_mgr = queue_mgr self.webhook_client = webhook_client self.only_use_webhook_url_to_send = only_use_webhook_url_to_send + self.long_connection_sender = long_connection_sender async def _mark_stream_complete(self, stream_id: str) -> None: back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) @@ -117,6 +121,18 @@ class WecomAIBotMessageEvent(AstrMessageEvent): return data + @staticmethod + def _extract_plain_text_from_chain(message_chain: MessageChain | None) -> str: + if not message_chain: + return "" + plain_parts: list[str] = [] + for comp in message_chain.chain: + if isinstance(comp, At): + plain_parts.append(f"@{comp.name} ") + elif isinstance(comp, Plain): + plain_parts.append(comp.text) + return "".join(plain_parts).strip() + async def send(self, message: MessageChain | None) -> None: """发送消息""" raw = self.message_obj.raw_message @@ -124,6 +140,44 @@ class WecomAIBotMessageEvent(AstrMessageEvent): "wecom_ai_bot platform event raw_message should be a dict" ) stream_id = raw.get("stream_id", self.session_id) + pending_response = self.queue_mgr.get_pending_response(stream_id) or {} + connection_mode = pending_response.get("callback_params", {}).get( + "connection_mode" + ) + req_id = pending_response.get("callback_params", {}).get("req_id") + + if ( + connection_mode == "long_connection" + and self.long_connection_sender + and isinstance(req_id, str) + and req_id + ): + if self.only_use_webhook_url_to_send and self.webhook_client and message: + await self.webhook_client.send_message_chain(message) + await super().send(MessageChain([])) + return + + if self.webhook_client and message: + await self.webhook_client.send_message_chain( + message, + unsupported_only=True, + ) + + content = self._extract_plain_text_from_chain(message) + await self.long_connection_sender( + req_id, + { + "msgtype": "stream", + "stream": { + "id": stream_id, + "finish": True, + "content": content, + }, + }, + ) + await super().send(MessageChain([])) + return + if self.only_use_webhook_url_to_send and self.webhook_client and message: await self.webhook_client.send_message_chain(message) await self._mark_stream_complete(stream_id) @@ -152,8 +206,77 @@ class WecomAIBotMessageEvent(AstrMessageEvent): "wecom_ai_bot platform event raw_message should be a dict" ) stream_id = raw.get("stream_id", self.session_id) + pending_response = self.queue_mgr.get_pending_response(stream_id) or {} + connection_mode = pending_response.get("callback_params", {}).get( + "connection_mode" + ) + req_id = pending_response.get("callback_params", {}).get("req_id") back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) + if ( + connection_mode == "long_connection" + and self.long_connection_sender + and isinstance(req_id, str) + and req_id + ): + if self.only_use_webhook_url_to_send and self.webhook_client: + merged_chain = MessageChain([]) + async for chain in generator: + merged_chain.chain.extend(chain.chain) + merged_chain.squash_plain() + await self.webhook_client.send_message_chain(merged_chain) + await self.long_connection_sender( + req_id, + { + "msgtype": "stream", + "stream": { + "id": stream_id, + "finish": True, + "content": "", + }, + }, + ) + await super().send_streaming(generator, use_fallback) + return + + increment_plain = "" + async for chain in generator: + if self.webhook_client: + await self.webhook_client.send_message_chain( + chain, + unsupported_only=True, + ) + + chain.squash_plain() + chunk_text = self._extract_plain_text_from_chain(chain) + if chunk_text: + increment_plain += chunk_text + await self.long_connection_sender( + req_id, + { + "msgtype": "stream", + "stream": { + "id": stream_id, + "finish": False, + "content": increment_plain, + }, + }, + ) + + await self.long_connection_sender( + req_id, + { + "msgtype": "stream", + "stream": { + "id": stream_id, + "finish": True, + "content": increment_plain, + }, + }, + ) + await super().send_streaming(generator, use_fallback) + return + if self.only_use_webhook_url_to_send and self.webhook_client: merged_chain = MessageChain([]) async for chain in generator: diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_long_connection.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_long_connection.py new file mode 100644 index 000000000..1017dd230 --- /dev/null +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_long_connection.py @@ -0,0 +1,236 @@ +"""企业微信智能机器人长连接客户端。""" + +import asyncio +import json +import uuid +from collections.abc import Awaitable, Callable +from typing import Any + +import aiohttp + +from astrbot.api import logger + + +class WecomAIBotLongConnectionClient: + """企业微信智能机器人 WebSocket 长连接客户端。""" + + def __init__( + self, + bot_id: str, + secret: str, + ws_url: str, + heartbeat_interval: int, + message_handler: Callable[[dict[str, Any]], Awaitable[None]], + ) -> None: + self.bot_id = bot_id + self.secret = secret + self.ws_url = ws_url + self.heartbeat_interval = max(5, int(heartbeat_interval)) + self.message_handler = message_handler + + self._session: aiohttp.ClientSession | None = None + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._shutdown_event = asyncio.Event() + self._send_lock = asyncio.Lock() + self._command_lock = asyncio.Lock() + self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {} + + @staticmethod + def gen_req_id() -> str: + return uuid.uuid4().hex + + async def start(self) -> None: + """启动长连接并自动重连。""" + reconnect_delay = 1 + while not self._shutdown_event.is_set(): + try: + await self._run_once() + reconnect_delay = 1 + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("[WecomAI][LongConn] 长连接异常: %s", e) + if self._shutdown_event.is_set(): + break + await asyncio.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, 30) + + async def _run_once(self) -> None: + timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=None) + async with aiohttp.ClientSession(timeout=timeout) as session: + self._session = session + logger.info("[WecomAI][LongConn] 正在连接: %s", self.ws_url) + async with session.ws_connect( + self.ws_url, heartbeat=None, autoping=True + ) as ws: + self._ws = ws + await self._subscribe() + logger.info("[WecomAI][LongConn] 订阅成功,已建立长连接") + + heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + try: + while not self._shutdown_event.is_set(): + message = await ws.receive() + if message.type == aiohttp.WSMsgType.TEXT: + await self._handle_text_message(message.data) + elif message.type in { + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.ERROR, + }: + break + finally: + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + self._ws = None + + async def _subscribe(self) -> None: + """发送 aibot_subscribe,并等待响应。""" + req_id = self.gen_req_id() + payload = { + "cmd": "aibot_subscribe", + "headers": {"req_id": req_id}, + "body": {"bot_id": self.bot_id, "secret": self.secret}, + } + await self._send_json(payload) + + if not self._ws: + raise RuntimeError("WebSocket 未建立") + + reply = await self._ws.receive(timeout=10) + if reply.type != aiohttp.WSMsgType.TEXT: + raise RuntimeError(f"订阅失败: 非文本响应 {reply.type}") + + data = json.loads(reply.data) + if data.get("errcode") != 0: + raise RuntimeError( + f"订阅失败 errcode={data.get('errcode')} errmsg={data.get('errmsg')}" + ) + + async def _heartbeat_loop(self) -> None: + while not self._shutdown_event.is_set(): + await asyncio.sleep(self.heartbeat_interval) + if self._shutdown_event.is_set(): + break + try: + await self.send_command("ping", self.gen_req_id(), None) + except Exception as e: + logger.warning("[WecomAI][LongConn] 发送心跳失败: %s", e) + return + + async def _handle_text_message(self, text: str) -> None: + try: + payload = json.loads(text) + except json.JSONDecodeError: + logger.warning("[WecomAI][LongConn] 收到非 JSON 消息: %s", text) + return + + headers = payload.get("headers") or {} + req_id = headers.get("req_id") + if isinstance(req_id, str): + waiter = self._response_waiters.get(req_id) + if waiter and not waiter.done(): + waiter.set_result(payload) + return + + cmd = payload.get("cmd") + if cmd in {"aibot_msg_callback", "aibot_event_callback"}: + await self.message_handler(payload) + return + + if payload.get("errcode") not in (None, 0): + logger.warning( + "[WecomAI][LongConn] 服务端返回错误: errcode=%s errmsg=%s", + payload.get("errcode"), + payload.get("errmsg"), + ) + + async def send_command( + self, + cmd: str, + req_id: str, + body: dict[str, Any] | None, + ) -> bool: + """发送长连接命令。""" + headers = {"req_id": req_id} + payload: dict[str, Any] = {"cmd": cmd, "headers": headers} + if body is not None: + payload["body"] = body + + async with self._command_lock: + max_retries = 3 + for attempt in range(max_retries + 1): + response = await self._send_and_wait_response(req_id, payload) + if not response: + if attempt < max_retries: + await asyncio.sleep(min(0.2 * (2**attempt), 2.0)) + continue + return False + + errcode = response.get("errcode") + if errcode in (0, None): + return True + + if errcode == 6000 and attempt < max_retries: + backoff = min(0.2 * (2**attempt), 2.0) + logger.warning( + "[WecomAI][LongConn] 命令冲突(errcode=6000),将重试。cmd=%s req_id=%s attempt=%d", + cmd, + req_id, + attempt + 1, + ) + await asyncio.sleep(backoff) + continue + + logger.warning( + "[WecomAI][LongConn] 命令失败: cmd=%s req_id=%s errcode=%s errmsg=%s", + cmd, + req_id, + errcode, + response.get("errmsg"), + ) + return False + + return False + + async def _send_and_wait_response( + self, + req_id: str, + payload: dict[str, Any], + timeout: float = 10.0, + ) -> dict[str, Any] | None: + loop = asyncio.get_running_loop() + waiter: asyncio.Future[dict[str, Any]] = loop.create_future() + self._response_waiters[req_id] = waiter + try: + await self._send_json(payload) + return await asyncio.wait_for(waiter, timeout=timeout) + except TimeoutError: + logger.warning( + "[WecomAI][LongConn] 等待命令响应超时: cmd=%s req_id=%s", + payload.get("cmd"), + req_id, + ) + return None + finally: + self._response_waiters.pop(req_id, None) + + async def _send_json(self, payload: dict[str, Any]) -> None: + ws = self._ws + if ws is None or ws.closed: + raise RuntimeError("长连接尚未建立") + async with self._send_lock: + await ws.send_json(payload) + + async def shutdown(self) -> None: + self._shutdown_event.set() + ws = self._ws + if ws is not None and not ws.closed: + await ws.close() + + session = self._session + if session is not None and not session.closed: + await session.close() diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py index 9b6e6b968..efa94b58e 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py @@ -4,6 +4,7 @@ """ import asyncio +import time from collections.abc import Awaitable, Callable from typing import Any @@ -82,7 +83,7 @@ class WecomAIQueueMgr: del self.pending_responses[session_id] logger.debug(f"[WecomAI] 移除待处理响应: {session_id}") if mark_finished: - self.completed_streams[session_id] = asyncio.get_event_loop().time() + self.completed_streams[session_id] = time.monotonic() logger.debug(f"[WecomAI] 标记流已结束: {session_id}") def remove_queue(self, session_id: str): @@ -135,7 +136,7 @@ class WecomAIQueueMgr: """ self.pending_responses[session_id] = { "callback_params": callback_params, - "timestamp": asyncio.get_event_loop().time(), + "timestamp": time.monotonic(), } logger.debug(f"[WecomAI] 设置待处理响应: {session_id}") @@ -160,7 +161,7 @@ class WecomAIQueueMgr: finished_at = self.completed_streams.get(session_id) if finished_at is None: return False - if asyncio.get_event_loop().time() - finished_at > max_age_seconds: + if time.monotonic() - finished_at > max_age_seconds: self.completed_streams.pop(session_id, None) return False return True @@ -172,7 +173,7 @@ class WecomAIQueueMgr: max_age_seconds: 最大存活时间(秒) """ - current_time = asyncio.get_event_loop().time() + current_time = time.monotonic() expired_sessions = [] for session_id, response_data in self.pending_responses.items(): diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index c01355974..bb7061ca1 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -369,7 +369,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform): if future: logger.debug(f"duplicate message id checked: {msg.id}") else: - future = asyncio.get_event_loop().create_future() + future = asyncio.get_running_loop().create_future() self.wexin_event_workers[msg_id] = future await self.convert_message(msg, future) # I love shield so much! @@ -461,7 +461,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform): elif msg.type == "voice": assert isinstance(msg, VoiceMessage) - resp: Response = await asyncio.get_event_loop().run_in_executor( + resp: Response = await asyncio.get_running_loop().run_in_executor( None, self.client.media.download, msg.media_id, diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index b7b726ad0..d9ac26960 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -21,8 +21,8 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path DEFAULT_MCP_CONFIG = {"mcpServers": {}} -DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0 -DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0 +DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0 +DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0 MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT" ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT" MAX_MCP_TIMEOUT_SECONDS = 300.0 @@ -417,9 +417,11 @@ class FunctionToolManager: for (name, cfg, _), result in zip(active_configs, results, strict=False): if isinstance(result, Exception): if isinstance(result, MCPInitTimeoutError): - logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)") + logger.error( + f"Connected to MCP server {name} timeout ({timeout_display} seconds)" + ) else: - logger.error(f"MCP 服务 {name} 初始化失败: {result}") + logger.error(f"Failed to initialize MCP server {name}: {result}") self._log_safe_mcp_debug_config(cfg) failed_services.append(name) async with self._runtime_lock: @@ -430,16 +432,18 @@ class FunctionToolManager: if failed_services: logger.warning( - f"以下 MCP 服务初始化失败: {', '.join(failed_services)}。" - f"请检查配置文件 mcp_server.json 和服务器可用性。" + f"The following MCP services failed to initialize: {', '.join(failed_services)}. " + f"Please check the mcp_server.json file and server availability." ) summary = MCPInitSummary( total=len(active_configs), success=success_count, failed=failed_services ) - logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功") + logger.info( + f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed." + ) if summary.total > 0 and summary.success == 0: - msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。" + msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability." if raise_on_all_failed: raise MCPAllServicesFailedError(msg) logger.error(msg) @@ -461,7 +465,7 @@ class FunctionToolManager: async with self._runtime_lock: if name in self._mcp_server_runtime or name in self._mcp_starting: logger.warning( - f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。" + f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})." ) self._log_safe_mcp_debug_config(cfg) return @@ -478,10 +482,10 @@ class FunctionToolManager: ) except asyncio.TimeoutError as exc: raise MCPInitTimeoutError( - f"MCP 服务 {name} 初始化超时({timeout:g} 秒)" + f"Connected to MCP server {name} timeout ({timeout:g} seconds)" ) from exc except Exception: - logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True) + logger.error(f"Failed to initialize MCP client {name}", exc_info=True) raise finally: if mcp_client is None: @@ -491,9 +495,9 @@ class FunctionToolManager: async def lifecycle() -> None: try: await shutdown_event.wait() - logger.info(f"收到 MCP 客户端 {name} 终止信号") + logger.info(f"Received shutdown signal for MCP client {name}") except asyncio.CancelledError: - logger.debug(f"MCP 客户端 {name} 任务被取消") + logger.debug(f"MCP client {name} task was cancelled") raise finally: await self._terminate_mcp_client(name) @@ -545,7 +549,7 @@ class FunctionToolManager: if strict: raise MCPShutdownTimeoutError(pending_names, timeout) logger.warning( - "MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s", + "MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s", f"{timeout:g}", ", ".join(pending_names), ) @@ -568,7 +572,9 @@ class FunctionToolManager: try: await mcp_client.cleanup() except Exception as cleanup_exc: # noqa: BLE001 - only log here - logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}") + logger.error( + f"Failed to cleanup MCP client resources {name}: {cleanup_exc}" + ) async def _init_mcp_client(self, name: str, config: dict) -> MCPClient: """初始化单个MCP客户端""" @@ -602,7 +608,7 @@ class FunctionToolManager: ) self.func_list.append(func_tool) - logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}") + logger.info(f"Connected to MCP server {name}, Tools: {tool_names}") return mcp_client async def _terminate_mcp_client(self, name: str) -> None: @@ -622,7 +628,7 @@ class FunctionToolManager: async with self._runtime_lock: self._mcp_server_runtime.pop(name, None) self._mcp_starting.discard(name) - logger.info(f"已关闭 MCP 服务 {name}") + logger.info(f"Disconnected from MCP server {name}") return # Runtime missing but stale tools may still exist after failed flows. diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index ef89027e8..520b36cd4 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -79,6 +79,7 @@ class ProviderManager: self._provider_change_hooks: list[ Callable[[str, ProviderType, str | None], None] ] = [] + self._mcp_init_task: asyncio.Task | None = None def set_provider_change_callback( self, @@ -330,24 +331,16 @@ class ProviderManager: if not self.curr_tts_provider_inst and self.tts_provider_insts: self.curr_tts_provider_inst = self.tts_provider_insts[0] - # 初始化 MCP Client 连接(等待完成以确保工具可用) - strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in { - "1", - "true", - "yes", - "on", - } - mcp_init_summary = await self.llm_tools.init_mcp_clients( - raise_on_all_failed=strict_mcp_init - ) - if ( - mcp_init_summary.total > 0 - and mcp_init_summary.success == 0 - and not strict_mcp_init - ): - logger.warning( - "MCP 服务全部初始化失败,系统将继续启动(可设置 " - "ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。" + async def _init_mcp_clients_bg() -> None: + try: + await self.llm_tools.init_mcp_clients() + except Exception: + logger.error("MCP init background task failed", exc_info=True) + + if self._mcp_init_task is None or self._mcp_init_task.done(): + self._mcp_init_task = asyncio.create_task( + _init_mcp_clients_bg(), + name="provider-manager:mcp-init", ) def dynamic_import_provider(self, type: str) -> None: @@ -817,6 +810,13 @@ class ProviderManager: await self.load_provider(new_config) async def terminate(self) -> None: + if self._mcp_init_task and not self._mcp_init_task.done(): + self._mcp_init_task.cancel() + try: + await self._mcp_init_task + except asyncio.CancelledError: + pass + for provider_inst in self.provider_insts: if hasattr(provider_inst, "terminate"): await provider_inst.terminate() # type: ignore diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 901efd005..345ad7b74 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -281,7 +281,24 @@ class TTSProvider(AbstractProvider): accumulated_text += text_part async def test(self) -> None: - await self.get_audio("hi") + audio_path = await self.get_audio("hi") + + # 检查生成的音频文件是否有效 + if not os.path.exists(audio_path): + raise Exception("TTS test failed: audio file was not created") + + file_size = os.path.getsize(audio_path) + if file_size == 0: + raise Exception( + "TTS test failed: generated audio file is empty (0 bytes). " + "Please check your TTS provider configuration, especially required parameters like group_id for MiniMax." + ) + + # 清理测试文件 + try: + os.remove(audio_path) + except Exception: + pass class EmbeddingProvider(AbstractProvider): diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index ec3c395a4..be70fdc74 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -276,9 +276,24 @@ class ProviderAnthropic(Provider): llm_response.id = completion.id llm_response.usage = self._extract_usage(completion.usage) - # TODO(Soulter): 处理 end_turn 情况 + # Handle cases where completion only contains ThinkingBlock (e.g., MiniMax max_tokens) + # When stop_reason='max_tokens', the model may return only thinking content + # This is valid and should not raise an exception if not llm_response.completion_text and not llm_response.tools_call_args: - raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。") + # Guard clause: raise early if no valid content at all + if not llm_response.reasoning_content: + raise ValueError( + f"Anthropic API returned unparsable completion: " + f"no text, tool_use, or thinking content found. " + f"Completion: {completion}" + ) + + # We have reasoning content (ThinkingBlock) - this is valid + stop_reason = getattr(completion, "stop_reason", "unknown") + logger.debug( + f"Completion contains only ThinkingBlock (stop_reason={stop_reason})" + ) + llm_response.completion_text = "" # Ensure empty string, not None return llm_response diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 0e8f00ce5..fc2bb6c09 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -20,6 +20,7 @@ from ..register import register_provider_adapter TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts" TEMP_DIR.mkdir(parents=True, exist_ok=True) +AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$" class OTTSProvider: @@ -116,7 +117,7 @@ class AzureNativeProvider(TTSProvider): "azure_tts_subscription_key", "", ).strip() - if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key): + if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key): raise ValueError("无效的Azure订阅密钥") self.region = provider_config.get("azure_tts_region", "eastus").strip() self.endpoint = ( @@ -235,9 +236,9 @@ class AzureTTSProvider(TTSProvider): raise ValueError(error_msg) from e except KeyError as e: raise ValueError(f"配置错误: 缺少必要参数 {e}") from e - if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value): + if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value): return AzureNativeProvider(config, self.provider_settings) - raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式") + raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式") async def get_audio(self, text: str) -> str: if isinstance(self.provider, OTTSProvider): diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 9b6816859..15e763f3e 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -87,7 +87,7 @@ class ProviderDashscopeTTSAPI(TTSProvider): model: str, text: str, ) -> tuple[bytes | None, str]: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() response = await loop.run_in_executor(None, self._call_qwen_tts, model, text) audio_bytes = await self._extract_audio_from_response(response) if not audio_bytes: @@ -143,7 +143,7 @@ class ProviderDashscopeTTSAPI(TTSProvider): voice=self.voice, format=AudioFormat.WAV_24000HZ_MONO_16BIT, ) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() audio_bytes = await loop.run_in_executor( None, synthesizer.call, diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index 8f9b6d91d..b76bf6b46 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -59,7 +59,7 @@ class GenieTTSProvider(TTSProvider): filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() def _generate(save_path: str) -> None: assert genie is not None @@ -85,7 +85,7 @@ class GenieTTSProvider(TTSProvider): text_queue: asyncio.Queue[str | None], audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]", ) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() while True: text = await text_queue.get() diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index 69860111c..f40cb968a 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -154,6 +154,14 @@ class ProviderMiniMaxTTSAPI(TTSProvider): audio_stream = self._call_tts_stream(text) audio = await self._audio_play(audio_stream) + # 检查音频数据是否为空 + if not audio or len(audio) == 0: + raise Exception( + "MiniMax TTS API returned empty audio data. " + "Please verify your configuration, especially the 'group_id' parameter. " + "You can find your group_id in Account Management -> Basic Information on the MiniMax platform." + ) + # 结果保存至文件 with open(path, "wb") as file: file.write(audio) @@ -161,4 +169,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider): return path except aiohttp.ClientError as e: - raise e + raise Exception(f"MiniMax TTS API request failed: {e!s}") diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index af6c0f631..d41ebaf62 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -43,7 +43,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...") # 将模型加载放到线程池中执行 - self.model = await asyncio.get_event_loop().run_in_executor( + self.model = await asyncio.get_running_loop().run_in_executor( None, lambda: SenseVoiceSmall(self.model_name, quantize=True, batch_size=16), ) @@ -88,7 +88,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): audio_url = output_path # 使用 run_in_executor 来调用模型进行识别 - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() res = await loop.run_in_executor( None, # 使用默认的线程池 lambda: cast(SenseVoiceSmall, self.model)( diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index 678deb948..519a64de6 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -31,7 +31,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): self.model = None async def initialize(self) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...") self.model = await loop.run_in_executor( None, @@ -50,7 +50,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): return False async def get_text(self, audio_url: str) -> str: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() is_tencent = False diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 68c58fdae..b812698f2 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -1374,10 +1374,23 @@ class PluginManager: return if "__del__" in star_metadata.star_cls_type.__dict__: - asyncio.get_event_loop().run_in_executor( + loop = asyncio.get_running_loop() + future = loop.run_in_executor( None, star_metadata.star_cls.__del__, ) + + def _log_del_exception(fut: asyncio.Future) -> None: + if fut.cancelled(): + return + if (exc := fut.exception()) is not None: + logger.error( + "插件 %s 在 __del__ 中抛出了异常:%r", + star_metadata.name, + exc, + ) + + future.add_done_callback(_log_del_exception) elif "terminate" in star_metadata.star_cls_type.__dict__: await star_metadata.star_cls.terminate() diff --git a/astrbot/core/utils/session_lock.py b/astrbot/core/utils/session_lock.py index 7810d6ce4..732a29b72 100644 --- a/astrbot/core/utils/session_lock.py +++ b/astrbot/core/utils/session_lock.py @@ -1,9 +1,13 @@ import asyncio +import threading +import weakref from collections import defaultdict from contextlib import asynccontextmanager -class SessionLockManager: +class _PerLoopSessionLockManager: + """Per-event-loop session lock manager; keeps original simple semantics.""" + def __init__(self) -> None: self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) self._lock_count: dict[str, int] = defaultdict(int) @@ -26,4 +30,26 @@ class SessionLockManager: self._lock_count.pop(session_id, None) +class SessionLockManager: + """Thread-safe session lock manager with per-event-loop isolation.""" + + def __init__(self) -> None: + self._state_guard = threading.Lock() + self._loop_managers: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, _PerLoopSessionLockManager + ] = weakref.WeakKeyDictionary() + + def _get_loop_manager(self) -> _PerLoopSessionLockManager: + """Get the lock manager for the current event loop.""" + loop = asyncio.get_running_loop() + with self._state_guard: + return self._loop_managers.setdefault(loop, _PerLoopSessionLockManager()) + + @asynccontextmanager + async def acquire_lock(self, session_id: str): + manager = self._get_loop_manager() + async with manager.acquire_lock(session_id): + yield + + session_lock_manager = SessionLockManager() diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index bb7769926..d151bbe6f 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -5,7 +5,8 @@ import os import ssl import traceback from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path import aiohttp import certifi @@ -352,6 +353,34 @@ class PluginRoute(Route): logger.warning(f"获取插件 Logo 失败: {e}") return None + def _resolve_plugin_dir(self, plugin) -> Path | None: + if not plugin.root_dir_name: + return None + + base_dir = Path( + self.plugin_manager.reserved_plugin_path + if plugin.reserved + else self.plugin_manager.plugin_store_path + ) + plugin_dir = base_dir / plugin.root_dir_name + if not plugin_dir.is_dir(): + return None + return plugin_dir + + def _get_plugin_installed_at(self, plugin) -> str | None: + plugin_dir = self._resolve_plugin_dir(plugin) + if plugin_dir is None: + return None + + try: + return datetime.fromtimestamp( + plugin_dir.stat().st_mtime, + timezone.utc, + ).isoformat() + except OSError as exc: + logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}") + return None + async def get_plugins(self): _plugin_resp = [] plugin_name = request.args.get("name") @@ -377,6 +406,7 @@ class PluginRoute(Route): "logo": f"/api/file/{logo_url}" if logo_url else None, "support_platforms": plugin.support_platforms, "astrbot_version": plugin.astrbot_version, + "installed_at": self._get_plugin_installed_at(plugin), } # 检查是否为全空的幽灵插件 if not any( diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 67ff25dc6..e10c9a69a 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -12,6 +12,32 @@ from .route import Response, Route, RouteContext DEFAULT_MCP_CONFIG = {"mcpServers": {}} +class EmptyMcpServersError(ValueError): + """Raised when mcpServers is empty.""" + + pass + + +def _extract_mcp_server_config(mcp_servers_value: object) -> dict: + """Extract server configuration from user-submitted mcpServers field. + + Raises: + ValueError: Invalid configuration + """ + if not isinstance(mcp_servers_value, dict): + raise ValueError("mcpServers must be a JSON object") + if not mcp_servers_value: + raise EmptyMcpServersError("mcpServers configuration cannot be empty") + key_0 = next(iter(mcp_servers_value)) + extracted = mcp_servers_value[key_0] + if not isinstance(extracted, dict): + raise ValueError( + "Invalid mcpServers format. Ensure each key in mcpServers is a server name, " + "and each value is an object containing fields like command/url." + ) + return extracted + + class ToolsRoute(Route): def __init__( self, @@ -33,13 +59,37 @@ class ToolsRoute(Route): self.register_routes() self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools + def _rollback_mcp_server(self, name: str) -> bool: + try: + rollback_config = self.tool_mgr.load_mcp_config() + if name in rollback_config["mcpServers"]: + rollback_config["mcpServers"].pop(name) + return self.tool_mgr.save_mcp_config(rollback_config) + return True + except Exception: + logger.error(traceback.format_exc()) + return False + async def get_mcp_servers(self): try: config = self.tool_mgr.load_mcp_config() servers = [] + mcp_servers = config.get("mcpServers", {}) + + if not isinstance(mcp_servers, dict): + logger.warning( + f"Invalid MCP server config type: {type(mcp_servers).__name__}. Expected object/dict; skipped all MCP servers." + ) + mcp_servers = {} # 获取所有服务器并添加它们的工具列表 - for name, server_config in config["mcpServers"].items(): + for name, server_config in mcp_servers.items(): + if not isinstance(server_config, dict): + logger.warning( + f"Invalid config for MCP server '{name}' (type: {type(server_config).__name__}); skipped." + ) + continue + server_info = { "name": name, "active": server_config.get("active", True), @@ -65,7 +115,7 @@ class ToolsRoute(Route): return Response().ok(servers).__dict__ except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"获取 MCP 服务器列表失败: {e!s}").__dict__ + return Response().error(f"Failed to get MCP server list: {e!s}").__dict__ async def add_mcp_server(self): try: @@ -75,7 +125,7 @@ class ToolsRoute(Route): # 检查必填字段 if not name: - return Response().error("服务器名称不能为空").__dict__ + return Response().error("Server name cannot be empty").__dict__ # 移除特殊字段并检查配置是否有效 has_valid_config = False @@ -85,21 +135,33 @@ class ToolsRoute(Route): for key, value in server_data.items(): if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段 if key == "mcpServers": - key_0 = list(server_data["mcpServers"].keys())[ - 0 - ] # 不考虑为空的情况 - server_config = server_data["mcpServers"][key_0] + try: + server_config = _extract_mcp_server_config( + server_data["mcpServers"] + ) + except ValueError as e: + return Response().error(f"{e!s}").__dict__ else: server_config[key] = value has_valid_config = True if not has_valid_config: - return Response().error("必须提供有效的服务器配置").__dict__ + return ( + Response() + .error("A valid server configuration is required") + .__dict__ + ) config = self.tool_mgr.load_mcp_config() if name in config["mcpServers"]: - return Response().error(f"服务器 {name} 已存在").__dict__ + return Response().error(f"Server {name} already exists").__dict__ + + try: + await self.tool_mgr.test_mcp_server_connection(server_config) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"MCP connection test failed: {e!s}").__dict__ config["mcpServers"][name] = server_config @@ -111,17 +173,27 @@ class ToolsRoute(Route): timeout=30, ) except TimeoutError: - return Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__ + rollback_ok = self._rollback_mcp_server(name) + err_msg = f"Timed out while enabling MCP server {name}." + if not rollback_ok: + err_msg += " Configuration rollback failed. Please check the config manually." + return Response().error(err_msg).__dict__ except Exception as e: logger.error(traceback.format_exc()) - return ( - Response().error(f"启用 MCP 服务器 {name} 失败: {e!s}").__dict__ - ) - return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__ - return Response().error("保存配置失败").__dict__ + rollback_ok = self._rollback_mcp_server(name) + err_msg = f"Failed to enable MCP server {name}: {e!s}" + if not rollback_ok: + err_msg += " Configuration rollback failed. Please check the config manually." + return Response().error(err_msg).__dict__ + return ( + Response() + .ok(None, f"Successfully added MCP server {name}") + .__dict__ + ) + return Response().error("Failed to save configuration").__dict__ except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"添加 MCP 服务器失败: {e!s}").__dict__ + return Response().error(f"Failed to add MCP server: {e!s}").__dict__ async def update_mcp_server(self): try: @@ -131,23 +203,25 @@ class ToolsRoute(Route): old_name = server_data.get("oldName") or name if not name: - return Response().error("服务器名称不能为空").__dict__ + return Response().error("Server name cannot be empty").__dict__ config = self.tool_mgr.load_mcp_config() if old_name not in config["mcpServers"]: - return Response().error(f"服务器 {old_name} 不存在").__dict__ + return Response().error(f"Server {old_name} does not exist").__dict__ is_rename = name != old_name if name in config["mcpServers"] and is_rename: - return Response().error(f"服务器 {name} 已存在").__dict__ + return Response().error(f"Server {name} already exists").__dict__ # 获取活动状态 - active = server_data.get( - "active", - config["mcpServers"][old_name].get("active", True), - ) + old_config = config["mcpServers"][old_name] + if isinstance(old_config, dict): + old_active = old_config.get("active", True) + else: + old_active = True + active = server_data.get("active", old_active) # 创建新的配置对象 server_config = {"active": active} @@ -165,17 +239,19 @@ class ToolsRoute(Route): "oldName", ]: # 排除特殊字段 if key == "mcpServers": - key_0 = list(server_data["mcpServers"].keys())[ - 0 - ] # 不考虑为空的情况 - server_config = server_data["mcpServers"][key_0] + try: + server_config = _extract_mcp_server_config( + server_data["mcpServers"] + ) + except ValueError as e: + return Response().error(f"{e!s}").__dict__ else: server_config[key] = value only_update_active = False # 如果只更新活动状态,保留原始配置 - if only_update_active: - for key, value in config["mcpServers"][old_name].items(): + if only_update_active and isinstance(old_config, dict): + for key, value in old_config.items(): if key != "active": # 除了active之外的所有字段都保留 server_config[key] = value @@ -200,7 +276,7 @@ class ToolsRoute(Route): return ( Response() .error( - f"启用前停用 MCP 服务器时 {old_name} 超时: {e!s}" + f"Timed out while disabling MCP server {old_name} before enabling: {e!s}" ) .__dict__ ) @@ -209,7 +285,7 @@ class ToolsRoute(Route): return ( Response() .error( - f"启用前停用 MCP 服务器时 {old_name} 失败: {e!s}" + f"Failed to disable MCP server {old_name} before enabling: {e!s}" ) .__dict__ ) @@ -221,13 +297,15 @@ class ToolsRoute(Route): ) except TimeoutError: return ( - Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__ + Response() + .error(f"Timed out while enabling MCP server {name}.") + .__dict__ ) except Exception as e: logger.error(traceback.format_exc()) return ( Response() - .error(f"启用 MCP 服务器 {name} 失败: {e!s}") + .error(f"Failed to enable MCP server {name}: {e!s}") .__dict__ ) # 如果要停用服务器 @@ -237,22 +315,26 @@ class ToolsRoute(Route): except TimeoutError: return ( Response() - .error(f"停用 MCP 服务器 {old_name} 超时。") + .error(f"Timed out while disabling MCP server {old_name}.") .__dict__ ) except Exception as e: logger.error(traceback.format_exc()) return ( Response() - .error(f"停用 MCP 服务器 {old_name} 失败: {e!s}") + .error(f"Failed to disable MCP server {old_name}: {e!s}") .__dict__ ) - return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__ - return Response().error("保存配置失败").__dict__ + return ( + Response() + .ok(None, f"Successfully updated MCP server {name}") + .__dict__ + ) + return Response().error("Failed to save configuration").__dict__ except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"更新 MCP 服务器失败: {e!s}").__dict__ + return Response().error(f"Failed to update MCP server: {e!s}").__dict__ async def delete_mcp_server(self): try: @@ -260,12 +342,12 @@ class ToolsRoute(Route): name = server_data.get("name", "") if not name: - return Response().error("服务器名称不能为空").__dict__ + return Response().error("Server name cannot be empty").__dict__ config = self.tool_mgr.load_mcp_config() if name not in config["mcpServers"]: - return Response().error(f"服务器 {name} 不存在").__dict__ + return Response().error(f"Server {name} does not exist").__dict__ del config["mcpServers"][name] @@ -275,51 +357,76 @@ class ToolsRoute(Route): await self.tool_mgr.disable_mcp_server(name, timeout=10) except TimeoutError: return ( - Response().error(f"停用 MCP 服务器 {name} 超时。").__dict__ + Response() + .error(f"Timed out while disabling MCP server {name}.") + .__dict__ ) except Exception as e: logger.error(traceback.format_exc()) return ( Response() - .error(f"停用 MCP 服务器 {name} 失败: {e!s}") + .error(f"Failed to disable MCP server {name}: {e!s}") .__dict__ ) - return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__ - return Response().error("保存配置失败").__dict__ + return ( + Response() + .ok(None, f"Successfully deleted MCP server {name}") + .__dict__ + ) + return Response().error("Failed to save configuration").__dict__ except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"删除 MCP 服务器失败: {e!s}").__dict__ + return Response().error(f"Failed to delete MCP server: {e!s}").__dict__ async def test_mcp_connection(self): - """测试 MCP 服务器连接""" + """Test MCP server connection.""" try: server_data = await request.json config = server_data.get("mcp_server_config", None) if not isinstance(config, dict) or not config: - return Response().error("无效的 MCP 服务器配置").__dict__ + return Response().error("Invalid MCP server configuration").__dict__ if "mcpServers" in config: - keys = list(config["mcpServers"].keys()) - if not keys: - return Response().error("MCP 服务器配置不能为空").__dict__ - if len(keys) > 1: - return Response().error("一次只能配置一个 MCP 服务器配置").__dict__ - config = config["mcpServers"][keys[0]] + mcp_servers = config["mcpServers"] + if isinstance(mcp_servers, dict) and len(mcp_servers) > 1: + return ( + Response() + .error( + "Only one MCP server configuration can be tested at a time" + ) + .__dict__ + ) + try: + config = _extract_mcp_server_config(mcp_servers) + except EmptyMcpServersError: + return ( + Response() + .error("MCP server configuration cannot be empty") + .__dict__ + ) + except ValueError as e: + return Response().error(f"{e!s}").__dict__ elif not config: - return Response().error("MCP 服务器配置不能为空").__dict__ + return ( + Response() + .error("MCP server configuration cannot be empty") + .__dict__ + ) tools_name = await self.tool_mgr.test_mcp_server_connection(config) return ( - Response().ok(data=tools_name, message="🎉 MCP 服务器可用!").__dict__ + Response() + .ok(data=tools_name, message="🎉 MCP server is available!") + .__dict__ ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"测试 MCP 连接失败: {e!s}").__dict__ + return Response().error(f"Failed to test MCP connection: {e!s}").__dict__ async def get_tool_list(self): - """获取所有注册的工具列表""" + """Get all registered tools.""" try: tools = self.tool_mgr.func_list tools_dict = [] @@ -356,17 +463,21 @@ class ToolsRoute(Route): return Response().ok(data=tools_dict).__dict__ except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"获取工具列表失败: {e!s}").__dict__ + return Response().error(f"Failed to get tool list: {e!s}").__dict__ async def toggle_tool(self): - """启用或停用指定的工具""" + """Activate or deactivate a specified tool.""" try: data = await request.json tool_name = data.get("name") action = data.get("activate") # True or False if not tool_name or action is None: - return Response().error("缺少必要参数: name 或 action").__dict__ + return ( + Response() + .error("Missing required parameters: name or activate") + .__dict__ + ) # Internal tools cannot be toggled by users for t in self.tool_mgr.func_list: @@ -377,20 +488,24 @@ class ToolsRoute(Route): try: ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map) except ValueError as e: - return Response().error(f"启用工具失败: {e!s}").__dict__ + return Response().error(f"Failed to activate tool: {e!s}").__dict__ else: ok = self.tool_mgr.deactivate_llm_tool(tool_name) if ok: - return Response().ok(None, "操作成功。").__dict__ - return Response().error(f"工具 {tool_name} 不存在或操作失败。").__dict__ + return Response().ok(None, "Operation successful.").__dict__ + return ( + Response() + .error(f"Tool {tool_name} does not exist or the operation failed.") + .__dict__ + ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"操作工具失败: {e!s}").__dict__ + return Response().error(f"Failed to operate tool: {e!s}").__dict__ async def sync_provider(self): - """同步 MCP 提供者配置""" + """Sync MCP provider configuration.""" try: data = await request.json provider_name = data.get("name") # modelscope, or others @@ -399,9 +514,11 @@ class ToolsRoute(Route): access_token = data.get("access_token", "") await self.tool_mgr.sync_modelscope_mcp_servers(access_token) case _: - return Response().error(f"未知: {provider_name}").__dict__ + return ( + Response().error(f"Unknown provider: {provider_name}").__dict__ + ) - return Response().ok(message="同步成功").__dict__ + return Response().ok(message="Sync completed").__dict__ except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"同步失败: {e!s}").__dict__ + return Response().error(f"Sync failed: {e!s}").__dict__ diff --git a/changelogs/v4.19.3.md b/changelogs/v4.19.3.md new file mode 100644 index 000000000..58bc48595 --- /dev/null +++ b/changelogs/v4.19.3.md @@ -0,0 +1,40 @@ +## What's Changed + +### 新增 + +- 新增技能 ZIP 批量上传能力 ([#5804](https://github.com/AstrBotDevs/AstrBot/pull/5804))。 + +### 修复 + +- 修复 MCP Server 配置异常时可能导致崩溃的问题 ([#5666](https://github.com/AstrBotDevs/AstrBot/pull/5666), [#5673](https://github.com/AstrBotDevs/AstrBot/pull/5673))。 +- 修复钉钉适配器文本消息被忽略、无法主动发送文件的问题 ([#5921](https://github.com/AstrBotDevs/AstrBot/pull/5921))。 +- 修复钉钉适配器无法接收图片与文件的问题 ([#5920](https://github.com/AstrBotDevs/AstrBot/pull/5920))。 +- fix(provider): handle MiniMax ThinkingBlock when max_tokens reached ([#5913](https://github.com/AstrBotDevs/AstrBot/pull/5913))。 +- 修复 OpenRouter `api_base` 配置错误的问题 ([#5911](https://github.com/AstrBotDevs/AstrBot/pull/5911))。 +- 修复插件市场中按展示名搜索已安装插件不生效的问题 ([#5806](https://github.com/AstrBotDevs/AstrBot/pull/5806), [#5811](https://github.com/AstrBotDevs/AstrBot/pull/5811))。 +- 修复仅图片响应未应用 `reply_with_quote` 与 `reply_with_mention` 的问题 ([#5219](https://github.com/AstrBotDevs/AstrBot/pull/5219))。 +- 修复 `RegexFilter` 使用 `re.match` 导致匹配范围不正确的问题 ([#5368](https://github.com/AstrBotDevs/AstrBot/pull/5368))。 +- 修复桌面运行环境检测依赖 frozen Python 的问题 ([#5859](https://github.com/AstrBotDevs/AstrBot/pull/5859))。 +- 修复通过“创建新配置”创建平台机器人后找不到 pipeline scheduler 的问题 ([#5776](https://github.com/AstrBotDevs/AstrBot/pull/5776))。 + +--- + +## What's Changed (EN) + +### New Features + +- Added batch upload support for multiple skill ZIP files ([#5804](https://github.com/AstrBotDevs/AstrBot/pull/5804)). + +### Bug Fixes + +- Fixed potential crash on malformed MCP server config ([#5666](https://github.com/AstrBotDevs/AstrBot/pull/5666), [#5673](https://github.com/AstrBotDevs/AstrBot/pull/5673)). +- Fixed DingTalk adapter issue where text messages were ignored and files could not be sent proactively ([#5921](https://github.com/AstrBotDevs/AstrBot/pull/5921)). +- Fixed DingTalk adapter issue where image and file messages could not be received ([#5920](https://github.com/AstrBotDevs/AstrBot/pull/5920)). +- Fixed incorrect OpenRouter `api_base` configuration ([#5911](https://github.com/AstrBotDevs/AstrBot/pull/5911)). +- Fixed searching installed plugins by display name in extensions ([#5806](https://github.com/AstrBotDevs/AstrBot/pull/5806), [#5811](https://github.com/AstrBotDevs/AstrBot/pull/5811)). +- Fixed image-only responses not applying `reply_with_quote` and `reply_with_mention` ([#5219](https://github.com/AstrBotDevs/AstrBot/pull/5219)). +- Fixed `RegexFilter` using `re.match` instead of `re.search` for expected matching behavior ([#5368](https://github.com/AstrBotDevs/AstrBot/pull/5368)). +- Fixed desktop runtime detection requiring frozen Python ([#5859](https://github.com/AstrBotDevs/AstrBot/pull/5859)). +- Fixed missing pipeline scheduler after creating a platform bot via "create new config" ([#5776](https://github.com/AstrBotDevs/AstrBot/pull/5776)). +- fix(provider): handle MiniMax ThinkingBlock when max_tokens reached ([#5913](https://github.com/AstrBotDevs/AstrBot/pull/5913)) + diff --git a/changelogs/v4.19.4.md b/changelogs/v4.19.4.md new file mode 100644 index 000000000..33244ff08 --- /dev/null +++ b/changelogs/v4.19.4.md @@ -0,0 +1,9 @@ +## What's Changed + +### 新增 + +- 企业微信智能机器人支持长连接模式。[#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930) + +### New + +- Wecom AI Bot supports long-connection mode(Websockets). [#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930) \ No newline at end of file diff --git a/changelogs/v4.19.5.md b/changelogs/v4.19.5.md new file mode 100644 index 000000000..fb800242e --- /dev/null +++ b/changelogs/v4.19.5.md @@ -0,0 +1,43 @@ +## What's Changed + +### 新增 + +- Lark 适配器支持 CardKit 流式输出(飞书)([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777))。 +- WebUI 已安装插件列表新增筛选与排序功能 ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923))。 + +### 优化 +- 启动时后台加载 MCP Server,不阻塞加载流程 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。 + +### 修复 + +- 部分情况下 MCP 页报错 500 导致查看不了 MCP 服务器 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。 +- 修复 TTS Provider 测试:增加文件大小校验,并补充 MiniMax 空音频检测 ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999))。 +- 修复前端切换到 Chat 后又回到 Welcome 时,页面切换配置未正确持久化的问题 ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792))。 +- 修复 Azure TTS 不支持 84 位订阅密钥的问题 ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813))。 + +### 文档 + +- 文档仓库迁移:将 `AstrBotDevs/AstrBot-docs` 内容迁移至 `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960))。 + +--- + +## What's Changed (EN) + +### New Features + +- Added CardKit streaming output support for the Lark/Feishu adapter ([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777)). +- Added filtering and sorting for installed plugins in the WebUI ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923)). + +### Impprovement +- MCP Server now loads in the background during startup without blocking the loading process ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993)). + +### Bug Fixes + +- Added file size validation in TTS provider tests and MiniMax empty-audio detection ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999)). +- Fixed frontend state persistence when switching from Chat back to Welcome ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792)). +- Fixed Azure TTS support for 84-character subscription keys ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813)). +- Reverted the MCP stdio missing-command error wording change after the previous fix ([#5992](https://github.com/AstrBotDevs/AstrBot/pull/5992)). + +### Documentation + +- Migrated documentation content from `AstrBotDevs/AstrBot-docs` into `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960)). diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue index 95b679580..d24bcec58 100644 --- a/dashboard/src/components/extension/McpServersSection.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -300,6 +300,10 @@ export default { this.loadingGettingServers = true; axios.get('/api/tools/mcp/servers') .then(response => { + if (response.data.status === 'error') { + this.showError(response.data.message || this.tm('messages.getServersError', { error: 'Unknown error' })); + return; + } this.mcpServers = response.data.data || []; this.mcpServers.forEach(server => { if (!this.mcpServerUpdateLoaders[server.name]) { @@ -372,6 +376,10 @@ export default { axios.post(endpoint, serverData) .then(response => { this.loading = false; + if (response.data.status === 'error') { + this.showError(response.data.message || this.tm('messages.saveError', { error: 'Unknown error' })); + return; + } this.showMcpServerDialog = false; this.addServerDialogMessage = ''; this.getServers(); diff --git a/dashboard/src/components/extension/PluginSortControl.vue b/dashboard/src/components/extension/PluginSortControl.vue new file mode 100644 index 000000000..4596424a7 --- /dev/null +++ b/dashboard/src/components/extension/PluginSortControl.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index a143678c2..47966918d 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -550,6 +550,10 @@ "description": "WeCom AI Bot Name", "hint": "Must be correct; otherwise some commands won't work." }, + "wecom_ai_bot_connection_mode": { + "description": "WeCom AI Bot Connection Mode", + "hint": "Webhook mode requires Token/EncodingAESKey; long_connection mode requires BotID/Secret." + }, "wecomaibot_friend_message_welcome_text": { "description": "WeCom AI Bot DM Welcome Message", "hint": "When a user enters a DM session on that day, reply with a welcome message. Leave empty to disable." @@ -558,6 +562,30 @@ "description": "WeCom AI Bot Initial Response Text", "hint": "First reply when the bot receives a message. Leave empty to disable." }, + "wecomaibot_token": { + "description": "WeCom AI Bot Token", + "hint": "Used for authentication in webhook callback mode." + }, + "wecomaibot_encoding_aes_key": { + "description": "WeCom AI Bot EncodingAESKey", + "hint": "Used for message encryption/decryption in webhook callback mode." + }, + "wecomaibot_ws_bot_id": { + "description": "Long Connection BotID", + "hint": "BotID credential for WeCom AI Bot long connection mode." + }, + "wecomaibot_ws_secret": { + "description": "Long Connection Secret", + "hint": "Secret credential for WeCom AI Bot long connection mode." + }, + "wecomaibot_ws_url": { + "description": "Long Connection WebSocket URL", + "hint": "Default is wss://openws.work.weixin.qq.com and usually does not need changes." + }, + "wecomaibot_heartbeat_interval": { + "description": "Long Connection Heartbeat Interval", + "hint": "Heartbeat interval (seconds) in long connection mode. 30 seconds is recommended." + }, "wpp_active_message_poll": { "description": "Enable Proactive Message Polling", "hint": "Only enable if WeChat messages are not syncing to AstrBot on time. Disabled by default." @@ -1493,4 +1521,4 @@ "helpMiddle": "or", "helpSuffix": "." } -} +} \ No newline at end of file diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index c97deaf49..07affcd62 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -23,6 +23,9 @@ "placeholder": "Search extensions...", "marketPlaceholder": "Search market extensions..." }, + "filters": { + "all": "All" + }, "views": { "card": "Card View", "list": "List View" @@ -122,10 +125,14 @@ "sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use." }, "sort": { + "by": "Sort by", "default": "Default", + "installTime": "Last Modified", + "name": "Name", "stars": "Stars", "author": "Author", "updated": "Last Updated", + "updateStatus": "Update Status", "ascending": "Ascending", "descending": "Descending" }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 015ce3082..3635bd814 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -543,7 +543,7 @@ }, "unified_webhook_mode": { "description": "统一 Webhook 模式", - "hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。" + "hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。" }, "webhook_uuid": { "description": "Webhook UUID", @@ -553,13 +553,41 @@ "description": "企业微信智能机器人的名字", "hint": "请务必填写正确,否则无法使用一些指令。" }, + "wecom_ai_bot_connection_mode": { + "description": "企业微信智能机器人连接模式", + "hint": "Webhook 回调模式需要配置 Token/EncodingAESKey;长连接模式需要配置 BotID/Secret。" + }, "wecomaibot_friend_message_welcome_text": { "description": "企业微信智能机器人私聊欢迎语", - "hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。" + "hint": "可选。当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。" }, "wecomaibot_init_respond_text": { "description": "企业微信智能机器人初始响应文本", - "hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。" + "hint": "可选。当机器人收到消息时,首先回复的文本内容。留空则不设置。" + }, + "wecomaibot_token": { + "description": "企业微信智能机器人 Token", + "hint": "用于 Webhook 回调模式的身份验证。" + }, + "wecomaibot_encoding_aes_key": { + "description": "企业微信智能机器人 EncodingAESKey", + "hint": "用于 Webhook 回调模式的消息加密解密。" + }, + "wecomaibot_ws_bot_id": { + "description": "长连接 BotID", + "hint": "企业微信智能机器人长连接模式凭证 BotID。" + }, + "wecomaibot_ws_secret": { + "description": "长连接 Secret", + "hint": "企业微信智能机器人长连接模式凭证 Secret。" + }, + "wecomaibot_ws_url": { + "description": "长连接 WebSocket 地址", + "hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。" + }, + "wecomaibot_heartbeat_interval": { + "description": "长连接心跳间隔", + "hint": "长连接模式心跳间隔(秒),建议 30 秒。" }, "wpp_active_message_poll": { "description": "是否启用主动消息轮询", @@ -582,11 +610,11 @@ }, "msg_push_webhook_url": { "description": "企业微信消息推送 Webhook URL", - "hint": "用于主动消息推送,请在企微群->消息推送得到 URL。强烈建议设置此项以带来更好的消息发送体验。" + "hint": "可选。用于主动消息推送,请在企微群->消息推送得到 URL。建议设置此项以带来更好的消息发送体验。" }, "only_use_webhook_url_to_send": { "description": "仅使用 Webhook 发送消息", - "hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。" + "hint": "可选。启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。" }, "kook_bot_token": { "description": "机器人 Token", @@ -1496,4 +1524,4 @@ "helpMiddle": "或", "helpSuffix": "。" } -} +} \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index a67fef728..f42173ffa 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -23,6 +23,9 @@ "placeholder": "搜索插件...", "marketPlaceholder": "搜索市场插件..." }, + "filters": { + "all": "全部" + }, "views": { "card": "卡片视图", "list": "列表视图" @@ -122,10 +125,14 @@ "sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。" }, "sort": { + "by": "排序方式", "default": "默认排序", + "installTime": "最后修改时间", + "name": "名称", "stars": "Star数", "author": "作者名", "updated": "更新时间", + "updateStatus": "更新状态", "ascending": "升序", "descending": "降序" }, diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 613771860..b552670c3 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -27,6 +27,7 @@ const customizer = useCustomizerStore(); const theme = useTheme(); const { t } = useI18n(); const route = useRoute(); +const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route'; let dialog = ref(false); let accountWarning = ref(false) let updateStatusDialog = ref(false); @@ -402,15 +403,32 @@ const viewMode = computed({ }); // 监听 viewMode 变化,切换到 bot 模式时跳转到首页 -watch(() => customizer.viewMode, (newMode, oldMode) => { - if (newMode === 'bot' && oldMode === 'chat') { - // 从 chat 模式切换到 bot 模式时,跳转到首页 - if (route.path !== '/') { - router.push('/'); +// 保存 bot 模式的最後路由 +// 監聽 route 變化,保存最後一次 bot 路由 +watch(() => route.fullPath, (newPath) => { + if (customizer.viewMode === 'bot' && typeof window !== 'undefined') { + try { + localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath); + } catch (e) { + console.error('Failed to save last bot route to localStorage:', e); } } }); +// 監聽 viewMode 切換 +watch(() => customizer.viewMode, (newMode, oldMode) => { + if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') { + // 從 chat 切換回 bot,跳轉到最後一次的 bot 路由 + let lastBotRoute = '/'; + try { + lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/'; + } catch (e) { + console.error('Failed to read last bot route from localStorage:', e); + } + router.push(lastBotRoute); + } +}); + // Merry Christmas! 🎄 const isChristmas = computed(() => { const today = new Date(); diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index 442ae2433..f5f62f5e8 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -1,4 +1,5 @@