diff --git a/actions/v11/basic.py b/actions/v11/basic.py index e25b09d..0542060 100644 --- a/actions/v11/basic.py +++ b/actions/v11/basic.py @@ -6,6 +6,7 @@ from actions import register_action from discord.abc import PrivateChannel import utils.node2image as node2image +import utils.native_forward as native_forward from discord.channel import CategoryChannel, ForumChannel from utils.logger import get_logger import os @@ -325,6 +326,11 @@ async def set_group_card(group_id: int, user_id: int, card: str) -> dict: @register_action("v11") async def send_group_forward_msg(group_id: int, messages: list) -> dict: + if config["system"].get("use_native_forward", True): + refs = await native_forward.can_native_forward(messages, group_id) + if refs is not None: + return await native_forward.send_native_forward(group_id, refs) + logger.debug("合并转发存在需降级的节点,回退图片方案") path = node2image.node2image(messages) return await send_group_msg( group_id=group_id, @@ -336,6 +342,7 @@ async def send_group_forward_msg(group_id: int, messages: list) -> dict: @register_action("v11") async def send_private_forward_msg(user_id: int, messages: list) -> dict: + # 私聊转发暂不支持原生 forward(转发到 DM 的可行性尚未实测),始终走图片方案 path = node2image.node2image(messages) return await send_private_msg( user_id=user_id, diff --git a/pyproject.toml b/pyproject.toml index c9a9ccf..4d83019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onedisc" -version = "1.0.1" +version = "1.0.2" description = "OneBot implement for Discord" authors = [ {name = "XiaoDeng3386",email = "1744793737@qq.com"} diff --git a/test_native_forward.py b/test_native_forward.py new file mode 100644 index 0000000..0931c1d --- /dev/null +++ b/test_native_forward.py @@ -0,0 +1,138 @@ +"""can_native_forward 判定逻辑单元测试(无需真实 Discord)""" +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import patch, AsyncMock + +import discord + +sys.path.insert(0, "/vol2/@apphome/trim.openclaw/data/workspace/onedisc-dev") + +from utils import native_forward as nf + + +def msg(**kw): + base = dict( + id=1001, + type=discord.MessageType.default, + poll=None, + reference=None, + channel=SimpleNamespace(id=2001), + guild=SimpleNamespace(id=111), + ) + base.update(kw) + return SimpleNamespace(**base) + + +class FakeSession: + def __init__(self, record): + self.record = record + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + async def get(self, model, id_): + return self.record + + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +def patch_env(cached=None, db_record=None, target_guild=SimpleNamespace(id=111), api_get=None): + from contextlib import ExitStack + + sess = FakeSession(db_record) + stack = ExitStack() + stack.enter_context(patch.object(nf, "client", SimpleNamespace( + cached_messages=cached or [], + get_channel=lambda cid: SimpleNamespace(guild=target_guild), + ))) + stack.enter_context(patch.object(nf, "get_session", lambda: sess)) + stack.enter_context(patch.object(nf.discord_api, "call", AsyncMock(return_value=api_get))) + return stack + + +results = [] + + +def check(name, cond): + results.append((name, cond)) + print(f"{'✅' if cond else '❌'} {name}") + + +# --- 1. 内联内容节点 → 整体回退 --- +with patch_env(): + r = run(nf.can_native_forward( + [{"type": "node", "data": {"user_id": 1, "nickname": "x", "content": "hi"}}], 3001)) + check("内联节点 → None", r is None) + +# --- 2. 引用节点 cache/DB 均无记录 → 回退 --- +with patch_env(cached=[], db_record=None): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 999}}], 3001)) + check("channel 无法解析 → None", r is None) + +# --- 3. cache 命中但类型不可转发(pins_add)→ 回退 --- +with patch_env(cached=[msg(id=1001, type=discord.MessageType.pins_add)]): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("不可转发类型 → None", r is None) + +# --- 4. cache 命中、类型可转发 → 返回 refs --- +with patch_env(cached=[msg(id=1001)]): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("正常引用 → refs", r == [{"message_id": 1001, "channel_id": 2001}]) + +# --- 5. 转发消息本身(reference.type==forward)→ 回退 --- +with patch_env(cached=[msg(id=1001, reference=SimpleNamespace(type=discord.MessageReferenceType.forward))]): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("转发消息不能再转发 → None", r is None) + +# --- 6. 数量超阈值 → 回退 --- +with patch.object(nf, "config", {**nf.config, "system": {**nf.config["system"], "native_forward_max_nodes": 1}}): + with patch_env(cached=[msg(id=1001), msg(id=1002)]): + r = run(nf.can_native_forward( + [{"type": "node", "data": {"message_id": 1001}}, + {"type": "node", "data": {"message_id": 1002}}], 3001)) + check("超阈值 → None", r is None) + # 阈值 1 但只有 1 条 → 放行 + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("未超阈值 → refs", r == [{"message_id": 1001, "channel_id": 2001}]) + +# --- 7. 跨服务器(cache 命中 guild 不同)→ 回退 --- +with patch_env(cached=[msg(id=1001, guild=SimpleNamespace(id=999))], target_guild=SimpleNamespace(id=111)): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("跨服务器 → None", r is None) + +# --- 8. DB 命中 + REST 预检通过 → refs --- +with patch_env(db_record=SimpleNamespace(channel=2001), api_get={"type": 0, "guild_id": 111}): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("DB+预检通过 → refs", r == [{"message_id": 1001, "channel_id": 2001}]) + +# --- 9. DB 命中 + 预检失败(不可转发类型 6=pins_add)→ 回退 --- +with patch_env(db_record=SimpleNamespace(channel=2001), api_get={"type": 6, "guild_id": 111}): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("DB+预检类型不可转发 → None", r is None) + +# --- 10. DB 命中 + 预检错误响应(消息不存在)→ 回退 --- +with patch_env(db_record=SimpleNamespace(channel=2001), api_get={"code": 10008, "message": "Unknown Message"}): + r = run(nf.can_native_forward([{"type": "node", "data": {"message_id": 1001}}], 3001)) + check("DB+预检消息不存在 → None", r is None) + +# --- 11. 混合:一条正常 + 一条内联 → 整体回退 --- +with patch_env(cached=[msg(id=1001)]): + r = run(nf.can_native_forward( + [{"type": "node", "data": {"message_id": 1001}}, + {"type": "node", "data": {"user_id": 1, "nickname": "x", "content": "hi"}}], 3001)) + check("混合节点 → None", r is None) + +# --- 12. 非 node 结构 → 回退 --- +with patch_env(): + r = run(nf.can_native_forward([{"type": "text", "data": {"text": "hi"}}], 3001)) + check("非 node 结构 → None", r is None) + +failed = [n for n, c in results if not c] +print(f"\n共 {len(results)} 例,失败 {len(failed)} 例") +sys.exit(1 if failed else 0) diff --git a/utils/discord_api.py b/utils/discord_api.py index a35e942..12299b9 100644 --- a/utils/discord_api.py +++ b/utils/discord_api.py @@ -19,12 +19,15 @@ def __init__(self, data: dict) -> None: async def call(method: str, path: str, data: dict | None = None, **params) -> dict: async with httpx.AsyncClient( - proxies=config["system"].get("proxy"), base_url="https://discord.com/api/v10" + # httpx >= 0.28 已将 proxies 参数改名为 proxy + proxy=config["system"].get("proxy"), + base_url="https://discord.com/api/v10", ) as client: response = await client.request( method, path, - data=data, + # Discord API 需要 JSON 编码(httpx 的 data= 会发表单格式) + json=data, headers={"Authorization": f"Bot {config['account_token']}"}, **params, ) diff --git a/utils/native_forward.py b/utils/native_forward.py new file mode 100644 index 0000000..26e2754 --- /dev/null +++ b/utils/native_forward.py @@ -0,0 +1,235 @@ +""" +Discord 原生转发支持(OneBot V11 合并转发) + +Discord API 支持通过 message_reference(type=FORWARD) 转发历史消息,快照由服务端 +生成,因此只需 message_id + channel_id 即可转发未被 discord.py 缓存的消息, +解决了「cached_messages 只覆盖内存中最近消息」的问题(channel 解析依赖 +cached_messages 快查 + 本地数据库持久记录)。 + +本模块遵循「要么全部原生转发、要么整体回退」原则:can_native_forward() 对每个 +node 做无损判定(引用型节点、channel 可解析、源消息可读、类型可转发、同服务器、 +数量阈值),任一节点不满足即返回 None,调用方应整体回退 node2image 图片方案。 +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import discord + +import utils.return_object as return_object +from utils import discord_api +from utils.client import client +from utils.config import config +from utils.db import get_session, Message +from utils.logger import get_logger + +logger = get_logger() + +# Discord 文档规定 FORWARD 仅支持基础消息类型: +# DEFAULT / REPLY / CHAT_INPUT_COMMAND / CONTEXT_MENU_COMMAND +_FORWARDABLE_TYPES = { + discord.MessageType.default, + discord.MessageType.reply, + discord.MessageType.chat_input_command, + discord.MessageType.context_menu_command, +} +# 对应 REST 响应中的 type 数值 +_FORWARDABLE_TYPE_VALUES = {0, 19, 20, 23} + +# 频道发送限流节奏:Discord 约 5 条 / 5 秒 +_RATE_LIMIT_BATCH = 5 +_RATE_LIMIT_SLEEP = 5.0 + + +def _is_forwardable_message(message: discord.Message) -> bool: + """检查 discord.Message 是否可被原生转发""" + if message.type not in _FORWARDABLE_TYPES: + return False + if message.poll is not None: + return False + reference = message.reference + if ( + reference is not None + and reference.type == discord.MessageReferenceType.forward + ): + # 转发消息本身不能被再次转发 + return False + return True + + +def _is_forwardable_api_message(data: dict) -> bool: + """检查 REST 返回的消息数据是否可被原生转发""" + if data.get("code") is not None: + # 错误响应(消息不存在 / 无权限等) + return False + if data.get("type") not in _FORWARDABLE_TYPE_VALUES: + return False + if data.get("poll") is not None: + return False + reference = data.get("message_reference") + if reference is not None and reference.get("type") == 1: + # 转发消息本身不能被再次转发 + return False + return True + + +async def _resolve_message( + message_id: int, +) -> tuple[int | None, discord.Message | None]: + """ + 解析 message_id 对应的频道:cached_messages 快查 → 本地数据库 + + Returns: + (channel_id, cached_message):缓存命中时返回消息对象,否则为 None + """ + for message in client.cached_messages: + if message.id == message_id: + return message.channel.id, message + async with get_session() as session: + record = await session.get(Message, message_id) + if record is not None: + return record.channel, None + return None, None + + +async def _precheck_api_message( + channel_id: int, message_id: int, target_guild_id: int | None +) -> bool: + """ + REST 预检:验证消息存在、可读、类型可转发、同服务器(只读,无副作用) + + 必须在批量发送前完成,保证「要么全部原生、要么全部回退」的原子性 + """ + try: + data = await discord_api.call( + "GET", f"/channels/{channel_id}/messages/{message_id}" + ) + except Exception: + logger.warning( + f"原生转发预检失败:无法读取消息 {message_id}(频道 {channel_id})" + ) + return False + if not _is_forwardable_api_message(data): + logger.warning( + f"原生转发预检失败:消息 {message_id} 不可转发" + f"(type={data.get('type')})" + ) + return False + if target_guild_id is not None and data.get("guild_id") != target_guild_id: + logger.warning( + f"原生转发预检失败:消息 {message_id} 与目标不在同一服务器" + ) + return False + return True + + +async def can_native_forward( + messages: list, target_channel_id: int +) -> list[dict] | None: + """ + 判断合并转发消息列表是否能够不折不扣地用原生 forward 发送 + + Args: + messages (list): OneBot V11 合并转发节点列表 + target_channel_id (int): 目标频道(群)ID + + Returns: + list[dict] | None: 全部节点可转发时返回引用列表 + ([{"message_id": int, "channel_id": int}, ...]), + 任一节点需要降级时返回 None + """ + max_nodes = config["system"].get("native_forward_max_nodes") + if max_nodes is not None and len(messages) > int(max_nodes): + logger.debug( + f"合并转发节点数 {len(messages)} 超过阈值 {max_nodes},回退图片方案" + ) + return None + + target = client.get_channel(target_channel_id) + target_guild_id = getattr(getattr(target, "guild", None), "id", None) + + refs: list[dict] = [] + for node in messages: + if not isinstance(node, dict) or node.get("type") != "node": + return None + data = node.get("data") or {} + message_id = data.get("message_id") + if message_id is None: + # 内联内容节点(user_id + nickname + content)无法原生转发 + return None + try: + message_id = int(message_id) + except (TypeError, ValueError): + return None + + channel_id, cached = await _resolve_message(message_id) + if channel_id is None: + logger.warning( + f"原生转发:找不到消息 {message_id} 的频道" + "(缓存与数据库均无记录),回退图片方案" + ) + return None + + if cached is not None: + if not _is_forwardable_message(cached): + return None + source_guild_id = getattr(getattr(cached, "guild", None), "id", None) + else: + if not await _precheck_api_message( + channel_id, message_id, target_guild_id + ): + return None + source_guild_id = None # 服务器一致性已在预检中校验 + + if ( + target_guild_id is not None + and source_guild_id is not None + and source_guild_id != target_guild_id + ): + # 跨服务器转发不受支持 + return None + + refs.append({"message_id": message_id, "channel_id": channel_id}) + return refs + + +async def send_native_forward(target_channel_id: int, refs: list[dict]) -> dict: + """ + 按引用列表批量发送原生转发,遵守频道发送限流(约 5 条 / 5 秒) + + Args: + target_channel_id (int): 目标频道(群)ID + refs (list[dict]): can_native_forward 返回的引用列表 + + Returns: + dict: OneBot 动作响应 + """ + if client.get_channel(target_channel_id) is None: + return return_object.get(10003, "无效的频道号") + for index, ref in enumerate(refs): + if index > 0 and index % _RATE_LIMIT_BATCH == 0: + await asyncio.sleep(_RATE_LIMIT_SLEEP) + try: + response: dict[str, Any] = await discord_api.call( + "POST", + f"/channels/{target_channel_id}/messages", + { + "message_reference": { + "type": 1, # MessageReferenceType.FORWARD + "message_id": str(ref["message_id"]), + "channel_id": str(ref["channel_id"]), + } + }, + ) + except Exception as exc: + logger.error(f"原生转发失败(已发送 {index} 条):{exc}") + return return_object.get(1400, f"原生转发失败:{exc}") + if response.get("code") is not None: + logger.error(f"原生转发失败(已发送 {index} 条):{response}") + return return_object.get( + 1400, f"原生转发失败:{response.get('message')}" + ) + logger.info(f"原生转发完成,共 {len(refs)} 条") + return return_object.get(0) diff --git a/utils/node2image.py b/utils/node2image.py index 6f3b9aa..4620e24 100644 --- a/utils/node2image.py +++ b/utils/node2image.py @@ -17,9 +17,8 @@ def get_message_by_id(message_id: int) -> dict | None: if message.id == message_id: return { "user_id": message.author.id, - "content": translator.translate_v12_message_to_v11( - parser.parse_string(message.content) - ), + # v11 纯文本 → CQ 数组;parse_string 不存在,正确入口是 parse_string_to_array + "content": parser.parse_string_to_array(message.content), "nickname": message.author.name, } logger.warning(f"解析合并转发节点时出现错误:找不到消息:{message_id}")