mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
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:
parent
69286297fd
commit
0b7cef2e0b
@ -767,11 +767,18 @@ class WechatChannel(Channel):
|
|||||||
return False
|
return False
|
||||||
return bool(auth_state.get("bot_token"))
|
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]:
|
async def _bind_via_qrcode(self) -> dict[str, Any]:
|
||||||
qrcode_data = await self._request_public_get_json(
|
qrcode_data = await self.request_login_qrcode()
|
||||||
"/ilink/bot/get_bot_qrcode",
|
|
||||||
params={"bot_type": self._qrcode_bot_type},
|
|
||||||
)
|
|
||||||
qrcode = str(qrcode_data.get("qrcode") or "").strip()
|
qrcode = str(qrcode_data.get("qrcode") or "").strip()
|
||||||
if not qrcode:
|
if not qrcode:
|
||||||
raise RuntimeError("iLink get_bot_qrcode did not return 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)
|
deadline = time.monotonic() + max(self._qrcode_poll_timeout, 1.0)
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
status_data = await self._request_public_get_json(
|
status_data = await self.request_login_status(qrcode)
|
||||||
"/ilink/bot/get_qrcode_status",
|
|
||||||
params={"qrcode": qrcode},
|
|
||||||
)
|
|
||||||
status = str(status_data.get("status") or "").strip().lower()
|
status = str(status_data.get("status") or "").strip().lower()
|
||||||
if status == "confirmed":
|
if status == "confirmed":
|
||||||
token = str(status_data.get("bot_token") or "").strip()
|
token = str(status_data.get("bot_token") or "").strip()
|
||||||
|
|||||||
221
backend/app/channels/wechat_qr_login.py
Normal file
221
backend/app/channels/wechat_qr_login.py
Normal 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()
|
||||||
@ -4,9 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@ -16,6 +17,7 @@ from app.channels.runtime_config_store import (
|
|||||||
apply_runtime_connection_config,
|
apply_runtime_connection_config,
|
||||||
merge_runtime_channel_configs,
|
merge_runtime_channel_configs,
|
||||||
)
|
)
|
||||||
|
from app.channels.wechat_qr_login import QRLoginError, WechatQRLogin
|
||||||
from app.gateway.deps import require_admin_user
|
from app.gateway.deps import require_admin_user
|
||||||
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
|
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
|
||||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
||||||
@ -80,6 +82,19 @@ class ChannelConnectResponse(BaseModel):
|
|||||||
expires_in: int
|
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):
|
class ChannelRuntimeConfigRequest(BaseModel):
|
||||||
values: dict[str, str] = Field(default_factory=dict)
|
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)
|
@router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse)
|
||||||
async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse:
|
async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse:
|
||||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
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)
|
config = await _get_channel_connections_config(request)
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
||||||
@ -656,6 +678,13 @@ async def configure_channel_provider_runtime(
|
|||||||
request: Request,
|
request: Request,
|
||||||
) -> ChannelProviderResponse:
|
) -> ChannelProviderResponse:
|
||||||
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
|
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)
|
config = await _get_channel_connections_config(request)
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
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().
|
# cached by get_app_config().
|
||||||
runtime_config["bot_username"] = values["bot_username"]
|
runtime_config["bot_username"] = values["bot_username"]
|
||||||
|
|
||||||
candidate_channels_config = dict(channels_config)
|
return await _apply_runtime_channel_config(request, config, provider, runtime_config)
|
||||||
candidate_channels_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)
|
started = await _restart_runtime_channel_if_available(provider, runtime_config)
|
||||||
if started is False:
|
if started is False:
|
||||||
display_name = _PROVIDER_META[provider]["display_name"]
|
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
|
request.app.state.channels_config = live_channels_config
|
||||||
|
|
||||||
return _provider_response(config, live_channels_config, provider, _PROVIDER_META[provider])
|
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"})
|
||||||
|
|||||||
@ -22,6 +22,8 @@ from deerflow.config.channel_connections_config import ChannelConnectionsConfig
|
|||||||
def _stub_app_config(monkeypatch):
|
def _stub_app_config(monkeypatch):
|
||||||
"""Keep router tests independent from a developer-local config.yaml."""
|
"""Keep router tests independent from a developer-local config.yaml."""
|
||||||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "0")
|
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"}}))
|
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
|
||||||
yield
|
yield
|
||||||
reset_app_config()
|
reset_app_config()
|
||||||
@ -1222,3 +1224,182 @@ def test_disconnect_connection_is_current_user_scoped(tmp_path):
|
|||||||
assert anyio.run(get_connection_status) == "connected"
|
assert anyio.run(get_connection_status) == "connected"
|
||||||
|
|
||||||
anyio.run(repo.close)
|
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()
|
||||||
|
|||||||
394
backend/tests/test_wechat_qr_login.py
Normal file
394
backend/tests/test_wechat_qr_login.py
Normal 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()
|
||||||
@ -84,6 +84,7 @@
|
|||||||
"nuxt-og-image": "^5.1.13",
|
"nuxt-og-image": "^5.1.13",
|
||||||
"ogl": "^1.0.11",
|
"ogl": "^1.0.11",
|
||||||
"papaparse": "5.7.0",
|
"papaparse": "5.7.0",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
|
|||||||
12
frontend/pnpm-lock.yaml
generated
12
frontend/pnpm-lock.yaml
generated
@ -200,6 +200,9 @@ importers:
|
|||||||
papaparse:
|
papaparse:
|
||||||
specifier: 5.7.0
|
specifier: 5.7.0
|
||||||
version: 5.7.0
|
version: 5.7.0
|
||||||
|
qrcode.react:
|
||||||
|
specifier: 4.2.0
|
||||||
|
version: 4.2.0(react@19.2.4)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@ -5063,6 +5066,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
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:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@ -11445,6 +11453,10 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
radix3@1.1.2: {}
|
radix3@1.1.2: {}
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LoaderCircleIcon } from "lucide-react";
|
import {
|
||||||
|
KeyRoundIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
QrCodeIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
@ -19,30 +25,36 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type {
|
import type {
|
||||||
ChannelProvider,
|
ChannelProvider,
|
||||||
ChannelRuntimeConfigValues,
|
ChannelRuntimeConfigValues,
|
||||||
} from "@/core/channels/types";
|
} from "@/core/channels/types";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 = {
|
type ChannelRuntimeConfigDialogProps = {
|
||||||
|
onConfigured?: (provider: ChannelProvider) => void;
|
||||||
provider: ChannelProvider | null;
|
provider: ChannelProvider | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
|
initialStep?: "setup" | "binding";
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
provider: ChannelProvider,
|
provider: ChannelProvider,
|
||||||
values: ChannelRuntimeConfigValues,
|
values: ChannelRuntimeConfigValues,
|
||||||
) => void;
|
) => void | Promise<ChannelProvider | void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SecretInputStyle = CSSProperties & {
|
type SecretInputStyle = CSSProperties & { WebkitTextSecurity?: "disc" };
|
||||||
WebkitTextSecurity?: "disc";
|
const SECRET_INPUT_STYLE: SecretInputStyle = { WebkitTextSecurity: "disc" };
|
||||||
};
|
|
||||||
|
|
||||||
const SECRET_INPUT_STYLE: SecretInputStyle = {
|
|
||||||
WebkitTextSecurity: "disc",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ChannelRuntimeConfigDialog({
|
export function ChannelRuntimeConfigDialog({
|
||||||
provider,
|
provider,
|
||||||
@ -50,8 +62,27 @@ export function ChannelRuntimeConfigDialog({
|
|||||||
submitting,
|
submitting,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onConfigured,
|
||||||
|
initialStep = "setup",
|
||||||
}: ChannelRuntimeConfigDialogProps) {
|
}: ChannelRuntimeConfigDialogProps) {
|
||||||
const { t } = useI18n();
|
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 [values, setValues] = useState<ChannelRuntimeConfigValues>({});
|
||||||
const fields = useMemo(
|
const fields = useMemo(
|
||||||
() => provider?.credential_fields ?? [],
|
() => provider?.credential_fields ?? [],
|
||||||
@ -74,84 +105,198 @@ export function ChannelRuntimeConfigDialog({
|
|||||||
);
|
);
|
||||||
}, [credentialValues, fields, open, provider]);
|
}, [credentialValues, fields, open, provider]);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEditing = provider.configured;
|
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>) => {
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
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 (
|
const credentialInputs = (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<div className="space-y-4">
|
||||||
<DialogContent>
|
{fields.map((field) => {
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
const inputId = `channel-${provider.provider}-${field.name}`;
|
||||||
<DialogHeader>
|
const isSecretField = field.type === "password";
|
||||||
<DialogTitle>
|
return (
|
||||||
{isEditing
|
<div key={field.name} className="space-y-2">
|
||||||
? t.channels.setupEditTitle(provider.display_name)
|
<label
|
||||||
: t.channels.setupTitle(provider.display_name)}
|
htmlFor={inputId}
|
||||||
</DialogTitle>
|
className="text-sm leading-none font-medium"
|
||||||
<DialogDescription>{t.channels.setupDescription}</DialogDescription>
|
>
|
||||||
</DialogHeader>
|
{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">
|
return (
|
||||||
{fields.map((field) => {
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
const inputId = `channel-${provider.provider}-${field.name}`;
|
<DialogContent
|
||||||
const isSecretField = field.type === "password";
|
className={cn(
|
||||||
return (
|
hasWechatQR &&
|
||||||
<div key={field.name} className="space-y-1.5">
|
"max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-2xl sm:max-w-[440px]",
|
||||||
<label
|
)}
|
||||||
htmlFor={inputId}
|
>
|
||||||
className="text-sm leading-none font-medium"
|
<form
|
||||||
>
|
onSubmit={handleSubmit}
|
||||||
{field.label}
|
className={cn("space-y-4", hasWechatQR && "space-y-5")}
|
||||||
</label>
|
>
|
||||||
<Input
|
<div className={cn(hasWechatQR && "flex items-center gap-3 pr-4")}>
|
||||||
id={inputId}
|
{hasWechatQR ? (
|
||||||
type="text"
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||||
value={values[field.name] ?? ""}
|
<ChannelProviderIcon provider="wechat" className="size-7" />
|
||||||
required={field.required}
|
</div>
|
||||||
autoComplete="off"
|
) : null}
|
||||||
autoCorrect="off"
|
<DialogHeader className={cn(hasWechatQR && "gap-1 text-left")}>
|
||||||
autoCapitalize="none"
|
<DialogTitle>
|
||||||
spellCheck={false}
|
{isEditing && !completionProvider
|
||||||
data-1p-ignore={isSecretField ? "true" : undefined}
|
? t.channels.setupEditTitle(provider.display_name)
|
||||||
data-bwignore={isSecretField ? "true" : undefined}
|
: t.channels.setupTitle(provider.display_name)}
|
||||||
data-form-type={isSecretField ? "other" : undefined}
|
</DialogTitle>
|
||||||
data-lpignore={isSecretField ? "true" : undefined}
|
<DialogDescription
|
||||||
style={isSecretField ? SECRET_INPUT_STYLE : undefined}
|
className={cn(hasWechatQR && "text-xs leading-relaxed")}
|
||||||
onChange={(event) => {
|
>
|
||||||
setValues((current) => ({
|
{hasWechatQR
|
||||||
...current,
|
? t.channels.wechatQr.description
|
||||||
[field.name]: event.target.value,
|
: t.channels.setupDescription}
|
||||||
}));
|
</DialogDescription>
|
||||||
}}
|
</DialogHeader>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
{hasWechatQR && completionProvider ? (
|
||||||
<Button
|
<WechatQRCompletion
|
||||||
type="button"
|
provider={completionProvider}
|
||||||
variant="outline"
|
onDone={() => onConfigured?.(completionProvider)}
|
||||||
disabled={submitting}
|
bindingToResume={pendingBinding}
|
||||||
onClick={() => onOpenChange(false)}
|
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}
|
<TabsList
|
||||||
</Button>
|
aria-label={t.channels.wechatQr.methodLabel}
|
||||||
<Button type="submit" disabled={submitting}>
|
className="grid w-full grid-cols-2"
|
||||||
{submitting ? (
|
>
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<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}
|
) : null}
|
||||||
{isEditing ? t.channels.saveChanges : t.channels.saveAndConnect}
|
</DialogFooter>
|
||||||
</Button>
|
)}
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
frontend/src/components/workspace/channels/wechat-qr-login.tsx
Normal file
292
frontend/src/components/workspace/channels/wechat-qr-login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -57,6 +57,7 @@ export function WorkspaceChannelsList() {
|
|||||||
const { enabled, providers, isLoading, error } = useChannelProviders();
|
const { enabled, providers, isLoading, error } = useChannelProviders();
|
||||||
const connectMutation = useConnectChannelProvider();
|
const connectMutation = useConnectChannelProvider();
|
||||||
const configureMutation = useConfigureChannelProvider();
|
const configureMutation = useConfigureChannelProvider();
|
||||||
|
const [setupStep, setSetupStep] = useState<"setup" | "binding">("setup");
|
||||||
const [setupProvider, setSetupProvider] = useState<ChannelProvider | null>(
|
const [setupProvider, setSetupProvider] = useState<ChannelProvider | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@ -66,6 +67,12 @@ export function WorkspaceChannelsList() {
|
|||||||
provider: ChannelProvider,
|
provider: ChannelProvider,
|
||||||
preparedWindow?: Window | null,
|
preparedWindow?: Window | null,
|
||||||
) => {
|
) => {
|
||||||
|
if (provider.provider === "wechat") {
|
||||||
|
closeConnectWindow(preparedWindow ?? null);
|
||||||
|
setSetupStep(provider.configured ? "binding" : "setup");
|
||||||
|
setSetupProvider(provider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const connectWindow =
|
const connectWindow =
|
||||||
preparedWindow !== undefined
|
preparedWindow !== undefined
|
||||||
? preparedWindow
|
? preparedWindow
|
||||||
@ -153,6 +160,7 @@ export function WorkspaceChannelsList() {
|
|||||||
providerNeedsRuntimeConfig(provider) ||
|
providerNeedsRuntimeConfig(provider) ||
|
||||||
(isConnected && canEditRuntimeConfig)
|
(isConnected && canEditRuntimeConfig)
|
||||||
) {
|
) {
|
||||||
|
setSetupStep("setup");
|
||||||
setSetupProvider(provider);
|
setSetupProvider(provider);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -180,7 +188,11 @@ export function WorkspaceChannelsList() {
|
|||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<ChannelRuntimeConfigDialog
|
<ChannelRuntimeConfigDialog
|
||||||
|
onConfigured={() => {
|
||||||
|
setSetupProvider(null);
|
||||||
|
}}
|
||||||
provider={setupProvider}
|
provider={setupProvider}
|
||||||
|
initialStep={setupStep}
|
||||||
open={setupProvider !== null}
|
open={setupProvider !== null}
|
||||||
submitting={configureMutation.isPending}
|
submitting={configureMutation.isPending}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -191,9 +203,10 @@ export function WorkspaceChannelsList() {
|
|||||||
onSubmit={(provider, values) => {
|
onSubmit={(provider, values) => {
|
||||||
const connectWindow =
|
const connectWindow =
|
||||||
provider.auth_mode === "deep_link" ? prepareConnectWindow() : null;
|
provider.auth_mode === "deep_link" ? prepareConnectWindow() : null;
|
||||||
void configureMutation
|
return configureMutation
|
||||||
.mutateAsync({ provider: provider.provider, values })
|
.mutateAsync({ provider: provider.provider, values })
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
|
if (updated.provider === "wechat") return updated;
|
||||||
setSetupProvider(null);
|
setSetupProvider(null);
|
||||||
if (providerCanConnect(updated)) {
|
if (providerCanConnect(updated)) {
|
||||||
startConnect(updated, connectWindow);
|
startConnect(updated, connectWindow);
|
||||||
|
|||||||
@ -117,6 +117,11 @@ function ChannelProviderItem({
|
|||||||
const configureMutation = useConfigureChannelProvider();
|
const configureMutation = useConfigureChannelProvider();
|
||||||
const disconnectProviderMutation = useDisconnectChannelProvider();
|
const disconnectProviderMutation = useDisconnectChannelProvider();
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
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 runtimeAvailable = provider.configured && !provider.unavailable_reason;
|
||||||
const isConnected =
|
const isConnected =
|
||||||
runtimeAvailable &&
|
runtimeAvailable &&
|
||||||
@ -142,6 +147,12 @@ function ChannelProviderItem({
|
|||||||
connectProvider: ChannelProvider,
|
connectProvider: ChannelProvider,
|
||||||
preparedWindow?: Window | null,
|
preparedWindow?: Window | null,
|
||||||
) => {
|
) => {
|
||||||
|
if (connectProvider.provider === "wechat") {
|
||||||
|
closeConnectWindow(preparedWindow ?? null);
|
||||||
|
setSetupStep(connectProvider.configured ? "binding" : "setup");
|
||||||
|
setSetupOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const connectWindow =
|
const connectWindow =
|
||||||
preparedWindow !== undefined
|
preparedWindow !== undefined
|
||||||
? preparedWindow
|
? preparedWindow
|
||||||
@ -205,7 +216,7 @@ function ChannelProviderItem({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isConnecting || isDisconnecting}
|
disabled={isConnecting || isDisconnecting}
|
||||||
onClick={() => setSetupOpen(true)}
|
onClick={openSetup}
|
||||||
>
|
>
|
||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<LoaderCircleIcon className="animate-spin" />
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
@ -251,7 +262,7 @@ function ChannelProviderItem({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isConnecting || isDisconnecting}
|
disabled={isConnecting || isDisconnecting}
|
||||||
onClick={() => setSetupOpen(true)}
|
onClick={openSetup}
|
||||||
>
|
>
|
||||||
{t.channels.modify}
|
{t.channels.modify}
|
||||||
</Button>
|
</Button>
|
||||||
@ -263,7 +274,7 @@ function ChannelProviderItem({
|
|||||||
title={unavailableReason}
|
title={unavailableReason}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (providerNeedsRuntimeConfig(provider)) {
|
if (providerNeedsRuntimeConfig(provider)) {
|
||||||
setSetupOpen(true);
|
openSetup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,7 +300,11 @@ function ChannelProviderItem({
|
|||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
<ChannelRuntimeConfigDialog
|
<ChannelRuntimeConfigDialog
|
||||||
|
onConfigured={() => {
|
||||||
|
setSetupOpen(false);
|
||||||
|
}}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
|
initialStep={setupStep}
|
||||||
open={setupOpen}
|
open={setupOpen}
|
||||||
submitting={configureMutation.isPending}
|
submitting={configureMutation.isPending}
|
||||||
onOpenChange={setSetupOpen}
|
onOpenChange={setSetupOpen}
|
||||||
@ -298,9 +313,10 @@ function ChannelProviderItem({
|
|||||||
submitProvider.auth_mode === "deep_link"
|
submitProvider.auth_mode === "deep_link"
|
||||||
? prepareConnectWindow()
|
? prepareConnectWindow()
|
||||||
: null;
|
: null;
|
||||||
void configureMutation
|
return configureMutation
|
||||||
.mutateAsync({ provider: submitProvider.provider, values })
|
.mutateAsync({ provider: submitProvider.provider, values })
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
|
if (updated.provider === "wechat") return updated;
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
if (providerCanConnect(updated)) {
|
if (providerCanConnect(updated)) {
|
||||||
startConnect(updated, connectWindow);
|
startConnect(updated, connectWindow);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { fetch } from "@/core/api/fetcher";
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
WechatQRLoginSession,
|
||||||
ChannelConnectResponse,
|
ChannelConnectResponse,
|
||||||
ChannelConnection,
|
ChannelConnection,
|
||||||
ChannelConnectionsResponse,
|
ChannelConnectionsResponse,
|
||||||
@ -106,3 +107,44 @@ export async function disconnectChannelProvider(
|
|||||||
}
|
}
|
||||||
return response.json() as Promise<ChannelProvider>;
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@ -51,3 +51,24 @@ export interface ChannelConnectResponse {
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
expires_in: number;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@ -1050,6 +1050,71 @@ export const enUS: Translations = {
|
|||||||
unavailableShort: "Unavailable",
|
unavailableShort: "Unavailable",
|
||||||
setupTitle: (name: string) => `Connect ${name}`,
|
setupTitle: (name: string) => `Connect ${name}`,
|
||||||
setupEditTitle: (name: string) => `Modify ${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:
|
setupDescription:
|
||||||
"Enter the values needed by this server process. They are not written to config.yaml.",
|
"Enter the values needed by this server process. They are not written to config.yaml.",
|
||||||
saveAndConnect: "Save and connect",
|
saveAndConnect: "Save and connect",
|
||||||
|
|||||||
@ -879,6 +879,59 @@ export interface Translations {
|
|||||||
setupTitle: (name: string) => string;
|
setupTitle: (name: string) => string;
|
||||||
setupEditTitle: (name: string) => string;
|
setupEditTitle: (name: string) => string;
|
||||||
setupDescription: 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;
|
saveAndConnect: string;
|
||||||
saveChanges: string;
|
saveChanges: string;
|
||||||
descriptions: Record<string, string>;
|
descriptions: Record<string, string>;
|
||||||
|
|||||||
@ -987,6 +987,61 @@ export const zhCN: Translations = {
|
|||||||
unavailableShort: "不可用",
|
unavailableShort: "不可用",
|
||||||
setupTitle: (name: string) => `连接 ${name}`,
|
setupTitle: (name: string) => `连接 ${name}`,
|
||||||
setupEditTitle: (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:
|
setupDescription:
|
||||||
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
|
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
|
||||||
saveAndConnect: "保存并连接",
|
saveAndConnect: "保存并连接",
|
||||||
|
|||||||
@ -455,3 +455,381 @@ test.describe("IM channels", () => {
|
|||||||
await expect(setupDialog.getByLabel("App secret")).toHaveValue("********");
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,9 @@ rs.mock("@/core/config", () => ({
|
|||||||
|
|
||||||
import { fetch as fetcher } from "@/core/api/fetcher";
|
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||||
import {
|
import {
|
||||||
|
cancelWechatQRLogin,
|
||||||
|
pollWechatQRLogin,
|
||||||
|
startWechatQRLogin,
|
||||||
configureChannelProvider,
|
configureChannelProvider,
|
||||||
connectChannelProvider,
|
connectChannelProvider,
|
||||||
disconnectChannelConnection,
|
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" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
459
frontend/tests/unit/core/channels/wechat-qr-login.dom.test.tsx
Normal file
459
frontend/tests/unit/core/channels/wechat-qr-login.dom.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user