Restore expansion and cross-chat forwarding for nested chat records that use preview-only cards.
Return an error when nested content cannot be retrieved instead of producing an unusable message.
Refs #589
Recognize forward cards with an omitted protocol service ID from the root msg attributes. Decode attribute references once so resource IDs and nested lookup keys survive XML escaping, while preserving ordinary XML when the root declaration is ambiguous.
Refs #547, #556, #558.
Co-authored-by: wanqing-last <2917149421@qq.com>
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.