login-waves ran its requestAnimationFrame loop unconditionally, ignoring
reduced-motion. Honour it (OS `prefers-reduced-motion` or the app's
`data-reduce-motion`): when reduced, paint a single static frame and never start
the loop; otherwise animate. React live to OS preference changes, and repaint the
static frame on resize while reduced so the field doesn't blank out. The loop is
torn down on unmount as before.
deliveries-widget polled /api/notifications/recent every 15s regardless of tab
visibility. Skip the poll while `document.hidden`, refresh immediately on
becoming visible, and add an in-flight guard so a slow request can't stack
overlapping polls.
The log viewer and each dashboard "recent alerts" widget opened their own
EventSource to /api/logs/stream, so N alert tiles + the viewer meant N+1
identical connections (plus a short-lived-connection churn on navigation). Make
openLogStream ref-counted: the first subscriber opens a single shared stream,
every frame and status change fans out to all subscribers (each isolated with
try/catch and re-checked against the live Set so a mid-frame unsubscribe is
skipped), and the last to leave closes it. Late subscribers replay the current
status.
Hardening: a no-token open no longer caches a dead disposer (it would have
blocked every later subscriber from ever connecting); the shared URL bakes in the
token, so setToken() tears down and reopens on any token change (re-login would
otherwise retry a stale-token URL forever); the connecting window seeds
'reconnecting' so a late joiner's badge isn't wrongly 'closed'. Consumers are
unchanged — the dedup is transparent behind api.logs.stream.
The live-activity feed kept its /api/debug/stream EventSource open while paused
and merely dropped frames in the handler, so a paused feed still held a backend
connection slot and processed every event for nothing. Make `paused` a
dependency of the subscription effect: pausing now closes the stream and
resuming reopens it. Events during a pause were already discarded, and the row
buffer is preserved across toggles, so the only change is that a paused feed
stops consuming.
Deliberately NOT gated on tab visibility: debug-page keeps this component mounted
across tabs on purpose (the comment there notes actions fired from other tabs
must still land in the feed), so closing on tab switch would regress that. Only
an explicit pause severs the connection. StatusPill gained a `paused` state so a
deliberate pause reads as 已暂停 rather than 未连接.
The logs page rendered every filtered row, so a full 2000-line view mounted
~10k DOM nodes and cost ~66ms of layout per commit. Switch to a virtualized
list (@tanstack/react-virtual) that mounts only the visible window plus a small
overscan — a few dozen nodes regardless of how many lines the view holds.
Rows are dynamically measured (measureElement) so wrap mode's variable-height
lines need no fixed row-height assumption; the size cache is keyed by row id so
the maxLines front-drop doesn't mis-attribute heights across the index shift.
The column header moved just outside the scroll viewport so the virtual list is
the sole scroll content (no sticky-offset math). Auto-scroll now follows the
tail via scrollToIndex with a next-frame re-pin (freshly-appended rows carry the
size estimate until measured, which otherwise left wrap-mode tails short), and
re-runs on filter/level/wrap/maxLines changes, not just new logs. ScrollArea
gained an optional viewportRef so the virtualizer can drive off the real Radix
scroll element. The per-row entrance fade is dropped (rows mount/unmount on
scroll); divide-y became a per-row border.
Merged-forward nodes always stamped the current time. A custom forward can now
set each node's displayed timestamp via the OneBot `data.time` field (unix
seconds), matching the field the wire already carries per node.
parseForwardNodes reads `data.time` on custom/fake nodes and threads it onto the
node payload (the `time?` field already existed for the receive side);
buildForwardPushBody uses it for `contentHead.timestamp`, falling back to now
when absent. The value is validated to a plausible unix-seconds range — the wire
field is uint32, so a millisecond or out-of-range input is rejected (would
overflow) and defaults to now rather than corrupting the timestamp. The
message_id-reference branch (time from the quoted message) and the c2c-file
expireTime (real now) are unchanged. Nested inner nodes honour their own time.
Symmetric with the ws-client fix (#208): the reverse-WS server now pings each
attached client and terminates one that goes fully silent for ~90s (2 unanswered
pings), reaping a half-open client that would otherwise linger in the connection
set. Reuses startHeartbeat with the same conservative, activity-reset tolerance,
so a client with live traffic is never dropped. The per-connection stop is kept
on the ForwardConn entry and cleared in both close() and the socket 'close'
handler; no shared instance state, so the #97 overlap does not apply here.
A reverse-WS client could end up half-open after the peer restarted: still
writable (events kept flowing out) but no longer readable, so inbound action
frames silently never arrived and only a full restart recovered it (#208). The
OneBot app-level meta heartbeat is a one-way push and cannot detect this.
Add an RFC6455 ping/pong keepalive (startHeartbeat) wired into the ws-client
adapter. It pings every 30s and declares the link dead only after 2 unanswered
pings (~90s of total silence), then terminates the socket so the existing
reconnect path runs. Any inbound activity — a pong OR a message — resets the
counter, so a connection carrying real traffic is never reaped; the conservative
tolerance keeps the false-positive rate near zero (a GC pause or latency spike is
absorbed). The stop is per-socket and also held on the instance so close()/reload
halt it immediately rather than waiting for the deferred 'close'; a symmetric
identity guard on 'open' mirrors the #97 stale-socket guard on 'close'.
The whole segment row in the debug message builder was `draggable`, so an
HTML5 drag started on any pointer gesture inside it — including selecting
part of a pasted URL in a segment input, which dragged the card instead of
selecting text.
Move `draggable` (and onDragStart/onDragEnd) off the row onto the grip
handle only; the row keeps onDragOver/onDrop so it stays the drop target.
The body is now fully selectable/editable. The grip also moves up beside
the segment label, freeing the card's left edge. Nested forward-node
builders keep their stopPropagation drag boundary.
Validated the 0x210/39 ProfileLikeTip field layout byte-exact against three
real captured like pushes (detail.txt='赞了我的资料卡N次', f3=uin, f5=nick).
Drop the pending-validation caveat; test now uses the real txt shape.
Decode the multiplexed 0x210 subType-39 push: when body.msgContent is a
ProfileLikeTip with inner msgType==0/subType==203, emit notify/profile_like
{operator_id, operator_nick, times}. times is parsed from detail.txt.
Proto shape from NapCat's raw decoder; wiring + decode verified, but the
byte-exact wire is pending a real like capture to confirm.
Decode the 0x2DC subType-16 field13==6 push: eventParam carries the member
uin (f5) and a gray-tip template (f2) whose last rich token holds the title
text. Emit notify/title {group_id, user_id, title}, matching NapCat. Field
layout taken from a real on-wire sample, not ported blindly.
Detect a group member's card change from message traffic (sender card vs
cached roster card) and emit group_card {group_id, user_id, card_new,
card_old}, matching NapCat's parseCardChangedEvent. Fires only on a real
change of a known member; first-contact and unchanged cards are suppressed.
Decode the 0x210/0x115 input-state sys_msg (RE'd from wrapper.linux.node
aio_input_state_worker) into a friend_input_status event and map it to a
notify/input_status notice, matching NapCat's onInputStatusPush.
event_type 1 = typing, 3 = recording voice; status_text synthesised to
match the client's own strings.
A fake forward node (send_group_forward_msg / send_private_forward_msg) whose
data.user_id was "0" or omitted got rejected with "forward node user_id/uin is
required", even though the protocol端 is logged in and the core forward builder
(bridge/apis/forward.ts) already defaults a zero sender to the bot's own uin.
Upstream frameworks (AstrBot, etc.) don't manage QQ uins and legitimately send
user_id "0"; NapCat and LLBot accept it. parseForwardNodes now falls back to
ref.selfId, keeping the throw only for the not-logged-in case (selfId 0).
Verified on-target (#203).
The width/height=0 → 文件已过期 fix only covered the normal upload path, which
ffprobes real dimensions from the video bytes. The fast-upload (fingerprint /
forward) path has NO bytes to probe, so every field it ships is caller-supplied
and untrusted — a video that arrived on the wire without metadata caches 0s, and
re-forwarding it then shipped width/height=0 (receiver renders 文件已过期) and
time=0 (every client renders 00:00).
Harden videoPayloadFromFingerprint so no degenerate value reaches the wire:
- width/height fall back with `||` (not `??`, so a cached 0 is replaced too);
- duration falls back the same way (0 → 1, never 00:00);
- the fallback cover is now a real 720x1280 image, and the fallback video/cover
dimensions are derived FROM it (detectImageFormat), so the declared video size,
the declared cover size and the actual cover pixels are the same numbers by
construction — never a 1x1 image lying about its size.
Real cached values always win; c2c is unaffected (the wire builder forces 0 for
c2c). Verified on-target by forwarding a zeroed video (renders, not expired).
The width/height=0 → 文件已过期 fix only covered the normal upload path, which
ffprobes real dimensions. The fast-upload (fingerprint / forward) path has no
bytes to probe and takes dimensions from cached metadata, which is 0 when the
source video arrived on the wire without dimensions (e.g. forwarded from another
client that itself sent 0x0). Re-forwarding such a video to a group then shipped
0x0 and the receiver rendered a stale "expired" placeholder.
videoPayloadFromFingerprint now substitutes a neutral 720x1280 portrait when a
dimension is absent or zero (|| not ??, so a cached 0 is replaced too). Real
cached dimensions always win; c2c is unaffected (the wire builder forces 0 there
regardless). A forwarded dimensionless video now renders and plays instead of
showing 文件已过期; verified on-target by forwarding a zeroed-dim video.
A member's cached group card could stay stale for the lifetime of a long-running
bot: nothing refreshes it on a card change (no push), and the member-list refetch
only fires on join/leave/admin events (+60s TTL), never periodically — so a quiet
group freezes at warmup. get_group_msg_history / live events then showed an empty
or outdated sender.card even though the current card was in the message.
Every group message carries the sender's current display name in grp.memberCard
(field 4 = group card if set, else base nickname). The group decoder now prefers
it over the possibly-stale cache when it differs from the base nickname (a real
group card); and packet-pipeline self-heals it back into the in-memory member
cache, gated so it writes only on an actual change. This keeps active members'
cards fresh for all cache consumers off message traffic alone — no extra fetch,
no rate-limit exposure. Verified on-target: a member's card change now reflects
in the next message without a member-list refetch.
Known, accepted edge: a member changing only their base nickname (no card) is
mislabeled as a card until the next member-list refetch corrects it — field 4
alone can't distinguish the two, and the mislabeled value is still the member's
own current name.
get_msg / get_group_msg_history returned an empty sender.nickname for the bot's
own group file / video sends. Those publish via OIDB (0x6d9_4 / c2c finalize)
rather than PbSendMsg, so the QQ server never echoes them back — and the echo is
what normally overwrites the synthetic self-sent store's placeholder nickname.
For a plain text send the echo arrives and fills it, which is why only file/video
surfaced empty.
Seed the synthetic event's sender.nickname from the bot's own
identity.nickname (we are the sender, so we know it). A normal text send's echo
still overwrites it with the same value. Verified on-target: a self-sent group
video now shows the bot nickname in history.
get_forward_msg returned empty sender.nickname for group merged-forward nodes.
The reporter blamed a mis-parsed group_id + a failing member-list enrichment
(0xfe7_3 0x899_AUTHORITY_FAIL), but on-target capture shows a mislabeled proto
field: the group response-head's sender display name lives in field 4, which we
had labeled `groupName`. It's populated on live pushes ("星屿" / the group card
"墨梓柒111") AND on merged-forward nodes (per-node sender name), while field 2
(`memberName`) is empty on live/forward and only set by the group-history fetch,
and the real group name is field 7. Lagrange labels field 4 `MemberName`.
Relabel ResponseGrp: field 4 `memberCard` (sender display name = card||nick),
field 7 `groupName` (the real group name); keep field 2 `memberName` for the
group-history slot. The group decoder now resolves the sender name by priority:
field-2 name → local member cache (clean nick/card split) → field-4 memberCard.
The last step is what lets a forward node from a group the bot isn't in keep its
per-node sender name with no member-list lookup, so the forward-specific fallback
and the AUTHORITY_FAIL enrichment path are no longer needed for the common case.
Verified on-target across scenarios: live message senderNick unchanged; a plain
forward, a forward from a never-joined group (placeholder uin 1094950020, four
distinct senders 金橘雪梨/Crel/503/Xiing0), and a nested forward all keep their
nicknames; no member-list call fires. group_id stays the payload's value.
get_forward_msg returned empty sender.nickname/card for group merged-forward
nodes. The reporter blamed a mis-parsed group_id + a failing member-list
enrichment (0xfe7_3 0x899_AUTHORITY_FAIL on a placeholder source group), but
on-target capture shows the sender display name IS in the payload: a
merged-forward node leaves `grp.memberName` null and instead carries the sender
name in `grp.groupName` (field 4) — QQ repurposes the group-name slot per node.
parseMsgPush only reads memberName (correct for live messages), so the name was
dropped and the code fell through to the member-list enrichment, which can't
resolve a group the bot isn't in.
Fix: in the forward-node decode, fall back to `grp.groupName` for the sender
nickname (scoped to the forward path — live-message decoding is untouched). This
also makes the AUTHORITY_FAIL enrichment fetch unnecessary for the common case.
Verified on a real forward (fromUin 1787882683 ↔ grp.groupName "墨梓柒"):
nickname now populated, no member-list call. group_id stays the payload's value
(genuinely a placeholder/source group, not a mis-parse).
Wire the streaming primitives into the group-file and private/offline-file
upload paths. Both now stage the source onto a local disk path
(stageSourceToDisk — hardlink/download), hash it in one streaming pass
(hashFileStreaming; the offline-file md510MCheckSum comes from the same pass via
headLimit=MD5_HEAD_LIMIT), and PUT the file through a FileChunkSource — so a file
up to the 4 GiB protocol ceiling never holds more than one 1 MiB chunk in RAM.
The old readFileSync/loadBinarySource + computeHashes + md5First10MB buffered
path is removed.
The highway session is resolved before the file handle is opened (the handle's
owner, uploadHighwayHttp, must be the next call so its finally-close always
runs); a size+mtime guard re-checks the staged file before the PUT so a source
mutated mid-send fails cleanly; and the staged temp is released in a finally on
every exit. The OIDB preflight fields, the remembered-upload cache, the ext
blobs, and the publish steps (group 0x6D9_4 / private sendC2cFile) are byte-for-
byte unchanged. Tests re-mock at the stage/hash seams and assert the streamed
FileChunkSource + cleanup for both group and c2c uploads.
Wire the streaming primitives into the video send path. `loadVideo` now stages
the source onto a local disk path (stageSourceToDisk — hardlink/download),
hashes it in one streaming pass (hashFileStreaming), and returns a `fileSource`
instead of a full in-memory buffer; the main video sub-file PUTs through a
`FileChunkSource`, so a multi-GiB video never holds more than one 1 MiB chunk in
RAM. The thumbnail and the fingerprint (noByteFallback) fast path stay
byte-based and unchanged.
`runPuts` now sizes its fast-path / fast-only checks off `fileSource ?? bytes`
and resolves the highway session before opening the file handle (the handle's
owner, uploadHighwayHttp, must be the next call so its finally-close always
runs). A size+mtime guard re-checks the staged file before the PUT so a source
mutated mid-send fails cleanly rather than shipping bytes that disagree with the
already-sent hashes. The staged temp is released in every exit path.
Output is byte-identical to the buffered path (the OIDB fileInfo md5/sha1/size
and the uploaded bytes are unchanged); the old readFileSync-based
stageVideoSource is removed. Tests cover the disk path (fileSource not bytes,
streamed hash == buffered hash), cleanup on upload failure, and empty sources.
Stage any upload source onto a local disk path so the uploader can stream it
without buffering the whole file. `stageSourceToDisk(source, maxBytes)` unifies:
local file:// / raw path → hardlink into an owned stage dir (O(1); the inode
link pins the bytes against a caller-side unlink/retention), streamed-copy
fallback on any link() failure; http(s):// → stream to a .part file; base64://
→ maxBytes-checked decode to a .part file. Temps are finalized atomically
(fsync file → rename → fsync dir); a TTL reaper reaps crash-orphaned temps while
an in-use `active` set protects live uploads (a hardlink keeps the source's old
mtime, so the staged path is reserved in `active` before it appears on disk).
To avoid divergence, the existing `loadBinarySource` HTTP transport is extracted
into a shared `downloadHttp(source, name, maxBytes, makeSink)` engine (browser
UA, redirect, timeout, Referer retry, declared + streamed size caps, noRetry);
`loadBinarySource` now drives it with an in-memory sink — behavior unchanged.
Additive only — not yet wired into video/file uploads. Tests cover the
retention race (hardlink survives the original's deletion), the reaper's
active-set protection, size caps, symlink resolution, the copy fallback, the
Referer retry reusing the .part, and streamed-overflow .part cleanup.
On push to `dev`, dispatch SnowLuma.Docker.Framework/docker-image.yml to build
and push motricseven7/snowluma:dev (rolling) + :dev-<12sha> (immutable) from
this run's workflow artifact — no GitHub Release involved. also_latest=false so
dev never touches the stable :latest; the downstream job prunes old dev-<sha>
tags (keep newest 5). The dispatch job writes source SHA / run id / downstream
run URL to the job summary.
Supporting changes:
- Bundle production node_modules (npm install --omit=dev) before packaging so
the lite tarball the image bakes is actually runnable — mirrors release.yml.
- Add a deterministic-name hand-off artifact (snowluma-dev-docker-linux-x64,
dev-only) that the cross-repo Docker build downloads by name.
- Concurrency: only `dev` shares a self-cancelling group; every other branch
keeps a unique per-run group, so their behavior is unchanged.
- Age-gate the artifact cleanup (delete >24h, collect-then-delete) so it can't
race-delete an in-flight dev hand-off artifact.
Single-pass, disk-streaming replacement for the buffered
`computeHashes` + `computeVideoSha1Blocks` (+ offline-file head md5): reads the
file 1 MiB at a time so hashing a multi-GiB upload never buffers the whole file.
Reproduces the buffered helpers exactly — whole-file md5/sha1, the per-1 MiB
intermediate SHA1 state snapshots plus the finalized whole-file SHA1, and an
optional md5 over the first N bytes (`headLimit`, for `md510MCheckSum`).
Additive only — not wired into any caller yet; the old helpers stay in place.
Lands in a new leaf module to avoid a utils↔highway-client import cycle. A
byte-oracle test asserts identity vs the buffered functions across sizes around
the 1 MiB block boundary (empty, sub-block, exact multiples, ±1, multi-block +
tail) and head limits across chunk boundaries.
Prep for streaming large media uploads from disk instead of buffering the
whole file in RAM. Add a `ChunkSource` abstraction — `BufferChunkSource`
(in-memory, byte-for-byte the old path) and `FileChunkSource` (disk-backed,
reads exact-length chunks via a FileHandle, loops on short reads, throws on
unexpected EOF) — and change `uploadHighwayHttp` to consume a `ChunkSource`
instead of a `Uint8Array`. The uploader now owns the source and closes it once
in `finally` (best-effort; never masks a primary upload error).
Pure structural refactor: no streaming is wired yet. All five call sites
(pipeline runPuts, group + private file upload, personal + group avatar) wrap
their bytes in `BufferChunkSource`, so behavior is unchanged. Adds a
byte-oracle test proving FileChunkSource ≡ BufferChunkSource across block
boundaries, plus short-read/EOF/empty-input coverage.
The 100 MB cap that re-routed larger videos to the file-upload pipeline
was a workaround for the width/height=0 → 已过期 bug (fixed in the prior
commit), not a real QQ size limit. Real-machine verified on tempserver:
88 MB / 149 MB / 299 MB / 497 MB videos all upload, render and play as
normal videos in a group. Raise MAX_VIDEO_SIZE to 1.5 GiB (matching the
file-upload ceiling); only videos above that still fall back to the file
path. Collapse the redundant MAX_VIDEO_SIZE_HARD (was also 1.5 GiB) into
the single constant and drop the now-dead 100 MB warn branches.
Note: the whole video is buffered in RAM for the Highway upload, so a
1.5 GiB send costs ~1.5 GiB+ of process memory — that, not a QQ limit,
is what bounds the ceiling.
A group video uploaded with fileInfo width/height = 0x0 reaches receivers
as 文件已过期 even though the resource is fresh and downloadable: QQ-NT
clients (Android especially) can't lay out the video tile without real
dimensions, so they render the expired placeholder. iOS is lenient — it
shows the expired badge but still opens the video, which is exactly the
"iOS 显示过期可以点开、安卓过期点不开" split users reported.
Real-machine capture on tempserver confirmed a working QQ group video's
MsgInfo carries real dimensions (e.g. 296x640) while ours sent 0x0. Fill
width/height from the probed video for group sends; c2c stays at 0 because
the QQ-NT server rejects non-zero dimensions there with a schema mismatch
(no c2c regression coverage yet). Verified end-to-end: a fresh 11 MB group
video that rendered 已过期 now displays and plays.
CPU 使用率: replace the flat per-core bar row with an adaptive heatmap
grid — auto-fill square cells whose color maps to per-core load — so it
reads cleanly from a few cores up to many-core (64/128) hosts instead of
squashing into an unreadable strip.
内存使用: replace the single usage bar with a segmented
本进程 / 其他已用 / 空闲 breakdown bar plus a legend, surfacing the
SnowLuma process's own RSS footprint (systemInfo.runtime.rss) that the
card never exposed before.
Removed the now-unused bestGrid() helper. All motion uses transform
(scale) rather than width/height to avoid layout thrash.
Older QQ clients (≤9.9.30) deliver a 闪传 (flash transfer) file as a richui
markdown commonElem (svc=45, busId=FlashTransfer); SnowLuma had no decoder for
it, so the message decoded to 0 elements and was reported empty. (Newer
clients ≥9.9.31 send a plain text+link message that already decodes fine.)
Decode svc=45 markdown → parse the FlashTransfer card JSON → emit a flash_file
element, surfaced as the segment the issue asked for:
{ type: "flash_file", data: { title, file_set_id, scene_type } }
- Field names (fileSetId / sceneType / title) confirmed against QQ NT's
flash-transfer manager in wrapper.node.i64. The card is built by the
sender's client so the exact JSON nesting is unknown — values are pulled by
a bounded recursive search rather than a pinned path (handles either shape).
- Non-FlashTransfer markdown is ignored; missing fields default (never empty).
- Manifest: D/S = yes, P/W = by-design-no (receive-only; sending is the
existing send_flash_msg action). Log preview shows [闪传文件:title].
- Tests: card decode (nested + alias + defaults + non-flash) and the
element→segment mapping.
Note: not end-to-end verified on a live message — the svc=45 format only comes
from ≤9.9.30 senders and no such client was available (every test sender here
is ≥9.9.31 → text format). Logic + field names are unit-tested / IDA-confirmed.
typecheck 12 包; protocol 596 / onebot 529 green.
* fix(protocol): retry qzone msglist via user.qzone route on -10000
emotion_cgi_msglist_v6 through the legacy h5.qzone.qq.com →
taotao.qzone.qq.com gateway can be rejected with `-10000 使用人数过多`
for some accounts (issue #182). Real-device captures show the same CGI
via user.qzone.qq.com → taotao.qq.com is not rate-limited, so retry once
through that route when the legacy route returns -10000.
Behaviour-preserving for accounts the legacy route already serves: the
retry only fires on a -10000 envelope; all other codes, transport
errors, and the missing-msglist case are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(port-probe): fix port probe logic (parse accounts more than 2) and add description for 4302-4310 port.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round out the OneBot v11 group-profile fields the issue asked for.
Group (get_group_info / get_group_list, inherited by get_group_info_ex /
get_group_detail_info):
- group_create_time + group_memo come free from the 0xFE5_2 list
(createdTime / announcement), verified live (createdTime=1729505343,
announcement populated).
- group_level lives only in the 0x88D_0 detail; get_group_info fetches it
(memoized 5min) so a joined group doesn't hit the detail every call.
get_group_list leaves it 0 — a per-group detail fetch across the whole
list would be far too costly.
Member (get_group_member_info / get_group_member_list):
- Add area / unfriendly / title_expire_time / card_changeable. QQ NT does
not expose these, so they're fixed placeholders ('' / false / 0 / true),
matching go-cqhttp / NapCat. sex/age stay unknown/0 (real values need a
per-member profile fetch — out of scope here).
typecheck 12 packages; onebot 523 / core 472 green.
set_group_add_request could fail with "matching group request not found"
for a request SnowLuma had itself just reported. handleGroupAddRequest only
fetched the main inbox (0x10C8 subCommand 1); a spam-filtered join/invite
lives in the filtered inbox (subCommand 2) and was never searched, so its
flag couldn't be resolved to a sequence.
Fetch both inboxes and search the union. Each request keeps the `filtered`
flag its approval subCommand needs, so a filtered request is approved
through subCommand 2. A failing inbox fetch degrades to the other instead
of aborting. Regular MSF invite pushes don't carry the approval msgseq
(only the private-card path does, #125), so the fetch is the only source.
Regression tests: filtered-only request is found + approved via subCommand
2; "not found" only when neither inbox has it.
The log-level accent colors (info/success/warn/error) are tuned as fills;
as small text on the light canvas they fell below WCAG AA (info 2.6:1,
success 2.4:1, warn 2.0:1, error 4.3:1, trace 2.5:1). Add dedicated
--log-* text tokens that are darkened in light mode (>=4.5:1) while
keeping the bright, already-passing values in dark mode, so the app-wide
accent is untouched. trace moves from /60 to /90 to clear AA yet stay the
faintest level. Applied to both the logs page and overview RecentAlerts.
abef797 misread the request and seeded the value as a bearer session token
(which the operator would have to dig out of localStorage). The intent is
simpler: `?token=<你的WebUI登录密码>` — the value is the plain WebUI login
password, so the link is trivially constructable from something the operator
already knows.
- Revert the client-constructor bearer seeding from abef797.
- AuthBoundary: when no stored token already authed us and the URL carries
?token=, run it through client.login() (the normal password path). A correct
password lands on the home page; a wrong one falls through to the login page
(server-verified, no bypass). The password is carried into the forced
change-password gate, matching the interactive login flow.
- The param is consumed once and stripped from the URL (replaceState) before
the login call — a password in the address bar leaks via history / logs /
Referer.
typecheck 12 包干净。
* feat(qzone): add set_qzone_msg_right to change a feed's visibility
Add updateQzoneMsgRight in the protocol layer, porting php-qzone's
updateRight: fetch the feed via emotion_cgi_msgdetail_v6, rebuild the
full publish-shaped payload (richval/pic_bo/con), then override
ugc_right/allow_uins and POST to emotion_cgi_update. Expose it through
QzoneApi.updateRight and the OneBot set_qzone_msg_right action, with the
same ugc_right/target_uins validation as send_qzone_msg. Covered by
protocol, core and onebot tests.
* fix(qzone): make qzoneParse more stable and fixed a test example