feat(qqofficial): implement one-click QR binding and enhance localization support

This commit is contained in:
fdc310
2026-06-17 00:31:29 +08:00
parent e6cfee541f
commit b55f073e62
11 changed files with 394 additions and 2 deletions

View File

@@ -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/<session_id>', 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/<session_id>', 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={})

View File

@@ -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:

View File

@@ -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<string, string>) => Record<string, string>;
successNoteKey?: string;
boundByKey?: string;
}
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
@@ -92,6 +98,22 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
}),
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<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | 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({
<p className="text-sm text-green-600 font-medium">
{t(platformConfig.successKey)}
</p>
{successMeta && (
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
{successMeta}
</p>
)}
{platformConfig.successNoteKey && (
<p className="text-xs text-muted-foreground text-center max-w-xs">
{t(platformConfig.successNoteKey)}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: '無効なプラグインページ',

View File

@@ -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: 'Недопустимая страница плагина',

View File

@@ -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: 'หน้าปลั๊กอินไม่ถูกต้อง',

View File

@@ -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ệ',

View File

@@ -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: '无效的插件页面',

View File

@@ -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: '無效的插件頁面',