Commit Graph
45 Commits
Author SHA1 Message Date
DrSmoothl 056188d63d chore: resolve async SQLite merge conflicts
Integrate the current development branch, preserve the album comment fixes, and align the changes with repository formatting and dependency tooling.
2026-10-13 09:55:16 +08:00
手瓜一十雪 e699351f90 feat: adopt async SQLite and improve messaging and runtime diagnostics 2026-10-12 15:44:55 +08:00
DrSmoothl 7c0519f75f lint 2026-10-12 15:43:34 +08:00
DrSmoothl effeb66554 feat(bridge): connect when a pipe is already up after load fails
A failed load used to stay in the error state. If the pipe is already
listening, connect instead, and keep retrying that case on later checks.
2026-10-10 10:44:15 +08:00
DrSmoothl 7496479782 feat(core): record runtime status while waiting to sign in
Refresh the bundled runtime binaries.
2026-10-09 15:30:35 +08:00
DrSmoothl f95d247e73 feat(runtime): override deploy settings from the environment
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.
2026-10-06 00:48:34 +08:00
DrSmoothl d5f1e35c31 fix(core): close Bridges on exit without a fake offline 2026-08-17 00:08:21 +08:00
DrSmoothl 80f34ca720 test(bridge): cover injector lookup and QQ login port probe 2026-08-14 10:57:16 +08:00
DrSmoothl 870e9a5fbc fix(bridge): recover stale outbound sessions
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
2026-08-09 21:40:09 +08:00
DrSmoothl 4a0d14ce56 fix(bridge): keep account recovery non-interactive
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.
2026-08-03 12:54:48 +08:00
DrSmoothl 8526d0c2c5 fix(bridge): restore active sessions after delayed startup
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
2026-08-03 10:49:47 +08:00
DrSmoothl abe4ea80c8 fix(bridge): harden local transport handling
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.
2026-08-01 17:37:16 +08:00
DrSmoothl a67614fa09 feat(logging): complete runtime trace diagnostics
Complete byte-level diagnostics for network, upload, runtime lifecycle, authenticated write, and background operation paths.

Preserve streaming behavior, file-storage boundaries, and default log volume while making failures, retries, cancellation, and final outcomes independently diagnosable.

Add focused regression coverage for correlation, partial data, resource context ownership, and terminal-state accuracy.
2026-07-31 11:21:12 +08:00
DrSmoothl 0d14408b6f feat(protocol): add byte-complete packet tracing
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.
2026-07-30 20:11:02 +08:00
DrSmoothl f97444c391 fix(bridge): bound process operations 2026-07-29 02:15:13 +08:00
DrSmoothl 63abe98a84 fix(bridge): retry transient startup loading failures
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.
2026-07-26 16:04:57 +08:00
DrSmoothl d05bbe3551 fix(bridge): synchronize reconciled login state
Route recovered login state through the hook client before dependent services start. Add regression coverage to keep packet actions available after reconciliation.

