feat(channels): support WeChat QR login from the web UI (#5582)

* feat(channels): add WeChat QR login and binding recovery

* fix(channels): enforce single-worker WeChat QR login and preserve bot ID

Reject QR login endpoints when multiple Gateway workers are configured, while keeping manual token setup available.

Preserve the configured bot ID when the provider omits it or returns an empty value. Add regression coverage for worker guards and credential persistence.

* fix(channels): sync WeChat completion state on provider updates

Show the connected step when refreshed provider data confirms the binding, so the dialog no longer waits indefinitely after polling is cancelled.

Add regression tests for provider updates during pending poll and binding requests, including late responses and expiry.

* fix(channels): preserve WeChat pairing codes across waits and redirects

---------

Co-authored-by: YxinMiracle <“939157765@qq.com”>
This commit is contained in:
YxinMiracle 2026-09-20 17:21:49 +08:00 committed by GitHub
parent 69286297fd
commit 0b7cef2e0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3046 additions and 93 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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"})

View File

@ -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()

View File

@ -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()

View File

@ -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",

View File

@ -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: {}

View File

@ -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<ChannelProvider | void>;
};
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<ChannelProvider | null>(null);
const [pendingBinding, setPendingBinding] =
useState<PendingWechatBinding | null>(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<ChannelRuntimeConfigValues>({});
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<HTMLFormElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>
{isEditing
? t.channels.setupEditTitle(provider.display_name)
: t.channels.setupTitle(provider.display_name)}
</DialogTitle>
<DialogDescription>{t.channels.setupDescription}</DialogDescription>
</DialogHeader>
const credentialInputs = (
<div className="space-y-4">
{fields.map((field) => {
const inputId = `channel-${provider.provider}-${field.name}`;
const isSecretField = field.type === "password";
return (
<div key={field.name} className="space-y-2">
<label
htmlFor={inputId}
className="text-sm leading-none font-medium"
>
{field.label}
</label>
<Input
id={inputId}
type="text"
value={values[field.name] ?? ""}
required={field.required}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
className={cn(hasWechatQR && "h-11 rounded-lg")}
placeholder={
hasWechatQR ? t.channels.wechatQr.tokenPlaceholder : undefined
}
data-1p-ignore={isSecretField ? "true" : undefined}
data-bwignore={isSecretField ? "true" : undefined}
data-form-type={isSecretField ? "other" : undefined}
data-lpignore={isSecretField ? "true" : undefined}
style={isSecretField ? SECRET_INPUT_STYLE : undefined}
onChange={(event) => {
setValues((current) => ({
...current,
[field.name]: event.target.value,
}));
}}
/>
</div>
);
})}
</div>
);
<div className="space-y-3">
{fields.map((field) => {
const inputId = `channel-${provider.provider}-${field.name}`;
const isSecretField = field.type === "password";
return (
<div key={field.name} className="space-y-1.5">
<label
htmlFor={inputId}
className="text-sm leading-none font-medium"
>
{field.label}
</label>
<Input
id={inputId}
type="text"
value={values[field.name] ?? ""}
required={field.required}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
data-1p-ignore={isSecretField ? "true" : undefined}
data-bwignore={isSecretField ? "true" : undefined}
data-form-type={isSecretField ? "other" : undefined}
data-lpignore={isSecretField ? "true" : undefined}
style={isSecretField ? SECRET_INPUT_STYLE : undefined}
onChange={(event) => {
setValues((current) => ({
...current,
[field.name]: event.target.value,
}));
}}
/>
</div>
);
})}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
hasWechatQR &&
"max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-2xl sm:max-w-[440px]",
)}
>
<form
onSubmit={handleSubmit}
className={cn("space-y-4", hasWechatQR && "space-y-5")}
>
<div className={cn(hasWechatQR && "flex items-center gap-3 pr-4")}>
{hasWechatQR ? (
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
<ChannelProviderIcon provider="wechat" className="size-7" />
</div>
) : null}
<DialogHeader className={cn(hasWechatQR && "gap-1 text-left")}>
<DialogTitle>
{isEditing && !completionProvider
? t.channels.setupEditTitle(provider.display_name)
: t.channels.setupTitle(provider.display_name)}
</DialogTitle>
<DialogDescription
className={cn(hasWechatQR && "text-xs leading-relaxed")}
>
{hasWechatQR
? t.channels.wechatQr.description
: t.channels.setupDescription}
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={() => onOpenChange(false)}
{hasWechatQR && completionProvider ? (
<WechatQRCompletion
provider={completionProvider}
onDone={() => onConfigured?.(completionProvider)}
bindingToResume={pendingBinding}
onRestart={(binding) => {
setPendingBinding(binding);
setStep("setup");
setMethod("qr");
setConfiguredProvider(null);
}}
/>
) : hasWechatQR ? (
<Tabs
value={selectedMethod}
onValueChange={(value) => {
if (value === "qr" || value === "token") setMethod(value);
}}
className="gap-0"
>
{t.common.cancel}
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
<TabsList
aria-label={t.channels.wechatQr.methodLabel}
className="grid w-full grid-cols-2"
>
<TabsTrigger value="qr" disabled={submitting}>
<QrCodeIcon />
{t.channels.wechatQr.login}
</TabsTrigger>
<TabsTrigger value="token" disabled={submitting}>
<KeyRoundIcon />
{t.channels.wechatQr.manual}
</TabsTrigger>
</TabsList>
<TabsContent value="qr" className="pt-5">
{showQR && open ? (
<WechatQRLogin onConfigured={setConfiguredProvider} />
) : null}
</TabsContent>
<TabsContent value="token" className="pt-5">
<div className="flex min-h-[328px] flex-col justify-center gap-6 pb-5">
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t.channels.wechatQr.tokenTitle}
</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
{t.channels.wechatQr.tokenDescription}
</p>
</div>
{credentialInputs}
<p className="text-muted-foreground text-xs leading-relaxed">
{t.channels.wechatQr.tokenHint}
</p>
</div>
</TabsContent>
</Tabs>
) : (
credentialInputs
)}
{hasWechatQR ? (
<p className="text-muted-foreground flex items-center justify-center gap-1.5 text-xs">
<ShieldCheckIcon className="size-3.5" />
{t.channels.wechatQr.privacy}
</p>
) : null}
{!completionProvider && (
<DialogFooter className={cn(hasWechatQR && "border-t pt-4")}>
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={() => handleOpenChange(false)}
>
{t.common.cancel}
</Button>
{!showQR ? (
<Button type="submit" disabled={submitting}>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
) : null}
{isEditing
? t.channels.saveChanges
: t.channels.saveAndConnect}
</Button>
) : null}
{isEditing ? t.channels.saveChanges : t.channels.saveAndConnect}
</Button>
</DialogFooter>
</DialogFooter>
)}
</form>
</DialogContent>
</Dialog>

