From b55f073e62d728d91311d6d282a8b8b4050d8299 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Wed, 17 Jun 2026 00:31:29 +0800 Subject: [PATCH] feat(qqofficial): implement one-click QR binding and enhance localization support --- .../controller/groups/platform/adapters.py | 240 ++++++++++++++++++ .../pkg/platform/sources/qqofficial.yaml | 18 +- .../qrcode-login/QrCodeLoginDialog.tsx | 38 ++- web/src/i18n/locales/en-US.ts | 13 + web/src/i18n/locales/es-ES.ts | 14 + web/src/i18n/locales/ja-JP.ts | 13 + web/src/i18n/locales/ru-RU.ts | 13 + web/src/i18n/locales/th-TH.ts | 12 + web/src/i18n/locales/vi-VN.ts | 13 + web/src/i18n/locales/zh-Hans.ts | 11 + web/src/i18n/locales/zh-Hant.ts | 11 + 11 files changed, 394 insertions(+), 2 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 435e74e8..4356e638 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -5,6 +5,29 @@ from ... import group from langbot.pkg.utils import importutil +def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str: + """Decrypt the AppSecret returned by the QQ Official QR binding endpoint. + + The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`. + `key` is the 32-byte AES-256 key locally generated when the bind task + was created and submitted as `key` to `q.qq.com/lite/create_bind_task`. + """ + import base64 + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + try: + raw = base64.b64decode(encrypted_b64) + except Exception as exc: + raise ValueError('Malformed encrypted credential') from exc + if len(key) != 32 or len(raw) <= 28: + raise ValueError('Invalid encrypted credential layout') + nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:] + try: + return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8') + except Exception as exc: + raise ValueError('Failed to decrypt credential') from exc + + @group.group_class('adapters', '/api/v1/platform/adapters') class AdaptersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @@ -650,3 +673,220 @@ class AdaptersRouterGroup(group.RouterGroup): if session and session.get('task') and not session['task'].done(): session['task'].cancel() return self.success(data={}) + + # ----------------------------------------------------------------------- + # QQ Official QR Binding + # ----------------------------------------------------------------------- + + _qqofficial_sessions: dict = {} + _QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window) + + def _cleanup_expired_qqofficial_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL + ] + for sid in expired: + session = _qqofficial_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/qqofficial/bind', methods=['POST']) + async def _() -> str: + """Start QQ Official QR binding. Returns session_id + QR URL. + + Flow: generate a local AES-256 key, register it with + `q.qq.com/lite/create_bind_task`, then poll + `q.qq.com/lite/poll_bind_result` until the user authorizes the + bind inside the QQ Bot Assistant on mobile QQ. The encrypted + AppSecret returned by the poll endpoint is decrypted with the + same key. The key never leaves this process. + """ + import uuid + import time + import secrets + import base64 + import aiohttp + + QQ_BIND_BASE = 'https://q.qq.com' + _cleanup_expired_qqofficial_sessions() + + bind_key_bytes = secrets.token_bytes(32) + bind_key = base64.b64encode(bind_key_bytes).decode('ascii') + + session_id = str(uuid.uuid4()) + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'appid': None, + 'secret': None, + 'user_openid': None, + 'error': None, + 'created_at': time.time(), + 'task_id': None, + 'bind_key_bytes': bind_key_bytes, + 'interval': 2, + } + _qqofficial_sessions[session_id] = session + + async def run_qr_binding(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: create_bind_task — register our AES key, get task_id + async with http.post( + f'{QQ_BIND_BASE}/lite/create_bind_task', + json={'key': bind_key}, + headers={'Accept': 'application/json'}, + ) as resp: + try: + data = await resp.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from QQ bind service' + return + if int(data.get('retcode', -1)) != 0: + session['status'] = 'error' + session['error'] = ( + data.get('msg') or data.get('message') or 'Failed to create bind task' + ) + return + task_id = str((data.get('data') or {}).get('task_id') or '').strip() + if not task_id: + session['status'] = 'error' + session['error'] = 'Missing task_id in QQ response' + return + + # The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant. + # `source=langbot` is a courtesy attribution parameter so Tencent + # can see LangBot adoption metrics, matching the convention used by + # other third-party integrations (e.g. hermes-agent uses `source=hermes`). + qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot' + session['task_id'] = task_id + session['qr_url'] = qr_url + session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL + session['status'] = 'waiting' + + # Step 2: poll_bind_result until completed (status=2) or expired (3). + deadline = time.time() + _QQOFFICIAL_SESSION_TTL + while time.time() < deadline: + await asyncio.sleep(session['interval']) + + async with http.post( + f'{QQ_BIND_BASE}/lite/poll_bind_result', + json={'task_id': task_id}, + headers={'Accept': 'application/json'}, + ) as poll_resp: + try: + poll_data = await poll_resp.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + continue + + if int(poll_data.get('retcode', -1)) != 0: + session['status'] = 'error' + session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed' + return + + payload = poll_data.get('data') or {} + try: + raw_status = int(payload.get('status', 0)) + except (TypeError, ValueError): + raw_status = 0 + + if raw_status == 2: + appid = str(payload.get('bot_appid') or '').strip() + encrypted = str(payload.get('bot_encrypt_secret') or '').strip() + if not appid or not encrypted: + session['status'] = 'error' + session['error'] = 'Incomplete credential payload' + return + try: + session['secret'] = _decrypt_qqofficial_secret( + encrypted, + bind_key_bytes, + ) + except ValueError as exc: + session['status'] = 'error' + session['error'] = str(exc) + return + session['appid'] = appid + # The scanner's OpenID is returned alongside the credentials — + # surfaced to the dashboard for audit / "bound by" display. + session['user_openid'] = str(payload.get('user_openid') or '').strip() or None + session['status'] = 'success' + return + + if raw_status == 3: + session['status'] = 'expired' + session['error'] = 'QR code expired' + return + # status 0 / 1: still pending, continue polling + + session['status'] = 'expired' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_qr_binding()) + session['task'] = task + + # Wait up to 10s for the QR URL to be ready before responding. + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/qqofficial/bind/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll QQ Official QR binding status.""" + _cleanup_expired_qqofficial_sessions() + session = _qqofficial_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['appid'] = session['appid'] + data['secret'] = session['secret'] + if session.get('user_openid'): + data['user_openid'] = session['user_openid'] + _qqofficial_sessions.pop(session_id, None) + elif session['status'] in ('error', 'expired'): + data['error'] = session['error'] + _qqofficial_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/qqofficial/bind/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a QQ Official QR binding session.""" + session = _qqofficial_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index f6afdabc..af0e58af 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -19,6 +19,18 @@ spec: en: https://link.langbot.app/en/platforms/qqofficial ja: https://link.langbot.app/ja/platforms/qqofficial config: + - name: one-click-bind + label: + en_US: One-Click QR Binding + zh_Hans: 一键扫码绑定 + zh_Hant: 一鍵掃碼綁定 + description: + en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token still needs to be filled manually) + zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(Token 仍需手动填写) + zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(Token 仍需手動填寫) + type: qr-code-login + login_platform: qqofficial + required: false - name: appid label: en_US: App ID @@ -40,8 +52,12 @@ spec: en_US: Token zh_Hans: 令牌 zh_Hant: 令牌 + description: + en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank. + zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。 + zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。 type: string - required: true + required: false default: "" - name: enable-webhook label: diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 2cc14493..5865dbb4 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -16,7 +16,12 @@ import { } from 'lucide-react'; import QRCode from 'qrcode'; -export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot'; +export type QrLoginPlatform = + | 'feishu' + | 'weixin' + | 'dingtalk' + | 'wecombot' + | 'qqofficial'; interface PlatformConfig { titleKey: string; @@ -29,6 +34,7 @@ interface PlatformConfig { apiBase: string; extractSuccess: (data: Record) => Record; successNoteKey?: string; + boundByKey?: string; } const PLATFORM_CONFIGS: Record = { @@ -92,6 +98,22 @@ const PLATFORM_CONFIGS: Record = { }), successNoteKey: 'wecombot.robotNameNote', }, + qqofficial: { + titleKey: 'qqofficial.createBinding', + connectingKey: 'qqofficial.connecting', + scanQRCodeKey: 'qqofficial.scanQRCode', + waitingKey: 'qqofficial.waitingForScan', + successKey: 'qqofficial.bindSuccess', + failedKey: 'qqofficial.bindFailed', + retryKey: 'qqofficial.retry', + apiBase: '/api/v1/platform/adapters/qqofficial/bind', + extractSuccess: (data) => ({ + appid: data.appid, + secret: data.secret, + }), + successNoteKey: 'qqofficial.tokenNote', + boundByKey: 'qqofficial.boundBy', + }, }; interface QrCodeLoginDialogProps { @@ -118,6 +140,7 @@ export default function QrCodeLoginDialog({ const [qrDataUrl, setQrDataUrl] = useState(''); const [expireIn, setExpireIn] = useState(0); const [errorMessage, setErrorMessage] = useState(''); + const [successMeta, setSuccessMeta] = useState(''); const pollTimerRef = useRef | null>(null); const countdownRef = useRef | null>(null); const checkExpiredRef = useRef | null>(null); @@ -178,6 +201,7 @@ export default function QrCodeLoginDialog({ setQrDataUrl(''); setExpireIn(0); setErrorMessage(''); + setSuccessMeta(''); const token = localStorage.getItem('token'); const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; @@ -275,6 +299,13 @@ export default function QrCodeLoginDialog({ sessionIdRef.current = null; cleanup(); setState('success'); + // Platform may return extra audit metadata (e.g. QQ Official returns + // the scanner's user_openid) — surface it briefly before the dialog closes. + if (rest.user_openid && cfg.boundByKey) { + setSuccessMeta( + tRef.current(cfg.boundByKey, { openid: rest.user_openid }), + ); + } setTimeout(() => { onSuccessRef.current(cfg.extractSuccess(rest)); onOpenChangeRef.current(false); @@ -395,6 +426,11 @@ export default function QrCodeLoginDialog({

{t(platformConfig.successKey)}

+ {successMeta && ( +

+ {successMeta} +

+ )} {platformConfig.successNoteKey && (

{t(platformConfig.successNoteKey)} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 762728c3..a22992ed 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1383,6 +1383,19 @@ const enUS = { robotNameNote: 'Robot Name cannot be obtained automatically. Please fill it in manually.', }, + qqofficial: { + createBinding: 'One-Click QR Binding for QQ Official Bot', + scanQRCode: + 'Scan the QR code below with mobile QQ and authorize the binding in QQ Bot Assistant', + waitingForScan: 'Waiting for scan', + bindSuccess: 'Bound successfully! AppID and Secret have been filled in', + bindFailed: 'Binding failed', + connecting: 'Connecting to QQ service...', + retry: 'Retry', + tokenNote: + 'The Token field is not used by the current adapter — you can leave it blank.', + boundBy: 'Bound by QQ user {{openid}}', + }, pluginPages: { selectFromSidebar: 'Select a plugin page from the sidebar', invalidPage: 'Invalid plugin page', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 1a8f444d..a205b152 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1426,6 +1426,20 @@ const esES = { robotNameNote: 'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.', }, + qqofficial: { + createBinding: 'Vinculación QR con un clic para el bot oficial de QQ', + scanQRCode: + 'Escanea el código QR siguiente con QQ móvil y autoriza la vinculación en «QQ Bot Assistant»', + waitingForScan: 'Esperando escaneo', + bindSuccess: + '¡Vinculación correcta! AppID y Secret se han rellenado automáticamente', + bindFailed: 'Error en la vinculación', + connecting: 'Conectando con el servicio de QQ...', + retry: 'Reintentar', + tokenNote: + 'El campo Token no es utilizado por el adaptador actual; puedes dejarlo vacío.', + boundBy: 'Vinculado por el usuario QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'Selecciona una página de plugin en la barra lateral', invalidPage: 'Página de plugin no válida', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index a0797f31..60edcd4c 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1385,6 +1385,19 @@ retry: '再試行', robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。', }, + qqofficial: { + createBinding: 'ワンクリックで QQ 公式ボットを QR バインド', + scanQRCode: + '以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください', + waitingForScan: 'スキャン待ち', + bindSuccess: 'バインド成功!AppID と Secret が自動入力されました', + bindFailed: 'バインド失敗', + connecting: 'QQ サービスに接続中...', + retry: '再試行', + tokenNote: + 'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。', + boundBy: 'QQ ユーザー {{openid}} によりバインドされました', + }, pluginPages: { selectFromSidebar: 'サイドバーからプラグインページを選択してください', invalidPage: '無効なプラグインページ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 4e84b5a2..6bdfed1b 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1395,6 +1395,19 @@ const ruRU = { robotNameNote: 'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.', }, + qqofficial: { + createBinding: 'Привязка официального бота QQ по QR-коду', + scanQRCode: + 'Отсканируйте QR-код ниже мобильным QQ и подтвердите привязку в «QQ Bot Assistant»', + waitingForScan: 'Ожидание сканирования', + bindSuccess: 'Привязка успешна! AppID и Secret заполнены автоматически', + bindFailed: 'Не удалось выполнить привязку', + connecting: 'Подключение к сервису QQ...', + retry: 'Повторить', + tokenNote: + 'Поле Token не используется текущим адаптером — его можно оставить пустым.', + boundBy: 'Привязано пользователем QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'Выберите страницу плагина на боковой панели', invalidPage: 'Недопустимая страница плагина', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index c5df899a..27900d55 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1361,6 +1361,18 @@ const thTH = { retry: 'ลองใหม่', robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง', }, + qqofficial: { + createBinding: 'ผูกบอต QQ Official ด้วย QR คลิกเดียว', + scanQRCode: + 'สแกนคิวอาร์โค้ดด้านล่างด้วย QQ มือถือ แล้วอนุญาตการผูกใน «QQ Bot Assistant»', + waitingForScan: 'กำลังรอสแกน', + bindSuccess: 'ผูกสำเร็จ! AppID และ Secret ถูกกรอกอัตโนมัติแล้ว', + bindFailed: 'การผูกล้มเหลว', + connecting: 'กำลังเชื่อมต่อบริการ QQ...', + retry: 'ลองใหม่', + tokenNote: 'อะแดปเตอร์ปัจจุบันไม่ได้ใช้ฟิลด์ Token จึงเว้นว่างไว้ได้', + boundBy: 'ผูกโดยผู้ใช้ QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง', invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 259c5398..b8aee8dd 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1385,6 +1385,19 @@ const viVN = { retry: 'Thử lại', robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.', }, + qqofficial: { + createBinding: 'Liên kết bot QQ Official bằng QR một chạm', + scanQRCode: + 'Quét mã QR bên dưới bằng QQ trên di động và xác nhận liên kết trong «QQ Bot Assistant»', + waitingForScan: 'Đang chờ quét', + bindSuccess: 'Liên kết thành công! AppID và Secret đã được điền tự động', + bindFailed: 'Liên kết thất bại', + connecting: 'Đang kết nối tới dịch vụ QQ...', + retry: 'Thử lại', + tokenNote: + 'Bộ chuyển đổi hiện tại không dùng trường Token; có thể để trống.', + boundBy: 'Được liên kết bởi người dùng QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'Chọn một trang plugin từ thanh bên', invalidPage: 'Trang plugin không hợp lệ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index c0b337fb..b3c8136a 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1319,6 +1319,17 @@ const zhHans = { retry: '重试', robotNameNote: '机器人名称无法自动获取,请手动填写。', }, + qqofficial: { + createBinding: '一键扫码绑定 QQ 机器人', + scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定', + waitingForScan: '等待扫码中', + bindSuccess: '绑定成功!AppID 与密钥已自动填入', + bindFailed: '绑定失败', + connecting: '正在连接 QQ 服务...', + retry: '重试', + tokenNote: 'Token 字段当前适配器未使用,留空即可。', + boundBy: '由 QQ 用户 {{openid}} 扫码绑定', + }, pluginPages: { selectFromSidebar: '从侧边栏选择一个插件页面', invalidPage: '无效的插件页面', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 36dbca76..2f7ca366 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1319,6 +1319,17 @@ const zhHant = { retry: '重試', robotNameNote: '機器人名稱無法自動取得,請手動填寫。', }, + qqofficial: { + createBinding: '一鍵掃碼綁定 QQ 機器人', + scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定', + waitingForScan: '等待掃碼中', + bindSuccess: '綁定成功!AppID 與密鑰已自動填入', + bindFailed: '綁定失敗', + connecting: '正在連線 QQ 服務...', + retry: '重試', + tokenNote: 'Token 欄位目前介面卡未使用,留空即可。', + boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定', + }, pluginPages: { selectFromSidebar: '從側邊欄選擇一個插件頁面', invalidPage: '無效的插件頁面',