/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.