diff --git a/astrbot/api/event/__init__.py b/astrbot/api/event/__init__.py index 2b8dd5a9b4..f88c609a43 100644 --- a/astrbot/api/event/__init__.py +++ b/astrbot/api/event/__init__.py @@ -5,7 +5,7 @@ MessageEventResult, ResultContentType, ) -from astrbot.core.platform import AstrMessageEvent +from astrbot.core.platform import AstrMessageEvent, RawPlatformEvent __all__ = [ "AstrMessageEvent", @@ -13,5 +13,6 @@ "EventResultType", "MessageChain", "MessageEventResult", + "RawPlatformEvent", "ResultContentType", ] diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index f5ab15ed09..273f6fb2b7 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -27,6 +27,9 @@ from astrbot.core.star.register import register_on_plugin_error as on_plugin_error from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded +from astrbot.core.star.register import ( + register_on_raw_platform_event as on_raw_platform_event, +) from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool from astrbot.core.star.register import ( register_on_waiting_llm_request as on_waiting_llm_request, @@ -59,6 +62,7 @@ "on_plugin_loaded", "on_plugin_unloaded", "on_platform_loaded", + "on_raw_platform_event", "on_waiting_llm_request", "permission_type", "platform_adapter_type", diff --git a/astrbot/api/platform/__init__.py b/astrbot/api/platform/__init__.py index 6a182c32b9..13ba009c62 100644 --- a/astrbot/api/platform/__init__.py +++ b/astrbot/api/platform/__init__.py @@ -7,6 +7,7 @@ MessageType, Platform, PlatformMetadata, + RawPlatformEvent, ) from astrbot.core.platform.register import register_platform_adapter @@ -18,5 +19,6 @@ "MessageType", "Platform", "PlatformMetadata", + "RawPlatformEvent", "register_platform_adapter", ] diff --git a/astrbot/core/pipeline/context_utils.py b/astrbot/core/pipeline/context_utils.py index 9402ce3e62..6a9154cc98 100644 --- a/astrbot/core/pipeline/context_utils.py +++ b/astrbot/core/pipeline/context_utils.py @@ -5,6 +5,7 @@ from astrbot import logger from astrbot.core.message.message_event_result import CommandResult, MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.platform.raw_platform_event import RawPlatformEvent from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry @@ -106,3 +107,43 @@ async def call_event_hook( return True return event.is_stopped() + + +async def call_raw_platform_event_hook( + event: RawPlatformEvent, + hook_type: EventType = EventType.OnRawPlatformEvent, +) -> bool: + """调用原始平台事件钩子函数。""" + handlers = star_handlers_registry.get_handlers_by_event_type( + hook_type, + plugins_name=event.plugins_name, + ) + for handler in handlers: + raw_platform_name = handler.extras_configs.get("raw_platform_name") + if raw_platform_name and raw_platform_name != event.platform_name: + continue + + raw_platform_id = handler.extras_configs.get("raw_platform_id") + if raw_platform_id and raw_platform_id != event.platform_id: + continue + + raw_event_type = handler.extras_configs.get("raw_event_type") + if raw_event_type and raw_event_type != event.event_type: + continue + + try: + assert inspect.iscoroutinefunction(handler.handler) + logger.debug( + f"hook({hook_type.name}) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}", + ) + await handler.handler(event) + except BaseException: + logger.error(traceback.format_exc()) + + if event.is_stopped(): + logger.info( + f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了原始平台事件传播。", + ) + return True + + return event.is_stopped() diff --git a/astrbot/core/platform/__init__.py b/astrbot/core/platform/__init__.py index 30b94723ed..99ea91ccf3 100644 --- a/astrbot/core/platform/__init__.py +++ b/astrbot/core/platform/__init__.py @@ -2,6 +2,7 @@ from .astrbot_message import AstrBotMessage, Group, MessageMember, MessageType from .platform import Platform from .platform_metadata import PlatformMetadata +from .raw_platform_event import RawPlatformEvent __all__ = [ "AstrBotMessage", @@ -11,4 +12,5 @@ "MessageType", "Platform", "PlatformMetadata", + "RawPlatformEvent", ] diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 68737b2bcf..516da181c1 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -96,6 +96,7 @@ async def initialize(self) -> None: # 网页聊天 webchat_inst = WebChatAdapter({}, self.settings, self.event_queue) + webchat_inst._astrbot_config = self.astrbot_config self.platform_insts.append(webchat_inst) self._start_platform_task("webchat", webchat_inst) @@ -198,6 +199,7 @@ async def load_platform(self, platform_config: dict) -> None: return cls_type = platform_cls_map[platform_config["type"]] inst: Platform = cls_type(platform_config, self.settings, self.event_queue) + inst._astrbot_config = self.astrbot_config self._inst_map[platform_config["id"]] = { "inst": inst, "client_id": inst.client_self_id, diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py index a7c181217d..72f603e90b 100644 --- a/astrbot/core/platform/platform.py +++ b/astrbot/core/platform/platform.py @@ -13,6 +13,7 @@ from .astr_message_event import AstrMessageEvent from .message_session import MessageSesion from .platform_metadata import PlatformMetadata +from .raw_platform_event import RawPlatformEvent class PlatformStatus(Enum): @@ -42,6 +43,9 @@ def __init__(self, config: dict, event_queue: Queue) -> None: self._event_queue = event_queue self.client_self_id = uuid.uuid4().hex + # 全局配置引用,由 PlatformManager 注入 + self._astrbot_config: dict | None = None + # 平台运行状态 self._status: PlatformStatus = PlatformStatus.PENDING self._errors: list[PlatformError] = [] @@ -163,3 +167,26 @@ async def webhook_callback(self, request: Any) -> Any: NotImplementedError: 平台未实现统一 Webhook 模式 """ raise NotImplementedError(f"平台 {self.meta().name} 未实现统一 Webhook 模式") + + async def emit_raw_platform_event( + self, + payload: Any, + *, + meta: dict[str, Any] | None = None, + plugins_name: list[str] | None = None, + ) -> bool: + """发射平台原始事件到框架级 hook。""" + from astrbot.core.pipeline.context_utils import call_raw_platform_event_hook + + if plugins_name is None and self._astrbot_config is not None: + plugin_set = self._astrbot_config.get("plugin_set", ["*"]) + if plugin_set != ["*"]: + plugins_name = plugin_set + + event = RawPlatformEvent( + payload=payload, + platform_meta=self.meta(), + meta=meta, + plugins_name=plugins_name, + ) + return await call_raw_platform_event_hook(event) diff --git a/astrbot/core/platform/raw_platform_event.py b/astrbot/core/platform/raw_platform_event.py new file mode 100644 index 0000000000..3ac771954d --- /dev/null +++ b/astrbot/core/platform/raw_platform_event.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from time import time +from typing import Any + +from .platform_metadata import PlatformMetadata + + +class RawPlatformEvent: + def __init__( + self, + payload: Any, + platform_meta: PlatformMetadata, + meta: dict[str, Any] | None = None, + plugins_name: list[str] | None = None, + ) -> None: + self.payload = payload + self.platform_meta = platform_meta + self.meta = meta or {} + self.created_at = time() + self.plugins_name = plugins_name + + self._extras: dict[str, Any] = {} + self._stopped = False + + # back compatibility with existing event access patterns + self.platform = platform_meta + + @property + def platform_name(self) -> str: + return self.platform_meta.name + + @property + def platform_id(self) -> str: + return self.platform_meta.id + + @property + def adapter_display_name(self) -> str: + return self.platform_meta.adapter_display_name or self.platform_meta.name + + @property + def event_type(self) -> str | None: + event_type = self.meta.get("event_type") + if event_type is None: + return None + return str(event_type) + + def get_platform_name(self) -> str: + return self.platform_name + + def get_platform_id(self) -> str: + return self.platform_id + + def stop_event(self) -> None: + self._stopped = True + + def continue_event(self) -> None: + self._stopped = False + + def is_stopped(self) -> bool: + return self._stopped + + def set_extra(self, key: str, value: Any) -> None: + self._extras[key] = value + + def get_extra(self, key: str | None = None, default=None) -> Any: + if key is None: + return self._extras + return self._extras.get(key, default) + + def clear_extra(self) -> None: + self._extras.clear() diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index 4c73fdf381..59fa0f95c9 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -158,6 +158,7 @@ async def run(self) -> None: self.config, self._event_queue, self.client, + self, ) await self.webhook_helper.initialize() diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 7af066020e..47289af2fd 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -8,6 +8,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from astrbot.api import logger +from astrbot.core.platform.platform import Platform # remove logger handler for handler in logging.root.handlers[:]: @@ -16,7 +17,11 @@ class QQOfficialWebhook: def __init__( - self, config: dict, event_queue: asyncio.Queue, botpy_client: Client + self, + config: dict, + event_queue: asyncio.Queue, + botpy_client: Client, + platform: Platform, ) -> None: self.appid = config["appid"] self.secret = config["secret"] @@ -39,6 +44,7 @@ def __init__( ) self.client = botpy_client self.event_queue = event_queue + self.platform = platform self.shutdown_event = asyncio.Event() # Deduplication cache for webhook retry callbacks. self._seen_event_ids: dict[str, float] = {} @@ -104,10 +110,18 @@ async def handle_callback(self, request) -> dict: opcode = msg.get("op") data = msg.get("d") + context = { + "opcode": opcode, + "event_type": event, + "is_validation": opcode == 13, + "request_path": getattr(request, "path", ""), + "request_method": getattr(request, "method", ""), + } + stopped = await self.platform.emit_raw_platform_event(msg, meta=context) + if opcode == 13: # validation signed = await self.webhook_validation(cast(dict, data)) - print(signed) return signed event_id = msg.get("id") @@ -126,7 +140,7 @@ async def handle_callback(self, request) -> dict: return {"opcode": 12} self._seen_event_ids[event_id] = now - if event and opcode == BotWebSocket.WS_DISPATCH_EVENT: + if not stopped and event and opcode == BotWebSocket.WS_DISPATCH_EVENT: event = msg["t"].lower() try: func = self._connection.parser[event] diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 5e99948cd2..edf48e526c 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -16,6 +16,7 @@ register_on_plugin_error, register_on_plugin_loaded, register_on_plugin_unloaded, + register_on_raw_platform_event, register_on_using_llm_tool, register_on_waiting_llm_request, register_permission_type, @@ -39,6 +40,7 @@ "register_on_plugin_loaded", "register_on_plugin_unloaded", "register_on_platform_loaded", + "register_on_raw_platform_event", "register_on_waiting_llm_request", "register_permission_type", "register_platform_adapter_type", diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 1385b50566..5f10a4f3d8 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -338,6 +338,36 @@ def decorator(awaitable): return decorator +def register_on_raw_platform_event( + platform_name: str | None = None, + platform_id: str | None = None, + event_type: str | None = None, + **kwargs, +): + """当平台接收到原始事件时。 + + Hook 参数: + event + + 说明: + 该 hook 不经过消息 pipeline,直接接收平台原始 payload。 + 首版建议通过 platform_name/platform_id/event_type 做精确匹配。 + """ + + if platform_name is not None: + kwargs["raw_platform_name"] = platform_name + if platform_id is not None: + kwargs["raw_platform_id"] = platform_id + if event_type is not None: + kwargs["raw_event_type"] = event_type + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnRawPlatformEvent, **kwargs) + return awaitable + + return decorator + + def register_on_plugin_error(**kwargs): """当插件处理消息异常时触发。 diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index d28ac726ae..aa9c392e89 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -121,6 +121,14 @@ def get_handlers_by_event_type( plugins_name: list[str] | None = None, ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnRawPlatformEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload def get_handlers_by_event_type( self, @@ -221,6 +229,7 @@ class EventType(enum.Enum): OnPluginErrorEvent = enum.auto() # 插件处理消息异常时 OnPluginLoadedEvent = enum.auto() # 插件加载完成 OnPluginUnloadedEvent = enum.auto() # 插件卸载完成 + OnRawPlatformEvent = enum.auto() # 收到平台原始事件 H = TypeVar("H", bound=Callable[..., Any]) diff --git a/tests/unit/test_raw_platform_event.py b/tests/unit/test_raw_platform_event.py new file mode 100644 index 0000000000..df92025856 --- /dev/null +++ b/tests/unit/test_raw_platform_event.py @@ -0,0 +1,374 @@ +"""Tests for RawPlatformEvent: stop_event pipeline interception and plugin_set filtering.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from astrbot.core.platform.platform import PlatformStatus +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.platform.raw_platform_event import RawPlatformEvent +from astrbot.core.star.star import StarMetadata +from astrbot.core.star.star_handler import EventType, StarHandlerMetadata + + +def _make_platform_meta( + name: str = "qq_official_webhook", platform_id: str = "qq1" +) -> PlatformMetadata: + return PlatformMetadata(name=name, description="test", id=platform_id) + + +def _make_handler( + handler_fn: AsyncMock | None = None, + module_path: str = "test_module", + extras: dict | None = None, +) -> StarHandlerMetadata: + if handler_fn is None: + handler_fn = AsyncMock() + return StarHandlerMetadata( + event_type=EventType.OnRawPlatformEvent, + handler_full_name=f"{module_path}_{handler_fn.__name__ or 'handler'}", + handler_name=handler_fn.__name__ or "handler", + handler_module_path=module_path, + handler=handler_fn, + event_filters=[], + extras_configs=extras or {}, + ) + + +def _make_star_metadata( + name: str = "test-plugin", + module_path: str = "test_module", + activated: bool = True, + reserved: bool = False, +) -> StarMetadata: + return StarMetadata( + name=name, + module_path=module_path, + activated=activated, + reserved=reserved, + ) + + +class TestCallRawPlatformEventHook: + """Tests for call_raw_platform_event_hook in context_utils.""" + + @pytest.mark.asyncio + async def test_basic_handler_called(self): + """Scene 1: Handler is called with correct event.""" + handler_fn = AsyncMock() + handler = _make_handler(handler_fn) + star_meta = _make_star_metadata() + + mock_registry = MagicMock() + mock_registry.get_handlers_by_event_type.return_value = [handler] + + event = RawPlatformEvent( + payload={"op": 0, "t": "MESSAGE_CREATE", "d": {"content": "hello"}}, + platform_meta=_make_platform_meta(), + meta={"event_type": "MESSAGE_CREATE"}, + ) + + with ( + patch( + "astrbot.core.pipeline.context_utils.star_handlers_registry", + mock_registry, + ), + patch( + "astrbot.core.pipeline.context_utils.star_map", + {"test_module": star_meta}, + ), + ): + from astrbot.core.pipeline.context_utils import ( + call_raw_platform_event_hook, + ) + + result = await call_raw_platform_event_hook(event) + + handler_fn.assert_called_once_with(event) + assert result is False + + @pytest.mark.asyncio + async def test_stop_event_blocks_subsequent_handlers(self): + """Scene 2: stop_event() prevents lower-priority handlers from running.""" + + async def stopping_handler(event): + event.stop_event() + + high_priority_fn = AsyncMock(side_effect=stopping_handler) + low_priority_fn = AsyncMock() + + handler_high = _make_handler(high_priority_fn, module_path="mod_a") + handler_low = _make_handler(low_priority_fn, module_path="mod_b") + star_a = _make_star_metadata(name="plugin-a", module_path="mod_a") + star_b = _make_star_metadata(name="plugin-b", module_path="mod_b") + + mock_registry = MagicMock() + mock_registry.get_handlers_by_event_type.return_value = [ + handler_high, + handler_low, + ] + + event = RawPlatformEvent( + payload={"op": 0}, + platform_meta=_make_platform_meta(), + ) + + with ( + patch( + "astrbot.core.pipeline.context_utils.star_handlers_registry", + mock_registry, + ), + patch( + "astrbot.core.pipeline.context_utils.star_map", + {"mod_a": star_a, "mod_b": star_b}, + ), + ): + from astrbot.core.pipeline.context_utils import ( + call_raw_platform_event_hook, + ) + + result = await call_raw_platform_event_hook(event) + + high_priority_fn.assert_called_once() + low_priority_fn.assert_not_called() + assert result is True + + @pytest.mark.asyncio + async def test_platform_name_filter(self): + """Scene 3: Handler with raw_platform_name filters non-matching events.""" + handler_fn = AsyncMock() + handler = _make_handler( + handler_fn, extras={"raw_platform_name": "qq_official_webhook"} + ) + star_meta = _make_star_metadata() + + mock_registry = MagicMock() + mock_registry.get_handlers_by_event_type.return_value = [handler] + + event = RawPlatformEvent( + payload={}, + platform_meta=_make_platform_meta(name="telegram"), + ) + + with ( + patch( + "astrbot.core.pipeline.context_utils.star_handlers_registry", + mock_registry, + ), + patch( + "astrbot.core.pipeline.context_utils.star_map", + {"test_module": star_meta}, + ), + ): + from astrbot.core.pipeline.context_utils import ( + call_raw_platform_event_hook, + ) + + result = await call_raw_platform_event_hook(event) + + handler_fn.assert_not_called() + assert result is False + + @pytest.mark.asyncio + async def test_platform_id_filter(self): + """Scene 4: Handler with raw_platform_id filters non-matching events.""" + handler_fn = AsyncMock() + handler = _make_handler(handler_fn, extras={"raw_platform_id": "qq1"}) + star_meta = _make_star_metadata() + + mock_registry = MagicMock() + mock_registry.get_handlers_by_event_type.return_value = [handler] + + event = RawPlatformEvent( + payload={}, + platform_meta=_make_platform_meta(platform_id="qq2"), + ) + + with ( + patch( + "astrbot.core.pipeline.context_utils.star_handlers_registry", + mock_registry, + ), + patch( + "astrbot.core.pipeline.context_utils.star_map", + {"test_module": star_meta}, + ), + ): + from astrbot.core.pipeline.context_utils import ( + call_raw_platform_event_hook, + ) + + result = await call_raw_platform_event_hook(event) + + handler_fn.assert_not_called() + assert result is False + + @pytest.mark.asyncio + async def test_event_type_filter(self): + """Scene 5: Handler with raw_event_type filters non-matching events.""" + handler_fn = AsyncMock() + handler = _make_handler( + handler_fn, extras={"raw_event_type": "GROUP_AT_MESSAGE_CREATE"} + ) + star_meta = _make_star_metadata() + + mock_registry = MagicMock() + mock_registry.get_handlers_by_event_type.return_value = [handler] + + event = RawPlatformEvent( + payload={}, + platform_meta=_make_platform_meta(), + meta={"event_type": "INTERACTION_CREATE"}, + ) + + with ( + patch( + "astrbot.core.pipeline.context_utils.star_handlers_registry", + mock_registry, + ), + patch( + "astrbot.core.pipeline.context_utils.star_map", + {"test_module": star_meta}, + ), + ): + from astrbot.core.pipeline.context_utils import ( + call_raw_platform_event_hook, + ) + + result = await call_raw_platform_event_hook(event) + + handler_fn.assert_not_called() + assert result is False + + +class TestEmitRawPlatformEventPluginSet: + """Tests for Platform.emit_raw_platform_event plugin_set filtering.""" + + def _make_platform(self, astrbot_config: dict | None = None): + """Create a concrete Platform subclass for testing.""" + from astrbot.core.platform.platform import Platform + + class TestPlatform(Platform): + async def run(self): + pass + + def meta(self): + return _make_platform_meta() + + inst = TestPlatform.__new__(TestPlatform) + inst.config = {} + inst._event_queue = asyncio.Queue() + inst.client_self_id = "test" + inst._status = PlatformStatus.PENDING + inst._errors = [] + inst._started_at = None + inst._astrbot_config = astrbot_config + return inst + + @pytest.mark.asyncio + async def test_plugin_set_whitelist_passes(self): + """Scene 6: Plugin in whitelist — handler called.""" + platform = self._make_platform({"plugin_set": ["test-plugin"]}) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=False, + ) as mock_hook: + await platform.emit_raw_platform_event({"data": 1}) + + event = mock_hook.call_args[0][0] + assert event.plugins_name == ["test-plugin"] + + @pytest.mark.asyncio + async def test_plugin_set_whitelist_blocks(self): + """Scene 7: Plugin not in whitelist — plugins_name set to filter.""" + platform = self._make_platform({"plugin_set": ["other-plugin"]}) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=False, + ) as mock_hook: + await platform.emit_raw_platform_event({"data": 1}) + + event = mock_hook.call_args[0][0] + assert event.plugins_name == ["other-plugin"] + + @pytest.mark.asyncio + async def test_plugin_set_wildcard_no_filter(self): + """Scene 8: plugin_set=["*"] — plugins_name is None (no filtering).""" + platform = self._make_platform({"plugin_set": ["*"]}) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=False, + ) as mock_hook: + await platform.emit_raw_platform_event({"data": 1}) + + event = mock_hook.call_args[0][0] + assert event.plugins_name is None + + @pytest.mark.asyncio + async def test_no_astrbot_config_no_filter(self): + """Scene 9: _astrbot_config=None — plugins_name is None (no filtering).""" + platform = self._make_platform(None) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=False, + ) as mock_hook: + await platform.emit_raw_platform_event({"data": 1}) + + event = mock_hook.call_args[0][0] + assert event.plugins_name is None + + @pytest.mark.asyncio + async def test_explicit_plugins_name_overrides_config(self): + """Scene 10: Explicit plugins_name takes precedence over config.""" + platform = self._make_platform({"plugin_set": ["*"]}) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=False, + ) as mock_hook: + await platform.emit_raw_platform_event( + {"data": 1}, plugins_name=["specific"] + ) + + event = mock_hook.call_args[0][0] + assert event.plugins_name == ["specific"] + + @pytest.mark.asyncio + async def test_emit_raw_platform_event_propagates_true(self): + """Scene 11: emit_raw_platform_event propagates True from hook.""" + platform = self._make_platform({"plugin_set": ["*"]}) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=True, + ) as mock_hook: + result = await platform.emit_raw_platform_event({"data": 1}) + + assert result is True + mock_hook.assert_awaited_once() + + @pytest.mark.asyncio + async def test_emit_raw_platform_event_propagates_false(self): + """Scene 12: emit_raw_platform_event propagates False from hook.""" + platform = self._make_platform({"plugin_set": ["*"]}) + + with patch( + "astrbot.core.pipeline.context_utils.call_raw_platform_event_hook", + new_callable=AsyncMock, + return_value=False, + ) as mock_hook: + result = await platform.emit_raw_platform_event({"data": 1}) + + assert result is False + mock_hook.assert_awaited_once()