feat(qqbot): add inline-keyboard approvals and update prompts

The QQ Bot v2 API supports inline keyboards on outbound messages. When a
user taps a button, the platform dispatches an INTERACTION_CREATE
gateway event; the bot ACKs it via PUT /interactions/{id} and decodes
the button's data payload to route the click.

This commit adds:

New module gateway/platforms/qqbot/keyboards.py

- Inline-keyboard dataclasses (InlineKeyboard, KeyboardRow, KeyboardButton,
  KeyboardButtonAction, KeyboardButtonRenderData, KeyboardButtonPermission)
  that serialize to the JSON shape the QQ API expects.
- build_approval_keyboard(session_key) — 3-button layout:
   允许一次 /  始终允许 /  拒绝, all sharing group_id='approval'
  so clicking one greys out the rest.
- build_update_prompt_keyboard() — Yes/No keyboard for update confirms.
- parse_approval_button_data() / parse_update_prompt_button_data() —
  decode the button_data payload from INTERACTION_CREATE.
  approve:<session_key>:<decision>  (decision = allow-once|allow-always|deny)
  update_prompt:<answer>            (answer = y|n)
- build_approval_text(ApprovalRequest) — markdown renderer for the
  surrounding message body (exec-approval and plugin-approval variants,
  with severity icons 🔴/🔵/🟡).
- parse_interaction_event(raw) → InteractionEvent dataclass — normalizes
  the nested raw payload (id / scene / openids / button_data / etc.).

Adapter changes (gateway/platforms/qqbot/adapter.py)

- _dispatch_payload routes INTERACTION_CREATE → _on_interaction.
- _on_interaction parses the event, ACKs via PUT /interactions/{id}, then
  invokes a user-registered interaction callback. Exceptions from the
  callback are caught and logged (never propagate into the WS loop).
- set_interaction_callback(cb) lets gateway wiring register a routing
  handler that inspects button_data and resolves the corresponding
  pending approval / update prompt.
- _send_c2c_text / _send_group_text now accept an optional keyboard kwarg
  and append it to the outbound body.
- send_with_keyboard(chat_id, content, keyboard, reply_to=None) — public
  helper that sends a single short message with a keyboard attached.
  Does NOT chunk-split (a keyboard message has one interactive surface).
  Guild chats are rejected non-retryably — they don't support keyboards.
- send_approval_request(chat_id, ApprovalRequest, reply_to=None) +
  send_update_prompt(chat_id, content, reply_to=None) — convenience
  wrappers over send_with_keyboard.

Tests

27 new unit tests under TestApprovalButtonData, TestUpdatePromptButtonData,
TestBuildApprovalKeyboard, TestBuildUpdatePromptKeyboard, TestBuildApprovalText,
TestInteractionEventParsing, and TestAdapterInteractionDispatch. Cover:

- Button-data round-trip (build → parse returns original session/decision)
- Keyboard JSON shape + mutual-exclusion group_id
- Exec vs plugin approval text templates + severity icons
- Interaction event parsing (c2c / group / guild scene codes)
- _on_interaction end-to-end: ACK invoked, callback receives parsed event,
  callback exceptions are swallowed, missing id skips ACK, no registered
  callback is harmless.

Full qqbot suite: 118 passed (72 existing + 19 chunked + 27 keyboards).

Co-authored-by: WideLee <limkuan24@gmail.com>
This commit is contained in:
WideLee 2026-05-07 07:27:36 -07:00 committed by Teknium
parent 9feaeb632b
commit de584cd1dd
4 changed files with 1067 additions and 5 deletions

View file

