mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 07:09:09 +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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user