send_group_msg / send_private_msg with a file segment that only has
file:// (no name/filename) used url.split('/').pop() as the upload
name, so percent-encoded CJK paths uploaded as
%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.xlsx. Derive the fallback through
guessFileNameFromUrl, which decodeURIComponent's the path basename
(invalid encodings are left as-is). An explicit name still wins.
Fixes#354
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
loadBinarySource issued a header-less fetch(source) for remote media.
Anti-hotlink / anti-bot front-ends reset such requests, surfacing as
undici `TypeError: fetch failed` (not a clean 403) — so quoting a
command and sending an image/GIF whose source is a guarded URL bounced
with send_group_msg retcode 1200 "fetch failed".
Mirror NapCat's download path: send a browser User-Agent on every
request and retry once with a Referer pointing at the resource when the
first attempt is refused (network error or 403/4xx). Size-limit
rejections are tagged noRetry so an oversized body isn't re-downloaded.
Also route set_group_notice's image fetch through loadBinarySource so it
inherits the same hardening (plus http/file/base64 handling).
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.
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`.
Add a repository .editorconfig and a root eslint.config.ts to standardize formatting and linting. Migrate packages/webui ESLint config to TypeScript (eslint.config.ts) with project-specific rules and tsconfigRootDir handling, and remove the old JS config. Update package.json to run ESLint directly (add lint and lint:fix) and add required ESLint/dev dependencies; also add jiti to packages/webui/package.json. Include a small formatting tweak to tsconfig.base.json.
Follow-up on the previous highway commit, addressing two real holes the
Copilot review caught:
* loadBinarySource HTTP path was buffering the entire response via
`await resp.arrayBuffer()` BEFORE length-checking. A server that omits
or understates `Content-Length` (chunked transfer, malicious upstream,
proxy stripping headers) bypassed the cap entirely. Switch to a
streaming reader: accumulate chunks, abort + reject as soon as the
running total exceeds `maxBytes`. The Content-Length precheck still
short-circuits the obvious case before we read a byte.
* video-upload `stageVideoSource` has its own local-path fast-path that
never went through `loadBinarySource`, so the 100 MB MAX_VIDEO_SIZE
guard ran AFTER `fs.readFileSync` had already allocated the entire
file. A 10 GiB local-file pointer would OOM the bot before the check
fired. Add an `fs.statSync` precheck on the local branch, and pass
`MAX_VIDEO_SIZE` explicitly when the source falls through to
`loadBinarySource` so the HTTP / base64 cap matches the on-disk one.
Update the DEFAULT_MAX_BINARY_SIZE comment to stop claiming video paths
are bounded by it — they're bounded by the smaller MAX_VIDEO_SIZE,
enforced inside video-upload.
Adds five regression tests:
- chunked streamed download (no Content-Length) over cap → throws
- Content-Length over cap → throws before reading body
- streamed download inside cap → returns full bytes
- local file over cap (statSync precheck) → throws
- base64 over cap → throws