deleteQzoneMsg POSTs a form-urlencoded tid to taotao.qzone.qq.com's
emotion_cgi_delete_v6 (proxied through h5.qzone), on the bot's own space.
Success has no positive payload beyond code 0, so the contract is
throw-on-non-zero-code (foreign/unknown tid or auth failure); the server
only deletes feeds the hostUin owns. Rejects empty tid pre-flight.
- core: QzoneApi.delete; onebot: delete_qzone_msg action; regen catalog.
- tests: tid form-body/url/headers lock, empty-tid preflight, non-zero
code throw, and bridge self-space threading.
Adversarial review (verdict NEEDS-FIXES, CRITICAL): emotion_cgi_publish_v6's
success envelope names the new feed id `t1_tid` and post time `t1_time`
(a STRING), NOT `tid`/`now`. So every successful publish hit the
'缺少 tid' guard and was reported as a failure (risking caller-retry
duplicate 说说). The tests passed only because they mocked the wrong field
names — they codified the bug.
- Read t1_tid (primary) with tid as a defensive fallback; t1_time||now
via Number() (t1_time is a string on the wire). Verified against the
Go reference lib starim00/AnimeAPI EmotionPublishVo.
- Added who=1 to the form body (both working references send it).
- Tests now encode the real contract (t1_tid/t1_time string), plus a
tid/now fallback case and a no-feed-id throw case.
First Qzone WRITE path. publishQzoneMsg POSTs a form-urlencoded body to
taotao.qzone.qq.com's emotion_cgi_publish_v6 (proxied through h5.qzone),
g_tk in the query, on the bot's own space. Returns the server-assigned
tid (the handle for later delete/comment/like) + post time.
- Throws on transport failure, non-zero Qzone code (content rejected /
rate-limited), or a success body with no tid — never reports a publish
as succeeded without the feed id. Rejects empty content pre-flight.
- 带图 (image) publishing is a later slice (needs the upload CGI first).
- Action carries a风控/限流 caveat: publishing is an active action and
high frequency is rate-limited by Qzone, same as sending messages.
- core: QzoneApi.publish; onebot: send_qzone_msg action; regen catalog.
- tests: form-body/url/headers lock, empty-content preflight, non-zero
code throw, missing-tid throw, and bridge self-space threading.
Cosmetic fidelity nit from re-review: the fixture used _Callback(...) while
the request pins callback=_preloadCallback. Parser is name-agnostic so
behavior is unchanged; this just makes the fixture mirror slice-1.
Adversarial review of the get_qzone_feeds slice (verdict NEEDS-FIXES):
- Host: hit ic2.qzone.qq.com directly, bypassing the proxy gateway that
slice-1/group-album use. The qzone.qq.com cookie jar only authenticates
against the proxy origin, so the direct call would fail referer/origin.
Now routed through h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/...
- Format: requested format=json but the test fed a JSONP body, so the
requested format was never pinned. Aligned to slice-1's
format=jsonp + callback=_preloadCallback; the test now asserts format.
- Pagination: documented honestly that page_num is reliable only for the
first page (deep paging needs a time cursor, deferred) — in the
docstring and the action summary/param desc.
- Clarified the key/feedskey fallback is the per-feed handle, not a cursor.
Second read-only Qzone slice. getQzoneFeeds over ic2.qzone.qq.com's
feeds3_html_more CGI, same cookie/g_tk plumbing + JSONP-tolerant parse +
throw-on-auth-failure contract as get_qzone_msg_list. Surfaces the stable
structured fields (uin/nickname/time/appid/key) and passes the
pre-rendered html blob through verbatim — deep HTML→segment parsing is
left to callers. Paginated via page_num (1-based) + count.
- protocol/web/qzone.ts: getQzoneFeeds + mapFeeds.
- core: QzoneApi.getFeeds (always the bot's own identity).
- onebot: get_qzone_feeds action; regenerated MCP catalog.
- tests: feeds mapping, HTTP url/params, empty-feed→[], auth-throw,
and bridge pageNum/count threading.
Exact CGI field names / pagination cursor to be re-confirmed against a
live capture (same posture as the msglist helper).
Adversarial review of the get_qzone_msg_list slice flagged two real
coverage gaps (verdict was SHIP, no correctness defects):
- protocol: empty space ({code:0,msglist:[]}) must return [] not throw —
the exact distinction the throw-on-auth-failure contract promises.
- core: QzoneApi.getMsgList target_uin defaulting + the >0 guard, the
only place the string(identity.uin)/number(param) boundary is crossed.
First slice of personal QQ-Zone (个人空间) support, an exclusive feature
vs NapCat/SL (both only do group albums). Read-only to prove out the
auth+parse chain before write ops (publish/like/comment).
- protocol/web/qzone.ts: getQzoneMsgList over taotao.qzone.qq.com's
emotion_cgi_msglist_v6 (h5.qzone proxy), reusing the same cookie/g_tk
plumbing as group-album. JSONP-tolerant parse; throws on auth failure
rather than swallowing to [] (mirrors group-signin contract).
- core: QzoneApi bridge class wired into ApiHub.
- onebot: get_qzone_msg_list action (target_uin/pos/num), regenerated
MCP catalog + new 空间 doc category.
- HTTP-layer tests lock URL/params, JSONP stripping, field mapping, and
the throw-on-auth-failure paths (9 cases, all green).
Exact CGI params/response fields to be re-confirmed against a live
capture when extending (same maintenance posture as group-album).
The 16px faint-bordered box + muted label read as nearly invisible. Make it a
full-width bordered row: a larger (20px) border-2 checkbox with a white unchecked
fill, a checked primary tint on the whole row, and the primary label in
foreground weight (English subtitle stays muted).
ConsentPage shown after login, before the forced password change: tabbed
EULA/PRIVACY rendered by an in-house, dependency-free markdown renderer
(React elements only, XSS-safe; single-pass bounded-class inline parser to
avoid ReDoS on large docs). App gate order is login -> consent -> password ->
app; api client gains agreements.get / recordConsent with a 409 stale-version
re-prompt that refetches the new text.
Server-side consent gate: GET /api/agreements (texts + content-hash version +
consentRequired) and POST /api/agreements/record-consent, persisted to
config/consent.json. Enforced in the auth middleware (CONSENT_ALLOWLIST,
ordered before the must-change-password gate) so the gate is real, not just a
frontend convention. The version is content-derived, so consent is stable
across app upgrades and re-prompts only when the agreement text changes. The
EULA/PRIVACY docs are bundled into dist/ (build fails if they are missing).
Switch from open-source to a NapCat-style source-available, NON-commercial
license so the free software cannot be resold / paid-deployed — which an OSI
license (AGPL included) cannot prevent. Source stays public for study and
non-commercial self-hosting; commercial use and public release of modifications
require written permission; the native addon (snowluma-*.node) is carved out as
proprietary. Explicitly NOT OSI open source.
- LICENSE: SnowLuma Source-Available Non-Commercial License v1.0 (bilingual)
- all 14 package.json: "license": "SEE LICENSE IN LICENSE"
- README license section; CONTRIBUTING contribution-licensing note + a grant
letting the project relicense contributions under commercial terms
(contributors keep copyright)
NapCat's get_qun_album_list calls the kernel AlbumService (trpc, not
statically recoverable). SnowLuma already exposes the same qun_list_album_v2
web API as get_group_album_list (raw array); this adds the NapCat-named
action reshaping it into NapCat's {album_list, attach_info, has_more}
envelope with {album_id, album_name, create_time, pic_num} items. Web
endpoint pulls up to 1000 albums in one shot → attach_info/has_more are
''/false (no cursor); cover_url not returned by this endpoint. Reuses the
existing groupAlbum.list bridge call — no new wire.
set_doubt_friends_add_request with approve:false now rejects/declines via
delDoubtBuddyReq instead of erroring. RE'd from QQNT wrapper.linux.node
(DoubtBuddyDelWorker ctor sub_3F3F320 → OIDB 0xd69_0; EncodeRequest
sub_3F3E860 body {1: varint const 3 (op discriminator; get uses 1),
3:{1: string uid}}, uid from kernel attr 21001). uin-form (reserved=1).
Adversarially IDA-verified; byte-oracle locks the tags. Split out as its
own commit because the approve-only 0xd69_0 work was already pushed.
All recovered from QQNT wrapper.linux.node via IDA — NapCat ships these as
kernel-only with no portable packet wire, so each cmd + protobuf was traced
from the binary (worker ctor cmd/subcmd via BaseOidbWorker::DoSendOidbReq;
field tags from the per-feature EncodeRequest). All uin-form OIDB (envelope
reserved=1, matching the NT worker flag=1). One commit for easy rollback.
New OIDB services + byte-oracle tests (fixed-wire, mutation-resistant):
- 0x9130_0 getBuddyRecommendContactArkJson → share_peer/send_ark_share (user)
- 0x8b7_5 getGroupRecommendContactArkJson → share_peer/send_ark_share (group),
share_group_ex/send_group_ark_share (NapCat's getArkJsonGroupShare
was NOT RE'd; routed to this confident equivalent — documented)
- 0xd69_0 getDoubtBuddyReq → get_doubt_friends_add_request
- 0xd69_0 approvalDoubtBuddyReq → set_doubt_friends_add_request (approve only)
- 0xf00_3 modifyGroupExtInfoV2 → set_group_robot_add_option (EXTInfo tags
30/31; GroupExtFilter is client-side gating, not on the wire)
NOT shipped (kernel-.so-side, no static cmd recoverable from this binary —
honest, no guessed packets): get_robot_uin_range (trpc method-routed),
create_collection/get_collection_list (Weiyun multi-step), trans_group_file.
Doubt-list string field NAMES (nick/source/msg) are medium-confidence
(generic serializer); uid+reqTime are high. READ path, so a mislabel is
cosmetic, never a wire/ban risk. Wired through bridge friend/contacts/
groupAdmin apis; proto-defs + per-file subpath exports registered.
QQ's recent-contact list is a kernel-local snapshot
(getRecentContactListSnapShot) with rich peer metadata
(peerName/remark/lastestMsg). SnowLuma has no SSO/packet wire for it, and
its own message store only covers observed sessions and lacks those
fields — so a faithful version isn't achievable app-layer. Rather than
ship a divergent approximation under a name implying QQ's native list,
register a documented stub returning [] (accepts count for compat),
matching how NapCat handles its own unobtainable get_online_clients.
The other ② stubs (online_clients/model_show/mark_all_as_read) were
finalized in P1; collection stays in the deferred deep-RE tier.
Adversarial review: swallowing transport/auth errors to [] made an expired
cookie indistinguishable from a group with zero sign-ins, and left the
onebot action's try/catch as dead code. Now getDaySignedListWebAPI
propagates transport errors and throws on a page-less response (matches
NapCat, which throws on missing page) — so auth failure becomes a proper
'failed' OneBot response; a genuinely empty group still maps to [].
Also: cover the previously-untested HTTP layer (exact URL + g_tk + json
body + error propagation), harden the rank de-skew with Number(), and
document the deliberate local-date (vs NapCat UTC) choice for dayYmd.
Group sign-in (打卡) list is NapCat's one kernel-free item here: it's a
plain HTTP POST to qun.qq.com/v2/signin/trpc/GetDaySignedList with g_tk
from p_skey, not OIDB. SnowLuma already has the cookie/bkn infra (same as
group notice/essence/honor), so this is a straight port — no RE.
- protocol/web/group-signin.ts: getDaySignedListWebAPI + pure
mapDaySignedList (de-skews signInRank via (r-1)/2+1, [] when no page).
- core WebApi.getSignedList(groupId) wires the qun.qq.com cookie jar.
- onebot get_group_signed_list groupAction (readOnly), [] on no sign-ins.
- Tests pin the rank/field transform and the action wiring.
Adversarial review: code/test comments claimed napcat-surface parity while
get_online_clients ({clients:[]}) and _get_model_show (echoes model vs
NapCat's hardcoded 'napcat') deliberately diverge. Correct the comments to
state these are intentional OneBot-v11/spec choices, and strengthen the
default-model test to pin 'snowluma' literally + cover explicit empty
string. No behavior change.
NapCat ships model_show/online_clients/mark_all_as_read as kernel-only
mock/no-op with no packet wire, so SnowLuma can't RE a wire that doesn't
exist. Align _get_model_show to NapCat's response shape
([{variants:{model_show,need_pay}}]) and echo the requested model
(default 'snowluma'). online_clients keeps the OneBot-standard
{clients:[]} envelope; _set_model_show / _mark_all_as_read stay no-ops.
Pin all four shapes with tests.
A group_invite carrying a fromUid is routed to the async dispatchGroupInvite
enrich path, which only resolved the requester profile when needsProfile
(uin missing/polluted) — so when the uin was already known it bypassed the
synchronous rememberRequestIdentity that friend_request uses, and the inviter's
uid↔uin was never stored. Record it up-front (mirrors the sync path), and
re-record after a profile resolve so a previously-unknown uin self-heals.
Fixes the long-standing bridge-member-identity test failure (core now fully
green: 387 passed).
- Change DEFAULT_BODY_TEMPLATE from plaintext to Server酱-style JSON
- Add JSON-safe escaping in renderTemplate for backslashes and quotes
- Update frontend DEFAULT_TEMPLATE to match backend
- Add tests for JSON escaping behavior
Fixes#106 (targets dev branch)
Adversarial pass (MINOR): the new Segmented/IconBtn regressed a11y vs the
project's existing Segmented. Add role=radiogroup/radio + aria-checked to the
segmented control, type="button" to all buttons (segmented, icon, execute,
row toggle), aria-label to icon buttons, aria-expanded to the row toggle.
Move the debug console out of the Settings 调试 subtab into a dedicated
top-level /debug page (sidebar nav + route + AppPath), and redesign it in an
Apple-HIG flavour: translucent sticky header with title block + connection
pill, soft layered cards (rounded-2xl, hairline borders, blurred backdrop),
an iOS-style segmented control with a sliding active pill (params form/JSON,
stream all/event/action filter), refined timeline rows + empty state, calm
type hierarchy with tabular-nums. Logic (tester + live SSE) carried over
verbatim. Remove the old settings/debug-panel + the Settings 'debug' subtab.
Adversarial Wave A3 flagged the hand-rolled SSE drop/marker logic as the
untested weak point. Extract createFramePusher (drop under desiredSize<=0,
coalesced {kind:'dropped'} marker before the next delivered frame, null = no
backpressure) and unit-test it; the stream handler now uses it. Non-blocking
hardening — no behavior change.
New Settings → 调试 tab (DebugPanel): an action tester (account picker +
schema-driven form from /api/debug/actions, with a raw-JSON escape hatch for
legacy actions, real-side-effect warning) and a live merged SSE view of OneBot
events + action calls (pause / clear / kind filter, expandable JSON, drop
marker). Typed debug API client domain (actions/invoke/stream over EventSource
with ?token=). View layer — verified by tsc + vite build.
GET /api/debug/actions (collectActionDocs for the tester form), POST
/api/debug/invoke {uin,action,params} (routes to that account's ApiHandler —
real side effects, /api/* auth, uin validated), GET /api/debug/stream (merged
SSE of OneBot events + action calls across accounts, tagged by uin+kind). Taps
attach only while a client is connected (on-demand); a slow client is dropped
with a periodic {kind:'dropped'} marker rather than buffering unbounded.
/api/debug/stream added to the ?token= allowlist for EventSource.
Add NetworkManager.subscribeDebug (notified for every emitEvent, before the
adapter check, so the debug stream sees events even with no adapter) and
ApiHandler.setObserver (fires after every handled action with
{action,params,response,ms}, success or failure). Both Set-based, return an
unsubscribe, and swallow subscriber throws so they never break dispatch.
OneBotInstance exposes subscribeDebugEvents / observeActions / invokeAction.
Foundation for the Wave A3 debug stream + action tester.
Adversarial Wave A2 pass, 3 MAJORs:
- #3/#3b: the allowlist missed per-account config/onebot_<uin>.json (the file
that actually carries each account's network config + OneBot access token).
Add it via an /^onebot_\d+\.json$/ pattern; classify ALL onebot config
(global + per-uin) as credential since it holds the access token — a
no-credentials backup no longer leaks tokens. Export scans config/ for them.
- #6: GET export could carry the private key/hash; add Cache-Control: no-store
+ Pragma so the response is never cached.
- #4: refactor applyBackup → pure planRestore + a two-phase atomic restore in
server.ts (snapshot → stage all .tmp → rename all), so a staging failure
leaves the live config untouched; clean up stray tmp on failure.
A '配置备份 / 恢复' card in the 服务 tab: export downloads the JSON bundle
(with an 包含登录凭据 toggle + red warning), restore picks a .json file and
calls importBackup with a 一并恢复登录凭据 toggle (default off). Shows
restored/skipped counts + restart-to-apply. Typed systemSettings.exportBackup/
importBackup client methods. View layer — verified by tsc + vite build.
GET /api/system/backup/export?credentials=1 streams the JSON bundle (download
header); POST /api/system/backup/import validates then applies, snapshotting
current config to config/.restore-backup-<ts>/ first (recoverable), writing
atomically (tmp+rename), key.pem at 0600. Both behind /api/* auth; import body
capped at 32MB. Restore is restart-to-apply.
Add reusable Select (styled native select + chevron) and Textarea components
matching Input's design language, and replace every native <select>/<textarea>
across the app (system-panel, node-edit-dialog, notification-channel-dialog,
settings customCss, widget note) with them.
TLS cert UX: once a cert is installed, the sensitive PEM is no longer rendered
on revisit — show an '已安装(未改变)' summary with 更改/删除 buttons; 更改
reveals the inputs again (with a 取消). Private key is never sent back to the
client. Verified by tsc + vite build.
Wrap the post-write chmodSync in try/catch like auth.ts: the key is already
written with mode 0600, so a rare chmod failure shouldn't misreport a
successful write as a 500.
Adversarial Wave A1 pass (MAJOR): the TLS private key was written with the
default umask mode (typically 0644 — world-readable). Write config/key.pem
with mode 0600 + an explicit chmodSync (writeFileSync's mode is ignored for an
existing file), mirroring auth.ts's webui.json handling. cert.pem stays public.
New Settings → 服务 tab (SystemPanel) over the /api/system/settings + TLS cert
API: edit port / bind host / trust-proxy, paste cert+key PEM to enable HTTPS,
delete cert. Flags env-overridden fields ('被环境变量覆盖') and shows a
restart-to-apply banner (no self-restart). Typed systemSettings API client
domain. View layer — verified by tsc + vite build (webui has no component-test
infra).
GET/POST /api/system/settings (port/host/tlsEnabled/trustProxy) persisted via
updateRuntimeConfig — listener-level, restart-to-apply; response carries
restartRequiredToApply + the env-overridden field names so the panel can flag
edits that won't take effect. Enabling TLS is blocked unless a usable cert is
present. POST/DELETE /api/system/tls/cert validates a pasted PEM pair
(validateTlsPair) before writing config/cert.pem + key.pem. Adds read-only
readRuntimeConfig() and a tested pure coerceSettingsPatch validator. All routes
sit behind the existing /api/* auth middleware.
Persist a partial RuntimeConfig update, merging onto the on-disk config (not
the env-merged view) so an env override is never baked into runtime.json.
Tested: patch merge + the env-not-persisted invariant (chdir to a temp cwd).
Move the trust-proxy/client-ip resolver from a module-level direct read of
SNOWLUMA_WEBUI_TRUST_PROXY into initWebUI, fed by runtime.json's trustProxy
(loadRuntimeConfig already merges the env override on top). Default '' parses
identically to the old undefined (trust socket peer only), so behavior is
unchanged when unset.
initWebUI now takes { host, tlsEnabled }. When TLS is enabled, load+validate
config/cert.pem + key.pem (tls.ts: validateTlsPair / resolveTlsContext, both
unit-tested) and serve via @hono/node-server's https createServer; a missing
or invalid cert falls back to HTTP with a loud warning (never bricks the WebUI).
Bind host is now configurable (default 0.0.0.0). Wired from runtime.json
(webuiHost / webuiTls.enabled) in core/index.ts.
Add webuiHost / webuiTls.enabled / trustProxy to RuntimeConfig as the shared
system-settings model for WebUI Wave A1 (HTTPS + self-config panel). Refactor
load into pure, tested normalizeRuntimeConfig + resolveRuntimeEnvOverrides
(env wins, in-memory only; SNOWLUMA_WEBUI_HOST added alongside the existing
PORT/TRUST_PROXY overrides). loadRuntimeConfig backfills older runtime.json
with the new defaults. Adds vitest to @snowluma/common (was typecheck-only).
Adversarial Wave 3 pass: the prior 'round-trip locks pb tags' test was a
fake lock — symmetric encode/decode via the same proto would not catch a tag
swap. Replace with a fixed hex byte-oracle (hand-decoded in the comment) that
goes red on any tag change, plus an empty-ocr-body deserialize test. Note in
the action that a passed-in URL is used verbatim (not rkey re-signed).
Server-side image OCR ported from Lagrange.Core ([Service]
"OidbSvcTrpcTcp.0xe07_0") cross-checked with NapCat's Oidb.0xE07 proto
(NapCat's transformer cmd 0xEB7 is a typo; the proto name + Lagrange + the
wire name all say 0xE07_0). New ImageOcr service + proto types, misc.ocrImage
api, and the ocr_image/.ocr_image action. The cmd takes an image URL, so the
action accepts an http(s) url or a cached image file_id (resolved via
getImageInfo); base64/local input is unsupported (NapCat's path is a
Windows-only kernel OCR, not portable). Wire round-trip + response-parse
tests lock the pb tags.
Adversarial Wave 2 pass (no blockers): get_rkey_server now returns
ACTION_FAILED instead of an 'expired_time=now, no keys' shell when no
download rkey is available, so a caching caller can't misread 'unavailable'
as 'valid-but-expired'. Added an empty-list test. Annotated rename_group_file's
{ok:true} body as a deliberate NapCat-parity deviation from sibling file ops.