更新
This commit is contained in:
+17
-2
@@ -1314,6 +1314,10 @@ class ConversationResponse(BaseModel):
|
|||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
content: str = ""
|
content: str = ""
|
||||||
|
# 调用方认定的收件人 UID。会话归属校验只保证会话属于本账号,保证不了
|
||||||
|
# 「这个人就是用户点选的那个人」——消息页按昵称兜底匹配会话时可能选中
|
||||||
|
# 同名的另一个人。带上它,发送链路会在写出去之前核对收件人。
|
||||||
|
peer_uid: Optional[str] = None
|
||||||
message_type: Optional[str] = None # text | image | sticker
|
message_type: Optional[str] = None # text | image | sticker
|
||||||
media_url: Optional[str] = None
|
media_url: Optional[str] = None
|
||||||
sticker_url: Optional[str] = None
|
sticker_url: Optional[str] = None
|
||||||
@@ -3102,6 +3106,9 @@ async def send_account_message(
|
|||||||
raise HTTPException(status_code=400, detail="消息内容不能为空")
|
raise HTTPException(status_code=400, detail="消息内容不能为空")
|
||||||
if not body.conversation_id:
|
if not body.conversation_id:
|
||||||
raise HTTPException(status_code=400, detail="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)
|
worker = manager.workers.get(account_id)
|
||||||
session = _build_account_im_session(account)
|
session = _build_account_im_session(account)
|
||||||
@@ -3113,13 +3120,21 @@ async def send_account_message(
|
|||||||
async def _do_send() -> bool:
|
async def _do_send() -> bool:
|
||||||
nonlocal last_error
|
nonlocal last_error
|
||||||
if worker and worker._im_service:
|
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 ""
|
last_error = worker._im_service.last_error or ""
|
||||||
if ok:
|
if ok:
|
||||||
await _persist_im_session_data(account_id, worker._im_service.session, db)
|
await _persist_im_session_data(account_id, worker._im_service.session, db)
|
||||||
return ok
|
return ok
|
||||||
async with DouyinImHttpClient(session, account_id=account_id) as http:
|
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 ""
|
last_error = http.last_error or ""
|
||||||
if ok:
|
if ok:
|
||||||
session.conv_meta = http.session.conv_meta
|
session.conv_meta = http.session.conv_meta
|
||||||
|
|||||||
@@ -703,6 +703,35 @@ class DouyinImHttpClient:
|
|||||||
return resolved
|
return resolved
|
||||||
return int(sess.my_uid or 0)
|
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:
|
def _log_send_failure(self, conversation_id: str, detail: str) -> None:
|
||||||
system_logger.record(
|
system_logger.record(
|
||||||
"私信发送失败",
|
"私信发送失败",
|
||||||
@@ -1281,11 +1310,17 @@ class DouyinImHttpClient:
|
|||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
conversation_short_id: str = "",
|
conversation_short_id: str = "",
|
||||||
|
expected_peer_uid: str = "",
|
||||||
_bypass_global_queue: bool = False,
|
_bypass_global_queue: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""通过 IM API 发送 Protobuf 编码的私信(带接口签名)
|
"""通过 IM API 发送 Protobuf 编码的私信(带接口签名)
|
||||||
|
|
||||||
content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。
|
content 可为纯文本,或 JSON 格式的结构化回复(文本/网址/卡片)。
|
||||||
|
|
||||||
|
``expected_peer_uid`` 是调用方认定的收件人 UID。会话归属校验只能保证
|
||||||
|
「这条会话是本账号的」,保证不了「这个人就是用户想发的人」——界面按昵称
|
||||||
|
兜底匹配会话时可能选中同名的另一个人。带上它,写入点就能在发出去之前
|
||||||
|
确认收件人确实是调用方指定的那个。
|
||||||
"""
|
"""
|
||||||
if not _bypass_global_queue:
|
if not _bypass_global_queue:
|
||||||
# This is the common write entry point used by automatic replies,
|
# This is the common write entry point used by automatic replies,
|
||||||
@@ -1327,6 +1362,7 @@ class DouyinImHttpClient:
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
content,
|
content,
|
||||||
conversation_short_id=conversation_short_id,
|
conversation_short_id=conversation_short_id,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
_bypass_global_queue=True,
|
_bypass_global_queue=True,
|
||||||
)
|
)
|
||||||
self.last_send_meta = dict(queued_http.last_send_meta)
|
self.last_send_meta = dict(queued_http.last_send_meta)
|
||||||
@@ -1417,6 +1453,17 @@ class DouyinImHttpClient:
|
|||||||
)
|
)
|
||||||
return False
|
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():
|
if not auth.is_sign_ready():
|
||||||
self._set_error("缺少 IM 签名密钥,请用浏览器登录补全 localStorage")
|
self._set_error("缺少 IM 签名密钥,请用浏览器登录补全 localStorage")
|
||||||
self._log_send_failure(
|
self._log_send_failure(
|
||||||
@@ -1425,13 +1472,6 @@ class DouyinImHttpClient:
|
|||||||
)
|
)
|
||||||
return False
|
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, {})
|
cached = self.last_send_meta.get(conversation_id, {})
|
||||||
conv_short_id = str(conversation_short_id or "").strip()
|
conv_short_id = str(conversation_short_id or "").strip()
|
||||||
|
|
||||||
@@ -1442,6 +1482,13 @@ class DouyinImHttpClient:
|
|||||||
)
|
)
|
||||||
if resolved_id:
|
if resolved_id:
|
||||||
conversation_id = 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 "")
|
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 "")
|
ticket = resolved_ticket or str(cached.get("ticket") or "")
|
||||||
|
|
||||||
|
|||||||
@@ -1556,12 +1556,14 @@ class DouyinImService:
|
|||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
conversation_short_id: str = "",
|
conversation_short_id: str = "",
|
||||||
|
expected_peer_uid: str = "",
|
||||||
) -> tuple[bool, Optional[dict]]:
|
) -> tuple[bool, Optional[dict]]:
|
||||||
async with self._session_lock:
|
async with self._session_lock:
|
||||||
return await self._send_text_unlocked(
|
return await self._send_text_unlocked(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
content,
|
content,
|
||||||
conversation_short_id=conversation_short_id,
|
conversation_short_id=conversation_short_id,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_text_unlocked(
|
async def _send_text_unlocked(
|
||||||
@@ -1569,6 +1571,7 @@ class DouyinImService:
|
|||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
conversation_short_id: str = "",
|
conversation_short_id: str = "",
|
||||||
|
expected_peer_uid: str = "",
|
||||||
) -> tuple[bool, Optional[dict]]:
|
) -> tuple[bool, Optional[dict]]:
|
||||||
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
|
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
|
||||||
|
|
||||||
@@ -1580,6 +1583,7 @@ class DouyinImService:
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
content,
|
content,
|
||||||
conversation_short_id=conversation_short_id,
|
conversation_short_id=conversation_short_id,
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
)
|
)
|
||||||
self.last_error = http.last_error
|
self.last_error = http.last_error
|
||||||
needs_refresh = http.last_send_needs_refresh
|
needs_refresh = http.last_send_needs_refresh
|
||||||
@@ -1681,8 +1685,17 @@ class DouyinImService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"on_session_invalid handler error: {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 .conv_util import normalize_conversation_id
|
||||||
from .auth import DouyinAuth
|
from .auth import DouyinAuth
|
||||||
from .dy_util import DEFAULT_USER_AGENT
|
from .dy_util import DEFAULT_USER_AGENT
|
||||||
@@ -1705,6 +1718,7 @@ class DouyinImService:
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
content,
|
content,
|
||||||
conversation_short_id=str(meta.get("conversation_short_id") or ""),
|
conversation_short_id=str(meta.get("conversation_short_id") or ""),
|
||||||
|
expected_peer_uid=expected_peer_uid,
|
||||||
)
|
)
|
||||||
if sent and resolved:
|
if sent and resolved:
|
||||||
self._conv_meta[conversation_id] = {
|
self._conv_meta[conversation_id] = {
|
||||||
|
|||||||
@@ -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 hosted_registry
|
||||||
from rpa_engine.douyin_im import ws_client as ws_module
|
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.conv_util import conversation_belongs_to
|
||||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||||
from rpa_engine.douyin_im.service import DouyinImService
|
from rpa_engine.douyin_im.service import DouyinImService
|
||||||
@@ -151,6 +152,80 @@ class ForeignSendRefusalTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertFalse(client.last_send_channel_retryable)
|
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):
|
class HostedPeerLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||||
"""两个本系统托管的账号之间不得互相自动回复(无限回环 → 抖音风控)。"""
|
"""两个本系统托管的账号之间不得互相自动回复(无限回环 → 抖音风控)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -188,15 +188,18 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"0:1:10001:20002",
|
"0:1:10001:20002",
|
||||||
"queued hello",
|
"queued hello",
|
||||||
conversation_short_id="short-before-send",
|
conversation_short_id="short-before-send",
|
||||||
|
expected_peer_uid="20002",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(sent)
|
self.assertFalse(sent)
|
||||||
submit.assert_awaited_once()
|
submit.assert_awaited_once()
|
||||||
queued_factory.assert_called_once_with(client.session, account_id=88)
|
queued_factory.assert_called_once_with(client.session, account_id=88)
|
||||||
|
# 收件人期望必须原样传给真正写出去的那个 client:排队调度层不能把它吃掉
|
||||||
queued_client.send_text_message.assert_awaited_once_with(
|
queued_client.send_text_message.assert_awaited_once_with(
|
||||||
"0:1:10001:20002",
|
"0:1:10001:20002",
|
||||||
"queued hello",
|
"queued hello",
|
||||||
conversation_short_id="short-before-send",
|
conversation_short_id="short-before-send",
|
||||||
|
expected_peer_uid="20002",
|
||||||
_bypass_global_queue=True,
|
_bypass_global_queue=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(client.last_send_meta, queued_meta)
|
self.assertEqual(client.last_send_meta, queued_meta)
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
+29
-52
@@ -18,6 +18,12 @@ import {
|
|||||||
buildStickerPayload,
|
buildStickerPayload,
|
||||||
parseMessageContent
|
parseMessageContent
|
||||||
} from '../utils/messageContent'
|
} from '../utils/messageContent'
|
||||||
|
import {
|
||||||
|
isGenericPeerName,
|
||||||
|
extractPeerUid,
|
||||||
|
matchPeerConversation,
|
||||||
|
resolveConversationId as resolvePeerConversationId
|
||||||
|
} from '../utils/peerMatch'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const logs = ref([])
|
const logs = ref([])
|
||||||
@@ -241,26 +247,6 @@ const getAccountName = (accountId) => {
|
|||||||
const getAccount = (accountId) =>
|
const getAccount = (accountId) =>
|
||||||
accounts.value.find((a) => Number(a.id) === Number(accountId)) || null
|
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 peerUidFromLog = (log) => {
|
||||||
const uid = extractPeerUid({ sender_id: log.sender_id })
|
const uid = extractPeerUid({ sender_id: log.sender_id })
|
||||||
if (uid) return uid
|
if (uid) return uid
|
||||||
@@ -272,11 +258,18 @@ const peerUidFromLog = (log) => {
|
|||||||
if (fromConv) return fromConv
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -310,17 +303,8 @@ const convKey = (log, peerUid = undefined) => {
|
|||||||
return `${log.account_id}::name:${log.sender_name || 'unknown'}`
|
return `${log.account_id}::name:${log.sender_name || 'unknown'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const findPeerMeta = (conv) => {
|
const findPeerMeta = (conv) =>
|
||||||
const list = convListCache.value[conv.account_id] || []
|
matchPeerConversation(convListCache.value[conv.account_id] || [], conv)
|
||||||
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 enrichConversation = (conv) => {
|
const enrichConversation = (conv) => {
|
||||||
const peer = findPeerMeta(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) => {
|
const fetchConvList = async (accountId) => {
|
||||||
if (!accountId) return []
|
if (!accountId) return []
|
||||||
if (convListCache.value[accountId]) {
|
if (convListCache.value[accountId]) {
|
||||||
@@ -349,20 +331,8 @@ const fetchConvList = async (accountId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveConversationId = (conv) => {
|
const resolveConversationId = (conv) =>
|
||||||
const peerUid = extractPeerUid(conv)
|
resolvePeerConversationId(convListCache.value[conv.account_id] || [], 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 isAccountOnline = (accountId) => {
|
const isAccountOnline = (accountId) => {
|
||||||
const acc = accounts.value.find(a => a.id === accountId)
|
const acc = accounts.value.find(a => a.id === accountId)
|
||||||
@@ -531,13 +501,20 @@ const sendMessage = async () => {
|
|||||||
}
|
}
|
||||||
const conversationId = resolveConversationId(conv)
|
const conversationId = resolveConversationId(conv)
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
message.error('无法解析会话 ID,请刷新日志或到私信收发页重试')
|
message.error(
|
||||||
|
'无法确认这条会话对应的抖音用户(常见于昵称重复或对方资料未解析),'
|
||||||
|
+ '已阻止发送以免发错人;请到「私信收发」页选中该用户后再发'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
const body = { conversation_id: conversationId, content }
|
const body = { conversation_id: conversationId, content }
|
||||||
|
// 把界面认定的收件人一并告诉后端:写入点会在发出去之前核对,
|
||||||
|
// 万一会话匹配选错了人(例如昵称重复),发送会被拒绝而不是发错。
|
||||||
|
const expectedPeerUid = extractPeerUid(conv)
|
||||||
|
if (expectedPeerUid) body.peer_uid = expectedPeerUid
|
||||||
const parsed = parseMessageContent(content)
|
const parsed = parseMessageContent(content)
|
||||||
if (parsed.type === 'sticker') {
|
if (parsed.type === 'sticker') {
|
||||||
body.message_type = 'sticker'
|
body.message_type = 'sticker'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
parseMessageContent
|
parseMessageContent
|
||||||
} from '../utils/messageContent'
|
} from '../utils/messageContent'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { conversationPeerUid } from '../utils/peerMatch'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
@@ -119,6 +120,9 @@ const sendMessage = async () => {
|
|||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
const body = { conversation_id: selectedConv.value.conversation_id, content }
|
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)
|
const parsed = parseMessageContent(content)
|
||||||
if (parsed.type === 'sticker') {
|
if (parsed.type === 'sticker') {
|
||||||
body.message_type = 'sticker'
|
body.message_type = 'sticker'
|
||||||
|
|||||||
Reference in New Issue
Block a user