View File

@ -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<PendingWechatBinding | null>(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<typeof setTimeout> | 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 (
<div className="flex min-h-[328px] flex-col gap-5">
<div className="flex items-start gap-3 rounded-xl bg-emerald-500/5 p-4">
<span className="rounded-full bg-emerald-500/10 p-1.5 text-emerald-600">
<CheckIcon className="size-4" />
</span>
<div className="space-y-1">
<p className="text-sm font-medium">{text.saved}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{text.savedDescription}
</p>
</div>
</div>
<div
role="status"
aria-live="polite"
className="flex flex-1 flex-col justify-center gap-3 text-center"
>
{stage === "connected" ? (
<>
<div className="mx-auto rounded-full bg-emerald-500/10 p-4 text-emerald-600">
<CheckIcon className="size-8" />
</div>
<p className="font-medium">{text.connectedTitle}</p>
<p className="text-muted-foreground text-sm">
{text.connectedDescription}
</p>
</>
) : stage === "binding" ? (
<>
<p className="text-sm font-medium">{text.bindTitle}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{text.bindDescription}
</p>
<code className="bg-muted rounded-lg border p-3 text-sm break-all select-all">
{command}
</code>
<Button type="button" variant="outline" onClick={() => void copy()}>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? text.copied : text.copyCommand}
</Button>
{copyFailed && (
<p className="text-destructive text-xs">{text.copyFailed}</p>
)}
<p className="text-muted-foreground flex items-center justify-center gap-2 text-xs">
<LoaderCircleIcon className="size-3 animate-spin" />
{text.bindWaiting}
</p>
</>
) : stage === "loading" ? (
<>
<LoaderCircleIcon className="mx-auto size-5 animate-spin" />
<p className="text-muted-foreground text-sm">{text.bindLoading}</p>
</>
) : (
<>
<p className="text-muted-foreground text-sm">
{stage === "expired" ? text.bindExpired : text.bindFailed}
</p>
<Button
type="button"
variant="outline"
onClick={() => setAttempt((value) => value + 1)}
>
{text.bindRetry}
</Button>
</>
)}
</div>
{stage !== "connected" && (
<div className="space-y-2 border-t pt-3 text-center">
<p className="text-muted-foreground text-xs leading-relaxed">
{text.restartHint}
</p>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={() => onRestart(binding ?? bindingToResume ?? null)}
>
<QrCodeIcon />
{text.restart}
</Button>
{stage === "binding" && (
<p className="text-muted-foreground text-xs leading-relaxed">
{text.restartKeepCommand}
</p>
)}
</div>
)}
<Button
type="button"
variant={stage === "connected" ? "default" : "outline"}
onClick={onDone}
>
{stage === "connected" ? text.done : t.common.close}
</Button>
</div>
);
}

