mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat(qrcode-login): enhance WeChat login flow with expiration handlin… (#2212)
* feat(qrcode-login): enhance WeChat login flow with expiration handling and improved session management * feat(qrcode-login): replace RefreshCw icon with RotateCw for loading state * feat(qrcode-login): adjust session expiration handling and improve error status management
This commit is contained in:
@@ -179,8 +179,6 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
@@ -208,60 +206,32 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
async def run_login():
|
async def run_login():
|
||||||
try:
|
try:
|
||||||
import qrcode as qr_lib
|
|
||||||
|
|
||||||
for _attempt in range(3):
|
def on_qrcode(qr_data_url: str, _qr_url: str):
|
||||||
qr_resp = await client.fetch_qrcode()
|
def _update():
|
||||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
session['qr_data_url'] = qr_data_url
|
||||||
raise Exception('Failed to get QR code from server')
|
session['expire_at'] = time.time() + 180
|
||||||
|
|
||||||
# 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
|
|
||||||
session['status'] = 'waiting'
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
loop.call_soon_threadsafe(_update_qr)
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
# 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'
|
|
||||||
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
session['status'] = 'error'
|
error_message = str(e)
|
||||||
session['error'] = 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:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
@@ -295,7 +265,11 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if not session:
|
if not session:
|
||||||
return self.http_status(404, -1, 'Session not found')
|
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':
|
if session['status'] == 'success':
|
||||||
data['token'] = session['token']
|
data['token'] = session['token']
|
||||||
@@ -305,6 +279,9 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
elif session['status'] == 'error':
|
elif session['status'] == 'error':
|
||||||
data['error'] = session['error']
|
data['error'] = session['error']
|
||||||
_weixin_login_sessions.pop(session_id, None)
|
_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)
|
return self.success(data=data)
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||||
@@ -96,7 +101,7 @@ interface QrCodeLoginDialogProps {
|
|||||||
onSuccess: (credentials: Record<string, string>) => void;
|
onSuccess: (credentials: Record<string, string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
@@ -115,8 +120,10 @@ export default function QrCodeLoginDialog({
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const baseUrlRef = useRef('');
|
||||||
const cleanedRef = useRef(false);
|
const cleanedRef = useRef(false);
|
||||||
|
|
||||||
const onSuccessRef = useRef(onSuccess);
|
const onSuccessRef = useRef(onSuccess);
|
||||||
@@ -140,11 +147,14 @@ export default function QrCodeLoginDialog({
|
|||||||
clearInterval(countdownRef.current);
|
clearInterval(countdownRef.current);
|
||||||
countdownRef.current = null;
|
countdownRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (checkExpiredRef.current) {
|
||||||
|
clearInterval(checkExpiredRef.current);
|
||||||
|
checkExpiredRef.current = null;
|
||||||
|
}
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
abortRef.current.abort();
|
abortRef.current.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
// Cancel backend session
|
|
||||||
if (sessionIdRef.current) {
|
if (sessionIdRef.current) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
@@ -171,6 +181,7 @@ export default function QrCodeLoginDialog({
|
|||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
baseUrlRef.current = baseUrl;
|
||||||
const cfg = platformConfigRef.current;
|
const cfg = platformConfigRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -191,8 +202,6 @@ export default function QrCodeLoginDialog({
|
|||||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||||
sessionIdRef.current = session_id;
|
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) {
|
if (qr_data_url) {
|
||||||
setQrDataUrl(qr_data_url);
|
setQrDataUrl(qr_data_url);
|
||||||
} else if (qr_url) {
|
} else if (qr_url) {
|
||||||
@@ -204,11 +213,9 @@ export default function QrCodeLoginDialog({
|
|||||||
}
|
}
|
||||||
setState('waiting');
|
setState('waiting');
|
||||||
|
|
||||||
// Calculate remaining seconds
|
|
||||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||||
setExpireIn(remaining);
|
setExpireIn(remaining);
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
countdownRef.current = setInterval(() => {
|
countdownRef.current = setInterval(() => {
|
||||||
setExpireIn((prev) => {
|
setExpireIn((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
@@ -222,7 +229,35 @@ export default function QrCodeLoginDialog({
|
|||||||
});
|
});
|
||||||
}, 1000);
|
}, 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 () => {
|
pollTimerRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const pollRes = await fetch(
|
const pollRes = await fetch(
|
||||||
@@ -237,7 +272,7 @@ export default function QrCodeLoginDialog({
|
|||||||
const { status, error, ...rest } = pollJson.data;
|
const { status, error, ...rest } = pollJson.data;
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
sessionIdRef.current = null; // backend already cleaned up
|
sessionIdRef.current = null;
|
||||||
cleanup();
|
cleanup();
|
||||||
setState('success');
|
setState('success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -249,9 +284,14 @@ export default function QrCodeLoginDialog({
|
|||||||
cleanup();
|
cleanup();
|
||||||
setState('error');
|
setState('error');
|
||||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||||
|
} else if (status === 'expired') {
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cleanup();
|
||||||
|
setExpireIn(0);
|
||||||
|
setState('expired');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore poll errors, will retry next interval
|
// ignore poll errors
|
||||||
}
|
}
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -323,6 +363,31 @@ export default function QrCodeLoginDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* QR code expired — click overlay to refresh */}
|
||||||
|
{state === 'expired' && qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t(platformConfig.scanQRCodeKey)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative border rounded-lg p-2 bg-white cursor-pointer group"
|
||||||
|
onClick={() => startLogin()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={qrDataUrl}
|
||||||
|
alt="QR Code"
|
||||||
|
className="w-56 h-56 opacity-40"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded-lg group-hover:bg-white/70 transition-colors">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-black/5 group-hover:bg-black/10 transition-colors">
|
||||||
|
<RotateCw className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Success */}
|
{/* Success */}
|
||||||
{state === 'success' && (
|
{state === 'success' && (
|
||||||
<div className="flex flex-col items-center space-y-3 py-8">
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
@@ -350,7 +415,7 @@ export default function QrCodeLoginDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state === 'error' && (
|
{state === 'error' && (
|
||||||
<DialogFooter>
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,7 +423,7 @@ export default function QrCodeLoginDialog({
|
|||||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
{t(platformConfig.retryKey)}
|
{t(platformConfig.retryKey)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user