From 48902ae95a67fc465ec394448cda9951cb32a84a Mon Sep 17 00:00:00 2001 From: xcosmosbox <2162381070@qq.com> Date: Tue, 24 Mar 2026 14:55:36 +0800 Subject: [PATCH] fix(WeiXin): auto-refresh expired QR code during login to improve success rate --- nanobot/channels/weixin.py | 49 ++++++++++++++++--------- tests/channels/test_weixin_channel.py | 51 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 115cca7ff..5ea887f02 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -63,6 +63,7 @@ SESSION_PAUSE_DURATION_S = 60 * 60 MAX_CONSECUTIVE_FAILURES = 3 BACKOFF_DELAY_S = 30 RETRY_DELAY_S = 2 +MAX_QR_REFRESH_COUNT = 3 # Default long-poll timeout; overridden by server via longpolling_timeout_ms. DEFAULT_LONG_POLL_TIMEOUT_S = 35 @@ -241,24 +242,25 @@ class WeixinChannel(BaseChannel): # QR Code Login (matches login-qr.ts) # ------------------------------------------------------------------ + async def _fetch_qr_code(self) -> tuple[str, str]: + """Fetch a fresh QR code. Returns (qrcode_id, scan_url).""" + data = await self._api_get( + "ilink/bot/get_bot_qrcode", + params={"bot_type": "3"}, + auth=False, + ) + qrcode_img_content = data.get("qrcode_img_content", "") + qrcode_id = data.get("qrcode", "") + if not qrcode_id: + raise RuntimeError(f"Failed to get QR code from WeChat API: {data}") + return qrcode_id, (qrcode_img_content or qrcode_id) + async def _qr_login(self) -> bool: """Perform QR code login flow. Returns True on success.""" try: logger.info("Starting WeChat QR code login...") - - data = await self._api_get( - "ilink/bot/get_bot_qrcode", - params={"bot_type": "3"}, - auth=False, - ) - qrcode_img_content = data.get("qrcode_img_content", "") - qrcode_id = data.get("qrcode", "") - - if not qrcode_id: - logger.error("Failed to get QR code from WeChat API: {}", data) - return False - - scan_url = qrcode_img_content or qrcode_id + refresh_count = 0 + qrcode_id, scan_url = await self._fetch_qr_code() self._print_qr_code(scan_url) logger.info("Waiting for QR code scan...") @@ -298,8 +300,23 @@ class WeixinChannel(BaseChannel): elif status == "scaned": logger.info("QR code scanned, waiting for confirmation...") elif status == "expired": - logger.warning("QR code expired") - return False + refresh_count += 1 + if refresh_count > MAX_QR_REFRESH_COUNT: + logger.warning( + "QR code expired too many times ({}/{}), giving up.", + refresh_count - 1, + MAX_QR_REFRESH_COUNT, + ) + return False + logger.warning( + "QR code expired, refreshing... ({}/{})", + refresh_count, + MAX_QR_REFRESH_COUNT, + ) + qrcode_id, scan_url = await self._fetch_qr_code() + self._print_qr_code(scan_url) + logger.info("New QR code generated, waiting for scan...") + continue # status == "wait" — keep polling await asyncio.sleep(1) diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 36e56315b..818e45d98 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -206,6 +206,57 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None: assert channel._session_pause_remaining_s() > 0 +@pytest.mark.asyncio +async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._save_state = lambda: None + channel._print_qr_code = lambda url: None + channel._api_get = AsyncMock( + side_effect=[ + {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, + {"status": "expired"}, + {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, + { + "status": "confirmed", + "bot_token": "token-2", + "ilink_bot_id": "bot-2", + "baseurl": "https://example.test", + "ilink_user_id": "wx-user", + }, + ] + ) + + ok = await channel._qr_login() + + assert ok is True + assert channel._token == "token-2" + assert channel.config.base_url == "https://example.test" + + +@pytest.mark.asyncio +async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None: + channel, _bus = _make_channel() + channel._running = True + channel._print_qr_code = lambda url: None + channel._api_get = AsyncMock( + side_effect=[ + {"qrcode": "qr-1", "qrcode_img_content": "url-1"}, + {"status": "expired"}, + {"qrcode": "qr-2", "qrcode_img_content": "url-2"}, + {"status": "expired"}, + {"qrcode": "qr-3", "qrcode_img_content": "url-3"}, + {"status": "expired"}, + {"qrcode": "qr-4", "qrcode_img_content": "url-4"}, + {"status": "expired"}, + ] + ) + + ok = await channel._qr_login() + + assert ok is False + + @pytest.mark.asyncio async def test_process_message_skips_bot_messages() -> None: channel, bus = _make_channel()