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:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user