From 39c7d99ef0e18816f24a14260d3826582954841a Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 19:56:41 +0800 Subject: [PATCH 01/15] feat(platform): add base remove_react method to AstrMessageEvent Co-Authored-By: Claude Opus 4.6 --- astrbot/core/platform/astr_message_event.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 021a4bff7c..11a97237b1 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -460,6 +460,14 @@ async def react(self, emoji: str) -> None: """ await self.send(MessageChain([Plain(emoji)])) + async def remove_react(self, emoji: str) -> None: + """移除消息上的表情回应。 + + 默认实现为空操作。 + 如需支持平台原生的撤回表情功能,请在对应平台的子类中重写本方法。 + """ + pass + async def get_group(self, group_id: str | None = None, **kwargs) -> Group | None: """获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息,返回当前群聊的数据。 From 5c40ca6bee3cf29ba7daace9996cba9afc396147 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 19:57:01 +0800 Subject: [PATCH 02/15] feat(config): add discord defaults and auto_remove to pre_ack_emoji config Co-Authored-By: Claude Opus 4.6 --- astrbot/core/config/default.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index bdabcd933e..85764a635a 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -214,10 +214,13 @@ "platform_specific": { # 平台特异配置:按平台分类,平台下按功能分组 "lark": { - "pre_ack_emoji": {"enable": False, "emojis": ["Typing"]}, + "pre_ack_emoji": {"enable": False, "emojis": ["Typing"], "auto_remove": True}, }, "telegram": { - "pre_ack_emoji": {"enable": False, "emojis": ["✍️"]}, + "pre_ack_emoji": {"enable": False, "emojis": ["✍️"], "auto_remove": True}, + }, + "discord": { + "pre_ack_emoji": {"enable": False, "emojis": ["✍️"], "auto_remove": True}, }, }, "wake_prefix": ["/"], @@ -3555,6 +3558,13 @@ class ChatProviderTemplate(TypedDict): "platform_specific.lark.pre_ack_emoji.enable": True, }, }, + "platform_specific.lark.pre_ack_emoji.auto_remove": { + "description": "[飞书] 处理完毕后自动撤回表情", + "type": "bool", + "condition": { + "platform_specific.lark.pre_ack_emoji.enable": True, + }, + }, "platform_specific.telegram.pre_ack_emoji.enable": { "description": "[Telegram] 启用预回应表情", "type": "bool", @@ -3568,6 +3578,13 @@ class ChatProviderTemplate(TypedDict): "platform_specific.telegram.pre_ack_emoji.enable": True, }, }, + "platform_specific.telegram.pre_ack_emoji.auto_remove": { + "description": "[Telegram] 处理完毕后自动撤回表情", + "type": "bool", + "condition": { + "platform_specific.telegram.pre_ack_emoji.enable": True, + }, + }, "platform_specific.discord.pre_ack_emoji.enable": { "description": "[Discord] 启用预回应表情", "type": "bool", @@ -3581,6 +3598,13 @@ class ChatProviderTemplate(TypedDict): "platform_specific.discord.pre_ack_emoji.enable": True, }, }, + "platform_specific.discord.pre_ack_emoji.auto_remove": { + "description": "[Discord] 处理完毕后自动撤回表情", + "type": "bool", + "condition": { + "platform_specific.discord.pre_ack_emoji.enable": True, + }, + }, }, }, }, From 463b94cc313aa391bb682118d22ffa9a0f22c4fb Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:00:26 +0800 Subject: [PATCH 03/15] feat(lark): implement remove_react for reaction removal Co-Authored-By: Claude Opus 4.6 --- .../core/platform/sources/lark/lark_event.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 92e3a32b9e..78436337f2 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -12,6 +12,7 @@ CreateImageRequestBody, CreateMessageReactionRequest, CreateMessageReactionRequestBody, + DeleteMessageReactionRequest, Emoji, ReplyMessageRequest, ReplyMessageRequestBody, @@ -555,6 +556,30 @@ async def react(self, emoji: str) -> None: logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}") return + # 保存 reaction_id 以便后续撤回 + if response.data and response.data.reaction_id: + self._pre_ack_reaction_id = response.data.reaction_id + + async def remove_react(self, emoji: str) -> None: + reaction_id = getattr(self, "_pre_ack_reaction_id", None) + if not reaction_id: + return + + if self.bot.im is None: + logger.warning("[Lark] API Client im 模块未初始化,无法撤回表情") + return + + request = ( + DeleteMessageReactionRequest.builder() + .message_id(self.message_obj.message_id) + .reaction_id(reaction_id) + .build() + ) + + response = await self.bot.im.v1.message_reaction.adelete(request) + if not response.success(): + logger.warning(f"撤回飞书表情回应失败({response.code}): {response.msg}") + async def send_streaming(self, generator, use_fallback: bool = False): buffer = None async for chain in generator: From 9ca8ded4b0377860d4faf9b3dbd650493a001ff0 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:02:09 +0800 Subject: [PATCH 04/15] feat(discord): implement remove_react for reaction removal Co-Authored-By: Claude Opus 4.6 --- .../platform/sources/discord/discord_platform_event.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index 02d4dae868..4ccf8c2653 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -280,6 +280,15 @@ async def react(self, emoji: str) -> None: except Exception as e: logger.error(f"[Discord] 添加反应失败: {e}") + async def remove_react(self, emoji: str) -> None: + """移除 bot 在原消息上的表情回应""" + try: + raw = self.message_obj.raw_message + if hasattr(raw, "remove_reaction") and self.client.user: + await cast(discord.Message, raw).remove_reaction(emoji, self.client.user) + except Exception as e: + logger.warning(f"[Discord] 移除反应失败: {e}") + def is_slash_command(self) -> bool: """判断是否为斜杠命令""" return ( From 59a222f09522fb79fd870a3fd173fec1a8ded37d Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:02:10 +0800 Subject: [PATCH 05/15] feat(telegram): implement remove_react for reaction removal Co-Authored-By: Claude Opus 4.6 --- astrbot/core/platform/sources/telegram/tg_event.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 43e58960ee..c0b9a7abec 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -355,6 +355,10 @@ async def react(self, emoji: str | None, big: bool = False) -> None: except Exception as e: logger.error(f"[Telegram] 添加反应失败: {e}") + async def remove_react(self, emoji: str) -> None: + """移除 bot 在原消息上的表情回应(Telegram 通过设置空 reaction 列表实现)""" + await self.react(None) + async def _send_message_draft( self, chat_id: str, From 629ae027bc2364162ad0b76d5c7587104d1bc3e5 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:04:25 +0800 Subject: [PATCH 06/15] feat(pipeline): add PreAckEmojiManager with full test coverage Co-Authored-By: Claude Opus 4.6 --- astrbot/core/pipeline/pre_ack_emoji.py | 60 ++++++++++ tests/test_pre_ack_emoji.py | 151 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 astrbot/core/pipeline/pre_ack_emoji.py create mode 100644 tests/test_pre_ack_emoji.py diff --git a/astrbot/core/pipeline/pre_ack_emoji.py b/astrbot/core/pipeline/pre_ack_emoji.py new file mode 100644 index 0000000000..1c0dba0c4f --- /dev/null +++ b/astrbot/core/pipeline/pre_ack_emoji.py @@ -0,0 +1,60 @@ +import random + +from astrbot.core import logger +from astrbot.core.platform import AstrMessageEvent + + +class PreAckEmojiManager: + """预回应表情管理器。 + + 在 pipeline 执行前贴表情,执行后根据配置撤回。 + 运行在洋葱模型外层,不参与 stage 调度。 + """ + + SUPPORTED_PLATFORMS = ("telegram", "lark", "discord") + + def __init__(self, config: dict) -> None: + self.config = config + + def _get_cfg(self, platform: str) -> dict: + return ( + self.config.get("platform_specific", {}) + .get(platform, {}) + .get("pre_ack_emoji", {}) + ) or {} + + async def add_emoji(self, event: AstrMessageEvent) -> str | None: + """贴表情。返回所选 emoji,或 None(未贴)。""" + platform = event.get_platform_name() + if platform not in self.SUPPORTED_PLATFORMS: + return None + + cfg = self._get_cfg(platform) + emojis = cfg.get("emojis") or [] + + if not cfg.get("enable", False) or not emojis or not event.is_at_or_wake_command: + return None + + emoji = random.choice(emojis) + try: + await event.react(emoji) + return emoji + except Exception as e: + logger.warning(f"{platform} 预回应表情发送失败: {e}") + return None + + async def remove_emoji(self, event: AstrMessageEvent, emoji: str | None) -> None: + """根据配置撤回表情。""" + if emoji is None: + return + + platform = event.get_platform_name() + cfg = self._get_cfg(platform) + + if not cfg.get("auto_remove", True): + return + + try: + await event.remove_react(emoji) + except Exception as e: + logger.warning(f"{platform} 预回应表情撤回失败: {e}") diff --git a/tests/test_pre_ack_emoji.py b/tests/test_pre_ack_emoji.py new file mode 100644 index 0000000000..b4abe3fdce --- /dev/null +++ b/tests/test_pre_ack_emoji.py @@ -0,0 +1,151 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from astrbot.core.pipeline.pre_ack_emoji import PreAckEmojiManager + + +def _make_config(platform: str, enable: bool = True, emojis: list | None = None, auto_remove: bool = True) -> dict: + """构造包含 platform_specific 的最小配置字典""" + if emojis is None: + emojis = ["👍"] + return { + "platform_specific": { + platform: { + "pre_ack_emoji": { + "enable": enable, + "emojis": emojis, + "auto_remove": auto_remove, + } + } + } + } + + +def _make_event(platform: str = "telegram", is_wake: bool = True) -> MagicMock: + """构造模拟的 AstrMessageEvent""" + event = MagicMock() + event.get_platform_name.return_value = platform + event.is_at_or_wake_command = is_wake + event.react = AsyncMock() + event.remove_react = AsyncMock() + return event + + +class TestPreAckEmojiAddEmoji: + """测试贴表情逻辑""" + + @pytest.mark.asyncio + async def test_disabled_does_not_react(self): + """功能关闭时不贴表情""" + cfg = _make_config("telegram", enable=False) + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + result = await mgr.add_emoji(event) + assert result is None + event.react.assert_not_called() + + @pytest.mark.asyncio + async def test_empty_emojis_does_not_react(self): + """emojis 列表为空时不贴表情""" + cfg = _make_config("telegram", emojis=[]) + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + result = await mgr.add_emoji(event) + assert result is None + event.react.assert_not_called() + + @pytest.mark.asyncio + async def test_unsupported_platform_does_not_react(self): + """非支持平台不贴表情""" + cfg = _make_config("aiocqhttp", enable=True) + mgr = PreAckEmojiManager(cfg) + event = _make_event("aiocqhttp") + result = await mgr.add_emoji(event) + assert result is None + event.react.assert_not_called() + + @pytest.mark.asyncio + async def test_not_wake_command_does_not_react(self): + """非 at/唤醒消息不贴表情""" + cfg = _make_config("telegram") + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram", is_wake=False) + result = await mgr.add_emoji(event) + assert result is None + event.react.assert_not_called() + + @pytest.mark.asyncio + async def test_emoji_chosen_from_config_list(self): + """选出的 emoji 在配置列表内""" + emojis = ["👍", "✍️", "🤔"] + cfg = _make_config("telegram", emojis=emojis) + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + result = await mgr.add_emoji(event) + assert result in emojis + event.react.assert_called_once_with(result) + + @pytest.mark.asyncio + async def test_react_failure_returns_none(self): + """贴表情失败时返回 None,不影响主流程""" + cfg = _make_config("telegram") + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + event.react.side_effect = Exception("API error") + result = await mgr.add_emoji(event) + assert result is None + + @pytest.mark.asyncio + async def test_all_three_platforms_supported(self): + """telegram、lark、discord 三个平台都支持""" + for platform in ("telegram", "lark", "discord"): + cfg = _make_config(platform) + mgr = PreAckEmojiManager(cfg) + event = _make_event(platform) + result = await mgr.add_emoji(event) + assert result == "👍" + event.react.assert_called_once_with("👍") + + +class TestPreAckEmojiRemoveEmoji: + """测试撤回表情逻辑""" + + @pytest.mark.asyncio + async def test_auto_remove_true_calls_remove_react(self): + """auto_remove=True 时撤回""" + cfg = _make_config("telegram", auto_remove=True) + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + await mgr.remove_emoji(event, "👍") + event.remove_react.assert_called_once_with("👍") + + @pytest.mark.asyncio + async def test_auto_remove_false_does_not_remove(self): + """auto_remove=False 时不撤回""" + cfg = _make_config("telegram", auto_remove=False) + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + await mgr.remove_emoji(event, "👍") + event.remove_react.assert_not_called() + + @pytest.mark.asyncio + async def test_none_emoji_skips_removal(self): + """emoji 为 None 时跳过撤回""" + cfg = _make_config("telegram") + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + await mgr.remove_emoji(event, None) + event.remove_react.assert_not_called() + + @pytest.mark.asyncio + async def test_remove_failure_logs_warning(self): + """撤回失败时记录 warning 日志""" + cfg = _make_config("telegram") + mgr = PreAckEmojiManager(cfg) + event = _make_event("telegram") + event.remove_react.side_effect = Exception("API error") + with patch("astrbot.core.pipeline.pre_ack_emoji.logger") as mock_logger: + await mgr.remove_emoji(event, "👍") + mock_logger.warning.assert_called_once() From 439700c7e12f5a22b05f80432fa1f6d71e1f4bca Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:07:23 +0800 Subject: [PATCH 07/15] feat(pipeline): integrate PreAckEmojiManager and remove old PreProcessStage emoji code PreAckEmojiManager now wraps pipeline execution in scheduler.execute(), completely outside the onion model. Old emoji logic removed from PreProcessStage. Co-Authored-By: Claude Opus 4.6 --- .../core/pipeline/preprocess_stage/stage.py | 21 ------------------- astrbot/core/pipeline/scheduler.py | 4 ++++ 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/astrbot/core/pipeline/preprocess_stage/stage.py b/astrbot/core/pipeline/preprocess_stage/stage.py index 464f584f8e..4b3d67585e 100644 --- a/astrbot/core/pipeline/preprocess_stage/stage.py +++ b/astrbot/core/pipeline/preprocess_stage/stage.py @@ -1,5 +1,4 @@ import asyncio -import random import traceback from collections.abc import AsyncGenerator @@ -26,26 +25,6 @@ async def process( event: AstrMessageEvent, ) -> None | AsyncGenerator[None, None]: """在处理事件之前的预处理""" - # 平台特异配置:platform_specific..pre_ack_emoji - supported = {"telegram", "lark", "discord"} - platform = event.get_platform_name() - cfg = ( - self.config.get("platform_specific", {}) - .get(platform, {}) - .get("pre_ack_emoji", {}) - ) or {} - emojis = cfg.get("emojis") or [] - if ( - cfg.get("enable", False) - and platform in supported - and emojis - and event.is_at_or_wake_command - ): - try: - await event.react(random.choice(emojis)) - except Exception as e: - logger.warning(f"{platform} 预回应表情发送失败: {e}") - # 路径映射 if mappings := self.platform_settings.get("path_mapping", []): # 支持 Record,Image 消息段的路径映射。 diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index ffb9c5c99c..c82e4adcc5 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -9,6 +9,7 @@ from astrbot.core.utils.active_event_registry import active_event_registry from .bootstrap import ensure_builtin_stages_registered +from .pre_ack_emoji import PreAckEmojiManager from .context import PipelineContext from .stage import registered_stages from .stage_order import STAGES_ORDER @@ -24,6 +25,7 @@ def __init__(self, context: PipelineContext) -> None: ) # 按照顺序排序 self.ctx = context # 上下文对象 self.stages = [] # 存储阶段实例 + self.pre_ack_emoji_mgr = PreAckEmojiManager(context.astrbot_config) async def initialize(self) -> None: """初始化管道调度器时, 初始化所有阶段""" @@ -83,6 +85,7 @@ async def execute(self, event: AstrMessageEvent) -> None: """ active_event_registry.register(event) + emoji = await self.pre_ack_emoji_mgr.add_emoji(event) try: await self._process_stages(event) @@ -92,4 +95,5 @@ async def execute(self, event: AstrMessageEvent) -> None: logger.debug("pipeline 执行完毕。") finally: + await self.pre_ack_emoji_mgr.remove_emoji(event, emoji) active_event_registry.unregister(event) From 1c6ecb54f66aba312cb66a3345af35ee39ed5542 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:09:21 +0800 Subject: [PATCH 08/15] feat(i18n): add auto_remove config descriptions for pre_ack_emoji Co-Authored-By: Claude Opus 4.6 --- .../src/i18n/locales/en-US/features/config-metadata.json | 9 +++++++++ .../src/i18n/locales/zh-CN/features/config-metadata.json | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 089aca7ad4..a2c898b3da 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -798,6 +798,9 @@ "emojis": { "description": "Emoji List (Lark Emoji Enum Names)", "hint": "Emoji enum names reference: [https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce)" + }, + "auto_remove": { + "description": "[Lark] Auto-remove emoji after processing" } } }, @@ -809,6 +812,9 @@ "emojis": { "description": "Emoji List (Unicode)", "hint": "Telegram only supports a fixed reaction set, reference: [https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)" + }, + "auto_remove": { + "description": "[Telegram] Auto-remove emoji after processing" } } }, @@ -820,6 +826,9 @@ "emojis": { "description": "Emoji List (Unicode or Custom Emoji Name)", "hint": "Enter Unicode emoji symbols, e.g., 👍, 🤔, ⏳" + }, + "auto_remove": { + "description": "[Discord] Auto-remove emoji after processing" } } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 158dbf3806..8675b714dc 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -801,6 +801,9 @@ "emojis": { "description": "表情列表(飞书表情枚举名)", "hint": "表情枚举名参考:[https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce)" + }, + "auto_remove": { + "description": "[飞书] 处理完毕后自动撤回表情" } } }, @@ -812,6 +815,9 @@ "emojis": { "description": "表情列表(Unicode)", "hint": "Telegram 仅支持固定反应集合,参考:[https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)" + }, + "auto_remove": { + "description": "[Telegram] 处理完毕后自动撤回表情" } } }, @@ -823,6 +829,9 @@ "emojis": { "description": "表情列表(Unicode 或自定义表情名)", "hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳" + }, + "auto_remove": { + "description": "[Discord] 处理完毕后自动撤回表情" } } } From 5f70becdf6f48ea22425cc0ce3bab555124a23ee Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:09:56 +0800 Subject: [PATCH 09/15] test(pipeline): add execution count tests for onion model correctness Co-Authored-By: Claude Opus 4.6 --- tests/test_pipeline_execution.py | 175 +++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/test_pipeline_execution.py diff --git a/tests/test_pipeline_execution.py b/tests/test_pipeline_execution.py new file mode 100644 index 0000000000..02f34f2bf3 --- /dev/null +++ b/tests/test_pipeline_execution.py @@ -0,0 +1,175 @@ +"""流水线执行次数测试。 + +验证洋葱模型阶段后的普通阶段只执行一次, +多层洋葱嵌套不重复执行, +未挂起的异步生成器阶段不阻断后续执行。 +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot.core.pipeline.scheduler import PipelineScheduler + + +def _make_normal_stage(name: str, counter: dict): + """创建普通(非洋葱)阶段""" + stage = MagicMock() + stage.__class__ = type(name, (), {}) + stage.__class__.__name__ = name + counter[name] = 0 + + async def process(event): + counter[name] += 1 + + stage.process = process + return stage + + +def _make_onion_stage(name: str, counter: dict): + """创建洋葱(generator)阶段""" + stage = MagicMock() + stage.__class__ = type(name, (), {}) + stage.__class__.__name__ = name + counter[f"{name}_pre"] = 0 + counter[f"{name}_post"] = 0 + + async def process(event): + counter[f"{name}_pre"] += 1 + yield + counter[f"{name}_post"] += 1 + + stage.process = process + return stage + + +def _make_non_suspending_generator_stage(name: str, counter: dict): + """创建不 yield 的异步生成器阶段(立即返回)""" + stage = MagicMock() + stage.__class__ = type(name, (), {}) + stage.__class__.__name__ = name + counter[name] = 0 + + async def process(event): + counter[name] += 1 + return + yield # noqa: RET504 — 使 process 成为 async generator 但永不 yield + + stage.process = process + return stage + + +def _make_event(): + event = MagicMock() + event.is_stopped.return_value = False + return event + + +def _make_scheduler_with_stages(stages): + """创建带有指定 stages 的 PipelineScheduler(绕过正常初始化)""" + scheduler = object.__new__(PipelineScheduler) + scheduler.stages = stages + scheduler.ctx = MagicMock() + scheduler.pre_ack_emoji_mgr = MagicMock() + scheduler.pre_ack_emoji_mgr.add_emoji = AsyncMock(return_value=None) + scheduler.pre_ack_emoji_mgr.remove_emoji = AsyncMock() + return scheduler + + +class TestOnionThenNormalExecutesOnce: + """洋葱阶段后的普通阶段只执行一次""" + + @pytest.mark.asyncio + async def test_normal_after_single_onion(self): + counter = {} + onion = _make_onion_stage("Onion", counter) + normal = _make_normal_stage("Normal", counter) + + scheduler = _make_scheduler_with_stages([onion, normal]) + event = _make_event() + + await scheduler._process_stages(event) + + assert counter["Onion_pre"] == 1 + assert counter["Normal"] == 1 + assert counter["Onion_post"] == 1 + + @pytest.mark.asyncio + async def test_normal_after_two_onions(self): + counter = {} + onion1 = _make_onion_stage("Onion1", counter) + onion2 = _make_onion_stage("Onion2", counter) + normal = _make_normal_stage("Normal", counter) + + scheduler = _make_scheduler_with_stages([onion1, onion2, normal]) + event = _make_event() + + await scheduler._process_stages(event) + + assert counter["Onion1_pre"] == 1 + assert counter["Onion2_pre"] == 1 + assert counter["Normal"] == 1 + assert counter["Onion2_post"] == 1 + assert counter["Onion1_post"] == 1 + + +class TestMultiOnionNesting: + """多层洋葱嵌套不重复执行""" + + @pytest.mark.asyncio + async def test_three_layer_onion_nesting(self): + counter = {} + stages = [ + _make_onion_stage("O1", counter), + _make_onion_stage("O2", counter), + _make_onion_stage("O3", counter), + _make_normal_stage("Final", counter), + ] + + scheduler = _make_scheduler_with_stages(stages) + event = _make_event() + + await scheduler._process_stages(event) + + assert counter["O1_pre"] == 1 + assert counter["O2_pre"] == 1 + assert counter["O3_pre"] == 1 + assert counter["Final"] == 1 + assert counter["O3_post"] == 1 + assert counter["O2_post"] == 1 + assert counter["O1_post"] == 1 + + +class TestNonSuspendingGeneratorStage: + """未挂起的异步生成器阶段不阻断后续执行""" + + @pytest.mark.asyncio + async def test_non_yielding_generator_does_not_block(self): + counter = {} + non_suspending = _make_non_suspending_generator_stage("NonSuspending", counter) + normal = _make_normal_stage("After", counter) + + scheduler = _make_scheduler_with_stages([non_suspending, normal]) + event = _make_event() + + await scheduler._process_stages(event) + + assert counter["NonSuspending"] == 1 + assert counter["After"] == 1 + + @pytest.mark.asyncio + async def test_non_yielding_generator_between_onions(self): + counter = {} + onion = _make_onion_stage("Onion", counter) + non_suspending = _make_non_suspending_generator_stage("NonSuspending", counter) + normal = _make_normal_stage("Final", counter) + + scheduler = _make_scheduler_with_stages([onion, non_suspending, normal]) + event = _make_event() + + await scheduler._process_stages(event) + + assert counter["Onion_pre"] == 1 + assert counter["NonSuspending"] == 1 + assert counter["Final"] == 1 + assert counter["Onion_post"] == 1 From 7ceacbde2e12b85e40c5b13eed1a0aef58a45116 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 20:19:10 +0800 Subject: [PATCH 10/15] fix: decouple Lark reaction_id from event, use EmojiRef dataclass - PreAckEmojiManager now returns EmojiRef(emoji, reaction_id) instead of str - Lark react() returns reaction_id instead of storing on event as side-effect - remove_react() accepts optional reaction_id parameter across all platforms - Discord remove_react adds hasattr guard for consistency with react() - Added 2 new tests for Lark reaction_id flow Co-Authored-By: Claude Opus 4.6 --- astrbot/core/pipeline/pre_ack_emoji.py | 23 ++++++--- astrbot/core/platform/astr_message_event.py | 6 ++- .../sources/discord/discord_platform_event.py | 4 +- .../core/platform/sources/lark/lark_event.py | 10 ++-- .../platform/sources/telegram/tg_event.py | 2 +- tests/test_pre_ack_emoji.py | 49 ++++++++++++++----- 6 files changed, 67 insertions(+), 27 deletions(-) diff --git a/astrbot/core/pipeline/pre_ack_emoji.py b/astrbot/core/pipeline/pre_ack_emoji.py index 1c0dba0c4f..e4adfa4c05 100644 --- a/astrbot/core/pipeline/pre_ack_emoji.py +++ b/astrbot/core/pipeline/pre_ack_emoji.py @@ -1,9 +1,18 @@ import random +from dataclasses import dataclass from astrbot.core import logger from astrbot.core.platform import AstrMessageEvent +@dataclass +class EmojiRef: + """贴出的表情引用,包含撤回所需的全部信息。""" + + emoji: str + reaction_id: str | None = None # 飞书需要 reaction_id 来撤回 + + class PreAckEmojiManager: """预回应表情管理器。 @@ -23,8 +32,8 @@ def _get_cfg(self, platform: str) -> dict: .get("pre_ack_emoji", {}) ) or {} - async def add_emoji(self, event: AstrMessageEvent) -> str | None: - """贴表情。返回所选 emoji,或 None(未贴)。""" + async def add_emoji(self, event: AstrMessageEvent) -> EmojiRef | None: + """贴表情。返回 EmojiRef,或 None(未贴)。""" platform = event.get_platform_name() if platform not in self.SUPPORTED_PLATFORMS: return None @@ -37,15 +46,15 @@ async def add_emoji(self, event: AstrMessageEvent) -> str | None: emoji = random.choice(emojis) try: - await event.react(emoji) - return emoji + reaction_id = await event.react(emoji) + return EmojiRef(emoji=emoji, reaction_id=reaction_id) except Exception as e: logger.warning(f"{platform} 预回应表情发送失败: {e}") return None - async def remove_emoji(self, event: AstrMessageEvent, emoji: str | None) -> None: + async def remove_emoji(self, event: AstrMessageEvent, ref: EmojiRef | None) -> None: """根据配置撤回表情。""" - if emoji is None: + if ref is None: return platform = event.get_platform_name() @@ -55,6 +64,6 @@ async def remove_emoji(self, event: AstrMessageEvent, emoji: str | None) -> None return try: - await event.remove_react(emoji) + await event.remove_react(ref.emoji, reaction_id=ref.reaction_id) except Exception as e: logger.warning(f"{platform} 预回应表情撤回失败: {e}") diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 11a97237b1..0bbc9e2839 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -460,11 +460,15 @@ async def react(self, emoji: str) -> None: """ await self.send(MessageChain([Plain(emoji)])) - async def remove_react(self, emoji: str) -> None: + async def remove_react(self, emoji: str, reaction_id: str | None = None) -> None: """移除消息上的表情回应。 默认实现为空操作。 如需支持平台原生的撤回表情功能,请在对应平台的子类中重写本方法。 + + Args: + emoji: 要移除的表情 + reaction_id: 平台特定的 reaction 标识符(如飞书的 reaction_id) """ pass diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index 4ccf8c2653..6da5a9af83 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -280,9 +280,11 @@ async def react(self, emoji: str) -> None: except Exception as e: logger.error(f"[Discord] 添加反应失败: {e}") - async def remove_react(self, emoji: str) -> None: + async def remove_react(self, emoji: str, reaction_id: str | None = None) -> None: """移除 bot 在原消息上的表情回应""" try: + if not hasattr(self.message_obj, "raw_message"): + return raw = self.message_obj.raw_message if hasattr(raw, "remove_reaction") and self.client.user: await cast(discord.Message, raw).remove_reaction(emoji, self.client.user) diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 78436337f2..24af3c2f59 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -554,14 +554,14 @@ async def react(self, emoji: str) -> None: response = await self.bot.im.v1.message_reaction.acreate(request) if not response.success(): logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}") - return + return None - # 保存 reaction_id 以便后续撤回 + # 返回 reaction_id 供调用方保存(如 PreAckEmojiManager 用于后续撤回) if response.data and response.data.reaction_id: - self._pre_ack_reaction_id = response.data.reaction_id + return response.data.reaction_id + return None - async def remove_react(self, emoji: str) -> None: - reaction_id = getattr(self, "_pre_ack_reaction_id", None) + async def remove_react(self, emoji: str, reaction_id: str | None = None) -> None: if not reaction_id: return diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index c0b9a7abec..ab040a9e94 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -355,7 +355,7 @@ async def react(self, emoji: str | None, big: bool = False) -> None: except Exception as e: logger.error(f"[Telegram] 添加反应失败: {e}") - async def remove_react(self, emoji: str) -> None: + async def remove_react(self, emoji: str, reaction_id: str | None = None) -> None: """移除 bot 在原消息上的表情回应(Telegram 通过设置空 reaction 列表实现)""" await self.react(None) diff --git a/tests/test_pre_ack_emoji.py b/tests/test_pre_ack_emoji.py index b4abe3fdce..59495b03fa 100644 --- a/tests/test_pre_ack_emoji.py +++ b/tests/test_pre_ack_emoji.py @@ -1,9 +1,8 @@ -import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest -from astrbot.core.pipeline.pre_ack_emoji import PreAckEmojiManager +from astrbot.core.pipeline.pre_ack_emoji import EmojiRef, PreAckEmojiManager def _make_config(platform: str, enable: bool = True, emojis: list | None = None, auto_remove: bool = True) -> dict: @@ -28,7 +27,7 @@ def _make_event(platform: str = "telegram", is_wake: bool = True) -> MagicMock: event = MagicMock() event.get_platform_name.return_value = platform event.is_at_or_wake_command = is_wake - event.react = AsyncMock() + event.react = AsyncMock(return_value=None) event.remove_react = AsyncMock() return event @@ -84,8 +83,9 @@ async def test_emoji_chosen_from_config_list(self): mgr = PreAckEmojiManager(cfg) event = _make_event("telegram") result = await mgr.add_emoji(event) - assert result in emojis - event.react.assert_called_once_with(result) + assert result is not None + assert result.emoji in emojis + event.react.assert_called_once_with(result.emoji) @pytest.mark.asyncio async def test_react_failure_returns_none(self): @@ -105,9 +105,21 @@ async def test_all_three_platforms_supported(self): mgr = PreAckEmojiManager(cfg) event = _make_event(platform) result = await mgr.add_emoji(event) - assert result == "👍" + assert result is not None + assert result.emoji == "👍" event.react.assert_called_once_with("👍") + @pytest.mark.asyncio + async def test_lark_reaction_id_stored_in_ref(self): + """飞书平台 react 返回的 reaction_id 保存在 EmojiRef 中""" + cfg = _make_config("lark") + mgr = PreAckEmojiManager(cfg) + event = _make_event("lark") + event.react.return_value = "lark_reaction_123" + result = await mgr.add_emoji(event) + assert result is not None + assert result.reaction_id == "lark_reaction_123" + class TestPreAckEmojiRemoveEmoji: """测试撤回表情逻辑""" @@ -118,8 +130,9 @@ async def test_auto_remove_true_calls_remove_react(self): cfg = _make_config("telegram", auto_remove=True) mgr = PreAckEmojiManager(cfg) event = _make_event("telegram") - await mgr.remove_emoji(event, "👍") - event.remove_react.assert_called_once_with("👍") + ref = EmojiRef(emoji="👍") + await mgr.remove_emoji(event, ref) + event.remove_react.assert_called_once_with("👍", reaction_id=None) @pytest.mark.asyncio async def test_auto_remove_false_does_not_remove(self): @@ -127,12 +140,13 @@ async def test_auto_remove_false_does_not_remove(self): cfg = _make_config("telegram", auto_remove=False) mgr = PreAckEmojiManager(cfg) event = _make_event("telegram") - await mgr.remove_emoji(event, "👍") + ref = EmojiRef(emoji="👍") + await mgr.remove_emoji(event, ref) event.remove_react.assert_not_called() @pytest.mark.asyncio - async def test_none_emoji_skips_removal(self): - """emoji 为 None 时跳过撤回""" + async def test_none_ref_skips_removal(self): + """ref 为 None 时跳过撤回""" cfg = _make_config("telegram") mgr = PreAckEmojiManager(cfg) event = _make_event("telegram") @@ -145,7 +159,18 @@ async def test_remove_failure_logs_warning(self): cfg = _make_config("telegram") mgr = PreAckEmojiManager(cfg) event = _make_event("telegram") + ref = EmojiRef(emoji="👍") event.remove_react.side_effect = Exception("API error") with patch("astrbot.core.pipeline.pre_ack_emoji.logger") as mock_logger: - await mgr.remove_emoji(event, "👍") + await mgr.remove_emoji(event, ref) mock_logger.warning.assert_called_once() + + @pytest.mark.asyncio + async def test_lark_reaction_id_passed_to_remove_react(self): + """飞书平台撤回时传递 reaction_id""" + cfg = _make_config("lark", auto_remove=True) + mgr = PreAckEmojiManager(cfg) + event = _make_event("lark") + ref = EmojiRef(emoji="Typing", reaction_id="lark_reaction_123") + await mgr.remove_emoji(event, ref) + event.remove_react.assert_called_once_with("Typing", reaction_id="lark_reaction_123") From 16d545c074cd9379b720d64d741bbfe44eccf9c5 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 21:13:23 +0800 Subject: [PATCH 11/15] fix: update react() return type annotation to str | None Address Sourcery review: Lark react() returns reaction_id but type hint was -> None. Updated base class and Lark override to -> str | None. Co-Authored-By: Claude Opus 4.6 --- astrbot/core/platform/astr_message_event.py | 8 ++++++-- astrbot/core/platform/sources/lark/lark_event.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 0bbc9e2839..b68780634c 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -451,12 +451,16 @@ async def send(self, message: MessageChain) -> None: ) self._has_send_oper = True - async def react(self, emoji: str) -> None: + async def react(self, emoji: str) -> str | None: """对消息添加表情回应。 默认实现为发送一条包含该表情的消息。 - 注意:此实现并不一定符合所有平台的原生“表情回应”行为。 + 注意:此实现并不一定符合所有平台的原生"表情回应"行为。 如需支持平台原生的消息反应功能,请在对应平台的子类中重写本方法。 + + Returns: + 平台特定的 reaction 标识符(如飞书的 reaction_id),用于后续撤回。 + 大多数平台返回 None。 """ await self.send(MessageChain([Plain(emoji)])) diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 24af3c2f59..1cb2db7087 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -535,7 +535,7 @@ async def _send_media_message( receive_id_type=receive_id_type, ) - async def react(self, emoji: str) -> None: + async def react(self, emoji: str) -> str | None: if self.bot.im is None: logger.error("[Lark] API Client im 模块未初始化,无法发送表情") return From 5dfb64d308b1f878eab4dc6e0875dc98af95a878 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 21:46:20 +0800 Subject: [PATCH 12/15] fix: remove is_at_or_wake_command guard from pre-ack emoji Root cause: add_emoji runs before _process_stages, so is_at_or_wake_command has not been set by WakingCheckStage yet. For Lark and Telegram this was always False, preventing emoji. Non-wake messages will have their emoji auto-removed in finally block after WakingCheckStage stops the event. Co-Authored-By: Claude Opus 4.6 --- astrbot/core/pipeline/pre_ack_emoji.py | 2 +- tests/test_pre_ack_emoji.py | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/astrbot/core/pipeline/pre_ack_emoji.py b/astrbot/core/pipeline/pre_ack_emoji.py index e4adfa4c05..c1b3ff96a4 100644 --- a/astrbot/core/pipeline/pre_ack_emoji.py +++ b/astrbot/core/pipeline/pre_ack_emoji.py @@ -41,7 +41,7 @@ async def add_emoji(self, event: AstrMessageEvent) -> EmojiRef | None: cfg = self._get_cfg(platform) emojis = cfg.get("emojis") or [] - if not cfg.get("enable", False) or not emojis or not event.is_at_or_wake_command: + if not cfg.get("enable", False) or not emojis: return None emoji = random.choice(emojis) diff --git a/tests/test_pre_ack_emoji.py b/tests/test_pre_ack_emoji.py index 59495b03fa..21e2e54ab3 100644 --- a/tests/test_pre_ack_emoji.py +++ b/tests/test_pre_ack_emoji.py @@ -65,16 +65,6 @@ async def test_unsupported_platform_does_not_react(self): assert result is None event.react.assert_not_called() - @pytest.mark.asyncio - async def test_not_wake_command_does_not_react(self): - """非 at/唤醒消息不贴表情""" - cfg = _make_config("telegram") - mgr = PreAckEmojiManager(cfg) - event = _make_event("telegram", is_wake=False) - result = await mgr.add_emoji(event) - assert result is None - event.react.assert_not_called() - @pytest.mark.asyncio async def test_emoji_chosen_from_config_list(self): """选出的 emoji 在配置列表内""" From 9e564762ef507d1e90f68324a4800e6917c4600b Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 21:56:50 +0800 Subject: [PATCH 13/15] fix(pipeline): prevent duplicate stage execution after onion yield When a generator stage yields, _process_stages recursively handles all subsequent stages. Without the did_yield break, the outer for-loop continued iterating and re-executed those stages. The did_yield flag breaks the outer loop after a yielding generator completes, while non-yielding generators (return before yield) continue normally. Co-Authored-By: Claude Opus 4.6 --- astrbot/core/pipeline/scheduler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index c82e4adcc5..5705a66c04 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -51,7 +51,9 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: if isinstance(coroutine, AsyncGenerator): # 如果返回的是异步生成器, 实现洋葱模型的核心 + did_yield = False async for _ in coroutine: + did_yield = True # 此处是前置处理完成后的暂停点(yield), 下面开始执行后续阶段 if event.is_stopped(): logger.debug( @@ -68,6 +70,10 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: f"阶段 {stage.__class__.__name__} 已终止事件传播。", ) break + + # 洋葱阶段已通过递归处理了后续所有阶段,跳出外层循环避免重复执行 + if did_yield: + break else: # 如果返回的是普通协程(不含yield的async函数), 则不进入下一层(基线条件) # 简单地等待它执行完成, 然后继续执行下一个阶段 From 828891fe228791921529f16ab8c4fda28c16b205 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 21:59:19 +0800 Subject: [PATCH 14/15] Revert "fix(pipeline): prevent duplicate stage execution after onion yield" This reverts commit 9e564762ef507d1e90f68324a4800e6917c4600b. --- astrbot/core/pipeline/scheduler.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 5705a66c04..c82e4adcc5 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -51,9 +51,7 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: if isinstance(coroutine, AsyncGenerator): # 如果返回的是异步生成器, 实现洋葱模型的核心 - did_yield = False async for _ in coroutine: - did_yield = True # 此处是前置处理完成后的暂停点(yield), 下面开始执行后续阶段 if event.is_stopped(): logger.debug( @@ -70,10 +68,6 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: f"阶段 {stage.__class__.__name__} 已终止事件传播。", ) break - - # 洋葱阶段已通过递归处理了后续所有阶段,跳出外层循环避免重复执行 - if did_yield: - break else: # 如果返回的是普通协程(不含yield的async函数), 则不进入下一层(基线条件) # 简单地等待它执行完成, 然后继续执行下一个阶段 From 805b75f213e3c38951d1bc02d0107fb35d2b2b81 Mon Sep 17 00:00:00 2001 From: AstrBot Maintainer Date: Thu, 26 Mar 2026 22:09:46 +0800 Subject: [PATCH 15/15] Revert "Revert "fix(pipeline): prevent duplicate stage execution after onion yield"" This reverts commit 828891fe228791921529f16ab8c4fda28c16b205. --- astrbot/core/pipeline/scheduler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index c82e4adcc5..5705a66c04 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -51,7 +51,9 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: if isinstance(coroutine, AsyncGenerator): # 如果返回的是异步生成器, 实现洋葱模型的核心 + did_yield = False async for _ in coroutine: + did_yield = True # 此处是前置处理完成后的暂停点(yield), 下面开始执行后续阶段 if event.is_stopped(): logger.debug( @@ -68,6 +70,10 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: f"阶段 {stage.__class__.__name__} 已终止事件传播。", ) break + + # 洋葱阶段已通过递归处理了后续所有阶段,跳出外层循环避免重复执行 + if did_yield: + break else: # 如果返回的是普通协程(不含yield的async函数), 则不进入下一层(基线条件) # 简单地等待它执行完成, 然后继续执行下一个阶段