View File

@ -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<WechatQRLoginSession | null>(null);
const [error, setError] = useState<string | null>(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<typeof setTimeout> | undefined;
let expiryTimer: ReturnType<typeof setTimeout> | 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 (
<div className="flex min-h-[328px] flex-col items-center gap-4">
<div className="relative flex size-[232px] shrink-0 items-center justify-center overflow-hidden rounded-2xl border bg-white p-3 shadow-xs">
{session && !ended && !scanned && !verificationRequired ? (
<QRCodeSVG
value={session.qrcode_content}
size={208}
marginSize={4}
level="M"
title={t.channels.wechatQr.imageTitle}
/>
) : null}
{ended ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 rounded-lg bg-neutral-50 text-neutral-600">
<CircleAlertIcon
className="size-8 stroke-[1.5]"
aria-hidden="true"
/>
<Button
type="button"
variant="outline"
className="bg-white text-neutral-900"
onClick={() => setAttempt((value) => value + 1)}
>
<RefreshCwIcon />
{t.channels.wechatQr.retry}
</Button>
</div>
) : verificationRequired ? (
<div className="flex w-full flex-col gap-3 text-neutral-900">
<SmartphoneIcon className="mx-auto size-8 text-emerald-600" />
<label
htmlFor="wechat-pairing-code"
className="text-center text-sm font-medium"
>
{t.channels.wechatQr.verifyLabel}
</label>
<Input
id="wechat-pairing-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={16}
value={verifyCode}
disabled={verifying}
className="text-center font-mono tracking-widest"
onChange={(event) =>
setVerifyCode(event.target.value.replace(/[^0-9]/g, ""))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (verifyCode && !verifying)
submitCodeRef.current(verifyCode);
}
}}
/>
<Button
type="button"
disabled={!verifyCode || verifying}
onClick={() => submitCodeRef.current(verifyCode)}
>
{verifying
? t.channels.wechatQr.verifying
: t.channels.wechatQr.verifySubmit}
</Button>
</div>
) : scanned ? (
<div className="flex flex-col items-center gap-3 text-emerald-600">
<div className="relative rounded-full bg-emerald-50 p-5">
<SmartphoneIcon className="size-10 stroke-[1.5]" />
<span className="absolute -right-1 bottom-0 rounded-full bg-emerald-600 p-1 text-white">
<CheckIcon className="size-4" />
</span>
</div>
</div>
) : !session ? (
<LoaderCircleIcon
className="size-6 animate-spin text-neutral-400"
aria-label={t.channels.wechatQr.loading}
/>
) : null}
</div>
<div
role="status"
aria-live="polite"
className="w-full space-y-1.5 text-center"
>
<p className="flex items-center justify-center gap-2 text-sm font-medium">
{session && !ended ? (
<span
className="size-1.5 rounded-full bg-emerald-500"
aria-hidden="true"
/>
) : null}
{title}
</p>
<p className="text-muted-foreground mx-auto max-w-[300px] text-xs leading-relaxed break-words">
{description}
</p>
{!ended && (
<p className="text-muted-foreground text-xs">
{t.channels.wechatQr.autoSave}
</p>
)}
</div>
</div>
);
}

