Commit Graph
17 Commits
Author SHA1 Message Date
DrSmoothl 4d93c727bb fix(protocol): retain C2C peer identities
Carry the stable sender identity through decoded private-message events and persist the UID/UIN pair at the receive boundary.

This keeps later conversation operations independent of stale message rows or fallible profile lookups, with regression coverage for decoding and identity indexing.
2026-07-13 23:01:54 +08:00
DrSmoothl 920f75718a fix(protocol): bound rich message decoding
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.
2026-07-10 13:41:19 +08:00
DrSmoothl 9b470d0412 feat(onebot): add group_name to group message events (#212)
Group message events (message + message_sent) now carry group_name — a
non-standard but common OneBot extension. The name comes from the
message's own wire field (ResponseGrp field 7, accurate even right after
a rename), falling back to the identity group cache, else '' when neither
is available. It's resolved in the group-message decoder, where both the
wire head and the identity cache are already at hand, so the converter
just passes it through (no ConverterContext change).

Verified live (tempserver): real inbound group messages from a non-self
account carry group_name set to the actual group name.
2026-07-09 18:50:54 +08:00
DrSmoothl 007dfaf6e2 fix(group): self-heal member group card from message field 4
A member's cached group card could stay stale for the lifetime of a long-running
bot: nothing refreshes it on a card change (no push), and the member-list refetch
only fires on join/leave/admin events (+60s TTL), never periodically — so a quiet
group freezes at warmup. get_group_msg_history / live events then showed an empty
or outdated sender.card even though the current card was in the message.

Every group message carries the sender's current display name in grp.memberCard
(field 4 = group card if set, else base nickname). The group decoder now prefers
it over the possibly-stale cache when it differs from the base nickname (a real
group card); and packet-pipeline self-heals it back into the in-memory member
cache, gated so it writes only on an actual change. This keeps active members'
cards fresh for all cache consumers off message traffic alone — no extra fetch,
no rate-limit exposure. Verified on-target: a member's card change now reflects
in the next message without a member-list refetch.

Known, accepted edge: a member changing only their base nickname (no card) is
mislabeled as a card until the next member-list refetch corrects it — field 4
alone can't distinguish the two, and the mislabeled value is still the member's
own current name.
2026-07-05 14:20:36 +08:00
DrSmoothl 770b224d58 feat(onebot): decode incoming 闪传 flash-file messages (#199/#200)
Older QQ clients (≤9.9.30) deliver a 闪传 (flash transfer) file as a richui
markdown commonElem (svc=45, busId=FlashTransfer); SnowLuma had no decoder for
it, so the message decoded to 0 elements and was reported empty. (Newer
clients ≥9.9.31 send a plain text+link message that already decodes fine.)

Decode svc=45 markdown → parse the FlashTransfer card JSON → emit a flash_file
element, surfaced as the segment the issue asked for:
  { type: "flash_file", data: { title, file_set_id, scene_type } }

- Field names (fileSetId / sceneType / title) confirmed against QQ NT's
  flash-transfer manager in wrapper.node.i64. The card is built by the
  sender's client so the exact JSON nesting is unknown — values are pulled by
  a bounded recursive search rather than a pinned path (handles either shape).
- Non-FlashTransfer markdown is ignored; missing fields default (never empty).
- Manifest: D/S = yes, P/W = by-design-no (receive-only; sending is the
  existing send_flash_msg action). Log preview shows [闪传文件:title].
- Tests: card decode (nested + alias + defaults + non-flash) and the
  element→segment mapping.

Note: not end-to-end verified on a live message — the svc=45 format only comes
from ≤9.9.30 senders and no such client was available (every test sender here
is ≥9.9.31 → text format). Logic + field names are unit-tested / IDA-confirmed.

typecheck 12 包; protocol 596 / onebot 529 green.
2026-07-04 19:02:47 +08:00
DrSmoothl 3b023b3275 fix(protocol): collapse ark/card messages structurally, not by string match (#146)
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.
2026-06-26 18:31:43 +08:00
DrSmoothl 319c7d607d fix(protocol): drop QQ ark-compat fallback text beside mini-app cards (#146)
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.
2026-06-26 18:08:26 +08:00
DrSmoothl 1bdb8a40c4 fix(protocol): dedup duplicate system pushes (#137 official-robot double group_increase)
Inviting an official robot (e.g. 2854207029) into a group made QQ push the
group_member_increase notice twice, so SnowLuma emitted two
notice.group_increase events; a normal member produced one.

Root cause (RE of wrapper.linux.node): QQ NT dedups every received system
message in sys_msg_mgr.cc::ProcessRecvSysMsg (sub_37BADE0) by a global_key
`{peerUid}_{chatType}_{msg_seq}_{random}` (sub_37BC4E0) — an already-seen key
is dropped ("ignore dup msg") before any listener sees it. The per-message
discriminators msg_seq (attr 40003 = contentHead field 5) and random (attr
40002 = contentHead field 4) are exactly head.sequence and head.msgId
(msg_header_codec_helper.cc sub_37C8AD0). Kernel-based bots (NapCat/LLOneBot)
receive events after this dedup; SnowLuma reads the raw OlPush before it.

Mirror it: SysMsgDedup, a per-account bounded ring that drops a duplicate
system push by (msgType, subType, fromUin, sequence, msgId). Push-level (drops
the whole duplicate like NT), live path only (Bridge owns the tracker; the
forward re-parse passes none), system kinds only (chat messages excluded), and
never dedups a push lacking a server identity (sequence or msgId 0).
2026-06-23 20:36:24 +08:00
DrSmoothl e160837f8b feat(onebot): tiered reply backfill + quoted-file recovery for get_msg (#128)
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.
2026-06-22 21:25:05 +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 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 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