diff --git a/nanobot/channels/feishu/connect.py b/nanobot/channels/feishu/connect.py index 12930e11..795a4a78 100644 --- a/nanobot/channels/feishu/connect.py +++ b/nanobot/channels/feishu/connect.py @@ -127,9 +127,16 @@ class FeishuConnectStore: session.last_error = str(exc) return _pending_payload(session) - session.domain = str(result.get("domain") or session.domain) status = result.get("status") if status == "succeeded": + if self._sessions.get(session_id) is not session: + return { + "session_id": session_id, + "instance_id": session.instance_id, + "status": "cancelled", + "message": "Feishu connection cancelled.", + } + session.domain = str(result.get("domain") or session.domain) session.instance_id = feishu.save_registration_result( result, instance_id=session.instance_id, @@ -145,6 +152,7 @@ class FeishuConnectStore: "app_id": result.get("app_id"), } + session.domain = str(result.get("domain") or session.domain) if status == "failed": self._sessions.pop(session_id, None) return { diff --git a/nanobot/channels/feishu/tests/test_connect.py b/nanobot/channels/feishu/tests/test_connect.py new file mode 100644 index 00000000..278580c1 --- /dev/null +++ b/nanobot/channels/feishu/tests/test_connect.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +import pytest + +from nanobot.channels.feishu import runtime as feishu +from nanobot.channels.feishu.connect import FeishuConnectStore + + +@pytest.mark.asyncio +async def test_feishu_cancel_wins_over_inflight_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + poll_started = threading.Event() + release_poll = threading.Event() + saved_results: list[dict[str, Any]] = [] + + monkeypatch.setattr(feishu, "_init_registration", lambda _domain: None) + monkeypatch.setattr( + feishu, + "_begin_registration", + lambda _domain: { + "device_code": "device-cancel", + "qr_url": "https://qr.example/cancel", + "expire_in": 600, + "interval": 2, + }, + ) + + def fake_poll_registration_once(**_kwargs: Any) -> dict[str, str]: + poll_started.set() + assert release_poll.wait(timeout=5) + return { + "status": "succeeded", + "domain": "feishu", + "app_id": "late-app", + "app_secret": "late-secret", + } + + def fake_save_registration_result( + result: dict[str, Any], + **_kwargs: Any, + ) -> str: + saved_results.append(result) + return "default" + + monkeypatch.setattr(feishu, "poll_registration_once", fake_poll_registration_once) + monkeypatch.setattr(feishu, "save_registration_result", fake_save_registration_result) + + store = FeishuConnectStore() + started = await store.handle("start", {}) + query = {"session_id": [started["session_id"]]} + poll_task = asyncio.create_task(store.handle("poll", query)) + assert await asyncio.to_thread(poll_started.wait, 5) + + cancelled = await store.handle("cancel", query) + release_poll.set() + completed = await poll_task + + assert cancelled["status"] == "cancelled" + assert completed["status"] == "cancelled" + assert saved_results == [] diff --git a/nanobot/channels/weixin/connect.py b/nanobot/channels/weixin/connect.py index a56a7a2f..a254b64d 100644 --- a/nanobot/channels/weixin/connect.py +++ b/nanobot/channels/weixin/connect.py @@ -130,6 +130,12 @@ class WeixinConnectStore: status = status_data.get("status", "") if status == "confirmed": + if self._sessions.get(session_id) is not session: + return { + "session_id": session_id, + "status": "cancelled", + "message": "WeChat login cancelled.", + } token = str(status_data.get("bot_token", "") or "") if not token: self._sessions.pop(session_id, None) diff --git a/nanobot/channels/weixin/tests/test_connect.py b/nanobot/channels/weixin/tests/test_connect.py index 47e4a825..e201ce04 100644 --- a/nanobot/channels/weixin/tests/test_connect.py +++ b/nanobot/channels/weixin/tests/test_connect.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json from typing import Any @@ -97,3 +98,52 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds( cancelled = await store.cancel(started["session_id"]) assert cancelled["status"] == "cancelled" assert json.loads(state_file.read_text(encoding="utf-8")) == existing + + +@pytest.mark.asyncio +async def test_weixin_cancel_wins_over_inflight_confirmation( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + state_dir = tmp_path / "weixin-state" + config_path = tmp_path / "config.json" + save_config( + Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}), + config_path, + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + poll_started = asyncio.Event() + release_poll = asyncio.Event() + + async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]: + return "qr-cancel", "https://qr.example/cancel" + + async def fake_api_get_with_base( + self: WeixinChannel, + **_kwargs: Any, + ) -> dict[str, str]: + poll_started.set() + await release_poll.wait() + return { + "status": "confirmed", + "bot_token": "late-token", + "ilink_user_id": "late-user", + } + + monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code) + monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base) + + store = WeixinConnectStore() + started = await store.handle("start", {}) + query = {"session_id": [started["session_id"]]} + poll_task = asyncio.create_task(store.handle("poll", query)) + await asyncio.wait_for(poll_started.wait(), timeout=5) + + cancelled = await store.handle("cancel", query) + release_poll.set() + completed = await poll_task + + assert cancelled["status"] == "cancelled" + assert completed["status"] == "cancelled" + assert not (state_dir / "account.json").exists()