WebSocket native is now built for darwin-arm64 — source is at
github.com/SnowLuma/snowluma-websocket (cmake-js + node-addon-api,
clones to dev/snowluma-websocket and builds with `npm install`).
npm install in dev/snowluma-websocket → build/Release/snowluma_ws.node
cp → packages/runtime/native/websocket-darwin-arm64.node (102 KB)
Reverts the SNOWLUMA_BUILD_SKIP_NATIVES escape hatch in vite.config.ts
that 67f8bf3 added — no longer needed; the darwin native binary is
present. Keeps `packages/core/config/.gitignore` (independently useful
runtime-state hygiene). vite build now runs clean on darwin with no
env overrides; the runtime SNOWLUMA_DEV_NO_NATIVE=1 stub also becomes
optional (only needed if the operator wants to bypass WebSocket I/O
specifically).
Existing native binaries unchanged. CI invariant restored: every
release build must include every platform's native set.
Two architectural cleanups that complement the bridge darwin support in
7343ae6, both opt-in / default-safe:
1) `vite.config.ts` — gate the missing-native build error behind
`SNOWLUMA_BUILD_SKIP_NATIVES=1`. Default behaviour (no env) still
throws as before, so the CI invariant "release builds must carry the
full native set" is untouched. Darwin developers without prebuilt
`websocket-darwin-arm64.node` (source lives in a separate private
build repo) can now run `SNOWLUMA_BUILD_SKIP_NATIVES=1 pnpm build`
and pair it with the runtime `SNOWLUMA_DEV_NO_NATIVE=1` stub at
`node dist/index.mjs` boot. The qq-hook path (separate addon) is
independent and unaffected.
2) `packages/core/config/.gitignore` — `runtime.json` is the only
tracked deploy-time override file in that dir; every other config
file is per-instance runtime state written by the daemon/dev server
(admin password hash, EULA consent, per-uin OneBot binding, WebUI
layout). Previously they showed up as 5 untracked files after every
`pnpm dev:mode`, polluting `git status` and risking accidental
commit of local creds via `git add -A`. Pattern: ignore everything
except `runtime.json` (and the .gitignore itself).
No code paths changed; both are operator-experience fixes for running
SnowLuma on darwin against the nnphook DYLD_INSERT hook.
Adds the missing macOS branch so HookManager + PipeWatcher discover QQ
processes when the dylib is injected via DYLD_INSERT_LIBRARIES (the
in-process injection model used by apps/qq/macos/qq_hook_dylib.cpp in
the nnphook private repo, vs Linux's out-of-process ptrace + N-API
addon model and Windows's manual-PE-loader injection).
Two small changes:
1. `linuxRuntimeDir()` (qq-hook-client.ts) — name kept for callsite
stability; the function now applies to every non-win32 platform.
New darwin branch prefers `$TMPDIR/snowluma-hook/` to match the
dylib's `runtime_dir()` (apps/qq/macos/qq_hook_dylib.cpp). The
Linux `/tmp/snowluma-<uid>` fallback stays as the last resort.
$TMPDIR is per-user under /var/folders/ on stock macOS, which is
the right place — /tmp/snowluma-<uid> doesn't exist by default.
2. `listSnowlumaPipePidsSync()` (qq-hook-client.ts) + darwin branch
in `listHookProcesses()` (injector.ts) — on macOS there is no
native enumerate-QQ-processes addon (DYLD_INSERT obviates the
need; the dylib is already mapped in QQ when SnowLuma launches).
The watcher's `listProcesses` dep is sync, so we provide a sync
directory scan that returns the pids encoded in mojo.<pid>.control.sock
filenames. The subsequent async `listLivePipes()` still gates the
pipe-up emit via a real connect probe — a dangling socket file
from a force-killed previous run can't drive a false connect.
Without these two changes, `pipe-watcher.tickOnce()` filtered out our
QQ pid at line 170 (`if (!newKnownPids.has(pid)) continue;`) because
`listProcessesNative()` returned []. The pipe stayed live (verified
by our nnphook recv hook firing), but the bridge never emitted
'pipe-up', HookManager never auto-loaded the session, and the WebUI
showed an empty process list.
Verified end-to-end against the resigned QQ 6.9.96-49738 + nnphook
gpt54 branch dylib on macOS 26.5.1 (Tahoe) arm64:
* Direct HookManager harness picks up PID=16032 in <2.5s,
fast-paths to 'reconnect' (the live-pipe-skip-injection branch
in hook-session.ts:200), pipe connected, status=loaded
* `pnpm dev` (SNOWLUMA_DEV_NO_NATIVE=1 for the websocket stub):
/api/processes returns the live QQ entry with method=reconnect,
connected=true, awaiting login
The websocket darwin-arm64.node native addon is still missing — set
SNOWLUMA_DEV_NO_NATIVE=1 to dev-mode boot with the existing stub
fallback. That's a separate runtime artifact and not in scope here.
`shouldAutoLoadPid()` returns true on non-linux without further checks
(no Electron-child-process scanning needed on darwin), and the
`hook-session.ts` "pipe already live → reconnect" fast path means
the bridge never tries to invoke the non-existent darwin native
injector. Auto-load on a freshly discovered macOS QQ is therefore
safe with or without `SNOWLUMA_HOOK_AUTOLOAD=1`.
Tests: bridge typecheck + 62 unit tests still pass.
* feat: make #sl status command trigger configurable (exact match, max 32 char)
The trigger word for the built-in #sl status command is now configurable
via statusCommand.trigger, with exact case-insensitive matching preserved.
* chore: reject control chars in trigger + add sync assertion
The root /data/ rule is anchored to the repo root and missed
packages/<pkg>/data/<uin>/*.db that IdentityService writes to cwd during
tests. Add a recursive **/data/ so those test artifacts stay untracked.
Phase 2. notifications/config and webui/ui-config dropped their verbatim-
duplicated isObject/boolOr/clampInt (+ ui-config clampNum) in favor of the
shared @snowluma/common/coerce. Pure delete-and-import: the shared
primitives are byte-identical to the originals (adversarially verified),
so behavior is unchanged.
Kept local: strOr (notifications-only), oneOf/isFiniteNum/hexOr
(ui-specific). notifications-config suite green (21), core+common
typecheck clean.
Phase 1 of de-duplicating config normalization. isObject/boolOr/clampNum/
clampInt were copied verbatim across notifications/config and webui/
ui-config; give them a single home as @snowluma/common/coerce (new
per-file subpath export, zero deps — fits common's leaf-utility charter).
Byte-identical to both originals (adversarially verified), so the phase-2
migration is a pure delete-and-import. clampInt is clamp-then-trunc;
10 table tests pin types, numeric-string coercion, non-finite→fallback,
bounds, and the clamp/trunc order. Not yet wired into consumers.
Phase 1. The connect/enable/heartbeat bootstrap triplet was built +
shaped + stringified + null-dropped identically in WsServerAdapter and
WsClientAdapter. Lift that into IOneBotNetworkAdapter:
- metaFrame(event, options): string|null — shape via shapeEventForAdapter
then JSON.stringify, or null when the shaper drops it
- bootstrapMetaFrames(options): string[] — the 3 bootstrap events,
framed and null-filtered
Both ws adapters' sendBootstrapMetaEvents become one-liners over
bootstrapMetaFrames (ws-client keeps its role guard at the call site);
ws-server's close() disable broadcast reuses metaFrame. Base now owns
the shape+serialize policy, adapters own only the transport — same
shape as ADR-0009's reload template method.
Behavior-preserving (adversarially verified byte-for-byte); the two ws
adapters drop their now-unused shapeEventForAdapter import. Safety net
(network-bootstrap-meta, 9 tests) green.
Phase 0 of the bootstrap-meta deepening. No production code; pins the
observable behavior the phase-1 base-class lift must preserve:
- shapeEventForAdapter shaping policy (meta passthrough, message_sent
drop under !reportSelfMessage, string-format raw collapse)
- ws-client emits [connect,enable,heartbeat] on open, role-guarded,
and skips a frame that shapes to null (options genuinely threaded)
- ws-server sends the triplet to an Event connection and broadcasts a
single disable on close(), skipping Api connections on both paths
Adversarially hardened: two gaps (null-drop/options-threading, close
Api-skip) were found via surviving mutations and closed. 9/9 green.
Round-2 follow-up. Two small holes flagged by the adversarial review of
the connection-diff-loop projector:
- A projector that starts throwing only after a shape regression would
wedge the diff baseline silently — no publishes, no log, the only
symptom is the dashboard's connections card freezing until restart.
Latch a one-shot console.error on the first throw so the failure is
visible without re-running the loop locally.
- The non-array fallback at the projector site in server.ts returned
`acc.adapters` verbatim, which would re-introduce volatile fields if
the snapshot shape ever drifts. Tighten to `[]`.
Also documented the deliberate trade-off: per-second adapter `detail`
rotation (client count, last-delivery HH:MM:SS) refreshes on the next
SSE push or the 30s REST reconcile rather than in real time. The user's
original bugs were about process/account edges (which DO push
instantly); detail-string freshness was not in scope. If it becomes one,
surface client-count as a structured AdapterStatus field instead of
parsing the localised `detail`.
437/437 core green.
Three findings from the adversarial review, all in the WebUI integration:
D: SSE {kind:'dropped'} now triggers a one-shot REST refresh of
qq-list/processes/connections instead of being silently ignored.
Reasoning: when createFramePusher drops frames under backpressure, the
NEXT delivered frame is per-resource (only the resource whose debounce
timer next fires) — NOT a refresh of all 3. If the dropped frame was
the only update for a slow-changing resource (e.g. connections stable
afterwards), the WebUI stays stale until the 30s reconcile. The
refresh is fire-and-forget; each refresh helper already swallows errors.
E: systemInfo gets its own fast useEffect on the user's pollInterval
(default 3s). The earlier commit slowed it to 30s claiming the data was
'daily cadence', but SystemInfo carries live values (uptime,
cpu.loadAvg, memory.usagePercent, runtime.heapUsed) that the overview
widget animates — 30s is a visible regression. The other 3 resources
stay on the 30s SSE-reconcile fallback.
F: connection-diff-loop's setInterval is now .unref()'d (mirrors the
tokenJanitor pattern at server.ts:462). The discarded handle is now
commented as intentional — initWebUI has no shutdown path, so the
handle has no consumer; unref is the right cleanup primitive.
WebUI typecheck clean. 437/437 core suite (no behavior change in core
tests — only the diff-loop file's timer.unref?.() call is new and is
optional-chained so vitest's fake timers don't crash).
Adversarial review caught: HttpPostAdapter.describeStatus() embeds
HH:MM:SS in 'detail' (e.g. "上次推送 14:53:01"). Every webhook delivery
rotates that string every second under any active push — and the diff
loop's raw JSON.stringify compared everything, so it published on every
500ms tick under any non-idle webhook. Net effect: SSE pushed a full
connections frame at 2 Hz, exactly the polling the loop was meant to
replace, just on the push channel.
Add an optional pickComparable projector. server.ts caller passes one
that returns { uin, nickname, adapters[{ name, kind, status }] } — the
LEVEL only, not the live-rendered detail string. The SSE handler still
ships the FULL snapshot (including detail) to the WebUI; only the diff
key is pruned.
2 new tests pin the invariant: 10 detail-only ticks → ZERO publishes; a
real status-level transition publishes exactly once. Plus a throwing-
projector isolation test. 7/7 diff-loop tests green.
Adversarial review caught: uneven snapshot latency can deliver a STALE
final frame. Concrete repro added as a test:
- publish('processes') @ T=0 → flush A starts a slow snapshot (200ms)
- publish('processes') @ T=60 → flush B fires at T=110 with a fast (10ms)
snapshot → send B at T=120
- flush A's slow snapshot resolves at T=250 → send A with snapshot data
captured BEFORE B → client's LAST frame is stale.
Fix: at most one in-flight flush per resource. While a flush is pending,
new publishes set a flag instead of starting an overlapping
flush; on completion the flag re-arms the debounce timer for exactly one
follow-up flush. Sustained publish streams therefore produce at most one
frame per (debounceMs + flushLatency).
Test 'serialises overlapping flushes' fails on the pre-fix code (last
frame = 'snap-T0' stale), passes on the fix (last frame = 'snap-T60'
fresh). Test 'coalesces N publishes during in-flight' verifies the
pending flag is set-like (5 publishes → 1 follow-up, not 5).
9/9 state-stream tests green; 432/432 core suite.
Adversarial review caught: EventSource cannot set custom headers, so every
SSE endpoint MUST appear in the TOKEN_QUERY_ALLOWLIST or the auth
middleware at server.ts:404 computes queryToken='' and 401s every connect.
Without this fix the entire phase-3 SSE endpoint is dead on arrival:
clients silently fall back to the 30s REST reconcile and the original
"WebUI must refresh to see new state" bug is unfixed.
Export the allowlist as SSE_TOKEN_QUERY_PATHS so a regression test can
lock the invariant — a missing entry has no error log, the only symptom
is the silent stream failure.
Phase 4b. OneBot adapters expose no event-emitter for connection-state edges
(listening / connected / client-count / last-delivery), so the SSE handler
can't subscribe — without this loop a connection change would only reach the
WebUI on the 30s REST reconcile fallback.
startConnectionDiffLoop polls getConnectionStatuses() at 500ms, compares
JSON-serialised snapshots, and publishes 'connections' through the StateBus
when (and only when) it actually moves. The first SUCCESSFUL observation is
treated as a baseline (no publish): the SSE handler's sendAllInitial has
already shipped the initial snapshot to every connected client on connect.
Snapshot exceptions are isolated — the loop retries on the next tick without
clobbering the baseline.
Started once per initWebUI lifetime; lives until process exit (consistent
with the other heartbeat-style intervals like the SSE 15s keepalives).
431/431 core suite green (5 new tests cover stable=silent, change=publish,
first-observation=baseline, dispose stops, snapshot-throw isolation).
Phase 4 of the SSE migration. The dashboard's three live resources now
update the moment the backend sees a change instead of within 0-3000ms of
the next polling tick.
- New api.stateStream({ onEvent, onStatus }) in lib/api/client.ts mirrors
the existing openLogStream / openDebugStream EventSource pattern: token in
the query string (EventSource can't set headers), single onerror →
'reconnecting' status, source.close() on dispose, JSON.parse with skip-
malformed isolation. Browser-native EventSource auto-reconnect covers
transport drops with no manual retry timer.
- StateStreamEvent union covers the three resource frames plus the two
control frames ('ready' on connect, 'dropped' on backpressure). The
resource frames carry typed HookProcessInfo[] / QQInfo[] /
AccountConnections[] payloads.
- AppLayout now subscribes on mount, applies pushed snapshots to the same
setProcessList / setQqList / setConnections React state slots the polling
tick used to drive. The REST polling tick is RETAINED but slowed to
max(pollInterval × 10, 10s) (default 3s → 30s) so it acts as a silent-
drop reconcile rather than the primary update path. systemInfo isn't on
the SSE feed — it stays on the slow REST loop.
Resolves the two reported bugs:
1. new QQ.exe not showing in WebUI until manual refresh → process-
discovered now publishes 'processes' through the StateBus → frame
arrives within 50ms.
2. injection panel stuck at "waiting for connection" after a successful
hook (group messages arriving) → HookSession 'status-changed' now
publishes 'processes' → 'online' status frame arrives within 50ms.
WebUI typecheck clean. No unit tests in packages/webui (consistent with the
existing openLogStream/openDebugStream which also have no client tests).
Phase 3 of switching the three dashboard resources from REST polling to
SSE push.
- New pure helper packages/core/src/webui/state-stream.ts owns the
debounce/snapshot/fan-out logic, decoupled from Hono so it's unit-testable
with fake timers (7 tests cover initial-snapshot, single-publish coalesce,
per-resource independent debounce, dispose-cancels-pending, snapshot
rejection isolation, send() throw isolation).
- /api/state/stream handler in server.ts mirrors the /api/debug/stream
pattern: ReadableStream + createFramePusher (drop-under-backpressure with
the same {kind:'dropped'} marker), 15s heartbeats, abort-signal teardown.
On connect: one frame per resource. After: a fresh-snapshot frame per
StateBus invalidation, 50ms per-resource debounce.
- The endpoint 503s when stateBus is absent (build without state-wiring),
so clients can still fall back to the REST endpoints; the existing
GET /api/processes etc remain in place for initial fetch + reconcile.
44 test files, 426/426 core suite green.
Phase 2 of switching processes/qq-list/connections from REST polling to SSE
push. Sinks the three publisher edges into a fresh StateBus:
- HookManager grows an OPTIONAL `onSessionsChanged: () => void` dep; called
from pipe-watcher process-discovered/process-gone and from the previously
unsubscribed HookSession 'status-changed' event (login/disconnected reach
it transitively via applyStatus, so a single subscription covers every
transition the WebUI processes view cares about). Throwing subscribers are
caught + logged so the watcher loop is never aborted.
- New packages/core/src/webui/state-wiring.ts owns the coupling: produces the
bus + the `onSessionsChanged` callback to feed HookManager, and registers
BridgeManager session-started / session-closed listeners that publish
'qq-list' + 'connections' (in source-of-truth order: account first, then
adapters).
- index.ts threads stateWiring.bus into initWebUI so the phase-3 SSE handler
has something to subscribe to; dispose runs on SIGINT/SIGTERM.
Bridge: 62/62 (was 57). Core: 419/419 (was 414). 10 new tests cover all
publish points + exception isolation + the optional-dep contract.
Phase 1 of switching processes/qq-list/connections from 3s REST polling to
SSE-pushed snapshots. The bus is a frozen-snapshot fan-out (subscribers added
during a publish() see only the next one, peer unsubscribes mid-fan-out don't
skip survivors) with exception isolation per listener.
Pure pub/sub by design — debounce and snapshot reads belong with the
subscriber (the /api/state/stream handler in phase 3) so different consumers
can coalesce on their own cadence.
8 unit tests, 414/414 core suite green.
Two tests pinning the one intended behavior change from the status-
machine deepening: refresh while the pipe is down on a connected-but-
never-logged-in session now reports 'connecting' (matching onPipeDown),
not the old spurious 'disconnected'; and a session that HAD logged in
still settles to 'disconnected' + emits disconnected(true), proving the
flip is scoped to the never-logged-in case.
Verified against the pre-refactor baseline: test (1) fails on old code
(produced 'disconnected'), so it locks the flip rather than restating it.
Phase 2. Migrate the ~6 hand-copied status ternaries to the pure
statusFor() and collapse the pipe up/down orchestration into two shared
private reconcilers:
- reconcilePipeUp ← onPipeUp + refresh's pipe-up branch
- reconcilePipeDown ← onPipeDown + refresh's pipe-down branch + the
client 'close' handler (4 call sites, all were near-duplicates)
applyStatus() pushes the statusFor-derived status; loading/error stay
explicit. refreshInternal collapses to a one-line pipe poll. Behavior-
preserving except the intended drift fix (refresh-while-down + never
logged in now reports 'connecting', matching onPipeDown, not the old
spurious 'disconnected').
reconcilePipeDown short-circuits when !connected so a pipe-down tick
can't clobber a diagnostic error or emit a spurious status-changed on a
failed-connect/failed-load session — covered by two new regression
tests (caught in adversarial review). 55/55 green.
Phase 1 of deepening the HookSession status machine. The 5 settled
HookProcessStatus values (available/connecting/loaded/online/disconnected)
were hand-derived from (injected,connected,loggedIn,wasLoggedIn) at ~6
call sites in hook-session.ts. Extract that truth table into a pure
statusFor() in hook-status.ts so it has one source of truth and a
mutation-tight exhaustive table test. loading/error stay out-of-band.
Not yet wired into hook-session (phase 2).
Inviting an official robot (e.g. 2854207029) into a group made QQ push the
group_member_increase notice twice, so SnowLuma emitted two
notice.group_increase events; a normal member produced one.
Root cause (RE of wrapper.linux.node): QQ NT dedups every received system
message in sys_msg_mgr.cc::ProcessRecvSysMsg (sub_37BADE0) by a global_key
`{peerUid}_{chatType}_{msg_seq}_{random}` (sub_37BC4E0) — an already-seen key
is dropped ("ignore dup msg") before any listener sees it. The per-message
discriminators msg_seq (attr 40003 = contentHead field 5) and random (attr
40002 = contentHead field 4) are exactly head.sequence and head.msgId
(msg_header_codec_helper.cc sub_37C8AD0). Kernel-based bots (NapCat/LLOneBot)
receive events after this dedup; SnowLuma reads the raw OlPush before it.
Mirror it: SysMsgDedup, a per-account bounded ring that drops a duplicate
system push by (msgType, subType, fromUin, sequence, msgId). Push-level (drops
the whole duplicate like NT), live path only (Bridge owns the tracker; the
forward re-parse passes none), system kinds only (chat messages excluded), and
never dedups a push lacking a server identity (sequence or msgId 0).
The server never pushes a member-decrease back to the member that left of its
own accord, so set_group_leave produced no notice.group_decrease — downstream
kept a 'zombie' group in its cache, and a later set_group_add_request failed
with 'matching group request not found'.
bridge.leave() now, after the 0x1097_1 OIDB succeeds, synthesizes a self
group_member_leave (user_id = operator_id = self, sub_type 'leave') through the
event bus — converted to notice.group_decrease by the existing path — and
calls identity.forgetGroup() so get_group_list / member lookups drop it
immediately (in-memory + DB inactive).
Kick / disband already arrive via the GroupMemberDecrease push (PkgType 34).
Limitation: a MANUAL leave in the NTQQ client isn't covered — SL is packet-
based with no kernel callback, and a voluntary self-leave isn't pushed back;
only kernel-based clients (NapCat/LLOneBot) catch that.
Validated by unit tests (bridge leave emits the event + forgets the group).
Reimplements PR #86 independently (author abandoned it after change-requests),
with the review feedback applied — the original tests asserted behavior the
code never had.
#84 — add @snowluma/common unit tests:
- hex: toHex/toHexUpper/hexPreview boundary + fromHex round-trip & odd-length throw
- log-summary: summarizeParams (top-level primitive is String()'d WITHOUT quotes;
fields quoted; nested collapsed; total cap) and renderParamsVerbose (redaction,
long-string marker is the Unicode ellipsis '…<N B>' not ASCII '...', circular guard)
- request-context: nextRequestId monotonic; runWithRequestId sync/async + nested scopes
- root scripts test:common/test:all/coverage; coverage/ gitignored
#85 — Utf8Validator: the second surrogate check was unreachable (within the
0x80–0xBF continuation-byte guard, 'b & 0x20' is true iff 'b >= 0xA0'); merge to
one check. No behavior change.
common suite green (26 tests).
The 0x8FC_2 body omitted uinName (tag 7), so the server accepted the request
(errorCode 0) but silently never applied the title — looking like the feature
was unsupported. Both NapCat (Oidb.0x8FC_2: uinName=title) and Lagrange
(0x8FC body @7 UinName=title) set it; mirror them by sending uinName = title.
No RE needed — two independent packet-based implementations agree on the field.
Note: setting a special title is group-owner-only, so on-target validation
requires a group the bot owns.
Harvests the sound ideas from PR #128 while KEEPING origSeqs[0] as the c2c
reply sequence (on-target capture in #114/#124 proved origSeqs[0] == the quoted
message's head.sequence; pbReserve.friendSequence is a small unrelated counter
that makes reply.id miss). The PR's friendSequence override is intentionally
NOT taken.
- rich-body-decoder: enrich the reply element with replySenderUin/replyTime and
the quoted message's own decoded elements (SrcMsg.elems, field 5); recover a
c2c quoted FILE from sourceMsg (field 9 -> RichText.notOnlineFile) when elems
carried none, so a quoted file survives into get_msg (#124).
- backfillReplyTarget: tiered fallback — Tier 0 local store, Tier 1 server fetch
(correct seq = origSeqs[0]), Tier 2 reconstruct from the embedded quoted
elements (no round-trip), Tier 3 minimal '[引用消息]' placeholder so get_msg
never returns 'message not found'.
- to-segment: per-element try/catch (logged, not silent) so one bad element
doesn't drop the whole message.
- actions/message: log get_msg misses.
- tests: fix request-actions getGroupInviteCardSequence mock (gap from #125);
cover backfill Tier 2 (reconstruct) + Tier 3 (placeholder); flip the stale
c2c reply test to assert origSeqs[0] (was asserting the disproven
friendSequence).
Validated on-target (Docker): get_msg on group reply, private text reply, and a
private quoted FILE all return correct content (file with name/size/id); full
onebot + protocol suites green.
A QQ NT reply carries the replied sender as a structural auto-mention
(MentionExtra.type=2, uin=0) right after srcMsg, plus a blank separator
text. SnowLuma reported both as a real [CQ:at] + empty/space segment, so
downstreams saw a phantom @ (#127). A real user @ carries a non-zero
MentionExtra.uin, so it is preserved.
- receive (rich-body-decoder): after a reply, drop a type=2/uin=0 mention
and the blank text that follows it; keep non-zero-uin mentions.
- send (element-builder, #129 follow-up): the reply auto-mention added for
reply+@ now uses uin=0 (real uid stays in `uid`) to match QQ's native wire
shape, so the bot's own replies are stripped the same way by receivers and
don't surface a phantom @ either.
Validated on-target (Docker, group): reply w/o @ -> [reply, text] (no
phantom @/empty); reply w/ @ -> [reply, at(real), text]; bot reply+@ still
delivers the @ (uin=0).
When a group message combined a reply with a user @ , the @ showed but
never notified (no blue link, no ping). On-target capture showed SnowLuma
sent [srcMsg, @target(mention), text] — the @ lands directly in the reply's
mention slot right after srcMsg, which the QQ NT group server does not honor.
Mirror Lagrange's ForwardEntity.PackElement: when a reply and an @ coexist
in a group message, emit the replied sender as a trailing mention element
right after srcMsg, so the user's @ is no longer the first post-srcMsg
element and is honored. Gated to the reply+@ case so plain replies are
unchanged.
Validated on-target (Docker, group send): baseline reply+at -> @ not
delivered; fixed -> @ delivered (blue link + notification).
A private (c2c) reply resolved to srcMsg.reserve.friendSequence, but a
received message's message_id is hashed from its head.sequence. On-target
capture showed origSeqs[0] == head.sequence (e.g. 12709) while friendSequence
is a small friend-relationship counter (e.g. 25) — so reply.id != the quoted
message_id. Downstream get_msg(reply_id) then missed:
- #114: reported reply id didn't match the quoted message's real id.
- #124: replying to a File message returned empty quoted content (AstrBot
relies on get_msg(reply_id) to learn the quote was a file; text replies
appeared fine because they read the inline follow-up text instead).
Drop the c2c friendSequence override (reverting cf8e62d's premise, which was
backwards) so both group and c2c replies use origSeqs[0].
Validated on-target (Docker): file message id == reply.id, and
get_msg(reply_id) -> retcode 0, message [{type:file}].
When the bot is invited to a group via a private-chat "qun.invite" ark
card, set_group_add_request failed: handleGroupAddRequest re-derived the
approval tuple from fetchGroupRequests (0x10c8_2), but for a self-invite the
only sequence the server accepts at 0x10c8_1 is the msgseq embedded in the
card's mqqapi://group/invite_join jumpUrl, applied with eventType=2 /
filtered=false. The MSF invite push (PkgType 87) never carries that msgseq,
so approval returned 120161001 'handle async message fail'.
- friend-message decoder: parse the private invite card's jumpUrl and stash
groupUin -> msgseq on the per-instance IdentityService.
- ContactsApi.getGroupInviteCardSequence(groupId) exposes it.
- handleGroupAddRequest: for an invite flag, if a card msgseq is cached,
approve with setAddRequest(groupId, msgseq, 2, approve, reason, false);
otherwise fall back to the existing fetchGroupRequests path.
Validated on-target (Docker): private invite card -> set_group_add_request
approve -> 0x10c8_1 ok -> bot joined the group (get_group_member_info shows
role=member, fresh join_time); no 120161001.
Large private files (e.g. 48 MB .flac via send_private_msg) failed with
"connection closed before response (received 0 bytes, headerSeen=false)".
uploadHighwayHttp sends one fresh TCP connection per 1 MB chunk with no
retry, so a 48 MB file is ~48 sequential connections and a single transient
peer-FIN / reset among them aborted the entire upload. Small files (1 chunk)
always worked; NapCat is unaffected because it delegates to QQ's native
highway client, which retries.
Wrap each chunk's connect + POST in a bounded retry (3 attempts, 300 ms ×
attempt backoff, fresh connection each time). Re-sending the same offset
range is idempotent server-side. Only transport failures retry; a decoded
highway error_code is a definitive server reject and is not retried.
* Add function to detect operating system distribution
* Add 'distro' property to system types
* Update subtext in StatTile to show distro instead of platform
* Add Linux distribution detection and architecture normalization
* Add archLabel to SystemInfo interface
* Update subtext to use archLabel instead of arch
* Add distro summarization function and update StatTile
* Refactor distro summary to use tooltip for details
* Refactor host system info detection functions
* Enhance Linux distribution detection logic
* Improve Linux distro detection and version parsing
Enhanced Linux distribution detection logic by adding kernel version parsing and improved source handling for OS release information.
* Enhance kernel and distro version detection on Linux
Refactor kernel version extraction and improve distro version parsing logic.
* Enhance RHEL family detection and error handling
* Enhance distro name formatting in overview-page
* Extend RHEL family detection and supported distros
* Add support for additional Linux distributions
* Enhance OS detection for Armbian and DietPi
Added support for detecting Armbian and DietPi distributions from kernel release strings.
* Add support for Raspbian and DietPi in OS name formatting
* Add function to detect operating system distribution
* Add 'distro' property to system types
* Update subtext in StatTile to show distro instead of platform
* Add Linux distribution detection and architecture normalization
* Add archLabel to SystemInfo interface
* Update subtext to use archLabel instead of arch
* Add distro summarization function and update StatTile
* Refactor distro summary to use tooltip for details
* Refactor host system info detection functions
The old default-collapsed pref went inert once the sidebar became a
hover-expand rail with no collapse button. Repurpose it as an explicit
opt-out: when 「钉住侧栏展开」 is on, the sidebar stays permanently expanded
and no longer collapses to the rail or reacts to hover.
Renamed the appearance field sidebarDefaultCollapsed -> sidebarPinned across
the webui types/defaults/settings UI and the core ui-config interface,
defaults and validator (kept in lock-step so persistence round-trips). Wired
into main-layout: effectiveCollapsed = !editing && !sidebarPinned.
Reshape the desktop shell to feel less "boxed-in", keeping the original
visual design — boundaries now read via depth/tone, not hard borders.
- Sidebar rests as a 64px icon rail and auto-expands on hover (or keyboard
focus-visible), pushing the content rather than overlaying it. Removes the
explicit collapse button; resting state is the rail (`effectiveCollapsed =
!editing`), so it always auto-collapses on mouse-leave.
- Drop hard region borders: sidebar border-r and topbar border-b/divider are
gone; the rail blends into a sidebar-toned chrome.
- Sidebar internal cleanup: brand border, the footer Separator, the update
card / edit-item / 完成 button borders removed; active nav indicator is now
a soft pill + sliding 2px primary left bar (layoutId).
- Content is a nested canvas with a rounded top-left corner at the
sidebar/topbar junction, lifted via a soft shadow; the corner slides with
the rail since the canvas is its flex sibling.
Note: the 「侧栏默认折叠」 appearance toggle (settings-page) is now inert for
the main nav sidebar — to be repurposed as "pin open" or removed in a follow-up.
detectImageFormat read the VP8X (extended WebP) canvas width/height with a
big-endian readBE24, but the WebP spec stores them as 24-bit LITTLE-endian
(Minus-One). A 1920×1080 image was reported as 8324865×3605505, so the
fileInfo sent to highway was garbage and QQ cropped the thumbnail. VP8
(readLE16) and VP8L (readLE32) were already correct.
- Replace readBE24 with readLE24 in the VP8X branch; readBE24 had no other
caller, so it's deleted (no dead code left).
- Add the previously-absent detectImageFormat regression suite (12 cases):
PNG/GIF/BMP/VP8/VP8L/VP8X/JPEG + JPEG DHT-skip + truncated/unknown
fallbacks. The VP8X cases pin little-endian byte order, including the
exact bytes from the issue and an asymmetric 256×1 case that a BE misread
would blow up to 16711681.
Verified TDD red→green: the 3 VP8X cases failed before the one-helper swap,
all 12 pass after.
Adversarial review (NEEDS-FIXES) — the high-risk parts (topicId base
hostUin_tid, uin/hostUin not swapped, format=fs, platformid=52) verified
correct against 3 community impls; the gaps:
- Added the re_feeds param family all 3 impls send and the publish sibling
already sends for this CGI family: feedsType=100, private=0, paramstr=1,
richtype/richval/isSignIn=''. Tests assert the load-bearing ones.
- qzreferrer used hostUin; all impls use the commenter's own uin → selfUin.
- topicId __1 suffix is impl-dependent (2/3 include it, 1 omits); kept as
default but the comment now flags the suffix specifically as the
unverified piece (base shape is confirmed), and the test no longer
presents it as canonical.
commentQzoneMsg POSTs a form body to taotao.qzone.qq.com's
emotion_cgi_re_feeds (proxied through h5.qzone), keyed by topicId
<hostUin>_<tid>__1, as the bot (uin=self) on target_uin's feed (defaults
to the bot's own space). Returns the new comment id best-effort — the
response field name varies (commentid/commentId) so a missing id with
code 0 is still success. Throws on non-zero code/subcode; rejects empty
tid/content pre-flight. WRITE — rate-limit (风控).
topicId shape + comment-id field are extrapolated from community impls,
pending a live capture (labeled in the helper comment).
- core: QzoneApi.comment; onebot: comment_qzone action; regen catalog.
- tests: re_feeds form lock (topicId/uin/hostUin/content/format=fs),
commentid + camelCase commentId fallback + missing-id success, empty
tid/content preflight, non-zero code throw, bridge owner-defaulting.
Adversarial review (NEEDS-FIXES) of the like/unlike slice:
- Missing abstime: every real dolike impl (QLiker.py, CSDN) sends the
target feed's post time. Threaded abstime through setQzoneLike →
QzoneApi.like → like_qzone/unlike_qzone actions (optional, default 0;
pass the feed's real time for reliability). Test asserts it.
- internal_unlike_app is the conventional paired CGI but NO public bot
impl exercises unlike, so it's best-guess. Labeled UNVERIFIED/pending a
live capture in the helper comment, the unlike action summary, and the
unlike test — instead of pinning it as confirmed truth (avoiding the
re-codify-a-wrong-constant trap the last 3 slices hit).
- Documented the success-signal (code/subcode) as extrapolated from
sibling CGIs (dolike may use a succ/fail token instead).
The confirmed mechanics (dolike CGI, opuin=liker, unikey/curkey mood
shape, appid=311, fid=tid) are noted as such.
setQzoneLike POSTs a form body to w.qzone.qq.com's internal_dolike_app
(like) or internal_unlike_app (unlike), proxied through h5.qzone, keyed by
the feed's mood unikey/curkey (http://user.qzone.qq.com/<uin>/mood/<tid>)
and fid=tid, as the bot (opUin). target_uin selects whose 说说 (defaults
to the bot's own); scope is 说说 (mood, appid 311). Throws on non-zero
code/subcode. Rejects empty tid pre-flight. WRITE — rate-limit (风控).
- core: QzoneApi.like(tid, targetUin?, like); onebot: like_qzone +
unlike_qzone actions; regen catalog.
- tests: dolike vs unlike CGI selection, unikey/curkey/fid/opuin form
lock, empty-tid preflight, code+subcode throws, and bridge owner-
defaulting (self vs friend) + like-flag threading.
Adversarial review (NEEDS-FIXES) — two wrong-constant defects (same family
as the publish t1_tid bug) + a destructive-write hardening:
- format=json → format=fs. Every working reference (silica-github/
qq_zone_delete, zhihu, daimagongfang) uses fs for the delete CGI; the
tests re-codified the wrong value.
- Removed the invented json=1 body param (no source includes it); test now
asserts it's absent.
- Also throw on a non-zero subcode (not just code), and documented that the
code/subcode success signal is EXTRAPOLATED from sibling CGIs pending a
live capture (public delete impls only check HTTP 2xx). Added subcode-throw
and code-absent-resolves tests to lock the branch.