Skip to content

feat(messages): 发私信 —— xhs send(HTTP short-link,含写入限流) - #84

Open
Jonathan0516 wants to merge 2 commits into
jackwener:mainfrom
Jonathan0516:feat/send-direct-messages
Open

Jonathan0516 wants to merge 2 commits into
jackwener:mainfrom
Jonathan0516:feat/send-direct-messages

Conversation

@Jonathan0516

Copy link
Copy Markdown

在 #83(只读私信)之上叠加发送能力。本 PR 包含 #83 的那一个 commit,因为跨 fork 的 PR 只能以上游 main 为 base;review 时看第二个 commit feat(messages): 发私信 即可,#83 合并后这里会自动收敏。

传输

私信可以不经 IM WebSocket 发送:payload 是 protobuf ChatSendMessage 信封,RSA 加密后 POST 到 /api/im/web/short_link/send_message。这条路径与 #83 的只读端点同在 edith 主机、共用同一套 x-s 签名,所以现有 _main_api_post 直接可用——不需要握手、长连接或新的传输层。

im_codec

按公开规范实现,不依赖任何第三方逆向项目的代码:

无法来自规范的只有两样:服务端公钥和消息字段号。二者是线格式的可观测事实而非程序逻辑,已连同布局一并写进模块文档。

有一处反直觉、值得单独说明:这个传输不省略默认值字段。空的 token / nickname / ref_id 仍然各占一个 tag + 长度 0,group_chat 显式发 0。proto3 编码器通常会省略这些,但服务端按字节布局判定,省略会被拒。所以 tests/test_im_codec.py 用一个逐字节固化的 fixture 把布局钉住——任何改变编码的重构会在这里响,而不是在发送时才炸。

不返回假成功

send_direct_message() 要求正面确认,而不是"没找到失败标记就算成功":

成功:服务端签发了 message_id(或显式 success=true / code=0)
失败:success=false,或非零 code
未确认:空响应体、非 dict、没有任何标记的形状 —— 报错并带上顶层字段名

_handle_response 对空 body 返回 None,所以"缺少失败标记"这种判据会把静默丢弃报成发送成功。写接口的假成功在外联场景里是最坏的失败模式:以为触达了 100 人,实际可能一个都没到。宁可误报失败(可恢复)也不误报成功(不可恢复)。

返回值同时给出 message_id(服务端签发,唯一的投递凭证)和 local_mid(客户端追踪 id)。命名上把两者分开,避免后者被当成送达证明。

写入限流

CLI 每次调用都是一个新进程,纯内存限流器等于没有保护,所以 limiter 把滚动窗口令牌桶落盘到配置目录,跨进程共享。默认 1 写/分钟,XHS_WRITE_RPM 可调、设 0 关闭。

只作用于新增的 dm bucket,不改变任何现有写命令的行为。

同时接入既有错误信封:新增 RateLimitedError 与 rate_limited 错误码(SCHEMA.md 已同步)。

一个顺带的修复

resolve_current_user_id() 原本只读顶层 user_id,但不同端点把 id 放在 login_info / basic_info / 顶层三个位置,所以对已登录用户也可能返回空串。改为逐级回退。只增加命中、不减少,向后兼容。

验证

已实测发送成功:同一账号、真实会话,连续两条消息送达(对方随后回复,确认人工可见)。服务端响应形状是:

{"chat_ack": ..., "message_id": "<server id>"}

上面的"正面确认"判据就是按这个真实形状校准的——最初我按 code 判断,结果对成功响应误报失败,正好证明了这条判据必须来自实测而不是猜测。

codec 重写后的等价性:发送验证用的是等价的早期实现;重写为"从规范实现 + pycryptodome"之后,protobuf 输出与当次成功发送的字节串逐字节一致(即 FIXTURE_HEX),RSA 部分从手写填充改为库实现。需要如实说明:重写后的代码没有再跑一次真实发送,等价性依据是 protobuf 字节相同 + RSA 交由标准库。

uv run pytest      → 178 passed, 27 skipped
uv run ruff check  → All checks passed!
uv run mypy        → Success

