Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl 7625c18768 feat(onebot): add get_rkey_server (NapCat server-shape rkey)
Reshape the download-rkey list (type 10=private, 20=group, same 0x9067
source) into NapCat's GetRkeyServer shape {private_rkey, group_rkey,
expired_time, name}, expired_time = now + min present ttl. get_rkey /
nc_get_rkey already existed (raw list) and are left unchanged.
2026-06-18 00:21:35 +08:00
DrSmoothl 07988629f1 feat(onebot): implement rename_group_file (OIDB 0x6D6_4)
Add the file-rename slot to the group-file OIDB envelope (req/resp field 5),
a RenameGroupFile service mirroring MoveGroupFile, a groupFile.rename() api
method, and the rename_group_file action (params group_id/file_id/
current_parent_directory/new_name, NapCat-aligned), replacing the stub.
Wire fields (busId=102, parentFolder=current dir, newFileName) verified
against NapCat's Oidb.0x6D6 transformer; subCommand 4 cross-checked against
the move op (sub 5 on both sides). Serialize + envelope round-trip test locks
the pb tags.
2026-06-18 00:19:00 +08:00
DrSmoothl 0ba5eba31c fix(onebot): neutral get_file cache-miss message, drop fake test
Second adversarial pass: the previous 'unsupported' wording over-corrected
— it mislabeled a genuinely-uncached image file_id as 'unsupported',
mirroring the original defect. A double cache miss is runtime-indistinguishable
between a group-file id and an evicted image/voice, so the message now states
the cache miss neutrally and offers get_group_file_url as guidance only. The
prior 'group-file id' test took the same code path as the cache-miss test
(a fake test); consolidated into one honest assertion.
2026-06-18 00:08:44 +08:00
DrSmoothl dbb5160be7 fix(onebot): clarify get_file limitation + honest _mark_all_as_read summary
Adversarial review of Wave 1: get_file's 'file not found in cache' was
misleading for group-file file_ids (unsupported here, not uncached).
Distinguish the two in the error message, surface the limitation in the
summary, and lock the contract with a negative test. Also annotate
get_group_shut_list's ~60s cache-delay tradeoff and mark _mark_all_as_read
as an explicit no-op pending an RE'd one-shot read cmd.
2026-06-18 00:05:20 +08:00
DrSmoothl 84e5254c3a feat(onebot): implement get_file via media cache resolution
Resolve file_id against the image cache, then the record cache (both
already mint a fresh url + carry file_size/file_name), replacing the
not-yet-implemented stub. Document-type files have no id-resolvable
cache yet, so they are intentionally left uncovered rather than faked.
2026-06-17 23:58:00 +08:00
DrSmoothl 8144dd9c27 feat(onebot): implement get_group_shut_list via member shutUpTime
Derive the muted-member list from the TTL-cached group member list
(shutUpTime = absolute expiry epoch seconds), replacing the placeholder
that always returned []. NapCat-shaped output {user_id,nickname,shut_up_time}.
2026-06-17 23:57:12 +08:00
DrSmoothl 16920b4444 chore(release): v1.9.12 2026-06-16 21:59:31 +08:00
DrSmoothl f014defd54 feat(notifications): node-config-style channel manager + lock disabled-channel opt-in
Settings → 通知 now mirrors the node-config UX: info-only channel cards
(name / id / url / template + an inline enable toggle), create + edit via a
dialog, and all changes debounced-auto-save (no save button). The save path
is generation-guarded so a superseded in-flight save can't clobber a newer
edit; the test action flushes the pending save first so it never hits an
unsaved channel. The channel id is locked while editing to keep per-account
channelIds references from orphaning.

Per-account opt-in: a globally-disabled channel's toggle is now locked +
forced off (it can never fire). The id is kept in channelIds — the backend
already filters disabled channels — so re-enabling globally restores the
prior selection.

New: notification-channel-dialog.tsx. webui typecheck + build green.
Adversarial review: 0 blockers / 0 should-fix (2 nits applied).
2026-06-16 21:54:19 +08:00
DrSmoothl 0b7fb0c69d feat(notifications): P4 WebUI — channel manager, per-account opt-in, deliveries widget
- API client: notifications surface (getConfig/saveConfig/recent/test) + types.
- Settings → 通知 tab: global channel CRUD (id/name/url/body-template/enabled) +
  global debounce window, per-channel test-send, dirty-tracked save; invalid
  channels (bad slug / non-http URL) are dropped server-side and surfaced.
- Per-account 通用设置: opt-in toggles bound to notifications.channelIds, riding
  the ConfigPage's existing debounced autosave; stale opt-ins preserved.
- Overview: opt-in 最近投递 widget (polls /api/notifications/recent, read-only).
  Hidden-by-default set is now DERIVED from the catalogue (defaultVisible) and
  shared by desktop + mobile reconcile — replaces the hardcoded STATIC_WIDGET_IDS
  so a new opt-in widget can't reappear unbidden on existing mobile dashboards.

