Replace the compatibility no-op with a real batched read action for current groups and observed incoming private conversations.
Exclude stale groups, group temporary sessions, and sent-message echoes from private targets, then expose the functional action through the generated MCP catalog.
Add action and message-store regressions for target selection and delegation.
Carry the stable sender identity through decoded private-message events and persist the UID/UIN pair at the receive boundary.
This keeps later conversation operations independent of stale message rows or fallible profile lookups, with regression coverage for decoding and identity indexing.
Replace message-header sequence forwarding with a server-guided read flow for group and private conversations.
Batch read reports conservatively, validate per-conversation results and cursor advancement, and fail with actionable errors when the server does not apply an update.
Add regression coverage for mixed batches, pagination, already-current sessions, malformed responses, and private sequence-domain mismatches.
Define the server response envelopes for group and private read reports, including per-conversation result codes and current/latest cursors.
This lets callers distinguish transport success from an actual read-state update.
Validate the public OneBot data.data field before converting JSON segments to the internal message element shape. Invalid payloads still return retcode 1400, but now identify the correct public field without reaching the QQ bridge.
Add Action-level regression coverage for issue #220.
Surface whether a OneBot configuration was persisted, applied live, or left degraded. Keep failed adapters and apply errors visible in the config and overview pages instead of reporting a false success.
Serialize desired network plans, isolate per-adapter apply failures, restore prior bindings when possible, and report saved versus applied state. Quiesce Action and event ingress, await in-flight dispatches and transport closes, retain failed generations for retry, and propagate shutdown failures.
Replace the open message bag with a validated discriminated union and preflight complete messages before uploads or sends. Return BAD_REQUEST for invalid or unsendable segments instead of dropping fields, sending partial content, or silently changing private messages.
Persist user saves as authoritative per-account snapshots while retaining legacy overlay compatibility. Validate deterministic topology errors before writing and replace configuration files atomically.
Compile canonical names, aliases, stream kinds, documentation, and raw reservations into one immutable registry. Reject duplicate, undeclared, mismatched, or late handler registration before requests can be dispatched.
Wait for asynchronous transports to flush bad-request frames before processStreamRequest returns. This prevents malformed Stream API requests from losing their terminal error response.
Cap long-message gunzip output at 32 MiB and fail before an oversized QQ response can exhaust process memory. Include the resource id, compressed size, and configured limit in observable errors.
Apply the configured payload limit while per-message deflate is producing output, including fragmented frames. Abort oversized expansion before it can allocate unbounded memory.
Reject malformed nested payloads, cap compressed card expansion, and enforce per-message decode budgets. Preserve unknown-wire metadata in diagnostics so unsupported QQ elements remain observable without exhausting memory.
Reject malformed varints, out-of-bounds fields, and invalid groups before they escape their parent frame. Aggregate unknown-field metadata and decode signed int32 values while preserving SnowLuma's existing outbound encoding bytes.
send_private_msg / send_msg can now reply to a group temp-session message
by passing the source group_id. Inbound temp messages surface their
source group on sender.group_id; a reply is only made into a temp session
the bot has an inbound record of. Group temp replies route through
RoutingHead.grpTmp (field 3).
9b470d0 added ctx.identity.findGroup() to the group-message decoder for
the group_name cache fallback, but several suites mock a minimal identity
without findGroup, so decodeGroupMessage threw at runtime once the
typecheck gate (fixed in 5e73d69) let CI reach the test phase — forward /
packet-pipeline-stranger-resolve / msg-push-handler. Optional-chain the
call: the real IdentityService always has findGroup; only test mocks omit
it, and an absent cache simply yields no fallback name.
core's typecheck covers its own tests (protocol/onebot typecheck src
only), so the newly-required GroupMessage.groupName field failed the
Dev Build typecheck on test fixtures that construct group_message
events. Add groupName to every such fixture across core/onebot/protocol
tests.
Group message events (message + message_sent) now carry group_name — a
non-standard but common OneBot extension. The name comes from the
message's own wire field (ResponseGrp field 7, accurate even right after
a rename), falling back to the identity group cache, else '' when neither
is available. It's resolved in the group-message decoder, where both the
wire head and the identity cache are already at hand, so the converter
just passes it through (no ConverterContext change).
Verified live (tempserver): real inbound group messages from a non-self
account carry group_name set to the actual group name.
The #211 concurrency attempt (c17fff6) was disproven on a live endpoint.
QQ's highway server requires blocks to arrive in offset order and rejects
an out-of-order PUT with error_code=102902 — ~20% failure under a real
account even after deferring the finalize block. Parallel connections
can't guarantee arrival order, so that approach is unshippable.
Replace it with a single persistent connection that PUTs blocks in strict
offset order, reusing the socket across the whole file via HTTP keep-alive:
- In order: the server never sees an out-of-order block, so no 102902.
- One warm connection: the TCP congestion window stays open across the
file instead of paying slow-start on a fresh connection per 1 MiB block.
That per-block cold start was the real throughput cap behind #211.
Adaptive to QQ's edge nodes FIN'ing between chunks (the reason #118 went
one-connection-per-chunk): on a transport failure, or a peer half-close
after a response, drop the socket, reconnect, and re-send the SAME block
(re-PUTting an offset is idempotent). Worst case degrades to one
connection per block — no worse than #118, and still strictly in order.
Also fixes a latent bug on the reconnect path, surfaced by review: a
response truncated mid-stream (header arrived, Content-Length unmet, then
FIN) was resolved as a partial frame and then fatally rejected by
unpackHighwayFrame OUTSIDE the retry loop, turning a recoverable mid-body
FIN into a failed upload. A truncated response now rejects as a retryable
transport failure so the block is re-sent. Added an idle read timeout so
a silent peer can't hang the upload forever (the persistent socket widens
that exposure).
Verified live (tempserver, repeated 48 MiB group-file uploads): 1
connection per file, 0 failures across dense repeats, ~1.4x throughput
over the old per-chunk-connection serial path in a low-RTT datacenter —
the win is larger on high-RTT home links where per-block slow-start hurt
most (the original #211 report). Unit tests cover keep-alive reuse,
FIN-after-each-response degradation, mid-stream reconnect+resume, body-
truncation retry, in-order exactly-once coverage, and close-once.
Group/private file uploads (and all highway media) topped out around
500 KB/s regardless of link speed. Two costs stacked up in
uploadHighwayHttp: chunks were sent strictly one at a time (throughput
capped at one 1 MiB block per round-trip), and each block opened a fresh
connection that paid TCP slow-start from a cold congestion window — so a
1 MiB block finished before the window ramped and never reached line
rate.
Drive the (already offset-addressed, self-contained) blocks through a
small worker pool instead of a sequential loop. Highway frames carry
their own dataOffset/chunkMd5/fileMd5, so blocks are independent and the
server reassembles by offset; a handful of connections overlap the
round-trip stalls and ramp their windows together. Concurrency is kept
modest (4) to stay friendly to the edge nodes' rate limiting, and the
one-connection-per-block invariant from #118 is preserved — parallelism
just puts several of those short-lived connections in flight at once.
Correctness preserved and covered by new tests:
- offset claim is synchronous (no await between read and advance of
nextOffset), so every byte range is uploaded exactly once — no skip,
no duplicate, ragged final block correct;
- concurrent ChunkSource reads are race-free (positional FileHandle
reads into per-call buffers; read-only Buffer subarrays);
- first permanent block failure aborts the whole upload and rejects;
peers stop claiming new blocks and drain the one in flight;
- workers never reject, so Promise.all settles all in-flight
reads/sockets before the source is closed exactly once — no
close-during-read, no masked upload error;
- 0-byte source still opens zero connections; peak memory bounded to
concurrency x ~1 MiB, never the whole file.
Give the virtualized log viewer motion without hurting readability or choking at
high rate. A genuinely new tail line fades and rises in (200ms, ease-out-expo);
detection lives in the SSE flush (real arrivals only), is bounded by a 260ms
timer, and never replays when an old row scrolls back into view or when a filter
toggles. A firehose batch (more than 4 new lines in one flush) suppresses the
per-row entrance so a 100 line/s stream never strobes — the scroll flow and edge
fade carry it instead.
Scrolling gets a soft, scroll-position-aware edge fade: a boundary dissolves only
when there is actually hidden content past it (a ResizeObserver + scroll listener
keep it in sync), so a fully-scrolled top/bottom row is never dimmed. The entrance
animation lives on an inner element so it never collides with the virtual row's
own translateY positioning or perturbs measureElement. All of it is gated on the
motion switches: the entrance on reduce/disable-motion, the static edge fade on
the hard 关闭全部动效 only.
The 加载 button added in #189 injected directly, unlike the process-list
load which routes through the confirm dialog. Fire the same
setConfirm({kind:'load'}) flow so both entry points share one
confirmation step.
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.