Commit Graph
33 Commits
Author SHA1 Message Date
DrSmoothl 7c0519f75f lint 2026-10-12 15:43:34 +08:00
DrSmoothl 44679bebd4 fix(protocol): keep the highway upload address
The session response address was discarded, so uploads used the default host.

Fixes #513
2026-10-06 15:20:24 +08:00
DrSmoothl d86e0fc7b5 fix(protocol): preserve sent media for message history
Keep media sent by the bot available for later history and forwarding,
including messages without a server echo.

Fixes #462
2026-10-04 14:00:03 +08:00
H3CoF6 368becd078 fix(protocol): don't send bytesGeneralFlags on private voice (#495)
Private-chat voice bubbles arrived without a waveform: the NapCat-derived
    14-byte c2c bytesGeneralFlags blob suppresses it. A/B testing that single
    field (everything else byte-identical) shows the receiver drops the shipped
    waveform while it is present and draws the bars as soon as it is gone, so c2c
    now sends no flags at all. Group voice keeps its 10-byte blob unchanged.
2026-09-30 13:55:47 +08:00
DrSmoothl 0cc06b6b5e feat(onebot): send saved custom faces with sticker presentation
Resolve saved stickers by resource ID or MD5, support group and private destinations with optional replies, and preserve presentation in upload and message metadata.

Fixes #468
2026-09-26 16:19:45 +08:00
DrSmoothl 0a6c5b7d2a fix(protocol): attach voice waveforms on outgoing records
Private-chat clients only draw the progress track from message metadata.
Compute amplitudes when uploading a record so PC and phone show the bar.
2026-09-13 22:21:57 +08:00
DrSmoothl b8b9be83c3 feat(core): stream flash-transfer uploads from disk
create_flash_task loaded every fileset file through loadBinarySource
and kept the bytes through hash, commit, and sliceupload, so a single
file over 1 GiB could not be sent. Stage to disk like group files,
stream MD5/SHA1/Sha1StateV, then POST 1 MiB chunks from FileChunkSource.

Do not reuse video sha1Blocks as Sha1StateV: an exact N-MiB file would
ship an extra un-finalized last-slice state. Local/HTTP sources use the
4 GiB group-file ceiling; inline base64 stays at 1 GiB because stage
still decodes it in RAM. Protocol order, seconds-pass, and multi-file
commit are unchanged.

Fixes #359
2026-08-13 18:34:27 +08:00
DrSmoothl 679f3b5b9f fix(highway): delay-retry 102902 instead of aborting the upload (#352)
upload_group_file of ~2 GiB files failed after a highway idle timeout
because the next decoded error_code=102902 was treated as a fatal
server reject. QQ's own BDH client (multi_uploader.cc NeedDelayRetry)
treats 102902 / 302902 as backpressure: wait, switch sender, and
re-PUT the same offset. Mirror that — drop the socket, wait 250 ms,
increment retryTimes, and retry the chunk up to 8 times. Definitive
codes such as 921 still fail immediately.

Fixes #352
2026-08-13 00:20:40 +08:00
DrSmoothl a67614fa09 feat(logging): complete runtime trace diagnostics
Complete byte-level diagnostics for network, upload, runtime lifecycle, authenticated write, and background operation paths.

Preserve streaming behavior, file-storage boundaries, and default log volume while making failures, retries, cancellation, and final outcomes independently diagnosable.

Add focused regression coverage for correlation, partial data, resource context ownership, and terminal-state accuracy.
2026-07-31 11:21:12 +08:00
DrSmoothl b4f5e9b71b fix(media): support base64 data URLs for uploads
Parse RFC 2397 base64 data URLs through the shared binary source classifier so inline media cannot fall through to local file handling.

Reuse the same parsing path for buffered and disk-staged uploads, reject malformed or oversized inputs explicitly, and add regression coverage for both paths.

Refs #236
2026-07-16 10:05:56 +08:00
DrSmoothl 01688c7567 fix(protocol): enforce message element contracts end to end
Replace the open message bag with a validated discriminated union and preflight complete messages before uploads or sends. Return BAD_REQUEST for invalid or unsendable segments instead of dropping fields, sending partial content, or silently changing private messages.
2026-07-10 13:47:06 +08:00
DrSmoothl 7ab91c81e9 perf(highway): single-connection pipelined upload — supersede #211 concurrency
The #211 concurrency attempt (c17fff6) was disproven on a live endpoint.
QQ's highway server requires blocks to arrive in offset order and rejects
an out-of-order PUT with error_code=102902 — ~20% failure under a real
account even after deferring the finalize block. Parallel connections
can't guarantee arrival order, so that approach is unshippable.

Replace it with a single persistent connection that PUTs blocks in strict
offset order, reusing the socket across the whole file via HTTP keep-alive:
- In order: the server never sees an out-of-order block, so no 102902.
- One warm connection: the TCP congestion window stays open across the
  file instead of paying slow-start on a fresh connection per 1 MiB block.
  That per-block cold start was the real throughput cap behind #211.

Adaptive to QQ's edge nodes FIN'ing between chunks (the reason #118 went
one-connection-per-chunk): on a transport failure, or a peer half-close
after a response, drop the socket, reconnect, and re-send the SAME block
(re-PUTting an offset is idempotent). Worst case degrades to one
connection per block — no worse than #118, and still strictly in order.

Also fixes a latent bug on the reconnect path, surfaced by review: a
response truncated mid-stream (header arrived, Content-Length unmet, then
FIN) was resolved as a partial frame and then fatally rejected by
unpackHighwayFrame OUTSIDE the retry loop, turning a recoverable mid-body
FIN into a failed upload. A truncated response now rejects as a retryable
transport failure so the block is re-sent. Added an idle read timeout so
a silent peer can't hang the upload forever (the persistent socket widens
that exposure).

