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"{tag}>")
+ 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"{tag}>" 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
]