"""Douyin browser control built on a narrowly scoped CDP contract.""" from __future__ import annotations import base64 import binascii import http.client import json import logging import math import re import threading import time import uuid from collections import deque from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager, suppress from dataclasses import dataclass from datetime import datetime, timezone from urllib.parse import urlsplit import websocket from .docker_client import RUNTIME_ID_RE LOG = logging.getLogger("creatorhub.douyin") ORIGIN = "https://www.douyin.com" ORIGIN_URL = ORIGIN + "/" IDENTITY_URL = ( ORIGIN + "/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp" ) WORKS_PATH = "/aweme/v1/web/aweme/post/" COMMENTS_PATH = "/aweme/v1/web/comment/list/" RESPONSE_LIMIT = 1 << 20 MEDIA_RESPONSE_LIMIT = 32 << 20 CONTROL_TIMEOUT = 15.0 UID_RE = re.compile(r"^[1-9][0-9]{0,19}$") ID_RE = re.compile(r"^[1-9][0-9]{0,63}$") ACCOUNT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$") ACTIONS = frozenset( {"follow", "dm", "reply_comment", "like_comment", "like_work", "repost"} ) class DouyinError(RuntimeError): def __init__(self, message: str): super().__init__(message) self.uncertain = False LISTENER_ERRORS = ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) @dataclass(frozen=True) class BrowserResponse: status: int body: str challenge: str = "" @dataclass(frozen=True) class BrowserMediaResponse: status: int content_type: str body_base64: str class CDPConnection: def __init__(self, socket: websocket.WebSocket) -> None: self.socket = socket self._lock = threading.RLock() self._next_id = 0 self._pending: deque[dict] = deque() def command(self, method: str, params: dict | None = None) -> dict: with self._lock: self._next_id += 1 command_id = self._next_id self.socket.send( json.dumps({"id": command_id, "method": method, "params": params or {}}) ) deadline = time.monotonic() + CONTROL_TIMEOUT while True: remaining = deadline - time.monotonic() if remaining <= 0: self._terminate_evaluation(method) raise DouyinError(f"CDP command timed out: {method}") message = self._take_pending(command_id) if message is None: remaining = deadline - time.monotonic() if remaining <= 0: self._terminate_evaluation(method) raise DouyinError(f"CDP command timed out: {method}") try: message = self._receive(remaining, method) except DouyinError: self._terminate_evaluation(method) raise if not isinstance(message, dict): raise DouyinError(f"CDP command returned invalid message: {method}") if message.get("id") != command_id: self._pending.append(message) continue if message.get("error"): raise DouyinError(f"CDP command rejected: {method}") result = message.get("result") if not isinstance(result, dict): raise DouyinError(f"CDP command returned invalid result: {method}") return result def wait_event( self, method: str, predicate: Callable[[dict], bool], timeout: float = CONTROL_TIMEOUT, ) -> dict: with self._lock: deadline = time.monotonic() + timeout while True: for index, message in enumerate(self._pending): params = message.get("params", {}) if ( message.get("method") == method and isinstance(params, dict) and predicate(params) ): del self._pending[index] return message remaining = deadline - time.monotonic() if remaining <= 0: raise DouyinError(f"CDP event timed out: {method}") message = self._receive(remaining, method) if isinstance(message, dict): params = message.get("params", {}) if ( message.get("method") == method and isinstance(params, dict) and predicate(params) ): return message self._pending.append(message) def _terminate_evaluation(self, method: str) -> None: if method != "Runtime.evaluate": return try: self._next_id += 1 command_id = self._next_id self.socket.settimeout(1.0) self.socket.send( json.dumps( { "id": command_id, "method": "Runtime.terminateExecution", "params": {}, } ) ) deadline = time.monotonic() + 1.0 while time.monotonic() < deadline: message = json.loads(self.socket.recv()) if isinstance(message, dict) and message.get("id") == command_id: return if isinstance(message, dict): self._pending.append(message) except ( OSError, TypeError, websocket.WebSocketException, json.JSONDecodeError, ) as exc: LOG.debug("failed to terminate timed-out CDP evaluation", exc_info=exc) def _take_pending(self, command_id: int) -> dict | None: for index, message in enumerate(self._pending): if message.get("id") == command_id: del self._pending[index] return message return None def _receive(self, timeout: float, operation: str) -> object: self.socket.settimeout(timeout) try: return json.loads(self.socket.recv()) except (TimeoutError, websocket.WebSocketTimeoutException) as exc: raise DouyinError(f"CDP command timed out during {operation}") from exc except ( OSError, TypeError, websocket.WebSocketException, json.JSONDecodeError, ) as exc: raise DouyinError(f"CDP message failed during {operation}") from exc def evaluate(self, expression: str) -> object: result = self.command( "Runtime.evaluate", { "expression": expression, "awaitPromise": True, "returnByValue": True, "userGesture": False, }, ) if result.get("exceptionDetails"): raise DouyinError("page evaluation failed") value = result.get("result", {}).get("value") if "value" not in result.get("result", {}): raise DouyinError("page evaluation returned no value") return value def close(self) -> None: with suppress(OSError, websocket.WebSocketException): self.socket.close() class DouyinBrowser: def __init__( self, endpoint: Callable[[str], str] | None = None, *, origin: str = ORIGIN, url_validator: Callable[[object], bool] | None = None, media_validator: Callable[[object], bool] | None = None, ) -> None: self.endpoint = endpoint or ( lambda alias: f"http://creatorhub-browser-{alias}:9222" ) self.origin = origin self.url_validator = url_validator or is_douyin_url self.media_validator = media_validator or is_douyin_media_url @contextmanager def connection(self, alias: str): connection = self._connect(alias) try: yield connection finally: connection.close() def _connect(self, alias: str) -> CDPConnection: if not RUNTIME_ID_RE.fullmatch(alias): raise DouyinError("browser alias is invalid") base = self.endpoint(alias).rstrip("/") try: parsed = urlsplit(base) port = parsed.port except (TypeError, ValueError) as exc: raise DouyinError("restricted browser endpoint is invalid") from exc if ( parsed.scheme != "http" or not parsed.hostname or not port or parsed.username or parsed.password or parsed.query or parsed.fragment ): raise DouyinError("restricted browser endpoint is invalid") http_connection = http.client.HTTPConnection( parsed.hostname, port, timeout=CONTROL_TIMEOUT ) try: http_connection.request( "GET", "/json/list", headers={"Accept": "application/json"} ) response = http_connection.getresponse() if response.status != 200: raise DouyinError("browser target discovery failed") payload = response.read(64 * 1024 + 1) except (OSError, http.client.HTTPException) as exc: raise DouyinError("restricted browser unavailable") from exc finally: http_connection.close() if len(payload) > 64 * 1024: raise DouyinError("browser target discovery response is too large") try: targets = json.loads(payload) except json.JSONDecodeError as exc: raise DouyinError("browser target discovery response is invalid") from exc if not isinstance(targets, list) or len(targets) > 32: raise DouyinError("browser target discovery response is invalid") page_targets = [ target for target in targets if isinstance(target, dict) and target.get("type") == "page" ] matching_targets = [ target for target in page_targets if self.url_validator(target.get("url", "")) ] if len(matching_targets) > 1 or ( not matching_targets and len(page_targets) > 1 ): raise DouyinError("browser has more than one page target") target = ( matching_targets[0] if matching_targets else page_targets[0] if page_targets else None ) if target is None: raise DouyinError("browser page target is unavailable") websocket_url = target.get("webSocketDebuggerUrl") try: parsed_ws = urlsplit( websocket_url if isinstance(websocket_url, str) else "" ) websocket_port = parsed_ws.port or port except (TypeError, ValueError) as exc: raise DouyinError("browser target websocket is invalid") from exc if ( parsed_ws.scheme != "ws" or not parsed_ws.hostname or not websocket_port or not parsed_ws.path.startswith("/devtools/page/") ): raise DouyinError("browser target websocket is invalid") if parsed_ws.hostname in ("localhost", "127.0.0.1", "::1"): parsed_ws = parsed_ws._replace(netloc=f"{parsed.hostname}:{websocket_port}") if parsed_ws.hostname != parsed.hostname or ( parsed_ws.port is not None and parsed_ws.port != port ): raise DouyinError("browser target websocket host is invalid") page_target = parsed_ws.geturl() try: socket = websocket.create_connection( page_target, timeout=CONTROL_TIMEOUT, origin="devtools://devtools", enable_multithread=True, ) except (OSError, websocket.WebSocketException) as exc: raise DouyinError("browser CDP connection failed") from exc return CDPConnection(socket) def get(self, alias: str, target: str) -> BrowserResponse: with self.connection(alias) as cdp: if cdp.evaluate("location.origin") != self.origin: raise DouyinError("restricted browser origin changed") expression = f"""(async()=>{{ const r=await fetch({json.dumps(target)},{{credentials:'include',redirect:'error'}}); if(!r.body)return {{status:r.status,body:'',too_large:false}}; const reader=r.body.getReader(), decoder=new TextDecoder(); let size=0, body=''; for(;;){{const item=await reader.read();if(item.done)break; if(size+item.value.byteLength>={RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}} size+=item.value.byteLength;body+=decoder.decode(item.value,{{stream:true}}); }} body+=decoder.decode();return {{status:r.status,body,too_large:false}}; }})()""" result = cdp.evaluate(expression) if ( not isinstance(result, dict) or result.get("too_large") or not isinstance(result.get("status"), int) ): raise DouyinError("restricted browser fetch failed") status = result["status"] if 300 <= status < 400: raise DouyinError("restricted browser fetch redirected") body = result.get("body") if not isinstance(body, str): raise DouyinError("restricted browser fetch returned invalid body") return BrowserResponse(status, body, detect_challenge(status, body)) def get_media(self, alias: str, target: str) -> BrowserMediaResponse: if not self.media_validator(target): raise DouyinError("restricted browser media target is invalid") with self.connection(alias) as cdp: navigation = cdp.command("Page.navigate", {"url": target}) frame_id = navigation.get("frameId") if not isinstance(frame_id, str) or navigation.get("errorText"): raise DouyinError("Douyin media page navigation failed") cdp.wait_event( "Page.frameNavigated", lambda params: ( params.get("frame", {}).get("id") == frame_id and self.url_validator(params.get("frame", {}).get("url")) ), ) deadline = time.monotonic() + CONTROL_TIMEOUT while time.monotonic() < deadline: if cdp.evaluate("document.readyState") in {"interactive", "complete"}: break time.sleep(0.1) else: raise DouyinError("Douyin media page did not load") result = cdp.evaluate( f"""(async()=>{{ const video=document.querySelector('video'); const source=video?.currentSrc||video?.src||''; if(!source)return {{error:'media_source_unavailable'}}; const r=await fetch(source,{{credentials:'include',redirect:'error'}}); if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:''}}; const reader=r.body.getReader(), chunks=[]; let size=0; for(;;){{const item=await reader.read();if(item.done)break; if(size+item.value.byteLength>{MEDIA_RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}} size+=item.value.byteLength;chunks.push(item.value); }} const bytes=new Uint8Array(size); let offset=0; for(const chunk of chunks){{bytes.set(chunk,offset);offset+=chunk.length;}} let binary=''; for(let offset=0;offset= 300: raise DouyinError("Douyin media response was not successful") try: decoded_size = len(base64.b64decode(body, validate=True)) except (ValueError, binascii.Error) as exc: raise DouyinError("Douyin media response is invalid") from exc if decoded_size > MEDIA_RESPONSE_LIMIT: raise DouyinError("Douyin media response is too large") return BrowserMediaResponse(status, content_type, body) def identity(self, alias: str, expected_uid: str | None = None) -> dict: response = self.get(alias, IDENTITY_URL) try: payload = json.loads(response.body) except json.JSONDecodeError as exc: raise DouyinError("Douyin identity response is invalid") from exc user = payload.get("user") if isinstance(payload, dict) else None uid = str(user.get("uid", "")) if isinstance(user, dict) else "" if ( response.status != 200 or not isinstance(payload, dict) or payload.get("status_code") != 0 or not UID_RE.fullmatch(uid) ): raise DouyinError("Douyin login is not valid") sec_uid = str(user.get("sec_uid", "")) if isinstance(user, dict) else "" unique_id = str(user.get("unique_id", "")) if isinstance(user, dict) else "" if not ACCOUNT_KEY_RE.fullmatch(sec_uid) or ( unique_id and not ACCOUNT_KEY_RE.fullmatch(unique_id) ): raise DouyinError("Douyin identity response is invalid") if expected_uid and uid != expected_uid: raise DouyinError("Douyin identity does not match the expected account") return { "uid": uid, "sec_uid": sec_uid, "unique_id": unique_id, "nickname": user.get("nickname", "") if isinstance(user, dict) else "", "short_id": user.get("short_id", "") if isinstance(user, dict) else "", } def action( self, alias: str, expected_uid: str, action: str, target_uid: str = "", comment_id: str = "", work_id: str = "", text: str = "", confirm: bool = False, ) -> dict: if not UID_RE.fullmatch(expected_uid): raise DouyinError("expected account UID is invalid") if target_uid and not UID_RE.fullmatch(target_uid): raise DouyinError("target UID is invalid") if work_id and not ID_RE.fullmatch(work_id): raise DouyinError("work ID is invalid") if comment_id and not ID_RE.fullmatch(comment_id): raise DouyinError("comment ID is invalid") if action not in ACTIONS: raise DouyinError("Douyin action is invalid") if action in {"follow", "dm"} and not target_uid: raise DouyinError("target UID is required") if action in {"like_work", "repost"} and not work_id: raise DouyinError("work ID is required") if action in {"reply_comment", "like_comment"} and ( not work_id or not comment_id or not target_uid ): raise DouyinError("comment target is incomplete") if action in {"dm", "reply_comment", "repost"} and ( not text.strip() or len(text) > 1000 ): raise DouyinError("action text is invalid") identity = self.identity(alias, expected_uid) if action == "follow": params = { "expected": expected_uid, "target": target_uid, "check": not confirm, } value = ( self._confirmed_evaluate(alias, follow_expression(params)) if confirm else self._evaluate(alias, follow_expression(params)) ) elif action == "dm": if not confirm: return { "action": "preview", "sender_uid": identity["uid"], "uid": target_uid, "text": text, } params = { "uid": target_uid, "text": text, "confirm": True, "limit": 20, "cursor": "9223372036854775807", "action": "send", } value = self._confirmed_evaluate(alias, im_expression(params, expected_uid)) else: params = { "action": action, "expected": expected_uid, "target": target_uid, "work": work_id, "comment": comment_id, "text": text, "confirm": confirm, } if not confirm: return { "action": "preview", "sender_uid": identity["uid"], "target_uid": target_uid, "target_work_id": work_id, "target_comment_id": comment_id, "text": text, } value = self._confirmed_evaluate(alias, action_expression(params)) if not isinstance(value, dict): raise DouyinError("Douyin action response is invalid") return value def _evaluate(self, alias: str, expression: str) -> object: with self.connection(alias) as cdp: if cdp.evaluate("location.origin") != self.origin: raise DouyinError("restricted browser origin changed") return cdp.evaluate(expression) def _confirmed_evaluate(self, alias: str, expression: str) -> object: try: return self._evaluate(alias, expression) except DouyinError as exc: exc.uncertain = True raise class DouyinSubscription: def __init__( self, browser: DouyinBrowser, alias: str, uid: str, pending: list[dict] | None = None, ) -> None: self.browser = browser self.alias = alias self.uid = uid # A stable key lets a fresh wrapper dispose a stale listener left on the # same page. Recovery disposes the old state before installing a new one. self.key = "__creatorhub_notice_sub_" + alias self._connection_lock = threading.RLock() self.queue: deque[dict] = deque(pending or []) self.condition = threading.Condition() self.stopped = threading.Event() self._epoch = 0 self._initial_boundary_pending = True self._browser_inflight: set[str] = set() self._browser_inflight_lock = threading.Lock() self._detail_pool = ThreadPoolExecutor( max_workers=4, thread_name_prefix=f"creatorhub-notice-details-{alias}" ) try: self.connection = self._open_listener() except Exception: self._detail_pool.shutdown(wait=False, cancel_futures=True) raise # Events observed before the consumer establishes its durable boundary # are explicitly classified as baseline and must never trigger writes. self._put( { "kind": "baseline", "reason": "listener_start", "uid": self.uid, "boundary_at": datetime.now(timezone.utc).isoformat(), } ) self.thread = threading.Thread( target=self._run, name=f"creatorhub-notices-{alias}", daemon=True ) self.thread.start() def _open_listener(self) -> CDPConnection: connection = self.browser._connect(self.alias) try: result = connection.evaluate(install_expression(self.key, self.uid)) if not isinstance(result, str): raise DouyinError("notification listener returned invalid state") state = json.loads(result) if not isinstance(state, dict) or not state.get("connected"): raise DouyinError("Douyin notification connection is not ready") return connection except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ): connection.close() raise def _get_connection(self) -> CDPConnection: with self._connection_lock: return self.connection def _run(self) -> None: wait = wait_expression(self.key) while not self.stopped.is_set(): try: raw = self._get_connection().evaluate(wait) events = json.loads(raw) if isinstance(raw, str) else raw if not isinstance(events, list): raise DouyinError("notification listener returned invalid events") initial_boundary = self._initial_boundary_pending self._initial_boundary_pending = False for event in events: if ( initial_boundary and isinstance(event, dict) and event.get("kind") == "push" ): event = dict(event) event["baseline"] = True self._handle(event) except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) as exc: if self.stopped.is_set(): break self._put({"kind": "error", "reason": str(exc)}) self._recover() self._dispose_current() 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. with self._connection_lock: old = self.connection try: old.evaluate(dispose_expression(self.key)) except DouyinError: LOG.debug( "old notification listener disposal was not available", exc_info=True, ) finally: old.close() self._initial_boundary_pending = True delay = 0.5 while not self.stopped.is_set(): if self.stopped.wait(delay): return try: connection = self._open_listener() except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) as exc: self._put({"kind": "error", "reason": str(exc)}) delay = min(delay * 2, 30.0) continue with self._connection_lock: self.connection = connection self._epoch += 1 self._put({"kind": "reconnected", "uid": self.uid}) # The boundary is a separate event so the control plane never treats # a transport reconnect itself as proof that new notices are safe. self._put( { "kind": "baseline", "reason": "listener_reconnected", "uid": self.uid, "boundary_at": datetime.now(timezone.utc).isoformat(), } ) return def _dispose_current(self) -> None: connection = self._get_connection() try: connection.evaluate(dispose_expression(self.key)) except DouyinError: LOG.debug("notification listener disposal was not available", exc_info=True) finally: connection.close() def _handle(self, event: object) -> None: if not isinstance(event, dict): raise DouyinError("notification event is invalid") kind = event.get("kind") delivery_id = event.get("delivery_id") if kind in {"open", "close"}: self._put({"kind": kind}) if delivery_id and hasattr(self, "_connection_lock"): self._ack_browser_event(delivery_id, self._get_connection()) if kind == "close": raise DouyinError("Douyin notification connection closed") return if kind == "error": self._put( { "kind": "error", "reason": str(event.get("reason", "notification continuity gap")), "continuity": "gap", } ) if delivery_id and hasattr(self, "_connection_lock"): self._ack_browser_event(delivery_id, self._get_connection()) return if kind != "push": raise DouyinError("notification event kind is invalid") if not hasattr(self, "_browser_inflight_lock"): self._browser_inflight_lock = threading.Lock() if not hasattr(self, "_browser_inflight"): self._browser_inflight = set() if isinstance(delivery_id, str) and delivery_id: with self._browser_inflight_lock: if delivery_id in self._browser_inflight: return self._browser_inflight.add(delivery_id) epoch = getattr(self, "_epoch", 0) baseline = bool(event.get("baseline")) if hasattr(self, "_detail_pool"): self._detail_pool.submit(self._process_push_async, event, epoch, baseline) else: success = self._process_push(event, self._get_connection(), baseline) if delivery_id: if success: self._ack_browser_event(delivery_id, self._get_connection()) else: self._retry_browser_event(delivery_id, self._get_connection()) if delivery_id: with self._browser_inflight_lock: self._browser_inflight.discard(delivery_id) def _handle_push_error( self, error: Exception, delivery_id: object, 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) def _process_push( self, event: dict, connection: CDPConnection, baseline: bool ) -> bool: try: ids = notice_ids(event) except ( DouyinError, KeyError, TypeError, ValueError, websocket.WebSocketException, ) as exc: self._put({"kind": "error", "reason": str(exc), "continuity": "gap"}) return False # Detail lookup is isolated per platform notification. A malformed or # temporarily unavailable detail must not discard its siblings. complete = True for notice_id in ids: try: for notice in self._details([notice_id], connection): normalized = normalize_notice(notice) if normalized is not None: self._put( { "kind": "notice", "notice": normalized, "baseline": baseline, } ) except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ) as exc: self._put( { "kind": "error", "reason": str(exc), "event_key": notice_id, "continuity": "gap", } ) complete = False return complete def _process_push_async(self, event: dict, epoch: int, baseline: bool) -> None: connection: CDPConnection | None = None success = False delivery_id = event.get("delivery_id") try: connection = self.browser._connect(self.alias) success = self._process_push( event, connection, baseline or epoch != getattr(self, "_epoch", 0), ) if isinstance(delivery_id, str): if success: self._ack_browser_event(delivery_id, connection) else: self._retry_browser_event(delivery_id, connection) except LISTENER_ERRORS as exc: self._handle_push_error(exc, delivery_id, connection) finally: if connection is not None: connection.close() if isinstance(delivery_id, str): with self._browser_inflight_lock: self._browser_inflight.discard(delivery_id) def _ack_browser_event( self, delivery_id: object, connection: CDPConnection ) -> None: if not isinstance(delivery_id, str) or not delivery_id: return try: result = connection.evaluate(ack_expression(self.key, [delivery_id])) if result not in (True, "true"): raise DouyinError("notification acknowledgement failed") except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ): LOG.warning("notification acknowledgement failed", exc_info=True) def _retry_browser_event( self, delivery_id: object, connection: CDPConnection ) -> None: if not isinstance(delivery_id, str) or not delivery_id: return try: result = connection.evaluate(retry_expression(self.key, [delivery_id])) if result not in (True, "true"): raise DouyinError("notification retry acknowledgement failed") except ( DouyinError, OSError, TypeError, ValueError, websocket.WebSocketException, ): LOG.warning("notification retry marker failed", exc_info=True) def pending(self) -> list[dict]: with self.condition: return list(self.queue) def _details( self, ids: list[str], connection: CDPConnection | None = None ) -> list[dict]: expected = set(ids) connection = connection or self._get_connection() for attempt in range(2): raw = connection.evaluate(details_expression(ids)) if not isinstance(raw, str): raise DouyinError("notification details returned invalid data") try: response = json.loads(raw) body = json.loads(response["body"]) except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise DouyinError("notification details could not be parsed") from exc if ( not isinstance(response, dict) or not isinstance(body, dict) or response.get("status") != 200 or body.get("status_code") != 0 ): raise DouyinError("notification details request failed") notices = body.get("notice_list_v2") if not isinstance(notices, list): raise DouyinError("notification details list is invalid") normalized: list[dict] = [] found: set[str] = set() for notice in notices: if ( not isinstance(notice, dict) or str(notice.get("user_id")) != self.uid ): raise DouyinError("notification identity changed") nid = str(notice.get("nid_str") or notice.get("nid") or "") if not nid.isascii() or not nid.isdecimal(): raise DouyinError("notification ID is invalid") found.add(nid) normalized.append(notice) if found == expected: return normalized if attempt == 0: time.sleep(0.2) continue raise DouyinError("notification details are incomplete") raise DouyinError("notification details are incomplete") def _put(self, item: dict) -> None: item = dict(item) item.setdefault("delivery_id", uuid.uuid4().hex) if item.get("kind") == "notice": notice = item.get("notice") if isinstance(notice, dict): notice.setdefault( "gateway_received_at", datetime.now(timezone.utc).isoformat() ) with self.condition: if len(self.queue) >= 1000: # Keep the backlog visible; replace only one oldest item with a # gap marker instead of silently dropping the entire queue. dropped = getattr(self, "_overflow_count", 0) + 1 self._overflow_count = dropped self.queue[0] = { "kind": "error", "reason": "notification queue overflow", "continuity": "gap", "dropped": dropped, "delivery_id": uuid.uuid4().hex, } else: self.queue.append(item) self.condition.notify_all() def poll(self, limit: int, wait_seconds: float) -> list[dict]: deadline = time.monotonic() + wait_seconds with self.condition: while not self.queue and not self.stopped.is_set(): remaining = deadline - time.monotonic() if remaining <= 0: break self.condition.wait(remaining) return list(self.queue)[:limit] def ack(self, delivery_ids: list[str]) -> None: ids = {value for value in delivery_ids if isinstance(value, str) and value} if not ids: return with self.condition: self.queue = deque( item for item in self.queue if item.get("delivery_id") not in ids ) def stop(self) -> None: self.stopped.set() with self._connection_lock: connection = self.connection connection.close() with self.condition: self.condition.notify_all() self.thread.join(timeout=2.0) if self.thread.is_alive(): LOG.error( "notification listener did not stop within timeout", extra={"alias": self.alias}, ) pool = getattr(self, "_detail_pool", None) if pool is not None: pool.shutdown(wait=True, cancel_futures=True) class SubscriptionManager: def __init__(self, browser: DouyinBrowser) -> None: self.browser = browser self._lock = threading.RLock() self._items: dict[str, DouyinSubscription] = {} def start(self, alias: str, uid: str) -> dict: with self._lock: previous = self._items.pop(alias, None) pending = previous.pending() if previous else [] if previous: previous.stop() item = DouyinSubscription(self.browser, alias, uid, pending=pending) self._items[alias] = item return {"connected": True, "alias": alias, "uid": uid} def poll(self, alias: str, limit: int, wait_seconds: float) -> list[dict]: with self._lock: item = self._items.get(alias) if not item: raise DouyinError("notification listener is not running") return item.poll(limit, wait_seconds) def ack(self, alias: str, delivery_ids: list[str]) -> None: with self._lock: item = self._items.get(alias) if not item: raise DouyinError("notification listener is not running") item.ack(delivery_ids) def stop(self, alias: str) -> None: with self._lock: item = self._items.pop(alias, None) if item: item.stop() def close(self) -> None: with self._lock: aliases = list(self._items) for alias in aliases: self.stop(alias) def _notice_id(value: object) -> str: if type(value) is int and value > 0: return str(value) if isinstance(value, str) and ID_RE.fullmatch(value): return value return "" def _direct_message_type(detail: dict, message: object) -> str: raw_type = ( detail.get("message_type") or detail.get("msg_type") or detail.get("type") ) if isinstance(message, dict): raw_type = raw_type or message.get("type") or message.get("message_type") if isinstance(raw_type, int) and not isinstance(raw_type, bool): raw_type = {1: "text", 2: "image", 3: "voice", 4: "video", 5: "sticker"}.get( raw_type ) if isinstance(raw_type, str): normalized = raw_type.strip().lower() aliases = { "text": "text", "txt": "text", "image": "image", "img": "image", "picture": "image", "voice": "voice", "audio": "voice", "video": "video", "sticker": "sticker", "emoji": "sticker", } if normalized in aliases: return aliases[normalized] if any(detail.get(key) for key in ("voice", "audio", "audio_url")): return "voice" if any(detail.get(key) for key in ("image", "picture", "image_url")): return "image" if any(detail.get(key) for key in ("video", "video_url")): return "video" if isinstance(message, str) and message: return "text" return "non_text" def normalize_notice(notice: object) -> dict | None: if not isinstance(notice, dict): raise DouyinError("notification detail is invalid") if notice.get("comment"): kind, event_type = "comment", "comment" elif notice.get("follow"): kind, event_type = "follow", "follow" elif notice.get("digg"): kind, event_type = "digg", "like" elif notice.get("share"): kind, event_type = "share", "repost" elif ( notice.get("dm") or notice.get("message") or notice.get("im") or notice.get("chat") ): kind, event_type = next( (candidate, "dm") for candidate in ("dm", "message", "im", "chat") if notice.get(candidate) ) else: return None detail = notice.get(kind) if not isinstance(detail, dict): raise DouyinError("notification detail payload is invalid") if event_type == "dm": sender = ( detail.get("from_user") or detail.get("sender") or detail.get("user") or {} ) if isinstance(sender, list): sender = sender[0] if sender else {} if not isinstance(sender, dict): raise DouyinError("direct-message sender is invalid") message = ( detail.get("text") or detail.get("content") or detail.get("message") or notice.get("text") or "" ) message_type = _direct_message_type(detail, message) if isinstance(message, dict): message = message.get("text") or message.get("content") or "" if not isinstance(message, str) or len(message) > 100000: raise DouyinError("direct-message text is invalid") event_key = _notice_id( notice.get("message_id") or notice.get("msg_id") or detail.get("message_id") or detail.get("msg_id") or notice.get("nid_str") or notice.get("nid") ) if not event_key: raise DouyinError("direct-message ID is invalid") result = { "event_key": event_key, "event_type": "dm", "interactor_uid": _notice_id(sender.get("uid") or sender.get("user_id")), "comment_id": "", "work_id": "", "message_type": message_type, "message_text": message, } create_time = notice.get("create_time") if isinstance(create_time, (int, float)) and not isinstance(create_time, bool): try: timestamp = float(create_time) if math.isfinite(timestamp) and timestamp > 0: result["platform_event_at"] = datetime.fromtimestamp( timestamp, timezone.utc ).isoformat() except (OverflowError, OSError, ValueError) as exc: raise DouyinError("notification timestamp is invalid") from exc return result users = detail.get("from_user") or [] if isinstance(users, dict): users = [users] if not isinstance(users, list): raise DouyinError("notification users are invalid") comment = detail.get("comment") or {} if not isinstance(comment, dict): raise DouyinError("notification comment is invalid") if not users and comment.get("user"): users = [comment["user"]] uids = { uid for user in users if isinstance(user, dict) for uid in [_notice_id(user.get("uid") or user.get("user_id"))] if uid } event_key = _notice_id(notice.get("nid_str") or notice.get("nid")) if not event_key: raise DouyinError("notification ID is invalid") work = detail.get("aweme") or notice.get("aweme") or {} if not isinstance(work, dict): work = {} comment_id = next( ( value for value in ( notice.get("comment_id"), detail.get("comment_id"), comment.get("cid_str"), comment.get("cid"), ) if _notice_id(value) ), "", ) work_id = next( ( value for value in ( notice.get("aweme_id"), detail.get("aweme_id"), work.get("aweme_id"), work.get("id"), ) if _notice_id(value) ), "", ) result = { "event_key": event_key, "event_type": event_type, "interactor_uid": next(iter(uids), "") if len(uids) == 1 else "", "comment_id": _notice_id(comment_id), "work_id": _notice_id(work_id), } create_time = notice.get("create_time") if isinstance(create_time, (int, float)) and not isinstance(create_time, bool): try: timestamp = float(create_time) if math.isfinite(timestamp) and timestamp > 0: result["platform_event_at"] = datetime.fromtimestamp( timestamp, timezone.utc ).isoformat() except (OverflowError, OSError, ValueError) as exc: raise DouyinError("notification timestamp is invalid") from exc return result def is_douyin_url(value: object) -> bool: if not isinstance(value, str): return False try: parsed = urlsplit(value) return ( parsed.scheme == "https" and parsed.hostname == "www.douyin.com" and parsed.port is None and parsed.username is None and parsed.password is None ) except (TypeError, ValueError): return False def is_douyin_media_url(value: object) -> bool: if not isinstance(value, str): return False try: parsed = urlsplit(value) return ( parsed.scheme == "https" and parsed.hostname == "www.douyin.com" and parsed.port is None and parsed.username is None and parsed.password is None and parsed.path.startswith("/video/") ) except (TypeError, ValueError): return False def detect_challenge(status: int, body: str) -> str: if status == 429: return "" if status == 412: return "captcha" lowered = body.lower() if any(token in lowered for token in ("captcha", "verify_code", "验证码")): return "captcha" if any(token in lowered for token in ("device_verify", "device_check", "设备验证")): return "device" return "" def notice_ids(event: dict) -> list[str]: try: payload = json.loads(event["payload"]) except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise DouyinError("notification push payload is invalid") from exc if not isinstance(payload, dict): raise DouyinError("notification push payload is invalid") if event.get("service") == 20313: notices = payload.get("notices", []) if not isinstance(notices, list): raise DouyinError("notification push list is invalid") selected = [ notice for notice in notices if isinstance(notice, dict) and {str(group) for group in notice.get("effect_groups", [])} & {"960", "961"} ] elif event.get("service") == 20003 and payload.get("notice_type") in { 45, 31, 9009, 9002, 514, 9067, }: selected = [payload] else: return [] ids: list[str] = [] for notice in selected: value = notice.get("notice_id_str") if not isinstance(value, str) or not value.isascii() or not value.isdecimal(): raise DouyinError("notification ID is invalid") ids.append(value) return list(dict.fromkeys(ids)) def install_expression(key: str, uid: str) -> str: return INSTALL_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace( "UID_VALUE", json.dumps(uid) ) def wait_expression(key: str) -> str: return WAIT_SCRIPT.replace("KEY_VALUE", json.dumps(key)) def dispose_expression(key: str) -> str: return f"(() => {{ const key={json.dumps(key)}; window[key]?.dispose(); delete window[key]; return true; }})()" def ack_expression(key: str, ids: list[str]) -> str: if ( not isinstance(key, str) or not key or not ids or any(not isinstance(value, str) or not value for value in ids) ): raise ValueError("notification delivery IDs are invalid") return ACK_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace( "IDS_VALUE", json.dumps(ids) ) def retry_expression(key: str, ids: list[str]) -> str: if ( not isinstance(key, str) or not key or not ids or any(not isinstance(value, str) or not value for value in ids) ): raise ValueError("notification delivery IDs are invalid") return RETRY_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace( "IDS_VALUE", json.dumps(ids) ) def details_expression(ids: list[str]) -> str: if not ids or any( not isinstance(value, str) or not value.isascii() or not value.isdecimal() for value in ids ): raise ValueError("notification IDs are invalid") return DETAILS_SCRIPT.replace("IDS_VALUE", json.dumps(ids)) def follow_expression(params: dict) -> str: return FOLLOW_SCRIPT.replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True)) def im_expression(params: dict, expected_uid: str) -> str: return IM_SCRIPT.replace("EXPECTED_UID_VALUE", json.dumps(expected_uid)).replace( "PARAMS_VALUE", json.dumps(params, ensure_ascii=True) ) def action_expression(params: dict) -> str: return ACTION_SCRIPT.replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True)) INSTALL_SCRIPT = r"""(async()=>{try{ if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN"); const key=KEY_VALUE,uid=UID_VALUE,chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error("RUNTIME_MISSING");const old=window[key],carry=Array.isArray(old?.queue)?old.queue.filter(e=>e&&e.delivery_id):[];old?.dispose?.();delete window[key];let req; chunks.push([["creatorhub-notice-"+Date.now()],{},r=>{req=r;}]);chunks.pop(); const entries=Object.entries(req.m||{});const entry=entries.find(([,f])=>String(f).includes("NOTICE_PUSH_EVENT_NAMES:function")); const codec=entries.find(([,f])=>{const s=String(f);return s.includes(".decodedFrame=")&&s.includes(".encodeFrame=");}); if(!entry||!codec)throw Error("SDK_CHANGED");const C=req(entry[0]).NoticeFrontier,decode=req(codec[0]).decodedFrame,f=C.frontierInstance; if(!f||String(f._options.deviceID)!==uid)throw Error("SOCKET_NOT_READY"); const state={queue:carry,wake:null,C,f,uid,dropped:0,nextDelivery:0};const emit=e=>{const item={...e,delivered:false,delivery_id:"browser-"+(++state.nextDelivery)};if(state.queue.length>=1000){state.queue.shift();state.dropped++;state.queue.push({kind:"error",reason:"QUEUE_OVERFLOW",continuity:"gap",dropped:state.dropped,delivered:false,delivery_id:"browser-"+(++state.nextDelivery)});}else state.queue.push(item);if(state.wake)state.wake();}; const message=e=>{try{const frame=decode(new Uint8Array(e.data));if(frame.service===20313||frame.service===20003)emit({kind:"push",service:frame.service,payload:new TextDecoder().decode(frame.payload)});}catch(_){emit({kind:"error",reason:"FRAME_DECODE_FAILED",continuity:"gap"});}}; const open=()=>emit({kind:"open"}),close=()=>emit({kind:"close"});f.addEventListener("message",message);f.addEventListener("open",open);f.addEventListener("close",close); state.dispose=()=>{f.removeEventListener("message",message);f.removeEventListener("open",open);f.removeEventListener("close",close);if(state.wake)state.wake();};window[key]=state; return JSON.stringify({connected:f.readyState===f.OPEN}); }catch(e){return JSON.stringify({bridge_error:String(e.message||e)});}})()""" WAIT_SCRIPT = r"""(async()=>{const s=window[KEY_VALUE];if(!s||s.C.frontierInstance!==s.f||String(s.f._options.deviceID)!==s.uid)throw Error("LISTENER_INVALID"); const ready=()=>s.queue.filter(e=>!e.delivered);if(!ready().length)await new Promise(resolve=>{const timer=setTimeout(done,2000);function done(){clearTimeout(timer);s.wake=null;resolve();}s.wake=done;}); const result=ready();result.forEach(e=>{e.delivered=true;});return JSON.stringify(result);})()""" ACK_SCRIPT = r"""((ids)=>{const s=window[KEY_VALUE];if(!s||!Array.isArray(ids))throw Error("LISTENER_INVALID");const wanted=new Set(ids);s.queue=s.queue.filter(e=>!wanted.has(e?.delivery_id));return true;})(IDS_VALUE)""" RETRY_SCRIPT = r"""((ids)=>{const s=window[KEY_VALUE];if(!s||!Array.isArray(ids))throw Error("LISTENER_INVALID");const wanted=new Set(ids);s.queue.forEach(e=>{if(wanted.has(e?.delivery_id))e.delivered=false;});if(s.wake)s.wake();return true;})(IDS_VALUE)""" DETAILS_SCRIPT = r"""(async ids=>{if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");const chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error("RUNTIME_MISSING");let req;chunks.push([["creatorhub-detail-"+Date.now()],{},r=>{req=r;}]);chunks.pop();const entry=Object.entries(req.m||{}).find(([,f])=>/getNoticeDetail\s*:/.test(String(f)));const sdk=entry&&req(entry[0]),client=window.axiosInstance;if(typeof sdk?.getNoticeDetail!=="function"||!client?.interceptors?.response)throw Error("SDK_NOT_READY");const params={id_list:JSON.stringify(ids.map(n=>({notice_id_str:n,type:0}))),is_mark_read:0};let raw,timer;const observer=client.interceptors.response.use(response=>{const config=response.config||{},url=new URL(config.url||"",location.origin),xhr=response.request;if(config.params?.id_list===params.id_list&&config.params.is_mark_read===0&&url.origin===location.origin&&url.pathname==="/aweme/v1/web/notice/detail/"&&xhr&&(!xhr.responseType||xhr.responseType==="text")&&typeof xhr.responseText==="string")raw={status:response.status,body:xhr.responseText};return response;});try{await Promise.race([sdk.getNoticeDetail(params),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error("TIMEOUT")),20000);})]);}finally{clearTimeout(timer);client.interceptors.response.eject(observer);}if(!raw)throw Error("RAW_RESPONSE_MISSING");return JSON.stringify(raw);})(IDS_VALUE)""" FOLLOW_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;try{if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)}),v=await r.json();if(!r.ok||v.status_code!==0)throw Error("READ_FAILED");return v;};const prefix="?device_platform=webapp&aid=6383&channel=channel_pc_web";const self=await get("/aweme/v1/web/user/profile/self/"+prefix);if(String(self.user?.uid)!==p.expected)throw Error("IDENTITY_MISMATCH");if(p.target===p.expected)throw Error("SELF_TARGET");const path="/aweme/v1/web/user/profile/other/"+prefix+"&user_id="+encodeURIComponent(p.target),profile=(await get(path)).user;if(String(profile?.uid)!==p.target)throw Error("TARGET_MISMATCH");if(![0,1,2].includes(profile.follow_status))throw Error("UNKNOWN_FOLLOW_STATE");if(profile.follow_status!==0||p.check)return {status:"succeeded",action:p.check?"checked":"already_following",follow_status:profile.follow_status,evidence:{target_uid:p.target,follow_status:String(profile.follow_status)}};sent=true;let result;try{const r=await fetch("/aweme/v1/web/commit/follow/user/"+prefix,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams({user_id:p.target,type:"1"}),signal:AbortSignal.timeout(10000)});result=await r.json();if(!r.ok)throw Error("POST_UNCERTAIN");}catch(e){if(e.message==="POST_UNCERTAIN")throw e;throw Error("POST_UNCERTAIN");}if(result.status_code!==0){const e=Error("BUSINESS_REJECTED");e.definitive=true;throw e;}if(![1,2].includes(result.follow_status))throw Error("POST_UNCONFIRMED");const verify=(await get(path)).user;return {status:String(verify?.uid)===p.target&&[1,2].includes(verify.follow_status)?"succeeded":"unknown",action:"followed",evidence:{target_uid:p.target,follow_status:String(verify?.follow_status??"")}};}catch(e){const code=["WRONG_ORIGIN","IDENTITY_MISMATCH","SELF_TARGET","TARGET_MISMATCH","UNKNOWN_FOLLOW_STATE"].includes(e.message)?e.message:(e.message==="BUSINESS_REJECTED"?e.message:"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()""" 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};}})()""" 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"};}})()"""