闲鱼 PC 端 IM 消息通道完整技术解析:协议逆向、故障定位与修复实战
版本:2026-08 关键词:闲鱼 / goofish / WebSocket / IMPaaS / syncPushPackage / mtop / MessagePack 一句话结论:PC 消息主通道仍是 wss-goofish.dingtalk.com;“连上却收不到”的根因通常不是 API 整体更换,而是 同步游标(pts)握手不完整——必须先 getState 再 ackDiff。
目录
1. 问题背景
1.1 典型故障现象
基于闲鱼网页协议的机器人,运行一段时间后出现:
| Cookie 仍可访问卖家工作台 | 是 |
| login.token 仍能返回 accessToken | 是 |
| WebSocket 可连接 wss-goofish.dingtalk.com | 是 |
| 心跳 /! 有响应 | 是 |
| 买家实时消息收不到 | 否 |
| 手动调用发送接口有时仍可发出 | 部分情况 |
这是一种非常误导人的故障形态:连接层健康,同步层失效。
1.2 错误假设 vs 正确方向
| 消息 API 整体下线 | 否,主通道仍在 |
| 必须改成 ACCS 收消息 | 否,ACCS 当前主要是心跳旁路 |
| 发送协议完全变了 | 否,sendByReceiverScope 未变 |
| Token API 改名了 | 否,idlemessage.pc.login.token 仍在用 |
| 只要 Cookie 有效就能收消息 | 否,还依赖 WS 同步握手 |
1.3 排查目标
2. 系统总览
2.1 逻辑分层
┌─────────────────────────────────────────────────────────┐
│ 业务层:AI 回复 / 人工接管 / 自动发货 / 订单轮询 │
├─────────────────────────────────────────────────────────┤
│ 会话层:chat_id、item_id、buyer_id、上下文 SQLite │
├─────────────────────────────────────────────────────────┤
│ IM 协议层:/reg、getState、ackDiff、syncPush、MessageSend │
├─────────────────────────────────────────────────────────┤
│ 传输层:WebSocket (wss-goofish.dingtalk.com) │
├─────────────────────────────────────────────────────────┤
│ 鉴权层:Cookie + mtop sign + IM accessToken │
└─────────────────────────────────────────────────────────┘
2.2 端到端数据流
浏览器登录 Cookie
│
▼
POST h5api.m.goofish.com
mtop.taobao.idlemessage.pc.login.token
│ accessToken
▼
WSS wss://wss-goofish.dingtalk.com/
│
├─ /reg 注册上线,拿到 sid
├─ listNewestPagination 拉会话列表(可选)
├─ getState 拿服务端真实 pts
├─ ackDiff 确认同步游标(关键)
│
├─ 收:/s/sync + syncPushPackage
│ └─ MessagePack 解密
│ └─ reminderContent / 订单状态
│
└─ 发:/r/MessageSend/sendByReceiverScope
2.3 关键常量一览
| 站点 | https://www.goofish.com | PC 网页入口 |
| mtop 域名 | https://h5api.m.goofish.com | H5 API 网关 |
| mtop appKey | 34839810 | 签名用 |
| IM app-key | 444e9908a51d1cb236a27862abc769c9 | WS 注册与 token body |
| WS 主通道 | wss://wss-goofish.dingtalk.com/ | 聊天收发 |
| ACCS 旁路 | wss://msgacs.m.taobao.com/accs/auth | 推送/心跳辅助 |
| accountSite | xianyu | mtop 查询参数 |
| jsv | 2.7.2 | mtop JS 版本号 |
| wv | im:3,au:3,sy:6 | WS 能力版本声明 |
| dt | j | 数据类型(JSON) |
| unitName | PNM | 注册返回的单元名 |
2.4 代码模块映射
| main.py / XianyuLive | WS 生命周期、握手、收发、业务分发 |
| XianyuApis.py | mtop Token / 商品 / 卖家接口 |
| utils/xianyu_utils.py | Cookie 规范化、签名、deviceId、MessagePack 解密 |
| context_manager.py | 会话上下文与消息落库 |
| XianyuAgent.py | 意图识别与 LLM 回复 |
3. 身份与鉴权体系
闲鱼 PC IM 不是单一 Token,而是 三层凭证叠加。
3.1 第一层:浏览器 Cookie(登录态)
常见关键字段:
| unb | 用户数字 ID(老字段) |
| havana_lgc2_* | 新登录体系,内含 hid |
| _m_h5_tk | mtop 签名 token,格式 token_expireTs |
| _m_h5_tk_enc | mtop token 加密伴生字段 |
| cookie2 / sgcookie | 会话相关 |
| cna | 设备/追踪相关 |
| XSRF-TOKEN | 部分登录接口用 |
兼容点:
- 新 Cookie 可能没有 unb
- 需从 havana_lgc2_* Base64 JSON 中取 hid
- 内部实现可把 hid 回填为 unb 以兼容老代码
3.2 第二层:mtop 签名(HTTP API)
签名算法:
sign = md5( f"{_m_h5_tk前半段}&{t}&{appKey}&{data}" )
其中:
- _m_h5_tk前半段:cookie['_m_h5_tk'].split('_')[0]
- t:毫秒时间戳字符串
- appKey:固定 34839810
- data:POST body 中的 data 字段原始 JSON 字符串
示例:
token = session.cookies.get("_m_h5_tk", "").split("_")[0]
t = str(int(time.time() * 1000))
data = '{"appKey":"444e9908a51d1cb236a27862abc769c9","deviceId":"…"}'
sign = md5(f"{token}&{t}&34839810&{data}")
3.3 第三层:IM accessToken(WebSocket)
通过:
mtop.taobao.idlemessage.pc.login.token
拿到:
{
"accessToken": "oauth_k1:…",
"accessTokenExpiredTime": "86400000",
"refreshToken": "oauth_k1:…"
}
- accessToken:用于 WS /reg 的 headers.token
- accessTokenExpiredTime:本次抓包为 86400000(24 小时,毫秒)
- refreshToken:网页端可能用于续期;当前机器人主路径仍是重新 get_token
3.4 deviceId 规则
网页与机器人都使用“UUID + 用户 ID”形式:
46BDFCA8-9193-4C68-8C0A-970470EF3807-2208946894812
│______________UUID 部分______________│ │__userId__│
生成逻辑要点:
3.5 mid / uuid 生成
| mid | 请求-响应关联 ID | 3891786173067720 0 |
| uuid | 发送消息去重 ID | -17861730736201 |
实现参考:
# mid: 随机前缀 + 毫秒时间戳 + " 0"
mid = f"{random.randint(0,999)}{int(time.time()*1000)} 0"
# uuid: 负号 + 毫秒时间戳 + 后缀
uuid = f"-{int(time.time()*1000)}1"
服务端响应会带回相同 mid,客户端据此匹配请求结果。
4. HTTP 层:mtop 与 Token
4.1 通用 mtop 请求形态
POST /h5/{api}/{version}/?jsv=2.7.2&appKey=34839810&t=…&sign=…&v=1.0&type=originaljson&accountSite=xianyu&dataType=json&timeout=20000&api={api}&sessionOption=AutoLoginOnly
Host: h5api.m.goofish.com
Origin: https://www.goofish.com
Referer: https://www.goofish.com/
Content-Type: application/x-www-form-urlencoded
data={json}
成功响应通用结构:
{
"api": "mtop.xxx",
"v": "1.0",
"ret": ["SUCCESS::调用成功"],
"data": {}
}
失败时 ret 可能包含:
- FAIL_SYS_TOKEN_EXORED / TOKEN_EMPTY:_m_h5_tk 过期
- SESSION_EXPIRED:登录态失效
- RGV587_ERROR / USER_VALIDATE:风控/滑块
4.2 消息页相关 HTTP API 全表
| mtop.taobao.idlemessage.pc.login.token | 1.0 | {"appKey":"444e…","deviceId":"…"} | 拿 IM accessToken |
| mtop.taobao.idlemessage.pc.accs.token | 1.0 | {} | 拿 ACCS token |
| mtop.taobao.idlemessage.pc.loginuser.get | 1.0 | {} | 当前 IM 用户 |
| mtop.taobao.idlemessage.pc.redpoint.query | 1.0 | {"sessionTypes":"1,19,15,…","fetch":50} | 未读红点 |
| mtop.taobao.idlemessage.pc.session.sync | 3.0 | {"sessionTypes":"[3]","fetchNum":30} | 会话同步 |
| mtop.taobao.idlemessage.pc.user.query | 4.0 | {"type":0,"sessionType":1,"sessionId":"…","isOwner":false} | 用户资料 |
| mtop.taobao.idlemessage.pc.blacklist.query | 1.0 | – | 黑名单 |
| mtop.taobao.idlemessage.face.emoji.load | 1.0 | – | 表情资源 |
| mtop.idle.trade.pc.message.headinfo | 1.0 | {"itemId":…,"sessionId":…,"sessionType":1} | 聊天顶栏商品 |
4.3 login.token 完整示例
请求:
POST /h5/mtop.taobao.idlemessage.pc.login.token/1.0/?jsv=2.7.2&appKey=34839810&t=1786154544791&sign=***&v=1.0&type=originaljson&accountSite=xianyu&dataType=json&timeout=20000&api=mtop.taobao.idlemessage.pc.login.token&sessionOption=AutoLoginOnly&spm_cnt=a21ybx.im.0.0
Body:
data={"appKey":"444e9908a51d1cb236a27862abc769c9","deviceId":"46BDFCA8-9193-4C68-8C0A-970470EF3807-2208946894812"}
Content-Length 约 140,与上述 JSON 长度一致。
响应:
{
"api": "mtop.taobao.idlemessage.pc.login.token",
"data": {
"accessToken": "oauth_k1:***",
"accessTokenExpiredTime": "86400000",
"refreshToken": "oauth_k1:***"
},
"ret": ["SUCCESS::调用成功"],
"v": "1.0"
}
4.4 其他 API 响应要点
loginuser.get
{
"data": {
"userId": 2208946894812
}
}
redpoint.query
{
"req": {"sessionTypes":"1,19,15,32,3,44,51,52,24","fetch":50},
"data": {"total": 0}
}
session.sync v3
{
"req": {"sessionTypes":"[3]","fetchNum":30},
"data": {
"hasMore": false,
"sessions": [
{
"session": {
"sessionId": 11402373130,
"sessionType": 3,
"ownerInfo": {"userId": "2208946894812", "fishNick": "…"},
"userInfo": {"userId": "100", "nick": "系统消息", "type": 10}
},
"message": {
"summary": {"summary": "你有50g能量即将过期", "unread": 0}
}
}
]
}
}
user.query v4
{
"req": {
"type": 0,
"sessionType": 1,
"sessionId": "65473344352",
"isOwner": false
},
"data": {
"needDecryptKeys": ["userInfo.nick"],
"userInfo": {
"fishNick": "Elton的小店铺",
"nick": "x***9",
"type": 0,
"logo": "http://img.alicdn.com/…"
}
}
}
说明:needDecryptKeys 表示部分字段可能做了脱敏/加密展示,网页端有额外解密逻辑;机器人主流程一般不依赖完整昵称明文。
message.headinfo
{
"req": {
"itemId": 1074426908540,
"sessionId": 65473344352,
"sessionType": 1
},
"data": {
"commonData": {
"itemId": "1074426908540",
"itemPreInfo": "{\\"soldPrice\\":\\"0.10\\",\\"title\\":\\"…\\",\\"isSelf\\":\\"true\\"}",
"supportPCTrade": "true"
},
"middle": {
"data": {"price": "0.10", "tips": "广东·深圳"}
}
}
}
5. WebSocket 层:钉钉 IMPaaS
5.1 连接参数
URL: wss://wss-goofish.dingtalk.com/
Origin: https://www.goofish.com
UA: Chrome/150…
网页端 Upgrade 请求头(HAR):
GET / HTTP/1.1
Host: wss-goofish.dingtalk.com
Connection: Upgrade
Upgrade: websocket
Origin: https://www.goofish.com
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
User-Agent: Mozilla/5.0 … Chrome/150.0.0.0 Safari/537.36
观察:
- 浏览器 WS 握手 不一定带 Cookie
- 鉴权主要靠后续 /reg.headers.token
- 机器人带 Cookie 一般无害,但不是必须
5.2 报文总结构
所有业务帧基本是 JSON:
{
"lwp": "/path",
"headers": {
"mid": "…",
"sid": "…",
"app-key": "…",
"ua": "…"
},
"body": {}
}
或响应:
{
"code": 200,
"headers": {"mid": "…", "sid": "…"},
"body": {}
}
5.3 请求-响应关联规则
{
"code": 200,
"headers": {
"mid": "<服务端mid>",
"sid": "<会话sid>",
"app-key": "…", // 若推送带了就回传
"ua": "…",
"dt": "j"
}
}
不 ACK 可能导致推送通道异常或重传堆积。
5.4 心跳
客户端:
{
"lwp": "/!",
"headers": {"mid": "4531786173127800 0"}
}
服务端:
{
"code": 200,
"headers": {
"mid": "4531786173127800 0",
"server-timestamp": "1786173127821"
}
}
建议间隔:15s 左右;超时未响应则重连。
6. 完整握手时序(HAR 对照)
以下来自真实 HAR(58 帧)还原,时间顺序已校对。
6.1 时序图
Client Server
| |
| 1) /reg (token, did, ua, wv) |
|———————————————->|
| 2) code=200 + sid/reg-uid/unitName=PNM |
|<———————————————-|
| 3) /r/Conversation/listNewestPagination |
|———————————————->|
| 4) /s/sync (syncExtraType) |
|<———————————————-|
| 5) ACK |
|———————————————->|
| 6) /r/SyncStatus/getState |
|———————————————->|
| 7) /s/vulcan (历史 syncPushPackage, 可很大) |
|<———————————————-|
| 8) ACK |
|———————————————->|
| 9) code=200 body={pipeline, pts, timestamp…}|
|<———————————————-|
|10) /r/SyncStatus/ackDiff (真实 pts) |
|———————————————->|
|11) code=200 |
|<———————————————-|
|12) listTop / getByCids / listUserMessages… |
|———————————————->|
|13) 进入稳态:心跳 + 实时 /s/sync 推送 |
6.2 帧 0:/reg
{
"lwp": "/reg",
"headers": {
"cache-header": "app-key token ua wv",
"app-key": "444e9908a51d1cb236a27862abc769c9",
"token": "oauth_k1:***",
"ua": "Mozilla/5.0 … Chrome/150.0.0.0 Safari/537.36 DingTalk(2.2.0) OS(Windows/10) Browser(Chrome/150.0.0.0) DingWeb/2.2.0 IMPaaS DingWeb/2.2.0",
"dt": "j",
"wv": "im:3,au:3,sy:6",
"sync": "0,0;0;0;",
"did": "46BDFCA8-9193-4C68-8C0A-970470EF3807-2208946894812",
"mid": "3891786173067720 0"
}
}
字段解释:
| cache-header | 告诉服务端 headers 中关键缓存键 |
| app-key | IM 应用标识 |
| token | login.token 返回的 accessToken |
| ua | 模拟钉钉 Web IM 容器 UA |
| dt=j | JSON |
| wv | 协议能力版本 |
| sync | 初始同步声明 |
| did | 设备 ID |
6.3 帧 1:reg 响应
{
"code": 200,
"headers": {
"mid": "3891786173067720 0",
"sid": "2108a6286a76d68b1a13562a109aef8311c030b5c9ac",
"reg-sid": "2108a6286a76d68b1a13562a109aef8311c030b5c9ac",
"reg-uid": "2208946894812@goofish",
"dt": "j",
"ip-digest": "…",
"real-ip": "x.x.x.x"
},
"body": {
"unitName": "PNM",
"cookie": "",
"timestamp": 1786173067741,
"isFromChina": true
}
}
实现要点:
- 保存 sid,后续 ACK 可回传
- 校验 reg-uid 是否与当前账号一致
- unitName=PNM 会在 tooLong2Tag 中出现(PNM,1)
6.4 帧 2:listNewestPagination
{
"lwp": "/r/Conversation/listNewestPagination",
"headers": {"mid": "…"},
"body": [9007199254740991, 50]
}
- 第一个参数类似 cursor(Number.MAX_SAFE_INTEGER)
- 第二个参数 page size
- 失败可不阻断收发,但网页会做
6.5 帧 4:getState
{
"lwp": "/r/SyncStatus/getState",
"headers": {"mid": "9181786173067838 0"},
"body": [{"topic": "sync"}]
}
6.6 帧 8:getState 响应(核心)
{
"code": 200,
"headers": {
"mid": "9181786173067838 0",
"sid": "2108a6286a76d68b1a13562a109aef8311c030b5c9ac",
"dt": "j"
},
"body": {
"pipeline": "sync",
"tooLong2Tag": "PNM,1",
"channel": "sync",
"topic": "sync",
"highPts": 0,
"pts": 1786155166159000,
"seq": 0,
"timestamp": 1786173067877
}
}
注意:
- pts 量级通常是“毫秒时间戳再乘 1000”的 16 位左右整数
- 不能自己瞎编一个当前时间替代
- 这个对象几乎原样用于下一步 ackDiff
6.7 帧 9:ackDiff
{
"lwp": "/r/SyncStatus/ackDiff",
"headers": {"mid": "…"},
"body": [
{
"pipeline": "sync",
"tooLong2Tag": "PNM,1",
"channel": "sync",
"topic": "sync",
"highPts": 0,
"pts": 1786155166159000,
"seq": 0,
"timestamp": 1786173067877
}
]
}
这是整次修复中最关键的一步。
6.8 握手期穿插推送
在 getState 前后,服务端可能推:
客户端必须:
否则 getState 会被历史包“淹没”导致超时。
6.9 网页完整 lwp 清单(本次 HAR)
| /reg | C→S | 1 | 注册 |
| /r/Conversation/listNewestPagination | C→S | 1 | 最近会话 |
| /r/SyncStatus/getState | C→S | 1 | 同步状态 |
| /r/SyncStatus/ackDiff | C→S | 1 | 确认游标 |
| /r/Conversation/listTop | C→S | 1 | 置顶会话 |
| /r/Conversation/getByCids | C→S | 1+ | 按 cid 取会话 |
| /r/MessageManager/listUserMessages | C→S | 多次 | 历史消息 |
| /r/MessageSend/sendByReceiverScope | C→S | 发送时 | 发消息 |
| /r/MessageStatus/read | C→S | 读后 | 已读 |
| /r/Conversation/clearRedPoint | C→S | 读后 | 清红点 |
| /! | C→S | 周期 | 心跳 |
| /s/sync | S→C | 多次 | 实时同步 |
| /s/vulcan | S→C | 握手期 | 历史同步 |
7. 收消息协议详解
7.1 实时推送外壳
{
"lwp": "/s/sync",
"headers": {
"app-key": "444e9908a51d1cb236a27862abc769c9",
"mid": "ceb9000a 0",
"sid": "…",
"ua": "…"
},
"body": {
"syncPushPackage": {
"maxHighPts": 0,
"startSeq": 3,
"endSeq": 3,
"minCreateTime": 1786173125670,
"maxPts": 1786173125937000,
"hasMore": 0,
"timestamp": 1786173125944,
"data": [
{
"bizType": 40,
"streamId": "40",
"objectType": 40000,
"data": "<base64 payload>"
}
]
},
"syncExtensionModel": {
"reconnectType": 0,
"failover": 0,
"fingerprint": –533269192
}
}
}
字段说明:
| startSeq/endSeq | 本次推送序号范围 |
| maxPts | 推送后的最大游标 |
| hasMore | 是否还有后续包 |
| data[] | 业务载荷列表,可能多条 |
| bizType | 业务类型;聊天常见 40 |
| objectType | 对象类型;聊天常见 40000 |
| data | Base64 编码的 MessagePack 或 JSON |
7.2 两类 payload
A. 纯 JSON Base64(系统/扩展)
特征:
- Base64 解码后可直接 json.loads
- 常见于会话脚本、系统提示、扩展操作
- 一般不是 C2C 文本聊天
B. MessagePack Base64(聊天主路径)
特征:
- Base64 解码后是二进制 MessagePack
- 直接当 UTF-8 JSON 会失败
- 需要 MessagePack 解码成嵌套 map
- 聊天消息多走这条路径
7.3 客户端处理流水线
收到 WS JSON
→ 若有 mid:先 ACK
→ 判断 body.syncPushPackage.data 是否非空
→ 遍历 data[]
→ 尝试 base64+utf8+json
成功:系统包,跳过
失败:MessagePack decrypt
→ 判断是否聊天消息 / 订单消息
→ 进入业务层
7.4 历史消息拉取(非实时)
点开会话时网页会发:
{
"lwp": "/r/MessageManager/listUserMessages",
"body": [
"65473344352@goofish",
false,
9007199254740991,
20,
false
]
}
参数语义(经验推断):
响应中的单条消息模型(节选):
{
"message": {
"messageId": "4241756936958.PNM",
"cid": "65473344352@goofish",
"createAt": 1786173125670,
"sender": {"uid": "2221874527209@goofish"},
"content": {
"contentType": 101,
"custom": {
"type": 1,
"data": "eyJhdFVzZXJzIjpbXSwiY29udGVudFR5cGUiOjEsInRleHQiOnsidGV4dCI6IuWkmuWwkemSsSJ9fQ==",
"summary": "多少钱"
}
},
"extension": {
"reminderContent": "多少钱",
"senderUserId": "2221874527209",
"sessionType": "1",
"reminderTitle": "Elton的小店铺",
"reminderUrl": "fleamarket://message_chat?itemId=1074426908540&peerUserId=2221874527209&sid=65473344352&…"
}
}
}
custom.data 解码后:
{
"atUsers": [],
"contentType": 1,
"text": {"text": "多少钱"}
}
7.5 已读与清红点
// 已读
{
"lwp": "/r/MessageStatus/read",
"body": [["4241756936958.PNM"]]
}
// 清红点
{
"lwp": "/r/Conversation/clearRedPoint",
"body": [[{
"messageId": "4241756936958.PNM",
"cid": "65473344352@goofish"
}]]
}
机器人若只做自动回复,可不实现;若要模拟真人已读,可补上。
8. 发消息协议详解
8.1 文本消息
HAR 捕获的真实发送帧(内容 nihaoya):
{
"lwp": "/r/MessageSend/sendByReceiverScope",
"headers": {"mid": "1491786173073621 0"},
"body": [
{
"uuid": "-17861730736201",
"cid": "65473344352@goofish",
"conversationType": 1,
"content": {
"contentType": 101,
"custom": {
"type": 1,
"data": "eyJjb250ZW50VHlwZSI6MSwidGV4dCI6eyJ0ZXh0IjoibmloYW95YSJ9fQ=="
}
},
"redPointPolicy": 0,
"extension": {"extJson": "{}"},
"ctx": {
"appVersion": "1.0",
"platform": "web"
},
"mtags": {},
"msgReadStatusSetting": 1
},
{
"actualReceivers": [
"2221874527209@goofish",
"2208946894812@goofish"
]
}
]
}
custom.data 编码前:
{
"contentType": 1,
"text": {"text": "nihaoya"}
}
编码方式:
inner = {"contentType": 1, "text": {"text": text}}
data_b64 = base64.b64encode(
json.dumps(inner, ensure_ascii=False).encode("utf-8")
).decode("utf-8")
8.2 字段语义
| cid | 会话 ID,格式 {sessionId}@goofish |
| conversationType | 1 = 单聊 |
| contentType=101 | 自定义消息外壳 |
| custom.type=1 | 文本类自定义体 |
| actualReceivers | 接收方列表:买家 + 自己 |
| platform=web | 标识网页端来源 |
| msgReadStatusSetting | 已读回执相关 |
8.3 图片消息(既有逆向结论)
图片先上传 CDN,再发 WS:
内层结构概念:
{
"atUsers": [],
"contentType": 2,
"image": {
"pics": [{
"height": 1080,
"type": 0,
"url": "https://…",
"width": 720
}]
}
}
外层仍走 sendByReceiverScope + contentType=101 + custom.data=base64。
8.4 发送成功判定
通常会收到同 mid 的:
{"code": 200, "headers": {"mid": "…", "sid": "…"}}
没有 code=200 不代表一定失败(网络延迟),但应做超时监控。
9. 辅助通道:ACCS
9.1 获取 ACCS token
POST mtop.taobao.idlemessage.pc.accs.token
body: data={}
响应:
{
"data": {"token": "AAAJ…"}
}
9.2 连接
wss://msgacs.m.taobao.com/accs/auth?token=<token>
Origin: https://www.goofish.com
9.3 帧内容
本次 HAR 仅观察到心跳:
{
"type": "ACK",
"protocol": "HEARTBEAT_ACCS_H5",
"data": ""
}
9.4 结论
| ACCS 是否替代钉钉 IM? | 目前没有证据 |
| 机器人是否必须接 ACCS? | 非必须 |
| 何时再研究 ACCS? | 钉钉通道稳定后,若出现仅 ACCS 推业务事件再做 |
10. 消息解密与字段映射
10.1 decrypt 流程
raw base64 string
→ 清洗非 base64 字符 / 补 padding
→ base64 decode → bytes
→ MessagePack decode → nested map/list
→ JSON 化(数字键保留为字符串键)
注意:这里的“解密”本质是 解码,不是 AES 一类对称解密。
10.2 聊天消息识别
机器人侧常用结构判定:
def is_chat_message(message: dict) –> bool:
return (
isinstance(message, dict)
and isinstance(message.get("1"), dict)
and isinstance(message["1"].get("10"), dict)
and "reminderContent" in message["1"]["10"]
)
即:
message["1"]["10"]["reminderContent"] 存在
10.3 业务字段经验映射
MessagePack 解码后是“数字键嵌套对象”,不同版本可能有漂移,但常见映射:
| 1 | 会话/发送方相关结构或 cid |
| 1.10.reminderContent | 消息摘要文本 |
| 1.10.reminderTitle | 会话标题/对方昵称 |
| 1.10.reminderUrl | deep link,含 itemId/sid/peerUserId |
| 1.10.senderUserId | 发送方 ID |
| 3.redReminder | 订单状态文案(等待发货等) |
| extension 中的 itemId | 商品 ID |
reminderUrl 示例:
fleamarket://message_chat?itemId=1074426908540&peerUserId=2221874527209&sid=65473344352&messageId=…
可直接解析:
- itemId
- peerUserId(买家)
- sid(chat_id / sessionId)
10.4 订单消息
当出现:
redReminder ∈ {等待买家付款, 等待卖家发货, 交易成功, 交易关闭}
应走订单业务,而不是普通问答。 自动发货通常关注:等待卖家发货。
10.5 用户 ID 规范
统一使用:
纯数字 ID,不带 @goofish 后缀
协议层多处带后缀:
2208946894812@goofish
65473344352@goofish
业务层存储前应 .split('@')[0]。
11. 根因分析
11.1 修复前实现
get_token()
connect WS
send /reg
sleep(1)
send ackDiff(pts = now_ms * 1000) ← 本地伪造
enter loop
11.2 网页真实实现
get_token()
connect WS
send /reg → wait code 200
send listNewestPagination
send getState → wait body.pts
send ackDiff(真实 pts)
enter loop
11.3 差异矩阵
| Token API | 正确 | 正确 | 无 |
| WS URL | 正确 | 正确 | 无 |
| /reg | 有 | 有 | 无 |
| UA | DingTalk 2.1.5 | 2.2.0 | 低~中 |
| getState | 无 | 有 | 高 |
| pts 来源 | 本地时间 | 服务端 | 高 |
| ackDiff | 有但错误 | 正确 | 高 |
| 发送协议 | 正确 | 正确 | 无 |
| 解密逻辑 | 基本正确 | 增强多 data | 中 |
11.4 为什么会“在线却收不到”
同步系统用 pts 表示“我已经确认到哪里”。
- 如果客户端上报一个不合理的未来/错误 pts
- 服务端可能认为“没有 diff 需要推”
- 或者推送窗口与客户端状态不一致
表现就是:
- 连接存活
- 心跳正常
- 历史接口/HTTP 正常
- 实时 /s/sync 业务聊天不来
这与本次实盘完全吻合。
12. 修复方案与实现要点
12.1 目标行为
12.2 关键实现:请求-等待
async def ws_send_and_wait(ws, payload, timeout=5.0):
mid = payload["headers"]["mid"]
await ws.send(json.dumps(payload, ensure_ascii=False))
deadline = time.time() + timeout
while time.time() < deadline:
raw = await asyncio.wait_for(ws.recv(), timeout=deadline – time.time())
data = json.loads(raw)
resp_mid = (data.get("headers") or {}).get("mid")
# 命中目标响应
if resp_mid == mid and "code" in data:
return data
# 插队推送:先 ACK,再继续等
lwp = data.get("lwp") or ""
if is_sync_package(data) or lwp.startswith("/s/"):
await ack(ws, data)
continue
return None
12.3 初始化握手
# 1) reg
reg_resp = await ws_send_and_wait(ws, {
"lwp": "/reg",
"headers": {
"cache-header": "app-key token ua wv",
"app-key": IM_APP_KEY,
"token": access_token,
"ua": WS_UA_2_2_0,
"dt": "j",
"wv": "im:3,au:3,sy:6",
"sync": "0,0;0;0;",
"did": device_id,
"mid": generate_mid(),
}
}, timeout=8)
# 2) 可选:会话列表
await ws_send_and_wait(ws, {
"lwp": "/r/Conversation/listNewestPagination",
"headers": {"mid": generate_mid()},
"body": [9007199254740991, 50]
})
# 3) getState
state_resp = await ws_send_and_wait(ws, {
"lwp": "/r/SyncStatus/getState",
"headers": {"mid": generate_mid()},
"body": [{"topic": "sync"}]
})
sync_state = state_resp["body"] # pipeline/pts/timestamp…
# 4) ackDiff
await ws_send_and_wait(ws, {
"lwp": "/r/SyncStatus/ackDiff",
"headers": {"mid": generate_mid()},
"body": [sync_state]
})
12.4 降级策略
若 getState 失败:
但应把“未拿到真实 pts”视为高优先级健康问题。
12.5 收包增强
for item in syncPushPackage["data"]:
raw = item["data"]
# 系统 JSON 包
try:
json.loads(base64.b64decode(raw).decode("utf-8"))
continue
except Exception:
pass
# 聊天 MessagePack 包
msg = json.loads(decrypt(raw))
if is_chat_message(msg) or "3" in msg:
handle_business(msg)
12.6 不建议同时改的部分
为降低回归风险,本次刻意不动:
- 发送协议主体
- Token API 名称与签名算法
- 业务层 AI 路由
- ACCS 通道接入
先恢复“能收”,再谈“增强”。
13. 验证结果
13.1 成功日志模板
WS 注册成功 sid=2132e3d26a771ac6… uid=2208946894812@goofish
同步游标 getState 成功 pts=1786173128937000
连接注册完成(reg + getState + ackDiff)
消息通道已就绪,开始监听买家消息
用户: Elton的小店铺 (ID: 2221874527209), 商品: 1074426908540, 会话: 65473344352, 消息: Hello
意图识别完成: default
机器人回复: 您好亲,全新考研资料,持续更新
13.2 验证矩阵
| /reg code=200 | 通过 |
| getState 返回 pts | 通过 |
| ackDiff 完成 | 通过 |
| 实时收到买家消息 | 通过 |
| AI 回复发出 | 通过 |
| 卖家工作台 identity 仍可用 | 通过 |
13.3 结论
最小闭环已恢复:
注册 → 同步 → 收消息 → 回复
14. 抓包方法论
14.1 工具选择
| Chrome net-export Default | 易开、全量事件 | 无 body / 无 WS 帧 | 初筛域名 |
| DevTools HAR + content | 有 HTTP body、可含 WS 帧 | 需手动操作 | 协议修复主路径 |
| mitmproxy / Fiddler | 可控、可脚本化 | HTTPS 证书配置 | 批量分析 |
14.2 推荐抓包步骤
14.3 必抓帧清单
- /reg send + response
- /r/SyncStatus/getState send + response
- /r/SyncStatus/ackDiff send + response
- 收到买家消息的 /s/sync
- 发送消息的 sendByReceiverScope
- login.token HTTP 请求响应
14.4 脱敏规范
公开文档务必处理:
- Cookie 全量
- accessToken / refreshToken
- ACCS token
- 真实手机号/完整收货地址
- 可保留:API 名、字段结构、pts 形态、lwp 路径
15. 排障手册
15.1 分层定位
A. Cookie/登录
B. mtop Token
C. WS 连接
D. /reg
E. getState/ackDiff
F. 推送到达
G. 解密/字段识别
H. 业务过滤(人工接管、过期消息、黑名单)
15.2 症状 → 原因速查
| Token 获取失败 | Cookie、_m_h5_tk、风控 |
| /reg 非 200 | accessToken 失效、did/app-key |
| 有 reg 无 getState | 握手代码未更新 |
| getState 超时 | 历史推送未 ACK,recv 循环被堵 |
| 全流程成功但仍无消息 | 解密失败、字段路径变化、业务过滤 |
| 只能收不能发 | send body / actualReceivers / cid |
| 短连接反复断开 | 风控、多端互踢、token 过期 |
15.3 最小复现实验
- 无 /s/sync:握手/账号/互踢问题
- 有 /s/sync 无业务:解密/字段问题
- 有业务无回复:业务层问题
15.4 日志建议
至少打印:
[IM] token ok, expire=…
[IM] reg ok, sid=…, uid=…
[IM] getState pts=…
[IM] ackDiff ok
[IM] recv lwp=…, syncItems=N
[IM] chat from=…, cid=…, text=…
[IM] send ok mid=…
16. 附录
附录 A:协议常量速查
MTOP_HOST=https://h5api.m.goofish.com
MTOP_APP_KEY=34839810
IM_APP_KEY=444e9908a51d1cb236a27862abc769c9
WS_URL=wss://wss-goofish.dingtalk.com/
ACCS_URL=wss://msgacs.m.taobao.com/accs/auth
TOKEN_API=mtop.taobao.idlemessage.pc.login.token
UA_WEB=… Chrome/150 …
UA_IM=… DingTalk(2.2.0) … DingWeb/2.2.0 IMPaaS DingWeb/2.2.0
WV=im:3,au:3,sy:6
附录 B:修复前后对比
| 连接 | 成功 | 成功 |
| 注册 | 成功 | 成功 |
| 同步游标 | 本地伪造 | 服务端真实 pts |
| 实时收消息 | 失败/不稳 | 成功 |
| 发消息 | 基本可用 | 可用 |
| UA | 2.1.5 | 2.2.0 |
| 握手完整性 | 低 | 对齐网页 |
附录 C:sessionType 经验值
| 1 | 普通 C2C 聊天 |
| 3 | 系统/通知类会话 |
| 24 | 平台助手类(如闲小蜜) |
| 其他 | 以 redpoint/session.sync 列表为准 |
附录 D:contentType 经验值
| 1 | 文本(内层) |
| 2 | 图片(内层) |
| 8 | 某些会话脚本/系统操作 |
| 101 | 自定义外壳(外层 WS content) |
附录 E:安全与合规
附录 F:可继续优化项
最终总结
闲鱼 PC IM 的技术骨架可以概括为:
Cookie 登录态
→ mtop 签名拿 accessToken
→ 钉钉 IMPaaS WebSocket 注册
→ getState/ackDiff 建立同步游标
→ syncPushPackage 收消息
→ sendByReceiverScope 发消息
本次故障的核心不是“通道消失”,而是:
同步握手少了 getState,ackDiff 用了错误 pts,导致实时推送窗口失效。
把网页端握手补齐后,链路即可恢复:
注册成功 → pts 同步成功 → 收到 Hello → 机器人正常回复
这对所有依赖网页 IM 协议的闲鱼自动化项目都有直接复用价值: 先对齐握手,再怀疑业务;先抓 WS 帧,再改 HTTP。
本文基于 2026-08 对 www.goofish.com/im 的 HAR 对照、协议帧还原与实机验证整理。若后续平台升级协议,请以新抓包为准更新本文中的字段与时序。




