From 0b7cef2e0bc8076fcf34e394c0ab02e0b7a52d0f Mon Sep 17 00:00:00 2001 From: YxinMiracle <56109594+YxinMiracle@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:21:49 +0800 Subject: [PATCH] feat(channels): support WeChat QR login from the web UI (#5582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(channels): add WeChat QR login and binding recovery * fix(channels): enforce single-worker WeChat QR login and preserve bot ID Reject QR login endpoints when multiple Gateway workers are configured, while keeping manual token setup available. Preserve the configured bot ID when the provider omits it or returns an empty value. Add regression coverage for worker guards and credential persistence. * fix(channels): sync WeChat completion state on provider updates Show the connected step when refreshed provider data confirms the binding, so the dialog no longer waits indefinitely after polling is cancelled. Add regression tests for provider updates during pending poll and binding requests, including late responses and expiry. * fix(channels): preserve WeChat pairing codes across waits and redirects --------- Co-authored-by: YxinMiracle <“939157765@qq.com”> --- backend/app/channels/wechat.py | 20 +- backend/app/channels/wechat_qr_login.py | 221 +++++++++ .../gateway/routers/channel_connections.py | 104 +++- .../tests/test_channel_connections_router.py | 181 +++++++ backend/tests/test_wechat_qr_login.py | 394 +++++++++++++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 12 + .../channel-runtime-config-dialog.tsx | 299 +++++++++--- .../channels/wechat-qr-completion.tsx | 256 ++++++++++ .../workspace/channels/wechat-qr-login.tsx | 292 +++++++++++ .../channels/workspace-channels-list.tsx | 15 +- .../settings/channels-settings-page.tsx | 24 +- frontend/src/core/channels/api.ts | 42 ++ frontend/src/core/channels/types.ts | 21 + frontend/src/core/i18n/locales/en-US.ts | 65 +++ frontend/src/core/i18n/locales/types.ts | 53 ++ frontend/src/core/i18n/locales/zh-CN.ts | 55 +++ frontend/tests/e2e/channels.spec.ts | 378 +++++++++++++++ frontend/tests/unit/core/channels/api.test.ts | 46 ++ .../wechat-qr-completion.dom.test.tsx | 201 ++++++++ .../channels/wechat-qr-login.dom.test.tsx | 459 ++++++++++++++++++ 21 files changed, 3046 insertions(+), 93 deletions(-) create mode 100644 backend/app/channels/wechat_qr_login.py create mode 100644 backend/tests/test_wechat_qr_login.py create mode 100644 frontend/src/components/workspace/channels/wechat-qr-completion.tsx create mode 100644 frontend/src/components/workspace/channels/wechat-qr-login.tsx create mode 100644 frontend/tests/unit/core/channels/wechat-qr-completion.dom.test.tsx create mode 100644 frontend/tests/unit/core/channels/wechat-qr-login.dom.test.tsx diff --git a/backend/app/channels/wechat.py b/backend/app/channels/wechat.py index 1836f8791..92421efad 100644 --- a/backend/app/channels/wechat.py +++ b/backend/app/channels/wechat.py @@ -767,11 +767,18 @@ class WechatChannel(Channel): return False return bool(auth_state.get("bot_token")) + async def request_login_qrcode(self) -> dict[str, Any]: + """Request QR payload without changing the running channel's credentials.""" + return await self._request_public_get_json("/ilink/bot/get_bot_qrcode", params={"bot_type": self._qrcode_bot_type}) + + async def request_login_status(self, qrcode: str, *, timeout: float | None = None, verify_code: str | None = None) -> dict[str, Any]: + params = {"qrcode": qrcode} + if verify_code: + params["verify_code"] = verify_code + return await self._request_public_get_json("/ilink/bot/get_qrcode_status", params=params, timeout=timeout) + async def _bind_via_qrcode(self) -> dict[str, Any]: - qrcode_data = await self._request_public_get_json( - "/ilink/bot/get_bot_qrcode", - params={"bot_type": self._qrcode_bot_type}, - ) + qrcode_data = await self.request_login_qrcode() qrcode = str(qrcode_data.get("qrcode") or "").strip() if not qrcode: raise RuntimeError("iLink get_bot_qrcode did not return qrcode") @@ -790,10 +797,7 @@ class WechatChannel(Channel): deadline = time.monotonic() + max(self._qrcode_poll_timeout, 1.0) while time.monotonic() < deadline: - status_data = await self._request_public_get_json( - "/ilink/bot/get_qrcode_status", - params={"qrcode": qrcode}, - ) + status_data = await self.request_login_status(qrcode) status = str(status_data.get("status") or "").strip().lower() if status == "confirmed": token = str(status_data.get("bot_token") or "").strip() diff --git a/backend/app/channels/wechat_qr_login.py b/backend/app/channels/wechat_qr_login.py new file mode 100644 index 000000000..78ea51d9d --- /dev/null +++ b/backend/app/channels/wechat_qr_login.py @@ -0,0 +1,221 @@ +"""Short-lived browser QR login sessions; bot credentials never leave the server.""" + +from __future__ import annotations + +import asyncio +import logging +import re +import secrets +import time +from collections.abc import Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import httpx + +from app.channels.message_bus import MessageBus +from app.channels.wechat import WechatChannel + +logger = logging.getLogger(__name__) +_ACTIVE = {"pending", "scanned", "verification_required"} + + +def _wechat_api_url(value: Any) -> str: + """Only accept provider-selected HTTPS origins within Tencent WeChat. + + Operator-supplied base_url remains configurable; untrusted login responses + must never redirect a polling identifier or bot token to arbitrary hosts. + """ + if not isinstance(value, str) or not re.fullmatch(r"https://(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+weixin\.qq\.com/?", value): + raise ValueError("Invalid WeChat API origin") + return value.rstrip("/") + + +class QRLoginError(Exception): + def __init__(self, detail: str, status_code: int = 400): + super().__init__(detail) + self.status_code = status_code + + +@dataclass +class _Session: + owner: str + config: dict[str, Any] + id: str = field(default_factory=lambda: secrets.token_urlsafe(32)) + expires_at: float = field(default_factory=lambda: time.monotonic() + 180) + status: str = "pending" + qrcode: str = "" + content: str = "" + provider: dict[str, Any] | None = None + error: str | None = None + verify_code: str | None = None + poll_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + def response(self) -> dict[str, Any]: + return {"id": self.id, "status": self.status, "qrcode_content": self.content, "expires_in": max(0, int(self.expires_at - time.monotonic())), "provider": self.provider, "error": self.error} + + +class WechatQRLogin: + """One process-local session, owned by the admin that started it. + + Network polling runs outside the mutation lock so cancellation can fence a + late confirmation. Credential application shares the lock with manual setup + and disconnect. No background task, open client or token is retained. + """ + + def __init__(self): + self.session: _Session | None = None + self._lock = asyncio.Lock() + + async def request(self, config: dict[str, Any], qrcode: str | None = None, *, verify_code: str | None = None) -> dict[str, Any]: + channel = WechatChannel(MessageBus(), config) + try: + if qrcode is None: + return await channel.request_login_qrcode() + return await channel.request_login_status(qrcode, timeout=35, verify_code=verify_code) + finally: + await channel.stop() + + def _get(self, owner: str, session_id: str) -> _Session: + session = self.session + if session is None or session.id != session_id or session.owner != owner: + raise QRLoginError("WeChat QR login session not found. Start again.", 404) + if session.status in _ACTIVE and time.monotonic() >= session.expires_at: + session.status = "expired" + session.verify_code = None + return session + + @asynccontextmanager + async def mutation(self): + async with self._lock: + self.session = None + yield + + async def start(self, owner: str, config: dict[str, Any]) -> dict[str, Any]: + async with self._lock: + current = self.session + if current and current.owner != owner and current.expires_at > time.monotonic() and current.status in _ACTIVE: + raise QRLoginError("Another administrator is connecting WeChat. Try again later.", 409) + session = _Session(owner, dict(config)) + self.session = session + try: + data = await self.request(session.config) + except (httpx.HTTPError, ValueError): + session.status = "failed" + raise QRLoginError("Unable to request a WeChat QR code. Try again.", 502) from None + async with self._lock: + self._get(owner, session.id) + qrcode, content = data.get("qrcode"), data.get("qrcode_img_content") + if not isinstance(qrcode, str) or not qrcode.strip() or not isinstance(content, str) or not content.strip() or len(content.encode("utf-8")) > 2048: + session.status = "failed" + raise QRLoginError("WeChat returned an invalid QR code. Try again.", 502) + session.qrcode, session.content = qrcode.strip(), content.strip() + return session.response() + + async def cancel(self, owner: str, session_id: str) -> None: + async with self._lock: + self._get(owner, session_id) + self.session = None + + async def poll(self, owner: str, session_id: str, apply: Callable[[dict[str, str]], Awaitable[dict[str, Any]]], *, verify_code: str | None = None) -> dict[str, Any]: + session = self._get(owner, session_id) + async with session.poll_lock: + session = self._get(owner, session_id) + if session.status not in _ACTIVE: + return session.response() + if verify_code is not None: + if session.status != "verification_required" or not re.fullmatch(r"[0-9]{1,16}", verify_code): + raise QRLoginError("Enter the digits shown in WeChat.") + session.verify_code = verify_code + submitted_code = session.verify_code + try: + if submitted_code: + data = await self.request(session.config, session.qrcode, verify_code=submitted_code) + else: + data = await self.request(session.config, session.qrcode) + except httpx.TimeoutException: + session = self._get(owner, session_id) + if submitted_code and session.status in _ACTIVE: + session.error = "network" + return session.response() + except httpx.HTTPError as exc: + session = self._get(owner, session_id) + if session.status not in _ACTIVE: + return session.response() + retryable = isinstance(exc, httpx.TransportError) or (isinstance(exc, httpx.HTTPStatusError) and (exc.response.status_code == 429 or exc.response.status_code >= 500)) + session.error = "network" if retryable else "invalid_response" + if not retryable: + session.status = "failed" + session.verify_code = None + logger.warning("WeChat QR poll transport error: %s; retryable=%s", type(exc).__name__, retryable) + return session.response() + except ValueError: + session = self._get(owner, session_id) + session.status, session.error = "failed", "invalid_response" + session.verify_code = None + return session.response() + async with self._lock: + session = self._get(owner, session_id) + if session.status not in _ACTIVE: + return session.response() + status = str(data.get("status", "")).strip().lower() + session.error = None + # Waits and IDC redirects do not acknowledge the submitted code. + # Keep sending it until WeChat accepts, rejects or ends the login. + if status not in {"wait", "pending", "scaned_but_redirect"}: + session.verify_code = None + if status == "confirmed": + token = data.get("bot_token") + session.status = "failed" + if not isinstance(token, str) or not token.strip(): + session.error = "invalid_response" + else: + credentials = {"bot_token": token.strip()} + ilink_bot_id = str(data.get("ilink_bot_id") or "").strip() + if ilink_bot_id: + credentials["ilink_bot_id"] = ilink_bot_id + base_url = data.get("baseurl") + if base_url is not None: + try: + credentials["base_url"] = _wechat_api_url(base_url) + except ValueError: + session.error = "invalid_response" + return session.response() + elif session.config.get("base_url"): + credentials["base_url"] = session.config["base_url"] + # A partial restart must never be automatically repeated. + try: + session.provider = await apply(credentials) + except Exception as exc: + logger.warning("WeChat QR credential application failed: %s", type(exc).__name__) + raise QRLoginError("Unable to save or start WeChat. Start again.", 502) from None + session.status = "confirmed" + logger.info("WeChat QR credentials saved and channel started") + elif status == "scaned_but_redirect": + try: + session.config["base_url"] = _wechat_api_url(f"https://{data.get('redirect_host', '')}") + session.status = "scanned" + except ValueError: + session.status, session.error = "failed", "invalid_response" + session.verify_code = None + elif status == "need_verifycode": + session.status = "verification_required" + if submitted_code: + session.error = "verification_rejected" + elif status in {"binded_redirect", "verify_code_blocked"}: + session.status = "failed" + session.error = "already_bound" if status == "binded_redirect" else "verification_blocked" + elif status in {"expired", "canceled", "cancelled", "invalid", "failed"}: + session.status = "expired" if status == "expired" else "failed" + elif status in {"scanned", "scaned"}: + session.status = "scanned" + elif status in {"wait", "pending"}: + if submitted_code: + # Keep browser polling instead of asking for the code again. + session.status = "scanned" + else: + session.status, session.error = "failed", "invalid_response" + # Log normalized states only: never URLs, codes, tokens or upstream bodies. + logger.debug("WeChat QR login: status=%s error=%s", session.status, session.error) + return session.response() diff --git a/backend/app/gateway/routers/channel_connections.py b/backend/app/gateway/routers/channel_connections.py index a435f7532..0733ff289 100644 --- a/backend/app/gateway/routers/channel_connections.py +++ b/backend/app/gateway/routers/channel_connections.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import logging +import os import secrets from datetime import UTC, datetime, timedelta -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel, Field @@ -16,6 +17,7 @@ from app.channels.runtime_config_store import ( apply_runtime_connection_config, merge_runtime_channel_configs, ) +from app.channels.wechat_qr_login import QRLoginError, WechatQRLogin from app.gateway.deps import require_admin_user from deerflow.config.channel_connections_config import ChannelConnectionsConfig from deerflow.persistence.channel_connections import ChannelConnectionRepository @@ -80,6 +82,19 @@ class ChannelConnectResponse(BaseModel): expires_in: int +class WechatQRLoginResponse(BaseModel): + id: str + status: Literal["pending", "scanned", "verification_required", "confirmed", "expired", "failed"] + qrcode_content: str + expires_in: int + provider: ChannelProviderResponse | None = None + error: Literal["network", "invalid_response", "verification_rejected", "verification_blocked", "already_bound"] | None = None + + +class WechatQRLoginPollRequest(BaseModel): + verify_code: str | None = Field(default=None, pattern=r"^[0-9]{1,16}$") + + class ChannelRuntimeConfigRequest(BaseModel): values: dict[str, str] = Field(default_factory=dict) @@ -570,6 +585,13 @@ async def disconnect_channel_connection(connection_id: str, request: Request) -> @router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse) async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse: await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) + if provider == "wechat": + async with _get_wechat_qr_login(request).mutation(): + return await _disconnect_channel_provider_runtime(provider, request) + return await _disconnect_channel_provider_runtime(provider, request) + + +async def _disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse: config = await _get_channel_connections_config(request) if not config.enabled: raise HTTPException(status_code=400, detail="Channel connections are disabled") @@ -656,6 +678,13 @@ async def configure_channel_provider_runtime( request: Request, ) -> ChannelProviderResponse: await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) + if provider == "wechat": + async with _get_wechat_qr_login(request).mutation(): + return await _configure_channel_provider_runtime(provider, body, request) + return await _configure_channel_provider_runtime(provider, body, request) + + +async def _configure_channel_provider_runtime(provider: str, body: ChannelRuntimeConfigRequest, request: Request) -> ChannelProviderResponse: config = await _get_channel_connections_config(request) if not config.enabled: raise HTTPException(status_code=400, detail="Channel connections are disabled") @@ -680,9 +709,10 @@ async def configure_channel_provider_runtime( # cached by get_app_config(). runtime_config["bot_username"] = values["bot_username"] - candidate_channels_config = dict(channels_config) - candidate_channels_config[provider] = runtime_config + return await _apply_runtime_channel_config(request, config, provider, runtime_config) + +async def _apply_runtime_channel_config(request: Request, config: ChannelConnectionsConfig, provider: str, runtime_config: dict[str, Any]) -> ChannelProviderResponse: started = await _restart_runtime_channel_if_available(provider, runtime_config) if started is False: display_name = _PROVIDER_META[provider]["display_name"] @@ -699,3 +729,71 @@ async def configure_channel_provider_runtime( request.app.state.channels_config = live_channels_config return _provider_response(config, live_channels_config, provider, _PROVIDER_META[provider]) + + +def _get_wechat_qr_login(request: Request) -> WechatQRLogin: + manager = getattr(request.app.state, "wechat_qr_login", None) + if manager is None: + manager = WechatQRLogin() + request.app.state.wechat_qr_login = manager + return manager + + +async def _require_wechat_qr_login(request: Request) -> ChannelConnectionsConfig: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) + config = await _get_channel_connections_config(request) + if not config.enabled or not config.wechat.enabled: + raise HTTPException(status_code=400, detail="WeChat channel connections are disabled") + # QR sessions and mutation locks live in one process. Reject every QR route + # before session access when requests could land on different workers. + # WEB_CONCURRENCY is Uvicorn's fallback when no worker count is supplied. + try: + workers = int(os.environ.get("GATEWAY_WORKERS", os.environ.get("WEB_CONCURRENCY", "1"))) + except ValueError: + workers = 0 + if workers != 1: + raise HTTPException( + status_code=503, + detail="WeChat QR login requires a single Gateway worker. Set GATEWAY_WORKERS=1 (or WEB_CONCURRENCY=1 when using Uvicorn directly), or enter a bot token manually.", + ) + return config + + +@router.post("/wechat/qr-login", response_model=WechatQRLoginResponse) +async def start_wechat_qr_login(request: Request, response: Response) -> dict[str, Any]: + await _require_wechat_qr_login(request) + channels = await _get_channels_config(request) + response.headers["Cache-Control"] = "no-store" + try: + return await _get_wechat_qr_login(request).start(str(_get_user_id(request)), channels.get("wechat") or {}) + except QRLoginError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None + + +@router.post("/wechat/qr-login/{session_id}/poll", response_model=WechatQRLoginResponse) +async def poll_wechat_qr_login(session_id: str, request: Request, response: Response, body: WechatQRLoginPollRequest | None = None) -> dict[str, Any]: + config = await _require_wechat_qr_login(request) + response.headers["Cache-Control"] = "no-store" + + async def apply(credentials: dict[str, str]) -> dict[str, Any]: + channels = await _get_channels_config(request) + runtime_config = dict(channels.get("wechat") or {}) + runtime_config.update(credentials) + runtime_config["enabled"] = True + provider = await _apply_runtime_channel_config(request, config, "wechat", runtime_config) + return provider.model_dump() + + try: + return await _get_wechat_qr_login(request).poll(str(_get_user_id(request)), session_id, apply, verify_code=body.verify_code if body else None) + except QRLoginError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None + + +@router.delete("/wechat/qr-login/{session_id}", status_code=204) +async def cancel_wechat_qr_login(session_id: str, request: Request) -> Response: + await _require_wechat_qr_login(request) + try: + await _get_wechat_qr_login(request).cancel(str(_get_user_id(request)), session_id) + except QRLoginError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None + return Response(status_code=204, headers={"Cache-Control": "no-store"}) diff --git a/backend/tests/test_channel_connections_router.py b/backend/tests/test_channel_connections_router.py index 7cc3f71b9..304eab728 100644 --- a/backend/tests/test_channel_connections_router.py +++ b/backend/tests/test_channel_connections_router.py @@ -22,6 +22,8 @@ from deerflow.config.channel_connections_config import ChannelConnectionsConfig def _stub_app_config(monkeypatch): """Keep router tests independent from a developer-local config.yaml.""" monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "0") + monkeypatch.delenv("GATEWAY_WORKERS", raising=False) + monkeypatch.delenv("WEB_CONCURRENCY", raising=False) set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}})) yield reset_app_config() @@ -1222,3 +1224,182 @@ def test_disconnect_connection_is_current_user_scoped(tmp_path): assert anyio.run(get_connection_status) == "connected" anyio.run(repo.close) + + +@pytest.mark.parametrize("worker_env", [{}, {"GATEWAY_WORKERS": "1"}, {"WEB_CONCURRENCY": "1"}]) +def test_wechat_qr_login_saves_credentials_without_exposing_token(monkeypatch, worker_env): + from app.channels.wechat_qr_login import WechatQRLogin + + for name, value in worker_env.items(): + monkeypatch.setenv(name, value) + app = _make_app(_enabled_connections_config(), None, {}) + login = WechatQRLogin() + login.request = AsyncMock(side_effect=[{"qrcode": "poll-secret", "qrcode_img_content": "scan-url"}, {"status": "confirmed", "bot_token": "new-secret", "ilink_bot_id": "bot-1"}]) + app.state.wechat_qr_login = login + restart = AsyncMock(return_value=True) + monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", restart) + with TestClient(app) as client: + started = client.post("/api/channels/wechat/qr-login") + assert started.status_code == 200 + assert started.headers["cache-control"] == "no-store" + assert "poll-secret" not in started.text + url = f"/api/channels/wechat/qr-login/{started.json()['id']}" + confirmed = client.post(f"{url}/poll") + assert confirmed.status_code == 200 + assert confirmed.json()["status"] == "confirmed" + assert confirmed.json()["provider"]["credential_values"]["bot_token"] == "********" + assert "new-secret" not in confirmed.text + assert app.state.channels_config["wechat"]["bot_token"] == "new-secret" + assert app.state.channels_config["wechat"]["ilink_bot_id"] == "bot-1" + saved = ChannelRuntimeConfigStore(app.state.channel_runtime_config_store._path).get_provider_config("wechat") + assert saved["bot_token"] == "new-secret" + assert saved["ilink_bot_id"] == "bot-1" + client.post(f"{url}/poll") + restart.assert_awaited_once() + assert client.delete(url).status_code == 204 + assert client.post(f"{url}/poll").status_code == 404 + + +def test_wechat_qr_login_requires_admin_before_any_provider_calls(): + from app.channels.wechat_qr_login import WechatQRLogin + + app = make_authed_test_app(user_factory=_non_admin_user) + login = WechatQRLogin() + login.request = AsyncMock() + app.state.wechat_qr_login = login + app.include_router(channel_connections.router) + with TestClient(app) as client: + assert client.post("/api/channels/wechat/qr-login").status_code == 403 + assert client.post("/api/channels/wechat/qr-login/id/poll").status_code == 403 + assert client.delete("/api/channels/wechat/qr-login/id").status_code == 403 + login.request.assert_not_awaited() + + +def test_wechat_qr_login_respects_disabled_provider(): + app = _make_app(ChannelConnectionsConfig.model_validate({"enabled": True, "wechat": {"enabled": False}}), None) + with TestClient(app) as client: + assert client.post("/api/channels/wechat/qr-login").status_code == 400 + + +@pytest.mark.parametrize("method,path", [("POST", "/api/channels/wechat/qr-login"), ("POST", "/api/channels/wechat/qr-login/session/poll"), ("DELETE", "/api/channels/wechat/qr-login/session")]) +def test_wechat_qr_routes_reject_cookie_requests_without_csrf(method, path): + from app.gateway.csrf_middleware import CSRFMiddleware + + app = _make_app(_enabled_connections_config(), None) + app.add_middleware(CSRFMiddleware) + with TestClient(app) as client: + response = client.request(method, path, headers={"Cookie": "session=browser-session"}) + assert response.status_code == 403 + + +def test_manual_wechat_setup_invalidates_pending_qr(monkeypatch): + from app.channels.wechat_qr_login import WechatQRLogin + + app = _make_app(_enabled_connections_config(), None) + login = WechatQRLogin() + login.request = AsyncMock(return_value={"qrcode": "id", "qrcode_img_content": "scan"}) + app.state.wechat_qr_login = login + monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", AsyncMock(return_value=True)) + with TestClient(app) as client: + session = client.post("/api/channels/wechat/qr-login").json() + assert client.post("/api/channels/wechat/runtime-config", json={"values": {"bot_token": "manual-token"}}).status_code == 200 + assert client.post(f"/api/channels/wechat/qr-login/{session['id']}/poll").status_code == 404 + assert app.state.channels_config["wechat"]["bot_token"] == "manual-token" + + +def test_wechat_pairing_code_is_validated_and_forwarded_without_echo(monkeypatch): + from app.channels.wechat_qr_login import WechatQRLogin + + app = _make_app(_enabled_connections_config(), None, {}) + login = WechatQRLogin() + login.request = AsyncMock( + side_effect=[ + {"qrcode": "private-id", "qrcode_img_content": "scan-url"}, + {"status": "need_verifycode"}, + {"status": "confirmed", "bot_token": "new-secret", "baseurl": "https://ilinkai2.weixin.qq.com/"}, + ] + ) + app.state.wechat_qr_login = login + monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", AsyncMock(return_value=True)) + with TestClient(app) as client: + session = client.post("/api/channels/wechat/qr-login").json() + poll_url = f"/api/channels/wechat/qr-login/{session['id']}/poll" + assert client.post(poll_url).json()["status"] == "verification_required" + assert client.post(poll_url, json={"verify_code": "not-a-code"}).status_code == 422 + result = client.post(poll_url, json={"verify_code": "123456"}) + assert result.status_code == 200 + assert result.json()["status"] == "confirmed" + assert "123456" not in result.text + assert "new-secret" not in result.text + assert login.request.call_args.kwargs["verify_code"] == "123456" + saved = ChannelRuntimeConfigStore(app.state.channel_runtime_config_store._path).get_provider_config("wechat") + assert saved["base_url"] == "https://ilinkai2.weixin.qq.com" + + +@pytest.mark.parametrize( + "returned_fields,expected_id", + [({}, "existing-bot"), ({"ilink_bot_id": None}, "existing-bot"), ({"ilink_bot_id": ""}, "existing-bot"), ({"ilink_bot_id": " "}, "existing-bot"), ({"ilink_bot_id": " replacement-bot "}, "replacement-bot")], +) +def test_wechat_qr_confirmation_preserves_bot_id_unless_replaced(monkeypatch, returned_fields, expected_id): + from app.channels.wechat_qr_login import WechatQRLogin + + existing = {"enabled": True, "bot_token": "old-token", "ilink_bot_id": "existing-bot"} + app = _make_app(_enabled_connections_config(), None, {"wechat": existing}) + app.state.channel_runtime_config_store.set_provider_config("wechat", existing) + login = WechatQRLogin() + login.request = AsyncMock(side_effect=[{"qrcode": "private-id", "qrcode_img_content": "scan-url"}, {"status": "confirmed", "bot_token": "new-token", **returned_fields}]) + app.state.wechat_qr_login = login + restart = AsyncMock(return_value=True) + monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", restart) + + with TestClient(app) as client: + started = client.post("/api/channels/wechat/qr-login") + assert started.status_code == 200 + result = client.post(f"/api/channels/wechat/qr-login/{started.json()['id']}/poll") + assert result.status_code == 200 + assert result.json()["status"] == "confirmed" + + expected = {**existing, "bot_token": "new-token", "ilink_bot_id": expected_id} + restart.assert_awaited_once_with("wechat", expected) + assert app.state.channels_config["wechat"] == expected + saved = ChannelRuntimeConfigStore(app.state.channel_runtime_config_store._path).get_provider_config("wechat") + assert saved == expected + + +@pytest.mark.parametrize("worker_env", [{"GATEWAY_WORKERS": "2"}, {"GATEWAY_WORKERS": "4"}, {"WEB_CONCURRENCY": "2"}, {"GATEWAY_WORKERS": "invalid"}]) +@pytest.mark.parametrize("method,suffix", [("POST", ""), ("POST", "/session/poll"), ("DELETE", "/session")]) +def test_wechat_qr_routes_reject_unsupported_workers_before_session_access(monkeypatch, worker_env, method, suffix): + from app.channels.wechat_qr_login import WechatQRLogin + + for name, value in worker_env.items(): + monkeypatch.setenv(name, value) + app = _make_app(_enabled_connections_config(), None) + login = WechatQRLogin() + session = {"id": "session", "status": "pending", "qrcode_content": "scan-url", "expires_in": 180} + login.start = AsyncMock(return_value=session) + login.poll = AsyncMock(return_value=session) + login.cancel = AsyncMock(return_value=None) + app.state.wechat_qr_login = login + + with TestClient(app) as client: + result = client.request(method, f"/api/channels/wechat/qr-login{suffix}") + + assert result.status_code == 503 + assert "single Gateway worker" in result.json()["detail"] + assert "GATEWAY_WORKERS=1" in result.json()["detail"] + login.start.assert_not_awaited() + login.poll.assert_not_awaited() + login.cancel.assert_not_awaited() + + +def test_manual_wechat_setup_remains_available_with_multiple_workers(monkeypatch): + monkeypatch.setenv("GATEWAY_WORKERS", "2") + app = _make_app(_enabled_connections_config(), None) + restart = AsyncMock(return_value=True) + monkeypatch.setattr(channel_connections, "_restart_runtime_channel_if_available", restart) + + with TestClient(app) as client: + result = client.post("/api/channels/wechat/runtime-config", json={"values": {"bot_token": "manual-token"}}) + assert result.status_code == 200 + assert app.state.channels_config["wechat"]["bot_token"] == "manual-token" + restart.assert_awaited_once() diff --git a/backend/tests/test_wechat_qr_login.py b/backend/tests/test_wechat_qr_login.py new file mode 100644 index 000000000..e94338810 --- /dev/null +++ b/backend/tests/test_wechat_qr_login.py @@ -0,0 +1,394 @@ +"""Browser QR sessions are bounded, owner-scoped and never expose credentials.""" + +import asyncio +from unittest.mock import AsyncMock + +import httpx +import pytest + +from app.channels.wechat_qr_login import QRLoginError, WechatQRLogin + + +@pytest.fixture +def login(): + manager = WechatQRLogin() + manager.request = AsyncMock(return_value={"qrcode": "private-poll-id", "qrcode_img_content": "https://example.com/scan"}) + return manager + + +@pytest.mark.asyncio +async def test_confirmation_applies_credentials_once_without_returning_them(login): + session = await login.start("alice", {}) + assert "private-poll-id" not in str(session) + login.request.return_value = {"status": "confirmed", "bot_token": "secret-token", "ilink_bot_id": "bot-1"} + apply = AsyncMock(return_value={"provider": "wechat", "configured": True}) + result = await login.poll("alice", session["id"], apply) + assert result["status"] == "confirmed" + assert "secret-token" not in str(result) + assert "bot_token" not in str(result) + apply.assert_awaited_once_with({"bot_token": "secret-token", "ilink_bot_id": "bot-1"}) + assert await login.poll("alice", session["id"], apply) == result + apply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_other_owner_cannot_read_cancel_or_replace_pending_login(login): + session = await login.start("alice", {}) + for action in [login.poll("bob", session["id"], AsyncMock()), login.cancel("bob", session["id"]), login.start("bob", {})]: + with pytest.raises(QRLoginError): + await action + + +@pytest.mark.asyncio +async def test_expiry_and_provider_timeout_do_not_apply_credentials(login): + session = await login.start("alice", {}) + apply = AsyncMock() + login.request.side_effect = httpx.ReadTimeout("sensitive URL") + assert (await login.poll("alice", session["id"], apply))["status"] == "pending" + login.session.expires_at = 0 + assert (await login.poll("alice", session["id"], apply))["status"] == "expired" + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancel_fences_late_confirmation(login): + session = await login.start("alice", {}) + entered, release = asyncio.Event(), asyncio.Event() + + async def request(*args): + entered.set() + await release.wait() + return {"status": "confirmed", "bot_token": "late-token"} + + login.request.side_effect = request + apply = AsyncMock() + poll = asyncio.create_task(login.poll("alice", session["id"], apply)) + await entered.wait() + await login.cancel("alice", session["id"]) + release.set() + with pytest.raises(QRLoginError): + await poll + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_invalid_provider_response_and_missing_credentials(login): + login.request.return_value = {"qrcode": "id"} + with pytest.raises(QRLoginError): + await login.start("alice", {}) + login.request.return_value = {"qrcode": "id", "qrcode_img_content": "scan"} + session = await login.start("alice", {}) + login.request.return_value = {"status": "confirmed"} + apply = AsyncMock() + assert (await login.poll("alice", session["id"], apply))["status"] == "failed" + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_new_session_invalidates_old_and_manual_mutation_cancels(login): + old = await login.start("alice", {}) + new = await login.start("alice", {}) + with pytest.raises(QRLoginError): + await login.poll("alice", old["id"], AsyncMock()) + async with login.mutation(): + pass + with pytest.raises(QRLoginError): + await login.poll("alice", new["id"], AsyncMock()) + + +@pytest.mark.asyncio +async def test_failed_apply_is_not_repeated_or_exposed(login): + session = await login.start("alice", {}) + login.request.return_value = {"status": "confirmed", "bot_token": "secret"} + apply = AsyncMock(side_effect=RuntimeError("secret-token-in-error")) + with pytest.raises(QRLoginError) as error: + await login.poll("alice", session["id"], apply) + assert "secret" not in str(error.value) + assert (await login.poll("alice", session["id"], apply))["status"] == "failed" + apply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_expiry_during_poll_rejects_late_credentials(login): + session = await login.start("alice", {}) + + async def request(*args): + login.session.expires_at = 0 + return {"status": "confirmed", "bot_token": "too-late"} + + login.request.side_effect = request + apply = AsyncMock() + assert (await login.poll("alice", session["id"], apply))["status"] == "expired" + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_browser_transport_closes_http_client_without_starting_channel(monkeypatch): + from app.channels.wechat import WechatChannel + + request = AsyncMock(return_value={"qrcode": "id", "qrcode_img_content": "scan"}) + stop = AsyncMock() + start = AsyncMock() + monkeypatch.setattr(WechatChannel, "request_login_qrcode", request) + monkeypatch.setattr(WechatChannel, "stop", stop) + monkeypatch.setattr(WechatChannel, "start", start) + await WechatQRLogin().start("alice", {}) + stop.assert_awaited_once() + start.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_concurrent_polls_only_apply_once(login): + session = await login.start("alice", {}) + entered, release = asyncio.Event(), asyncio.Event() + + async def request(*args): + entered.set() + await release.wait() + return {"status": "confirmed", "bot_token": "secret"} + + login.request.side_effect = request + apply = AsyncMock(return_value={"provider": "wechat"}) + first = asyncio.create_task(login.poll("alice", session["id"], apply)) + await entered.wait() + second = asyncio.create_task(login.poll("alice", session["id"], apply)) + release.set() + results = await asyncio.gather(first, second) + assert all(result["status"] == "confirmed" for result in results) + apply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_redirect_then_confirmation_saves_returned_api_host(login): + session = await login.start("alice", {}) + login.request.return_value = {"status": "scaned_but_redirect", "redirect_host": "ilinkai2.weixin.qq.com"} + apply = AsyncMock(return_value={"provider": "wechat"}) + assert (await login.poll("alice", session["id"], apply))["status"] == "scanned" + login.request.return_value = {"status": "confirmed", "bot_token": "secret", "baseurl": "https://ilinkai2.weixin.qq.com/"} + await login.poll("alice", session["id"], apply) + assert login.request.call_args.args[0]["base_url"] == "https://ilinkai2.weixin.qq.com" + assert apply.call_args.args[0]["base_url"] == "https://ilinkai2.weixin.qq.com" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("host", ["127.0.0.1", "evil.example", "weixin.qq.com.evil.example", "ilinkai.weixin.qq.com@127.0.0.1", "ilinkai.weixin.qq.com:8001", "ilinkai.weixin.qq.com/path", ""]) +async def test_untrusted_provider_redirect_is_rejected(login, host): + session = await login.start("alice", {}) + login.request.return_value = {"status": "scaned_but_redirect", "redirect_host": host} + apply = AsyncMock() + result = await login.poll("alice", session["id"], apply) + assert result["status"] == "failed" + assert result["error"] == "invalid_response" + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_verification_code_is_submitted_without_exposing_or_reusing_it(login): + session = await login.start("alice", {}) + apply = AsyncMock() + login.request.return_value = {"status": "need_verifycode"} + assert (await login.poll("alice", session["id"], apply))["status"] == "verification_required" + result = await login.poll("alice", session["id"], apply, verify_code="123456") + assert login.request.call_args.kwargs["verify_code"] == "123456" + assert result["error"] == "verification_rejected" + assert "123456" not in str(result) + login.request.return_value = {"status": "scaned"} + assert (await login.poll("alice", session["id"], apply, verify_code="654321"))["status"] == "scanned" + await login.poll("alice", session["id"], apply) + assert not login.request.call_args.kwargs.get("verify_code") + apply.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status,error", [("binded_redirect", "already_bound"), ("verify_code_blocked", "verification_blocked"), ("new-unsupported-status", "invalid_response")]) +async def test_action_required_states_do_not_silently_wait(login, status, error): + session = await login.start("alice", {}) + login.request.return_value = {"status": status} + apply = AsyncMock() + result = await login.poll("alice", session["id"], apply) + assert result["status"] == "failed" + assert result["error"] == error + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_temporary_provider_error_retries_same_qr_and_clears_feedback(login): + session = await login.start("alice", {}) + request = httpx.Request("GET", "https://example.com/?qrcode=secret") + login.request.side_effect = httpx.HTTPStatusError("sensitive-response", request=request, response=httpx.Response(502, request=request)) + result = await login.poll("alice", session["id"], AsyncMock()) + assert result["status"] == "pending" + assert result["error"] == "network" + assert "secret" not in str(result) + login.request.side_effect = None + login.request.return_value = {"status": "scaned"} + result = await login.poll("alice", session["id"], AsyncMock()) + assert result["status"] == "scanned" + assert result["error"] is None + + +@pytest.mark.asyncio +async def test_confirmation_cannot_save_token_to_untrusted_api_host(login): + session = await login.start("alice", {}) + login.request.return_value = {"status": "confirmed", "bot_token": "secret", "baseurl": "http://127.0.0.1"} + apply = AsyncMock() + assert (await login.poll("alice", session["id"], apply))["status"] == "failed" + apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_pairing_transport_uses_long_poll_and_provider_query(monkeypatch): + from app.channels.wechat import WechatChannel + + request = AsyncMock(return_value={"status": "scaned"}) + monkeypatch.setattr(WechatChannel, "_request_public_get_json", request) + result = await WechatQRLogin().request({}, "private-id", verify_code="123456") + assert result["status"] == "scaned" + request.assert_awaited_once_with("/ilink/bot/get_qrcode_status", params={"qrcode": "private-id", "verify_code": "123456"}, timeout=35) + + +@pytest.mark.asyncio +async def test_verification_input_and_expired_sessions_do_not_reach_provider(login): + session = await login.start("alice", {}) + login.request.reset_mock() + with pytest.raises(QRLoginError): + await login.poll("alice", session["id"], AsyncMock(), verify_code="123456") + login.session.status = "verification_required" + with pytest.raises(QRLoginError): + await login.poll("alice", session["id"], AsyncMock(), verify_code="not-digits") + login.session.expires_at = 0 + assert (await login.poll("alice", session["id"], AsyncMock(), verify_code="123456"))["status"] == "expired" + login.request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_pairing_timeout_keeps_code_and_signals_automatic_retry(login): + session = await login.start("alice", {}) + login.request.return_value = {"status": "need_verifycode"} + await login.poll("alice", session["id"], AsyncMock()) + login.request.side_effect = httpx.ReadTimeout("private URL and code") + result = await login.poll("alice", session["id"], AsyncMock(), verify_code="123456") + assert result["error"] == "network" + assert "123456" not in str(result) + login.request.side_effect = None + login.request.return_value = {"status": "scaned"} + assert (await login.poll("alice", session["id"], AsyncMock()))["status"] == "scanned" + assert login.request.call_args.kwargs["verify_code"] == "123456" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + {"status": "wait"}, + {"status": "pending"}, + {"status": "scaned_but_redirect", "redirect_host": "ilinkai2.weixin.qq.com"}, + ], +) +async def test_pairing_wait_and_redirect_keep_polling_with_submitted_code(login, response): + session = await login.start("alice", {}) + apply = AsyncMock(return_value={"provider": "wechat", "configured": True}) + login.request.return_value = {"status": "need_verifycode"} + await login.poll("alice", session["id"], apply) + + login.request.return_value = response + result = await login.poll("alice", session["id"], apply, verify_code="123456") + # The browser automatically polls scanned sessions, but waits for input + # when verification_required has no network error. + assert result["status"] == "scanned" + assert result["error"] is None + assert "123456" not in str(result) + apply.assert_not_awaited() + + # No resubmission from the browser is needed, even after multiple waits. + login.request.return_value = {"status": "wait"} + result = await login.poll("alice", session["id"], apply) + assert result["status"] == "scanned" + assert login.request.call_args.kwargs.get("verify_code") == "123456" + if response["status"] == "scaned_but_redirect": + assert login.request.call_args.args[0]["base_url"] == "https://ilinkai2.weixin.qq.com" + + login.request.return_value = {"status": "confirmed", "bot_token": "secret-token"} + result = await login.poll("alice", session["id"], apply) + assert login.request.call_args.kwargs.get("verify_code") == "123456" + assert result["status"] == "confirmed" + assert login.session.verify_code is None + assert "123456" not in str(result) + assert "secret-token" not in str(result) + apply.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response,status,error", + [ + ({"status": "need_verifycode"}, "verification_required", "verification_rejected"), + ({"status": "scaned"}, "scanned", None), + ({"status": "expired"}, "expired", None), + ({"status": "verify_code_blocked"}, "failed", "verification_blocked"), + ({"status": "binded_redirect"}, "failed", "already_bound"), + ({"status": "scaned_but_redirect", "redirect_host": "evil.example"}, "failed", "invalid_response"), + ({"status": "confirmed"}, "failed", "invalid_response"), + ({"status": "unsupported"}, "failed", "invalid_response"), + ], +) +async def test_pairing_code_is_cleared_after_provider_decides(login, response, status, error): + session = await login.start("alice", {}) + apply = AsyncMock() + login.request.return_value = {"status": "need_verifycode"} + await login.poll("alice", session["id"], apply) + login.request.return_value = {"status": "wait"} + await login.poll("alice", session["id"], apply, verify_code="123456") + + login.request.return_value = response + result = await login.poll("alice", session["id"], apply) + assert login.request.call_args.kwargs.get("verify_code") == "123456" + assert result["status"] == status + assert result["error"] == error + assert login.session.verify_code is None + assert "123456" not in str(result) + apply.assert_not_awaited() + + if status == "verification_required": + login.request.return_value = {"status": "scaned"} + await login.poll("alice", session["id"], apply, verify_code="654321") + assert login.request.call_args.kwargs["verify_code"] == "654321" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["timeout", "transport", "invalid_response", "expired"]) +async def test_pairing_wait_handles_network_retry_and_session_end(login, failure): + session = await login.start("alice", {}) + apply = AsyncMock(return_value={"provider": "wechat"}) + login.request.return_value = {"status": "need_verifycode"} + await login.poll("alice", session["id"], apply) + login.request.return_value = {"status": "wait"} + await login.poll("alice", session["id"], apply, verify_code="123456") + login.request.reset_mock() + + if failure == "expired": + login.session.expires_at = 0 + else: + login.request.side_effect = { + "timeout": httpx.ReadTimeout("private URL and code"), + "transport": httpx.ConnectError("private URL and code"), + "invalid_response": ValueError("private URL and code"), + }[failure] + result = await login.poll("alice", session["id"], apply) + assert "123456" not in str(result) + apply.assert_not_awaited() + + if failure in {"timeout", "transport"}: + assert result["status"] == "scanned" + assert result["error"] == "network" + login.request.side_effect = None + login.request.return_value = {"status": "confirmed", "bot_token": "secret-token"} + assert (await login.poll("alice", session["id"], apply))["status"] == "confirmed" + assert login.request.call_args.kwargs.get("verify_code") == "123456" + assert login.session.verify_code is None + apply.assert_awaited_once() + else: + assert result["status"] == ("expired" if failure == "expired" else "failed") + assert login.session.verify_code is None + if failure == "expired": + login.request.assert_not_awaited() diff --git a/frontend/package.json b/frontend/package.json index e30326ac3..33df90976 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -84,6 +84,7 @@ "nuxt-og-image": "^5.1.13", "ogl": "^1.0.11", "papaparse": "5.7.0", + "qrcode.react": "4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-resizable-panels": "^4.4.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 196d7b3ad..4af3ec5f4 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: papaparse: specifier: 5.7.0 version: 5.7.0 + qrcode.react: + specifier: 4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -5063,6 +5066,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -11445,6 +11453,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + queue-microtask@1.2.3: {} radix3@1.1.2: {} diff --git a/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx b/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx index 85f4f5af4..bf915f6e2 100644 --- a/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx +++ b/frontend/src/components/workspace/channels/channel-runtime-config-dialog.tsx @@ -1,11 +1,17 @@ "use client"; -import { LoaderCircleIcon } from "lucide-react"; +import { + KeyRoundIcon, + LoaderCircleIcon, + QrCodeIcon, + ShieldCheckIcon, +} from "lucide-react"; import { type CSSProperties, type FormEvent, useEffect, useMemo, + useRef, useState, } from "react"; @@ -19,30 +25,36 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { ChannelProvider, ChannelRuntimeConfigValues, } from "@/core/channels/types"; import { useI18n } from "@/core/i18n/hooks"; +import { cn } from "@/lib/utils"; + +import { ChannelProviderIcon } from "./channel-provider-icon"; +import { + WechatQRCompletion, + type PendingWechatBinding, +} from "./wechat-qr-completion"; +import { WechatQRLogin } from "./wechat-qr-login"; type ChannelRuntimeConfigDialogProps = { + onConfigured?: (provider: ChannelProvider) => void; provider: ChannelProvider | null; open: boolean; submitting: boolean; + initialStep?: "setup" | "binding"; onOpenChange: (open: boolean) => void; onSubmit: ( provider: ChannelProvider, values: ChannelRuntimeConfigValues, - ) => void; + ) => void | Promise; }; -type SecretInputStyle = CSSProperties & { - WebkitTextSecurity?: "disc"; -}; - -const SECRET_INPUT_STYLE: SecretInputStyle = { - WebkitTextSecurity: "disc", -}; +type SecretInputStyle = CSSProperties & { WebkitTextSecurity?: "disc" }; +const SECRET_INPUT_STYLE: SecretInputStyle = { WebkitTextSecurity: "disc" }; export function ChannelRuntimeConfigDialog({ provider, @@ -50,8 +62,27 @@ export function ChannelRuntimeConfigDialog({ submitting, onOpenChange, onSubmit, + onConfigured, + initialStep = "setup", }: ChannelRuntimeConfigDialogProps) { const { t } = useI18n(); + const submissionGeneration = useRef(0); + const [step, setStep] = useState<"setup" | null>(null); + const [configuredProvider, setConfiguredProvider] = + useState(null); + const [pendingBinding, setPendingBinding] = + useState(null); + const [method, setMethod] = useState<"qr" | "token" | null>(null); + useEffect(() => { + submissionGeneration.current += 1; + setStep(null); + setMethod(null); + setConfiguredProvider(null); + setPendingBinding(null); + return () => { + submissionGeneration.current += 1; + }; + }, [open, provider?.provider, initialStep]); const [values, setValues] = useState({}); const fields = useMemo( () => provider?.credential_fields ?? [], @@ -74,84 +105,198 @@ export function ChannelRuntimeConfigDialog({ ); }, [credentialValues, fields, open, provider]); - if (!provider) { - return null; - } - + if (!provider) return null; const isEditing = provider.configured; - + const hasWechatQR = provider.provider === "wechat" && !!onConfigured; + const selectedMethod = method ?? (isEditing ? "token" : "qr"); + const completionProvider = + configuredProvider ?? + (step === null && initialStep === "binding" && provider.configured + ? provider + : null); + const showQR = hasWechatQR && !completionProvider && selectedMethod === "qr"; + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) submissionGeneration.current += 1; + onOpenChange(nextOpen); + }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - onSubmit(provider, values); + if (showQR || completionProvider) return; + const generation = submissionGeneration.current; + const submission = onSubmit(provider, values); + if (hasWechatQR && submission) { + void submission.then((updated) => { + if (updated && submissionGeneration.current === generation) + setConfiguredProvider(updated); + }); + } }; - return ( - - -
- - - {isEditing - ? t.channels.setupEditTitle(provider.display_name) - : t.channels.setupTitle(provider.display_name)} - - {t.channels.setupDescription} - + const credentialInputs = ( +
+ {fields.map((field) => { + const inputId = `channel-${provider.provider}-${field.name}`; + const isSecretField = field.type === "password"; + return ( +
+ + { + setValues((current) => ({ + ...current, + [field.name]: event.target.value, + })); + }} + /> +
+ ); + })} +
+ ); -
- {fields.map((field) => { - const inputId = `channel-${provider.provider}-${field.name}`; - const isSecretField = field.type === "password"; - return ( -
- - { - setValues((current) => ({ - ...current, - [field.name]: event.target.value, - })); - }} - /> -
- ); - })} + return ( + + + +
+ {hasWechatQR ? ( +
+ +
+ ) : null} + + + {isEditing && !completionProvider + ? t.channels.setupEditTitle(provider.display_name) + : t.channels.setupTitle(provider.display_name)} + + + {hasWechatQR + ? t.channels.wechatQr.description + : t.channels.setupDescription} + +
- - - + {!showQR ? ( + ) : null} - {isEditing ? t.channels.saveChanges : t.channels.saveAndConnect} - - + + )}
diff --git a/frontend/src/components/workspace/channels/wechat-qr-completion.tsx b/frontend/src/components/workspace/channels/wechat-qr-completion.tsx new file mode 100644 index 000000000..5cf4e3296 --- /dev/null +++ b/frontend/src/components/workspace/channels/wechat-qr-completion.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { + CheckIcon, + CopyIcon, + LoaderCircleIcon, + QrCodeIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + connectChannelProvider, + listChannelConnections, + listChannelProviders, +} from "@/core/channels/api"; +import { + startConnectionPoll, + type ConnectPollHandle, +} from "@/core/channels/connect-poll"; +import { + channelConnectionsQueryKey, + channelProviderQueryKey, +} from "@/core/channels/hooks"; +import type { ChannelProvider } from "@/core/channels/types"; +import { useI18n } from "@/core/i18n/hooks"; + +export type PendingWechatBinding = { + code: string; + expiresAt: number; +}; + +/** Keep credential setup and user identity binding visible as separate steps. */ +export function WechatQRCompletion({ + provider, + onDone, + onRestart, + bindingToResume, +}: { + provider: ChannelProvider; + onDone: () => void; + onRestart: (binding: PendingWechatBinding | null) => void; + bindingToResume?: PendingWechatBinding | null; +}) { + const { t } = useI18n(); + const text = t.channels.wechatQr; + const queryClient = useQueryClient(); + const alreadyConnected = provider.connection_status === "connected"; + const [stage, setStage] = useState< + "loading" | "binding" | "connected" | "error" | "expired" + >(alreadyConnected ? "connected" : "loading"); + const [attempt, setAttempt] = useState(0); + const [binding, setBinding] = useState(null); + const command = binding ? `/connect ${binding.code}` : ""; + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + + useEffect(() => { + if (alreadyConnected) { + setStage("connected"); + return; + } + let stopped = false; + let poller: ConnectPollHandle | undefined; + let expiry: ReturnType | undefined; + setStage("loading"); + setCopied(false); + setCopyFailed(false); + const connected = () => { + if (stopped) return; + clearTimeout(expiry); + setStage("connected"); + void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey }); + void queryClient.invalidateQueries({ + queryKey: channelConnectionsQueryKey, + }); + }; + const start = setTimeout(() => { + void (async () => { + // Runtime setup's response does not include existing user bindings. + const current = await queryClient.fetchQuery({ + queryKey: channelProviderQueryKey, + queryFn: listChannelProviders, + staleTime: 0, + }); + if (stopped) return; + if ( + current.providers.some( + (item) => + item.provider === "wechat" && + item.connection_status === "connected", + ) + ) { + connected(); + return; + } + // Keep the command already copied to the phone, with its original + // deadline. Rescanning must not create another copy/switch-app loop. + let nextBinding = bindingToResume; + if (!nextBinding || nextBinding.expiresAt <= Date.now()) { + const result = await connectChannelProvider("wechat"); + const lifetime = + Number.isFinite(result.expires_in) && result.expires_in > 0 + ? result.expires_in + : 600; + nextBinding = { + code: result.code, + expiresAt: Date.now() + lifetime * 1000, + }; + } + if (stopped) return; + setBinding(nextBinding); + const expiresIn = (nextBinding.expiresAt - Date.now()) / 1000; + if (expiresIn <= 0) { + setStage("expired"); + return; + } + setStage("binding"); + expiry = setTimeout(() => { + poller?.cancel(); + if (!stopped) setStage("expired"); + }, expiresIn * 1000); + poller = startConnectionPoll({ + provider: "wechat", + expiresInSeconds: expiresIn, + fetchConnections: () => + queryClient.fetchQuery({ + queryKey: channelConnectionsQueryKey, + queryFn: listChannelConnections, + staleTime: 0, + }), + onConnected: connected, + }); + })().catch(() => { + if (!stopped) setStage("error"); + }); + }, 0); + return () => { + stopped = true; + clearTimeout(start); + clearTimeout(expiry); + poller?.cancel(); + }; + }, [alreadyConnected, attempt, bindingToResume, queryClient]); + + const copy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + setCopyFailed(false); + } catch { + setCopyFailed(true); + } + }; + + return ( +
+
+ + + +
+

{text.saved}

+

+ {text.savedDescription} +

+
+
+
+ {stage === "connected" ? ( + <> +
+ +
+

{text.connectedTitle}

+

+ {text.connectedDescription} +

+ + ) : stage === "binding" ? ( + <> +

{text.bindTitle}

+

+ {text.bindDescription} +

+ + {command} + + + {copyFailed && ( +

{text.copyFailed}

+ )} +

+ + {text.bindWaiting} +

+ + ) : stage === "loading" ? ( + <> + +

{text.bindLoading}

+ + ) : ( + <> +

+ {stage === "expired" ? text.bindExpired : text.bindFailed} +

+ + + )} +
+ {stage !== "connected" && ( +
+

+ {text.restartHint} +

+ + {stage === "binding" && ( +

+ {text.restartKeepCommand} +

+ )} +
+ )} + +
+ ); +} diff --git a/frontend/src/components/workspace/channels/wechat-qr-login.tsx b/frontend/src/components/workspace/channels/wechat-qr-login.tsx new file mode 100644 index 000000000..ac0257209 --- /dev/null +++ b/frontend/src/components/workspace/channels/wechat-qr-login.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { + CheckIcon, + CircleAlertIcon, + LoaderCircleIcon, + RefreshCwIcon, + SmartphoneIcon, +} from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + cancelWechatQRLogin, + pollWechatQRLogin, + startWechatQRLogin, +} from "@/core/channels/api"; +import type { + ChannelProvider, + WechatQRLoginSession, +} from "@/core/channels/types"; +import { useI18n } from "@/core/i18n/hooks"; + +export function WechatQRLogin({ + onConfigured, +}: { + onConfigured: (provider: ChannelProvider) => void; +}) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + const [attempt, setAttempt] = useState(0); + const [session, setSession] = useState(null); + const [error, setError] = useState(null); + const [verifyCode, setVerifyCode] = useState(""); + const [verifying, setVerifying] = useState(false); + const submitCodeRef = useRef<(code: string) => void>(() => undefined); + const onConfiguredRef = useRef(onConfigured); + useEffect(() => { + onConfiguredRef.current = onConfigured; + }, [onConfigured]); + + useEffect(() => { + let stopped = false; + let polling = false; + let sessionId: string | undefined; + let timer: ReturnType | undefined; + let expiryTimer: ReturnType | undefined; + const controller = new AbortController(); + setSession(null); + setError(null); + setVerifyCode(""); + setVerifying(false); + + const cancel = (id: string) => { + void cancelWechatQRLogin(id).catch(() => undefined); + }; + const poll = async (code?: string) => { + if (stopped || !sessionId) return; + polling = true; + try { + const result = await pollWechatQRLogin( + sessionId, + controller.signal, + code, + ); + if (stopped) return; + setSession(result); + setVerifying(false); + if (result.status === "confirmed" && result.provider) { + void queryClient.invalidateQueries({ + queryKey: ["channelProviders"], + }); + void queryClient.invalidateQueries({ + queryKey: ["channelConnections"], + }); + onConfiguredRef.current(result.provider); + } else if ( + result.status === "pending" || + result.status === "scanned" || + (result.status === "verification_required" && + result.error === "network") + ) { + timer = setTimeout(() => { + void poll(); + }, 1500); + } + } catch (error) { + if (!stopped && !controller.signal.aborted) { + setVerifying(false); + setError(error instanceof Error ? error.message : ""); + } + } finally { + polling = false; + } + }; + + submitCodeRef.current = (code) => { + setVerifying(true); + setVerifyCode(""); + clearTimeout(timer); + void poll(code); + }; + + // Defer one tick so Strict Mode's setup/cleanup probe cannot start a + // second server session and invalidate the QR the user is scanning. + const startTimer = setTimeout(() => { + // Let a pending start finish so its session can still be cancelled if the + // dialog was closed before the server returned its ID. + void startWechatQRLogin() + .then((result) => { + if (stopped) { + cancel(result.id); + return; + } + sessionId = result.id; + expiryTimer = setTimeout(() => { + // Let an in-flight confirmation finish saving credentials. The + // server also checks expiry before applying a polling response. + if (stopped || polling) return; + controller.abort(); + clearTimeout(timer); + setVerifying(false); + setSession((current) => + current && current.status !== "confirmed" + ? { ...current, status: "expired", error: null } + : current, + ); + }, result.expires_in * 1000); + setSession(result); + timer = setTimeout(() => { + void poll(); + }, 1500); + }) + .catch((error: unknown) => { + if (!stopped) setError(error instanceof Error ? error.message : ""); + }); + }, 0); + + return () => { + stopped = true; + controller.abort(); + clearTimeout(timer); + clearTimeout(startTimer); + clearTimeout(expiryTimer); + submitCodeRef.current = () => undefined; + if (sessionId) cancel(sessionId); + }; + }, [attempt, queryClient]); + + const verificationRequired = session?.status === "verification_required"; + const ended = + session?.status === "expired" || + session?.status === "failed" || + error !== null; + const scanned = + session?.status === "scanned" || session?.status === "confirmed"; + const title = ended + ? session?.status === "expired" + ? t.channels.wechatQr.expiredTitle + : t.channels.wechatQr.failedTitle + : verificationRequired + ? t.channels.wechatQr.verifyTitle + : scanned + ? t.channels.wechatQr.scannedTitle + : session + ? t.channels.wechatQr.waiting + : t.channels.wechatQr.loading; + const description = + (error === "" ? null : error) ?? + (session?.error ? t.channels.wechatQr[session.error] : null) ?? + (verificationRequired ? t.channels.wechatQr.verifyDescription : null) ?? + (session?.status === "expired" + ? t.channels.wechatQr.expired + : session?.status === "failed" || error !== null + ? t.channels.wechatQr.failed + : session?.status === "scanned" + ? t.channels.wechatQr.scanned + : session?.status === "confirmed" + ? t.channels.wechatQr.confirmed + : t.channels.wechatQr.scan); + + return ( +
+
+ {session && !ended && !scanned && !verificationRequired ? ( + + ) : null} + {ended ? ( +
+
+ ) : verificationRequired ? ( +
+ + + + setVerifyCode(event.target.value.replace(/[^0-9]/g, "")) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (verifyCode && !verifying) + submitCodeRef.current(verifyCode); + } + }} + /> + +
+ ) : scanned ? ( +
+
+ + + + +
+
+ ) : !session ? ( + + ) : null} +
+
+

+ {session && !ended ? ( +

+

+ {description} +

+ {!ended && ( +

+ {t.channels.wechatQr.autoSave} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/workspace/channels/workspace-channels-list.tsx b/frontend/src/components/workspace/channels/workspace-channels-list.tsx index 9e361f945..6108e56b7 100644 --- a/frontend/src/components/workspace/channels/workspace-channels-list.tsx +++ b/frontend/src/components/workspace/channels/workspace-channels-list.tsx @@ -57,6 +57,7 @@ export function WorkspaceChannelsList() { const { enabled, providers, isLoading, error } = useChannelProviders(); const connectMutation = useConnectChannelProvider(); const configureMutation = useConfigureChannelProvider(); + const [setupStep, setSetupStep] = useState<"setup" | "binding">("setup"); const [setupProvider, setSetupProvider] = useState( null, ); @@ -66,6 +67,12 @@ export function WorkspaceChannelsList() { provider: ChannelProvider, preparedWindow?: Window | null, ) => { + if (provider.provider === "wechat") { + closeConnectWindow(preparedWindow ?? null); + setSetupStep(provider.configured ? "binding" : "setup"); + setSetupProvider(provider); + return; + } const connectWindow = preparedWindow !== undefined ? preparedWindow @@ -153,6 +160,7 @@ export function WorkspaceChannelsList() { providerNeedsRuntimeConfig(provider) || (isConnected && canEditRuntimeConfig) ) { + setSetupStep("setup"); setSetupProvider(provider); return; } @@ -180,7 +188,11 @@ export function WorkspaceChannelsList() { })} { + setSetupProvider(null); + }} provider={setupProvider} + initialStep={setupStep} open={setupProvider !== null} submitting={configureMutation.isPending} onOpenChange={(open) => { @@ -191,9 +203,10 @@ export function WorkspaceChannelsList() { onSubmit={(provider, values) => { const connectWindow = provider.auth_mode === "deep_link" ? prepareConnectWindow() : null; - void configureMutation + return configureMutation .mutateAsync({ provider: provider.provider, values }) .then((updated) => { + if (updated.provider === "wechat") return updated; setSetupProvider(null); if (providerCanConnect(updated)) { startConnect(updated, connectWindow); diff --git a/frontend/src/components/workspace/settings/channels-settings-page.tsx b/frontend/src/components/workspace/settings/channels-settings-page.tsx index c3c830285..ff5e5c6c7 100644 --- a/frontend/src/components/workspace/settings/channels-settings-page.tsx +++ b/frontend/src/components/workspace/settings/channels-settings-page.tsx @@ -117,6 +117,11 @@ function ChannelProviderItem({ const configureMutation = useConfigureChannelProvider(); const disconnectProviderMutation = useDisconnectChannelProvider(); const [setupOpen, setSetupOpen] = useState(false); + const [setupStep, setSetupStep] = useState<"setup" | "binding">("setup"); + const openSetup = () => { + setSetupStep("setup"); + setSetupOpen(true); + }; const runtimeAvailable = provider.configured && !provider.unavailable_reason; const isConnected = runtimeAvailable && @@ -142,6 +147,12 @@ function ChannelProviderItem({ connectProvider: ChannelProvider, preparedWindow?: Window | null, ) => { + if (connectProvider.provider === "wechat") { + closeConnectWindow(preparedWindow ?? null); + setSetupStep(connectProvider.configured ? "binding" : "setup"); + setSetupOpen(true); + return; + } const connectWindow = preparedWindow !== undefined ? preparedWindow @@ -205,7 +216,7 @@ function ChannelProviderItem({ variant="outline" size="sm" disabled={isConnecting || isDisconnecting} - onClick={() => setSetupOpen(true)} + onClick={openSetup} > {isConnecting ? ( @@ -251,7 +262,7 @@ function ChannelProviderItem({ variant="outline" size="sm" disabled={isConnecting || isDisconnecting} - onClick={() => setSetupOpen(true)} + onClick={openSetup} > {t.channels.modify} @@ -263,7 +274,7 @@ function ChannelProviderItem({ title={unavailableReason} onClick={() => { if (providerNeedsRuntimeConfig(provider)) { - setSetupOpen(true); + openSetup(); return; } @@ -289,7 +300,11 @@ function ChannelProviderItem({ { + setSetupOpen(false); + }} provider={provider} + initialStep={setupStep} open={setupOpen} submitting={configureMutation.isPending} onOpenChange={setSetupOpen} @@ -298,9 +313,10 @@ function ChannelProviderItem({ submitProvider.auth_mode === "deep_link" ? prepareConnectWindow() : null; - void configureMutation + return configureMutation .mutateAsync({ provider: submitProvider.provider, values }) .then((updated) => { + if (updated.provider === "wechat") return updated; setSetupOpen(false); if (providerCanConnect(updated)) { startConnect(updated, connectWindow); diff --git a/frontend/src/core/channels/api.ts b/frontend/src/core/channels/api.ts index e55bfcde8..39ad175c5 100644 --- a/frontend/src/core/channels/api.ts +++ b/frontend/src/core/channels/api.ts @@ -3,6 +3,7 @@ import { fetch } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { + WechatQRLoginSession, ChannelConnectResponse, ChannelConnection, ChannelConnectionsResponse, @@ -106,3 +107,44 @@ export async function disconnectChannelProvider( } return response.json() as Promise; } + +export async function startWechatQRLogin(): Promise { + const response = await fetch(channelsUrl("/wechat/qr-login"), { + method: "POST", + }); + if (!response.ok) + await throwGatewayApiError(response, "Unable to start WeChat QR login"); + return response.json() as Promise; +} + +export async function pollWechatQRLogin( + id: string, + signal?: AbortSignal, + verifyCode?: string, +): Promise { + const response = await fetch( + channelsUrl(`/wechat/qr-login/${encodeURIComponent(id)}/poll`), + { + method: "POST", + signal, + ...(verifyCode + ? { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ verify_code: verifyCode }), + } + : {}), + }, + ); + if (!response.ok) + await throwGatewayApiError(response, "Unable to check WeChat QR login"); + return response.json() as Promise; +} + +export async function cancelWechatQRLogin(id: string): Promise { + const response = await fetch( + channelsUrl(`/wechat/qr-login/${encodeURIComponent(id)}`), + { method: "DELETE" }, + ); + if (!response.ok && response.status !== 404) + await throwGatewayApiError(response, "Unable to cancel WeChat QR login"); +} diff --git a/frontend/src/core/channels/types.ts b/frontend/src/core/channels/types.ts index c0cdce388..2e9e85dc4 100644 --- a/frontend/src/core/channels/types.ts +++ b/frontend/src/core/channels/types.ts @@ -51,3 +51,24 @@ export interface ChannelConnectResponse { instruction: string; expires_in: number; } + +export type WechatQRLoginSession = { + id: string; + status: + | "pending" + | "scanned" + | "verification_required" + | "confirmed" + | "expired" + | "failed"; + error?: + | "network" + | "invalid_response" + | "verification_rejected" + | "verification_blocked" + | "already_bound" + | null; + qrcode_content: string; + expires_in: number; + provider: ChannelProvider | null; +}; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index c6b985e39..be8caaa1a 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1050,6 +1050,71 @@ export const enUS: Translations = { unavailableShort: "Unavailable", setupTitle: (name: string) => `Connect ${name}`, setupEditTitle: (name: string) => `Modify ${name}`, + wechatQr: { + restart: "Scan again", + restartHint: + "Left the bot screen in WeChat? Start again with a new QR code.", + restartKeepCommand: + "The command you already copied stays valid until it expires.", + autoSave: "Your token will be saved automatically after confirmation.", + verifyTitle: "Enter the code shown in WeChat", + verifyDescription: "Enter the digits on your phone to finish connecting.", + verifyLabel: "Pairing code", + verifySubmit: "Continue connecting", + verifying: "Verifying…", + network: "WeChat is temporarily unreachable. Retrying automatically…", + invalid_response: + "WeChat returned an unexpected response. Refresh the QR code and try again.", + verification_rejected: + "The code did not match. Check the digits on your phone and try again.", + verification_blocked: + "Too many incorrect attempts. Wait a moment, then refresh the QR code.", + already_bound: + "WeChat says this bot is already linked. Close this dialog and check its connection, or choose a different bot on your phone.", + saved: "Token saved securely", + savedDescription: + "DeerFlow has saved your token on the server and started the WeChat channel.", + bindTitle: "One more step: link your account", + bindDescription: + "Send this command to the bot in WeChat to link it to your DeerFlow account.", + bindWaiting: "Waiting for your message in WeChat…", + bindLoading: "Preparing your account connection…", + bindFailed: + "Your token is saved, but account binding could not start. Try again.", + bindExpired: + "This binding code has expired. Generate a new one; no need to scan again.", + bindRetry: "Generate binding code", + copyCommand: "Copy command", + copied: "Copied", + copyFailed: "Could not copy. Select and copy the command above.", + connectedTitle: "WeChat is connected", + connectedDescription: "You can now send a message to your bot in WeChat.", + done: "Done", + + login: "Scan QR code", + manual: "Use token", + description: "Connect WeChat to your DeerFlow workspace.", + loading: "Generating QR code…", + imageTitle: "WeChat login QR code", + scan: "Scan this code with WeChat, then confirm on your phone.", + scanned: "Code scanned. Confirm the login on your phone.", + expired: "This QR code has expired. Generate a new one.", + failed: "WeChat login failed or was cancelled. Try again.", + confirmed: "WeChat login confirmed.", + retry: "Refresh QR code", + methodLabel: "Connection method", + tokenTitle: "Connect with a bot token", + tokenDescription: + "Paste your existing WeChat iLink bot token to connect.", + tokenPlaceholder: "Paste your bot token", + tokenHint: + "Don’t have a token? Choose Scan QR code to connect with your phone.", + privacy: "Credentials are saved only on your server.", + waiting: "Waiting for scan", + scannedTitle: "Scan complete", + expiredTitle: "QR code expired", + failedTitle: "Unable to connect", + }, setupDescription: "Enter the values needed by this server process. They are not written to config.yaml.", saveAndConnect: "Save and connect", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 2afdeab91..a9f61f9f1 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -879,6 +879,59 @@ export interface Translations { setupTitle: (name: string) => string; setupEditTitle: (name: string) => string; setupDescription: string; + wechatQr: { + restart: string; + restartHint: string; + restartKeepCommand: string; + autoSave: string; + verifyTitle: string; + verifyDescription: string; + verifyLabel: string; + verifySubmit: string; + verifying: string; + network: string; + invalid_response: string; + verification_rejected: string; + verification_blocked: string; + already_bound: string; + saved: string; + savedDescription: string; + bindTitle: string; + bindDescription: string; + bindWaiting: string; + bindLoading: string; + bindFailed: string; + bindExpired: string; + bindRetry: string; + copyCommand: string; + copied: string; + copyFailed: string; + connectedTitle: string; + connectedDescription: string; + done: string; + + methodLabel: string; + tokenTitle: string; + tokenDescription: string; + tokenPlaceholder: string; + tokenHint: string; + privacy: string; + waiting: string; + scannedTitle: string; + expiredTitle: string; + failedTitle: string; + login: string; + manual: string; + description: string; + loading: string; + imageTitle: string; + scan: string; + scanned: string; + expired: string; + failed: string; + confirmed: string; + retry: string; + }; saveAndConnect: string; saveChanges: string; descriptions: Record; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index bf2cfdc00..99f8b6323 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -987,6 +987,61 @@ export const zhCN: Translations = { unavailableShort: "不可用", setupTitle: (name: string) => `连接 ${name}`, setupEditTitle: (name: string) => `修改 ${name}`, + wechatQr: { + restart: "重新扫码", + restartHint: "离开了微信中的机器人页面?可以重新扫码继续连接。", + restartKeepCommand: "已复制的绑定指令在有效期内仍可使用。", + autoSave: "确认后将自动保存 Token,无需手动复制。", + verifyTitle: "输入微信显示的配对码", + verifyDescription: "填写手机微信页面显示的数字,继续连接。", + verifyLabel: "配对码", + verifySubmit: "继续连接", + verifying: "正在验证…", + network: "暂时无法连接微信,正在自动重试…", + invalid_response: "微信返回了异常状态,请刷新二维码重试。", + verification_rejected: "配对码不匹配,请检查手机上的数字后重试。", + verification_blocked: "配对码错误次数过多,请稍候再刷新二维码。", + already_bound: + "微信提示此机器人已连接。请关闭弹窗检查连接状态,或在手机上选择其他机器人。", + saved: "Token 已安全保存", + savedDescription: "DeerFlow 已在服务器保存 Token 并启动微信渠道。", + bindTitle: "最后一步:绑定你的账号", + bindDescription: + "将下方指令发送给微信中的机器人,即可绑定到你的 DeerFlow 账号。", + bindWaiting: "正在等待微信中的绑定消息…", + bindLoading: "正在准备账号绑定…", + bindFailed: "Token 已保存,但账号绑定暂时不可用,请重试。", + bindExpired: "绑定码已过期,请重新生成,无需再次扫码。", + bindRetry: "生成绑定码", + copyCommand: "复制指令", + copied: "已复制", + copyFailed: "复制失败,请选中上方指令手动复制。", + connectedTitle: "微信已连接", + connectedDescription: "现在可以在微信中向机器人发送消息了。", + done: "完成", + + login: "扫码连接", + manual: "使用 Token", + description: "将微信连接到你的 DeerFlow 工作空间。", + loading: "正在生成二维码…", + imageTitle: "微信登录二维码", + scan: "请使用微信扫描二维码,并在手机上确认登录。", + scanned: "已扫码,请在手机上确认登录。", + expired: "二维码已过期,请重新生成。", + failed: "微信登录失败或已取消,请重试。", + confirmed: "微信登录已确认。", + retry: "刷新二维码", + methodLabel: "连接方式", + tokenTitle: "使用已有的机器人 Token", + tokenDescription: "粘贴微信 iLink 机器人的 Token 即可连接。", + tokenPlaceholder: "粘贴你的 Bot token", + tokenHint: "还没有 Token?选择「扫码连接」,用手机即可完成。", + privacy: "凭据仅保存在你的服务器上", + waiting: "等待微信扫码", + scannedTitle: "已扫码", + expiredTitle: "二维码已过期", + failedTitle: "连接未完成", + }, setupDescription: "填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。", saveAndConnect: "保存并连接", diff --git a/frontend/tests/e2e/channels.spec.ts b/frontend/tests/e2e/channels.spec.ts index a48db0365..4f93639ac 100644 --- a/frontend/tests/e2e/channels.spec.ts +++ b/frontend/tests/e2e/channels.spec.ts @@ -455,3 +455,381 @@ test.describe("IM channels", () => { await expect(setupDialog.getByLabel("App secret")).toHaveValue("********"); }); }); + +test("WeChat QR setup keeps account binding separate and supports manual entry", async ({ + page, +}, testInfo) => { + const hydrationErrors: string[] = []; + page.on("console", (message) => { + if ( + /hydration|hydrated|server rendered HTML|Minified React error #(418|423|425)/i.test( + message.text(), + ) + ) + hydrationErrors.push(message.text()); + }); + mockLangGraphAPI(page); + const wechat: MockChannelProvider = { + provider: "wechat", + display_name: "WeChat", + enabled: true, + configured: false, + connectable: false, + auth_mode: "binding_code", + connection_status: "not_connected", + credential_fields: [ + { + name: "bot_token", + label: "Bot token", + type: "password", + required: true, + }, + ], + }; + mockChannelsAPI(page, [wechat]); + let confirm = false; + let cancelled = 0; + let connected = 0; + let bound = false; + await page.route("**/api/channels/connections", (route) => + route.fulfill({ + json: { + connections: bound + ? [{ id: "wechat-binding", provider: "wechat", status: "connected" }] + : [], + }, + }), + ); + let starts = 0; + let session = { + id: "preview-session", + status: "pending", + qrcode_content: "https://example.com/wechat-demo", + expires_in: 180, + provider: null, + }; + await page.route("**/api/channels/wechat/qr-login", (route) => { + starts += 1; + session = { + ...session, + id: `preview-session-${starts}`, + qrcode_content: `https://example.com/wechat-demo?attempt=${starts}`, + }; + return route.fulfill({ json: session }); + }); + await page.route("**/api/channels/wechat/qr-login/*/poll", (route) => + route.fulfill({ + json: confirm + ? { + ...session, + status: "confirmed", + provider: { + ...wechat, + configured: true, + connectable: true, + credential_values: { bot_token: "********" }, + }, + } + : session, + }), + ); + await page.route("**/api/channels/wechat/qr-login/*", (route) => { + cancelled += 1; + return route.fulfill({ status: 204 }); + }); + await page.route("**/api/channels/wechat/connect", (route) => { + connected += 1; + return route.fulfill({ + json: { + provider: "wechat", + mode: "binding_code", + code: "demo-code", + instruction: "Send /connect demo-code to the WeChat bot.", + expires_in: 600, + }, + }); + }); + + await page.goto("/workspace/chats/new"); + const sidebar = page.locator("[data-sidebar='sidebar']"); + await sidebar.getByRole("button", { name: "Connect", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect( + dialog.getByRole("img", { name: "WeChat login QR code" }), + ).toBeAttached(); + await expect( + dialog.getByText("Scan this code with WeChat, then confirm on your phone."), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("wechat-qr-preview.png"), + animations: "disabled", + }); + const cancellationsBeforeSwitch = cancelled; + await dialog.getByRole("tab", { name: "Use token" }).click(); + await expect(dialog.getByLabel("Bot token")).toBeVisible(); + await dialog.getByLabel("Bot token").fill("example-token"); + await page.screenshot({ + path: testInfo.outputPath("wechat-token-preview.png"), + animations: "disabled", + }); + await expect.poll(() => cancelled).toBeGreaterThan(cancellationsBeforeSwitch); + await dialog.getByRole("tab", { name: "Scan QR code" }).click(); + await expect( + dialog.getByRole("img", { name: "WeChat login QR code" }), + ).toBeAttached(); + await dialog.getByRole("tab", { name: "Use token" }).click(); + await expect(dialog.getByLabel("Bot token")).toHaveValue("example-token"); + await dialog.getByRole("tab", { name: "Scan QR code" }).click(); + confirm = true; + await expect(dialog.getByText("Token saved securely")).toBeVisible({ + timeout: 10_000, + }); + await expect.poll(() => connected).toBe(1); + await expect( + dialog.getByText("/connect demo-code", { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Copy command" }), + ).toBeVisible(); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await dialog.getByRole("button", { name: "Copy command" }).click(); + await expect( + dialog.getByRole("button", { name: "Copied", exact: true }), + ).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + "/connect demo-code", + ); + await page.screenshot({ + path: testInfo.outputPath("wechat-binding-preview.png"), + animations: "disabled", + }); + // Moving the command to the phone can take the user out of the bot screen. + // Rescanning must recover in-place and keep that already-copied command. + const startsBeforeRetry = starts; + confirm = false; + await dialog.getByRole("button", { name: "Scan again", exact: true }).click(); + await expect( + dialog.getByRole("img", { name: "WeChat login QR code" }), + ).toBeVisible(); + await expect.poll(() => starts).toBe(startsBeforeRetry + 1); + await expect( + dialog.getByText("/connect demo-code", { exact: true }), + ).toHaveCount(0); + await expect( + dialog.getByRole("tab", { name: "Scan QR code" }), + ).toHaveAttribute("aria-selected", "true"); + confirm = true; + await expect( + dialog.getByText("/connect demo-code", { exact: true }), + ).toBeVisible(); + expect(connected).toBe(1); + bound = true; + await expect(dialog.getByText("WeChat is connected")).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("wechat-connected-preview.png"), + animations: "disabled", + }); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await expect(dialog).not.toBeVisible(); + expect(hydrationErrors).toEqual([]); +}); + +test("WeChat phone pairing code leads to persistent connection success", async ({ + page, +}, testInfo) => { + mockLangGraphAPI(page); + const wechat: MockChannelProvider = { + provider: "wechat", + display_name: "WeChat", + enabled: true, + configured: false, + connectable: false, + auth_mode: "binding_code", + connection_status: "not_connected", + credential_fields: [ + { + name: "bot_token", + label: "Bot token", + type: "password", + required: true, + }, + ], + }; + mockChannelsAPI(page, [wechat]); + const session = { + id: "pairing-session", + status: "pending", + qrcode_content: "https://example.com/scan", + expires_in: 180, + provider: null, + }; + let starts = 0; + let submitted = ""; + await page.route("**/api/channels/wechat/qr-login", (route) => { + starts += 1; + return route.fulfill({ json: session }); + }); + await page.route( + "**/api/channels/wechat/qr-login/pairing-session/poll", + (route) => { + const body = route.request().postDataJSON() as { + verify_code?: string; + } | null; + submitted = body?.verify_code ?? ""; + return route.fulfill({ + json: + submitted === "123456" + ? { + ...session, + status: "confirmed", + provider: { + ...wechat, + configured: true, + connection_status: "connected", + }, + } + : { ...session, status: "verification_required" }, + }); + }, + ); + await page.route("**/api/channels/wechat/qr-login/pairing-session", (route) => + route.fulfill({ status: 204 }), + ); + await page.goto("/workspace/chats/new"); + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: "Connect", exact: true }) + .click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByLabel("Pairing code")).toBeVisible(); + expect(starts).toBe(1); + await dialog.getByLabel("Pairing code").fill("123456"); + await page.screenshot({ + path: testInfo.outputPath("wechat-pairing-preview.png"), + animations: "disabled", + }); + await dialog.getByRole("button", { name: "Continue connecting" }).click(); + await expect(dialog.getByText("Token saved securely")).toBeVisible(); + await expect(dialog.getByText("WeChat is connected")).toBeVisible(); + expect(submitted).toBe("123456"); + await expect(dialog.getByRole("tab")).toHaveCount(0); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await expect(dialog).not.toBeVisible(); +}); + +for (const entry of ["sidebar", "settings"] as const) { + for (const flow of ["existing token", "manual token"] as const) { + test(`WeChat ${flow} from ${entry} keeps binding and QR recovery in a dialog`, async ({ + page, + }) => { + mockLangGraphAPI(page); + const wechat: MockChannelProvider = { + provider: "wechat", + display_name: "WeChat", + enabled: true, + configured: flow === "existing token", + connectable: flow === "existing token", + auth_mode: "binding_code", + connection_status: "not_connected", + credential_fields: [ + { + name: "bot_token", + label: "Bot token", + type: "password", + required: true, + }, + ], + credential_values: + flow === "existing token" ? { bot_token: "********" } : {}, + }; + mockChannelsAPI(page, [wechat]); + let bindingCalls = 0; + let qrStarts = 0; + await page.route("**/api/channels/wechat/connect", (route) => { + bindingCalls += 1; + return route.fulfill({ + json: { + provider: "wechat", + mode: "binding_code", + code: "recovery-demo", + instruction: + "Send /connect recovery-demo to the DeerFlow WeChat bot.", + expires_in: 600, + }, + }); + }); + await page.route("**/api/channels/wechat/runtime-config", (route) => { + expect(route.request().method()).toBe("POST"); + wechat.configured = true; + wechat.connectable = true; + wechat.credential_values = { bot_token: "********" }; + return route.fulfill({ json: wechat }); + }); + const session = { + id: "recovery-session", + status: "pending", + qrcode_content: "https://example.com/recovery", + expires_in: 180, + provider: null, + }; + await page.route("**/api/channels/wechat/qr-login", (route) => { + qrStarts += 1; + return route.fulfill({ json: session }); + }); + await page.route( + "**/api/channels/wechat/qr-login/recovery-session/poll", + (route) => route.fulfill({ json: session }), + ); + await page.route( + "**/api/channels/wechat/qr-login/recovery-session", + (route) => route.fulfill({ status: 204 }), + ); + await page.goto("/workspace/chats/new"); + const sidebar = page.locator("[data-sidebar='sidebar']"); + if (entry === "settings") { + await sidebar + .getByRole("button", { name: /Settings and more/ }) + .click(); + await page.getByRole("menuitem", { name: "Settings" }).click(); + await page + .getByRole("button", { name: "Channels", exact: true }) + .click(); + await page + .getByRole("dialog", { name: "Settings", exact: true }) + .getByRole("button", { name: "Connect", exact: true }) + .click(); + } else { + await sidebar + .getByRole("button", { name: "Connect", exact: true }) + .click(); + } + const dialog = page.getByRole("dialog", { + name: "Connect WeChat", + exact: true, + }); + if (flow === "manual token") { + await dialog.getByRole("tab", { name: "Use token" }).click(); + await dialog.getByLabel("Bot token").fill("manual-demo-token"); + await dialog.getByRole("button", { name: "Save and connect" }).click(); + } + await expect( + dialog.getByText("/connect recovery-demo", { exact: true }), + ).toBeVisible(); + expect(bindingCalls).toBe(1); + await expect( + page.locator("[data-sonner-toast]").getByText(/Send \/connect/), + ).toHaveCount(0); + const startsBeforeRecovery = qrStarts; + await dialog + .getByRole("button", { name: "Scan again", exact: true }) + .click(); + // Saved providers normally default to the token tab; recovery must force QR. + await expect( + page.getByRole("dialog").filter({ + has: page.getByRole("img", { name: "WeChat login QR code" }), + }), + ).toBeVisible(); + expect(qrStarts).toBe(startsBeforeRecovery + 1); + }); + } +} diff --git a/frontend/tests/unit/core/channels/api.test.ts b/frontend/tests/unit/core/channels/api.test.ts index 333029425..8744db813 100644 --- a/frontend/tests/unit/core/channels/api.test.ts +++ b/frontend/tests/unit/core/channels/api.test.ts @@ -10,6 +10,9 @@ rs.mock("@/core/config", () => ({ import { fetch as fetcher } from "@/core/api/fetcher"; import { + cancelWechatQRLogin, + pollWechatQRLogin, + startWechatQRLogin, configureChannelProvider, connectChannelProvider, disconnectChannelConnection, @@ -218,3 +221,46 @@ describe("channels api", () => { ); }); }); + +test("WeChat QR requests use authenticated mutations and support cancellation", async () => { + const session = { + id: "qr/id", + status: "pending", + qrcode_content: "scan", + expires_in: 180, + provider: null, + }; + mockedFetch.mockResolvedValueOnce(jsonResponse(200, session)); + await expect(startWechatQRLogin()).resolves.toEqual(session); + expect(mockedFetch).toHaveBeenLastCalledWith( + "/backend/api/channels/wechat/qr-login", + { method: "POST" }, + ); + const controller = new AbortController(); + mockedFetch.mockResolvedValueOnce(jsonResponse(200, session)); + await pollWechatQRLogin(session.id, controller.signal); + expect(mockedFetch).toHaveBeenLastCalledWith( + "/backend/api/channels/wechat/qr-login/qr%2Fid/poll", + { method: "POST", signal: controller.signal }, + ); + mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await cancelWechatQRLogin(session.id); + expect(mockedFetch).toHaveBeenLastCalledWith( + "/backend/api/channels/wechat/qr-login/qr%2Fid", + { method: "DELETE" }, + ); +}); + +test("submits a pairing code in the request body, never the URL", async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, { status: "scanned" })); + await pollWechatQRLogin("session", undefined, "123456"); + expect(mockedFetch).toHaveBeenCalledWith( + "/backend/api/channels/wechat/qr-login/session/poll", + { + method: "POST", + signal: undefined, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ verify_code: "123456" }), + }, + ); +}); diff --git a/frontend/tests/unit/core/channels/wechat-qr-completion.dom.test.tsx b/frontend/tests/unit/core/channels/wechat-qr-completion.dom.test.tsx new file mode 100644 index 000000000..63be1aa62 --- /dev/null +++ b/frontend/tests/unit/core/channels/wechat-qr-completion.dom.test.tsx @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { StrictMode } from "react"; + +rs.mock("@/core/channels/api", () => ({ + connectChannelProvider: rs.fn(), + listChannelProviders: rs.fn(), + listChannelConnections: rs.fn(), +})); + +import { + WechatQRCompletion, + type PendingWechatBinding, +} from "@/components/workspace/channels/wechat-qr-completion"; +import { + connectChannelProvider, + listChannelProviders, + listChannelConnections, +} from "@/core/channels/api"; +import type { ChannelProvider, ChannelConnection } from "@/core/channels/types"; +import { I18nProvider } from "@/core/i18n/context"; + +beforeEach(() => { + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [], + }); + rs.mocked(listChannelConnections).mockResolvedValue([]); + rs.mocked(connectChannelProvider).mockResolvedValue({ + provider: "wechat", + mode: "binding_code", + code: "demo", + instruction: "Send /connect demo", + expires_in: 600, + }); +}); +afterEach(() => { + cleanup(); + rs.useRealTimers(); + rs.resetAllMocks(); +}); +function mount(connected = false, bindingToResume?: PendingWechatBinding) { + const onDone = rs.fn(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = (connected: boolean) => ( + + + + + + + + ); + const { rerender } = render(view(connected)); + return { + onDone, + updateConnection: (connected: boolean) => rerender(view(connected)), + }; +} + +it("keeps saved credentials and the binding instruction visible until connected", async () => { + const { onDone } = mount(); + expect(await screen.findByText("/connect demo")).toBeTruthy(); + expect(screen.getByText("Token saved securely")).toBeTruthy(); + expect(onDone).not.toHaveBeenCalled(); + expect(connectChannelProvider).toHaveBeenCalledTimes(1); + rs.mocked(listChannelConnections).mockResolvedValue([ + { provider: "wechat", status: "connected" } as ChannelConnection, + ]); + await screen.findByText("WeChat is connected", {}, { timeout: 4000 }); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(onDone).toHaveBeenCalledTimes(1); +}); + +it("shows a stable success step when no user binding is required", async () => { + const { onDone } = mount(true); + expect(screen.getByText("WeChat is connected")).toBeTruthy(); + expect(connectChannelProvider).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(onDone).toHaveBeenCalledTimes(1); +}); + +it("shows success when the provider connects before the binding poll finishes", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + let finishPoll!: (connections: ChannelConnection[]) => void; + rs.mocked(listChannelConnections).mockReturnValueOnce( + new Promise((resolve) => { + finishPoll = resolve; + }), + ); + const { onDone, updateConnection } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect demo")).toBeTruthy(); + await act(() => rs.advanceTimersByTimeAsync(2000)); + expect(listChannelConnections).toHaveBeenCalledTimes(1); + + updateConnection(true); + expect(screen.getByText("WeChat is connected")).toBeTruthy(); + expect(screen.queryByText("/connect demo")).toBeNull(); + expect(screen.queryByRole("button", { name: "Scan again" })).toBeNull(); + expect(screen.getByRole("button", { name: "Done" })).toBeTruthy(); + + await act(async () => finishPoll([])); + await act(() => rs.advanceTimersByTimeAsync(600_000)); + expect(screen.getByText("WeChat is connected")).toBeTruthy(); + expect(listChannelConnections).toHaveBeenCalledTimes(1); + expect(connectChannelProvider).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(onDone).toHaveBeenCalledTimes(1); +}); + +it("keeps success when the provider connects while a binding request is pending", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + let failBinding!: (reason: Error) => void; + rs.mocked(connectChannelProvider).mockReturnValueOnce( + new Promise((_resolve, reject) => { + failBinding = reject; + }), + ); + const { updateConnection } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(connectChannelProvider).toHaveBeenCalledTimes(1); + + updateConnection(true); + expect(screen.getByText("WeChat is connected")).toBeTruthy(); + await act(async () => failBinding(new Error("late binding failure"))); + await act(() => rs.advanceTimersByTimeAsync(600_000)); + expect(screen.getByText("WeChat is connected")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Done" })).toBeTruthy(); + expect(listChannelConnections).not.toHaveBeenCalled(); +}); + +it("retries account binding without asking the user to scan again", async () => { + rs.mocked(connectChannelProvider).mockRejectedValueOnce( + new Error("unavailable"), + ); + mount(); + await screen.findByText( + "Your token is saved, but account binding could not start. Try again.", + ); + fireEvent.click( + screen.getByRole("button", { name: "Generate binding code" }), + ); + expect(await screen.findByText("/connect demo")).toBeTruthy(); + await waitFor(() => expect(connectChannelProvider).toHaveBeenCalledTimes(2)); +}); + +it("offers a new binding code when the old one expires", async () => { + rs.mocked(connectChannelProvider).mockResolvedValueOnce({ + provider: "wechat", + mode: "binding_code", + code: "demo", + instruction: "Send /connect demo", + expires_in: 0.05, + }); + mount(); + await screen.findByText( + "This binding code has expired. Generate a new one; no need to scan again.", + ); + expect(screen.queryByText("/connect demo")).toBeNull(); + expect( + screen.getByRole("button", { name: "Generate binding code" }), + ).toBeTruthy(); +}); + +it("keeps the original command deadline across rescans and replaces it only after expiry", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + mount(false, { code: "already-on-phone", expiresAt: Date.now() + 5000 }); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect already-on-phone")).toBeTruthy(); + expect(connectChannelProvider).not.toHaveBeenCalled(); + await act(() => rs.advanceTimersByTimeAsync(5000)); + expect(screen.queryByText("/connect already-on-phone")).toBeNull(); + expect(screen.getByRole("button", { name: "Scan again" })).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "Generate binding code" }), + ); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect demo")).toBeTruthy(); + expect(connectChannelProvider).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/tests/unit/core/channels/wechat-qr-login.dom.test.tsx b/frontend/tests/unit/core/channels/wechat-qr-login.dom.test.tsx new file mode 100644 index 000000000..0c3b221cd --- /dev/null +++ b/frontend/tests/unit/core/channels/wechat-qr-login.dom.test.tsx @@ -0,0 +1,459 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { StrictMode } from "react"; + +rs.mock("@/core/channels/api", () => ({ + startWechatQRLogin: rs.fn(), + pollWechatQRLogin: rs.fn(), + cancelWechatQRLogin: rs.fn(), + connectChannelProvider: rs.fn(), + listChannelConnections: rs.fn(), + listChannelProviders: rs.fn(), +})); + +import { ChannelRuntimeConfigDialog } from "@/components/workspace/channels/channel-runtime-config-dialog"; +import { WechatQRLogin } from "@/components/workspace/channels/wechat-qr-login"; +import { + cancelWechatQRLogin, + connectChannelProvider, + listChannelConnections, + listChannelProviders, + pollWechatQRLogin, + startWechatQRLogin, +} from "@/core/channels/api"; +import type { + ChannelProvider, + WechatQRLoginSession, +} from "@/core/channels/types"; +import { I18nProvider } from "@/core/i18n/context"; + +const session: WechatQRLoginSession = { + id: "session-1", + status: "pending", + qrcode_content: "https://example.com/scan", + expires_in: 180, + provider: null, +}; +const provider = { provider: "wechat", configured: true } as ChannelProvider; +function mount(onConfigured = rs.fn()) { + return { + onConfigured, + ...render( + + + + + , + ), + }; +} + +beforeEach(() => { + rs.mocked(startWechatQRLogin).mockResolvedValue(session); + rs.mocked(pollWechatQRLogin).mockResolvedValue({ + ...session, + status: "expired", + }); + rs.mocked(cancelWechatQRLogin).mockResolvedValue(undefined); +}); +afterEach(() => { + cleanup(); + rs.useRealTimers(); + rs.resetAllMocks(); +}); + +describe("WeChat QR login", () => { + it("renders a local QR code and offers retry after expiry", async () => { + mount(); + expect(await screen.findByTitle("WeChat login QR code")).toBeTruthy(); + expect( + await screen.findByText( + "This QR code has expired. Generate a new one.", + {}, + { timeout: 4000 }, + ), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Refresh QR code" })); + await waitFor(() => expect(startWechatQRLogin).toHaveBeenCalledTimes(2)); + expect(cancelWechatQRLogin).toHaveBeenCalledWith("session-1"); + }); + + it("continues with the configured provider once confirmation succeeds", async () => { + rs.mocked(pollWechatQRLogin).mockResolvedValue({ + ...session, + status: "confirmed", + provider, + }); + const { onConfigured } = mount(); + await waitFor(() => expect(onConfigured).toHaveBeenCalledWith(provider), { + timeout: 4000, + }); + expect(onConfigured).toHaveBeenCalledTimes(1); + }); + + it("cancels a start response arriving after the dialog closes", async () => { + let resolve!: (session: WechatQRLoginSession) => void; + rs.mocked(startWechatQRLogin).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const { unmount, onConfigured } = mount(); + await waitFor(() => expect(startWechatQRLogin).toHaveBeenCalled()); + unmount(); + await act(async () => { + resolve(session); + }); + expect(cancelWechatQRLogin).toHaveBeenCalledWith("session-1"); + expect(pollWechatQRLogin).not.toHaveBeenCalled(); + expect(onConfigured).not.toHaveBeenCalled(); + }); +}); + +function mountDialog( + configured = false, + initialStep: "setup" | "binding" = "setup", +) { + const onSubmit = rs.fn(); + render( + + + + + , + ); + return { onSubmit }; +} + +it("keeps the token draft while switching methods and cancels the QR session", async () => { + mountDialog(); + expect( + screen + .getByRole("tab", { name: "Scan QR code" }) + .getAttribute("aria-selected"), + ).toBe("true"); + await screen.findByTitle("WeChat login QR code"); + fireEvent.mouseDown(screen.getByRole("tab", { name: "Use token" }), { + button: 0, + ctrlKey: false, + }); + const input = screen.getByLabelText("Bot token"); + fireEvent.change(input, { target: { value: "draft-token" } }); + await waitFor(() => + expect(cancelWechatQRLogin).toHaveBeenCalledWith("session-1"), + ); + fireEvent.mouseDown(screen.getByRole("tab", { name: "Scan QR code" }), { + button: 0, + ctrlKey: false, + }); + await waitFor(() => expect(startWechatQRLogin).toHaveBeenCalledTimes(2)); + fireEvent.mouseDown(screen.getByRole("tab", { name: "Use token" }), { + button: 0, + ctrlKey: false, + }); + expect(screen.getByLabelText("Bot token").value).toBe( + "draft-token", + ); +}); + +it("opens existing credentials in the token tab without starting QR login", () => { + const { onSubmit } = mountDialog(true); + expect( + screen + .getByRole("tab", { name: "Use token" }) + .getAttribute("aria-selected"), + ).toBe("true"); + expect(screen.getByLabelText("Bot token").value).toBe( + "********", + ); + expect(startWechatQRLogin).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ provider: "wechat" }), + { bot_token: "********" }, + ); +}); + +it("does not create competing sessions during Strict Mode's effect replay", async () => { + render( + + + + + + + , + ); + await screen.findByTitle("WeChat login QR code"); + expect(startWechatQRLogin).toHaveBeenCalledTimes(1); + expect(cancelWechatQRLogin).not.toHaveBeenCalled(); +}); + +it("asks for the phone pairing code and shows a rejected code inline", async () => { + rs.mocked(pollWechatQRLogin) + .mockResolvedValueOnce({ ...session, status: "verification_required" }) + .mockResolvedValueOnce({ + ...session, + status: "verification_required", + error: "verification_rejected", + }) + .mockResolvedValueOnce({ ...session, status: "confirmed", provider }); + const { onConfigured } = mount(); + const input = await screen.findByLabelText( + "Pairing code", + {}, + { timeout: 4000 }, + ); + fireEvent.change(input, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Continue connecting" })); + expect( + await screen.findByText( + "The code did not match. Check the digits on your phone and try again.", + ), + ).toBeTruthy(); + expect(pollWechatQRLogin).toHaveBeenLastCalledWith( + "session-1", + expect.any(AbortSignal), + "123456", + ); + fireEvent.change(input, { target: { value: "654321" } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1)); +}); + +it("keeps the same QR while automatically retrying temporary network errors", async () => { + rs.mocked(pollWechatQRLogin) + .mockResolvedValueOnce({ ...session, error: "network" }) + .mockResolvedValueOnce({ ...session, status: "confirmed", provider }); + const { onConfigured } = mount(); + await screen.findByText( + "WeChat is temporarily unreachable. Retrying automatically…", + {}, + { timeout: 4000 }, + ); + expect(screen.getByTitle("WeChat login QR code")).toBeTruthy(); + await waitFor(() => expect(onConfigured).toHaveBeenCalledTimes(1), { + timeout: 4000, + }); + expect(startWechatQRLogin).toHaveBeenCalledTimes(1); +}); + +it.each([ + { status: "confirmed", provider }, + { status: "verification_required", error: "verification_rejected" }, + { status: "expired" }, + { status: "failed", error: "verification_blocked" }, +] satisfies Partial[])( + "keeps polling a submitted pairing code until $status", + async (outcome) => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + rs.mocked(pollWechatQRLogin) + .mockResolvedValueOnce({ ...session, status: "verification_required" }) + // Backend wait/redirect responses after submission normalize to scanned. + .mockResolvedValueOnce({ ...session, status: "scanned" }) + .mockResolvedValueOnce({ + ...session, + status: "scanned", + error: "network", + }) + .mockResolvedValueOnce({ ...session, status: "scanned" }) + .mockResolvedValueOnce({ ...session, ...outcome }); + const { onConfigured } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1501)); + fireEvent.change(screen.getByLabelText("Pairing code"), { + target: { value: "123456" }, + }); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Continue connecting" }), + ); + }); + expect(pollWechatQRLogin).toHaveBeenNthCalledWith( + 2, + "session-1", + expect.any(AbortSignal), + "123456", + ); + expect(screen.queryByLabelText("Pairing code")).toBeNull(); + expect(onConfigured).not.toHaveBeenCalled(); + + for (let call = 3; call <= 5; call++) { + await act(() => rs.advanceTimersByTimeAsync(1500)); + // The server retains the submitted code; the browser need not resend it. + expect(pollWechatQRLogin).toHaveBeenNthCalledWith( + call, + "session-1", + expect.any(AbortSignal), + undefined, + ); + } + if (outcome.status === "confirmed") { + expect(onConfigured).toHaveBeenCalledExactlyOnceWith(provider); + } else { + expect(onConfigured).not.toHaveBeenCalled(); + if (outcome.status === "verification_required") { + expect( + screen.getByLabelText("Pairing code").value, + ).toBe(""); + expect( + screen.getByText( + "The code did not match. Check the digits on your phone and try again.", + ), + ).toBeTruthy(); + } else { + expect( + screen.getByRole("button", { name: "Refresh QR code" }), + ).toBeTruthy(); + } + } + await act(() => rs.advanceTimersByTimeAsync(5000)); + expect(pollWechatQRLogin).toHaveBeenCalledTimes(5); + expect(startWechatQRLogin).toHaveBeenCalledTimes(1); + }, +); + +it("restarts scanning while keeping the unexpired phone command and ignoring an old poll", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [], + }); + rs.mocked(connectChannelProvider).mockResolvedValue({ + provider: "wechat", + mode: "binding_code", + code: "copied-to-phone", + instruction: "Send /connect copied-to-phone", + expires_in: 600, + }); + let resolveOldPoll!: (connections: []) => void; + rs.mocked(listChannelConnections) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOldPoll = resolve; + }), + ) + .mockResolvedValue([]); + rs.mocked(startWechatQRLogin) + .mockResolvedValueOnce(session) + .mockResolvedValueOnce({ + ...session, + id: "new-session", + qrcode_content: "https://example.com/new-scan", + }); + rs.mocked(pollWechatQRLogin) + .mockResolvedValueOnce({ ...session, status: "confirmed", provider }) + .mockResolvedValue({ ...session, id: "new-session", status: "pending" }); + mountDialog(); + await act(() => rs.advanceTimersByTimeAsync(1)); + await act(() => rs.advanceTimersByTimeAsync(1500)); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect copied-to-phone")).toBeTruthy(); + await act(() => rs.advanceTimersByTimeAsync(2000)); + expect(listChannelConnections).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Scan again" })); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByTitle("WeChat login QR code")).toBeTruthy(); + expect(startWechatQRLogin).toHaveBeenCalledTimes(2); + expect(screen.queryByText("/connect copied-to-phone")).toBeNull(); + await act(async () => { + resolveOldPoll([]); + }); + await act(() => rs.advanceTimersByTimeAsync(2500)); + expect(listChannelConnections).toHaveBeenCalledTimes(1); + expect(screen.getByTitle("WeChat login QR code")).toBeTruthy(); + rs.mocked(pollWechatQRLogin).mockResolvedValue({ + ...session, + id: "new-session", + status: "confirmed", + provider, + }); + await act(() => rs.advanceTimersByTimeAsync(1500)); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect copied-to-phone")).toBeTruthy(); + expect(connectChannelProvider).toHaveBeenCalledTimes(1); +}); + +it("opens an existing WeChat configuration at binding with a route back to QR", async () => { + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [], + }); + rs.mocked(connectChannelProvider).mockResolvedValue({ + provider: "wechat", + mode: "binding_code", + code: "existing-demo", + instruction: "Send command", + expires_in: 600, + }); + mountDialog(true, "binding"); + await screen.findByText("/connect existing-demo"); + expect(startWechatQRLogin).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Scan again" })); + await screen.findByTitle("WeChat login QR code"); +}); + +it("continues manual token saving inside the binding dialog", async () => { + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [], + }); + rs.mocked(connectChannelProvider).mockResolvedValue({ + provider: "wechat", + mode: "binding_code", + code: "manual-demo", + instruction: "Send command", + expires_in: 600, + }); + const { onSubmit } = mountDialog(true); + onSubmit.mockResolvedValue(provider); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("/connect manual-demo"); + expect(screen.getByRole("button", { name: "Scan again" })).toBeTruthy(); +}); + +it("ignores a manual save result after the user closes the dialog", async () => { + const { onSubmit } = mountDialog(true); + let finish!: (provider: ChannelProvider) => void; + onSubmit.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => { + finish(provider); + }); + expect(connectChannelProvider).not.toHaveBeenCalled(); + expect(screen.queryByText("Token saved securely")).toBeNull(); +});