Commit Graph
175 Commits
Author SHA1 Message Date
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
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 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 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 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 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 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 90f26ca4f4 fix(proto): emit state=0 on whole-ban unmute via new pb_optional marker
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.
2026-05-30 17:55:23 +08:00
DrSmoothl c16064d7e3 refactor(packages): rename @snowluma/bridge → @snowluma/protocol
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.
2026-05-23 23:31:03 +08:00