Commit Graph
1191 Commits
Author SHA1 Message Date
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
snowluma-bot[bot] 08b7ab7f15 Merge pull request #63 from SnowLuma/dev
chore: promote dev → main (ee0f159)
v1.9.1
2026-05-21 09:50:31 +00:00
DrSmoothl e088167476 fix(lockfile): rebase against origin/main to unblock PR #63 auto-merge
The previous commit (ee0f159) regenerated the lockfile starting from
dev's pre-fix state, which diverged structurally from main's lockfile
(main carried dependabot's hono 4.12.18 bump that dev never inherited).
GitHub's auto-merger saw the two lockfiles as touching overlapping
hunks and marked PR #63 CONFLICTING, blocking the bot's auto-merge
flow into main.

Re-seed the lockfile from origin/main (`git show origin/main:pnpm-lock.yaml`)
then run `pnpm install` again — this time the regeneration starts from
main's already-bumped state, so the only delta is the 6 lines removed
from `packages/onebot`'s importer entry (no snapshot churn). PR #63
becomes a clean fast-forward for the bot to merge.

No code or package.json change here; this is purely a lockfile rebase.
Verified: `pnpm install --frozen-lockfile` passes locally against the
new lockfile.
2026-05-21 17:50:15 +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
snowluma-bot[bot] 4a92dd71ad Merge pull request #62 from SnowLuma/dev
chore: promote dev → main (607a7b3)
2026-05-21 09:34:03 +00:00
DrSmoothl 607a7b319b chore(release): v1.9.1 2026-05-21 17:33:35 +08:00
墨梓柒 a13e09131f Merge pull request #61 from SnowLuma/native/auto-update-native-03d4d0e
chore(native): replace native artifacts (native-03d4d0e)
2026-05-21 16:17:00 +08:00
snowluma-bot[bot] 66f8a5eb2f chore(native): replace native artifacts (native-03d4d0e)
Source: SnowLuma/nnphook@03d4d0ede7
Build:  https://github.com/SnowLuma/SnowLumaNative/releases/tag/native-03d4d0e
2026-05-21 07:09:34 +00:00
墨梓柒 d68535e3d1 Merge pull request #60 from SnowLuma/native/auto-update-native-117413b
chore(native): replace native artifacts (native-117413b)
2026-05-21 14:41:06 +08:00
snowluma-bot[bot] 61f40703ca chore(native): replace native artifacts (native-117413b)
Source: SnowLuma/nnphook@117413b517
Build:  https://github.com/SnowLuma/SnowLumaNative/releases/tag/native-117413b
2026-05-21 06:40:38 +00: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
墨梓柒 4e34f8aeb0 Merge pull request #59 from SnowLuma/native/auto-update-native-c0dc4db
chore(native): replace native artifacts (native-c0dc4db)
2026-05-21 10:56:09 +08:00
snowluma-bot[bot] 26777d0010 chore(native): replace native artifacts (native-c0dc4db)
Source: SnowLuma/nnphook@c0dc4db649
Build:  https://github.com/SnowLuma/SnowLumaNative/releases/tag/native-c0dc4db
2026-05-21 02:50:23 +00:00
DrSmoothl b69d8ac83c build(ci): refresh pnpm-lock.yaml for runtime better-sqlite3 dep
`pnpm install --frozen-lockfile` in CI rejected the lockfile after
998eba2 added `better-sqlite3` to packages/runtime/package.json
without bumping the lock. Running `pnpm install` regenerates the
`packages/runtime: { dependencies: { better-sqlite3: ... } }` block
plus the resolved version pin. No code change — purely the lockfile
catching up to the manifest.
2026-05-21 10:45:56 +08:00
DrSmoothl 998eba2ba8 build(ci): wire better-sqlite3 native binding into release artifacts
Followup to 0b6541c. That commit added `better-sqlite3` as a
workspace dependency and marked it as a vite external — meaning
the bundled `dist/index.mjs` does `require('better-sqlite3')` at
runtime instead of inlining its JS wrapper. Without the binding
+ wrapper present in the production archive, every shipped
SnowLuma would crash on first SQLite op.

The fix is in three small pieces:

1. `packages/runtime/package.json` — declare `better-sqlite3` as a
   runtime dep. `vite-plugin-cp` copies this manifest into
   `dist/package.json` during build (see `runtimeDistFiles` in
   `packages/core/vite.config.ts`), so the release workflow's
   existing `npm install --omit=dev` step inside `dist/` picks it
   up and compiles the native binding for the matrix target via
   prebuild-install (with node-gyp fallback). This is the same
   mechanism release.yml has used since v1.x — it was just empty
   until now because the previous vite config bundled everything.

2. `.github/workflows/dev-build.yml` — mirror the
   `Bundle runtime dependencies` step from release.yml. Dev-build
   was packaging `dist/` straight after vite, which would have
   shipped a broken artifact (no `dist/node_modules/`). Added the
   `npm install --omit=dev` step before the tar/zip packaging
   steps so dev-build archives carry the binding too.

3. `package.json` root — add `dist:install` script (just
   `cd dist && npm install --omit=dev`) so local prod-mimic flows
   work: `pnpm build && pnpm dist:install && pnpm dev:mode`. Not
   chained into `pnpm build` itself to keep the watch-cycle fast
   for users who only need the vite build output; the install
   step adds ~17s.

Note on dev mode: `pnpm dev` continues to work unchanged — it
runs via tsx against workspace `node_modules/`, which already has
`better-sqlite3` compiled (from the workspace-level pnpm install).
Only the dist/-based prod-mimic path needed the explicit install.

Verification
------------
- `pnpm build` (with stubbed native binary): clean, dist/ has the
  updated `dist/package.json` with the dependency declared
- `pnpm dist:install`: installs 38 packages, compiles
  `dist/node_modules/better-sqlite3/build/Release/better_sqlite3.node`
- Smoke-test from `dist/`: `node -e "import('better-sqlite3')..."`
  prints `OK: [ { id: 42 } ]`, confirming the binding loads + a
  basic INSERT/SELECT roundtrip works.

CI matrix coverage: win-x64, linux-x64, linux-arm64 (release.yml +
dev-build.yml share the same matrix). Each builds its own native
binding during the install step — prebuild-install hits
better-sqlite3's prebuilt-binary registry for these three targets
(no source compile needed in the common case, falls back to
node-gyp if a prebuilt isn't available).
2026-05-21 10:44:08 +08:00
DrSmoothl 3f2b474120 RoadMap: tick off #5 after the perf series lands
Three perf commits delivered the wins:
- f3f5d27 perf(bench): bench harness for receive/send chains (RoadMap #5 baseline)
- 0b6541c perf(sqlite): swap node:sqlite for better-sqlite3 + cache prepared stmts
- a70f194 perf(msg-push): drop Buffer.from() wrap on pkt.body (free allocation)

Headline numbers (RoadMap #5 baseline → after):

  Bridge — receive pipeline:
    group_member_join (full) 17.5 μs/op →  4.7 μs/op  (~3.7x)
    parseMsgPush text         0.29 μs/op →  0.25 μs/op (~1.2x)

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

  Combined send hot path:    ~40 μs → ~29 μs per message

Stopped here intentionally — the remaining knobs on the list
(elementsToJson sync-fast-path, WAL checkpoint policy, JSON
serialization swap, dedup-on-unchanged identity writes) have
diminishing returns vs the SQLite win we already cashed in.
Bench harness lives on; if real-world throughput pressure shows
up later, the baseline + delta methodology is already wired and
auditable.

All V2.0.0-pre items are now done.
2026-05-21 10:37:33 +08:00
DrSmoothl a70f194218 perf(msg-push): drop Buffer.from() wrap on pkt.body (free allocation)
`PacketInfo.body` is typed as `Uint8Array` in `@snowluma/common/
protocol-types` and arrives as one — the `Buffer.from(uint8Array)`
wrap that fed it into `protobuf_decode<PushMsg>(...)` was a no-op
copy. `Buffer` is a `Uint8Array` subclass, and proton's decoder
accepts the parent type directly, so the wrap added one allocation
per incoming push packet without changing behavior.

Bench delta (parseMsgPush, pure decode):
  group_message (text only)    0.29 μs/op → 0.25 μs/op  (~1.2x)
  group_message (5 mixed)      0.50 μs/op → 0.47 μs/op  (~1.07x)
  group_member_join            0.25 μs/op → 0.30 μs/op  (within noise)

Small absolute gain — but `parseMsgPush` runs once per incoming
packet at the very front of the pipeline, so the saved allocation
compounds with the per-message GC churn. The text/mixed group-
message cases pick up cleanly; the join case sits in noise (it has
a smaller body so the Buffer wrap was cheap relative to total).

Verification: pnpm -r typecheck clean; 552/552 tests pass.
2026-05-21 10:36:20 +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 f3f5d27c65 perf(bench): bench harness for receive/send chains (RoadMap #5 baseline)
vitest-driven benches at `packages/bridge/bench/recv.bench.ts` and
`packages/onebot/bench/send.bench.ts`. Picks up proton's vite plugin
so call sites get the inlined codecs (running via tsx + proton
runtime-fallback would throw without a pre-built runtime map).

Run via `pnpm -F @snowluma/bridge bench` and
`pnpm -F @snowluma/onebot bench`.

What's measured
---------------
Receive chain:
  - parseMsgPush: pure decode + dispatch (no SQLite, no event emit)
    * group_message text          ~ 0.29 μs/op  (3.4M ops/sec)
    * group_message 5 mixed       ~ 0.50 μs/op  (2.0M ops/sec)
    * group_member_join           ~ 0.25 μs/op  (4.1M ops/sec)
  - IncomingPacketPipeline.process: full pipeline (with SQLite via
    rememberGroupMemberIdentity, BridgeEventBus emit)
    * group_message text          ~ 0.42 μs/op  (2.4M ops/sec)
    * group_message 5 mixed       ~ 0.67 μs/op  (1.5M ops/sec)
    * group_member_join           ~17.5  μs/op  ( 57K ops/sec) ⚠

Send chain:
  - parseMessage (OneBot input → MessageElement[])
    * CQ string text              ~ 0.31 μs/op
    * CQ string mixed             ~ 1.22 μs/op
    * segment array text          ~ 0.32 μs/op
    * segment array 5             ~ 1.07 μs/op
  - messageStore SQLite writes:
    * storeMeta                   ~17.0  μs/op  ( 59K ops/sec) ⚠
    * storeEvent (~250B JSON)     ~23.2  μs/op  ( 43K ops/sec) ⚠
  - messageStore reads:
    * findMeta hit                ~ 5.4 μs/op  (186K ops/sec)
    * findMeta miss               ~ 3.7 μs/op  (272K ops/sec)
    * findEvent hit               ~ 3.8 μs/op  (262K ops/sec)

Headline finding
----------------
Pure decode/encode is already cheap (sub-μs). The real cost per
sent message is the SQLite write pair (storeMeta + storeEvent =
~40 μs). On receive, group_message itself does NOT trigger
identity writes; only join/leave/admin events do — but those go
through 4-6 db.prepare(sql).run/get(...) calls each, all re-parsing
SQL on every invocation (the canonical SQLite anti-pattern).

The follow-up commits in this series target:
  1. Replace `node:sqlite` (experimental, API may shift) with
     `better-sqlite3` (stable, sync, ~30-50% faster wire) + cache
     prepared statements as readonly fields. Single commit, both
     wins together — better-sqlite3's API encourages stmt caching
     by design. Expected: storeMeta / storeEvent drop to ~3-5 μs,
     group_member_join drops to ~3-5 μs.
  2. Skip rememberGroupMemberIdentity DB write when the in-memory
     uidByUin / uinByUid cache already has the mapping unchanged.
  3. Drop the `Buffer.from(pkt.body)` allocation in msg-push
     context (pkt.body is already Uint8Array; proton accepts it).
  4. Make `elementsToJson` sync-fast-path when no async resolvers
     are wired (saves per-element microtask overhead).

Each commit re-runs these benches and the message should quote the
before/after numbers so the deltas are auditable.

Note: tsx is NOT involved — vitest's bench mode owns the run, and
the proton vite plugin is configured in each `vitest.config.ts`.
Absolute numbers reflect the production code path.
2026-05-21 10:26:29 +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 450b551701 RoadMap: tick off #6 after the OneBot-side ctx slim-down lands
#6 was the "API层挂到ctx" item — Core side delivered the 13 typed
Api classes back in the e8d1af4...f4c6838 commit series; this turn
finished the OneBot side via the 4 ctx slim-down commits
(cb698d2 / 4c4ce60 / 8bf2ff3 / d983635).

ApiActionContext shrank from 90+ fields → ~30. Removed surfaces:
  - groupAdmin / groupFile / groupAlbum / friend / interaction /
    message / profile / web — all moved to direct
    `ctx.bridge.apis.<area>.method()` invocations.
Kept surfaces (real composition, not pass-through):
  - instance state: bridge / getLoginInfo / isOnline / getMessage*
  - module helpers: sendXxxMessage / sendXxxForwardMsg / forwardSingle /
    deleteMessage / setEssenceMsg / setMsgEmojiLike / handleGroupRequest
  - cache-backed reads: getFriendList / getGroupList / getGroupInfo
    /Member* / getStrangerInfo / getGroupFiles / get*MsgHistory /
    handleGetGroupSystemMsg / getDownloadRKeys
  - media store reads: getImageInfo / getRecordInfo

Full NapCat class-per-action conversion (TypeBox schemas / actionName
enum / auto-register decorator / 170 single-file classes) deliberately
not pursued — the metadata-driven design is meaningful only with
downstream doc/test tooling that consumes it, which SnowLuma doesn't
ship today. Marked as a follow-up under the same item if/when that
tooling appears.
2026-05-21 09:32:07 +08:00
DrSmoothl d983635c8f refactor(onebot): inline ctx.bridge.apis.profile/web/groupAlbum direct calls
Remove the remaining ~20 passthrough fields from ApiActionContext
spanning four Api areas — most were already dead (no caller) since
extended.ts had migrated to direct `ctx.bridge.apis.X` paths in
earlier #6 commits.

  profile area:
    - setProfile / setOnlineStatus / fetchCustomFace /
      setGroupAvatar     ── all already dead
    - getProfileLike    ── one live caller in extended.ts inlined

  web area:
    - getGroupHonorInfo / getGroupEssence / getGroupEssenceAll /
      sendGroupNotice / getGroupNotice / deleteGroupNotice /
      getCookiesStr / getCsrfToken / getCredentials /
      forceFetchClientKey  ── all already dead

  groupAlbum area:
    - getGroupAlbumList / uploadImageToGroupAlbum /
      getGroupAlbumMediaList / commentGroupAlbumMedia /
      deleteGroupAlbumMedia / likeGroupAlbumMedia  ── all 7 live
      callers in actions/group-album.ts inlined to
      `ctx.bridge.apis.groupAlbum.<m>(...)`

  friend area:
    - setFriendRemark   ── already dead

Behavior preserved: getProfileLike's `userId = 0 → self` semantics
keep working because ProfileApi.getLike uses `isSelf = !userId`
internally (0 and undefined are equivalent for that check); same
default args at the action site (`start = 0`, `count = 10`).

Group-album action handlers now wrap the typed Api return values
(GroupAlbumMediaResult, GroupAlbumCommentResult) with
`as unknown as JsonValue` for okResponse compatibility — the old
ctx wrapper was typed `Promise<any>` which silently hid the cast;
making the cast explicit doesn't change runtime.

Part 4 of the OneBot-side slim-down. 552/552 tests still green.
2026-05-21 09:29:28 +08:00
DrSmoothl 8bf2ff3f36 refactor(onebot): inline ctx.bridge.apis.friend/interaction/message direct calls
Remove 8 passthrough fields from ApiActionContext spanning three Api
areas:

  friend area:
    - handleFriendRequest  (set_friend_add_request)
    - handleDeleteFriend   (delete_friend)
    - setFriendRemark already kept the field name but now unused

  interaction area:
    - sendFriendPoke / sendGroupPoke  (friend_poke, group_poke, send_poke)
    - getEmojiLikes  (was already dead — extended.ts used the direct
      path; field removed)

  message area:
    - markGroupMsgAsRead / markPrivateMsgAsRead  (already dead —
      mark_msg_as_read sites use the module helper)

`handleGroupRequest` stays on ctx because `handleGroupAddRequest` is
a module helper that does the flag-parsing + group-request roster
lookup before routing to `bridge.apis.groupAdmin.setAddRequest` —
real composition, not a passthrough.

Behavior preserved: same flag parsing (`asString/asNumber/asBoolean`),
same `isGroup` flag flatten into `sendPoke(isGroup, peerUin, target?)`,
same default `block=false` for delete_friend.

Part 3 of the OneBot-side slim-down. 552/552 tests still green.
2026-05-21 09:23:32 +08:00
DrSmoothl 4c4ce60bf2 refactor(onebot): inline ctx.bridge.apis.groupFile direct calls
Remove 6 passthrough fields from ApiActionContext for the group-file
area (`uploadGroupFile / uploadPrivateFile / getGroupFileUrl /
createGroupFileFolder / getPrivateFileUrl / getGroupFileCount`).
Action sites (`actions/group-file.ts` and one site in
`actions/extended.ts`) now call `ctx.bridge.apis.groupFile.<method>`
directly.

`getGroupFiles` stays on the ctx because its module helper
(`getGroupFiles(bridge, groupId, folderId)` in `modules/contact-actions.ts`)
does identity-lookup enrichment of uploader/creator names — not a pure
passthrough.

Behavior preserved: same default values (`name = ''`, `folderId = '/'`,
`busId = 102`, `uploadFile = true`) — they were baked into the ctx
wrapper before, now they're explicit in the action site (`asString`
returns `''`, `... || '/'`, `... || 102`, `asBoolean(..., true)`).
The result-unwrap (`fileId` extraction from `UploadFileResult`) moves
from the ctx wrapper into the action handler.

Part 2 of the OneBot-side slim-down. 552/552 tests still green.
2026-05-21 09:19:27 +08:00
DrSmoothl cb698d2435 refactor(onebot): inline ctx.bridge.apis.groupAdmin direct calls
Remove the 6 pure pass-through fields from `ApiActionContext` for the
group-admin area (`setGroupKick / setGroupKickMembers / setGroupBan /
setGroupWholeBan / setGroupLeave / setGroupRemark`). All 5 call sites
in `actions/group-admin.ts` now invoke `ctx.bridge.apis.groupAdmin.<m>`
directly. `setGroupRemark` had zero remaining callers (extended.ts
was already on the direct path) so the field is simply deleted.

Part 1 of RoadMap #6 OneBot-side slim-down. Core side's typed Api
hub (bridge.apis.X) has been in place since the #6 commit series;
OneBot actions were still routing through a 90+ field bag-of-
functions on `ApiActionContext`. Most of those fields were 1:1
shims like `setGroupKick: (g,u,r) => bridge.apis.groupAdmin.kickMember(g,u,r)` —
pure indirection with no composition logic. This commit kills the
group-admin chunk; the friend / interaction / profile / web / album /
groupFile / message chunks land in follow-up commits.

Behavior preserved byte-for-byte: same parameter parsing
(`asNumber/asString/asBoolean`), same default values, same 1800s
mute fallback, same error responses. 552/552 tests still green.
2026-05-21 09:16:15 +08:00
DrSmoothl a011b8f033 chore(sdk): point actions-sync test at @snowluma/onebot
Path follow-up to the 4-package split — the action registrations
moved from `packages/core/src/onebot/actions/` to
`packages/onebot/src/actions/` in Phase 3. The test was failing with
ENOENT on the old path before reaching the actual assertion. Note
that this test has a separate pre-existing failure (it imports a
non-exported `SNOWLUMA_ACTIONS` symbol from the SDK); the path fix
just clears the first of the two errors.
2026-05-21 08:54:44 +08:00
DrSmoothl 72320121c3 RoadMap: tick off #3 after the 4-package split lands
Three phased commits delivered the split:
- 46b302f refactor(common): leaf utilities → @snowluma/common
- (the bridge phase commit) protocol layer → @snowluma/bridge
- (the onebot phase commit) translation layer → @snowluma/onebot

Final layout:

  packages/common/    ~8 files    logger / hex / packet-sender /
                                  protocol-types / runtime / json /
                                  log-file-transport / log-summary
  packages/bridge/    ~35 files   events / event-bus / identity /
                                  packet-pipeline / element-builder /
                                  bridge-oidb / qq-info / format /
                                  msg-push/* / highway/* / web/*
  packages/core/      ~32 files   bridge.ts (god-class) / bridge-
                                  interface / bridge-context (extends
                                  bridge package's slim BridgeContext
                                  with apis: ApiHub) / manager /
                                  apis/* (13 Api classes) / hook/* /
                                  webui/* / index.ts (app entry)
  packages/onebot/    ~43 files   actions/* / modules/* / network/* /
                                  event-converter/* / instance /
                                  instance-context / manager /
                                  message-{id,parser,store} /
                                  media-{store,indexer,url-resolver}
2026-05-21 08:52:54 +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 e1c9ede9de RoadMap: tick off #2/#4 and partial #6 after the dev-branch refactor
- #2 (Proton ts+map fallback for unbundled debug) — landed in
  `994633e feat(proton): resolve bare specifiers via TypeScript module
  resolution` + `6c7e56d refactor(proto-defs)`. Bare specifiers like
  `@snowluma/proto-defs/oidb` now resolve to the original `.ts` source
  through `ts.resolveModuleName` (Bundler mode), so tsx/node + vite
  share the same import surface.

- #4 (proto definitions in their own package) — landed in
  `6c7e56d refactor(proto-defs)`. Moved 13 proto `.ts` files (~3.3k
  LOC) into the new `@snowluma/proto-defs` workspace package with
  per-file subpath exports; 44 call sites across core/tests rewritten.

- #6 (NapCat-style Api on ctx) — half done. Core side fully landed
  through 13 sequential commits (3e5dca1 → f4c6838) — `bridge.apis.<
  area>.method()` covers all 13 areas, `BridgeInterface` is gone, the
  `actions/*` directory is deleted. OneBot side is NOT done: actions
  still receive an `ApiActionContext` (a flat bag of functions) and
  call `ctx.bridge.apis.*` directly rather than going through a
  dedicated OneBot Api class layer.
2026-05-21 08:13:06 +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
DrSmoothl d953b9824c refactor(api): move 2 forward methods to ForwardApi (#6 commit 9/13)
Inlines `actions/forward.ts` into `apis/forward.ts::ForwardApi`. The
file is the largest unmoved one — 475 lines covering long-message
upload with NapCat-style piggyback for nested {type:"node"} chains,
plus the cached fetch side.

The module-scoped `forwardResCache` Map stays at file scope rather
than as an instance field. Forward res_ids are globally unique and
an outbound upload often gets queried back through `fetch` on a
different Bridge instance (different OneBot connection hitting the
same account) — NapCat's behaviour relies on the cache being shared
across the process.

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

  uploadForwardNodes → upload
  fetchForwardNodes  → fetch

The private `uploadForwardNodesRecursive` becomes
`this.uploadRecursive(...)` — same body, threads through `this.ctx`.

Callers updated: `modules/message-actions.ts` (4 sites).
Tests rewritten:
  - `forward.test.ts` → `new ForwardApi(bridge as any).upload/fetch(...)`
  - `forward-file.test.ts` → same pattern
  - `forward-nested.test.ts` → mocks `apis.forward.upload` instead of
    flat `bridge.uploadForwardNodes`

587/587 tests pass.
2026-05-20 23:32:14 +08:00
DrSmoothl 62c5590fe1 refactor(api): move 10 profile methods to ProfileApi (#6 commit 8/13)
Inlines `actions/profile.ts` into `apis/profile.ts::ProfileApi`. The
file covers personal profile (nickname / long-nick / personal note),
online status (regular + DIY with custom face/wording), avatars
(personal + group via highway), input-status, likes (received from
others), unidirectional friend list, and custom face roaming.

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

  getProfileLike → getLike    (only obvious shortening; rest keep
                               their `set*`/`fetch*` prefixes because
                               they're already minimal at the call
                               site under `apis.profile.X`)

Callers updated: `instance-context.ts`, `actions/group-admin.ts`,
`actions/extended.ts`. The extended-actions test gained a sizable
`APIS_ROUTING` block covering profile/interaction/friend (all of the
late-stage Api hubs need auto-promotion now), so legacy tests that
stub `setDiyOnlineStatus`/`setOnlineStatus`/`deleteFriend`/etc as flat
methods keep working.

Tests rewritten to use `new ProfileApi(bridge as any).method(...)`.

587/587 tests pass.
2026-05-20 23:26:35 +08:00
DrSmoothl 2ec72d59f9 refactor(api): move 3 friend methods to FriendApi (#6 commit 7/13)
Inlines `actions/friend.ts` into `apis/friend.ts::FriendApi`. The
roster READ side (fetchFriendList) stays on ContactsApi where it
naturally lives next to fetchGroupList — the friend/group rosters
share the same pipeline.

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

  setFriendAddRequest → handleRequest    (accept/reject UID-or-flag)
  deleteFriend        → delete           (with cache refresh)
  setFriendRemark     → setRemark

`delete` still does the post-OIDB best-effort
`apis.contacts.fetchFriendList()` refresh to evict the stale entry.

Callers updated: `instance-context.ts`, `actions/extended.ts`.
Tests rewritten to use `new FriendApi(bridge as any).method(...)`.

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