Keep forwarded cards readable when previews contain unsupported characters
without changing the forwarded messages.
Reject invalid resource references and record affected preview fields for
troubleshooting.
Refs #467
Allow image replies in existing group temporary conversations while preserving passive-only restrictions. Missing, expired, or mismatched sessions remain rejected before media processing, and sending never creates or refreshes a session.
Fixes#471
Bot-built merged forwards left hollow NT images after long-msg fetch,
so get_forward_msg returned empty file/url. Decode now rebuilds identity
from md5, ignores stubs, and forward uploads keep a legacy image sibling.
C2C offline-file download receipts were reported as the peer sending a
file. Treat downloadFlag=2 as a receipt and drop it from the chat stream.
Prevent ordinary messages and forwarded nodes from combining video content with visible sibling segments or additional videos. Invalid combinations now fail before parsing side effects, media work, or bridge sends, while exact empty text placeholders remain compatible with a single video.
Cover group, private, temporary-session, CQ string, nested forward, cached forward, and protocol-builder paths. Preserve the existing standalone window-shake behavior and keep valid single-video messages unchanged.
Encode poke message segments as private window shake messages while keeping the dedicated send_poke action unchanged.
Restrict window shakes to a single ordinary private-chat segment. Reject group, temporary-session, mixed-message, and forwarded usage before identity lookup, media upload, or packet transmission.
Add full outbound request coverage, OneBot action regressions, and live private-chat verification for issue #259.
Closes#259
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.
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.
Multi-layer 合并转发 was uploading correctly but the outer's
LongMsgResult piggyback was *unreachable* by the receiver because
two things were missing:
1. The forward preview element was emitted as the old XML
`richMsg serviceID=35 m_resid=…` shape, which carries the
inner resId but has NO uniseq field.
2. The piggyback `actionCommand: <uuid>` slot generated a fresh
UUID that was never reflected into the preview either.
Result: QQ-NT receivers saw the inner preview's resid, scanned the
outer's actions[] for a matching uniseq, found none, and either
fell back to a fresh server fetch per layer or dropped the layer
entirely.
Cross-checked against:
- dev/Lagrange.Core/Lagrange.Core/Message/Entity/MultiMsgEntity.cs:43-115
- dev/NapCatQQ/packages/napcat-core/helper/forward-msg-builder.ts:52-122
- dev/NapCatQQ/packages/napcat-onebot/action/msg/SendMsg.ts:208-347
Both upstream impls use the modern `com.tencent.multimsg` LightApp
JSON whose `meta.detail.uniseq` is the round-trip key that links
the inner preview to the matching `actionCommand` in the outer's
LongMsgResult — no separate fetch needed.
Changes:
- `element-builder.makeForwardElem` now emits a deflated
`com.tencent.multimsg` LightApp JSON (LightAppElem, field 51)
instead of the XML RichMsg (field 12). `forwardUuid` on the
MessageElement is baked into `meta.detail.uniseq` and
`extra.filename`. Auto-generates a UUID when absent.
- `MessageElement.forwardUuid?: string` added for the round-trip.
- `forward.uploadRecursive` threads `inner.uuid` into the outer's
preview element AND uses the same uuid for the outer's
piggyback actionCommand — closing the receiver-side lookup loop.
- `forward.fetch` now walks ALL actions (not just `MultiMsg`),
indexes piggybacks by uuid, and pre-populates the cache for
each inner resId discovered in the outer's preview bubbles.
Follow-up `fetch(innerResId)` hits the cache instead of the wire.
- `rich-body-decoder` parses LightApp JSON; when `app ===
com.tencent.multimsg`, emits `{type: 'forward', resId,
forwardUuid}` so the OneBot layer can walk in. Legacy XML
serviceID=35 path stays as a fallback for mobile QQ senders.
Wire-byte tests cover:
- send: lightApp.data is 0x01-prefixed deflated JSON with the
right meta + extra fields
- send/nested: outer LongMsgResult carries `MultiMsg` AND a
uuid-keyed piggyback; the piggyback uuid matches the
preview's `meta.detail.uniseq` baked into the same outer
msgBody
- send/3-deep: outermost action[] has 1 + 2 entries (own
MultiMsg + 2 piggybacks hoisted from deeper levels)
- recv: lightApp `com.tencent.multimsg` → forward element with
both resId and forwardUuid; non-multimsg LightApp falls
through to {type:'json'}; legacy XML path still emits
{type:'forward'}.
641 tests pass (was 628 + 13 new); typecheck clean; lint clean.
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`.
The chat-publish step after upload_group_file used to ship a PbSendMsg
with a transElem(elemType=24) — but that wire shape is RECEIVE-side
only, and current QQ-NT rejects it on send with `result=79`. Lagrange.
Core V2 uses a dedicated `OidbSvcTrpcTcp.0x6d9_4` for the publish
(`GroupSendFileService.cs`); mirror that. Send_group_msg with a
`{type:'file', file_id}` segment now routes through the same path
after the OneBot layer splits file segments out (matching the c2c
file split pattern).
Separately, send_private_msg with just `{file_id}` was shipping a 0 B
file because the wire packet for c2c file send needs fileSize/fileMd5/
fileName/fileHash but the OneBot caller only echoes the file_id from
upload_private_file. Add a bounded (1024-entry, FIFO) bridge cache
populated at upload time so the send-message path can rehydrate the
full tuple; inline segment fields still take precedence so callers
that track the metadata themselves can bypass the cache. Parser now
also accepts `file_hash`/`fileHash` on file segments.
Also: file segments no longer get a `[空消息]` log line — the format
string now shows `[文件:<name-or-id>]`.
Two off-by-one bugs in the group-file chat-post element that combined
to make the QQ-NT server reject every group file send with result=79
(visible as "group file uploaded but chat post failed" in the logs):
1. Inner GroupFileInfo fields were shifted one tag left:
- fileSha was field 5, should be field 6
- extInfo was field 6, should be field 7
- fileMd5 was field 7, should be field 8
Field 5 is actually a uint32 placeholder (`field5` in both NapCat
and acidify). With the bug, our fileSha bytes were landing where
the server expected a varint and the rest of the message got
skipped as garbage.
2. Outer GroupFileExtra was missing the magic field1=6 + fileName
slots that NapCat's encoder writes
(`packet/message/element.ts:589`). Acidify's schema also models
them as required even though it serialises with defaults. The
current server build rejects sends without these slots populated.
Cross-referenced:
- NapCat `GroupFileExtraInfo` (component.ts:146-155) — matches our
new field numbers exactly
- acidify `GroupFileExtra.Inner.Info` (proto/message/extra/GroupFileExtra.kt)
— same field numbers; their schema uses generic `field5`/`field7`
names but the wire layout is identical
Changes:
- Proton + legacy schemas: shift inner fields, add outer field1/
fileName/display
- `makeGroupFileElem`: write field1=6 + fileName on the outer wrapper
- Regression test asserts each inner tag lands at 50/58/66 (not the
old 42/50/58) and verifies the new outer field1 + fileName bytes
are present
Private-chat video and voice sends were getting bounced by the QQ NT
server with `PbSendMsg result=79`. Highway upload logs said success,
but the final SendMsg the bot constructed used the group-scene tag
inside the outgoing commonElem, and the c2c routing path doesn't
recognise it.
The convention — verified against the receive-side decoder at
msg-push/rich-body-decoder.ts:261-330 which explicitly branches on the
pairs — is:
image : commonElem.businessType c2c=10 group=20
video : commonElem.businessType c2c=11 group=21
record : commonElem.businessType c2c=12 group=22
makeImageElem already honoured the split (`isGroup ? 20 : 10`).
makeVideoElem and makePttElem hardcoded the group value for both
scenes. Fix them to follow the same pattern.
Also fix a symmetric asymmetry inside the highway upload itself:
uploadVideoMsgInfo hardcoded `compatQmsgSceneType: 2` while
uploadImageMsgInfo and uploadPttMsgInfo both use `isGroup ? 2 : 1`.
Same root cause — group value leaking into the c2c path.
Regression test (tests/element-builder.test.ts) pins all six
businessType pairs by mocking the highway upload functions and
inspecting the proto element the builder returns; would have caught
the bug had it existed at the time element-builder.ts was written.