diff --git a/backend/main.py b/backend/main.py index 12ffee4..c38098d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1314,6 +1314,10 @@ class ConversationResponse(BaseModel): class SendMessageRequest(BaseModel): conversation_id: str content: str = "" + # 调用方认定的收件人 UID。会话归属校验只保证会话属于本账号,保证不了 + # 「这个人就是用户点选的那个人」——消息页按昵称兜底匹配会话时可能选中 + # 同名的另一个人。带上它,发送链路会在写出去之前核对收件人。 + peer_uid: Optional[str] = None message_type: Optional[str] = None # text | image | sticker media_url: Optional[str] = None sticker_url: Optional[str] = None @@ -3102,6 +3106,9 @@ async def send_account_message( raise HTTPException(status_code=400, detail="消息内容不能为空") if not body.conversation_id: raise HTTPException(status_code=400, detail="conversation_id 不能为空") + expected_peer_uid = str(body.peer_uid or "").strip() + if expected_peer_uid and not expected_peer_uid.isdigit(): + raise HTTPException(status_code=400, detail="peer_uid 必须是数字用户 ID") worker = manager.workers.get(account_id) session = _build_account_im_session(account) @@ -3113,13 +3120,21 @@ async def send_account_message( async def _do_send() -> bool: nonlocal last_error if worker and worker._im_service: - ok = await worker._im_service.send_message(body.conversation_id, content) + ok = await worker._im_service.send_message( + body.conversation_id, + content, + expected_peer_uid=expected_peer_uid, + ) last_error = worker._im_service.last_error or "" if ok: await _persist_im_session_data(account_id, worker._im_service.session, db) return ok async with DouyinImHttpClient(session, account_id=account_id) as http: - ok = await http.send_text_message(body.conversation_id, content) + ok = await http.send_text_message( + body.conversation_id, + content, + expected_peer_uid=expected_peer_uid, + ) last_error = http.last_error or "" if ok: session.conv_meta = http.session.conv_meta diff --git a/backend/rpa_engine/douyin_im/http_client.py b/backend/rpa_engine/douyin_im/http_client.py index bd47de6..7531c49 100644 --- a/backend/rpa_engine/douyin_im/http_client.py +++ b/backend/rpa_engine/douyin_im/http_client.py @@ -703,6 +703,35 @@ class DouyinImHttpClient: return resolved return int(sess.my_uid or 0) + def _peer_matches_expectation( + self, + conversation_id: str, + peer_uid, + expected_peer_uid: str, + ) -> bool: + """收件人必须与调用方指定的 UID 一致,否则拒发。""" + expected = str(expected_peer_uid or "").strip() + if not expected: + return True + actual = str(peer_uid or "").strip() + if actual == expected: + return True + detail = ( + f"发送目标与预期不一致:会话 {conversation_id} 的对方是 {actual or '未知'}," + f"调用方指定的收件人是 {expected},已拒绝发送以免发错人。" + ) + self._set_error(detail) + self.last_send_channel_retryable = False + self._log_send_failure(conversation_id, detail) + logger.error( + "Account %s refused send: peer mismatch conv=%s actual=%s expected=%s", + self.account_id, + conversation_id, + actual, + expected, + ) + return False + def _log_send_failure(self, conversation_id: str, detail: str) -> None: system_logger.record( "私信发送失败", @@ -1281,11 +1310,17 @@ class DouyinImHttpClient: conversation_id: str, content: str, conversation_short_id: str = "", + expected_peer_uid: str = "", _bypass_global_queue: bool = False, ) -> bool: """通过 IM API 发送 Protobuf 编码的私信(带接口签名) content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。 + + ``expected_peer_uid`` 是调用方认定的收件人 UID。会话归属校验只能保证 + 「这条会话是本账号的」,保证不了「这个人就是用户想发的人」——界面按昵称 + 兜底匹配会话时可能选中同名的另一个人。带上它,写入点就能在发出去之前 + 确认收件人确实是调用方指定的那个。 """ if not _bypass_global_queue: # This is the common write entry point used by automatic replies, @@ -1327,6 +1362,7 @@ class DouyinImHttpClient: conversation_id, content, conversation_short_id=conversation_short_id, + expected_peer_uid=expected_peer_uid, _bypass_global_queue=True, ) self.last_send_meta = dict(queued_http.last_send_meta) @@ -1417,6 +1453,17 @@ class DouyinImHttpClient: ) return False + conversation_id = normalize_conversation_id(conversation_id, my_uid) + peer_uid = resolve_peer_uid(conversation_id, my_uid) + if not peer_uid: + self._set_error("无法解析对方用户 ID") + self._log_send_failure(conversation_id, "无法从会话 ID 解析对方用户 ID") + return False + if not self._peer_matches_expectation( + conversation_id, peer_uid, expected_peer_uid + ): + return False + if not auth.is_sign_ready(): self._set_error("缺少 IM 签名密钥,请用浏览器登录补全 localStorage") self._log_send_failure( @@ -1425,13 +1472,6 @@ class DouyinImHttpClient: ) return False - conversation_id = normalize_conversation_id(conversation_id, my_uid) - peer_uid = resolve_peer_uid(conversation_id, my_uid) - if not peer_uid: - self._set_error("无法解析对方用户 ID") - self._log_send_failure(conversation_id, "无法从会话 ID 解析对方用户 ID") - return False - cached = self.last_send_meta.get(conversation_id, {}) conv_short_id = str(conversation_short_id or "").strip() @@ -1442,6 +1482,13 @@ class DouyinImHttpClient: ) if resolved_id: conversation_id = resolved_id + # 抖音回来的会话 ID 才是真正会被写入的那条:再确认一次收件人没被换掉。 + if not self._peer_matches_expectation( + conversation_id, + resolve_peer_uid(conversation_id, my_uid) or peer_uid, + expected_peer_uid, + ): + return False conv_short_id = resolved_short_id or conv_short_id or str(cached.get("conversation_short_id") or "") ticket = resolved_ticket or str(cached.get("ticket") or "") diff --git a/backend/rpa_engine/douyin_im/service.py b/backend/rpa_engine/douyin_im/service.py index 738388c..779b43d 100644 --- a/backend/rpa_engine/douyin_im/service.py +++ b/backend/rpa_engine/douyin_im/service.py @@ -1556,12 +1556,14 @@ class DouyinImService: conversation_id: str, content: str, conversation_short_id: str = "", + expected_peer_uid: str = "", ) -> tuple[bool, Optional[dict]]: async with self._session_lock: return await self._send_text_unlocked( conversation_id, content, conversation_short_id=conversation_short_id, + expected_peer_uid=expected_peer_uid, ) async def _send_text_unlocked( @@ -1569,6 +1571,7 @@ class DouyinImService: conversation_id: str, content: str, conversation_short_id: str = "", + expected_peer_uid: str = "", ) -> tuple[bool, Optional[dict]]: """发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。 @@ -1580,6 +1583,7 @@ class DouyinImService: conversation_id, content, conversation_short_id=conversation_short_id, + expected_peer_uid=expected_peer_uid, ) self.last_error = http.last_error needs_refresh = http.last_send_needs_refresh @@ -1681,8 +1685,17 @@ class DouyinImService: except Exception as e: logger.error(f"on_session_invalid handler error: {e}") - async def send_message(self, conversation_id: str, content: str) -> bool: - """手动发送私信""" + async def send_message( + self, + conversation_id: str, + content: str, + expected_peer_uid: str = "", + ) -> bool: + """手动发送私信。 + + ``expected_peer_uid`` 由调用方(消息页)指定收件人,写入点会在发出去 + 之前核对,避免界面按昵称匹配到同名的另一个人。 + """ from .conv_util import normalize_conversation_id from .auth import DouyinAuth from .dy_util import DEFAULT_USER_AGENT @@ -1705,6 +1718,7 @@ class DouyinImService: conversation_id, content, conversation_short_id=str(meta.get("conversation_short_id") or ""), + expected_peer_uid=expected_peer_uid, ) if sent and resolved: self._conv_meta[conversation_id] = { diff --git a/backend/tests/test_cross_account_isolation.py b/backend/tests/test_cross_account_isolation.py index 99f6acc..76f503f 100644 --- a/backend/tests/test_cross_account_isolation.py +++ b/backend/tests/test_cross_account_isolation.py @@ -24,6 +24,7 @@ if str(BACKEND_DIR) not in sys.path: from rpa_engine.douyin_im import hosted_registry from rpa_engine.douyin_im import ws_client as ws_module +from rpa_engine.douyin_im.auth import DouyinAuth from rpa_engine.douyin_im.conv_util import conversation_belongs_to from rpa_engine.douyin_im.http_client import DouyinImHttpClient from rpa_engine.douyin_im.service import DouyinImService @@ -151,6 +152,80 @@ class ForeignSendRefusalTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(client.last_send_channel_retryable) +class ExpectedRecipientTests(unittest.IsolatedAsyncioTestCase): + """手动发送必须打给调用方点选的那个人(昵称重复时会话可能匹配错)。""" + + OTHER_PEER = 975976494279630 + + def _client(self) -> DouyinImHttpClient: + return DouyinImHttpClient( + DouyinImSession(cookies={"sessionid": "a"}, my_uid=ACCOUNT_A_UID), + account_id=1, + ) + + async def _send(self, client, conversation_id, expected_peer_uid): + resolve_meta = AsyncMock(return_value=("", "", "")) + with ( + patch.object( + DouyinImHttpClient, + "_resolve_authoritative_uid", + return_value=ACCOUNT_A_UID, + ), + # 本组用例只验收件人闸门,凭证是否齐全与它无关 + patch.object(DouyinAuth, "is_sign_ready", return_value=True), + patch.object( + DouyinImHttpClient, "resolve_conversation_meta", resolve_meta + ), + patch("rpa_engine.douyin_im.http_client.system_logger.record", Mock()), + ): + sent = await client.send_text_message( + conversation_id, + "你好", + expected_peer_uid=expected_peer_uid, + _bypass_global_queue=True, + ) + return sent, resolve_meta + + async def test_refuses_when_the_conversation_points_at_someone_else(self): + client = self._client() + + sent, resolve_meta = await self._send( + client, + f"0:1:{ACCOUNT_A_UID}:{self.OTHER_PEER}", + str(PEER_OF_B), + ) + + self.assertFalse(sent) + # 必须在解析 ticket / 发包之前就拒绝 + resolve_meta.assert_not_awaited() + self.assertIn("发送目标与预期不一致", client.last_error) + self.assertFalse(client.last_send_channel_retryable) + + async def test_allows_the_intended_recipient(self): + client = self._client() + + sent, resolve_meta = await self._send( + client, + f"0:1:{ACCOUNT_A_UID}:{PEER_OF_B}", + str(PEER_OF_B), + ) + + # ticket 解析被 mock 成空 -> 发送仍会失败,但必须是「拿不到票据」而不是被闸门拦下 + self.assertFalse(sent) + resolve_meta.assert_awaited() + self.assertNotIn("发送目标与预期不一致", client.last_error) + + async def test_no_expectation_keeps_the_old_behaviour(self): + client = self._client() + + _, resolve_meta = await self._send( + client, f"0:1:{ACCOUNT_A_UID}:{self.OTHER_PEER}", "" + ) + + resolve_meta.assert_awaited() + self.assertNotIn("发送目标与预期不一致", client.last_error) + + class HostedPeerLoopTests(unittest.IsolatedAsyncioTestCase): """两个本系统托管的账号之间不得互相自动回复(无限回环 → 抖音风控)。""" diff --git a/backend/tests/test_send_entry_and_worker_lifecycle.py b/backend/tests/test_send_entry_and_worker_lifecycle.py index 2828046..1a98eb2 100644 --- a/backend/tests/test_send_entry_and_worker_lifecycle.py +++ b/backend/tests/test_send_entry_and_worker_lifecycle.py @@ -188,15 +188,18 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase): "0:1:10001:20002", "queued hello", conversation_short_id="short-before-send", + expected_peer_uid="20002", ) self.assertFalse(sent) submit.assert_awaited_once() queued_factory.assert_called_once_with(client.session, account_id=88) + # 收件人期望必须原样传给真正写出去的那个 client:排队调度层不能把它吃掉 queued_client.send_text_message.assert_awaited_once_with( "0:1:10001:20002", "queued hello", conversation_short_id="short-before-send", + expected_peer_uid="20002", _bypass_global_queue=True, ) self.assertEqual(client.last_send_meta, queued_meta) diff --git a/frontend/src/utils/peerMatch.js b/frontend/src/utils/peerMatch.js new file mode 100644 index 0000000..19f9cdf --- /dev/null +++ b/frontend/src/utils/peerMatch.js @@ -0,0 +1,97 @@ +/** + * 把「日志聚合出来的会话」对应回「账号真实会话列表」里的那一条。 + * + * 这条链路决定手动发送打给谁,所以匹配必须是分级的、宁缺毋滥的: + * 抖音昵称大量重复(还有「用户1234567」「未知用户」这类占位名), + * 早先版本把 UID / 会话 ID / 昵称写在同一个 OR 谓词里交给 Array.find, + * 于是列表里靠前的一条只要昵称相同就胜出,哪怕后面有 UID 精确匹配的那条 + * —— 结果就是消息发给了同名的另一个人。 + */ + +export const isGenericPeerName = (name, peerUid = '') => { + const value = String(name || '').trim() + if (!value) return true + if (value === '未知用户') return true + if (peerUid && value === String(peerUid)) return true + if (/^\d+$/.test(value)) return true + if (/^用户\d+$/.test(value)) return true + return false +} + +export const isConvId = (id) => /^0:1:\d+:\d+$/.test(String(id || '').trim()) + +export const extractPeerUid = (conv) => { + const raw = String(conv?.sender_id || '').trim() + if (!raw) return '' + if (/^\d+$/.test(raw)) return raw + if (isConvId(raw)) return raw.split(':')[3] || '' + const last = raw.split(':').pop() + return /^\d+$/.test(last || '') ? last : '' +} + +/** 会话条目代表的对方 UID:sender_id 优先,其次会话 ID 末段。 */ +export const conversationPeerUid = (item) => + extractPeerUid(item) || extractPeerUid({ sender_id: item?.conversation_id }) + +/** + * 分级匹配,命中一级就返回,绝不降级: + * 1) 对方 UID 精确相等 —— 有 UID 时只认 UID + * 2) 会话 ID 完全相等 + * 3) 会话 ID 末段等于 sender_id,且全列表唯一 + * 4) 昵称相等,且昵称不是占位名、且全列表唯一 + * 任何一级出现多个候选都返回 null:宁可不匹配,也不能猜错人。 + */ +export const matchPeerConversation = (list, conv) => { + const items = Array.isArray(list) ? list : [] + if (!items.length || !conv) return null + + const peerUid = extractPeerUid(conv) + if (peerUid) { + return items.find((item) => conversationPeerUid(item) === peerUid) || null + } + + const rawId = String(conv.sender_id || '').trim() + if (rawId) { + const exact = items.find( + (item) => String(item.conversation_id || '').trim() === rawId + ) + if (exact) return exact + const suffix = items.filter((item) => + String(item.conversation_id || '').endsWith(`:${rawId}`) + ) + if (suffix.length === 1) return suffix[0] + if (suffix.length > 1) return null + } + + const name = String(conv.sender_name || '').trim() + if (!name || isGenericPeerName(name)) return null + const byName = items.filter( + (item) => String(item.sender_name || '').trim() === name + ) + return byName.length === 1 ? byName[0] : null +} + +/** + * 解析手动发送要用的 conversation_id;无法确认对方身份时返回空串, + * 由调用方提示用户,而不是拿一个「差不多」的会话把消息发出去。 + */ +export const resolveConversationId = (list, conv) => { + if (!conv) return '' + const peerUid = extractPeerUid(conv) + const match = matchPeerConversation(list, conv) + + if (match?.conversation_id) { + const matchedPeer = conversationPeerUid(match) + // 最后一道断言:匹配到的会话必须和当前会话指向同一个人。 + if (!peerUid || !matchedPeer || matchedPeer === peerUid) { + return String(match.conversation_id) + } + return '' + } + + const raw = String(conv.sender_id || '').trim() + if (isConvId(raw)) return raw + // 裸 UID 是安全的:后端会用「当前账号 UID + 该 UID」拼出本账号的会话。 + if (/^\d+$/.test(raw)) return raw + return '' +} diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue index 7ed090b..6028d15 100644 --- a/frontend/src/views/Logs.vue +++ b/frontend/src/views/Logs.vue @@ -18,6 +18,12 @@ import { buildStickerPayload, parseMessageContent } from '../utils/messageContent' +import { + isGenericPeerName, + extractPeerUid, + matchPeerConversation, + resolveConversationId as resolvePeerConversationId +} from '../utils/peerMatch' const route = useRoute() const logs = ref([]) @@ -241,26 +247,6 @@ const getAccountName = (accountId) => { const getAccount = (accountId) => accounts.value.find((a) => Number(a.id) === Number(accountId)) || null -const isGenericPeerName = (name, peerUid = '') => { - const value = (name || '').trim() - if (!value) return true - if (peerUid && value === peerUid) return true - if (/^\d+$/.test(value)) return true - if (/^用户\d+$/.test(value)) return true - return false -} - -const extractPeerUid = (conv) => { - const raw = String(conv?.sender_id || '').trim() - if (!raw) return '' - if (/^\d+$/.test(raw)) return raw - if (/^0:1:\d+:\d+$/.test(raw)) { - return raw.split(':')[3] || '' - } - const last = raw.split(':').pop() - return /^\d+$/.test(last || '') ? last : '' -} - const peerUidFromLog = (log) => { const uid = extractPeerUid({ sender_id: log.sender_id }) if (uid) return uid @@ -272,11 +258,18 @@ const peerUidFromLog = (log) => { if (fromConv) return fromConv } } - if (log.sender_name && log.sender_name !== '[系统发送]') { - const byName = list.find( - (c) => c.sender_name === log.sender_name && extractPeerUid(c) + // 昵称回退只在「非占位名 + 全列表唯一」时可信:抖音重名很多, + // 猜错会把两个人的消息并进同一个会话,随后手动发送就发错人。 + if ( + log.sender_name + && log.sender_name !== '[系统发送]' + && !isGenericPeerName(log.sender_name) + ) { + const byName = list.filter( + (c) => String(c.sender_name || '').trim() === String(log.sender_name).trim() + && extractPeerUid(c) ) - if (byName) return extractPeerUid(byName) + if (byName.length === 1) return extractPeerUid(byName[0]) } return '' } @@ -310,17 +303,8 @@ const convKey = (log, peerUid = undefined) => { return `${log.account_id}::name:${log.sender_name || 'unknown'}` } -const findPeerMeta = (conv) => { - const list = convListCache.value[conv.account_id] || [] - const peerUid = extractPeerUid(conv) - return list.find( - (c) => - c.sender_name === conv.sender_name || - c.conversation_id === conv.sender_id || - (peerUid && (c.sender_id === peerUid || extractPeerUid(c) === peerUid)) || - (conv.sender_id && c.conversation_id?.endsWith(`:${conv.sender_id}`)) - ) || null -} +const findPeerMeta = (conv) => + matchPeerConversation(convListCache.value[conv.account_id] || [], conv) const enrichConversation = (conv) => { const peer = findPeerMeta(conv) @@ -333,8 +317,6 @@ const enrichConversation = (conv) => { } } -const isConvId = (id) => /^0:1:\d+:\d+$/.test(String(id || '').trim()) - const fetchConvList = async (accountId) => { if (!accountId) return [] if (convListCache.value[accountId]) { @@ -349,20 +331,8 @@ const fetchConvList = async (accountId) => { } } -const resolveConversationId = (conv) => { - const peerUid = extractPeerUid(conv) - const list = convListCache.value[conv.account_id] || [] - const match = list.find( - (c) => - (peerUid && (c.sender_id === peerUid || extractPeerUid(c) === peerUid)) || - c.sender_name === conv.sender_name || - c.conversation_id === conv.sender_id || - (conv.sender_id && c.conversation_id?.endsWith(`:${conv.sender_id}`)) - ) - if (match?.conversation_id) return match.conversation_id - if (isConvId(conv.sender_id)) return conv.sender_id - return conv.sender_id || '' -} +const resolveConversationId = (conv) => + resolvePeerConversationId(convListCache.value[conv.account_id] || [], conv) const isAccountOnline = (accountId) => { const acc = accounts.value.find(a => a.id === accountId) @@ -531,13 +501,20 @@ const sendMessage = async () => { } const conversationId = resolveConversationId(conv) if (!conversationId) { - message.error('无法解析会话 ID,请刷新日志或到私信收发页重试') + message.error( + '无法确认这条会话对应的抖音用户(常见于昵称重复或对方资料未解析),' + + '已阻止发送以免发错人;请到「私信收发」页选中该用户后再发' + ) return } sending.value = true try { const body = { conversation_id: conversationId, content } + // 把界面认定的收件人一并告诉后端:写入点会在发出去之前核对, + // 万一会话匹配选错了人(例如昵称重复),发送会被拒绝而不是发错。 + const expectedPeerUid = extractPeerUid(conv) + if (expectedPeerUid) body.peer_uid = expectedPeerUid const parsed = parseMessageContent(content) if (parsed.type === 'sticker') { body.message_type = 'sticker' diff --git a/frontend/src/views/Messages.vue b/frontend/src/views/Messages.vue index 7a2b708..0f48c21 100644 --- a/frontend/src/views/Messages.vue +++ b/frontend/src/views/Messages.vue @@ -12,6 +12,7 @@ import { parseMessageContent } from '../utils/messageContent' import { useAuthStore } from '../stores/auth' +import { conversationPeerUid } from '../utils/peerMatch' const auth = useAuthStore() const accounts = ref([]) @@ -119,6 +120,9 @@ const sendMessage = async () => { sending.value = true try { const body = { conversation_id: selectedConv.value.conversation_id, content } + // 会话是用户在列表里点选的,把它的对方 UID 一并送去后端核对收件人 + const expectedPeerUid = conversationPeerUid(selectedConv.value) + if (expectedPeerUid) body.peer_uid = expectedPeerUid const parsed = parseMessageContent(content) if (parsed.type === 'sticker') { body.message_type = 'sticker'