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

Also: removed extensive JSDoc/comment blocks from channel/adapter/channel-ctx/core/hub files to slim the public API surface, reordered/cleaned exports in packages/channel/src/index.ts, added a new hub-events.ts placeholder, and cleaned up a test helper comment. Disposal of replaced channels is wrapped with a try/catch and logged at debug level.
2026-05-27 18:07:03 +08:00
手瓜一十雪 af8d5ab06b Split channel packages and rename Account→Core
Reorganize package layout and rename core/account types: - Move channel hook & socket sources/tests into packages/channel-hook and packages/channel-socket (many files renamed). - Remove ChannelManager from packages/channel and drop its export from package.json (multi-account host folded into Hub). - Update tests to use HookSink instead of BridgeManagerSink. - In @snowluma/core: move account/apis/* up to src/apis, rename account-context→core-context, account-interface→core-ctx, account→core, and add hub.ts; delete AccountManager. - Update constructors and type usages to accept CoreContext/CoreCtx and adapt identifiers accordingly. This is a refactor to clarify responsibilities (split transport implementations, fold multi-account manager into Hub, and rebrand the account layer as Core).
2026-05-27 14:11:45 +08:00
手瓜一十雪 adf0744f75 Rename bridge → channel and reorganize packages
Move/rename the transport/bridge code into a new @snowluma/channel layout and extract hook implementations into channel-specific packages. Many bridge sources were deleted and equivalent channel files added (adapter/ctx/interface/channel/hook/socket pieces moved/renamed), along with tests and configs relocated under packages/channel. RoadMap.md replaced with a detailed v3 architecture plan describing Channel/ChannelCtx/Core/CoreCtx/Hub boundaries and migration steps. Also apply related core package updates (account/core reshaping and package.json adjustments) to match the new architecture.
2026-05-27 13:39:17 +08:00
手瓜一十雪 652bafd75a Extract bridge transport into @snowluma/bridge
Move transport-layer bridge code out of @snowluma/core into a dedicated @snowluma/bridge package and introduce a clear account layer in core. Adds Bridge, BridgeInterface, InjectBridge, ProtocolBridge (stub), adapters, and manager files under packages/bridge and updates package.json exports. In @snowluma/core the former bridge surface is replaced by Account, AccountContext, AccountInterface and AccountManager; apis were moved/renamed to account/apis and now consume AccountContext. Adjustments include packet handling plumbing (use deliverPacket / setPacketHandler), updated sendRawPacket wiring, uploaded-file cache and lifecycle changes (InjectBridge.dispose, ProtocolBridge returns a structured not-implemented result). Tests and imports were updated accordingly.
2026-05-27 12:27:13 +08:00
手瓜一十雪 5440cbc37e Refactor bridge system: adapters & transports
Introduce a transport-adapter layer and refactor the bridge abstractions to be transport-agnostic. Added BridgeAdapter and BridgeAdapterHost (packages/core/src/bridge/adapter.ts) plus concrete InjectBridge/InjectBridgeAdapter and ProtocolBridge/ProtocolBridgeAdapter implementations (new files). Made Bridge an abstract base exposing kind/id and abstract sendRawPacket, moved transport-specific PID/sender logic into InjectBridge. Reworked BridgeManager to register/start adapters, manage bridges by id/uin, pick a primary per-UIN (inject preferred over protocol), and provide a host API for adapters to add/remove bridges. Updated index bootstrapping to register adapters and wire the WebUI to the inject adapter's HookManager. Adjusted tests and OneBot code to remove PID tracking from OneBotInstance/OneBotManager and to instantiate InjectBridge in tests. Also exported new entry points in package.json for adapter/bridge modules. This decouples transports from manager logic and adds a stubbed protocol runtime for future pure-protocol support.
2026-05-27 11:49:43 +08:00
手瓜一十雪 456086599c Normalize imports, typings and formatting
Reorder and normalize imports, add missing type imports, and fix whitespace/formatting across multiple files. Notable updates: eslint.config.ts import ordering; core bridge APIs (group-album, misc, web) now import Json types; tests/highway pipeline import ordering and added type imports; protocol changes (element-builder, highway client/pipeline) reorganized imports and added proto type imports; many oidb service modules moved protobuf encode/decode imports for consistency; web group modules (album, essence, honor, notice, request-util) reordered imports, adjusted typings/indentation, and small whitespace fixes. These edits are stylistic and typing improvements and should not change runtime behavior.
2026-05-24 13:39:35 +08:00
手瓜一十雪 80ee12ecc2 Enforce no-explicit-any; allow in tests
Enable @typescript-eslint/no-explicit-any as an error in the ESLint config to tighten typing across the codebase, and add an override to disable this rule for test files. Also apply non-functional cleanup in packages/proton/src/runtime.ts: reorder/imports and adjust formatting/indentation and string alignment for readability — no behavioral changes.
2026-05-24 13:38:13 +08:00
DrSmoothl f53df5e313 refactor(packages): extract @snowluma/bridge (hook + QQ lifecycle)
Split the QQ NT client lifecycle / hook injection layer out of
@snowluma/core into its own self-contained package. The new bridge
package owns:
  - native hook addon loader (injector)
  - HookManager + per-PID HookSession orchestration
  - PipeWatcher for QQ process discovery
  - QqHookClient pipe protocol
  - qq-port-probe for early UIN sniffing

The hook layer used to type its main collaborator as
`BridgeManager` directly. To keep the new package free of any
dependency on @snowluma/core, the cross-package coupling is now
expressed via a 3-method `BridgeManagerSink` interface that lives
in the bridge package. BridgeManager (in core) satisfies the
interface structurally with no source changes; the top-level
app/webui still inject it the same way.

Pure file relocation otherwise: git mv preserves history at 100%
on every file, the only logic edit is the interface introduction
in hook-manager.ts and an updated `nativeSearchDirs()` offset
(one fewer `..` since hook now lives at packages/bridge/src
instead of packages/core/src/hook).
2026-05-23 23:40:57 +08:00
DrSmoothl c16064d7e3 refactor(packages): rename @snowluma/bridge → @snowluma/protocol
The package previously named "bridge" houses the protocol-byte layer
(OIDB cmds, msg-push decoders, highway uploaders, element-builder,
event bus). The name "bridge" suggested transport/lifecycle, which it
isn't — that role lives in core/hook and core/bridge/manager. Rename
to match what's actually inside.

This is a pure rename: zero logic changes, all 73 import sites updated
mechanically and TS-verified. core/onebot dependencies key updated.
ESLint override path adjusted for the moved oidb-services directory.

Rename detection threshold: git log --find-renames=20% recommended
when following history across this commit — content-replacement
(@snowluma/bridge → @snowluma/protocol) dropped per-file similarity
below git's default 50% on import-heavy files.
2026-05-23 23:31:03 +08:00
DrSmoothl 4755a91cb3 fix(image): use Chinese summary defaults to match QQ ecosystem
Mobile QQ, Lagrange.Core, and NapCat all use the literal Chinese
bubble-preview strings `[图片]` / `[动画表情]`. SnowLuma was emitting
English `[image]` / `[sticker]` as fallback, so QQ clients showed
"[image]" in chat-list previews instead of the expected Chinese.

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

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

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

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

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

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

Findings:

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

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

Hardening:

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

If the user reports the same 921 again, the new diagnostic message
should show enough server-side context to triage server-vs-client.
2026-05-23 13:06:09 +08:00
DrSmoothl cd9403f27a fix(forward): align resid+uniseq for nested forwards
Multi-layer 合并转发 was uploading correctly but the outer's
LongMsgResult piggyback was *unreachable* by the receiver because
two things were missing:

  1. The forward preview element was emitted as the old XML
     `richMsg serviceID=35 m_resid=…` shape, which carries the
     inner resId but has NO uniseq field.
  2. The piggyback `actionCommand: <uuid>` slot generated a fresh
     UUID that was never reflected into the preview either.

Result: QQ-NT receivers saw the inner preview's resid, scanned the
outer's actions[] for a matching uniseq, found none, and either
fell back to a fresh server fetch per layer or dropped the layer
entirely.

Cross-checked against:
  - dev/Lagrange.Core/Lagrange.Core/Message/Entity/MultiMsgEntity.cs:43-115
  - dev/NapCatQQ/packages/napcat-core/helper/forward-msg-builder.ts:52-122
  - dev/NapCatQQ/packages/napcat-onebot/action/msg/SendMsg.ts:208-347

Both upstream impls use the modern `com.tencent.multimsg` LightApp
JSON whose `meta.detail.uniseq` is the round-trip key that links
the inner preview to the matching `actionCommand` in the outer's
LongMsgResult — no separate fetch needed.

Changes:
  - `element-builder.makeForwardElem` now emits a deflated
    `com.tencent.multimsg` LightApp JSON (LightAppElem, field 51)
    instead of the XML RichMsg (field 12). `forwardUuid` on the
    MessageElement is baked into `meta.detail.uniseq` and
    `extra.filename`. Auto-generates a UUID when absent.
  - `MessageElement.forwardUuid?: string` added for the round-trip.
  - `forward.uploadRecursive` threads `inner.uuid` into the outer's
    preview element AND uses the same uuid for the outer's
    piggyback actionCommand — closing the receiver-side lookup loop.
  - `forward.fetch` now walks ALL actions (not just `MultiMsg`),
    indexes piggybacks by uuid, and pre-populates the cache for
    each inner resId discovered in the outer's preview bubbles.
    Follow-up `fetch(innerResId)` hits the cache instead of the wire.
  - `rich-body-decoder` parses LightApp JSON; when `app ===
     com.tencent.multimsg`, emits `{type: 'forward', resId,
     forwardUuid}` so the OneBot layer can walk in. Legacy XML
     serviceID=35 path stays as a fallback for mobile QQ senders.

Wire-byte tests cover:
  - send: lightApp.data is 0x01-prefixed deflated JSON with the
    right meta + extra fields
  - send/nested: outer LongMsgResult carries `MultiMsg` AND a
    uuid-keyed piggyback; the piggyback uuid matches the
    preview's `meta.detail.uniseq` baked into the same outer
    msgBody
  - send/3-deep: outermost action[] has 1 + 2 entries (own
    MultiMsg + 2 piggybacks hoisted from deeper levels)
  - recv: lightApp `com.tencent.multimsg` → forward element with
    both resId and forwardUuid; non-multimsg LightApp falls
    through to {type:'json'}; legacy XML path still emits
    {type:'forward'}.

641 tests pass (was 628 + 13 new); typecheck clean; lint clean.
2026-05-23 12:45:26 +08:00
DrSmoothl 521808a6eb refactor(oidb): migrate group-file.ts OIDB cmds to namespaces
Splits the 16 OIDB cmds from GroupFileApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-file/. Adds
@snowluma/bridge/oidb-services/shared with `toInt` / `ensureRetCodeZero`
helpers so namespaces can validate sub-responses without an
@snowluma/core dependency.

Boundaries:
  - Single-OIDB methods (publish / getCount / list-page / *url /
    delete / move / *folder) become one-line forwarders.
  - 3-stage methods (`upload` / `uploadPrivate`) keep their
    orchestration on the facade — OIDB preflight goes through the
    new namespace, but highway PUT + publish/sendC2cFile + upload-
    cache bookkeeping stay where they are.
  - `list` keeps its pagination loop on the facade; the page-fetch
    namespace is one call.
  - The NTV2-rich-media URL fetchers (group/private × ptt/video)
    share a deserialize helper (`parseNtv2DownloadUrl`) so all four
    namespaces only differ in their scene block.

Wire-byte equivalence preserved. The 0x6D6 family shares one
OidbGroupFileReq envelope; each namespace populates a different
slot (file / download / delete / move) and proton monomorphizes
per call site as before.
2026-05-23 00:22:59 +08:00
DrSmoothl 8d22868777 refactor(oidb): migrate group-admin.ts OIDB cmds to namespaces
Splits the 14 OIDB cmds from GroupAdminApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-admin/. The
facade becomes one-line forwarders; per-cmd capability is narrowed
to OidbSender (+ resolveUserUid / identity where the cmd actually
needs it).

Notes:
  - 0x89A_0 hosts MuteAll, SetAddOption, SetSearch, SetGroupName —
    same (cmd, subcmd) tuple, four different proto bodies; each gets
    its own namespace so proton can monomorphize independently.
  - 0x8A0_1 splits KickMember (single) and KickMembers (batch) for
    the same reason.
  - SetAddRequest carries uinForm=true and dynamic subCommand
    (filtered ? 2 : 1) on the same namespace.
  - GetAtAllRemain needs the bot's own uin → declares
    Pick<BridgeContext, 'identity'> in its Deps.

Wire-byte equivalence preserved; corner case for getAtAllRemain
empty-envelope path now returns {false, 0, 0} instead of throwing —
the new infrastructure normalises empty bodies and the OneBot action
ergonomics are unchanged.
2026-05-23 00:06:40 +08:00
DrSmoothl 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 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 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 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 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
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
手瓜一十雪 aea2676c31 Add TypeScript ESLint configs and lint scripts
Add a repository .editorconfig and a root eslint.config.ts to standardize formatting and linting. Migrate packages/webui ESLint config to TypeScript (eslint.config.ts) with project-specific rules and tsconfigRootDir handling, and remove the old JS config. Update package.json to run ESLint directly (add lint and lint:fix) and add required ESLint/dev dependencies; also add jiti to packages/webui/package.json. Include a small formatting tweak to tsconfig.base.json.
2026-05-20 19:08:45 +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 cc574632f1 fix(test): declare vi.fn param types so mock.calls[0] infers tuple
`vi.fn(async () => undefined)` infers `mock.calls: [][]`, so
destructuring `const [groupId, fileId] = ...mock.calls[0]!` fails
under TS strict tuple checks (`error TS2493: Tuple type '[]' of
length '0' has no element at index '0'`). Declare the param types
inline so the call-args tuple is `[number, string]`.
2026-05-20 13:32:35 +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
手瓜一十雪 6de2e071be Forward image sub_type and summary fields
Include optional element.subType and element.summary in the image segment payload (mapped to sub_type and summary) so custom emoji/sticker metadata is preserved. Adds a unit test to verify these fields are forwarded.
2026-05-20 10:52:43 +08:00
DrSmoothl 1db287d8cc fix(c2c-file): rewrite sendC2cFileMessage wire shape per Lagrange.Core V2
The c2c file send path was wrong on three independent axes — every
c2c file the bot tried to deliver was getting rejected by the QQ-NT
server. Aligned to `dev/Lagrange.Core` (V2 branch) which is the only
direct-protocol reference for c2c files; NapCat goes through QQ-NT
IPC and never builds this packet itself.

Three wire-shape fixes:

1. **Routing**: `routingHead.c2c { uin, uid }` → `routingHead.trans0x211
   { ccCmd: 4, uid }`. RoutingHead field 15 is the c2c-file scene's
   slot. `MessagePacker.BuildPacketBase` makes this conditional on
   `chain.HasTypeOf<FileEntity>()`: when a file is present, C2C goes
   nil and Trans0X211 carries the routing.

2. **File payload location**: `messageBody.richText.notOnlineFile {...}`
   → `messageBody.msgContent = serialize(FileExtra{file: NotOnlineFile})`.
   The server doesn't read c2c file metadata from `richText` at all —
   `MessageBody.cs:12` even has the comment "Offline file is now put
   here(?". Confirmed by `FileEntity.PackElement()` returning empty
   and `PackMessageContent()` returning the FileExtra wrapper.

3. **contentHead.c2cCmd**: `11` → `0` (default/omitted). `11` was a
   stale go-cqhttp value the QQ-NT server doesn't recognise on the
   c2c-file dispatcher. Lagrange leaves it at the C# default (0).

Schema fixes (proton + legacy, both directions):

- `RoutingHead` gains `trans0x211?: pb<15, RoutingTrans0x211>`. The
  new message has `toUin@1`, `ccCmd@2`, `uid@8`.
- `SendMessageBody` gains `msgContent?: pb<2, bytes>` (the receive-side
  `MessageBody` already had it).
- `FileExtraInfo` deleted — was wrong on every tag (fileSize=1,
  fileName=2, fileMd5=3, fileUuid=4, fileHash=5, none of which match
  the wire). `FileExtra { file: pb<1, NotOnlineFile> }` now wraps the
  same `NotOnlineFile` used on the receive path. This is why c2c file
  PUSHES were also silently failing the four-field truthy check in
  `extractMsgContent` — the schema couldn't decode anything.
- `NotOnlineFile` gains the three intake-validator fields the server
  requires on send: `subcmd@9 = 1`, `dangerEvel@50 = 0`, `expireTime@55`
  = now+7d.

Receive side (`rich-body-decoder.ts::extractMsgContent`): drop the
"all four fields must be truthy" guard (it was a workaround for the
broken schema) and accept any payload that decodes a fileUuid.

Tests:

- `bridge-private-routing.test.ts`: new regression that round-trips
  `sendC2cFileMessage` through `protoDecode` and asserts trans0x211
  routing, msgContent FileExtra wrapper, NotOnlineFile field tags,
  c2cCmd=0. Catches each of the three wire-shape regressions
  independently.
- `send-private-c2c-file.test.ts`: vi.fn arg signatures explicit for
  CI typecheck.

647/647 tests pass.
2026-05-19 23:45:40 +08:00
DrSmoothl f65fe959bb fix(send-private): split c2c file segments out to sendC2cFileMessage (was shipping "[空消息]")
User report: `send_private_msg` with a `{type:'file', file_id}` segment
fired `[ElemBuilder] file send via elems[] is group-only; use bridge.
sendC2cFileMessage for c2c` and the message went out as "[空消息]".

Root cause: c2c files live on `RichText.notOnlineFile` (parallel to the
elems array, see proto/proton/message.ts), not inside `elems[]`. The
element-builder explicitly drops c2c `{type:'file'}` elements with a
warning. `sendPrivateMessage` was passing the file straight to
`bridge.sendPrivateMessage(userId, [fileElement])` which then ran
through `buildSendElems`, lost the file at the drop, and shipped a
0-element wire send — rendered as "[空消息]" by every receiver.

Fix: split at the OneBot layer in `sendPrivateMessage`. Pull file
elements out of the batch and dispatch each via
`bridge.sendC2cFileMessage`; route the rest through the regular
elems[] path. Matches NapCat's `dev/NapCatQQ/.../SendMsg.ts:404-415`
where FILE / VIDEO / ARK / PTT each get their own sendMsg call and
never mix with the regular elems[] batch.

- File-only message: only the c2c file API is called (single roundtrip)
- Mixed text + file: two sends, text first then file
- File without file_id: dropped with a warn (was silently shipping
  empty messages)
- UID resolution failure: surfaced as throw instead of empty send

Also rewrote the element-builder fallback warn from "use
sendC2cFileMessage for c2c" (now stale advice) to "BUG: c2c file
reached element-builder — should be split out at the OneBot layer".

Tests: 4 new regression cases under
`tests/onebot/send-private-c2c-file.test.ts`. 646/646 pass.
2026-05-19 23:36:12 +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 0b8dc1f7af fix(video): populate NTV2 fileInfo.time with the real duration (receiver was rendering 00:00)
The NTV2 upload request for video carried `time: 0` on the main mp4
sub-file, mirroring NapCat's transformer. But NapCat lives on top of
the desktop QQ-NT client's IPC layer, which patches the duration into
the resulting MsgInfo before the wire send — we don't have that layer,
so the `time: 0` we passed in ended up baked into the `VideoFile.
fileTime` field of every video the bot sent. Receivers then rendered
every video bubble as "00:00".

Cross-checked acidify's `RichMediaUpload.kt::buildVideoUploadInfoList`
(2026-04 refactor) — they're a protocol-direct client too and write
`time = payload.videoDuration` for exactly this reason. We already
have `video.duration` from `loadVideo` (probed via ffprobe), so it's
a one-liner.

Width/height kept at 0 deliberately — acidify fills them, NapCat
zeros them, and our earlier comment recorded "QQ NT server treats
non-zero values here as schema mismatch on c2c sends". Out of scope
for this fix; revisit if a c2c regression surfaces.

Test asserts `fileInfo.time === 10` for the main sub-file (matches
FINGERPRINT.duration) and stays 0 on the thumb.
2026-05-19 22:20:38 +08:00
DrSmoothl 8f839f4f30 fix(group-file): correct GroupFileExtra wire shape — server was rejecting with result=79
Two off-by-one bugs in the group-file chat-post element that combined
to make the QQ-NT server reject every group file send with result=79
(visible as "group file uploaded but chat post failed" in the logs):

1. Inner GroupFileInfo fields were shifted one tag left:
   - fileSha    was field 5, should be field 6
   - extInfo    was field 6, should be field 7
   - fileMd5    was field 7, should be field 8
   Field 5 is actually a uint32 placeholder (`field5` in both NapCat
   and acidify). With the bug, our fileSha bytes were landing where
   the server expected a varint and the rest of the message got
   skipped as garbage.

2. Outer GroupFileExtra was missing the magic field1=6 + fileName
   slots that NapCat's encoder writes
   (`packet/message/element.ts:589`). Acidify's schema also models
   them as required even though it serialises with defaults. The
   current server build rejects sends without these slots populated.

Cross-referenced:
- NapCat `GroupFileExtraInfo` (component.ts:146-155) — matches our
  new field numbers exactly
- acidify `GroupFileExtra.Inner.Info` (proto/message/extra/GroupFileExtra.kt)
  — same field numbers; their schema uses generic `field5`/`field7`
  names but the wire layout is identical

Changes:
- Proton + legacy schemas: shift inner fields, add outer field1/
  fileName/display
- `makeGroupFileElem`: write field1=6 + fileName on the outer wrapper
- Regression test asserts each inner tag lands at 50/58/66 (not the
  old 42/50/58) and verifies the new outer field1 + fileName bytes
  are present
2026-05-19 21:51:35 +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