mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 12:56:34 +08:00
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:
@@ -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="通知配置保存失败")
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
升级前历史没有来源标记,界面显示“未记录”,不会按当前订阅设置追溯推断。
|
||||
普通订阅的首次提交与洗版订阅补缺在活动中都标记为首次订阅。
|
||||
|
||||
### 通知配置与交互身份
|
||||
|
||||
`POST /api/v1/notification/config` 保存通知渠道时要求名称和稳定 `id` 各自唯一。
|
||||
改名应保留原 `id`;复制为新的渠道实例应使用新的 `id`,避免共享登录凭据。
|
||||
通过消息渠道发起普通重启时,完成回执保留发起时的 `source` 配置。
|
||||
|
||||
Discord 仅指定 `userid` 的定向通知使用私聊;无法私聊时返回失败,不转发公共频道。
|
||||
`/skills` 安装和删除按展示时的条目身份操作,刷新或翻页后的旧按钮会失效,应使用最新列表重新选择。
|
||||
|
||||
### 文件整理与恢复
|
||||
|
||||
整理成功历史保存 `episode_group`;批量复用历史时仅恢复所有记录共同的剧集组,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
189
tests/test_messaging_review_attachments.py
Normal file
189
tests/test_messaging_review_attachments.py
Normal 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 = "😀&正文\n" * 1000
|
||||
html = '<b><a href="https://example.invalid/?a=1&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()
|
||||
115
tests/test_messaging_review_boundaries.py
Normal file
115
tests/test_messaging_review_boundaries.py
Normal 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 = "😀&<正文\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
|
||||
160
tests/test_messaging_review_regressions.py
Normal file
160
tests/test_messaging_review_regressions.py
Normal 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 == "搜索中的订阅与下载洗版"
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user