fix(messages): use chat profiles and diagnose stale gateway responses
This commit is contained in:
@@ -60,7 +60,7 @@
|
||||
|
||||
事件聚合资料:互动用户展示真实昵称与 UID,以平台返回的 secUID 链接主页,不使用数字 UID 猜测主页;发生时间在前、接收时间紧邻其后,所属账号及筛选选项仅显示昵称。对应作品展示 48px 小封面并链接抖音作品详情,图文使用 note 地址;缺封面保留作品入口,缺资料明确标示。通知中的作品封面复用 `<作者 UID>/<作品 ID>.<图片扩展名>` 本地缓存,不创建占位账号或作品,不回退展示远程图片。历史核对可补齐已保存事件的用户与作品资料,但只更新同一互动 UID、同一作品 ID 的资料,不改原事件时间、内容、历史标记或去重规则;封面下载失败单独记录和展示,不能阻断通知保存,也不能伪装成已缓存。
|
||||
|
||||
私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。会话列表与消息区联系人展示抖音用户资料中的真实昵称,不用 UID 或会话标题占位;未获取昵称显示“昵称未获取”。昵称独立于新消息同步,已有会话可补齐及更新昵称;核验资料 UID 与会话对方一致,读取失败明确显示错误但不阻断消息保存,不改发送对象或聊天记录。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。
|
||||
私信管理:仅支持已开启监听的自有抖音账号,独立聊天标签共享原浏览器资料,不干扰采集或主页面登录。展示聊天客户端已加载会话的最近 50 条消息,不宣称完整历史;只支持手动发送文字,不自动回复、不群发。聊天界面采用 Ant Design X Conversations、Bubble.List 和 Sender:左侧合并联系人并明确所属账号,同一联系人在不同账号下为独立会话,草稿按账号与联系人隔离。会话列表与消息区联系人展示聊天客户端专用资料接口返回的真实昵称,支持普通用户与 AI 分身,不用普通主页接口代替、不用 UID 或会话标题占位;平台昵称本身为“用户+数字”时保留原值,未获取昵称显示“昵称未获取”。昵称独立于新消息同步,已有会话可补齐及更新昵称;核验资料 UID 与会话对方一致,读取失败明确显示错误但不阻断消息保存,不改发送对象或聊天记录。专用标签从已登录的个人中心“消息”入口进入,沿用现有登录,不增加私信扫码;直接 /chat 的二维码不能作为账号未登录的依据。仅以聊天客户端的实际 UID 与已绑定 UID 一致确认私信身份,不创建账号或改变绑定。发送请求先持久化并按请求 ID 去重;超时、重启中断及 SDK 网络错误 1008 标记“结果未确认”,不自动重发,不伪造送达或已读。同步记录须持久化后推进检查点;关闭监听阻止旧代次写入。
|
||||
|
||||
评论聚合:独立页面通过“我的作品评论”“竞品作品评论”两个 TAB 严格区分来源,各 TAB 保留筛选与页码。列表展示评论发布时间、评论者、内容、所属账号名称及对应作品的小封面;所属账号列与筛选下拉均不展示 UID,缺名仅显示前端“未命名账号”,评论者显示不变;作品使用本地封面,点击在新标签页打开抖音作品页面,缺图明确提示,不展示作品标题、不使用远程图片兜底。支持所属账号筛选,以及最近 1/6/12 小时、1/3/5/7 天筛选,默认最近 1 天。按评论发布时间计算范围,最新评论在前,同时间按评论 ID 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。
|
||||
|
||||
|
||||
@@ -1351,24 +1351,45 @@ INBOX_SCRIPT = r"""(async()=>{
|
||||
if(!/^[1-9][0-9]{0,19}$/.test(peer)||peer===expected)fail("CONVERSATION_IDENTITY_INVALID");
|
||||
}
|
||||
let cache=window.__creatorhubPrivatePeerProfiles;
|
||||
if(!cache||cache.sdk!==sdk||cache.uid!==expected){cache={sdk,uid:expected,profiles:new Map()};window.__creatorhubPrivatePeerProfiles=cache;}
|
||||
const peerIds=[...new Set(conversations.map(c=>String(c.toParticipantUserId)))];
|
||||
const peers=await Promise.all(peerIds.map(async peer=>{
|
||||
const saved=cache.profiles.get(peer);if(saved&&saved.expiresAt>Date.now())return saved.value;
|
||||
let value;
|
||||
try{
|
||||
const qs=new URLSearchParams({device_platform:'webapp',aid:'6383',channel:'channel_pc_web',user_id:peer});
|
||||
const response=await fetch(location.origin+'/aweme/v1/web/user/profile/other/?'+qs,{credentials:'include',signal:AbortSignal.timeout(10000)});
|
||||
if(!response.ok)throw Error('昵称读取 HTTP '+response.status);
|
||||
const data=await response.json();if(data.status_code!==0)throw Error('昵称读取平台错误 '+data.status_code);
|
||||
const user=data.user;
|
||||
if(!user||String(user.uid||'')!==peer)throw Error('昵称资料 UID 与会话对方不一致');
|
||||
if(typeof user.nickname!=='string'||!user.nickname.trim())throw Error('昵称资料缺少真实昵称');
|
||||
value={peer_uid:peer,peer_name:user.nickname.trim(),error:''};
|
||||
}catch(error){value={peer_uid:peer,peer_name:'',error:'私信昵称读取失败(UID '+peer+'):'+String(error)};}
|
||||
cache.profiles.set(peer,{value,expiresAt:Date.now()+(value.error?30000:300000)});
|
||||
return value;
|
||||
if(!cache||cache.sdk!==sdk||cache.uid!==expected||cache.source!=='im_user_info'){
|
||||
cache={sdk,uid:expected,source:'im_user_info',profiles:new Map()};window.__creatorhubPrivatePeerProfiles=cache;
|
||||
}
|
||||
const contacts=new Map(conversations.map(c=>{
|
||||
const peer=String(c.toParticipantUserId),participants=c.firstPageParticipant?.participants;
|
||||
const participant=Array.isArray(participants)?participants.find(p=>String(p.user_id)===peer):null;
|
||||
return [peer,typeof participant?.sec_uid==='string'?participant.sec_uid:''];
|
||||
}));
|
||||
const saveProfile=(peer,secUid,name,error)=>{
|
||||
const value={peer_uid:peer,peer_name:name,error:error?'私信昵称读取失败(UID '+peer+'):'+String(error):''};
|
||||
cache.profiles.set(peer,{value,secUid,expiresAt:Date.now()+(error?30000:300000)});
|
||||
};
|
||||
const pending=[];
|
||||
for(const [peer,secUid] of contacts){
|
||||
const saved=cache.profiles.get(peer);if(saved&&saved.secUid===secUid&&saved.expiresAt>Date.now())continue;
|
||||
if(!secUid)saveProfile(peer,secUid,'','会话缺少可信的对方 secUID');else pending.push({peer,secUid});
|
||||
}
|
||||
const batches=[];for(let i=0;i<pending.length;i+=30)batches.push(pending.slice(i,i+30));
|
||||
await Promise.all(batches.map(async batch=>{
|
||||
try{
|
||||
const manager=service.context?.requestManager;if(typeof manager?.request!=='function')throw Error('私信资料读取接口不可用');
|
||||
const response=await manager.request({method:'POST',url:'/aweme/v1/web/im/user/info/?version_code=170400',
|
||||
headers:{'Content-Type':'application/x-www-form-urlencoded'},data:{sec_user_ids:JSON.stringify(batch.map(p=>p.secUid))},timeout:10000});
|
||||
if(response?.status_code!==0)throw Error('昵称读取平台错误 '+response?.status_code);
|
||||
if(!Array.isArray(response.data))throw Error('昵称响应缺少资料列表');
|
||||
const users=new Map();
|
||||
for(const user of response.data){
|
||||
const uid=String(user?.uid||'');
|
||||
if(!batch.some(p=>p.peer===uid&&p.secUid===user.sec_uid))throw Error('昵称资料 UID 或 secUID 与会话对方不一致');
|
||||
if(users.has(uid))throw Error('昵称响应包含重复的用户资料');
|
||||
users.set(uid,user);
|
||||
}
|
||||
for(const {peer,secUid} of batch){
|
||||
const user=users.get(peer),name=typeof user?.nickname==='string'?user.nickname.trim():'';
|
||||
saveProfile(peer,secUid,name,name?'':'聊天客户端未返回真实昵称');
|
||||
}
|
||||
}catch(error){for(const {peer,secUid} of batch)saveProfile(peer,secUid,'',error);}
|
||||
}));
|
||||
const peers=[...contacts.keys()].map(peer=>cache.profiles.get(peer).value);
|
||||
if(String(sdk.ctx?.option?.userId||'')!==expected)fail('ACCOUNT_IDENTITY_MISMATCH');
|
||||
const peerNames=new Map(peers.map(peer=>[peer.peer_uid,peer.peer_name]));
|
||||
for(const conversation of conversations){
|
||||
|
||||
@@ -135,12 +135,13 @@ 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:'456'},getMessageList(){seen++;return [message]}};
|
||||
const conversation={type:1,toParticipantUserId:'456',firstPageParticipant:{participants:[{user_id:'456',sec_uid:'peer-sec'}]},lastMessage:message,coreInfo:{name:'456'},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)};
|
||||
let profileCalls=0;
|
||||
const context={URLSearchParams,location:{origin:'https://www.douyin.com'},fetch:async url=>{profileCalls++;assert.ok(url.includes('/user/profile/other/'));assert.ok(url.includes('user_id=456'));return {ok:true,json:async()=>({status_code:0,user:{uid:'456',nickname:'真实昵称'}})}},AbortSignal:{timeout:()=>null},crypto:{randomUUID:()=> 'id'},window:{'@pc-im/im:version':chunks}};
|
||||
const service={imSdkManager:{getImSdkInstance:()=>sdk},context:{requestManager:{request:async config=>{profileCalls++;assert.equal(config.method,'POST');assert.equal(config.url,'/aweme/v1/web/im/user/info/?version_code=170400');assert.equal(config.headers['Content-Type'],'application/x-www-form-urlencoded');assert.deepEqual(JSON.parse(config.data.sec_user_ids),['peer-sec']);return {status_code:0,data:[{uid:'456',sec_uid:'peer-sec',nickname:'真实昵称'}]}}}}};
|
||||
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}};
|
||||
const chunks=[];chunks.push=function(args){args[2](req)};
|
||||
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw Error('must not use public profile endpoint')},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);
|
||||
@@ -159,30 +160,52 @@ const context={URLSearchParams,location:{origin:'https://www.douyin.com'},fetch:
|
||||
def test_inbox_profiles_refresh_without_messages_and_report_failures(self):
|
||||
script = '''
|
||||
const vm=require('node:vm'),assert=require('node:assert/strict');
|
||||
const source=SOURCE;let now=1000,calls=0,profile={uid:'456',nickname:'初始昵称'},status=0,ok=true,network=false;
|
||||
const conversation={type:1,toParticipantUserId:'456',lastMessage:{serverId:'100'},coreInfo:{name:'不是用户昵称'},getMessageList(){throw Error('unchanged checkpoint must not read messages')}};
|
||||
const source=SOURCE;let now=1000,calls=0,profile={uid:'456',sec_uid:'peer-sec',nickname:'初始昵称'},status=0,network=false,data;
|
||||
const conversation={type:1,toParticipantUserId:'456',firstPageParticipant:{participants:[{user_id:'456',sec_uid:'peer-sec'}]},lastMessage:{serverId:'100'},coreInfo:{name:'不是用户昵称'},getMessageList(){throw Error('unchanged checkpoint must not read messages')}};
|
||||
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>[conversation]};
|
||||
const service={imSdkManager:{getImSdkInstance:()=>sdk}};
|
||||
const service={imSdkManager:{getImSdkInstance:()=>sdk},context:{requestManager:{request:async()=>{calls++;if(network)throw Error('offline');return {status_code:status,data:data===undefined?[profile]:data}}}}};
|
||||
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}};
|
||||
const chunks=[];chunks.push=function(args){args[2](req)};
|
||||
const context={URLSearchParams,location:{origin:'https://www.douyin.com'},fetch:async()=>{calls++;if(network)throw Error('offline');return {ok,status:ok?200:503,json:async()=>({status_code:status,user:profile})}},AbortSignal:{timeout:()=>null},crypto:{randomUUID:()=> 'id'},Date:{now:()=>now},window:{'@pc-im/im:version':chunks}};
|
||||
const context={location:{origin:'https://www.douyin.com'},fetch:async()=>{throw Error('must not use public profile endpoint')},crypto:{randomUUID:()=> 'id'},Date:{now:()=>now},window:{'@pc-im/im:version':chunks}};
|
||||
const read=()=>vm.runInNewContext(source,context);
|
||||
(async()=>{
|
||||
let result=await read();assert.equal(result.messages.length,0);assert.equal(result.peers[0].peer_name,'初始昵称');assert.equal(calls,1);
|
||||
profile.nickname='改名后的昵称';now+=300001;result=await read();assert.equal(result.peers[0].peer_name,'改名后的昵称');assert.equal(calls,2);
|
||||
for(const failure of ['mismatch','missing','http','business','network']){
|
||||
now+=300001;profile={uid:failure==='mismatch'?'789':'456',nickname:failure==='missing'?'':'错误昵称'};ok=failure!=='http';status=failure==='business'?8:0;network=failure==='network';
|
||||
for(const failure of ['mismatch','sec-mismatch','missing','missing-record','invalid-data','duplicate','business','network']){
|
||||
now+=300001;profile={uid:failure==='mismatch'?'789':'456',sec_uid:failure==='sec-mismatch'?'wrong-sec':'peer-sec',nickname:failure==='missing'?'':'错误昵称'};data=({'missing-record':[],'invalid-data':{},duplicate:[profile,profile]})[failure];status=failure==='business'?8:0;network=failure==='network';
|
||||
result=await read();assert.equal(result.status,'succeeded');assert.equal(result.messages.length,0);assert.equal(result.peers[0].peer_name,'');assert.match(result.peers[0].error,/昵称/);
|
||||
const count=calls;await read();assert.equal(calls,count);
|
||||
}
|
||||
profile={uid:'456',nickname:'恢复昵称'};network=false;status=0;ok=true;now+=30001;result=await read();assert.equal(result.peers[0].peer_name,'恢复昵称');assert.equal(result.peers[0].error,'');
|
||||
context.fetch=async()=>{sdk.ctx.option.userId='999';return {ok:true,json:async()=>({status_code:0,user:{uid:'456',nickname:'不可保存'}})}};
|
||||
profile={uid:'456',sec_uid:'peer-sec',nickname:'恢复昵称'};network=false;status=0;data=undefined;now+=30001;result=await read();assert.equal(result.peers[0].peer_name,'恢复昵称');assert.equal(result.peers[0].error,'');
|
||||
service.context.requestManager.request=async()=>{sdk.ctx.option.userId='999';return {status_code:0,data:[{uid:'456',sec_uid:'peer-sec',nickname:'不可保存'}]}};
|
||||
now+=300001;result=await read();assert.equal(result.code,'ACCOUNT_IDENTITY_MISMATCH');
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
'''.replace('SOURCE',json.dumps(inbox_expression('123', {'456':'100'})))
|
||||
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
|
||||
def test_inbox_batches_chat_profiles_and_keeps_valid_peers_when_one_profile_is_missing(self):
|
||||
script = '''
|
||||
const vm=require('node:vm'),assert=require('node:assert/strict');const source=SOURCE;
|
||||
let calls=0;
|
||||
const rows=Array.from({length:80},(_,i)=>({uid:String(1000+i),sec_uid:'sec-'+i,nickname:i===0?'用户3290971208926':i===1?'正式AI分身':'联系人'+i}));
|
||||
const conversations=rows.map(row=>({type:1,toParticipantUserId:row.uid,firstPageParticipant:{participants:[{user_id:row.uid,sec_uid:row.sec_uid}]},lastMessage:{serverId:'100'},coreInfo:{name:''},getMessageList:()=>[]}));
|
||||
const sdk={ctx:{option:{userId:'123'}},getConversationList:()=>conversations};
|
||||
const service={imSdkManager:{getImSdkInstance:()=>sdk},context:{requestManager:{request:async config=>{calls++;const ids=JSON.parse(config.data.sec_user_ids);assert.ok(ids.length<=30);return {status_code:0,data:rows.filter(r=>ids.includes(r.sec_uid)&&r.uid!=='1002')}}}}};
|
||||
const req={m:{one:function(){/* getOrCreatePrivateConversationByUid */}},c:{one:{exports:{A:{instance:{imSdkService:service}}}}}};
|
||||
const chunks=[];chunks.push=function(args){args[2](req)};
|
||||
const context={location:{origin:'https://www.douyin.com'},crypto:{randomUUID:()=> 'id'},Date,window:{'@pc-im/im:version':chunks}};
|
||||
(async()=>{
|
||||
let r=await vm.runInNewContext(source,context);assert.equal(r.status,'succeeded');assert.equal(calls,3);assert.equal(r.peers.length,80);assert.equal(r.peers[0].peer_name,'用户3290971208926');assert.equal(r.peers[1].peer_name,'正式AI分身');assert.match(r.peers[2].error,/昵称/);assert.equal(r.peers[79].peer_name,'联系人79');
|
||||
await vm.runInNewContext(source,context);assert.equal(calls,3);
|
||||
context.window.__creatorhubPrivatePeerProfiles.source='obsolete';await vm.runInNewContext(source,context);assert.equal(calls,6);
|
||||
conversations[0].firstPageParticipant={participants:[]};context.window.__creatorhubPrivatePeerProfiles=undefined;r=await vm.runInNewContext(source,context);assert.match(r.peers[0].error,/secUID/);assert.equal(r.peers[1].peer_name,'正式AI分身');
|
||||
service.context.requestManager=undefined;context.window.__creatorhubPrivatePeerProfiles=undefined;r=await vm.runInNewContext(source,context);assert.equal(r.status,'succeeded');assert.ok(r.peers.every(p=>p.error));
|
||||
})().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()
|
||||
|
||||
@@ -138,9 +138,15 @@ func DecodePrivateInbox(raw []byte, expectedUID string) (*PrivateInbox, error) {
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
return nil, fmt.Errorf("decode inbox: %w", err)
|
||||
}
|
||||
if response.Status != "succeeded" || response.AccountUID != expectedUID || response.Messages == nil || response.Peers == nil {
|
||||
if response.Status != "succeeded" || response.AccountUID != expectedUID {
|
||||
return nil, fmt.Errorf("invalid inbox response: status=%s code=%s identity=%s", response.Status, response.Code, response.AccountUID)
|
||||
}
|
||||
if response.Messages == nil {
|
||||
return nil, errors.New("inbox response missing messages; verify running browser gateway version")
|
||||
}
|
||||
if response.Peers == nil {
|
||||
return nil, errors.New("inbox response missing peers; verify running browser gateway version")
|
||||
}
|
||||
peerNames := make(map[string]string, len(*response.Peers))
|
||||
for i := range *response.Peers {
|
||||
p := &(*response.Peers)[i]
|
||||
|
||||
@@ -51,6 +51,18 @@ func TestDecodePrivateInbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePrivateInboxMissingFieldsIdentifiesGatewayMismatch(t *testing.T) {
|
||||
for _, test := range []struct{ raw, field string }{
|
||||
{`{"status":"succeeded","account_uid":"123","messages":[]}`, "peers"},
|
||||
{`{"status":"succeeded","account_uid":"123","peers":[]}`, "messages"},
|
||||
} {
|
||||
_, err := DecodePrivateInbox([]byte(test.raw), "123")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing "+test.field) || !strings.Contains(err.Error(), "browser gateway") {
|
||||
t.Fatalf("missing-field diagnostic: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePrivateInboxPeersWithoutNewMessages(t *testing.T) {
|
||||
inbox, err := DecodePrivateInbox([]byte(`{"status":"succeeded","account_uid":"123","messages":[],"peers":[{"peer_uid":"456","peer_name":" 更新后的昵称 ","error":""}]}`), "123")
|
||||
if err != nil || len(inbox.Messages) != 0 || inbox.Peers[0].PeerName != "更新后的昵称" {
|
||||
|
||||
Reference in New Issue
Block a user