Fixes #286.
2026-07-26 15:53:57 +08:00
DrSmoothl 3c2a402b8a test(bridge): make pipe-close regression deterministic
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.
2026-07-19 16:52:45 +08:00
DrSmoothl e8cb49ced9 feat(bridge): detect stalled receive paths
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.
2026-07-15 16:46:16 +08:00
DrSmoothl c90a3954e4 fix(bridge): self-heal login detection when the hook misses the edge
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.
2026-06-30 23:29:51 +08:00
DrSmoothl 37f768dd33 fix(bridge): isolate process enumeration so a blocked native call can't freeze the loop (#158)
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.
2026-06-28 23:30:10 +08:00
DrSmoothl 53f68b3945 feat(core): wire HookManager/BridgeManager edges into the StateBus
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.
2026-06-24 21:35:27 +08:00
DrSmoothl fb7675f3cb test(bridge): lock HookSession refresh drift fix
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.
2026-06-24 19:49:21 +08:00
DrSmoothl 1d2a56bcdb refactor(bridge): wire HookSession status through statusFor + shared reconcilers
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.
2026-06-24 19:42:59 +08:00
DrSmoothl 74c7e086c7 refactor(bridge): extract pure statusFor for HookSession status derivation
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).
2026-06-24 19:23:00 +08:00
DrSmoothl 6a2fd9e8f3 fix(bridge): don't crash the daemon when the hook pipe closes mid-send
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.
2026-06-07 22:15:07 +08:00
DrSmoothl f53df5e313 refactor(packages): extract @snowluma/bridge (hook + QQ lifecycle)
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).
2026-05-23 23:40:57 +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
DrSmoothl 98296fcec5 fix(send-like): update serialization to resolve user UID and adjust payload structure 2026-05-23 22:50:40 +08:00
DrSmoothl d8c72c2ebc fix(events): surface verify-text on group_invite ("你们好" → comment)
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.
2026-05-23 22:00:14 +08:00
DrSmoothl 8631453940 fix(events): self-heal group_invite cache pollution from pre-fix builds
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.
2026-05-23 18:40:01 +08:00
DrSmoothl a4909ec9e5 fix(events): resolve stranger nickname on group_invite (no more 未知昵称)
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.
2026-05-23 17:15:59 +08:00
DrSmoothl cd9403f27a fix(forward): align resid+uniseq for nested forwards
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.
2026-05-23 12:45:26 +08:00
DrSmoothl 5724f490b4 refactor(oidb): thread ctx into serialize/deserialize for self-contained namespaces
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.
2026-05-23 12:27:26 +08:00
DrSmoothl 9b4ff9c5e9 chore(lint): exempt oidb-services from no-namespace + clean stale directives
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.
2026-05-23 00:30:23 +08:00
DrSmoothl 521808a6eb refactor(oidb): migrate group-file.ts OIDB cmds to namespaces
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.
2026-05-23 00:22:59 +08:00
DrSmoothl 8d22868777 refactor(oidb): migrate group-admin.ts OIDB cmds to namespaces
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.
2026-05-23 00:06:40 +08:00
DrSmoothl 014b87c7ce refactor(oidb): migrate contacts.ts OIDB cmds to namespaces
6 OIDB cmds moved; pagination + per-group cache + inflight
coalescing stay on the facade because they need cross-call state.

  - FetchFriendListPage       (0xFD4_1) — facade drives the cursor loop
  - FetchGroupList            (0xFE5_2) — uinForm; verbatim config blob
  - FetchGroupMemberListPage  (0xFE7_3) — facade drives the token loop +
                                          owns the TTL cache + inflight
                                          coalescing that keeps busy
                                          OneBot clients from tripping
                                          Tencent risk-control
  - FetchUserProfile          (0xFE1_2) — uinForm; full property-key
                                          catalogue cross-checked against
                                          LagrangeV2 FetchStrangerService.cs
  - FetchGroupRequests        (0x10C0_1/_2) — dynamic subcmd via filtered flag
  - FetchDownloadRkeys        (0x9067_202) — uinForm; respHead.retCode
                                             check moved into deserialize

35 new namespace-level tests covering serialize / deserialize edge
cases (property decoding, sex enum mapping, avatar URL suffix,
empty body handling, pagination cursor behaviour).

Verification:
  - typecheck green
  - @snowluma/bridge: 30 files / 233 tests
  - @snowluma/core: 302 tests
Wire bytes unchanged.
2026-05-22 23:51:33 +08:00
DrSmoothl 80afa31f5c refactor(oidb): migrate extras.ts OIDB cmds to namespaces
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.
2026-05-22 23:45:13 +08:00
DrSmoothl 8356121518 refactor(oidb): migrate web.ts OIDB cmds to namespaces
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.
2026-05-22 23:40:17 +08:00
DrSmoothl 58e8081420 refactor(oidb): migrate misc.ts OIDB cmds to namespaces
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.
2026-05-22 23:37:41 +08:00
DrSmoothl f78431242c refactor(oidb): migrate friend.ts to namespaces
3 OIDB cmds moved to self-contained namespaces:
  - HandleFriendRequest (0xB5D_44) — digit-only flag still
                                     auto-resolves via resolveUserUid
  - DeleteFriend        (0x126B_0) — verbatim magic constants in the
                                     nested field2 sub-message
                                     (130/109/8/8/50 — server-side
                                     discriminator, MUST stay byte-
                                     equal to NTQQ wire)
  - SetFriendRemark     (0xB6E_2)

Cache invalidation (fetchFriendList after deletion) stays in the
facade — that side-effect needs `ctx.apis.contacts`, which namespaces
deliberately can't reach.

Tests migrated to assert against bridge.sendRawPacket. 15 new
namespace-level tests covering each invoke branch + serialize edge
cases.

Verification:
  - typecheck green
  - @snowluma/bridge: 15 files / 133 tests
  - friend.test.ts: 5/5
Wire bytes unchanged.
2026-05-22 23:33:22 +08:00
DrSmoothl a38f7ea26e refactor(oidb): migrate profile.ts OIDB cmds to namespaces
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.
2026-05-22 23:29:55 +08:00
DrSmoothl ff2b902cbd refactor(oidb): migrate interaction.ts remaining 3 cmds to namespaces
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.
2026-05-22 23:22:26 +08:00
DrSmoothl 0238bcb280 refactor(oidb): namespace-per-cmd service layer — vertical slice (3 cmds)
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.)
2026-05-22 23:15:20 +08:00