From 10d0a4c58346c7ccdd1d2a263a54deba9be4c27c Mon Sep 17 00:00:00 2001 From: armjukl Date: Wed, 22 Jul 2026 17:46:37 +0800 Subject: [PATCH 1/6] feat: support QQ official button interactions --- astrbot/api/event/filter/__init__.py | 4 + .../qqofficial/qqofficial_platform_adapter.py | 47 ++++++++ astrbot/core/star/register/__init__.py | 2 + astrbot/core/star/register/star_handler.py | 17 +++ astrbot/core/star/star_handler.py | 1 + .../dev/star/guides/listen-message-event.md | 25 +++++ .../dev/star/guides/listen-message-event.md | 25 +++++ tests/test_qqofficial_group_message_create.py | 100 ++++++++++++++++++ 8 files changed, 221 insertions(+) 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/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 66d2fc3472..ea0465c570 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -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,46 @@ def __init__( self.test_mode = os.environ.get("TEST_MODE", "off") == "on" + 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 = 1 + try: + 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 + if result is None: + continue + if type(result) is int and 0 <= result <= 5: + result_code = result + break + logger.warning( + f"QQ Official interaction handler returned an invalid result code: {result!r}" + ) + finally: + 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 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..73bbca3340 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 + interaction result code 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..6478c082c8 100644 --- a/docs/en/dev/star/guides/listen-message-event.md +++ b/docs/en/dev/star/guides/listen-message-event.md @@ -485,3 +485,28 @@ 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 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 failure code `1`. + +```python +from typing import Any + +from astrbot.api.event import filter + + +@filter.on_qqofficial_interaction() +async def on_qqofficial_interaction(self, interaction: Any) -> int | 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 0 +``` + +Acknowledgement codes are: `0` success, `1` failed, `2` rate limited, `3` duplicate, `4` forbidden, and `5` administrator only. This feature is available only on the QQ Official WebSocket adapter. diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md index 18d1f5f2cc..743e548038 100644 --- a/docs/zh/dev/star/guides/listen-message-event.md +++ b/docs/zh/dev/star/guides/listen-message-event.md @@ -486,3 +486,28 @@ 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 对象。返回 `0` 到 `5` 的整数作为 QQ 回执码;返回 `None` 表示当前插件不处理该点击,后续已启用的处理器可以继续匹配。第一个有效返回值会结束处理并回执 QQ;没有插件处理时返回失败码 `1`。 + +```python +from typing import Any + +from astrbot.api.event import filter + + +@filter.on_qqofficial_interaction() +async def on_qqofficial_interaction(self, interaction: Any) -> int | 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 0 +``` + +回执码为:`0` 成功、`1` 操作失败、`2` 操作频繁、`3` 重复操作、`4` 无权限、`5` 仅管理员可操作。该功能仅适用于 QQ 官方 WebSocket 适配器。 diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index ee064646a9..ec195072df 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -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,102 @@ async def test_result_decorate_segments_qqofficial_ws_plain_result(): "第一段", "第二段", ] + + +@pytest.mark.asyncio +async def test_ws_qqofficial_enables_interaction_intent(): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + + 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 = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + adapter.client.api = SimpleNamespace(on_interaction_result=AsyncMock()) + interaction = SimpleNamespace(id="interaction-1") + handler = SimpleNamespace( + handler=AsyncMock(return_value=0), + 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", 0 + ) + + +@pytest.mark.asyncio +async def test_ws_interaction_dispatch_acknowledges_failure_when_unhandled(monkeypatch): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + 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", 1 + ) From b44f0ab608f5beeaa53db604d47006acbcc6470e Mon Sep 17 00:00:00 2001 From: armjukl Date: Wed, 22 Jul 2026 18:06:48 +0800 Subject: [PATCH 2/6] refactor: clarify QQ interaction result handling --- astrbot/api/event/__init__.py | 3 + astrbot/api/event/qqofficial_interaction.py | 14 +++ .../qqofficial/qqofficial_platform_adapter.py | 89 ++++++++++++------- astrbot/core/star/register/star_handler.py | 2 +- .../dev/star/guides/listen-message-event.md | 25 ++++++ .../dev/star/guides/listen-message-event.md | 25 ++++++ tests/test_qqofficial_group_message_create.py | 48 ++++------ 7 files changed, 146 insertions(+), 60 deletions(-) create mode 100644 astrbot/api/event/qqofficial_interaction.py 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/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 ea0465c570..5494940e0a 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, @@ -328,45 +328,74 @@ def __init__( self.test_mode = os.environ.get("TEST_MODE", "off") == "on" + @staticmethod + def _normalize_interaction_result_code( + result: Any, + ) -> QQOfficialInteractionResultCode | None: + """Convert a plugin result into a valid QQ Official acknowledgement code. + + Args: + result: The result returned by an interaction handler. + + 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.warning( + f"QQ Official interaction handler returned an invalid result code: {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 = 1 - try: - 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 - if result is None: - continue - if type(result) is int and 0 <= result <= 5: - result_code = result - break - logger.warning( - f"QQ Official interaction handler returned an invalid result code: {result!r}" - ) - finally: - interaction_id = str(getattr(interaction, "id", "")) - if not interaction_id: - logger.warning( - "QQ Official interaction has no id; cannot acknowledge it." - ) - return + result_code = QQOfficialInteractionResultCode.FAILED + for handler in star_handlers_registry.get_handlers_by_event_type( + EventType.OnQQOfficialInteractionEvent + ): try: - await self.client.api.on_interaction_result(interaction_id, result_code) + result = await handler.handler(interaction) except Exception: logger.exception( - f"Failed to acknowledge QQ Official interaction: {interaction_id}" + f"QQ Official interaction handler failed: {handler.handler_full_name}" ) + continue + normalized_result = self._normalize_interaction_result_code(result) + if normalized_result is not None: + result_code = normalized_result + break + await self._acknowledge_interaction(interaction, result_code) async def send_by_session( self, diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 73bbca3340..0e2eb8b730 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -358,7 +358,7 @@ 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 - interaction result code from 0 to 5. Return None when the interaction is + QQOfficialInteractionResultCode or an integer from 0 to 5. Return None when the interaction is not handled so later handlers can inspect it. """ diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md index 6478c082c8..1d2fa58849 100644 --- a/docs/en/dev/star/guides/listen-message-event.md +++ b/docs/en/dev/star/guides/listen-message-event.md @@ -510,3 +510,28 @@ async def on_qqofficial_interaction(self, interaction: Any) -> int | None: ``` Acknowledgement codes are: `0` success, `1` failed, `2` rate limited, `3` duplicate, `4` forbidden, and `5` administrator only. This feature is available only on the QQ Official WebSocket adapter. + +## 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) -> int | 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. diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md index 743e548038..df99c671da 100644 --- a/docs/zh/dev/star/guides/listen-message-event.md +++ b/docs/zh/dev/star/guides/listen-message-event.md @@ -511,3 +511,28 @@ async def on_qqofficial_interaction(self, interaction: Any) -> int | None: ``` 回执码为:`0` 成功、`1` 操作失败、`2` 操作频繁、`3` 重复操作、`4` 无权限、`5` 仅管理员可操作。该功能仅适用于 QQ 官方 WebSocket 适配器。 + +## 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) -> int | 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 适配器。 diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index ec195072df..377ddb073e 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -9,7 +9,7 @@ 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, @@ -432,9 +432,11 @@ async def test_result_decorate_segments_qqofficial_ws_plain_result(): ] -@pytest.mark.asyncio -async def test_ws_qqofficial_enables_interaction_intent(): - adapter = QQOfficialPlatformAdapter( +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", @@ -446,6 +448,11 @@ async def test_ws_qqofficial_enables_interaction_intent(): asyncio.Queue(), ) + +@pytest.mark.asyncio +async def test_ws_qqofficial_enables_interaction_intent(): + adapter = _make_qqofficial_adapter() + assert adapter.intents.interaction is True @@ -466,21 +473,14 @@ async def test_ws_client_forwards_interaction_to_platform(): @pytest.mark.asyncio async def test_ws_interaction_dispatch_acknowledges_first_handler_result(monkeypatch): - adapter = QQOfficialPlatformAdapter( - { - "id": "qq-official-test", - "appid": "123", - "secret": "secret", - "enable_group_c2c": True, - "enable_guild_direct_message": False, - }, - {}, - asyncio.Queue(), - ) + adapter = _make_qqofficial_adapter() adapter.client.api = SimpleNamespace(on_interaction_result=AsyncMock()) interaction = SimpleNamespace(id="interaction-1") + result_code = type("ResultCode", (int,), {})( + QQOfficialInteractionResultCode.SUCCESS + ) handler = SimpleNamespace( - handler=AsyncMock(return_value=0), + handler=AsyncMock(return_value=result_code), handler_full_name="test.callback_handler", ) requested_event_types: list[EventType] = [] @@ -500,23 +500,13 @@ def get_handlers(event_type: EventType): 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", 0 + "interaction-1", QQOfficialInteractionResultCode.SUCCESS ) @pytest.mark.asyncio async def test_ws_interaction_dispatch_acknowledges_failure_when_unhandled(monkeypatch): - adapter = QQOfficialPlatformAdapter( - { - "id": "qq-official-test", - "appid": "123", - "secret": "secret", - "enable_group_c2c": True, - "enable_guild_direct_message": False, - }, - {}, - asyncio.Queue(), - ) + adapter = _make_qqofficial_adapter() adapter.client.api = SimpleNamespace(on_interaction_result=AsyncMock()) monkeypatch.setattr( star_handlers_registry, @@ -527,5 +517,5 @@ async def test_ws_interaction_dispatch_acknowledges_failure_when_unhandled(monke await adapter.dispatch_interaction(SimpleNamespace(id="interaction-2")) adapter.client.api.on_interaction_result.assert_awaited_once_with( - "interaction-2", 1 + "interaction-2", QQOfficialInteractionResultCode.FAILED ) From bb4becfc36e45e8f1fd58ee0c4e65ce359453d6d Mon Sep 17 00:00:00 2001 From: kamicry Date: Wed, 22 Jul 2026 19:20:25 +0800 Subject: [PATCH 3/6] docs: remove duplicate QQ interaction guides --- .../dev/star/guides/listen-message-event.md | 25 ------------------- .../dev/star/guides/listen-message-event.md | 25 ------------------- 2 files changed, 50 deletions(-) diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md index 1d2fa58849..ad8081c5a3 100644 --- a/docs/en/dev/star/guides/listen-message-event.md +++ b/docs/en/dev/star/guides/listen-message-event.md @@ -490,31 +490,6 @@ Assuming there's a plugin A, after A terminates event propagation, all subsequen 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 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 failure code `1`. - -```python -from typing import Any - -from astrbot.api.event import filter - - -@filter.on_qqofficial_interaction() -async def on_qqofficial_interaction(self, interaction: Any) -> int | 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 0 -``` - -Acknowledgement codes are: `0` success, `1` failed, `2` rate limited, `3` duplicate, `4` forbidden, and `5` administrator only. This feature is available only on the QQ Official WebSocket adapter. - -## 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 diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md index df99c671da..2f44522648 100644 --- a/docs/zh/dev/star/guides/listen-message-event.md +++ b/docs/zh/dev/star/guides/listen-message-event.md @@ -491,31 +491,6 @@ async def check_ok(self, event: AstrMessageEvent): QQ 官方机器人的回调按钮(`action.type = 1`)会通过 WebSocket 发送 interaction 事件,不会作为普通消息进入 LLM 或命令管线。插件可使用 `on_qqofficial_interaction` 处理点击事件。 -处理器接收 qq-botpy 的 interaction 对象。返回 `0` 到 `5` 的整数作为 QQ 回执码;返回 `None` 表示当前插件不处理该点击,后续已启用的处理器可以继续匹配。第一个有效返回值会结束处理并回执 QQ;没有插件处理时返回失败码 `1`。 - -```python -from typing import Any - -from astrbot.api.event import filter - - -@filter.on_qqofficial_interaction() -async def on_qqofficial_interaction(self, interaction: Any) -> int | 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 0 -``` - -回执码为:`0` 成功、`1` 操作失败、`2` 操作频繁、`3` 重复操作、`4` 无权限、`5` 仅管理员可操作。该功能仅适用于 QQ 官方 WebSocket 适配器。 - -## QQ 官方按钮回调 - -QQ 官方机器人的回调按钮(`action.type = 1`)会通过 WebSocket 发送 interaction 事件,不会作为普通消息进入 LLM 或命令管线。插件可使用 `on_qqofficial_interaction` 处理点击事件。 - 处理器接收 qq-botpy 的 interaction 对象。返回 `QQOfficialInteractionResultCode` 或 `0` 到 `5` 的整数作为 QQ 回执码;返回 `None` 表示当前插件不处理该点击,后续已启用的处理器可以继续匹配。第一个有效返回值会结束处理并回执 QQ;没有插件处理时返回失败码 `FAILED`。 ```python From d2820add66b38fe097e60bbb2623615b74549bca Mon Sep 17 00:00:00 2001 From: kamicry Date: Wed, 22 Jul 2026 19:33:01 +0800 Subject: [PATCH 4/6] docs: clarify QQ interaction result types --- docs/en/dev/star/guides/listen-message-event.md | 4 +++- docs/zh/dev/star/guides/listen-message-event.md | 4 +++- tests/test_qqofficial_group_message_create.py | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md index ad8081c5a3..028df5c0cf 100644 --- a/docs/en/dev/star/guides/listen-message-event.md +++ b/docs/en/dev/star/guides/listen-message-event.md @@ -499,7 +499,9 @@ from astrbot.api.event import QQOfficialInteractionResultCode, filter @filter.on_qqofficial_interaction() -async def on_qqofficial_interaction(self, interaction: Any) -> int | None: +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": diff --git a/docs/zh/dev/star/guides/listen-message-event.md b/docs/zh/dev/star/guides/listen-message-event.md index 2f44522648..3d9e1e15b8 100644 --- a/docs/zh/dev/star/guides/listen-message-event.md +++ b/docs/zh/dev/star/guides/listen-message-event.md @@ -500,7 +500,9 @@ from astrbot.api.event import QQOfficialInteractionResultCode, filter @filter.on_qqofficial_interaction() -async def on_qqofficial_interaction(self, interaction: Any) -> int | None: +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": diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 377ddb073e..b3bed58109 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -435,6 +435,7 @@ 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( { @@ -476,6 +477,7 @@ async def test_ws_interaction_dispatch_acknowledges_first_handler_result(monkeyp 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 ) From b464ff1e851eb176ecb7dba22a5ad30e28448fe2 Mon Sep 17 00:00:00 2001 From: kamicry Date: Wed, 22 Jul 2026 19:37:52 +0800 Subject: [PATCH 5/6] docs: clarify WebSocket-only interaction support --- docs/en/dev/star/guides/listen-message-event.md | 2 +- docs/zh/dev/star/guides/listen-message-event.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/dev/star/guides/listen-message-event.md b/docs/en/dev/star/guides/listen-message-event.md index 028df5c0cf..029eba8268 100644 --- a/docs/en/dev/star/guides/listen-message-event.md +++ b/docs/en/dev/star/guides/listen-message-event.md @@ -511,4 +511,4 @@ async def on_qqofficial_interaction( 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. +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 3d9e1e15b8..59917d884c 100644 --- a/docs/zh/dev/star/guides/listen-message-event.md +++ b/docs/zh/dev/star/guides/listen-message-event.md @@ -512,4 +512,4 @@ async def on_qqofficial_interaction( return QQOfficialInteractionResultCode.SUCCESS ``` -回执码使用 `QQOfficialInteractionResultCode`:`SUCCESS`(0)、`FAILED`(1)、`RATE_LIMITED`(2)、`DUPLICATE`(3)、`FORBIDDEN`(4)和 `ADMIN_ONLY`(5)。该功能仅适用于 QQ 官方 WebSocket 适配器。 +回执码使用 `QQOfficialInteractionResultCode`:`SUCCESS`(0)、`FAILED`(1)、`RATE_LIMITED`(2)、`DUPLICATE`(3)、`FORBIDDEN`(4)和 `ADMIN_ONLY`(5)。该功能仅适用于 QQ 官方 WebSocket 适配器;QQ 官方 Webhook 适配器目前不会分发 interaction 事件,暂不支持此功能。 From 9bdda20581bdb58826c6a152279c4e0368444fac Mon Sep 17 00:00:00 2001 From: kamicry Date: Thu, 23 Jul 2026 00:20:00 +0800 Subject: [PATCH 6/6] fix: clarify invalid QQ interaction handlers --- .../qqofficial/qqofficial_platform_adapter.py | 12 ++++++-- tests/test_qqofficial_group_message_create.py | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 5494940e0a..9e68ddacab 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -331,11 +331,13 @@ def __init__( @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. @@ -347,8 +349,9 @@ def _normalize_interaction_result_code( return QQOfficialInteractionResultCode(result) except ValueError: pass - logger.warning( - f"QQ Official interaction handler returned an invalid result code: {result!r}" + logger.debug( + "QQ Official interaction handler returned an invalid result code " + f"from {handler_name or ''}: {result!r}" ) return None @@ -391,7 +394,10 @@ async def dispatch_interaction(self, interaction: Any) -> None: f"QQ Official interaction handler failed: {handler.handler_full_name}" ) continue - normalized_result = self._normalize_interaction_result_code(result) + 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 diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index b3bed58109..99d2f7628f 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -2,7 +2,7 @@ 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 @@ -521,3 +521,31 @@ async def test_ws_interaction_dispatch_acknowledges_failure_when_unhandled(monke 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 + )