Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl 37dd7d2173 fix(webui): rotate stale bootstrap password and highlight credentials log
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.
2026-05-17 18:20:05 +08:00
DrSmoothl 0c5eef87e6 ci: publish @snowluma/ui to npm on push to main
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.
2026-05-17 17:24:21 +08:00
DrSmoothl 0d54fd83c6 feat(ui): add --titlebar-height token for Desktop's frameless window
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.
2026-05-17 16:49:27 +08:00
DrSmoothl b3e37e2f7c feat(ui): add indeterminate mode to Progress
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.
2026-05-17 16:31:30 +08:00
airline233 b441647df4 feat(http-server): support parse content-type of urlencoded 2026-05-17 15:27:43 +08:00
DrSmoothl d7252b6c31 feat(ui): Button success/warning variants + active translate + icon-xs
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`.
2026-05-17 14:42:29 +08:00
DrSmoothl 593f5f42bc fix(ui): Dialog and Select no longer overflow at narrow widths
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.
2026-05-17 14:24:09 +08:00
DrSmoothl 8a94188dc6 fix(ui): dark-mode Switch + Checkbox visibility
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.
2026-05-17 14:11:29 +08:00
DrSmoothl 3e943e8f0d feat(ui): refresh theme tokens + add Popover/StatusDot/EmptyState/Kbd
Visual overhaul to give @snowluma/ui a coherent, accessible identity.

Tokens (styles/theme.css)
- Deeper dark-mode surface ramp with more contrast between background /
  card / popover so dropdowns and dialogs read clearly in dark mode
- New radius scale (xs → 3xl) anchored at 0.625rem for tighter feel
- Layered shadow tokens (xs/sm/md/lg/xl) that adapt to mode
- Semantic success / warning / info / destructive colour + foreground
  pairs replacing ad-hoc lookups
- `--chrome-height` (52px) for consistent top-bar / sidebar header height
- Utilities: `hover-lift`, `surface-glass`, `surface-gloss`; consumer-
  scoped scrollbar styling so native scrollbars match
- Note: consumers must also `@import "tw-animate-css";`

Components
- Select: solid bg-popover + ring-1 ring-foreground/5; rotating chevron
  on open state; rounded-xl menu with shadow-xl (fixes the transparent
  dropdown bug)
- Dialog / AlertDialog: bg-popover + ring; backdrop-blur on overlay;
  slide-in motion
- Tooltip: ring + zoom-in motion, max-w-xs for long labels
- Button: explicit transition list with active:translate-y-px; success /
  warning variants; icon-xs size
- Input: hover border + focus ring + aria-invalid handling
- Card: tighter line-height + typography
- Badge: variants for success / warning / info / soft, size scale
- ScrollArea: hover-only mode by default, slightly thicker thumb

New components
- Popover (radix wrapper) — for menus / inline overlays
- StatusDot — semantic colored dot (replaces ad-hoc 🟢/🟡/🔴 emoji)
- EmptyState — uniform placeholder for "nothing here yet" surfaces
- Kbd — inline keyboard-key label
2026-05-17 13:56:11 +08:00
DrSmoothl af1c50f4e7 feat(compat): add QQ compatibility manifest
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.
2026-05-17 00:24:10 +08:00
DrSmoothl bc9f7b49be feat(core): support env-var overrides for webui port and bootstrap password
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.
2026-05-17 00:23:55 +08:00
DrSmoothl a8661ea216 feat(ui): add @snowluma/ui shared UI library
Lifts the shadcn primitives + Tailwind 4 theme tokens from webui into a
dedicated workspace package consumable by both webui and SnowLumaDesktop.

- 14 primitives (Button, Dialog, AlertDialog, Input, Label, Checkbox,
  Select, Switch, Tabs, Card, Avatar, Badge, Progress, ScrollArea,
  Separator, Sheet, Skeleton, Tooltip) + sonner Toaster wrapper
- Tailwind 4 theme tokens exported via styles/theme.css
- tsup ESM build with declaration files
- publishConfig.access=public mirroring @snowluma/sdk

Consumers @import '@snowluma/ui/styles/theme.css' for tokens and add an
@source directive to scan the package for utility classes.
2026-05-17 00:23:37 +08:00
DrSmoothl 2d21316a74 fix(onebot): bound download_file and drop unused error helper
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).
2026-05-16 21:49:32 +08:00
时瑾 ffe0c57abf feat(webui): add SNOWLUMA_WEBUI_TRUST_PROXY for reverse-proxy deploys
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
2026-05-16 21:31:54 +08:00
时瑾 d66f0b2c6b fix(bridge): point at file-upload actions in video-too-large error
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'.
2026-05-16 21:31:54 +08:00
时瑾 b40cee18cc fix(bridge): close two binary-size cap gaps flagged by review
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
2026-05-16 21:31:54 +08:00
时瑾 b3df633c87 fix(bridge): correctness pass on highway, hook, and identity service
* 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.
2026-05-16 21:31:54 +08:00
时瑾 b2c53b56f7 fix(onebot): correctness pass on actions and message parser
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 `&#44;` (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.
2026-05-16 21:31:34 +08:00
时瑾 917f08ce4d fix(webui): tighten auth handling and remove read-side write effects
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.
2026-05-16 21:31:28 +08:00
时瑾 2c80e1a267 refactor(onebot): make loadOneBotConfig read-only by default
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.
2026-05-16 21:31:28 +08:00
墨梓柒 133cfc0c0e Merge pull request #40 from SnowLuma/revert/pr-38-c2c-video-sceneType
Revert "fix(send): keep c2c video compatQMsgSceneType at 2 (#38)"
2026-05-16 16:44:38 +08:00
DrSmoothl b959783ded Revert "fix(send): keep c2c video compatQMsgSceneType at 2 (#38)"
This reverts commit 463e0ffb5d.
2026-05-16 14:54:56 +08:00
时瑾 655af833dc fix(send): keep c2c video compatQMsgSceneType at 2 (#38)
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.
2026-05-16 14:51:13 +08:00
H3CoF6 e2ee528939 fix: address P0 and P1 issues from code review
- 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
2026-05-16 14:33:42 +08:00
时瑾 463e0ffb5d fix(send): keep c2c video compatQMsgSceneType at 2 (#38)
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.
2026-05-16 14:22:49 +08:00
H3CoF6 c58f4d4085 fix: fix group album upload error 2026-05-16 13:34:02 +08:00
H3CoF6 16bdf1be1e Fix merge conflict and remove one group avatar implement 2026-05-16 12:39:00 +08:00
H3CoF6 0401ac4afb feat: implement some group album actions 2026-05-16 12:17:23 +08:00
DrSmoothl ad65aa5b1b fix(send): resolve at-mention display name from the group roster
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.
2026-05-16 11:55:36 +08:00
DrSmoothl a1a49944df refactor(webui): migrate routing to TanStack Router
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.
2026-05-16 02:35:49 +08:00
DrSmoothl caf724b25c fix(webui): preserve config edits on save and UIN choice across tabs
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.
2026-05-16 02:22:48 +08:00
DrSmoothl 7cef104f93 refactor(webui): deepen Hook process ops behind a single seam
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.
2026-05-16 02:13:09 +08:00
DrSmoothl b89d51d984 refactor(webui): extract ApiClient + lift OneBotInstance config editor
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.
2026-05-16 02:04:11 +08:00
DrSmoothl 47921eee12 fix(sdk): catch up SNOWLUMA_ACTIONS with the 14 new core actions
`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
2026-05-16 00:39:32 +08:00
snowluma-bot[bot] 9d55e86b1c Merge pull request #37 from SnowLuma/dev
chore: promote dev → main (7da52bc)
v1.8.1
2026-05-15 23:18:33 +08:00
DrSmoothl 7da52bc591 chore(release): v1.8.1 2026-05-15 23:17:44 +08:00
DrSmoothl 29f28d611f fix(send): use c2c businessType for private video / record
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.
2026-05-15 22:56:15 +08:00
DrSmoothl 97ad2e22bb feat(onebot): implement set_group_portrait via highway cmdId 3000
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.
2026-05-15 19:47:35 +08:00
DrSmoothl f7457e3e59 feat(onebot): add set_diy_online_status
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.
2026-05-15 19:26:40 +08:00
DrSmoothl ee62f5aa0b feat(onebot): add napcat-parity actions (Tier 1 + Tier 2)
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).
2026-05-15 19:22:35 +08:00
DrSmoothl 78d42029ba test(hook): guard against per-reconnect leak when QQ never logs in
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).
2026-05-15 15:03:00 +08:00
DrSmoothl 6b236f70bf refactor(bridge): split MsgPush handler into per-PkgType decoders
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.
2026-05-15 14:32:34 +08:00
DrSmoothl 1a072bc6f1 refactor(utils): consolidate uppercase hex into toHexUpper
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.
2026-05-15 14:29:44 +08:00
DrSmoothl 5f88e00135 docs: rewrite CONTRIBUTING for new contributors and add Code of Conduct
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.
2026-05-15 14:01:57 +08:00
snowluma-bot[bot] 07449788c6 Merge pull request #36 from SnowLuma/dev
chore: promote dev → main (85b59bc)
v1.8.0
2026-05-15 01:48:08 +08:00
DrSmoothl 85b59bc3a1 chore(release): v1.8.0 2026-05-15 01:47:06 +08:00
DrSmoothl 7f53bcd085 refactor(onebot): drop pure pass-through closures from ApiActionContext
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).
2026-05-15 01:38:11 +08:00
DrSmoothl 29fa2696c8 test(onebot): exercise contact-actions through a fake BridgeInterface
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.
2026-05-15 01:28:37 +08:00
DrSmoothl 37140b4ddd refactor(bridge): extract IncomingPacketPipeline as a deep module
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.
2026-05-15 01:21:56 +08:00
DrSmoothl e6b609dd4e refactor(bridge): extract BridgeInterface as the OneBot-facing seam
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.
2026-05-15 01:12:40 +08:00