Typecheck + build green. Adversarial review: 1 should-fix (mobile hidden-set) +
2 nits found, fixed/triaged, and re-verified clean.
2026-06-16 19:53:31 +08:00
DrSmoothl c0af18e1c2 feat(notifications): P3 HTTP face — channel config CRUD + recent + test-send
Bearer-gated WebUI routes:
- GET/POST /api/notifications/config — read/normalize the global channel store
  (whole-config CRUD, mirroring the ui-config pattern).
- GET /api/notifications/recent?limit=N — in-memory delivery history (≤100).
- POST /api/notifications/test {channelId} — one-off test to a single channel
  (ignores enabled/opt-in, sample vars, reuses render+POST, not recorded).

NotificationManager.testSend() added; initWebUI takes the manager (optional).
Tests: testSend unit coverage. 16 manager tests green, typecheck clean.
Adversarial review: 0 blockers / 0 should-fix / 0 nits.
2026-06-16 19:30:54 +08:00
DrSmoothl 30bcd97a3f feat(notifications): P2 manager — debounce state machine + render/POST + history
Global singleton observing every account's online/offline edge:
- Pure per-UIN debounce state machine (debounce.ts): an offline surviving
  debounceSeconds fires "offline"; recovery fires the paired "online"; a
  self-heal within the window fires nothing; a cold online fires nothing.
- NotificationManager (manager.ts): renders each opted-in + enabled channel's
  body template and POSTs it (single attempt + timeout, JSON), recording the
  outcome in a bounded in-memory history (lost on restart). Side effects
  (config/channelIds/post/clock) are injected for testability.
- BridgeManager: single-callback setters → additive listeners so OneBotManager
  and NotificationManager observe independently; close edge now carries the
  bridge (read nickname before dispose); fire-all with per-listener try/catch.
- Wired into the composition root, bound after OneBotManager.

Tests: debounce machine (pure, all misfire sequences) + manager (selectChannels,
dispatch, history cap, fake-timer self-heal/pairing). 23 green, typecheck clean.
Adversarial review: 0 blockers / 0 should-fix (2 NITs applied).
2026-06-16 19:20:55 +08:00
DrSmoothl 34d05f41f5 feat(notifications): P1 config layer — global channel store + per-UIN opt-in
Global channel store at config/notifications.json, mirroring ui-config's
total-normalize + atomic-write + cached-load conventions: NotificationsConfig
(version, debounceSeconds clamp [0,3600] default 30, channels[]), a total
normalizeNotificationsConfig (drops malformed/non-http channels, dedupes by
id, strips unknown fields), and the pure renderTemplate ({key} substitution;
missing keys left as-is; no re-expansion of substituted values).

Per-UIN opt-in: OneBotConfig.notifications.channelIds (validated slugs,
last-write-wins merge across global+per-uin, deduped) in the onebot package.

Tests: 19 (core notifications-config) + 12 (onebot config), all green.
Adversarial review: 0 blockers / 0 should-fix.
2026-06-16 19:02:01 +08:00
DrSmoothl bf5978fb51 chore(spike): remove KickNT body-capture probe — capture complete
The read-only push-body probe in Bridge has served its purpose; the KickNT
push body and its reason fields are captured for the upcoming notification
reason-enrichment. Bridge back to baseline.
2026-06-16 18:37:22 +08:00
DrSmoothl 652a817568 chore(spike): retire KickNT force-offline probes, keep read-only body capture
Conclude the spike on the inbound
`trpc.qq_new_tech.status_svc.StatusService.KickNT` push and remove its
throwaway instrumentation:

- Bridge: drop the outbound send probe.
- HookSession: drop the loggedIn-gate bypass; restore `if (!this.loggedIn) return;`.
- HookManager: drop the disconnect-timing log.

Keep one read-only `[KICK-BODY]` probe in Bridge that logs the KickNT push
body (hex + utf8) so the reason fields (tipsTitle / tipsDesc) can be reversed
for the upcoming notification reason-enrichment. Remove once captured.
2026-06-16 18:17:46 +08:00
DrSmoothl fb567177b4 feat(webui): Apple-widgets dashboard editor — gallery drag in/out
Reworks the desktop overview edit UX from per-card show/hide toggles to an
Apple-widgets model:

- The grid shows only the widgets that are on it; hidden ones live in a new
  right-side 部件库 ("store") panel that slides in during edit mode.
- Add: drag a gallery tile onto the canvas (HTML5 DnD) — dropped at the cursor
  cell, driven entirely through React state.
- Remove ("hide"): drag a card back onto the gallery (gridstack `removable`).
  Move/resize/remove all funnel through one onChange writer that derives removal
  from a block's absence in the grid snapshot — no separate handler, no state
  race.
- Card affordance: the big top "notch" bar is gone. Edit mode now shows two
  small square corner icons — a subtle semi-transparent drag hint (top-left, the
  whole card stays draggable) and the settings gear (top-right, configurable
  widgets only). The eye toggle is removed.

