If the operator never completed the forced-change flow, the plaintext
bootstrap password was unrecoverable on the next start (only the hash
remained on disk, and the prior log line was likely gone). Now we
detect a persisted state with mustChangePassword=true at load time and
generate a fresh CSPRNG password so the operator can actually log in.
The initial-credentials log block is also wrapped with separators and
a bilingual notice so it stands out at boot. The
'initial credentials: user=admin password=%s' line is preserved
verbatim because third-party launchers parse it.
Mirror of sdk-npm-publish.yml for the UI package. Same triggers
(packages/ui/** + lockfile/workspace/tsconfig.base) and same
already-published guard via `npm view`, so reruns are idempotent.
Differences from the SDK workflow:
- Drops the `test` step (UI package has no Vitest suite yet).
- Filters and working-directory point at packages/ui.
Auth uses the same `NPM_TOKEN` secret in the SnowLuma environment.
Publishes with `--provenance` so the npm registry attests the build
came from this exact GitHub Actions run.
Desktop is switching from the native Windows titlebar to a custom one
rendered in the renderer (frameless BrowserWindow + custom Min/Max/Close).
The bar is 32px tall — the same height Windows draws its system
titlebar — so expose it as a token here for any future Desktop layout
that needs to subtract it.
Desktop's core-download dialog needs to show progress while the server
returns no Content-Length (some mirrors omit it; the SnowLuma core
release artifact does have it but GitHub's CDN sometimes strips it on
redirects). Hard-coded 50% was a placeholder.
Add an `indeterminate` boolean prop: when true, render a marquee bar
that animates left-to-right indefinitely. When false, render the
familiar bytes-done / bytes-total fill at `value%`. Hooked up via a new
`@keyframes progress-marquee` in theme.css.
Also dropped the bar's CSS transition from 500ms → 200ms so live
download updates feel responsive instead of laggy.
Polish pass that an earlier edit lost:
- Add `success` and `warning` button variants (semantic colour pairs
consumers can reach for in confirm dialogs / status actions).
- Outline gets a real card background + shadow so it pops on tinted
surfaces (previously bg-transparent looked invisible on muted/40 rows).
- Tighter motion: explicit transition list + `active:translate-y-px` so
clicks feel mechanical.
- New size `icon-xs` for inline action buttons that need to fit in a
log-row toolbar without ballooning the row height.
- Default radius bumped to `rounded-lg`; sm stays `rounded-md`.
CSS grid's default `min-width: auto` per track was letting long
selected-option text and file paths inside dialogs push the container
past its `max-w-lg` cap, which on Windows / smaller viewports clipped
the right edge of inputs and the Select chevron.
- DialogContent / AlertDialogContent: `grid-cols-[minmax(0,1fr)]` clamps
the single track to its container width; `w-[calc(100%-2rem)]` keeps
a 1 rem gutter to the viewport edge on small screens.
- SelectTrigger: `min-w-0` on the trigger itself plus a child-targeted
truncate (`[&>span:not([data-radix-select-icon])]:truncate`) so the
selected text shrinks before the chevron does; chevron gets `shrink-0`
for the same reason.
Both controls used `bg-muted` / `bg-background` for unchecked / thumb
which collapse to the same hue as the surrounding card in dark mode,
making them effectively invisible.
- Switch: unchecked track now bg-input (light) / bg-muted-foreground/25
(dark) with a shadow-inner; thumb uses bg-card (light) /
bg-foreground/95 (dark) so it pops in both states.
- Checkbox: explicit bg-card + hover:border-border/80; slightly larger
radius (5px) for visual rhythm with the rest of the surface.
Static JSON served from raw.githubusercontent.com/.../main/compat/qq.json
that SnowLumaDesktop polls (3-day cached fallback) to classify the local
QQ install as known-good / known-bad / unknown / too-old.
- compat/qq.json — initial allow-all manifest (policy.allowUnknown=true)
- compat/qq.schema.json — JSON Schema for the manifest shape
Editing this file is how we ship "QQ X.Y.Z breaks Hook" advisories
without cutting a Desktop release.
Two additions for trusted local launchers (notably SnowLumaDesktop) to
configure core without writing to disk-shared config:
- SNOWLUMA_WEBUI_PORT overrides runtime.json's webuiPort at startup
without persisting; CLI/Docker users continue to drive it from the
file. Mirrors the existing SNOWLUMA_HOOK_RUNTIME_DIR / *_TRUST_PROXY
conventions.
- SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD seeds the initial WebUI password on
first launch (when webui.json doesn't yet exist), with
mustChangePassword=false. After the file is persisted the env var is
ignored — password rotation continues through the UI.
PR #39 hardened most of the OneBot action surface but left download_file
on the original `await response.arrayBuffer()` path — an authenticated
client could still hand us a multi-GB URL (or a chunked response with no
Content-Length) and OOM the bot. The base64 branch had the same problem
in the other direction: `Buffer.from(huge, 'base64')` allocates the
decoded payload before any size check.
Match the loadBinarySource pattern that `0804351` introduced for
highway uploads:
* HTTP path runs through `fetchDownloadFile`: AbortSignal.timeout
(60s), Content-Length precheck, then a streaming reader that aborts
the moment the running total crosses the 1 GiB cap.
* Base64 path rejects before `Buffer.from` based on the
upper-bound `floor(len * 3 / 4)`, with a post-decode check for
safety.
* Writes go through `fs.promises.writeFile` so a multi-hundred-MB
download doesn't stall every other bot action on the event loop.
Also drop the `actionFailed` helper added in the same PR — it was
intended as a sanitized-error wrapper but never wired up. Wiring it
into the dozen-or-so `String(e)` sites is its own cleanup; for now
remove the dead code (and the no-longer-used createLogger import).
The previous commit tightened login rate-limiting to key off the real
TCP socket peer, which fixed the X-Forwarded-For bypass but had the
known caveat of collapsing every client into a single global bucket
when the WebUI runs behind nginx / Caddy / a SaaS CDN.
Give operators an explicit, audited way to opt back into per-client
keying without re-introducing the spoof:
* `SNOWLUMA_WEBUI_TRUST_PROXY` env var. Modes:
- unset / empty → keep socket peer (safe default, unchanged)
- "loopback" → trust X-Real-IP / X-Forwarded-For when the
immediate socket peer is 127.0.0.1 / ::1
(nginx + WebUI on the same host)
- "1" / "true" / → trust headers from any peer (only safe behind
"all" a SaaS proxy that strips client-set headers)
- comma-separated → trust headers only when the socket peer
IP list matches one of these (proxy on another box)
* When trusting headers we prefer X-Real-IP (single value the proxy
overwrites unconditionally) and fall back to the first hop of XFF.
We deliberately do NOT walk the full XFF chain — without a fully
trusted proxy chain the leftmost value is still spoofable; a
single-tier proxy is the only setup this app realistically sees.
* Boot log prints "login rate-limit keyed by: …" so operators can
verify the mode took effect, plus an extra WARN for "all" reminding
them it's only safe with header-stripping in front.
Logic lives in webui/client-ip.ts as a pure pickClientIp() that takes
a socket-IP resolver, so the trust-proxy matrix can be exercised
without standing up a server — see webui-client-ip.test.ts for the
11 new unit tests covering parse + dispatch + loopback / list / all /
none modes.
Smoke-tested with SNOWLUMA_WEBUI_TRUST_PROXY=loopback:
- 5× failed login w/ X-Forwarded-For: 1.1.1.1 → 401
- 6th attempt same XFF → 429 (locked, per-XFF)
- 6th attempt X-Forwarded-For: 2.2.2.2 → 401 (fresh bucket)
- X-Real-IP overrides XFF when both present
QQ's short-video element protocol hard-limits a single video at 100 MB
(NapCat enforces the same cap in highwayContext.uploadVideo with the
same threshold and tells operators to switch to file upload). Mirror
that hint so OneBot bot authors who hit the cap immediately see the
correct alternative — `upload_group_file` / `upload_private_file`,
which ride the FtnUpload path and go up to 4 GiB — instead of having
to read the docs to figure out why a send_msg with a 200 MB video
silently 'won't work'.
Follow-up on the previous highway commit, addressing two real holes the
Copilot review caught:
* loadBinarySource HTTP path was buffering the entire response via
`await resp.arrayBuffer()` BEFORE length-checking. A server that omits
or understates `Content-Length` (chunked transfer, malicious upstream,
proxy stripping headers) bypassed the cap entirely. Switch to a
streaming reader: accumulate chunks, abort + reject as soon as the
running total exceeds `maxBytes`. The Content-Length precheck still
short-circuits the obvious case before we read a byte.
* video-upload `stageVideoSource` has its own local-path fast-path that
never went through `loadBinarySource`, so the 100 MB MAX_VIDEO_SIZE
guard ran AFTER `fs.readFileSync` had already allocated the entire
file. A 10 GiB local-file pointer would OOM the bot before the check
fired. Add an `fs.statSync` precheck on the local branch, and pass
`MAX_VIDEO_SIZE` explicitly when the source falls through to
`loadBinarySource` so the HTTP / base64 cap matches the on-disk one.
Update the DEFAULT_MAX_BINARY_SIZE comment to stop claiming video paths
are bounded by it — they're bounded by the smaller MAX_VIDEO_SIZE,
enforced inside video-upload.
Adds five regression tests:
- chunked streamed download (no Content-Length) over cap → throws
- Content-Length over cap → throws before reading body
- streamed download inside cap → returns full bytes
- local file over cap (statSync precheck) → throws
- base64 over cap → throws
* highway/utils detectImageFormat: JPEG SOF marker check now excludes
0xC4 (DHT), 0xC8 (JPG), and 0xCC (DAC). The old `(marker & 0xFC) ===
0xC0` matched DHT et al. and then read Huffman table bytes as image
dimensions, producing junk width/height for many real JPEGs.
* loadBinarySource takes an optional maxBytes; defaults to 1 GiB to
defend against an authenticated client triggering a multi-GB load
into memory. Group/private file uploads pass FILE_UPLOAD_MAX_BYTES
(4 GiB) because QQ's file protocol legitimately supports that size.
HTTP fetches also pick up a 60s timeout and a Content-Length precheck.
* highway-client.tcpConnect detaches its error/timeout listeners after
a successful connect so subsequent upload IO errors don't reject an
already-resolved promise. readHttpResponseBody uses local listener
refs + a central detach(), eliminating MaxListenersExceededWarning
during multi-chunk uploads.
* hook/qq-hook-client.nextRequestId now wraps inside uint32 explicitly
and skips id 0 / in-flight ids. The previous `nextRequestId++ >>> 0`
loses precision past Number.MAX_SAFE_INTEGER (postfix increment
evaluates before the shift) and could collide live request ids on
very long-running processes.
* identity-service.rememberUidUin removes stale partner entries before
inserting a new (uid, uin) pair. The old code left orphan uidByUin /
uinByUid entries after a uid/uin re-bind, so the two maps could
disagree.
Action-layer fixes, all cross-checked against NapCat's reference
implementation in packages/napcat-onebot:
* download_file (extended.ts:539) now sanitizes `name` via
path.basename + resolve/relative containment check. Previously an
authenticated client could write `../../config/onebot_<uin>.json`
and replace access tokens / webhook URLs.
* mark_group_msg_as_read / mark_private_msg_as_read / mark_msg_as_read
fall back to meta.targetId when the optional group_id/user_id arg
is missing. The old code passed 0 to the Bridge. NapCat resolves the
peer from message_id alone in MarkMsgAsRead.ts; we now match that.
* set_friend_remark / set_group_remark refuse a missing `remark` field.
The previous `asString(...) ?? ''` silently CLEARED the remark for
any client that forgot the parameter. NapCat's typebox schema makes
`remark` required; we now do the same.
* upload_forward_msg threads `group_id` down to uploadForwardNodes so
the resulting resId lives in the correct namespace (type:3 + groupUin
vs type:1 + selfUid — see bridge/actions/forward.ts). NapCat passes
`peer.chatType === GROUP ? +peerUid : 0` for the same reason.
ApiActionContext.sendForwardMsg + uploadForwardMessage signatures
pick up an optional groupId.
* Private-message reply meta now resolves senderUin from the cached
event (real sender), with a self-id fallback when only meta exists
(bot's own outbound message — `cacheMessageMeta` does not populate
the event row). The old code used meta.targetId, which is the
conversation peer — wrong when the bot replies to its own message.
* message-parser:
- intOr(value, fallback) helper replaces parseInt(String(...)) at
face/at/reply/image/xml/poke/node sites so non-numeric input no
longer produces NaN downstream. Invalid face ids skip the
element rather than emit NaN.
- cqUnescape decodes `,` (escaped comma) — the OneBot 11 spec
requires it and clients in the wild emit it.
* Drop the dead Array.isArray(rawAuth) branch in onebot/network/utils
(Node's headers.authorization is `string | undefined`, never an
array) and the unused WebHonorType import in api-handler.
Stack of WebUI fixes prompted by an internal security/correctness pass:
* getClientIp now resolves the real socket peer via @hono/node-server's
getConnInfo helper instead of trusting `X-Forwarded-For`. The previous
behaviour let any client rotate that header to bypass the 5-attempts/
15-minute login lockout.
* `?token=` is restricted to /api/logs/stream (the only endpoint that
can't use the Authorization header, because EventSource lacks custom
headers). Other routes used to leak tokens via access logs / Referer
/ browser history.
* change-password now invalidates every session (including the current
one) and signals requireRelogin. Keeping the current token alive made
the rotation pointless if the old credentials were already in an
attacker's hands.
* GET /api/config/:uin no longer writes defaults to disk and no longer
doubles up the payload (`{config}` only, dropping the {…config, config}
legacy shape). Frontend's `data?.config ?? data` fallback still works.
* webui.json is moved to .bak.<ts> instead of being silently overwritten
when it fails to parse — losing the prior hash silently was rotating
operator credentials with no warning.
* Process load/unload/refresh routes are deduped into a single helper;
internal error messages are logged but no longer surfaced verbatim to
the client.
* SSE log stream tolerates a closed controller (try/catch around enqueue
+ central teardown) and runs an unref'd janitor to GC expired session
tokens + login attempt counters every minute.
GET on a load helper used to mint a fresh access_token and write it to
disk whenever the per-UIN file was missing or in legacy format. Reading
should not have side effects, and the WebUI's read-only GET handler in
particular was racing on concurrent requests and surprising operators
with materialized config files.
Introduce a `{ persistDefaults?: boolean }` option (default false). The
two boot-time call sites in `OneBotManager` (onSessionStarted, reloadConfig)
opt in explicitly; WebUI's GET path will rely on the new default once
that side of the patch lands.
Also stop reviving default 0.0.0.0 listeners when the operator has
explicitly emptied every adapter — only seed defaults on a true fresh
install (no per-uin and no global config on disk).
Drop the unused deepClone / deepMerge helpers; tryLoadJson now logs a
warning instead of silently swallowing JSON parse errors.
Commit 29f28d6 changed c2c video `compatQmsgSceneType` from 2 to 1
as a "symmetric asymmetry" cleanup, matching the image/ptt convention
where the field tracks the upload's sceneType (c2c=1, group=2). For
video this is wrong.
Image and ptt have *different* legacy compat elements per scene
(notOnlineImage vs customFace; ptt c2c vs group), so the server needs
to know which scene shape to emit. The legacy `videoFile` element
(packet element type 19) has no scene split — fromChatType /
toChatType live inside the element itself — so the QQ NT server
emits a single group-shaped compat payload regardless. NapCat
hardcodes `compatQMsgSceneType: 2` for *both* UploadGroupVideo and
UploadPrivateVideo for exactly this reason.
Setting it to 1 on c2c made the server emit a c2c-scene shaped
compat blob that older QQ clients failed to resolve, showing the
message as "视频已过期" on those receivers. Newer clients only read
the commonElem(48, 11) and displayed fine — which is why only "some
devices" saw the breakage.
Keep `commonElem.businessType = isGroup ? 21 : 11` untouched — that
piece of 29f28d6 is still correct and is what fixed PbSendMsg
result=79.
- P0-1: restore customExt field in SetStatusReqSchema
- P0-2: remove duplicate setGroupAvatar implementation
- P1-3: add attach_info pagination support for get_group_album_media_list
- P1-5: clean up commented console.log statements
- P1-6: remove raw field from GroupAlbumMediaResult
Commit 29f28d6 changed c2c video `compatQmsgSceneType` from 2 to 1
as a "symmetric asymmetry" cleanup, matching the image/ptt convention
where the field tracks the upload's sceneType (c2c=1, group=2). For
video this is wrong.
Image and ptt have *different* legacy compat elements per scene
(notOnlineImage vs customFace; ptt c2c vs group), so the server needs
to know which scene shape to emit. The legacy `videoFile` element
(packet element type 19) has no scene split — fromChatType /
toChatType live inside the element itself — so the QQ NT server
emits a single group-shaped compat payload regardless. NapCat
hardcodes `compatQMsgSceneType: 2` for *both* UploadGroupVideo and
UploadPrivateVideo for exactly this reason.
Setting it to 1 on c2c made the server emit a c2c-scene shaped
compat blob that older QQ clients failed to resolve, showing the
message as "视频已过期" on those receivers. Newer clients only read
the commonElem(48, 11) and displayed fine — which is why only "some
devices" saw the breakage.
Keep `commonElem.businessType = isGroup ? 21 : 11` untouched — that
piece of 29f28d6 is still correct and is what fixed PbSendMsg
result=79.
OneBot clients commonly send [CQ:at,qq=12345] without a name field; we
were piping `@12345` into the text element so QQ rendered the bare uin
instead of the member's card/nickname (napcat resolves via getGroupMember
before encoding). Move the fallback into the element builder so it
consults the IdentityService roster and only falls back to the uin when
the roster has nothing.
The four-page switch lived as a string union (Page) plus an if/else
chain in AppInner, with TopBar reverse-importing Sidebar's NAV_ITEMS
to find the icon for the active tab. Adding a page touched four
places and there was no URL, no history, no deep-linking.
Move routing behind @tanstack/react-router with a layout route
(AppLayout) that owns the polling lists, processOps, selectedUin
and the unload-failed alert; child routes render through <Outlet/>
and read shared state from a new AppStateContext. AuthBoundary still
gates login / forced-password-change above the router. Sidebar uses
<Link> + useRouterState for active state; TopBar and MainLayout look
up the current path the same way.
Behaviour-preserving:
- LoginPage / ChangePasswordPage still mount above the router.
- AppLayout never unmounts during navigation, so selectedUin,
processOps state, the polling state and the unload-failed dialog
all survive page switches just like before.
- Logout calls history.replaceState('/') so the next login lands on
the overview page, matching pre-router behaviour.
- The sidebar-active-pill layoutId animation and the page-transition
AnimatePresence are preserved, keyed off pathname instead of the
removed `active` prop.
New URLs (/, /config, /logs, /settings) work on refresh because the
backend already had SPA fallback in core/src/webui/server.ts. Bundle
grows ~84KB (gzip +27KB) for the router runtime.
Two regressions slipped into the ApiClient/ConfigPage refactor:
1. api.config.save() ran the POST response through normalizeOneBotConfig
and used it to overwrite local state. But POST /api/config/:uin
returns { success, reloaded, message } — no config body — so the
normaliser produced an empty config and wiped the user's edits as
soon as the save succeeded. Honour the "save returns canonical view"
contract by following the POST with a GET inside the client; the
round-trip stays hidden behind the seam.
2. selectedUin lived inside useOneBotInstanceConfig, so navigating away
from ConfigPage unmounted the hook and lost the user's selection.
Lift selectedUin to AppInner and make the hook externally controlled
via { selectedUin, onSelectedUinChange }. Clear it on logout.
App.tsx held three copy-pasted handlers (load / unload / refresh) and
three parallel processXxxPid state slots that were mutually exclusive
but unenforced by the type system. The unload-failed alert was a
separate path, and OverviewPage needed 11 props to drive the table.
Extract useHookProcessOps: one inflight Set for dedup, one Map<pid,
OpKind> for view state, one run() that branches on kind via a
message table, and ownership of the unload-failed alert. OverviewPage
takes a single HookProcessOps prop and queries statusOf(pid).
App.tsx 270 → 216 LOC; OverviewPage props 11 → 7.
App.tsx was the single source of truth for token, fetch policy, 401
handling, and per-UIN config edit state — fetchApi was drilled into
every page and LogsPage read localStorage directly to build its SSE
URL, giving two parallel truths for the bearer token.
Promote the closure to a deep ApiClient module (domain methods +
request() escape hatch) injected via ApiProvider/useApi, and lift
selectedUin/config/saveStatus into useOneBotInstanceConfig so
ConfigPage is self-contained. normalizeOneBotConfig becomes a pure
function. ConfigPage now guards UIN switches when the editor is
dirty and refetches the server-normalised config after save.
ConfigPage prop count drops from 7 to 1; App.tsx from 421 to 270 LOC.
`actions-sync.test.ts` walks every `registerAction(...)` in
`@snowluma/core/src/onebot/actions/` and asserts the SDK's exported
list matches. The napcat-parity work (Tier 1 + Tier 2 + Tier 3 + AI
voice) added 14 actions to core without ever touching the SDK, so the
test caught the drift:
bot_exit cancel_group_todo
complete_group_todo delete_group_folder
get_ai_characters get_ai_record
get_group_ignore_add_request
nc_get_packet_status nc_get_rkey
nc_get_user_status
send_group_ai_record send_packet
set_diy_online_status set_group_todo
Added them to `SNOWLUMA_ACTIONS` and gave each one a typed
`SnowLumaActionMap` entry. Where the request body shape is non-trivial
(AI voice, group todo, DIY status, send_packet, nc_get_user_status),
introduced dedicated param types in `types/params.ts`; where the
response is structured (AI character list, user-status word),
introduced result types in `types/results.ts`. Everything else routes
through `JsonObject` / `EmptyData` so the SDK surface stays small.
Verified:
pnpm --filter @snowluma/sdk typecheck → clean
pnpm --filter @snowluma/sdk test → 17/17 pass
Private-chat video and voice sends were getting bounced by the QQ NT
server with `PbSendMsg result=79`. Highway upload logs said success,
but the final SendMsg the bot constructed used the group-scene tag
inside the outgoing commonElem, and the c2c routing path doesn't
recognise it.
The convention — verified against the receive-side decoder at
msg-push/rich-body-decoder.ts:261-330 which explicitly branches on the
pairs — is:
image : commonElem.businessType c2c=10 group=20
video : commonElem.businessType c2c=11 group=21
record : commonElem.businessType c2c=12 group=22
makeImageElem already honoured the split (`isGroup ? 20 : 10`).
makeVideoElem and makePttElem hardcoded the group value for both
scenes. Fix them to follow the same pattern.
Also fix a symmetric asymmetry inside the highway upload itself:
uploadVideoMsgInfo hardcoded `compatQmsgSceneType: 2` while
uploadImageMsgInfo and uploadPttMsgInfo both use `isGroup ? 2 : 1`.
Same root cause — group value leaking into the c2c path.
Regression test (tests/element-builder.test.ts) pins all six
businessType pairs by mocking the highway upload functions and
inspecting the proto element the builder returns; would have caught
the bug had it existed at the time element-builder.ts was written.
Port the Lagrange.Core protocol path for group avatar upload —
napcat's NTQQ GroupService.setHeader has no oidb wrapper, but
Lagrange.Core/Internal/Context/Logic/Implementation/OperationLogic.cs
spells out the wire shape: same highway HTTP endpoint as personal
avatar, but with cmdId=3000 and a GroupAvatarExtra proto carried as
the `extend` blob (type=101, groupUin, field3={field1=1}, field5=3,
field6=1 — all four are protocol-prescribed constants).
Adds:
- GroupAvatarExtraSchema in proto/oidb-action.ts
- bridge.setGroupAvatar(groupId, source) wrapping the highway upload
- BridgeInterface.setGroupAvatar
- OneBot set_group_portrait action (was a stub returning
'not yet implemented'; accepts the same file-source variants as
set_qq_avatar)
5 new tests: 3 bridge-action (cmdId, extra-blob round-trip, empty-file
guard) + 2 OneBot (forwarding, param validation) — also tightens the
existing setAvatar test to assert the extend blob stays empty.
DIY online status reuses the existing StatusService.SetStatus wire
call. napcat hardcodes status=10 / extStatus=2000 and threads the
faceId / wording / faceType through a customExt sub-message (field 4
of SetStatus, per napcat packet/transformer/proto/action/action.ts).
Implementation:
- Extend SetStatusReqSchema with optional customExt field. Encoder
skips undefined fields so the existing set_online_status path stays
byte-identical on the wire (verified by a new round-trip test).
- New bridge.setDiyOnlineStatus(faceId, wording, faceType) wraps the
same dispatchSetStatus helper as setOnlineStatus.
- OneBot action set_diy_online_status accepts napcat-style payloads
(face_id / face_type can be string or number; wording defaults to '').
9 new tests across actions/profile and onebot/extended-actions.
Wires 16 OneBot actions that napcat exposes but SnowLuma previously
either lacked or stubbed:
Tier 1 (pure glue over existing Bridge capabilities)
- send_packet / .send_packet: real impl over Bridge.sendRawPacket
- bot_exit, nc_get_packet_status, nc_get_rkey
- get_group_ignored_notifies / get_group_ignore_add_request: project
fetchGroupRequests(filtered=true) into napcat shapes (was empty stub)
- delete_group_folder alias for delete_group_file_folder
Tier 2 (new oidb proto + Bridge method)
- set_/complete_/cancel_group_todo (oidb 0xF90_1/2/3)
- nc_get_user_status (oidb 0xFE1_2 with napcat's status decode)
- get_ai_characters / get_ai_record / send_group_ai_record (oidb
0x929D / 0x929B; AI voice reuses the existing fetchGroupPttUrlByNode)
BridgeInterface now exposes sendRawPacket as the deliberate escape
hatch for send_packet — every other surface method already wraps it.
46 new tests: 13 bridge-action (mocked runOidb dispatch checks) + 33
OneBot-action (ApiHandler.handle end-to-end).
50 onPipeUp/onPipeDown cycles → at most 1 live FakeClient, zero
listeners on every torn-down client. E2E variant additionally asserts
BridgeManager.onHookLogin / onPidDisconnected / onPacket are never
called, so no Bridge / Identity / OneBotInstance is allocated.
These prove no leak exists at this layer. Investigated after a Docker
OOM report which turned out to be QQ Browser GC-thrashing while idle on
the login screen (75-second LongTask entries from PID 61 in the report).
The 885-line msg-push-handler.ts is replaced by bridge/msg-push/:
8 per-PkgType decoders plus MsgPushRegistry (single decoder per
PkgType, catch + log on error), MsgPushContext (header decoded
once at entry), and rich-body-decoder (one deep function hides
~400 lines of element conversion shared by all three message
types).
parseMsgPush(pkt, identity) signature preserved; existing tests
pass with an import-path change only.
Adds toHexUpper alongside toHex in utils/hex.ts. Routes
group-file's bytesToHexUpper through it so the incoming
msg-push split can share one implementation.
Refocus CONTRIBUTING.md on first-time contributor onboarding (setup,
PR flow, conventions) and move the release/auto-merge workflow into a
clearly labeled maintainer section. Adopt Contributor Covenant v2.1
as the project Code of Conduct, pointed at the QQ/TG channels.
instance-context.ts used to declare ~130 closures inside buildApiContext,
mirrored by ~130 method signatures on ApiActionContext. About 38 of
those were pure 1:1 pass-throughs — `setGroupAdmin: (gid, uid, en) =>
bridge.setGroupAdmin(gid, uid, en)`, `setOnlineStatus`, `setProfile`,
`setAvatar`, `markGroupMsgAsRead`, `getCookiesStr`, `getCsrfToken`,
`getCredentials`, the lot of them. Each was three places to edit (the
closure, the interface signature, the action call site) and zero added
value: the OneBot name matched the Bridge name and no defaults / rename
/ composition was happening.
Expose `bridge: BridgeInterface` on ApiActionContext and have action
handlers route through `ctx.bridge.<name>` for those cases. The 38
closure declarations go away; ApiActionContext shrinks from ~70 method
signatures to ~30; instance-context.ts loses ~50 lines of boilerplate.
The ~30 adapter closures stay — every one of them does real work:
- Renames: setGroupKick → bridge.kickGroupMember, sendFriendPoke →
bridge.sendPoke(false, …), handleDeleteFriend → bridge.deleteFriend
- Field extraction: uploadGroupFile returns result.fileId, not the
full {fileId} envelope
- Baked constants: setEssenceMsg passes `true`, deleteEssenceMsg
passes `false` to the shared setEssenceMessage module function
- Compositions: sendPrivateMessage / sendGroupMessage / setMsgEmojiLike
need messageStore + mediaStore + bridge wired together
- OneBot-specific defaults: getProfileLike defaults start=0 limit=10,
getGroupEssence defaults pageStart=0 pageLimit=50
Two small downstream nudges that fell out of the type changes:
- extended.ts:99 — `essenceDataAll.flatMap(res => …)` now needs an
explicit `(res: any)` since bridge.getGroupEssenceAll returns
`Promise<any>`, not the previously-asserted `GroupEssenceMsgRet[]`.
- extended.ts:835 — `okResponse(clientKeyInfo)` widened via spread to
`okResponse({ ...clientKeyInfo })` because `ClientKeyInfo` is a
named interface (no index signature) and okResponse expects
JsonValue/JsonObject. Behavior identical; the response shape is
unchanged.
402 tests pass (the OneBot contact-actions test added in PR6 doubles as
regression coverage for the seam).
First OneBot unit test that proves the BridgeInterface seam (PR4)
delivers real testability. The fake bridge is a Proxy over a tiny
override map — un-stubbed methods throw with a clear message, so each
test only spells out the surface it actually exercises, and a missing
stub never silently passes.
Covers the five contact-actions read paths users hit most through the
OneBot API: getLoginInfo / getFriendList / getGroupList / getGroupInfo
/ getGroupMemberList / getGroupMemberInfo / getStrangerInfo — 19 cases
spanning happy path, network-fail fallback to identity cache,
noCache=true forcing a refetch, and missing-data nulls. No SQLite, no
native packet sender, no BridgeManager — just BridgeInterface.
Bridge.onPacket previously inlined 5 distinct responsibilities (cmd
dispatch, identity-refresh decision, async member-roster refresh,
side-effect persistence, event emission) plus all the helper state
(cmdHandlers_, memberRefreshTasks_) and the per-event printer. Move
the whole stage chain into bridge/packet-pipeline.ts as
IncomingPacketPipeline, keeping the orchestration single-owned and
testable without booting Bridge.
The pipeline's public surface is three methods — registerCmd /
handlesCmd / process — plus a single constructor dep bag. Internally
the stage order is exactly as before:
parse → enrich identity (sync, or async refresh)
→ side-effects (rememberX on Identity + coalesced roster refresh)
→ log
→ emit on the typed event bus
Bridge keeps the protocol-layer methods (sendRawPacket,
fetchGroupList, fetchGroupMemberList, etc.) and injects
refreshMemberCache as the one pipeline dep that touches the wire.
Bridge.onPacket / registerCmd / handlesCmd are now thin pass-throughs
so the existing external test (RefreshingBridge extends Bridge,
bridge.onPacket(makePacket()) → expects forced member fetch) keeps
working unchanged.
Net diff: bridge.ts shrinks by ~245 lines; packet-pipeline.ts is
~225 lines; one dead helper (elementsToText) deleted on the way out.
Runtime behavior is byte-identical — verified by tests/bridge-member-identity.test.ts
covering the full async-refresh path.
OneBot used to depend on the concrete Bridge class — 778 lines, ~99
public methods including PID management, raw packet dispatch, and cmd
handler registration that no external consumer should touch. Add an
explicit BridgeInterface (bridge/bridge-interface.ts) enumerating only
the surface OneBot actually reads (verified by grepping every bridge.*
access in onebot/), and have Bridge `implements BridgeInterface` so the
compiler enforces alignment.
Net effect at runtime: zero — Bridge's methods, fields, and bodies are
unchanged; the interface is a TypeScript-only declaration. Net effect
for typing: the OneBot package (instance, instance-context,
instance-rkey, manager, media-url-resolver, and the modules under
onebot/modules/) now imports `BridgeInterface` instead of `Bridge`, so
tests / fakes can satisfy the interface without subclassing or pulling
in the SQLite/native bits.
In-tree bridge actions (actions/*, bridge-contacts, highway) keep their
concrete `Bridge` typing — they legitimately need sendRawPacket and
other low-level entry points that intentionally stay off the
interface.