View File

@ -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<ChannelProvider | null>(
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() {
})}
</SidebarMenu>
<ChannelRuntimeConfigDialog
onConfigured={() => {
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);

View File

@ -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 ? (
<LoaderCircleIcon className="animate-spin" />
@ -251,7 +262,7 @@ function ChannelProviderItem({
variant="outline"
size="sm"
disabled={isConnecting || isDisconnecting}
onClick={() => setSetupOpen(true)}
onClick={openSetup}
>
{t.channels.modify}
</Button>
@ -263,7 +274,7 @@ function ChannelProviderItem({
title={unavailableReason}
onClick={() => {
if (providerNeedsRuntimeConfig(provider)) {
setSetupOpen(true);
openSetup();
return;
}
@ -289,7 +300,11 @@ function ChannelProviderItem({
</ItemActions>
</Item>
<ChannelRuntimeConfigDialog
onConfigured={() => {
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);

View File

@ -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<ChannelProvider>;
}
export async function startWechatQRLogin(): Promise<WechatQRLoginSession> {
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<WechatQRLoginSession>;
}
export async function pollWechatQRLogin(
id: string,
signal?: AbortSignal,
verifyCode?: string,
): Promise<WechatQRLoginSession> {
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<WechatQRLoginSession>;
}
export async function cancelWechatQRLogin(id: string): Promise<void> {
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");
}

View File

@ -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;
};

View File

@ -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",

View File

@ -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<string, string>;

View File

@ -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: "保存并连接",

View File

@ -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);
});
}
}

View File

@ -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" }),
},
);
});

View File

@ -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) => (
<StrictMode>
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en-US">
<WechatQRCompletion
provider={
{
provider: "wechat",
connection_status: connected ? "connected" : "not_connected",
} as ChannelProvider
}
onDone={onDone}
onRestart={rs.fn()}
bindingToResume={bindingToResume}
/>
</I18nProvider>
</QueryClientProvider>
</StrictMode>
);
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);
});

View File

@ -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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en-US">
<WechatQRLogin onConfigured={onConfigured} />
</I18nProvider>
</QueryClientProvider>,
),
};
}
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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en-US">
<ChannelRuntimeConfigDialog
provider={{
...provider,
display_name: "WeChat",
configured,
credential_fields: [
{
name: "bot_token",
label: "Bot token",
type: "password",
required: true,
},
],
credential_values: configured ? { bot_token: "********" } : {},
}}
open
initialStep={initialStep}
submitting={false}
onOpenChange={rs.fn()}
onConfigured={rs.fn()}
onSubmit={onSubmit}
/>
</I18nProvider>
</QueryClientProvider>,
);
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<HTMLInputElement>("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<HTMLInputElement>("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(
<StrictMode>
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en-US">
<WechatQRLogin onConfigured={rs.fn()} />
</I18nProvider>
</QueryClientProvider>
</StrictMode>,
);
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<WechatQRLoginSession>[])(
"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<HTMLInputElement>("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<ChannelProvider>((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();
});