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.
209 lines
8.5 KiB
TypeScript
209 lines
8.5 KiB
TypeScript
// 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';
|
|
import { inflateSync } from 'zlib';
|
|
|
|
vi.mock('@snowluma/protocol/highway/image-upload', () => ({
|
|
uploadImageMsgInfo: vi.fn(async () => new Uint8Array([7, 8, 9])),
|
|
}));
|
|
vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({
|
|
uploadPttMsgInfo: vi.fn(async () => new Uint8Array([4, 5, 6])),
|
|
}));
|
|
vi.mock('@snowluma/protocol/highway/video-upload', () => ({
|
|
uploadVideoMsgInfo: vi.fn(async () => new Uint8Array([1, 2, 3])),
|
|
}));
|
|
|
|
import { buildSendElems } from '@snowluma/protocol/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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('element-builder / file element is no longer carried in elems[]', () => {
|
|
// Regression for the `result=79` class: previously the element-builder
|
|
// emitted a `transElem(elemType=24, ...)` for `{type:'file'}` segments
|
|
// and the QQ-NT server rejected the outgoing PbSendMsg with that wire
|
|
// shape. The fix moves group-file publishing onto a dedicated OIDB
|
|
// call (`OidbSvcTrpcTcp.0x6d9_4`), driven from the OneBot layer in
|
|
// `modules/message-actions.ts::sendGroupMessage` after the file
|
|
// segment is split off. The element-builder therefore must NOT emit
|
|
// any element for `{type:'file'}` anymore — if it does, the message
|
|
// ships with a transElem(24) payload and result=79 returns.
|
|
it('produces an empty elems[] for a {type:"file"} segment (must be split out at OneBot layer)', async () => {
|
|
const result = await buildSendElems(
|
|
[{
|
|
type: 'file',
|
|
fileId: 'fid-abc',
|
|
fileName: 'doc.txt',
|
|
fileSize: 123,
|
|
md5Hex: 'aabbccddeeff00112233445566778899',
|
|
sha1Hex: '0102030405060708090a0b0c0d0e0f1011121314',
|
|
} as any],
|
|
{ bridge: fakeBridge, groupId: 12345 },
|
|
);
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('element-builder / forward preview (com.tencent.multimsg LightApp)', () => {
|
|
// The forward preview is the bubble the recipient renders in chat
|
|
// before tapping to expand. It MUST be the modern LightApp /
|
|
// `com.tencent.multimsg` JSON (not the older `richMsg serviceID=35`
|
|
// XML) because nested forwards rely on `meta.detail.uniseq` to link
|
|
// each inner preview to the matching `actionCommand` piggyback on
|
|
// the outer's LongMsgResult — without uniseq the QQ-NT client has
|
|
// no way to walk the tree and has to re-fetch each inner resId.
|
|
function decodeLightApp(elem: any): unknown {
|
|
const data: Uint8Array = elem.lightApp.data;
|
|
expect(data[0]).toBe(0x01); // deflate prefix
|
|
const inflated = inflateSync(Buffer.from(data.subarray(1))).toString('utf8');
|
|
return JSON.parse(inflated);
|
|
}
|
|
|
|
it('emits a lightApp.data blob with prefix=0x01 (deflated JSON, not XML/serviceID=35)', async () => {
|
|
const [elem] = await buildSendElems(
|
|
[{ type: 'forward', resId: 'res-XYZ' } as any],
|
|
{ bridge: fakeBridge, groupId: 12345 },
|
|
);
|
|
expect(elem.lightApp).toBeDefined();
|
|
expect((elem as any).richMsg).toBeUndefined();
|
|
expect(elem.lightApp!.data![0]).toBe(0x01);
|
|
});
|
|
|
|
it('places resid + uniseq inside meta.detail (and uniseq matches extra.filename)', async () => {
|
|
const [elem] = await buildSendElems(
|
|
[{ type: 'forward', resId: 'res-XYZ', forwardUuid: 'fixed-uuid-1234' } as any],
|
|
{ bridge: fakeBridge, groupId: 12345 },
|
|
);
|
|
const json = decodeLightApp(elem) as any;
|
|
expect(json.app).toBe('com.tencent.multimsg');
|
|
expect(json.meta.detail.resid).toBe('res-XYZ');
|
|
expect(json.meta.detail.uniseq).toBe('fixed-uuid-1234');
|
|
// `extra` is a JSON string holding {filename, tsum}; filename must
|
|
// round-trip the same uniseq so the QQ-NT client links them.
|
|
const extra = JSON.parse(json.extra);
|
|
expect(extra.filename).toBe('fixed-uuid-1234');
|
|
});
|
|
|
|
it('autogenerates a uniseq when the element omits forwardUuid (flat forward — cosmetic)', async () => {
|
|
const [elem] = await buildSendElems(
|
|
[{ type: 'forward', resId: 'res-flat' } as any],
|
|
{ bridge: fakeBridge, groupId: 12345 },
|
|
);
|
|
const json = decodeLightApp(elem) as any;
|
|
expect(json.meta.detail.resid).toBe('res-flat');
|
|
// Auto-generated UUID — non-empty, non-trivial.
|
|
expect(json.meta.detail.uniseq).toMatch(/^[0-9a-f-]{36}$/i);
|
|
const extra = JSON.parse(json.extra);
|
|
expect(extra.filename).toBe(json.meta.detail.uniseq);
|
|
});
|
|
|
|
it('threads forwardSource / forwardSummary / forwardPrompt / forwardNews / forwardTSum verbatim', async () => {
|
|
const [elem] = await buildSendElems(
|
|
[{
|
|
type: 'forward', resId: 'r1', forwardUuid: 'u1',
|
|
forwardSource: 'alice和bob的聊天记录',
|
|
forwardSummary: '查看3条转发消息',
|
|
forwardPrompt: '[聊天记录]',
|
|
forwardNews: [{ text: 'alice: hi' }, { text: 'bob: hey' }],
|
|
forwardTSum: 3,
|
|
} as any],
|
|
{ bridge: fakeBridge, groupId: 12345 },
|
|
);
|
|
const json = decodeLightApp(elem) as any;
|
|
expect(json.meta.detail.source).toBe('alice和bob的聊天记录');
|
|
expect(json.meta.detail.summary).toBe('查看3条转发消息');
|
|
expect(json.meta.detail.news).toEqual([{ text: 'alice: hi' }, { text: 'bob: hey' }]);
|
|
expect(json.desc).toBe('[聊天记录]');
|
|
expect(json.prompt).toBe('[聊天记录]');
|
|
expect(JSON.parse(json.extra).tsum).toBe(3);
|
|
});
|
|
|
|
it('drops the forward element when resId is missing (preview unrenderable, fail open)', async () => {
|
|
// The dispatcher's `case 'forward': if (elem.resId) ...` short-
|
|
// circuits when resId is empty so a malformed segment from the
|
|
// OneBot client doesn't blow up the whole send. Receiver sees
|
|
// no forward bubble — same outcome as omitting the segment.
|
|
const out = await buildSendElems(
|
|
[{ type: 'forward' } as any],
|
|
{ bridge: fakeBridge, groupId: 12345 },
|
|
);
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|