Files
creator-hub/browser_gateway/test_private_messages.py
T
rogee e50bed9ad8
douyin-release-gate / verify (push) Failing after 18m54s
feat: add account events and private message management
Add Douyin notification polling, event details, and manual multi-account private messaging.

Refine environment memory settings, account operations, login collection recovery, and message UI; update tests and documentation.
2026-10-07 10:23:05 +08:00

287 lines
19 KiB
Python

import unittest
import threading
import json
import subprocess
from contextlib import nullcontext, contextmanager
from unittest.mock import Mock, patch
from .platform.douyin import DouyinBrowser, DouyinError, inbox_expression, im_expression, private_identity_expression
from .server.http import Gateway, GatewayHandler, RequestError
class PrivateMessageTests(unittest.TestCase):
def test_private_login_uses_sdk_uid_and_never_queries_profile(self):
script='''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>{throw Error('login must not read messages')}};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:{imSdkManager:{getImSdkInstance:()=>sdk}}}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:()=>{throw Error('profile is not chat authentication')},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}};
(async()=>{
let result=await vm.runInNewContext(source,context);assert.equal(result.status,'logged_in');assert.equal(result.uid,'123');
sdk.ctx.option.userId='999';result=await vm.runInNewContext(source,context);assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');
context.window={};result=await vm.runInNewContext(source,context);assert.equal(result.status,'manual_login');assert.equal(result.reason,'awaiting_login');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(private_identity_expression('123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_private_message_entry_reuses_logged_in_profile_without_qr(self):
from .platform.douyin import CHAT_PAGE_URL, CHAT_ENTRY_SCRIPT
self.assertEqual(CHAT_PAGE_URL,'https://www.douyin.com/user/self')
self.assertIn('im-entry',CHAT_ENTRY_SCRIPT)
self.assertIn('something-button',CHAT_ENTRY_SCRIPT)
self.assertIn('imSaasContainerId',CHAT_ENTRY_SCRIPT)
self.assertNotIn('get_qrcode',CHAT_ENTRY_SCRIPT)
script='''
const vm=require('node:vm'),assert=require('node:assert/strict');let opened=false,clicks=0;
const container={getBoundingClientRect:()=>({width:opened?700:0,height:opened?400:0})};
const entry={click:()=>{clicks++;opened=true}};
const document={querySelector:selector=>selector==='#imSaasContainerId'?container:entry};
(async()=>{const context={document,location:{origin:'https://www.douyin.com'},Date,setTimeout};
assert.equal((await vm.runInNewContext(SOURCE,context)).ready,true);
assert.equal((await vm.runInNewContext(SOURCE,context)).ready,true);assert.equal(clicks,1);
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(CHAT_ENTRY_SCRIPT))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_gateway_requests_queue_before_the_native_exclusive_lock(self):
active=threading.Lock();entered=threading.Event();release=threading.Event();second=threading.Event();errors=[]
@contextmanager
def native_lock(alias):
if not active.acquire(blocking=False):raise AssertionError('native lock overlapped')
try:yield
finally:active.release()
runtimes=Mock();runtimes.alias_lock.side_effect=native_lock
gateway=Gateway(runtimes,'token','node',Mock())
def first():
try:
with gateway._alias_lock('account'):entered.set();release.wait(3)
except BaseException as e:errors.append(e)
def following():
try:
with gateway._alias_lock('account'):second.set()
except BaseException as e:errors.append(e)
t1=threading.Thread(target=first);t2=threading.Thread(target=following);t1.start();self.assertTrue(entered.wait(1));t2.start()
try:self.assertFalse(second.wait(.1));self.assertEqual(errors,[])
finally:release.set();t1.join(3);t2.join(3)
self.assertTrue(second.is_set());self.assertEqual(errors,[])
def test_extra_private_login_routes_are_removed(self):
handler=object.__new__(GatewayHandler);gateway=Mock();handler.server_as_gateway=Mock(return_value=Mock(gateway=gateway))
for action in ['private-login-qr','private-login-verify']:
with self.assertRaises(RequestError):handler._route('POST','/v1/browsers/account/douyin/'+action,{}, {'expected_uid':'123'})
def test_listener_pacing_does_not_wait_while_holding_alias_lock(self):
gateway = object.__new__(Gateway)
gateway._alias_lock = lambda alias: threading.Lock()
gateway._require_douyin_generation = Mock()
gateway.subscriptions = Mock()
gateway.subscriptions.poll.return_value = []
payload = {'runtime_id':'a'*64, 'network_id':'native-'+'b'*32, 'binding_version':1}
self.assertEqual(gateway.poll_douyin_events('account', payload, {'wait':['25']}), [])
gateway.subscriptions.poll.assert_called_once_with('account', 50, 0)
def test_listener_restart_validates_checkpoint_before_touching_browser(self):
gateway = object.__new__(Gateway)
gateway.subscriptions = Mock()
with self.assertRaises(RequestError):
gateway.start_douyin_events('account', {'expected_uid':'123','notice_checkpoints':{'960':'1'}})
gateway.subscriptions.start.assert_not_called()
def test_inbox_route_is_dispatched(self):
handler = object.__new__(GatewayHandler)
gateway = Mock()
server = Mock(gateway=gateway)
handler.server_as_gateway = Mock(return_value=server)
body = {'expected_uid':'123'}
handler._route('POST', '/v1/browsers/account/douyin/inbox', {}, body)
gateway.douyin_inbox.assert_called_once_with('account',body)
def test_inbox_script_uses_identity_and_server_messages(self):
script = inbox_expression('123')
for text in ['getConversationList', 'getMessageList', 's:server_message_create_time', 's:sender_uid', 'serverId', 'ACCOUNT_IDENTITY_MISMATCH', 'IM_INBOX_LIMIT_EXCEEDED']:
self.assertIn(text, script)
self.assertNotIn('getMessagesByConversation', script)
self.assertNotIn('sendMessage(', script)
self.assertNotIn('Date.now()', script)
self.assertIn('123', script)
def test_sdk_network_error_does_not_claim_definitive_rejection(self):
script='''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;
let result={success:false,statusCode:1008,status:1};
const conversation={type:1,id:'conversation',shortId:'short',toParticipantUserId:'456'};
const message={serverId:'100',clientId:'client',sender:'123',type:7,content:'{}',sendFunc(){}};
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation],createMessage:async()=>message,sendMessage:async()=>result};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:{imSdkManager:{getImSdkInstance:()=>sdk}}}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>({ok:true,json:async()=>({status_code:0,user:{uid:'123'}})}),AbortSignal:{timeout:()=>null},Date,setTimeout:()=>0,window:{'@pc-im/im:version':chunks}};
(async()=>{
let response=await vm.runInNewContext(source,context);assert.equal(response.status,'unknown');assert.equal(response.code,'SDK_NETWORK_ERROR');assert.equal(response.status_code,1008);
result={success:false,statusCode:0,checkCode:5,checkMessage:'unavailable'};response=await vm.runInNewContext(source,context);assert.equal(response.status,'failed');assert.equal(response.check_code,'5');
result={success:false,statusCode:0};response=await vm.runInNewContext(source,context);assert.equal(response.status,'unknown');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(im_expression({'uid':'456','action':'send','text':'你好','confirm':True},'123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_inbox_reads_live_sdk_cache_and_honors_committed_checkpoints(self):
script = '''
const vm=require('node:vm'),assert=require('node:assert/strict');
const source=SOURCE;
let seen=0;
const message={type:7,serverId:'100',sender:'456',content:'{"aweType":700,"text":"你好"}',ext:{'s:server_message_create_time':'1700000000000'}};
const conversation={type:1,toParticipantUserId:'456',lastMessage:message,coreInfo:{name:'好友'},getMessageList(){seen++;return [message]}};
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation]};
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:{imSdkManager:{getImSdkInstance:()=>sdk}}}}}}}};
const chunks=[];chunks.push=function(args){args[2](req)};
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw Error('inbox polling must not make repeated profile requests')},AbortSignal:{timeout:()=>null},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}};
(async()=>{
let result=await vm.runInNewContext(source,context);
assert.equal(result.status,'succeeded');assert.equal(result.messages[0].server_id,'100');assert.equal(result.checkpoints['456'],'100');assert.equal(seen,1);
result=await vm.runInNewContext(source.replace('const expected="123", checkpoints={}', 'const expected="123", checkpoints={"456":"100"}'),context);
assert.equal(result.messages.length,0);assert.equal(seen,1);
sdk.ctx.option.userId='999';result=await vm.runInNewContext(source,context);assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');sdk.ctx.option.userId='123';
message.sender='777';result=await vm.runInNewContext(source,context);assert.equal(result.code,'MESSAGE_IDENTITY_INVALID');
conversation.toParticipantUserId='bad';result=await vm.runInNewContext(source,context);assert.equal(result.code,'CONVERSATION_IDENTITY_INVALID');
context.window={};result=await vm.runInNewContext(source,context);assert.equal(result.code,'IM_SDK_NOT_READY');
})().catch(e=>{console.error(e);process.exitCode=1});
'''.replace('SOURCE',json.dumps(inbox_expression('123')))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_action_ownership_write_returns_a_cdp_value(self):
browser=object.__new__(DouyinBrowser)
browser._evaluate=Mock()
browser.set_action_ownership('account',{'operation_id':'one'})
self.assertIn('return true',browser._evaluate.call_args.args[1])
def test_manual_dm_uses_the_chat_page_not_the_primary_page(self):
browser=DouyinBrowser()
private=Mock();private._evaluate.return_value={'status':'logged_in','uid':'123'};private._confirmed_evaluate.return_value={'status':'succeeded'}
browser._chat_browser=Mock(return_value=private)
browser.identity=Mock(side_effect=AssertionError('primary page used for DM'))
result=browser.action('account','123','dm','456',text='你好',confirm=True)
self.assertEqual(result['status'],'succeeded')
private.identity.assert_not_called()
private._evaluate.assert_called_once_with('account',private_identity_expression('123'))
private._confirmed_evaluate.assert_called_once()
browser.identity.assert_not_called()
def test_chat_page_is_created_once_and_never_navigates_the_primary_page(self):
browser=DouyinBrowser(endpoint=lambda alias:'http://127.0.0.1:19000')
from urllib.parse import urlsplit
primary={'id':'primary','type':'page','url':'https://www.douyin.com/user/self'}
chat={'id':'chat','type':'page','url':'https://www.douyin.com/user/self#__creatorhub_private_messages'}
browser._target_list=Mock(side_effect=[(urlsplit('http://127.0.0.1:19000'),19000,[primary]),(urlsplit('http://127.0.0.1:19000'),19000,[primary,chat])])
browser._create_chat_target=Mock(return_value='chat')
dedicated=Mock();cdp=Mock();cdp.evaluate.side_effect=lambda expression:'/user/self' if expression=='location.pathname' else {'ready':True}
dedicated.connection=lambda alias:nullcontext(cdp);browser._browser_for_target=Mock(return_value=dedicated)
self.assertIs(browser._chat_browser('account'),dedicated)
self.assertIs(browser._chat_browser('account'),dedicated)
browser._create_chat_target.assert_called_once()
browser._browser_for_target.assert_called_with('chat')
def test_chat_page_is_rediscovered_after_gateway_restart(self):
browser=DouyinBrowser(endpoint=lambda alias:'http://127.0.0.1:19000')
from urllib.parse import urlsplit
browser._target_list=Mock(return_value=(urlsplit('http://127.0.0.1:19000'),19000,[{'id':'primary','type':'page','url':'https://www.douyin.com/user/self'},{'id':'chat','type':'page','url':'https://www.douyin.com/user/self#__creatorhub_private_messages'}]))
browser._is_chat_target=Mock(side_effect=lambda alias,target:target['id']=='chat')
browser._create_chat_target=Mock()
dedicated=Mock();cdp=Mock();cdp.evaluate.side_effect=lambda expression:'/user/self' if expression=='location.pathname' else {'ready':True}
dedicated.connection=lambda alias:nullcontext(cdp);browser._browser_for_target=Mock(return_value=dedicated)
self.assertIs(browser._chat_browser('account'),dedicated)
browser._create_chat_target.assert_not_called()
self.assertEqual(browser._chat_targets['account'],'chat')
def test_chat_entry_does_not_wait_on_background_page_timers(self):
from .platform.douyin import CHAT_ENTRY_SCRIPT
script = '''
const vm=require('node:vm'),assert=require('node:assert/strict');let clicks=0;
const context={document:{querySelector:s=>s==='#imSaasContainerId'?null:{click:()=>clicks++}},location:{origin:'https://www.douyin.com'},Date,setTimeout:()=>{throw Error('background timers cannot drive readiness')}};
const result=vm.runInNewContext(SOURCE,context);
assert.equal(typeof result.then,'undefined');assert.equal(result.ready,false);assert.equal(result.clicked,true);assert.equal(clicks,1);
'''.replace('SOURCE', json.dumps(CHAT_ENTRY_SCRIPT))
result = subprocess.run(['node', '-e', script], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def chat_setup(self, cdp):
from urllib.parse import urlsplit
browser = DouyinBrowser(endpoint=lambda alias: 'http://127.0.0.1:19000')
browser._target_list = Mock(return_value=(urlsplit('http://127.0.0.1:19000'), 19000, [{'id': 'chat', 'type': 'page', 'url': 'https://www.douyin.com/user/self#__creatorhub_private_messages'}]))
browser._chat_targets['account'] = 'chat'
dedicated = Mock()
dedicated.connection = lambda alias: nullcontext(cdp)
browser._browser_for_target = Mock(return_value=dedicated)
return browser, dedicated
def test_stale_chat_page_is_refreshed_once_without_touching_the_primary_page(self):
from .platform.douyin import CHAT_ENTRY_SCRIPT
cdp = Mock(); refreshed = False
def evaluate(script):
if script == 'location.pathname': return '/user/self'
return {'ready': refreshed, 'clicked': not refreshed}
def call(method, *args, **kwargs):
nonlocal refreshed
if method == 'Page.reload': refreshed = True
cdp.evaluate.side_effect = evaluate; cdp.command.side_effect = call
browser, dedicated = self.chat_setup(cdp)
with patch('browser_gateway.platform.douyin.time.monotonic', side_effect=[0, 0, 13, 13, 13]), patch('browser_gateway.platform.douyin.time.sleep'):
self.assertIs(browser._chat_browser('account'), dedicated)
self.assertEqual([c.args[0] for c in cdp.command.call_args_list], ['Page.enable', 'Page.reload'])
cdp.wait_event.assert_called_once()
self.assertEqual(cdp.wait_event.call_args.args[0], 'Page.domContentEventFired')
self.assertEqual(sum(c.args[0] == CHAT_ENTRY_SCRIPT for c in cdp.evaluate.call_args_list), 2)
browser._browser_for_target.assert_called_once_with('chat')
def test_chat_polling_clicks_entry_once_and_still_reports_failure_after_refresh(self):
from .platform.douyin import CHAT_ENTRY_SCRIPT
cdp = Mock()
cdp.evaluate.side_effect = lambda script: '/user/self' if script == 'location.pathname' else {'ready': False, 'clicked': True}
browser, _ = self.chat_setup(cdp)
with patch('browser_gateway.platform.douyin.time.monotonic', side_effect=[0, 0, 1, 13, 13, 13, 14, 26]), patch('browser_gateway.platform.douyin.time.sleep'):
with self.assertRaisesRegex(DouyinError, 'private-message panel.*refresh'):
browser._chat_browser('account')
self.assertEqual(sum(c.args[0] == CHAT_ENTRY_SCRIPT for c in cdp.evaluate.call_args_list), 2)
self.assertEqual([c.args[0] for c in cdp.command.call_args_list], ['Page.enable', 'Page.reload'])
def test_chat_invalid_response_is_not_hidden_by_refresh(self):
cdp = Mock()
cdp.evaluate.side_effect = ['/user/self', {}]
browser, _ = self.chat_setup(cdp)
with self.assertRaisesRegex(DouyinError, 'response is invalid'):
browser._chat_browser('account')
cdp.command.assert_not_called()
def test_browser_inbox_validation_and_response(self):
browser = object.__new__(DouyinBrowser)
browser.identity = Mock()
browser._evaluate = Mock(return_value={'status': 'succeeded', 'account_uid': '123', 'messages': []})
browser._chat_browser = Mock(return_value=browser)
self.assertEqual(browser.inbox('account', '123')['status'], 'succeeded')
browser.identity.assert_not_called()
with self.assertRaises(DouyinError): browser.inbox('account', 'invalid')
browser._evaluate.return_value = []
with self.assertRaises(DouyinError): browser.inbox('account', '123')
def test_gateway_generation_and_response(self):
gateway = object.__new__(Gateway)
gateway.browser = Mock()
gateway.browser.inbox.return_value = {'status':'succeeded','account_uid':'123','messages':[]}
gateway._alias_lock = Mock(return_value=nullcontext())
gateway._require_douyin_generation = Mock()
payload = {'runtime_id':'a'*64,'network_id':'native-'+'b'*32,'binding_version':1,'expected_uid':'123'}
self.assertEqual(gateway.douyin_inbox('alias', payload)['status'],'succeeded')
self.assertEqual(gateway._require_douyin_generation.call_count,2)
gateway.browser.inbox.assert_called_once_with('alias','123',{})
for bad in [{},dict(payload,expected_uid='bad'),dict(payload,binding_version=0)]:
with self.assertRaises(RequestError):gateway.douyin_inbox('alias',bad)
gateway.browser.inbox.return_value = {'status':'failed','code':'IM_SDK_NOT_READY'}
self.assertEqual(gateway.douyin_inbox('alias',payload)['code'],'IM_SDK_NOT_READY')
gateway.browser.inbox.side_effect = DouyinError('broken')
with self.assertRaises(RequestError):gateway.douyin_inbox('alias',payload)