Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl feaa94bec7 build(core,runtime): native websocket-darwin-arm64.node + drop build hatch
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.
2026-06-25 16:25:56 +08:00
DrSmoothl 67f8bf3941 build(core): SNOWLUMA_BUILD_SKIP_NATIVES=1 escape hatch for darwin dev
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.
2026-06-25 14:27:59 +08:00
DrSmoothl 7343ae6372 feat(bridge): darwin support — DYLD_INSERT-style hook discovery
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.
2026-06-25 02:53:44 +08:00
stla 41bad7f24c feat: make #sl status command trigger configurable (exact match, max 32 char) (#138)
* 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
2026-06-25 01:02:17 +08:00
DrSmoothl 19c03eb0ba chore(gitignore): ignore per-package runtime data dirs (**/data/)
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.
2026-06-25 00:21:21 +08:00
DrSmoothl fbc5a3c981 refactor(core): use @snowluma/common/coerce in config normalizers
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.
2026-06-25 00:05:46 +08:00
DrSmoothl 1ce7385d1e feat(common): add zero-dep scalar coercion primitives (coerce)
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.
2026-06-25 00:01:39 +08:00
DrSmoothl 3a4af38842 refactor(onebot): lift bootstrap-meta framing into the network adapter base
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.
2026-06-24 23:40:31 +08:00
DrSmoothl e11601bfec test(onebot): characterize network bootstrap-meta behavior (safety net)
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.
2026-06-24 23:35:07 +08:00
DrSmoothl a305afbc76 fix(webui): observable projector wedge + tighten non-array fallback
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.
2026-06-24 23:17:26 +08:00
DrSmoothl 84cdd99868 fix(webui): dropped-frame reconcile + split systemInfo cadence + diff-loop unref
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).
2026-06-24 23:05:25 +08:00
DrSmoothl bae9f8edb0 fix(webui): connection-diff-loop compares ONLY level — strip detail timestamps
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.
2026-06-24 22:55:51 +08:00
DrSmoothl f9ce49ac7e fix(webui): single-flight per resource in bindStateStream — ordering safety
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.
2026-06-24 22:44:09 +08:00
DrSmoothl bb752b13a1 fix(webui): add /api/state/stream to SSE token-query allowlist
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.
2026-06-24 22:36:40 +08:00
DrSmoothl a9f5f277f1 feat(core): connection-status diff loop — publish 'connections' on change
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).
2026-06-24 22:09:13 +08:00
DrSmoothl de3bb4817d feat(webui): subscribe to /api/state/stream — push-driven dashboard state
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).
2026-06-24 21:51:35 +08:00
DrSmoothl a7522deb82 feat(webui): /api/state/stream SSE — push processes/qq-list/connections
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.
2026-06-24 21:44:28 +08:00
DrSmoothl 53f68b3945 feat(core): wire HookManager/BridgeManager edges into the StateBus
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.
2026-06-24 21:35:27 +08:00
DrSmoothl 5302341dfc feat(core): StateBus — pure pub/sub for WebUI state invalidations
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.
2026-06-24 21:10:53 +08:00
DrSmoothl fb7675f3cb test(bridge): lock HookSession refresh drift fix
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.
2026-06-24 19:49:21 +08:00
DrSmoothl 1d2a56bcdb refactor(bridge): wire HookSession status through statusFor + shared reconcilers
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.
2026-06-24 19:42:59 +08:00
DrSmoothl 74c7e086c7 refactor(bridge): extract pure statusFor for HookSession status derivation
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).
2026-06-24 19:23:00 +08:00
DrSmoothl 9ad37d4ada chore(github): add PR template, bug-report deploy field, feature-request willingness dropdown 2026-06-24 00:46:15 +08:00
DrSmoothl 1bdb8a40c4 fix(protocol): dedup duplicate system pushes (#137 official-robot double group_increase)
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).
2026-06-23 20:36:24 +08:00
DrSmoothl 4e0ec92c23 fix(core): emit group_decrease + drop cache on set_group_leave (#133)
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).
2026-06-22 23:07:44 +08:00
DrSmoothl 2b3d1c67c2 test(common): cover hex/log-summary/request-context; fix Utf8Validator dead code (#84, #85)
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).
2026-06-22 22:53:21 +08:00
DrSmoothl b918450483 fix(protocol): set_group_special_title — send uinName so the title actually applies
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.
2026-06-22 21:53:33 +08:00
DrSmoothl 1bd126562f chore(release): v1.10.2 2026-06-22 21:31:06 +08:00
DrSmoothl e160837f8b feat(onebot): tiered reply backfill + quoted-file recovery for get_msg (#128)
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.
2026-06-22 21:25:05 +08:00
DrSmoothl 4593ba3340 fix(protocol): strip reply auto-mention on receive; align send to uin=0 (#127)
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).
2026-06-22 20:53:10 +08:00
DrSmoothl 45aae92af5 fix(protocol): group reply+at — emit replied-sender mention after srcMsg (#129)
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).
2026-06-22 20:37:19 +08:00
DrSmoothl 839de51519 fix(protocol): c2c reply uses origSeqs[0], not friendSequence (#114, #124)
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}].
2026-06-22 16:07:36 +08:00
DrSmoothl 11e7584d1c fix(onebot): approve bot self-invite via private card msgseq (#125)
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.
2026-06-22 15:25:28 +08:00
DrSmoothl 8b87f80951 chore(release): v1.10.1 2026-06-22 01:45:25 +08:00
DrSmoothl b135752577 fix(highway): retry chunk upload on transient transport errors (#118)
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.
2026-06-22 01:45:12 +08:00
snowluma-bot[bot] fd2345b772 chore(native): replace native artifacts (native-latest) (#121)
Build: https://github.com/SnowLuma/SnowLumaNative/releases/tag/native-latest

Co-authored-by: snowluma-bot[bot] <283088347+snowluma-bot[bot]@users.noreply.github.com>
2026-06-22 01:41:50 +08:00
stla 907cfddfa3 fix:docker容器正确获取宿主机系统信息 (#119)
* 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
2026-06-22 01:39:11 +08:00
DrSmoothl 9efe16237d chore(release): v1.10.0 2026-06-21 20:22:46 +08:00
snowluma-bot[bot] c7b7c0b323 chore(native): replace native artifacts (native-1.10.0) (#116)
Build: https://github.com/SnowLuma/SnowLumaNative/releases/tag/native-1.10.0

Co-authored-by: snowluma-bot[bot] <283088347+snowluma-bot[bot]@users.noreply.github.com>
2026-06-21 20:20:43 +08:00
stla e2415210cd Enhance OS detection and update StatTile for distribution display (#115)
* 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
2026-06-21 19:45:23 +08:00
Qiao 127f2cd2d6 feat(flash-transfer): QQ flash transfer (fileset) upload, share, download (#113)
* feat(proto-defs): add flash-transfer OIDB proto types

QQ 闪传(FlashTransfer / fileset)的 protobuf 类型定义:
- FlashFileEntry(0x93d2/0x93d3 文件条目)
- 0x93cf 申请 fileSet / 0x93d0 commit / 0x93db 完成 / 0x93d1 设状态
- 0x93d3 文件集详情 / 0x93d4 下载 URL(含主文件 fileId + 缩略图)
- 0x12a9 sub=100/103/200 prepare/apply/get-download
- sliceupload body + Sha1StateV
- 0x9407 删除 / 0x9427 重命名 / 0x93d7 发送
注册到 oidb-actions/index.ts,package.json 加 exports。

* feat(protocol): add flash-transfer OIDB services + sha1-stream

闪传协议层(OIDB namespace,复用 invokeOidb/OidbCallSpec):
- apply-fileset / commit-file / complete-fileset / set-status(上传流程)
- prepare-upload (0x12a9_100) / apply-upload (0x12a9_103)(sliceupload 凭证)
- get-fileset-detail (0x93d3) / list-filesets (0x93d2) / get-download-url (0x93d4)
- get-flash-download (0x12a9_200,主文件下载直链)
- delete-file (0x9407) / rename-file (0x9427,sub=0+uinForm) / send-flash (0x93d7)
- sha1-stream:SHA1 中间 state 计算器(sliceupload f107.f6 需要,Node crypto 不支持)
- byte-oracle 单测(apply/prepare/get-download-url/send-flash)+ sha1-stream 单测
package.json 加 exports。

* feat(core): add FlashTransferApi facade

FlashTransferApi 编排闪传全流程,复用 OIDB 基础设施(invokeOidb)+
computeHashes/loadBinarySource/fetch:
- createFlashTask:统一走 sub=100/103+sliceupload(含缩略图),不走小文件 PUT
  (PUT 不上报主文件 sha1,fileset 卡在未完成;QQ 客户端小文件也走 sliceupload)
- buildFileId:主文件 appid=14901(进 f14.f1),缩略图 png=14903/jpg=14902
- uploadThumbnail:随机纯色 PNG 占位(手写编码,不引入图像库),关联主文件下载入口
- getFilesetInfo/getFlashFileList/getShareLink/getFlashFileUrl/downloadFileset
- sendFlashMsg(私聊 uid / 群聊 groupId)/ getFilesetIdByCode(网页解析)
- deleteFlashFile / renameFlashFile / listFilesets
注册到 ApiHub(flashTransfer 字段)+ export。

* feat(onebot): add flash-transfer actions

OneBot action(extended.ts)10 个:
create_flash_task / get_fileset_info / get_flash_file_list /
get_flash_file_url / get_share_link / download_fileset /
send_flash_msg(私聊 user_id / 群聊 group_id)/ get_fileset_id /
rename_flash_file / delete_flash_file
MCP catalog(生成文件)同步新增 action 元数据。
extended-actions 接线单测覆盖。

* fix(onebot): align flash file list field name with NapCat

flashFileInfoToJson 的文件大小字段从 file_size 改为 size,对齐 NapCat
get_flash_file_list 响应(NapCat 用 size)。get_fileset_info 的 file_list
复用同一映射,一并生效。便于 NapCat 客户端 drop-in 迁移。

* feat(proto-defs): support multi-file commit in flash-transfer

0x93d0 commit 的 f4 改为 repeated,一个请求携带 fileset 内全部文件条目。
FlashCommitFileInfo 的 f3/f5/f6/f7/f10/f12 用 pb_optional,强制 0 值上 wire
(服务端多文件时严格校验字段存在,省略 0 会导致后续文件不计入 fileset)。

* feat(protocol): parameterize flash-transfer upload for multi-file

CommitFile.Params 改为 entries 数组,serialize 输出 repeated f4,每条 f6=文件序号。
PrepareUpload/ApplyUpload 加 fileIndex + formatCode 参数:filesetWrap.f4 用 fileIndex
(与 commit f6 对齐),f7 用 formatCode(mp4=2 非 26),f9=1。

* feat(core): multi-file flash-transfer upload

createFlashTask 支持多文件共用一个 fileset。关键改动:
- ApplyFileset 多文件时 fileName 用「<首文件名>等N个文件」、fileSize 用总和,
  服务端据此判定 fileset 为多文件,commit 的后续 entry 才被计入
- 一次性 CommitFile 提交所有文件元数据(f4 repeated)
- 两阶段上传:先全部 prepare+apply 注册 fileId,再全部 sliceupload 落盘
- 拆分原 uploadLargeFile 为 prepareAndApply + sliceuploadFile
- fileTypeCode mp4 formatCode 26→2(与 commit f7/filesetWrap.f7 一致)

* feat(onebot): multi-file create_flash_task + list_filesets action

create_flash_task 的 files 参数支持 string 或 string[],多文件共用一个 fileset。
新增 list_filesets action(facade.listFilesets 之前未暴露)。MCP catalog 同步更新
files schema(anyOf string/array)与 list_filesets 条目。

* fix(flash-transfer): multi-file download by fileIndex

0x93d4 响应 f1.f3 是 repeated fileInfo(多文件 fileset 每个文件一条,f6=序号、
f14=主文件 fileId),之前定义成单个导致多文件只能拿到第一个文件。
FlashDownloadEntry.fileInfo 改 pb_repeated,GetDownloadUrl 返回所有文件元信息,
download_fileset 按 fileIndex 选中目标文件走 0x12a9 sub=200 拿直链。

* test(protocol): inline byte-oracle hex fixtures

两个 flash-transfer byte-oracle 测试原先用 readFileSync 读仓库外的
.claude/reverse/scripts/proto/*.hex(本地逆向笔记目录),CI 上不存在导致失败。
把 hex 内容内联成常量,测试自包含。

* fix(flash-transfer): address review feedback

1. get_flash_file_url 接 file_index 参数并在 action 透传(之前漏接,多文件永远返回首文件)
2. getFileDownload 越界 fileIndex 不再静默回退首文件,找不到返回 null
3. postSliceupload status 缺失不再当成功,非 success 一律抛错
4. computeSha1StateV 改 O(n) 增量 update(之前每片 reset 重算整个前缀,O(n²))
5. get_flash_file_url 找不到文件抛错(之前返回空串当成功)
6. create_flash_task files 数组拒绝空字符串
2026-06-21 14:24:51 +08:00
DrSmoothl ebadfb968b feat(webui): replace 「侧栏默认折叠」 with 「钉住侧栏展开」 pin option
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.
2026-06-20 23:37:21 +08:00
DrSmoothl bba9b82ce9 feat(webui): hover-expand sidebar rail + borderless nested-canvas chrome
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.
2026-06-20 23:30:08 +08:00
DrSmoothl 9d1e39f25f fix(highway): VP8X WebP canvas dims are little-endian, not big-endian (#112)
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.
2026-06-20 20:19:10 +08:00
DrSmoothl b9d8247309 fix(qzone): complete re_feeds param set + correct qzreferrer/topicId honesty
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.
2026-06-20 02:10:12 +08:00
DrSmoothl 95f24ffc4a feat(qzone): comment_qzone — comment on a 说说 via web API
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.
2026-06-20 02:05:56 +08:00
DrSmoothl 870504ae3a fix(qzone): thread abstime into like, flag unlike endpoint as unverified
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.
2026-06-20 02:02:25 +08:00
DrSmoothl d497f1e6c7 feat(qzone): like_qzone / unlike_qzone — like/unlike a 说说 via web API
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.
2026-06-20 01:56:23 +08:00
DrSmoothl e406a3bc2b docs(qzone): delete header comment mentions subcode in the success contract
Trivial accuracy nit from re-review (verdict SHIP) — header said
throw-on-non-zero-code but the helper also throws on a non-zero subcode.
2026-06-20 01:54:19 +08:00
DrSmoothl 4d75dc9ae3 fix(qzone): delete_v6 uses format=fs, drop bogus json param, harden success check
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.
2026-06-20 01:52:47 +08:00