From fc0eed08ccf486d81c93bc0754ecccfa3519cf69 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:49:53 +0800 Subject: [PATCH] fix: preserve directed messaging and channel interaction contracts (#7148) * fix: retain plugin lifecycle ownership and recover installation state * fix: preserve directed messaging and channel interaction contracts * test: cover plugin rollback and uninstall lifecycle boundaries --------- Co-authored-by: jxxghp --- app/api/endpoints/notification.py | 15 +- app/application/messaging/skill.py | 87 ++++---- app/chain/interaction.py | 4 + app/chain/music_interaction/music.py | 6 +- app/chain/system.py | 4 +- app/modules/discord/__init__.py | 7 +- app/modules/discord/discord.py | 32 +-- app/modules/qqbot/gateway.py | 6 +- app/modules/slack/__init__.py | 2 +- app/modules/slack/slack.py | 17 +- app/modules/telegram/telegram.py | 105 +++++++++- app/modules/wechat/wechatbot.py | 9 +- app/modules/wechatclawbot/wechatclawbot.py | 69 +++---- docs/mcp-api.md | 9 + .../architecture/complexity-baseline.json | 1 - .../architecture/dependency-baseline.json | 12 +- .../architecture/dependency-policy.json | 2 +- tests/test_architecture_egress.py | 4 +- tests/test_messaging_review_attachments.py | 189 ++++++++++++++++++ tests/test_messaging_review_boundaries.py | 115 +++++++++++ tests/test_messaging_review_regressions.py | 160 +++++++++++++++ tests/test_skills_command.py | 2 +- 22 files changed, 708 insertions(+), 149 deletions(-) create mode 100644 tests/test_messaging_review_attachments.py create mode 100644 tests/test_messaging_review_boundaries.py create mode 100644 tests/test_messaging_review_regressions.py diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index b3921f5eb..a7334774e 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -18,13 +18,23 @@ from app.schemas.types import SystemConfigKey router = ResponseAPIRouter() -def _normalize_configs(value: list[NotificationConf]) -> list[dict[str, Any]]: +def _normalize_configs(value: list[NotificationConf], *, validate_identities: bool = True) -> list[dict[str, Any]]: """ 规整通知配置身份和名称,拒绝会导致运行目录覆盖的输入。 + + :param value: 待规整的通知配置集合 + :param validate_identities: 新保存值校验稳定身份;旧值只用于缓存对账,允许读取以修复历史重复 + :return: 已规整的独立配置字典列表 + :raises ValueError: 名称为空或重复,或新值稳定身份为空或重复 """ normalized: list[dict[str, Any]] = [] names: set[str] = set() + identities: set[str] = set() for config in value: + identity = config.id.strip() + if validate_identities and (not identity or identity in identities): + raise ValueError("通知渠道稳定身份ID不能为空或重复") + identities.add(identity) name = (config.name or "").strip() if not name: raise ValueError("通知渠道名称不能为空") @@ -33,6 +43,7 @@ def _normalize_configs(value: list[NotificationConf]) -> list[dict[str, Any]]: raise ValueError(f"通知渠道名称重复:{name}") names.add(key) data = config.model_dump() + data["id"] = identity data["name"] = name normalized.append(data) return normalized @@ -61,7 +72,7 @@ async def save_config( if not legacy.get("id"): legacy["id"] = f"legacy-{legacy.get('type') or 'notification'}-{legacy.get('name') or 'unnamed'}" previous_models.append(NotificationConf(**legacy)) - previous_data = _normalize_configs(previous_models) + previous_data = _normalize_configs(previous_models, validate_identities=False) saved = await get_configured_system_config().async_set(SystemConfigKey.Notifications, current) if saved is False: return _SchemaResponse(success=False, message="通知配置保存失败") diff --git a/app/application/messaging/skill.py b/app/application/messaging/skill.py index 445a20b89..d62bb1c2e 100644 --- a/app/application/messaging/skill.py +++ b/app/application/messaging/skill.py @@ -4,6 +4,7 @@ import re import uuid +from copy import deepcopy from dataclasses import dataclass, field from datetime import datetime from typing import Any, Callable, List, Optional, Protocol, Tuple, Union @@ -140,6 +141,9 @@ class PendingSkillInteraction: :param market_query: 市场筛选词,空字符串表示不按关键词筛选 :param awaiting_input: 等待的输入用途:market-search、source-add 或 None :param created_at: 会话创建的本地时间,用于计算过期时间 + :param selection_id: 最近一次列表展示的唯一标识,旧展示按钮不复用新列表序号 + :param selection_view: 最近一次展示的列表类别,installed或market;尚未展示时为空 + :param selection_items: 展示时条目的独立快照,操作仅按该快照定位身份 """ request_id: str @@ -153,6 +157,9 @@ class PendingSkillInteraction: market_query: str = "" awaiting_input: Optional[str] = None created_at: datetime = field(default_factory=datetime.now) + selection_id: str = "" + selection_view: str = "" + selection_items: List[Any] = field(default_factory=list) class SkillInteractionManager( @@ -250,7 +257,8 @@ class SkillInteractionHandler: """ 解析 /skills 按钮回调。 - 回调格式:skills:{request_id}:{action}[:index] + 导航格式:skills:{request_id}:{action}[:index]。 + 安装/删除格式:skills:{request_id}:{action}:{index}:{selection_id},展示标识另行校验。 """ if not callback_data.startswith("skills:"): return None @@ -318,6 +326,11 @@ class SkillInteractionHandler: ) return True + if self._selection_callback_is_stale(request, action, callback_data): + self._messenger.post_message(Message(channel=channel, source=source, userid=userid, + username=username, title="技能列表已变化,请使用最新列表重新选择")) + return True + request.channel = channel request.source = source request.username = username @@ -385,27 +398,9 @@ class SkillInteractionHandler: request.market_page = max(0, request.market_page - 1) elif action == "install" and index: request.awaiting_input = None - success, message = self._install_market_skill(request, index) - if success: - self._messenger.post_message( - Message( - channel=channel, - source=source, - userid=userid, - username=username, - title=message, - ) - ) - else: - self._messenger.post_message( - Message( - channel=channel, - source=source, - userid=userid, - username=username, - title=message, - ) - ) + _, message = self._install_market_skill(request, index) + self._messenger.post_message(Message(channel=channel, source=source, userid=userid, + username=username, title=message)) elif action == "remove" and index: request.awaiting_input = None success, message = self._remove_local_skill(request, index) @@ -756,21 +751,31 @@ class SkillInteractionHandler: ) return True + @staticmethod + def _selection_callback_is_stale(request: PendingSkillInteraction, action: str, callback_data: str) -> bool: + """ + 核对安装/删除按钮的展示标识及当前视图,旧列表不能沿用新序号。 + + :param request: 当前技能交互会话 + :param action: 解析后的按钮动作 + :param callback_data: 包含展示标识的完整按钮数据 + :return: 属于安装/删除且展示已失效时为True,其它导航动作不受影响 + """ + expected_view = {"install": "market", "remove": "installed"}.get(action) + return expected_view is not None and ( + callback_data.split(":")[4:] != [request.selection_id] + or request.selection_view != expected_view or request.view != expected_view + ) + def _install_market_skill( self, request: PendingSkillInteraction, page_index: int, ) -> Tuple[bool, str]: """ - 按当前市场页的可见序号安装技能,避免跨页序号歧义。 + 按展示快照的可见序号安装技能,不重新按实时目录计算序号。 """ - market_skills = self._get_market_skills(request=request) - items, page, _ = self._page_items( - items=market_skills, - page=request.market_page, - page_size=self._page_size(request.channel), - ) - request.market_page = page + items = request.selection_items if request.selection_view == "market" else [] if page_index < 1 or page_index > len(items): return False, "安装序号无效" return self.skillhelper.install_market_skill(items[page_index - 1]) @@ -781,18 +786,14 @@ class SkillInteractionHandler: page_index: int, ) -> Tuple[bool, str]: """ - 按当前已安装页的可见序号删除技能,并拦截内置技能。 + 按展示快照删除对应技能,目标变化或内置技能不会被删除。 """ - local_skills = self.skillhelper.list_local_skills() - items, page, _ = self._page_items( - items=local_skills, - page=request.local_page, - page_size=self._page_size(request.channel), - ) - request.local_page = page + items = request.selection_items if request.selection_view == "installed" else [] if page_index < 1 or page_index > len(items): return False, "删除序号无效" target = items[page_index - 1] + if target not in self.skillhelper.list_local_skills(): + return False, "技能列表已变化,请刷新后重新选择" if not target.removable: return False, f"技能 {target.id} 是内置技能,不能删除" return self.skillhelper.remove_local_skill(target.id) @@ -919,6 +920,9 @@ class SkillInteractionHandler: page_size=self._page_size(request.channel), ) request.local_page = page + request.selection_id = uuid.uuid4().hex[:12] + request.selection_view = "installed" + request.selection_items = deepcopy(items) text_lines = [f"第 {page + 1}/{total_pages} 页,共 {len(local_skills)} 个技能"] if not items: @@ -952,7 +956,7 @@ class SkillInteractionHandler: [ { "text": f"删除 {index}", - "callback_data": f"skills:{request.request_id}:remove:{index}", + "callback_data": f"skills:{request.request_id}:remove:{index}:{request.selection_id}", } ] ) @@ -983,6 +987,9 @@ class SkillInteractionHandler: page_size=self._page_size(request.channel), ) request.market_page = page + request.selection_id = uuid.uuid4().hex[:12] + request.selection_view = "market" + request.selection_items = deepcopy(items) text_lines = [f"第 {page + 1}/{total_pages} 页,共 {len(market_skills)} 个可安装技能"] if request.market_query: @@ -1052,7 +1059,7 @@ class SkillInteractionHandler: [ { "text": f"安装 {index}", - "callback_data": f"skills:{request.request_id}:install:{index}", + "callback_data": f"skills:{request.request_id}:install:{index}:{request.selection_id}", } ] ) diff --git a/app/chain/interaction.py b/app/chain/interaction.py index 5656d0a1a..9ea2db3a4 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -490,6 +490,10 @@ class MediaInteractionChain(ChainBase): """ 处理媒体选择阶段的序号输入。 """ + if request.phase != "media": + self._post_invalid_input(channel=channel, source=source, userid=userid, username=username, + title="媒体选择已结束,请使用当前资源列表") + return page_items, page, _ = self._page_items( items=request.items, page=request.page, diff --git a/app/chain/music_interaction/music.py b/app/chain/music_interaction/music.py index 7fcec484b..4a28674ed 100644 --- a/app/chain/music_interaction/music.py +++ b/app/chain/music_interaction/music.py @@ -147,11 +147,11 @@ def resolve_media_action(text: str) -> Tuple[Optional[str], str]: if music_action: return music_action, music_content if text.startswith("订阅"): - return "Subscribe", re.sub(r"订阅[::\s]*", "", text) + return "Subscribe", re.sub(r"^订阅[::\s]*", "", text, count=1) if text.startswith("洗版"): - return "ReSubscribe", re.sub(r"洗版[::\s]*", "", text) + return "ReSubscribe", re.sub(r"^洗版[::\s]*", "", text, count=1) if text.startswith("搜索") or text.startswith("下载"): - return "ReSearch", re.sub(r"(搜索|下载)[::\s]*", "", text) + return "ReSearch", re.sub(r"^(搜索|下载)[::\s]*", "", text, count=1) if url_tools.is_link(text): return None, text if not title_rules.is_media_title_like(text): diff --git a/app/chain/system.py b/app/chain/system.py index 0680d2242..af7bd45ad 100644 --- a/app/chain/system.py +++ b/app/chain/system.py @@ -306,7 +306,8 @@ class SystemChain(_SystemUpdateChain): save_history=False)) self.save_cache({ "channel": channel.value, - "userid": userid + "userid": userid, + "source": source, }, self._restart_file) self.backup_plugins() SystemHelper.restart() @@ -614,6 +615,7 @@ class SystemChain(_SystemUpdateChain): title = self.__get_version_message() self.post_message(Message( channel=channel, + source=restart_channel.get("source"), title=f"系统已重启完成!\n{title}", userid=userid, save_history=False)) diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index dea03ceff..fc7f1878d 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -518,7 +518,7 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): :param title: 消息标题 :param buttons: 新的按钮列表 :param metadata: 兼容统一接口的元数据,当前不使用 - :return: 客户端返回非空结果时为 True;未处理或失败时为 False,非 Discord 渠道为 None + :return: 客户端明确成功时为 True;未处理或失败时为 False,非 Discord 渠道为 None """ if channel != self._channel: return None @@ -534,8 +534,9 @@ class DiscordModule(_MessageChannelModuleBase[Discord]): original_message_id=message_id, original_chat_id=str(chat_id), ) - if result and isinstance(result, tuple) and result[0]: - return True + if isinstance(result, tuple): + if result and result[0]: + return True elif result: return True return False diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 4f417d482..d6a1e7e17 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -1449,10 +1449,10 @@ class Discord: """ 按回复上下文和配置优先级解析发送频道。 - 顺序为显式频道、用户最近发言频道、默认广播频道、服务器内可发送文字频道、用户私聊。 - 查到的广播频道会缓存;用户标识本身不保证使用私聊。 + 显式频道用于原会话回复;只指定用户时必须使用私聊,失败不会回落公共频道。 + 未指定接收人时才选择配置或自动发现的广播频道,并缓存成功结果。 - :param userid: 用户标识,用于查找最近发言频道及最终私聊回退 + :param userid: 定向用户标识,仅发送至该用户私聊 :param chat_id: 显式频道标识,优先用于原频道回复 :return: 首个可用频道或私聊对象;均不可用时返回 None """ @@ -1473,23 +1473,10 @@ class Discord: return channel except Exception as err: logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}") + return None if userid: - mapped_chat_id = self._get_user_chat_id(str(userid)) - if mapped_chat_id: - logger.debug(f"[Discord] 从用户映射获取 chat_id={mapped_chat_id}") - channel = self._client.get_channel(int(mapped_chat_id)) - if channel: - logger.debug(f"[Discord] 通过映射找到频道: {channel}") - return channel - try: - channel = await self._client.fetch_channel(int(mapped_chat_id)) - logger.debug( - f"[Discord] 通过 fetch_channel 找到映射频道: {channel}" - ) - return channel - except Exception as err: - logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败:{err}") + return await self._get_dm_channel(str(userid)) if self._broadcast_channel: logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}") @@ -1527,15 +1514,6 @@ class Discord: self._broadcast_channel = channel return channel - if userid: - logger.debug(f"[Discord] 回退到私聊: userid={userid}") - dm = await self._get_dm_channel(str(userid)) - if dm: - logger.debug(f"[Discord] 获取到私聊频道: {dm}") - return dm - else: - logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道") - return None async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]: diff --git a/app/modules/qqbot/gateway.py b/app/modules/qqbot/gateway.py index 660a168b2..f220a3e8a 100644 --- a/app/modules/qqbot/gateway.py +++ b/app/modules/qqbot/gateway.py @@ -220,8 +220,9 @@ def run_gateway( if match: content = f"CALLBACK:{match.group(1)}" msg_id = d.get("id", "") - if content: + if content or d.get("attachments"): on_message_fn({ + **d, "type": "C2C_MESSAGE_CREATE", "content": content, "author": {"user_openid": user_openid}, @@ -237,8 +238,9 @@ def run_gateway( if match: content = f"CALLBACK:{match.group(1)}" msg_id = d.get("id", "") - if content: + if content or d.get("attachments"): on_message_fn({ + **d, "type": "GROUP_AT_MESSAGE_CREATE", "content": content, "author": {"member_openid": member_openid}, diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py index 36888f7f3..f90280d43 100644 --- a/app/modules/slack/__init__.py +++ b/app/modules/slack/__init__.py @@ -223,7 +223,7 @@ class SlackModule(_MessageChannelModuleBase[Slack]): return None elif msg_json.get("command"): userid = msg_json.get("user_id") - text = msg_json.get("command") + text = " ".join(part for part in [msg_json.get("command"), msg_json.get("text")] if part).strip() username = msg_json.get("user_name") chat_id = msg_json.get("channel_id") if self._should_reject_admin_command(client_config.config, userid): diff --git a/app/modules/slack/slack.py b/app/modules/slack/slack.py index 3b04b705a..807d4b392 100644 --- a/app/modules/slack/slack.py +++ b/app/modules/slack/slack.py @@ -411,15 +411,18 @@ class Slack: result = None target_channel = original_chat_id if original_message_id and original_chat_id else channel - for index, batch in enumerate(self._block_batches(blocks)): + batches = [(message_text[:1000], batch) for batch in self._block_batches(blocks)] if blocks else [ + (message_text[offset:offset + 4000], []) for offset in range(0, len(message_text), 4000) + ] + for index, (plain_text, batch) in enumerate(batches): if index == 0 and original_message_id and original_chat_id: result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, - text=message_text[:1000], blocks=batch or [], + text=plain_text, blocks=batch or [], ) else: result = self._client.chat_postMessage( - channel=target_channel, text=message_text[:1000], blocks=batch, mrkdwn=True, + channel=target_channel, text=plain_text, blocks=batch, mrkdwn=True, ) return True, result except Exception as msg_e: @@ -617,11 +620,9 @@ class Slack: blocks.append({ "type": "divider" }) - index = 1 - # 如果有自定义按钮,先添加所有媒体项,然后添加统一的按钮 if buttons: - for media in medias: + for index, media in enumerate(medias, start=1): if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ @@ -646,7 +647,6 @@ class Slack: } } ) - index += 1 for button_row in buttons: elements = [] @@ -679,7 +679,7 @@ class Slack: "elements": elements }) else: - for media in medias: + for index, media in enumerate(medias, start=1): if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ @@ -721,7 +721,6 @@ class Slack: ] } ) - index += 1 if original_message_id and original_chat_id: result = self._client.chat_update( diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index 9499c1827..696d9f7ce 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -7,6 +7,7 @@ import html as html_utils import re import threading import time +from html.parser import HTMLParser from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Union, cast @@ -60,6 +61,99 @@ TELEGRAM_PARSE_MODE_ALIASES = { } +class _HtmlMessageSplitter(HTMLParser): + """ + 按可见文本UTF-16长度拆分Telegram HTML,分块处关闭并重开仍生效的标签。 + + :ivar chunks: 各块可独立解析的HTML正文 + :ivar _parts: 当前块的原始标记与文本片段 + :ivar _stack: 仍生效的标签名与原始起始标记,保留链接等属性 + :ivar _size: 当前块解析实体后的UTF-16长度 + :ivar _limit: 每块允许的最大可见长度 + """ + + def __init__(self, limit: int) -> None: + """ + 保留实体原文,使分块不会切开实体或丢失换行。 + + :param limit: 每块可见文本UTF-16长度上限,必须为正数 + """ + super().__init__(convert_charrefs=False) + self.chunks: list[str] = [] + self._parts: list[str] = [] + self._stack: list[tuple[str, str]] = [] + self._size = 0 + self._limit = max(1, limit) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + """ + 保存完整起始标签及属性,供后续分块重新打开。 + """ + raw = self.get_starttag_text() or f"<{tag}>" + self._parts.append(raw) + self._stack.append((tag, raw)) + + def handle_endtag(self, tag: str) -> None: + """ + 结束当前标签,输入须遵守Telegram原有合法HTML合同。 + """ + self._parts.append(f"") + if self._stack and self._stack[-1][0] == tag: + self._stack.pop() + + def handle_data(self, data: str) -> None: + """ + 仅在Unicode字符之间拆分正文,非BMP字符占两个UTF-16单元。 + """ + for character in data: + self._append_content(character, 2 if ord(character) > 0xFFFF else 1) + + def handle_entityref(self, name: str) -> None: + """ + 作为不可拆分的整体追加命名实体。 + """ + self._append_entity(f"&{name};") + + def handle_charref(self, name: str) -> None: + """ + 作为不可拆分的整体追加数字实体。 + """ + self._append_entity(f"&#{name};") + + def _append_entity(self, raw: str) -> None: + """ + 按解码后的可见长度核对实体边界,发送时保留其原始编码。 + """ + self._append_content(raw, len(html_utils.unescape(raw).encode("utf-16-le")) // 2) + + def _append_content(self, raw: str, size: int) -> None: + """ + 在当前内容超限前结算并重新打开标签,不重复可见正文。 + """ + if self._size and self._size + size > self._limit: + self._flush() + self._parts.append(raw) + self._size += size + + def _flush(self) -> None: + """ + 结算可独立解析的HTML块,并恢复下一块所需的标签上下文。 + """ + closing = "".join(f"" for tag, _raw in reversed(self._stack)) + self.chunks.append("".join(self._parts) + closing) + self._parts = [raw for _tag, raw in self._stack] + self._size = 0 + + def finish(self) -> List[str]: + """ + 返回包含最后一块的完整结果,不制造只有重复标签的空块。 + """ + self.close() + if self._size: + self._flush() + return self.chunks + + class RetryException(Exception): """ Telegram 消息发送重试异常。 @@ -2110,20 +2204,25 @@ class Telegram: **kwargs: Any, ) -> Optional[JsonDict]: """ - 按字符上限拆分普通或 HTML 正文并发送各分块。 + 按可见长度拆分普通或HTML正文并发送各分块,HTML保持实体和标签完整。 键盘仅附在第一块。非限流异常由装饰器重试整个方法,已成功的分块可能再次发送; 限流时记录会话冷却并停止立即重试。 :param image: 可选图片字节,仅用于第一块 - :param caption: 完整正文,HTML 标签不在此方法重新配平 + :param caption: 完整正文,HTML分块关闭并重新打开跨块标签 :param caption_limit: 每块字符上限;图片说明通常为 1024,纯文本为 4096 :param disable_web_page_preview: 是否关闭文本分块的链接预览 :param kwargs: 目标会话、解析格式、话题、回复和键盘参数 :return: 最后一条发送的 Bot API 消息 """ reply_markup = kwargs.pop("reply_markup", None) - chunks = self._split_plain_text(caption, caption_limit) + if kwargs.get("parse_mode") == TELEGRAM_PARSE_MODE_HTML: + splitter = _HtmlMessageSplitter(caption_limit) + splitter.feed(caption) + chunks = splitter.finish() + else: + chunks = self._split_plain_text(caption, caption_limit) ret = None try: for index, chunk in enumerate(chunks): diff --git a/app/modules/wechat/wechatbot.py b/app/modules/wechat/wechatbot.py index 20d4dfa78..7ac9dbdaa 100644 --- a/app/modules/wechat/wechatbot.py +++ b/app/modules/wechat/wechatbot.py @@ -633,9 +633,9 @@ class WeChatBot: def _handle_callback_message(self, payload: Dict[str, Any]) -> None: """ - 接收私聊文本或图片,记录互动用户并检查管理员命令权限。 + 接收私聊文本、图片、语音或文件,记录互动用户并检查管理员命令权限。 - 群聊及无文字、无图片的消息会忽略;通过检查的原始报文交给统一消息入口。 + 群聊及无支持内容的消息会忽略;通过检查的原始报文交给统一消息入口。 :param payload: 完整机器人消息回调报文 """ @@ -654,7 +654,10 @@ class WeChatBot: if text: text = re.sub(r"@\S+", "", text).strip() - if not text and not images: + has_media = body.get("msgtype") == "voice" or ( + body.get("msgtype") == "file" and bool((body.get("file") or {}).get("download_url")) + ) + if not text and not images and not has_media: return self._remember_target(sender) diff --git a/app/modules/wechatclawbot/wechatclawbot.py b/app/modules/wechatclawbot/wechatclawbot.py index b2677ad35..da90b685f 100644 --- a/app/modules/wechatclawbot/wechatclawbot.py +++ b/app/modules/wechatclawbot/wechatclawbot.py @@ -2334,50 +2334,25 @@ class WechatClawBot: mime_type: Optional[str] = None, ) -> bytes: """ - 尝试 ECB、CBC 及去填充候选,以已知文件头选择可用内容。 - - 此兼容逻辑只做格式启发式判断,不保证任意文件都能判定解密是否成功。 + 按资源协议使用AES-ECB解密并校验PKCS7填充,不根据文件内容猜测加密方式。 :param content: 下载到的原始字节 :param aeskey: 可选编码后的 AES 密钥 - :param mime_type: 优先匹配的文件 MIME 类型 - :return: 找到有效文件头的候选字节;无密钥、解密失败或无法识别时保留原始内容 + :param mime_type: 保留调用兼容的文件MIME类型,不参与密码协议判断 + :return: 完整明文;未声明密钥时原样返回下载内容 + :raises ValueError: 已声明密钥但密钥、密文或填充无效 """ + del mime_type + if not aeskey: + return content aes_bytes = cls._decode_aes_key(aeskey) if not aes_bytes or not content: - return content - candidates = [content] - try: - candidates.append(AES.new(aes_bytes, AES.MODE_ECB).decrypt(content)) - except Exception: - pass - try: - candidates.append(AES.new(aes_bytes, AES.MODE_CBC, aes_bytes[:16]).decrypt(content)) - except Exception: - pass - normalized_candidates: List[bytes] = [] - for candidate in candidates: - normalized_candidates.append(candidate) - unpadded = cls._unpad_bytes(candidate) - if unpadded: - normalized_candidates.append(unpadded) - preferred_prefixes = [] - mime_value = (mime_type or "").lower() - if mime_value.startswith("image/png"): - preferred_prefixes.append(b"\x89PNG\r\n\x1a\n") - elif mime_value.startswith("image/jpeg"): - preferred_prefixes.append(b"\xff\xd8\xff") - elif mime_value.startswith("image/gif"): - preferred_prefixes.extend([b"GIF87a", b"GIF89a"]) - elif mime_value.startswith("audio/"): - preferred_prefixes.extend([b"#!AMR", b"ID3", b"OggS", b"RIFF"]) - for candidate in normalized_candidates: - if any(candidate.startswith(prefix) for prefix in preferred_prefixes): - return candidate - for candidate in normalized_candidates: - if candidate.startswith((b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a", b"PK\x03\x04", b"#!AMR", b"ID3", b"OggS", b"RIFF")): - return candidate - return content + raise ValueError("附件密钥或密文无效") + plaintext = AES.new(aes_bytes, AES.MODE_ECB).decrypt(content) + unpadded = cls._unpad_bytes(plaintext) + if unpadded is None: + raise ValueError("附件密文填充校验失败") + return unpadded @staticmethod def _guess_binary_mime(content: bytes, default: str = "application/octet-stream") -> str: @@ -2415,6 +2390,10 @@ class WechatClawBot: payload = self._decode_ref_payload(ref=ref, kind=kind) if not payload: return None + encryption_type = payload.get("encrypt_type") + if encryption_type not in (None, 1, "1") or (encryption_type is not None and not payload.get("aeskey")): + logger.warning("附件使用不支持的加密方式或缺少密钥,已拒绝下载内容") + return None download_url = payload.get("url") if not download_url: return None @@ -2425,11 +2404,15 @@ class WechatClawBot: return None if not resp or not resp.content: return None - content = self._decrypt_if_needed( - content=resp.content, - aeskey=payload.get("aeskey"), - mime_type=payload.get("mime_type"), - ) + try: + content = self._decrypt_if_needed( + content=resp.content, + aeskey=payload.get("aeskey"), + mime_type=payload.get("mime_type"), + ) + except ValueError: + logger.warning("附件密文或密钥无效,已拒绝返回未解密内容") + return None mime_type = payload.get("mime_type") or self._guess_binary_mime( content, (resp.headers.get("Content-Type") or "application/octet-stream").split( diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 9af986c58..b3ab5110e 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -26,6 +26,15 @@ 升级前历史没有来源标记,界面显示“未记录”,不会按当前订阅设置追溯推断。 普通订阅的首次提交与洗版订阅补缺在活动中都标记为首次订阅。 +### 通知配置与交互身份 + +`POST /api/v1/notification/config` 保存通知渠道时要求名称和稳定 `id` 各自唯一。 +改名应保留原 `id`;复制为新的渠道实例应使用新的 `id`,避免共享登录凭据。 +通过消息渠道发起普通重启时,完成回执保留发起时的 `source` 配置。 + +Discord 仅指定 `userid` 的定向通知使用私聊;无法私聊时返回失败,不转发公共频道。 +`/skills` 安装和删除按展示时的条目身份操作,刷新或翻页后的旧按钮会失效,应使用最新列表重新选择。 + ### 文件整理与恢复 整理成功历史保存 `episode_group`;批量复用历史时仅恢复所有记录共同的剧集组, diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json index c224e968c..c4444dbc6 100644 --- a/tests/fixtures/architecture/complexity-baseline.json +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -85,7 +85,6 @@ "app/domain/projection/anilist.py:project": 17, "app/domain/projection/douban.py:project": 25, "app/modules/discord/discord.py:Discord._build_embed": 26, - "app/modules/discord/discord.py:Discord._resolve_channel": 20, "app/modules/emby/emby.py:Emby.get_tv_episodes": 18, "app/modules/emby/emby.py:Emby.get_webhook_message": 18, "app/modules/filemanager/storages/alipan.py:AliPan.upload": 16, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 42cdd3090..4dbafedfa 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -481,14 +481,13 @@ ] }, { + "source": "app.modules.discord.discord", + "target": "discord", + "kind": "network_sdk", "bindings": [ "from:discord.app_commands as app_commands", "import:discord as discord" ], - "fingerprint": "a46a1d6da767a51eb4e1085de5fb936490fc8360c2f3a814635d408468c1b150", - "kind": "network_sdk", - "source": "app.modules.discord.discord", - "target": "discord", "uses": [ "Discord.__init__|call:Client", "Discord.__init__|call:Intents.default", @@ -515,8 +514,6 @@ "Discord._register_events|call:event", "Discord._resolve_channel|call:fetch_channel", "Discord._resolve_channel|call:fetch_channel", - "Discord._resolve_channel|call:fetch_channel", - "Discord._resolve_channel|call:get_channel", "Discord._resolve_channel|call:get_channel", "Discord._resolve_channel|call:get_channel", "Discord._resolve_channel|call:get_guild", @@ -531,7 +528,8 @@ "Discord._sync_registered_commands|call:sync", "Discord._sync_registered_commands|call:wait_until_ready", "Discord.stop|call:close" - ] + ], + "fingerprint": "2761ef9a9e5a805bfb20ea832c5d34edea12db21ca3eb27c3db7ce512a5ee7f5" }, { "bindings": [ diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 8aef68df5..ed24d2e21 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -138,7 +138,7 @@ {"source": "app.agent.llm.discovery", "target": "google.genai", "kind": "network_sdk", "fingerprint": "5b87a7bb210b521bd21971fb6e3d19ca450658548670106e3c756c0ce3a83cad"}, {"source": "app.agent.llm.discovery", "target": "openai", "kind": "network_sdk", "fingerprint": "fff154461ff3fdfdf1daea0530dcb68589d6ad7a67f3966e3b86338b483e6dfd"}, {"source": "app.agent.tools.impl.search_web", "target": "ddgs", "kind": "network_sdk", "fingerprint": "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b"}, - {"source": "app.modules.discord.discord", "target": "discord", "kind": "network_sdk", "fingerprint": "a46a1d6da767a51eb4e1085de5fb936490fc8360c2f3a814635d408468c1b150"}, + {"source": "app.modules.discord.discord", "target": "discord", "kind": "network_sdk", "fingerprint": "2761ef9a9e5a805bfb20ea832c5d34edea12db21ca3eb27c3db7ce512a5ee7f5"}, {"source": "app.modules.filemanager.storages.smb", "target": "smbclient", "kind": "network_sdk", "fingerprint": "37619f050b03598fa559c6ae2df94bda6adf7cffd1cc42d9db9059f1fb41af47"}, {"source": "app.modules.filemanager.storages.u115", "target": "oss2", "kind": "network_sdk", "fingerprint": "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae"}, {"source": "app.modules.plex.plex", "target": "plexapi", "kind": "network_sdk", "fingerprint": "6109de36f963b5c6193ed60941a88856cbee9c37303dedf13da3a3be94478543"}, diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index 63b6fd0cc..4adb89e6b 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -120,8 +120,8 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { ("app.cli", "urllib.request"): "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b", ("app.doctor.checks", "socket.create_connection"): "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6", ("app.doctor.checks", "urllib.request"): "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354", - # 事件装饰器改为等价的显式注册;未增加事件或网络调用。 - ("app.modules.discord.discord", "discord"): "a46a1d6da767a51eb4e1085de5fb936490fc8360c2f3a814635d408468c1b150", + # 删除用户历史频道回落的get_channel/fetch_channel各一次;未新增网络能力或其它SDK调用。 + ("app.modules.discord.discord", "discord"): "2761ef9a9e5a805bfb20ea832c5d34edea12db21ca3eb27c3db7ce512a5ee7f5", ("app.modules.emby.emby", "requests"): "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31", ("app.adapters.network.feishu", "websocket"): "95aca65eed288e0c7654ab49aad6c535d9313d5e38c44b1cb1768dcdfc772453", ("app.modules.filemanager.storages.smb", "smbclient"): "37619f050b03598fa559c6ae2df94bda6adf7cffd1cc42d9db9059f1fb41af47", diff --git a/tests/test_messaging_review_attachments.py b/tests/test_messaging_review_attachments.py new file mode 100644 index 000000000..21e32d89f --- /dev/null +++ b/tests/test_messaging_review_attachments.py @@ -0,0 +1,189 @@ +""" +通过真实网关回调、附件密码协议及交互入口验证消息修复,隔离所有外部发送。 +""" + +import json +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from lxml import etree + +from app.agent.skills.registry import SkillInfo +from app.application.messaging.media import media_interaction_manager +from app.application.messaging.skill import PendingSkillInteraction, SkillInteractionHandler, skill_interaction_manager +from app.chain.interaction import MediaInteractionChain +from app.domain.context import Context, MediaInfo, TorrentInfo +from app.modules.qqbot import gateway +from app.modules.telegram.telegram import Telegram +from app.modules.wechat.wechatbot import WeChatBot +from app.modules.wechatclawbot.wechatclawbot import ILinkClient, WechatClawBot +from app.schemas.types import NotificationChannel + + +@pytest.mark.parametrize("event", ["C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"]) +@pytest.mark.parametrize("text", ["", "Attached review"]) +def test_qq_gateway_preserves_attachments(monkeypatch, event, text): + """ + 两种网关事件均转交纯附件及带文字附件,实际回调处理保留原附件字段。 + """ + stop = threading.Event() + attachments = [{"url": "https://files.invalid/review.pdf", "content_type": "application/pdf"}] + received = [] + message = {"op": 0, "t": event, "d": {"id": "M1", "content": text, + "author": {"user_openid": "U1", "member_openid": "U1"}, "group_openid": "G1", + "attachments": attachments}} + + def websocket(_url, **callbacks): + """ + 仅把固定事件交回真实网关处理函数,不创建网络连接。 + """ + def receive(**_kwargs): + """ + 将固定附件事件交给网关回调并结束隔离的接收循环。 + """ + callbacks["on_message"](None, json.dumps(message)) + stop.set() + return SimpleNamespace(run_forever=receive) + + monkeypatch.setattr(gateway.websocket, "WebSocketApp", websocket) + gateway.run_gateway("id", "secret", "review", lambda *_args: "token", + lambda _token: "wss://gateway.invalid", received.append, stop, []) + assert received[0]["attachments"] == attachments + assert received[0]["content"] == text + + +def test_wechat_bot_gateway_forwards_supported_file(monkeypatch): + """ + 企业微信支持的文件报文经过真实网关过滤仍能进入统一入口。 + """ + client = object.__new__(WeChatBot) + client._config_name = "review" + client._admins = [] + forwarded = Mock() + monkeypatch.setattr(client, "_remember_target", Mock()) + monkeypatch.setattr(client, "_forward_to_message_chain", forwarded) + payload = {"body": {"from": {"userid": "U1"}, "msgtype": "file", "chattype": "single", + "file": {"download_url": "https://files.invalid/review.pdf", "name": "review.pdf"}}} + client._handle_callback_message(payload) + forwarded.assert_called_once_with(payload) + + +@pytest.mark.parametrize("content,mime", [(b"%PDF-1.7 review", "application/pdf"), + (b"plain review text", "text/plain"), (b"\x89PNG\r\n\x1a\nreview", "image/png")]) +def test_clawbot_roundtrips_protocol_cipher_without_file_header_guess(content, mime): + """ + 真实发送端协议生成的密文,接收端必须完整解密并去填充,未知文件头也支持。 + """ + key = bytes(range(16)) + ciphertext = ILinkClient._encrypt_aes_ecb(content, key) + assert WechatClawBot._decrypt_if_needed(ciphertext, ILinkClient._encode_media_aes_key(key), mime) == content + + +def test_clawbot_invalid_cipher_is_not_returned_as_plaintext(): + """ + 已声明密钥但长度无效的密文必须拒绝,不能当作合法附件返回。 + """ + with pytest.raises(ValueError): + WechatClawBot._decrypt_if_needed(b"broken", bytes(range(16)).hex(), "application/pdf") + + +@pytest.mark.parametrize("limit", [1024, 4096]) +def test_telegram_long_html_remains_valid_and_complete(limit): + """ + 跨长文本边界的嵌套标签与实体必须合法配平且不丢失换行。 + """ + client = object.__new__(Telegram) + client._bot = Mock() + body = "😀&正文\n" * 1000 + html = '' + body + '' + client._Telegram__send_long_plain_message(image=None, caption=html, caption_limit=limit, + chat_id="U1", parse_mode="HTML") + texts = [] + for call in client._api.send_message.call_args_list: + root = etree.fromstring(("" + call.kwargs["text"] + "").encode()) + text = "".join(root.itertext()) + assert len(text.encode("utf-16-le")) // 2 <= limit + texts.append(text) + assert "".join(texts) == ("😀&正文\n" * 1000) + + +@pytest.mark.parametrize("market", [False, True]) +def test_skill_actions_bind_displayed_item_identity(monkeypatch, market): + """ + 列表显示后删除首项,再按原序号操作第二项,目标仍是展示时的技能。 + """ + skills = [SkillInfo(id=key, name=key, description="review", removable=True) for key in ("A", "B", "C")] + catalog = SimpleNamespace(list_local_skills=lambda: list(skills), list_market_skills=lambda **_kw: list(skills), + remove_local_skill=Mock(return_value=(True, "removed")), install_market_skill=Mock(return_value=(True, "installed"))) + handler = SkillInteractionHandler(messenger=Mock(), skill_catalog=catalog) + request = PendingSkillInteraction("request", "U1", NotificationChannel.Telegram, "review", "user") + if market: + handler._build_market_view(request) + else: + handler._build_installed_view(request) + skills.pop(0) + if market: + assert handler._install_market_skill(request, 2)[0] + assert catalog.install_market_skill.call_args.args[0].id == "B" + else: + assert handler._remove_local_skill(request, 2)[0] + catalog.remove_local_skill.assert_called_once_with("B") + + +def test_stale_media_button_cannot_select_torrent_as_media(monkeypatch): + """ + 已进入资源阶段的旧媒体按钮不得读取新列表或覆盖已选媒体身份。 + """ + request = media_interaction_manager.create_or_replace("U1", NotificationChannel.Telegram, "review", "user", + action="Search", keyword="Review", items=[]) + request.phase = "torrent" + selected = MediaInfo(title="Selected") + request.current_media = selected + request.items = [Context(torrent_info=TorrentInfo(title="Resource"))] + chain = object.__new__(MediaInteractionChain) + monkeypatch.setattr(chain, "post_message", Mock()) + search = Mock() + monkeypatch.setattr(chain, "_search_media_resources", search) + try: + assert chain.handle_callback_interaction(f"media:{request.request_id}:select:1", + NotificationChannel.Telegram, "review", "U1", "user") + assert request.current_media is selected + search.assert_not_called() + finally: + media_interaction_manager.clear() + + +@pytest.mark.parametrize("encrypt_type,key", [(1, None), (2, bytes(range(16)).hex())]) +def test_clawbot_declared_encryption_requires_supported_protocol(monkeypatch, encrypt_type, key): + """ + 已声明加密但缺密钥或模式不支持时,真实附件下载入口必须拒绝返回内容。 + """ + client = object.__new__(WechatClawBot) + payload = {"url": "https://files.invalid/encrypted", "aeskey": key, "encrypt_type": encrypt_type} + monkeypatch.setattr(client, "_decode_ref_payload", lambda **_kw: payload) + ciphertext = ILinkClient._encrypt_aes_ecb(b"%PDF review", bytes(range(16))) + monkeypatch.setattr("app.modules.wechatclawbot.wechatclawbot.RequestUtils", lambda **_kw: SimpleNamespace( + get_res=lambda _url: SimpleNamespace(content=ciphertext, headers={}))) + assert client._download_ref_bytes("wxclaw://file/review", "file") is None + + +def test_skill_old_render_button_cannot_operate_new_page(): + """ + 同一会话刷新列表后,旧按钮携带的展示标识必须过期。 + """ + catalog = SimpleNamespace(list_local_skills=lambda: [ + SkillInfo(id="A", name="A", description="review", removable=True)], remove_local_skill=Mock()) + handler = SkillInteractionHandler(messenger=Mock(), skill_catalog=catalog) + request = skill_interaction_manager.create_or_replace("U1", NotificationChannel.Telegram, "review", "user") + request.view = "installed" + try: + _, _, buttons = handler._build_installed_view(request) + callback = next(button["callback_data"] for row in buttons for button in row if button["text"] == "删除 1") + assert len(callback.encode()) <= 64 + handler._build_installed_view(request) + assert handler.handle_callback_interaction(callback, NotificationChannel.Telegram, "review", "U1", "user") + catalog.remove_local_skill.assert_not_called() + finally: + skill_interaction_manager.clear() diff --git a/tests/test_messaging_review_boundaries.py b/tests/test_messaging_review_boundaries.py new file mode 100644 index 000000000..f4a9a0de5 --- /dev/null +++ b/tests/test_messaging_review_boundaries.py @@ -0,0 +1,115 @@ +""" +独立复核消息修复的真实回调、附件解密和图片说明分片边界。 +""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from Crypto.Cipher import AES +from lxml import etree + +from app.agent.skills.registry import SkillInfo +from app.application.messaging.skill import SkillInteractionHandler, skill_interaction_manager +from app.modules.telegram.telegram import Telegram +from app.modules.wechatclawbot.wechatclawbot import ILinkClient, WechatClawBot +from app.schemas.types import NotificationChannel + + +@pytest.mark.parametrize("plaintext", [b"x" * 15 + b"\x00", b"x" * 14 + b"\x01\x02"]) +def test_claw_attachment_rejects_full_length_cipher_with_invalid_padding(monkeypatch, plaintext): + """ + 长度合法但PKCS7尾部非法的密文不得从真实资源引用入口返回。 + """ + key = bytes(range(16)) + client = object.__new__(WechatClawBot) + ref = ILinkClient._encode_ref_payload("file", { + "url": "https://files.invalid/review", "aeskey": ILinkClient._encode_media_aes_key(key), + "encrypt_type": 1, "name": "review.bin", + }) + ciphertext = AES.new(key, AES.MODE_ECB).encrypt(plaintext) + monkeypatch.setattr("app.modules.wechatclawbot.wechatclawbot.RequestUtils", lambda **_kwargs: SimpleNamespace( + get_res=lambda _url: SimpleNamespace(content=ciphertext, headers={}), + )) + assert client._download_ref_bytes(ref, "file") is None + + +@pytest.mark.parametrize("encrypted", [False, True]) +def test_claw_attachment_real_reference_preserves_complete_content_and_metadata(monkeypatch, encrypted): + """ + 真实内部引用同时支持未加密文件和协议密文,并完整保留名称及MIME。 + """ + key = bytes(range(16)) + content = bytes(range(64)) + b"\x00\x01\x02" + client = object.__new__(WechatClawBot) + ref = ILinkClient._encode_ref_payload("file", { + "url": "https://files.invalid/review", "name": "review.bin", "mime_type": "application/custom", + "aeskey": ILinkClient._encode_media_aes_key(key) if encrypted else None, + "encrypt_type": 1 if encrypted else None, + }) + downloaded = ILinkClient._encrypt_aes_ecb(content, key) if encrypted else content + monkeypatch.setattr("app.modules.wechatclawbot.wechatclawbot.RequestUtils", lambda **_kwargs: SimpleNamespace( + get_res=lambda _url: SimpleNamespace(content=downloaded, headers={}), + )) + assert client._download_ref_bytes(ref, "file") == (content, "application/custom", "review.bin") + + +@pytest.mark.parametrize("market", [False, True]) +def test_skill_real_callback_keeps_displayed_identity_after_reordering(market): + """ + 合法展示按钮经过公开回调入口仍定位原第二项,其他用户不能复用该按钮。 + """ + skills = [SkillInfo(id=name, name=name, description="复核技能", removable=True) for name in ("A", "B", "C")] + catalog = SimpleNamespace(list_local_skills=lambda: list(skills), + list_market_skills=lambda **_kwargs: list(skills), + remove_local_skill=Mock(return_value=(True, "已删除")), + install_market_skill=Mock(return_value=(True, "已安装"))) + handler = SkillInteractionHandler(messenger=Mock(), skill_catalog=catalog) + request = skill_interaction_manager.create_or_replace("review-owner", NotificationChannel.Telegram, + "review-source", "review") + request.view = "market" if market else "installed" + action = "install" if market else "remove" + try: + if market: + _, _, buttons = handler._build_market_view(request) + else: + _, _, buttons = handler._build_installed_view(request) + callback = next(button["callback_data"] for row in buttons for button in row + if f":{action}:2:" in button.get("callback_data", "")) + assert handler.handle_callback_interaction(callback, NotificationChannel.Telegram, + "review-source", "other-user", "other") + catalog.install_market_skill.assert_not_called() + catalog.remove_local_skill.assert_not_called() + skills.pop(0) + assert handler.handle_callback_interaction(callback, NotificationChannel.Telegram, + "review-source", "review-owner", "review") + if market: + selected = catalog.install_market_skill.call_args.args[0] + assert selected.id == "B" and selected is not skills[0] + else: + catalog.remove_local_skill.assert_called_once_with("B") + finally: + skill_interaction_manager.clear() + + +def test_telegram_photo_caption_chunks_preserve_numeric_entities_and_first_keyboard(): + """ + 图片首块与后续文本保持合法HTML和UTF16上限,键盘只附在首条。 + """ + client = object.__new__(Telegram) + client._bot = Mock() + body = "😀&<正文\n" * 500 + markup = {"inline_keyboard": [[{"text": "复核", "callback_data": "review"}]]} + client._Telegram__send_long_plain_message(image=b"image", caption=f"{body}", + caption_limit=1024, chat_id="review", parse_mode="HTML", reply_markup=markup) + photo = client._api.send_photo.call_args.kwargs + messages = [call.kwargs for call in client._api.send_message.call_args_list] + assert messages and photo["reply_markup"] == markup + assert all(message["reply_markup"] is None for message in messages) + restored = [] + for chunk in [photo["caption"], *(message["text"] for message in messages)]: + root = etree.fromstring(("" + chunk + "").encode()) + visible = "".join(root.itertext()) + assert len(visible.encode("utf-16-le")) // 2 <= 1024 + restored.append(visible) + assert "".join(restored) == "😀&<正文\n" * 500 diff --git a/tests/test_messaging_review_regressions.py b/tests/test_messaging_review_regressions.py new file mode 100644 index 000000000..f3ce9c6ad --- /dev/null +++ b/tests/test_messaging_review_regressions.py @@ -0,0 +1,160 @@ +""" +消息渠道审查发现的真实路由、身份与文本边界回归,外部渠道均用替身隔离。 +""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.api.endpoints import notification as notification_endpoint +from app.api.endpoints.notification import _normalize_configs +from app.chain.music_interaction.music import resolve_media_action +from app.chain.system import SystemChain +from app.domain.context import MediaInfo +from app.modules.discord import DiscordModule +from app.modules.discord.discord import Discord +from app.modules.slack import SlackModule +from app.modules.slack.slack import Slack +from app.schemas.system import NotificationConf +from app.schemas.types import NotificationChannel + + +@pytest.mark.parametrize("available", [True, False]) +def test_discord_explicit_recipient_never_uses_broadcast(available): + """ + 指定用户的通知只能到私聊;DM不可用时明确失败,不回落公共频道。 + """ + client = object.__new__(Discord) + private = object() if available else None + client._broadcast_channel = object() + client._channel_id = 456 + client._guild_id = None + client._get_user_chat_id = Mock(return_value=None) + client._get_dm_channel = AsyncMock(return_value=private) + client._client = Mock() + assert asyncio.run(client._resolve_channel(userid="123")) is private + client._get_dm_channel.assert_awaited_once_with("123") + + +@pytest.mark.parametrize("result,expected", [((False, None), False), ((True, {}), True), (False, False)]) +def test_discord_edit_reports_tuple_status(monkeypatch, result, expected): + """ + 编辑返回失败tuple时不能凭容器真值误报成功。 + """ + module = object.__new__(DiscordModule) + module._channel = NotificationChannel.Discord + monkeypatch.setattr(module, "get_configs", lambda: {"review": SimpleNamespace(name="review")}) + monkeypatch.setattr(module, "get_instance", lambda _name: SimpleNamespace(send_msg=lambda **_kw: result)) + assert module.edit_message(NotificationChannel.Discord, "review", "1", "2", "text") is expected + + +def test_slack_slash_command_preserves_arguments(monkeypatch): + """ + 原生命令和text参数一起传给统一命令处理器。 + """ + module = object.__new__(SlackModule) + monkeypatch.setattr(module, "get_config", lambda _source: SimpleNamespace(name="review", config={})) + monkeypatch.setattr(module, "get_instance", lambda _name: Mock()) + monkeypatch.setattr(module, "_should_reject_admin_command", lambda *_args: False) + message = module.message_parser("review", json.dumps({"command": "/search", "text": "Review title", + "user_id": "U1", "user_name": "review", "channel_id": "C1"}), None, None) + assert message.text == "/search Review title" + + +def test_slack_media_buttons_keep_original_positions(): + """ + 前项无海报时,后项卡片仍对应原列表第二项。 + """ + client = object.__new__(Slack) + client._client = Mock() + media = [MediaInfo(title="No poster"), MediaInfo(title="With poster", poster_path="https://img.invalid/p.jpg")] + assert client.send_medias_msg(media, userid="U1", title="Review") + blocks = client._client.chat_postMessage.call_args.kwargs["blocks"] + values = [button["value"] for block in blocks if block["type"] == "actions" for button in block["elements"]] + assert values[-1] == "2" + + +@pytest.mark.parametrize("repeat", [800, 3000]) +def test_slack_plain_text_preserves_complete_long_body(repeat): + """ + 没有富文本blocks的普通通知也必须完整发送,长文本不能截断。 + """ + client = object.__new__(Slack) + client._client = Mock() + body = "完整正文" * repeat + assert client.send_msg(title="Review", text=body, userid="U1")[0] + sent = [call.kwargs for call in client._client.chat_postMessage.call_args_list] + assert "".join(item["text"] for item in sent) == "Review\n" + body + assert all(len(item["text"]) <= 4000 for item in sent) + + +def test_notification_config_ids_are_unique(): + """ + 不同显示名不能共享同一稳定身份,以免复用另一配置的登录凭据。 + """ + with pytest.raises(ValueError, match="身份|ID|id"): + _normalize_configs([NotificationConf(id="stable", name="A", type="wechatclawbot"), + NotificationConf(id="stable", name="B", type="wechatclawbot")]) + + +@pytest.mark.asyncio +async def test_notification_config_can_repair_legacy_duplicate_ids(monkeypatch): + """ + 拒绝新重复身份的同时,允许管理员把旧重复数据修复为唯一身份。 + """ + service = SimpleNamespace(get=lambda _key: [ + {"id": "legacy-duplicate", "name": "A", "type": "wechatclawbot"}, + {"id": "legacy-duplicate", "name": "B", "type": "wechatclawbot"}], async_set=AsyncMock(return_value=True)) + monkeypatch.setattr(notification_endpoint, "get_configured_system_config", lambda: service) + reconcile = Mock(return_value={"success": True}) + monkeypatch.setattr(notification_endpoint, "NotificationChain", lambda: SimpleNamespace(manage_channel=reconcile)) + response = await notification_endpoint.save_config([ + NotificationConf(id="unique-A", name="A", type="wechatclawbot"), + NotificationConf(id="unique-B", name="B", type="wechatclawbot")], None) + assert response.success + service.async_set.assert_awaited_once() + + +def test_discord_invalid_explicit_chat_does_not_fall_back_to_broadcast(): + """ + 指定原频道不可访问时拒绝发送,不能泄漏到默认广播频道。 + """ + client = object.__new__(Discord) + client._broadcast_channel = object() + client._channel_id = 456 + client._guild_id = None + client._client = SimpleNamespace(get_channel=lambda _id: None, + fetch_channel=AsyncMock(side_effect=RuntimeError("channel unavailable"))) + assert asyncio.run(client._resolve_channel(chat_id="789")) is None + + +def test_remote_restart_preserves_source_in_cache_and_receipt(monkeypatch): + """ + 普通重启的前后通知保持同一配置来源。 + """ + chain = object.__new__(SystemChain) + messages = [] + saved = {} + monkeypatch.setattr(chain, "post_message", messages.append) + monkeypatch.setattr(chain, "save_cache", lambda data, key: saved.update({key: data})) + monkeypatch.setattr(chain, "load_cache", lambda key: saved.get(key)) + monkeypatch.setattr(chain, "remove_cache", lambda key: saved.pop(key, None)) + monkeypatch.setattr(chain, "backup_plugins", lambda: None) + monkeypatch.setattr(chain, "_SystemChain__get_version_message", lambda: "review") + monkeypatch.setattr("app.chain.system.SystemHelper.restart", lambda: None) + chain.restart(NotificationChannel.Telegram, "U1", source="specific-config") + assert saved[chain._restart_file]["source"] == "specific-config" + chain.restart_finish() + assert [message.source for message in messages] == ["specific-config", "specific-config"] + + +@pytest.mark.parametrize("prefix", ["搜索", "下载", "订阅", "洗版"]) +def test_media_command_removes_only_leading_command(prefix): + """ + 作品名中的同名命令词必须作为检索词保留。 + """ + _, query = resolve_media_action(f"{prefix}:搜索中的订阅与下载洗版") + assert query == "搜索中的订阅与下载洗版" diff --git a/tests/test_skills_command.py b/tests/test_skills_command.py index ce6df919f..6d99ab53c 100644 --- a/tests/test_skills_command.py +++ b/tests/test_skills_command.py @@ -641,7 +641,7 @@ def test_skills_chain_installed_view_builds_remove_buttons() -> None: assert "builtin" in text assert "custom" in text assert buttons - assert {"text": "删除 2", "callback_data": f"skills:{request.request_id}:remove:2"} in [ + assert {"text": "删除 2", "callback_data": f"skills:{request.request_id}:remove:2:{request.selection_id}"} in [ button for row in buttons for button in row ]