Mobile is unchanged (single-column reorder panel) — the gallery drag UX is
desktop-only by design.
2026-06-16 14:57:39 +08:00
DrSmoothl 2913a7a19b test(webui): regression tests for ui-config normalizers (B4)
First test coverage for the WebUI config trust boundary + the B1–B3 schema
additions (22 cases in packages/core/tests):

- normalizeAppearance totality (garbage → defaults, clamps, bad enums); custom
  fonts accept free-form families but reject control chars/injection/over-length;
  cssVars keep only whitelisted tokens with break-out-proof colour values.
- normalizeLayout: overviewMobile/topbarItems order-preserving + de-duped +
  fallback; overviewBlocks coord clamping; per-widget config 4096-byte cap.
- normalizePages: logs preset oneOf (unknown → custom).
- sniffImageMime magic bytes; normalizeUiConfig never derives image fields from
  the value.
- fs-backed (isolated temp cwd + fresh module): saveUiConfig forces the
  server-managed image fields (client can't forge hasImage); publicAppearance
  strips customCss but keeps cssVars + other cosmetic fields.
2026-06-16 14:38:57 +08:00
DrSmoothl 7355fe9ea4 feat(webui): log buffer export + view presets (B3)
- 导出当前视图: a toolbar button dumps the current (filtered) log view to a
  client-side `.log` text file — no server endpoint, respects the active level
  filter + search.
- 视图预设: 开发 / 运维 / 精简 presets bundle the existing display prefs
  (visibleLevels / maxLines / autoScroll / wrap); a new `UiLogsPrefs.preset`
  field stores the active id. Any hand edit to those prefs drops to 自定义.
  The client owns the preset→prefs bundles; the server just stores + validates
  the id (oneOf, defaults custom). No non-chronological reordering, per plan.
2026-06-16 14:36:02 +08:00
DrSmoothl 1a0bc12ec0 feat(webui): kiosk (展示模式) for chrome-free wall displays (B2)
A session-sticky kiosk mode for showing the dashboard on a wall display:

- Entry: `?kiosk=1` URL param (stripped after read) or a top-bar button
  (toggleable via topbarItems 'kiosk').
- Hides the sidebar + top bar (MainLayout) and any in-content edit entry marked
  data-kiosk-hide (the overview 编辑布局 toolbar) via an html[data-kiosk] CSS rule.
- Exit: Esc key or a dim corner button.

Purely cosmetic — never touches auth and issues no token. A new KioskProvider
(sessionStorage-backed, mounted under LayoutProvider) owns the state.

Completes the B2 dashboard/components batch.
2026-06-16 14:32:57 +08:00
DrSmoothl 72e0d6bcca feat(webui): static note / link / account widgets (B2)
Three opt-in static widgets join the GRID_WIDGETS catalogue (hidden by default
— enabled from the layout editor), with content stored in block.config:

- 便签 (note): pre-wrap plain text, capped at 4000 chars (under the server's
  4096-byte config cap). No Markdown.
- 链接卡 (link): label + http(s)-only url (javascript:/data: rejected) + an
  icon from a curated lucide set; renders as a clickable card.
- 账号快捷卡 (account): a specified uin's avatar/nickname/online dot, with a
  datalist of connected accounts in the config form.

GridWidgetSpec gains `defaultVisible`, honored by defaultOverviewGrid/Mobile and
migrateOverviewBlocks; reconcileLayoutItems gains a `hiddenByDefault` set so the
mobile catalogue appends these hidden too. parse* validators keep config total
over corrupt input.
2026-06-16 14:28:56 +08:00
DrSmoothl 60d1f0b04f feat(webui): config forms for host + connections widgets (B2)
Adds the gear config form to two more overview widgets (stat tiles stay
form-less, per plan):

- 主机资源 (host): toggle which resource panels show (CPU / 内存 / 运行进程);
  the grid column count tracks the visible panel count, and an all-off config
  falls back to all-on (no empty card).
- OneBot 连接 (connections): filter by account nickname/uin, an "仅显示异常端点"
  toggle (drops healthy adapters and emptied accounts), and sort by 默认 /
  名称 / 异常优先 (worst status first).

Both register in CONFIGURABLE_WIDGETS with parse* validators, render via the
existing dialog, and surface on mobile through the same per-widget config map.
2026-06-16 14:24:21 +08:00
DrSmoothl 23048894b5 feat(webui): top-bar element show/hide (B2)
The top-bar's optional right-side elements (connection-status badge, theme
toggle) are now toggleable via `topbarItems`. Essential controls — the
menu/collapse button, page title, and logout — always render and aren't listed.

- A TOPBAR_CATALOGUE (id+label) drives both the render gating and the settings
  toggles; TopBar reconciles `topbarItems` against it so a new element defaults
  to shown.
- A 顶栏 group in settings → 外观 exposes one switch per catalogue element.
2026-06-16 14:20:23 +08:00
DrSmoothl 754621de04 feat(webui): independent single-column mobile overview (B2)
Phones now render the overview from the dedicated `overviewMobile` ordering
instead of deriving a 1-column view from the desktop grid — the two layouts are
fully disjoint, so reordering on a phone never touches grid coords and vice
versa.

