Commit Graph
316 Commits
Author SHA1 Message Date
Qiao 127f2cd2d6 feat(flash-transfer): QQ flash transfer (fileset) upload, share, download (#113)
* feat(proto-defs): add flash-transfer OIDB proto types

QQ 闪传(FlashTransfer / fileset)的 protobuf 类型定义:
- FlashFileEntry(0x93d2/0x93d3 文件条目)
- 0x93cf 申请 fileSet / 0x93d0 commit / 0x93db 完成 / 0x93d1 设状态
- 0x93d3 文件集详情 / 0x93d4 下载 URL(含主文件 fileId + 缩略图)
- 0x12a9 sub=100/103/200 prepare/apply/get-download
- sliceupload body + Sha1StateV
- 0x9407 删除 / 0x9427 重命名 / 0x93d7 发送
注册到 oidb-actions/index.ts,package.json 加 exports。

* feat(protocol): add flash-transfer OIDB services + sha1-stream

闪传协议层(OIDB namespace,复用 invokeOidb/OidbCallSpec):
- apply-fileset / commit-file / complete-fileset / set-status(上传流程)
- prepare-upload (0x12a9_100) / apply-upload (0x12a9_103)(sliceupload 凭证)
- get-fileset-detail (0x93d3) / list-filesets (0x93d2) / get-download-url (0x93d4)
- get-flash-download (0x12a9_200,主文件下载直链)
- delete-file (0x9407) / rename-file (0x9427,sub=0+uinForm) / send-flash (0x93d7)
- sha1-stream:SHA1 中间 state 计算器(sliceupload f107.f6 需要,Node crypto 不支持)
- byte-oracle 单测(apply/prepare/get-download-url/send-flash)+ sha1-stream 单测
package.json 加 exports。

* feat(core): add FlashTransferApi facade

FlashTransferApi 编排闪传全流程,复用 OIDB 基础设施(invokeOidb)+
computeHashes/loadBinarySource/fetch:
- createFlashTask:统一走 sub=100/103+sliceupload(含缩略图),不走小文件 PUT
  (PUT 不上报主文件 sha1,fileset 卡在未完成;QQ 客户端小文件也走 sliceupload)
- buildFileId:主文件 appid=14901(进 f14.f1),缩略图 png=14903/jpg=14902
- uploadThumbnail:随机纯色 PNG 占位(手写编码,不引入图像库),关联主文件下载入口
- getFilesetInfo/getFlashFileList/getShareLink/getFlashFileUrl/downloadFileset
- sendFlashMsg(私聊 uid / 群聊 groupId)/ getFilesetIdByCode(网页解析)
- deleteFlashFile / renameFlashFile / listFilesets
注册到 ApiHub(flashTransfer 字段)+ export。

* feat(onebot): add flash-transfer actions

OneBot action(extended.ts)10 个:
create_flash_task / get_fileset_info / get_flash_file_list /
get_flash_file_url / get_share_link / download_fileset /
send_flash_msg(私聊 user_id / 群聊 group_id)/ get_fileset_id /
rename_flash_file / delete_flash_file
MCP catalog(生成文件)同步新增 action 元数据。
extended-actions 接线单测覆盖。

* fix(onebot): align flash file list field name with NapCat

flashFileInfoToJson 的文件大小字段从 file_size 改为 size,对齐 NapCat
get_flash_file_list 响应(NapCat 用 size)。get_fileset_info 的 file_list
复用同一映射,一并生效。便于 NapCat 客户端 drop-in 迁移。

* feat(proto-defs): support multi-file commit in flash-transfer

0x93d0 commit 的 f4 改为 repeated,一个请求携带 fileset 内全部文件条目。
FlashCommitFileInfo 的 f3/f5/f6/f7/f10/f12 用 pb_optional,强制 0 值上 wire
(服务端多文件时严格校验字段存在,省略 0 会导致后续文件不计入 fileset)。

* feat(protocol): parameterize flash-transfer upload for multi-file

CommitFile.Params 改为 entries 数组,serialize 输出 repeated f4,每条 f6=文件序号。
PrepareUpload/ApplyUpload 加 fileIndex + formatCode 参数:filesetWrap.f4 用 fileIndex
(与 commit f6 对齐),f7 用 formatCode(mp4=2 非 26),f9=1。

* feat(core): multi-file flash-transfer upload

createFlashTask 支持多文件共用一个 fileset。关键改动:
- ApplyFileset 多文件时 fileName 用「<首文件名>等N个文件」、fileSize 用总和,
  服务端据此判定 fileset 为多文件,commit 的后续 entry 才被计入
- 一次性 CommitFile 提交所有文件元数据(f4 repeated)
- 两阶段上传:先全部 prepare+apply 注册 fileId,再全部 sliceupload 落盘
- 拆分原 uploadLargeFile 为 prepareAndApply + sliceuploadFile
- fileTypeCode mp4 formatCode 26→2(与 commit f7/filesetWrap.f7 一致)

* feat(onebot): multi-file create_flash_task + list_filesets action

create_flash_task 的 files 参数支持 string 或 string[],多文件共用一个 fileset。
新增 list_filesets action(facade.listFilesets 之前未暴露)。MCP catalog 同步更新
files schema(anyOf string/array)与 list_filesets 条目。

* fix(flash-transfer): multi-file download by fileIndex

0x93d4 响应 f1.f3 是 repeated fileInfo(多文件 fileset 每个文件一条,f6=序号、
f14=主文件 fileId),之前定义成单个导致多文件只能拿到第一个文件。
FlashDownloadEntry.fileInfo 改 pb_repeated,GetDownloadUrl 返回所有文件元信息,
download_fileset 按 fileIndex 选中目标文件走 0x12a9 sub=200 拿直链。

* test(protocol): inline byte-oracle hex fixtures

两个 flash-transfer byte-oracle 测试原先用 readFileSync 读仓库外的
.claude/reverse/scripts/proto/*.hex(本地逆向笔记目录),CI 上不存在导致失败。
把 hex 内容内联成常量,测试自包含。

* fix(flash-transfer): address review feedback

1. get_flash_file_url 接 file_index 参数并在 action 透传(之前漏接,多文件永远返回首文件)
2. getFileDownload 越界 fileIndex 不再静默回退首文件,找不到返回 null
3. postSliceupload status 缺失不再当成功,非 success 一律抛错
4. computeSha1StateV 改 O(n) 增量 update(之前每片 reset 重算整个前缀,O(n²))
5. get_flash_file_url 找不到文件抛错(之前返回空串当成功)
6. create_flash_task files 数组拒绝空字符串
2026-06-21 14:24:51 +08:00
DrSmoothl 9d1e39f25f fix(highway): VP8X WebP canvas dims are little-endian, not big-endian (#112)
detectImageFormat read the VP8X (extended WebP) canvas width/height with a
big-endian readBE24, but the WebP spec stores them as 24-bit LITTLE-endian
(Minus-One). A 1920×1080 image was reported as 8324865×3605505, so the
fileInfo sent to highway was garbage and QQ cropped the thumbnail. VP8
(readLE16) and VP8L (readLE32) were already correct.

- Replace readBE24 with readLE24 in the VP8X branch; readBE24 had no other
  caller, so it's deleted (no dead code left).
- Add the previously-absent detectImageFormat regression suite (12 cases):
  PNG/GIF/BMP/VP8/VP8L/VP8X/JPEG + JPEG DHT-skip + truncated/unknown
  fallbacks. The VP8X cases pin little-endian byte order, including the
  exact bytes from the issue and an asymmetric 256×1 case that a BE misread
  would blow up to 16711681.

Verified TDD red→green: the 3 VP8X cases failed before the one-helper swap,
all 12 pass after.
2026-06-20 20:19:10 +08:00
DrSmoothl b9d8247309 fix(qzone): complete re_feeds param set + correct qzreferrer/topicId honesty
Adversarial review (NEEDS-FIXES) — the high-risk parts (topicId base
hostUin_tid, uin/hostUin not swapped, format=fs, platformid=52) verified
correct against 3 community impls; the gaps:
- Added the re_feeds param family all 3 impls send and the publish sibling
  already sends for this CGI family: feedsType=100, private=0, paramstr=1,
  richtype/richval/isSignIn=''. Tests assert the load-bearing ones.
- qzreferrer used hostUin; all impls use the commenter's own uin → selfUin.
- topicId __1 suffix is impl-dependent (2/3 include it, 1 omits); kept as
  default but the comment now flags the suffix specifically as the
  unverified piece (base shape is confirmed), and the test no longer
  presents it as canonical.
2026-06-20 02:10:12 +08:00
DrSmoothl 95f24ffc4a feat(qzone): comment_qzone — comment on a 说说 via web API
commentQzoneMsg POSTs a form body to taotao.qzone.qq.com's
emotion_cgi_re_feeds (proxied through h5.qzone), keyed by topicId
<hostUin>_<tid>__1, as the bot (uin=self) on target_uin's feed (defaults
to the bot's own space). Returns the new comment id best-effort — the
response field name varies (commentid/commentId) so a missing id with
code 0 is still success. Throws on non-zero code/subcode; rejects empty
tid/content pre-flight. WRITE — rate-limit (风控).

topicId shape + comment-id field are extrapolated from community impls,
pending a live capture (labeled in the helper comment).

- core: QzoneApi.comment; onebot: comment_qzone action; regen catalog.
- tests: re_feeds form lock (topicId/uin/hostUin/content/format=fs),
  commentid + camelCase commentId fallback + missing-id success, empty
  tid/content preflight, non-zero code throw, bridge owner-defaulting.
2026-06-20 02:05:56 +08:00
DrSmoothl 870504ae3a fix(qzone): thread abstime into like, flag unlike endpoint as unverified
Adversarial review (NEEDS-FIXES) of the like/unlike slice:
- Missing abstime: every real dolike impl (QLiker.py, CSDN) sends the
  target feed's post time. Threaded abstime through setQzoneLike →
  QzoneApi.like → like_qzone/unlike_qzone actions (optional, default 0;
  pass the feed's real time for reliability). Test asserts it.
- internal_unlike_app is the conventional paired CGI but NO public bot
  impl exercises unlike, so it's best-guess. Labeled UNVERIFIED/pending a
  live capture in the helper comment, the unlike action summary, and the
  unlike test — instead of pinning it as confirmed truth (avoiding the
  re-codify-a-wrong-constant trap the last 3 slices hit).
- Documented the success-signal (code/subcode) as extrapolated from
  sibling CGIs (dolike may use a succ/fail token instead).

The confirmed mechanics (dolike CGI, opuin=liker, unikey/curkey mood
shape, appid=311, fid=tid) are noted as such.
2026-06-20 02:02:25 +08:00
DrSmoothl d497f1e6c7 feat(qzone): like_qzone / unlike_qzone — like/unlike a 说说 via web API
setQzoneLike POSTs a form body to w.qzone.qq.com's internal_dolike_app
(like) or internal_unlike_app (unlike), proxied through h5.qzone, keyed by
the feed's mood unikey/curkey (http://user.qzone.qq.com/<uin>/mood/<tid>)
and fid=tid, as the bot (opUin). target_uin selects whose 说说 (defaults
to the bot's own); scope is 说说 (mood, appid 311). Throws on non-zero
code/subcode. Rejects empty tid pre-flight. WRITE — rate-limit (风控).

- core: QzoneApi.like(tid, targetUin?, like); onebot: like_qzone +
  unlike_qzone actions; regen catalog.
- tests: dolike vs unlike CGI selection, unikey/curkey/fid/opuin form
  lock, empty-tid preflight, code+subcode throws, and bridge owner-
  defaulting (self vs friend) + like-flag threading.
2026-06-20 01:56:23 +08:00
DrSmoothl e406a3bc2b docs(qzone): delete header comment mentions subcode in the success contract
Trivial accuracy nit from re-review (verdict SHIP) — header said
throw-on-non-zero-code but the helper also throws on a non-zero subcode.
2026-06-20 01:54:19 +08:00
DrSmoothl 4d75dc9ae3 fix(qzone): delete_v6 uses format=fs, drop bogus json param, harden success check
Adversarial review (NEEDS-FIXES) — two wrong-constant defects (same family
as the publish t1_tid bug) + a destructive-write hardening:
- format=json → format=fs. Every working reference (silica-github/
  qq_zone_delete, zhihu, daimagongfang) uses fs for the delete CGI; the
  tests re-codified the wrong value.
- Removed the invented json=1 body param (no source includes it); test now
  asserts it's absent.
- Also throw on a non-zero subcode (not just code), and documented that the
  code/subcode success signal is EXTRAPOLATED from sibling CGIs pending a
  live capture (public delete impls only check HTTP 2xx). Added subcode-throw
  and code-absent-resolves tests to lock the branch.
2026-06-20 01:52:47 +08:00
DrSmoothl a704f12945 feat(qzone): delete_qzone_msg — delete a 说说 by tid via web API
deleteQzoneMsg POSTs a form-urlencoded tid to taotao.qzone.qq.com's
emotion_cgi_delete_v6 (proxied through h5.qzone), on the bot's own space.
Success has no positive payload beyond code 0, so the contract is
throw-on-non-zero-code (foreign/unknown tid or auth failure); the server
only deletes feeds the hostUin owns. Rejects empty tid pre-flight.

- core: QzoneApi.delete; onebot: delete_qzone_msg action; regen catalog.
- tests: tid form-body/url/headers lock, empty-tid preflight, non-zero
  code throw, and bridge self-space threading.
2026-06-20 01:47:50 +08:00
DrSmoothl 9e21c3a7ef fix(qzone): read real t1_tid/t1_time publish fields — was false-throwing every success
Adversarial review (verdict NEEDS-FIXES, CRITICAL): emotion_cgi_publish_v6's
success envelope names the new feed id `t1_tid` and post time `t1_time`
(a STRING), NOT `tid`/`now`. So every successful publish hit the
'缺少 tid' guard and was reported as a failure (risking caller-retry
duplicate 说说). The tests passed only because they mocked the wrong field
names — they codified the bug.

- Read t1_tid (primary) with tid as a defensive fallback; t1_time||now
  via Number() (t1_time is a string on the wire). Verified against the
  Go reference lib starim00/AnimeAPI EmotionPublishVo.
- Added who=1 to the form body (both working references send it).
- Tests now encode the real contract (t1_tid/t1_time string), plus a
  tid/now fallback case and a no-feed-id throw case.
2026-06-20 01:44:50 +08:00
DrSmoothl 9f2eb37232 feat(qzone): send_qzone_msg — publish a text 说说 via web API
First Qzone WRITE path. publishQzoneMsg POSTs a form-urlencoded body to
taotao.qzone.qq.com's emotion_cgi_publish_v6 (proxied through h5.qzone),
g_tk in the query, on the bot's own space. Returns the server-assigned
tid (the handle for later delete/comment/like) + post time.

- Throws on transport failure, non-zero Qzone code (content rejected /
  rate-limited), or a success body with no tid — never reports a publish
  as succeeded without the feed id. Rejects empty content pre-flight.
- 带图 (image) publishing is a later slice (needs the upload CGI first).
- Action carries a风控/限流 caveat: publishing is an active action and
  high frequency is rate-limited by Qzone, same as sending messages.

- core: QzoneApi.publish; onebot: send_qzone_msg action; regen catalog.
- tests: form-body/url/headers lock, empty-content preflight, non-zero
  code throw, missing-tid throw, and bridge self-space threading.
2026-06-20 01:40:06 +08:00
DrSmoothl b00f5ab759 test(qzone): align feeds JSONP fixture to requested _preloadCallback name
Cosmetic fidelity nit from re-review: the fixture used _Callback(...) while
the request pins callback=_preloadCallback. Parser is name-agnostic so
behavior is unchanged; this just makes the fixture mirror slice-1.
2026-06-20 01:37:55 +08:00
DrSmoothl 5164ba9e39 fix(qzone): route feeds3_html_more through proxy + pin JSONP format
Adversarial review of the get_qzone_feeds slice (verdict NEEDS-FIXES):
- Host: hit ic2.qzone.qq.com directly, bypassing the proxy gateway that
  slice-1/group-album use. The qzone.qq.com cookie jar only authenticates
  against the proxy origin, so the direct call would fail referer/origin.
  Now routed through h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/...
- Format: requested format=json but the test fed a JSONP body, so the
  requested format was never pinned. Aligned to slice-1's
  format=jsonp + callback=_preloadCallback; the test now asserts format.
- Pagination: documented honestly that page_num is reliable only for the
  first page (deep paging needs a time cursor, deferred) — in the
  docstring and the action summary/param desc.
- Clarified the key/feedskey fallback is the per-feed handle, not a cursor.
2026-06-20 01:36:04 +08:00
DrSmoothl 094dc12f0c feat(qzone): get_qzone_feeds — read friend feeds (好友动态) via web API
Second read-only Qzone slice. getQzoneFeeds over ic2.qzone.qq.com's
feeds3_html_more CGI, same cookie/g_tk plumbing + JSONP-tolerant parse +
throw-on-auth-failure contract as get_qzone_msg_list. Surfaces the stable
structured fields (uin/nickname/time/appid/key) and passes the
pre-rendered html blob through verbatim — deep HTML→segment parsing is
left to callers. Paginated via page_num (1-based) + count.

- protocol/web/qzone.ts: getQzoneFeeds + mapFeeds.
- core: QzoneApi.getFeeds (always the bot's own identity).
- onebot: get_qzone_feeds action; regenerated MCP catalog.
- tests: feeds mapping, HTTP url/params, empty-feed→[], auth-throw,
  and bridge pageNum/count threading.

Exact CGI field names / pagination cursor to be re-confirmed against a
live capture (same posture as the msglist helper).
2026-06-20 01:31:36 +08:00
DrSmoothl 0083a8fb07 test(qzone): cover empty-space happy path + bridge target_uin defaulting
Adversarial review of the get_qzone_msg_list slice flagged two real
coverage gaps (verdict was SHIP, no correctness defects):
- protocol: empty space ({code:0,msglist:[]}) must return [] not throw —
  the exact distinction the throw-on-auth-failure contract promises.
- core: QzoneApi.getMsgList target_uin defaulting + the >0 guard, the
  only place the string(identity.uin)/number(param) boundary is crossed.
2026-06-20 01:28:57 +08:00
DrSmoothl 61b4bd04a6 feat(qzone): get_qzone_msg_list — read personal Qzone 说说 list via web API
First slice of personal QQ-Zone (个人空间) support, an exclusive feature
vs NapCat/SL (both only do group albums). Read-only to prove out the
auth+parse chain before write ops (publish/like/comment).

- protocol/web/qzone.ts: getQzoneMsgList over taotao.qzone.qq.com's
  emotion_cgi_msglist_v6 (h5.qzone proxy), reusing the same cookie/g_tk
  plumbing as group-album. JSONP-tolerant parse; throws on auth failure
  rather than swallowing to [] (mirrors group-signin contract).
- core: QzoneApi bridge class wired into ApiHub.
- onebot: get_qzone_msg_list action (target_uin/pos/num), regenerated
  MCP catalog + new 空间 doc category.
- HTTP-layer tests lock URL/params, JSONP stripping, field mapping, and
  the throw-on-auth-failure paths (9 cases, all green).

Exact CGI params/response fields to be re-confirmed against a live
capture when extending (same maintenance posture as group-album).
2026-06-20 01:11:07 +08:00
Qiao d38a0a4c1b feat(protocol): modify/move custom face via OIDB 0x902e + 0x902f (#110)
modify(改备注)走 OIDB 0x902e_1 opType=3 单包;move(移动排序)走
0x902f_1 移动指令 + 0x902e_1 opType=2 上传新顺序两步流程。两者均带
envelope f12=1(uinForm),实测持久化(跨 QQ 重启保持)。

- proto-defs: CustomFaceModifyBody/Resp、CustomFaceOrderBody、CustomFaceMoveBody
- protocol: ModifyCustomFace、OrderCustomFace、MoveCustomFace 三个 namespace + wire byte-oracle 测试
- core: ProfileApi 加 modifyCustomFace / moveCustomFace(move 内部 fetch + 两步发包)
- onebot: modify_custom_face、move_custom_face action
2026-06-19 15:47:43 +08:00
DrSmoothl f71ed2e4db chore(license): source-available non-commercial license
Switch from open-source to a NapCat-style source-available, NON-commercial
license so the free software cannot be resold / paid-deployed — which an OSI
license (AGPL included) cannot prevent. Source stays public for study and
non-commercial self-hosting; commercial use and public release of modifications
require written permission; the native addon (snowluma-*.node) is carved out as
proprietary. Explicitly NOT OSI open source.

- LICENSE: SnowLuma Source-Available Non-Commercial License v1.0 (bilingual)
- all 14 package.json: "license": "SEE LICENSE IN LICENSE"
- README license section; CONTRIBUTING contribution-licensing note + a grant
  letting the project relicense contributions under commercial terms
  (contributors keep copyright)
2026-06-19 02:26:33 +08:00
Qiao 552238fd04 feat(protocol): favorite emoji fetch/delete/add via Faceroam.OpReq + highway (#107)
* feat(protocol): fetch/delete favorite emoji via Faceroam.OpReq

First non-OIDB services in oidb-services/ — Faceroam.OpReq is a trpc
service, so these bypass invokeOidb and call sendRawPacket directly.

- proto-defs: reuse FaceroamOpReq/Inner/Resp, add field5 + FaceroamOpBody
- protocol: oidb-services/emoji/{shared,fetch-fav-list,delete-fav-emoji}
- core: EmojiApi facade + ApiHub registration
- onebot: fetch_fav_emoji_list + delete_fav_emoji actions

Request wire matches 9.9.26-44343 captures byte-for-byte; fetch response
decode + delete verified end-to-end against a live account.

* feat(protocol): add favorite emoji via BDHExpressionRoam + highway HTTP upload

Flow: BDHExpressionRoam (apply upload, get token) -> highway frame
(PicUp.DataUp commandId=9, serviceTicket = BDHExpressionRoam token) ->
HTTP POST httpconn?htcmd=0x6FF0087 -> fetch refresh.

- proto-defs: BDHExpressionRoamReq/Resp + FavEmojiHighwayHead types
- protocol: oidb-services/emoji/add-fav-emoji (fetchHighwaySession +
  BDHExpressionRoam + packHighwayFrame + HTTP POST)
- core: EmojiApi.addFavEmoji facade (reuses loadBinarySource)
- onebot: add_fav_emoji action
- mcp: regenerate catalog

End-to-end verified: new emoji_id appears in fetch list after upload.

* refactor(emoji): reuse fetchCustomFace for fetch_fav_emoji_list

Address review: Faceroam.OpReq had two call sites (profile.fetchCustomFace
+ the new FetchFavList service). Drop the duplicate service —
fetch_fav_emoji_list now reuses ProfileApi.fetchCustomFace and extracts
emoji_id from the returned image URL (qq_expression/<uin>/<id>/0).

delete/add unchanged (independent protocols).

* refactor: align custom-face naming with NapCat

Address review: 'fav_emoji' vs existing 'custom_face' named the same
thing two ways. Rename across the stack to match NapCat's custom_face:
- services: oidb-services/emoji/* -> oidb-services/custom-face/*
  (DeleteFavEmoji -> DeleteCustomFace, AddFavEmoji -> AddCustomFace)
- facade: EmojiApi dropped; delete/add moved to ProfileApi
  (deleteCustomFace / addCustomFace), next to fetchCustomFace
- actions: fetch_fav_emoji_list -> fetch_custom_face_ids,
  delete_fav_emoji -> delete_custom_face, add_fav_emoji -> add_custom_face
- package.json subpaths + catalog regenerated

* refactor(onebot): merge fetch_custom_face_ids into fetch_custom_face

Per review: merge fetch_custom_face_ids back into fetch_custom_face
with a return_type param ('url' default / 'id'). One action, one service,
no separate id-only variant.
2026-06-19 02:15:54 +08:00
DrSmoothl 7ea1ffb497 feat(onebot): TierB ③ followup — doubt-buddy reject path (delDoubtBuddyReq)
set_doubt_friends_add_request with approve:false now rejects/declines via
delDoubtBuddyReq instead of erroring. RE'd from QQNT wrapper.linux.node
(DoubtBuddyDelWorker ctor sub_3F3F320 → OIDB 0xd69_0; EncodeRequest
sub_3F3E860 body {1: varint const 3 (op discriminator; get uses 1),
3:{1: string uid}}, uid from kernel attr 21001). uin-form (reserved=1).
Adversarially IDA-verified; byte-oracle locks the tags. Split out as its
own commit because the approve-only 0xd69_0 work was already pushed.
2026-06-18 22:18:08 +08:00
DrSmoothl 43cd4a0b99 feat(onebot): TierB ③ — RE'd OIDB actions (ark share / doubt friends / robot-add)
All recovered from QQNT wrapper.linux.node via IDA — NapCat ships these as
kernel-only with no portable packet wire, so each cmd + protobuf was traced
from the binary (worker ctor cmd/subcmd via BaseOidbWorker::DoSendOidbReq;
field tags from the per-feature EncodeRequest). All uin-form OIDB (envelope
reserved=1, matching the NT worker flag=1). One commit for easy rollback.

New OIDB services + byte-oracle tests (fixed-wire, mutation-resistant):
- 0x9130_0 getBuddyRecommendContactArkJson → share_peer/send_ark_share (user)
- 0x8b7_5  getGroupRecommendContactArkJson → share_peer/send_ark_share (group),
           share_group_ex/send_group_ark_share (NapCat's getArkJsonGroupShare
           was NOT RE'd; routed to this confident equivalent — documented)
- 0xd69_0  getDoubtBuddyReq → get_doubt_friends_add_request
- 0xd69_0  approvalDoubtBuddyReq → set_doubt_friends_add_request (approve only)
- 0xf00_3  modifyGroupExtInfoV2 → set_group_robot_add_option (EXTInfo tags
           30/31; GroupExtFilter is client-side gating, not on the wire)

NOT shipped (kernel-.so-side, no static cmd recoverable from this binary —
honest, no guessed packets): get_robot_uin_range (trpc method-routed),
create_collection/get_collection_list (Weiyun multi-step), trans_group_file.

Doubt-list string field NAMES (nick/source/msg) are medium-confidence
(generic serializer); uid+reqTime are high. READ path, so a mislabel is
cosmetic, never a wire/ban risk. Wired through bridge friend/contacts/
groupAdmin apis; proto-defs + per-file subpath exports registered.
2026-06-18 21:12:29 +08:00
DrSmoothl 9b787dfdc1 fix(onebot): TierB ① — propagate signed-list errors instead of fail-soft []
Adversarial review: swallowing transport/auth errors to [] made an expired
cookie indistinguishable from a group with zero sign-ins, and left the
onebot action's try/catch as dead code. Now getDaySignedListWebAPI
propagates transport errors and throws on a page-less response (matches
NapCat, which throws on missing page) — so auth failure becomes a proper
'failed' OneBot response; a genuinely empty group still maps to [].

Also: cover the previously-untested HTTP layer (exact URL + g_tk + json
body + error propagation), harden the rank de-skew with Number(), and
document the deliberate local-date (vs NapCat UTC) choice for dayYmd.
2026-06-18 19:28:52 +08:00
DrSmoothl 36d557e5ea feat(onebot): TierB ① — get_group_signed_list via qun.qq.com web API
Group sign-in (打卡) list is NapCat's one kernel-free item here: it's a
plain HTTP POST to qun.qq.com/v2/signin/trpc/GetDaySignedList with g_tk
from p_skey, not OIDB. SnowLuma already has the cookie/bkn infra (same as
group notice/essence/honor), so this is a straight port — no RE.

- protocol/web/group-signin.ts: getDaySignedListWebAPI + pure
  mapDaySignedList (de-skews signInRank via (r-1)/2+1, [] when no page).
- core WebApi.getSignedList(groupId) wires the qun.qq.com cookie jar.
- onebot get_group_signed_list groupAction (readOnly), [] on no sign-ins.
- Tests pin the rank/field transform and the action wiring.
2026-06-18 19:24:42 +08:00
DrSmoothl dfb6aa54c7 chore(release): v1.9.13 2026-06-18 17:16:15 +08:00
DrSmoothl aee543c7de fix(protocol): record inviter identity for uid-bearing group_invite
A group_invite carrying a fromUid is routed to the async dispatchGroupInvite
enrich path, which only resolved the requester profile when needsProfile
(uin missing/polluted) — so when the uin was already known it bypassed the
synchronous rememberRequestIdentity that friend_request uses, and the inviter's
uid↔uin was never stored. Record it up-front (mirrors the sync path), and
re-record after a profile resolve so a previously-unknown uin self-heals.
Fixes the long-standing bridge-member-identity test failure (core now fully
green: 387 passed).
2026-06-18 16:16:26 +08:00
DrSmoothl 90d3f7c0d6 test(protocol): real byte-oracle for ocr_image request wire
Adversarial Wave 3 pass: the prior 'round-trip locks pb tags' test was a
fake lock — symmetric encode/decode via the same proto would not catch a tag
swap. Replace with a fixed hex byte-oracle (hand-decoded in the comment) that
goes red on any tag change, plus an empty-ocr-body deserialize test. Note in
the action that a passed-in URL is used verbatim (not rkey re-signed).
2026-06-18 00:40:26 +08:00
DrSmoothl e07090376a feat(onebot): implement ocr_image via OIDB 0xE07_0
Server-side image OCR ported from Lagrange.Core ([Service]
"OidbSvcTrpcTcp.0xe07_0") cross-checked with NapCat's Oidb.0xE07 proto
(NapCat's transformer cmd 0xEB7 is a typo; the proto name + Lagrange + the
wire name all say 0xE07_0). New ImageOcr service + proto types, misc.ocrImage
api, and the ocr_image/.ocr_image action. The cmd takes an image URL, so the
action accepts an http(s) url or a cached image file_id (resolved via
getImageInfo); base64/local input is unsupported (NapCat's path is a
Windows-only kernel OCR, not portable). Wire round-trip + response-parse
tests lock the pb tags.
2026-06-18 00:36:07 +08:00
DrSmoothl 07988629f1 feat(onebot): implement rename_group_file (OIDB 0x6D6_4)
Add the file-rename slot to the group-file OIDB envelope (req/resp field 5),
a RenameGroupFile service mirroring MoveGroupFile, a groupFile.rename() api
method, and the rename_group_file action (params group_id/file_id/
current_parent_directory/new_name, NapCat-aligned), replacing the stub.
Wire fields (busId=102, parentFolder=current dir, newFileName) verified
against NapCat's Oidb.0x6D6 transformer; subCommand 4 cross-checked against
the move op (sub 5 on both sides). Serialize + envelope round-trip test locks
the pb tags.
2026-06-18 00:19:00 +08:00
DrSmoothl 16920b4444 chore(release): v1.9.12 2026-06-16 21:59:31 +08:00
DrSmoothl e0d5b47488 feat(msg-push): filter C2C control pushes by c2c_cmd, matching QQ NT (#102)
Upgrades the blank-body heuristic to NT's exact discriminator. RE of
wrapper.linux.node (msg_header_codec_helper.cc::DecodeRoutingHead, sub_3B42650)
shows the official client classifies a C2C push by (svr_msg_type, c2c_cmd):
msgType ∈ {141,166,167} with c2c_cmd ∈ {1,73,75,129,131,133,135,192} is routed
through OnRecvSysMsg (long_cnn_msg_mgr.cc) as a system/control signal and never
shown as a chat bubble — the group-invite "[空消息]" phantom is one of these.

- proto-defs: ContentHead field 3 was mislabelled `divSeq`; it is `c2c_cmd`.
  Renamed (only other use was forward.ts, which zeroes it on a fake send).
- MsgPushHead now carries c2cCmd; new isC2cControlPush() in blank-filter.ts.
- parseMsgPush + both history fetches drop control pushes by (msgType, c2cCmd)
  regardless of body, so a control signal carrying stray content is caught too
  (not just the empty case). isBlankMessage stays as the fallback for
  content-less pushes we don't yet classify by cmd. Live path debug-logs a
  control push that carried elements, in case the table ever eats real content.

NOTE: the c2c_cmd set is taken from NT; we have not captured #24's actual cmd
(our diagnostic never logged field 3), so the blank fallback remains load-bearing
until a live capture confirms which cmd the invite phantom uses.
2026-06-16 01:34:38 +08:00
DrSmoothl 2ffb6192dd fix(msg-push): drop blank "[空消息]" pushes in c2c/group history too (#102)
The #102 fix dropped content-less control pushes only in parseMsgPush (live).
History fetches (fetchC2cMessageRange / fetchGroupMessageRange) decoded straight
into the result, so the same phantom resurfaced as "[空消息]" on backfill. RE of
wrapper.linux.node confirms QQ NT filters blanks on BOTH paths — live/roam C2C
via c2c_roam_msg_mgr.cc::FilterBlankMsgAndRetryFetch and group roam via
group_roam_msg_worker.cc::FilterBlankSeqsMsg.

Extracts the blank test into blank-filter.ts (bodyHasDecodableContent +
isBlankMessage) and applies it in all three message-producing paths. A message
that decoded to 0 elements but whose body carried content is a missing decoder,
not a blank — isBlankMessage returns false there so it's kept (live path still
warns).
2026-06-16 01:24:31 +08:00
DrSmoothl d5af6b10ef fix(msg-push): drop genuinely-empty c2c push instead of surfacing "[空消息]" (#102)
A group invite makes QQ emit a content-less c2c message (msgType=166/0, body
richText.elems=[]) right after the invite card. The TRACE log from #102's
diagnostic confirmed the body is truly empty — no commonElem, no msgContent,
no ptt/file — so it's a phantom control push, not an element type we fail to
decode. It was reaching OneBot clients as a confusing "[空消息]".

parseMsgPush now drops a message-kind event that decodes to zero elements when
its body carries nothing decodable. When the body *did* carry content but still
decoded to nothing (a real missing decoder), the event is kept and a warning
fires so the gap gets noticed rather than silently swallowed — replacing the
temporary #102 diagnostic with the actual fix.
2026-06-16 00:54:28 +08:00
DrSmoothl 6003acac56 chore(msg-push): log body of empty-decoded messages (#102 diagnostic)
A group invite surfaces a content-less "[空消息]" friend message (#102). Before
deciding whether to drop or decode it, we need to know what its body actually
carries — SnowLuma's decoder covers fewer element types than Lagrange, so an
empty decode may be a missing decoder (real content) rather than a genuinely
empty control push.

parseMsgPush now warns, on any message that decodes to zero elements, with a
summary of the body: each element's field names, every commonElem's
serviceType/businessType + payload hex, and any msgContent. One log line is
enough to identify the culprit element offline.

No behavior change — the message still dispatches as before. Temporary; remove
once the #102 culprit is identified and handled.
2026-06-15 22:07:50 +08:00
DrSmoothl b0bfb9e6a7 feat(contacts): resolve non-member group name via 0x88D_0 (group invites)
A group invite push carries no group name (confirmed across SnowLuma / Lagrange
/ NapCat — all three invite protos only have groupUin + invitorUid), and
get_group_info only resolved joined groups (fetchGroupList → roster), so an
invited group rendered "未知群名". Add a by-id server lookup so the name
resolves even when the bot isn't a member.

- proto-defs: 0x88D_0 request (field1 + config.uin + a request-mask `flags`
  block) + response (groupInfo.results: name / member counts / owner / …),
  field tags transcribed from
  dev/Lagrange.Core/.../OidbSvcTrpcTcp0x88D_0{,Response}.cs.
- protocol: FetchGroupDetail oidb-service. Envelope reserved=0 (NO uinForm) —
  Lagrange's GetGroupInfoService builds it with isUid=false.
- core: ContactsApi.fetchGroupDetail(gid) → QQGroupInfo | null; deliberately
  NOT remembered into the joined-groups roster (mustn't pollute get_group_list).
- onebot: get_group_info falls back to fetchGroupDetail for non-member groups,
  behind a 5-min per-id TTL cache so a burst of invites for one group can't
  hammer 0x88D_0 (rate-limit / kick risk). noCache bypasses it.
- tests: request/envelope round-trip (reserved=0, uin, command), response
  decode; + non-member resolve / cache-hit / noCache-bypass at the onebot layer.

Live note: byte-identical to Lagrange's 0x88D_0, but not verified against a real
account on this host.
2026-06-15 14:33:06 +08:00
DrSmoothl 7e59dbf79b chore(release): v1.9.11 2026-06-15 13:57:38 +08:00
DrSmoothl cf8e62d2ac fix(onebot): resolve c2c reply target by srcMsg friendSequence, not origSeqs
Replying to a bot self-sent private message still failed get_msg on the quoted
id ("无法解析申请信息"). Root cause is the reply DECODE, not the keying: for a
c2c (friend) reply the canonical replied-to sequence is carried in
srcMsg.pbReserve.friendSequence (tag 8), while origSeqs[0] holds the per-sender
clientSequence. The decoder read origSeqs[0], so the reply resolved to a
sequence that never matches how the original message is keyed (by its server /
private sequence) — get_msg missed. Mirrors Lagrange's incoming-reply rule
`Sequence = reserve.FriendSequence ?? OrigSeqs[0]` (ForwardEntity.cs +
SourceMsg.PbPreserve.cs ProtoMember(8)).

- proto-defs: +SrcMsgPbReserve { receiverUid@7, friendSequence@8 }
- protocol: decodeRichBody reads friendSequence for c2c replies (group keeps
  origSeqs[0], the shared group seq); fall back to origSeqs[0] when absent
- tests: c2c→friendSequence, group→origSeqs[0], no-reserve→fallback

This supersedes the keying approach in 4571864 (self-sent c2c keyed by
clientSequence), which is fully reverted here: the message is keyed by its
private sequence as before, and the fix is purely on the decode side so it also
covers replies to *received* old c2c messages, not just self-sent ones.
2026-06-15 11:58:28 +08:00
DrSmoothl 8dc4ff48f8 feat(onebot): unify market faces to image segments + add send support
Market faces (商城表情) now decode to an OneBot `image` segment carrying
NapCat-style markers (emoji_id/emoji_package_id/key/summary) plus a gxh
gif URL, so clients that don't special-case `mface` still render the
sticker while aware clients can reproduce it.

Adds the previously-missing send path: a dedicated `mface` segment OR an
`image` segment carrying `emoji_id` rebuilds the wire `marketFace`
(faceId=hex(emoji_id), itemType 6 / subType 3 / 300×300 /
pbReserve.field8=1) — byte-matched against NapCat and Lagrange.Core.

- proto-defs: MarketFace.key bytes→string; +MarketFacePbReserve{field8@8}
- protocol: decode faceId→emojiId(hex)/tabId/key; makeMarketFaceElem +
  buildSendElems 'mface' case; format preview keeps the sticker name
- onebot: to-segment mface→image; message-parser mface + image(emoji_id)
- tests: wire decode + full proton round-trip; parse→build; segment shape

Reply/forward round-trips preserved (element keeps type 'mface' + text).
2026-06-14 20:47:50 +08:00
DrSmoothl 58c02d415f feat(onebot): fetch real private (c2c) history from server (#99)
Mirror the group-history fetch for private chats via SsoGetC2cMsg
(trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg, fields per Lagrange:
flat friendUid/start/end request, messages@7 response). get_friend_msg_history
now resolves the anchor sequence + the friend's UID, fetches real history from
the server, converts + persists each message, and falls back to the local
store on unknown anchor / no uid / empty / error.

- protocol: fetchC2cMessageRange (decodes via the friend decoder).
- core: the group + c2c fetch now share a generic walkBackHistory loop
  (same throttle gate + caps); added MessageApi.getC2cHistory.
- onebot: getFriendHistory + a generalized persistHistoryEvent (keys private
  by sender uin, exactly like the live path — so reply to old private
  messages resolves too).

Tests: SsoGetC2cMsg request/response round-trip + friend-decode reuse.
2026-06-14 20:15:38 +08:00
DrSmoothl 987ac09896 feat(onebot): fetch real group history from server (#99)
get_group_msg_history was served only from the local observed-message store,
so it could never return more than what SnowLuma saw live (the "only 100
messages" symptom). It now fetches real history from the server.

- protocol: new SsoGetGroupMsg packet (trpc.msg.register_proxy.RegisterProxy
  .SsoGetGroupMsg, sequence-range fetch; fields per Lagrange). Each returned
  PushMsgBody is decoded by the existing group decoder via a factored-out
  buildContextFromMessage.
- core: MessageApi.getGroupHistory walks backward in small sequence windows
  with rate-limiting + data-volume guards — a process-wide throttle (≥300ms
  between sends), 30-seq chunks, ≤12 requests, ≤200 messages per call — so a
  hammering client can't make us flood the server and get kicked.
- onebot: get_group_msg_history resolves the anchor sequence (requested
  message_id or latest observed), fetches from the server, converts + returns,
  and persists each fetched message. Falls back to the local store on
  unknown anchor / empty / error.

Persisting fetched history also fixes replying to pre-injection messages:
the reply path resolves message_id→sequence via the store, which now contains
the fetched messages.

Tests: SsoGetGroupMsg request/response round-trip + decode reuse (protocol);
walk-back chunking/dedup/cap/floor (core).
2026-06-14 19:11:03 +08:00
DrSmoothl ee8eb928b3 chore(release): v1.9.10 2026-06-14 16:28:39 +08:00
DrSmoothl a5335687ec fix(protocol): fetch group-join verify comment regardless of uin cache (#98)
The verify text a user types when requesting to join a group is never in the
push — it lives on the OIDB pending-request queue. The fetch
(resolveGroupJoinRequest) was gated behind the UID→UIN stranger-resolve, so
when the requester's uin was already cached the whole async path (and thus the
comment fetch) was skipped, leaving the OneBot request.group `comment` empty.

Decouple the two: every uid-bearing group_invite now goes through the async
enrich path (needsGroupInviteEnrich → dispatchGroupInvite), which ALWAYS
fetches the comment and resolves the UIN profile only when needed (the
fromUin === groupId cache-pollution self-heal is preserved). Matches
Lagrange's unconditional FetchGroupRequests and NapCat's notify.postscript.

Rewrites the cache-hit test (which had pinned the buggy behavior) into a
regression asserting the comment is fetched + applied on a cache hit.
346/346 protocol tests pass. Verified by adversarial review.
2026-06-13 12:03:04 +08:00
DrSmoothl e9ae2ec751 feat(log): show ARK card title in JSON segment preview (#96)
The JSON segment preview was a fixed "[JSON]" placeholder, hiding both
real card titles and QQ's server-side fallback prompts (e.g. cardId
-896954183 "当前QQ版本不支持此应用,请升级", which is what #96 reported
seeing). Best-effort extract a title from the common ARK fields —
meta.{detail_1,detail,news,mannounce,music,video}.title, single-section
meta, then root.prompt / root.desc — and render as "[卡片:...]"; fall
back to "[JSON]" on parse failure or an unrecognised schema. Title is
truncated at 30 chars so a long prompt cannot blow up the log line.

OneBot segment shape is unchanged: downstream consumers still get the
full payload at data.data; this is purely a log-preview readability fix.
The helper is pure best-effort and never throws — every failure path
falls through to the existing "[JSON]" output.
2026-06-11 20:47:54 +08:00
DrSmoothl a3a36718d6 chore(release): v1.9.9 2026-06-11 16:42:23 +08:00
游手好闲的言柒 d2a67cb560 fix(onebot): add canonical fields to file segment and fix log rendering (#94)
* fix(onebot): support inline file url/path in send_group/private_msg

* test(onebot): update file segment parser test for inline url/path support

* fix(onebot): add canonical fields to file segment and fix log rendering
2026-06-11 15:40:47 +08:00
DrSmoothl 14e02bce3c fix(events): keep member role cache in sync on group admin change
get_group_member_info serves straight from the in-memory member cache
on its default no_cache=false path — a deliberate choice, since clients
like OlivaDice/MaiBot query it once per inbound message and a per-call
OIDB refetch trips Tencent risk-control. But the group-admin push
decoder only emitted the notice and never updated the cached role, so a
member promoted to admin kept reading back as `member` and permission
gates rejected their commands. Patch the cached role in the decoder on
set/unset, only touching members we already know and never downgrading
the owner.

Also fix promote/demote misdetection: proton materializes an absent
embedded-message field as an empty `{}` rather than undefined, so the
old `extraEnable !== undefined` test was always true — every demote was
reported as a promotion with a fallback userUin. Key off whichever
sub-message actually carries an adminUid instead.

Fixes #93
2026-06-09 16:30:10 +08:00
DrSmoothl d508814dfa chore(release): v1.9.8 2026-06-09 14:22:43 +08:00
DrSmoothl 515bac4b3c chore(release): v1.9.7 2026-06-07 22:29:37 +08:00
DrSmoothl 174150c5e1 chore(release): v1.9.6 2026-06-07 21:19:00 +08:00
DrSmoothl 28db1d1079 fix(onebot): distinguish group disband from kick_me (#87)
group_decrease collapsed GroupChange.decreaseType into a single isKick boolean, so a group disband (decreaseType 129) was indistinguishable from the bot being kicked — both surfaced as kick_me.

Preserve the protocol reason as a leaveType discriminator (leave | kick | disband) on GroupMemberLeave: decreaseType 129 -> disband, 0/130 -> leave, otherwise kick. The OneBot converter emits disband, or splits kick into kick/kick_me by selfId as before. leave/kick/kick_me are unchanged.
2026-06-05 11:49:14 +08:00
DrSmoothl 9374b8ac64 fix(protocol): invokeOidb rejects on packet send failure (success=false)
invokeOidb only threw on success=false when an OIDB errorCode was set (the
block added in #69); a send failure with no errorCode silently resolved with an
undecoded result. Surface it — throw errorMessage || 'packet send failed',
matching runOidb and the oidb-service tests that were already red on dev.
2026-06-04 23:36:22 +08:00