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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user