- View mode reuses DashboardGrid (drag disabled, cols=1), stacking each widget
  at its catalogue height via a pure prefix-sum, so cards keep their cell
  sizing. Per-widget config is shared with the desktop blocks.
- Edit mode surfaces a motion `Reorder` drag-sort panel (grip + show/hide eye +
  config gear for configurable widgets) — no 2D drag on mobile, per plan.
- The 编辑布局 toggle now appears on mobile too, with a mobile-specific hint.

Order/visibility reconciles against the live widget catalogue, so a widget
added later just appears on phones as well.
2026-06-16 14:18:40 +08:00
DrSmoothl 45d651614b feat(webui): layout schema for mobile overview + topbar items (B2)
Schema foundation for the B2 dashboard batch, mirroring the navItems shape:

- `overviewMobile: UiLayoutItem[]` — a separate single-column (id+visible)
  ordering for phones, kept fully disjoint from the desktop 2D `overviewBlocks`
  grid so mobile ordering never pollutes grid coords.
- `topbarItems: UiLayoutItem[]` — show/hide list for toggleable top-bar
  elements (status badge, theme toggle, kiosk button); essential elements stay
  pinned in the consumer.

Server `normalizeLayout` round-trips both (idOr + dedup, total over corrupt
input); defaults seed first run. `LayoutContext` exposes them with
setOverviewMobile/setTopbarItems setters and reconcile-on-consume semantics, so
adding a widget/element later just appears. Old configs lacking the fields fall
back to defaults on load.

Consumers (mobile render, topbar render + config, drag-sort panel) land in
follow-up B2 commits.
2026-06-16 14:14:03 +08:00
DrSmoothl 6d4e8538ea fix(ui): add focus-visible ring to ToggleSwitch and Sheet close (B1)
Keyboard-a11y polish to match the codebase's standard
`focus-visible:ring-[3px] focus-visible:ring-ring/40` treatment:

- ToggleSwitch (role=switch, used across every settings/config surface) had
  no focus-visible ring — keyboard users got no focus indicator.
- Sheet's close button used a bare `focus:outline-none`; now mirrors the
  Dialog close button's ring.

Slider, Button, Input, Select, Tabs, Checkbox, Switch already carry the ring.
2026-06-16 14:10:04 +08:00
DrSmoothl 636189d7c9 feat(webui): CSS snippet templates + low-contrast accent advisory (B1)
- Custom-CSS editor stays a plain <textarea> (native Cmd/Ctrl+Z undo) but
  gains insert-at-cursor template buttons (string-constant snippets) and a
  best-effort on-blur validation that warns on unbalanced braces or CSS that
  parses to zero rules. Never blocks saving; ?safe-mode=1 remains the escape.

- Accent picker: choosing a custom colour whose contrast against the canvas
  falls below WCAG 3:1 surfaces a one-time (per-session) advisory offering to
  revert. Pure-advisory — applies immediately, no persistent banner, no nag.
2026-06-16 14:08:09 +08:00
DrSmoothl 5b07f518cc feat(webui): custom fonts + theme-variable override panel (B1)
Adds two appearance customizations from the WebUI plan's B1 batch:

