更新
This commit is contained in:
@@ -30,67 +30,69 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
account_id=9,
|
||||
)
|
||||
|
||||
async def test_terminal_token_error_does_not_probe_other_payloads(self):
|
||||
async def test_inbox_is_fetched_with_exactly_one_protobuf_request(self):
|
||||
"""imapi 只认 protobuf;轮询一次就只该发一个请求。"""
|
||||
client = self._make_client()
|
||||
client._request = AsyncMock(
|
||||
return_value={
|
||||
"status_code": 500,
|
||||
"error_desc": "empty token",
|
||||
"body": {},
|
||||
}
|
||||
)
|
||||
client.fetch_inbox_messages = AsyncMock(return_value=[])
|
||||
client._request = AsyncMock()
|
||||
|
||||
self.assertEqual(await client.get_conversations(), [])
|
||||
client._request.assert_awaited_once()
|
||||
self.assertEqual(client._request.await_args.args[0], "POST")
|
||||
client.fetch_inbox_messages.assert_awaited_once()
|
||||
# 不能再退回 JSON 的 /v1/conversation/list:那个请求恒被抖音拒绝。
|
||||
client._request.assert_not_awaited()
|
||||
|
||||
async def test_successful_empty_response_stops_after_first_payload(self):
|
||||
async def test_transport_failure_is_recorded_and_returns_empty(self):
|
||||
client = self._make_client()
|
||||
client._request = AsyncMock(
|
||||
return_value={"status_code": 0, "body": {"conversation_list": []}}
|
||||
)
|
||||
|
||||
self.assertEqual(await client.get_conversations(), [])
|
||||
client._request.assert_awaited_once()
|
||||
|
||||
async def test_parameter_error_can_fall_through_to_compatible_payload(self):
|
||||
client = self._make_client()
|
||||
client._request = AsyncMock(
|
||||
side_effect=[
|
||||
{"status_code": 400, "error_desc": "invalid parameter"},
|
||||
{"status_code": 0, "body": {"conversation_list": []}},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(await client.get_conversations(), [])
|
||||
self.assertEqual(client._request.await_count, 2)
|
||||
self.assertTrue(
|
||||
all(call.args[0] == "POST" for call in client._request.await_args_list)
|
||||
)
|
||||
|
||||
async def test_get_fallback_only_runs_after_transport_failure(self):
|
||||
client = self._make_client()
|
||||
client._request = AsyncMock(
|
||||
side_effect=[None, {"status_code": 0, "body": {}}]
|
||||
)
|
||||
|
||||
self.assertEqual(await client.get_conversations(), [])
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in client._request.await_args_list],
|
||||
["POST", "GET"],
|
||||
)
|
||||
|
||||
async def test_transport_outage_stops_after_one_post_and_get_pair(self):
|
||||
client = self._make_client()
|
||||
client._request = AsyncMock(return_value=None)
|
||||
client.fetch_inbox_messages = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with self.assertLogs("douyin_im.http", level="WARNING"):
|
||||
self.assertEqual(await client.get_conversations(), [])
|
||||
|
||||
self.assertEqual(client._request.await_count, 2)
|
||||
self.assertIn("boom", client.last_error)
|
||||
|
||||
async def test_inbox_messages_group_into_one_row_per_conversation(self):
|
||||
client = self._make_client()
|
||||
client.fetch_inbox_messages = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"conversation_id": "0:1:10001:20001",
|
||||
"server_message_id": "700",
|
||||
"conversation_short_id": "555",
|
||||
"message_type": 7,
|
||||
"sender": "20001",
|
||||
"content": '{"text":"旧"}',
|
||||
},
|
||||
{
|
||||
"conversation_id": "0:1:10001:20001",
|
||||
"server_message_id": "900",
|
||||
"conversation_short_id": "555",
|
||||
"message_type": 7,
|
||||
"sender": "20001",
|
||||
"content": '{"text":"新"}',
|
||||
},
|
||||
{
|
||||
"conversation_id": "0:1:10001:20002",
|
||||
"server_message_id": "800",
|
||||
"message_type": 7,
|
||||
"sender": "20002",
|
||||
"content": '{"text":"另一个"}',
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
rows = await client.get_conversations(enrich_profiles=False)
|
||||
|
||||
by_id = {r["conversation_id"]: r for r in rows}
|
||||
self.assertEqual(len(rows), 2)
|
||||
# 同一会话只保留 server_message_id 最大的那条
|
||||
self.assertEqual(by_id["0:1:10001:20001"]["server_message_id"], "900")
|
||||
self.assertIn("新", by_id["0:1:10001:20001"]["content"])
|
||||
# peer_uid 由 conversation_id 推导,不能直接取 sender(可能是自己)
|
||||
self.assertEqual(by_id["0:1:10001:20002"]["peer_uid"], "20002")
|
||||
# 顺手缓存 short_id,发送时就不必再 create 一次会话
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in client._request.await_args_list],
|
||||
["POST", "GET"],
|
||||
client.session.conv_meta["0:1:10001:20001"]["conversation_short_id"],
|
||||
"555",
|
||||
)
|
||||
|
||||
def test_websocket_reconciliation_is_slow_and_http_fallback_stays_fast(self):
|
||||
@@ -156,6 +158,7 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
class _HttpClient:
|
||||
def __init__(self):
|
||||
self.get_conversations = AsyncMock(return_value=[])
|
||||
self.conversation_list_unsupported = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -254,6 +257,7 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
class _HttpClient:
|
||||
def __init__(self):
|
||||
self.get_conversations = AsyncMock(side_effect=snapshots)
|
||||
self.conversation_list_unsupported = False
|
||||
self.enter_count = 0
|
||||
self.exit_count = 0
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
@@ -24,6 +26,7 @@ from rpa_engine.egress_channels import (
|
||||
resolve_send_channels,
|
||||
)
|
||||
from models.db_migrate import migrate_accounts_table
|
||||
from models.models import Account
|
||||
|
||||
|
||||
class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -98,6 +101,13 @@ class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class EgressMigrationTests(unittest.TestCase):
|
||||
def test_mysql_accounts_uses_longtext_for_browser_payloads(self):
|
||||
ddl = str(CreateTable(Account.__table__).compile(dialect=mysql.dialect()))
|
||||
|
||||
self.assertIn("cookie_data LONGTEXT", ddl)
|
||||
self.assertIn("im_session_data LONGTEXT", ddl)
|
||||
self.assertIn("qr_code_base64 LONGTEXT", ddl)
|
||||
|
||||
def test_old_accounts_table_receives_egress_columns(self):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
"""接收私信链路的回归测试。
|
||||
|
||||
覆盖三个曾让「托管中收不到抖音下发的私信」的缺陷:
|
||||
1. frontier 长连接地址用了账号 UID 而不是设备号,握手成功却订阅错地址;
|
||||
2. 浏览器本次登录抓到的真实 frontier 地址被 DB 里的旧地址挤掉;
|
||||
3. PushFrame 负载是 gzip / payloadType 不是 'pb' 时整帧被丢弃。
|
||||
另外覆盖会话列表接口被抖音拒绝时不能再伪装成「收件箱为空」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, 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 frontier as frontier_module
|
||||
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||
from rpa_engine.douyin_im.protocol import parse_ws_payload
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.douyin_im.static import Live_pb2, Response_pb2
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
def _build_push_frame(
|
||||
*,
|
||||
conversation_id: str,
|
||||
sender: int,
|
||||
content: str,
|
||||
message_type: int = 7,
|
||||
server_message_id: int = 7665317099296081465,
|
||||
encoding: str = "",
|
||||
payload_type: str = "pb",
|
||||
) -> bytes:
|
||||
response = Response_pb2.Response()
|
||||
notify = response.body.new_message_notify
|
||||
notify.conversation_id = conversation_id
|
||||
message = notify.message
|
||||
message.conversation_id = conversation_id
|
||||
message.conversation_type = 1
|
||||
message.server_message_id = server_message_id
|
||||
message.message_type = message_type
|
||||
message.sender = sender
|
||||
message.content = content
|
||||
|
||||
body = response.SerializeToString()
|
||||
if encoding == "gzip":
|
||||
body = gzip.compress(body)
|
||||
|
||||
frame = Live_pb2.PushFrame()
|
||||
frame.seqId = 1
|
||||
frame.service = 6
|
||||
frame.method = 2
|
||||
frame.payloadEncoding = encoding
|
||||
frame.payloadType = payload_type
|
||||
frame.payload = body
|
||||
return frame.SerializeToString()
|
||||
|
||||
|
||||
class FrontierAddressTests(unittest.TestCase):
|
||||
"""frontier 按 device_id 寻址,不能用账号 UID 顶替。"""
|
||||
|
||||
def _session(self) -> DouyinImSession:
|
||||
return DouyinImSession(
|
||||
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||
# query/user 返回的 id:本浏览器的设备注册号
|
||||
device_id="7678285795559818786",
|
||||
web_id="7678286623234475535",
|
||||
my_uid=2609567359568155,
|
||||
uid_verified=True,
|
||||
)
|
||||
|
||||
def test_built_url_uses_device_id_not_account_uid(self):
|
||||
session = self._session()
|
||||
|
||||
url = ensure_frontier_ws(session)
|
||||
|
||||
self.assertIsNotNone(url)
|
||||
self.assertIn("device_id=7678285795559818786", url)
|
||||
self.assertNotIn("device_id=2609567359568155", url)
|
||||
|
||||
def test_missing_device_id_falls_back_to_query_user_lookup(self):
|
||||
session = self._session()
|
||||
session.device_id = ""
|
||||
session.web_id = ""
|
||||
|
||||
with patch.object(
|
||||
frontier_module,
|
||||
"fetch_device_id",
|
||||
return_value="7678285795559818786",
|
||||
) as fetch:
|
||||
url = ensure_frontier_ws(session)
|
||||
|
||||
fetch.assert_called_once()
|
||||
self.assertIn("device_id=7678285795559818786", url)
|
||||
|
||||
def test_proto_auth_keeps_device_id_for_verified_uid(self):
|
||||
session = self._session()
|
||||
|
||||
auth = DouyinAuth.from_im_session(session)
|
||||
|
||||
self.assertEqual(auth.device_id, "7678285795559818786")
|
||||
|
||||
|
||||
class CapturedFrontierUrlTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""浏览器本次抓到的真实地址必须压过 DB 里的旧地址。"""
|
||||
|
||||
async def test_fresh_browser_ws_url_wins_over_cached_url(self):
|
||||
cached = (
|
||||
"wss://frontier-im.douyin.com/ws/v2?aid=6383&device_platform=douyin_pc"
|
||||
"&fpid=9&device_id=7678285795559818786&token=stale&access_key=stale"
|
||||
)
|
||||
captured = (
|
||||
"wss://frontier31-normal.zijieapi.com/ws/v2?aid=6383&fpid=9"
|
||||
"&device_id=7678285795559818786&access_key=realkey&token=realtoken"
|
||||
)
|
||||
saved = DouyinImSession(
|
||||
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||
ws_urls=[cached],
|
||||
device_id="7678285795559818786",
|
||||
my_uid=2609567359568155,
|
||||
)
|
||||
row = SimpleNamespace(
|
||||
im_session_data=json.dumps(saved.to_dict()),
|
||||
cookie_updated_at=datetime.utcnow(),
|
||||
uid=None,
|
||||
profile_updated_at=None,
|
||||
)
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = row
|
||||
db = SimpleNamespace(execute=AsyncMock(return_value=result), close=AsyncMock())
|
||||
|
||||
worker = DouyinWorker(account_id=400, login_mode="browser")
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||
|
||||
session = await worker._build_im_session_from_storage(
|
||||
{"cookies": [{"name": "sessionid", "value": "6313fec013ec0000000000000000abcd"}]},
|
||||
{"ws_urls": [captured]},
|
||||
)
|
||||
|
||||
self.assertEqual(session.ws_urls[0], captured)
|
||||
self.assertEqual(ensure_frontier_ws(session), captured)
|
||||
|
||||
|
||||
class PushFramePayloadTests(unittest.TestCase):
|
||||
"""PushFrame 负载的编码/类型不能再决定整帧被不被丢弃。"""
|
||||
|
||||
def test_plain_protobuf_frame_is_parsed(self):
|
||||
raw = _build_push_frame(
|
||||
conversation_id="0:1:869032150442612:2609567359568155",
|
||||
sender=869032150442612,
|
||||
content=json.dumps({"text": "你好", "aweType": 700}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
messages = parse_ws_payload(raw)
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["sender_uid"], "869032150442612")
|
||||
self.assertEqual(
|
||||
messages[0]["conversation_id"], "0:1:869032150442612:2609567359568155"
|
||||
)
|
||||
|
||||
def test_gzip_encoded_frame_is_parsed(self):
|
||||
raw = _build_push_frame(
|
||||
conversation_id="0:1:869032150442612:2609567359568155",
|
||||
sender=869032150442612,
|
||||
content=json.dumps({"text": "在吗", "aweType": 700}, ensure_ascii=False),
|
||||
encoding="gzip",
|
||||
)
|
||||
|
||||
messages = parse_ws_payload(raw)
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["sender_uid"], "869032150442612")
|
||||
|
||||
def test_non_pb_payload_type_is_still_parsed(self):
|
||||
# 现网 frontier 帧会带 payloadType='text/json';只认 'pb' 会整帧丢弃。
|
||||
raw = _build_push_frame(
|
||||
conversation_id="0:1:869032150442612:2609567359568155",
|
||||
sender=869032150442612,
|
||||
content=json.dumps({"text": "在吗", "aweType": 700}, ensure_ascii=False),
|
||||
payload_type="text/json",
|
||||
)
|
||||
|
||||
messages = parse_ws_payload(raw)
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0]["sender_uid"], "869032150442612")
|
||||
|
||||
def test_empty_payload_control_frame_yields_no_message(self):
|
||||
frame = Live_pb2.PushFrame()
|
||||
frame.service = 6
|
||||
frame.method = 2
|
||||
frame.payloadEncoding = "utf-8"
|
||||
frame.payloadType = "text/json"
|
||||
|
||||
self.assertEqual(parse_ws_payload(frame.SerializeToString()), [])
|
||||
|
||||
|
||||
class InboxProtobufTests(unittest.TestCase):
|
||||
"""imapi 只认 protobuf:解析真实响应形状,而不是 JSON。"""
|
||||
|
||||
@staticmethod
|
||||
def _response(*, cmd=200, status=0, message="OK", messages=()):
|
||||
from rpa_engine.douyin_im.http_client import _pb_int, _pb_msg, _pb_str
|
||||
|
||||
entries = b""
|
||||
for m in messages:
|
||||
entries += _pb_msg(
|
||||
1,
|
||||
_pb_str(1, m["conversation_id"])
|
||||
+ _pb_int(3, m["server_message_id"])
|
||||
+ _pb_int(5, m.get("conversation_short_id", 0))
|
||||
+ _pb_int(6, m.get("message_type", 7))
|
||||
+ _pb_int(7, m["sender"])
|
||||
+ _pb_str(8, m.get("content", "")),
|
||||
)
|
||||
return (
|
||||
_pb_int(1, cmd)
|
||||
+ _pb_int(3, status)
|
||||
+ _pb_str(4, message)
|
||||
+ _pb_msg(6, _pb_msg(cmd, entries))
|
||||
)
|
||||
|
||||
def test_status_is_read_from_the_protobuf_envelope(self):
|
||||
from rpa_engine.douyin_im.http_client import _pb_response_status
|
||||
|
||||
ok = self._response()
|
||||
self.assertEqual(_pb_response_status(ok), (0, "OK"))
|
||||
|
||||
rejected = self._response(status=1, message="unexepcted session length")
|
||||
self.assertEqual(
|
||||
_pb_response_status(rejected),
|
||||
(1, "unexepcted session length"),
|
||||
)
|
||||
|
||||
def test_message_bodies_are_extracted_from_the_inbox_response(self):
|
||||
from rpa_engine.douyin_im.http_client import _pb_parse_inbox_messages
|
||||
|
||||
raw = self._response(
|
||||
messages=[
|
||||
{
|
||||
"conversation_id": "0:1:2609567359568155:869032150442612",
|
||||
"server_message_id": 7678140298052355621,
|
||||
"conversation_short_id": 7654765893796266545,
|
||||
"message_type": 7,
|
||||
"sender": 869032150442612,
|
||||
"content": '{"text":"你好"}',
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
parsed = _pb_parse_inbox_messages(raw, 200)
|
||||
|
||||
self.assertEqual(len(parsed), 1)
|
||||
self.assertEqual(
|
||||
parsed[0]["conversation_id"],
|
||||
"0:1:2609567359568155:869032150442612",
|
||||
)
|
||||
self.assertEqual(parsed[0]["server_message_id"], "7678140298052355621")
|
||||
self.assertEqual(parsed[0]["sender"], "869032150442612")
|
||||
self.assertIn("你好", parsed[0]["content"])
|
||||
|
||||
def test_unrelated_protobuf_is_not_mistaken_for_a_message(self):
|
||||
from rpa_engine.douyin_im.http_client import (
|
||||
_pb_int, _pb_msg, _pb_parse_inbox_messages, _pb_str,
|
||||
)
|
||||
|
||||
# 一段带字符串字段 1 但不是 conversation_id 的子消息
|
||||
noise = _pb_msg(6, _pb_msg(200, _pb_msg(1, _pb_str(1, "not-a-conv") + _pb_int(3, 5))))
|
||||
self.assertEqual(_pb_parse_inbox_messages(noise, 200), [])
|
||||
|
||||
def test_empty_inbox_yields_no_messages(self):
|
||||
from rpa_engine.douyin_im.http_client import _pb_parse_inbox_messages
|
||||
|
||||
self.assertEqual(_pb_parse_inbox_messages(self._response(), 200), [])
|
||||
|
||||
|
||||
class InboxCursorAndListTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""轮询用小窗口,用户点开列表用全量——同一个 cmd,只是游标不同。"""
|
||||
|
||||
def _client(self) -> DouyinImHttpClient:
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "s", "x_tt_token": "00" + "a" * 353},
|
||||
device_id="7678285795559818786",
|
||||
my_uid=2609567359568155,
|
||||
)
|
||||
return DouyinImHttpClient(session, account_id=405)
|
||||
|
||||
@staticmethod
|
||||
def _cursor_from_payload(payload: bytes) -> int:
|
||||
from rpa_engine.douyin_im.http_client import _pb_parse_fields
|
||||
|
||||
for fn, wt, val in _pb_parse_fields(payload):
|
||||
if fn != 8 or wt != 2:
|
||||
continue
|
||||
for bfn, bwt, bval in _pb_parse_fields(val):
|
||||
if bfn != 200 or bwt != 2:
|
||||
continue
|
||||
for cfn, cwt, cval in _pb_parse_fields(bval):
|
||||
if cfn == 1 and cwt == 0:
|
||||
return int(cval)
|
||||
return -1
|
||||
|
||||
async def _capture_cursor(self, **kwargs) -> int:
|
||||
from rpa_engine.douyin_im.http_client import _pb_int, _pb_str
|
||||
|
||||
client = self._client()
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_post(url, auth, payload, **_kw):
|
||||
captured["payload"] = payload
|
||||
return SimpleNamespace(
|
||||
content=_pb_int(1, 200) + _pb_int(3, 0) + _pb_str(4, "OK"),
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
with patch.object(client, "_post_protobuf", fake_post):
|
||||
await client.fetch_inbox_messages(**kwargs)
|
||||
return self._cursor_from_payload(captured["payload"])
|
||||
|
||||
async def test_poll_window_sends_a_recent_microsecond_cursor(self):
|
||||
import time as _time
|
||||
|
||||
cursor = await self._capture_cursor(lookback_seconds=1800)
|
||||
now_us = int(_time.time() * 1_000_000)
|
||||
|
||||
self.assertGreater(cursor, 0)
|
||||
# 游标应落在「大约半小时前」,允许几秒误差
|
||||
self.assertLess(now_us - cursor, int(1810 * 1_000_000))
|
||||
self.assertGreater(now_us - cursor, int(1790 * 1_000_000))
|
||||
|
||||
async def test_zero_lookback_means_no_cursor_not_now(self):
|
||||
# lookback=0 若被算成 now,就只要「比此刻更新」的消息,永远是空列表。
|
||||
self.assertEqual(await self._capture_cursor(lookback_seconds=0), 0)
|
||||
|
||||
@staticmethod
|
||||
def _page(*, entries=(), next_cursor=0, has_more=False, cmd=200):
|
||||
from rpa_engine.douyin_im.http_client import _pb_int, _pb_msg, _pb_str
|
||||
|
||||
inner = b""
|
||||
for short_id, conv_id in entries:
|
||||
inner += _pb_msg(6, _pb_int(1, short_id) + _pb_str(4, conv_id))
|
||||
inner += _pb_int(2, next_cursor) + _pb_int(3, 1 if has_more else 0)
|
||||
return (
|
||||
_pb_int(1, cmd)
|
||||
+ _pb_int(3, 0)
|
||||
+ _pb_str(4, "OK")
|
||||
+ _pb_msg(6, _pb_msg(cmd, inner))
|
||||
)
|
||||
|
||||
async def test_paging_follows_the_cursor_and_dedupes_conversations(self):
|
||||
client = self._client()
|
||||
pages = [
|
||||
self._page(
|
||||
entries=[(1, "0:1:10001:20001"), (2, "0:1:10001:20002")],
|
||||
next_cursor=111,
|
||||
has_more=True,
|
||||
),
|
||||
self._page(
|
||||
# 第二页重复一个、新增一个
|
||||
entries=[(2, "0:1:10001:20002"), (3, "0:1:10001:20003")],
|
||||
next_cursor=222,
|
||||
has_more=True,
|
||||
),
|
||||
]
|
||||
cursors: list[int] = []
|
||||
|
||||
async def fake_post(url, auth, payload, **_kw):
|
||||
cursors.append(self._cursor_from_payload(payload))
|
||||
return SimpleNamespace(
|
||||
content=pages[len(cursors) - 1], raise_for_status=lambda: None
|
||||
)
|
||||
|
||||
with patch.object(client, "_post_protobuf", fake_post):
|
||||
await client.fetch_inbox_messages(lookback_seconds=0, max_pages=2)
|
||||
|
||||
self.assertEqual(cursors, [0, 111])
|
||||
self.assertEqual(
|
||||
[c["conversation_id"] for c in client._last_inbox_conversations],
|
||||
["0:1:10001:20001", "0:1:10001:20002", "0:1:10001:20003"],
|
||||
)
|
||||
# 预算用完但抖音还说 has_more:必须承认列表不完整
|
||||
self.assertTrue(client.inbox_truncated)
|
||||
|
||||
async def test_last_page_is_not_reported_as_truncated(self):
|
||||
client = self._client()
|
||||
page = self._page(entries=[(1, "0:1:10001:20001")], has_more=False)
|
||||
|
||||
async def fake_post(url, auth, payload, **_kw):
|
||||
return SimpleNamespace(content=page, raise_for_status=lambda: None)
|
||||
|
||||
with patch.object(client, "_post_protobuf", fake_post):
|
||||
await client.fetch_inbox_messages(lookback_seconds=0, max_pages=5)
|
||||
|
||||
self.assertFalse(client.inbox_truncated)
|
||||
|
||||
async def test_a_stalled_cursor_stops_paging(self):
|
||||
client = self._client()
|
||||
# 抖音回 has_more=1 但游标不前进:不能无限翻同一页
|
||||
page = self._page(
|
||||
entries=[(1, "0:1:10001:20001")], next_cursor=0, has_more=True
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(url, auth, payload, **_kw):
|
||||
calls["n"] += 1
|
||||
return SimpleNamespace(content=page, raise_for_status=lambda: None)
|
||||
|
||||
with patch.object(client, "_post_protobuf", fake_post):
|
||||
await client.fetch_inbox_messages(lookback_seconds=0, max_pages=10)
|
||||
|
||||
self.assertEqual(calls["n"], 1)
|
||||
|
||||
async def test_conversations_without_recent_messages_still_listed(self):
|
||||
client = self._client()
|
||||
client.fetch_inbox_messages = AsyncMock(return_value=[])
|
||||
client._last_inbox_conversations = [
|
||||
{"conversation_id": "0:1:10001:20001", "conversation_short_id": "555"},
|
||||
{"conversation_id": "0:1:10001:20002", "conversation_short_id": "666"},
|
||||
]
|
||||
|
||||
rows = await client.get_conversations(
|
||||
enrich_profiles=False, lookback_seconds=0
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{r["conversation_id"] for r in rows},
|
||||
{"0:1:10001:20001", "0:1:10001:20002"},
|
||||
)
|
||||
self.assertEqual(
|
||||
client.session.conv_meta["0:1:10001:20002"]["conversation_short_id"],
|
||||
"666",
|
||||
)
|
||||
|
||||
async def test_control_frames_never_become_a_conversation_preview(self):
|
||||
client = self._client()
|
||||
client.fetch_inbox_messages = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"conversation_id": "0:1:10001:20001",
|
||||
"server_message_id": "100",
|
||||
"message_type": 7,
|
||||
"sender": "20001",
|
||||
"content": '{"text":"真实消息"}',
|
||||
},
|
||||
{
|
||||
"conversation_id": "0:1:10001:20001",
|
||||
"server_message_id": "200",
|
||||
"message_type": 50001,
|
||||
"sender": "20001",
|
||||
"content": '{"command_type":6,"conversation_id":"0:1:10001:20001"}',
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
rows = await client.get_conversations(enrich_profiles=False)
|
||||
|
||||
# 控制帧 server_message_id 更大,但不能顶掉真实消息成为预览,
|
||||
# 否则 _handle_incoming 会拿它去匹配自动回复。
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertIn("真实消息", rows[0]["content"])
|
||||
self.assertEqual(rows[0]["server_message_id"], "100")
|
||||
|
||||
|
||||
class ReadRequestTokenTests(unittest.TestCase):
|
||||
"""读接口的 Request.token 必须是 x_tt_token。
|
||||
|
||||
带 auth.ticket 时抖音照样回 status_code=0 "OK",但把调用方当匿名用户,
|
||||
正文恒为空——和「收件箱没有消息」完全无法区分,是最难发现的那类故障。
|
||||
实测同一请求只换 token:auth.ticket 73 字节 0 条,x_tt_token 113KB 47 条。
|
||||
"""
|
||||
|
||||
def _auth(self):
|
||||
session = DouyinImSession(
|
||||
cookies={
|
||||
"sessionid": "6313fec013ec0000000000000000abcd",
|
||||
"x_tt_token": "00" + "a" * 353,
|
||||
},
|
||||
device_id="7678285795559818786",
|
||||
my_uid=2609567359568155,
|
||||
)
|
||||
return DouyinAuth.from_im_session(session)
|
||||
|
||||
def test_read_request_uses_x_tt_token(self):
|
||||
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
|
||||
|
||||
auth = self._auth()
|
||||
request = ProtoBuilder.build_read_request(auth, 200)
|
||||
|
||||
self.assertEqual(request.token, "00" + "a" * 353)
|
||||
self.assertNotEqual(request.token, auth.ticket)
|
||||
|
||||
def test_read_request_keeps_ticket_when_cookie_missing(self):
|
||||
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
|
||||
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||
device_id="7678285795559818786",
|
||||
my_uid=2609567359568155,
|
||||
)
|
||||
auth = DouyinAuth.from_im_session(session)
|
||||
|
||||
request = ProtoBuilder.build_read_request(auth, 200)
|
||||
|
||||
self.assertEqual(request.token, auth.ticket or "")
|
||||
|
||||
def test_send_request_is_left_on_the_normal_envelope(self):
|
||||
from rpa_engine.douyin_im.proto_builder import ProtoBuilder
|
||||
|
||||
# 发送接口另有 bd-ticket-guard 签名且线上可用,不能顺手改掉它的 token。
|
||||
auth = self._auth()
|
||||
request = ProtoBuilder.build_normal_request(auth, 100)
|
||||
|
||||
self.assertEqual(request.token, auth.ticket or "")
|
||||
|
||||
|
||||
class AuthoritativeUidTests(unittest.TestCase):
|
||||
"""imapi 响应字段 13 是抖音认定的本账号 IM uid。"""
|
||||
|
||||
def test_response_uid_corrects_a_wrong_my_uid(self):
|
||||
from rpa_engine.douyin_im.http_client import _pb_int, _pb_str
|
||||
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "s"},
|
||||
my_uid=7678285795559818786, # 误把 frontier 设备号当成了 IM uid
|
||||
)
|
||||
client = DouyinImHttpClient(session, account_id=404)
|
||||
content = (
|
||||
_pb_int(1, 200)
|
||||
+ _pb_int(3, 0)
|
||||
+ _pb_str(4, "OK")
|
||||
+ _pb_int(13, 2609567359568155)
|
||||
)
|
||||
|
||||
client._adopt_authoritative_uid(content)
|
||||
|
||||
self.assertEqual(session.my_uid, 2609567359568155)
|
||||
self.assertTrue(session.uid_verified)
|
||||
|
||||
def test_matching_uid_is_left_alone(self):
|
||||
from rpa_engine.douyin_im.http_client import _pb_int, _pb_str
|
||||
|
||||
session = DouyinImSession(cookies={"sessionid": "s"}, my_uid=2609567359568155)
|
||||
client = DouyinImHttpClient(session, account_id=404)
|
||||
content = _pb_int(1, 200) + _pb_int(3, 0) + _pb_str(4, "OK") + _pb_int(
|
||||
13, 2609567359568155
|
||||
)
|
||||
|
||||
client._adopt_authoritative_uid(content)
|
||||
|
||||
self.assertEqual(session.my_uid, 2609567359568155)
|
||||
self.assertFalse(session.uid_verified)
|
||||
|
||||
|
||||
class ConversationListRejectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""接口被拒不能再伪装成「收件箱为空」。"""
|
||||
|
||||
def _client(self) -> DouyinImHttpClient:
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||
device_id="7678285795559818786",
|
||||
my_uid=2609567359568155,
|
||||
)
|
||||
return DouyinImHttpClient(session, account_id=401)
|
||||
|
||||
async def test_rejected_protobuf_response_is_reported(self):
|
||||
client = self._client()
|
||||
raw = InboxProtobufTests._response(
|
||||
status=1, message="unexepcted session length"
|
||||
)
|
||||
post = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
content=raw, raise_for_status=lambda: None
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(client, "_post_protobuf", post),
|
||||
patch(
|
||||
"rpa_engine.douyin_im.auth.DouyinAuth.from_im_session",
|
||||
return_value=SimpleNamespace(source_ip=""),
|
||||
),
|
||||
patch(
|
||||
"rpa_engine.douyin_im.proto_builder.ProtoBuilder.build_normal_request",
|
||||
return_value=SimpleNamespace(SerializeToString=lambda: b""),
|
||||
),
|
||||
patch.object(client, "_report_conversation_list_rejected") as report,
|
||||
):
|
||||
self.assertEqual(await client.fetch_inbox_messages(), [])
|
||||
|
||||
post.assert_awaited_once()
|
||||
report.assert_called_once_with("unexepcted session length")
|
||||
|
||||
async def test_rejection_marks_the_endpoint_unsupported(self):
|
||||
client = self._client()
|
||||
client._report_conversation_list_rejected("unexepcted session length")
|
||||
|
||||
self.assertTrue(client.conversation_list_unsupported)
|
||||
self.assertIn("unexepcted session length", client.last_error)
|
||||
|
||||
async def test_empty_but_successful_inbox_is_not_reported_as_failure(self):
|
||||
client = self._client()
|
||||
client.fetch_inbox_messages = AsyncMock(return_value=[])
|
||||
|
||||
with patch.object(
|
||||
client, "_report_conversation_list_rejected"
|
||||
) as report:
|
||||
self.assertEqual(
|
||||
await client.get_conversations(enrich_profiles=False), []
|
||||
)
|
||||
|
||||
report.assert_not_called()
|
||||
self.assertEqual(client.last_error, "")
|
||||
self.assertFalse(client.conversation_list_unsupported)
|
||||
|
||||
|
||||
class LoggedOutDetectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""抖音回「用户未登录」时必须明确报出来,不能当成资料接口抖动。"""
|
||||
|
||||
def test_status_code_8_is_reported_as_logged_out(self):
|
||||
from rpa_engine import account_profile as ap
|
||||
|
||||
auth = SimpleNamespace(cookie={}, msToken="t", get_uid=lambda: "938334054809296")
|
||||
payloads = [
|
||||
{"status_code": 0, "user_uid": "938334054809296"},
|
||||
{"status_code": 8, "status_msg": "用户未登录", "user": None},
|
||||
{"status_code": 8, "status_msg": "用户未登录", "user": None},
|
||||
]
|
||||
responses = [SimpleNamespace(json=lambda v=v: v) for v in payloads]
|
||||
|
||||
with (
|
||||
patch.object(ap, "_build_auth", return_value=(auth, "ua")),
|
||||
patch.object(ap.requests, "get", side_effect=responses),
|
||||
patch.object(ap, "generate_a_bogus", return_value="a-bogus"),
|
||||
patch.object(ap, "generate_webid", return_value="web-id"),
|
||||
patch.object(ap, "_requests_proxies", return_value=None),
|
||||
):
|
||||
detail = ap.fetch_douyin_profile_detail_sync("cookie-json", "ua")
|
||||
|
||||
self.assertTrue(detail["logged_out"])
|
||||
self.assertFalse(detail["fetched"])
|
||||
self.assertIn("用户未登录", detail["message"])
|
||||
|
||||
async def test_hosting_reports_logged_out_once(self):
|
||||
worker = DouyinWorker(account_id=403, login_mode="im_direct")
|
||||
with patch(
|
||||
"rpa_engine.playwright_worker.system_logger.record"
|
||||
) as record:
|
||||
await worker._report_douyin_logged_out("抖音返回「用户未登录」")
|
||||
await worker._report_douyin_logged_out("抖音返回「用户未登录」")
|
||||
|
||||
record.assert_called_once()
|
||||
self.assertEqual(record.call_args.kwargs["level"], "error")
|
||||
|
||||
|
||||
class ReconciliationBackoffTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""被抖音拒绝过的接口不能每 120 秒再白打一次。"""
|
||||
|
||||
def _service(self):
|
||||
from rpa_engine.douyin_im.service import DouyinImService
|
||||
|
||||
return DouyinImService(
|
||||
session=DouyinImSession(
|
||||
cookies={"sessionid": "6313fec013ec0000000000000000abcd"},
|
||||
device_id="7678285795559818786",
|
||||
my_uid=2609567359568155,
|
||||
),
|
||||
match_reply=AsyncMock(return_value=[]),
|
||||
log_fn=AsyncMock(),
|
||||
account_id=402,
|
||||
)
|
||||
|
||||
async def test_second_poll_skips_a_rejected_endpoint(self):
|
||||
service = self._service()
|
||||
|
||||
client = MagicMock()
|
||||
client.get_conversations = AsyncMock(return_value=[])
|
||||
client.conversation_list_unsupported = True
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"rpa_engine.douyin_im.service.DouyinImHttpClient",
|
||||
return_value=client,
|
||||
):
|
||||
self.assertEqual(await service._poll_conversations(), [])
|
||||
self.assertTrue(service._conversation_list_unsupported)
|
||||
# 第二轮完全不再构造 HTTP 客户端 / 发请求
|
||||
self.assertEqual(await service._poll_conversations(), [])
|
||||
|
||||
client.get_conversations.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -18,13 +20,114 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine import account_profile as account_profile_module
|
||||
from rpa_engine.credential import build_im_session_from_storage
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_im_direct_missing_sec_user_id_exits_before_online_state(self):
|
||||
async def test_current_profile_uid_overrides_browser_runtime_uid(self):
|
||||
now = datetime.utcnow()
|
||||
row = SimpleNamespace(
|
||||
im_session_data=None,
|
||||
cookie_updated_at=now,
|
||||
uid="2609567359568155",
|
||||
profile_updated_at=now,
|
||||
)
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = row
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=result),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
worker = DouyinWorker(account_id=300, login_mode="im_direct")
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||
storage = {
|
||||
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||
"my_uid": 7678285795559818786,
|
||||
"origins": [
|
||||
{
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "web_runtime_security_uid",
|
||||
"value": "7678285795559818786",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
session = await worker._build_im_session_from_storage(storage)
|
||||
|
||||
self.assertEqual(session.my_uid, 2609567359568155)
|
||||
# device_id 必须与权威 UID 同步(protobuf 发送时 device_id 优先取
|
||||
# session.device_id,残留的浏览器采集值会导致 device_id != my_uid -> KICK)
|
||||
self.assertEqual(session.device_id, "2609567359568155")
|
||||
self.assertTrue(session.uid_verified)
|
||||
db.close.assert_awaited_once()
|
||||
|
||||
async def test_stale_profile_uid_does_not_override_new_cookie(self):
|
||||
now = datetime.utcnow()
|
||||
row = SimpleNamespace(
|
||||
im_session_data=None,
|
||||
cookie_updated_at=now,
|
||||
uid="2609567359568155",
|
||||
profile_updated_at=now - timedelta(seconds=1),
|
||||
)
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = row
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=result),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
worker = DouyinWorker(account_id=300, login_mode="im_direct")
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
|
||||
|
||||
session = await worker._build_im_session_from_storage(
|
||||
{
|
||||
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||
"my_uid": 7678285795559818786,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(session.my_uid, 7678285795559818786)
|
||||
self.assertFalse(session.uid_verified)
|
||||
|
||||
def test_persisted_verified_uid_wins_in_generic_session_builder(self):
|
||||
saved = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
my_uid=2609567359568155,
|
||||
device_id="7678285795559818786",
|
||||
uid_verified=True,
|
||||
)
|
||||
|
||||
session = build_im_session_from_storage(
|
||||
{
|
||||
"cookies": [{"name": "sessionid", "value": "test-session"}],
|
||||
"my_uid": 7678285795559818786,
|
||||
"origins": [
|
||||
{
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "web_runtime_security_uid",
|
||||
"value": "7678285795559818786",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
json.dumps(saved.to_dict()),
|
||||
)
|
||||
|
||||
self.assertEqual(session.my_uid, 2609567359568155)
|
||||
# device_id 同步为已核验 UID,避免凭证残留设备号导致 device_id != my_uid
|
||||
self.assertEqual(session.device_id, "2609567359568155")
|
||||
self.assertTrue(session.uid_verified)
|
||||
|
||||
async def test_im_direct_missing_sec_user_id_continues_im_hosting(self):
|
||||
worker = DouyinWorker(account_id=301, login_mode="im_direct")
|
||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
im_session = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
@@ -42,37 +145,34 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
{"cookies": [{"name": "sessionid", "value": "test-session"}]}
|
||||
)
|
||||
|
||||
self.assertFalse(started)
|
||||
self.assertIn("sec_user_id", reason)
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._require_sec_user_id.await_args
|
||||
self.assertTrue(require_call.kwargs["refresh_if_missing"])
|
||||
self.assertTrue(require_call.kwargs["refresh_if_stale"])
|
||||
self.assertTrue(started)
|
||||
self.assertEqual(reason, "")
|
||||
worker._best_effort_sec_user_id.assert_awaited_once_with(
|
||||
refresh_if_missing=True,
|
||||
refresh_if_stale=True,
|
||||
)
|
||||
worker._build_im_session_from_storage.assert_awaited_once()
|
||||
validate.assert_awaited_once_with(im_session)
|
||||
worker._persist_im_session.assert_not_awaited()
|
||||
worker._run_im_direct_service.assert_not_awaited()
|
||||
worker._persist_im_session.assert_awaited_once()
|
||||
worker._run_im_direct_service.assert_awaited_once_with(im_session)
|
||||
|
||||
async def test_running_guard_precedes_disabled_follow_welcome_setting(self):
|
||||
async def test_disabled_follow_welcome_does_not_require_identity(self):
|
||||
worker = DouyinWorker(account_id=302, login_mode="im_direct")
|
||||
worker.is_running = True
|
||||
worker._im_service = SimpleNamespace(_running=True)
|
||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
||||
worker._refresh_follow_welcome_config = AsyncMock()
|
||||
worker._best_effort_sec_user_id = AsyncMock()
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker.get_db = AsyncMock()
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._require_sec_user_id.await_args
|
||||
self.assertFalse(require_call.kwargs.get("refresh_if_missing", False))
|
||||
# The identity guard must run before the account's follow-welcome flag
|
||||
# is queried. Otherwise accounts with that feature disabled could stay
|
||||
# hosted indefinitely without a sec_user_id.
|
||||
worker._refresh_follow_welcome_config.assert_not_awaited()
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||
worker.get_db.assert_not_awaited()
|
||||
|
||||
async def test_cached_disabled_follow_setting_still_guards_missing_identity(self):
|
||||
async def test_cached_disabled_follow_setting_skips_missing_identity(self):
|
||||
worker = DouyinWorker(account_id=311, login_mode="im_direct")
|
||||
worker.is_running = True
|
||||
worker._im_service = SimpleNamespace(_running=True)
|
||||
@@ -80,12 +180,12 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||
worker._require_sec_user_id.assert_awaited_once_with("托管运行中")
|
||||
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||
|
||||
async def test_blank_sec_user_id_is_missing_and_stops_hosting(self):
|
||||
worker = DouyinWorker(account_id=303, login_mode="im_direct")
|
||||
@@ -297,6 +397,141 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("托管已自动退出", status_call.kwargs["error_msg"])
|
||||
record_system_log.assert_called_once()
|
||||
|
||||
def test_profile_payload_uid_outranks_query_user_and_cookie_uid(self):
|
||||
"""资料接口的 UID 必须压过 query/user 的 user_uid 和 cookie 兜底。
|
||||
|
||||
实测同一个 Cookie:query/user 返回 user_uid=938334054809296,
|
||||
而账号资料 UID 是 2609567359568155。以前前者先占位,导致账号卡片
|
||||
一直显示「用户938334054809296」,反查 sec_user_id 也永远失败。
|
||||
"""
|
||||
auth = SimpleNamespace(
|
||||
cookie={},
|
||||
msToken="test-ms-token",
|
||||
get_uid=lambda: "938334054809296",
|
||||
)
|
||||
payloads = [
|
||||
# 1) query/user:只有 user_uid,属于弱兜底
|
||||
{"status_code": 0, "user_uid": "938334054809296"},
|
||||
# 2) user/profile/self:权威账号资料
|
||||
{
|
||||
"status_code": 0,
|
||||
"user": {
|
||||
"uid": "2609567359568155",
|
||||
"nickname": "凤的心",
|
||||
"sec_uid": "MS4wLjABAAAA-real-sec-user-id",
|
||||
"unique_id": "39688250979",
|
||||
"aweme_count": 50,
|
||||
"follower_count": 154,
|
||||
},
|
||||
},
|
||||
]
|
||||
responses = [SimpleNamespace(json=lambda value=value: value) for value in payloads]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
account_profile_module, "_build_auth",
|
||||
return_value=(auth, "test-agent"),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module.requests, "get", side_effect=responses,
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module, "generate_a_bogus", return_value="a-bogus",
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module, "generate_webid", return_value="web-id",
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module, "_requests_proxies", return_value=None,
|
||||
),
|
||||
):
|
||||
detail = account_profile_module.fetch_douyin_profile_detail_sync(
|
||||
"cookie-json", "test-agent",
|
||||
)
|
||||
|
||||
self.assertEqual(detail["uid"], "2609567359568155")
|
||||
self.assertEqual(detail["nickname"], "凤的心")
|
||||
self.assertTrue(detail["fetched"])
|
||||
|
||||
async def test_sync_writes_one_identity_to_both_account_and_profile(self):
|
||||
"""卡片与详细资料必须来自同一次抓取,不能各抓一次抓出两个身份。"""
|
||||
detail = {
|
||||
"uid": "2609567359568155",
|
||||
"nickname": "凤的心",
|
||||
"avatar_url": "https://example.test/avatar.png",
|
||||
"unique_id": "39688250979",
|
||||
"signature": "",
|
||||
"sec_user_id": "MS4wLjABAAAA-real-sec-user-id",
|
||||
"sec_user_id_status": "found",
|
||||
"video_count": 50,
|
||||
"follower_count": 154,
|
||||
"following_count": 162,
|
||||
"total_favorited": 612,
|
||||
"favoriting_count": 0,
|
||||
"fetched": True,
|
||||
"message": "",
|
||||
}
|
||||
account = SimpleNamespace(
|
||||
id=1,
|
||||
username="用户938334054809296",
|
||||
douyin_uid="938334054809296",
|
||||
avatar_url=None,
|
||||
user_agent="test-agent",
|
||||
)
|
||||
profile = SimpleNamespace(
|
||||
account_id=1, uid=None, nickname=None, avatar_url=None,
|
||||
unique_id=None, signature=None, sec_user_id=None,
|
||||
follower_count=None, following_count=None, total_favorited=None,
|
||||
favoriting_count=None, video_count=None, synced_at=None,
|
||||
sync_message=None,
|
||||
)
|
||||
profile_result = MagicMock()
|
||||
profile_result.scalar_one_or_none.return_value = profile
|
||||
username_result = MagicMock()
|
||||
username_result.scalar_one_or_none.return_value = None
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=lambda stmt: (
|
||||
username_result if "accounts.username" in str(stmt).lower()
|
||||
or "username" in str(stmt).lower() else profile_result
|
||||
)),
|
||||
add=MagicMock(),
|
||||
commit=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"fetch_douyin_profile_detail_with_sec_user_id",
|
||||
new=AsyncMock(return_value=detail),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"fetch_douyin_user_videos",
|
||||
new=AsyncMock(return_value={"videos": [], "message": "无作品"}),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"fetch_douyin_profile",
|
||||
new=AsyncMock(side_effect=AssertionError("must not re-fetch")),
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"load_account_profile_from_db",
|
||||
new=AsyncMock(return_value={}),
|
||||
),
|
||||
):
|
||||
await account_profile_module.sync_account_profile_to_db(
|
||||
db, account, "cookie-json",
|
||||
)
|
||||
|
||||
# 同一个身份同时写进 accounts 行和 account_profile_details 行
|
||||
self.assertEqual(account.douyin_uid, "2609567359568155")
|
||||
self.assertEqual(account.username, "凤的心")
|
||||
self.assertEqual(account.avatar_url, "https://example.test/avatar.png")
|
||||
self.assertEqual(profile.uid, "2609567359568155")
|
||||
self.assertEqual(profile.nickname, "凤的心")
|
||||
|
||||
async def test_cookie_uid_without_valid_profile_payload_stays_unknown(self):
|
||||
auth = SimpleNamespace(
|
||||
cookie={},
|
||||
@@ -490,7 +725,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
),
|
||||
patch.object(
|
||||
account_profile_module,
|
||||
"apply_douyin_profile",
|
||||
"apply_profile_to_account",
|
||||
apply_profile,
|
||||
),
|
||||
patch.object(
|
||||
@@ -518,19 +753,19 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
"profile endpoint temporarily unavailable",
|
||||
)
|
||||
|
||||
async def test_browser_login_missing_sec_user_id_closes_browser_and_skips_im(self):
|
||||
async def test_browser_login_missing_sec_user_id_still_starts_im(self):
|
||||
worker = DouyinWorker(account_id=305, login_mode="browser")
|
||||
worker.is_running = True
|
||||
events: list[str] = []
|
||||
|
||||
async def reject_identity(*_args, **_kwargs):
|
||||
events.append("require-sec-user-id")
|
||||
return False
|
||||
return ""
|
||||
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
worker._probe_existing_login = AsyncMock(return_value=True)
|
||||
worker._finalize_login_session = AsyncMock()
|
||||
worker._require_sec_user_id = AsyncMock(side_effect=reject_identity)
|
||||
worker._best_effort_sec_user_id = AsyncMock(side_effect=reject_identity)
|
||||
worker._setup_im_network_listener = AsyncMock()
|
||||
worker._navigate_to_message_center = AsyncMock()
|
||||
worker._harvest_im_credentials = AsyncMock()
|
||||
@@ -595,8 +830,8 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
worker._finalize_login_session.assert_awaited_once_with()
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._require_sec_user_id.await_args
|
||||
worker._best_effort_sec_user_id.assert_awaited_once()
|
||||
require_call = worker._best_effort_sec_user_id.await_args
|
||||
self.assertTrue(require_call.kwargs["force_refresh"])
|
||||
self.assertEqual(
|
||||
events,
|
||||
@@ -607,8 +842,10 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
||||
worker._persist_cookies.assert_awaited_once_with()
|
||||
worker._build_im_session.assert_awaited_once_with()
|
||||
worker._persist_im_session.assert_not_awaited()
|
||||
worker._run_im_direct_service.assert_not_awaited()
|
||||
# sec_user_id 只服务关注欢迎语;缺它不能阻断私信托管,
|
||||
# 否则浏览器登录后账号立刻下线、永远不会自动回复。
|
||||
worker._persist_im_session.assert_awaited_once()
|
||||
worker._run_im_direct_service.assert_awaited_once_with(im_session)
|
||||
worker._close_browser_only.assert_awaited_once_with()
|
||||
|
||||
async def test_browser_login_with_sec_user_id_continues_to_im(self):
|
||||
@@ -617,7 +854,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
worker._probe_existing_login = AsyncMock(return_value=True)
|
||||
worker._finalize_login_session = AsyncMock()
|
||||
worker._require_sec_user_id = AsyncMock(return_value=True)
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="sec-uid-306")
|
||||
worker._setup_im_network_listener = AsyncMock()
|
||||
worker._navigate_to_message_center = AsyncMock()
|
||||
worker._harvest_im_credentials = AsyncMock()
|
||||
@@ -674,8 +911,10 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
self.assertTrue(worker._require_sec_user_id.await_args.kwargs["force_refresh"])
|
||||
worker._best_effort_sec_user_id.assert_awaited_once()
|
||||
self.assertTrue(
|
||||
worker._best_effort_sec_user_id.await_args.kwargs["force_refresh"]
|
||||
)
|
||||
worker._setup_im_network_listener.assert_awaited_once_with()
|
||||
worker._navigate_to_message_center.assert_awaited_once_with()
|
||||
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
|
||||
|
||||
@@ -18,9 +18,12 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from rpa_engine.douyin_im import http_client as http_client_module
|
||||
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.egress_channels import EgressChannel
|
||||
from rpa_engine import playwright_worker as playwright_worker_module
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
@@ -33,6 +36,75 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
return DouyinImHttpClient(session, account_id=account_id)
|
||||
|
||||
def test_query_user_does_not_replace_existing_im_uid(self):
|
||||
client = self._make_client()
|
||||
auth = SimpleNamespace(
|
||||
source_ip="",
|
||||
get_uid=MagicMock(return_value=938334054809296),
|
||||
)
|
||||
|
||||
resolved = client._resolve_authoritative_uid(auth)
|
||||
|
||||
self.assertEqual(resolved, 10001)
|
||||
self.assertEqual(client.session.my_uid, 10001)
|
||||
auth.get_uid.assert_not_called()
|
||||
|
||||
def test_verified_uid_does_not_replace_the_runtime_device_id(self):
|
||||
# device_id 是 query/user 返回的设备注册号,my_uid 是账号 UID。
|
||||
# 拿 my_uid 顶替 device_id 会让 frontier 订阅到另一个地址:握手照样
|
||||
# 成功,却永远收不到这个账号的私信。
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
my_uid=2609567359568155,
|
||||
device_id="7678285795559818786",
|
||||
web_id="7678286623234475535",
|
||||
uid_verified=True,
|
||||
)
|
||||
|
||||
auth = DouyinAuth.from_im_session(session)
|
||||
|
||||
self.assertEqual(auth.device_id, "7678285795559818786")
|
||||
self.assertEqual(session.device_id, "7678285795559818786")
|
||||
|
||||
def test_captured_tokenless_browser_frontier_url_is_kept(self):
|
||||
url = (
|
||||
"wss://frontier100-normal.zijieapi.com/ws/v2?aid=6383&"
|
||||
"device_platform=web&fpid=9&device_id=7678285795559818786&"
|
||||
"access_key=0123456789abcdef0123456789abcdef&version_code=fws_1.0.0"
|
||||
)
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
ws_urls=[url],
|
||||
my_uid=2609567359568155,
|
||||
device_id="7678285795559818786",
|
||||
)
|
||||
|
||||
resolved = ensure_frontier_ws(session)
|
||||
|
||||
self.assertEqual(resolved, url)
|
||||
self.assertEqual(session.ws_urls, [url])
|
||||
|
||||
def test_non_im_frontier_product_is_rejected(self):
|
||||
url = (
|
||||
"wss://frontier100-normal.zijieapi.com/ws/v2?aid=6383&"
|
||||
"device_platform=web&fpid=971&device_id=7678285795559818786&"
|
||||
"access_key=0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "test-session"},
|
||||
ws_urls=[url],
|
||||
my_uid=2609567359568155,
|
||||
device_id="7678285795559818786",
|
||||
uid_verified=True,
|
||||
)
|
||||
|
||||
resolved = ensure_frontier_ws(session)
|
||||
|
||||
self.assertIn("frontier-im.douyin.com", resolved)
|
||||
self.assertIn("fpid=9", resolved)
|
||||
self.assertIn("device_id=7678285795559818786", resolved)
|
||||
self.assertNotIn("fpid=971", resolved)
|
||||
|
||||
async def test_public_entry_submits_the_whole_send_to_outbound_queue(self):
|
||||
client = self._make_client(account_id=73)
|
||||
submit = AsyncMock(return_value=True)
|
||||
@@ -194,6 +266,59 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class WorkerLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_virtual_display_starts_before_playwright_driver(self):
|
||||
ensure_display = AsyncMock()
|
||||
|
||||
async def start_driver():
|
||||
ensure_display.assert_awaited_once_with(False)
|
||||
return "playwright-driver"
|
||||
|
||||
manager = SimpleNamespace(start=AsyncMock(side_effect=start_driver))
|
||||
with (
|
||||
patch.object(
|
||||
playwright_worker_module,
|
||||
"resolve_headless",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
playwright_worker_module,
|
||||
"ensure_browser_display",
|
||||
ensure_display,
|
||||
),
|
||||
patch.object(
|
||||
playwright_worker_module,
|
||||
"async_playwright",
|
||||
return_value=manager,
|
||||
),
|
||||
):
|
||||
driver, headless = await playwright_worker_module._start_playwright_for_browser()
|
||||
|
||||
self.assertEqual(driver, "playwright-driver")
|
||||
self.assertFalse(headless)
|
||||
manager.start.assert_awaited_once_with()
|
||||
|
||||
async def test_visible_login_prompt_overrides_stale_sessionid(self):
|
||||
worker = DouyinWorker(account_id=917, login_mode="browser")
|
||||
worker._has_visible_login_prompt = AsyncMock(return_value=True)
|
||||
worker.check_homepage_login_status = AsyncMock(return_value=True)
|
||||
worker.check_logged_in_by_cookie = AsyncMock(return_value=True)
|
||||
|
||||
logged_in = await worker._verify_login_state()
|
||||
|
||||
self.assertFalse(logged_in)
|
||||
worker.check_homepage_login_status.assert_not_awaited()
|
||||
worker.check_logged_in_by_cookie.assert_not_awaited()
|
||||
|
||||
async def test_homepage_message_entry_is_not_login_evidence(self):
|
||||
worker = DouyinWorker(account_id=918, login_mode="browser")
|
||||
worker._has_visible_login_prompt = AsyncMock(return_value=False)
|
||||
worker.page = SimpleNamespace(query_selector=AsyncMock(return_value=None))
|
||||
|
||||
logged_in = await worker.check_homepage_login_status()
|
||||
|
||||
self.assertFalse(logged_in)
|
||||
worker.page.query_selector.assert_awaited_once()
|
||||
|
||||
async def test_start_saves_task_and_stop_waits_until_it_is_done(self):
|
||||
worker = DouyinWorker(account_id=919, login_mode="im_direct")
|
||||
loop_started = asyncio.Event()
|
||||
|
||||
@@ -109,7 +109,7 @@ class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
worker._build_im_session_from_storage = AsyncMock(return_value=session)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="sec-user")
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="sec-user")
|
||||
worker._persist_im_session = AsyncMock()
|
||||
worker._run_im_direct_service = AsyncMock()
|
||||
|
||||
@@ -124,7 +124,10 @@ class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(started)
|
||||
self.assertEqual(reason, "")
|
||||
validate.assert_not_awaited()
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
worker._best_effort_sec_user_id.assert_awaited_once_with(
|
||||
refresh_if_missing=True,
|
||||
refresh_if_stale=True,
|
||||
)
|
||||
worker._run_im_direct_service.assert_awaited_once_with(session)
|
||||
|
||||
async def test_disabled_follow_welcome_uses_cached_lightweight_config(self):
|
||||
@@ -171,20 +174,18 @@ class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker.get_db.assert_not_awaited()
|
||||
worker._require_sec_user_id.assert_not_awaited()
|
||||
|
||||
async def test_missing_cached_sec_user_id_stops_hosting(self):
|
||||
async def test_disabled_follow_welcome_ignores_missing_cached_sec_user_id(self):
|
||||
worker = DouyinWorker(account_id=505)
|
||||
worker._im_service = SimpleNamespace(session=object())
|
||||
worker._follow_config_loaded = True
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
||||
worker._best_effort_sec_user_id = AsyncMock(return_value="")
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._require_sec_user_id.assert_awaited_once_with(
|
||||
"托管运行中"
|
||||
)
|
||||
worker._best_effort_sec_user_id.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,7 +20,10 @@ from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.douyin_im.ws_client import DouyinImWsClient, _reconnect_delay
|
||||
|
||||
|
||||
TEST_WS_URL = "wss://frontier-im.douyin.com/ws/v2?token=test-token-value"
|
||||
TEST_WS_URL = (
|
||||
"wss://frontier-im.douyin.com/ws/v2?fpid=9&device_id=10001&"
|
||||
"token=test-token-value"
|
||||
)
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
@@ -108,6 +111,25 @@ class WebSocketScalingTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertFalse(client.connected)
|
||||
self.assertIsNone(client._connection)
|
||||
|
||||
async def test_browser_frontier_uses_text_heartbeat_and_filters_ack(self):
|
||||
client = self._make_client()
|
||||
client._running = True
|
||||
websocket = AsyncMock()
|
||||
|
||||
heartbeat = asyncio.create_task(client._run_browser_heartbeat(websocket))
|
||||
for _ in range(20):
|
||||
if websocket.send.await_count:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
heartbeat.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await heartbeat
|
||||
|
||||
websocket.send.assert_awaited_once_with("hi")
|
||||
with patch.object(ws_module, "parse_ws_payload") as parse:
|
||||
await client._dispatch("hi")
|
||||
parse.assert_not_called()
|
||||
|
||||
async def test_starting_500_clients_does_not_create_os_threads(self):
|
||||
parked = asyncio.Event()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user