Bot-built merged forwards left hollow NT images after long-msg fetch,
so get_forward_msg returned empty file/url. Decode now rebuilds identity
from md5, ignores stubs, and forward uploads keep a legacy image sibling.
C2C offline-file download receipts were reported as the peer sending a
file. Treat downloadFlag=2 as a receipt and drop it from the chat stream.
get_forward_msg returned image segments with empty url and file when
the long-msg payload omitted a download path. Rebuild the URL from
the file fingerprint the payload still carries.
Fixes#412
Identify QQ's service-37 fallback by its byte-exact nested Text reserve so compatibility descriptions are removed without consuming user-authored sibling text or malformed payload fallbacks.
Fixes#289
Prefer a successfully decoded modern voice record over the empty legacy voice placeholder that can accompany private message pushes.
Preserve legacy-only voice messages and add a protobuf round-trip regression covering the duplicate segment reported in #291.
Fixes#291
Reject malformed nested payloads, cap compressed card expansion, and enforce per-message decode budgets. Preserve unknown-wire metadata in diagnostics so unsupported QQ elements remain observable without exhausting memory.
Follow-up to the previous #146 fix, which dropped QQ's compat fallback text by
content-matching the "版本不支持 … 升级" family. RE of the QQ kernel
(wrapper.linux.node) showed that is not how QQ does it:
- the receiver binary contains NONE of these fallback strings, so QQ cannot be
matching by content;
- the on-target wire is [lightApp, text(compat), generalFlags, extraInfo] and the
text element has no distinguishing marker (no pbReserve / attr6Buf);
- QQ NT's kernel codec (msg_codec_mgr) collapses the message to a single ark
element — the sibling text is never emitted. NapCat maps kernel elements 1:1
with no ark-aware skip yet still surfaces only the card, confirming the kernel
drops it upstream.
Switch to that structural rule: when a lightApp/richMsg card is present, drop
sibling plain text outright. It is version/locale-proof (won't break when Tencent
rewords the string) and matches QQ NT + NapCat output exactly. @ mentions /
replies / faces are not plain text and survive; QQ clients can't attach a caption
to a card anyway, so no QQ-origin message loses real text.
Verified live on a logged-in build: get_msg returns a single json segment.
A QQ mini-program / ark share (B站 video, QQ 小程序, …) arrives on the wire
as a lightApp/richMsg ark element followed by a plain text element carrying
QQ's graceful-degradation compat string "当前QQ版本不支持此应用,请升级". That
text is meant only for protocol-old clients that can't render the card; QQ NT
and the kernel-backed bridges (NapCat) surface just the card.
On-target capture (test group) confirmed the text element has no distinguishing
wire marker (no pbReserve / attr6Buf) — it is a bare text sibling of the ark.
SnowLuma was decoding both, so every mini-app card came through as the card
plus a spurious "当前QQ版本不支持" text segment.
Drop the compat string when it sits beside a card, matched narrowly (the
"版本不支持 … 升级/更新" family, <=40 chars) so genuine user captions sent
alongside a card are preserved.
Verified live on a logged-in build: get_msg now returns a single json segment.
Harvests the sound ideas from PR #128 while KEEPING origSeqs[0] as the c2c
reply sequence (on-target capture in #114/#124 proved origSeqs[0] == the quoted
message's head.sequence; pbReserve.friendSequence is a small unrelated counter
that makes reply.id miss). The PR's friendSequence override is intentionally
NOT taken.
- rich-body-decoder: enrich the reply element with replySenderUin/replyTime and
the quoted message's own decoded elements (SrcMsg.elems, field 5); recover a
c2c quoted FILE from sourceMsg (field 9 -> RichText.notOnlineFile) when elems
carried none, so a quoted file survives into get_msg (#124).
- backfillReplyTarget: tiered fallback — Tier 0 local store, Tier 1 server fetch
(correct seq = origSeqs[0]), Tier 2 reconstruct from the embedded quoted
elements (no round-trip), Tier 3 minimal '[引用消息]' placeholder so get_msg
never returns 'message not found'.
- to-segment: per-element try/catch (logged, not silent) so one bad element
doesn't drop the whole message.
- actions/message: log get_msg misses.
- tests: fix request-actions getGroupInviteCardSequence mock (gap from #125);
cover backfill Tier 2 (reconstruct) + Tier 3 (placeholder); flip the stale
c2c reply test to assert origSeqs[0] (was asserting the disproven
friendSequence).
Validated on-target (Docker): get_msg on group reply, private text reply, and a
private quoted FILE all return correct content (file with name/size/id); full
onebot + protocol suites green.
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).
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.