diff --git a/README.md b/README.md index c8ef0bc7..ff504119 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,11 @@ parser_custom_font_weight: int = 700 # [可选] 是否需要转发媒体内容(超过 4 项时始终使用合并转发) parser_need_forward_contents=True +# [可选] 是否将一次解析产生的所有消息放入同一条合并转发 +# 默认关闭以保持原有发送行为;启用后外部只发送一条消息,插件不会主动限制内部节点数量 +# 实际可发送数量仍受适配器及平台限制 +parser_forward_all_messages=False + # [可选] emoji 渲染 CDN # 例如 ELK_SH_CDN = "https://emojicdn.elk.sh", MQRIO_DEV_CDN = "https://emoji-cdn.mqrio.dev" parser_emoji_cdn="https://emojicdn.elk.sh" diff --git a/src/nonebot_plugin_parser/config.py b/src/nonebot_plugin_parser/config.py index 439ca0d7..0b38a417 100644 --- a/src/nonebot_plugin_parser/config.py +++ b/src/nonebot_plugin_parser/config.py @@ -52,6 +52,8 @@ class Config(BaseModel): """字体粗细程度""" parser_need_forward_contents: bool = True """是否需要转发媒体内容""" + parser_forward_all_messages: bool = False + """是否将一次解析的所有消息放入同一条合并转发中""" parser_emoji_cdn: str = ELK_SH_CDN """Pilmoji 表情 CDN""" parser_emoji_style: EmojiStyle = EmojiStyle.FACEBOOK @@ -174,6 +176,11 @@ def need_forward_contents(self) -> bool: """是否需要转发媒体内容""" return self.parser_need_forward_contents + @property + def forward_all_messages(self) -> bool: + """是否将一次解析的所有消息放入同一条合并转发中""" + return self.parser_forward_all_messages + @property def emoji_cdn(self) -> str: """Pilmoji 表情 CDN""" diff --git a/src/nonebot_plugin_parser/helper.py b/src/nonebot_plugin_parser/helper.py index 73c5d26c..ad874700 100644 --- a/src/nonebot_plugin_parser/helper.py +++ b/src/nonebot_plugin_parser/helper.py @@ -13,6 +13,7 @@ Image, Video, Voice, + RefNode, Segment, Reference, CustomNode, @@ -64,6 +65,24 @@ def construct_forward_message( return Reference(nodes=nodes) + @staticmethod + def construct_forward_messages( + messages: Sequence[UniMessage], + user_id: str | None = None, + ) -> Reference: + """将多条消息合并为一条转发消息,并展开已有的转发节点。""" + if user_id is None: + user_id = current_bot.get().self_id + + nodes: list[RefNode | CustomNode] = [] + for message in messages: + if len(message) == 1 and isinstance(message[0], Reference): + nodes.extend(message[0].children) + else: + nodes.append(CustomNode(uid=user_id, name=pconfig.nickname, content=message)) + + return Reference(nodes=nodes) + @staticmethod def img_seg( file: Path | bytes, diff --git a/src/nonebot_plugin_parser/matchers/__init__.py b/src/nonebot_plugin_parser/matchers/__init__.py index 5427a3f6..4fa17153 100644 --- a/src/nonebot_plugin_parser/matchers/__init__.py +++ b/src/nonebot_plugin_parser/matchers/__init__.py @@ -1,5 +1,6 @@ import re -from typing import TypeVar +from typing import Any, TypeVar, Protocol +from collections.abc import AsyncIterator from nonebot import logger, get_driver, on_command from nonebot.params import CommandArg @@ -13,6 +14,10 @@ from ..renders import get_renderer +class _MessageRenderer(Protocol): + def render_messages(self) -> AsyncIterator[UniMessage[Any]]: ... + + def _get_enabled_parser_classes() -> list[type[BaseParser]]: disabled_platforms = set(pconfig.disabled_platforms) all_subclass = BaseParser.get_all_subclass() @@ -60,6 +65,27 @@ def clear_result_cache(): _RESULT_CACHE.clear() +async def send_rendered_messages(renderer: _MessageRenderer) -> None: + """发送渲染结果,可选将所有输出聚合为单条合并转发。""" + if pconfig.forward_all_messages: + messages: list[UniMessage[Any]] = [] + render_error: Exception | None = None + try: + async for message in renderer.render_messages(): + messages.append(message) + except Exception as e: + render_error = e + + if messages: + forward = UniHelper.construct_forward_messages(messages) + await UniMessage(forward).send() + if render_error is not None: + raise render_error + else: + async for message in renderer.render_messages(): + await message.send() + + @UniHelper.with_reaction async def parser_handler( sr: SearchResult = Searched(), @@ -79,8 +105,7 @@ async def parser_handler( # 3. 渲染内容消息并发送 renderer = get_renderer(result.platform.name)(result) - async for message in renderer.render_messages(): - await message.send() + await send_rendered_messages(renderer) # 4. 缓存解析结果 _RESULT_CACHE[cache_key] = result diff --git a/tests/others/test_forward_messages.py b/tests/others/test_forward_messages.py new file mode 100644 index 00000000..ab83938d --- /dev/null +++ b/tests/others/test_forward_messages.py @@ -0,0 +1,134 @@ +def test_construct_forward_messages_flattens_existing_references(): + from nonebot_plugin_alconna.uniseg import Text, Image, Video, Reference, CustomNode, UniMessage + + from nonebot_plugin_parser.helper import UniHelper + + existing = Reference( + nodes=[ + CustomNode(uid="1", name="parser", content=UniMessage(Image(url="https://example.com/1.jpg"))), + CustomNode(uid="1", name="parser", content=UniMessage(Image(url="https://example.com/2.jpg"))), + ] + ) + messages = [ + UniMessage(Text("preview")), + UniMessage(existing), + UniMessage(Video(url="https://example.com/1.mp4")), + UniMessage(Video(url="https://example.com/2.mp4")), + ] + + forward = UniHelper.construct_forward_messages(messages, user_id="1") + + assert len(forward.children) == 5 + node_types = [] + for node in forward.children: + assert isinstance(node, CustomNode) + assert isinstance(node.content, UniMessage) + node_types.append(node.content[0].type) + assert node_types == ["text", "image", "image", "video", "video"] + + +async def test_send_rendered_messages_sends_once(monkeypatch): + from nonebot_plugin_alconna.uniseg import Text, Image, Video, Reference, CustomNode, UniMessage + + from nonebot_plugin_parser.config import pconfig + from nonebot_plugin_parser.matchers import send_rendered_messages + + class Renderer: + async def render_messages(self): + yield UniMessage(Text("preview")) + yield UniMessage( + Reference( + nodes=[ + CustomNode(uid="1", name="parser", content=UniMessage(Image(url="https://example.com/1.jpg"))), + CustomNode(uid="1", name="parser", content=UniMessage(Image(url="https://example.com/2.jpg"))), + ] + ) + ) + yield UniMessage(Video(url="https://example.com/1.mp4")) + yield UniMessage(Video(url="https://example.com/2.mp4")) + + sent: list[UniMessage] = [] + + async def send(message, *args, **kwargs): + sent.append(message) + + monkeypatch.setattr(pconfig, "parser_forward_all_messages", True) + bot_context = type("BotContext", (), {"get": lambda self: type("Bot", (), {"self_id": "1"})()})() + monkeypatch.setattr("nonebot_plugin_parser.helper.current_bot", bot_context) + monkeypatch.setattr(UniMessage, "send", send) + + await send_rendered_messages(Renderer()) + + assert len(sent) == 1 + assert len(sent[0]) == 1 + assert isinstance(sent[0][0], Reference) + assert len(sent[0][0].children) == 5 + + +async def test_send_rendered_messages_preserves_original_behavior_when_disabled(monkeypatch): + from nonebot_plugin_alconna.uniseg import Text, UniMessage + + from nonebot_plugin_parser.config import pconfig + from nonebot_plugin_parser.matchers import send_rendered_messages + + class Renderer: + async def render_messages(self): + yield UniMessage(Text("first")) + yield UniMessage(Text("second")) + + sent: list[UniMessage] = [] + + async def send(message, *args, **kwargs): + sent.append(message) + + monkeypatch.setattr(pconfig, "parser_forward_all_messages", False) + monkeypatch.setattr(UniMessage, "send", send) + + await send_rendered_messages(Renderer()) + + assert [message.extract_plain_text() for message in sent] == ["first", "second"] + + +async def test_send_rendered_messages_sends_partial_results_before_reraising(monkeypatch): + import pytest + from nonebot_plugin_alconna.uniseg import Text, Reference, UniMessage + + from nonebot_plugin_parser.config import pconfig + from nonebot_plugin_parser.matchers import send_rendered_messages + + class RenderError(Exception): + pass + + class Renderer: + async def render_messages(self): + yield UniMessage(Text("preview")) + raise RenderError + + sent: list[UniMessage] = [] + + async def send(message, *args, **kwargs): + sent.append(message) + + monkeypatch.setattr(pconfig, "parser_forward_all_messages", True) + bot_context = type("BotContext", (), {"get": lambda self: type("Bot", (), {"self_id": "1"})()})() + monkeypatch.setattr("nonebot_plugin_parser.helper.current_bot", bot_context) + monkeypatch.setattr(UniMessage, "send", send) + + with pytest.raises(RenderError): + await send_rendered_messages(Renderer()) + + assert len(sent) == 1 + assert isinstance(sent[0][0], Reference) + assert len(sent[0][0].children) == 1 + + +def test_construct_forward_messages_does_not_limit_nodes(): + from nonebot_plugin_alconna.uniseg import Text, UniMessage + + from nonebot_plugin_parser.helper import UniHelper + + messages = [UniMessage(Text(str(index))) for index in range(128)] + + forward = UniHelper.construct_forward_messages(messages, user_id="1") + + assert len(forward.children) == len(messages)