The walk-back loop enforces a real ~300ms gap between each SsoGetGroupMsg send
(a deliberate process-wide anti-flood throttle). The test paid that gap for
real — up to 300ms x 12 sends per case — making the file ~5.2s.
Install vitest fake timers and drain the queued send-gap timers via
runAllTimersAsync, so the loop/cursor logic runs instantly. The production
throttle is untouched (only the test file changes).
Walk-back test body ~5237ms -> ~4ms; full core suite ~21s -> ~4.4s.
Follow-up to #161: trans_group_file's group_id/file_id used plain
f.uint()/f.string(); switch them to f.groupId()/f.fileId() (identical
coercion) so the WebUI debug console renders a group picker + file-id
control for the new action and the catalog carries the roles. Catalog
regenerated.
Add ugc_right and target_uins to the qzone publish path across protocol,
bridge, and the send_qzone_msg OneBot action, regenerate the MCP catalog,
and cover restricted visibility (16/128) with validation tests.
Docker SnowLuma could hang forever at "等待登录" after QQ had already
logged in. Root cause: login detection is edge-triggered — it only learns
the state from a loginState frame the native hook PUSHES when it observes
the login transition. `getLoginState()` is a cached value read once at
connect; after that the session waits passively. Desktop attaches the
hook before the user logs in, so it catches the edge. In Docker, QQ
auto-logs-in from a persisted session — fast on startup — so the
transition lands before the recv pipe is connected (frames aren't
replayed) or via a silent restore path the hook doesn't intercept. The
edge is missed and the cached state stays not-logged-in.
Fix: while connected but not logged in, actively re-probe QQ's own
ptlogin/deeplink ports via probeQqLoginInfo (level-triggered, pipe-
independent — the same call the workaround used) and synthesize the login
once it reports a real uin. Immediate probe on connect + a 3s interval;
stopped on login / teardown / dispose, with an in-flight guard so a slow
port scan can't stack concurrent probes. Idempotent with the pushed-frame
path. 7 tests cover detect-via-probe, the later-interval case, the
not-logged-in / non-real-uin / throwing-probe guards, no double-emit, and
no concurrent stacking. probeLogin is injectable for tests.
bridge-private-routing.test.ts loaded the Bridge dependency tree via
`await import(...)` INSIDE each `it`, so the first test paid the whole
~3.8s module-eval cost against its 5000ms per-test budget — fine locally
but tipping over on slower CI runners (surfaced as a timeout on an
unrelated PR). Moved the Bridge / IdentityService imports to top-level
statics (vi.mock is hoisted above them, so element-builder stays mocked):
the heavy load now happens once at file collection, not against a test
timeout. Per-test execution dropped from ~3.8s to ~7ms.
get_forward_msg nodes came back with empty or "QQ用户" sender names: the
SsoRecvLongMsg payload doesn't always populate the member name, and the
sync decode only consulted the L1 identity cache. So a sender absent from
that cache (just-started bot, inactive member) had no name.
ForwardApi.enrichSenders() now backfills nameless nodes on both fetch
paths (wire + cache-hit, with write-back so seeded inner caches enrich on
first read):
- L3: one fetchGroupMemberList per distinct group (inflight+TTL cached)
resolves every in-group sender at once; card preferred over nickname,
matching the existing wire-decode convention.
- L4: per-uin fetchUserProfile only for the residue (members who left the
group, private nodes).
Best-effort: a failed lookup keeps the placeholder, never failing fetch.
Adversarially reviewed — fixed the one High it found: the L4 fallback was
an unbounded Promise.all of un-cached OIDB 0xFE1_2 lookups, which a
high-churn group's historical forward (many since-left senders) or a
flaky member-list call could amplify into a rate-limit ban (the very risk
ContactsApi's own cache note warns about). Now: member-list failure keeps
the placeholder instead of fanning out, and the residue is capped
(FORWARD_PROFILE_MAX) and resolved in small concurrency-limited batches.
blank_issues_enabled: false only hides the chooser link — the CLI / API /
direct URLs still create template-less issues (e.g. #174 by a CONTRIBUTOR).
This workflow enforces the policy: an issue opened by someone without write
access that carries no template label (bug / enhancement) is commented on and
closed as not-planned. Proper bug reports / feature requests and write-access
users are unaffected.
Takes effect only once on the default branch (main).
set_group_name (OIDB 0x89a_15) always failed with OIDB error 1006 even
with admin rights. The group name in the body sits at tag 3, but we had
it at tag 1 — so the server rejected the malformed request structure
before ever checking permission. Confirmed against Lagrange's
OidbSvcTrpcTcp0x89A_15Body (ProtoMember(3) TargetName).
The reporter had tried tag 1 and tag 2 (both still 1006) — the answer was
tag 3. Live-verified on tempserver: with the fix the error changes from
1006 (structure rejected) to 1328 (permission — the test bot is only a
member), proving the request structure is now accepted; an admin bot will
succeed.
fetch_ptt_text failed for every voice with QQ errCode -1079 ("ptt
translate failed"). RE'd the cause in wrapper.linux.node: EncodeTroopPtt /
EncodeC2CPtt (ptt_trans_worker.cc) run the md5 through a bytes→hex helper
(sub_7A02D80, the 000102…ff table) before putting it in the request — so
the md5 field is a 32-char lowercase HEX STRING, but we were sending the
raw 16 bytes (hexToBytes(md5Hex)). Wrong length + encoding → server reject.
All other fields matched QQ NT exactly (verified field-by-field against
the two encoders): group {1=msg,2=sender,3=group,4=fileId,5=md5,6=dur,
7=size,8=format,9=uuid,10=event}, c2c {…,9=md5}, type 1=group/2=c2c.
Fix: md5 field is `string` (the lowercased hex), not `bytes`. Live-
verified on tempserver — errCode -1079 is gone and a real voice now
returns ASR text. (The "Live-verified end to end" claim in the old
comment was wrong; c2c had the same latent bug.)
When QQ's ASR returns a non-zero errCode, log the request fields we
sourced from cache (md5 length / format / uuid presence / duration /
size) alongside the errCode, so a server-side ASR failure can be told
apart from our own missing/zero field. Behaviour unchanged — still
throws. Helps diagnose the "ptt translate failed" instability reported
in #165 before deciding whether it's our bug.
#165: get_record gains an optional `out_format` (mp3/amr/wma/m4a/spx/ogg/
wav/flac, mirroring NapCat). When set, the QQ voice (SILK/AMR) is
transcoded server-side and the result is attached as `data.base64`; the
original info (file/url/file_size) is preserved. Absent ⇒ behaviour
unchanged. This spares ASR consumers from shipping a SILK decoder.
- protocol: convertAudioBytes(bytes, format, deps?) drives the bundled
SILK-capable ffmpeg addon's convertFile through temp files (always
cleaned up), with a format allowlist and an output-size cap. Pure/
injectable, unit-tested with a fake addon (6 cases).
- core: ExtrasApi.convertRecord(source, format) = loadBinarySource (64 MiB
cap) → convertAudioBytes.
- onebot: get_record wires out_format; transcode failure (incl. an addon
lacking SILK decode) degrades to ACTION_FAILED, never crashes.
Adversarially reviewed: no path traversal (format allowlisted), no SSRF
(source is the cached CDN url, not user input), cleanup covers every path,
graceful degradation confirmed. Catalog regenerated. Real-machine e2e
(does the shipped .node actually decode SILK?) is Phase 2.
Two alignment fixes in the 消息构建 tab:
- MessageBuilder is now a bounded "compose canvas" (always bordered) so the
empty hint reads as an intentional empty state inside a defined input
region, instead of a stray centered line floating in the left-aligned
form.
- The 账号 / 目标 label rows are pinned to a fixed h-8 (== the 群聊/私聊
segmented control's height) so both labels and the selectors beneath them
share a baseline — min-h-7 wasn't enough since the segmented is ~32px.
Phase 0 added `role` to FieldDoc and `stream` to ActionDoc and regenerated
catalog.ts, but the mcp package's own CatalogParam/CatalogAction types
(intentionally decoupled from onebot per ADR-0005) weren't updated — so
`tsc --noEmit` failed on the generated catalog.ts (TS2353, unknown
properties role/stream). The mcp vitest suite passed because esbuild
doesn't type-check the generated snapshot, which is why this slipped.
CatalogParam gains role?: string (loose, to stay decoupled from onebot's
FieldRole union); CatalogAction gains stream?: boolean. `pnpm typecheck`
is green across all packages.
Phase 6 of the debug-console expansion — the four pieces become one
console: 测试台 / 消息构建 / 实时活动 / API 浏览器, behind an Apple-HIG
sliding-indicator tab bar.
- ApiBrowser: a searchable, category-grouped reference for every
declarative action (~520), straight from /api/debug/actions — params /
returns / invariants / role + readOnly + stream badges. Each row's 试一下
prefills the tester and switches to it (ActionTester gains a
presetAction prop keyed by nonce).
- Tester / compose / activity stay MOUNTED and toggle via CSS, so the
live feed's SSE never drops — it keeps capturing events fired while you
invoke from the tester tab (the invoke-then-observe loop) — and
in-progress params/messages survive a peek at another tab. The API
browser, heavy and stateless, mounts on demand.
Adversarially reviewed: no crash / leak / mis-trigger; the review caught
that conditional-mounting silently dropped cross-tab live events and
discarded in-progress work — fixed by keeping the stateful panels mounted.
Phase 5 of the debug-console expansion. A drag-reorderable segment
builder so you never hand-write a OneBot message array: common segments
first-class (text / @ / image / face / reply / record / video), advanced
folded (file / mface / poke / json / xml / markdown / forward-node), with
`node` nesting a full builder (merged forward, depth-capped). @ uses the
group member picker via the sibling group context; media via FileSource.
Each segment carries a stable `_id` key so editing a field never remounts
the row.
- MessageBuilder + toOneBot() serialise to the exact OneBot v11 shapes
SnowLuma's parser reads (verified field-by-field).
- MessageComposer (the 消息构建 tab): pick target, build, preview, send —
routing through send_*_forward_msg when the message has nodes, else
send_*_msg. Refuses a mixed node/non-node forward (the backend would
silently drop the stray segments) and validates incomplete segments
before sending.
- ParamField renders the builder for message-type params in the tester
(raw-text escape hatch kept), emitting the serialized array as the
string value.
Adversarially reviewed: serialization contract and React key/focus both
verified clean (no _id leakage); fixed the mixed-forward silent-drop,
added pre-send segment validation, and made the drag boundary explicit.
Phase 4 of the debug-console expansion — the tester no longer makes you
hand-write JSON. Each param renders the right smart control from its
semantic role: group/friend/member pickers (member linked to the sibling
group_id), FileSource for media, FaceGrid, datetime for timestamps,
switch for bool, select for enum — with the raw-JSON escape hatch kept.
- Read-only actions badge green, side-effecting amber; a name-pattern set
of destructive actions (kick/ban/recall/delete/…) confirms first.
- Stream API actions invoke over /api/debug/invoke-stream and render
frames live, tracked in the app-level task registry with progress; a
clean success requires the terminal response frame, and a failed frame
shows its reason (not just the {type:'error'} marker).
- Every call lands in capped localStorage history with one-click replay;
responses render in a collapsible JsonTree.
- Extracted LiveActivity (filter / search / export) and the Segmented
control into their own components; debug-page is now a thin host.
Adversarially reviewed: stream terminal-frame judging, member/group
linkage, and contract shapes verified; fixed the failed-stream-frame
reason being hidden, a latent required-enum controlled-select trap, stale
member value on group change, and several low UX edges.
Phase 3 of the debug-console expansion — the Apple-HIG foundation the
new tabs build on (reusing the existing ui kit; new controls match its
idiom, no browser-native widgets).
API client + types:
- debug.invokeStream() reads /api/debug/invoke-stream via fetch + a body
reader, parsing data:<json> SSE frames (header auth, cancellable).
- debug.upload() streams a file via XHR for real upload progress.
- DebugActionParam.role, DebugActionDoc.stream/invariants, FieldRole,
DebugStreamFrame, DebugUploadResult.
Primitives:
- Tabs (sliding indicator + roving tabindex), Picker (searchable,
windowed combobox with a raw-value escape hatch), JsonTree (collapsible
response viewer, image-url preview, copy w/ http fallback, node cap),
FileSource (URL / server-path / browser-upload tri-state), FaceGrid.
Infra:
- DebugTaskProvider + TaskBadge: app-level registry of in-flight upload/
stream tasks so they survive in-app tab switches, with a global
progress chip and a beforeunload guard; eviction never drops a running
task. Wired into App.
- use-debug-contacts: lazy friend/group/member data layer, shared module
cache + localStorage (version-stamped, avatars stripped to dodge quota)
+ TTL + request-id race guard, degrading to raw input on error.
Adversarially reviewed (no automated component tests): virtualization
math and SSE framing verified correct; fixed the stale-contacts-on-switch
member-selection hazard, the quota-kills-persistence issue, and several
low UX edges (active-row clamp, filter scroll reset, copy fallback).
Phase 2 of the debug-console expansion. Two backend endpoints under the
existing /api/* bearer auth:
- POST /api/debug/invoke-stream — drive a Stream API action (#163) and
relay every frame to the browser over an SSE body. The frontend reads
it with fetch()+a stream reader (header auth), so it needs no SSE
query-token allowlist entry. Frames are NEVER dropped: backpressure is
handled by waiting for the consumer to drain, not by discarding (a lost
chunk corrupts a transfer). Non-stream actions emit a single frame.
- POST /api/debug/upload — stream a browser-selected file to a temp path
on THIS server (the bot runs server-side, not on the operator's
machine), so the path can feed a send action's file/image/record/video
param. Raw bytes in the body (no multipart buffering), ?filename= for a
readable suffix, streamed to disk under os.tmpdir()/snowluma-debug-
upload with a 4 GiB cap and a 24h TTL sweep.
instance.invokeStream() delegates to ApiHandler.processStreamRequest.
Pure logic (filename sanitisation, fenced size-capped disk sink, the
stream-invoke Response builder) lives in debug-tools.ts with 12 unit
tests. Adversarially reviewed: path traversal is robust (19 hostile
filenames all fenced), frames provably never dropped, auth runs before
any disk write. Fixed a High found in review — the write stream had no
'error' listener (an async ENOSPC/EIO during flush would crash the whole
process) and a late-open race could leak a partial file; both now raced
into the awaits and cleaned up after 'close'.