SNOWLUMA_ variables can set the factory OneBot listen ports, WebUI TLS,
packet and hook timeouts, the stream directory, file download timeout,
and notification HTTP timeout. The values apply in memory and are not
written back to saved config.
Recover invalidated native send bindings from same-account receive evidence. Track outbound connection loss in status health and refresh the six public native artifacts from native build 94f2b63.
Closes#342
Keep background account discovery passive and distinguish an identified account from a ready session.
Update the process dialog and regression coverage so recovery waits for authoritative readiness before exposing the account as online.
Allow SnowLuma to discover and restore accounts that were already active before startup.
Avoid reporting sessions ready before they can serve requests, preserve account changes, and keep discovery failures observable.
Add regression coverage for delayed startup, recovery, and process isolation.
Refs #305
Refs #307
Strengthen validation and buffering for local process communication while preserving the established compatibility boundary.\n\nAdd regression coverage for malformed input, boundary values, fragmented delivery, combined traffic, and normal request behavior.
Capture complete packet request, reply, and push payloads during temporary TRACE sessions while preserving the existing non-persistent logging boundary.
Correlate transport, parser, enrichment, drop, dispatch, timeout, and terminal outcomes with their real identifiers so exported logs can explain packet failures without relying on user descriptions.
Lock byte completeness, lazy disabled behavior, concurrency, context propagation, and truthful terminal outcomes with regression coverage.
Retry narrowly classified startup failures on later watcher ticks with a fixed attempt limit. Preserve permanent failures and record recovery, pending, and exhausted outcomes.
Add regression coverage for recovery, non-retry, and bounded exhaustion.
Route recovered login state through the hook client before dependent services start. Add regression coverage to keep packet actions available after reconciliation.
Fixes#286.
Replace the Unix socket servers with an in-memory Duplex transport so the mid-send close regression runs in restricted environments and on Windows.
Wait until the control write begins before closing the transport, assert the exact close error, and use an event-loop boundary to detect leaked promise rejections without timing sleeps.
This keeps the test on the public QqHookClient send path while removing unrelated OS socket setup and does not change production behavior.
Arm receive monitoring only after an existing QQ heartbeat has been observed, preserving compatibility with sessions that do not expose the signal.
Require 90 seconds of receive silence plus a 15-second confirmation window before reporting degradation. Any later inbound frame refreshes or restores health, including non-routable frames that still prove the receive path is alive.
Bind watchdog state to the active account epoch so direct login switches and packet-driven account migration cannot inherit stale deadlines from a previous account.
Forward health transitions to the bridge layer without reconnecting, reinjecting, or otherwise changing session recovery behavior. Add regression coverage for arming, recovery, sleep-resume confirmation, account migration, and invalid-UIN frames.
Refs #233.
Docker SnowLuma could hang forever at "等待登录" after QQ had already
logged in. Root cause: login detection is edge-triggered — it only learns
the state from a loginState frame the native hook PUSHES when it observes
the login transition. `getLoginState()` is a cached value read once at
connect; after that the session waits passively. Desktop attaches the
hook before the user logs in, so it catches the edge. In Docker, QQ
auto-logs-in from a persisted session — fast on startup — so the
transition lands before the recv pipe is connected (frames aren't
replayed) or via a silent restore path the hook doesn't intercept. The
edge is missed and the cached state stays not-logged-in.
Fix: while connected but not logged in, actively re-probe QQ's own
ptlogin/deeplink ports via probeQqLoginInfo (level-triggered, pipe-
independent — the same call the workaround used) and synthesize the login
once it reports a real uin. Immediate probe on connect + a 3s interval;
stopped on login / teardown / dispose, with an in-flight guard so a slow
port scan can't stack concurrent probes. Idempotent with the pushed-frame
path. 7 tests cover detect-via-probe, the later-interval case, the
not-logged-in / non-real-uin / throwing-probe guards, no double-emit, and
no concurrent stacking. probeLogin is injectable for tests.
QQ's silent hot-update churns the process table; the watcher's synchronous
native getAllMainProcess() (/proc walk reading cmdline/maps) blocks while a
process is mid-exec(). Running on the main event loop, that froze the whole
bot — OneBot stopped responding, the watcher stopped ticking, the supervisor
saw it 'alive' so never restarted it (silent death until manual restart).
- run the native enumeration in a worker_threads worker, raced against a
deadline on the main thread; a blocked call no longer stalls the loop
- on timeout/failure return an UNKNOWN sentinel (null); PipeWatcher keeps the
prior process+pipe snapshot instead of treating missing data as 'all QQ
gone' (which would dispose live sessions and tear down a healthy hook)
- graceful fallback to in-loop enumeration when the addon can't load in a
worker or there's no addon (macOS); custom listers (tests) bypass the worker
- bounded orphan workers across a sustained hang
Tests: PipeWatcher null-sentinel (no false process-gone, recovers); enumerator
sync-fallback + broken-isolation paths. Native /proc-walk hardening rides in
the nnphook repo separately.
Phase 2 of switching processes/qq-list/connections from REST polling to SSE
push. Sinks the three publisher edges into a fresh StateBus:
- HookManager grows an OPTIONAL `onSessionsChanged: () => void` dep; called
from pipe-watcher process-discovered/process-gone and from the previously
unsubscribed HookSession 'status-changed' event (login/disconnected reach
it transitively via applyStatus, so a single subscription covers every
transition the WebUI processes view cares about). Throwing subscribers are
caught + logged so the watcher loop is never aborted.
- New packages/core/src/webui/state-wiring.ts owns the coupling: produces the
bus + the `onSessionsChanged` callback to feed HookManager, and registers
BridgeManager session-started / session-closed listeners that publish
'qq-list' + 'connections' (in source-of-truth order: account first, then
adapters).
- index.ts threads stateWiring.bus into initWebUI so the phase-3 SSE handler
has something to subscribe to; dispose runs on SIGINT/SIGTERM.
Bridge: 62/62 (was 57). Core: 419/419 (was 414). 10 new tests cover all
publish points + exception isolation + the optional-dep contract.
Two tests pinning the one intended behavior change from the status-
machine deepening: refresh while the pipe is down on a connected-but-
never-logged-in session now reports 'connecting' (matching onPipeDown),
not the old spurious 'disconnected'; and a session that HAD logged in
still settles to 'disconnected' + emits disconnected(true), proving the
flip is scoped to the never-logged-in case.
Verified against the pre-refactor baseline: test (1) fails on old code
(produced 'disconnected'), so it locks the flip rather than restating it.
Phase 2. Migrate the ~6 hand-copied status ternaries to the pure
statusFor() and collapse the pipe up/down orchestration into two shared
private reconcilers:
- reconcilePipeUp ← onPipeUp + refresh's pipe-up branch
- reconcilePipeDown ← onPipeDown + refresh's pipe-down branch + the
client 'close' handler (4 call sites, all were near-duplicates)
applyStatus() pushes the statusFor-derived status; loading/error stay
explicit. refreshInternal collapses to a one-line pipe poll. Behavior-
preserving except the intended drift fix (refresh-while-down + never
logged in now reports 'connecting', matching onPipeDown, not the old
spurious 'disconnected').
reconcilePipeDown short-circuits when !connected so a pipe-down tick
can't clobber a diagnostic error or emit a spurious status-changed on a
failed-connect/failed-load session — covered by two new regression
tests (caught in adversarial review). 55/55 green.
Phase 1 of deepening the HookSession status machine. The 5 settled
HookProcessStatus values (available/connecting/loaded/online/disconnected)
were hand-derived from (injected,connected,loggedIn,wasLoggedIn) at ~6
call sites in hook-session.ts. Extract that truth table into a pure
statusFor() in hook-status.ts so it has one source of truth and a
mutation-tight exhaustive table test. loading/error stay out-of-band.
Not yet wired into hook-session (phase 2).
When the control pipe drops while a send() is awaiting its ack,
rejectControlPending() rejects both the ack and the reply deferreds. The
reply is only awaited after the ack, so an ack-first rejection leaves the
reply promise rejected with NO awaiter — an unhandled rejection that, under
Node's default policy, kills the process. In Docker supervisor restarts it
(every session drops for ~1s); on a bare Windows run it just dies, surfacing
as "connection pipe closed" with the bot offline after hours of uptime.
Attach a no-op .catch() to each deferred at creation so a rejection can never
go unhandled (the real await still observes the value/rejection). Add a
regression test that drops the pipe mid-send and asserts no unhandled
rejection escapes — it fails without the guard.
Also install last-resort process guards (unhandledRejection / uncaughtException)
in the app entry: log and keep serving instead of exiting, so any other stray
rejection cannot take the daemon down and its stack is captured. The bridge's
watcher reconnects the dropped pipe on the next tick.
Split the QQ NT client lifecycle / hook injection layer out of
@snowluma/core into its own self-contained package. The new bridge
package owns:
- native hook addon loader (injector)
- HookManager + per-PID HookSession orchestration
- PipeWatcher for QQ process discovery
- QqHookClient pipe protocol
- qq-port-probe for early UIN sniffing
The hook layer used to type its main collaborator as
`BridgeManager` directly. To keep the new package free of any
dependency on @snowluma/core, the cross-package coupling is now
expressed via a 3-method `BridgeManagerSink` interface that lives
in the bridge package. BridgeManager (in core) satisfies the
interface structurally with no source changes; the top-level
app/webui still inject it the same way.
Pure file relocation otherwise: git mv preserves history at 100%
on every file, the only logic edit is the interface introduction
in hook-manager.ts and an updated `nativeSearchDirs()` offset
(one fewer `..` since hook now lives at packages/bridge/src
instead of packages/core/src/hook).
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.
User report:
SnowLuma's group_invite event renders to OneBot with empty
`comment`, so the bot's approval-prompt template shows nickname
+ uin + flag but loses the requester's verify message.
NapCat's same flow shows the verify text:
【进群申请】批准/驳回:
昵称:KitaIkuyo
QQ:1957003260
flag:1779543612823906
等级:7
你们好 ← only NapCat shows this line
Root cause:
The OIDB push (GroupRequestJoinNotice subType=84) only carries
`(groupUin, targetUid)` and a few opaque numeric fields — no
string for the verify text. NapCat doesn't use the push directly;
they poll `nodeIKernelGroupService.getGroupNotifies` (= our OIDB
0x10C0 pending-request queue at `FetchGroupRequests`) which
returns each pending row with `postscript` (the verify text) and
`seq` (server sequence id). Reference:
dev/NapCatQQ/packages/napcat-onebot/index.ts:483-498
(`flag = notify.seq`, `comment = notify.postscript`).
Our pipeline didn't enrich. The decoder set `message: ''` and
emitted, so OneBot's `comment` was empty.
Fix:
- Extend `PacketPipelineDeps` with a `resolveGroupJoinRequest(
groupId, uid, subType)` hook. Bridge.ts wires it to
`fetchGroupRequests` and matches on `(groupId, uid)` —
targetUid for `subType='add'`, invitorUid for
`subType='invite'`.
- `dispatchAfterStrangerResolve` now fires the profile + request
lookups in PARALLEL via `Promise.allSettled` so a flake on one
path doesn't kill the other. The request lookup's `comment`
lands on `event.message`; OneBot converts that to `comment`.
Decisions:
- Kept the existing `add:<groupId>:<uid>` flag format (consumer
bots already parse it). Switching to NapCat's sequence-form
flag would break callers and is a separate breaking change.
- Both lookups fail-open: if either rejects, the event still
emits with whatever info we did get, so the bot at least sees
the request and can render the bare nickname.
Regression tests:
- `populates event.message from the pending-request queue` — pins
the user-reported "你们好" case end-to-end through the pipeline.
- `runs lookups in parallel` — profile resolves, request fails;
event still ships with the resolved uin and an empty message.
- `invite subtype matches the request on invitorUid` — explicit
contract for the invite-forwarded-to-admin case where the
pending row's REQUESTER is `invitorUid`, not `targetUid`.
345 bridge tests pass (was 342 + 3 new); typecheck + lint clean.
User report: after [a4909ec], log STILL shows
群邀请 950929451 -> [bot group(950929451)]
get_stranger_info params=user_id=950929451 → 未知昵称
But NapCat and a sibling SnowLuma bot in the same group BOTH decoded
the same push as `QQ:1957003260 nickname:KitaIkuyo`. So the wire is
correct — our bot is just looking it up wrong.
Root cause: **legacy cache pollution**. The pre-fix decoder fallback
stored `<requester_uid> → <groupUin>` in the identity DB whenever
`resolveUidToUin` missed (because the fallback `ctx.fromUin` on a
group-scoped push IS the group's own uin). After upgrading to
[a4909ec], `decodeGroupJoinRequest` calls `resolveUidToUin` which
hits the polluted in-memory map (re-populated from SQLite at boot)
and returns the groupUin instead of 0. The new `needsStrangerResolve`
guard only fired on `fromUin <= 0` — so the polluted value flowed
straight through and re-emitted the bug.
Two-part heal:
1. `needsStrangerResolve` now ALSO fires when `fromUin === groupId`
— the pollution signature. A real requester's uin can never
equal the group's own uin, so this is a safe override that
forces the async UID-form FetchUserProfile lookup, which then
`rememberUidUin`s the correct mapping on success (eviction is
already wired in identity-service.ts:468-471).
2. `rememberEventIdentity` for group_invite now passes `uin: 0`
when `event.fromUin === event.groupId`, so future writes don't
re-pollute the cache (`rememberUidUin` short-circuits on
normalized uin ≤ 0).
Cache self-heals on first event after upgrade — no manual DB nuke
needed.
Regression test pins the legacy-pollution path: feeds an event
where `fromUin === groupId` (matching the user's log), verifies the
resolver fires and patches fromUin to the correct value.
User report: when a stranger requests to join a group, the bot
receives `user_id=<groupId>` (instead of the requester's actual
uin) and `get_stranger_info` returns no nickname → bot displays
"未知昵称".
Two layered bugs:
1. **Decoder fallback was wrong.** `decodeGroupJoinRequest` /
`decodeGroupInvitation` / `decodeGroupInvite` all passed
`ctx.fromUin` as the fallback uin when `resolveUidToUin` missed
the local cache. But for group-scoped pushes `ctx.fromUin` is
the GROUP's own uin — falling back to it produces the bogus
`fromUin === groupId` collision the user saw. Fix: pass 0.
2. **No async resolve for stranger UIDs.** The push only carries
the requester's UID string. When the UID isn't in our cache
(true for any stranger who isn't yet a group member), we have
no UIN — and our UIN-form `FetchUserProfile` can't help. The
pipeline never tried to look up the stranger by UID, so the
event went out with `fromUin=0` (or, with the old fallback,
the group's own uin).
Cross-checked against Lagrange.Core's flow:
dev/Lagrange.Core/.../MessagingLogic.cs:215-224
case GroupSysRequestJoinEvent join:
var fetchUidEvent = FetchUserInfoEvent.Create(join.TargetUid);
var results = await Collection.Business.SendEvent(fetchUidEvent);
uint targetUin = results.Count == 0 ? 0
: ((FetchUserInfoEvent)results[0]).UserInfo.Uin;
var joinArgs = new GroupJoinRequestEvent(join.GroupUin, targetUin);
dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-26
— UID-form variant of the same OIDB 0xFE1_2: field 1 is `uid:
string`, envelope `reserved=0` (no uinForm flag).
dev/Lagrange.Core/.../FetchUserInfoService.cs:66-78
— picks UID vs UIN form based on which input is set.
Changes:
- `proto-defs/oidb-actions/base.ts`: add `OidbUserInfoByUidRequest`
(same shape as the UIN variant but field 1 is `uid: string`).
- `bridge/oidb-services/contacts/fetch-user-profile-by-uid.ts`:
new namespace at 0xFE1_2 with UID-form encoding, no `uinForm`.
- `bridge/msg-push/decoders/group-join-request.ts`: 3 decoders'
`resolveUidToUin` fallbacks change `ctx.fromUin` → 0.
- `bridge/packet-pipeline.ts`: new `resolveStrangerProfile(uid)`
dep + `needsStrangerResolve` check + async dispatch that fires
the lookup before emitting the event. Fail open if the lookup
rejects (event still ships with `fromUin=0` so the OneBot bot
can fall back to the uid in the flag).
- `core/bridge/apis/contacts.ts`: `fetchUserProfileByUid(uid)`
facade method.
- `core/bridge/bridge.ts`: wire `resolveStrangerProfile` into the
pipeline deps so it routes through the new namespace.
Tests:
- UID-form namespace: declares the right cmd/subcmd, packages uid
as field 1, decodes nickname + uin so the pipeline patch works.
- Pipeline stranger resolve: patches event.fromUin from the
resolver response; fails open on null / throw; skips the wire
call when fromUin is already populated or fromUid is empty.
All 655 tests pass (was 645 + 10 new); typecheck + lint clean.
Multi-layer 合并转发 was uploading correctly but the outer's
LongMsgResult piggyback was *unreachable* by the receiver because
two things were missing:
1. The forward preview element was emitted as the old XML
`richMsg serviceID=35 m_resid=…` shape, which carries the
inner resId but has NO uniseq field.
2. The piggyback `actionCommand: <uuid>` slot generated a fresh
UUID that was never reflected into the preview either.
Result: QQ-NT receivers saw the inner preview's resid, scanned the
outer's actions[] for a matching uniseq, found none, and either
fell back to a fresh server fetch per layer or dropped the layer
entirely.
Cross-checked against:
- dev/Lagrange.Core/Lagrange.Core/Message/Entity/MultiMsgEntity.cs:43-115
- dev/NapCatQQ/packages/napcat-core/helper/forward-msg-builder.ts:52-122
- dev/NapCatQQ/packages/napcat-onebot/action/msg/SendMsg.ts:208-347
Both upstream impls use the modern `com.tencent.multimsg` LightApp
JSON whose `meta.detail.uniseq` is the round-trip key that links
the inner preview to the matching `actionCommand` in the outer's
LongMsgResult — no separate fetch needed.
Changes:
- `element-builder.makeForwardElem` now emits a deflated
`com.tencent.multimsg` LightApp JSON (LightAppElem, field 51)
instead of the XML RichMsg (field 12). `forwardUuid` on the
MessageElement is baked into `meta.detail.uniseq` and
`extra.filename`. Auto-generates a UUID when absent.
- `MessageElement.forwardUuid?: string` added for the round-trip.
- `forward.uploadRecursive` threads `inner.uuid` into the outer's
preview element AND uses the same uuid for the outer's
piggyback actionCommand — closing the receiver-side lookup loop.
- `forward.fetch` now walks ALL actions (not just `MultiMsg`),
indexes piggybacks by uuid, and pre-populates the cache for
each inner resId discovered in the outer's preview bubbles.
Follow-up `fetch(innerResId)` hits the cache instead of the wire.
- `rich-body-decoder` parses LightApp JSON; when `app ===
com.tencent.multimsg`, emits `{type: 'forward', resId,
forwardUuid}` so the OneBot layer can walk in. Legacy XML
serviceID=35 path stays as a fallback for mobile QQ senders.
Wire-byte tests cover:
- send: lightApp.data is 0x01-prefixed deflated JSON with the
right meta + extra fields
- send/nested: outer LongMsgResult carries `MultiMsg` AND a
uuid-keyed piggyback; the piggyback uuid matches the
preview's `meta.detail.uniseq` baked into the same outer
msgBody
- send/3-deep: outermost action[] has 1 + 2 entries (own
MultiMsg + 2 piggybacks hoisted from deeper levels)
- recv: lightApp `com.tencent.multimsg` → forward element with
both resId and forwardUuid; non-multimsg LightApp falls
through to {type:'json'}; legacy XML path still emits
{type:'forward'}.
641 tests pass (was 628 + 13 new); typecheck clean; lint clean.
The first pass at namespace-per-cmd left a smell: namespaces that
needed identity/uid resolution had to declare `Deps` AND then rewrite
their own spec inside `invoke` via a closure-over-resolved-value:
// before
export const serialize = (p: Params, targetUid: string) => ({...});
export const invoke = async (deps, params) => {
const targetUid = await deps.resolveUserUid(params.userId);
return invokeOidb(deps, {
...Self,
serialize: p => serialize(p, targetUid), // ← escape hatch
}, params);
};
Reading "what does this cmd do on the wire?" required reading the
namespace AND the closure rewrite — the namespace wasn't really
self-contained, just a forwarder of two pure functions.
Fix: thread `ctx` (the namespace's full capability surface) into
`serialize` and `deserialize` directly. `serialize` may now return
`Promise<TReq>` so identity / uid lookups happen in place:
// after
export const serialize = async (ctx: Deps, p: Params) => ({
groupUin: p.groupId, type: 1,
body: {
targetUid: await ctx.resolveUserUid(p.userId, p.groupId),
duration: p.duration,
},
});
export const invoke = (deps, params) => invokeOidb(deps, Self, params);
OidbCallSpec gains a `TCtx extends OidbSender` generic; invokeOidb's
first arg becomes `ctx: TCtx` (the sender capability is just a slice
of ctx now). buildOidbRequest / parseOidbResponse follow the same
shape. Async serialize is awaited inside invokeOidb.
17 namespaces had closure rewrites; all 17 collapse to plain
`invokeOidb(deps, Self, params)` invokes. Only one closure remains —
`fetch-user-profile` binds `params.uin` into deserialize as a server-
echo fallback; kept as a documented exception (the rare case where
deserialize wants request context, not ctx).
Wire bytes preserved across all migrations. 326 bridge tests + 302
core tests pass; lint clean.
The namespace-per-cmd OIDB service layer was tripping
`@typescript-eslint/no-namespace` across 59 files; namespace IS
the OidbCallSpec by design (structural typing, one file per
(cmd, subcmd)) so disable the rule for that subtree only. Drop
four stale `eslint-disable-next-line @typescript-eslint/no-empty-
object-type` comments that aren't needed anymore (the empty
`Params {}` shape no longer triggers the rule), and fix an indent
nit in the get-unidirectional-friend-list test.
`npx eslint packages` now reports 0 errors. The 5 remaining
warnings are unused-vars / unused-eslint-disable in unrelated
pre-existing files.
Splits the 16 OIDB cmds from GroupFileApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-file/. Adds
@snowluma/bridge/oidb-services/shared with `toInt` / `ensureRetCodeZero`
helpers so namespaces can validate sub-responses without an
@snowluma/core dependency.
Boundaries:
- Single-OIDB methods (publish / getCount / list-page / *url /
delete / move / *folder) become one-line forwarders.
- 3-stage methods (`upload` / `uploadPrivate`) keep their
orchestration on the facade — OIDB preflight goes through the
new namespace, but highway PUT + publish/sendC2cFile + upload-
cache bookkeeping stay where they are.
- `list` keeps its pagination loop on the facade; the page-fetch
namespace is one call.
- The NTV2-rich-media URL fetchers (group/private × ptt/video)
share a deserialize helper (`parseNtv2DownloadUrl`) so all four
namespaces only differ in their scene block.
Wire-byte equivalence preserved. The 0x6D6 family shares one
OidbGroupFileReq envelope; each namespace populates a different
slot (file / download / delete / move) and proton monomorphizes
per call site as before.
Splits the 14 OIDB cmds from GroupAdminApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-admin/. The
facade becomes one-line forwarders; per-cmd capability is narrowed
to OidbSender (+ resolveUserUid / identity where the cmd actually
needs it).
Notes:
- 0x89A_0 hosts MuteAll, SetAddOption, SetSearch, SetGroupName —
same (cmd, subcmd) tuple, four different proto bodies; each gets
its own namespace so proton can monomorphize independently.
- 0x8A0_1 splits KickMember (single) and KickMembers (batch) for
the same reason.
- SetAddRequest carries uinForm=true and dynamic subCommand
(filtered ? 2 : 1) on the same namespace.
- GetAtAllRemain needs the bot's own uin → declares
Pick<BridgeContext, 'identity'> in its Deps.
Wire-byte equivalence preserved; corner case for getAtAllRemain
empty-envelope path now returns {false, 0, 0} instead of throwing —
the new infrastructure normalises empty bodies and the OneBot action
ergonomics are unchanged.
5 logical OIDB cmds → 4 namespace files (group todo's 3 sub-cmds
collapse into one namespace with a dynamic resolver):
- GroupTodo (0xF90_1/2/3) — set/complete/cancel via the
new `action` discriminator
- GetStrangerStatus (0xFE1_2) — uinForm=true; preserves the
(status*10, ext_status) decode
quirk verbatim
- FetchAiVoiceList (0x929D_0)
- FetchAiVoice (0x929B_0) — single poll; retry loop
stays on the facade so callers
can plug their own back-off
Facade keeps the 6 public methods (setGroupTodo / completeGroupTodo /
cancelGroupTodo / getStrangerStatus / fetchAiVoiceList / fetchAiVoice)
as one-line forwarders; only `getStrangerStatus` wraps in try/catch
to preserve its "return null on transport failure" contract, and
`fetchAiVoice` keeps the polling loop.
30 new namespace-level tests; extras.test.ts moved onto the
sendRawPacket assertion model.
Verification:
- typecheck green
- @snowluma/bridge: 24 files / 195 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
2 OIDB cmds moved (the only OIDB calls in this file — the rest are
HTTP cookie / qun.qq.com / qzone web requests):
- ForceFetchClientKey (0x102A_1) — used by ptlogin2 jump flow to
swap clientKey → cookie jar
- GetPskey (0x102A_0) — p_skey fallback when ptlogin2
omits it
The private `forceFetchClientKeyInner` / `getPSkey` helpers in
WebApi are now one-line forwarders into the namespaces. Facade
methods (`getCookies`, `getCsrfToken`, `getCredentials`, etc.) and
all HTTP-only cmds untouched.
12 new namespace-level tests covering deserialize edge cases
(empty server response, missing fields, dropped invalid items)
and e2e wire routing.
Verification:
- typecheck green
- @snowluma/bridge: 20 files / 165 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
3 OIDB cmds migrated; the 1 non-OIDB cmd (getMiniAppArk via
LightAppSvc) stays on the facade unchanged.
- TranslateEnToZh (0x990_2)
- ClickInlineKeyboardButton (0x112E_1)
- SendGroupSign (0xEB7_1) — preserves historic UPPERCASE
EB7 wire name via the new
`wireName` override (the only
cmd in the codebase with this
quirk; every other cmd uses
the default lowercase).
Tests:
- misc.test.ts moved to bridge.sendRawPacket assertion model
- 18 new namespace-level tests covering serialize, deserialize edge
cases, magic constants, BigInt widening, and the EB7 case quirk
Verification:
- typecheck green
- @snowluma/bridge: 18 files / 153 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
5 of profile.ts's methods were thin OIDB wrappers — migrated to the
new namespace pattern. The 5 non-OIDB methods (setOnlineStatus /
setDiyOnlineStatus / setAvatar / setGroupAvatar / fetchCustomFace)
stay on the facade unchanged — they hit `trpc.qq_new_tech.*`,
Highway, or `Faceroam.OpReq` directly and aren't in scope for the
OIDB-namespace refactor.
New namespaces (each with focused Pick<BridgeContext, …> deps):
- SetProfile (0x112A_2) — needs identity for uin
- SetSelfLongNick (0x112A_2) — same cmd different proto, ditto
- SetInputStatus (0xCD4_1) — needs resolveUserUid
- GetLike (0x7ED_12) — needs identity + resolveUserUid
- GetUnidirectionalFriendList (0xE17_0) — needs identity; non-standard
wire name handled via the new
wireName override on OidbCallSpec
Infrastructure tweak:
- OidbCallSpec gains an optional `wireName(cmd, sub) → string`. Most
cmds use the default `OidbSvcTrpcTcp.0xNNNN_N` scheme; 0xe17_0
routes through `MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0`.
Test mock updates:
- profile.test.ts moves the migrated cmds onto bridge.sendRawPacket
mock (the new path bypasses the bridge-oidb runOidb mock). Non-OIDB
cmds (setOnlineStatus, setAvatar, etc.) keep their existing mocks.
- 36 new namespace-level tests in @snowluma/bridge covering serialize
branches, deserialize edge cases, deps resolution, and e2e wire
routing.
Verification:
- pnpm -r typecheck: green
- @snowluma/bridge: 12 files / 118 tests
- @snowluma/core: 33 files / 302 tests
Wire shape unchanged across migrated cmds — same bytes produced.
Completes the InteractionApi facade — every method is now a one-line
forwarder. New namespaces:
- SendPoke (0xED3_1) — group + friend variants discriminated by
which of groupUin/friendUin is set
- SendLike (0x7E5_104) — thumbs-up to a target user
- SetEssence (0xEAC_1/_2) — set/unset essence message, dynamic subcmd
Facade methods reduced to thin forwarders, mirror-fixed comment block
removed (the namespaces now own the wire-shape commentary).
Tests: 28 new in @snowluma/bridge covering each namespace's serialize
edge cases + dynamic subcmd resolution + e2e wire name + envelope body
shape. Cleaned the legacy `vi.mock('bridge-oidb')` setup out of
interaction.test.ts — the post-migration path doesn't route through
the mocked symbols anymore.
Wire behavior unchanged: every cmd produces the same bytes as before,
verified by encode round-trip in the unit tests.
Introduce a new way to organise OIDB cmd implementations:
- one TypeScript namespace per (cmd, subcmd) tuple
- the namespace's exported members ARE the OidbCallSpec (structural
typing — no `implements` clause)
- a single invokeOidb() template method drives every call:
envelope build → encode → send → errorCode check → decode →
deserialize transformation
- each namespace declares its own minimal `Deps` capability via
Pick / intersection on BridgeContext (Interface Segregation):
most cmds need only OidbSender (the `sendRawPacket` method)
The facade Api classes (InteractionApi etc.) stay for now, with the
migrated methods reduced to one-line forwarders that hand off to the
namespace. Callers still write `bridge.apis.X.Y(...)`; the OIDB
wire-encoding work just moved to a self-contained file per cmd.
Vertical slice scope: 3 cmds (the reaction line)
- SetReaction (0x9082_1 / _2; dynamic subcmd)
- FetchReactionSummary (0x9084_1)
- GetEmojiLikes (0x9083_1; legacy stub)
Infrastructure:
- new packages/bridge/src/oidb-service.ts:
OidbSender (sendRawPacket-only capability),
OidbCallSpec<TReq, TResp, TParams, TResult>,
invokeOidb(), buildOidbRequest(), parseOidbResponse(), OidbError
- runOidb() refactored to accept OidbSender instead of BridgeContext
(structurally compatible — no caller changes anywhere)
- new OidbEmpty placeholder type in proto-defs/oidb.ts for cmds whose
response carries no body (proton needs a real message interface,
not `void` / `never`)
- empty-body responses substituted with {} in invokeOidb so
deserialize always receives a defined object
Tests (65 in @snowluma/bridge, all green):
- oidb-service.test.ts (20): every helper branch — static + dynamic
subcmd, uinForm flag, error code propagation as OidbError, empty
body substitution, sender failure modes (success=false /
gotResponse=false / both with and without errorMessage),
timeoutMs propagation
- set-reaction.test.ts: const declarations, dynamic subcmd both
ways, type=1/2 boundary on code length, e2e envelope inspection
- fetch-reaction-summary.test.ts: BigInt serialization, catalog-tail
filtering, default emojiId/Type/lastReactionTime
- get-emoji-likes.test.ts: BigInt serialization, default count /
emojiType, base64 cookie round-trip, uin=0 filtering, isLast
computation, e2e
Existing interaction.test.ts moved off the now-bypassed runOidb mock
onto sendRawPacket mock (the new invokeOidb path routes there).
Workspace-wide typecheck: green. Workspace test totals:
proton 99/99, bridge 65/65, core 302/302, onebot 257/257
(1 pre-existing onebot suite failure, network-manager native addon
load — unrelated to this change.)