From 8fe774b056ab9281b6b5dfd87baae0f93eec2599 Mon Sep 17 00:00:00 2001 From: Ziheng Zhang <142805986+MuXinCG2004@users.noreply.github.com> Date: Mon, 11 May 2026 17:44:20 +0800 Subject: [PATCH] Feat/qq interactive buttons (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(qq): add inline keyboard buttons for C2C HITL approval QQ Bot supports inline buttons via `markdown + keyboard` payloads. Clicks arrive as `interaction_create` events through the existing botpy WebSocket gateway — no extra subscription needed beyond enabling the `interaction` intent. Group-scope clicks are out of scope here (DM only). Send path - `_build_qq_keyboard(buttons)` mirrors the Feishu helper, mapping the generic `{text, value, type}` shape to QQ's `{render_data, action}` with action.type=1 (callback). One button per row for mobile clarity. - `_send_chunk` extracts `metadata["buttons"]` and threads a `keyboard` payload into `_post_markdown_message` for C2C only. - Markdown→plain fallback can't carry a keyboard, so when buttons were attached the fallback content gets a textual `Reply: 1=Approve, …` hint built from the button list. `_parse_approval_reply` accepts the same values typed manually, so the user is never stuck. Receive path - `on_interaction_create` is registered on the bot class. - `_on_interaction` extracts `data.resolved.button_data`, builds an InboundMessage, runs it through inbound middleware (Dedup suppresses retry callbacks), and publishes directly to the bus — bypassing the per-sender debounce buffer so the click value isn't merged with any text typed in the same window. - Always ACKs via `api.on_interaction_result(id, 0)` in `finally` so QQ doesn't show the button as "expired", even if middleware drops the click or something throws downstream. `QQ.inline_buttons=True`; `_approval_prompt_metadata` now auto-attaches the Approve/Reject/Approve-all button row for QQ HITL prompts. * fix(qq): button-value coercion, ACK timing, HITL consumer wiring Fixes 6 bugs found in the inline-keyboard commit and consolidates the button helpers so the keyboard builder, plain-text fallback hint, and interaction handler share one coercion path. - Plain-text fallback no longer crashes on non-string `value` (e.g. `{"text": "OK", "value": 42}`). Extracted `_normalize_button` is now the single place that resolves `(label, value)` and coerces non-strings. - `metadata["button_value"]` is the coerced string instead of the raw payload, matching `content` and downstream string comparisons. - `_on_interaction` ACKs first, before publishing to the bus, so the QQ button UI never shows "expired" if middleware is slow. - Wire `_approval_prompt_metadata` + `_format_approval_prompt(with_buttons=)` into `InboundConsumer._stream_with_hitl` and `cli.channel.channel_hitl_prompt` so the QQ `inline_buttons=True` capability is actually used end-to-end (HITL prompts auto-attach Approve/Reject/Approve-all buttons when the channel advertises the capability). - Trim contradictory `_QQ_DEFAULT_PERMISSION` comment. - Fix `test_group_interaction_ignored` docstring (ACK runs first now, not in `finally` after a `return`). Tests: `_normalize_button` covered indirectly via existing keyboard tests; new regressions for non-string fallback hint, ACK-on-handler-throw, and string-coerced `button_value` metadata. * refactor(qq): slim button helpers and explicit has_buttons flag Inline single-use _button_hint and the _QQ_BUTTON_STYLE/_QQ_DEFAULT_PERMISSION constants in qq/channel.py; tighten _on_interaction (drop unreachable "[button click]" sentinel and unused triggering_message_id metadata; collapse "if resolved else" ternaries via `or ""`). Replace the metadata round-trip ("buttons" in metadata) used to detect button support in consumer.py and cli/channel.py with an explicit has_buttons bool threaded through both the prompt formatter and metadata builder. Apply ruff format to the previously unformatted blocks introduced earlier on this branch so CI lint passes. * feat(qq): send post-decision confirmation after HITL approval Send a visible confirmation message ("✅ 已批准" / "❌ 已拒绝") right after the user resolves a HITL approval — QQ Bot has no message-recall or edit API for C2C, so a follow-up message is the only way to give the click/reply strong feedback. Bus consumer (consumer.py): only sends the confirmation when the user actually responded (event was set), to avoid pretending the user approved when the request really timed out and auto-approved. CLI HITL prompt (cli/channel.py): mirrors the same set of confirmation strings. Timeout and unrecognized-reply paths keep their existing English text since their semantics differ (auto-reject vs auto-approve, plus a hint about the unparsed input). --- EvoScientist/channels/capabilities.py | 1 + EvoScientist/channels/consumer.py | 76 +++++-- EvoScientist/channels/qq/channel.py | 153 ++++++++++++- EvoScientist/cli/channel.py | 23 +- tests/test_qq_channel.py | 309 +++++++++++++++++++++++++- 5 files changed, 539 insertions(+), 23 deletions(-) diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py index 7846ca4..8a8f380 100644 --- a/EvoScientist/channels/capabilities.py +++ b/EvoScientist/channels/capabilities.py @@ -160,6 +160,7 @@ QQ = ChannelCapabilities( format_type="plain", max_text_length=4096, typing=False, # no typing API for QQ bots + inline_buttons=True, # markdown + keyboard payload (C2C only) media_send=True, media_receive=True, voice=False, # qq-botpy does not expose voice as a distinct message type diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py index c5418e8..f593f0c 100644 --- a/EvoScientist/channels/consumer.py +++ b/EvoScientist/channels/consumer.py @@ -149,8 +149,14 @@ def _should_auto_approve(action_requests: list[dict]) -> bool: return True -def _format_approval_prompt(action_requests: list[dict]) -> str: - """Format an approval prompt as a text message for channel users.""" +def _format_approval_prompt( + action_requests: list[dict], *, with_buttons: bool = False +) -> str: + """Format an approval prompt as a text message for channel users. + + When *with_buttons* is True, the trailing "Reply: 1=Approve..." + instruction is dropped — the buttons replace the textual cue. + """ lines = ["\u26a0\ufe0f Approval Required\n"] for i, req in enumerate(action_requests, 1): name = ( @@ -167,9 +173,10 @@ def _format_approval_prompt(action_requests: list[dict]) -> str: lines.append(f" {i}. {name}: {command}") else: lines.append(f" {i}. {name}") - lines.append("") - lines.append("Reply: 1=Approve, 2=Reject, 3=Approve all") - lines.append("(Auto-reject in 2 min if no reply)") + if not with_buttons: + lines.append("") + lines.append("Reply: 1=Approve, 2=Reject, 3=Approve all") + lines.append("(Auto-reject in 2 min if no reply)") return "\n".join(lines) @@ -188,6 +195,25 @@ def _parse_approval_reply(text: str) -> str | None: return None +def _approval_prompt_metadata( + base_metadata: dict | None, *, with_buttons: bool +) -> dict: + """Outbound metadata for the HITL approval prompt. + + When *with_buttons* is True, attaches Approve/Reject/Auto buttons whose + values match ``_parse_approval_reply`` so a click flows through the same + path as a typed ``"1"``/``"2"``/``"3"`` reply. + """ + metadata = dict(base_metadata or {}) + if with_buttons: + metadata["buttons"] = [ + {"text": "Approve", "value": "1", "type": "primary"}, + {"text": "Reject", "value": "2", "type": "danger"}, + {"text": "Approve all", "value": "3"}, + ] + return metadata + + @dataclass class _PendingInterrupt: """Stored state for a pending HITL interrupt awaiting channel user reply.""" @@ -605,13 +631,21 @@ class InboundConsumer: continue # Needs user approval — send prompt to channel - prompt_text = _format_approval_prompt(action_reqs) + has_buttons = ( + channel is not None and channel.capabilities.inline_buttons + ) + prompt_text = _format_approval_prompt( + action_reqs, with_buttons=has_buttons + ) + approval_metadata = _approval_prompt_metadata( + msg.metadata, with_buttons=has_buttons + ) await self.bus.publish_outbound( OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=prompt_text, - metadata=msg.metadata, + metadata=approval_metadata, ) ) @@ -636,15 +670,27 @@ class InboundConsumer: decision = pending.decision or "approve" - if decision == "reject": - await self.bus.publish_outbound( - OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="Tool execution rejected.", - metadata=msg.metadata, + # Visible confirmation so the click/reply registers (QQ has no + # message recall API for C2C). Only fires when the user + # actually responded — silent on timeout to avoid claiming + # the user approved when they just walked away. + if pending.event.is_set(): + feedback_text = { + "approve": "\u2705 已批准", + "auto": "\u2705 已批准(后续自动通过)", + "reject": "\u274c 已拒绝", + }.get(decision) + if feedback_text: + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=feedback_text, + metadata=msg.metadata, + ) ) - ) + + if decision == "reject": return if decision == "auto": diff --git a/EvoScientist/channels/qq/channel.py b/EvoScientist/channels/qq/channel.py index b693c05..36554eb 100644 --- a/EvoScientist/channels/qq/channel.py +++ b/EvoScientist/channels/qq/channel.py @@ -26,6 +26,55 @@ except ImportError: GroupMessage = None +# ── Inline keyboard (button) helpers ───────────────────────────────── + + +def _normalize_button(btn: dict) -> tuple[str, str] | None: + """Return ``(label, value)`` for a button, or ``None`` if no label.""" + label = (btn.get("text") or "").strip() + if not label: + return None + raw = btn.get("value") + return label, str(raw) if raw is not None else label + + +def _build_qq_keyboard(buttons: list[dict]) -> dict | None: + """Build a QQ Bot keyboard payload (one button per row). + + Render style: 1 = primary (blue), 0 = secondary (grey) — QQ has no danger. + ``action.permission`` is required by the schema; ``type=2`` is harmless for + C2C (the click always comes from the DM peer). Returns ``None`` if no + button has a usable label. + """ + rows: list[dict] = [] + for idx, btn in enumerate(buttons): + norm = _normalize_button(btn) + if norm is None: + continue + label, value = norm + style = 1 if btn.get("type") == "primary" else 0 + rows.append( + { + "buttons": [ + { + "id": btn.get("id") or f"btn_{idx}", + "render_data": { + "label": label, + "visited_label": label, + "style": style, + }, + "action": { + "type": 1, # callback (server pushes interaction event) + "permission": {"type": 2}, + "data": value, + }, + } + ] + } + ) + return {"content": {"rows": rows}} if rows else None + + @dataclass class QQConfig(BaseChannelConfig): app_id: str = "" @@ -35,7 +84,11 @@ class QQConfig(BaseChannelConfig): def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": """Create a botpy Client subclass bound to the given channel.""" - intents = botpy.Intents(public_messages=True, direct_message=True) + intents = botpy.Intents( + public_messages=True, + direct_message=True, + interaction=True, # button clicks → on_interaction_create + ) class _Bot(botpy.Client): def __init__(self): @@ -50,6 +103,9 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": async def on_group_at_message_create(self, message: "GroupMessage"): await channel._on_msg(message, "group") + async def on_interaction_create(self, interaction): + await channel._on_interaction(interaction) + return _Bot @@ -174,6 +230,76 @@ class QQChannel(Channel): except Exception as e: logger.error(f"Error handling QQ message: {e}") + async def _on_interaction(self, interaction) -> None: + """Handle ``on_interaction_create`` (button click). + + Surfaces the click as an :class:`InboundMessage` whose ``content`` is + the button's ``data`` verbatim — so a "1"/"approve"/… click flows + through ``_parse_approval_reply`` exactly like a typed reply. + + The click runs through inbound middleware (Dedup suppresses QQ + retries) but is published directly to the bus so the per-sender + debounce buffer doesn't merge the click value with subsequent text. + Group-scope clicks are ignored (DM-only by design). + """ + # ACK first — QQ requires a response within ~5s or the button UI + # shows "expired". Code 0 just means "received"; downstream still + # decides the actual approval/rejection. + interaction_id = getattr(interaction, "id", "") or "" + if interaction_id and self._client: + try: + await self._client.api.on_interaction_result(interaction_id, 0) + except Exception as ack_exc: + logger.debug("QQ interaction ack failed: %s", ack_exc) + + try: + user_openid = getattr(interaction, "user_openid", "") or "" + if not user_openid: + logger.debug("QQ interaction ignored (no user_openid; not C2C)") + return + + resolved = getattr(getattr(interaction, "data", None), "resolved", None) + button_data = getattr(resolved, "button_data", "") or "" + button_id = getattr(resolved, "button_id", "") or "" + triggering_msg_id = getattr(resolved, "message_id", "") or "" + + # QQ may serialize non-str values; coerce. Fall back to button id + # when no data — same path as a typed reply via _parse_approval_reply. + button_value = str(button_data) if button_data != "" else "" + text = button_value or button_id + + # Stable id so DedupMiddleware suppresses any QQ retry callbacks. + message_id = ( + f"{triggering_msg_id}:action:{interaction_id}" + if interaction_id + else f"qq_action:{datetime.now().timestamp()}" + ) + + raw = RawIncoming( + sender_id=user_openid, + chat_id=user_openid, # C2C: chat_id == user_openid + text=text, + timestamp=datetime.now(), + message_id=message_id, + metadata={ + "chat_id": user_openid, + "msg_type": "c2c", + "event_id": triggering_msg_id, + "backend": "qq", + "button_click": True, + "button_id": button_id, + "button_value": button_value, + }, + is_group=False, + was_mentioned=True, + ) + + inbound = await self._build_inbound_async(raw) + if inbound is not None and self._bus: + await self._bus.publish_inbound(inbound) + except Exception: + logger.exception("QQ interaction handler error") + # ── Send ────────────────────────────────────────────────────── def _next_msg_seq(self, msg_id: str) -> int: @@ -195,8 +321,16 @@ class QQChannel(Channel): msg_type = (metadata or {}).get("msg_type", "c2c") msg_id = (metadata or {}).get("event_id", "") seq = self._next_msg_seq(msg_id) + + # Inline keyboard is C2C-only here — group keyboards have stricter + # permission semantics and are out of scope for now. + buttons = (metadata or {}).get("buttons") if msg_type == "c2c" else None + keyboard = _build_qq_keyboard(buttons) if buttons else None + try: - await self._post_markdown_message(chat_id, raw_text, msg_type, msg_id, seq) + await self._post_markdown_message( + chat_id, raw_text, msg_type, msg_id, seq, keyboard=keyboard + ) return except Exception as exc: if not self._should_fallback_to_plain_text(exc): @@ -224,6 +358,18 @@ class QQChannel(Channel): # always advance to a fresh seq before the fallback send. fallback_seq = self._next_msg_seq(msg_id) plain_text = self._plain_formatter.format(raw_text) + # Plain-text fallback can't carry a keyboard. Append `value=label` + # pairs so the user can still type "1"/"approve"/… instead of + # tapping (`_parse_approval_reply` accepts the same values). + if buttons: + pairs = [] + for btn in buttons: + norm = _normalize_button(btn) + if norm is not None: + label, value = norm + pairs.append(f"{value}={label}") + if pairs: + plain_text = f"{plain_text}\n\nReply: {', '.join(pairs)}" try: await self._post_plain_message( chat_id, plain_text, msg_type, msg_id, fallback_seq @@ -301,6 +447,7 @@ class QQChannel(Channel): msg_type: str, msg_id: str, seq: int, + keyboard: dict | None = None, ) -> None: payload = { "msg_type": 2, @@ -308,6 +455,8 @@ class QQChannel(Channel): "msg_id": msg_id, "msg_seq": seq, } + if keyboard is not None: + payload["keyboard"] = keyboard if msg_type == "group": await self._client.api.post_group_message( group_openid=chat_id, diff --git a/EvoScientist/cli/channel.py b/EvoScientist/cli/channel.py index 49e544f..52c1741 100644 --- a/EvoScientist/cli/channel.py +++ b/EvoScientist/cli/channel.py @@ -623,6 +623,7 @@ def channel_hitl_prompt( """ from ..channels.bus.events import OutboundMessage from ..channels.consumer import ( + _approval_prompt_metadata, _format_approval_prompt, _parse_approval_reply, ) @@ -637,7 +638,17 @@ def channel_hitl_prompt( _channel_logger.debug("HITL: no bus_loop or bus_ref, rejecting") return None - def _send(content: str) -> bool: + # Look up the channel instance so we can attach buttons when the channel + # supports `inline_buttons` (Feishu cards, QQ keyboards, …). + channel_obj = ( + _manager.get_channel(msg.channel_type) if _manager is not None else None + ) + has_buttons = channel_obj is not None and channel_obj.capabilities.inline_buttons + approval_metadata = _approval_prompt_metadata( + msg.metadata, with_buttons=has_buttons + ) + + def _send(content: str, *, metadata: dict | None = None) -> bool: """Send a message to the channel user. Returns True on success.""" try: asyncio.run_coroutine_threadsafe( @@ -646,7 +657,7 @@ def channel_hitl_prompt( channel=msg.channel_type, chat_id=msg.chat_id, content=content, - metadata=msg.metadata, + metadata=metadata if metadata is not None else msg.metadata, ) ), bus_loop, @@ -657,8 +668,8 @@ def channel_hitl_prompt( return False # 1. Send approval prompt - prompt_text = _format_approval_prompt(action_requests) - if not _send(prompt_text): + prompt_text = _format_approval_prompt(action_requests, with_buttons=has_buttons) + if not _send(prompt_text, metadata=approval_metadata): return None # 2. Wait for channel user's reply @@ -680,12 +691,14 @@ def channel_hitl_prompt( decision = _parse_approval_reply(reply_text) if decision == "auto": _hitl_auto_approve.add(session_key) + _send("\u2705 已批准(后续自动通过)") return [{"type": "approve"} for _ in action_requests] if decision == "approve": + _send("\u2705 已批准") return [{"type": "approve"} for _ in action_requests] feedback = ( - "Action rejected." + "\u274c 已拒绝" if decision == "reject" else "Unrecognized reply. Action rejected." ) diff --git a/tests/test_qq_channel.py b/tests/test_qq_channel.py index 3332915..5c9fa6e 100644 --- a/tests/test_qq_channel.py +++ b/tests/test_qq_channel.py @@ -3,7 +3,11 @@ from unittest.mock import AsyncMock, MagicMock from EvoScientist.channels.base import OutboundMessage -from EvoScientist.channels.qq.channel import QQChannel, QQConfig +from EvoScientist.channels.qq.channel import ( + QQChannel, + QQConfig, + _build_qq_keyboard, +) from tests.conftest import run_async as _run @@ -166,3 +170,306 @@ class TestQQChannelSend: assert second["msg_type"] == 0 # Fresh seq on fallback — avoids QQ "duplicate msg_seq" rejection. assert second["msg_seq"] == 2 + + +class TestQQKeyboardBuilder: + def test_basic_buttons(self): + kb = _build_qq_keyboard( + [ + {"text": "Approve", "value": "1", "type": "primary"}, + {"text": "Reject", "value": "2", "type": "danger"}, + ] + ) + rows = kb["content"]["rows"] + assert len(rows) == 2 # one button per row + approve_btn = rows[0]["buttons"][0] + assert approve_btn["render_data"]["label"] == "Approve" + assert approve_btn["render_data"]["style"] == 1 # primary + assert approve_btn["action"]["type"] == 1 # callback + assert approve_btn["action"]["data"] == "1" + # "danger" maps to grey (style 0) — QQ has no danger style + assert rows[1]["buttons"][0]["render_data"]["style"] == 0 + + def test_default_value_uses_label(self): + kb = _build_qq_keyboard([{"text": "OK"}]) + assert kb["content"]["rows"][0]["buttons"][0]["action"]["data"] == "OK" + + def test_skips_empty_label(self): + kb = _build_qq_keyboard( + [ + {"text": "", "value": "skip"}, + {"text": "Keep", "value": "k"}, + ] + ) + rows = kb["content"]["rows"] + assert len(rows) == 1 + assert rows[0]["buttons"][0]["render_data"]["label"] == "Keep" + + def test_returns_none_when_no_valid_buttons(self): + assert _build_qq_keyboard([]) is None + assert _build_qq_keyboard([{"text": ""}]) is None + + def test_explicit_id_preserved(self): + kb = _build_qq_keyboard([{"text": "Go", "value": "go", "id": "custom_id"}]) + assert kb["content"]["rows"][0]["buttons"][0]["id"] == "custom_id" + + def test_non_string_value_coerced(self): + kb = _build_qq_keyboard([{"text": "OK", "value": 42}]) + assert kb["content"]["rows"][0]["buttons"][0]["action"]["data"] == "42" + + +class TestQQSendWithButtons: + """Send path attaches `keyboard` to markdown payload for C2C messages.""" + + @staticmethod + def _make_channel() -> QQChannel: + channel = QQChannel(QQConfig(app_id="id", app_secret="secret")) + channel._running = True + channel._client = MagicMock() + channel._client.api = MagicMock() + channel._client.api.post_c2c_message = AsyncMock() + channel._client.api.post_group_message = AsyncMock() + return channel + + def test_c2c_send_attaches_keyboard(self): + channel = self._make_channel() + msg = OutboundMessage( + channel="qq", + chat_id="openid", + content="Pick:", + metadata={ + "chat_id": "openid", + "event_id": "evt_btn", + "msg_type": "c2c", + "buttons": [ + {"text": "Approve", "value": "1", "type": "primary"}, + {"text": "Reject", "value": "2"}, + ], + }, + ) + assert _run(channel.send(msg)) is True + + sent = channel._client.api.post_c2c_message.await_args.kwargs + assert sent["msg_type"] == 2 + assert "keyboard" in sent + rows = sent["keyboard"]["content"]["rows"] + assert rows[0]["buttons"][0]["action"]["data"] == "1" + assert rows[1]["buttons"][0]["action"]["data"] == "2" + + def test_group_send_does_not_attach_keyboard(self): + """Group keyboards are out of scope — silently dropped.""" + channel = self._make_channel() + msg = OutboundMessage( + channel="qq", + chat_id="group_openid", + content="Pick:", + metadata={ + "chat_id": "group_openid", + "event_id": "evt_group", + "msg_type": "group", + "buttons": [{"text": "Approve", "value": "1"}], + }, + ) + assert _run(channel.send(msg)) is True + sent = channel._client.api.post_group_message.await_args.kwargs + assert "keyboard" not in sent + + def test_fallback_appends_button_hint_when_keyboard_present(self): + """If markdown send fails and we fall back to plain text, the + keyboard is lost — append a textual hint so the user still has + a way to reply (the values still pass `_parse_approval_reply`). + """ + channel = self._make_channel() + channel._client.api.post_c2c_message = AsyncMock( + side_effect=[ + RuntimeError( + '{"code": 304014, "message": "markdown template not configured"}' + ), + None, + ] + ) + msg = OutboundMessage( + channel="qq", + chat_id="openid", + content="Pick:", + metadata={ + "chat_id": "openid", + "event_id": "evt_fb", + "msg_type": "c2c", + "buttons": [ + {"text": "Approve", "value": "1"}, + {"text": "Reject", "value": "2"}, + ], + }, + ) + assert _run(channel.send(msg)) is True + + plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs + assert plain_call["msg_type"] == 0 + # Fallback content includes hint mapping value→label so the user + # can type "1"/"2" without the original button UI. + assert "1=Approve" in plain_call["content"] + assert "2=Reject" in plain_call["content"] + + def test_fallback_hint_handles_non_string_button_value(self): + """Regression: integer/None button values must not crash the + plain-text fallback (the keyboard builder already coerces them).""" + channel = self._make_channel() + channel._client.api.post_c2c_message = AsyncMock( + side_effect=[ + RuntimeError('{"code": 304014, "message": "template not configured"}'), + None, + ] + ) + msg = OutboundMessage( + channel="qq", + chat_id="openid", + content="Pick:", + metadata={ + "chat_id": "openid", + "event_id": "evt_coerce", + "msg_type": "c2c", + "buttons": [ + {"text": "OK", "value": 42}, # int + {"text": "Cancel"}, # value omitted → defaults to label + ], + }, + ) + assert _run(channel.send(msg)) is True + plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs + assert "42=OK" in plain_call["content"] + assert "Cancel=Cancel" in plain_call["content"] + + +class TestQQInteractionCallback: + """`_on_interaction` should publish click as InboundMessage to the bus + (skipping debounce) and ACK the interaction.""" + + @staticmethod + def _make_channel() -> QQChannel: + from EvoScientist.channels.bus.events import InboundMessage + + channel = QQChannel(QQConfig(app_id="id", app_secret="secret")) + channel._running = True + channel._client = MagicMock() + channel._client.api = MagicMock() + channel._client.api.on_interaction_result = AsyncMock() + + async def _fake_build(raw): + channel._captured_raw = raw + return InboundMessage( + channel="qq", + sender_id=raw.sender_id, + chat_id=raw.chat_id, + content=raw.text, + metadata=raw.metadata, + message_id=raw.message_id, + is_group=raw.is_group, + was_mentioned=raw.was_mentioned, + ) + + channel._build_inbound_async = _fake_build + channel._captured_raw = None + channel._bus = MagicMock() + channel._bus.publish_inbound = AsyncMock() + return channel + + @staticmethod + def _make_interaction(button_data="1", button_id="btn_0", user_openid="u_xxx"): + resolved = MagicMock( + button_id=button_id, + button_data=button_data, + message_id="msg_orig", + user_id=None, + feature_id=None, + ) + data = MagicMock(type=None, resolved=resolved) + interaction = MagicMock( + id="intr_1", + user_openid=user_openid, + group_openid=None, + data=data, + ) + return interaction + + def test_click_publishes_to_bus_with_button_data(self): + channel = self._make_channel() + _run(channel._on_interaction(self._make_interaction("1"))) + + channel._bus.publish_inbound.assert_awaited_once() + inbound = channel._bus.publish_inbound.await_args[0][0] + assert inbound.content == "1" + assert inbound.sender_id == "u_xxx" + assert inbound.chat_id == "u_xxx" + assert inbound.metadata["button_click"] is True + assert inbound.metadata["button_value"] == "1" + assert inbound.metadata["msg_type"] == "c2c" + + def test_click_acks_interaction(self): + channel = self._make_channel() + _run(channel._on_interaction(self._make_interaction("1"))) + channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0) + + def test_click_bypasses_debounce(self): + """Click never hits queue_message (debounce buffer).""" + channel = self._make_channel() + channel.queue_message = AsyncMock() + _run(channel._on_interaction(self._make_interaction("3"))) + channel.queue_message.assert_not_called() + channel._bus.publish_inbound.assert_awaited_once() + + def test_group_interaction_ignored(self): + """No user_openid → group/guild click → don't publish.""" + channel = self._make_channel() + intr = self._make_interaction(user_openid="") + intr.group_openid = "group_xxx" + _run(channel._on_interaction(intr)) + channel._bus.publish_inbound.assert_not_called() + # ACK still fires — it runs first, before the group-skip return. + channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0) + + def test_click_dropped_when_middleware_rejects(self): + channel = self._make_channel() + channel._build_inbound_async = AsyncMock(return_value=None) + _run(channel._on_interaction(self._make_interaction("1"))) + channel._bus.publish_inbound.assert_not_called() + # ACK still fires (we don't want the user staring at a stuck button) + channel._client.api.on_interaction_result.assert_awaited_once() + + def test_empty_button_data_falls_back_to_button_id(self): + channel = self._make_channel() + _run( + channel._on_interaction( + self._make_interaction(button_data="", button_id="btn_3") + ) + ) + inbound = channel._bus.publish_inbound.await_args[0][0] + assert inbound.content == "btn_3" + + def test_ack_fires_even_when_handler_throws(self): + """ACK must run before downstream processing so the QQ button UI + stays responsive even if middleware/bus crashes.""" + channel = self._make_channel() + channel._build_inbound_async = AsyncMock(side_effect=RuntimeError("boom")) + # Should not raise — handler swallows downstream errors. + _run(channel._on_interaction(self._make_interaction("1"))) + channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0) + + def test_button_value_metadata_is_string_coerced(self): + """Regression: metadata['button_value'] must be a string (was raw).""" + channel = self._make_channel() + resolved = MagicMock(button_id="btn_0", button_data=42, message_id="msg_orig") + data = MagicMock(type=None, resolved=resolved) + intr = MagicMock(id="intr_1", user_openid="u_x", group_openid=None, data=data) + _run(channel._on_interaction(intr)) + inbound = channel._bus.publish_inbound.await_args[0][0] + assert inbound.content == "42" + assert inbound.metadata["button_value"] == "42" + + +class TestQQCapabilitiesButtons: + def test_inline_buttons_enabled(self): + from EvoScientist.channels.capabilities import QQ + + assert QQ.inline_buttons is True + assert QQ.supports("inline_buttons") is True