Commit Graph
1191 Commits
Author SHA1 Message Date
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 c92daa56df refactor(api): move 6 group-album methods to GroupAlbumApi (#6 commit 5/13)
Inlines `actions/group-album.ts` (OIDB sendRawPacket via QunAlbum.trpc.*)
and `web-actions/group-album.ts` (HTTP cookie-based) into
`apis/group-album.ts::GroupAlbumApi`. Both action files deleted. Pure
HTTP helpers in `web/group-album.ts` stay where they are — they're
transport-layer code.

Method renaming follows the now-standard "drop the redundant suffix"
rule (the Api namespace already says `groupAlbum`):

  getGroupAlbumList         → list           (HTTP)
  uploadImageToGroupAlbum   → upload         (HTTP slice-upload)
  getGroupAlbumMediaList    → getMediaList
  commentGroupAlbumMedia    → comment
  likeGroupAlbumMedia       → like
  deleteGroupAlbumMedia     → delete

Callers updated: `instance-context.ts`. `web-actions/index.ts` no
longer re-exports the deleted album functions. No tests existed for
this surface area, so no test churn.

587/587 tests pass.
2026-05-20 23:12:53 +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 9a678442e9 fix(msg): unswap markRead import aliases in Bridge
The `markGroupMsgAsRead` / `markPrivateMsgAsRead` Bridge methods were
calling the wrong underlying implementations because the alias names
on the import line were swapped:

  - `markPrivateMessageRead as markGroupMsgAsRead_` meant calling
    `bridge.markGroupMsgAsRead(groupId, …)` actually fired the c2c
    (private) read-report wire shape with `groupId` shoved into the
    `userId` slot — the server then resolved a uid for what was
    really a groupId and posted to the wrong route.

  - `markGroupMessageRead as markPrivateMsgAsRead_` was the mirror:
    `bridge.markPrivateMsgAsRead(userId, …)` fired the group read-
    report shape with `userId` in the `groupUin` field.

Realigned to the intended pairing. The underlying implementations
in `actions/group-message.ts` are already covered by
`tests/actions/group-message.test.ts` (asserts both hit
`SsoReadedReport` with their proper wire shapes); they pass
unchanged because they always tested the action-layer functions
directly, never the Bridge facade where the swap lived.

Caught while planning the #6 Api-on-ctx refactor — preserving the
swap into the new MessageApi would have carried the bug forward.
2026-05-20 22:08:38 +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
DrSmoothl 994633eafa feat(proton): resolve bare specifiers via TypeScript module resolution
`resolveModulePath` used to skip any specifier not starting with '.'
which meant proton's vite plugin only saw proto definitions that lived
in the same package as the call site. Trying to move definitions into
a sibling workspace package (e.g. `@snowluma/proto-defs`) caused those
imports to silently fail — the call-site type lookup found nothing
and the protobuf_encode<T> replacement never fired.

Add a bare-specifier branch that delegates to `ts.resolveModuleName`
with `moduleResolution: Bundler`. That covers monorepo workspaces,
npm packages, package.json `exports`, and tsconfig path aliases with
zero extra user configuration.

The relative-path branch is preserved byte-for-byte so existing
single-package consumers see identical behaviour. Resolution is
constrained to `.ts` source files (not `.d.ts`) because proton walks
interface AST to collect `pb<>` field tags, and generated declaration
files typically strip those generic arguments.

Per-process `ModuleResolutionCache` keeps the hot path (HMR rebuild)
from re-scanning node_modules every call.

Tests: new `cross-package.test.ts` builds a fake monorepo under
`os.tmpdir()` and exercises smoke / transform / round-trip / negative
paths. 99/99 proton tests pass; SnowLuma core regression unchanged
(587 baseline ⇄ 587 with change).
2026-05-20 21:22:48 +08:00
手瓜一十雪 fe00774191 Remove obsolete comment about receiverUid
Delete an outdated comment block in fetchPrivateFileUrl that described receiverUid behavior (Lagrange.Core/NapCat and NTQQ fulllink timeout). No functional code changes were made aside from removing the explanatory comment; the surrounding logic (resolveSelfUid, void userId, and OIDB envelope creation) remains unchanged.
2026-05-20 19:47:25 +08:00
手瓜一十雪 0464d6439f RoadMap: add Proton fallback approach for debug
Update the debug optimization item to specify a Proton fallback plan: implement unpackaged debug with correct data directory and use TypeScript + map-based type extraction for tsx and Node.js, which will be stripped during normal Vite builds.
2026-05-20 19:46:32 +08:00
手瓜一十雪 db412ad498 更新 RoadMap:拆分 core 与优化计划
在 RoadMap.md 中新增多项 v2.0.0 前计划:拆分 core 为 Common/OneBot/Core/Bridge、将 ProtocolBuffer 定义剥离到独立包、整理消息发送与解析链路以提升性能,并对齐 NapCat 设计(在 ctx 上挂载 Api 层)。这些条目明确了架构重构与性能优化的方向。
2026-05-20 19:43:33 +08:00
手瓜一十雪 b56868878c Refactor bridge modules: imports & cleanup
Standardize and reorganize imports, type usages and minor formatting across the bridge modules. This commit moves protobuf helpers, makeOidbEnvelope/runOidb, OidbBase and logger imports into consistent places, reorders and groups action/contacts exports in Bridge, removes or trims file-level comments, and fixes whitespace/logic ordering in several highway, element-builder and msg-push files. These are refactors and import/type adjustments across many files under packages/core/src (bridge, highway, msg-push, webui, utils, etc.) with no intended behavioral changes.
2026-05-20 19:35:09 +08:00
手瓜一十雪 c49ae1c8d9 Remove actions list and update SDK exports
Delete the obsolete actions.ts (SNOWLUMA_ACTIONS / isSnowLumaAction) and update package SDK top-level exports. index.ts was refactored to reorganize and expose additional helpers and types (createSnowLumaApiError, createEventContext, event/type guards, request/notice helpers, fromCQString, MessageChain, SnowLumaHttpClientOptions, etc.), remove duplicate/unused re-exports, and streamline message/client/error exports for consumers.
2026-05-20 19:23:22 +08:00
手瓜一十雪 36c3ed031b Apply formatting and fix web request utilities
Add consistent indent rule to ESLint and normalize formatting/indentation across multiple files. Refactor RequestUtil: improve cookie extraction, redirect handling, and HttpGetJson/HttpGetText request flow; fix cookie string builder formatting. Fix uploadGroupNoticeImage promise/error handling and network request flow. Also apply minor style/refactor tweaks in bridge web APIs, onebot handlers, message-store SQL alignment, and Proton AST collector utilities to improve readability and correctness.
2026-05-20 19:16:06 +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
手瓜一十雪 f21740a5d3 Support types re-exported via export *
Add support for resolving types re-exported with `export * from '...'`. Introduces extractExportAllSpecifiers and a new exportAllTypeSources map on ParsedFileEntry, populates it while parsing (recursively following export-all chains using the cache and an inProgress set to avoid cycles), and falls back to exportAllTypeSources when resolving imported type names. This allows types and generic templates exported through export-all declarations to be discovered and resolved.
2026-05-20 18:37:58 +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
手瓜一十雪 02bab710b8 Add protobuf_decode import to interaction actions
Import protobuf_decode from @snowluma/proton in packages/core/src/bridge/actions/interaction.ts to enable decoding of protobuf messages within interaction-related bridge actions. No other changes to logic in this diff.
2026-05-20 18:04:19 +08:00
手瓜一十雪 bf4c130641 Use protobuf_encode for OIDB envelopes
Replace the encodeOidbEnv wrapper with direct protobuf_encode<OidbBase<T>> calls across bridge modules. Updated many bridge action files (extras, friend, group-admin, group-file, group-message, interaction, misc, profile, bridge-contacts, highway pipeline, etc.) to import protobuf_encode and OidbBase and to pass encoded OIDB envelopes via protobuf_encode. Removed the encodeOidbEnv implementation from bridge-oidb and restructured proton OIDB definitions by adding new proto/proton/oidb-actions modules (base, group-album, group-file, media, index) and adjusting proton proto usage. This centralizes envelope encoding for better monomorphization and simplifies the OIDB encoding flow.
2026-05-20 18:03:57 +08:00
手瓜一十雪 ca0a888003 Clarify proton proto schemas and comments
Refine and clarify inline documentation across proton schema files (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts). Replaces verbose legacy preambles with concise comments, adds concrete notes about server expectations (e.g. Trans0x211 routing for C2C files, required NotOnlineFile fields on send, NTV2 upload extBizInfo video scene fields), and documents GroupFileExtra/GroupFileInfo field alignment and magic tags to explain past send/receive failures. Changes are purely comment and schema documentation updates to improve maintainability and explain interoperability pitfalls with the server; no runtime API/signature changes were introduced.
2026-05-20 17:48:13 +08:00
手瓜一十雪 3cd3155d1a Simplify comments and section headers in oidb.ts
Remove long legacy/explanatory comment blocks and ASCII-art dividers, replacing them with shorter, consistent section headers (e.g. "Oidb.0xFD4_1 Friend list", "Oidb.0xFE5_2 Group list"). Trim redundant commentary about OidbBase/OidbBaseMeta while keeping all type/interface definitions unchanged. This cleans up the file for readability without altering the public types or runtime behavior.
2026-05-20 17:37:02 +08:00
手瓜一十雪 4b217eb4b8 Use self UID and add OIDB timeout
fetchPrivateFileUrl now uses the current account UID (resolveSelfUid) as receiverUid instead of resolving the peer UID, matching Lagrange.Core/NapCat behavior and avoiding NTQQ fulllink handler timeouts. The userId parameter is intentionally unused. runOidb gained an optional timeoutMs argument and the private-file request uses a 5000ms timeout; bridge.sendRawPacket is forwarded this timeout.
2026-05-20 17:26:39 +08:00
snowluma-bot[bot] 1bd0cbc359 Merge pull request #57 from SnowLuma/dev
chore: promote dev → main (2de1ff4)
v1.9.0
2026-05-20 06:21:32 +00:00
DrSmoothl 2de1ff4f0d chore(release): v1.9.0 2026-05-20 14:21: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
H3CoF6andClaude Sonnet 4.6 312df29b5f feat: 添加注入前探测 QQ 登录状态功能 (#56)
通过扫描 QQ 进程开放的 9210-9219 端口,在注入 DLL 前即可获取登录状态和账号信息。

- 新增端口探测模块 (qq-port-probe.ts)
- 前端添加"探测登录"按钮和状态显示对话框
- 兼容 Windows 和 Linux 平台
- 修复端口查询逻辑,正确识别每个 PID 开放的端口

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:29:41 +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
DrSmoothl 3c65df668c fix(msg-push): silently acknowledge Event0x210 subType=380 (unmapped client state)
Surveyed Lagrange.Core (PushMessageService.cs), LagrangeGo
(client/listener.go), lagrange-python (server_push/msg.py), mania
(push_msg.rs), and the tsuzcx/qq_apk decompile — zero handlers for
380 / 0x17C across the lot. Pattern matches subType=38 (also a
QQ-client-internal state push with no OneBot mapping); drop it
through the same silent-acknowledge path so we stop spamming
`MsgPush.Unknown` at debug level on every push.
2026-05-19 21:25:41 +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
手瓜一十雪 1daf6f6218 Remove runtime dependency bundling step
Delete the "Bundle runtime dependencies" step from .github/workflows/dev-build.yml. The removed step changed to working-directory: dist and ran npm install --omit=dev, so CI will no longer install runtime deps into dist during the dev build, reducing build time and avoiding bundling node modules into the dev artifacts.
2026-05-19 15:30:41 +08:00
DrSmoothl 898898a507 feat(msg-push): handle three friend-related events we used to drop on the floor
Close three coverage gaps surfaced by the cross-Lagrange-family
audit (Lagrange V2 / LagrangeGo / acidify / lagrange-python):

  - **0x210 subType 179 + 226 (NewFriend)** — mutual-accept friend
    notice. 179 fires when bot sent a request and the other side
    accepted; 226 fires for the opposite direction. Both decode
    through the same wire shape. LagrangeGo handles the pair via a
    `case 226: case 179:` fallthrough in `client/listener.go:248`.
    We surface them as `kind: 'friend_add'`, which the existing
    converter routes straight to OneBot `notice.friend_add` — bot
    frameworks that wait for "auto-add a friend after they accept"
    were previously stuck because nothing arrived.
  - **0x210 subType 139 (self-recall)** — bot recalled its own
    message sent to a friend. Same `FriendRecall` wire shape as 138
    but the peer is on `toUid` instead of `fromUid` — acidify's
    `parseFriendRecall:380` splits exactly this way. Without 139,
    self-recalls disappeared and downstream `notice.friend_recall`
    handlers thought the bot had never recalled anything.
  - **PkgType 85 (bot self-joined a group)** — fires on the bot's
    session once `set_group_add_request` is approved or an invite is
    accepted, ported from lagrange-python's `server_push/msg.py:108`.
    PkgType 33 (GroupMemberIncreaseNotice) only fires for *other*
    members joining, so without 85 the bot never received its own
    `notice.group_increase` until the first message arrived. We
    decode `SelfJoinInGroup` and emit `kind: 'group_member_join'`
    with `userUin = selfUin`; the existing converter picks
    `sub_type: 'approve' | 'invite'` from the operator/user equality
    so consumers see the standard OneBot shape.

While wiring 139, also fix a *pre-existing* prod bug spotted via
proton call-site analysis on the resulting bundle: `event-0x210.ts`
imported `FriendRecall` from both `proto/proton/notify` (as
`FriendRecallNotify`) *and* `events` (as the event interface).
Proton's import-resolver registers `importedTypeSources` keyed on
the *imported* name (last-write-wins), so the events.ts import
shadowed the proto one — `protobuf_decode<FriendRecall>(...)` lost
its codec, fell through to the runtime fallback throw, and was
silently swallowed by `MsgPushRegistry.decode`'s catch. Verified by
the substituted codec list in `dist/index.mjs`: pre-fix it had
`FriendRequest` / `GeneralGrayTipInfo` but no `FriendRecall`;
post-fix `FriendRecall` / `NewFriend` / `SelfJoinInGroup` are all
emitted as expected. Side-step the collision by importing the
proto types unaliased and re-deriving the colliding event type via
`Extract<QQEventVariant, { kind: 'friend_recall' }>`.

New proto interfaces in `proto/proton/notify.ts`:
  - `NewFriend` + `NewFriendInfo` (uid/time/message/nickName fields,
    fields 1-9; ported from
    `LagrangeGo/.../notify.proto:25 NewFriend`)
  - `SelfJoinInGroup` (groupUin / operatorUid + a few placeholder
    fields; ported from
    `lagrange-python/pb/status/group.py:170 PBSelfJoinInGroup`)

Regression coverage in `tests/msg-push-handler.test.ts`:
  - subType 179/226 both produce identical friend_add events
  - falls back to `routingHead.fromUin` when the new friend's uid
    isn't in the local cache (matches LagrangeGo's
    "emit-then-resolve" pattern)
  - subType 138 keeps fromUid as the peer (other recalled their
    own msg to us); subType 139 picks toUid (we recalled ours
    sent to them)
  - PkgType 85 emits group_member_join with userUin == selfUin,
    operator resolved through identity when known

core: 635/635 tests pass (+7), typecheck clean. Production bundle
now contains substituted codecs for FriendRecall, NewFriend,
SelfJoinInGroup (cross-checked via `grep protobuf_decode_X` on
`dist/index.mjs`).
2026-05-19 14:51:48 +08:00
DrSmoothl 604dbc3f64 fix(msg-push): Event0x210 subType=38 is the troop-shortcut-bar push, not friend-related
Earlier this commit acknowledged subType 38 as a placeholder
"FriendGrayTipInfo" because none of the LagrangeDev-family projects
(Lagrange V2 / lagrange-python / LagrangeGo / acidify) have a
handler for it — they all just fall through with a generic
"unknown subType" debug log. The "best guess" gray-tip framing was
wrong.

A contributor traced it through the decompiled stock QQ Android
client to `com.tencent.imcore.message.ext.codec.decoder.msgType0x210
.SubType0x26` (tsuzcx/qq_apk @ afe46ef). The payload is
`submsgtype0x26.MsgBody` dispatching on `uint32_sub_cmd`:

  - 0x1 → `UpdateAppUnreadNum`: a list of
    `{group_code, app_id, unread_num}` keeping group-internal
    "mini-app" badge counts (e.g. `appId=101846662` / `101896870`
    for the troop shortcut bar) in sync.
  - 0x3 → `UpdateDiscussAppInfo`: `{conf_uin, app_tip_notify.text}`
    discussion-group app tips, routed through `getGAudioHandler()`.
  - 0x4 → delegated to the troop online-push handler.

None of these have a meaningful OneBot mapping — they're
QQ-client-UI state pushes (badge counts, in-app tips), not
conversation events. Rename the enum entry to `GroupAppStatePush`
to reflect the actual semantics, cite the decompiled source
inline, and keep the silent-drop behaviour from the previous
attempt. Tests + decoder routing follow the rename.

628/628 tests pass (unchanged), typecheck clean.
2026-05-19 14:04:43 +08:00
DrSmoothl ff5fc651d0 fix(tests): declare full vi.fn arg signatures so CI typecheck doesn't trip on tuple length
CI's `tsc --noEmit` was rejecting `forward-nested.test.ts` because the
`vi.fn(async () => 'RES')` mocks for `bridge.uploadForwardNodes` only
inferred zero or one arg from the lambda — so
`uploadForwardNodes.mock.calls[0]` came out typed as `[]` or
`[nodes: any[]]`, and indexing `[1]` / `[2]` for groupId / userId
hit `Tuple type ... has no element at index 'N'`.

Local `tsc --noEmit` happened to be looser about this in my version,
which is why the bad assertions slipped past `e637647`.

Declare the full 3-arg signature on every `uploadForwardNodes` mock
— `(_nodes, _groupId?, _userId?)` — matching the real
`Bridge.uploadForwardNodes` contract. `mock.calls[i]` now types as a
3-tuple, the index accesses check out, and the assertions stay
unchanged. No production code touched, no test behaviour change.

626/626 still passing, typecheck clean.
2026-05-19 13:00:41 +08:00
DrSmoothl c758eb6569 fix(onebot): recursively parse nested forward chains (node-inside-node)
A custom forward node's `content` field can itself be a list of
`{type:'node'}` entries — that's the nested-forward case where one
forward bubble contains another. `parseForwardNodes` used to pass
the whole `content` straight into `parseMessage`, which routed
each inner `{type:'node'}` through `message-parser.ts:217 case
'node':` and turned them into opaque
`MessageElement{type:'node', resId: JSON.stringify(content)}`
placeholders. `element-builder.buildSendElems` then dropped those
at its default branch — so the outer forward shipped with either
zero elements (pure-nested → server rejects with "message is
empty") or text-only elements (mixed content → silently truncated
chat bubble). User-facing symptom: "嵌套转发解析后发不出去".

Add depth-aware recursion to `parseForwardNodes`, modelled on
NapCat's `SendMsg.uploadForwardedNodesPacket` (3-level cap):

  - When a node's `content` is *all* `{type:'node'}` entries,
    recurse: parse the inner chain into ForwardNodePayload[],
    upload it via `bridge.uploadForwardNodes` to mint an
    inner res_id, then embed a `{type:'forward', resId}` element
    in the outer node. The receive-side rendering pipeline already
    builds the ARK preview card for that element shape, so the
    QQ client renders a tap-to-expand nested bubble.
  - Mixed content (text + node siblings) deliberately stays on the
    flat path — the meaning is ambiguous, and forcing a recursion
    would silently drop the non-node siblings.
  - Hard cap at depth 3; deeper attempts throw "depth exceeds 3".
    Matches NapCat and prevents one slow inner upload from
    aborting an unreasonably deep tree.

Threading: each forward send entry point passes its destination
into the parser (`{groupId}` from group-send, `{userId}` from
private-send) so the inner forward uploads against the same
res_id namespace.

Test coverage in `tests/onebot/forward-nested.test.ts`:
  - group: inner uploads first, outer wraps inner res_id in a
    forward element, both calls happen in order
  - private: c2c path threads userId (not groupId) into the
    inner uploadForwardNodes
  - depth cap rejects 4-level nesting with /depth/
  - flat (non-nested) content still uploads exactly once
  - mixed content stays on the legacy parseMessage path

core: 626/626 tests pass (+5), typecheck clean, prod build clean.
2026-05-19 12:53:24 +08:00
DrSmoothl 10154cfbff fix(onebot): parse {type:'file', file_id:'…'} segments instead of dropping them
The companion to `e637647`. That commit fixed
`upload_group_file` / `upload_private_file` to publish the file as
a chat message atomically (matching napcat). This one fixes the
OTHER half of the same bug: when a bot framework does

  send_msg([{type:'file', file_id:'<previously uploaded>'}])

— attaching an already-uploaded file as a message segment — the
parser used to fall through to its default warn-and-drop branch
(`message-parser.ts:340`). The dropped segment then either:
  - became "message is empty" if it was the only segment, or
  - silently disappeared if it shared the payload with text,
producing a chat bubble missing the file.

Adds a `case 'file':` that maps the OneBot segment shape to the
existing `MessageElement{type:'file', fileId, fileName, fileSize,
md5Hex, sha1Hex}` that `element-builder` already knows how to
emit as `TransElem(24)`. Accepts the common alias spellings
(`file_id`/`fileId`, `name`/`filename`/`fileName`,
`size`/`fileSize`, `md5`/`md5Hex`, `sha1`/`sha1Hex`).

Deliberately rejects `{file: '/path'}` form — kicking off an
upload from inside the synchronous parser would either swallow
errors or restructure the whole parseMessage signature, neither
worth it. Callers wanting upload-and-send should keep using
`upload_group_file` / `upload_private_file`, which now does both
atomically.

core: 621/621 tests pass (+3), typecheck clean.
2026-05-19 12:42:47 +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 a50e038272 docs(readme): list @snowluma/proton in the workspace table
Vendored in 6294e29, used by every protobuf call site in `core`
since the OIDB migration landed (5b55f5b). Add a one-liner to the
workspace table next to the other infrastructure packages.
2026-05-19 11:52:55 +08:00
DrSmoothl ecb8064ca9 perf(webui): code-split routes; cut initial JS from 606 kB to 284 kB
`vite build` was emitting a single ~606 kB chunk for the whole SPA
(Vite even printed its >500 kB warning). Every route — overview,
config (with its full edit-dialog form surface), logs, settings —
was eagerly imported in `router/index.tsx`, so users paid the full
parse + execute cost before the login redirect even resolved.
That's the most plausible cause of the "loading node config is
somewhat slow" report.

Switch each route to `lazyRouteComponent(() => import('…'), '<Name>')`
so the page chunk is fetched on demand, and wrap `<Outlet />` in
`AppLayout` with a `<Suspense>` boundary backed by a small skeleton
fallback. `defaultPreload: 'intent'` is already set on the router,
so hovering a sidebar link warms the next chunk before the click —
navigation still feels instant after the first idle moment.

Build now emits:
  index.js          606 kB  →  284 kB    (-53%)
  overview-page                  15 kB
  config-page                    25 kB
  logs-page                      5.6 kB
  settings-page                  5.8 kB
  button (shared primitives)   164 kB
  + smaller per-component chunks Vite shook out
And the >500 kB warning is gone.

Bonus: replace the per-render
`list.filter(...).map(...).includes(item.name)` O(n²) duplicate-name
check in `NetworkTabView` with a one-shot `Map<name, count>` —
matters every time the user types in the edit dialog and the tab
underneath re-renders.
2026-05-19 11:48:41 +08:00
DrSmoothl 70715763bd fix(core): wire @snowluma/proton vite plugin into the production build
The proton plugin was only in `vitest.config.ts`, so tests and
typecheck passed but the bundled `dist/index.mjs` shipped with the
unsubstituted generic shells — every `protobuf_encode<T>()` /
`protobuf_decode<T>()` call hit the runtime fallback and threw:

  "protobuf_encode<T>() was not transformed by the
   @snowluma/proton Vite plugin. …"

at every msg-push, rkey/friend/group/profile warmup, etc. on the
Win/Linux releases.

Add `protobufVitePlugin()` to the shared `BaseConfigPlugin` so the
transform runs in both dev (`vite build --mode development`) and
prod (`vite build`). Sanity-check on a fresh `vite build` shows the
bundle now contains 481 distinct monomorphized codecs (704 encode +
511 decode call-site replacements), and the only remaining
"was not transformed" string is the dead runtime fallback that no
longer has a caller.
2026-05-19 11:20:47 +08:00
DrSmoothl d821336943 fix(onebot): hot-reload HTTP server accessToken without re-binding
`HttpServerAdapter.startServer` baked `accessToken` into the
`createServer` callback's closure, then relied on
`bindingSignature` including the token to trigger a
`close()`+`open()` round-trip on every change. Two problems:

  1. The three sibling adapters (`ws-server`, `http-post`,
     `ws-client`) all read `this.config.accessToken` inline at the
     point of use, so the HTTP-server's closure-capture pattern was
     the odd one out.
  2. `server.close()` is asynchronous; the immediately-following
     `server.listen()` on the same port can race with the OS
     releasing it. When it loses, the new listener fails with
     EADDRINUSE (logged via `server.on('error', …)` but not bubbled
     up), the old server is gone, and the operator's `reload` looks
     successful while the endpoint is actually broken — which lines
     up with the "token didn't hot-reload" reports.

Drop the closure capture: `handleRequest` reads
`this.config.path` and `this.config.accessToken` fresh per request
(`reload()` already replaces `this.config` in place via
`structuredClone(next)`). Remove `accessToken` from
`bindingSignature` so a token change no longer triggers a port
re-bind. `host`/`port`/`path` stay in the signature because Node's
`Server.listen()` binds the socket on those.

Four hot-reload tests pin the new contract: token swap, clearing
to empty, setting from empty, and "token-only change returns
`NetworkReloadType.Normal`" (no Reopened/Closed). All four hit a
single OS-assigned port and verify the port is unchanged after
reload — a re-bind would either land on a different port or fail.

core: 613/613 tests pass (+4), typecheck clean.
2026-05-19 11:16:54 +08:00