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(); +});