更新
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -13,8 +15,9 @@ from rpa_engine.egress_channels import (
|
||||
resolve_send_channels,
|
||||
)
|
||||
from .conv_util import build_conversation_id, normalize_conversation_id, resolve_peer_uid
|
||||
from .message_content import format_im_message, serialize_message_content
|
||||
from .peer_profile import enrich_conversation_item, fetch_peer_profile, is_generic_peer_name
|
||||
from .protocol import normalize_im_payload, normalize_im_payload_from_bytes, _pick_avatar_url
|
||||
from .protocol import normalize_im_payload_from_bytes, _pick_avatar_url
|
||||
from .session import DouyinImSession
|
||||
|
||||
logger = logging.getLogger("douyin_im.http")
|
||||
@@ -23,6 +26,27 @@ IMAPI_BASE = "https://imapi.douyin.com"
|
||||
|
||||
# 抖音 IM「按会话拉取消息」cmd(与电商/web 一致);body 字段号 == cmd。
|
||||
CMD_GET_MESSAGES_BY_CONVERSATION = 301
|
||||
# 「按用户拉取收件箱」cmd:抖音网页版打开私信时用它一次性同步各会话最新消息。
|
||||
CMD_GET_MESSAGES_BY_USER_INIT = 200
|
||||
|
||||
# MessageBody 的字段号(与 Response.proto 的 MessageBody 一致)。
|
||||
_MSG_FIELD_CONVERSATION_ID = 1
|
||||
_MSG_FIELD_SERVER_MESSAGE_ID = 3
|
||||
_MSG_FIELD_CONVERSATION_SHORT_ID = 5
|
||||
_MSG_FIELD_MESSAGE_TYPE = 6
|
||||
_MSG_FIELD_SENDER = 7
|
||||
_MSG_FIELD_CONTENT = 8
|
||||
|
||||
_CONVERSATION_ID_RE = re.compile(r"^0:\d+:\d+:\d+$")
|
||||
|
||||
# 托管轮询只需覆盖长连接断开的时间窗;游标是微秒时间戳,窗口越小响应越小
|
||||
# (实测同一账号:游标 0 -> 113KB,回看 30 分钟 -> 约 2KB)。
|
||||
INBOX_POLL_LOOKBACK_SECONDS = 1800.0
|
||||
|
||||
# 会话列表被抖音拒绝时的系统日志节流:托管期间每个账号最多每 30 分钟记一次,
|
||||
# 既保证「收不到私信」有据可查,又不会被每轮轮询刷屏。
|
||||
_CONVERSATION_REJECT_LOG_INTERVAL = 1800.0
|
||||
_conversation_reject_logged_at: dict[tuple[int, str], float] = {}
|
||||
|
||||
|
||||
def _pb_varint(n: int) -> bytes:
|
||||
@@ -94,6 +118,173 @@ def _pb_parse_fields(buf: bytes) -> list[tuple[int, int, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int:
|
||||
try:
|
||||
return int(str(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _pb_message_body(buf: bytes) -> Optional[dict]:
|
||||
"""把一段字节按 MessageBody 解析;形状不像就返回 None。
|
||||
|
||||
判据是「有合法的 conversation_id + server_message_id」,而不是它出现在
|
||||
哪个字段号上——收件箱响应里 messages 挂在哪一层随接口而变。
|
||||
"""
|
||||
msg: dict = {}
|
||||
try:
|
||||
fields = _pb_parse_fields(buf)
|
||||
except Exception:
|
||||
return None
|
||||
for fn, wt, val in fields:
|
||||
if fn == _MSG_FIELD_CONVERSATION_ID and wt == 2:
|
||||
try:
|
||||
conv_id = val.decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
if not _CONVERSATION_ID_RE.match(conv_id):
|
||||
return None
|
||||
msg["conversation_id"] = conv_id
|
||||
elif fn == _MSG_FIELD_SERVER_MESSAGE_ID and wt == 0:
|
||||
msg["server_message_id"] = str(val)
|
||||
elif fn == _MSG_FIELD_CONVERSATION_SHORT_ID and wt == 0:
|
||||
msg["conversation_short_id"] = str(val)
|
||||
elif fn == _MSG_FIELD_MESSAGE_TYPE and wt == 0:
|
||||
msg["message_type"] = val
|
||||
elif fn == _MSG_FIELD_SENDER and wt == 0:
|
||||
msg["sender"] = str(val)
|
||||
elif fn == _MSG_FIELD_CONTENT and wt == 2:
|
||||
msg["content"] = val.decode("utf-8", errors="replace")
|
||||
if msg.get("conversation_id") and msg.get("server_message_id"):
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
def _pb_collect_message_bodies(
|
||||
buf: bytes,
|
||||
out: list[dict],
|
||||
depth: int = 0,
|
||||
) -> None:
|
||||
"""递归找出响应体里所有 MessageBody。"""
|
||||
if depth > 6:
|
||||
return
|
||||
parsed = _pb_message_body(buf)
|
||||
if parsed is not None:
|
||||
out.append(parsed)
|
||||
return
|
||||
try:
|
||||
fields = _pb_parse_fields(buf)
|
||||
except Exception:
|
||||
return
|
||||
for _fn, wt, val in fields:
|
||||
if wt == 2 and isinstance(val, bytes) and val:
|
||||
_pb_collect_message_bodies(val, out, depth + 1)
|
||||
|
||||
|
||||
def _pb_parse_inbox_messages(content: bytes, cmd: int) -> list[dict]:
|
||||
"""从 get_by_user_init 响应里取出各会话的最新消息。"""
|
||||
out: list[dict] = []
|
||||
for fn, wt, val in _pb_parse_fields(content):
|
||||
if fn != 6 or wt != 2: # Response.body
|
||||
continue
|
||||
for bfn, bwt, bval in _pb_parse_fields(val):
|
||||
if bfn != cmd or bwt != 2: # ResponseBody.<cmd>
|
||||
continue
|
||||
_pb_collect_message_bodies(bval, out)
|
||||
return out
|
||||
|
||||
|
||||
def _is_inbox_control_message(msg: dict) -> bool:
|
||||
"""收件箱里的会话控制/状态帧(不是用户发的消息)。"""
|
||||
from .protocol import _is_control_payload
|
||||
|
||||
try:
|
||||
message_type = int(msg.get("message_type") or 0)
|
||||
except (TypeError, ValueError):
|
||||
message_type = 0
|
||||
content_json: Any = None
|
||||
raw = msg.get("content")
|
||||
if raw:
|
||||
try:
|
||||
content_json = json.loads(raw)
|
||||
except Exception:
|
||||
content_json = None
|
||||
return _is_control_payload(content_json, message_type)
|
||||
|
||||
|
||||
def _pb_parse_inbox_conversations(content: bytes, cmd: int) -> list[dict]:
|
||||
"""取出 cmd 200 响应里的会话条目。
|
||||
|
||||
响应体除了 messages(字段 1) 还带一组会话条目(字段 6):
|
||||
f1=conversation_short_id(varint) f4=conversation_id(string)
|
||||
游标为 0 时这组条目就是账号的完整会话列表,所以「列全部会话」不必再去拉
|
||||
cmd 203 的 1.5MB 全量快照。
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for fn, wt, val in _pb_parse_fields(content):
|
||||
if fn != 6 or wt != 2: # Response.body
|
||||
continue
|
||||
for bfn, bwt, bval in _pb_parse_fields(val):
|
||||
if bfn != cmd or bwt != 2:
|
||||
continue
|
||||
for cfn, cwt, cval in _pb_parse_fields(bval):
|
||||
if cfn != 6 or cwt != 2: # repeated conversation entry
|
||||
continue
|
||||
short_id = ""
|
||||
conv_id = ""
|
||||
for efn, ewt, eval_ in _pb_parse_fields(cval):
|
||||
if efn == 1 and ewt == 0:
|
||||
short_id = str(eval_)
|
||||
elif efn == 4 and ewt == 2:
|
||||
try:
|
||||
candidate = eval_.decode("utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if _CONVERSATION_ID_RE.match(candidate):
|
||||
conv_id = candidate
|
||||
if conv_id:
|
||||
out.append(
|
||||
{
|
||||
"conversation_id": conv_id,
|
||||
"conversation_short_id": short_id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _pb_parse_inbox_page(content: bytes, cmd: int) -> tuple[int, bool]:
|
||||
"""返回收件箱这一页的 (next_cursor, has_more)。"""
|
||||
next_cursor = 0
|
||||
has_more = False
|
||||
for fn, wt, val in _pb_parse_fields(content):
|
||||
if fn != 6 or wt != 2:
|
||||
continue
|
||||
for bfn, bwt, bval in _pb_parse_fields(val):
|
||||
if bfn != cmd or bwt != 2:
|
||||
continue
|
||||
for cfn, cwt, cval in _pb_parse_fields(bval):
|
||||
if cfn == 2 and cwt == 0:
|
||||
next_cursor = int(cval)
|
||||
elif cfn == 3 and cwt == 0:
|
||||
has_more = bool(cval)
|
||||
return next_cursor, has_more
|
||||
|
||||
|
||||
def _pb_response_status(content: bytes) -> tuple[Optional[int], str]:
|
||||
"""返回 IM protobuf 响应的 (status_code, message)。"""
|
||||
status: Optional[int] = None
|
||||
message = ""
|
||||
try:
|
||||
for fn, wt, val in _pb_parse_fields(content):
|
||||
if fn == 3 and wt == 0:
|
||||
status = int(val)
|
||||
elif fn == 4 and wt == 2:
|
||||
message = val.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None, ""
|
||||
return status, message
|
||||
|
||||
|
||||
def _pb_parse_conversation_messages(content: bytes, cmd: int) -> list[dict]:
|
||||
"""解析 get_by_conversation 的 protobuf 响应,返回消息列表。"""
|
||||
out: list[dict] = []
|
||||
@@ -350,6 +541,13 @@ class DouyinImHttpClient:
|
||||
# another channel. Ambiguous read timeouts stay false to avoid duplicates.
|
||||
self.last_send_channel_retryable: bool = False
|
||||
self.last_request_debug: str = ""
|
||||
# 会话列表被抖音判定为「请求本身不合法」时置位。换 payload、换 cookie
|
||||
# 都修不好,上层据此停掉这轮轮询,别每 120 秒白打一次请求。
|
||||
self.conversation_list_unsupported: bool = False
|
||||
# 最近一次收件箱响应里的会话条目(只覆盖翻到的那些页)
|
||||
self._last_inbox_conversations: list[dict] = []
|
||||
# 翻页预算用尽但抖音还说 has_more:这次拿到的会话列表不完整
|
||||
self.inbox_truncated: bool = False
|
||||
self._proxy_url: str = ""
|
||||
self._source_ip_override = str(source_ip or "").strip()
|
||||
self._egress_public_ip_override = str(egress_public_ip or "").strip()
|
||||
@@ -448,16 +646,43 @@ class DouyinImHttpClient:
|
||||
def _set_error(self, msg: str) -> None:
|
||||
self.last_error = msg or ""
|
||||
|
||||
def _resolve_authoritative_uid(self, auth) -> int:
|
||||
"""用 query/user 接口核验当前账号真实 UID,并回写 session.my_uid。
|
||||
def _report_conversation_list_rejected(self, reason: str) -> None:
|
||||
"""把「会话列表被抖音拒绝」变成可见故障,而不是静默的空收件箱。"""
|
||||
self._set_error(f"会话列表接口被抖音拒绝:{reason}")
|
||||
self.conversation_list_unsupported = True
|
||||
logger.warning(
|
||||
"Conversation list rejected by IM API (account=%s): %s",
|
||||
self.account_id,
|
||||
reason,
|
||||
)
|
||||
key = (int(self.account_id or 0), reason)
|
||||
now = time.monotonic()
|
||||
last_at = _conversation_reject_logged_at.get(key)
|
||||
if last_at is not None and now - last_at < _CONVERSATION_REJECT_LOG_INTERVAL:
|
||||
return
|
||||
_conversation_reject_logged_at[key] = now
|
||||
system_logger.record(
|
||||
"会话列表接口被抖音拒绝,已停用轮询兜底",
|
||||
detail=(
|
||||
f"抖音返回:{reason}。该请求本身被判定为不合法,重试也修不好,"
|
||||
"本轮托管不再重复调用。私信改为完全依赖实时长连接接收;"
|
||||
"长连接断开期间漏收的消息无法再通过轮询补齐。"
|
||||
),
|
||||
level="error",
|
||||
category="poll",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
采集端从 tea_cache 推断的 my_uid 可能取到访客/对方 id(导致 cmd=609
|
||||
INVALID_REQUEST、会话列表为 0)。query/user 返回的 user_uid 才是权威值。
|
||||
核验成功后写回 session 并打标,避免每次发送都请求接口。
|
||||
def _resolve_authoritative_uid(self, auth) -> int:
|
||||
"""Resolve a usable UID without overwriting a known IM identity.
|
||||
|
||||
Douyin's query/user ``user_uid`` can differ from the UID used by IM.
|
||||
It is therefore only a last-resort value when the session has no UID;
|
||||
a profile-verified or collected numeric UID always wins.
|
||||
"""
|
||||
sess = self.session
|
||||
auth.source_ip = self._source_ip
|
||||
if getattr(sess, "uid_verified", False) and sess.my_uid:
|
||||
if sess.my_uid:
|
||||
return int(sess.my_uid)
|
||||
resolved = None
|
||||
try:
|
||||
@@ -466,16 +691,10 @@ class DouyinImHttpClient:
|
||||
logger.warning(f"query/user 解析 my_uid 失败: {e}")
|
||||
if resolved and str(resolved).isdigit():
|
||||
resolved = int(resolved)
|
||||
old = int(sess.my_uid or 0)
|
||||
if old and old != resolved:
|
||||
logger.warning("my_uid 修正(query/user):采集值 %s -> 权威值 %s", old, resolved)
|
||||
sess.my_uid = resolved
|
||||
# 本系统里 device_id 等同账号 uid(采集端常与 my_uid 一起取错,导致会话列表为 0)。
|
||||
# device_id 为空 / 非数字 / 等于旧的错误 my_uid 时,一并修正为权威 uid。
|
||||
dev = str(sess.device_id or "")
|
||||
if (not dev.isdigit()) or (old and dev == str(old)):
|
||||
if not dev.isdigit():
|
||||
sess.device_id = str(resolved)
|
||||
sess.uid_verified = True
|
||||
return resolved
|
||||
return int(sess.my_uid or 0)
|
||||
|
||||
@@ -734,7 +953,9 @@ class DouyinImHttpClient:
|
||||
|
||||
cmd = CMD_GET_MESSAGES_BY_CONVERSATION
|
||||
try:
|
||||
request = await asyncio.to_thread(ProtoBuilder.build_normal_request, auth, cmd)
|
||||
# 同样要用 x_tt_token:带 auth.ticket 时抖音回 OK 但正文恒为空,
|
||||
# 于是 WS 瘦推送的图片/语音一直补不全真实 content。
|
||||
request = await asyncio.to_thread(ProtoBuilder.build_read_request, auth, cmd)
|
||||
conv_req = (
|
||||
_pb_str(1, conversation_id)
|
||||
+ _pb_int(2, 1)
|
||||
@@ -815,83 +1036,207 @@ class DouyinImHttpClient:
|
||||
pass
|
||||
return total
|
||||
|
||||
async def get_conversations(self, *, enrich_profiles: bool = True) -> list[dict]:
|
||||
"""拉取会话列表,返回标准化会话"""
|
||||
payloads = [
|
||||
{"cursor": 0, "count": 50, "inbox_type": 0},
|
||||
{"cursor": 0, "limit": 50},
|
||||
{},
|
||||
]
|
||||
conversations = []
|
||||
seen = set()
|
||||
async def fetch_inbox_messages(
|
||||
self,
|
||||
limit: int = 50,
|
||||
lookback_seconds: float = INBOX_POLL_LOOKBACK_SECONDS,
|
||||
max_pages: int = 1,
|
||||
) -> list[dict]:
|
||||
"""用 protobuf 拉取收件箱消息,按游标翻页。
|
||||
|
||||
for body in payloads:
|
||||
data = await self._request("POST", "/v1/conversation/list", body)
|
||||
# Only a transport/parse failure warrants trying GET. An empty
|
||||
# JSON object/list can be a perfectly valid empty inbox response.
|
||||
if data is None:
|
||||
data = await self._request("GET", "/v1/conversation/list", body)
|
||||
if data is None:
|
||||
# Payload variants only help with schema compatibility. They
|
||||
# cannot repair a network outage, so stop after POST + GET
|
||||
# both fail instead of occupying a scarce global slot for up
|
||||
# to four more full request timeouts.
|
||||
logger.warning("Conversation poll transport failed; skipping payload fallbacks")
|
||||
break
|
||||
imapi.douyin.com 只接受 protobuf:发 JSON body 会被当成 protobuf 解析,
|
||||
固定返回 status_code=1 "unexepcted session length"(与 Cookie 无关,
|
||||
实测不带任何 Cookie 也是同一条错误)。这里用与发送/拉消息同一套 Request
|
||||
信封,抖音网页版打开私信时用的也是这个 cmd。
|
||||
|
||||
status_code = data.get("status_code") if isinstance(data, dict) else None
|
||||
error_text = ""
|
||||
if isinstance(data, dict):
|
||||
error_text = str(
|
||||
data.get("error_desc")
|
||||
or data.get("message")
|
||||
or data.get("error")
|
||||
or ""
|
||||
).strip().lower()
|
||||
explicit_success = status_code in (0, "0")
|
||||
structured_without_status = (
|
||||
isinstance(data, (dict, list)) and status_code is None
|
||||
响应是**分页**的:body 的 f2=next_cursor、f3=has_more,随附的会话条目
|
||||
只覆盖这一页里出现过的会话。实测 cursor=0 返回 9 个会话且 has_more=1,
|
||||
翻 6 页后累计 35 个且仍未翻完——所以「一次请求 = 完整会话列表」是错的,
|
||||
翻不完时必须把 inbox_truncated 置位,别把一页伪装成全部。
|
||||
"""
|
||||
from .auth import DouyinAuth
|
||||
from .proto_builder import ProtoBuilder
|
||||
|
||||
cmd = CMD_GET_MESSAGES_BY_USER_INIT
|
||||
auth = DouyinAuth.from_im_session(self.session)
|
||||
auth.source_ip = self._source_ip
|
||||
# 字段 1 是游标(微秒时间戳)。托管轮询只回看一个窗口:这条链路只用来
|
||||
# 补齐长连接断开期间漏收的消息。lookback_seconds<=0 表示不设游标
|
||||
# (游标 0 = 从头翻),别算成「now」,那等于只要比此刻更新的消息。
|
||||
if lookback_seconds <= 0:
|
||||
cursor = 0
|
||||
else:
|
||||
cursor = max(0, int((time.time() - lookback_seconds) * 1_000_000))
|
||||
|
||||
messages: list[dict] = []
|
||||
conversations: list[dict] = []
|
||||
seen_conversations: set[str] = set()
|
||||
self.inbox_truncated = False
|
||||
pages = max(1, int(max_pages))
|
||||
for page in range(pages):
|
||||
request = await asyncio.to_thread(
|
||||
ProtoBuilder.build_read_request, auth, cmd
|
||||
)
|
||||
terminal_credential_error = (
|
||||
status_code not in (None, 0, "0")
|
||||
and any(
|
||||
marker in error_text
|
||||
for marker in (
|
||||
"empty token",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"credential expired",
|
||||
"authentication",
|
||||
"unauthorized",
|
||||
"not login",
|
||||
"not logged",
|
||||
)
|
||||
body = _pb_int(1, cursor) + _pb_int(2, int(limit))
|
||||
payload = request.SerializeToString() + _pb_msg(8, _pb_msg(cmd, body))
|
||||
resp = await self._post_protobuf(
|
||||
f"{IMAPI_BASE}/v1/message/get_by_user_init",
|
||||
auth,
|
||||
payload,
|
||||
signed=False,
|
||||
log_label="inbox",
|
||||
)
|
||||
resp.raise_for_status()
|
||||
status_code, message = _pb_response_status(resp.content)
|
||||
if status_code is not None and status_code != 0:
|
||||
self._report_conversation_list_rejected(
|
||||
message or f"status_code={status_code}"
|
||||
)
|
||||
)
|
||||
return []
|
||||
if page == 0:
|
||||
self._adopt_authoritative_uid(resp.content)
|
||||
messages.extend(_pb_parse_inbox_messages(resp.content, cmd))
|
||||
for entry in _pb_parse_inbox_conversations(resp.content, cmd):
|
||||
conv_id = str(entry.get("conversation_id") or "")
|
||||
if conv_id and conv_id not in seen_conversations:
|
||||
seen_conversations.add(conv_id)
|
||||
conversations.append(entry)
|
||||
|
||||
normalized = normalize_im_payload(data)
|
||||
for item in normalized:
|
||||
name = item.get("sender_name") or ""
|
||||
key = name or item.get("conversation_id") or ""
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
conversations.append(item)
|
||||
|
||||
# 也从原始结构提取会话级 unread
|
||||
self._extract_conversation_rows(data, conversations, seen)
|
||||
|
||||
# Compatibility payloads are alternatives, not pagination. Stop
|
||||
# after a successful empty response as well as a non-empty one;
|
||||
# otherwise every idle account issues three identical endpoint
|
||||
# calls on every poll. Credential errors cannot be repaired by
|
||||
# changing only the JSON shape, so do not amplify those either.
|
||||
if (
|
||||
conversations
|
||||
or explicit_success
|
||||
or structured_without_status
|
||||
or terminal_credential_error
|
||||
):
|
||||
next_cursor, has_more = _pb_parse_inbox_page(resp.content, cmd)
|
||||
if not has_more:
|
||||
break
|
||||
# 游标不前进就停:否则同一页会被无限翻下去。
|
||||
if not next_cursor or next_cursor == cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
if page == pages - 1:
|
||||
self.inbox_truncated = True
|
||||
logger.info(
|
||||
"Inbox paging stopped at the %d-page budget for account %s; "
|
||||
"%d conversations so far, more remain",
|
||||
pages,
|
||||
self.account_id,
|
||||
len(conversations),
|
||||
)
|
||||
|
||||
self._last_inbox_conversations = conversations
|
||||
return messages
|
||||
|
||||
def _adopt_authoritative_uid(self, content: bytes) -> None:
|
||||
"""响应字段 13 是抖音认定的本账号 IM uid,用它纠正 session.my_uid。
|
||||
|
||||
my_uid 取错时 _is_self_message 拦不住自己发的消息(机器人会自问自答),
|
||||
resolve_peer_uid 也会把会话对端认成自己。
|
||||
"""
|
||||
uid = 0
|
||||
try:
|
||||
for fn, wt, val in _pb_parse_fields(content):
|
||||
if fn == 13 and wt == 0:
|
||||
uid = int(val)
|
||||
break
|
||||
except Exception:
|
||||
return
|
||||
if not uid or uid == int(self.session.my_uid or 0):
|
||||
return
|
||||
logger.warning(
|
||||
"Account %s IM uid corrected from %s to %s (imapi response field 13)",
|
||||
self.account_id,
|
||||
self.session.my_uid,
|
||||
uid,
|
||||
)
|
||||
self.session.my_uid = uid
|
||||
self.session.uid_verified = True
|
||||
|
||||
async def get_conversations(
|
||||
self,
|
||||
*,
|
||||
enrich_profiles: bool = True,
|
||||
lookback_seconds: float = INBOX_POLL_LOOKBACK_SECONDS,
|
||||
max_pages: int = 1,
|
||||
) -> list[dict]:
|
||||
"""拉取会话列表,返回标准化会话。
|
||||
|
||||
lookback_seconds=0 表示从头翻;max_pages 是翻页预算。抖音不提供「一次
|
||||
取回全部会话」的接口,翻页预算用完时 inbox_truncated 会被置位——调用方
|
||||
必须知道自己拿到的可能只是一部分,不能把一页当成完整会话列表。
|
||||
托管轮询用默认的小窗口 + 单页,只为补齐长连接断开期间漏收的消息。
|
||||
"""
|
||||
conversations: list[dict] = []
|
||||
try:
|
||||
messages = await self.fetch_inbox_messages(
|
||||
lookback_seconds=lookback_seconds,
|
||||
max_pages=max_pages,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_error(str(exc))
|
||||
logger.warning("Conversation poll transport failed: %s", exc)
|
||||
return []
|
||||
|
||||
# 一个会话只保留最新的一条:server_message_id 单调递增。
|
||||
latest: dict[str, dict] = {}
|
||||
for msg in messages:
|
||||
conv_id = str(msg.get("conversation_id") or "")
|
||||
if not conv_id:
|
||||
continue
|
||||
# 会话状态/已读位等控制帧不是用户消息:既不该当成会话预览,
|
||||
# 更不该被 _handle_incoming 拿去匹配自动回复(WS 侧同样过滤)。
|
||||
if _is_inbox_control_message(msg):
|
||||
continue
|
||||
current = latest.get(conv_id)
|
||||
if current is None or _as_int(msg.get("server_message_id")) > _as_int(
|
||||
current.get("server_message_id")
|
||||
):
|
||||
latest[conv_id] = msg
|
||||
|
||||
# 窗口内没有消息、但账号里确实存在的会话也要出现在列表里,
|
||||
# 否则「会话列表」会退化成「最近有动静的会话」。
|
||||
for entry in self._last_inbox_conversations:
|
||||
conv_id = str(entry.get("conversation_id") or "")
|
||||
short_id = str(entry.get("conversation_short_id") or "")
|
||||
if short_id and short_id != "0":
|
||||
self.session.conv_meta.setdefault(conv_id, {})
|
||||
self.session.conv_meta[conv_id]["conversation_short_id"] = short_id
|
||||
if conv_id and conv_id not in latest:
|
||||
latest[conv_id] = {
|
||||
"conversation_id": conv_id,
|
||||
"conversation_short_id": short_id,
|
||||
"server_message_id": "0",
|
||||
"content": "",
|
||||
}
|
||||
|
||||
for conv_id, msg in latest.items():
|
||||
content = str(msg.get("content") or "")
|
||||
try:
|
||||
message_type = int(msg.get("message_type") or 0)
|
||||
except (TypeError, ValueError):
|
||||
message_type = 0
|
||||
preview = content
|
||||
try:
|
||||
parsed = format_im_message(json.loads(content), message_type)
|
||||
preview = serialize_message_content(parsed) if parsed else content
|
||||
except Exception:
|
||||
pass
|
||||
short_id = str(msg.get("conversation_short_id") or "")
|
||||
if short_id and short_id != "0":
|
||||
# 发送私信需要 short_id;从收件箱顺手补上,省掉一次 create。
|
||||
self.session.conv_meta.setdefault(conv_id, {})
|
||||
self.session.conv_meta[conv_id]["conversation_short_id"] = short_id
|
||||
conversations.append(
|
||||
{
|
||||
"conversation_id": conv_id,
|
||||
"conversation_short_id": short_id,
|
||||
"sender_name": "",
|
||||
"sender_avatar": None,
|
||||
"content": preview,
|
||||
"raw_content": content,
|
||||
"message_type": message_type,
|
||||
"server_message_id": str(msg.get("server_message_id") or ""),
|
||||
# sender 可能是自己(我方发出的最后一条),不能当 peer;
|
||||
# 交给 enrich_conversation_item 从 conversation_id 推。
|
||||
"sender_uid": str(msg.get("sender") or ""),
|
||||
"unread_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
my_uid = int(self.session.my_uid or 0)
|
||||
enriched: list[dict] = []
|
||||
@@ -917,51 +1262,6 @@ class DouyinImHttpClient:
|
||||
logger.info(f"Fetched {len(enriched)} conversations from IM API")
|
||||
return enriched
|
||||
|
||||
def _extract_conversation_rows(self, data: Any, out: list, seen: set, depth: int = 0):
|
||||
if depth > 10:
|
||||
return
|
||||
if isinstance(data, dict):
|
||||
name = (
|
||||
data.get("nick_name")
|
||||
or data.get("nickname")
|
||||
or (
|
||||
(data.get("core_info") or {}).get("nick_name")
|
||||
if isinstance(data.get("core_info"), dict)
|
||||
else None
|
||||
)
|
||||
)
|
||||
unread = data.get("unread_count") or data.get("unreadCount") or 0
|
||||
conv_id = data.get("conversation_id") or data.get("conversationId") or ""
|
||||
preview = ""
|
||||
last = data.get("last_message") or data.get("latest_message")
|
||||
if isinstance(last, dict):
|
||||
preview = last.get("content") or last.get("text") or ""
|
||||
elif isinstance(last, str):
|
||||
preview = last
|
||||
|
||||
if isinstance(name, str) and name.strip():
|
||||
name = name.strip()
|
||||
sender_avatar = _pick_avatar_url(data)
|
||||
if name not in seen:
|
||||
try:
|
||||
unread = int(unread or 0)
|
||||
except (TypeError, ValueError):
|
||||
unread = 0
|
||||
out.append({
|
||||
"sender_name": name,
|
||||
"sender_avatar": sender_avatar or None,
|
||||
"content": str(preview or ""),
|
||||
"conversation_id": str(conv_id or ""),
|
||||
"unread_count": unread,
|
||||
})
|
||||
seen.add(name)
|
||||
|
||||
for v in data.values():
|
||||
self._extract_conversation_rows(v, out, seen, depth + 1)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
self._extract_conversation_rows(item, out, seen, depth + 1)
|
||||
|
||||
async def send_text_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
@@ -1213,7 +1513,7 @@ class DouyinImHttpClient:
|
||||
self.last_send_channel_retryable = True
|
||||
detail = (
|
||||
"抖音安全网关返回 decision=KICK,当前登录/安全会话已被服务端踢下线;"
|
||||
"请停止托管后用浏览器模式重新登录,并打开一次私信页重新采集凭证"
|
||||
"系统正在自动重登录,请留意账号卡片上的二维码并扫码"
|
||||
)
|
||||
elif decision:
|
||||
detail = f"抖音安全网关拒绝发送 decision={decision}"
|
||||
|
||||
Reference in New Issue
Block a user