232 Commits
Author SHA1 Message Date
手瓜一十雪 3870052fb4 Simplify Hub channel management
Refactor Hub to treat each UIN as a single active Channel: channelsByUin_ now maps UIN -> Channel (no Set of IDs), primary selection logic and primaryByUin_/pickPrimary were removed, and addChannel replaces any existing channel for a UIN (disposing the previous one). Updated add/remove flows to update channelsById_ and channelsByUin_ accordingly and to bring cores online/offline in the simplified model.

Also: removed extensive JSDoc/comment blocks from channel/adapter/channel-ctx/core/hub files to slim the public API surface, reordered/cleaned exports in packages/channel/src/index.ts, added a new hub-events.ts placeholder, and cleaned up a test helper comment. Disposal of replaced channels is wrapped with a try/catch and logged at debug level.
2026-05-27 18:07:03 +08:00
手瓜一十雪 af8d5ab06b Split channel packages and rename Account→Core
Reorganize package layout and rename core/account types: - Move channel hook & socket sources/tests into packages/channel-hook and packages/channel-socket (many files renamed). - Remove ChannelManager from packages/channel and drop its export from package.json (multi-account host folded into Hub). - Update tests to use HookSink instead of BridgeManagerSink. - In @snowluma/core: move account/apis/* up to src/apis, rename account-context→core-context, account-interface→core-ctx, account→core, and add hub.ts; delete AccountManager. - Update constructors and type usages to accept CoreContext/CoreCtx and adapt identifiers accordingly. This is a refactor to clarify responsibilities (split transport implementations, fold multi-account manager into Hub, and rebrand the account layer as Core).
2026-05-27 14:11:45 +08:00
手瓜一十雪 adf0744f75 Rename bridge → channel and reorganize packages
Move/rename the transport/bridge code into a new @snowluma/channel layout and extract hook implementations into channel-specific packages. Many bridge sources were deleted and equivalent channel files added (adapter/ctx/interface/channel/hook/socket pieces moved/renamed), along with tests and configs relocated under packages/channel. RoadMap.md replaced with a detailed v3 architecture plan describing Channel/ChannelCtx/Core/CoreCtx/Hub boundaries and migration steps. Also apply related core package updates (account/core reshaping and package.json adjustments) to match the new architecture.
2026-05-27 13:39:17 +08:00
手瓜一十雪 652bafd75a Extract bridge transport into @snowluma/bridge
Move transport-layer bridge code out of @snowluma/core into a dedicated @snowluma/bridge package and introduce a clear account layer in core. Adds Bridge, BridgeInterface, InjectBridge, ProtocolBridge (stub), adapters, and manager files under packages/bridge and updates package.json exports. In @snowluma/core the former bridge surface is replaced by Account, AccountContext, AccountInterface and AccountManager; apis were moved/renamed to account/apis and now consume AccountContext. Adjustments include packet handling plumbing (use deliverPacket / setPacketHandler), updated sendRawPacket wiring, uploaded-file cache and lifecycle changes (InjectBridge.dispose, ProtocolBridge returns a structured not-implemented result). Tests and imports were updated accordingly.
2026-05-27 12:27:13 +08:00
手瓜一十雪 5440cbc37e Refactor bridge system: adapters & transports
Introduce a transport-adapter layer and refactor the bridge abstractions to be transport-agnostic. Added BridgeAdapter and BridgeAdapterHost (packages/core/src/bridge/adapter.ts) plus concrete InjectBridge/InjectBridgeAdapter and ProtocolBridge/ProtocolBridgeAdapter implementations (new files). Made Bridge an abstract base exposing kind/id and abstract sendRawPacket, moved transport-specific PID/sender logic into InjectBridge. Reworked BridgeManager to register/start adapters, manage bridges by id/uin, pick a primary per-UIN (inject preferred over protocol), and provide a host API for adapters to add/remove bridges. Updated index bootstrapping to register adapters and wire the WebUI to the inject adapter's HookManager. Adjusted tests and OneBot code to remove PID tracking from OneBotInstance/OneBotManager and to instantiate InjectBridge in tests. Also exported new entry points in package.json for adapter/bridge modules. This decouples transports from manager logic and adds a stubbed protocol runtime for future pure-protocol support.
2026-05-27 11:49:43 +08:00
手瓜一十雪 2c565fbd8d Switch from @snowluma/sqlite to node:sqlite (sync)
Remove the in-tree @snowluma/sqlite wrapper and vendored better-sqlite3 prebuilts; migrate consumers to node:sqlite's synchronous API. Replaced imports and types (Database/Statement -> DatabaseSync/StatementSync), updated PRAGMA calls to use exec, and adjusted code in onebot (media/message/reaction stores) and protocol (identity service). Vite config no longer bundles sqlite native binaries and references to the sqlite workspace were removed. Also delete the packages/sqlite package and its native prebuild artifacts, and add a couple of unrelated AST improvements in proton: use a module-level shared wrapper cache and add an inheritance cache to avoid repeated scans. Minor import reordering/cleanup applied where needed.
2026-05-26 21:59:26 +08:00
DrSmoothl 5924346823 chore(release): v1.9.2 2026-05-24 18:01:50 +08:00
手瓜一十雪 456086599c Normalize imports, typings and formatting
Reorder and normalize imports, add missing type imports, and fix whitespace/formatting across multiple files. Notable updates: eslint.config.ts import ordering; core bridge APIs (group-album, misc, web) now import Json types; tests/highway pipeline import ordering and added type imports; protocol changes (element-builder, highway client/pipeline) reorganized imports and added proto type imports; many oidb service modules moved protobuf encode/decode imports for consistency; web group modules (album, essence, honor, notice, request-util) reordered imports, adjusted typings/indentation, and small whitespace fixes. These edits are stylistic and typing improvements and should not change runtime behavior.
2026-05-24 13:39:35 +08:00
手瓜一十雪 80ee12ecc2 Enforce no-explicit-any; allow in tests
Enable @typescript-eslint/no-explicit-any as an error in the ESLint config to tighten typing across the codebase, and add an override to disable this rule for test files. Also apply non-functional cleanup in packages/proton/src/runtime.ts: reorder/imports and adjust formatting/indentation and string alignment for readability — no behavioral changes.
2026-05-24 13:38:13 +08:00
手瓜一十雪 acc5084647 Refactor group-album and remove eslint ignore
Remove an unused Bridge import, the asBridge helper and a stale comment from group-album, and relocate the GroupAlbumMediaResult and GroupAlbumCommentResult interfaces to the end of the file. In the sqlite package, remove the inline eslint-disable-next-line for @typescript-eslint/no-explicit-any near the BetterSqlite3 constructor. These edits clean up dead/stale code and re-enable the lint rule; there are no behavioral changes.
2026-05-24 13:16:36 +08:00
手瓜一十雪 a49e507e15 Refactor Bridge APIs, slim Bridge and reorder imports
Move and standardize API implementations under bridge/apis, slim down the Bridge class (remove inline action implementations, caches and large comment blocks) and relocate related types/exports. Clean up and reorder imports across many bridge/api modules, normalize constructors, add logger imports where needed, and adjust WebUI routing/import ordering. These changes centralize API logic, reduce Bridge surface area, and tidy module boundaries and imports.
2026-05-24 13:13:23 +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 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 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 4755a91cb3 fix(image): use Chinese summary defaults to match QQ ecosystem
Mobile QQ, Lagrange.Core, and NapCat all use the literal Chinese
bubble-preview strings `[图片]` / `[动画表情]`. SnowLuma was emitting
English `[image]` / `[sticker]` as fallback, so QQ clients showed
"[image]" in chat-list previews instead of the expected Chinese.

Cross-checked against:
  dev/NapCatQQ/.../packet/message/element.ts:367-371
  dev/Lagrange.Core/.../Message/Entity/ImageEntity.cs:186-192

Both branch the same way: subType=1 → `[动画表情]`, else → `[图片]`.

Changes:
  - `bridge/highway/image-upload.ts`: both code paths (fingerprint +
    load-from-source) now emit the Chinese defaults. Subtype branch
    flipped to `subType === 1 ? '[动画表情]' : '[图片]'` to match the
    upstream convention (our old check was `subType === 0` →
    `[image]`, which would label any non-zero subType — not just
    stickers — as `[sticker]`).
  - `bridge/msg-push/rich-body-decoder.ts`: all three decode paths
    (NotOnlineImage / CustomFace / commonElem image) now substitute
    the Chinese default when the server omits `textSummary`. Empty-
    string is also treated as missing (matches NapCat's `=== ''`).
  - Tests updated to assert the Chinese defaults; new test pins
    that a caller-supplied summary still overrides verdef.

The OneBot caller's `summary` field on the MessageElement still
wins when set, so downstream clients passing custom bubble text
(e.g. for accessibility) keep working.
2026-05-23 15:05:28 +08:00
DrSmoothl c8f259ef4a test(highway): pin multi-image sequential semantics + richer 921 diag
User reported `highway upload error_code=921` when sending two images
in one OneBot message — image 1 fast-uploaded (server already had the
md5), image 2 (58 bytes) triggered the actual PUT and failed.

Verified the claim from code inspection vs NapCatQQ + Lagrange.Core:

  - dev/NapCatQQ/.../highwayContext.ts:62-94 (checkAvailable + prepareUpload)
  - dev/NapCatQQ/.../highwayHttpUploader.ts:8-43 (HTTP block upload)
  - dev/NapCatQQ/.../highwayContext.ts:219-254 (uploadGroupImage)
  - dev/Lagrange.Core/.../HighwayContext.cs:98-180 (UploadSrcByStreamAsync)

Findings:

  1. The pipeline runs `runNtv2Upload` sequentially per image (await
     inside `buildSendElems`); there's no concurrency or shared state.
  2. Each call gets its own OIDB request, its own lazy session fetch,
     its own buildHighwayExtend output. No aliasing.
  3. The 921 comes from the highway PUT's response head (errorCode),
     not from anything client-emitted being malformed.

Verdict: not reproducible from code inspection — almost certainly
server-side (most likely Tencent rejecting the 58-byte image as a
malformed payload; 58B is too small to even be a valid 1×1 PNG).

Hardening:

  - Three regression tests pin the sequential two-image semantics so
    a future state-leak bug fails loudly:
      - extend bytes for image 2 must come from image 2's response,
        not image 1's,
      - each runNtv2Upload fetches its own non-null session,
      - each OIDB request carries its own fileInfo + a unique
        clientRandomId.
  - The `error_code=N` message now carries segRetCode, cmdId, chunk
    size, offset, and the first 8 bytes of the file md5 — without
    these the user couldn't tell apart "image rejected" vs "session
    expired" vs "rate-limited" on future reports.

If the user reports the same 921 again, the new diagnostic message
should show enough server-side context to triage server-vs-client.
2026-05-23 13:06:09 +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 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
DrSmoothl c3675aef7c feat(emoji-like): back fetch_emoji_like / get_emoji_likes with a local cache
Two rounds of production wire-level probing established that the OIDB
SSO path cannot surface the reactor user list for an emoji:

  - 0x9083_1                  → 4-byte ack `18 01 20 01` (no list)
  - 0x9084_1                  → per-emoji summary (count + timestamp,
                                still no user IDs)
  - 0x9082_3..5, 0x9083_0/2/3, 0x9084_2..5, 0x9085_1/2, 0x9087_1
                              → "no privilege" — the cmds exist but
                                aren't in NTQQ's client capability
                                bitmap, so the server blocks anything
                                not registered at login. NTQQ itself
                                serves getMsgEmojiLikesList from a
                                wrapper-internal cache, not SSO.

Mirror that wrapper-cache approach on the OneBot side. SnowLuma
already receives GroupMsgEmojiLikeEvent push events for every
reaction add/remove (with operator uin + uid + emoji + msgSeq); we
just weren't persisting them. Now we do.

Changes:

* proto-defs: add Oidb0x9084 Req/Resp schema for the summary call.
  RespEntry's lastReactionTime/count are optional because the
  "available reactions catalog" tail entries omit both — only the
  "used" subset carries metadata.

* core InteractionApi: revert the 9-candidate probe loop, restore a
  single 0x9083_1 call as a legacy stub (returns empty list, with a
  debug-level hex dump on empty responses), and add
  fetchReactionSummary() that calls 0x9084_1 and yields the per-emoji
  summary (count + last-reaction timestamp).

* onebot ReactionStore: new SQLite-backed cache, schema
  reactions(group_id, msg_seq, emoji_id, emoji_type, operator_uin,
  operator_uid, set_at), PK on (group_id, msg_seq, emoji_id,
  operator_uin). Mirrors MessageStore design — prepared statements at
  construction, WAL journal.

* onebot event-pipeline: subscribe group_msg_emoji_like, call
  reactionStore.recordAdd / recordRemove before convertAndDispatch so
  a fetch_emoji_like racing with the push still sees the new row.

* onebot instance / instance-context: own the ReactionStore, expose
  it on OneBotInstanceContext, close it on dispose. New
  fetchEmojiLikeUsers helper on ApiActionContext combines local-cache
  reads with a 0x9084_1 cross-check so callers can detect cache gaps
  (events missed before bot boot, etc).

* onebot actions: get_emoji_likes and fetch_emoji_like now read from
  the cache. fetch_emoji_like's NapCat-style cookie becomes a numeric
  offset for cache pagination (no real cursor needed against local
  SQLite).

* tests: 7 new ReactionStore unit tests (CRUD + pagination +
  cross-key isolation + summarizeMessage). Existing event-pipeline
  test harness picks up a no-op ReactionStore stub.
2026-05-22 22:08:45 +08:00
DrSmoothl 65636953e7 chore(interaction): round-2 cmd probe — focus on 0x9084 family
Round-1 wire dump revealed:
  - 0x9083_1 returns a 4-byte ack (`18 01 20 01`), no user data
  - 0x9082_3/_4, 0x9083_0/_2/_3 all return "no privilege" (server-side
    deny linked to iwiki.woa.com permission docs)
  - 0x9084_1 returns 932 bytes — a real reaction *summary* on the
    message: first entry carries (timestamp, count, type, emoji_id)
    for the used emoji ("76" with count=3 in our test), followed by
    a 28-entry catalog of available reaction emojis. NOT a user list,
    but proves 0x9084 family has live handlers.

The "fetch users who reacted with emoji X" sibling is almost
certainly another subcmd of 0x9084. Probe the immediate neighborhood:

  0x9084_2..5   first-choice candidates (sibling subcmds)
  0x9085_1..2   exploratory adjacent cmd
  0x9086_1, 0x9087_1  wider probe
  0x9084_1      kept at end so we still capture the summary baseline
2026-05-22 21:38:13 +08:00
DrSmoothl 7e3e833f84 chore(interaction): probe 7 OIDB cmd candidates per fetch_emoji_like call
Replace the env-driven probe with an unconditional round-robin across
the candidate list every fetch_emoji_like invocation:

  0x9083_1  baseline (returns 4-byte ack, known not to be fetch)
  0x9082_3  sibling of set/unset reaction
  0x9082_4
  0x9083_0
  0x9083_2
  0x9083_3
  0x9084_1  exploratory

For each candidate the cmd, response length, parsed user count,
request hex, and response hex are dumped at debug level. The first
candidate that surfaces users wins and is returned; failures inside
runOidb (server-side error code, no handler) are caught + continued
so a bad cmd in the middle doesn't tank the whole probe.

fetch_emoji_like is user-triggered ("view who reacted"), so the
extra SSO traffic on each call is acceptable for a debug build —
we only need one production hit to identify the real cmd.
2026-05-22 21:13:37 +08:00
DrSmoothl a24272b25a chore(interaction): env-driven cmd/subcmd override for fetch_emoji_like probing
Wire dump from production confirmed 0x9083_1 is a real server endpoint
(routed, errorCode=0, trace string echoes "OidbSvcTrpcTcp.0x9083_1")
but its reply is always a 4-byte minimal ack `18 01 20 01` regardless
of message / emoji / whether the caller just set a reaction — so the
semantics of 0x9083_1 are not "fetch emoji-like users". The real cmd
must be a sibling (0x9082_3, 0x9082_4, 0x9083_2, 0x9083_3, …) but we
don't yet know which.

Rather than rebuild for each candidate, accept an env override
`SNOWLUMA_EMOJI_FETCH_CMD=0x9082_3`. Format is `0xNNNN_N`; a malformed
value warns and falls back to the historic 0x9083_1. Wire name is
recomputed from the parsed (cmd, subcmd) so the trpc routing matches
the OIDB envelope cmd field.

Also unconditionally dump req/resp hex at debug level while we're
probing — once we land on a working tuple the dump can be tightened
back to "only on empty users". The pre-existing log line is widened
to include the wire name and the result count so deployment can scan
multiple cmd values from a single log stream.
2026-05-22 21:10:39 +08:00
DrSmoothl 2638d13d11 feat(profile): surface QQ level from OIDB 0xFE1_2 number-property 105
`fetchUserProfile` already requested key 105 in the keys[] array but
the decode pass never read it, so callers got nothing. NapCat exposes
the same value as `qq_level` on `get_stranger_info`; users coming from
NapCat noticed the field was missing on the SnowLuma side.

Cross-checked against LagrangeV2 Internal/Services/System/
FetchStrangerService.cs which explicitly labels key 105 = Level and
uses `numbers[105]` directly.

Changes:
- UserProfileInfo: add `level: number`.
- fetchUserProfile decode: `info.level = numMap.get(105) ?? 0`.
- getStrangerInfo: emit both `qq_level` (NapCat-compat) and `level`.
- Test/manager call sites updated for the new required field.
2026-05-22 19:02:05 +08:00
DrSmoothl ce78a3bbdf fix(oidb): update sequence type in Oidb0x9083Req to uint_64 for compatibility 2026-05-22 08:09:29 +08:00
DrSmoothl 737b121dfe fix(oidb): correct 0x9083 field layout so fetch_emoji_like returns users
Two bugs collapsed fetch_emoji_like into a perma-empty list with status=ok
and no error output:

1. Req fields 4 and 5 were swapped (emojiType on field 4 / emojiId on
   field 5). The sibling 0x9082 OidbGroupReaction has field 4 = code
   (string) and field 5 = type (uint), and 0x9083 mirrors that layout —
   verified against LagrangeDev/LagrangeV2
   `Internal/Packets/Service/SetGroupReaction.cs`. With our swap the
   wire types also mismatched (varint vs length-delim), so the server
   protobuf decoder dropped both fields and returned an empty list
   without ever surfacing an error.

2. Resp `inner.userInfo` was declared as a single message, but the
   server emits one entry per liker (repeated). Even after fixing the
   req, multi-user responses would have collapsed into the last entry.

Map the decoded array on the InteractionApi side and filter uin=0
placeholders. Test now mocks two users to exercise the repeated path.
2026-05-21 22:10:33 +08:00
DrSmoothl ee0f159b7e fix(deps): drop dead hono devDeps from @snowluma/onebot, align core to ^4.12.18
`packages/onebot` declared `hono` and `@hono/node-server` as devDeps but
never imported either — they were leftover scaffolding from when onebot
was carved out of `@snowluma/core`. Combined with dependabot's hono
bump only landing on core's pin (4.12.14 → 4.12.18 in cb06624), this
left two distinct peer-dep resolutions of `@hono/node-server` in the
lockfile (`(hono@4.12.14)` for onebot, `(hono@4.12.18)` for core).
On the dev→main merge the auto-resolver kept main's snapshot list,
dropping the `(hono@4.12.14)` variant onebot still referenced — so
v1.9.1 shipped with an inconsistent lockfile and CI's
`pnpm install --frozen-lockfile` blew up with
`ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY: no entry for
'@hono/node-server@1.19.14(hono@4.12.14)'`.

Fix:
- Remove `hono` + `@hono/node-server` from onebot (verified zero source
  imports — only matches were `honour` / `WebHonorType` false hits).
- Bump core's `hono` pin to `^4.12.18` so dev matches what dependabot
  already landed on main (otherwise the drift comes back on next merge).
- Regenerate pnpm-lock.yaml — now resolves to a single
  `@hono/node-server@1.19.14(hono@4.12.21)` snapshot.
2026-05-21 17:43:10 +08:00
DrSmoothl 607a7b319b chore(release): v1.9.1 2026-05-21 17:33:35 +08:00
DrSmoothl 5f1d4400dc fix(build): inject createRequire banner so bundled CJS requires resolve
better-sqlite3's JS wrapper is pure CJS — its `require('fs')` /
`require('path')` / `require('bindings')` calls get inlined into the
ESM bundle as `__require("fs")` shims by rolldown. The shim's
fast-path checks `typeof require !== "undefined"` (true in CJS, false
in our `.mjs` output) and otherwise throws "Calling `require` for
\"fs\" in an environment that doesn't expose the `require` function".
The launcher crashed at startup the moment `@snowluma/sqlite` resolved
its `import BetterSqlite3 from 'better-sqlite3'`.

Inject `const require = createRequire(import.meta.url)` via
`rollupOptions.output.banner` so every inlined CJS require routes to
Node's CJS loader. No call-site changes; the only diff in the emitted
bundle is two new top-of-file import/const lines.
2026-05-21 14:04:14 +08:00
DrSmoothl 4e51b4bd7d build(sqlite): wrap better-sqlite3 with @snowluma/sqlite + vendor prebuilts
Replaces "runtime npm dependency that gets `npm install`-ed into
dist/" with "TS wrapper that loads the native `.node` from
`dist/native/` like every other native binding in the project".

Why the change
--------------
The previous approach (better-sqlite3 declared in
packages/runtime/package.json, materialized by `npm install
--omit=dev` in dist/) worked but produced an artifact that needed
runtime npm install — a step the rest of SnowLuma's native bindings
don't need (websocket / snowluma-hook / ffmpeg are all standalone
`.node` files copied into `dist/native/` by vite-plugin-cp). The
asymmetry was friction: dev mode required `pnpm dist:install`,
build artifacts carried a `dist/node_modules/` tree the other
modules didn't need, and Docker images had to special-case the
glibc-vs-musl ABI lookup that prebuild-install runs.

The new shape:
  - `@snowluma/sqlite` (packages/sqlite/) — TS wrapper. Has
    `better-sqlite3` as a devDep (for the JS-layer types + the
    actual JS wrapper code that vite inlines). At runtime, calls
    `createRequire().require()` against
    `<dist|repo>/native/better-sqlite3-v<abi>-<platform>-<arch>.node`
    and passes the resolved addon to `new Database(path, {
    nativeBinding })`.
  - `packages/runtime/native/better-sqlite3-v<abi>-<triplet>.node`
    — prebuilt binaries vendored into the repo. Two ABIs ship side-
    by-side (v127 = Node 22 LTS used by CI, v137 = Node 24 used by
    dev boxes) so `process.versions.modules` picks the matching one
    at load time.
  - `tools/fetch-sqlite-prebuilts.mjs` — fetches all 5 platforms ×
    N ABIs from WiseLibs's GitHub releases. Idempotent: existing
    `.node` files skip. Accepts extra ABI args:
    `node tools/fetch-sqlite-prebuilts.mjs 12.10.0 v127 v137 v115`.

What was unwound
----------------
- packages/runtime/package.json — dependencies removed (runtime
  artifact is now self-contained, no `npm install` step needed).
- root package.json — `dist:install` script removed (no node_modules
  to install).
- .github/workflows/dev-build.yml — `Bundle runtime dependencies`
  step removed.
- .github/workflows/release.yml — kept the `Bundle runtime
  dependencies` step (was a no-op already, but harmless safety net
  in case a future runtime dep shows up).
- packages/core/vite.config.ts — `better-sqlite3` no longer
  external; vite inlines the JS wrapper (+~33 KB to bundle, ~2.84
  MB → 2.87 MB total).

Naming
------
Mirrors the existing `websocket-${triplet}.node` convention (flat
dir under `runtime/native/`, dash-separated `<platform>-<arch>`).
Adds a `v<abi>` segment because better-sqlite3 is NOT N-API — one
binary per Node ABI. Naming:

  better-sqlite3-v127-darwin-arm64.node    Node 22 / macOS arm
  better-sqlite3-v127-darwin-x64.node      Node 22 / macOS intel
  better-sqlite3-v127-linux-arm64.node     Node 22 / Linux arm
  better-sqlite3-v127-linux-x64.node       Node 22 / Linux x64
  better-sqlite3-v127-win32-x64.node       Node 22 / Windows x64
  better-sqlite3-v137-... (5)              Node 24 / same 5 hosts

Total: 10 binaries × ~1.9 MB = ~19 MB committed. Aligned with the
existing native-vendoring footprint (websocket ~2 MB × 5, snowluma-
hook ~10 MB × Windows + Linux, ffmpeg ~10-30 MB × 4).

What changes for consumers
--------------------------
Source-side: nothing. `import Database, { type Database, type
Statement } from '@snowluma/sqlite'` is a drop-in for the previous
`import Database from 'better-sqlite3'`. The wrapper re-exports the
same types and the `Database` callable factory has the same
overload set.

Runtime: `node` finds the binding at
`<dist|repo>/native/better-sqlite3-v${process.versions.modules}-
${process.platform}-${process.arch}.node`. If the ABI doesn't
match a vendored binary, the wrapper throws a clear error pointing
at `tools/fetch-sqlite-prebuilts.mjs` with the missing ABI/triplet.

Bumped better-sqlite3 from 11.10.0 → 12.10.0 because v11 only had
prebuilt up to Node ABI v127 (no v137 = no Node 24). v12.10.0
ships both. Both versions are API-compat (the breaking changes
between v11 and v12 were either internal or in error-handling
paths SnowLuma doesn't exercise).

Verification
------------
- pnpm -r typecheck (12 projects): clean
- @snowluma/core test: 302/302
- @snowluma/onebot test: 250/250 (552/552 combined, unchanged from
  the perf-series baseline)
- Bench numbers preserved — wrapper indirection is sub-μs:
  storeMeta ~11 μs, storeEvent ~17 μs, findMeta hit ~1.1 μs (all
  within noise of the post-RoadMap-#5 measurements).
- Vite build clean, bundle = 2.87 MB / 230 KB gzip
- `dist/index.mjs` no longer contains a `node:sqlite` import (it
  did before — media-store hadn't migrated; now neither it nor
  message-store reach into the Node builtin).

Bonus side-fix: `packages/onebot/src/media-store.ts` also moved
off `node:sqlite` (caught by the post-build bundle grep — the
file was using `DatabaseSync` + `StatementSync` from the builtin
even though it had already been written to cache its prepared
statements). Its hot paths now go through the same wrapper.
2026-05-21 13:49:19 +08:00
DrSmoothl 0b6541ca36 perf(sqlite): swap node:sqlite for better-sqlite3 + cache prepared stmts
Replaces the experimental `node:sqlite` builtin with the stable
`better-sqlite3` wrapper across the two hot SQLite users — the
OneBot message-store and the bridge identity-service. While the API
surface is essentially identical (sync, prepared-statement-driven,
`Database`/`Statement` shape), better-sqlite3 (a) doesn't carry the
"ExperimentalWarning: SQLite is an experimental feature" baggage
that ships with `node:sqlite`, (b) has a more mature C++ binding
that consistently outperforms the builtin by ~30-50%, and (c)
encourages reusing `Statement` objects by design — exactly the win
the second half of this commit is after.

Why both at once
----------------
Caching prepared statements (was P1 in the RoadMap-#5 analysis) is
the largest single optimization opportunity on the SQLite path. The
old shape was the textbook SQLite anti-pattern:

  // every call re-parsed the SQL
  this.db.prepare(BIG_SQL_STRING).run(...);

Each `prepare(sql)` runs the full SQL parser + planner — for simple
inserts/updates/selects that dominates wall-clock cost. Doing both
the driver swap AND the statement caching in one commit lets the
benchmark numbers attribute the combined effect honestly; splitting
them would have double-charged the same disruption budget.

What changed code-wise
----------------------
**messageStore** — 8 distinct SQL templates, all now `readonly`
fields prepared once in the constructor:
  - stmtStoreEvent      — INSERT/UPDATE event row + JSON payload
  - stmtStoreMeta       — INSERT/UPDATE meta-only row
  - stmtFindEvent       — single-row SELECT by message_hash
  - stmtFindMeta        — single-row SELECT (full meta columns)
  - stmtResolveReplyGroup, stmtResolveReplyPrivate
                        — 2 variants for the reply-id lookup
  - stmtListEventsAnchored, stmtListEventsLatest
                        — 2 variants for /get_history_msg

**identity-service** — 25+ distinct statements. Used a Map-based
`pstmt(sql)` memoization helper rather than enumerating all 25 as
named fields (call sites stay readable; Map lookup is O(1) hash +
string-eq, negligible next to the SQL parse it replaces). The
helper short-circuits to the cached `Statement` on second-and-later
calls.

API differences from node:sqlite (handled in this commit):
  - `import { DatabaseSync } from 'node:sqlite'`
       → `import Database from 'better-sqlite3'`
  - `new DatabaseSync(path)` → `new Database(path)`
  - `db.exec('PRAGMA journal_mode = WAL')`
       → `db.pragma('journal_mode = WAL')`
  - `db.exec('BEGIN' / 'COMMIT' / 'ROLLBACK')` — kept as-is; both
       drivers accept raw `exec()` for transaction control. The
       `db.transaction()` higher-order wrapper is more idiomatic
       but the existing manual control already works and changing
       it would invalidate the `transaction()` helper's matched
       try/catch shape.

vite build wiring:
  - `node:sqlite` removed from the externals list
  - `better-sqlite3` added — must stay external because it has a
    native binding (`require('./build/Release/better_sqlite3.node')`)
    that Vite can't bundle. Vendoring the prebuilt `.node` into
    `packages/runtime/native/` is a follow-up packaging task; the
    workspace `pnpm install` already places the binding under
    `node_modules` so dev + tests work today.

Bench results (RoadMap #5 baseline → after)
-------------------------------------------
@snowluma/bridge — recv pipeline (with SQLite identity writes):
  group_member_join         17.5 μs/op → 4.7 μs/op   (~3.7x faster)
  group_message text         0.42→0.42 μs            (no path change)
  group_message mixed        0.67→0.67 μs            (no path change)

@snowluma/onebot — send pipeline:
  messageStore.storeMeta    17.0 μs/op → 10.9 μs/op  (~1.6x)
  messageStore.storeEvent   23.2 μs/op → 18.2 μs/op  (~1.3x)
  messageStore.findMeta hit  5.4 μs/op →  1.3 μs/op  (~4x)
  messageStore.findMeta miss 3.7 μs/op →  0.7 μs/op  (~5x)
  messageStore.findEvent     3.8 μs/op →  1.7 μs/op  (~2.3x)

Read paths gained the most because they're parse-bound (small SQL
+ short execution → the parser was most of the cost). Writes gained
less because the fsync/journal overhead on each commit still
dominates the parse savings. Combined send hot path (storeMeta +
storeEvent, called on every outbound message + the /get_msg-fix
self-event store) drops from ~40 μs to ~29 μs — about a 27%
reduction per message sent.

Verification
------------
- pnpm -r typecheck: all 11 projects clean
- @snowluma/onebot test suite: 250/250
- @snowluma/core test suite: 302/302
- Combined: 552/552 (unchanged baseline)
- Vite build (with stubbed native binary): clean, 2.84 MB / 223 KB
  gzip (matches pre-Phase-2 envelope after removing the better-
  sqlite3 inline-bundle attempt — externalizing it shaves the
  ~30 KB regression we saw mid-flight)

Follow-up (next commits in this series)
---------------------------------------
- Skip rememberGroupMemberIdentity DB write when the (uid, uin,
  nickname, card) tuple is unchanged in the in-memory cache.
- Drop the `Buffer.from(pkt.body)` allocation in msg-push context.
- Synchronous fast-path for elementsToJson when no async resolver
  is wired.
- Vendor `better-sqlite3` prebuilt `.node` into the runtime/native
  layout for the production package step.
2026-05-21 10:34:37 +08:00
DrSmoothl 070e39090a fix(hook): retry stranded 'disconnected' sessions when pipe is still up
Windows users reported "running a while then stops" with logs:
  WARN  [Hook]   pipe error: PID=1312 err=read EPIPE
  DEBUG [Bridge] session closed: UIN=752645868
  INFO  [OneBot] session closed: UIN=752645868

The user-side socket read fails with EPIPE (idle-timeout, transient
Windows named-pipe fault, or similar). The cascade:

  1. hook-client's `socket.on('error')` → emits 'error'
     → hook-session logs "pipe error: PID=… err=read EPIPE"
  2. socket then emits 'close' → bindClient's close handler runs
     `tearDownClient()` and sets status to 'disconnected' (because
     wasLoggedIn = true)
  3. PipeWatcher's next tick polls `listLivePipes()` natively — and
     the OS-level named pipe is STILL listed (DLL is still resident
     in QQ.exe, it never closed its end). So no `pipe-down` →
     `pipe-up` cycle ever fires.
  4. hook-manager's `tick` reconcile loop only retried sessions
     whose status was exactly 'connecting' (line 208 in
     hook-manager.ts). A 'disconnected' session that should
     reconnect was simply ignored.

Result: the session was stranded forever; QQ kept running, DLL
kept running, but SnowLuma never re-established its socket. The
only recovery was a full SnowLuma restart.

Fix: include `'disconnected'` in the retry predicate. `onPipeUp` is
idempotent (it checks `isClosed` / `!connected` internally and rebuilds
the socket pair from scratch) so we can safely call it from the
tick loop every ~1.5s until either the reconnect lands or the
watcher reports a genuine pipe-down.

No behavior change for sessions already in any other state — only
the 'disconnected' branch gains the auto-retry.
2026-05-21 10:22:38 +08:00
DrSmoothl 9a30df9907 refactor(onebot): extract OneBot protocol translation into @snowluma/onebot
Phase 3 of the 4-package split (RoadMap #3). Moves the entire
`packages/core/src/onebot/` subtree (43 source files + 20 test files
spanning network adapters, action handlers, event converters, message
parser, message store, media store, instance + manager) into the new
`@snowluma/onebot` workspace package. The remaining `@snowluma/core`
now hosts only the api-layer (Bridge class + 13 Api classes), the
hook tooling, the WebUI server, and the runnable `index.ts`.

Why
---
- The OneBot adapter is the public-facing OneBot11 protocol surface;
  it has its own version cadence and its own consumers (third-party
  bots talking OneBot WS / HTTP). Splitting it lets that cadence move
  independently of the Api-on-ctx layer.
- The two were sharing the `packages/core/` build pipeline only
  because they happened to live in the same directory. Nothing in
  the api-layer reaches into OneBot internals.
- Mirrors NapCat / Lagrange.OneBot's repo split (their OneBot adapter
  is a separate assembly from the Core protocol library).

Layout
------
Moved via `git mv` (history preserved):
  packages/core/src/onebot/*  → @snowluma/onebot/src/<x>
  packages/core/tests/onebot/* (6 files)   → @snowluma/onebot/tests/
  packages/core/tests/{message-parser,message-store,event-pipeline,
    media-store,media-url-resolver,event-converter,
    http-server-hot-reload,http-server-content-type,
    api-handler-logging,message-id,group-msg-emoji-like,
    config,media-indexer,network-manager}.test.ts (14 files)
                                              → @snowluma/onebot/tests/

What stays in @snowluma/core
----------------------------
- `bridge/{bridge,bridge-interface,bridge-context,manager}.ts`
- `bridge/apis/*` (the 13 typed Api classes)
- `hook/*` (debugger/injector — tooling for the core protocol layer)
- `webui/*` (admin UI server — application wiring, not the OneBot
  adapter itself)
- `index.ts` (application entry that wires BridgeManager +
  OneBotManager + HookManager + WebUI together)

Changes
-------
- New package `packages/onebot/` (package.json with subpath exports
  mirroring legacy `core/src/onebot/<x>` 1:1, tsconfig.json,
  vitest.config.ts with `SNOWLUMA_LOG_LEVEL=debug` env so the action-
  dispatch logging tests see debug-level subscribers — copied verbatim
  from core's vitest.config.ts).
- @snowluma/core grows new subpath exports for the api-layer files
  OneBot's type-only imports need: `./bridge`, `./bridge-interface`,
  `./bridge-context`, `./manager`, `./apis`.
- `@snowluma/onebot` depends on `@snowluma/bridge`, `@snowluma/common`,
  `@snowluma/core`, `@snowluma/proto-defs`, `@snowluma/proton`,
  `@snowluma/websocket`.
- `@snowluma/core` depends on `@snowluma/onebot` (for index.ts +
  webui/server.ts wiring).
- 100+ import sites rewritten via sed:
  * OneBot → core: `'../bridge/bridge'` → `'@snowluma/core/bridge'`,
    `'../bridge/manager'` → `'@snowluma/core/manager'`,
    `'../bridge/bridge-interface'` → `'@snowluma/core/bridge-interface'`,
    `'@/bridge/bridge'` (vite path alias) → `'@snowluma/core/bridge'`.
  * Core → OneBot: `'./onebot/manager'` → `'@snowluma/onebot/manager'`,
    `'../onebot/config'` → `'@snowluma/onebot/config'`, etc.
  * Test relative paths: `'../../src/onebot/<x>'` → `'../src/<x>'`
    after the test file move (one level shallower).
- New `packages/onebot/src/env.d.ts` declaring `__APP_VERSION__` and
  `__BUILD_WEBUI__` (the vite-injected globals `info.ts` reads).

Workspace cycle
---------------
core ↔ onebot is allowed by pnpm workspaces. The runtime direction
is one-way: core → onebot (index.ts constructs OneBotManager,
webui/server reads OneBot config). OneBot only reaches into core for
TYPES (Bridge, BridgeInterface, BridgeManager, ClientKeyInfo,
DownloadRKeyInfo); those imports use `import type` so they compile
out at runtime and don't introduce a real cycle.

Verification
------------
- Full workspace `pnpm -r typecheck`: all 11 projects clean.
- `@snowluma/core` test suite: 302/302 pass (33 test files).
- `@snowluma/onebot` test suite: 250/250 pass (20 test files;
  pre-existing `network-manager.test.ts` native-addon failure
  unchanged — same baseline as before the split).
- Combined: 552/552 — identical pass count to the pre-Phase-3
  baseline.
- Vite build with stubbed native binary: clean, 2.84 MB bundle /
  224 KB gzip (identical envelope).
2026-05-21 08:52:00 +08:00
DrSmoothl f7a03ca05a refactor(bridge): extract protocol layer into @snowluma/bridge package
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`.
2026-05-21 08:41:34 +08:00
DrSmoothl 46b302f738 refactor(common): extract leaf utilities into @snowluma/common package
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.
2026-05-21 08:27:05 +08:00
DrSmoothl e7afef5d63 fix(proto): correct OidbGroupReaction field numbers (off-by-one)
`set_msg_emoji_like` was failing with
"invalid ReqBody.EmojiType: value must be greater than 0" because
`OidbGroupReaction` had every field number off by one — `type` was at
field 4, but the server reads `EmojiType` at field 5. The mistake
covered all four fields:

  field   what       was   should be
  ─────   ────────   ───   ─────────
  2       groupUin    1    2
  3       sequence    2    3
  4       code        3    4
  5       type        4    5

Cross-checked with Lagrange.Core V2's `OidbSvcTrpcTcp0x9082.cs` —
their definition starts at field 2 and runs through 7. The OIDB
envelope wraps the body at field 4, so the inner body's own fields
must start at 2 to avoid collision with the wrapper. (The previous
1..4 layout happened to encode SOMETHING wire-valid, just not what
the server's `OidbSvcTrpcTcp0x9082` validator expected.)

Also added the trailing `field6` and `field7` unused booleans that
Lagrange serialises as `false`. The server currently tolerates them
missing; including them keeps SnowLuma byte-identical with Lagrange
in case the validator gets stricter.

The reaction-type heuristic stays the same (`code.length > 3 ? 2 : 1`
— Lagrange uses the same `IsEmoji => Code.Length > 3` check in
`GroupAddReactionEvent.cs`).
2026-05-21 08:10:53 +08:00
DrSmoothl 0479924482 fix(msg): cache self-sent messages so /get_msg can find them
`/get_msg` with a `message_id` returned by `send_*_msg` was failing
with "message not found" because the send-side only called
`cacheMessageMeta` (used by recall/delete), never `storeEvent`
(used by /get_msg lookup).

The QQ server sometimes echoes self-sent messages back through the
receive channel (which would populate `storeEvent` via the normal
event pipeline), but it's not reliable — Lagrange and NapCat both
work around this by persisting a local copy at send time. Lagrange
writes into Realm DB inside `SendMessageOperation.cs`; NapCat caches
in-memory via its msg service.

Mirror them: synthesise a `post_type: 'message_sent'` event from the
parsed `MessageElement[]` (converted back to OneBot segments via the
existing `elementsToOneBotSegments`) and write it directly into
`messageStore.storeEvent` — bypassing `dispatchEvent` so we don't
broadcast the synthetic event over the WS bridge (the QQ server's
own echo is the source of truth for that).

Wired into all five outbound paths:
- sendPrivateMessage (modules/message-actions.ts)
- sendGroupMessage
- sendGroupForwardMessage (the wrapping ARK card)
- sendPrivateForwardMessage
- forwardSingleMessage (both branches)

The helper is defensive: tests stub `messageStore` as
`{ findEvent: () => null } as any` without `storeEvent`, so the
helper checks for the function and bails quietly when absent.
Failure inside the helper is logged but never thrown — recall +
the send return value matter more than /get_msg lookup.
2026-05-21 08:10:35 +08:00
DrSmoothl e5e85d8d4b fix(file): consult upload cache when emitting transElem(24) in forwards
`send_forward_msg` with a `{type:'file', file_id}` segment to a GROUP
was sending a 0 B file (no name, no size, no md5/sha1) because
`makeGroupFileElem` read those fields directly off `MessageElement`
without falling back to `bridge.recallUploadedFile()`.

The c2c forward path in `apis/forward.ts::buildForwardPushBody`
already does this lookup (line ~126) — that's why forwarding to a
private chat worked but to a group didn't. Mirror it here.

OneBot11 callers typically thread only the file_id through (the
upload action returns just the id), so the cached metadata is the
only place size/md5/sha1/name come from. Without it the long-msg
payload encodes `GroupFileExtra { fileSize=0, fileName='', md5='',
fileSha='' }` and the receiver renders a "0 B" file bubble.
2026-05-21 08:10:16 +08:00
DrSmoothl f4c6838e65 refactor(api): move 10 web methods to WebApi + finalize cleanup (#6 commit 12/13)
Final commit of the #6 NapCat-style Api refactor. Inlines the four
files under `web-actions/` (cookies / group-essence / group-honor /
group-notice) into `apis/web.ts::WebApi`. Both the `actions/` and
`web-actions/` directories are now empty and deleted from the tree.

Method renaming (Api namespace already says "web"):

  forceFetchClientKey   → forceFetchClientKey  (kept — unique name)
  getCookiesStr         → getCookiesStr        (kept — disambiguates from
                                                 the new public getCookies)
  getCsrfToken          → getCsrfToken
  getCredentials        → getCredentials
  getGroupEssence       → getEssence
  getGroupEssenceAll    → getEssenceAll
  getGroupHonorInfo     → getHonorInfo
  sendGroupNotice       → sendNotice
  getGroupNotice        → getNotice
  deleteGroupNotice     → deleteNotice

Plus a new public `getCookies(domain)` on WebApi so GroupAlbumApi can
reach the cookie-acquisition flow through `this.ctx.apis.web.getCookies`
rather than reaching into a private helper. Internal helpers
(`getPSkey` / `getSKey` / `getBknFromSKey`) remain file-private since
they're only used by the publicly-exposed methods above.

Cleanup performed in this same commit:

  • `actions/shared.ts` → moved to `apis/shared.ts` (only file left in
    actions/; it's a small helper module used by 4 Api classes).
  • `actions/` directory deleted entirely.
  • `web-actions/` directory deleted entirely.
  • `BridgeInterface` slimmed to `BridgeContext + activePid` (the only
    non-Api field OneBot consumers reach into). The 70-line list of
    method signatures is gone — every method type now comes from the
    `ApiHub` definition in `bridge-context.ts`.
  • `bridge.ts` no longer imports/wraps any action functions. Its
    public class is just the per-account state holder + `apis: ApiHub`.

Callers updated: `instance-context.ts`, `actions/extended.ts`,
`actions/group-info.ts`. Tests under `tests/web-actions/` deleted —
those tested file-private helpers (`getPSkey`/`getSKey`/`getBknFromSKey`)
that are no longer reachable from outside; the public WebApi surface
is exercised through OneBot integration tests in `extended-actions.test.ts`.

552/552 remaining tests pass. (Test count down 35 from the deleted
web-actions tests; production behaviour unchanged.)

This concludes the #6 refactor. The 12 Api hubs that the OneBot side
can reach via `bridge.apis.*`:

  message / contacts / groupAdmin / groupFile / groupAlbum
  interaction / friend / profile / forward / misc / extras / web
2026-05-20 23:49:48 +08:00
DrSmoothl 22add4ad1c refactor(api): move Tier-2 extras to ExtrasApi (#6 commit 11/13)
Inlines `actions/extras.ts` into `apis/extras.ts::ExtrasApi`. Covers
the napcat-parity Tier-2 surface: group todo CRUD (0xF90_1/2/3),
stranger online/ext status decode (0xFE1_2), and AI voice list +
synthesis (0x929D / 0x929B).

`StrangerStatus`, `AiVoiceChatType`, `AiVoiceCategory`, `AiVoiceItem`
are now exported from `apis/extras.ts` and re-exported through
`bridge.ts` for backwards compatibility (the OneBot side imports
them via `@snowluma/core`).

No renaming — the names already read cleanly under `apis.extras.X`.

The `fetchAiVoiceList` / `fetchAiVoice` chatType parameter is widened
to `AiVoiceChatType | number` because the OneBot side passes a raw
number from JSON. The wire encoder is happy with any integer; the
strict enum just gives autocompletion at call sites that have it.

Callers updated: `actions/extended.ts` (7 sites).
Tests rewritten:
  - `extras.test.ts` → uses `new ExtrasApi(bridge as any).method(...)`
    and the it.each dispatch switches to method-name strings.
  - `extended-actions.test.ts` APIS_ROUTING gains the extras block.

587/587 tests pass.
2026-05-20 23:40:57 +08:00
DrSmoothl e5ac2a61b1 refactor(api): move 4 misc methods to MiscApi (#6 commit 10/13)
Inlines `actions/misc.ts` into `apis/misc.ts::MiscApi`. The Api holds
the methods that didn't naturally group into any other area:
translation, mini-app ARK card builder, inline-keyboard callback,
group sign-in.

No renaming — these names are already descriptive at the call site
under `apis.misc.X`.

Callers updated: `actions/extended.ts` (4 sites).
Tests rewritten to use `new MiscApi(bridge as any).method(...)`.

587/587 tests pass.
2026-05-20 23:35:37 +08:00