Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl c839121fdb fix(protocol): send super/new faces with the right wire encoding (#168)
A face was always built as a legacy FaceElem (face.index). QQ-NT only renders
classic small faces that way; newer 'super'/animated faces sent as face.index
are silently remapped by the server (e.g. 424→168) or shown as a placeholder.

Classify each face id off QQ's own system-face catalog (0x9154_1, ported from
SLE/Lagrange) and pick the matching wire form:
  - super (animated, aniSticker pack ≠ (1,1)) → CommonElem 37 + QFaceExtra
  - other id ≥ 260                            → CommonElem 33 + QSmallFaceExtra
  - classic id < 260                          → legacy FaceElem

The catalog is fetched once (shared, lazy-warmed off the first face send) and
classify falls back to an id-range guess until it lands. Data-driven so all 66
non-(1,1) super faces encode correctly, not just a hardcoded few.

Verified on a live account: 424→424 (was 168), super 392→392, classic 14→14.
2026-06-30 16:09:28 +08:00
DrSmoothl f9900053b0 feat(onebot): backfill semantic roles across action params
Phase 1 of the debug-console expansion. Sweeps the 8 action files,
swapping bare coercion primitives for the role-stamped equivalents from
Phase 0 (or appending `.role()` where a `.default()`/`.optional()` field
must keep its exact coercion). Coercion is byte-for-byte unchanged; the
only intentional bound change is face_id min 1→0 (QQ face 0 is valid).

Classification (verified against each run()):
- group_id → f.groupId() everywhere.
- user_id → f.userId() for any-user / friend / stranger / private-route
  targets (send_private, friend poke, friend add/remove, stranger info,
  private file transfer, forward-to-friend, share-contact).
- user_id → f.memberId() only when it's a member of a sibling group_id
  (batch kick elements; the groupUserAction preset already injects it).
- media file/image/record + file_id → f.file()/image()/record()/fileId()
  (or .role() when defaulted/optional); message_id/timestamp/duration via
  the constructor or .role() hatch as coercion requires.