Verified live (tempserver, repeated 48 MiB group-file uploads): 1
connection per file, 0 failures across dense repeats, ~1.4x throughput
over the old per-chunk-connection serial path in a low-RTT datacenter —
the win is larger on high-RTT home links where per-block slow-start hurt
most (the original #211 report). Unit tests cover keep-alive reuse,
FIN-after-each-response degradation, mid-stream reconnect+resume, body-
truncation retry, in-order exactly-once coverage, and close-once.
2026-07-09 13:55:25 +08:00
DrSmoothl c17fff6e2f perf(core): parallelize highway chunk uploads (#211)
Group/private file uploads (and all highway media) topped out around
500 KB/s regardless of link speed. Two costs stacked up in
uploadHighwayHttp: chunks were sent strictly one at a time (throughput
capped at one 1 MiB block per round-trip), and each block opened a fresh
connection that paid TCP slow-start from a cold congestion window — so a
1 MiB block finished before the window ramped and never reached line
rate.

Drive the (already offset-addressed, self-contained) blocks through a
small worker pool instead of a sequential loop. Highway frames carry
their own dataOffset/chunkMd5/fileMd5, so blocks are independent and the
server reassembles by offset; a handful of connections overlap the
round-trip stalls and ramp their windows together. Concurrency is kept
modest (4) to stay friendly to the edge nodes' rate limiting, and the
one-connection-per-block invariant from #118 is preserved — parallelism
just puts several of those short-lived connections in flight at once.

Correctness preserved and covered by new tests:
- offset claim is synchronous (no await between read and advance of
  nextOffset), so every byte range is uploaded exactly once — no skip,
  no duplicate, ragged final block correct;
- concurrent ChunkSource reads are race-free (positional FileHandle
  reads into per-call buffers; read-only Buffer subarrays);
- first permanent block failure aborts the whole upload and rejects;
  peers stop claiming new blocks and drain the one in flight;
- workers never reject, so Promise.all settles all in-flight
  reads/sockets before the source is closed exactly once — no
  close-during-read, no masked upload error;
- 0-byte source still opens zero connections; peak memory bounded to
  concurrency x ~1 MiB, never the whole file.
2026-07-09 01:20:26 +08:00
DrSmoothl e1e0c6b02f fix(video): zero-trust metadata on fast-upload group video
The width/height=0 → 文件已过期 fix only covered the normal upload path, which
ffprobes real dimensions from the video bytes. The fast-upload (fingerprint /
forward) path has NO bytes to probe, so every field it ships is caller-supplied
and untrusted — a video that arrived on the wire without metadata caches 0s, and
re-forwarding it then shipped width/height=0 (receiver renders 文件已过期) and
time=0 (every client renders 00:00).

Harden videoPayloadFromFingerprint so no degenerate value reaches the wire:
- width/height fall back with `||` (not `??`, so a cached 0 is replaced too);
- duration falls back the same way (0 → 1, never 00:00);
- the fallback cover is now a real 720x1280 image, and the fallback video/cover
  dimensions are derived FROM it (detectImageFormat), so the declared video size,
  the declared cover size and the actual cover pixels are the same numbers by
  construction — never a 1x1 image lying about its size.

Real cached values always win; c2c is unaffected (the wire builder forces 0 for
c2c). Verified on-target by forwarding a zeroed video (renders, not expired).
2026-07-05 18:10:05 +08:00
DrSmoothl ac20956076 fix(video): fall back to portrait dims on fast-upload group video
The width/height=0 → 文件已过期 fix only covered the normal upload path, which
ffprobes real dimensions. The fast-upload (fingerprint / forward) path has no
bytes to probe and takes dimensions from cached metadata, which is 0 when the
source video arrived on the wire without dimensions (e.g. forwarded from another
client that itself sent 0x0). Re-forwarding such a video to a group then shipped
0x0 and the receiver rendered a stale "expired" placeholder.

videoPayloadFromFingerprint now substitutes a neutral 720x1280 portrait when a
dimension is absent or zero (|| not ??, so a cached 0 is replaced too). Real
cached dimensions always win; c2c is unaffected (the wire builder forces 0 there
regardless). A forwarded dimensionless video now renders and plays instead of
showing 文件已过期; verified on-target by forwarding a zeroed-dim video.
2026-07-05 15:39:35 +08:00
DrSmoothl e83eb80f2c feat(video): stream video uploads from disk instead of buffering (phase 4)
Wire the streaming primitives into the video send path. `loadVideo` now stages
the source onto a local disk path (stageSourceToDisk — hardlink/download),
hashes it in one streaming pass (hashFileStreaming), and returns a `fileSource`
instead of a full in-memory buffer; the main video sub-file PUTs through a
`FileChunkSource`, so a multi-GiB video never holds more than one 1 MiB chunk in
RAM. The thumbnail and the fingerprint (noByteFallback) fast path stay
byte-based and unchanged.

`runPuts` now sizes its fast-path / fast-only checks off `fileSource ?? bytes`
and resolves the highway session before opening the file handle (the handle's
owner, uploadHighwayHttp, must be the next call so its finally-close always
runs). A size+mtime guard re-checks the staged file before the PUT so a source
mutated mid-send fails cleanly rather than shipping bytes that disagree with the
already-sent hashes. The staged temp is released in every exit path.

Output is byte-identical to the buffered path (the OIDB fileInfo md5/sha1/size
and the uploaded bytes are unchanged); the old readFileSync-based
stageVideoSource is removed. Tests cover the disk path (fileSource not bytes,
streamed hash == buffered hash), cleanup on upload failure, and empty sources.
2026-07-05 03:23:19 +08:00
DrSmoothl 0e4d958b5f feat(highway): add stageSourceToDisk + shared HTTP downloader (phase 3)
Stage any upload source onto a local disk path so the uploader can stream it
without buffering the whole file. `stageSourceToDisk(source, maxBytes)` unifies:
local file:// / raw path → hardlink into an owned stage dir (O(1); the inode
link pins the bytes against a caller-side unlink/retention), streamed-copy
fallback on any link() failure; http(s):// → stream to a .part file; base64://
→ maxBytes-checked decode to a .part file. Temps are finalized atomically
(fsync file → rename → fsync dir); a TTL reaper reaps crash-orphaned temps while
an in-use `active` set protects live uploads (a hardlink keeps the source's old
mtime, so the staged path is reserved in `active` before it appears on disk).

To avoid divergence, the existing `loadBinarySource` HTTP transport is extracted
into a shared `downloadHttp(source, name, maxBytes, makeSink)` engine (browser
UA, redirect, timeout, Referer retry, declared + streamed size caps, noRetry);
`loadBinarySource` now drives it with an in-memory sink — behavior unchanged.

Additive only — not yet wired into video/file uploads. Tests cover the
retention race (hardlink survives the original's deletion), the reaper's
active-set protection, size caps, symlink resolution, the copy fallback, the
Referer retry reusing the .part, and streamed-overflow .part cleanup.
2026-07-05 03:01:10 +08:00
DrSmoothl 7b91903f80 feat(highway): add streaming file hasher hashFileStreaming (phase 2)
Single-pass, disk-streaming replacement for the buffered
`computeHashes` + `computeVideoSha1Blocks` (+ offline-file head md5): reads the
file 1 MiB at a time so hashing a multi-GiB upload never buffers the whole file.
Reproduces the buffered helpers exactly — whole-file md5/sha1, the per-1 MiB
intermediate SHA1 state snapshots plus the finalized whole-file SHA1, and an
optional md5 over the first N bytes (`headLimit`, for `md510MCheckSum`).

Additive only — not wired into any caller yet; the old helpers stay in place.
Lands in a new leaf module to avoid a utils↔highway-client import cycle. A
byte-oracle test asserts identity vs the buffered functions across sizes around
the 1 MiB block boundary (empty, sub-block, exact multiples, ±1, multi-block +
tail) and head limits across chunk boundaries.
2026-07-05 02:37:56 +08:00
DrSmoothl 5324e2f014 refactor(highway): introduce ChunkSource for uploadHighwayHttp (phase 1)
Prep for streaming large media uploads from disk instead of buffering the
whole file in RAM. Add a `ChunkSource` abstraction — `BufferChunkSource`
(in-memory, byte-for-byte the old path) and `FileChunkSource` (disk-backed,
reads exact-length chunks via a FileHandle, loops on short reads, throws on
unexpected EOF) — and change `uploadHighwayHttp` to consume a `ChunkSource`
instead of a `Uint8Array`. The uploader now owns the source and closes it once
in `finally` (best-effort; never masks a primary upload error).

Pure structural refactor: no streaming is wired yet. All five call sites
(pipeline runPuts, group + private file upload, personal + group avatar) wrap
their bytes in `BufferChunkSource`, so behavior is unchanged. Adds a
byte-oracle test proving FileChunkSource ≡ BufferChunkSource across block
boundaries, plus short-read/EOF/empty-input coverage.
2026-07-05 02:29:13 +08:00
DrSmoothl 9a6c17cb27 fix(video): carry real width/height on group video sends (#145)
A group video uploaded with fileInfo width/height = 0x0 reaches receivers
as 文件已过期 even though the resource is fresh and downloadable: QQ-NT
clients (Android especially) can't lay out the video tile without real
dimensions, so they render the expired placeholder. iOS is lenient — it
shows the expired badge but still opens the video, which is exactly the
"iOS 显示过期可以点开、安卓过期点不开" split users reported.

Real-machine capture on tempserver confirmed a working QQ group video's
MsgInfo carries real dimensions (e.g. 296x640) while ours sent 0x0. Fill
width/height from the probed video for group sends; c2c stays at 0 because
the QQ-NT server rejects non-zero dimensions there with a schema mismatch
(no c2c regression coverage yet). Verified end-to-end: a fresh 11 MB group
video that rendered 已过期 now displays and plays.
2026-07-04 23:15:03 +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 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
tt-P607 118bcbe83c fix(protocol): one TCP connection per highway chunk
Highway 上传超过 1 MiB 的载荷(多 chunk 场景)会失败,OneBot 侧表现
为 retcode=1200 / `connection closed before response`。

根因:`uploadHighwayHttp` 用同一个 TCP socket,靠 HTTP keep-alive 把
每个 1 MiB chunk 依次 POST 出去。但 QQ highway 的边缘节点(以及链
路上的代理/NAT)经常在第一次响应之后立刻 FIN 关闭连接,第二个 chunk
的 POST 写到一个已被对端 close 的 socket 上,`readHttpResponseBody`
立刻收到 `close` 事件并抛出 `connection closed before response`。

为什么之前看起来像网络问题:≤1 MiB 的载荷(文本、PTT、小图)只发一
次 POST,永远不会触发;但凡跨过 `HIGHWAY_BLOCK_SIZE = 1 MiB`,第二
个 chunk 路径必然失败。改 Clash MTU、关 TUN、加直连规则都没用,因
为问题不在网络层。

改动:
- `uploadHighwayHttp`:每个 chunk 都新开一个 TCP 连接,`finally` 里
  `destroy()`。建连开销相对 1 MiB chunk 的传输时间可以忽略,行为也
  和 NapCat / Lagrange 在大文件上传时的连接生命周期一致。
- `readHttpResponseBody`:报错信息带上 `received=NB, headerSeen=
  true|false`,方便后续区分 “连接刚握手就被关” 和 “响应读到一半被
  截断” 两种情况。

测试:`packages/core/tests/highway/highway-client.test.ts` 三个用例
(0.5 MiB 单 chunk / 1.5 MiB 两 chunk —— 即本次 bug 复现 / 2.5 MiB
三 chunk),断言 `net.createConnection` 被调用的次数等于
`ceil(size / HIGHWAY_BLOCK_SIZE)`,并且每个用过的 socket 在下一次
建连之前都被 destroy。

线上验证:之前每次发 1 MB 以上图片必出 retcode=1200 的账号,打补丁
后通过 OneBot HTTP 已能正常发出图片。
2026-06-01 14:35:57 +08:00
手瓜一十雪 456086599c Normalize imports, typings and formatting
Reorder and normalize imports, add missing type imports, and fix whitespace/formatting across multiple files. Notable updates: eslint.config.ts import ordering; core bridge APIs (group-album, misc, web) now import Json types; tests/highway pipeline import ordering and added type imports; protocol changes (element-builder, highway client/pipeline) reorganized imports and added proto type imports; many oidb service modules moved protobuf encode/decode imports for consistency; web group modules (album, essence, honor, notice, request-util) reordered imports, adjusted typings/indentation, and small whitespace fixes. These edits are stylistic and typing improvements and should not change runtime behavior.
2026-05-24 13:39:35 +08:00
手瓜一十雪 80ee12ecc2 Enforce no-explicit-any; allow in tests
Enable @typescript-eslint/no-explicit-any as an error in the ESLint config to tighten typing across the codebase, and add an override to disable this rule for test files. Also apply non-functional cleanup in packages/proton/src/runtime.ts: reorder/imports and adjust formatting/indentation and string alignment for readability — no behavioral changes.
2026-05-24 13:38:13 +08:00
DrSmoothl c16064d7e3 refactor(packages): rename @snowluma/bridge → @snowluma/protocol
The package previously named "bridge" houses the protocol-byte layer
(OIDB cmds, msg-push decoders, highway uploaders, element-builder,
event bus). The name "bridge" suggested transport/lifecycle, which it
isn't — that role lives in core/hook and core/bridge/manager. Rename
to match what's actually inside.

This is a pure rename: zero logic changes, all 73 import sites updated
mechanically and TS-verified. core/onebot dependencies key updated.
ESLint override path adjusted for the moved oidb-services directory.

Rename detection threshold: git log --find-renames=20% recommended
when following history across this commit — content-replacement
(@snowluma/bridge → @snowluma/protocol) dropped per-file similarity
below git's default 50% on import-heavy files.
2026-05-23 23:31:03 +08:00
DrSmoothl 4755a91cb3 fix(image): use Chinese summary defaults to match QQ ecosystem
Mobile QQ, Lagrange.Core, and NapCat all use the literal Chinese
bubble-preview strings `[图片]` / `[动画表情]`. SnowLuma was emitting
English `[image]` / `[sticker]` as fallback, so QQ clients showed
"[image]" in chat-list previews instead of the expected Chinese.

Cross-checked against:
  dev/NapCatQQ/.../packet/message/element.ts:367-371
  dev/Lagrange.Core/.../Message/Entity/ImageEntity.cs:186-192

Both branch the same way: subType=1 → `[动画表情]`, else → `[图片]`.

Changes:
  - `bridge/highway/image-upload.ts`: both code paths (fingerprint +
    load-from-source) now emit the Chinese defaults. Subtype branch
    flipped to `subType === 1 ? '[动画表情]' : '[图片]'` to match the
    upstream convention (our old check was `subType === 0` →
    `[image]`, which would label any non-zero subType — not just
    stickers — as `[sticker]`).
  - `bridge/msg-push/rich-body-decoder.ts`: all three decode paths
    (NotOnlineImage / CustomFace / commonElem image) now substitute
    the Chinese default when the server omits `textSummary`. Empty-
    string is also treated as missing (matches NapCat's `=== ''`).
  - Tests updated to assert the Chinese defaults; new test pins
    that a caller-supplied summary still overrides verdef.

The OneBot caller's `summary` field on the MessageElement still
wins when set, so downstream clients passing custom bubble text
(e.g. for accessibility) keep working.
2026-05-23 15:05:28 +08:00
DrSmoothl c8f259ef4a test(highway): pin multi-image sequential semantics + richer 921 diag
User reported `highway upload error_code=921` when sending two images
in one OneBot message — image 1 fast-uploaded (server already had the
md5), image 2 (58 bytes) triggered the actual PUT and failed.

Verified the claim from code inspection vs NapCatQQ + Lagrange.Core:

  - dev/NapCatQQ/.../highwayContext.ts:62-94 (checkAvailable + prepareUpload)
  - dev/NapCatQQ/.../highwayHttpUploader.ts:8-43 (HTTP block upload)
  - dev/NapCatQQ/.../highwayContext.ts:219-254 (uploadGroupImage)
  - dev/Lagrange.Core/.../HighwayContext.cs:98-180 (UploadSrcByStreamAsync)

Findings:

  1. The pipeline runs `runNtv2Upload` sequentially per image (await
     inside `buildSendElems`); there's no concurrency or shared state.
  2. Each call gets its own OIDB request, its own lazy session fetch,
     its own buildHighwayExtend output. No aliasing.
  3. The 921 comes from the highway PUT's response head (errorCode),
     not from anything client-emitted being malformed.

Verdict: not reproducible from code inspection — almost certainly
server-side (most likely Tencent rejecting the 58-byte image as a
malformed payload; 58B is too small to even be a valid 1×1 PNG).

Hardening:

  - Three regression tests pin the sequential two-image semantics so
    a future state-leak bug fails loudly:
      - extend bytes for image 2 must come from image 2's response,
        not image 1's,
      - each runNtv2Upload fetches its own non-null session,
      - each OIDB request carries its own fileInfo + a unique
        clientRandomId.
  - The `error_code=N` message now carries segRetCode, cmdId, chunk
    size, offset, and the first 8 bytes of the file md5 — without
    these the user couldn't tell apart "image rejected" vs "session
    expired" vs "rate-limited" on future reports.

If the user reports the same 921 again, the new diagnostic message
should show enough server-side context to triage server-vs-client.
2026-05-23 13:06:09 +08:00
DrSmoothl f7a03ca05a refactor(bridge): extract protocol layer into @snowluma/bridge package
Phase 2 of the 4-package split (RoadMap #3). Moves ~35 files —
events / event-bus / identity-service / packet-pipeline / element-
builder / bridge-oidb / qq-info / msg-push/* / highway/* / web/* +
the bridge-side formatters — out of `packages/core/src/bridge/`
into the new `@snowluma/bridge` workspace package. Everything that
translates QQ wire bytes ↔ typed events/elements now lives there.

Why
---
- `packages/core/src/bridge/` was actually two layers: a low-level
  "protocol decoder + I/O" set (msg-push, highway, packet-pipeline,
  identity, element-builder) AND the Api-class host (bridge.ts +
  apis/*). The Api layer can't move without the protocol layer
  moving first, but the two had grown into one directory.
- Bridge-internal consumers (highway uploaders, OIDB envelope
  helper, element-builder, msg-push parsers) only ever needed
  `bridge.identity` + `bridge.sendRawPacket` + the upload-metadata
  cache. Splitting them out lets that minimal surface live in the
  bridge package as `BridgeContext` while the api-layer's extended
  context (with `apis: ApiHub`) stays in @snowluma/core.

What stays in @snowluma/core (apis layer):
  - bridge.ts          ── the god-class that composes the protocol
                          building blocks + mounts apis
  - bridge-interface.ts ── public façade for OneBot consumers
  - manager.ts          ── per-uin BridgeManager hub
  - apis/*              ── the 13 typed Api classes
  - bridge-context.ts (NEW) ── extends @snowluma/bridge's slim
                              BridgeContext with `apis: ApiHub` so
                              the 13 Api classes keep their typed
                              `this.ctx.apis.X.method()` access.

Layout
------
Moved via `git mv` (history preserved):
  events / event-bus / identity-service / packet-pipeline /
  element-builder / bridge-oidb / qq-info / msg-push/* /
  highway/* / web/*           → @snowluma/bridge/<sub>
  utils/event-format.ts        → @snowluma/bridge/format.ts

  (the last one renamed because the destination directory only has
  bridge-related files; `format.ts` reads cleaner than `event-format`.
  Listed as a rename in git's `RM` output — content unchanged.)

Layering fixes (functionality preserved, just type signatures)
--------------------------------------------------------------
- `element-builder.SendContext.bridge` → typed as `BridgeContext`
  instead of `Bridge`. Reads only `bridge.identity` and the
  upload-cache (both on BridgeContext); the concrete `Bridge`
  instance still passes via free upcast.
- `bridge-oidb.runOidb` signature widened from `Bridge` to
  `BridgeContext` — same reason.
- `highway/{highway-client,pipeline,image-upload,ptt-upload,
  video-upload}.ts` — same.
- `format.ts::formatReply` parameter typed as a new local
  `ReplyEventLookup` structural type (`{ findEvent(id): JsonObject |
  null }`) instead of `MessageStore` from @snowluma/onebot. The
  OneBot `MessageStore` is a structural superset; consumers pass
  it through unchanged.
- JSON value types moved to a new `@snowluma/common/json` subpath
  (consumed by format.ts) — `onebot/types.ts` re-exports them so
  external `import { JsonValue } from '<…>/onebot/types'` paths
  keep working.
- `UploadedFileMeta` interface moved from `core/src/bridge/bridge.ts`
  into `@snowluma/bridge/bridge-context.ts` — element-builder + the
  forward-builder both read the upload cache through BridgeContext,
  so the type has to live in @snowluma/bridge. `bridge.ts` keeps
  its re-export for back-compat.

Changes
-------
- New package `packages/bridge/` (package.json with per-file subpath
  exports mirroring legacy `core/src/bridge/<x>` layout 1:1,
  tsconfig.json, typecheck script).
- 35 `.ts` files moved via `git mv` (3 files counted by git as
  RM — rename + modify because Bridge → BridgeContext type swap).
- 150+ import sites rewritten across `packages/core/src` and
  `packages/core/tests`: `'../bridge/<x>'`, `'../../bridge/<x>'`,
  `'../src/bridge/<x>'`, `'@/bridge/web/<x>'`, and vitest
  `vi.mock(...)` literals all collapse to `@snowluma/bridge/<x>`.
- `@snowluma/core` depends on `@snowluma/bridge` (workspace:*).
- `@snowluma/bridge` depends on `@snowluma/common`,
  `@snowluma/proto-defs`, `@snowluma/proton`.
- `packages/core/src/utils/` directory removed (empty).

Verification
------------
- Full workspace `pnpm -r typecheck`: all 10 projects clean.
- core test suite: 552 passed / 552 total (pre-existing
  websocket-darwin-arm64.node addon failure unchanged — same
  baseline as the previous refactor commits).
- Vite build (with stubbed native binary): clean — 143 modules,
  2.84 MB bundle / 224 KB gzip, identical envelope to the
  pre-split build.

Phase 3 (next): OneBot + WebUI move into @snowluma/onebot. The
runnable `index.ts` stays in @snowluma/core as the application
entry, importing `OneBotManager` from `@snowluma/onebot`.
2026-05-21 08:41:34 +08:00
DrSmoothl 6c7e56d5a1 refactor(proto-defs): extract wire-protobuf definitions into standalone package
Move `packages/core/src/bridge/proto/proton/*.ts` (13 files, ~3.3k
LOC) into a new `@snowluma/proto-defs` workspace package. The proton
spike landed in the previous commit means bare-specifier imports
(`@snowluma/proto-defs/oidb`, `…/message`, …) now resolve correctly
through proton's vite plugin, so call-site replacement still fires.

Why
---
- Lets downstream consumers share the same wire
  definitions without copy-pasting them — multiple workspace packages
  can now depend on `@snowluma/proto-defs` directly.
- Trims `@snowluma/core`'s surface: the bridge no longer owns the
  protocol schema, only its consumers.
- Sets up future #5 (`message-parsing perf`) and #6 (`Api-on-ctx
  refactor`) — both want a thinner core.

Layout
------
Per-file subpath exports (mirrors the legacy `proto/proton/<sub>`
shape 1:1). The barrel `index.ts` intentionally skips `highway` and
`oidb-actions/media` because the two files share six NTV2-prefixed
type names — `export *` would silently drop one side. Subpath imports
are unambiguous, which is also why every call site in core uses them.

Changes
-------
- New package `packages/proto-defs/` (package.json, tsconfig.json,
  src/index.ts).
- 13 `.ts` files moved via `git mv` to preserve history.
- 44 import sites across 26 source files + 15 tests rewritten via sed
  from `./[…/]proto/proton/<sub>` → `@snowluma/proto-defs/<sub>`.
- `@snowluma/core` depends on `@snowluma/proto-defs` (workspace:*).

Verification
------------
- proto-defs typecheck: clean
- core typecheck: clean
- core test suite: 587 passed (same baseline as before the refactor;
  one failure is the pre-existing websocket native addon, unrelated)
- proton test suite: 99 passed (8 files including the new
  cross-package.test.ts from the spike)
- Full workspace `pnpm -r typecheck`: all 8 projects clean

Note: proton's `dist/index.js` MUST be rebuilt for the vite plugin to
pick up the bare-specifier resolution. The previous commit's source
change is only effective after `pnpm -F @snowluma/proton build`.
2026-05-20 21:31:30 +08:00
手瓜一十雪 5ea1c3a961 Remove bridge proto schemas and parity test
Delete proto schema modules under packages/core/src/bridge/proto (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts, oidb-action.ts, oidb.ts) and the related test packages/core/tests/proton-parity.test.ts. These files contained protobuf schema definitions for the bridge layer; remove them as part of a cleanup/refactor. Update any imports or references to these modules to their new locations or replacements.
2026-05-20 18:23:03 +08:00
DrSmoothl 0b8dc1f7af fix(video): populate NTV2 fileInfo.time with the real duration (receiver was rendering 00:00)
The NTV2 upload request for video carried `time: 0` on the main mp4
sub-file, mirroring NapCat's transformer. But NapCat lives on top of
the desktop QQ-NT client's IPC layer, which patches the duration into
the resulting MsgInfo before the wire send — we don't have that layer,
so the `time: 0` we passed in ended up baked into the `VideoFile.
fileTime` field of every video the bot sent. Receivers then rendered
every video bubble as "00:00".

Cross-checked acidify's `RichMediaUpload.kt::buildVideoUploadInfoList`
(2026-04 refactor) — they're a protocol-direct client too and write
`time = payload.videoDuration` for exactly this reason. We already
have `video.duration` from `loadVideo` (probed via ffprobe), so it's
a one-liner.

Width/height kept at 0 deliberately — acidify fills them, NapCat
zeros them, and our earlier comment recorded "QQ NT server treats
non-zero values here as schema mismatch on c2c sends". Out of scope
for this fix; revisit if a c2c regression surfaces.

Test asserts `fileInfo.time === 10` for the main sub-file (matches
FINGERPRINT.duration) and stays 0 on the thumb.
2026-05-19 22:20:38 +08:00
DrSmoothl 269cd6f081 refactor(core): extract NTV2 upload pipeline shared by image/ptt/video
The three highway uploaders — image-upload, ptt-upload, video-upload —
each implemented the same NTV2 envelope assembly, OIDB response decode,
error handling, and Highway PUT loop, with the only real per-format
differences being the file-info fields, the extBizInfo blob, and the
Highway command id. The duplicated boilerplate had drifted slightly
between files and any future NTQQ protocol change would have needed
parallel edits in three places.

New: packages/core/src/bridge/highway/pipeline.ts (249 lines).

  runNtv2Upload({ bridge, isGroup, targetIdOrUid, oidbCmd, serviceCmd,
                  requestId, businessType, uploadInfo, compatQmsgSceneType,
                  extBizInfo, uploads, label })
    -> awaited upload object

    Builds the full reqHead + upload envelope, sends through OIDB,
    decodes the response, runs the per-sub-file Highway PUTs (caching
    the Highway session across them — video uses two PUTs and now only
    fetches the session once), and surfaces a typed fastOnlyError when
    the server demands bytes but the caller passed a fingerprint.

  finalizeMediaMsgInfo(upload, defaultPic?) -> Uint8Array

    Encodes the outgoing commonElem MsgInfo. `defaultPic` is the
    image-only fallback: image fills bizType + textSummary when the
    server omits the pic ext-biz-info; ptt / video pass undefined.

  hexToBytes(hex), makeClientRandomId(): shared helpers used by every
  uploader's fingerprint payload + client-randomId generation.

MediaSubFileUpload models per-sub-file routing:
  - source: 'top'   -> upload.uKey + upload.ipv4s (main file)
  - source: N       -> upload.subFileInfos[N].uKey + .ipv4s (video thumb)
  - cmdId, bytes, md5, sha1, subFileIndex, fastOnlyError

Each upload file shrinks to its format-specific logic only:

  image-upload.ts:  237 -> 163  (-31%)
    format detection, picFormat mapping, pic/reserveTroop/reserveC2c
    extBizInfo, group/c2c command id choice, finalize with defaultPic.

  ptt-upload.ts:    300 -> 205  (-32%)
    silk transcoding, temp-file cleanup, NapCat-style bytesGeneralFlags
    (different per group/c2c), voiceFormat honouring.

  video-upload.ts:  584 -> 507  (-13%)
    Sha1StreamState (per-1MB streaming SHA1 for the main Highway PUT),
    ffmpeg-driven thumb extraction with FALLBACK_THUMB, the two-sub-file
    OIDB body (main mp4 + thumb jpg with subFileType 100), and the
    fastOnlyError asymmetry (main file has it; thumb always has bytes).
    Video stays the largest because its load step is genuinely the most
    work — the share-out gain here is in concentration, not line count.

Behavior preserved end-to-end. Same NTV2 OIDB cmd numbers (image
0x11C4/5, ptt 0x126E/D, video 0x11EA/9), same service-cmd strings,
same requestId (image=1, ptt=group:1/c2c:4, video=3), same
compatQmsgSceneType (image+ptt: isGroup?2:1; video: 2), same
NapCat-derived bytesGeneralFlags, same fastUpload fallback strings.

Adds 42 happy-path tests across 4 new files in tests/highway/ :

  pipeline.test.ts          22  envelope construction, response decode,
                                OIDB error paths, group vs c2c scene,
                                Highway PUT trigger + fast-path skip,
                                sub-file routing, session caching,
                                fastOnlyError fire / silent-skip,
                                finalize with/without defaultPic, helpers.
  image-upload.test.ts       7  group/c2c oidbCmd + cmdId mapping,
                                fingerprint payload shape, picFormat /
                                dimensions wiring, subType -> bizType.
  ptt-upload.test.ts         6  group requestId=1 / c2c requestId=4,
                                per-scene bytesGeneralFlags, voiceFormat
                                default and override, fingerprint
                                preconditions, finalize-without-pic.
  video-upload.test.ts       7  two-sub-file shape, thumb subFileInfos[0]
                                routing, fastOnlyError asymmetry,
                                uploadInfo[mp4+jpg] with subFileType 0+100,
                                FALLBACK_THUMB always has bytes.

Tests mock highway-client for transport and the pipeline module (for
per-uploader tests) so each suite hits exactly the contract it's
testing. Pipeline tests roundtrip a real OIDB envelope through
protobuf encode/decode to exercise the actual decode path.

285 tests pass (was 244). Same pre-existing macOS-arm64 websocket
addon failure, unrelated.
2026-05-12 17:46:56 +08:00