fix: complete Douyin release remediation
This commit is contained in:
@@ -504,6 +504,28 @@ class DouyinBrowser:
|
||||
raise DouyinError("Douyin platform clock is invalid") from exc
|
||||
return result
|
||||
|
||||
def action_ownership(self, alias: str) -> dict | None:
|
||||
value = self._evaluate(
|
||||
alias,
|
||||
"(() => { const raw = localStorage.getItem('__creatorhub_action_ownership_v1'); return raw === null ? null : JSON.parse(raw); })()",
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise DouyinError("browser action ownership marker is invalid")
|
||||
return value
|
||||
|
||||
def set_action_ownership(self, alias: str, marker: dict) -> None:
|
||||
if not isinstance(marker, dict):
|
||||
raise DouyinError("browser action ownership marker is invalid")
|
||||
expression = f"localStorage.setItem('__creatorhub_action_ownership_v1', {json.dumps(json.dumps(marker, separators=(',', ':')))})"
|
||||
self._evaluate(alias, expression)
|
||||
|
||||
def clear_action_ownership(self, alias: str, operation_id: str = "") -> None:
|
||||
expected = json.dumps(operation_id)
|
||||
expression = f"(() => {{ const key='__creatorhub_action_ownership_v1'; const raw=localStorage.getItem(key); if ({expected} === '' || raw === null || JSON.parse(raw).operation_id === {expected}) localStorage.removeItem(key); return true; }})()"
|
||||
self._evaluate(alias, expression)
|
||||
|
||||
def action(
|
||||
self,
|
||||
alias: str,
|
||||
@@ -604,17 +626,22 @@ class DouyinBrowser:
|
||||
raise
|
||||
|
||||
def message_history(
|
||||
self, alias: str, expected_uid: str, target_uid: str, limit: int = 100
|
||||
self,
|
||||
alias: str,
|
||||
expected_uid: str,
|
||||
target_uid: str,
|
||||
limit: int = 100,
|
||||
cursor: str = "",
|
||||
) -> dict:
|
||||
if not UID_RE.fullmatch(expected_uid) or not UID_RE.fullmatch(target_uid):
|
||||
raise DouyinError("message history UID is invalid")
|
||||
if not 1 <= limit <= 200:
|
||||
raise DouyinError("message history limit is invalid")
|
||||
if not 1 <= limit <= 200 or not isinstance(cursor, str) or len(cursor) > 500:
|
||||
raise DouyinError("message history request is invalid")
|
||||
self.identity(alias, expected_uid)
|
||||
value = self._evaluate(
|
||||
alias,
|
||||
message_history_expression(
|
||||
{"uid": target_uid, "limit": limit}, expected_uid
|
||||
{"uid": target_uid, "cursor": cursor, "limit": limit}, expected_uid
|
||||
),
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
@@ -644,6 +671,7 @@ class DouyinSubscription:
|
||||
self._initial_boundary_pending = True
|
||||
self._browser_inflight: set[str] = set()
|
||||
self._browser_inflight_lock = threading.Lock()
|
||||
self._recovery_lock = threading.Lock()
|
||||
self._detail_pool = ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix=f"creatorhub-notice-details-{alias}"
|
||||
)
|
||||
@@ -745,9 +773,17 @@ class DouyinSubscription:
|
||||
if self.stopped.is_set():
|
||||
break
|
||||
self._put({"kind": "error", "reason": str(exc)})
|
||||
self._recover()
|
||||
self._request_recovery()
|
||||
self._dispose_current()
|
||||
|
||||
def _request_recovery(self) -> None:
|
||||
if not self._recovery_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
self._recover()
|
||||
finally:
|
||||
self._recovery_lock.release()
|
||||
|
||||
def _recover(self) -> None:
|
||||
# Dispose the old same-page handlers before installing a new state with
|
||||
# the stable key; disposing afterward would remove the fresh handlers.
|
||||
@@ -830,6 +866,8 @@ class DouyinSubscription:
|
||||
return
|
||||
if kind != "push":
|
||||
raise DouyinError("notification event kind is invalid")
|
||||
event = dict(event)
|
||||
event.setdefault("gateway_received_at", datetime.now(timezone.utc).isoformat())
|
||||
if not hasattr(self, "_browser_inflight_lock"):
|
||||
self._browser_inflight_lock = threading.Lock()
|
||||
if not hasattr(self, "_browser_inflight"):
|
||||
@@ -849,7 +887,10 @@ class DouyinSubscription:
|
||||
if success:
|
||||
self._ack_browser_event(delivery_id, self._get_connection())
|
||||
else:
|
||||
self._retry_browser_event(delivery_id, self._get_connection())
|
||||
if not self._retry_browser_event(
|
||||
delivery_id, self._get_connection()
|
||||
):
|
||||
self._request_recovery()
|
||||
if delivery_id:
|
||||
with self._browser_inflight_lock:
|
||||
self._browser_inflight.discard(delivery_id)
|
||||
@@ -861,8 +902,10 @@ class DouyinSubscription:
|
||||
connection: CDPConnection | None,
|
||||
) -> None:
|
||||
self._put({"kind": "error", "reason": str(error), "continuity": "gap"})
|
||||
if isinstance(delivery_id, str) and connection is not None:
|
||||
self._retry_browser_event(delivery_id, connection)
|
||||
if not isinstance(delivery_id, str):
|
||||
return
|
||||
if connection is None or not self._retry_browser_event(delivery_id, connection):
|
||||
self._request_recovery()
|
||||
|
||||
def _process_push(
|
||||
self, event: dict, connection: CDPConnection, baseline: bool
|
||||
@@ -884,7 +927,9 @@ class DouyinSubscription:
|
||||
for notice_id in ids:
|
||||
try:
|
||||
for notice in self._details([notice_id], connection):
|
||||
normalized = normalize_notice(notice)
|
||||
normalized = normalize_notice(
|
||||
notice, event.get("gateway_received_at")
|
||||
)
|
||||
if normalized is not None:
|
||||
self._put(
|
||||
{
|
||||
@@ -926,7 +971,11 @@ class DouyinSubscription:
|
||||
if success:
|
||||
self._ack_browser_event(delivery_id, connection)
|
||||
else:
|
||||
self._retry_browser_event(delivery_id, connection)
|
||||
# A malformed detail is a continuity gap, not a retryable
|
||||
# delivery. Drop the bad delivery, record the gap, and
|
||||
# establish a new baseline before accepting later events.
|
||||
self._ack_browser_event(delivery_id, connection)
|
||||
self._request_recovery()
|
||||
except LISTENER_ERRORS as exc:
|
||||
self._handle_push_error(exc, delivery_id, connection)
|
||||
finally:
|
||||
@@ -956,13 +1005,14 @@ class DouyinSubscription:
|
||||
|
||||
def _retry_browser_event(
|
||||
self, delivery_id: object, connection: CDPConnection
|
||||
) -> None:
|
||||
) -> bool:
|
||||
if not isinstance(delivery_id, str) or not delivery_id:
|
||||
return
|
||||
return False
|
||||
try:
|
||||
result = connection.evaluate(retry_expression(self.key, [delivery_id]))
|
||||
if result not in (True, "true"):
|
||||
raise DouyinError("notification retry acknowledgement failed")
|
||||
return True
|
||||
except (
|
||||
DouyinError,
|
||||
OSError,
|
||||
@@ -971,6 +1021,7 @@ class DouyinSubscription:
|
||||
websocket.WebSocketException,
|
||||
):
|
||||
LOG.warning("notification retry marker failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def pending(self) -> list[dict]:
|
||||
with self.condition:
|
||||
@@ -1172,7 +1223,9 @@ def _direct_message_type(detail: dict, message: object) -> str:
|
||||
return "non_text"
|
||||
|
||||
|
||||
def normalize_notice(notice: object) -> dict | None:
|
||||
def normalize_notice(
|
||||
notice: object, gateway_received_at: str | None = None
|
||||
) -> dict | None:
|
||||
if not isinstance(notice, dict):
|
||||
raise DouyinError("notification detail is invalid")
|
||||
if notice.get("comment"):
|
||||
@@ -1248,6 +1301,8 @@ def normalize_notice(notice: object) -> dict | None:
|
||||
).isoformat()
|
||||
except (OverflowError, OSError, ValueError) as exc:
|
||||
raise DouyinError("notification timestamp is invalid") from exc
|
||||
if isinstance(gateway_received_at, str) and gateway_received_at:
|
||||
result["gateway_received_at"] = gateway_received_at
|
||||
return result
|
||||
users = detail.get("from_user") or []
|
||||
if isinstance(users, dict):
|
||||
@@ -1315,6 +1370,8 @@ def normalize_notice(notice: object) -> dict | None:
|
||||
).isoformat()
|
||||
except (OverflowError, OSError, ValueError) as exc:
|
||||
raise DouyinError("notification timestamp is invalid") from exc
|
||||
if isinstance(gateway_received_at, str) and gateway_received_at:
|
||||
result["gateway_received_at"] = gateway_received_at
|
||||
return result
|
||||
|
||||
|
||||
@@ -1709,6 +1766,6 @@ FOLLOW_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;try{if(locati
|
||||
ACTION_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const qs="device_platform=webapp&aid=6383&channel=channel_pc_web";const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)});let v;try{v=await r.json();}catch(_){fail("INVALID_RESPONSE");}if(!r.ok||v.status_code!==0)fail("READ_FAILED");return v;};const post=async(path,data)=>{const r=await fetch(path,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams(data),signal:AbortSignal.timeout(10000)});let v;try{v=await r.json();}catch(_){fail(r.ok?"INVALID_RESPONSE":"POST_UNCERTAIN");}if(!r.ok)fail("POST_UNCERTAIN");if(v.status_code===undefined)fail("POST_UNCONFIRMED");if(v.status_code!==0)fail("BUSINESS_REJECTED",true);return v;};const self=await get("/aweme/v1/web/user/profile/self/?"+qs);if(String(self.user?.uid)!==p.expected)fail("IDENTITY_MISMATCH");if((p.action==="follow"||p.action==="dm")&&p.target===p.expected)fail("SELF_TARGET");const detail=async()=>get("/aweme/v1/web/aweme/detail/?"+qs+"&aweme_id="+encodeURIComponent(p.work));const findComment=async()=>{let cursor=0;for(let page=0;page<100;page++){const response=await get("/aweme/v1/web/comment/list/?"+qs+"&aweme_id="+encodeURIComponent(p.work)+"&cursor="+cursor+"&count=50");if(!Array.isArray(response.comments)||typeof response.has_more!=="boolean")fail("READ_FAILED");const list=response.comments;const comment=list.find(c=>String(c?.cid||c?.comment_id||"")===p.comment);if(comment){if(String(comment.aweme_id||comment.item_id||p.work)!==p.work||String(comment.user?.uid||comment.user_id||"")!==p.target)fail("TARGET_MISMATCH");return comment;}if(!response.has_more)break;const next=Number(response.cursor);if(!Number.isSafeInteger(next)||next<=cursor)fail("PAGINATION_INVALID");cursor=next;}fail("TARGET_NOT_FOUND");};if(p.action==="like_work"){const before=(await detail()).aweme_detail;if(String(before?.aweme_id)!==p.work)fail("TARGET_MISMATCH");if(Number(before.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:{work_id:p.work,user_digged:"1"}};sent=true;const result=await post("/aweme/v1/web/commit/item/digg/?"+qs,{aweme_id:p.work,type:"1",item_type:"0"});if(Number(result.is_digg)!==1)fail("POST_UNCONFIRMED");const after=(await detail()).aweme_detail;return {status:Number(after?.user_digged)===1?"succeeded":"unknown",action:"liked",evidence:{work_id:p.work,user_digged:String(after?.user_digged??"")}};}if(p.action==="like_comment"){const comment=await findComment();if(Number(comment.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:"comment.user_digged"};sent=true;await post("/aweme/v1/web/comment/digg?"+qs,{cid:p.comment,aweme_id:p.work,digg_type:"1",channel_id:"0",app_name:"aweme",item_type:"0",level:"1"});const after=await findComment();return {status:Number(after.user_digged)===1?"succeeded":"unknown",action:"liked_comment",evidence:{comment_id:p.comment,work_id:p.work,user_digged:String(after.user_digged??"")}};}if(p.action==="reply_comment"){await findComment();sent=true;const result=await post("/aweme/v1/web/comment/publish?"+qs,{app_name:"aweme",enter_from:"pc_web",previous_page:"video",reply_id:p.comment,reply_to_reply_id:"0",aweme_id:p.work,text:p.text,text_extra:"[]",comment_send_celltime:"0",comment_video_celltime:"0"});const posted=result.comment||result.comment_info||result.data?.comment;const postedID=String(posted?.cid||posted?.comment_id||"");const postedWork=String(posted?.aweme_id||posted?.item_id||"");const postedAuthor=String(posted?.user?.uid||posted?.user_id||"");const postedText=String(posted?.text??posted?.content??"");if(!posted||!postedID||postedWork!==p.work||postedAuthor!==p.expected||postedText!==p.text)fail("UNCONFIRMED");return {status:"succeeded",action:"replied",evidence:{comment_id:postedID,work_id:postedWork,author_uid:postedAuthor,text:postedText}};}if(p.action==="repost"){if(!p.text)fail("TEXT_REQUIRED");fail("REPOST_TEXT_UNSUPPORTED");}fail("ACTION_NOT_IMPLEMENTED");}catch(e){const code=String(e.message||"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()"""
|
||||
|
||||
|
||||
MESSAGE_HISTORY_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;const fail=code=>{throw Error(code);};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-history-"+Date.now()],{},r=>{req=r;}]);chunks.pop();for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m?.[id]||"").includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}if(service)break;}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});let cursor,previousCursor="",hasMore=false;for(let page=0;page<20;page++){const request={conversation,limit:Math.min(50,p.limit)};if(cursor!==undefined)request.cursor=cursor;const result=await sdk.getMessagesByConversation(request);if(!result||!Array.isArray(result.messages))fail("MESSAGE_HISTORY_INVALID");hasMore=Boolean(result.hasMore);if(!hasMore)break;const next=result.cursor,key=String(next?.toString?.()??next??"");if(!key||key===previousCursor)fail("MESSAGE_HISTORY_CURSOR_INVALID");previousCursor=key;cursor=next;}const messages=conversation.getMessageList();if(!Array.isArray(messages))fail("MESSAGE_HISTORY_INVALID");const pack=m=>{const ext=m.ext||{};const rawTime=ext["s:server_message_create_time"]||"";return {server_id:String(m.serverId||""),sender_uid:String(m.sender||""),message_type:String(m.type??""),content:typeof m.content==="string"?m.content.slice(0,100000):m.content,created_at:/^[0-9]+$/.test(String(rawTime))?String(rawTime):null,server_status:m.serverStatus??null};};return {status:"succeeded",action:"history",history_source:"im_sdk_pull",history_has_more:hasMore,account_uid:String(profile.user.uid),conversation:meta(conversation),messages:messages.slice(-p.limit).map(pack)};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","MESSAGE_HISTORY_INVALID","MESSAGE_HISTORY_CURSOR_INVALID"];return {status:"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
|
||||
MESSAGE_HISTORY_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;const fail=code=>{throw Error(code);};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-history-"+Date.now()],{},r=>{req=r;}]);chunks.pop();for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m?.[id]||"").includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}if(service)break;}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});let cursor=p.cursor?String(p.cursor):undefined,previousCursor=cursor??"",hasMore=false;for(let page=0;page<20;page++){const request={conversation,limit:Math.min(50,p.limit)};if(cursor!==undefined)request.cursor=cursor;const result=await sdk.getMessagesByConversation(request);if(!result||!Array.isArray(result.messages))fail("MESSAGE_HISTORY_INVALID");hasMore=Boolean(result.hasMore);if(!hasMore)break;const next=result.cursor,key=String(next?.toString?.()??next??"");if(!key||key===previousCursor)fail("MESSAGE_HISTORY_CURSOR_INVALID");previousCursor=key;cursor=next;}const messages=conversation.getMessageList();if(!Array.isArray(messages))fail("MESSAGE_HISTORY_INVALID");const pack=m=>{const ext=m.ext||{};const rawTime=ext["s:server_message_create_time"]||"";return {server_id:String(m.serverId||""),sender_uid:String(m.sender||""),message_type:String(m.type??""),content:typeof m.content==="string"?m.content.slice(0,100000):m.content,created_at:/^[0-9]+$/.test(String(rawTime))?String(rawTime):null,server_status:m.serverStatus??null};};return {status:"succeeded",action:"history",history_source:"im_sdk_pull",history_cursor:hasMore?String(cursor??""):"",history_has_more:hasMore,account_uid:String(profile.user.uid),conversation:meta(conversation),messages:messages.slice(-p.limit).map(pack)};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","MESSAGE_HISTORY_INVALID","MESSAGE_HISTORY_CURSOR_INVALID"];return {status:"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
|
||||
|
||||
IM_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-im-"+Date.now()],{},r=>{req=r;}]);for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m[id]).includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});const pack=m=>({server_id:String(m.serverId||""),client_id:m.clientId||null,sender:String(m.sender),type:m.type,content:m.content,created_at:m.createdAt,server_status:m.serverStatus});let conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation&&p.action==="send")conversation=await service.conversationManager.getOrCreatePrivateConversationByUid(p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");if(conversation.type!==1||String(conversation.toParticipantUserId)!==p.uid)fail("TARGET_MISMATCH");if(p.action!=="send"||!p.confirm)return {status:"preview",action:"preview",sender_uid:String(profile.user.uid),uid:p.uid,text:p.text,conversation:meta(conversation)};const message=await sdk.createMessage({conversation,type:7,content:JSON.stringify({aweType:700,type:0,richTextInfos:[],text:p.text})});if(!message||typeof message.sendFunc!=="function")fail("MESSAGE_BUILD_FAILED");sent=true;const result=await Promise.race([sdk.sendMessage({message}),new Promise((_,reject)=>setTimeout(()=>reject(Error("MESSAGE_UNCONFIRMED")),10000))]);if(result?.success===false)fail("MESSAGE_REJECTED",true);if(result?.success!==true)fail("MESSAGE_UNCONFIRMED");const packed=pack(message);if(!packed.server_id&&!packed.client_id)fail("MESSAGE_UNCONFIRMED");return {status:"succeeded",action:"send",success:true,status_code:result?.statusCode??null,check_code:String(result?.checkCode??""),conversation:meta(conversation),message:packed,evidence:{conversation_id:String(conversation.id),message_server_id:packed.server_id,message_client_id:String(packed.client_id??"")}};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","TARGET_MISMATCH","MESSAGE_BUILD_FAILED","MESSAGE_REJECTED","MESSAGE_UNCONFIRMED"];return {status:sent?(e.definitive?"failed":"unknown"):"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
|
||||
|
||||
@@ -834,10 +834,13 @@ class Gateway:
|
||||
expected_uid = input.get("expected_uid", "")
|
||||
target_uid = input.get("target_uid", "")
|
||||
limit = input.get("limit", 100)
|
||||
cursor = input.get("cursor", "")
|
||||
if (
|
||||
not valid_douyin_generation(input)
|
||||
or not isinstance(expected_uid, str)
|
||||
or not isinstance(target_uid, str)
|
||||
or not isinstance(cursor, str)
|
||||
or len(cursor) > 500
|
||||
or type(limit) is not int
|
||||
or not UID_RE.fullmatch(expected_uid)
|
||||
or not UID_RE.fullmatch(target_uid)
|
||||
@@ -848,9 +851,14 @@ class Gateway:
|
||||
with self._alias_lock(alias):
|
||||
self._require_douyin_generation(alias, input)
|
||||
try:
|
||||
result = self.browser.message_history(
|
||||
alias, expected_uid, target_uid, limit
|
||||
)
|
||||
if cursor:
|
||||
result = self.browser.message_history(
|
||||
alias, expected_uid, target_uid, limit, cursor=cursor
|
||||
)
|
||||
else:
|
||||
result = self.browser.message_history(
|
||||
alias, expected_uid, target_uid, limit
|
||||
)
|
||||
self._require_douyin_generation(alias, input)
|
||||
except DouyinError as exc:
|
||||
LOG.warning(
|
||||
@@ -869,6 +877,7 @@ class Gateway:
|
||||
target_work_id = input.get("target_work_id", "")
|
||||
text = input.get("text", "")
|
||||
confirm = input.get("confirm", False)
|
||||
operation_id = input.get("operation_id", "")
|
||||
if (
|
||||
not valid_douyin_generation(input)
|
||||
or not isinstance(expected_uid, str)
|
||||
@@ -877,6 +886,8 @@ class Gateway:
|
||||
or not isinstance(target_comment_id, str)
|
||||
or not isinstance(target_work_id, str)
|
||||
or not isinstance(text, str)
|
||||
or not isinstance(operation_id, str)
|
||||
or len(operation_id) > 200
|
||||
or type(confirm) is not bool
|
||||
or not UID_RE.fullmatch(expected_uid)
|
||||
or action not in ACTIONS
|
||||
@@ -886,7 +897,11 @@ class Gateway:
|
||||
raise RequestError("ACTION_UNAVAILABLE", 409)
|
||||
with self._alias_lock(alias):
|
||||
self._require_douyin_generation(alias, input)
|
||||
self._claim_action(alias)
|
||||
self._claim_action(
|
||||
alias,
|
||||
str(input.get("runtime_id", "")),
|
||||
operation_id,
|
||||
)
|
||||
try:
|
||||
result = self.browser.action(
|
||||
alias,
|
||||
@@ -900,22 +915,22 @@ class Gateway:
|
||||
)
|
||||
self._require_douyin_generation(alias, input)
|
||||
except DouyinError as exc:
|
||||
self._handle_douyin_action_error(alias, action, exc)
|
||||
self._handle_douyin_action_error(alias, action, exc, operation_id)
|
||||
raise RequestError("Douyin action failed") from exc
|
||||
except Exception:
|
||||
self._release_action_ownership(alias)
|
||||
self._release_action_ownership(alias, operation_id)
|
||||
raise
|
||||
else:
|
||||
self._release_action_ownership(alias)
|
||||
self._release_action_ownership(alias, operation_id)
|
||||
return result
|
||||
|
||||
def _handle_douyin_action_error(
|
||||
self, alias: str, action: str, error: DouyinError
|
||||
self, alias: str, action: str, error: DouyinError, operation_id: str = ""
|
||||
) -> None:
|
||||
if getattr(error, "uncertain", False) or "timed out" in str(error).lower():
|
||||
self._retain_action_ownership(alias)
|
||||
self._retain_action_ownership(alias, operation_id)
|
||||
else:
|
||||
self._release_action_ownership(alias)
|
||||
self._release_action_ownership(alias, operation_id)
|
||||
LOG.warning(
|
||||
"Douyin action failed alias=%s action=%s reason=%s",
|
||||
alias,
|
||||
@@ -923,20 +938,37 @@ class Gateway:
|
||||
str(error),
|
||||
)
|
||||
|
||||
def _claim_action(self, alias: str) -> None:
|
||||
def _claim_action(
|
||||
self, alias: str, runtime_id: str = "", operation_id: str = ""
|
||||
) -> None:
|
||||
now = time.monotonic()
|
||||
with self._action_ownership_lock:
|
||||
until = self._uncertain_actions.get(alias, 0.0)
|
||||
if until > now:
|
||||
raise RequestError("previous Douyin action outcome is uncertain", 409)
|
||||
if runtime_id:
|
||||
marker = self.browser.action_ownership(alias)
|
||||
if marker is not None:
|
||||
raise RequestError("previous Douyin action outcome is uncertain", 409)
|
||||
self.browser.set_action_ownership(
|
||||
alias,
|
||||
{
|
||||
"runtime_id": runtime_id,
|
||||
"operation_id": operation_id,
|
||||
"started_at": time.time(),
|
||||
},
|
||||
)
|
||||
with self._action_ownership_lock:
|
||||
self._uncertain_actions.pop(alias, None)
|
||||
self._uncertain_actions[alias] = 0.0
|
||||
|
||||
def _release_action_ownership(self, alias: str) -> None:
|
||||
def _release_action_ownership(self, alias: str, operation_id: str = "") -> None:
|
||||
if operation_id:
|
||||
self.browser.clear_action_ownership(alias, operation_id)
|
||||
with self._action_ownership_lock:
|
||||
self._uncertain_actions.pop(alias, None)
|
||||
|
||||
def _retain_action_ownership(self, alias: str) -> None:
|
||||
def _retain_action_ownership(self, alias: str, operation_id: str = "") -> None:
|
||||
with self._action_ownership_lock:
|
||||
# A timed-out page script may still finish its network write. Keep the
|
||||
# alias blocked until the browser generation is removed or replaced.
|
||||
|
||||
@@ -885,6 +885,18 @@ class BrowserTests(unittest.TestCase):
|
||||
subscription.ack([first[0]["delivery_id"]])
|
||||
self.assertEqual(subscription.poll(10, 0), [])
|
||||
|
||||
def test_subscription_connection_failure_requests_recovery(self) -> None:
|
||||
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
||||
subscription.alias = "safe"
|
||||
subscription.browser = Mock()
|
||||
subscription.browser._connect.side_effect = DouyinError("browser unavailable")
|
||||
subscription._put = Mock()
|
||||
subscription._request_recovery = Mock()
|
||||
subscription._browser_inflight_lock = threading.Lock()
|
||||
subscription._browser_inflight = set()
|
||||
subscription._process_push_async({"delivery_id": "delivery-1"}, 0, False)
|
||||
subscription._request_recovery.assert_called_once_with()
|
||||
|
||||
def test_subscription_detail_failure_does_not_discard_siblings(self) -> None:
|
||||
bad = json.dumps(
|
||||
{
|
||||
@@ -2244,18 +2256,21 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
|
||||
def test_identity_and_message_history_reject_invalid_values(self) -> None:
|
||||
browser = DouyinBrowser()
|
||||
for server_now in (0, float("nan")):
|
||||
cast(Any, browser).get = lambda alias, target, now=server_now: BrowserResponse(
|
||||
200,
|
||||
json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"extra": {"now": now},
|
||||
"user": {"uid": "123", "sec_uid": "sec"},
|
||||
}
|
||||
),
|
||||
cast(Any, browser).get = lambda alias, target, now=server_now: (
|
||||
BrowserResponse(
|
||||
200,
|
||||
json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"extra": {"now": now},
|
||||
"user": {"uid": "123", "sec_uid": "sec"},
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
with self.subTest(server_now=server_now), self.assertRaisesRegex(
|
||||
DouyinError, "platform clock"
|
||||
with (
|
||||
self.subTest(server_now=server_now),
|
||||
self.assertRaisesRegex(DouyinError, "platform clock"),
|
||||
):
|
||||
browser.identity("safe")
|
||||
|
||||
@@ -2273,9 +2288,12 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
|
||||
("123", "456", 0),
|
||||
("123", "456", 201),
|
||||
):
|
||||
with self.subTest(
|
||||
expected_uid=expected_uid, target_uid=target_uid, limit=limit
|
||||
), self.assertRaises(DouyinError):
|
||||
with (
|
||||
self.subTest(
|
||||
expected_uid=expected_uid, target_uid=target_uid, limit=limit
|
||||
),
|
||||
self.assertRaises(DouyinError),
|
||||
):
|
||||
browser.message_history("safe", expected_uid, target_uid, limit)
|
||||
evaluate.return_value = "bad"
|
||||
with self.assertRaisesRegex(DouyinError, "message history response"):
|
||||
@@ -2432,5 +2450,40 @@ class AdditionalGatewayCoverageTests(unittest.TestCase):
|
||||
load_config({**base, "BROWSER_CDP_ALIAS": "bad alias"})
|
||||
|
||||
|
||||
class DouyinReleaseRemediationTests(unittest.TestCase):
|
||||
def test_browser_action_marker_is_persisted_in_profile_storage(self) -> None:
|
||||
browser = DouyinBrowser()
|
||||
browser._evaluate = Mock(side_effect=[{"runtime_id": "old"}, True, True])
|
||||
self.assertEqual(browser.action_ownership("safe"), {"runtime_id": "old"})
|
||||
browser.set_action_ownership(
|
||||
"safe", {"runtime_id": "new", "operation_id": "op-1"}
|
||||
)
|
||||
browser.clear_action_ownership("safe", "op-1")
|
||||
self.assertEqual(browser._evaluate.call_count, 3)
|
||||
self.assertIn(
|
||||
"__creatorhub_action_ownership_v1",
|
||||
browser._evaluate.call_args_list[1].args[1],
|
||||
)
|
||||
|
||||
def test_gateway_action_claim_rejects_marker_from_any_runtime(self) -> None:
|
||||
gateway = Gateway.__new__(Gateway)
|
||||
gateway._action_ownership_lock = threading.Lock()
|
||||
gateway._uncertain_actions = {}
|
||||
gateway.browser = Mock()
|
||||
gateway.browser.action_ownership.return_value = {"runtime_id": "old-runtime"}
|
||||
with self.assertRaises(RequestError):
|
||||
gateway._claim_action("safe", "new-runtime", "op-1")
|
||||
gateway.browser.clear_action_ownership.assert_not_called()
|
||||
gateway.browser.set_action_ownership.assert_not_called()
|
||||
|
||||
def test_notice_keeps_raw_gateway_receipt_time(self) -> None:
|
||||
notice = normalize_notice(
|
||||
{"dm": {"message_id": "1", "from_user": {"uid": "2"}, "text": "hi"}},
|
||||
"2026-09-15T00:00:00+00:00",
|
||||
)
|
||||
assert notice is not None
|
||||
self.assertEqual(notice["gateway_received_at"], "2026-09-15T00:00:00+00:00")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user