mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
- Replace async create_bind_task/poll_bind_result with synchronous httpx.Client equivalents, eliminating manual event loop management - Move _render_qr and full qr_register() entry-point into onboard.py, mirroring the Feishu onboarding pattern - Remove _qqbot_render_qr and _qqbot_qr_flow from gateway.py (~90 lines); call site becomes a single qr_register() import - Fix potential segfault: previous code called loop.close() in the EXPIRED branch and again in the finally block (double-close crashed under uvloop)
220 lines
7 KiB
Python
220 lines
7 KiB
Python
"""
|
|
QQBot scan-to-configure (QR code onboard) module.
|
|
|
|
Mirrors the Feishu onboarding pattern: synchronous HTTP + a single public
|
|
entry-point ``qr_register()`` that handles the full flow (create task →
|
|
display QR code → poll → decrypt credentials).
|
|
|
|
Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to
|
|
generate a QR-code URL and poll for scan completion. On success the caller
|
|
receives the bot's *app_id*, *client_secret* (decrypted locally), and the
|
|
scanner's *user_openid* — enough to fully configure the QQBot gateway.
|
|
|
|
Reference: https://bot.q.qq.com/wiki/develop/api-v2/
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from enum import IntEnum
|
|
from typing import Optional, Tuple
|
|
from urllib.parse import quote
|
|
|
|
from .constants import (
|
|
ONBOARD_API_TIMEOUT,
|
|
ONBOARD_CREATE_PATH,
|
|
ONBOARD_POLL_INTERVAL,
|
|
ONBOARD_POLL_PATH,
|
|
PORTAL_HOST,
|
|
QR_URL_TEMPLATE,
|
|
)
|
|
from .crypto import decrypt_secret, generate_bind_key
|
|
from .utils import get_api_headers
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bind status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BindStatus(IntEnum):
|
|
"""Status codes returned by ``_poll_bind_result``."""
|
|
|
|
NONE = 0
|
|
PENDING = 1
|
|
COMPLETED = 2
|
|
EXPIRED = 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# QR rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
try:
|
|
import qrcode as _qrcode_mod
|
|
except (ImportError, TypeError):
|
|
_qrcode_mod = None # type: ignore[assignment]
|
|
|
|
|
|
def _render_qr(url: str) -> bool:
|
|
"""Try to render a QR code in the terminal. Returns True if successful."""
|
|
if _qrcode_mod is None:
|
|
return False
|
|
try:
|
|
qr = _qrcode_mod.QRCode(
|
|
error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
|
|
border=2,
|
|
)
|
|
qr.add_data(url)
|
|
qr.make(fit=True)
|
|
qr.print_ascii(invert=True)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synchronous HTTP helpers (mirrors Feishu _post_registration pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]:
|
|
"""Create a bind task and return *(task_id, aes_key_base64)*.
|
|
|
|
Raises:
|
|
RuntimeError: If the API returns a non-zero ``retcode``.
|
|
"""
|
|
import httpx
|
|
|
|
url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}"
|
|
key = generate_bind_key()
|
|
|
|
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
resp = client.post(url, json={"key": key}, headers=get_api_headers())
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if data.get("retcode") != 0:
|
|
raise RuntimeError(data.get("msg", "create_bind_task failed"))
|
|
|
|
task_id = data.get("data", {}).get("task_id")
|
|
if not task_id:
|
|
raise RuntimeError("create_bind_task: missing task_id in response")
|
|
|
|
logger.debug("create_bind_task ok: task_id=%s", task_id)
|
|
return task_id, key
|
|
|
|
|
|
def _poll_bind_result(
|
|
task_id: str,
|
|
timeout: float = ONBOARD_API_TIMEOUT,
|
|
) -> Tuple[BindStatus, str, str, str]:
|
|
"""Poll the bind result for *task_id*.
|
|
|
|
Returns:
|
|
A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``.
|
|
|
|
Raises:
|
|
RuntimeError: If the API returns a non-zero ``retcode``.
|
|
"""
|
|
import httpx
|
|
|
|
url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}"
|
|
|
|
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
resp = client.post(url, json={"task_id": task_id}, headers=get_api_headers())
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if data.get("retcode") != 0:
|
|
raise RuntimeError(data.get("msg", "poll_bind_result failed"))
|
|
|
|
d = data.get("data", {})
|
|
return (
|
|
BindStatus(d.get("status", 0)),
|
|
str(d.get("bot_appid", "")),
|
|
d.get("bot_encrypt_secret", ""),
|
|
d.get("user_openid", ""),
|
|
)
|
|
|
|
|
|
def build_connect_url(task_id: str) -> str:
|
|
"""Build the QR-code target URL for a given *task_id*."""
|
|
return QR_URL_TEMPLATE.format(task_id=quote(task_id))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public entry-point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MAX_REFRESHES = 3
|
|
|
|
|
|
def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
|
|
"""Run the QQBot scan-to-configure QR registration flow.
|
|
|
|
Mirrors ``feishu.qr_register()``: handles create → display → poll →
|
|
decrypt in one call. Unexpected errors propagate to the caller.
|
|
|
|
:returns:
|
|
``{"app_id": ..., "client_secret": ..., "user_openid": ...}`` on
|
|
success, or ``None`` on failure / expiry / cancellation.
|
|
"""
|
|
deadline = time.monotonic() + timeout_seconds
|
|
|
|
for refresh_count in range(_MAX_REFRESHES + 1):
|
|
# ── Create bind task ──
|
|
try:
|
|
task_id, aes_key = _create_bind_task()
|
|
except Exception as exc:
|
|
logger.warning("[QQBot onboard] Failed to create bind task: %s", exc)
|
|
return None
|
|
|
|
url = build_connect_url(task_id)
|
|
|
|
# ── Display QR code + URL ──
|
|
print()
|
|
if _render_qr(url):
|
|
print(f" Scan the QR code above, or open this URL directly:\n {url}")
|
|
else:
|
|
print(f" Open this URL in QQ on your phone:\n {url}")
|
|
print(" Tip: pip install qrcode to display a scannable QR code here")
|
|
print()
|
|
|
|
# ── Poll loop ──
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id)
|
|
except Exception:
|
|
time.sleep(ONBOARD_POLL_INTERVAL)
|
|
continue
|
|
|
|
if status == BindStatus.COMPLETED:
|
|
client_secret = decrypt_secret(encrypted_secret, aes_key)
|
|
print()
|
|
print(f" QR scan complete! (App ID: {app_id})")
|
|
if user_openid:
|
|
print(f" Scanner's OpenID: {user_openid}")
|
|
return {
|
|
"app_id": app_id,
|
|
"client_secret": client_secret,
|
|
"user_openid": user_openid,
|
|
}
|
|
|
|
if status == BindStatus.EXPIRED:
|
|
if refresh_count >= _MAX_REFRESHES:
|
|
logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES)
|
|
return None
|
|
print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})")
|
|
break # next for-loop iteration creates a new task
|
|
|
|
time.sleep(ONBOARD_POLL_INTERVAL)
|
|
else:
|
|
# deadline reached without completing
|
|
logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds)
|
|
return None
|
|
|
|
return None
|