44 Commits
Author SHA1 Message Date
手瓜一十雪 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
手瓜一十雪 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
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 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 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 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 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 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 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
DrSmoothl 73f1cd3c80 refactor(api): move interaction methods to InteractionApi (#6 commit 6/13)
Folds the entire `actions/interaction.ts` (sendPoke / sendLike /
setGroupReaction / getEmojiLikes) plus the lone-survivor
`setGroupEssence` from `actions/group-message.ts` into
`apis/interaction.ts::InteractionApi`. Both action files deleted —
the recall + markRead halves of group-message were already absorbed
into MessageApi back in commit 1.

Skipping the planned standalone GroupMessageApi commit: with only one
method left (`setGroupEssence`), giving it its own Api class would be
pure overhead. Essence is conceptually an "interaction" anyway (mark
a message as essence is parallel to "react with an emoji").

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

  sendPoke              → sendPoke     (no rename — already minimal)
  sendLike              → sendLike
  setGroupReaction      → setReaction
  setGroupEssence       → setEssence
  getEmojiLikes         → getEmojiLikes (no rename — read-side, fine)

Callers updated: `instance-context.ts`, `modules/message-actions.ts`,
`actions/extended.ts`. Tests `interaction.test.ts` rewritten to use
`new InteractionApi(bridge as any).method(...)` and gained a
`setEssence` test. `group-message.test.ts` rewritten to target
MessageApi.recall* / markRead* methods that were already moved.

587/587 tests pass.
2026-05-20 23:17:51 +08:00
DrSmoothl dad501ebc0 refactor(api): move 17 group-file methods to GroupFileApi (#6 commit 4/13)
Inlines `actions/group-file.ts` into `apis/group-file.ts::GroupFileApi`,
deletes the old action file, removes the matching 17 facade methods
off `Bridge`, and slims `BridgeInterface` accordingly.

Method renaming follows the same "strip the redundant suffix" rule we
applied to GroupAdminApi:

  uploadGroupFile           → upload
  uploadPrivateFile         → uploadPrivate
  sendGroupFileMessage      → publish        (OIDB 0x6d9_4)
  fetchGroupFiles           → list
  fetchGroupFileCount       → getCount
  fetchGroupFileUrl         → getUrl
  fetchPrivateFileUrl       → getPrivateUrl
  fetchGroupPttUrlByNode    → getPttUrl
  fetchPrivatePttUrlByNode  → getPrivatePttUrl
  fetchGroupVideoUrlByNode  → getVideoUrl
  fetchPrivateVideoUrlByNode → getPrivateVideoUrl
  deleteGroupFile           → delete
  moveGroupFile             → move
  createGroupFileFolder     → createFolder
  deleteGroupFileFolder     → deleteFolder
  renameGroupFileFolder     → renameFolder

`upload`'s atomic upload+publish flow now calls `this.publish(...)`
directly instead of going through `bridge.sendGroupFileMessage`, and
`uploadPrivate` calls `this.ctx.apis.message.sendC2cFile(...)`.

Callers updated: `instance-context.ts`, `media-url-resolver.ts`,
`actions/group-file.ts` (onebot), `actions/extended.ts`,
`modules/{contact,media,message}-actions.ts`.

Tests: `group-file.test.ts` rewritten to use
`new GroupFileApi(bridge as any).method(...)`. The "verify publish was
called" assertions now use `vi.spyOn(api, 'publish').mockResolvedValue()`
since `publish` is an instance method on the same Api. `_helpers.ts`
gains `mockGroupFileApi()` for cross-Api callers and drops the flat
`sendGroupFileMessage` shim. `media-url-resolver.test.ts` and
`send-group-file.test.ts` switched to `bridge.apis.groupFile.*`; the
extended-actions `APIS_ROUTING` map now carries a `[area, newName]`
tuple so renamed methods auto-promote.

587/587 tests pass. The network-manager file remains as the only
failing test file (pre-existing infrastructure issue unrelated to #6).
2026-05-20 23:08:50 +08:00
DrSmoothl 0b91e4db8d refactor(api): move 14 group-admin methods to GroupAdminApi (#6 commit 3/13)
Third of 13 commits. Folds the previous `actions/group-admin.ts`
plus the matching Bridge facade methods into a typed `apis.groupAdmin`
hub.

Methods (14, all renamed to drop the redundant `Group` prefix once
the `apis.groupAdmin` namespace makes it implicit):

  muteGroupMember     → apis.groupAdmin.muteMember
  muteGroupAll        → apis.groupAdmin.muteAll
  setGroupAddOption   → apis.groupAdmin.setAddOption
  setGroupSearch      → apis.groupAdmin.setSearch
  setGroupAddRequest  → apis.groupAdmin.setAddRequest
  kickGroupMember     → apis.groupAdmin.kickMember
  kickGroupMembers    → apis.groupAdmin.kickMembers
  leaveGroup          → apis.groupAdmin.leave
  setGroupAdmin       → apis.groupAdmin.setAdmin
  setGroupCard        → apis.groupAdmin.setCard
  setGroupName        → apis.groupAdmin.setName
  setGroupSpecialTitle→ apis.groupAdmin.setSpecialTitle
  setGroupRemark      → apis.groupAdmin.setRemark
  getGroupAtAllRemain → apis.groupAdmin.getAtAllRemain

actions/group-admin.ts deleted. The corresponding `*_` alias imports
and `async xxx() { return xxx_(this, …); }` delegate methods on
Bridge are gone.

Caller updates
--------------
* onebot/instance-context.ts — 6 sites (the multi-method binding for
  the OneBot action layer).
* onebot/actions/extended.ts — 8 sites including `getAtAllRemain`.
* onebot/actions/group-admin.ts — all 6 onebot action handlers.
* onebot/modules/request-actions.ts — `setGroupAddRequest`.

Test updates
------------
* tests/actions/group-admin.test.ts — was importing the deleted
  `* as admin` module and calling `admin.muteGroupMember(bridge, ...)`.
  Switched to `new GroupAdminApi(bridge as any).muteMember(...)` —
  same wire validation, just constructs the real Api class once per
  call (the Api is stateless so it's effectively free).
* tests/onebot/request-actions.test.ts — was stubbing
  `setGroupAddRequest` flat on bridge. Updated to
  `apis: { groupAdmin: { setAddRequest } } as any`.

Tests: 587/587 pass (network-manager native-addon failure unchanged).
2026-05-20 22:34:54 +08:00
DrSmoothl 6bf53890dc refactor(api): move contacts methods to ContactsApi (#6 commit 2/13)
Second of 13 commits decomposing Bridge. Moves the six "fetch the
roster / a profile / outstanding requests / download rkeys" methods
off Bridge and onto a new `apis.contacts.*`.

Methods moved
-------------
* fetchFriendList       — OIDB 0xfd4_1 + identity.rememberFriends
* fetchGroupList        — OIDB 0xfe5_2 + identity.rememberGroups
* fetchGroupMemberList  — OIDB 0xfe7_3 + identity.rememberGroupMembers
                          (includes the 60s TTL + per-group inflight
                          coalesce cache that prevents Tencent
                          risk-control from triggering)
* fetchUserProfile      — OIDB 0xfe1_2 + identity.rememberUserProfile
* fetchGroupRequests    — OIDB 0x10c0_{1,2}
* fetchDownloadRKeys    — OIDB 0x9067_202

Cache fields moved
------------------
The two member-list throttle maps (`memberListInflight_`,
`memberListLastFetch_`) move from Bridge to `ContactsApi` as private
fields. Same TTL, same semantics — just owned by the Api class that
exposes the method.

Constructor ordering
--------------------
`buildApiHub(this)` now runs BEFORE `identity.setFetcher(...)` in the
Bridge constructor because the `fetchProfile` / `fetchGroupMemberList`
callbacks installed there reach into `this.apis.contacts.*`. With
eager construction this is safe; the alternative (pass a thunk that
forwards once apis is built) was strictly worse for readability.

bridge-contacts.ts removed
--------------------------
The file existed solely as the previous module-function delegate
target; ContactsApi inlines every function body so there's nothing
left to keep.

Caller-site updates
-------------------
* `bridge/actions/friend.ts` — `bridge.fetchFriendList()` →
  `bridge.apis.contacts.fetchFriendList()`.
* `onebot/manager.ts`, `onebot/instance-rkey.ts`,
  `onebot/modules/contact-actions.ts`, `modules/request-actions.ts`,
  `actions/extended.ts` — same shape rewrite (6 method names).

Test plumbing
-------------
* `tests/actions/_helpers.ts` — `mockApiHub()` grows a `contacts`
  field built by `mockContactsApi()`.
* `tests/bridge-member-identity.test.ts::RefreshingBridge` — was
  `override async fetchGroupMemberList()`. After the move the method
  lives on `this.apis.contacts`, not `this`. Switched to patching the
  method on the constructed ContactsApi instance directly.
* `tests/actions/friend.test.ts` — `bridge.fetchFriendList` →
  `bridge.apis.contacts.fetchFriendList`; the "refresh throws" branch
  overrides via `bridge.apis.contacts.fetchFriendList = vi.fn(…)`.
* `tests/onebot/contact-actions.test.ts`, `extended-actions.test.ts`,
  `request-actions.test.ts` — local `fakeBridge` helpers gained an
  auto-promotion shim that rewrites flat method stubs (the pre-#6
  test API) into the new `apis.<area>.method` shape. Keeps existing
  test setup readable while the production code uses the new path.

Tests: 587/587 pass (same baseline; pre-existing network-manager
native-addon failure unchanged).
2026-05-20 22:27:32 +08:00
DrSmoothl 3e5dca1bd4 refactor(api): introduce MessageApi + BridgeContext + ApiHub skeleton (#6 commit 1/13)
First of 13 commits decomposing the Bridge god-object into typed Api
classes following NapCat's `ctx.apis.XxxApi.method()` pattern. This
commit lays the foundation (BridgeContext, ApiHub, eager construction)
plus moves the message-send/recall/mark-read methods onto MessageApi.
Subsequent commits will move the other 12 areas (group admin, files,
profile, …) similarly.

Architecture
------------
* `bridge-context.ts` — new interface, the surface Api classes see.
  Carries identity / events / apis / sendRawPacket / resolveUserUid /
  next{Message,Client}Sequence helpers / upload meta cache. Replaces
  the previous `BridgeInterface` for Api-class consumption (the
  legacy interface still exists, just slimmed; full removal happens
  in commit 13).

* `apis/index.ts` — `ApiHub` interface + `buildApiHub(ctx)` factory.
  Each commit grows `ApiHub` by one entry; today only `message` is
  populated.

* `apis/message.ts` — `MessageApi` with seven methods inlined from
  the previous Bridge implementation:
    sendGroup        ← Bridge.sendGroupMessage
    sendPrivate      ← Bridge.sendPrivateMessage
    sendC2cFile      ← Bridge.sendC2cFileMessage
    recallGroup      ← actions/group-message.ts::recallGroupMessage
    recallPrivate    ← actions/group-message.ts::recallPrivateMessage
    markGroupRead    ← actions/group-message.ts::markGroupMessageRead
    markPrivateRead  ← actions/group-message.ts::markPrivateMessageRead
  (Names trimmed: the `apis.message` prefix already signals the
  context, so `apis.message.sendGroupMessage` would be redundant.)

Bridge changes
--------------
* Removed the 7 methods listed above (moved to MessageApi).
* Removed unused imports + `SEND_MSG_CMD` constant + `buildSendElems`
  + `protobuf_encode/decode` (now used only inside MessageApi).
* Added `readonly apis: ApiHub` field, built eagerly in constructor.
* Promoted `nextMessageRandom` / `nextClientSequence` from private →
  public (Api classes need them to build SendMessageRequest packets).
* `BridgeInterface` now also lists `apis: ApiHub` + the two
  generators; the 7 deleted method signatures are gone.

Caller-site updates (~9 files)
------------------------------
* `bridge/actions/group-file.ts` — `bridge.sendC2cFileMessage(...)`
  → `bridge.apis.message.sendC2cFile(...)`.
* `onebot/instance-context.ts` — markGroupMsgAsRead / markPrivateMsgAsRead
  bindings now route through `apis.message.markGroup/PrivateRead`.
* `onebot/modules/message-actions.ts` — every send/recall site
  rerouted; `typeof ref.bridge.sendXxx` type refs also updated.
* `onebot/actions/extended.ts` — same.

Tests (~5 files)
----------------
* `tests/actions/_helpers.ts` — `mockBridge()` grows an `apis` field
  built by `mockApiHub()`; the previous flat `sendGroupMessage` etc.
  vi.fn stubs are replaced with `mockMessageApi()`.
* All test files using direct `bridge.sendXxx` / `bridge.recallXxx` /
  `bridge.markXxxAsRead` calls or stubs updated to `bridge.apis.message.*`.
* No behaviour changes — 587/587 pass (same baseline; pre-existing
  network-manager native-addon failure unchanged).
2026-05-20 22:17:47 +08:00
DrSmoothl 6c7e56d5a1 refactor(proto-defs): extract wire-protobuf definitions into standalone package
Move `packages/core/src/bridge/proto/proton/*.ts` (13 files, ~3.3k
LOC) into a new `@snowluma/proto-defs` workspace package. The proton
spike landed in the previous commit means bare-specifier imports
(`@snowluma/proto-defs/oidb`, `…/message`, …) now resolve correctly
through proton's vite plugin, so call-site replacement still fires.

Why
---
- Lets downstream consumers share the same wire
  definitions without copy-pasting them — multiple workspace packages
  can now depend on `@snowluma/proto-defs` directly.
- Trims `@snowluma/core`'s surface: the bridge no longer owns the
  protocol schema, only its consumers.
- Sets up future #5 (`message-parsing perf`) and #6 (`Api-on-ctx
  refactor`) — both want a thinner core.

Layout
------
Per-file subpath exports (mirrors the legacy `proto/proton/<sub>`
shape 1:1). The barrel `index.ts` intentionally skips `highway` and
`oidb-actions/media` because the two files share six NTV2-prefixed
type names — `export *` would silently drop one side. Subpath imports
are unambiguous, which is also why every call site in core uses them.

Changes
-------
- New package `packages/proto-defs/` (package.json, tsconfig.json,
  src/index.ts).
- 13 `.ts` files moved via `git mv` to preserve history.
- 44 import sites across 26 source files + 15 tests rewritten via sed
  from `./[…/]proto/proton/<sub>` → `@snowluma/proto-defs/<sub>`.
- `@snowluma/core` depends on `@snowluma/proto-defs` (workspace:*).

Verification
------------
- proto-defs typecheck: clean
- core typecheck: clean
- core test suite: 587 passed (same baseline as before the refactor;
  one failure is the pre-existing websocket native addon, unrelated)
- proton test suite: 99 passed (8 files including the new
  cross-package.test.ts from the spike)
- Full workspace `pnpm -r typecheck`: all 8 projects clean

Note: proton's `dist/index.js` MUST be rebuilt for the vite plugin to
pick up the bare-specifier resolution. The previous commit's source
change is only effective after `pnpm -F @snowluma/proton build`.
2026-05-20 21:31:30 +08:00
手瓜一十雪 5ea1c3a961 Remove bridge proto schemas and parity test
Delete proto schema modules under packages/core/src/bridge/proto (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts, oidb-action.ts, oidb.ts) and the related test packages/core/tests/proton-parity.test.ts. These files contained protobuf schema definitions for the bridge layer; remove them as part of a cleanup/refactor. Update any imports or references to these modules to their new locations or replacements.
2026-05-20 18:23:03 +08:00
DrSmoothl 5d83aedd83 fix(typecheck): narrow ctx explicitly and type buildSendElems mock params
CI tsc flagged two strict-mode issues in d1aade6:

  * element-builder: `ctx?.forwardFake` doesn't carry the non-null
    narrowing to a subsequent `ctx.groupId` access — replace with an
    explicit `ctx && ctx.forwardFake` guard.
  * forward-file.test: `vi.fn(async () => [])` infers `mock.calls`
    as `[][]`, so `const [, ctx] = mock.calls[0]!` is "Tuple of length
    '0' has no element at index '1'". Declare the mock's parameter
    types (`unknown[], Record<string, unknown>?`) so the call tuple
    has the right arity, and use `ctx?.groupId` since the ctx slot
    is now typed as optional.
2026-05-20 14:07:36 +08:00
DrSmoothl d1aade6f87 fix(forward): keep file segments in long-msg payload via forwardFake flag
Previous fix split file segments off at the OneBot live-send layer
(c2c → sendC2cFileMessage, group → sendGroupFileMessage) and made
element-builder drop any `{type:'file'}` segment that reached it.
That broke forward messages: send_forward_msg encodes each node
through buildSendElems for the long-msg upload, so file segments
inside a forward node hit the drop-with-warn path and the forward
bubble shipped without the file.

Forward upload is NOT a live PbSendMsg — it stores the gzipped
protobuf verbatim through `trpc.group.long_msg_interface.MsgService.
SsoSendLongMsg`, and the recipient decodes file entities through the
normal msg-push path (transElem(24) → GroupFileExtra; msgContent →
FileExtra { file: NotOnlineFile }). So `result=79` doesn't apply here.

Add a `forwardFake?: true` flag on SendContext; when set:
  * group file → element-builder emits transElem(24) again (restored
    makeGroupFileElem, exactly the receive-side wire shape).
  * c2c file → element-builder still skips; forward-builder pulls the
    segment off the node and writes `body.msgContent` as
    FileExtra { file: NotOnlineFile }, with file metadata hydrated
    from the upload cache when inline fields are missing (same
    pattern as send_private_msg).

Mirrors NapCat's PacketMsgFileElement.{buildElement,buildContent}
split (dev/napcatQQ/.../packet/message/element.ts:530-610) and
Lagrange.Core V2's MultiMsgUploadService.cs.
2026-05-20 13:56:31 +08:00
DrSmoothl c83c333e73 fix(file): route group publish through OIDB 0x6d9_4 and cache upload meta for c2c resend
The chat-publish step after upload_group_file used to ship a PbSendMsg
with a transElem(elemType=24) — but that wire shape is RECEIVE-side
only, and current QQ-NT rejects it on send with `result=79`. Lagrange.
Core V2 uses a dedicated `OidbSvcTrpcTcp.0x6d9_4` for the publish
(`GroupSendFileService.cs`); mirror that. Send_group_msg with a
`{type:'file', file_id}` segment now routes through the same path
after the OneBot layer splits file segments out (matching the c2c
file split pattern).

Separately, send_private_msg with just `{file_id}` was shipping a 0 B
file because the wire packet for c2c file send needs fileSize/fileMd5/
fileName/fileHash but the OneBot caller only echoes the file_id from
upload_private_file. Add a bounded (1024-entry, FIFO) bridge cache
populated at upload time so the send-message path can rehydrate the
full tuple; inline segment fields still take precedence so callers
that track the metadata themselves can bypass the cache. Parser now
also accepts `file_hash`/`fileHash` on file segments.

Also: file segments no longer get a `[空消息]` log line — the format
string now shows `[文件:<name-or-id>]`.
2026-05-20 13:30:13 +08:00
DrSmoothl cec38b4c1b fix(forward): piggyback every nested level onto the outermost long-msg upload (NapCat model)
Cross-referenced `dev/NapCatQQ/.../SendMsg.uploadForwardedNodesPacket`
(208-347): every layer of a nested forward attaches its own msgBody to
the *outer* long-msg upload under a uuid `actionCommand` slot. Receiver
walks the whole tree from one server fetch instead of resolving each
layer's res_id separately.

`dev/Lagrange.Core` (V2 branch on `linwenxuan04/v2`,
`MultiMsgUploadService.cs:23-31`) only writes a single MultiMsg action
— that's why NapCat-style receivers couldn't follow our previous
nested-forward sends past the first level.

Refactor:

1. `ForwardNodePayload` gets an optional `innerForward?:
   ForwardNodePayload[]` field. `parseForwardNodes` no longer eagerly
   uploads inner chains — it just attaches them here.

2. `uploadForwardNodes` now drives the whole recursion. The new
   `uploadForwardNodesRecursive`:
   - walks each node, recursively uploads any `innerForward` first
   - replaces that node's `elements` with a real `{type:'forward'}`
     preview pointing at the inner res_id
   - piggybacks the inner level's `{uuid, msgBody}` + every deeper
     level it surfaced into this level's `myInnerActions`
   - encodes the long-msg payload as
     `[{MultiMsg, ownMsgBody}, ...innerActions.map(uuid+msgBody)]`
   - returns `{resId, msgBody, uuid, innerActions}` so an outer call
     can keep climbing

   Top-level public signature unchanged: `Promise<string>`.

3. New `deriveInnerSource` / `previewLinesFromNodes` /
   `previewFromElements` helpers synthesise the ARK preview metadata
   the inner bubble shows before the user taps to expand. They handle
   text/image/record/video/file/forward/face element previews.

4. `parseForwardNodes`: when `content` is an all-node array, attach
   the parsed inner chain to `node.innerForward` and let the upload
   pipeline take over. `elements = []` placeholder is replaced by
   `uploadForwardNodes` once it has the real res_id.

Tests:

- `actions/forward.test.ts`:
  - `uploadForwardNodes recursively uploads nested innerForward chains
    (NapCat piggyback model)` — asserts two SsoSendLongMsg roundtrips
    (inner first, then outer) and the outer res_id propagates up
  - `flat sends still take a single roundtrip` — backwards-compat

- `onebot/forward-nested.test.ts`: rewritten — mock now sees exactly
  one `uploadForwardNodes` call with `innerForward` attached, instead
  of two sequential calls; depth-limit + mixed-content tests
  unchanged.
2026-05-19 23:29:59 +08:00
DrSmoothl 04a905460b fix(file-upload): read upload host from rtpMediaPlatformUploadAddress (NT 2026 rollout)
The 2026 QQ-NT server has stopped populating *every* legacy host field
(uploadIp / uploadDomain / uploadIpList / uploadHttpsDomain / uploadDns).
The host now arrives in the new field 210 `rtpMediaPlatformUploadAddress`
— a repeated `IPv4` message carrying `inIP`/`inPort` (LAN) and
`outIP`/`outPort` (WAN). Coworker's debugging hint "host 好像是 fix64"
was the 8-12-byte length-delimited IPv4 message looking like a
fixed-width field on the wire.

Cross-referenced acidify (https://github.com/LagrangeDev/acidify) —
the only NT-protocol fork that's tracked this change (refactored in
their 2026-04 protobuf rewrite). They read `inIP`+`inPort` exclusively
because that's the address the highway PUT actually needs to reach
(same DC as the OIDB endpoint). LagrangeGo / Lagrange.Core / NapCat
still ship the old schema and would fail the same way against this
server build.

Changes:
- Proton schema: add `IPv4` message (fields 1-5: outIP/outPort/inIP/
  inPort/iPType, all int32 varint) + field 210 on the upload response
- Consumer: prefer `rtpMediaPlatformUploadAddress[0].inIP`+`inPort`
  before falling back through the legacy host chain
- New helper `int32ToIpv4Dotted` reverses acidify's `Int.toIpString()`
  (little-endian byte order, `>>>` for unsigned shift so high-bit IPs
  don't render as negative octets)
- Diagnostic warn-log now dumps the rtp address array too
- Regression test: 192.168.1.1 (LE-packed 0x0101A8C0 = 16885952) +
  inPort=8080 should reach highway PUT
2026-05-19 21:45:40 +08:00
DrSmoothl 87ac6ffedb fix(file-upload): fall back through parallel host fields when uploadIp is empty
User report: `upload_private_file` was failing with "private file upload
host is invalid" on a fresh upload (boolFileExist=false). The OIDB call
succeeded but `uploadIp` (field 60) came back empty, and we treated
that single field as the only source of truth.

Cross-referenced LagrangeGo's `OidbSvcTrpcTcp0xE37_800.proto` + NapCat's
`Oidb.0XE37_800.ts`: the server has six parallel host slots on this
response (uploadIp / uploadDomain / uploadIpList[0] / uploadHttpsDomain
/ uploadDns / uploadLanip). LagrangeGo + Lagrange.Core only read field
60 and would hit the same dead-end — we walk the chain instead. Also
log the full slot set when we genuinely can't find a host so future
reports can name which field the server actually populated.

Adds two regression tests: one proves the fallback to `uploadDomain`
goes to highway successfully, one proves we still throw when every
slot is empty.
2026-05-19 21:18:08 +08:00
DrSmoothl e637647877 fix(bridge): publish uploaded files as chat messages (the "empty message" bug)
`upload_group_file` / `upload_private_file` used to do only the
upload half of the napcat-equivalent flow:

  OIDB 0x6D6_0 alloc  →  highway PUT  →  return fileId

The fileId was real, the bytes were on QQ's CDN, the logs said
"uploaded" — but the chat showed nothing because no message was
ever sent referring to the upload. NapCat's `UploadGroupFile`
action (`dev/napcatQQ/.../UploadGroupFile.ts:54-58`) does the
upload AND immediately calls `MsgApi.sendMsg(peer, [fileElement])`
in the same action; that second step is what makes the file
visible in the chat.

Add the missing send step. Two paths because the wire shape
differs:

  - **Group files** ride on `Elem.transElem` (type 24) in the
    `RichText.elems` array. Add a `makeGroupFileElem()` helper +
    `case 'file':` in `element-builder.buildSendElems`, then
    `bridge.sendGroupMessage(groupId, [fileElement])` after a
    successful upload. The TransElem wraps a `GroupFileExtra`
    protobuf in `0x01 | BE16(len) | payload`, byte-symmetric with
    the receive-side decoder at `rich-body-decoder.ts:171-185`.
  - **C2C (private) files** live on `RichText.notOnlineFile`
    (field 3 on RichText, parallel to `elems`), so they can't go
    through `buildSendElems` at all. Add `Bridge.sendC2cFileMessage`
    that builds the `SendMessageRequest` with `notOnlineFile`
    directly and dispatches via `sendRawPacket`. The decoder side
    that lifts these back to `{type:'file'}` segments has lived
    at `rich-body-decoder.ts:425-433` the whole time.

Behavioural detail: both upload paths call the chat-publish step
*only* when `uploadFile=true` (the default). `uploadFile=false`
keeps the upload-only semantics for callers that just want a
fileId without dropping a message in the chat. If the chat post
fails for any reason (server rejection / network), the action
still returns the fileId with a warning log — the file is on QQ's
side, callers can re-publish if they want.

Regression coverage in `tests/actions/group-file.test.ts`:
  - publishes the file as `{type:'file'}` via sendGroupMessage on
    group upload
  - skips the post when uploadFile=false
  - returns success even when the chat post fails
  - same trio for private upload going through sendC2cFileMessage
  - and confirms private path does NOT route through the
    `sendPrivateMessage` elems[] pipeline

core: 618/618 tests pass (+5), typecheck clean. Production build
contains 8 substituted call sites for the new GroupFileExtra +
SendMessageRequest paths under proton.
2026-05-19 12:31:05 +08:00
DrSmoothl 5802812392 feat(proton): finish OIDB migration — runOidb on protobuf_encode<OidbBase<T>>
Closes the OIDB phase of the proton migration. Every consumer that
talked to the OIDB service through the old runtime
`makeOidbBaseSchema()` factory now goes through the compile-time
generic `OidbBase<T>` instead — proton monomorphizes the codec per
call-site instantiation.

@snowluma/proton — two analyzer fixes uncovered by the migration:

1. Skip queuing call-site type-args that reference an *enclosing
   generic function's* type parameter for monomorphization. Before
   this, a wrapper like

     export function encodeOidbEnv<T>(env: OidbBase<T>): Uint8Array {
       return protobuf_encode<OidbBase<T>>(env);
     }

   would cause the analyzer to mint a literal `OidbBase__T` registry
   entry with `body.typeName = 'T'`, which the post-walk wire-type
   guard then tripped on. The enclosing-scope type params are tracked
   on a stack as the walker descends into FunctionDeclaration,
   FunctionExpression, ArrowFunction, MethodDeclaration nodes; matching
   call sites are skipped (the wrapper-binding path is still
   responsible for materialising concrete codecs at downstream call
   sites).

2. Register *explicitly* empty interfaces (`interface X {}`) as
   zero-field proto messages. OIDB has a handful of these
   ('this call has no meaningful response body' placeholders —
   Oidb0xeb7Resp, Oidb0xcd4Resp, Oidb0x112aResp, Oidb0x8a0Resp,
   Oidb0xf16Resp, OidbClientKeyReq). Proto3 wire allows zero-field
   messages, and downstream code does `protobuf_decode<OidbBase<X>>`
   where X is one of these — the wire-type guard would otherwise trip
   on the missing `X` registration. Interfaces that have members but
   no pb-marked fields are still NOT registered.

@snowluma/core — the OIDB-layer refactor itself:

bridge-oidb.ts ditches the runtime ProtoSchema factory and exposes:
  - makeOidbEnvelope<T>(oidbCmd, subCmd, body, isUid)   pure helper
  - encodeOidbEnv<T>(env)                               pass-through
  - decodeOidbEnv<T>(bytes)                             pass-through
  - runOidb(bridge, cmd, envelopeBytes)                 non-generic raw

`encodeOidbEnv` / `decodeOidbEnv` are pure pass-through generic
wrappers — proton wrapper-binds them and substitutes every call site
with the inlined `protobuf_encode_OidbBase__X` / `protobuf_decode_…`
function. `runOidb` is non-generic (proton leaves it alone) so it can
be mocked in tests. `makeOidbEnvelope` is plain TS — also mockable for
introspection.

56 `runOidb` call sites across 11 files migrated to the new API:
  - bridge-contacts.ts, actions/extras.ts, actions/friend.ts,
    actions/group-admin.ts, actions/group-file.ts,
    actions/group-message.ts, actions/interaction.ts,
    actions/misc.ts, actions/profile.ts, web-actions/cookies.ts,
    highway/pipeline.ts

pipeline.ts also drops `makeOidbBaseSchema(NTV2UploadRichMediaReqSchema)`
and the residual `protoEncode({...}, EncodableMediaMsgInfoSchema)` —
both go through proton now.

Tests:
  - 8 test files migrated from `vi.mocked(decodeOidbEnv).mockReturnValueOnce`
    to `vi.mocked(runOidb).mockResolvedValueOnce(protobuf_encode<OidbBase<X>>({body: …}))`
    — the proton substitution means the old mock layer is bypassed at
    build time, so the only mockable point is the bytes layer at `runOidb`.
  - 2 new parity tests pinning `OidbBase<T>` ↔ legacy
    `makeOidbBaseSchema(InnerSchema)` byte equivalence.

Verification:
  - @snowluma/proton: 95/95 tests pass, tsc --noEmit clean
  - @snowluma/core:   605/605 tests pass (network-manager.test.ts
    fails to load due to a missing darwin-arm64 native binary in this
    environment — pre-existing), tsc --noEmit clean
2026-05-19 08:19:14 +08:00
时瑾 b3df633c87 fix(bridge): correctness pass on highway, hook, and identity service
* highway/utils detectImageFormat: JPEG SOF marker check now excludes
  0xC4 (DHT), 0xC8 (JPG), and 0xCC (DAC). The old `(marker & 0xFC) ===
  0xC0` matched DHT et al. and then read Huffman table bytes as image
  dimensions, producing junk width/height for many real JPEGs.
* loadBinarySource takes an optional maxBytes; defaults to 1 GiB to
  defend against an authenticated client triggering a multi-GB load
  into memory. Group/private file uploads pass FILE_UPLOAD_MAX_BYTES
  (4 GiB) because QQ's file protocol legitimately supports that size.
  HTTP fetches also pick up a 60s timeout and a Content-Length precheck.
* highway-client.tcpConnect detaches its error/timeout listeners after
  a successful connect so subsequent upload IO errors don't reject an
  already-resolved promise. readHttpResponseBody uses local listener
  refs + a central detach(), eliminating MaxListenersExceededWarning
  during multi-chunk uploads.
* hook/qq-hook-client.nextRequestId now wraps inside uint32 explicitly
  and skips id 0 / in-flight ids. The previous `nextRequestId++ >>> 0`
  loses precision past Number.MAX_SAFE_INTEGER (postfix increment
  evaluates before the shift) and could collide live request ids on
  very long-running processes.
* identity-service.rememberUidUin removes stale partner entries before
  inserting a new (uid, uin) pair. The old code left orphan uidByUin /
  uinByUid entries after a uid/uin re-bind, so the two maps could
  disagree.
2026-05-16 21:31:54 +08:00
DrSmoothl 97ad2e22bb feat(onebot): implement set_group_portrait via highway cmdId 3000
Port the Lagrange.Core protocol path for group avatar upload —
napcat's NTQQ GroupService.setHeader has no oidb wrapper, but
Lagrange.Core/Internal/Context/Logic/Implementation/OperationLogic.cs
spells out the wire shape: same highway HTTP endpoint as personal
avatar, but with cmdId=3000 and a GroupAvatarExtra proto carried as
the `extend` blob (type=101, groupUin, field3={field1=1}, field5=3,
field6=1 — all four are protocol-prescribed constants).

Adds:
- GroupAvatarExtraSchema in proto/oidb-action.ts
- bridge.setGroupAvatar(groupId, source) wrapping the highway upload
- BridgeInterface.setGroupAvatar
- OneBot set_group_portrait action (was a stub returning
  'not yet implemented'; accepts the same file-source variants as
  set_qq_avatar)

5 new tests: 3 bridge-action (cmdId, extra-blob round-trip, empty-file
guard) + 2 OneBot (forwarding, param validation) — also tightens the
existing setAvatar test to assert the extend blob stays empty.
2026-05-15 19:47:35 +08:00
DrSmoothl f7457e3e59 feat(onebot): add set_diy_online_status
DIY online status reuses the existing StatusService.SetStatus wire
call. napcat hardcodes status=10 / extStatus=2000 and threads the
faceId / wording / faceType through a customExt sub-message (field 4
of SetStatus, per napcat packet/transformer/proto/action/action.ts).

Implementation:
- Extend SetStatusReqSchema with optional customExt field. Encoder
  skips undefined fields so the existing set_online_status path stays
  byte-identical on the wire (verified by a new round-trip test).
- New bridge.setDiyOnlineStatus(faceId, wording, faceType) wraps the
  same dispatchSetStatus helper as setOnlineStatus.
- OneBot action set_diy_online_status accepts napcat-style payloads
  (face_id / face_type can be string or number; wording defaults to '').

9 new tests across actions/profile and onebot/extended-actions.
2026-05-15 19:26:40 +08:00
DrSmoothl ee62f5aa0b feat(onebot): add napcat-parity actions (Tier 1 + Tier 2)
Wires 16 OneBot actions that napcat exposes but SnowLuma previously
either lacked or stubbed:

Tier 1 (pure glue over existing Bridge capabilities)
- send_packet / .send_packet: real impl over Bridge.sendRawPacket
- bot_exit, nc_get_packet_status, nc_get_rkey
- get_group_ignored_notifies / get_group_ignore_add_request: project
  fetchGroupRequests(filtered=true) into napcat shapes (was empty stub)
- delete_group_folder alias for delete_group_file_folder

Tier 2 (new oidb proto + Bridge method)
- set_/complete_/cancel_group_todo (oidb 0xF90_1/2/3)
- nc_get_user_status (oidb 0xFE1_2 with napcat's status decode)
- get_ai_characters / get_ai_record / send_group_ai_record (oidb
  0x929D / 0x929B; AI voice reuses the existing fetchGroupPttUrlByNode)

BridgeInterface now exposes sendRawPacket as the deliberate escape
hatch for send_packet — every other surface method already wraps it.

46 new tests: 13 bridge-action (mocked runOidb dispatch checks) + 33
OneBot-action (ApiHandler.handle end-to-end).
2026-05-15 19:22:35 +08:00
DrSmoothl 59cdfa98a2 refactor(oidb): replace 3 positional helpers with one named runOidb
bridge-oidb.ts used to expose three thin functions —
makeOidbRequest / sendOidbAndCheck / sendOidbAndDecode — and every
action file repeated the same five-step envelope assembly with long
positional argument lists. Collapse them into one `runOidb(bridge,
call)` taking an OidbCall object: cmd / oidbCmd / subCmd / request
{ schema, value, isUid? } / response? { schema }.

Behavioural contract is preserved:
  - No `response`: verify success + gotResponse, peek envelope retCode
    iff response data is present (fire-and-check semantics).
  - With `response`: require responseData, decode through the base
    schema, validate retCode, return the inner body typed as TResp.

Migration touches 51 call sites across 9 source files (bridge-contacts,
actions/{group-file, group-admin, profile, group-message, friend,
interaction, misc}, web-actions/cookies) plus 9 test files that used
to assert on positional `.mock.calls[0]![4]` indices and now read
through `.mock.calls[0]![1].request.value` / `.cmd` / `.oidbCmd`.

Net effect: callers now name what each piece is for, and adding
cross-cutting OIDB behaviour (retry / timeout / per-cmd logging) is a
single-site change inside runOidb instead of a sweep across 9 files.
2026-05-15 01:03:25 +08:00
DrSmoothl ac10c663f1 refactor(identity): absorb QQInfo into IdentityService
QQInfo's in-memory state (self identity, friends, group rosters,
profile cache) and read methods (findFriend / findGroup /
findGroupMember / findUserProfile / nickname / uin / selfProfile) now
live directly on IdentityService. The QQInfo class is gone; its
interfaces stay in `qq-info.ts` as plain types shared across the
remember*/find* surface.

This eliminates a long-standing duplication: every observation used to
maintain both QQInfo's in-memory structures and IdentityService's
SQLite mirror in parallel, with the uidByUin/uinByUid Maps tracked
separately. With one Module owning all three layers, every remember*
hydrates one set of structures and `loadSnapshot` repopulates the
in-memory uid↔uin index from SQLite on startup — closing the cold-cache
gap that previously forced findUidByUin to fall through to a SQLite
query after every restart.

Knock-on changes:
  - `Bridge` no longer holds a QQInfo; constructor takes an
    `IdentityService`. The CmdParser signature on the cmd dispatch
    table now passes `IdentityService` to parsers.
  - `msg-push-handler.ts` drops its private `resolveUidToUin(qqInfo, …)`
    helper that bypassed IdentityService's cascade — the parser hot
    path now reads through `identity.findUinByUid(uid, groupId)` and
    benefits from the O(1) `uinByUid` map even on freshly-loaded
    sessions.
  - `BridgeManager`'s session-started callback drops the QQInfo
    parameter; OneBotInstance no longer carries a `qqInfo` field and
    exposes a `nickname` getter for the WebUI listing.
  - `contact-actions` and the per-theme bridge action files stop
    importing QQInfo; they read everything via `bridge.identity.*`.
2026-05-15 00:44:01 +08:00
DrSmoothl 030336a25c refactor(identity): consolidate UID resolution behind IdentityService
Pull the cache→DB→network-fetch fallback cascade for UIN→UID translation
into a single async `resolveUid` on IdentityService, and drop the
duplicated `resolveUserUid` helper that used to live in bridge-oidb. Bridge
injects itself as the IdentityFetcher post-construction so the network
fallback runs through the same OIDB profile fetch the helper used.

Each action module's `resolveUserUid(bridge, uin, groupId?)` call becomes
`bridge.resolveUserUid(uin, groupId?)` — same signature on Bridge, but
internally it now goes through `identity.resolveUid` so future changes
(retry, coalescing, alternative resolvers) only need to land in one place.

This is part 1 of the IdentityService deepening (RoadMap item 1). The
QQInfo bypass in msg-push-handler and the broader QQInfo absorption land
in the follow-up commit.
2026-05-15 00:32:59 +08:00
DrSmoothl dd02535a73 chore(core): flatten single-file dirs + drop dead writeFailed surface
Two small cleanups that came out of the round-2 architecture review
and are sub-five-minutes each — bundled because each one is too
small to justify its own commit.

#5 — flatten single-file directories
  packages/core/src/bridge/builders/element-builder.ts
    -> packages/core/src/bridge/element-builder.ts
  packages/core/src/bridge/handlers/msg-push-handler.ts
    -> packages/core/src/bridge/msg-push-handler.ts

  Both dirs had exactly one file in them — a smell that says
  "planned a sibling that never landed". Moving up one level
  removes the wrapper directory.

  Touch list:
    - bridge.ts:                  2 import paths
    - bridge/actions/forward.ts:  2 import paths
    - msg-push-handler.ts:        internal ../X -> ./X for events,
                                  proto/element, proto/message (the
                                  dynamic-import-typeof references too)
    - element-builder.ts:         internal ../X -> ./X for bridge,
                                  events, highway/*
    - 4 test files:               import paths (2 regular, 2 vi.mock)

#6 — drop IdentityService.hasWriteFailure
  identity-service.ts had a `private writeFailed = false` field set
  by the runWrite catch block and exposed via `get hasWriteFailure()`,
  but a full-repo grep showed zero callers of the getter. It was a
  public API stub for a feature that hadn't been wired (a webui
  health indicator, presumably).

  Removed:
    - the field
    - the getter
    - the assignment in runWrite's catch block (the error log on
      the next line is the actual observable behavior; nothing else
      reads the flag)

  If we want a "did any write fail this session" surface later,
  it's trivial to add back with an explicit consumer in mind.

Behavior preserved end-to-end. No public API change outside the
moved files. 369 tests pass (unchanged). 3 stability runs.
2026-05-12 20:59:25 +08:00
DrSmoothl 8506b60d07 refactor(core): split bridge-actions.ts (1814 lines) into themed action modules
bridge-actions.ts was a single 1814-line file holding 55 unrelated
exported functions — admin/file/forward/interaction/recall/profile/
status/translate/sign/avatar all jumbled together. Each function
already takes `bridge: Bridge` as its first parameter, so the
"deepening" target (per-UIN state object threaded into themed action
functions) was structurally there — what was missing was a coherent
file-level grouping that AI tools and humans could navigate.

This commit is candidate #2 from the deepening list, A-only variant:
pure file split, no facade change, no caller change outside the
bridge module.

New layout under packages/core/src/bridge/actions/ :

  shared.ts        82  private helpers (toInt, ensureRetCodeZero,
                       resolveSelfUid) and the MediaIndexNode type.
  group-admin.ts  153  mute/kick/leave + setGroup{Admin/Card/Name/
                       SpecialTitle/AddOption/Search/Remark/AddRequest}
                       + getGroupAtAllRemain.                (14 fns)
  group-file.ts   821  upload/list/url/delete/move/folder ops, both
                       group + private files, media-URL fetchers for
                       ptt/video, fetchGroupFileCount. Owns file-
                       specific helpers (normalizeDirectory,
                       buildGroupFileUploadExt, normalizeMediaNode,
                       fetchNtv2DownloadUrl, ...).            (15 fns)
  group-message   101  recall* / markRead* / setGroupEssence.  (5 fns)
  interaction.ts   61  poke / like / reaction / emoji-likes.   (4 fns)
  forward.ts      197  uploadForwardNodes, fetchForwardNodes +
                       in-memory resId cache.                  (2 fns)
  friend.ts        53  setFriendAddRequest, deleteFriend,
                       setFriendRemark.                        (3 fns)
  profile.ts      269  setOnlineStatus, setProfile, setSelfLongNick,
                       setInputStatus, setAvatar, fetchCustomFace,
                       getProfileLike, getUnidirectionalFriendList.
                                                              (8 fns)
  misc.ts         173  translateEn2Zh, getMiniAppArk,
                       clickInlineKeyboardButton, sendGroupSign.
                                                              (4 fns)

bridge.ts updates its imports — one themed import block per action
module instead of one giant block from bridge-actions.ts. GroupFilesResult
and MediaIndexNode type imports now point at actions/group-file and
actions/shared. Public Bridge facade API is unchanged: every caller
outside `bridge/` keeps the same `bridge.X(...)` surface and the
delegated function it points at.

bridge-actions.ts is deleted. The new shared.ts holds the three
helpers that span themes; everything else lives co-located with the
functions that use it.

Adds 60 happy-path tests across tests/actions/ — one .test.ts per
themed file plus a shared `_helpers.ts` (mockBridge fixture):

  group-admin     10  command + payload dispatch, UID resolution,
                      flag-driven sub-command selection (mute on/off,
                      add-request filtered/unfiltered).
  group-file      13  fetchGroupFileCount, upload happy path with
                      and without highway, OIDB error bubbling, file
                      listing pagination + folder/file split, URL
                      builder shape, delete/move/folder dispatch,
                      NTV2 URL assembly.
  group-message    5  recall (group/private), markRead, essence on/off,
                      transport failure propagation.
  interaction      6  poke (group vs friend payload), like, reaction
                      on/off, emoji-likes cookie/isLast logic.
  forward          5  empty-array rejection, dispatch + res_id return,
                      transport failure, cache reuse after upload.
  friend           5  numeric flag → UID resolution path, opaque flag
                      passthrough, delete + best-effort cache refresh.
  profile          9  status dispatch, setProfile no-op when undefined,
                      setAvatar highway cmd 90, getProfileLike +
                      getUnidirectionalFriendList JSON parsing.
  misc             6  translate, mini-app type guard, click + sign
                      dispatch.

Tests use vi.mock on `bridge-oidb` (and `highway-client`/`highway-utils`
where relevant) so each action runs against a tiny fake Bridge, no
native pipe / real OIDB exchange needed. Previously these 55 functions
had zero unit-test coverage.

244 tests pass (was 184); only failing suite remains the pre-existing
macOS-arm64 missing websocket native addon, unrelated.
2026-05-12 16:51:34 +08:00