Commit Graph
1191 Commits
Author SHA1 Message Date
tt-P607 118bcbe83c fix(protocol): one TCP connection per highway chunk
Highway 上传超过 1 MiB 的载荷(多 chunk 场景)会失败,OneBot 侧表现
为 retcode=1200 / `connection closed before response`。

根因:`uploadHighwayHttp` 用同一个 TCP socket,靠 HTTP keep-alive 把
每个 1 MiB chunk 依次 POST 出去。但 QQ highway 的边缘节点(以及链
路上的代理/NAT)经常在第一次响应之后立刻 FIN 关闭连接,第二个 chunk
的 POST 写到一个已被对端 close 的 socket 上,`readHttpResponseBody`
立刻收到 `close` 事件并抛出 `connection closed before response`。

为什么之前看起来像网络问题:≤1 MiB 的载荷(文本、PTT、小图)只发一
次 POST,永远不会触发;但凡跨过 `HIGHWAY_BLOCK_SIZE = 1 MiB`,第二
个 chunk 路径必然失败。改 Clash MTU、关 TUN、加直连规则都没用,因
为问题不在网络层。

改动:
- `uploadHighwayHttp`:每个 chunk 都新开一个 TCP 连接,`finally` 里
  `destroy()`。建连开销相对 1 MiB chunk 的传输时间可以忽略,行为也
  和 NapCat / Lagrange 在大文件上传时的连接生命周期一致。
- `readHttpResponseBody`:报错信息带上 `received=NB, headerSeen=
  true|false`,方便后续区分 “连接刚握手就被关” 和 “响应读到一半被
  截断” 两种情况。

测试:`packages/core/tests/highway/highway-client.test.ts` 三个用例
(0.5 MiB 单 chunk / 1.5 MiB 两 chunk —— 即本次 bug 复现 / 2.5 MiB
三 chunk),断言 `net.createConnection` 被调用的次数等于
`ceil(size / HIGHWAY_BLOCK_SIZE)`,并且每个用过的 socket 在下一次
建连之前都被 destroy。

