fix(send): use c2c businessType for private video / record

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.
This commit is contained in:
DrSmoothl
2026-05-15 22:56:15 +08:00
parent 97ad2e22bb
commit 29f28d611f
3 changed files with 107 additions and 3 deletions
+8 -2
View File
@@ -208,11 +208,14 @@ async function makePttElem(ctx: SendContext, element: MessageElement): Promise<P
const msgInfo = await uploadPttMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
// commonElem.businessType is the QQ NT scene tag the receive-side
// decoder pairs with: 12=c2c, 22=group. Sending the group tag on a
// c2c message bounces with PbSendMsg result=79.
return {
commonElem: {
serviceType: 48,
pbElem: msgInfo,
businessType: 22,
businessType: isGroup ? 22 : 12,
} as any,
};
}
@@ -226,11 +229,14 @@ async function makeVideoElem(ctx: SendContext, element: MessageElement): Promise
const msgInfo = await uploadVideoMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
// commonElem.businessType is the QQ NT scene tag the receive-side
// decoder pairs with: 11=c2c, 21=group. Sending the group tag on a
// c2c message bounces with PbSendMsg result=79.
return {
commonElem: {
serviceType: 48,
pbElem: msgInfo,
businessType: 21,
businessType: isGroup ? 21 : 11,
} as any,
};
}
@@ -487,7 +487,11 @@ export async function uploadVideoMsgInfo(
subFileType: 100,
},
],
compatQmsgSceneType: 2,
// Scene tag the NTV2 upload registers under. image-upload and
// ptt-upload both differentiate (group=2, c2c=1); video was
// hardcoded to the group value, which contributed to the c2c
// send-back rejection (PbSendMsg result=79).
compatQmsgSceneType: isGroup ? 2 : 1,
extBizInfo: {
pic: { bizType: 0, textSummary: 'Nya~' },
video: { bytesPbReserve: new Uint8Array([0x80, 0x01, 0x00]) },
@@ -0,0 +1,94 @@
// Regression test for the c2c-vs-group businessType asymmetry that made
// private-chat video / record sends bounce with `send private message
// rejected: result=79`. The receive-side decoder
// (msg-push/rich-body-decoder.ts) explicitly treats the businessType
// pairs as:
//
// image : c2c=10, group=20
// video : c2c=11, group=21
// record : c2c=12, group=22
//
// `makeImageElem` always honoured this split (`isGroup ? 20 : 10`).
// `makeVideoElem` and `makePttElem` used to hardcode the group value
// for both scenes, so any c2c video / private voice send arrived at the
// QQ NT server with a businessType the c2c routing path did not
// recognise and got rejected with result=79.
import { describe, expect, it, vi } from 'vitest';
vi.mock('../src/bridge/highway/image-upload', () => ({
uploadImageMsgInfo: vi.fn(async () => new Uint8Array([7, 8, 9])),
}));
vi.mock('../src/bridge/highway/ptt-upload', () => ({
uploadPttMsgInfo: vi.fn(async () => new Uint8Array([4, 5, 6])),
}));
vi.mock('../src/bridge/highway/video-upload', () => ({
uploadVideoMsgInfo: vi.fn(async () => new Uint8Array([1, 2, 3])),
}));
import { buildSendElems } from '../src/bridge/element-builder';
const fakeBridge = {} as any;
function commonElem(elem: any): { serviceType: number; businessType: number; pbElem: Uint8Array } {
return elem.commonElem;
}
describe('element-builder / commonElem.businessType per scene', () => {
describe('image', () => {
it('c2c → businessType 10', async () => {
const [elem] = await buildSendElems(
[{ type: 'image', url: 'file:///tmp/a.png' } as any],
{ bridge: fakeBridge, userUid: 'u_peer' },
);
expect(commonElem(elem).serviceType).toBe(48);
expect(commonElem(elem).businessType).toBe(10);
});
it('group → businessType 20', async () => {
const [elem] = await buildSendElems(
[{ type: 'image', url: 'file:///tmp/a.png' } as any],
{ bridge: fakeBridge, groupId: 12345 },
);
expect(commonElem(elem).businessType).toBe(20);
});
});
describe('video', () => {
it('c2c → businessType 11 (regression: was 21, server returned result=79)', async () => {
const [elem] = await buildSendElems(
[{ type: 'video', url: 'file:///tmp/clip.mp4' } as any],
{ bridge: fakeBridge, userUid: 'u_peer' },
);
expect(commonElem(elem).serviceType).toBe(48);
expect(commonElem(elem).businessType).toBe(11);
});
it('group → businessType 21', async () => {
const [elem] = await buildSendElems(
[{ type: 'video', url: 'file:///tmp/clip.mp4' } as any],
{ bridge: fakeBridge, groupId: 12345 },
);
expect(commonElem(elem).businessType).toBe(21);
});
});
describe('record', () => {
it('c2c → businessType 12 (regression: was 22)', async () => {
const [elem] = await buildSendElems(
[{ type: 'record', url: 'file:///tmp/voice.amr' } as any],
{ bridge: fakeBridge, userUid: 'u_peer' },
);
expect(commonElem(elem).serviceType).toBe(48);
expect(commonElem(elem).businessType).toBe(12);
});
it('group → businessType 22', async () => {
const [elem] = await buildSendElems(
[{ type: 'record', url: 'file:///tmp/voice.amr' } as any],
{ bridge: fakeBridge, groupId: 12345 },
);
expect(commonElem(elem).businessType).toBe(22);
});
});
});