@ -975,3 +975,329 @@ class TestChunkedUploaderFlow:
)
assert result["file_info"] == "F"
assert put_attempts["n"] == 2
# ---------------------------------------------------------------------------
# Inline keyboards — approval + update-prompt flows
# ---------------------------------------------------------------------------
class TestApprovalButtonData:
def test_parse_allow_once(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
result = parse_approval_button_data("approve:agent:main:qqbot:c2c:UID:allow-once")
assert result == ("agent:main:qqbot:c2c:UID", "allow-once")
def test_parse_allow_always(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
assert parse_approval_button_data("approve:sess:allow-always") == ("sess", "allow-always")
def test_parse_deny(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
assert parse_approval_button_data("approve:sess:deny") == ("sess", "deny")
def test_parse_invalid_prefix_returns_none(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
assert parse_approval_button_data("update_prompt:y") is None
def test_parse_unknown_decision_returns_none(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
assert parse_approval_button_data("approve:sess:maybe") is None
def test_parse_empty_returns_none(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
assert parse_approval_button_data("") is None
assert parse_approval_button_data(None) is None # type: ignore[arg-type]
class TestUpdatePromptButtonData:
def test_parse_yes(self):
from gateway.platforms.qqbot.keyboards import parse_update_prompt_button_data
assert parse_update_prompt_button_data("update_prompt:y") == "y"
def test_parse_no(self):
from gateway.platforms.qqbot.keyboards import parse_update_prompt_button_data
assert parse_update_prompt_button_data("update_prompt:n") == "n"
def test_parse_unknown_returns_none(self):
from gateway.platforms.qqbot.keyboards import parse_update_prompt_button_data
assert parse_update_prompt_button_data("update_prompt:maybe") is None
def test_parse_wrong_prefix(self):
from gateway.platforms.qqbot.keyboards import parse_update_prompt_button_data
assert parse_update_prompt_button_data("approve:sess:deny") is None
class TestBuildApprovalKeyboard:
def test_three_buttons_in_single_row(self):
from gateway.platforms.qqbot.keyboards import build_approval_keyboard
kb = build_approval_keyboard("session-1")
assert len(kb.content.rows) == 1
assert len(kb.content.rows[0].buttons) == 3
def test_button_data_embeds_session_key(self):
from gateway.platforms.qqbot.keyboards import build_approval_keyboard
kb = build_approval_keyboard("agent:main:qqbot:c2c:UID")
datas = [b.action.data for b in kb.content.rows[0].buttons]
assert datas[0] == "approve:agent:main:qqbot:c2c:UID:allow-once"
assert datas[1] == "approve:agent:main:qqbot:c2c:UID:allow-always"
assert datas[2] == "approve:agent:main:qqbot:c2c:UID:deny"
def test_buttons_share_group_id_for_mutual_exclusion(self):
from gateway.platforms.qqbot.keyboards import build_approval_keyboard
kb = build_approval_keyboard("s")
group_ids = {b.group_id for b in kb.content.rows[0].buttons}
assert group_ids == {"approval"}
def test_to_dict_has_expected_shape(self):
from gateway.platforms.qqbot.keyboards import build_approval_keyboard
kb = build_approval_keyboard("s")
d = kb.to_dict()
assert "content" in d
assert "rows" in d["content"]
assert len(d["content"]["rows"]) == 1
btn0 = d["content"]["rows"][0]["buttons"][0]
assert btn0["id"] == "allow"
assert btn0["action"]["type"] == 1
assert btn0["action"]["data"].startswith("approve:s:")
assert btn0["render_data"]["label"]
assert btn0["render_data"]["visited_label"]
def test_round_trip_parse_matches_build(self):
"""Every button built by build_approval_keyboard is parseable."""
from gateway.platforms.qqbot.keyboards import (
build_approval_keyboard, parse_approval_button_data,
)
session_key = "agent:main:qqbot:c2c:UID123"
kb = build_approval_keyboard(session_key)
for btn in kb.content.rows[0].buttons:
parsed = parse_approval_button_data(btn.action.data)
assert parsed is not None
assert parsed[0] == session_key
assert parsed[1] in ("allow-once", "allow-always", "deny")
class TestBuildUpdatePromptKeyboard:
def test_two_buttons(self):
from gateway.platforms.qqbot.keyboards import build_update_prompt_keyboard
kb = build_update_prompt_keyboard()
assert len(kb.content.rows[0].buttons) == 2
def test_button_data_shape(self):
from gateway.platforms.qqbot.keyboards import build_update_prompt_keyboard
kb = build_update_prompt_keyboard()
datas = [b.action.data for b in kb.content.rows[0].buttons]
assert datas == ["update_prompt:y", "update_prompt:n"]
class TestBuildApprovalText:
def test_exec_approval_includes_command_preview(self):
from gateway.platforms.qqbot.keyboards import (
ApprovalRequest, build_approval_text,
)
req = ApprovalRequest(
session_key="s",
title="t",
command_preview="rm -rf /tmp/demo",
cwd="/home/user",
timeout_sec=60,
)
text = build_approval_text(req)
assert "命令执行审批" in text
assert "rm -rf /tmp/demo" in text
assert "/home/user" in text
assert "60" in text
def test_plugin_approval_uses_severity_icon(self):
from gateway.platforms.qqbot.keyboards import (
ApprovalRequest, build_approval_text,
)
crit = ApprovalRequest(
session_key="s", title="dangerous op",
severity="critical", tool_name="shell", timeout_sec=30,
)
assert "🔴" in build_approval_text(crit)
info = ApprovalRequest(
session_key="s", title="read-only", severity="info", tool_name="q",
)
assert "🔵" in build_approval_text(info)
default = ApprovalRequest(session_key="s", title="t", tool_name="x")
assert "🟡" in build_approval_text(default)
def test_truncates_long_commands(self):
from gateway.platforms.qqbot.keyboards import (
ApprovalRequest, build_approval_text,
)
long = "x" * 1000
req = ApprovalRequest(
session_key="s", title="t", command_preview=long, cwd="/x",
)
text = build_approval_text(req)
# Preview is truncated to 300 chars; 1000 "x"s would still push the
# body past 300, but the inline preview specifically must be capped.
preview_line = [
line for line in text.split("\n") if line.startswith("```")
]
# 2 backtick fences; the content line in between is separate.
xs_in_preview = sum(line.count("x") for line in text.split("\n") if line and "```" not in line)
assert xs_in_preview <= 301 # 300 xs + one-off tolerance
class TestInteractionEventParsing:
def test_parse_c2c_interaction(self):
from gateway.platforms.qqbot.keyboards import parse_interaction_event
raw = {
"id": "interaction-42",
"chat_type": 2,
"user_openid": "user-1",
"data": {
"type": 11,
"resolved": {
"button_data": "approve:sess:allow-once",
"button_id": "allow",
},
},
}
ev = parse_interaction_event(raw)
assert ev.id == "interaction-42"
assert ev.scene == "c2c"
assert ev.chat_type == 2
assert ev.user_openid == "user-1"
assert ev.button_data == "approve:sess:allow-once"
assert ev.button_id == "allow"
assert ev.operator_openid == "user-1"
def test_parse_group_interaction(self):
from gateway.platforms.qqbot.keyboards import parse_interaction_event
raw = {
"id": "i-1",
"chat_type": 1,
"group_openid": "grp-1",
"group_member_openid": "mem-1",
"data": {
"type": 11,
"resolved": {
"button_data": "update_prompt:y",
"button_id": "yes",
},
},
}
ev = parse_interaction_event(raw)
assert ev.scene == "group"
assert ev.group_openid == "grp-1"
assert ev.group_member_openid == "mem-1"
assert ev.operator_openid == "mem-1" # member openid preferred in group
def test_parse_missing_data_gracefully(self):
from gateway.platforms.qqbot.keyboards import parse_interaction_event
ev = parse_interaction_event({"id": "i", "chat_type": 0})
assert ev.id == "i"
assert ev.scene == "guild"
assert ev.button_data == ""
assert ev.button_id == ""
assert ev.type == 0
class TestAdapterInteractionDispatch:
"""End-to-end verification of _on_interaction including ACK + callback."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_callback_invoked_with_parsed_event(self):
adapter = self._make_adapter()
# Stub ACK so we don't require a live http_client.
ack_calls = []
async def fake_ack(interaction_id, code=0):
ack_calls.append((interaction_id, code))
adapter._acknowledge_interaction = fake_ack # type: ignore[assignment]
received = []
async def cb(event):
received.append(event)
adapter.set_interaction_callback(cb)
await adapter._on_interaction({
"id": "i-1",
"chat_type": 2,
"user_openid": "user-1",
"data": {
"type": 11,
"resolved": {"button_data": "approve:s:deny", "button_id": "deny"},
},
})
assert len(ack_calls) == 1
assert ack_calls[0][0] == "i-1"
assert len(received) == 1
assert received[0].button_data == "approve:s:deny"
assert received[0].scene == "c2c"
@pytest.mark.asyncio
async def test_missing_id_skips_ack(self):
adapter = self._make_adapter()
ack_calls = []
async def fake_ack(interaction_id, code=0):
ack_calls.append(interaction_id)
adapter._acknowledge_interaction = fake_ack # type: ignore[assignment]
callback_calls = []
async def cb(event):
callback_calls.append(event)
adapter.set_interaction_callback(cb)
await adapter._on_interaction({
"chat_type": 2, # no id
"data": {"resolved": {"button_data": "approve:s:deny"}},
})
assert ack_calls == []
assert callback_calls == []
@pytest.mark.asyncio
async def test_callback_exception_does_not_propagate(self):
adapter = self._make_adapter()
async def fake_ack(interaction_id, code=0):
pass
adapter._acknowledge_interaction = fake_ack # type: ignore[assignment]
async def bad_cb(event):
raise RuntimeError("boom")
adapter.set_interaction_callback(bad_cb)
# Should NOT raise.
await adapter._on_interaction({
"id": "i-2",
"chat_type": 2,
"user_openid": "u",
"data": {"resolved": {"button_data": "approve:s:deny"}},
})
@pytest.mark.asyncio
async def test_no_callback_is_harmless(self):
adapter = self._make_adapter()
async def fake_ack(interaction_id, code=0):
pass
adapter._acknowledge_interaction = fake_ack # type: ignore[assignment]
# No callback set — default None.
await adapter._on_interaction({
"id": "i-3",
"chat_type": 2,
"user_openid": "u",
"data": {"resolved": {"button_data": "approve:s:deny"}},
})