Polymorphic ids left as bare uint on purpose (no single role can honestly
describe them): mark_msg_as_read.target_id (group# or uin), bot_appid,
msg_seq, fileset_id, folder_id, chunk indices, stream_id.

Catalog regenerated: 71 group_id / 25 user_id / 9 member_id / 19
message_id / 11 file_id / 7 image / 6 file roles surfaced. Done by 4
parallel agents; adversarially reviewed — zero coercion regressions,
zero member/user misclassifications.
2026-06-30 16:00:21 +08:00
DrSmoothl 14a61f4ce8 feat(onebot): add semantic role field + stream flag to action metadata
Phase 0 of the debug-console expansion. Introduces a closed `FieldRole`
union (group_id/user_id/member_id/message_id/file_id/file/image/record/
video/duration/timestamp/face_id) orthogonal to `type` (which governs
coercion), so the WebUI debug console can render semantic pickers instead
of forcing hand-written JSON.

- FieldDoc gains `role?`; Field gains a `.role()` modifier (survives
  optional/default/describe). toJsonSchema emits it as `x-role`.
- Semantic constructors f.groupId/userId/memberId/fileId/file/image/
  record/video/duration/timestamp/faceId wrap the coercion primitives and
  stamp a role (messageId backfilled). Media sources reject '' to preserve
  the empty-string guard they replace. faceId allows 0 (valid QQ face id).
- Extracted stringCore so f.string() and string-valued roles share it.
- ActionDoc gains `stream?`; defineStreamAction sets stream:true.
- Presets: groupAction injects f.groupId(); groupUserAction injects
  f.groupId() + f.memberId() (a group action's user_id is a member of
  that group). Backfills role onto 56 group_id / 7 member_id sites.
- Regenerated MCP catalog (79 x-role, 5 stream).

Adversarially reviewed: no coercion regression, member_id stamping has
zero false positives across all 7 groupUserAction sites, x-role is inert
to MCP consumers.
2026-06-30 15:42:25 +08:00
DrSmoothl 7285db1e37 feat(onebot,mcp): catalog the Stream API actions (#163 phase 4)
Surface the six Stream API actions in the self-generated docs catalog under
a new 流式接口 category: export each module's specs as an `actions` array
(register now iterates it) and widen the doc collector's spec type to anything
carrying describe() so defineStreamAction results qualify alongside
defineAction. Regenerated the MCP catalog snapshot.

End-to-end verified on a live account over HTTP: test_download_stream (10
frames + terminal), an upload_file_stream round-trip (200 KB chunked in →
sent as a c2c file → download_file_stream back out, byte- and sha256-exact),
and the SSRF guard rejecting a cloud-metadata URL.
2026-06-30 14:40:39 +08:00
DrSmoothl 369dea582c feat(onebot): download stream actions (#163 phase 3)
NapCat-compatible server-push download: one request answered by a file_info
header, N base64 file_chunk frames, then a terminal file_complete. Bytes come
from a local file under the stream temp root, an http(s) URL, or a cached
image/voice id resolved to its QQ CDN URL. test_download_stream pushes 10
canned frames so a client can validate the transport without touching QQ.

Actions: download_file_stream / download_file_image_stream /
download_file_record_stream / test_download_stream.

Hardened beyond the NapCat original (which streams any local path and follows
redirects to anywhere), per three adversarial review passes:
- arbitrary-file-read: local reads are fenced inside the stream temp root,
  realpath-resolved so a symlink can't point out.
- SSRF: every URL (incl. each redirect hop, followed manually) is guarded —
  http(s) only, and neither the literal host nor any resolved address may be
  private/loopback/link-local; IPv6 is byte-parsed so normalized v4-mapped
  literals (::ffff:a9fe:a9fe) can't slip past. (DNS rebinding remains a
  documented residual — needs connect-time peer-IP validation.)
- robustness: TTFB-only connect timeout (doesn't cut a large body mid-stream)
  plus a per-chunk idle watchdog that aborts a stalled source so a hung
  upstream can't pin a socket; total size capped; non-2xx bodies cancelled;
  client disconnect aborts via the sink.

501 onebot tests green.
2026-06-30 14:40:39 +08:00
DrSmoothl 4f0a0f89ae feat(onebot): upload_file_stream + clean_stream_temp_file (#163 phase 2)
NapCat-compatible chunked upload: a client pushes a file in base64 chunks
across repeated upload_file_stream calls keyed by stream_id; the bot
reassembles them into a local temp file and returns its path for a normal
send. Sidesteps the 2 MB HTTP body cap and the shared-filesystem assumption
of file:// sends.

Reassembly streams chunk-by-chunk to disk (no whole-file Buffer.concat) so
an arbitrarily large upload won't OOM. clean_stream_temp_file wipes only the
stream temp dirs.

Hardened well past the NapCat original (which has all of these holes), per
two adversarial review passes:
- stream_id is allowlist-validated + every derived path fenced inside the
  upload dir — no path traversal (the original lets stream_id write/delete
  arbitrary paths).
- total_chunks / concurrent streams / per-chunk + per-stream bytes are all
  bounded — no single-request OOM or disk/fd exhaustion.
- chunk indices are claimed synchronously and only counted complete once
  flushed — no double-count race, no truncated-read-on-concurrent-complete.
- state + temp dirs are namespaced per account.

494 onebot tests green.
2026-06-30 14:40:39 +08:00
DrSmoothl 01ef98abb6 feat(onebot): Stream API transport plumbing (#163 phase 1)
Foundation for NapCat-compatible Stream API actions (chunked file
upload/download): a single request answered by many response frames.

- streaming.ts: StreamSink + frame wrappers (wrapStreamFrame /
  wrapStreamTerminal) matching NapCat's wire — each frame an OB11 envelope
  with stream:'stream-action', data.type ∈ stream/response/error, and
  message mirrored into wording.
- ApiHandler: registerStreamAction / isStreamAction; handle() threads a
  sink; processStreamRequest drives WS multi-frame dispatch with optional
  liveness-based abort; dead processRequest removed.
- action-kit: defineStreamAction (sink-aware run, registers as a stream
  action) reusing defineAction's parse/describe.
- adapters: HTTP server streams chunked \r\n\r\n-delimited frames (no
  Content-Length, no false application/json), aborting on client
  disconnect; WS server/client use a backpressure-aware safeSendAsync and
  abort a stream once the socket closes.

No stream actions are registered yet — those land in the next phases.
Verified by two adversarial review passes. 480 onebot tests green.
2026-06-30 14:40:39 +08:00
Airline233 62eb8fd763 feat: Qzone支持上传图片(发说说、评论附图) (#152)
* feat(core,onebot,protocol): support publish shuoshuo and comment with image

* Fix: Accidentally changed notes
2026-06-30 01:47:21 +08:00
DrSmoothl 547640bbfd fix(onebot): route oversized videos to the file path up front (#145)
A video larger than MAX_VIDEO_SIZE (~100 MB, QQ's video-resource cap)
uploads "successfully" — the server accepts the Highway bytes — but the
receiver sees 视频已过期 because no valid video resource is ever produced.

The existing large-video → file fallback only fired from the send catch
block, i.e. when the upload threw. An oversized upload doesn't throw, so
the fallback never triggered. Add a proactive split before the send: any
video whose source size is known to exceed MAX_VIDEO_SIZE is re-routed to
the file pipeline up front (mirrored for private and group sends). Smaller
videos (verified up to ~49 MB) are unaffected and still send as videos.

This pairs with the c2c-file fix (f8caa92): the re-routed video now rides
the now-working offline-file path and downloads correctly.
2026-06-30 01:24:00 +08:00
DrSmoothl f8caa92333 fix(core): make c2c files >5MiB downloadable — drop stale busId, fix md5-head magic
Private (c2c) files larger than a few MiB uploaded fine but the receiver
always got "download failed" (#157). Two independent bugs in the offline-
file upload (OidbSvcTrpcTcp.0xe37 + highway cmdId 95), both confirmed on a
live account and against a plaintext packet capture of the real QQ client:

1. FileUploadExt.busiBuff carried a stale `busId: 102`. NapCat, Lagrange and
   the QQ client all emit busiBuff with senderUin only. The extra busId let
   the server accept the highway upload but never finalise it as a
   downloadable offline file once the payload passed ~5 MiB.

2. md510MCheckSum used `10 * 1024 * 1024` (10485760) as the "first 10 MB"
   limit. QQ's magic number is 0x98A000 = 10002432. With the wrong limit the
   server's first-block verification failed for any file larger than
   0x98A000, so files >~10 MB uploaded but never finalised either.

With both fixed, c2c files of arbitrary size (verified up to ~20 MB; the
general highway path has no inherent size cap) download correctly.
2026-06-30 00:13:34 +08:00
DrSmoothl 57ff0e69d8 fix(core): make c2c file 0xE37_800 finalize best-effort, not fatal (#157)
Real-machine retest showed a plain c2c file send (NotOnlineFile only, no
field6) is already downloadable — the original #157 'fix' made the 0xE37_800
finalize a hard dependency, so any finalize error would now fail a send that
would otherwise succeed (a regression, same speculative-NapCat-parity trap as
the #145 force-full).

Wrap the finalize in try/catch: on success we still attach field6 (extra
download routing that mirrors NapCat and may help some receiver clients); on
failure we log and send without it. field6 is now enrichment, not a
requirement.

Adds a regression test: a failing 0xE37_800 still produces a PbSendMsg with
the file and no field6.
2026-06-29 16:08:20 +08:00
DrSmoothl 053137669a fix(protocol): drop the ineffective video force-full-on-fast-path retry (#145)
forceFullOnFastPath re-issued the NTV2 upload with tryFastUploadCompleted:
false to try to refresh a possibly-expired video resource. Real-machine
testing on a live account + RE of the QQ NT kernel (wrapper.node) proved it
never works:
- the server's fast-path is driven by md5-metadata existence and ignores
  the flag (proton omits a false bool anyway); the retry re-fast-paths
- the kernel itself does no validity check — HandleRspUploadV3 trusts the
  response's fileExist and goes straight to SendUploadSuccFTN

So an expired-but-still-indexed video resource is a platform-level
limitation, not something the upload flow can refresh. Removing the retry
drops two wasted OIDB round-trips per cached-video re-send. The underlying
#145 (receiver '资源已过期' for a server-expired resource) remains a
platform issue, tracked separately.

Tests updated to assert the single-request behaviour.
2026-06-29 15:32:57 +08:00
DrSmoothl 37f768dd33 fix(bridge): isolate process enumeration so a blocked native call can't freeze the loop (#158)
QQ's silent hot-update churns the process table; the watcher's synchronous
native getAllMainProcess() (/proc walk reading cmdline/maps) blocks while a
process is mid-exec(). Running on the main event loop, that froze the whole
bot — OneBot stopped responding, the watcher stopped ticking, the supervisor
saw it 'alive' so never restarted it (silent death until manual restart).

- run the native enumeration in a worker_threads worker, raced against a
  deadline on the main thread; a blocked call no longer stalls the loop
- on timeout/failure return an UNKNOWN sentinel (null); PipeWatcher keeps the
  prior process+pipe snapshot instead of treating missing data as 'all QQ
  gone' (which would dispose live sessions and tear down a healthy hook)
- graceful fallback to in-loop enumeration when the addon can't load in a
  worker or there's no addon (macOS); custom listers (tests) bypass the worker
- bounded orphan workers across a sustained hang

Tests: PipeWatcher null-sentinel (no false process-gone, recovers); enumerator
sync-fallback + broken-isolation paths. Native /proc-walk hardening rides in
the nnphook repo separately.
2026-06-28 23:30:10 +08:00
DrSmoothl 5dc3ec88d4 fix(webui): smooth sidebar collapse — no reflow, centered icons, pin button
The hover-rail flipped its content to a collapsed layout instantly while the
width animated, so on every expand/collapse icons jumped toward the top-left and
labels flashed in, wrapped super-tall, then settled — and the collapsed rail
leaked the first glyph of every label plus hard-clipped highlight bars.

Lay the rail content out at a fixed width and let the aside clip it: nothing
reflows during the width animation. Icons live in a fixed centered column (never
move); labels stay mounted with nowrap and just fade out when collapsed (no
wrap, no leak); the active pill and update banner render their background as an
absolute layer that becomes a centered squircle on the rail instead of a
hard-clipped bar.

Adds the promised pin toggle to the sidebar footer (desktop) — mirrors the
「钉住侧栏展开」 appearance setting: pinned keeps the rail open, unpinned returns
to hover-to-peek.
2026-06-28 23:29:25 +08:00
DrSmoothl 7ec74f2928 chore(release): v1.10.4 2026-06-28 22:48:00 +08:00
DrSmoothl 2b15bc6710 feat(webui): group Settings sidebar into 控制台 / SnowLuma / 平台
The flat tab strip mixed three domains — the dashboard itself, the all-accounts
SnowLuma protocol runtime, and the host/platform. Regroup into 3 labelled
sections (macOS System-Settings idiom): section headers show on the desktop
rail; the mobile strip stays a flat wrap via display:contents. Each group is a
role=group + aria-labelledby; the nav gains an aria-label.

This gives '全局配置' (and future global protocol knobs) a principled home and
ends the mixing. Phase 4 (final) of the global-settings split.
2026-06-28 22:25:39 +08:00
DrSmoothl 196f98e97c feat(webui): global-config settings tab + /api/global-config
Adds a '全局配置' Settings tab that edits config/snowluma.json — the rkey
fallback-server list and the deployment-wide musicSignUrl — backed by a new
bearer-gated GET/POST /api/global-config (load/saveGlobalSettings, then
oneBotManager.reloadGlobalSettings to hot-apply to live instances).

The panel reuses the established settings-panel idiom (Input/Button/Label,
debounced auto-save, flash); rkey servers are an inline add/remove list with
client-side http(s) validation hints. It deliberately does not reconcile inputs
from the server's normalized response (would erase a half-typed URL); a
load-failure shows an error+retry, and the save toast says '无效项已忽略' when
the server would drop an entry.

Removes the now-dead per-UIN musicSignUrl form (moved to global in Phase 2).
Phase 3 of the global-settings split (nav grouping is Phase 4).
2026-06-28 22:16:07 +08:00
DrSmoothl ea1c5ff9eb feat(onebot): migrate musicSignUrl from per-UIN to global config
musicSignUrl is a deployment-wide signing-service endpoint (same for every
account), so it belongs in the global config/snowluma.json next to rkey, not in
per-UIN onebot_<uin>.json.

- GlobalSettings gains musicSignUrl (http(s)-validated, '' = built-in default);
  consumption switches to globalSettings.musicSignUrl and hot-reloads via
  applyGlobalSettings.
- One-shot copy-up at boot (migrateGlobalSettings, called first in main()):
  lifts the first VALID legacy value from any config/onebot*.json into the
  global store. Idempotent via a key-PRESENCE guard, so an intentional
  clear-to-'' is never reverted by a stale legacy value.
- Remove musicSignUrl from per-UIN OneBotConfig (type + parse/serialize).

Phase 2 of the global-settings split. (WebUI form follows in Phase 3.)
2026-06-28 21:38:32 +08:00
DrSmoothl 659970077a refactor(onebot): move rkey fallback from per-UIN to a global config store
The rkey fallback endpoint is deployment infra (one self-hosted server serves
all accounts; fallback rkeys are fungible), so it doesn't belong in per-UIN
onebot_<uin>.json. Introduce config/snowluma.json — a generic home for small
all-accounts SnowLuma settings — and move rkey.fallbackServers there.

- New onebot/global-config.ts: load/save/normalize with section-merge + atomic
  write; http(s)+host URL validation; default [] (off). Lives in onebot (not
  core) because the onebot manager constructs instances and must not depend on
  core.
- OneBotInstance takes globalSettings; manager loads it on session start and
  exposes reloadGlobalSettings() to hot-push edits to every live instance via
  applyGlobalSettings → RKeyCache.setFallbackServers.
- Remove rkey from per-UIN OneBotConfig + the WebUI per-UIN round-trip (never
  released, so no migration). Register snowluma.json in the backup allowlist.

Reworks the per-UIN rkey from 12b0190. Phase 1 of the global-settings split.
2026-06-28 20:52:07 +08:00
DrSmoothl 5d39cf5782 fix(core): finalize c2c file upload so the recipient can download (#157)
Private (c2c) file sends uploaded the bytes (apply 0xE37_1700 + Highway)
and PbSendMsg returned ok, but the receiver saw "文件传输失败": the message
carried only FileExtra.file (metadata) and not the server-issued download
routing. Group files use a different element, hence c2c-only.

Port NapCat's upload→finalize→send sequence:
- add 0xE37_800 (FinalizeOfflineFile) OIDB service, run after the Highway
  PUT to fetch the download metadata (field30)
- extend FileExtra with field6 (PrivateFileExtra.field2) and populate it
  from that metadata in sendC2cFile; covers both the url-upload and the
  file_id-resend send paths (both funnel through sendC2cFile)

Tests: byte-oracle locks the 0xE37_800 request + FileExtra.field6 tags;
core routing test now asserts finalize runs and field6 is populated.
Needs real-machine verification (send a private file, confirm download).
2026-06-28 20:11:17 +08:00
DrSmoothl 12b01907bc feat(onebot): opt-in remote rkey fallback server (#156)
On accounts where the native OIDB 0x9067_202 rkey fetch persistently returns
nothing, every image was served as a bare multimedia.nt.qq.com.cn URL the CDN
rejects with invalid rkey — SnowLuma had no fallback, unlike NapCat.

Add config.rkey.fallbackServers (default []: OFF; no third-party server is
contacted unless the operator configures one). When the native fetch leaves the
cache without a usable image rkey, each configured HTTP endpoint is tried in
turn for { group_rkey, private_rkey, expired_time } (NapCat rkey-server format;
an OneBot { retcode, data } wrapper is also accepted), populating the group/
private image rkey types. Values keep any leading &rkey=/?rkey= since
resolveImageUrl strips before re-appending.

WebUI has no form yet but round-trips rkey.fallbackServers verbatim so a hand-
edited value survives an unrelated config save. Part B of #156 (cooldown is A).
2026-06-28 01:37:58 +08:00
DrSmoothl 219d60410a fix(onebot): prefer real url over unresolvable file id in media segments
When a bot framework echoes a received image/voice/video, it resends the
original `file=<md5>.ext` (a QQ-internal media id) together with the
download `url`. The parser collapsed sources as `file ?? url ?? ...`, so
`file` won and the send path statSync'd the id as a local path → ENOENT
(retcode 1200). Reported in #155 for images via Yunzai复读.

Add pickMediaSource(): keep `file` when it is directly loadable (inline
base64/data bytes, a remote url, or a path with a separator); otherwise
fall back to a sibling http(s) `url`. Applied to image/record/video.

Tests cover the #155 echo case plus the path/base64-wins guards.
2026-06-28 01:33:07 +08:00
DrSmoothl 182df4b0e2 fix(onebot): retry rkey fetch eagerly while cache holds none (#156)
When the OIDB 0x9067_202 rkey fetch returns empty or fails, the cache stays
empty and resolveImageUrl hands out a bare multimedia.nt.qq.com.cn URL the CDN
rejects with {retcode:-5503010, invalid rkey}. The 30s refresh cooldown then
blacked out every image for 30s+ after each empty/failed fetch.

Use a short RKEY_EMPTY_COOLDOWN (10s) whenever no usable image rkey is cached,
keeping the 30s steady-state backoff only once we actually hold one — so a
transient empty fetch self-heals in seconds instead of 30s. (Part A of #156;
remote-server fallback follows.)
2026-06-28 01:25:19 +08:00
DrSmoothl 6c2a3fc325 fix(ci): strip ANSI before matching TRACE in needs-logs bot
The needs-logs workflow tested /\bTRACE\b/i against the raw issue body.
Pasted terminal logs wrap the level in ANSI color codes (\x1b[90mTRACE\x1b[0m);
the trailing 'm' of the color code abuts 'TRACE', so the leading \b never
matches and a log that clearly contains TRACE is wrongly flagged needs-logs
(e.g. #156). Strip ANSI escapes first, and measure the too-short heuristic on
the cleaned text too.
2026-06-28 01:25:03 +08:00
DrSmoothl 5eaac0870c feat(webui): scale notification-channel opt-in to large lists
The per-account 上下线通知渠道 picker rendered every global channel as a
flat toggle row, so 40+ channels produced an unbounded, messy wall inside
通用设置. Rework it into a collapsible control:

- collapsed: one-line summary — selected count badge + selected channels
  as removable chips (cap 8, then "+N 更多")
- expanded: description + name/url search filter + 全选/清空 bulk actions
  + a height-bounded ScrollArea list (max-h-64) so it never blows up
- globally-disabled channels stay locked off; data flow unchanged

Reuses Badge / ScrollArea / ToggleSwitch / Input.
2026-06-28 01:23:12 +08:00
DrSmoothl 04591517fa feat(webui): redesign adapter edit dialog (Apple-HIG) + custom dropdown
Rework the OneBot adapter create/edit modal into an iOS grouped-list
layout matching the debug-page language: captioned soft-card sections,
a per-kind icon chip, settings rows, and staggered motion entrance.

- reportSelfMessage: segmented control -> shared ToggleSwitch
- messageFormat (+ WS role): segmented/native select -> new custom
  DropdownSelect (accessible listbox, keyboard nav, spring popover)
- enable toggle promoted to a prominent top card
- new components/ui/dropdown-select.tsx (zero new deps, hand-rolled)

Presentation-only; draft/validation/data flow unchanged.
2026-06-28 01:14:37 +08:00
DrSmoothl e1f5267764 fix(onebot): make group large-video file fallback actually fire
#153 added a video→file fallback but the group side was dead code: it
lived in element-builder behind `isGroup && element.fileId`, and a video
sent by url/path has no fileId, so oversize group videos just rethrew the
raw Highway error (real bot: `error_code=323` on a 1.4 GB group send).

Move the fallback to the OneBot layer for groups, mirroring the private
path: on a failed Highway send, re-route oversize videos through
groupFile.upload. Verified on a live bot — a 1.4 GB group video now lands
as `[文件:video.mp4]` instead of failing.

Extract the shared heuristic (isHighwaySizeError / videoNeedsFileFallback
/ splitVideoFileFallback) so private and group stay in sync, and drop the
now-unreachable element-builder video catch. Adds send-video-fallback
tests covering both the known-oversize and remote-error-heuristic paths.
2026-06-27 16:27:33 +08:00
stla 85182795b5 feat(protocol,onebot): raise video size limit with Highway-first fallback (#153)
Round 3: fix hardcoded 4096 MB in error messages
- Use MAX_VIDEO_SIZE_HARD constant instead of hardcoded 4096 MB
- Both throw sites now derive limit from the same constant
2026-06-27 16:10:25 +08:00
DrSmoothl 70fd40e34c chore(mcp): regenerate catalog after returnsSchema backfill 2026-06-26 23:08:38 +08:00
DrSmoothl 0e8a194d4f feat(onebot): backfill returnsSchema on remaining read-only actions (c2: source-proven only) 2026-06-26 23:05:52 +08:00
DrSmoothl d2490787ae feat(onebot): backfill returnsSchema on info/group-info read-only actions 2026-06-26 22:44:10 +08:00
DrSmoothl 0163af979a fix(ci): robust catalog sync (untracked-file guard, mkdir, rebase) + stale-catalog gate 2026-06-26 22:03:42 +08:00
DrSmoothl 75f14540c8 ci: sync action catalog.json to SnowLumaDocs via bot on release 2026-06-26 22:03:42 +08:00
DrSmoothl 5e0d2db8b2 feat(mcp): emit catalog.json twin + carry returnsSchema in catalog 2026-06-26 22:03:42 +08:00
DrSmoothl 18014fb3c5 feat(onebot): backfill returnsSchema on representative read-only actions 2026-06-26 22:03:42 +08:00
DrSmoothl 355cce1e52 feat(onebot): add optional returnsSchema to action doc metadata 2026-06-26 22:03:42 +08:00
游手好闲的言柒 04a9016a7d fix(onebot): strip at segments from private messages (#151)
* fix(onebot): support inline file url/path in send_group/private_msg

* test(onebot): update file segment parser test for inline url/path support

* fix(onebot): strip at segments from private messages
2026-06-26 21:08:01 +08:00
DrSmoothl cf7de9f7bd chore: update vite version to ^8.0.16 in pnpm-workspace.yaml 2026-06-26 19:59:58 +08:00
DrSmoothl 351db573d8 chore(release): v1.10.3 2026-06-26 19:49:41 +08:00
DrSmoothl 3b023b3275 fix(protocol): collapse ark/card messages structurally, not by string match (#146)
Follow-up to the previous #146 fix, which dropped QQ's compat fallback text by
content-matching the "版本不支持 … 升级" family. RE of the QQ kernel
(wrapper.linux.node) showed that is not how QQ does it:
- the receiver binary contains NONE of these fallback strings, so QQ cannot be
  matching by content;
- the on-target wire is [lightApp, text(compat), generalFlags, extraInfo] and the
  text element has no distinguishing marker (no pbReserve / attr6Buf);
- QQ NT's kernel codec (msg_codec_mgr) collapses the message to a single ark
  element — the sibling text is never emitted. NapCat maps kernel elements 1:1
  with no ark-aware skip yet still surfaces only the card, confirming the kernel
  drops it upstream.

Switch to that structural rule: when a lightApp/richMsg card is present, drop
sibling plain text outright. It is version/locale-proof (won't break when Tencent
rewords the string) and matches QQ NT + NapCat output exactly. @ mentions /
replies / faces are not plain text and survive; QQ clients can't attach a caption
to a card anyway, so no QQ-origin message loses real text.

Verified live on a logged-in build: get_msg returns a single json segment.
2026-06-26 18:31:43 +08:00
DrSmoothl 319c7d607d fix(protocol): drop QQ ark-compat fallback text beside mini-app cards (#146)
A QQ mini-program / ark share (B站 video, QQ 小程序, …) arrives on the wire
as a lightApp/richMsg ark element followed by a plain text element carrying
QQ's graceful-degradation compat string "当前QQ版本不支持此应用,请升级". That
text is meant only for protocol-old clients that can't render the card; QQ NT
and the kernel-backed bridges (NapCat) surface just the card.

On-target capture (test group) confirmed the text element has no distinguishing
wire marker (no pbReserve / attr6Buf) — it is a bare text sibling of the ark.
SnowLuma was decoding both, so every mini-app card came through as the card
plus a spurious "当前QQ版本不支持" text segment.

Drop the compat string when it sits beside a card, matched narrowly (the
"版本不支持 … 升级/更新" family, <=40 chars) so genuine user captions sent
alongside a card are preserved.

Verified live on a logged-in build: get_msg now returns a single json segment.
2026-06-26 18:08:26 +08:00
DrSmoothl 9de12db447 fix(onebot): align file-url actions with NapCat param tolerance (#147)
get_private_file_url required file_hash (and user_id), but NapCat needs
only file_id — it re-derives the hash from its composite file_id. Make
user_id and file_hash optional; the empty hash is accepted on the wire
(SnowLuma's private file_id already carries the md5 prefix). Verified
live: only-file_id returns a working URL (148237B download matches).

get_group_file_url rejected a present non-numeric busid with
`expected an integer` after the f.int().default(102) refactor, while
NapCat/LLBot omit busid entirely and ignore any value. Restore the
legacy `asNumber(busid) || 102` tolerance via f.raw()+busidOr102.
Verified live: null/"102"/omitted/"abc" all return a working URL.
2026-06-26 17:34:16 +08:00
DrSmoothl ecea72df40 test(onebot): expect forced full video upload retry on stale fast-path
The NTV2 fast-upload video test still asserted a single sendRawPacket
call, but #145 (forceFullOnFastPath) re-issues the OIDB request with
fast-upload disabled when a flagged main file carrying real bytes is
fast-pathed by the server. The stale assertion was never caught because
the dev push workflow doesn't run the suite — only the PR gate (ci.yml)
does, so it first surfaced on #149.

Assert two packets go out and that the second round drops
tryFastUploadCompleted, locking #145's behavior at the onebot layer.
2026-06-26 17:09:18 +08:00
DrSmoothl 214287da28 ci(issues): auto-close stale needs-logs reports
Daily stale sweep over issues still labeled needs-logs: mark stale after
7 days of inactivity, close (not_planned) after another 7. Issues that
get valid logs lose the needs-logs label and are skipped by only-labels.
PRs are excluded.
2026-06-26 17:05:50 +08:00
DrSmoothl 81969a4590 ci(issues): enforce TRACE logs on bug reports via github-script
Form validation can only force a field non-empty, not validate content.
On bug issue opened/edited, parse the TRACE-log section: if empty, missing
a TRACE marker, or too short, label needs-logs + post a one-time guide
comment; auto-remove the label once the reporter edits in valid logs.
2026-06-26 17:05:50 +08:00
DrSmoothl 860848a310 feat(issue-template): require TRACE-level logs in bug reports
- Make the logs textarea required so a bug report can't be submitted empty.
- Document how to enable TRACE: WebUI server log-level switch (+ '开发'
  preset so the export actually contains trace lines) or SNOWLUMA_LOG_LEVEL=trace.
- Add a required preflight checkbox attesting TRACE logs are attached.
2026-06-26 17:05:50 +08:00
Airline233 39338c3f1d fix(protocol,onebot): generate and send ark of contact cards correctly (#149) 2026-06-26 17:04:40 +08:00
Airline233 02de2acea4 fix(core): support file sources in forwarded messages (#144)
Pre-upload file/url/base64 segments for forwarded message nodes into the
target scope, re-upload cached file_ids when crossing private/group scope,
and suppress standalone file-message publishing during forward preparation.
2026-06-25 20:55:39 +08:00
DrSmoothl 3eba2a677c fix(highway): force full video upload when server fast-paths a stale resource (#145)
Group/c2c video resources expire server-side. When the bot re-sends a
video whose md5 the server already cached, the NTV2 OIDB returns no uKey
for the main file (fast-upload hit) and SnowLuma trusted it — the send
"succeeds" but the receiver sees 资源已过期, because the reused object had
expired. Confirmed by the reporter: same MP4 fast-paths and breaks, but
mutating one byte (new md5) forces a full upload and plays fine.

Add a per-sub `forceFullOnFastPath` flag on MediaSubFileUpload. When a
flagged sub-file (video main) carries real bytes but the server fast-paths
it, runNtv2Upload re-issues the OIDB request with tryFastUploadCompleted
disabled (proton drops a `false` bool, which the server reads as "no
fast-upload"), forcing a fresh resource + full PUT. The thumb stays
fast-path-OK so a cached thumb never re-pushes the main video; forwarding
paths carry no bytes so they never trigger the retry.

Also fixes the observability gap the reporter hit: the main file being
silently skipped on a fast-path now logs explicitly, and so does the
forced-retry, instead of only the thumb PUT showing up.

Note: the retry relies on the server treating an absent
tryFastUploadCompleted as "no fast-upload" — needs real-device
confirmation that the forced round actually returns a uKey.
2026-06-25 20:52:11 +08:00
stlaandSnowLuma Dev 78b0e1a124 feat(webui): auto-compress per-core density grid to fixed container (#140) (#141)
Replace fixed grid-cols-8 per-core bar display with dynamically
computed square grid that fills a fixed-height (80px) container.

- Adds bestGrid() utility that finds optimal (cols,rows) partition
  by minimizing empty cells and aspect balance
- Grid uses grid-template-rows: repeat(N, 1fr) for equal row height
- Empty cells filled with hidden divs for stable layout
- Preserves existing color tokens (bg-muted, bg-primary/70, rounded-sm)
- No scrollbar regardless of core count (tested up to 256)

Closes #140

Co-authored-by: SnowLuma Dev <dev@snowluma.local>
2026-06-25 20:46:16 +08:00