Report distinct invitations separately and approve only the request that belongs to the account and inviter. Reject ambiguous approvals and resolve the sender of invitations from outside the contact list.
Refs #612
Trigger account detection and group requests from observed changes. Remove native stage log forwarding and its lifecycle state, and simplify the README.
Replace the shallow refreshMemberCache adapter with fetchGroupList
and fetchGroupMemberList ports. Skip, swallow, and coalesce stay
next to the join/leave/admin trigger.
Decoder used to write Identity: decodeGroupAdmin patched member roles
and decodeFriendMessage stashed invite-card msgseq. That split
observation with IncomingPacketPipeline.rememberEventIdentity and put
pending-application state on Identity.
Decoder now only returns events. Invite-card groupUin/msgseq ride on
FriendMessage; the live pipeline writes them to ContactsApi through
an optional port. Role self-heal for #93 stays in
rememberEventIdentity. History and forward decode do not write.
Identity no longer stores invite-card sequences.
Live #93 and #125 behavior is unchanged.
Inbound person lookup used to be split across decoders, the packet
pipeline, and Bridge closures. Missing identities were filled from
unrelated packet headers, and "who is this person" was coupled to
roster refresh and pending-request enrichment.
Identity now owns both directions of resolution. The hot path stays
synchronous and emits unresolved events on a miss. Only the paths
that already waited before dispatch perform a network lookup, and
that lookup is a profile fetch by UID rather than a member-list
refresh. Pending-request comments and approval flags stay outside
Identity.
Decoders no longer invent a UIN from a group routing header. Private
chat routing still uses a hop's own wire UIN when the hop is named
that way. Inbound misses stay availability-first: they do not throw
and do not mark Identity degraded.
Validate the service result before reporting friend remark changes as successful. Rejected, incomplete, or contradictory confirmations now reach callers instead of being reported as completed.
Keep confirmed remarks synchronized in the identity cache and consume cross-client remark updates without exposing a new OneBot event. Local cache failures remain observable without turning an already completed remote change into a retryable failure.
Preserve friend remarks exactly across restarts and add request, response, persistence, receive-pipeline, and action-level regression coverage.
Fixes#303.
Fetch QQ's versioned robot UIN ranges, classify and persist member status, and surface is_robot from both group member actions.
Preserve unknown legacy cache entries and fail visibly when classification is unavailable.
Closes#265
Use the C2C roam cursor and canonical NT sequence for bidirectional history. Persist direction-aware recall tombstones so recalled messages cannot reappear.
Recognize both raw UTF-8 UIDs and nested OperatorInfo envelopes in GroupChange pushes while keeping self-kick and voluntary-leave semantics explicit.\n\nFixes #253.
Decode Event 0x210 subtype 349 into typed online-device snapshots, including QQ-compatible low-byte client type normalization and one-entry-per-device-category projection.
Keep replacement snapshots outside system-message deduplication, reject malformed device identities observably, and add wire-level regressions for complete, packed, repeated, and invalid payloads. Live verification covered mobile account switching without requiring native changes.
Clear only the membership flag bit when normalizing group increase operation codes so unrelated high bits cannot collapse into the invitation value.
Add regression coverage for extended operation codes.
Derive group increase approval and invitation subtypes from the member-change operation code instead of actor identity.
Treat voluntary leave field 5 as structured metadata and attribute the operation to the departing member so valid notices are not discarded.
Add protocol and OneBot regression coverage for issue #237.
Handle member joins and ordinary removals using their direct operator identity while preserving the special nested form used when the bot is removed.
Reject malformed values with bounded diagnostic context and add regression coverage for #228 and related member-decrease events.
Validated the 0x210/39 ProfileLikeTip field layout byte-exact against three
real captured like pushes (detail.txt='赞了我的资料卡N次', f3=uin, f5=nick).
Drop the pending-validation caveat; test now uses the real txt shape.
Decode the multiplexed 0x210 subType-39 push: when body.msgContent is a
ProfileLikeTip with inner msgType==0/subType==203, emit notify/profile_like
{operator_id, operator_nick, times}. times is parsed from detail.txt.
Proto shape from NapCat's raw decoder; wiring + decode verified, but the
byte-exact wire is pending a real like capture to confirm.
Decode the 0x2DC subType-16 field13==6 push: eventParam carries the member
uin (f5) and a gray-tip template (f2) whose last rich token holds the title
text. Emit notify/title {group_id, user_id, title}, matching NapCat. Field
layout taken from a real on-wire sample, not ported blindly.
Decode the 0x210/0x115 input-state sys_msg (RE'd from wrapper.linux.node
aio_input_state_worker) into a friend_input_status event and map it to a
notify/input_status notice, matching NapCat's onInputStatusPush.
event_type 1 = typing, 3 = recording voice; status_text synthesised to
match the client's own strings.
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
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.
Phase 2 of the 4-package split (RoadMap #3). Moves ~35 files —
events / event-bus / identity-service / packet-pipeline / element-
builder / bridge-oidb / qq-info / msg-push/* / highway/* / web/* +
the bridge-side formatters — out of `packages/core/src/bridge/`
into the new `@snowluma/bridge` workspace package. Everything that
translates QQ wire bytes ↔ typed events/elements now lives there.
Why
---
- `packages/core/src/bridge/` was actually two layers: a low-level
"protocol decoder + I/O" set (msg-push, highway, packet-pipeline,
identity, element-builder) AND the Api-class host (bridge.ts +
apis/*). The Api layer can't move without the protocol layer
moving first, but the two had grown into one directory.
- Bridge-internal consumers (highway uploaders, OIDB envelope
helper, element-builder, msg-push parsers) only ever needed
`bridge.identity` + `bridge.sendRawPacket` + the upload-metadata
cache. Splitting them out lets that minimal surface live in the
bridge package as `BridgeContext` while the api-layer's extended
context (with `apis: ApiHub`) stays in @snowluma/core.
What stays in @snowluma/core (apis layer):
- bridge.ts ── the god-class that composes the protocol
building blocks + mounts apis
- bridge-interface.ts ── public façade for OneBot consumers
- manager.ts ── per-uin BridgeManager hub
- apis/* ── the 13 typed Api classes
- bridge-context.ts (NEW) ── extends @snowluma/bridge's slim
BridgeContext with `apis: ApiHub` so
the 13 Api classes keep their typed
`this.ctx.apis.X.method()` access.
Layout
------
Moved via `git mv` (history preserved):
events / event-bus / identity-service / packet-pipeline /
element-builder / bridge-oidb / qq-info / msg-push/* /
highway/* / web/* → @snowluma/bridge/<sub>
utils/event-format.ts → @snowluma/bridge/format.ts
(the last one renamed because the destination directory only has
bridge-related files; `format.ts` reads cleaner than `event-format`.
Listed as a rename in git's `RM` output — content unchanged.)
Layering fixes (functionality preserved, just type signatures)
--------------------------------------------------------------
- `element-builder.SendContext.bridge` → typed as `BridgeContext`
instead of `Bridge`. Reads only `bridge.identity` and the
upload-cache (both on BridgeContext); the concrete `Bridge`
instance still passes via free upcast.
- `bridge-oidb.runOidb` signature widened from `Bridge` to
`BridgeContext` — same reason.
- `highway/{highway-client,pipeline,image-upload,ptt-upload,
video-upload}.ts` — same.
- `format.ts::formatReply` parameter typed as a new local
`ReplyEventLookup` structural type (`{ findEvent(id): JsonObject |
null }`) instead of `MessageStore` from @snowluma/onebot. The
OneBot `MessageStore` is a structural superset; consumers pass
it through unchanged.
- JSON value types moved to a new `@snowluma/common/json` subpath
(consumed by format.ts) — `onebot/types.ts` re-exports them so
external `import { JsonValue } from '<…>/onebot/types'` paths
keep working.
- `UploadedFileMeta` interface moved from `core/src/bridge/bridge.ts`
into `@snowluma/bridge/bridge-context.ts` — element-builder + the
forward-builder both read the upload cache through BridgeContext,
so the type has to live in @snowluma/bridge. `bridge.ts` keeps
its re-export for back-compat.
Changes
-------
- New package `packages/bridge/` (package.json with per-file subpath
exports mirroring legacy `core/src/bridge/<x>` layout 1:1,
tsconfig.json, typecheck script).
- 35 `.ts` files moved via `git mv` (3 files counted by git as
RM — rename + modify because Bridge → BridgeContext type swap).
- 150+ import sites rewritten across `packages/core/src` and
`packages/core/tests`: `'../bridge/<x>'`, `'../../bridge/<x>'`,
`'../src/bridge/<x>'`, `'@/bridge/web/<x>'`, and vitest
`vi.mock(...)` literals all collapse to `@snowluma/bridge/<x>`.
- `@snowluma/core` depends on `@snowluma/bridge` (workspace:*).
- `@snowluma/bridge` depends on `@snowluma/common`,
`@snowluma/proto-defs`, `@snowluma/proton`.
- `packages/core/src/utils/` directory removed (empty).
Verification
------------
- Full workspace `pnpm -r typecheck`: all 10 projects clean.
- core test suite: 552 passed / 552 total (pre-existing
websocket-darwin-arm64.node addon failure unchanged — same
baseline as the previous refactor commits).
- Vite build (with stubbed native binary): clean — 143 modules,
2.84 MB bundle / 224 KB gzip, identical envelope to the
pre-split build.
Phase 3 (next): OneBot + WebUI move into @snowluma/onebot. The
runnable `index.ts` stays in @snowluma/core as the application
entry, importing `OneBotManager` from `@snowluma/onebot`.
Phase 1 of the 4-package split called out in RoadMap #3. Moves the
"infrastructure" leaves — files with zero business dependencies that
@snowluma/bridge, @snowluma/core, and @snowluma/onebot all consume —
into a dedicated workspace package. Mirrors what #4 (proto-defs)
did for wire-protobuf definitions.
Why
---
- Cleaner layering: these were never bridge- or onebot-specific.
Logger / hex codec / runtime config sit at the bottom of every
module's import graph; giving them their own package makes that
graph readable and prevents future leaks the other direction.
- Sets up #3's bridge + onebot extractions: those need a clean leaf
to depend on. Without this, every cross-package import would have
to re-export through core, which defeats the split.
- Mirrors @snowluma/proto-defs layout — per-file subpath exports
(`@snowluma/common/logger`, `@snowluma/common/hex`, …) keep import
sites unambiguous and let bundlers tree-shake aggressively.
Layout
------
Moved (via `git mv` — history preserved):
packages/core/src/utils/logger.ts → @snowluma/common/logger
packages/core/src/utils/hex.ts → @snowluma/common/hex
packages/core/src/utils/log-file-transport.ts → @snowluma/common/log-file-transport
packages/core/src/utils/log-summary.ts → @snowluma/common/log-summary
packages/core/src/protocol/packet-sender.ts → @snowluma/common/packet-sender
packages/core/src/protocol/types.ts → @snowluma/common/protocol-types
packages/core/src/common/runtime.ts → @snowluma/common/runtime
Not moved (deferred to Phase 2): `packages/core/src/utils/event-format.ts`
— it has reverse imports on `bridge/events`, `bridge/identity-service`,
`onebot/message-store`, `onebot/types`. It moves into @snowluma/bridge
in Phase 2, with the MessageStore parameter decoupled via a structural
type so bridge doesn't end up depending on onebot.
Changes
-------
- New package `packages/common/` (package.json, tsconfig.json — no
src/index.ts barrel, callers use the subpath exports directly to
avoid pulling in `node:fs` when only the hex codec is needed).
- 7 `.ts` files moved via `git mv`.
- 50+ import sites rewritten via sed across `packages/core/src` and
`packages/core/tests`. Old shapes (`../utils/logger`,
`../../protocol/types`, `./common/runtime`, `../src/utils/hex`)
all collapse to `@snowluma/common/<sub>`.
- `@snowluma/core` depends on `@snowluma/common` (workspace:*).
- `packages/core/src/{protocol,common}/` directories removed (empty).
Verification
------------
- core typecheck: clean
- common typecheck: clean
- Full workspace `pnpm -r typecheck`: all 9 projects clean
- core test suite: 552 passed / 552 total (pre-existing
websocket-darwin-arm64.node addon failure unchanged — same baseline
as the previous refactor commits)
- Vite build with stubbed native binary: clean (143 modules, 2.84 MB
bundle, 224 KB gzip — same size envelope as before)
Note: `utils/event-format.ts` is the only file remaining under the
old `utils/` namespace. It moves into @snowluma/bridge alongside the
events / identity-service / packet-pipeline trio in Phase 2.
Move `packages/core/src/bridge/proto/proton/*.ts` (13 files, ~3.3k
LOC) into a new `@snowluma/proto-defs` workspace package. The proton
spike landed in the previous commit means bare-specifier imports
(`@snowluma/proto-defs/oidb`, `…/message`, …) now resolve correctly
through proton's vite plugin, so call-site replacement still fires.
Why
---
- Lets downstream consumers share the same wire
definitions without copy-pasting them — multiple workspace packages
can now depend on `@snowluma/proto-defs` directly.
- Trims `@snowluma/core`'s surface: the bridge no longer owns the
protocol schema, only its consumers.
- Sets up future #5 (`message-parsing perf`) and #6 (`Api-on-ctx
refactor`) — both want a thinner core.
Layout
------
Per-file subpath exports (mirrors the legacy `proto/proton/<sub>`
shape 1:1). The barrel `index.ts` intentionally skips `highway` and
`oidb-actions/media` because the two files share six NTV2-prefixed
type names — `export *` would silently drop one side. Subpath imports
are unambiguous, which is also why every call site in core uses them.
Changes
-------
- New package `packages/proto-defs/` (package.json, tsconfig.json,
src/index.ts).
- 13 `.ts` files moved via `git mv` to preserve history.
- 44 import sites across 26 source files + 15 tests rewritten via sed
from `./[…/]proto/proton/<sub>` → `@snowluma/proto-defs/<sub>`.
- `@snowluma/core` depends on `@snowluma/proto-defs` (workspace:*).
Verification
------------
- proto-defs typecheck: clean
- core typecheck: clean
- core test suite: 587 passed (same baseline as before the refactor;
one failure is the pre-existing websocket native addon, unrelated)
- proton test suite: 99 passed (8 files including the new
cross-package.test.ts from the spike)
- Full workspace `pnpm -r typecheck`: all 8 projects clean
Note: proton's `dist/index.js` MUST be rebuilt for the vite plugin to
pick up the bare-specifier resolution. The previous commit's source
change is only effective after `pnpm -F @snowluma/proton build`.
Delete proto schema modules under packages/core/src/bridge/proto (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts, oidb-action.ts, oidb.ts) and the related test packages/core/tests/proton-parity.test.ts. These files contained protobuf schema definitions for the bridge layer; remove them as part of a cleanup/refactor. Update any imports or references to these modules to their new locations or replacements.
Close three coverage gaps surfaced by the cross-Lagrange-family
audit (Lagrange V2 / LagrangeGo / acidify / lagrange-python):
- **0x210 subType 179 + 226 (NewFriend)** — mutual-accept friend
notice. 179 fires when bot sent a request and the other side
accepted; 226 fires for the opposite direction. Both decode
through the same wire shape. LagrangeGo handles the pair via a
`case 226: case 179:` fallthrough in `client/listener.go:248`.
We surface them as `kind: 'friend_add'`, which the existing
converter routes straight to OneBot `notice.friend_add` — bot
frameworks that wait for "auto-add a friend after they accept"
were previously stuck because nothing arrived.
- **0x210 subType 139 (self-recall)** — bot recalled its own
message sent to a friend. Same `FriendRecall` wire shape as 138
but the peer is on `toUid` instead of `fromUid` — acidify's
`parseFriendRecall:380` splits exactly this way. Without 139,
self-recalls disappeared and downstream `notice.friend_recall`
handlers thought the bot had never recalled anything.
- **PkgType 85 (bot self-joined a group)** — fires on the bot's
session once `set_group_add_request` is approved or an invite is
accepted, ported from lagrange-python's `server_push/msg.py:108`.
PkgType 33 (GroupMemberIncreaseNotice) only fires for *other*
members joining, so without 85 the bot never received its own
`notice.group_increase` until the first message arrived. We
decode `SelfJoinInGroup` and emit `kind: 'group_member_join'`
with `userUin = selfUin`; the existing converter picks
`sub_type: 'approve' | 'invite'` from the operator/user equality
so consumers see the standard OneBot shape.
While wiring 139, also fix a *pre-existing* prod bug spotted via
proton call-site analysis on the resulting bundle: `event-0x210.ts`
imported `FriendRecall` from both `proto/proton/notify` (as
`FriendRecallNotify`) *and* `events` (as the event interface).
Proton's import-resolver registers `importedTypeSources` keyed on
the *imported* name (last-write-wins), so the events.ts import
shadowed the proto one — `protobuf_decode<FriendRecall>(...)` lost
its codec, fell through to the runtime fallback throw, and was
silently swallowed by `MsgPushRegistry.decode`'s catch. Verified by
the substituted codec list in `dist/index.mjs`: pre-fix it had
`FriendRequest` / `GeneralGrayTipInfo` but no `FriendRecall`;
post-fix `FriendRecall` / `NewFriend` / `SelfJoinInGroup` are all
emitted as expected. Side-step the collision by importing the
proto types unaliased and re-deriving the colliding event type via
`Extract<QQEventVariant, { kind: 'friend_recall' }>`.
New proto interfaces in `proto/proton/notify.ts`:
- `NewFriend` + `NewFriendInfo` (uid/time/message/nickName fields,
fields 1-9; ported from
`LagrangeGo/.../notify.proto:25 NewFriend`)
- `SelfJoinInGroup` (groupUin / operatorUid + a few placeholder
fields; ported from
`lagrange-python/pb/status/group.py:170 PBSelfJoinInGroup`)
Regression coverage in `tests/msg-push-handler.test.ts`:
- subType 179/226 both produce identical friend_add events
- falls back to `routingHead.fromUin` when the new friend's uid
isn't in the local cache (matches LagrangeGo's
"emit-then-resolve" pattern)
- subType 138 keeps fromUid as the peer (other recalled their
own msg to us); subType 139 picks toUid (we recalled ours
sent to them)
- PkgType 85 emits group_member_join with userUin == selfUin,
operator resolved through identity when known
core: 635/635 tests pass (+7), typecheck clean. Production bundle
now contains substituted codecs for FriendRecall, NewFriend,
SelfJoinInGroup (cross-checked via `grep protobuf_decode_X` on
`dist/index.mjs`).
Earlier this commit acknowledged subType 38 as a placeholder
"FriendGrayTipInfo" because none of the LagrangeDev-family projects
(Lagrange V2 / lagrange-python / LagrangeGo / acidify) have a
handler for it — they all just fall through with a generic
"unknown subType" debug log. The "best guess" gray-tip framing was
wrong.
A contributor traced it through the decompiled stock QQ Android
client to `com.tencent.imcore.message.ext.codec.decoder.msgType0x210
.SubType0x26` (tsuzcx/qq_apk @ afe46ef). The payload is
`submsgtype0x26.MsgBody` dispatching on `uint32_sub_cmd`:
- 0x1 → `UpdateAppUnreadNum`: a list of
`{group_code, app_id, unread_num}` keeping group-internal
"mini-app" badge counts (e.g. `appId=101846662` / `101896870`
for the troop shortcut bar) in sync.
- 0x3 → `UpdateDiscussAppInfo`: `{conf_uin, app_tip_notify.text}`
discussion-group app tips, routed through `getGAudioHandler()`.
- 0x4 → delegated to the troop online-push handler.
None of these have a meaningful OneBot mapping — they're
QQ-client-UI state pushes (badge counts, in-app tips), not
conversation events. Rename the enum entry to `GroupAppStatePush`
to reflect the actual semantics, cite the decompiled source
inline, and keep the silent-drop behaviour from the previous
attempt. Tests + decoder routing follow the rename.
628/628 tests pass (unchanged), typecheck clean.
The 885-line msg-push-handler.ts is replaced by bridge/msg-push/:
8 per-PkgType decoders plus MsgPushRegistry (single decoder per
PkgType, catch + log on error), MsgPushContext (header decoded
once at entry), and rich-body-decoder (one deep function hides
~400 lines of element conversion shared by all three message
types).
parseMsgPush(pkt, identity) signature preserved; existing tests
pass with an import-path change only.
QQInfo's in-memory state (self identity, friends, group rosters,
profile cache) and read methods (findFriend / findGroup /
findGroupMember / findUserProfile / nickname / uin / selfProfile) now
live directly on IdentityService. The QQInfo class is gone; its
interfaces stay in `qq-info.ts` as plain types shared across the
remember*/find* surface.
This eliminates a long-standing duplication: every observation used to
maintain both QQInfo's in-memory structures and IdentityService's
SQLite mirror in parallel, with the uidByUin/uinByUid Maps tracked
separately. With one Module owning all three layers, every remember*
hydrates one set of structures and `loadSnapshot` repopulates the
in-memory uid↔uin index from SQLite on startup — closing the cold-cache
gap that previously forced findUidByUin to fall through to a SQLite
query after every restart.
Knock-on changes:
- `Bridge` no longer holds a QQInfo; constructor takes an
`IdentityService`. The CmdParser signature on the cmd dispatch
table now passes `IdentityService` to parsers.
- `msg-push-handler.ts` drops its private `resolveUidToUin(qqInfo, …)`
helper that bypassed IdentityService's cascade — the parser hot
path now reads through `identity.findUinByUid(uid, groupId)` and
benefits from the O(1) `uinByUid` map even on freshly-loaded
sessions.
- `BridgeManager`'s session-started callback drops the QQInfo
parameter; OneBotInstance no longer carries a `qqInfo` field and
exposes a `nickname` getter for the WebUI listing.
- `contact-actions` and the per-theme bridge action files stop
importing QQInfo; they read everything via `bridge.identity.*`.
Two small cleanups that came out of the round-2 architecture review
and are sub-five-minutes each — bundled because each one is too
small to justify its own commit.
#5 — flatten single-file directories
packages/core/src/bridge/builders/element-builder.ts
-> packages/core/src/bridge/element-builder.ts
packages/core/src/bridge/handlers/msg-push-handler.ts
-> packages/core/src/bridge/msg-push-handler.ts
Both dirs had exactly one file in them — a smell that says
"planned a sibling that never landed". Moving up one level
removes the wrapper directory.
Touch list:
- bridge.ts: 2 import paths
- bridge/actions/forward.ts: 2 import paths
- msg-push-handler.ts: internal ../X -> ./X for events,
proto/element, proto/message (the
dynamic-import-typeof references too)
- element-builder.ts: internal ../X -> ./X for bridge,
events, highway/*
- 4 test files: import paths (2 regular, 2 vi.mock)
#6 — drop IdentityService.hasWriteFailure
identity-service.ts had a `private writeFailed = false` field set
by the runWrite catch block and exposed via `get hasWriteFailure()`,
but a full-repo grep showed zero callers of the getter. It was a
public API stub for a feature that hadn't been wired (a webui
health indicator, presumably).
Removed:
- the field
- the getter
- the assignment in runWrite's catch block (the error log on
the next line is the actual observable behavior; nothing else
reads the flag)
If we want a "did any write fail this session" surface later,
it's trivial to add back with an explicit consumer in mind.
Behavior preserved end-to-end. No public API change outside the
moved files. 369 tests pass (unchanged). 3 stability runs.