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.
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.
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).
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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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).
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.
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).
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.
set_group_whole_ban false (unmute) failed with `OIDB error 1007` while
enabling worked (issue #70). OIDB (0x89A, 0) is shared by several commands
(SetSearch / SetAddOption / mute-all) disambiguated by body shape. Unmute
sends muteState.state=0, but proton omits zero scalars (proto3), leaving an
empty `muteState` the server can't classify. Enable sends 0xFFFFFFFF, which
is non-zero and survives — hence the asymmetry.
Add a `pb_optional<N, T>` marker (proto3 explicit presence): a scalar so
marked serialises even at its zero/default value. It is opt-in per field via
a NEW `explicitPresence` flag set ONLY by this marker — never reused from the
TS-`?`-derived `isOptional` (true for nearly every field). The encoder routes
explicit-presence scalars to a dedicated block; every `pb<>` / `pb_repeated<>`
field takes the untouched branches, so existing wire output is byte-identical
(proton's 99 existing tests, incl. byte-level round-trips, stay green).
Mark OidbMuteAllState.state `pb_optional<17, uint_32>` so unmute now puts
field 17 (=0) on the wire. New tests: proton pb_optional encode/round-trip
(incl. that sibling pb<> fields still omit zero), and a byte-level assertion
that mute-all unmute carries the state field.
Note: proton ships as a built Vite plugin (dist, gitignored); rebuild it
(`pnpm --filter @snowluma/proton build`) for the change to reach consumers.
The package previously named "bridge" houses the protocol-byte layer
(OIDB cmds, msg-push decoders, highway uploaders, element-builder,
event bus). The name "bridge" suggested transport/lifecycle, which it
isn't — that role lives in core/hook and core/bridge/manager. Rename
to match what's actually inside.
This is a pure rename: zero logic changes, all 73 import sites updated
mechanically and TS-verified. core/onebot dependencies key updated.
ESLint override path adjusted for the moved oidb-services directory.
Rename detection threshold: git log --find-renames=20% recommended
when following history across this commit — content-replacement
(@snowluma/bridge → @snowluma/protocol) dropped per-file similarity
below git's default 50% on import-heavy files.