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

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