diff --git a/astrbot/api/event/__init__.py b/astrbot/api/event/__init__.py index 2b8dd5a9b4..8fa46007f8 100644 --- a/astrbot/api/event/__init__.py +++ b/astrbot/api/event/__init__.py @@ -7,8 +7,11 @@ ) from astrbot.core.platform import AstrMessageEvent +from .qqofficial_interaction import QQOfficialInteractionResultCode + __all__ = [ "AstrMessageEvent", + "QQOfficialInteractionResultCode", "CommandResult", "EventResultType", "MessageChain", diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index 650bce0425..2b83431294 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -29,6 +29,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_qqofficial_interaction as on_qqofficial_interaction, +) 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, @@ -63,6 +66,7 @@ "on_plugin_loaded", "on_plugin_unloaded", "on_platform_loaded", + "on_qqofficial_interaction", "on_waiting_llm_request", "permission_type", "platform_adapter_type", diff --git a/astrbot/api/event/qqofficial_interaction.py b/astrbot/api/event/qqofficial_interaction.py new file mode 100644 index 0000000000..39dc10d2fb --- /dev/null +++ b/astrbot/api/event/qqofficial_interaction.py @@ -0,0 +1,14 @@ +"""Public types for QQ Official callback-button interactions.""" + +from enum import IntEnum + + +class QQOfficialInteractionResultCode(IntEnum): + """QQ Official callback-button acknowledgement result codes.""" + + SUCCESS = 0 + FAILED = 1 + RATE_LIMITED = 2 + DUPLICATE = 3 + FORBIDDEN = 4 + ADMIN_ONLY = 5 diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 66d2fc3472..9e68ddacab 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -16,7 +16,7 @@ from botpy.gateway import BotWebSocket from astrbot import logger -from astrbot.api.event import MessageChain +from astrbot.api.event import MessageChain, QQOfficialInteractionResultCode from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video from astrbot.api.platform import ( AstrBotMessage, @@ -27,6 +27,7 @@ ) from astrbot.core.message.components import BaseMessageComponent from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.star.star_handler import EventType, star_handlers_registry from astrbot.core.utils.media_utils import MediaResolver from ...register import register_platform_adapter @@ -254,6 +255,10 @@ def _commit(self, abm: AstrBotMessage) -> None: self.platform.remember_session_message_id(abm.session_id, abm.message_id) self.platform.commit_event(self.platform.create_event(abm)) + async def on_interaction_create(self, interaction: Any) -> None: + """Forward QQ Official callback-button interactions to the platform.""" + await self.platform.dispatch_interaction(interaction) + async def bot_connect(self, session) -> None: logger.info("[QQOfficial] Websocket session starting.") @@ -299,11 +304,13 @@ def __init__( public_messages=True, public_guild_messages=True, direct_message=guild_dm, + interaction=True, ) else: self.intents = botpy.Intents( public_guild_messages=True, direct_message=guild_dm, + interaction=True, ) self.client = botClient( intents=self.intents, @@ -321,6 +328,81 @@ def __init__( self.test_mode = os.environ.get("TEST_MODE", "off") == "on" + @staticmethod + def _normalize_interaction_result_code( + result: Any, + handler_name: str | None = None, + ) -> QQOfficialInteractionResultCode | None: + """Convert a plugin result into a valid QQ Official acknowledgement code. + + Args: + result: The result returned by an interaction handler. + handler_name: Fully qualified name of the handler that returned the result. + + Returns: + A valid QQ Official result code, or None when the result is absent or invalid. + """ + if result is None: + return None + if isinstance(result, int) and not isinstance(result, bool): + try: + return QQOfficialInteractionResultCode(result) + except ValueError: + pass + logger.debug( + "QQ Official interaction handler returned an invalid result code " + f"from {handler_name or ''}: {result!r}" + ) + return None + + async def _acknowledge_interaction( + self, + interaction: Any, + result_code: QQOfficialInteractionResultCode, + ) -> None: + """Acknowledge a QQ Official interaction. + + Args: + interaction: The botpy interaction object received from the gateway. + result_code: The result code returned to QQ. + """ + interaction_id = str(getattr(interaction, "id", "")) + if not interaction_id: + logger.warning("QQ Official interaction has no id; cannot acknowledge it.") + return + try: + await self.client.api.on_interaction_result(interaction_id, result_code) + except Exception: + logger.exception( + f"Failed to acknowledge QQ Official interaction: {interaction_id}" + ) + + async def dispatch_interaction(self, interaction: Any) -> None: + """Dispatch a QQ Official callback-button interaction and acknowledge it. + + Args: + interaction: The botpy interaction object received from the gateway. + """ + result_code = QQOfficialInteractionResultCode.FAILED + for handler in star_handlers_registry.get_handlers_by_event_type( + EventType.OnQQOfficialInteractionEvent + ): + try: + result = await handler.handler(interaction) + except Exception: + logger.exception( + f"QQ Official interaction handler failed: {handler.handler_full_name}" + ) + continue + normalized_result = self._normalize_interaction_result_code( + result, + getattr(handler, "handler_full_name", None), + ) + if normalized_result is not None: + result_code = normalized_result + break + await self._acknowledge_interaction(interaction, result_code) + async def send_by_session( self, session: MessageSesion, diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 2363c722ac..5c8148a9db 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -18,6 +18,7 @@ register_on_plugin_error, register_on_plugin_loaded, register_on_plugin_unloaded, + register_on_qqofficial_interaction, register_on_using_llm_tool, register_on_waiting_llm_request, register_permission_type, @@ -43,6 +44,7 @@ "register_on_plugin_loaded", "register_on_plugin_unloaded", "register_on_platform_loaded", + "register_on_qqofficial_interaction", "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 45d91bf15e..0e2eb8b730 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -354,6 +354,23 @@ def decorator(awaitable): return decorator +def register_on_qqofficial_interaction(**kwargs): + """Register a QQ Official callback-button interaction handler. + + The handler receives a botpy interaction object and should return an + QQOfficialInteractionResultCode or an integer from 0 to 5. Return None when the interaction is + not handled so later handlers can inspect it. + """ + + def decorator(awaitable): + _ = get_handler_or_create( + awaitable, EventType.OnQQOfficialInteractionEvent, **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 ea87e57850..2ca16dbcbc 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -224,6 +224,7 @@ class EventType(enum.Enum): OnAstrBotLoadedEvent = enum.auto() # AstrBot 加载完成 OnPlatformLoadedEvent = enum.auto() # 平台加载完成 + OnQQOfficialInteractionEvent = enum.auto() # QQ Official callback button AdapterMessageEvent = enum.auto() # 收到适配器发来的消息 OnWaitingLLMRequestEvent = enum.auto() # 等待调用 LLM(在获取锁之前,仅通知) diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md index 15c958de7d..029eba8268 100644 --- a/docs/en/dev/star/guides/listen-message-event.md +++ b/docs/en/dev/star/guides/listen-message-event.md @@ -485,3 +485,30 @@ async def check_ok(self, event: AstrMessageEvent): When event propagation is stopped, all subsequent steps will not be executed. Assuming there's a plugin A, after A terminates event propagation, all subsequent operations will not be executed, such as executing other plugins' handlers or requesting the LLM. + +## QQ Official Button Interactions + +QQ Official callback buttons (`action.type = 1`) are delivered as WebSocket interaction events. They do not enter the normal message, command, or LLM pipeline. Plugins can handle them with `on_qqofficial_interaction`. + +A handler receives the qq-botpy interaction object. Return `QQOfficialInteractionResultCode` or an integer from `0` to `5` as the QQ acknowledgement code, or return `None` when the click does not belong to the plugin so later enabled handlers can inspect it. The first valid result is acknowledged to QQ; an unhandled interaction receives the `FAILED` code. + +```python +from typing import Any + +from astrbot.api.event import QQOfficialInteractionResultCode, filter + + +@filter.on_qqofficial_interaction() +async def on_qqofficial_interaction( + self, interaction: Any +) -> QQOfficialInteractionResultCode | None: + resolved = getattr(getattr(interaction, "data", None), "resolved", {}) + button_data = getattr(resolved, "button_data", None) + if button_data != "demo:confirm": + return None + + # Start or schedule the real operation here. Return promptly so QQ can stop loading. + return QQOfficialInteractionResultCode.SUCCESS +``` + +Acknowledgement codes use `QQOfficialInteractionResultCode`: `SUCCESS` (0), `FAILED` (1), `RATE_LIMITED` (2), `DUPLICATE` (3), `FORBIDDEN` (4), and `ADMIN_ONLY` (5). This feature is available only on the QQ Official WebSocket adapter; the QQ Official Webhook adapter does not currently dispatch interaction events and is not supported. diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md index 18d1f5f2cc..59917d884c 100644 --- a/docs/zh/dev/star/guides/listen-message-event.md +++ b/docs/zh/dev/star/guides/listen-message-event.md @@ -486,3 +486,30 @@ async def check_ok(self, event: AstrMessageEvent): 当事件停止传播,后续所有步骤将不会被执行。 假设有一个插件 A,A 终止事件传播之后所有后续操作都不会执行,比如执行其它插件的 handler、请求 LLM。 + +## QQ 官方按钮回调 + +QQ 官方机器人的回调按钮(`action.type = 1`)会通过 WebSocket 发送 interaction 事件,不会作为普通消息进入 LLM 或命令管线。插件可使用 `on_qqofficial_interaction` 处理点击事件。 + +处理器接收 qq-botpy 的 interaction 对象。返回 `QQOfficialInteractionResultCode` 或 `0` 到 `5` 的整数作为 QQ 回执码;返回 `None` 表示当前插件不处理该点击,后续已启用的处理器可以继续匹配。第一个有效返回值会结束处理并回执 QQ;没有插件处理时返回失败码 `FAILED`。 + +```python +from typing import Any + +from astrbot.api.event import QQOfficialInteractionResultCode, filter + + +@filter.on_qqofficial_interaction() +async def on_qqofficial_interaction( + self, interaction: Any +) -> QQOfficialInteractionResultCode | None: + resolved = getattr(getattr(interaction, "data", None), "resolved", {}) + button_data = getattr(resolved, "button_data", None) + if button_data != "demo:confirm": + return None + + # Start or schedule the real operation here. Return promptly so QQ can stop loading. + return QQOfficialInteractionResultCode.SUCCESS +``` + +回执码使用 `QQOfficialInteractionResultCode`:`SUCCESS`(0)、`FAILED`(1)、`RATE_LIMITED`(2)、`DUPLICATE`(3)、`FORBIDDEN`(4)和 `ADMIN_ONLY`(5)。该功能仅适用于 QQ 官方 WebSocket 适配器;QQ 官方 Webhook 适配器目前不会分发 interaction 事件,暂不支持此功能。 diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index ee064646a9..99d2f7628f 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -2,14 +2,14 @@ import re from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import botpy import botpy.message import pytest from botpy import ConnectionSession -from astrbot.api.event import MessageChain +from astrbot.api.event import MessageChain, QQOfficialInteractionResultCode from astrbot.api.message_components import At, Image, Plain, Reply from astrbot.core.message.message_event_result import ( MessageEventResult, @@ -29,6 +29,7 @@ from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_adapter import ( QQOfficialWebhookPlatformAdapter, ) +from astrbot.core.star.star_handler import EventType, star_handlers_registry def _make_group_payload( @@ -429,3 +430,122 @@ async def test_result_decorate_segments_qqofficial_ws_plain_result(): "第一段", "第二段", ] + + +def test_ws_interaction_result_code_rejects_boolean(): + assert QQOfficialPlatformAdapter._normalize_interaction_result_code(True) is None + + +def _make_qqofficial_adapter() -> QQOfficialPlatformAdapter: + return QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + + +@pytest.mark.asyncio +async def test_ws_qqofficial_enables_interaction_intent(): + adapter = _make_qqofficial_adapter() + + assert adapter.intents.interaction is True + + +@pytest.mark.asyncio +async def test_ws_client_forwards_interaction_to_platform(): + platform = SimpleNamespace(dispatch_interaction=AsyncMock()) + client = QQOfficialBotClient( + intents=botpy.Intents(interaction=True), + bot_log=False, + ) + client.set_platform(platform) + interaction = SimpleNamespace(id="interaction-1") + + await client.on_interaction_create(interaction) + + platform.dispatch_interaction.assert_awaited_once_with(interaction) + + +@pytest.mark.asyncio +async def test_ws_interaction_dispatch_acknowledges_first_handler_result(monkeypatch): + adapter = _make_qqofficial_adapter() + adapter.client.api = SimpleNamespace(on_interaction_result=AsyncMock()) + interaction = SimpleNamespace(id="interaction-1") + # Covers integer subclasses returned by third-party libraries, e.g. numpy.int64. + result_code = type("ResultCode", (int,), {})( + QQOfficialInteractionResultCode.SUCCESS + ) + handler = SimpleNamespace( + handler=AsyncMock(return_value=result_code), + handler_full_name="test.callback_handler", + ) + requested_event_types: list[EventType] = [] + + def get_handlers(event_type: EventType): + requested_event_types.append(event_type) + return [handler] + + monkeypatch.setattr( + star_handlers_registry, + "get_handlers_by_event_type", + get_handlers, + ) + + await adapter.dispatch_interaction(interaction) + + assert requested_event_types == [EventType.OnQQOfficialInteractionEvent] + handler.handler.assert_awaited_once_with(interaction) + adapter.client.api.on_interaction_result.assert_awaited_once_with( + "interaction-1", QQOfficialInteractionResultCode.SUCCESS + ) + + +@pytest.mark.asyncio +async def test_ws_interaction_dispatch_acknowledges_failure_when_unhandled(monkeypatch): + adapter = _make_qqofficial_adapter() + adapter.client.api = SimpleNamespace(on_interaction_result=AsyncMock()) + monkeypatch.setattr( + star_handlers_registry, + "get_handlers_by_event_type", + lambda event_type: [], + ) + + await adapter.dispatch_interaction(SimpleNamespace(id="interaction-2")) + + adapter.client.api.on_interaction_result.assert_awaited_once_with( + "interaction-2", QQOfficialInteractionResultCode.FAILED + ) + + +@pytest.mark.asyncio +async def test_ws_interaction_dispatch_logs_invalid_result_handler(monkeypatch): + adapter = _make_qqofficial_adapter() + adapter.client.api = SimpleNamespace(on_interaction_result=AsyncMock()) + debug = MagicMock() + handler = SimpleNamespace( + handler=AsyncMock(return_value="invalid"), + handler_full_name="test.invalid_callback_handler", + ) + monkeypatch.setattr( + star_handlers_registry, + "get_handlers_by_event_type", + lambda event_type: [handler], + ) + monkeypatch.setattr( + "astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter.logger.debug", + debug, + ) + + await adapter.dispatch_interaction(SimpleNamespace(id="interaction-3")) + + debug.assert_called_once() + assert "test.invalid_callback_handler" in debug.call_args.args[0] + adapter.client.api.on_interaction_result.assert_awaited_once_with( + "interaction-3", QQOfficialInteractionResultCode.FAILED + )