From 2a3d93a6c534265d425f8f27cab894243de5e44e Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:59:50 +0800 Subject: [PATCH 01/17] fix: clean up qq official websocket shutdown (#7395) * fix: clean up qq official websocket shutdown fixes: #7259 * fix: do not catch KeyboardInterrupt/SystemExit Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- .../qqofficial/qqofficial_platform_adapter.py | 55 ++++++++++++++++++- .../qqofficial_webhook/qo_webhook_adapter.py | 2 +- astrbot/dashboard/server.py | 2 +- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 8419c0ea0..3037ab2d8 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -13,6 +13,7 @@ from typing import Any, cast import botpy import botpy.message from botpy import Client +from botpy.gateway import BotWebSocket from astrbot import logger from astrbot.api.event import MessageChain @@ -37,11 +38,37 @@ for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) +class ManagedBotWebSocket(BotWebSocket): + def __init__(self, session, connection: Any, client: botClient): + super().__init__(session, connection) + self._client = client + + async def on_closed(self, close_status_code, close_msg): + if self._client.is_shutting_down: + logger.debug("[QQOfficial] Ignore websocket reconnect during shutdown.") + return + await super().on_closed(close_status_code, close_msg) + + async def close(self) -> None: + self._can_reconnect = False + if self._conn is not None and not self._conn.closed: + await self._conn.close() + + # QQ 机器人官方框架 class botClient(Client): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._shutting_down = False + self._active_websockets: set[ManagedBotWebSocket] = set() + def set_platform(self, platform: QQOfficialPlatformAdapter) -> None: self.platform = platform + @property + def is_shutting_down(self) -> bool: + return self._shutting_down or self.is_closed() + # 收到群消息 async def on_group_at_message_create( self, message: botpy.message.GroupMessage @@ -100,6 +127,30 @@ class botClient(Client): ), ) + async def bot_connect(self, session) -> None: + logger.info("[QQOfficial] Websocket session starting.") + + websocket = ManagedBotWebSocket(session, self._connection, self) + self._active_websockets.add(websocket) + try: + await websocket.ws_connect() + except Exception as e: + if not self.is_shutting_down: + await websocket.on_error(e) + finally: + self._active_websockets.discard(websocket) + + async def shutdown(self) -> None: + if self.is_shutting_down: + return + + self._shutting_down = True + await asyncio.gather( + *(websocket.close() for websocket in list(self._active_websockets)), + return_exceptions=True, + ) + await self.close() + @register_platform_adapter("qq_official", "QQ 机器人官方 API 适配器") class QQOfficialPlatformAdapter(Platform): @@ -542,5 +593,5 @@ class QQOfficialPlatformAdapter(Platform): return self.client async def terminate(self) -> None: - await self.client.close() - logger.info("QQ 官方机器人接口 适配器已被优雅地关闭") + await self.client.shutdown() + logger.info("QQ 官方机器人接口 适配器已被关闭") diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index b4dbe36b0..d2e14826a 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -193,4 +193,4 @@ class QQOfficialWebhookPlatformAdapter(Platform): f"Exception occurred during QQOfficialWebhook server shutdown: {exc}", exc_info=True, ) - logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭") + logger.info("QQ 机器人官方 API 适配器已经被关闭") diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index cbb7296bd..b3dc75945 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -451,4 +451,4 @@ class AstrBotDashboard: async def shutdown_trigger(self) -> None: await self.shutdown_event.wait() - logger.info("AstrBot WebUI 已经被优雅地关闭") + logger.info("AstrBot WebUI 已经被关闭") From 0ce5fde7f837619b8975561c7c4c773b393c6bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=9E=E6=A5=BD=E5=9D=82=C2=B7=E5=96=B5?= Date: Mon, 6 Apr 2026 18:27:28 +0800 Subject: [PATCH 02/17] feat(platform): add Mattermost bot support (#7369) * feat(platform): add Mattermost bot support (#6009) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(platform): address Mattermost review feedback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(platform): improve Mattermost streaming and file IO Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * perf(platform): optimize Mattermost duplicate detection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix(platform): preserve Mattermost command prefixes after mentions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * test(platform): cover Mattermost attachment parsing Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs: add mattermost docs --------- Co-authored-by: Sisyphus Co-authored-by: Soulter <905617992@qq.com> --- README.md | 1 + astrbot/core/config/default.py | 25 +- astrbot/core/platform/manager.py | 4 + .../platform/sources/mattermost/__init__.py | 0 .../platform/sources/mattermost/client.py | 260 ++++++++++++++ .../sources/mattermost/mattermost_adapter.py | 323 ++++++++++++++++++ .../sources/mattermost/mattermost_event.py | 88 +++++ .../images/platform_logos/mattermost.svg | 2 + .../en-US/features/config-metadata.json | 12 + .../ru-RU/features/config-metadata.json | 12 + .../zh-CN/features/config-metadata.json | 12 + dashboard/src/utils/platformUtils.js | 2 + docs/.vitepress/config.mjs | 2 + docs/en/platform/mattermost.md | 139 ++++++++ docs/zh/platform/mattermost.md | 139 ++++++++ tests/test_mattermost_adapter.py | 95 ++++++ 16 files changed, 1115 insertions(+), 1 deletion(-) create mode 100644 astrbot/core/platform/sources/mattermost/__init__.py create mode 100644 astrbot/core/platform/sources/mattermost/client.py create mode 100644 astrbot/core/platform/sources/mattermost/mattermost_adapter.py create mode 100644 astrbot/core/platform/sources/mattermost/mattermost_event.py create mode 100644 dashboard/src/assets/images/platform_logos/mattermost.svg create mode 100644 docs/en/platform/mattermost.md create mode 100644 docs/zh/platform/mattermost.md create mode 100644 tests/test_mattermost_adapter.py diff --git a/README.md b/README.md index d3c30ccda..34b75b468 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Connect AstrBot to your favorite chat platform. | LINE | Official | | Satori | Official | | Misskey | Official | +| Mattermost | Official | | WhatsApp (Coming Soon) | Official | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community | | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community | diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 14fa32e7a..ee42ca99d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -506,7 +506,7 @@ CONFIG_METADATA_2 = { "satori_heartbeat_interval": 10, "satori_reconnect_delay": 5, }, - "kook": { + "KOOK": { "id": "kook", "type": "kook", "enable": False, @@ -519,6 +519,14 @@ CONFIG_METADATA_2 = { "kook_max_heartbeat_failures": 3, "kook_max_consecutive_failures": 5, }, + "Mattermost": { + "id": "mattermost", + "type": "mattermost", + "enable": False, + "mattermost_url": "https://chat.example.com", + "mattermost_bot_token": "", + "mattermost_reconnect_delay": 5.0, + }, # "WebChat": { # "id": "webchat", # "type": "webchat", @@ -653,6 +661,21 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。", }, + "mattermost_url": { + "description": "Mattermost URL", + "type": "string", + "hint": "Mattermost 服务地址,例如 https://chat.example.com。", + }, + "mattermost_bot_token": { + "description": "Mattermost Bot Token", + "type": "string", + "hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。", + }, + "mattermost_reconnect_delay": { + "description": "Mattermost 重连延迟", + "type": "float", + "hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。", + }, "misskey_instance_url": { "description": "Misskey 实例 URL", "type": "string", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 15c04166d..d592eb2fb 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -188,6 +188,10 @@ class PlatformManager: from .sources.kook.kook_adapter import ( KookPlatformAdapter, # noqa: F401 ) + case "mattermost": + from .sources.mattermost.mattermost_adapter import ( + MattermostPlatformAdapter, # noqa: F401 + ) except (ImportError, ModuleNotFoundError) as e: logger.error( f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。", diff --git a/astrbot/core/platform/sources/mattermost/__init__.py b/astrbot/core/platform/sources/mattermost/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py new file mode 100644 index 000000000..88619e215 --- /dev/null +++ b/astrbot/core/platform/sources/mattermost/client.py @@ -0,0 +1,260 @@ +import asyncio +import json +import mimetypes +from pathlib import Path +from typing import Any + +import aiohttp + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +class MattermostClient: + def __init__(self, base_url: str, token: str) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self._session: aiohttp.ClientSession | None = None + + async def ensure_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + + def _auth_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.token}"} + + async def get_json(self, path: str) -> dict[str, Any]: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/{path.lstrip('/')}" + async with session.get(url, headers=self._headers()) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost GET {path} failed: {resp.status} {body}" + ) + data = await resp.json() + if not isinstance(data, dict): + raise RuntimeError(f"Mattermost GET {path} returned non-object JSON") + return data + + async def post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/{path.lstrip('/')}" + async with session.post(url, headers=self._headers(), json=payload) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost POST {path} failed: {resp.status} {body}" + ) + data = await resp.json() + if not isinstance(data, dict): + raise RuntimeError(f"Mattermost POST {path} returned non-object JSON") + return data + + async def get_me(self) -> dict[str, Any]: + return await self.get_json("users/me") + + async def get_channel(self, channel_id: str) -> dict[str, Any]: + return await self.get_json(f"channels/{channel_id}") + + async def get_file_info(self, file_id: str) -> dict[str, Any]: + return await self.get_json(f"files/{file_id}/info") + + async def download_file(self, file_id: str) -> bytes: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/files/{file_id}" + async with session.get(url, headers=self._auth_headers()) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost download file {file_id} failed: {resp.status} {body}" + ) + return await resp.read() + + async def upload_file( + self, + channel_id: str, + file_bytes: bytes, + filename: str, + content_type: str, + ) -> str: + session = await self.ensure_session() + url = f"{self.base_url}/api/v4/files" + form = aiohttp.FormData() + form.add_field("channel_id", channel_id) + form.add_field( + "files", + file_bytes, + filename=filename, + content_type=content_type, + ) + async with session.post(url, headers=self._auth_headers(), data=form) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost upload file failed: {resp.status} {body}" + ) + data = await resp.json() + file_infos = data.get("file_infos", []) + if not file_infos: + raise RuntimeError("Mattermost upload file returned no file_infos") + file_id = file_infos[0].get("id", "") + if not file_id: + raise RuntimeError("Mattermost upload file returned empty file id") + return str(file_id) + + async def create_post( + self, + channel_id: str, + message: str, + *, + file_ids: list[str] | None = None, + root_id: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "channel_id": channel_id, + "message": message, + } + if file_ids: + payload["file_ids"] = file_ids + if root_id: + payload["root_id"] = root_id + return await self.post_json("posts", payload) + + async def ws_connect(self) -> aiohttp.ClientWebSocketResponse: + session = await self.ensure_session() + ws_url = self.base_url.replace("https://", "wss://", 1).replace( + "http://", "ws://", 1 + ) + ws_url = f"{ws_url}/api/v4/websocket" + return await session.ws_connect(ws_url, heartbeat=30.0) + + async def send_message_chain( + self, + channel_id: str, + message_chain: MessageChain, + ) -> dict[str, Any]: + text_parts: list[str] = [] + file_ids: list[str] = [] + root_id: str | None = None + + for segment in message_chain.chain: + if isinstance(segment, Plain): + text_parts.append(segment.text) + elif isinstance(segment, At): + mention_name = str(segment.name or segment.qq or "").strip() + if mention_name: + text_parts.append(f"@{mention_name}") + elif isinstance(segment, Reply): + if segment.id: + root_id = str(segment.id) + elif isinstance(segment, Image): + path = await segment.convert_to_file_path() + file_path = Path(path) + file_bytes = await asyncio.to_thread(file_path.read_bytes) + file_ids.append( + await self.upload_file( + channel_id, + file_bytes, + file_path.name, + mimetypes.guess_type(file_path.name)[0] or "image/jpeg", + ) + ) + elif isinstance(segment, (File, Record, Video)): + if isinstance(segment, File): + path = await segment.get_file() + filename = segment.name or Path(path).name + else: + path = await segment.convert_to_file_path() + filename = Path(path).name + file_path = Path(path) + file_bytes = await asyncio.to_thread(file_path.read_bytes) + file_ids.append( + await self.upload_file( + channel_id, + file_bytes, + filename, + mimetypes.guess_type(filename)[0] or "application/octet-stream", + ) + ) + else: + logger.debug( + "Mattermost send_message_chain skipped unsupported segment: %s", + segment.type, + ) + + return await self.create_post( + channel_id, + "".join(text_parts).strip(), + file_ids=file_ids or None, + root_id=root_id, + ) + + async def parse_post_attachments( + self, + file_ids: list[str], + ) -> tuple[list[Any], list[str]]: + components: list[Any] = [] + temp_paths: list[str] = [] + + for file_id in file_ids: + try: + info = await self.get_file_info(file_id) + file_bytes = await self.download_file(file_id) + except Exception as exc: + logger.warning( + "Mattermost fetch attachment failed %s: %s", file_id, exc + ) + continue + + filename = str(info.get("name") or f"file_{file_id}") + mime_type = str(info.get("mime_type") or "application/octet-stream") + suffix = Path(filename).suffix + file_path = Path(get_astrbot_temp_path()) / f"mattermost_{file_id}{suffix}" + try: + await asyncio.to_thread(file_path.write_bytes, file_bytes) + except OSError as exc: + logger.warning( + "Mattermost write attachment failed %s -> %s: %s", + file_id, + file_path, + exc, + ) + continue + temp_paths.append(str(file_path)) + + if mime_type.startswith("image/"): + components.append(Image.fromFileSystem(str(file_path))) + elif mime_type.startswith("audio/"): + components.append(Record.fromFileSystem(str(file_path))) + elif mime_type.startswith("video/"): + components.append(Video.fromFileSystem(str(file_path))) + else: + components.append(File(name=filename, file=str(file_path))) + + return components, temp_paths + + @staticmethod + def parse_websocket_post(raw_post: str) -> dict[str, Any] | None: + try: + parsed = json.loads(raw_post) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + return parsed diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py new file mode 100644 index 000000000..583e0d0af --- /dev/null +++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py @@ -0,0 +1,323 @@ +import asyncio +import json +import re +import time +from collections import deque +from typing import Any, cast + +import aiohttp + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, Plain +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + Platform, + PlatformMetadata, +) +from astrbot.core.platform.astr_message_event import MessageSesion + +from ...register import register_platform_adapter +from .client import MattermostClient +from .mattermost_event import MattermostMessageEvent + + +@register_platform_adapter( + "mattermost", + "Mattermost 平台适配器", + support_streaming_message=False, +) +class MattermostPlatformAdapter(Platform): + def __init__( + self, + platform_config: dict, + platform_settings: dict, + event_queue: asyncio.Queue, + ) -> None: + super().__init__(platform_config, event_queue) + self.settings = platform_settings + self.base_url = str(platform_config.get("mattermost_url", "")).rstrip("/") + self.bot_token = str(platform_config.get("mattermost_bot_token", "")).strip() + self.reconnect_delay = float( + platform_config.get("mattermost_reconnect_delay", 5.0) + ) + + if not self.base_url: + raise ValueError("Mattermost URL 是必需的") + if not self.bot_token: + raise ValueError("Mattermost bot token 是必需的") + + self.client = MattermostClient(self.base_url, self.bot_token) + self.metadata = PlatformMetadata( + name="mattermost", + description="Mattermost 平台适配器", + id=cast(str, self.config.get("id", "mattermost")), + support_streaming_message=False, + ) + self.bot_self_id = "" + self.bot_username = "" + self._mention_pattern: re.Pattern[str] | None = None + self._running = True + self._seen_post_ids: dict[str, float] = {} + self._seen_post_queue: deque[tuple[str, float]] = deque() + self._dedup_ttl = 300.0 + + async def send_by_session( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + await self.client.send_message_chain(session.session_id, message_chain) + await super().send_by_session(session, message_chain) + + def meta(self) -> PlatformMetadata: + return self.metadata + + async def run(self) -> None: + me = await self.client.get_me() + self.bot_self_id = str(me.get("id", "")) + self.bot_username = str(me.get("username", "")) + self._mention_pattern = self._build_mention_pattern(self.bot_username) + if not self.bot_self_id: + raise RuntimeError("Mattermost auth succeeded but returned empty user id") + + logger.info( + "Mattermost auth test OK. Bot: @%s (%s)", + self.bot_username, + self.bot_self_id, + ) + + while self._running: + try: + await self._ws_connect_and_listen() + except asyncio.CancelledError: + raise + except Exception as exc: + if not self._running: + break + logger.warning( + "Mattermost websocket disconnected: %s. Retrying in %.1fs.", + exc, + self.reconnect_delay, + ) + await asyncio.sleep(self.reconnect_delay) + + async def _ws_connect_and_listen(self) -> None: + ws = await self.client.ws_connect() + try: + await ws.send_json( + { + "seq": 1, + "action": "authentication_challenge", + "data": {"token": self.bot_token}, + } + ) + + async for message in ws: + if message.type != aiohttp.WSMsgType.TEXT: + if message.type in { + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.ERROR, + }: + break + continue + + try: + payload = json.loads(message.data) + except json.JSONDecodeError: + logger.debug( + "Mattermost websocket received non-JSON text frame: %r", + message.data, + ) + continue + if isinstance(payload, dict): + await self._handle_ws_event(payload) + finally: + await ws.close() + + async def _handle_ws_event(self, payload: dict[str, Any]) -> None: + if payload.get("event") != "posted": + return + + data = payload.get("data") + if not isinstance(data, dict): + return + + raw_post = data.get("post") + if not isinstance(raw_post, str): + return + + post = self.client.parse_websocket_post(raw_post) + if not post: + return + + user_id = str(post.get("user_id", "")) + if not user_id or user_id == self.bot_self_id: + return + if post.get("type"): + return + + post_id = str(post.get("id", "")) + if post_id and self._is_duplicate_post(post_id): + return + + abm = await self.convert_message(post=post, data=data) + if abm is not None: + await self.handle_msg(abm) + + def _is_duplicate_post(self, post_id: str) -> bool: + now = time.monotonic() + self._prune_seen_posts(now) + if post_id in self._seen_post_ids: + return True + self._seen_post_ids[post_id] = now + self._seen_post_queue.append((post_id, now)) + return False + + def _prune_seen_posts(self, now: float) -> None: + while self._seen_post_queue: + queued_post_id, seen_at = self._seen_post_queue[0] + if now - seen_at <= self._dedup_ttl: + break + self._seen_post_queue.popleft() + current_seen_at = self._seen_post_ids.get(queued_post_id) + if current_seen_at == seen_at: + del self._seen_post_ids[queued_post_id] + + async def convert_message( + self, + *, + post: dict[str, Any], + data: dict[str, Any], + ) -> AstrBotMessage | None: + channel_id = str(post.get("channel_id", "") or "") + if not channel_id: + return None + + channel_type = str(data.get("channel_type", "O") or "O") + sender_id = str(post.get("user_id", "") or "") + sender_name = str(data.get("sender_name", "") or sender_id).lstrip("@") + message_text = str(post.get("message", "") or "") + file_ids = [ + str(file_id) + for file_id in (post.get("file_ids") or []) + if str(file_id).strip() + ] + + abm = AstrBotMessage() + abm.self_id = self.bot_self_id + abm.sender = MessageMember(user_id=sender_id, nickname=sender_name) + abm.session_id = channel_id + abm.message_id = str(post.get("id") or channel_id) + abm.raw_message = post + abm.timestamp = self._parse_timestamp(post.get("create_at")) + abm.message = self._parse_text_components(message_text) + + if channel_type == "D": + abm.type = MessageType.FRIEND_MESSAGE + else: + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = channel_id + + if file_ids: + ( + attachment_components, + temp_paths, + ) = await self.client.parse_post_attachments(file_ids) + abm.message.extend(attachment_components) + setattr(abm, "temporary_file_paths", temp_paths) + + abm.message_str = self._build_message_str( + abm.message, + message_text, + self.bot_self_id, + ) + return abm + + def _parse_text_components(self, message_text: str) -> list[Any]: + if not message_text: + return [] + + components: list[Any] = [] + if not self.bot_username: + return [Plain(message_text)] + + mention_pattern = self._mention_pattern + if mention_pattern is None: + mention_pattern = self._build_mention_pattern(self.bot_username) + if mention_pattern is None: + return [Plain(message_text)] + last_end = 0 + + for match in mention_pattern.finditer(message_text): + if match.start() > last_end: + components.append(Plain(message_text[last_end : match.start()])) + components.append(At(qq=self.bot_self_id, name=self.bot_username)) + last_end = match.end() + + if last_end < len(message_text): + components.append(Plain(message_text[last_end:])) + + if not components: + components.append(Plain(message_text)) + return components + + @staticmethod + def _build_mention_pattern(bot_username: str) -> re.Pattern[str] | None: + if not bot_username: + return None + return re.compile( + rf"(? str: + text_parts: list[str] = [] + leading_self_mention_skipped = False + + for component in components: + if isinstance(component, Plain): + text_parts.append(component.text) + elif isinstance(component, At): + is_self_mention = str(component.qq) == self_id + if not leading_self_mention_skipped and is_self_mention: + leading_self_mention_skipped = True + if not text_parts or not "".join(text_parts).strip(): + continue + mention_name = str(component.name or component.qq or "").strip() + if mention_name: + text_parts.append(f"@{mention_name}") + message_str = "".join(text_parts).strip() + return message_str or fallback.strip() + + @staticmethod + def _parse_timestamp(raw_value: Any) -> int: + if isinstance(raw_value, int): + return raw_value // 1000 if raw_value > 1_000_000_000_000 else raw_value + return int(time.time()) + + async def handle_msg(self, message: AstrBotMessage) -> None: + message_event = MattermostMessageEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.client, + ) + self.commit_event(message_event) + + async def terminate(self) -> None: + self._running = False + await self.client.close() + + def get_client(self) -> MattermostClient: + return self.client diff --git a/astrbot/core/platform/sources/mattermost/mattermost_event.py b/astrbot/core/platform/sources/mattermost/mattermost_event.py new file mode 100644 index 000000000..5faaf7134 --- /dev/null +++ b/astrbot/core/platform/sources/mattermost/mattermost_event.py @@ -0,0 +1,88 @@ +import asyncio +import re +from collections.abc import AsyncGenerator + +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import Plain +from astrbot.api.platform import Group, MessageMember + +from .client import MattermostClient + + +class MattermostMessageEvent(AstrMessageEvent): + _FALLBACK_SENTENCE_PATTERN = re.compile(r"[^。?!~…]+[。?!~…]+") + + def __init__( + self, + message_str, + message_obj, + platform_meta, + session_id, + client: MattermostClient, + ) -> None: + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + for path in getattr(message_obj, "temporary_file_paths", []): + self.track_temporary_local_file(path) + + async def send(self, message: MessageChain) -> None: + await self.client.send_message_chain(self.get_session_id(), message) + await super().send(message) + + async def send_streaming( + self, + generator: AsyncGenerator, + use_fallback: bool = False, + ) -> None: + await super().send_streaming(generator, use_fallback) + + if not use_fallback: + message_buffer: MessageChain | None = None + async for chain in generator: + if not message_buffer: + message_buffer = chain + else: + message_buffer.chain.extend(chain.chain) + if not message_buffer: + return None + message_buffer.squash_plain() + await self.send(message_buffer) + return None + + text_buffer = "" + + async for chain in generator: + if isinstance(chain, MessageChain): + for comp in chain.chain: + if isinstance(comp, Plain): + text_buffer += comp.text + if any(p in text_buffer for p in "。?!~…"): + text_buffer = await self.process_buffer( + text_buffer, + self._FALLBACK_SENTENCE_PATTERN, + ) + else: + await self.send(MessageChain(chain=[comp])) + await asyncio.sleep(1.5) + + if text_buffer.strip(): + await self.send(MessageChain([Plain(text_buffer)])) + return None + + async def get_group(self, group_id=None, **kwargs): + channel_id = group_id or self.get_group_id() + if not channel_id: + return None + channel = await self.client.get_channel(channel_id) + return Group( + group_id=channel_id, + group_name=channel.get("display_name") or channel.get("name") or channel_id, + group_owner="", + group_admins=[], + members=[ + MessageMember( + user_id=self.get_sender_id(), + nickname=self.get_sender_name(), + ) + ], + ) diff --git a/dashboard/src/assets/images/platform_logos/mattermost.svg b/dashboard/src/assets/images/platform_logos/mattermost.svg new file mode 100644 index 000000000..9b055325a --- /dev/null +++ b/dashboard/src/assets/images/platform_logos/mattermost.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file 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 4bde673c8..360dc832a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -556,6 +556,18 @@ "description": "Bot Token", "hint": "If you are in mainland China, set a proxy or change api_base in Other Settings." }, + "mattermost_url": { + "description": "Mattermost URL", + "hint": "Mattermost service URL, for example https://chat.example.com." + }, + "mattermost_bot_token": { + "description": "Mattermost Bot Token", + "hint": "The access token generated after creating a bot account in Mattermost." + }, + "mattermost_reconnect_delay": { + "description": "Mattermost Reconnect Delay", + "hint": "Delay in seconds before reconnecting after the WebSocket disconnects. Defaults to 5 seconds." + }, "type": { "description": "Adapter Type" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 7621ab965..89b368fd8 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -552,6 +552,18 @@ "description": "Токен бота", "hint": "Если вы находитесь в материковом Китае, установите прокси или измените api_base в разделе «Другие настройки»." }, + "mattermost_url": { + "description": "URL Mattermost", + "hint": "Адрес сервиса Mattermost, например https://chat.example.com." + }, + "mattermost_bot_token": { + "description": "Bot Token Mattermost", + "hint": "Токен доступа, созданный после добавления bot-аккаунта в Mattermost." + }, + "mattermost_reconnect_delay": { + "description": "Задержка переподключения Mattermost", + "hint": "Сколько секунд ждать перед переподключением после разрыва WebSocket. По умолчанию 5 секунд." + }, "type": { "description": "Тип адаптера" }, 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 eaf3bbf6d..eb82648ca 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -558,6 +558,18 @@ "description": "Bot Token", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。" }, + "mattermost_url": { + "description": "Mattermost URL", + "hint": "Mattermost 服务地址,例如 https://chat.example.com。" + }, + "mattermost_bot_token": { + "description": "Mattermost Bot Token", + "hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。" + }, + "mattermost_reconnect_delay": { + "description": "Mattermost 重连延迟", + "hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。" + }, "type": { "description": "适配器类型" }, diff --git a/dashboard/src/utils/platformUtils.js b/dashboard/src/utils/platformUtils.js index ae39396d7..d69af9237 100644 --- a/dashboard/src/utils/platformUtils.js +++ b/dashboard/src/utils/platformUtils.js @@ -40,6 +40,8 @@ export function getPlatformIcon(name) { return new URL('@/assets/images/platform_logos/line.png', import.meta.url).href } else if (name === 'matrix') { return new URL('@/assets/images/platform_logos/matrix.svg', import.meta.url).href + } else if (name === 'mattermost') { + return new URL('@/assets/images/platform_logos/mattermost.svg', import.meta.url).href } } diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index a4f104de0..5f6e1d242 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -98,6 +98,7 @@ export default defineConfig({ { text: "Telegram", link: "/telegram" }, { text: "LINE", link: "/line" }, { text: "Slack", link: "/slack" }, + { text: "Mattermost", link: "/mattermost" }, { text: "Misskey", link: "/misskey" }, { text: "Discord", link: "/discord" }, { text: "KOOK", link: "/kook" }, @@ -336,6 +337,7 @@ export default defineConfig({ { text: "Telegram", link: "/telegram" }, { text: "LINE", link: "/line" }, { text: "Slack", link: "/slack" }, + { text: "Mattermost", link: "/mattermost" }, { text: "Misskey", link: "/misskey" }, { text: "Discord", link: "/discord" }, { diff --git a/docs/en/platform/mattermost.md b/docs/en/platform/mattermost.md new file mode 100644 index 000000000..feb071684 --- /dev/null +++ b/docs/en/platform/mattermost.md @@ -0,0 +1,139 @@ +# Connecting to Mattermost + +The Mattermost adapter connects to your Mattermost server through a Bot Token and WebSocket. After finishing the two parts below, AstrBot can send and receive messages in Mattermost channels and direct messages. + +## Create the AstrBot Mattermost Platform Adapter + +Go to the `Bots` page, click `+ Create Bot`, and choose `Mattermost`. + +On the configuration page, enable it first, then fill in: + +- `Mattermost URL`: your Mattermost server URL, for example `https://chat.example.com` +- `Mattermost Bot Token`: the access token generated after creating a bot account in Mattermost +- `Mattermost Reconnect Delay`: how long AstrBot waits before reconnecting after a WebSocket disconnect, default `5` + +Then click save. + +## Deploy Mattermost + +If you do not have a Mattermost server yet, use the official Mattermost Docker Compose repository: + +- Official docs: https://docs.mattermost.com/deployment-guide/server/containers/install-docker.html +- Official repository: https://github.com/mattermost/docker + +The current quick-start flow recommended by Mattermost is: + +```bash +git clone https://github.com/mattermost/docker +cd docker +cp env.example .env +``` + +Then update at least these values in `.env`: + +- `DOMAIN` +- `MATTERMOST_IMAGE_TAG` +- It is also recommended to set `MM_SUPPORTSETTINGS_SUPPORTEMAIL` + +Create the data directories and set ownership: + +```bash +mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes} +sudo chown -R 2000:2000 ./volumes/app/mattermost +``` + +Choose one startup mode: + +Without the bundled NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.without-nginx.yml up -d +``` + +With the bundled NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d +``` + +Access URLs: + +- Without NGINX: `http://your-domain:8065` +- With NGINX: `https://your-domain` + +> [!TIP] +> Mattermost currently states that production Docker support is Linux-only. macOS and Windows are better suited for development or testing. + +## Create a Bot in Mattermost + +### 1. Enable Bot Account Creation + +Open the Mattermost system console: + +`System Console > Integrations > Bot Accounts` + +Enable `Enable Bot Account Creation`. + +### 2. Create the Bot Account + +Go to: + +`Product menu > Integrations > Bot Accounts` + +Click `Add Bot Account` and fill in: + +- `Username` +- `Display Name` +- `Description` + +After creation, copy the generated Bot Token. It is shown only once. Paste it into AstrBot's `Mattermost Bot Token` field. + +### 3. Add the Bot to a Channel + +Add the bot to the channel where AstrBot should work. Otherwise the bot will not be able to properly receive and send messages in that channel. + +## How to Fill in Mattermost URL + +`Mattermost URL` should be the external URL of your Mattermost server, without a trailing slash. For example: + +```text +https://chat.example.com +``` + +If you are only testing locally, you can also use: + +```text +http://127.0.0.1:8065 +``` + +If both AstrBot and Mattermost run in containers, prefer an address reachable from the AstrBot container, such as the Mattermost service name on the same Docker network. + +## Start and Verify + +After saving the AstrBot platform adapter configuration: + +1. Make sure the AstrBot logs do not show Mattermost authentication or WebSocket connection errors. +2. Send a message in a channel that includes the bot, or send the bot a direct message. +3. If AstrBot replies normally, the integration is working. + +## Common Issues + +### Invalid Token Errors + +Usually one of these: + +- You copied a user token instead of the bot token +- The token contains extra spaces +- The bot account was deleted or the token was regenerated + +### Connected but No Channel Messages Arrive + +Check these first: + +- The bot has been added to the target channel +- `Mattermost URL` points to an address AstrBot can actually reach +- Your Mattermost reverse proxy forwards WebSocket traffic correctly + +### Mattermost Opens in Browser but AstrBot Still Cannot Connect + +If AstrBot runs in a container while `Mattermost URL` is set to `localhost` or `127.0.0.1`, AstrBot will connect to itself instead of the Mattermost service. In that case, switch to an address reachable inside the Docker network. diff --git a/docs/zh/platform/mattermost.md b/docs/zh/platform/mattermost.md new file mode 100644 index 000000000..be67494e0 --- /dev/null +++ b/docs/zh/platform/mattermost.md @@ -0,0 +1,139 @@ +# 接入 Mattermost + +Mattermost 适配器通过 Bot Token 和 WebSocket 连接到 Mattermost 服务器。完成下面两部分配置后,AstrBot 就可以在 Mattermost 频道和私聊中收发消息。 + +## 创建 AstrBot Mattermost 平台适配器 + +进入 `机器人` 页面,点击 `+ 创建机器人`,选择 `Mattermost`。 + +在配置页中先打开 `启用`,然后填写以下字段: + +- `Mattermost URL`:你的 Mattermost 服务地址,例如 `https://chat.example.com` +- `Mattermost Bot Token`:在 Mattermost 中创建 Bot 账户后生成的访问令牌 +- `Mattermost 重连延迟`:WebSocket 断开后的重连等待时间,默认 `5` + +填写完成后点击保存。 + +## 部署 Mattermost + +如果你还没有 Mattermost 服务,建议直接使用 Mattermost 官方提供的 Docker Compose 仓库: + +- 官方文档:https://docs.mattermost.com/deployment-guide/server/containers/install-docker.html +- 官方仓库:https://github.com/mattermost/docker + +官方当前推荐的快速部署步骤如下: + +```bash +git clone https://github.com/mattermost/docker +cd docker +cp env.example .env +``` + +然后至少修改 `.env` 中的: + +- `DOMAIN` +- `MATTERMOST_IMAGE_TAG` +- 建议补充 `MM_SUPPORTSETTINGS_SUPPORTEMAIL` + +接着创建数据目录并设置权限: + +```bash +mkdir -p ./volumes/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes} +sudo chown -R 2000:2000 ./volumes/app/mattermost +``` + +启动方式二选一: + +不使用内置 NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.without-nginx.yml up -d +``` + +使用内置 NGINX: + +```bash +docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d +``` + +访问地址: + +- 不使用 NGINX:`http://你的域名:8065` +- 使用 NGINX:`https://你的域名` + +> [!TIP] +> Mattermost 官方当前说明中,Docker 生产支持仅限 Linux。macOS 和 Windows 更适合开发或测试用途。 + +## 在 Mattermost 中创建 Bot + +### 1. 开启 Bot 账户创建 + +进入 Mattermost 的系统控制台: + +`System Console > Integrations > Bot Accounts` + +开启 `Enable Bot Account Creation`。 + +### 2. 创建 Bot 账户 + +进入: + +`Product menu(左上角的图标) > Integrations > Bot Accounts` + +点击 `Add Bot Account`,填写: + +- `Username` +- `Display Name` +- `Description` + +创建完成后复制生成的 Bot Token。这个 Token 只会展示一次,随后填写到 AstrBot 的 `Mattermost Bot Token` 中。 + +### 3. 将 Bot 加入频道 + +把刚创建的 Bot 添加到你准备让 AstrBot 工作的频道中,否则机器人无法在该频道正常收发消息。 + +## Mattermost URL 如何填写 + +`Mattermost URL` 填 Mattermost 的外部访问地址,不要带结尾斜杠。例如: + +```text +https://chat.example.com +``` + +如果你当前只是在本机测试,也可以填写: + +```text +http://127.0.0.1:8065 +``` + +如果 AstrBot 和 Mattermost 都在 Docker 中运行,请优先填写 AstrBot 容器可访问到的地址,例如同一 Docker 网络中的服务名地址。 + +## 启动并验证 + +保存 AstrBot 平台适配器配置后: + +1. 确保 AstrBot 日志中没有出现 Mattermost 认证失败或 WebSocket 连接失败。 +2. 在 Mattermost 中向 Bot 所在频道发送消息,或直接给 Bot 发私聊。 +3. 如果 AstrBot 正常回复,说明接入成功。 + +## 常见问题 + +### 提示 Token 无效 + +通常是以下原因: + +- 复制的不是 Bot Token +- Token 复制时带了空格 +- Bot 账户被删除或重新生成了 Token + +### 连接成功但收不到频道消息 + +优先检查: + +- Bot 是否已经加入目标频道 +- Mattermost URL 是否填写为 AstrBot 实际可访问的地址 +- Mattermost 反向代理是否正确转发了 WebSocket 请求 + +### 本机部署能打开页面,但 AstrBot 连接不到 + +如果 AstrBot 运行在容器里,而 Mattermost URL 填的是 `localhost` 或 `127.0.0.1`,那么 AstrBot 实际连接到的是它自己的容器,而不是 Mattermost。此时应改为 Docker 网络内可访问的地址。 diff --git a/tests/test_mattermost_adapter.py b/tests/test_mattermost_adapter.py new file mode 100644 index 000000000..7fd13d35d --- /dev/null +++ b/tests/test_mattermost_adapter.py @@ -0,0 +1,95 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import astrbot.api.message_components as Comp +from astrbot.core.platform.sources.mattermost.client import MattermostClient +from astrbot.core.platform.sources.mattermost.mattermost_adapter import ( + MattermostPlatformAdapter, +) +from tests.fixtures.helpers import make_platform_config + + +def _build_adapter() -> MattermostPlatformAdapter: + adapter = MattermostPlatformAdapter( + make_platform_config( + "mattermost", + id="test_mattermost", + mattermost_url="https://chat.example.com", + mattermost_bot_token="test_token", + mattermost_reconnect_delay=5.0, + ), + {}, + asyncio.Queue(), + ) + adapter.bot_self_id = "bot-id" + adapter.bot_username = "bot" + adapter._mention_pattern = adapter._build_mention_pattern(adapter.bot_username) + return adapter + + +@pytest.mark.asyncio +async def test_mattermost_convert_message_strips_leading_self_mention(): + adapter = _build_adapter() + + result = await adapter.convert_message( + post={ + "id": "post-1", + "channel_id": "channel-1", + "user_id": "user-1", + "message": "@bot /help now", + "create_at": 1_700_000_000_000, + "file_ids": [], + }, + data={ + "channel_type": "O", + "sender_name": "alice", + }, + ) + + assert result is not None + assert result.message_str == "/help now" + assert isinstance(result.message[0], Comp.At) + assert result.message[0].qq == "bot-id" + assert any( + isinstance(component, Comp.Plain) and component.text.strip() == "/help now" + for component in result.message + ) + + +@pytest.mark.asyncio +async def test_mattermost_parse_post_attachments_maps_media_types(tmp_path): + client = MattermostClient("https://chat.example.com", "test_token") + + file_infos = { + "img": {"name": "image.png", "mime_type": "image/png"}, + "audio": {"name": "voice.ogg", "mime_type": "audio/ogg"}, + "video": {"name": "clip.mp4", "mime_type": "video/mp4"}, + "doc": {"name": "report.pdf", "mime_type": "application/pdf"}, + } + + client.get_file_info = AsyncMock(side_effect=lambda file_id: file_infos[file_id]) + client.download_file = AsyncMock(return_value=b"payload") + + with patch( + "astrbot.core.platform.sources.mattermost.client.get_astrbot_temp_path", + MagicMock(return_value=str(tmp_path)), + ): + components, temp_paths = await client.parse_post_attachments( + ["img", "audio", "video", "doc"] + ) + + assert len(components) == 4 + assert isinstance(components[0], Comp.Image) + assert isinstance(components[1], Comp.Record) + assert isinstance(components[2], Comp.Video) + assert isinstance(components[3], Comp.File) + assert len(temp_paths) == 4 + + expected_names = ["image.png", "voice.ogg", "clip.mp4", "report.pdf"] + for temp_path, expected_name in zip(temp_paths, expected_names): + path = Path(temp_path) + assert path.exists() + assert path.name.endswith(Path(expected_name).suffix) From 8c6c00ae62b6aa9321b9ce5f45511aa53c0ca7ec Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Apr 2026 18:37:52 +0800 Subject: [PATCH 03/17] fix: update tool result assertions to reflect dynamic threshold values --- tests/test_tool_loop_agent_runner.py | 57 ++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 4a415752f..be6833e87 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -568,7 +568,9 @@ async def test_tool_result_includes_all_calltoolresult_content( async def test_same_tool_consecutive_results_include_escalating_guidance( runner, mock_tool_executor, mock_hooks ): - provider = SequentialToolProvider(["test_tool"] * 5) + runner_cls = type(runner) + total_calls = runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD + provider = SequentialToolProvider(["test_tool"] * total_calls) tool = FunctionTool( name="test_tool", description="测试工具", @@ -590,16 +592,15 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( streaming=False, ) - async for _ in runner.step_until_done(6): + async for _ in runner.step_until_done(total_calls + 1): pass tool_messages = [ m for m in runner.run_context.messages if getattr(m, "role", None) == "tool" ] - assert len(tool_messages) == 5 + assert len(tool_messages) == total_calls tool_contents = [str(message.content) for message in tool_messages] - runner_cls = type(runner) level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( tool_name="test_tool", streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD, @@ -613,19 +614,33 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( streak=runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD, ) - assert level_1_notice not in tool_contents[0] - assert level_2_notice not in tool_contents[0] - assert level_1_notice in tool_contents[1] - assert level_2_notice in tool_contents[2] - assert level_3_notice in tool_contents[4] + for streak, content in enumerate(tool_contents, start=1): + if streak < runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD: + assert level_1_notice not in content + assert level_2_notice not in content + assert level_3_notice not in content + elif streak < runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD: + assert level_1_notice in content + assert level_2_notice not in content + assert level_3_notice not in content + elif streak < runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD: + assert level_1_notice not in content + assert level_2_notice in content + assert level_3_notice not in content + else: + assert level_1_notice not in content + assert level_2_notice not in content + assert level_3_notice in content @pytest.mark.asyncio async def test_same_tool_streak_resets_after_switching_tools( runner, mock_tool_executor, mock_hooks ): + runner_cls = type(runner) + repeated_after_reset = runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD provider = SequentialToolProvider( - ["test_tool", "other_tool", "test_tool", "test_tool"] + ["test_tool", "other_tool", *(["test_tool"] * repeated_after_reset)] ) tool_a = FunctionTool( name="test_tool", @@ -654,16 +669,15 @@ async def test_same_tool_streak_resets_after_switching_tools( streaming=False, ) - async for _ in runner.step_until_done(5): + async for _ in runner.step_until_done(repeated_after_reset + 3): pass tool_messages = [ m for m in runner.run_context.messages if getattr(m, "role", None) == "tool" ] - assert len(tool_messages) == 4 + assert len(tool_messages) == repeated_after_reset + 2 tool_contents = [str(message.content) for message in tool_messages] - runner_cls = type(runner) level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( tool_name="test_tool", streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD, @@ -675,9 +689,20 @@ async def test_same_tool_streak_resets_after_switching_tools( assert level_1_notice not in tool_contents[0] assert level_1_notice not in tool_contents[1] - assert level_1_notice not in tool_contents[2] - assert level_2_notice not in tool_contents[2] - assert level_1_notice in tool_contents[3] + assert level_2_notice not in tool_contents[0] + assert level_2_notice not in tool_contents[1] + + repeated_contents = tool_contents[2:] + for streak_after_reset, content in enumerate(repeated_contents, start=1): + if streak_after_reset < runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD: + assert level_1_notice not in content + assert level_2_notice not in content + elif streak_after_reset < runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD: + assert level_1_notice in content + assert level_2_notice not in content + else: + assert level_1_notice not in content + assert level_2_notice in content @pytest.mark.asyncio From c48288d8f6a6f19460b3aa280d4f9b19c2bae929 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:12:20 +0800 Subject: [PATCH 04/17] chore(deps): bump docker/login-action in the github-actions group (#7401) Bumps the github-actions group with 1 update: [docker/login-action](https://github.com/docker/login-action). Updates `docker/login-action` from 4.0.0 to 4.1.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v4.0.0...v4.1.0) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-image.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index f80e8588f..b0378532d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -70,14 +70,14 @@ jobs: uses: docker/setup-buildx-action@v4.0.0 - name: Log in to DockerHub - uses: docker/login-action@v4.0.0 + uses: docker/login-action@v4.1.0 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@v4.0.0 + uses: docker/login-action@v4.1.0 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} @@ -169,14 +169,14 @@ jobs: uses: docker/setup-buildx-action@v4.0.0 - name: Log in to DockerHub - uses: docker/login-action@v4.0.0 + uses: docker/login-action@v4.1.0 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@v4.0.0 + uses: docker/login-action@v4.1.0 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} From 42fc16f1f00d2a4dee62d88a57fe9c5d5a63e530 Mon Sep 17 00:00:00 2001 From: M1LKT <144798909+M1LKT@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:39:38 +0800 Subject: [PATCH 05/17] fix: reduce MCP server status polling frequency (#7399) --- dashboard/src/components/extension/McpServersSection.vue | 3 ++- dashboard/src/views/ExtensionPage.vue | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue index d24bcec58..ae42d8ca6 100644 --- a/dashboard/src/components/extension/McpServersSection.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -284,8 +284,9 @@ export default { mounted() { this.getServers(); this.refreshInterval = setInterval(() => { + // 轮询时间延长到30秒,减少服务器压力,同时保持数据相对新鲜 this.getServers(); - }, 5000); + }, 30000); }, unmounted() { if (this.refreshInterval) { diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index ce859296c..c369c5caa 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -162,7 +162,7 @@ const { - +

{{ tm("tabs.handlersOperation") }}

@@ -180,7 +180,7 @@ const { - +

{{ tm("tabs.installedMcpServers") }}

@@ -198,7 +198,7 @@ const { - +

{{ tm("tabs.skills") }}

From dfca5cdb795332b5e444b674aaec4e8504a75aed Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 7 Apr 2026 18:26:27 +0800 Subject: [PATCH 06/17] fix: improve warning message for missing context token in WeixinOCAdapter --- astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index abb4c9599..b317cb5b8 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -782,7 +782,7 @@ class WeixinOCAdapter(Platform): context_token = self._context_tokens.get(user_id) if not context_token: logger.warning( - "weixin_oc(%s): context token missing for %s, skip send", + "weixin_oc(%s): context token missing for %s, skip send. You should send one message to refresh context_token.", self.meta().id, user_id, ) From 2fae22107d011b5955e872b597f14ba1bd62cc99 Mon Sep 17 00:00:00 2001 From: Aster <54532857+Aster-amellus@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:33:39 +0800 Subject: [PATCH 07/17] fix: defer faiss imports during startup (#7400) * fix: defer faiss imports during startup * chore: gemini cr Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * chore: ruff format * fix: move torch import to _resolve_device method and remove obsolete tests * fix: remove obsolete test for eager loading of faiss during startup imports * test: fix kb helper patch target after faiss lazy import --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/config/default.py | 7 ++++++ astrbot/core/knowledge_base/kb_db_sqlite.py | 9 ++++--- astrbot/core/knowledge_base/kb_helper.py | 9 +++++-- .../core/knowledge_base/retrieval/manager.py | 17 ++++++++----- .../retrieval/sparse_retriever.py | 7 ++++-- .../sources/whisper_selfhosted_source.py | 24 ++++++++++++++++--- astrbot/dashboard/utils.py | 5 +++- .../en-US/features/config-metadata.json | 4 ++++ .../ru-RU/features/config-metadata.json | 4 ++++ .../zh-CN/features/config-metadata.json | 4 ++++ tests/unit/test_kb_manager_resilience.py | 2 +- 11 files changed, 74 insertions(+), 18 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index ee42ca99d..83d86fbcc 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1556,6 +1556,7 @@ CONFIG_METADATA_2 = { "enable": False, "id": "whisper_selfhost", "model": "tiny", + "whisper_device": "cpu", }, "SenseVoice(Local)": { "type": "sensevoice_stt_selfhost", @@ -2555,6 +2556,12 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。", }, + "whisper_device": { + "description": "推理设备", + "type": "string", + "hint": "Whisper 推理设备。Apple Silicon 可选 mps;其他环境建议使用 cpu。若指定 mps 但当前环境不可用,将自动回退到 cpu。", + "options": ["cpu", "mps"], + }, "id": { "description": "ID", "type": "string", diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py index 4b9dcf7dd..6a2cb5e0a 100644 --- a/astrbot/core/knowledge_base/kb_db_sqlite.py +++ b/astrbot/core/knowledge_base/kb_db_sqlite.py @@ -1,12 +1,12 @@ from contextlib import asynccontextmanager from pathlib import Path +from typing import TYPE_CHECKING from sqlalchemy import delete, func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlmodel import col, desc from astrbot.core import logger -from astrbot.core.db.vec_db.faiss_impl import FaissVecDB from astrbot.core.knowledge_base.models import ( BaseKBModel, KBDocument, @@ -15,6 +15,9 @@ from astrbot.core.knowledge_base.models import ( ) from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path +if TYPE_CHECKING: + from astrbot.core.db.vec_db.faiss_impl import FaissVecDB + class KBSQLiteDatabase: def __init__(self, db_path: str | None = None) -> None: @@ -296,7 +299,7 @@ class KBSQLiteDatabase: return metadata_map - async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None: + async def delete_document_by_id(self, doc_id: str, vec_db: "FaissVecDB") -> None: """删除单个文档及其相关数据""" # 在知识库表中删除 async with self.get_db() as session, session.begin(): @@ -324,7 +327,7 @@ class KBSQLiteDatabase: result = await session.execute(stmt) return result.scalar_one_or_none() - async def update_kb_stats(self, kb_id: str, vec_db: FaissVecDB) -> None: + async def update_kb_stats(self, kb_id: str, vec_db: "FaissVecDB") -> None: """更新知识库统计信息""" chunk_cnt = await vec_db.count_documents() diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py index cb74cb2ba..0863f7e6d 100644 --- a/astrbot/core/knowledge_base/kb_helper.py +++ b/astrbot/core/knowledge_base/kb_helper.py @@ -4,12 +4,12 @@ import re import time import uuid from pathlib import Path +from typing import TYPE_CHECKING import aiofiles from astrbot.core import logger from astrbot.core.db.vec_db.base import BaseVecDB -from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB from astrbot.core.provider.manager import ProviderManager from astrbot.core.provider.provider import ( EmbeddingProvider, @@ -27,6 +27,9 @@ from .parsers.url_parser import extract_text_from_url from .parsers.util import select_parser from .prompts import TEXT_REPAIR_SYSTEM_PROMPT +if TYPE_CHECKING: + from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB + class RateLimiter: """一个简单的速率限制器""" @@ -160,7 +163,7 @@ class KBHelper: return None return rp - async def _ensure_vec_db(self) -> FaissVecDB: + async def _ensure_vec_db(self) -> "FaissVecDB": if not self.kb.embedding_provider_id: raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider") @@ -173,6 +176,8 @@ class KBHelper: f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 初始化重排序能力失败,将跳过重排序: {e}", ) + from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB + vec_db = FaissVecDB( doc_store_path=str(self.kb_dir / "doc.db"), index_store_path=str(self.kb_dir / "index.faiss"), diff --git a/astrbot/core/knowledge_base/retrieval/manager.py b/astrbot/core/knowledge_base/retrieval/manager.py index e5bdb6e65..1d65401ce 100644 --- a/astrbot/core/knowledge_base/retrieval/manager.py +++ b/astrbot/core/knowledge_base/retrieval/manager.py @@ -5,10 +5,10 @@ import time from dataclasses import dataclass +from typing import TYPE_CHECKING from astrbot import logger from astrbot.core.db.vec_db.base import Result -from astrbot.core.db.vec_db.faiss_impl import FaissVecDB from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase from astrbot.core.knowledge_base.retrieval.rank_fusion import RankFusion from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever @@ -16,6 +16,9 @@ from astrbot.core.provider.provider import RerankProvider from ..kb_helper import KBHelper +if TYPE_CHECKING: + from astrbot.core.db.vec_db.faiss_impl import FaissVecDB + @dataclass class RetrievalResult: @@ -170,18 +173,20 @@ class RetrievalManager: first_rerank = None for kb_id in kb_ids: vec_db = kb_options[kb_id]["vec_db"] - if not isinstance(vec_db, FaissVecDB): - logger.warning(f"vec_db for kb_id {kb_id} is not FaissVecDB") + rerank_provider = ( + getattr(vec_db, "rerank_provider", None) if vec_db else None + ) + if rerank_provider is None: continue rerank_pi = kb_options[kb_id]["rerank_provider_id"] if ( vec_db - and vec_db.rerank_provider + and rerank_provider and rerank_pi - and rerank_pi == vec_db.rerank_provider.meta().id + and rerank_pi == rerank_provider.meta().id ): - first_rerank = vec_db.rerank_provider + first_rerank = rerank_provider break if first_rerank and retrieval_results: try: diff --git a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py index d453251d1..9d485dab2 100644 --- a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py +++ b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py @@ -6,13 +6,16 @@ import json import os from dataclasses import dataclass +from typing import TYPE_CHECKING import jieba from rank_bm25 import BM25Okapi -from astrbot.core.db.vec_db.faiss_impl import FaissVecDB from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase +if TYPE_CHECKING: + from astrbot.core.db.vec_db.faiss_impl import FaissVecDB + @dataclass class SparseResult: @@ -73,7 +76,7 @@ class SparseRetriever: top_k_sparse = 0 chunks = [] for kb_id in kb_ids: - vec_db: FaissVecDB = kb_options.get(kb_id, {}).get("vec_db") + vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db") if not vec_db: continue result = await vec_db.document_storage.get_documents( diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index 519a64de6..0622df6fc 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -1,6 +1,7 @@ import asyncio import os import uuid +from functools import partial from typing import cast import whisper @@ -28,17 +29,34 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): ) -> None: super().__init__(provider_config, provider_settings) self.set_model(provider_config["model"]) + self.device = str(provider_config.get("whisper_device", "cpu")).strip().lower() self.model = None + def _resolve_device(self) -> str: + if self.device == "mps": + import torch # torch is a dependency of openai-whisper + + mps_backend = getattr(torch.backends, "mps", None) + if mps_backend and mps_backend.is_available(): + return "mps" + logger.warning("Whisper 已配置为使用 MPS,但当前环境不可用,将回退到 CPU。") + return "cpu" + if self.device != "cpu": + logger.warning( + "Whisper 配置了未知 device=%s,将回退到 CPU。", + self.device, + ) + return "cpu" + async def initialize(self) -> None: loop = asyncio.get_running_loop() + device = self._resolve_device() logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...") self.model = await loop.run_in_executor( None, - whisper.load_model, - self.model_name, + partial(whisper.load_model, self.model_name, device=device), ) - logger.info("Whisper 模型加载完成。") + logger.info("Whisper 模型加载完成。device=%s", device) async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py index 3a0ee5bdc..f8b44d5a1 100644 --- a/astrbot/dashboard/utils.py +++ b/astrbot/dashboard/utils.py @@ -1,12 +1,15 @@ import base64 import traceback from io import BytesIO +from typing import TYPE_CHECKING from astrbot.api import logger -from astrbot.core.db.vec_db.faiss_impl import FaissVecDB from astrbot.core.knowledge_base.kb_helper import KBHelper from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager +if TYPE_CHECKING: + from astrbot.core.db.vec_db.faiss_impl import FaissVecDB + async def generate_tsne_visualization( query: str, 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 360dc832a..520193209 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1514,6 +1514,10 @@ "description": "Notes for local Whisper deployment", "hint": "Before enabling, install the openai-whisper library (NVIDIA users download ~2GB mainly for torch and cuda; CPU users download ~1GB), and install ffmpeg. Otherwise STT will not work." }, + "whisper_device": { + "description": "Inference device", + "hint": "Whisper inference device. Apple Silicon can use mps; other environments should use cpu. If mps is selected but unavailable, AstrBot will fall back to cpu." + }, "id": { "description": "ID" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 89b368fd8..4fdb9eae9 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1511,6 +1511,10 @@ "description": "Заметки по локальному развертыванию Whisper", "hint": "Перед включением установите openai-whisper и ffmpeg." }, + "whisper_device": { + "description": "Устройство инференса", + "hint": "Устройство для инференса Whisper. На Apple Silicon можно выбрать mps; в остальных средах рекомендуется cpu. Если выбран mps, но он недоступен, AstrBot автоматически переключится на cpu." + }, "id": { "description": "ID провайдера" }, 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 eb82648ca..1cf241cc3 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1516,6 +1516,10 @@ "description": "本地部署 Whisper 模型须知", "hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。" }, + "whisper_device": { + "description": "推理设备", + "hint": "Whisper 推理设备。Apple Silicon 可选 mps;其他环境建议使用 cpu。若指定 mps 但当前环境不可用,将自动回退到 cpu。" + }, "id": { "description": "ID" }, diff --git a/tests/unit/test_kb_manager_resilience.py b/tests/unit/test_kb_manager_resilience.py index f048def2a..ed43a338f 100644 --- a/tests/unit/test_kb_manager_resilience.py +++ b/tests/unit/test_kb_manager_resilience.py @@ -253,7 +253,7 @@ async def test_ensure_vec_db_clears_stale_init_error( mock_vec_db.close = AsyncMock() with patch( - "astrbot.core.knowledge_base.kb_helper.FaissVecDB", + "astrbot.core.db.vec_db.faiss_impl.vec_db.FaissVecDB", return_value=mock_vec_db, ): # Execute _ensure_vec_db From 301df11102df273e27fd0d2c1cf30096a4500268 Mon Sep 17 00:00:00 2001 From: Double2and9 <68542386+michealmachine@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:15:43 +0800 Subject: [PATCH 08/17] fix: prevent KeyError in Telegram collect_commands when plugin handler not in star_map (#7405) * fix: prevent KeyError in collect_commands when plugin handler not in star_map * chore: ruff format --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/platform/sources/telegram/tg_adapter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index e7d5a7ad4..46887344d 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -228,7 +228,10 @@ class TelegramPlatformAdapter(Platform): for handler_md in star_handlers_registry: handler_metadata = handler_md - if not star_map[handler_metadata.handler_module_path].activated: + if ( + handler_metadata.handler_module_path not in star_map + or not star_map[handler_metadata.handler_module_path].activated + ): continue if not handler_metadata.enabled: continue From 1ab925ed9f1f1a27351efc01682274c3c1f1aeb7 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:19:47 +0800 Subject: [PATCH 09/17] fix: skip FunctionCallingConfig when only native tools are present (#7407) * fix: skip FunctionCallingConfig when only native tools are present When native tools (google_search, url_context) are enabled without any function_declarations, _prepare_query_config was still creating a FunctionCallingConfig, which makes Gemini API return 400 INVALID_ARGUMENT. Now we only set tool_config when tool_list actually contains function_declarations. Fixes #7406 * style: ruff format --- astrbot/core/provider/sources/gemini_source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index e5fae456d..9bf422aba 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -215,7 +215,8 @@ class ProviderGoogleGenAI(Provider): ] tool_config = None - if tools and tool_list: + has_func_decl = tool_list and any(t.function_declarations for t in tool_list) + if has_func_decl: tool_config = types.ToolConfig( function_calling_config=types.FunctionCallingConfig( mode=( From 2020eecc72eeadfc444e9b0e05d67ed2e2286727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B0=95=E6=B0=99?= <2014440212@qq.com> Date: Wed, 8 Apr 2026 11:29:24 +0800 Subject: [PATCH 10/17] fix(dashboard): fix WebUI dark mode rendering and multiple UI interaction bugs (#7173) * fix(dashboard): comprehensive dark mode improvements for WebUI - Tune DarkTheme.ts color palette: soften primary, fix on-surface-variant from black to light grey, adjust surface/background/border colors, add codeBg/preBg/code variables for dark code blocks - Add _HljsDark.scss for highlight.js dark token overrides and markstream-vue CSS variable overrides under .v-theme--PurpleThemeDark - Add global dark mode overrides in _override.scss: soften flat primary buttons, timeline dots, dialog card backgrounds, markdown link color - Fix hardcoded light backgrounds in IPythonToolBlock, ExtensionCard, T2ITemplateEditor, ExtensionPage, KnowledgeBase, LongTermMemory - Remove hardcoded color='black' from icons in ProviderPage, PlatformPage, PersonaPage - Desaturate console log ANSI colors for dark mode readability - Add iframe dark mode inversion filter in VerticalSidebar - Fix language switcher mobile adaptation and click behavior - Fix login page theme toggle tooltip showing wrong label * fix(dashboard): apply dark mode review feedback --- .../message_list_comps/IPythonToolBlock.vue | 4 +- .../components/shared/ConsoleDisplayer.vue | 14 +-- .../src/components/shared/ExtensionCard.vue | 6 +- .../src/components/shared/ReadmeDialog.vue | 2 +- .../components/shared/T2ITemplateEditor.vue | 8 +- .../full/vertical-header/VerticalHeader.vue | 7 +- .../full/vertical-sidebar/VerticalSidebar.vue | 90 ++++++++------- dashboard/src/scss/_override.scss | 31 +++++ dashboard/src/scss/components/_HljsDark.scss | 107 ++++++++++++++++++ dashboard/src/scss/style.scss | 1 + dashboard/src/theme/DarkTheme.ts | 30 ++--- dashboard/src/views/ExtensionPage.vue | 8 +- dashboard/src/views/PersonaPage.vue | 2 +- dashboard/src/views/PlatformPage.vue | 2 +- dashboard/src/views/ProviderPage.vue | 2 +- dashboard/src/views/alkaid/KnowledgeBase.vue | 18 ++- dashboard/src/views/alkaid/LongTermMemory.vue | 15 +++ .../views/authentication/auth/LoginPage.vue | 4 +- 18 files changed, 271 insertions(+), 80 deletions(-) create mode 100644 dashboard/src/scss/components/_HljsDark.scss diff --git a/dashboard/src/components/chat/message_list_comps/IPythonToolBlock.vue b/dashboard/src/components/chat/message_list_comps/IPythonToolBlock.vue index 5ff9469a0..a031f9bc1 100644 --- a/dashboard/src/components/chat/message_list_comps/IPythonToolBlock.vue +++ b/dashboard/src/components/chat/message_list_comps/IPythonToolBlock.vue @@ -150,7 +150,7 @@ onMounted(async () => { } .code-fallback.dark-theme { - background-color: transparent; + background-color: rgb(var(--v-theme-codeBg)); } .result-section { @@ -178,7 +178,7 @@ onMounted(async () => { } .result-content.dark-theme { - background-color: transparent; + background-color: rgb(var(--v-theme-codeBg)); } .animate-fade-in { diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index d69feea39..cf4be65cf 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -36,14 +36,14 @@ export default { autoScroll: true, isFullscreen: false, logColorAnsiMap: { - '\u001b[1;34m': 'color: #39C5BB; font-weight: bold;', - '\u001b[1;36m': 'color: #00FFFF; font-weight: bold;', - '\u001b[1;33m': 'color: #FFFF00; font-weight: bold;', - '\u001b[31m': 'color: #FF0000;', - '\u001b[1;31m': 'color: #FF0000; font-weight: bold;', + '\u001b[1;34m': 'color: #6cb6d9; font-weight: bold;', + '\u001b[1;36m': 'color: #72c4cc; font-weight: bold;', + '\u001b[1;33m': 'color: #d4b95e; font-weight: bold;', + '\u001b[31m': 'color: #d46a6a;', + '\u001b[1;31m': 'color: #e06060; font-weight: bold;', '\u001b[0m': 'color: inherit; font-weight: normal;', - '\u001b[32m': 'color: #00FF00;', - 'default': 'color: #FFFFFF;' + '\u001b[32m': 'color: #6cc070;', + 'default': 'color: #c8c8c8;' }, logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], selectedLevels: [0, 1, 2, 3, 4], diff --git a/dashboard/src/components/shared/ExtensionCard.vue b/dashboard/src/components/shared/ExtensionCard.vue index 061a76c56..f7c05c5c5 100644 --- a/dashboard/src/components/shared/ExtensionCard.vue +++ b/dashboard/src/components/shared/ExtensionCard.vue @@ -164,11 +164,13 @@ const viewChangelog = () => { ? marketMode ? '#f8f0dd' : '#ffffff' - : '#282833', + : marketMode + ? '#3a3425' + : '#282833', color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' - : '#ffffff', + : '#ffffffdd', }" > - + {{ tm('t2iTemplateEditor.dialogTitle') }} @@ -560,3 +560,9 @@ code { font-size: 0.875em; } + + diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 1fb32b48a..4ac9e1d6e 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -488,6 +488,7 @@ const isChristmas = computed(() => { }); // 语言切换相关 +const mainMenuOpen = ref(false); const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher(); const languages = computed(() => languageOptions.value.map(lang => ({ @@ -499,6 +500,7 @@ const languages = computed(() => const currentLocale = computed(() => locale.value); const changeLanguage = async (langCode: string) => { await switchLanguage(langCode as Locale); + mainMenuOpen.value = false; }; onMounted(async () => { @@ -591,7 +593,7 @@ onMounted(async () => { - +