- Custom fonts: new `fontSansCustom`/`fontMonoCustom` free-form family
  fields (mirroring accent's preset/custom dual-field). A "自定义" segment
  reveals a text input; on Chromium it gets a native <datalist> of installed
  fonts via `queryLocalFonts()`, feature-detected with a silent fallback to
  a plain text box on Firefox/Safari/mobile. No font-file uploads.

- Theme-variable panel: new `cssVars` map lets the operator override
  individual theme tokens (background/foreground/primary/…) with colour
  pickers, layered last over palette+accent. Both keys (token whitelist) and
  values (hex/named/functional colour) are validated, so — unlike customCss —
  this stays safe to serve to the unauthenticated login page.

Server `normalizeAppearance` gains `fontFamilyOr` (rejects control
chars/injection) and `normalizeCssVars` (whitelist keys + colour-only values,
total over corrupt input). Client `ThemeContext` re-validates defensively
(the localStorage cache is locally tamperable) before injecting into CSS.

Tests for the new normalizers land with the B4 batch.
2026-06-16 14:04:59 +08:00
DrSmoothl e0d5b47488 feat(msg-push): filter C2C control pushes by c2c_cmd, matching QQ NT (#102)
Upgrades the blank-body heuristic to NT's exact discriminator. RE of
wrapper.linux.node (msg_header_codec_helper.cc::DecodeRoutingHead, sub_3B42650)
shows the official client classifies a C2C push by (svr_msg_type, c2c_cmd):
msgType ∈ {141,166,167} with c2c_cmd ∈ {1,73,75,129,131,133,135,192} is routed
through OnRecvSysMsg (long_cnn_msg_mgr.cc) as a system/control signal and never
shown as a chat bubble — the group-invite "[空消息]" phantom is one of these.

- proto-defs: ContentHead field 3 was mislabelled `divSeq`; it is `c2c_cmd`.
  Renamed (only other use was forward.ts, which zeroes it on a fake send).
- MsgPushHead now carries c2cCmd; new isC2cControlPush() in blank-filter.ts.
- parseMsgPush + both history fetches drop control pushes by (msgType, c2cCmd)
  regardless of body, so a control signal carrying stray content is caught too
  (not just the empty case). isBlankMessage stays as the fallback for
  content-less pushes we don't yet classify by cmd. Live path debug-logs a
  control push that carried elements, in case the table ever eats real content.

NOTE: the c2c_cmd set is taken from NT; we have not captured #24's actual cmd
(our diagnostic never logged field 3), so the blank fallback remains load-bearing
until a live capture confirms which cmd the invite phantom uses.
2026-06-16 01:34:38 +08:00
DrSmoothl 2ffb6192dd fix(msg-push): drop blank "[空消息]" pushes in c2c/group history too (#102)
The #102 fix dropped content-less control pushes only in parseMsgPush (live).
History fetches (fetchC2cMessageRange / fetchGroupMessageRange) decoded straight
into the result, so the same phantom resurfaced as "[空消息]" on backfill. RE of
wrapper.linux.node confirms QQ NT filters blanks on BOTH paths — live/roam C2C
via c2c_roam_msg_mgr.cc::FilterBlankMsgAndRetryFetch and group roam via
group_roam_msg_worker.cc::FilterBlankSeqsMsg.

Extracts the blank test into blank-filter.ts (bodyHasDecodableContent +
isBlankMessage) and applies it in all three message-producing paths. A message
that decoded to 0 elements but whose body carried content is a missing decoder,
not a blank — isBlankMessage returns false there so it's kept (live path still
warns).
2026-06-16 01:24:31 +08:00
DrSmoothl d5af6b10ef fix(msg-push): drop genuinely-empty c2c push instead of surfacing "[空消息]" (#102)
A group invite makes QQ emit a content-less c2c message (msgType=166/0, body
richText.elems=[]) right after the invite card. The TRACE log from #102's
diagnostic confirmed the body is truly empty — no commonElem, no msgContent,
no ptt/file — so it's a phantom control push, not an element type we fail to
decode. It was reaching OneBot clients as a confusing "[空消息]".

parseMsgPush now drops a message-kind event that decodes to zero elements when
its body carries nothing decodable. When the body *did* carry content but still
decoded to nothing (a real missing decoder), the event is kept and a warning
fires so the gap gets noticed rather than silently swallowed — replacing the
temporary #102 diagnostic with the actual fix.
2026-06-16 00:54:28 +08:00
DrSmoothl 300fa70148 feat(spike): add logging and handling for KickNT command in bridge and session 2026-06-16 00:34:03 +08:00
DrSmoothl 6003acac56 chore(msg-push): log body of empty-decoded messages (#102 diagnostic)
A group invite surfaces a content-less "[空消息]" friend message (#102). Before
deciding whether to drop or decode it, we need to know what its body actually
carries — SnowLuma's decoder covers fewer element types than Lagrange, so an
empty decode may be a missing decoder (real content) rather than a genuinely
empty control push.

parseMsgPush now warns, on any message that decodes to zero elements, with a
summary of the body: each element's field names, every commonElem's
serviceType/businessType + payload hex, and any msgContent. One log line is
enough to identify the culprit element offline.

No behavior change — the message still dispatches as before. Temporary; remove
once the #102 culprit is identified and handled.
2026-06-15 22:07:50 +08:00
DrSmoothl b0bfb9e6a7 feat(contacts): resolve non-member group name via 0x88D_0 (group invites)
A group invite push carries no group name (confirmed across SnowLuma / Lagrange
/ NapCat — all three invite protos only have groupUin + invitorUid), and
get_group_info only resolved joined groups (fetchGroupList → roster), so an
invited group rendered "未知群名". Add a by-id server lookup so the name
resolves even when the bot isn't a member.

- proto-defs: 0x88D_0 request (field1 + config.uin + a request-mask `flags`
  block) + response (groupInfo.results: name / member counts / owner / …),
  field tags transcribed from
  dev/Lagrange.Core/.../OidbSvcTrpcTcp0x88D_0{,Response}.cs.
- protocol: FetchGroupDetail oidb-service. Envelope reserved=0 (NO uinForm) —
  Lagrange's GetGroupInfoService builds it with isUid=false.
- core: ContactsApi.fetchGroupDetail(gid) → QQGroupInfo | null; deliberately
  NOT remembered into the joined-groups roster (mustn't pollute get_group_list).
- onebot: get_group_info falls back to fetchGroupDetail for non-member groups,
  behind a 5-min per-id TTL cache so a burst of invites for one group can't
  hammer 0x88D_0 (rate-limit / kick risk). noCache bypasses it.
- tests: request/envelope round-trip (reserved=0, uin, command), response
  decode; + non-member resolve / cache-hit / noCache-bypass at the onebot layer.

Live note: byte-identical to Lagrange's 0x88D_0, but not verified against a real
account on this host.
2026-06-15 14:33:06 +08:00
DrSmoothl 7e59dbf79b chore(release): v1.9.11 2026-06-15 13:57:38 +08:00
DrSmoothl 28633f3c62 feat(onebot): back-fill a quoted message from the server when not stored
When an incoming reply quotes a message SnowLuma's store doesn't have (e.g. one
from before it was running, or never observed), the receive pipeline now fetches
just that message from the server and persists it before dispatch — so a
consumer's get_msg on the quote resolves instead of returning "message not
found". Works for both group and private (c2c).

- core: MessageApi.getGroupMessageBySeq / getC2cMessageBySeq — fetch a single
  message over a 1-wide [seq,seq] range, through the shared history throttle
  gate (same rate-limit as the bulk history APIs — no flood / kick risk).
- onebot: backfillReplyTarget — on a group/friend reply whose target isn't in
  the store, fetch (group SsoGetGroupMsg / c2c SsoGetC2cMsg, uid resolved for
  c2c) and persist under the EXACT id the reply resolves to, so get_msg hits
  regardless of who sent the quoted message (a bot self-sent c2c message
  converts with user_id=self but is keyed under the peer session). Awaited
  before dispatch so the consumer sees it; best-effort — a back-fill failure
  never blocks delivery of the live message.
- tests: gating (already-stored / no-reply / nothing-on-server) + keying
  (group, c2c, c2c self-sent under peer session).

Only fires on a store miss, so the common path (no reply, or quote already
stored) stays zero-cost.
2026-06-15 13:53:04 +08:00
DrSmoothl cf8e62d2ac fix(onebot): resolve c2c reply target by srcMsg friendSequence, not origSeqs
Replying to a bot self-sent private message still failed get_msg on the quoted
id ("无法解析申请信息"). Root cause is the reply DECODE, not the keying: for a
c2c (friend) reply the canonical replied-to sequence is carried in
srcMsg.pbReserve.friendSequence (tag 8), while origSeqs[0] holds the per-sender
clientSequence. The decoder read origSeqs[0], so the reply resolved to a
sequence that never matches how the original message is keyed (by its server /
private sequence) — get_msg missed. Mirrors Lagrange's incoming-reply rule
`Sequence = reserve.FriendSequence ?? OrigSeqs[0]` (ForwardEntity.cs +
SourceMsg.PbPreserve.cs ProtoMember(8)).

- proto-defs: +SrcMsgPbReserve { receiverUid@7, friendSequence@8 }
- protocol: decodeRichBody reads friendSequence for c2c replies (group keeps
  origSeqs[0], the shared group seq); fall back to origSeqs[0] when absent
- tests: c2c→friendSequence, group→origSeqs[0], no-reserve→fallback

This supersedes the keying approach in 4571864 (self-sent c2c keyed by
clientSequence), which is fully reverted here: the message is keyed by its
private sequence as before, and the fix is purely on the decode side so it also
covers replies to *received* old c2c messages, not just self-sent ones.
2026-06-15 11:58:28 +08:00
DrSmoothl 457186419f fix(onebot): key self-sent c2c messages by clientSequence so replies resolve
A friend replying to a bot self-sent private message → bot get_msg(quoted id)
returned "message not found" (bot then answered "无法解析申请信息"). c2c replies
reference a message by the sender's clientSequence, not the server sequence
(Lagrange: OrigSeqs = ClientSequence || Sequence), but self-sent private
messages were keyed by the server (private) sequence — so the incoming reply's
resolved id hash(clientSequence, peer, private) never matched the stored event.

Reply-SENDING still worked because it only needs the meta row (resolveReplySequence
queries by hash, no data), not the full event (findEvent requires data IS NOT
NULL) — which is exactly why get_msg / "引用解析" failed but replying didn't.

Key self-sent c2c messages by clientSequence (fallback to sequence, mirroring
Lagrange's `||`) via a shared c2cSelfMessageId helper, across all three c2c
self-sent paths (send_private_msg, private forward, forward-single-to-user).
Group messages unchanged (their replies reference the shared group sequence).
2026-06-15 10:03:26 +08:00
DrSmoothl 9dfa31bdbc fix(onebot): allow negative message_id in get_{group,friend}_msg_history
message_id is a signed int32 hash (hashMessageIdInt32) and is frequently
NEGATIVE, but both history actions declared it as `f.int({min:0}).default(0)`
— so a real anchor like message_id=-497311472 was rejected at param
validation (retcode 1400, 0ms) before run() fired, and the server-side
history fetch never happened (the client then reports "未找到足够的群聊记录").

Drop the min:0 bound → `f.int().default(0)`: negatives pass, absent/present-0
still mean "fetch latest", count stays ≥0. Matches every other action, which
uses f.messageId() for signed message IDs.

Regression tests: negative group/friend anchors now reach the handler;
absent message_id still defaults to 0.
2026-06-14 23:45:59 +08:00
DrSmoothl 8dc4ff48f8 feat(onebot): unify market faces to image segments + add send support
Market faces (商城表情) now decode to an OneBot `image` segment carrying
NapCat-style markers (emoji_id/emoji_package_id/key/summary) plus a gxh
gif URL, so clients that don't special-case `mface` still render the
sticker while aware clients can reproduce it.

Adds the previously-missing send path: a dedicated `mface` segment OR an
`image` segment carrying `emoji_id` rebuilds the wire `marketFace`
(faceId=hex(emoji_id), itemType 6 / subType 3 / 300×300 /
pbReserve.field8=1) — byte-matched against NapCat and Lagrange.Core.

- proto-defs: MarketFace.key bytes→string; +MarketFacePbReserve{field8@8}
- protocol: decode faceId→emojiId(hex)/tabId/key; makeMarketFaceElem +
  buildSendElems 'mface' case; format preview keeps the sticker name
- onebot: to-segment mface→image; message-parser mface + image(emoji_id)
- tests: wire decode + full proton round-trip; parse→build; segment shape

Reply/forward round-trips preserved (element keeps type 'mface' + text).
2026-06-14 20:47:50 +08:00
DrSmoothl 58c02d415f feat(onebot): fetch real private (c2c) history from server (#99)
Mirror the group-history fetch for private chats via SsoGetC2cMsg
(trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg, fields per Lagrange:
flat friendUid/start/end request, messages@7 response). get_friend_msg_history
now resolves the anchor sequence + the friend's UID, fetches real history from
the server, converts + persists each message, and falls back to the local
store on unknown anchor / no uid / empty / error.

- protocol: fetchC2cMessageRange (decodes via the friend decoder).
- core: the group + c2c fetch now share a generic walkBackHistory loop
  (same throttle gate + caps); added MessageApi.getC2cHistory.
- onebot: getFriendHistory + a generalized persistHistoryEvent (keys private
  by sender uin, exactly like the live path — so reply to old private
  messages resolves too).

Tests: SsoGetC2cMsg request/response round-trip + friend-decode reuse.
2026-06-14 20:15:38 +08:00
DrSmoothl 987ac09896 feat(onebot): fetch real group history from server (#99)
get_group_msg_history was served only from the local observed-message store,
so it could never return more than what SnowLuma saw live (the "only 100
messages" symptom). It now fetches real history from the server.

- protocol: new SsoGetGroupMsg packet (trpc.msg.register_proxy.RegisterProxy
  .SsoGetGroupMsg, sequence-range fetch; fields per Lagrange). Each returned
  PushMsgBody is decoded by the existing group decoder via a factored-out
  buildContextFromMessage.
- core: MessageApi.getGroupHistory walks backward in small sequence windows
  with rate-limiting + data-volume guards — a process-wide throttle (≥300ms
  between sends), 30-seq chunks, ≤12 requests, ≤200 messages per call — so a
  hammering client can't make us flood the server and get kicked.
- onebot: get_group_msg_history resolves the anchor sequence (requested
  message_id or latest observed), fetches from the server, converts + returns,
  and persists each fetched message. Falls back to the local store on
  unknown anchor / empty / error.

Persisting fetched history also fixes replying to pre-injection messages:
the reply path resolves message_id→sequence via the store, which now contains
the fetched messages.

Tests: SsoGetGroupMsg request/response round-trip + decode reuse (protocol);
walk-back chunking/dedup/cap/floor (core).
2026-06-14 19:11:03 +08:00
DrSmoothl ee8eb928b3 chore(release): v1.9.10 2026-06-14 16:28:39 +08:00
DrSmoothl 8f61b1486e feat(webui): Apple-style overview pass + roomier stat tiles
- Grid row height 80→108px so a 1-row stat tile's icon + 3 text lines clear
  the padding with real breathing room (the previous fix still clipped ~8px);
  stat tile gets an airier layout (size-10 icon, text-lg value, py-3.5).
- Carry the app's Apple language across the homepage: inner surfaces and
  empty-state containers round to xl, empty-state icons calm to size-8/opacity,
  edit hint + first-run nudge rounded consistently.
2026-06-14 16:20:19 +08:00
DrSmoothl 44a29c6ee4 feat(webui): OLED/accent/palette theming fixes + light-dark reveal
- OLED: toggle switch off-track is now a neutral foreground tint with a white
  knob (was invisible bg-muted + bg-background on pure black); the OLED sidebar
  override is scoped to sidebar-style=follow so panel/accent work in OLED too.
- Accent: the chosen accent now also tints the selected-state surfaces
  (--accent / --sidebar-accent) so the sidebar active item and settings nav
  rail follow it — default palette only, respecting accent scope.
- Palettes: add 6 more soft schemes (Rosé Pine / Moon / Dawn, Nord, Everforest
  dark + light) via a pal() builder; schema unions + normalizer + bootstrap
  light/dark classification updated to match.
- Light/dark switch now animates: a circular View Transitions reveal from the
  click (mode/palette/intensity changes only; gated off under reduced/disabled
  motion; instant fallback where the API is absent).
2026-06-14 16:20:05 +08:00
DrSmoothl 84021a19dc feat(webui): deep-link settings tabs via ?tab= and jump update banner to 关于
The settings sub-tab now lives in the URL (typed `?tab=` search param on the
/settings route, validated against the tab whitelist; default 外观 omits it).
The active tab is derived from the URL and tab clicks navigate (replace), so
tabs are shareable/back-button friendly. The sidebar "有新版本可用" banner now
links to /settings?tab=about, landing directly on 关于 (where 软件更新 lives)
instead of the default settings tab.
2026-06-14 15:45:42 +08:00
DrSmoothl df0b79a87d feat(webui): restyle login page with Apple-style vibrancy
- Frosted "vibrancy" card (bg-card/65 + backdrop-blur-2xl) floating over the
  wavy background, with a centered brand focal point (logo tile, name,
  version, subtitle).
- Larger rounded fields/button (h-12, rounded-xl) for a calmer iOS feel.
- The "我觉得这个界面很卡怎么办?" help line becomes a 灵动岛-style frosted
  capsule (rounded-full, blur, float, spring on hover/tap).
- New entrance/hover motion gated on reduceMotion/disableMotion.
2026-06-14 00:47:56 +08:00
DrSmoothl dc26cc93ba fix(webui): hide native Edge password reveal control on login
The login password field showed two eye icons — our custom show/hide
toggle plus Edge/IE's native ::-ms-reveal. Hide the native reveal/clear
pseudo-elements so only our toggle remains.
2026-06-14 00:46:46 +08:00
DrSmoothl 985dfd4705 feat(webui): animated login background + three independent motion settings
Login page now has a mouse-reactive wavy-line background (ported a-waves +
embedded Perlin noise, themed to the accent, rendered behind the card which
sits on a 95%-opaque blurred surface so content stays readable). A "我觉得这个
界面很卡怎么办?" link opens a dialog that toggles it off — a device-local
preference (localStorage, works pre-auth, defaults off when the OS prefers
reduced motion), independent of the system motion settings.

Motion settings are now three:
- 关闭登录页的动效 — the login background toggle above (device-local).
- 减弱动效 (reduceMotion) — soft: transforms/springs off, durations
  collapsed, page/sidebar instant; gentle entrance fades remain.
- 关闭全部动效 (disableMotion, NEW appearance field) — everything reduce does
  plus data-no-motion CSS (kills pulse dots; spinners excluded so "busy"
  stays legible) and the entrance opacity fades gated off across
  overview/processes/logs/config lists.
2026-06-14 00:41:18 +08:00
DrSmoothl 9dd46198cb feat(webui): custom scrollbars + sliders, stat-tile fit, reduce-motion
- Scrollbars: theme native scrollbars (thin, token-colored thumb) for plain
  overflow containers; Radix ScrollArea keeps its own custom bar (it hides
  the native one, so no double bar).
- Sliders: new <Slider> component + `.slider` CSS replaces the native range
  inputs (UI scale / mask opacity / blur). Filled track + ring-bordered
  thumb, themed; input height matches the track so the thumb centers in every
  engine; Firefox fills via ::-moz-range-progress.
- Stat tiles: tighten the h=1 tile (py-2 + mt-0.5) so the three text lines +
  padding fit the 80px cell without hugging the border.
- Reduce motion: gate the always-present sidebar-width + page-transition
  animations to instant when enabled (Framer's reducedMotion only suppresses
  transforms, so these needed an explicit opt-out); kill the slider thumb
  transition too.
2026-06-14 00:16:26 +08:00
DrSmoothl 7d647e0ed7 feat(webui): redesign settings About panel in the Apple style
Split the single centered card into a clean three-section stack consistent
with the rest of settings:

- Brand hero card: logo glow + name + version pill + tagline/description.
- "软件更新" grouped list (reuses Group/SettingRow): current-version row +
  check-update row (status dot/spinner + 重新检查); when an update exists, a
  tinted detail block with version, notes, platform asset hint and 前往下载.
- Support card: refined Star CTA + repo/releases/issues links.

UpdateStatus → UpdateGroup; all update states (checking/disabled/error/
latest) preserved.
2026-06-13 23:50:55 +08:00
DrSmoothl e93a17de78 feat(webui): add Catppuccin Latte/Frappé/Macchiato/Mocha palettes
Add a "配色方案" picker offering the four Catppuccin flavors as full color
schemes alongside the default sky theme.

- Schema: new `palette` field on UiAppearance (core + client mirror),
  normalized to a 5-value whitelist defaulting to 'default'.
- Apply: each flavor's base/surface tokens are injected into the shared
  theme stylesheet over :root (Latte) or .dark (the three dark flavors);
  accent (--primary/--ring) stays separate so it composes. A flavor fixes
  light/dark (Latte→light, rest→dark), overriding `mode`, and forces
  dark-intensity to soft so the OLED "black" rule can't fight the palette.
  sidebar-style + high-contrast still compose (higher specificity).
- Pre-paint bootstrap is palette-aware (no light↔dark flash on reload).
- Settings: swatch-preview picker; mode + dark-intensity controls disable
  while a flavor is active. The top-bar ThemeToggle hides then (it would be
  inert under a fixed-scheme palette).
2026-06-13 23:42:16 +08:00