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 <jxxghp@gmail.com>
This commit is contained in:
Aqr-K
2026-10-23 07:49:53 +08:00
committed by GitHub
parent daa2729bdd
commit fc0eed08cc
22 changed files with 708 additions and 149 deletions

View File

@@ -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="通知配置保存失败")

View File

@@ -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}",
}
]
)

View File

@@ -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,

View File

@@ -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):

View File

@@ -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))

View File

@@ -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

View File

@@ -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]:

View File

@@ -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},

View File

@@ -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):

View File

@@ -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(

View File

@@ -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):

View File

@@ -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)

View File

@@ -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(

View File

@@ -26,6 +26,15 @@
升级前历史没有来源标记,界面显示“未记录”,不会按当前订阅设置追溯推断。
普通订阅的首次提交与洗版订阅补缺在活动中都标记为首次订阅。
### 通知配置与交互身份
`POST /api/v1/notification/config` 保存通知渠道时要求名称和稳定 `id` 各自唯一。
改名应保留原 `id`;复制为新的渠道实例应使用新的 `id`,避免共享登录凭据。
通过消息渠道发起普通重启时,完成回执保留发起时的 `source` 配置。
Discord 仅指定 `userid` 的定向通知使用私聊;无法私聊时返回失败,不转发公共频道。
`/skills` 安装和删除按展示时的条目身份操作,刷新或翻页后的旧按钮会失效,应使用最新列表重新选择。
### 文件整理与恢复
整理成功历史保存 `episode_group`;批量复用历史时仅恢复所有记录共同的剧集组,

View File

@@ -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,

View File

@@ -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": [

View File

@@ -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"},

View File

@@ -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",

View File

@@ -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 = "😀&amp;正文\n" * 1000
html = '<b><a href="https://example.invalid/?a=1&amp;b=2">' + body + '</a></b>'
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(("<root>" + call.kwargs["text"] + "</root>").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()

View File

@@ -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 = "&#x1F600;&amp;&lt;正文\n" * 500
markup = {"inline_keyboard": [[{"text": "复核", "callback_data": "review"}]]}
client._Telegram__send_long_plain_message(image=b"image", caption=f"<b><i>{body}</i></b>",
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(("<root>" + chunk + "</root>").encode())
visible = "".join(root.itertext())
assert len(visible.encode("utf-16-le")) // 2 <= 1024
restored.append(visible)
assert "".join(restored) == "😀&<正文\n" * 500

View File

@@ -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 == "搜索中的订阅与下载洗版"

View File

@@ -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
]