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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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)
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.
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).
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 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.
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.
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.
* 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
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
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.
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.