线上验证:之前每次发 1 MB 以上图片必出 retcode=1200 的账号,打补丁
后通过 OneBot HTTP 已能正常发出图片。
2026-06-01 14:35:57 +08:00
DrSmoothl 0d63bf7415 fix(onebot): sign forward-msg media + pick image rkey by appid (#74)
/get_forward_msg returned raw, rkey-less image/media URLs because
getForwardMessage converted forward nodes WITHOUT the URL resolvers the
normal receive path (to-message.ts) uses. Wire imageUrlResolver +
mediaUrlResolver into it so forwarded images/voice/video get signed
download URLs — exactly issue #74 (image url 缺少 rkey).

Also derive the image rkey scene from the appid baked into the URL
(1406 -> private, 1407 -> group) instead of the carrying message's
group/private context, falling back to isGroup only when the URL has no
recognised appid. A c2c image forwarded into a group keeps its original
c2c appid, so the old isGroup-based pick signed it with the group rkey ->
`invalid rkey` on download. Mirrors NapCat's getImageUrlFromParsedUrl;
Lagrange/NapCat both reuse one unified converter so this never drifts.

Fix the self-contradictory `c2c images` rkey test (its fixture used the
group appid 1407) and add cross-tests asserting appid outranks isGroup
both ways, plus the no-appid fallback.
2026-05-31 23:50:08 +08:00
DrSmoothl f9252fb26d fix(bridge): type Ptlogin probe + de-magic process threshold (PR #73 follow-up)
PR #73's login-state probe shipped with issues:
- `fetchPtlogin` returned `Promise<any[]>`, tripping the repo's
  `@typescript-eslint/no-explicit-any` (error) and breaking `pnpm lint`.
  Modelled the read fields as a `PtloginUin` interface and cast the parsed
  JSONP to it.
- `tryPtloginMethod` had a dead `if` whose two branches both returned
  'fallback'; collapsed to a single return with a comment.
- The `< 6` QQ-process-count heuristic was a bare magic number (one copy
  annotated only with "13579 5个端口全部用光"). Extracted to
  `LOGGED_OUT_PROCESS_COUNT_MAX` with an explanation and reused in all three
  sites (incl. the error fallback).

Behaviour-preserving: the probing logic (Ptlogin parse, deep-link fallback,
PID port discovery) is unchanged — only typing and the magic constant.
2026-05-31 22:33:22 +08:00
墨梓柒 e821080ca8 Merge pull request #73 from H3CoF6/fix/detect
New implement for QQ status detection
2026-05-31 15:43:19 +08:00
H3CoF6 b3749560c8 New implement for QQ status detection 2026-05-31 04:51:54 +08:00
DrSmoothl c3803e4ba8 feat(proto): implement deflated payload for JSON and XML elements in message builder 2026-05-30 22:52:15 +08:00
snowluma-bot[bot] f2840c7074 Merge pull request #71 from SnowLuma/dev
chore: promote dev → main (ad081e2)
v1.9.3
2026-05-30 12:54:09 +00:00
DrSmoothl ad081e2149 chore(release): v1.9.3 2026-05-30 20:53:45 +08:00
DrSmoothl 0e4b881f76 feat(webui): add live connection status for OneBot adapters in dashboard 2026-05-30 18:02:01 +08:00
DrSmoothl 90f26ca4f4 fix(proto): emit state=0 on whole-ban unmute via new pb_optional marker
set_group_whole_ban false (unmute) failed with `OIDB error 1007` while
enabling worked (issue #70). OIDB (0x89A, 0) is shared by several commands
(SetSearch / SetAddOption / mute-all) disambiguated by body shape. Unmute
sends muteState.state=0, but proton omits zero scalars (proto3), leaving an
empty `muteState` the server can't classify. Enable sends 0xFFFFFFFF, which
is non-zero and survives — hence the asymmetry.

Add a `pb_optional<N, T>` marker (proto3 explicit presence): a scalar so
marked serialises even at its zero/default value. It is opt-in per field via
a NEW `explicitPresence` flag set ONLY by this marker — never reused from the
TS-`?`-derived `isOptional` (true for nearly every field). The encoder routes
explicit-presence scalars to a dedicated block; every `pb<>` / `pb_repeated<>`
field takes the untouched branches, so existing wire output is byte-identical
(proton's 99 existing tests, incl. byte-level round-trips, stay green).

Mark OidbMuteAllState.state `pb_optional<17, uint_32>` so unmute now puts
field 17 (=0) on the wire. New tests: proton pb_optional encode/round-trip
(incl. that sibling pb<> fields still omit zero), and a byte-level assertion
that mute-all unmute carries the state field.

Note: proton ships as a built Vite plugin (dist, gitignored); rebuild it
(`pnpm --filter @snowluma/proton build`) for the change to reach consumers.
2026-05-30 17:55:23 +08:00
DrSmoothl c4413dabb5 fix(proto): correct 0x8FC body/targetName field tags (set_group_card)
set_group_card always failed with `OIDB error 1007 on 0x8fc_3` (issue #70).
The 0x8FC request protos put the body wrapper at tag 2 and the member card
name at tag 2, but QQ-NT expects body at tag 3 and targetName at tag 8, so
the server found neither and rejected the request.

Cross-checked byte-for-byte against two working references:
  - Lagrange.Core OidbSvcTrpcTcp0x8FC: Body=ProtoMember(3), TargetName=8
  - NapCat Oidb.0x8FC_2: body=ProtoField(3), targetName=8

Fixes OidbRenameMember (0x8FC_3 set card) and the same latent bug in
OidbSpecialTitle (0x8FC_2 set special title), whose body was also at tag 2.
Send-only protos (empty deserialize); existing tests assert the command
name, not field tags, so they're unaffected.
2026-05-30 17:29:17 +08:00
DrSmoothl 71be3ae2e1 feat(webui): split overview into a processes page + mobile/ultrawide pass
The homepage was doing double duty — monitoring + injection control. Split it:

- New /processes page ("进程注入", sidebar #2) owns the QQ-process control
  surface (load / unload / refresh / probe-login + its dialogs). Overview is
  now a read-only dashboard: stats, host metrics, online accounts, plus a
  read-only injection-summary tile (online / loadable, links to /processes)
  and a first-run CTA that points at /processes while nothing is online.
  Route + AppPath + nav item wired; state/polling stay layout-owned.

Responsive + a11y pass:
- Mobile sidebar Sheet gets an sr-only title + description, fixing the Radix
  "DialogContent requires a DialogTitle" accessibility error.
- Log rows stack on phones (meta line + full-width message) instead of a
  ~40px column that broke every few chars; time parses defensively so a
  non-ISO value never renders "Invalid Date".
- Process cards stack on phones (info row + actions row) so name / PID / path
  stop collapsing to "Q.."; the status badge no longer wraps.
- Config tab strip scrolls horizontally on phones instead of wrapping to
  three rows; account strip defaults collapsed ≤lg; header buttons wrap.
- Dialogs keep a 1rem gutter on tiny screens via w-[calc(100vw-2rem)].
- Content widens to 1600px on ≥2xl; account/process grids gain a column at
  2xl so >1700px screens aren't mostly empty gutter.
2026-05-30 16:56:34 +08:00
手瓜一十雪 3cfac51811 Add SVG logo and update README
Add a new animated SVG logo at .github/logo.svg and embed it in README.md. Update the README content: refresh badges, simplify and translate the project intro and feature list into Chinese, add concise quickstart steps, adjust links (Releases → 发布版, RoadMap), and replace long/duplicated sections with a shorter overview and contributor badge. Overall this cleans up and modernizes the README and adds a branded logo for the project.
2026-05-30 16:06:29 +08:00
DrSmoothl 76c6ebc700 feat(webui): settings tabs, password dialog, instant node save, error pages
Five WebUI changes:

1. Settings page rebuilt around mini tabs (外观 / 数据刷新 / 账号安全)
   instead of three stacked cards; content and behaviour unchanged.

2. "修改访问密码" now opens an independent ChangePasswordDialog modal
   rather than swapping the whole page for the forced-change screen. The
   form is extracted to a shared ChangePasswordForm used by both the modal
   and the forced full-screen ChangePasswordPage — behaviour identical.

3. Node config: creating / editing / deleting / enable-toggling an
   HTTP/WS endpoint now persists immediately — clicking 保存 in the editor
   dialog IS the save. Previously it only marked the config dirty until you
   also hit the top-right 保存, which silently cost many users their token
   edits. `save()` takes an explicit config so it persists the freshly
   computed value, not the stale closed-over state. The general-settings
   tab keeps its explicit top-right save.

4. Forced first-time change-password no longer renders an old-password
   field: it carries the password the user just logged in with. Browsers
   (Edge/Chrome) were autofilling that field on upgrade, so users thought
   the old password was pre-filled correctly and could never save. A
   returning already-authed session (no fresh login) still shows the field.

5. Added default error + 404 screens (status-screens.tsx), wired into the
   TanStack router via defaultErrorComponent / defaultNotFoundComponent.
2026-05-30 13:34:41 +08:00
DrSmoothl 74b6fe97e5 build(core): externalize node:sqlite instead of SSR-bundling
Addresses MliKiowa's review on PR #68 ("单独排除 sqlite 就行了"). That PR
fixed the `DatabaseSync is not a constructor` crash by switching the core
bundle to SSR mode (build.ssr + ssr.noExternal + target node22 + a
catch-all isNodeBuiltin external fn). The root cause was narrower: on
Node 22.x `node:sqlite` is experimental and absent from `builtinModules`,
so the `nodeModules` sweep didn't externalize it and Vite swapped in a
browser shell. The SSR switch also self-inflicted the @hono/node-server
MODULE_NOT_FOUND it then had to paper over with noExternal — the original
config already bundled all non-builtin deps.

Revert vite.config.ts to the pre-PR state and just add 'node:sqlite'
(+ bare 'sqlite') to the external list. Verified: `SNOWLUMA_TARGET=linux-x64
vite build` succeeds and dist/index.mjs now carries a real
`import { DatabaseSync } from "node:sqlite"` rather than a shimmed shell.
2026-05-29 20:22:06 +08:00
DrSmoothl 4a0626f00b fix(onebot): re-sign image rkey on get_image / get_msg lookups
get_image and get_msg returned the URL baked into the message store when
the message first arrived. That URL's rkey expires, so a client fetching
it later (or re-querying after the original push) got
`{"retcode":-5503010,"retmsg":"invalid rkey"}`.

Re-sign at lookup time from the raw unsigned `imageUrl` the media store
already keeps alongside the signed `url` (plus `isGroup`): getImageInfo
now takes the image rkey resolver and mints a current rkey, and get_msg
walks its segment array refreshing each image URL through it. Both are
best-effort — a resolver miss/throw or a non-array message body falls
back to the stored URL untouched. Mirrors NapCat re-signing on every
getImageUrl call instead of handing back a stale baked URL.

The resolver is reused from converterCtx (no new rkeyCache wiring); a
cache-hit re-sign is a string concat, only an expired rkey awaits the
already-deduped/cooled-down/timeout-bounded refresh.
2026-05-29 20:14:21 +08:00
墨梓柒 ba19cee07d Merge pull request #68 from H3CoF6/Dev
Fix SSR bundle and node:sqlite resolution in Vite config
2026-05-29 20:14:11 +08:00
DrSmoothl 624959f099 fix(onebot): await a fresh rkey before signing media URLs
Image/video/record download URLs are signed at receive-time with an rkey
(OIDB 0x9067_202, TTL-bounded). RKeyCache resolved synchronously: on a
cache miss or inside the expiry skew it returned the URL *without* an
rkey and only kicked off a fire-and-forget refresh (plus a 30s cooldown).
So during the expire→refresh window — and whenever the OIDB hiccuped —
SnowLuma emitted unsigned URLs, which the CDN rejects with
`{"retcode":-5503010,"retmsg":"invalid rkey"}`. Intermittent by nature.

Mirror NapCat's getImageUrl (`if (isExpired()) await refreshRkey()`):
make resolveImageUrl/resolveMediaUrl async and block on one refresh when
the cache can't serve a still-valid rkey. The refresh coalesces
concurrent callers onto a single OIDB round-trip, backs off for 30s after
a failure, and is bounded by a 5s timeout so a stuck packet send never
wedges message conversion (it degrades to whatever the cache holds).

Widen ImageUrlResolver to `string | Promise<string>` and await it in
to-segment so the image path can resolve asynchronously like media.
2026-05-29 17:56:39 +08:00
DrSmoothl 0110b71dd3 fix(protocol): send browser UA + Referer retry on media downloads
loadBinarySource issued a header-less fetch(source) for remote media.
Anti-hotlink / anti-bot front-ends reset such requests, surfacing as
undici `TypeError: fetch failed` (not a clean 403) — so quoting a
command and sending an image/GIF whose source is a guarded URL bounced
with send_group_msg retcode 1200 "fetch failed".

Mirror NapCat's download path: send a browser User-Agent on every
request and retry once with a Referer pointing at the resource when the
first attempt is refused (network error or 403/4xx). Size-limit
rejections are tagged noRetry so an oversized body isn't re-downloaded.
Also route set_group_notice's image fetch through loadBinarySource so it
inherits the same hardening (plus http/file/base64 handling).
2026-05-29 17:37:10 +08:00
H3CoF6 00ca582f1a feat(protocol): implement OIDB 0xcde_2 (RequestDbKey) (#69) 2026-05-28 15:18:06 +08:00
H3CoF6 bf932477dd build(core): fix SSR bundle and node:sqlite resolution in Vite config 2026-05-28 02:02:29 +08:00
手瓜一十雪 f2b7ef0056 Switch from @snowluma/sqlite to node:sqlite (sync)
Remove the in-tree @snowluma/sqlite wrapper and vendored better-sqlite3 prebuilts; migrate consumers to node:sqlite's synchronous API. Replaced imports and types (Database/Statement -> DatabaseSync/StatementSync), updated PRAGMA calls to use exec, and adjusted code in onebot (media/message/reaction stores) and protocol (identity service). Vite config no longer bundles sqlite native binaries and references to the sqlite workspace were removed. Also delete the packages/sqlite package and its native prebuild artifacts, and add a couple of unrelated AST improvements in proton: use a module-level shared wrapper cache and add an inheritance cache to avoid repeated scans. Minor import reordering/cleanup applied where needed.
2026-05-27 20:02:40 +08:00
snowluma-bot[bot] 54c4641a0e Merge pull request #66 from SnowLuma/dev
chore: promote dev → main (5924346)
v1.9.2
2026-05-24 10:02:15 +00:00
DrSmoothl 5924346823 chore(release): v1.9.2 2026-05-24 18:01:50 +08:00
手瓜一十雪 456086599c Normalize imports, typings and formatting
Reorder and normalize imports, add missing type imports, and fix whitespace/formatting across multiple files. Notable updates: eslint.config.ts import ordering; core bridge APIs (group-album, misc, web) now import Json types; tests/highway pipeline import ordering and added type imports; protocol changes (element-builder, highway client/pipeline) reorganized imports and added proto type imports; many oidb service modules moved protobuf encode/decode imports for consistency; web group modules (album, essence, honor, notice, request-util) reordered imports, adjusted typings/indentation, and small whitespace fixes. These edits are stylistic and typing improvements and should not change runtime behavior.
2026-05-24 13:39:35 +08:00
手瓜一十雪 80ee12ecc2 Enforce no-explicit-any; allow in tests
Enable @typescript-eslint/no-explicit-any as an error in the ESLint config to tighten typing across the codebase, and add an override to disable this rule for test files. Also apply non-functional cleanup in packages/proton/src/runtime.ts: reorder/imports and adjust formatting/indentation and string alignment for readability — no behavioral changes.
2026-05-24 13:38:13 +08:00
手瓜一十雪 acc5084647 Refactor group-album and remove eslint ignore
Remove an unused Bridge import, the asBridge helper and a stale comment from group-album, and relocate the GroupAlbumMediaResult and GroupAlbumCommentResult interfaces to the end of the file. In the sqlite package, remove the inline eslint-disable-next-line for @typescript-eslint/no-explicit-any near the BetterSqlite3 constructor. These edits clean up dead/stale code and re-enable the lint rule; there are no behavioral changes.
2026-05-24 13:16:36 +08:00
手瓜一十雪 afbec8ed44 Cleanup imports and formatting in bridge
Reorder and tidy imports, re-export types, and apply minor formatting fixes across the bridge package. Changes include:

- Reordered imports and grouped related imports in hook-manager, hook-session, pipe-watcher, qq-hook-client, qq-port-probe and others.
- Moved several type re-exports (HookProcessBaseInfo, QqPortLoginInfo, HookProcessInfo/Status) in hook-manager and consolidated exports in package entry index.ts.
- Minor whitespace/style tweaks: constructor spacing, for-loop formatting, empty catch handlers, and small refactors that do not change runtime behavior.

These are non-functional housekeeping changes to improve consistency and readability.
2026-05-24 13:14:36 +08:00
手瓜一十雪 a49e507e15 Refactor Bridge APIs, slim Bridge and reorder imports
Move and standardize API implementations under bridge/apis, slim down the Bridge class (remove inline action implementations, caches and large comment blocks) and relocate related types/exports. Clean up and reorder imports across many bridge/api modules, normalize constructors, add logger imports where needed, and adjust WebUI routing/import ordering. These changes centralize API logic, reduce Bridge surface area, and tidy module boundaries and imports.
2026-05-24 13:13:23 +08:00
手瓜一十雪 a52a9724cb onebot: clean up comments, imports and whitespace
Remove extraneous comments, redundant explanatory notes and extra blank lines across onebot packages; reorder and normalize import lists in group-admin and group-album; remove unused comment blocks in event-converter. These are non-functional cleanup changes in packages/onebot/src/actions/extended.ts, packages/onebot/src/actions/group-admin.ts, packages/onebot/src/actions/group-album.ts and packages/onebot/src/event-converter/index.ts to improve readability and maintain consistent style.
2026-05-24 13:08:24 +08:00
手瓜一十雪 245405d294 Refactor comments and small type fixes in extended.ts
Add JsonObject import and use it for handleSendPacket param typing; replace deprecated hex.substr with hex.slice; convert and consolidate many English docstrings/comments into Chinese for clarity; remove some JSDoc noise and minor formatting tweaks; improve explanatory comments around streaming download, base64 size checks, safe path resolution and async file writes. These are primarily documentation and small type/cleanup changes with no functional behavior changes.
2026-05-24 12:54:21 +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 98296fcec5 fix(send-like): update serialization to resolve user UID and adjust payload structure 2026-05-23 22:50:40 +08:00
DrSmoothl d8c72c2ebc fix(events): surface verify-text on group_invite ("你们好" → comment)
User report:
  SnowLuma's group_invite event renders to OneBot with empty
  `comment`, so the bot's approval-prompt template shows nickname
  + uin + flag but loses the requester's verify message.

  NapCat's same flow shows the verify text:
    【进群申请】批准/驳回:
    昵称:KitaIkuyo
    QQ:1957003260
    flag:1779543612823906
    等级:7
    你们好               ← only NapCat shows this line

Root cause:
  The OIDB push (GroupRequestJoinNotice subType=84) only carries
  `(groupUin, targetUid)` and a few opaque numeric fields — no
  string for the verify text. NapCat doesn't use the push directly;
  they poll `nodeIKernelGroupService.getGroupNotifies` (= our OIDB
  0x10C0 pending-request queue at `FetchGroupRequests`) which
  returns each pending row with `postscript` (the verify text) and
  `seq` (server sequence id). Reference:
    dev/NapCatQQ/packages/napcat-onebot/index.ts:483-498
    (`flag = notify.seq`, `comment = notify.postscript`).

  Our pipeline didn't enrich. The decoder set `message: ''` and
  emitted, so OneBot's `comment` was empty.

Fix:
  - Extend `PacketPipelineDeps` with a `resolveGroupJoinRequest(
    groupId, uid, subType)` hook. Bridge.ts wires it to
    `fetchGroupRequests` and matches on `(groupId, uid)` —
    targetUid for `subType='add'`, invitorUid for
    `subType='invite'`.
  - `dispatchAfterStrangerResolve` now fires the profile + request
    lookups in PARALLEL via `Promise.allSettled` so a flake on one
    path doesn't kill the other. The request lookup's `comment`
    lands on `event.message`; OneBot converts that to `comment`.

Decisions:
  - Kept the existing `add:<groupId>:<uid>` flag format (consumer
    bots already parse it). Switching to NapCat's sequence-form
    flag would break callers and is a separate breaking change.
  - Both lookups fail-open: if either rejects, the event still
    emits with whatever info we did get, so the bot at least sees
    the request and can render the bare nickname.

Regression tests:
  - `populates event.message from the pending-request queue` — pins
    the user-reported "你们好" case end-to-end through the pipeline.
  - `runs lookups in parallel` — profile resolves, request fails;
    event still ships with the resolved uin and an empty message.
  - `invite subtype matches the request on invitorUid` — explicit
    contract for the invite-forwarded-to-admin case where the
    pending row's REQUESTER is `invitorUid`, not `targetUid`.

345 bridge tests pass (was 342 + 3 new); typecheck + lint clean.
2026-05-23 22:00:14 +08:00
DrSmoothl 8631453940 fix(events): self-heal group_invite cache pollution from pre-fix builds
User report: after [a4909ec], log STILL shows
  群邀请 950929451 -> [bot group(950929451)]
  get_stranger_info params=user_id=950929451 → 未知昵称

But NapCat and a sibling SnowLuma bot in the same group BOTH decoded
the same push as `QQ:1957003260 nickname:KitaIkuyo`. So the wire is
correct — our bot is just looking it up wrong.

Root cause: **legacy cache pollution**. The pre-fix decoder fallback
stored `<requester_uid> → <groupUin>` in the identity DB whenever
`resolveUidToUin` missed (because the fallback `ctx.fromUin` on a
group-scoped push IS the group's own uin). After upgrading to
[a4909ec], `decodeGroupJoinRequest` calls `resolveUidToUin` which
hits the polluted in-memory map (re-populated from SQLite at boot)
and returns the groupUin instead of 0. The new `needsStrangerResolve`
guard only fired on `fromUin <= 0` — so the polluted value flowed
straight through and re-emitted the bug.

Two-part heal:

  1. `needsStrangerResolve` now ALSO fires when `fromUin === groupId`
     — the pollution signature. A real requester's uin can never
     equal the group's own uin, so this is a safe override that
     forces the async UID-form FetchUserProfile lookup, which then
     `rememberUidUin`s the correct mapping on success (eviction is
     already wired in identity-service.ts:468-471).

  2. `rememberEventIdentity` for group_invite now passes `uin: 0`
     when `event.fromUin === event.groupId`, so future writes don't
     re-pollute the cache (`rememberUidUin` short-circuits on
     normalized uin ≤ 0).

Cache self-heals on first event after upgrade — no manual DB nuke
needed.

Regression test pins the legacy-pollution path: feeds an event
where `fromUin === groupId` (matching the user's log), verifies the
resolver fires and patches fromUin to the correct value.
2026-05-23 18:40:01 +08:00
DrSmoothl a4909ec9e5 fix(events): resolve stranger nickname on group_invite (no more 未知昵称)
User report: when a stranger requests to join a group, the bot
receives `user_id=<groupId>` (instead of the requester's actual
uin) and `get_stranger_info` returns no nickname → bot displays
"未知昵称".

Two layered bugs:

  1. **Decoder fallback was wrong.** `decodeGroupJoinRequest` /
     `decodeGroupInvitation` / `decodeGroupInvite` all passed
     `ctx.fromUin` as the fallback uin when `resolveUidToUin` missed
     the local cache. But for group-scoped pushes `ctx.fromUin` is
     the GROUP's own uin — falling back to it produces the bogus
     `fromUin === groupId` collision the user saw. Fix: pass 0.

  2. **No async resolve for stranger UIDs.** The push only carries
     the requester's UID string. When the UID isn't in our cache
     (true for any stranger who isn't yet a group member), we have
     no UIN — and our UIN-form `FetchUserProfile` can't help. The
     pipeline never tried to look up the stranger by UID, so the
     event went out with `fromUin=0` (or, with the old fallback,
     the group's own uin).

Cross-checked against Lagrange.Core's flow:

  dev/Lagrange.Core/.../MessagingLogic.cs:215-224
    case GroupSysRequestJoinEvent join:
        var fetchUidEvent = FetchUserInfoEvent.Create(join.TargetUid);
        var results = await Collection.Business.SendEvent(fetchUidEvent);
        uint targetUin = results.Count == 0 ? 0
            : ((FetchUserInfoEvent)results[0]).UserInfo.Uin;
        var joinArgs = new GroupJoinRequestEvent(join.GroupUin, targetUin);

  dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-26
    — UID-form variant of the same OIDB 0xFE1_2: field 1 is `uid:
      string`, envelope `reserved=0` (no uinForm flag).

  dev/Lagrange.Core/.../FetchUserInfoService.cs:66-78
    — picks UID vs UIN form based on which input is set.

Changes:

  - `proto-defs/oidb-actions/base.ts`: add `OidbUserInfoByUidRequest`
    (same shape as the UIN variant but field 1 is `uid: string`).
  - `bridge/oidb-services/contacts/fetch-user-profile-by-uid.ts`:
    new namespace at 0xFE1_2 with UID-form encoding, no `uinForm`.
  - `bridge/msg-push/decoders/group-join-request.ts`: 3 decoders'
    `resolveUidToUin` fallbacks change `ctx.fromUin` → 0.
  - `bridge/packet-pipeline.ts`: new `resolveStrangerProfile(uid)`
    dep + `needsStrangerResolve` check + async dispatch that fires
    the lookup before emitting the event. Fail open if the lookup
    rejects (event still ships with `fromUin=0` so the OneBot bot
    can fall back to the uid in the flag).
  - `core/bridge/apis/contacts.ts`: `fetchUserProfileByUid(uid)`
    facade method.
  - `core/bridge/bridge.ts`: wire `resolveStrangerProfile` into the
    pipeline deps so it routes through the new namespace.

Tests:
  - UID-form namespace: declares the right cmd/subcmd, packages uid
    as field 1, decodes nickname + uin so the pipeline patch works.
  - Pipeline stranger resolve: patches event.fromUin from the
    resolver response; fails open on null / throw; skips the wire
    call when fromUin is already populated or fromUid is empty.

All 655 tests pass (was 645 + 10 new); typecheck + lint clean.
2026-05-23 17:15:59 +08:00
DrSmoothl 4755a91cb3 fix(image): use Chinese summary defaults to match QQ ecosystem
Mobile QQ, Lagrange.Core, and NapCat all use the literal Chinese
bubble-preview strings `[图片]` / `[动画表情]`. SnowLuma was emitting
English `[image]` / `[sticker]` as fallback, so QQ clients showed
"[image]" in chat-list previews instead of the expected Chinese.

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

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

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

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

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

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

Findings:

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

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

Hardening:

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

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

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

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

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

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

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

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

641 tests pass (was 628 + 13 new); typecheck clean; lint clean.
2026-05-23 12:45:26 +08:00
DrSmoothl 5724f490b4 refactor(oidb): thread ctx into serialize/deserialize for self-contained namespaces
The first pass at namespace-per-cmd left a smell: namespaces that
needed identity/uid resolution had to declare `Deps` AND then rewrite
their own spec inside `invoke` via a closure-over-resolved-value:

  // before
  export const serialize = (p: Params, targetUid: string) => ({...});
  export const invoke = async (deps, params) => {
    const targetUid = await deps.resolveUserUid(params.userId);
    return invokeOidb(deps, {
      ...Self,
      serialize: p => serialize(p, targetUid),  // ← escape hatch
    }, params);
  };

Reading "what does this cmd do on the wire?" required reading the
namespace AND the closure rewrite — the namespace wasn't really
self-contained, just a forwarder of two pure functions.

Fix: thread `ctx` (the namespace's full capability surface) into
`serialize` and `deserialize` directly. `serialize` may now return
`Promise<TReq>` so identity / uid lookups happen in place:

  // after
  export const serialize = async (ctx: Deps, p: Params) => ({
    groupUin: p.groupId, type: 1,
    body: {
      targetUid: await ctx.resolveUserUid(p.userId, p.groupId),
      duration: p.duration,
    },
  });
  export const invoke = (deps, params) => invokeOidb(deps, Self, params);

OidbCallSpec gains a `TCtx extends OidbSender` generic; invokeOidb's
first arg becomes `ctx: TCtx` (the sender capability is just a slice
of ctx now). buildOidbRequest / parseOidbResponse follow the same
shape. Async serialize is awaited inside invokeOidb.

17 namespaces had closure rewrites; all 17 collapse to plain
`invokeOidb(deps, Self, params)` invokes. Only one closure remains —
`fetch-user-profile` binds `params.uin` into deserialize as a server-
echo fallback; kept as a documented exception (the rare case where
deserialize wants request context, not ctx).

Wire bytes preserved across all migrations. 326 bridge tests + 302
core tests pass; lint clean.
2026-05-23 12:27:26 +08:00
DrSmoothl 99772851a7 chore(deps): add vitest coverage packages to devDependencies 2026-05-23 00:43:27 +08:00
DrSmoothl 9b4ff9c5e9 chore(lint): exempt oidb-services from no-namespace + clean stale directives
The namespace-per-cmd OIDB service layer was tripping
`@typescript-eslint/no-namespace` across 59 files; namespace IS
the OidbCallSpec by design (structural typing, one file per
(cmd, subcmd)) so disable the rule for that subtree only. Drop
four stale `eslint-disable-next-line @typescript-eslint/no-empty-
object-type` comments that aren't needed anymore (the empty
`Params {}` shape no longer triggers the rule), and fix an indent
nit in the get-unidirectional-friend-list test.

`npx eslint packages` now reports 0 errors. The 5 remaining
warnings are unused-vars / unused-eslint-disable in unrelated
pre-existing files.
2026-05-23 00:30:23 +08:00
DrSmoothl 521808a6eb refactor(oidb): migrate group-file.ts OIDB cmds to namespaces
Splits the 16 OIDB cmds from GroupFileApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-file/. Adds
@snowluma/bridge/oidb-services/shared with `toInt` / `ensureRetCodeZero`
helpers so namespaces can validate sub-responses without an
@snowluma/core dependency.

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

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

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

Wire-byte equivalence preserved; corner case for getAtAllRemain
empty-envelope path now returns {false, 0, 0} instead of throwing —
the new infrastructure normalises empty bodies and the OneBot action
ergonomics are unchanged.
2026-05-23 00:06:40 +08:00
DrSmoothl 014b87c7ce refactor(oidb): migrate contacts.ts OIDB cmds to namespaces
6 OIDB cmds moved; pagination + per-group cache + inflight
coalescing stay on the facade because they need cross-call state.

  - FetchFriendListPage       (0xFD4_1) — facade drives the cursor loop
  - FetchGroupList            (0xFE5_2) — uinForm; verbatim config blob
  - FetchGroupMemberListPage  (0xFE7_3) — facade drives the token loop +
                                          owns the TTL cache + inflight
                                          coalescing that keeps busy
                                          OneBot clients from tripping
                                          Tencent risk-control
  - FetchUserProfile          (0xFE1_2) — uinForm; full property-key
                                          catalogue cross-checked against
                                          LagrangeV2 FetchStrangerService.cs
  - FetchGroupRequests        (0x10C0_1/_2) — dynamic subcmd via filtered flag
  - FetchDownloadRkeys        (0x9067_202) — uinForm; respHead.retCode
                                             check moved into deserialize

35 new namespace-level tests covering serialize / deserialize edge
cases (property decoding, sex enum mapping, avatar URL suffix,
empty body handling, pagination cursor behaviour).

Verification:
  - typecheck green
  - @snowluma/bridge: 30 files / 233 tests
  - @snowluma/core: 302 tests
Wire bytes unchanged.
2026-05-22 23:51:33 +08:00
DrSmoothl 80afa31f5c refactor(oidb): migrate extras.ts OIDB cmds to namespaces
5 logical OIDB cmds → 4 namespace files (group todo's 3 sub-cmds
collapse into one namespace with a dynamic resolver):

  - GroupTodo          (0xF90_1/2/3) — set/complete/cancel via the
                                       new `action` discriminator
  - GetStrangerStatus  (0xFE1_2)     — uinForm=true; preserves the
                                       (status*10, ext_status) decode
                                       quirk verbatim
  - FetchAiVoiceList   (0x929D_0)
  - FetchAiVoice       (0x929B_0)    — single poll; retry loop
                                       stays on the facade so callers
                                       can plug their own back-off

Facade keeps the 6 public methods (setGroupTodo / completeGroupTodo /
cancelGroupTodo / getStrangerStatus / fetchAiVoiceList / fetchAiVoice)
as one-line forwarders; only `getStrangerStatus` wraps in try/catch
to preserve its "return null on transport failure" contract, and
`fetchAiVoice` keeps the polling loop.

30 new namespace-level tests; extras.test.ts moved onto the
sendRawPacket assertion model.

Verification:
  - typecheck green
  - @snowluma/bridge: 24 files / 195 tests
  - @snowluma/core: 302 tests
Wire bytes unchanged.
2026-05-22 23:45:13 +08:00
DrSmoothl 8356121518 refactor(oidb): migrate web.ts OIDB cmds to namespaces
2 OIDB cmds moved (the only OIDB calls in this file — the rest are
HTTP cookie / qun.qq.com / qzone web requests):

  - ForceFetchClientKey (0x102A_1) — used by ptlogin2 jump flow to
                                     swap clientKey → cookie jar
  - GetPskey            (0x102A_0) — p_skey fallback when ptlogin2
                                     omits it

The private `forceFetchClientKeyInner` / `getPSkey` helpers in
WebApi are now one-line forwarders into the namespaces. Facade
methods (`getCookies`, `getCsrfToken`, `getCredentials`, etc.) and
all HTTP-only cmds untouched.

12 new namespace-level tests covering deserialize edge cases
(empty server response, missing fields, dropped invalid items)
and e2e wire routing.

Verification:
  - typecheck green
  - @snowluma/bridge: 20 files / 165 tests
  - @snowluma/core: 302 tests
Wire bytes unchanged.
2026-05-22 23:40:17 +08:00
DrSmoothl 58e8081420 refactor(oidb): migrate misc.ts OIDB cmds to namespaces
3 OIDB cmds migrated; the 1 non-OIDB cmd (getMiniAppArk via
LightAppSvc) stays on the facade unchanged.

  - TranslateEnToZh           (0x990_2)
  - ClickInlineKeyboardButton (0x112E_1)
  - SendGroupSign             (0xEB7_1) — preserves historic UPPERCASE
                                          EB7 wire name via the new
                                          `wireName` override (the only
                                          cmd in the codebase with this
                                          quirk; every other cmd uses
                                          the default lowercase).

Tests:
  - misc.test.ts moved to bridge.sendRawPacket assertion model
  - 18 new namespace-level tests covering serialize, deserialize edge
    cases, magic constants, BigInt widening, and the EB7 case quirk

Verification:
  - typecheck green
  - @snowluma/bridge: 18 files / 153 tests
  - @snowluma/core: 302 tests
Wire bytes unchanged.
2026-05-22 23:37:41 +08:00
DrSmoothl f78431242c refactor(oidb): migrate friend.ts to namespaces
3 OIDB cmds moved to self-contained namespaces:
  - HandleFriendRequest (0xB5D_44) — digit-only flag still
                                     auto-resolves via resolveUserUid
  - DeleteFriend        (0x126B_0) — verbatim magic constants in the
                                     nested field2 sub-message
                                     (130/109/8/8/50 — server-side
                                     discriminator, MUST stay byte-
                                     equal to NTQQ wire)
  - SetFriendRemark     (0xB6E_2)

Cache invalidation (fetchFriendList after deletion) stays in the
facade — that side-effect needs `ctx.apis.contacts`, which namespaces
deliberately can't reach.

Tests migrated to assert against bridge.sendRawPacket. 15 new
namespace-level tests covering each invoke branch + serialize edge
cases.

Verification:
  - typecheck green
  - @snowluma/bridge: 15 files / 133 tests
  - friend.test.ts: 5/5
Wire bytes unchanged.
2026-05-22 23:33:22 +08:00