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

@ -41,6 +41,20 @@ from .chunked_upload import ( # noqa: F401
UploadFileTooLargeError, UploadFileTooLargeError,
) )
# -- Inline keyboards ------------------------------------------------------
from .keyboards import ( # noqa: F401
ApprovalRequest,
ApprovalSender,
InlineKeyboard,
InteractionEvent,
build_approval_keyboard,
build_approval_text,
build_update_prompt_keyboard,
parse_approval_button_data,
parse_interaction_event,
parse_update_prompt_button_data,
)
__all__ = [ __all__ = [
# adapter # adapter
"QQAdapter", "QQAdapter",
@ -63,4 +77,15 @@ __all__ = [
"ChunkedUploader", "ChunkedUploader",
"UploadDailyLimitExceededError", "UploadDailyLimitExceededError",
"UploadFileTooLargeError", "UploadFileTooLargeError",
# keyboards
"ApprovalRequest",
"ApprovalSender",
"InlineKeyboard",
"InteractionEvent",
"build_approval_keyboard",
"build_approval_text",
"build_update_prompt_keyboard",
"parse_approval_button_data",
"parse_interaction_event",
"parse_update_prompt_button_data",
] ]

View file

@ -41,7 +41,7 @@ import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
try: try:
@ -124,6 +124,17 @@ from gateway.platforms.qqbot.chunked_upload import (
UploadDailyLimitExceededError, UploadDailyLimitExceededError,
UploadFileTooLargeError, UploadFileTooLargeError,
) )
from gateway.platforms.qqbot.keyboards import (
ApprovalRequest,
ApprovalSender,
InlineKeyboard,
InteractionEvent,
build_approval_keyboard,
build_update_prompt_keyboard,
parse_approval_button_data,
parse_interaction_event,
parse_update_prompt_button_data,
)
def check_qq_requirements() -> bool: def check_qq_requirements() -> bool:
@ -213,6 +224,14 @@ class QQAdapter(BasePlatformAdapter):
# Upload cache: content_hash -> {file_info, file_uuid, expires_at} # Upload cache: content_hash -> {file_info, file_uuid, expires_at}
self._upload_cache: Dict[str, Dict[str, Any]] = {} self._upload_cache: Dict[str, Dict[str, Any]] = {}
# Inline-keyboard interaction routing. The callback (if set) is invoked
# for every INTERACTION_CREATE event after the adapter has already
# ACKed it. Callers (gateway wiring for approvals / update prompts)
# register via set_interaction_callback().
self._interaction_callback: Optional[
Callable[[InteractionEvent], Awaitable[None]]
] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Properties # Properties
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -764,6 +783,8 @@ class QQAdapter(BasePlatformAdapter):
"GUILD_AT_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE",
): ):
asyncio.create_task(self._on_message(t, d)) asyncio.create_task(self._on_message(t, d))
elif t == "INTERACTION_CREATE":
self._create_task(self._on_interaction(d))
else: else:
logger.debug("[%s] Unhandled dispatch: %s", self._log_tag, t) logger.debug("[%s] Unhandled dispatch: %s", self._log_tag, t)
return return
@ -837,6 +858,111 @@ class QQAdapter(BasePlatformAdapter):
elif event_type == "DIRECT_MESSAGE_CREATE": elif event_type == "DIRECT_MESSAGE_CREATE":
await self._handle_dm_message(d, msg_id, content, author, timestamp) await self._handle_dm_message(d, msg_id, content, author, timestamp)
# ------------------------------------------------------------------
# Inline-keyboard interactions (INTERACTION_CREATE)
# ------------------------------------------------------------------
def set_interaction_callback(
self,
callback: Optional[Callable[[InteractionEvent], Awaitable[None]]],
) -> None:
"""Register (or clear) the interaction callback.
Invoked once per ``INTERACTION_CREATE`` event *after* the adapter has
ACKed the interaction. The callback is responsible for routing the
button click to the right subsystem (approval resolver, update-prompt
resolver, etc.) based on the ``button_data`` payload.
"""
self._interaction_callback = callback
async def _on_interaction(self, d: Any) -> None:
"""Handle an ``INTERACTION_CREATE`` event.
Responsibilities:
1. Parse the raw payload into an :class:`InteractionEvent`.
2. ACK the interaction (``PUT /interactions/{id}``) so the client
stops showing a loading indicator on the button.
3. Dispatch to the registered interaction callback, if any.
"""
if not isinstance(d, dict):
return
try:
event = parse_interaction_event(d)
except Exception as exc:
logger.warning(
"[%s] Failed to parse INTERACTION_CREATE: %s", self._log_tag, exc
)
return
if not event.id:
logger.warning(
"[%s] INTERACTION_CREATE missing id, skipping ACK", self._log_tag
)
return
# ACK the interaction promptly — per the QQ docs the client will show
# an error icon on the button if we don't respond quickly.
try:
await self._acknowledge_interaction(event.id)
except Exception as exc:
logger.warning(
"[%s] Failed to ACK interaction %s: %s",
self._log_tag, event.id, exc,
)
logger.info(
"[%s] Interaction: scene=%s button_data=%r operator=%s",
self._log_tag, event.scene, event.button_data, event.operator_openid,
)
callback = self._interaction_callback
if callback is None:
logger.debug(
"[%s] No interaction callback registered; dropping button "
"click %r",
self._log_tag, event.button_data,
)
return
try:
await callback(event)
except Exception as exc:
logger.error(
"[%s] Interaction callback raised: %s",
self._log_tag, exc, exc_info=True,
)
async def _acknowledge_interaction(
self,
interaction_id: str,
code: int = 0,
) -> None:
"""ACK a button interaction via ``PUT /interactions/{id}``.
:param interaction_id: The ``id`` field from the
``INTERACTION_CREATE`` event.
:param code: Response code (``0`` = success).
"""
if not self._http_client:
raise RuntimeError("HTTP client not initialized — not connected?")
token = await self._ensure_token()
headers = {
"Authorization": f"QQBot {token}",
"Content-Type": "application/json",
"User-Agent": build_user_agent(),
}
resp = await self._http_client.put(
f"{API_BASE}/interactions/{interaction_id}",
headers=headers,
json={"code": code},
timeout=DEFAULT_API_TIMEOUT,
)
if resp.status_code >= 400:
raise RuntimeError(
f"Interaction ACK failed [{resp.status_code}]: "
f"{resp.text[:200]}"
)
async def _handle_c2c_message( async def _handle_c2c_message(
self, self,
d: Dict[str, Any], d: Dict[str, Any],
@ -1997,26 +2123,44 @@ class QQAdapter(BasePlatformAdapter):
return SendResult(success=False, error=error_msg, retryable=retryable) return SendResult(success=False, error=error_msg, retryable=retryable)
async def _send_c2c_text( async def _send_c2c_text(
self, openid: str, content: str, reply_to: Optional[str] = None self,
openid: str,
content: str,
reply_to: Optional[str] = None,
keyboard: Optional[InlineKeyboard] = None,
) -> SendResult: ) -> SendResult:
"""Send text to a C2C user via REST API.""" """Send text to a C2C user via REST API.
:param keyboard: Optional inline keyboard attached to the message.
"""
self._next_msg_seq(reply_to or openid) self._next_msg_seq(reply_to or openid)
body = self._build_text_body(content, reply_to) body = self._build_text_body(content, reply_to)
if reply_to: if reply_to:
body["msg_id"] = reply_to body["msg_id"] = reply_to
if keyboard is not None:
body["keyboard"] = keyboard.to_dict()
data = await self._api_request("POST", f"/v2/users/{openid}/messages", body) data = await self._api_request("POST", f"/v2/users/{openid}/messages", body)
msg_id = str(data.get("id", uuid.uuid4().hex[:12])) msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
return SendResult(success=True, message_id=msg_id, raw_response=data) return SendResult(success=True, message_id=msg_id, raw_response=data)
async def _send_group_text( async def _send_group_text(
self, group_openid: str, content: str, reply_to: Optional[str] = None self,
group_openid: str,
content: str,
reply_to: Optional[str] = None,
keyboard: Optional[InlineKeyboard] = None,
) -> SendResult: ) -> SendResult:
"""Send text to a group via REST API.""" """Send text to a group via REST API.
:param keyboard: Optional inline keyboard attached to the message.
"""
self._next_msg_seq(reply_to or group_openid) self._next_msg_seq(reply_to or group_openid)
body = self._build_text_body(content, reply_to) body = self._build_text_body(content, reply_to)
if reply_to: if reply_to:
body["msg_id"] = reply_to body["msg_id"] = reply_to
if keyboard is not None:
body["keyboard"] = keyboard.to_dict()
data = await self._api_request( data = await self._api_request(
"POST", f"/v2/groups/{group_openid}/messages", body "POST", f"/v2/groups/{group_openid}/messages", body
@ -2036,6 +2180,100 @@ class QQAdapter(BasePlatformAdapter):
msg_id = str(data.get("id", uuid.uuid4().hex[:12])) msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
return SendResult(success=True, message_id=msg_id, raw_response=data) return SendResult(success=True, message_id=msg_id, raw_response=data)
# ------------------------------------------------------------------
# Inline-keyboard outbound helpers (approval / update-prompt flows)
# ------------------------------------------------------------------
async def send_with_keyboard(
self,
chat_id: str,
content: str,
keyboard: InlineKeyboard,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a single text message with an inline keyboard attached.
Unlike :meth:`send`, this does NOT split long content into chunks
a keyboard message has exactly one interactive surface, and splitting
would orphan the buttons from the first chunk. Callers should keep
approval/update-prompt bodies short.
Guild (channel) chats don't support inline keyboards; returns a
non-retryable failure for those.
"""
if not self.is_connected:
if not await self._wait_for_reconnection():
return SendResult(
success=False, error="Not connected", retryable=True
)
chat_type = self._guess_chat_type(chat_id)
formatted = self.format_message(content)
truncated = formatted[: self.MAX_MESSAGE_LENGTH]
try:
if chat_type == "c2c":
return await self._send_c2c_text(
chat_id, truncated, reply_to, keyboard=keyboard,
)
if chat_type == "group":
return await self._send_group_text(
chat_id, truncated, reply_to, keyboard=keyboard,
)
return SendResult(
success=False,
error=(
f"Inline keyboards not supported for chat_type "
f"{chat_type!r}"
),
retryable=False,
)
except Exception as exc:
logger.error(
"[%s] send_with_keyboard failed: %s", self._log_tag, exc
)
return SendResult(success=False, error=str(exc))
async def send_approval_request(
self,
chat_id: str,
req: ApprovalRequest,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a 3-button approval request (``allow-once / allow-always / deny``).
The rendered text comes from :func:`build_approval_text`; callers can
override by passing a custom :class:`ApprovalRequest`.
Users click the button ``INTERACTION_CREATE`` fires the adapter's
registered :meth:`set_interaction_callback` handler decodes
``button_data`` via :func:`parse_approval_button_data`.
"""
from gateway.platforms.qqbot.keyboards import build_approval_text
return await self.send_with_keyboard(
chat_id,
build_approval_text(req),
build_approval_keyboard(req.session_key),
reply_to=reply_to,
)
async def send_update_prompt(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a Yes/No update-confirmation prompt with inline buttons.
Button clicks surface as ``INTERACTION_CREATE`` with
``button_data = 'update_prompt:y'`` or ``'update_prompt:n'``.
"""
return await self.send_with_keyboard(
chat_id,
content,
build_update_prompt_keyboard(),
reply_to=reply_to,
)
def _build_text_body( def _build_text_body(
self, content: str, reply_to: Optional[str] = None self, content: str, reply_to: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:

View file

@ -0,0 +1,473 @@
"""QQ Bot inline keyboards + approval / update-prompt senders.
QQ Bot v2 supports attaching inline keyboards to outbound messages. When a
user clicks a button, the platform dispatches an ``INTERACTION_CREATE``
gateway event containing the button's ``data`` payload. The bot must ACK the
interaction promptly via ``PUT /interactions/{id}`` or the user sees an
error indicator on the button.
This module provides:
- :class:`InlineKeyboard` + button dataclasses serialized into the
``keyboard`` field of the outbound message body.
- :func:`build_approval_keyboard` 3-button once / always / deny
keyboard for tool-approval flows.
- :func:`build_update_prompt_keyboard` Yes/No keyboard for update confirms.
- :func:`parse_approval_button_data` / :func:`parse_update_prompt_button_data`
decode the ``button_data`` payload from ``INTERACTION_CREATE``.
- :class:`ApprovalRequest` + :class:`ApprovalSender` high-level helper that
builds an approval message with keyboard and posts it to a c2c / group chat.
``button_data`` formats::
approve:<session_key>:<decision> # decision = allow-once|allow-always|deny
update_prompt:<answer> # answer = y|n
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``approval.py`` + ``dto.py``
keyboard types). Authorship preserved via Co-authored-by.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
# ── button_data prefixes + patterns ──────────────────────────────────
APPROVAL_BUTTON_PREFIX = "approve:"
UPDATE_PROMPT_PREFIX = "update_prompt:"
# Pattern: approve:<session_key>:<decision>
# session_key may itself contain colons (e.g. agent:main:qqbot:c2c:OPENID),
# so the session_key group is greedy but trails the decision.
_APPROVAL_DATA_RE = re.compile(
r"^approve:(.+):(allow-once|allow-always|deny)$"
)
# Pattern: update_prompt:y | update_prompt:n
_UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$")
# ── Keyboard dataclasses ─────────────────────────────────────────────
@dataclass
class KeyboardButtonPermission:
"""Button permission metadata. ``type=2`` means all users can click."""
type: int = 2
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type}
@dataclass
class KeyboardButtonAction:
"""What happens when the button is clicked.
:param type: ``1`` (Callback triggers ``INTERACTION_CREATE``) or
``2`` (Link opens a URL).
:param data: Payload delivered in ``data.resolved.button_data`` when
``type=1``.
:param permission: :class:`KeyboardButtonPermission`.
:param click_limit: Max clicks per user (``1`` = single-use).
"""
type: int
data: str
permission: KeyboardButtonPermission = field(
default_factory=KeyboardButtonPermission
)
click_limit: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"data": self.data,
"permission": self.permission.to_dict(),
"click_limit": self.click_limit,
}
@dataclass
class KeyboardButtonRenderData:
"""Visual rendering of a button.
:param label: Pre-click label.
:param visited_label: Post-click label (button stays greyed in place).
:param style: ``0`` = grey, ``1`` = blue.
"""
label: str
visited_label: str
style: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"label": self.label,
"visited_label": self.visited_label,
"style": self.style,
}
@dataclass
class KeyboardButton:
"""One button in a keyboard.
:param group_id: Buttons sharing a ``group_id`` are mutually exclusive
clicking one greys the rest.
"""
id: str
render_data: KeyboardButtonRenderData
action: KeyboardButtonAction
group_id: str = "default"
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"render_data": self.render_data.to_dict(),
"action": self.action.to_dict(),
"group_id": self.group_id,
}
@dataclass
class KeyboardRow:
buttons: List[KeyboardButton] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {"buttons": [b.to_dict() for b in self.buttons]}
@dataclass
class KeyboardContent:
rows: List[KeyboardRow] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {"rows": [r.to_dict() for r in self.rows]}
@dataclass
class InlineKeyboard:
"""Top-level keyboard payload — goes into ``MessageToCreate.keyboard``."""
content: KeyboardContent = field(default_factory=KeyboardContent)
def to_dict(self) -> Dict[str, Any]:
return {"content": self.content.to_dict()}
# ── INTERACTION_CREATE parsing ───────────────────────────────────────
def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]:
"""Parse approval ``button_data`` into ``(session_key, decision)``.
:param button_data: Raw ``data.resolved.button_data`` from
``INTERACTION_CREATE``.
:returns: ``(session_key, decision)`` or ``None`` if not an approval button.
"""
m = _APPROVAL_DATA_RE.match(button_data or "")
if not m:
return None
return m.group(1), m.group(2)
def parse_update_prompt_button_data(button_data: str) -> Optional[str]:
"""Parse update-prompt ``button_data`` into ``'y'`` or ``'n'``."""
m = _UPDATE_PROMPT_RE.match(button_data or "")
if not m:
return None
return m.group(1)
# ── Keyboard builders ────────────────────────────────────────────────
def _make_callback_button(
btn_id: str,
label: str,
visited_label: str,
data: str,
style: int,
group_id: str,
) -> KeyboardButton:
return KeyboardButton(
id=btn_id,
render_data=KeyboardButtonRenderData(
label=label,
visited_label=visited_label,
style=style,
),
action=KeyboardButtonAction(type=1, data=data),
group_id=group_id,
)
def build_approval_keyboard(session_key: str) -> InlineKeyboard:
"""Build the 3-button approval keyboard.
Layout: ``[ 允许一次] [ 始终允许] [ 拒绝]`` all three share
``group_id='approval'`` so clicking one greys out the rest.
:param session_key: Embedded into ``button_data`` so the decision
routes back to the right pending approval.
"""
return InlineKeyboard(
content=KeyboardContent(
rows=[
KeyboardRow(buttons=[
_make_callback_button(
btn_id="allow",
label="✅ 允许一次",
visited_label="已允许",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once",
style=1,
group_id="approval",
),
_make_callback_button(
btn_id="always",
label="⭐ 始终允许",
visited_label="已始终允许",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always",
style=1,
group_id="approval",
),
_make_callback_button(
btn_id="deny",
label="❌ 拒绝",
visited_label="已拒绝",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny",
style=0,
group_id="approval",
),
]),
]
)
)
def build_update_prompt_keyboard() -> InlineKeyboard:
"""Build a Yes/No keyboard for update confirmation prompts."""
return InlineKeyboard(
content=KeyboardContent(
rows=[
KeyboardRow(buttons=[
_make_callback_button(
btn_id="yes",
label="✓ 确认",
visited_label="已确认",
data=f"{UPDATE_PROMPT_PREFIX}y",
style=1,
group_id="update_prompt",
),
_make_callback_button(
btn_id="no",
label="✗ 取消",
visited_label="已取消",
data=f"{UPDATE_PROMPT_PREFIX}n",
style=0,
group_id="update_prompt",
),
]),
]
)
)
# ── ApprovalRequest + text builder ───────────────────────────────────
@dataclass
class ApprovalRequest:
"""Structured approval-request display data.
:param session_key: Routes the decision back to the waiting caller.
:param title: Short title at the top.
:param description: Optional longer description.
:param command_preview: Command text (exec approvals).
:param cwd: Working directory (exec approvals).
:param tool_name: Tool name (plugin approvals).
:param severity: ``'critical' | 'info' | ''``.
:param timeout_sec: Seconds until the approval expires.
"""
session_key: str
title: str
description: str = ""
command_preview: str = ""
cwd: str = ""
tool_name: str = ""
severity: str = ""
timeout_sec: int = 120
def build_approval_text(req: ApprovalRequest) -> str:
"""Render an :class:`ApprovalRequest` into the message body (markdown)."""
if req.command_preview or req.cwd:
return _build_exec_text(req)
return _build_plugin_text(req)
def _build_exec_text(req: ApprovalRequest) -> str:
lines: List[str] = ["🔐 **命令执行审批**", ""]
if req.command_preview:
preview = req.command_preview[:300]
lines.append(f"```\n{preview}\n```")
if req.cwd:
lines.append(f"📁 目录: {req.cwd}")
if req.title and req.title != req.command_preview:
lines.append(f"📋 {req.title}")
if req.description:
lines.append(f"📝 {req.description}")
lines.append("")
lines.append(f"⏱️ 超时: {req.timeout_sec}")
return "\n".join(lines)
def _build_plugin_text(req: ApprovalRequest) -> str:
icon = (
"🔴" if req.severity == "critical"
else "🔵" if req.severity == "info"
else "🟡"
)
lines: List[str] = [f"{icon} **审批请求**", ""]
lines.append(f"📋 {req.title}")
if req.description:
lines.append(f"📝 {req.description}")
if req.tool_name:
lines.append(f"🔧 工具: {req.tool_name}")
lines.append("")
lines.append(f"⏱️ 超时: {req.timeout_sec}")
return "\n".join(lines)
# ── ApprovalSender ───────────────────────────────────────────────────
PostMessageFn = Callable[..., Awaitable[Dict[str, Any]]]
"""Signature of an async POST to ``/v2/{users|groups}/{id}/messages``.
Implementations accept a body dict and return the raw API response.
"""
class ApprovalSender:
"""Send an approval-request message with an inline keyboard.
Decoupled from the adapter via callables so it can be unit-tested in
isolation. Pass the adapter's ``_send_message_with_keyboard`` helper
(or any equivalent) as ``post_message``.
"""
def __init__(
self,
post_c2c: PostMessageFn,
post_group: PostMessageFn,
log_tag: str = "QQBot",
) -> None:
self._post_c2c = post_c2c
self._post_group = post_group
self._log_tag = log_tag
async def send(
self,
chat_type: str,
chat_id: str,
req: ApprovalRequest,
msg_id: Optional[str] = None,
) -> bool:
"""Send an approval message to *chat_id*.
:param chat_type: ``'c2c'`` or ``'group'``.
:param chat_id: User openid or group openid.
:param req: :class:`ApprovalRequest`.
:param msg_id: Reply-to message id (required for passive messages).
:returns: ``True`` on success, ``False`` on failure.
"""
text = build_approval_text(req)
keyboard = build_approval_keyboard(req.session_key)
logger.info(
"[%s] Sending approval request to %s:%s (session=%.20s…)",
self._log_tag, chat_type, chat_id, req.session_key,
)
try:
if chat_type == "c2c":
await self._post_c2c(chat_id, text, msg_id, keyboard)
elif chat_type == "group":
await self._post_group(chat_id, text, msg_id, keyboard)
else:
logger.warning(
"[%s] Approval: unsupported chat_type %r",
self._log_tag, chat_type,
)
return False
logger.info(
"[%s] Approval message sent to %s:%s",
self._log_tag, chat_type, chat_id,
)
return True
except Exception as exc:
logger.error(
"[%s] Failed to send approval message to %s:%s: %s",
self._log_tag, chat_type, chat_id, exc,
)
return False
# ── INTERACTION_CREATE event shape ───────────────────────────────────
@dataclass
class InteractionEvent:
"""Parsed ``INTERACTION_CREATE`` event payload.
See https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
"""
id: str = ""
"""Interaction event id — required for the ``PUT /interactions/{id}`` ACK."""
type: int = 0
"""Event type code (``11`` = message button)."""
chat_type: int = 0
"""``0`` = guild, ``1`` = group, ``2`` = c2c."""
scene: str = ""
"""``'guild'`` | ``'group'`` | ``'c2c'`` — human-readable scene."""
group_openid: str = ""
group_member_openid: str = ""
user_openid: str = ""
channel_id: str = ""
guild_id: str = ""
button_data: str = ""
button_id: str = ""
resolver_user_id: str = ""
@property
def operator_openid(self) -> str:
"""Best available operator openid (group → member; c2c → user)."""
return (
self.group_member_openid
or self.user_openid
or self.resolver_user_id
)
def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent:
"""Parse a raw ``INTERACTION_CREATE`` dispatch payload (``d``)."""
data_raw = raw.get("data") or {}
resolved = data_raw.get("resolved") or {}
scene_code = int(raw.get("chat_type", 0) or 0)
scene = {0: "guild", 1: "group", 2: "c2c"}.get(scene_code, "")
return InteractionEvent(
id=str(raw.get("id", "")),
type=int(data_raw.get("type", 0) or 0),
chat_type=scene_code,
scene=scene,
group_openid=str(raw.get("group_openid", "")),
group_member_openid=str(raw.get("group_member_openid", "")),
user_openid=str(raw.get("user_openid", "")),
channel_id=str(raw.get("channel_id", "")),
guild_id=str(raw.get("guild_id", "")),
button_data=str(resolved.get("button_data", "")),
button_id=str(resolved.get("button_id", "")),
resolver_user_id=str(resolved.get("user_id", "")),
)

View file

@ -975,3 +975,329 @@ class TestChunkedUploaderFlow:
) )
assert result["file_info"] == "F" assert result["file_info"] == "F"
assert put_attempts["n"] == 2 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"}},
})