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 2e034bfc..435e74e8 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -179,8 +179,6 @@ class AdaptersRouterGroup(group.RouterGroup): """Start WeChat QR code login. Returns session_id + QR code data URL.""" import uuid import time - import io - import base64 from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL @@ -208,60 +206,32 @@ class AdaptersRouterGroup(group.RouterGroup): async def run_login(): try: - import qrcode as qr_lib - for _attempt in range(3): - qr_resp = await client.fetch_qrcode() - if not qr_resp.qrcode or not qr_resp.qrcode_img_content: - raise Exception('Failed to get QR code from server') - - # Generate QR code image locally - qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L) - qr.add_data(qr_resp.qrcode_img_content) - qr.make(fit=True) - img = qr.make_image(fill_color='black', back_color='white') - buf = io.BytesIO() - img.save(buf, format='PNG') - b64 = base64.b64encode(buf.getvalue()).decode('utf-8') - data_url = f'data:image/png;base64,{b64}' - - def _update_qr(): - session['qr_data_url'] = data_url - session['expire_at'] = time.time() + 480 # 8 minutes + def on_qrcode(qr_data_url: str, _qr_url: str): + def _update(): + session['qr_data_url'] = qr_data_url + session['expire_at'] = time.time() + 180 session['status'] = 'waiting' - loop.call_soon_threadsafe(_update_qr) - - # Poll for scan status - deadline = loop.time() + 180 - while loop.time() < deadline: - try: - status_resp = await client.poll_qrcode_status(qr_resp.qrcode) - except Exception: - await asyncio.sleep(2) - continue - - if status_resp.status == 'confirmed' and status_resp.bot_token: - session['status'] = 'success' - session['token'] = status_resp.bot_token - session['base_url'] = status_resp.baseurl or client.base_url - session['account_id'] = status_resp.ilink_bot_id or '' - return - - if status_resp.status == 'expired': - break # retry with new QR code - - await asyncio.sleep(1) - else: - pass # timeout, retry - - # All retries exhausted - session['status'] = 'error' - session['error'] = 'QR code login failed: max retries exceeded' + loop.call_soon_threadsafe(_update) + result = await client.login( + max_retries=1, + poll_timeout_ms=180_000, + on_qrcode=on_qrcode, + ) + session['status'] = 'success' + session['token'] = result.token + session['base_url'] = result.base_url + session['account_id'] = result.account_id except Exception as e: - session['status'] = 'error' - session['error'] = str(e) + error_message = str(e) + if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower(): + session['status'] = 'expired' + session['error'] = 'QR code expired' + else: + session['status'] = 'error' + session['error'] = error_message finally: await client.close() @@ -295,7 +265,11 @@ class AdaptersRouterGroup(group.RouterGroup): if not session: return self.http_status(404, -1, 'Session not found') - data = {'status': session['status']} + data = { + 'status': session['status'], + 'qr_data_url': session['qr_data_url'], + 'expire_at': session['expire_at'], + } if session['status'] == 'success': data['token'] = session['token'] @@ -305,6 +279,9 @@ class AdaptersRouterGroup(group.RouterGroup): elif session['status'] == 'error': data['error'] = session['error'] _weixin_login_sessions.pop(session_id, None) + elif session['status'] == 'expired': + data['error'] = session['error'] + _weixin_login_sessions.pop(session_id, None) return self.success(data=data) diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 766dafb0..2cc14493 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -4,11 +4,16 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { useTranslation } from 'react-i18next'; -import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react'; +import { + Loader2, + RefreshCw, + RotateCw, + CheckCircle2, + XCircle, +} from 'lucide-react'; import QRCode from 'qrcode'; export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot'; @@ -96,7 +101,7 @@ interface QrCodeLoginDialogProps { onSuccess: (credentials: Record) => void; } -type DialogState = 'connecting' | 'waiting' | 'success' | 'error'; +type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error'; const POLL_INTERVAL_MS = 3000; @@ -115,8 +120,10 @@ export default function QrCodeLoginDialog({ const [errorMessage, setErrorMessage] = useState(''); const pollTimerRef = useRef | null>(null); const countdownRef = useRef | null>(null); + const checkExpiredRef = useRef | null>(null); const abortRef = useRef(null); const sessionIdRef = useRef(null); + const baseUrlRef = useRef(''); const cleanedRef = useRef(false); const onSuccessRef = useRef(onSuccess); @@ -140,11 +147,14 @@ export default function QrCodeLoginDialog({ clearInterval(countdownRef.current); countdownRef.current = null; } + if (checkExpiredRef.current) { + clearInterval(checkExpiredRef.current); + checkExpiredRef.current = null; + } if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } - // Cancel backend session if (sessionIdRef.current) { const token = localStorage.getItem('token'); const baseUrl = @@ -171,6 +181,7 @@ export default function QrCodeLoginDialog({ const token = localStorage.getItem('token'); const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; + baseUrlRef.current = baseUrl; const cfg = platformConfigRef.current; try { @@ -191,8 +202,6 @@ export default function QrCodeLoginDialog({ const { session_id, qr_data_url, qr_url, expire_at } = json.data; sessionIdRef.current = session_id; - // qr_data_url is a pre-rendered data URL (WeChat); - // qr_url is a plain URL string (Feishu) that needs local QR generation. if (qr_data_url) { setQrDataUrl(qr_data_url); } else if (qr_url) { @@ -204,11 +213,9 @@ export default function QrCodeLoginDialog({ } setState('waiting'); - // Calculate remaining seconds const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000)); setExpireIn(remaining); - // Start countdown countdownRef.current = setInterval(() => { setExpireIn((prev) => { if (prev <= 1) { @@ -222,7 +229,35 @@ export default function QrCodeLoginDialog({ }); }, 1000); - // Start polling + // When countdown hits 0, stop polling and show expired state + checkExpiredRef.current = setInterval(() => { + setExpireIn((current) => { + if (current <= 0) { + if (checkExpiredRef.current) { + clearInterval(checkExpiredRef.current); + checkExpiredRef.current = null; + } + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (sessionIdRef.current) { + fetch( + `${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + keepalive: true, + }, + ).catch(() => {}); + sessionIdRef.current = null; + } + setState('expired'); + } + return current; + }); + }, 500); + pollTimerRef.current = setInterval(async () => { try { const pollRes = await fetch( @@ -237,7 +272,7 @@ export default function QrCodeLoginDialog({ const { status, error, ...rest } = pollJson.data; if (status === 'success') { - sessionIdRef.current = null; // backend already cleaned up + sessionIdRef.current = null; cleanup(); setState('success'); setTimeout(() => { @@ -249,9 +284,14 @@ export default function QrCodeLoginDialog({ cleanup(); setState('error'); setErrorMessage(error || tRef.current(cfg.failedKey)); + } else if (status === 'expired') { + sessionIdRef.current = null; + cleanup(); + setExpireIn(0); + setState('expired'); } } catch { - // ignore poll errors, will retry next interval + // ignore poll errors } }, POLL_INTERVAL_MS); } catch (err: unknown) { @@ -323,6 +363,31 @@ export default function QrCodeLoginDialog({ )} + {/* QR code expired — click overlay to refresh */} + {state === 'expired' && qrDataUrl && ( +
+

+ {t(platformConfig.scanQRCodeKey)} +

+ +
+ )} + {/* Success */} {state === 'success' && (
@@ -350,7 +415,7 @@ export default function QrCodeLoginDialog({
{state === 'error' && ( - +
@@ -358,7 +423,7 @@ export default function QrCodeLoginDialog({ {t(platformConfig.retryKey)} - +
)}