Feat/qq interactive buttons (#220)

* 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).
This commit is contained in:
Ziheng Zhang
2026-05-11 17:44:20 +08:00
committed by GitHub
parent ccb3083183
commit 8fe774b056
5 changed files with 539 additions and 23 deletions
+1
View File
@@ -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
+61 -15
View File
@@ -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":
+151 -2
View File
@@ -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,
+18 -5
View File
@@ -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."
)
+308 -1
View File
@@ -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