fix(forward): keep file segments in long-msg payload via forwardFake flag

Previous fix split file segments off at the OneBot live-send layer
(c2c → sendC2cFileMessage, group → sendGroupFileMessage) and made
element-builder drop any `{type:'file'}` segment that reached it.
That broke forward messages: send_forward_msg encodes each node
through buildSendElems for the long-msg upload, so file segments
inside a forward node hit the drop-with-warn path and the forward
bubble shipped without the file.

Forward upload is NOT a live PbSendMsg — it stores the gzipped
protobuf verbatim through `trpc.group.long_msg_interface.MsgService.
SsoSendLongMsg`, and the recipient decodes file entities through the
normal msg-push path (transElem(24) → GroupFileExtra; msgContent →
FileExtra { file: NotOnlineFile }). So `result=79` doesn't apply here.

Add a `forwardFake?: true` flag on SendContext; when set:
  * group file → element-builder emits transElem(24) again (restored
    makeGroupFileElem, exactly the receive-side wire shape).
  * c2c file → element-builder still skips; forward-builder pulls the
    segment off the node and writes `body.msgContent` as
    FileExtra { file: NotOnlineFile }, with file metadata hydrated
    from the upload cache when inline fields are missing (same
    pattern as send_private_msg).

Mirrors NapCat's PacketMsgFileElement.{buildElement,buildContent}
split (dev/napcatQQ/.../packet/message/element.ts:530-610) and
Lagrange.Core V2's MultiMsgUploadService.cs.
This commit is contained in:
DrSmoothl
2026-05-20 13:56:31 +08:00
parent cc574632f1
commit d1aade6f87
3 changed files with 335 additions and 28 deletions
+69 -4
View File
@@ -18,7 +18,7 @@ import type {
SendLongMsgReq,
SendLongMsgResp,
} from '../proto/proton/longmsg';
import type { PushMsg, PushMsgBody } from '../proto/proton/message';
import type { FileExtra, PushMsg, PushMsgBody } from '../proto/proton/message';
import { resolveSelfUid, toInt } from './shared';
// Module-scoped cache, keyed by res_id. Survives only for the lifetime
@@ -40,16 +40,67 @@ async function buildForwardPushBody(
// forward's recipient: group → groupId, private → recipient uid.
// Without this the OIDB upload (0x11c4/0x11c5) has no scene and the
// element builder throws "private image target uid is missing".
//
// `forwardFake: true` keeps `{type:'file'}` segments in the
// long-msg payload: group → emitted as transElem(24) in elems[],
// c2c → handled below via msgContent (FileExtra { file: NotOnlineFile }).
// The QQ-NT live-send pipeline would reject these (transElem(24) →
// result=79; notOnlineFile via PbSendMsg doesn't render), but the
// long-msg upload service stores the bytes verbatim and the
// receiver decodes them through the normal msg-push path.
const sendCtx = groupId !== undefined
? { bridge, groupId }
? { bridge, groupId, forwardFake: true }
: userUid
? { bridge, userUid }
: { bridge };
? { bridge, userUid, forwardFake: true }
: { bridge, forwardFake: true };
const elems = await buildSendElems(node.elements, sendCtx);
const now = Math.floor(Date.now() / 1000);
const random = Math.floor(Math.random() * 0x7fffffff) >>> 0;
const seq = Math.floor(Math.random() * 9000000) + 1000000;
// c2c-file in a forward node — element-builder intentionally skips
// it (RichText.notOnlineFile + msgContent live outside elems[]). For
// forward fake-send we mirror what an inbound c2c file message looks
// like on the wire: serialise the FileExtra blob and ride it on
// `body.msgContent`. The receiver's rich-body-decoder reads from
// `msgContent` first (see msg-push/rich-body-decoder.ts:446+), so
// the bubble renders with the right file name + size when the
// forward is expanded. Only the FIRST c2c file in a node ships —
// QQ NT's bubble can only carry one msgContent payload, same as
// a live c2c send. Group nodes ignore this branch entirely (the
// transElem(24) elems[] entry above covers them).
let msgContent: Uint8Array | undefined;
if (groupId === undefined) {
const fileEl = node.elements.find(e => e.type === 'file' && e.fileId);
if (fileEl) {
// Honour inline segment fields first; fall back to the upload
// metadata cache (populated by upload_private_file). Without
// size/md5 the receiver would render "0 B" so prefer recovering
// them — see same logic in modules/message-actions.ts.
const cached = bridge.recallUploadedFile(fileEl.fileId ?? '');
const fileMd5 = fileEl.md5Hex
? hexToBytesLocal(fileEl.md5Hex)
: (cached?.fileMd5 ?? new Uint8Array(0));
const fileSize = fileEl.fileSize ?? cached?.fileSize ?? 0;
const fileName = fileEl.fileName ?? cached?.fileName ?? 'file';
const fileHash = fileEl.fileHash ?? cached?.fileHash ?? '';
const sevenDays = 7 * 24 * 60 * 60;
msgContent = protobuf_encode<FileExtra>({
file: {
fileType: 0,
fileUuid: fileEl.fileId!,
fileMd5,
fileName,
fileSize: BigInt(fileSize),
subcmd: 1,
dangerEvel: 0,
expireTime: now + sevenDays,
fileHash,
},
});
}
}
return {
responseHead: {
fromUin,
@@ -70,10 +121,24 @@ async function buildForwardPushBody(
richText: {
elems,
},
...(msgContent ? { msgContent } : {}),
},
};
}
// Local hex-decode helper — avoids importing the heavyweight `pipeline`
// module just for one call. Mirrors `highway/pipeline.ts::hexToBytes`.
function hexToBytesLocal(hex: string): Uint8Array {
const s = hex.trim();
if (!s) return new Uint8Array(0);
const len = s.length >> 1;
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) {
out[i] = parseInt(s.substr(i * 2, 2), 16);
}
return out;
}
// Per-layer piggyback entry. Each level of a nested forward attaches
// its own msgBody under a uuid `actionCommand`, so when the receiver
// fetches the outermost res_id it gets every layer in one shot
+101 -24
View File
@@ -13,12 +13,11 @@ import type {
MentionExtraSend,
MarkdownData,
} from './proto/proton/action';
// `GroupFileExtra` + `hexToBytes` used to support `makeGroupFileElem`
// (removed — see comment above the former call site). The receive-side
// decoder in `msg-push/rich-body-decoder.ts` still pulls those in.
import type { GroupFileExtra } from './proto/proton/element';
import { uploadImageMsgInfo } from './highway/image-upload';
import { uploadPttMsgInfo } from './highway/ptt-upload';
import { uploadVideoMsgInfo } from './highway/video-upload';
import { hexToBytes } from './highway/pipeline';
type ProtoElem = Partial<ProtoDecoded<typeof ElemSchema>>;
@@ -26,6 +25,18 @@ export interface SendContext {
bridge: Bridge;
groupId?: number;
userUid?: string;
/**
* Set by the forward-message upload path. When `true`, file segments
* are encoded as receive-side wire shapes (group → `transElem(24)`,
* c2c → caller writes `body.msgContent` separately) instead of being
* dropped. The QQ-NT server rejects outgoing PbSendMsg with
* `transElem(24)` (result=79), but the long-msg upload service does
* NOT — it stores the gzipped protobuf verbatim, so the receiver
* can walk it back through the regular decoder. Mirrors NapCat's
* `PacketMsgFileElement.buildElement` + `buildContent` split (see
* `dev/napcatQQ/.../packet/message/element.ts:530-610`).
*/
forwardFake?: boolean;
}
function makeTextElem(text: string): ProtoElem {
@@ -247,12 +258,61 @@ async function makePttElem(ctx: SendContext, element: MessageElement): Promise<P
};
}
// (formerly `makeGroupFileElem` — removed. The `transElem(24)` shape it
// produced was a RECEIVE-side encoding only. The QQ-NT server rejects
// outgoing PbSendMsg with that element with `result=79`; group files
// publish through `OidbSvcTrpcTcp.0x6d9_4` instead, called from the
// OneBot layer at `modules/message-actions.ts::sendGroupMessage` after
// the file segment is split off.)
/**
* Receive-side group file element shape — `transElem(elemType=24,
* elemValue: 0x01 | BE16(len) | GroupFileExtra)`.
*
* NOT used for outgoing PbSendMsg (the QQ-NT server rejects that with
* result=79); the group-file send-side flow goes through dedicated
* OIDB 0x6d9_4 via `bridge.sendGroupFileMessage`. This shape IS used
* inside the long-msg upload (forward / multi-msg) — the long-msg
* service stores the gzipped protobuf verbatim, and the receiver
* decodes file entities from `transElem(24)` (see
* `msg-push/rich-body-decoder.ts:169-187`).
*/
function makeGroupFileElem(element: MessageElement): ProtoElem {
if (!element.fileId) throw new Error('file element missing fileId');
const fileSize = element.fileSize ?? 0;
const fileName = element.fileName ?? '';
const md5 = element.md5Hex ? hexToBytes(element.md5Hex) : new Uint8Array(0);
const sha1 = element.sha1Hex ? hexToBytes(element.sha1Hex) : new Uint8Array(0);
// Outer field1 is hardcoded to 6 in NapCat's encoder
// (`packet/message/element.ts:589`). Inner GroupFileExtraInfo carries
// busId=102 + the file_id + name + size + sha1/md5 hashes.
const extraBytes = protobuf_encode<GroupFileExtra>({
field1: 6,
fileName,
inner: {
info: {
busId: 102,
fileId: element.fileId,
fileSize: BigInt(fileSize),
fileName,
fileSha: sha1,
extInfoString: '',
fileMd5: md5,
},
},
});
if (extraBytes.length > 0xFFFF) {
// The 16-bit length prefix caps the payload at 64 KiB; even the
// densest GroupFileExtra (fileId/name/two hashes) is well under.
throw new Error(`group file extra too large (${extraBytes.length} > 65535)`);
}
const elemValue = new Uint8Array(3 + extraBytes.length);
elemValue[0] = 0x01;
elemValue[1] = (extraBytes.length >> 8) & 0xff;
elemValue[2] = extraBytes.length & 0xff;
elemValue.set(extraBytes, 3);
return {
transElem: {
elemType: 24,
elemValue,
} as any,
};
}
async function makeVideoElem(ctx: SendContext, element: MessageElement): Promise<ProtoElem> {
const isGroup = ctx.groupId !== undefined;
@@ -342,22 +402,39 @@ export async function buildSendElems(elements: MessageElement[], ctx?: SendConte
break;
case 'file':
// Files NEVER ride on the elems[] pipeline anymore — both flavors
// route at the OneBot layer:
// * c2c (private): split off and dispatched via
// `bridge.sendC2cFileMessage` (uses routingHead.trans0x211 +
// msgContent FileExtra; see bridge.ts::sendC2cFileMessage).
// * group: split off and dispatched via
// `bridge.sendGroupFileMessage` (uses OIDB 0x6d9_4; see
// actions/group-file.ts::sendGroupFileMessage).
// Files split into two routes depending on whether this is a
// live send or a forward-message fake-send:
//
// The old transElem(24) shape DOES work for receive-side decode
// (rich-body-decoder unpacks it into a FileEntity), but the
// QQ-NT server rejects it on outgoing with result=79. If a file
// segment still reaches us here the caller forgot to route at
// the OneBot layer — drop with a loud warn so it doesn't silently
// ship as "[空消息]" or a 0-byte stub.
console.warn('[ElemBuilder] BUG: {type:"file"} reached element-builder — must be split out at the OneBot layer (see modules/message-actions.ts::sendPrivateMessage / ::sendGroupMessage)');
// 1. LIVE send (PbSendMsg / OIDB 0x6d9_4) — never carried on
// elems[]. The OneBot layer splits file segments off before
// calling buildSendElems:
// * c2c (private): split → `bridge.sendC2cFileMessage`
// (routingHead.trans0x211 + msgContent FileExtra).
// * group: split → `bridge.sendGroupFileMessage`
// (OIDB 0x6d9_4 publish).
// The QQ-NT server REJECTS outgoing PbSendMsg with a
// transElem(24) (result=79), so an element reaching here
// in live-send mode is a routing bug — drop with a warn.
//
// 2. FORWARD-FAKE upload (long-msg) — `ctx.forwardFake===true`.
// The long-msg service stores the gzipped protobuf verbatim
// and the receiver decodes file entities from transElem(24)
// + msgContent FileExtra. Mirror NapCat's split (see
// `dev/napcatQQ/.../packet/message/element.ts:530-610`):
// * group → emit transElem(24) via makeGroupFileElem
// * c2c → emit NOTHING here; the forward-builder pulls
// the file segment off and writes msgContent
// separately (because RichText.notOnlineFile +
// FileExtra both live outside elems[]).
if (ctx?.forwardFake) {
if (ctx.groupId !== undefined) {
result.push(makeGroupFileElem(elem));
}
// c2c forwardFake intentionally falls through — handled by
// the forward-builder at the msgContent level.
} else {
console.warn('[ElemBuilder] BUG: {type:"file"} reached element-builder — must be split out at the OneBot layer (see modules/message-actions.ts::sendPrivateMessage / ::sendGroupMessage)');
}
break;
default:
@@ -0,0 +1,165 @@
// Regression coverage for the file-inside-send_forward_msg bug.
//
// Symptom: caller put `{type:'file', file_id}` inside a forward node;
// the element-builder dropped it (`[ElemBuilder] BUG: {type:"file"}
// reached element-builder — must be split out at the OneBot layer`)
// because the live-send paths route files through dedicated methods
// (sendC2cFileMessage / sendGroupFileMessage), and the forward
// builder was naïvely calling buildSendElems with the same ctx. The
// forward upload succeeded but the bubble shipped without the file
// element.
//
// Fix: forward upload passes `forwardFake: true` on the SendContext.
// The element-builder honours that flag by emitting the receive-side
// shapes:
// * group file → transElem(elemType=24, GroupFileExtra)
// * c2c file → handled at the forward-builder level, written into
// `body.msgContent` as FileExtra { file: NotOnlineFile }
//
// These shapes are RECEIVE-side: the QQ-NT live-send pipeline rejects
// transElem(24) (result=79), but the long-msg upload service stores the
// gzipped protobuf verbatim and the recipient's msg-push decoder pulls
// the file entity back out via the normal path (rich-body-decoder.ts).
// Mirrors NapCat's `PacketMsgFileElement.{buildElement,buildContent}`
// split.
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../src/bridge/bridge-oidb', () => ({
runOidb: vi.fn(async () => new Uint8Array()),
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
encodeOidbEnv: vi.fn(() => new Uint8Array()),
decodeOidbEnv: vi.fn(() => ({ body: {} })),
}));
// `vi.mock` factories are hoisted above the imports — declare the spy
// inside a `vi.hoisted` block so it's available at the same moment.
const { buildSendElemsMock } = vi.hoisted(() => ({
buildSendElemsMock: vi.fn(async () => []),
}));
vi.mock('../../src/bridge/element-builder', () => ({
buildSendElems: buildSendElemsMock,
}));
import { protoEncode } from '../../src/protobuf/decode';
import { SendLongMsgRespSchema } from '../../src/bridge/proto/longmsg';
import * as forward from '../../src/bridge/actions/forward';
import { mockBridge } from './_helpers';
function uploadResponseWithResId(resId: string) {
const encoded = protoEncode({ result: { resId } }, SendLongMsgRespSchema);
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(encoded),
};
}
describe('actions/forward — file segment inside forward node', () => {
it('group forward sets forwardFake:true on the SendContext so transElem(24) is emitted', async () => {
// Group case: the file element rides on elems[] as transElem(24).
// The element-builder receives ctx.forwardFake=true so it knows to
// emit the receive-side shape instead of dropping the segment.
buildSendElemsMock.mockClear();
const bridge = mockBridge({
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-grp-file')) as any,
});
await forward.uploadForwardNodes(bridge as any, [
{
userUin: 10001,
nickname: 'alice',
elements: [{ type: 'file', fileId: 'gfid-1', fileName: 'a.txt', fileSize: 99 } as any],
},
], 12345);
expect(buildSendElemsMock).toHaveBeenCalled();
const [, ctx] = buildSendElemsMock.mock.calls[0]!;
expect(ctx).toMatchObject({ groupId: 12345, forwardFake: true });
});
it('c2c forward writes the file as msgContent (FileExtra { file: NotOnlineFile })', async () => {
// Private case: c2c files live on `body.msgContent`, not in elems[].
// The forward-builder pulls the file segment off and synthesises a
// FileExtra payload so the recipient's decoder (which reads
// msgContent first) renders the file bubble.
buildSendElemsMock.mockClear();
let captured: Uint8Array | undefined;
const bridge = mockBridge({
sendRawPacket: vi.fn(async (_cmd: string, body: Uint8Array) => {
captured = body;
return uploadResponseWithResId('res-c2c-file');
}) as any,
// Inline fields cover everything we need; cache lookup must not be
// required for the path to work.
recallUploadedFile: vi.fn(() => undefined),
});
await forward.uploadForwardNodes(bridge as any, [
{
userUin: 10001,
nickname: 'alice',
elements: [{
type: 'file',
fileId: 'pfid-1',
fileName: 'invoice.pdf',
fileSize: 4096,
md5Hex: 'aabbccddeeff00112233445566778899',
fileHash: 'srv-hash-abc',
} as any],
},
], undefined, 67890);
expect(buildSendElemsMock).toHaveBeenCalled();
const [, ctx] = buildSendElemsMock.mock.calls[0]!;
expect(ctx).toMatchObject({ forwardFake: true });
expect(ctx.groupId).toBeUndefined();
// Spot-check that the upload body carries the file metadata. The
// payload is gzipped + protobuf-wrapped, so we just verify the
// raw filename string survived — that's enough to confirm the
// FileExtra branch ran (a "missing file" regression would drop
// these bytes entirely).
expect(captured).toBeDefined();
const flat = Buffer.from(captured!).toString('binary');
// The filename gets gzipped, so we can't substring-search; instead
// assert the body is large enough that the FileExtra wasn't elided.
// (A name-only forward with no msgContent runs ~80-100 bytes; with
// the FileExtra blob it's noticeably larger.)
expect(flat.length).toBeGreaterThan(80);
});
it('c2c forward falls back to the upload metadata cache when inline fields are missing', async () => {
// The OneBot caller often passes only `file_id` — the rest comes
// from the bridge's uploaded-file cache. This is the same
// hydration path send_private_msg uses; the forward builder must
// honour it too or every cached-only forward ships a 0 B bubble.
buildSendElemsMock.mockClear();
const recallUploadedFile = vi.fn((id: string) => id === 'pfid-cached' ? {
fileId: 'pfid-cached',
scope: 'private' as const,
userId: 67890,
fileName: 'cached-name.zip',
fileSize: 8192,
fileMd5: Buffer.from('ffeeddccbbaa99887766554433221100', 'hex'),
fileSha1: new Uint8Array(20),
fileHash: 'cached-hash',
rememberedAt: 0,
} : undefined);
const bridge = mockBridge({
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-cache')) as any,
recallUploadedFile,
});
await forward.uploadForwardNodes(bridge as any, [{
userUin: 10001,
nickname: 'alice',
elements: [{ type: 'file', fileId: 'pfid-cached' } as any],
}], undefined, 67890);
expect(recallUploadedFile).toHaveBeenCalledWith('pfid-cached');
});
});