"""托管多个账号时的会话归属隔离回归测试。 复现的缺陷:账号 A 的处理链路收到属于账号 B 的会话(0:1:B:B的好友)后, resolve_peer_uid 把末段当成「对方」、normalize_conversation_id 再拼成 0:1:A:B的好友,于是账号 A 用自己的凭证把自动回复发给了账号 B 的好友。 """ from __future__ import annotations import asyncio import os import sys import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch BACKEND_DIR = Path(__file__).resolve().parents[1] os.environ.setdefault("KEFU_DB_TYPE", "sqlite") os.environ.setdefault("KEFU_DATABASE_URL", "") os.environ.setdefault("KEFU_DB_PATH", str(BACKEND_DIR / "kefu.db")) if str(BACKEND_DIR) not in sys.path: sys.path.insert(0, str(BACKEND_DIR)) from rpa_engine.douyin_im import hosted_registry from rpa_engine.douyin_im import ws_client as ws_module from rpa_engine.douyin_im.conv_util import conversation_belongs_to from rpa_engine.douyin_im.http_client import DouyinImHttpClient from rpa_engine.douyin_im.service import DouyinImService from rpa_engine.douyin_im.session import DouyinImSession from rpa_engine.douyin_im.ws_client import DouyinImWsClient ACCOUNT_A_UID = 7670159096859706425 ACCOUNT_B_UID = 7670157997767050299 PEER_OF_B = 66578464308 class ConversationOwnershipTests(unittest.TestCase): def test_foreign_single_chat_is_rejected(self): self.assertFalse( conversation_belongs_to( f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", ACCOUNT_A_UID ) ) def test_own_conversation_in_either_position(self): self.assertTrue( conversation_belongs_to(f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}", ACCOUNT_A_UID) ) self.assertTrue( conversation_belongs_to(f"0:1:{PEER_OF_B}:{ACCOUNT_A_UID}", ACCOUNT_A_UID) ) def test_undecidable_shapes_pass_through(self): # 缺 my_uid / 群聊 / 裸 UID:本来就判不了归属,保守放行 self.assertTrue(conversation_belongs_to(f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", 0)) self.assertTrue(conversation_belongs_to("0:2:123:456", ACCOUNT_A_UID)) self.assertTrue(conversation_belongs_to(str(PEER_OF_B), ACCOUNT_A_UID)) self.assertTrue(conversation_belongs_to("", ACCOUNT_A_UID)) class ForeignMessageDropTests(unittest.IsolatedAsyncioTestCase): def _service(self) -> DouyinImService: service = DouyinImService( session=DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID), match_reply=AsyncMock(return_value=["自动回复"]), log_fn=AsyncMock(), account_id=1, ) service._running = True return service async def test_message_from_another_account_never_schedules_a_reply(self): service = self._service() service._resolve_peer_profile = AsyncMock( return_value=("B 的好友", "", str(PEER_OF_B)) ) with patch( "rpa_engine.douyin_im.service.system_logger.record", Mock() ) as record: result = await service._prepare_incoming( { "conversation_id": f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", "sender_uid": str(PEER_OF_B), "content": "在吗", "server_message_id": "7665317099296081465", } ) self.assertIsNone(result) service.match_reply.assert_not_awaited() service.log_fn.assert_not_awaited() self.assertEqual(service._conv_meta, {}) self.assertTrue(record.called) async def test_own_message_is_still_processed(self): service = self._service() conv_id = f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}" service._resolve_peer_profile = AsyncMock( return_value=("我的好友", "", str(PEER_OF_B)) ) service._resolve_cooldown_seconds = AsyncMock(return_value=0) service._resolve_reply_delay_seconds = AsyncMock(return_value=0) service._send_auto_reply = AsyncMock() with patch("rpa_engine.douyin_im.service.system_logger.record", Mock()): send_reply = await service._prepare_incoming( { "conversation_id": conv_id, "sender_uid": str(PEER_OF_B), "content": "在吗", "server_message_id": "7665317099296081466", } ) self.assertIsNotNone(send_reply) service.match_reply.assert_awaited() self.assertIn(conv_id, service._conv_meta) class ForeignSendRefusalTests(unittest.IsolatedAsyncioTestCase): async def test_send_refuses_a_conversation_owned_by_another_account(self): client = DouyinImHttpClient( DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID), account_id=1, ) resolve_meta = AsyncMock() with ( patch.object( DouyinImHttpClient, "_resolve_authoritative_uid", return_value=ACCOUNT_A_UID, ), patch.object( DouyinImHttpClient, "resolve_conversation_meta", resolve_meta ), patch("rpa_engine.douyin_im.http_client.system_logger.record", Mock()), ): sent = await client.send_text_message( f"0:1:{ACCOUNT_B_UID}:{PEER_OF_B}", "你好", _bypass_global_queue=True, ) self.assertFalse(sent) # 关键断言:拒发必须发生在解析 ticket / 真正写出去之前 resolve_meta.assert_not_awaited() self.assertIn("不是本账号", client.last_error) self.assertFalse(client.last_send_channel_retryable) class HostedPeerLoopTests(unittest.IsolatedAsyncioTestCase): """两个本系统托管的账号之间不得互相自动回复(无限回环 → 抖音风控)。""" def _service(self) -> DouyinImService: service = DouyinImService( session=DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID), match_reply=AsyncMock(return_value=["自动回复"]), log_fn=AsyncMock(), account_id=1, ) service._running = True service._resolve_cooldown_seconds = AsyncMock(return_value=0) service._resolve_reply_delay_seconds = AsyncMock(return_value=0) return service def tearDown(self): hosted_registry.unregister(ACCOUNT_B_UID) async def _incoming_from(self, service, peer_uid: int, message_id: str): service._resolve_peer_profile = AsyncMock( return_value=("对方", "", str(peer_uid)) ) with patch("rpa_engine.douyin_im.service.system_logger.record", Mock()): return await service._prepare_incoming( { "conversation_id": f"0:1:{ACCOUNT_A_UID}:{peer_uid}", "sender_uid": str(peer_uid), "content": "在吗", "server_message_id": message_id, } ) async def test_no_auto_reply_to_another_hosted_account(self): hosted_registry.register(ACCOUNT_B_UID) service = self._service() result = await self._incoming_from(service, ACCOUNT_B_UID, "1") self.assertIsNone(result) service.match_reply.assert_not_awaited() # 消息本身照常入库,只是标记为未回复 statuses = [ call.kwargs.get("status") for call in service.log_fn.await_args_list ] self.assertIn("received", statuses) self.assertIn("ignored", statuses) async def test_ordinary_follower_still_gets_a_reply(self): hosted_registry.register(ACCOUNT_B_UID) service = self._service() service._send_auto_reply = AsyncMock() result = await self._incoming_from(service, PEER_OF_B, "2") self.assertIsNotNone(result) service.match_reply.assert_awaited() class FrontierDeviceExclusivityTests(unittest.IsolatedAsyncioTestCase): """同一个 frontier 设备号同时只允许一个账号建连。""" WS_URL = ( "wss://frontier-im.douyin.com/ws/v2?fpid=9&device_id=987654321&" "token=shared-token" ) def setUp(self): ws_module._FRONTIER_DEVICE_OWNERS.clear() def tearDown(self): ws_module._FRONTIER_DEVICE_OWNERS.clear() def _client(self, account_id: int) -> DouyinImWsClient: client = DouyinImWsClient( DouyinImSession(cookies={"sessionid": "s"}, ws_urls=[self.WS_URL]), AsyncMock(), account_id=account_id, ) client._running = True client._task = SimpleNamespace(done=lambda: False) return client def test_second_account_is_denied_while_the_first_holds_the_device(self): first = self._client(11) second = self._client(12) self.assertTrue(first._claim_frontier_device(self.WS_URL)) self.assertFalse(second._claim_frontier_device(self.WS_URL)) self.assertEqual(second._blocked_device_owner_id, 11) # 让出方不会被误标为已占用,重连时仍是 HTTP 轮询兜底 self.assertFalse(second.connected) def test_device_is_taken_over_after_the_owner_stops(self): first = self._client(11) second = self._client(12) self.assertTrue(first._claim_frontier_device(self.WS_URL)) first._running = False first._release_frontier_device() self.assertTrue(second._claim_frontier_device(self.WS_URL)) def test_same_account_reconnect_keeps_its_own_device(self): client = self._client(11) self.assertTrue(client._claim_frontier_device(self.WS_URL)) self.assertTrue(client._claim_frontier_device(self.WS_URL)) async def test_run_loop_does_not_open_a_second_connection(self): owner = self._client(11) self.assertTrue(owner._claim_frontier_device(self.WS_URL)) blocked = self._client(12) blocked._prepare_url = AsyncMock(return_value=self.WS_URL) run_connection = AsyncMock() blocked._run_connection = run_connection async def stop_after_first_backoff(_seconds): blocked._running = False with ( patch.object(ws_module, "_reconnect_delay", return_value=0.0), patch.object(ws_module.system_logger, "record") as record, patch.object(ws_module.asyncio, "sleep", stop_after_first_backoff), ): await asyncio.wait_for(blocked._run_loop(self.WS_URL), timeout=1.0) run_connection.assert_not_awaited() self.assertFalse(blocked.connected) self.assertTrue(record.called) def test_url_without_device_id_is_not_blocked(self): first = self._client(11) second = self._client(12) url = "wss://frontier-im.douyin.com/ws/v2?fpid=9&token=t" self.assertTrue(first._claim_frontier_device(url)) self.assertTrue(second._claim_frontier_device(url)) if __name__ == "__main__": unittest.main()