新增测试全部纯逻辑、不走网络:varint 已知编码、字节布局 fixture、默认值字段必须发出、字段 10 必须不发、UTF-8 长度、RSA 分块对齐与填充随机性、限流的窗口/桶隔离/环境变量/状态文件,以及 send 的端点、限流消耗、未确认形状、非文本 content_type 拒绝、sender 解析失败等。

注意

私信是敏感操作,且平台对私信次数本身有限制。默认 1 写/分钟是有意保守的设置,建议不要为了跑量把它关掉。

🤖 Generated with Claude Code

Jonathan0516 and others added 2 commits September 10, 2026 13:48
小红书私信不在 `/api/sns/web/` 命名空间,而是独立的 `/api/im/web/` 前缀,
但同在 edith 主机、共用同一套 x-s 签名,所以现有 `_main_api_get` 直接可用,
不需要新的传输层、握手或依赖。

这也补上了一个容易误判的缺口:`/api/sns/web/unread_count`(`xhs unread`)
只统计通知(评论和@ / 赞和收藏 / 新增关注),**不包含私信**,所以此前无法
从 CLI 得知是否有人发来私信。私信未读在 `/api/im/web/chat/get_unread`。

新增:

- `MessagingEndpointsMixin` —— `get_im_unread()` / `get_chats()` /
  `get_chat_messages()` 三个只读端点。
- `xhs chats` —— 会话列表;把 API 埋在 `info` 里的昵称提上来,并按
  `--unread`(默认开)合并每个会话的未读数。
- `xhs messages <user_id>` —— 单会话历史,标注 sent/received 方向。
- `unwrap_message_content()` —— 私信正文是多层嵌套 JSON 字符串(卡片/笔记
  分享常嵌 3 层以上),逐层剥到最内层文本;卡片优先取 `frontChain` 以保留
  "[笔记]" 类型标记,与网页显示一致。畸形输入原样返回,不抛错。
- `tests/test_messaging.py` —— 15 例,纯逻辑,不走网络。

发送私信走的是另一套传输(protobuf over WebSocket,或 RSA 加密的 short-link
HTTP 兜底),本 PR 不涉及。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
私信可以不经 IM WebSocket 发送:payload 是 protobuf ChatSendMessage 信封,
RSA 加密后 POST 到 /api/im/web/short_link/send_message。该路径与只读端点同在
edith 主机、共用同一套 x-s 签名,因此现有 _main_api_post 直接可用,不需要
握手或长连接。

新增:

- im_codec —— 按公开规范实现:protobuf 线格式(protobuf.dev 的 varint /
  length-delimited)与 RSAES-PKCS1-v1_5(RFC 8017 §7.2.1,交由已声明依赖的
  pycryptodome 执行,不手写填充)。无法来自规范的只有服务端公钥和字段号,
  二者是线格式的可观测事实,已连同布局记录在模块文档里。
  注意本传输不省略默认值字段(空 token/nickname/ref_id 仍占一个 tag,
  group_chat 显式发 0);proto3 编码器通常会省略,而服务端按字节布局判定,
  所以测试用一个逐字节固化的 fixture 钉住布局。

- send_direct_message() —— 解析当前用户 id 作为 sender,编码、加密、POST,
  然后要求正面确认:服务端签发的 message_id 才算投递凭证。空响应体、非
  dict、无标记的形状一律报 unconfirmed 并带上顶层字段名,便于诊断。宁可
  误报失败也不误报成功 —— 写接口的假成功在外联场景里是最坏的失败模式。
  返回值同时给出 message_id(服务端)与 local_mid(客户端追踪 id),命名上
  不让后者被当成送达证明。

- limiter —— 跨进程写入限流。CLI 每次调用都是新进程,纯内存限流器等于没有,
  所以状态落盘在配置目录,滚动窗口令牌桶,默认 1 写/分钟,XHS_WRITE_RPM
  可调、设 0 关闭。只作用于 dm bucket,不改变现有写命令的行为。

- xhs send <user_id> -c "...";RateLimitedError 与 rate_limited 错误码接入
  既有 envelope。

- resolve_current_user_id() 增加多层回退:不同端点把 id 放在 login_info /
  basic_info / 顶层,原实现只读顶层,会把已登录用户当成空 id。只增加命中,
  不减少,向后兼容。

- tests/test_im_codec.py 与 tests/test_limiter.py 及 send 用例,全部纯逻辑
  不走网络。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant