245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
"""QQ Bot inline keyboards + approval / update-prompt helpers.
|
|
|
|
QQ Bot v2 attaches inline keyboards to outbound messages. A button click
|
|
dispatches an ``INTERACTION_CREATE`` gateway event carrying the button's
|
|
``data`` payload; the bot must ACK promptly via ``PUT /interactions/{id}`` or
|
|
the user sees an error indicator on the button.
|
|
|
|
``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 re
|
|
from dataclasses import dataclass, field, fields, is_dataclass
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
APPROVAL_BUTTON_PREFIX = "approve:"
|
|
UPDATE_PROMPT_PREFIX = "update_prompt:"
|
|
|
|
# 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)$")
|
|
_UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$")
|
|
|
|
|
|
# ── Keyboard dataclasses ─────────────────────────────────────────────
|
|
|
|
def _to_dict(value: Any) -> Any:
|
|
"""Serialize a dataclass tree in field-declaration order (the wire shape)."""
|
|
if is_dataclass(value):
|
|
return {f.name: _to_dict(getattr(value, f.name)) for f in fields(value)}
|
|
if isinstance(value, list):
|
|
return [_to_dict(v) for v in value]
|
|
return value
|
|
|
|
|
|
class _Serializable:
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return _to_dict(self)
|
|
|
|
|
|
@dataclass
|
|
class KeyboardButtonPermission(_Serializable):
|
|
"""Button permission metadata. ``type=2`` means all users can click."""
|
|
type: int = 2
|
|
|
|
|
|
@dataclass
|
|
class KeyboardButtonAction(_Serializable):
|
|
"""Click behaviour: ``type`` 1 = Callback (INTERACTION_CREATE with ``data``), 2 = Link.
|
|
``click_limit=1`` = single-use.
|
|
"""
|
|
type: int
|
|
data: str
|
|
permission: KeyboardButtonPermission = field(default_factory=KeyboardButtonPermission)
|
|
click_limit: int = 1
|
|
|
|
|
|
@dataclass
|
|
class KeyboardButtonRenderData(_Serializable):
|
|
"""Visual rendering: pre/post-click labels; ``style`` 0 = grey, 1 = blue."""
|
|
label: str
|
|
visited_label: str
|
|
style: int = 1
|
|
|
|
|
|
@dataclass
|
|
class KeyboardButton(_Serializable):
|
|
"""One button; buttons sharing a ``group_id`` are mutually exclusive."""
|
|
id: str
|
|
render_data: KeyboardButtonRenderData
|
|
action: KeyboardButtonAction
|
|
group_id: str = "default"
|
|
|
|
|
|
@dataclass
|
|
class KeyboardRow(_Serializable):
|
|
buttons: List[KeyboardButton] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class KeyboardContent(_Serializable):
|
|
rows: List[KeyboardRow] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class InlineKeyboard(_Serializable):
|
|
"""Top-level keyboard payload — goes into ``MessageToCreate.keyboard``."""
|
|
content: KeyboardContent = field(default_factory=KeyboardContent)
|
|
|
|
|
|
# ── INTERACTION_CREATE parsing ───────────────────────────────────────
|
|
|
|
def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]:
|
|
"""Parse approval ``button_data`` into ``(session_key, decision)`` or ``None``."""
|
|
m = _APPROVAL_DATA_RE.match(button_data or "")
|
|
return (m.group(1), m.group(2)) if m else None
|
|
|
|
|
|
def parse_update_prompt_button_data(button_data: str) -> Optional[str]:
|
|
"""Parse update-prompt ``button_data`` into ``'y'`` / ``'n'`` or ``None``."""
|
|
m = _UPDATE_PROMPT_RE.match(button_data or "")
|
|
return m.group(1) if m else None
|
|
|
|
|
|
# ── 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 _single_row_keyboard(buttons: List[KeyboardButton]) -> InlineKeyboard:
|
|
return InlineKeyboard(content=KeyboardContent(rows=[KeyboardRow(buttons=buttons)]))
|
|
|
|
|
|
def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard:
|
|
"""Build ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` (one group, so a click greys the rest).
|
|
|
|
The ⭐ button is hidden when persistent scope is unavailable. *session_key*
|
|
is embedded in ``button_data`` so the decision routes to the right approval.
|
|
"""
|
|
prefix = f"{APPROVAL_BUTTON_PREFIX}{session_key}"
|
|
buttons = [_make_callback_button("allow", "✅ 允许一次", "已允许", f"{prefix}:allow-once", 1, "approval")]
|
|
if allow_permanent:
|
|
buttons.append(_make_callback_button("always", "⭐ 始终允许", "已始终允许", f"{prefix}:allow-always", 1, "approval"))
|
|
buttons.append(_make_callback_button("deny", "❌ 拒绝", "已拒绝", f"{prefix}:deny", 0, "approval"))
|
|
return _single_row_keyboard(buttons)
|
|
|
|
|
|
def build_update_prompt_keyboard() -> InlineKeyboard:
|
|
"""Build a Yes/No keyboard for update confirmation prompts."""
|
|
return _single_row_keyboard([
|
|
_make_callback_button("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1, "update_prompt"),
|
|
_make_callback_button("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0, "update_prompt"),
|
|
])
|
|
|
|
|
|
# ── ApprovalRequest + text builder ───────────────────────────────────
|
|
|
|
@dataclass
|
|
class ApprovalRequest:
|
|
"""Approval-request display data.
|
|
|
|
``command_preview`` / ``cwd`` are set for exec approvals, ``tool_name`` for
|
|
plugin approvals; ``severity`` is ``'critical' | 'info' | ''``.
|
|
"""
|
|
session_key: str
|
|
title: str
|
|
description: str = ""
|
|
command_preview: str = ""
|
|
cwd: str = ""
|
|
tool_name: str = ""
|
|
severity: str = ""
|
|
timeout_sec: int = 120
|
|
allow_permanent: bool = True
|
|
|
|
|
|
_SEVERITY_ICONS = {"critical": "🔴", "info": "🔵"}
|
|
|
|
|
|
def build_approval_text(req: ApprovalRequest) -> str:
|
|
"""Render an :class:`ApprovalRequest` into the message body (markdown)."""
|
|
if req.command_preview or req.cwd:
|
|
lines = ["🔐 **命令执行审批**", ""]
|
|
if req.command_preview:
|
|
lines.append(f"```\n{req.command_preview[:300]}\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}")
|
|
else:
|
|
lines = [f"{_SEVERITY_ICONS.get(req.severity, '🟡')} **审批请求**", "", f"📋 {req.title}"]
|
|
if req.description:
|
|
lines.append(f"📝 {req.description}")
|
|
if req.tool_name:
|
|
lines.append(f"🔧 工具: {req.tool_name}")
|
|
lines += ["", f"⏱️ 超时: {req.timeout_sec} 秒"]
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── INTERACTION_CREATE event shape ───────────────────────────────────
|
|
|
|
@dataclass
|
|
class InteractionEvent:
|
|
"""Parsed ``INTERACTION_CREATE`` payload.
|
|
|
|
See https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
|
"""
|
|
id: str = "" # 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'
|
|
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
|
|
|
|
|
|
_SCENE_NAMES = {0: "guild", 1: "group", 2: "c2c"}
|
|
|
|
|
|
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)
|
|
return InteractionEvent(
|
|
id=str(raw.get("id", "")),
|
|
type=int(data_raw.get("type", 0) or 0),
|
|
chat_type=scene_code,
|
|
scene=_SCENE_NAMES.get(scene_code, ""),
|
|
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", "")),
|
|
)
|