Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
|
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
|
import type { DatalineMsgBody, DatalineTextMsg } from '@snowluma/proto-defs/dataline';
|
|
import type {
|
|
SendMessageRequest,
|
|
SendMessageResponse,
|
|
SendRichText,
|
|
} from '@snowluma/proto-defs/action';
|
|
import type { FileExtra } from '@snowluma/proto-defs/message';
|
|
import { DATALINE_UIN_PHONE } from '@snowluma/protocol/dataline/device-contacts';
|
|
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
|
import type { OidbOfflineFileFinalizeResp } from '@snowluma/proto-defs/oidb-actions/media';
|
|
|
|
vi.mock('@snowluma/protocol/element-builder', () => ({
|
|
buildSendElems: vi.fn(async () => [{ text: { str: 'stub media elem' } }]),
|
|
encodePrivateRichText: vi.fn(async () =>
|
|
protobuf_encode<SendRichText>({ elems: [{ text: { str: 'stub media elem' } }] }),
|
|
),
|
|
}));
|
|
|
|
// Imported statically (not via `await import` inside each `it`) so the heavy
|
|
// Bridge dependency-tree load happens once at file collection — not against a
|
|
// single test's 5s timeout, which tipped over on slower CI runners. vi.mock is
|
|
// hoisted above these by vitest, so element-builder is still mocked.
|
|
import { Bridge } from '../src/bridge/bridge';
|
|
import { IdentityService } from '@snowluma/protocol/identity-service';
|
|
|
|
describe('Bridge private media routing', () => {
|
|
it('includes resolved uid in the final c2c send request for media messages', async () => {
|
|
class TestBridge extends Bridge {
|
|
capturedBody: Uint8Array | null = null;
|
|
|
|
override async resolveUserUid(uin: number): Promise<string> {
|
|
expect(uin).toBe(12345);
|
|
return 'u_peer_12345';
|
|
}
|
|
|
|
override async sendRawPacket(
|
|
serviceCmd: string,
|
|
body: Uint8Array,
|
|
): Promise<SendPacketResult> {
|
|
expect(serviceCmd).toBe('MessageSvc.PbSendMsg');
|
|
this.capturedBody = body;
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<SendMessageResponse>({
|
|
result: 0,
|
|
errMsg: '',
|
|
privateSequence: 88,
|
|
timestamp1: 1710000000,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
const bridge = new TestBridge(IdentityService.memory('10000'));
|
|
await bridge.apis.message.sendPrivate(12345, [
|
|
{ type: 'video', url: 'file:///tmp/clip.mp4' } as any,
|
|
]);
|
|
|
|
expect(bridge.capturedBody).toBeInstanceOf(Uint8Array);
|
|
const request = protobuf_decode<SendMessageRequest>(bridge.capturedBody as Uint8Array);
|
|
expect(request?.routingHead?.c2c).toMatchObject({
|
|
uin: 12345,
|
|
uid: 'u_peer_12345',
|
|
});
|
|
});
|
|
|
|
it('sendC2cFileMessage uses trans0x211 routing + msgContent FileExtra (NOT richText.notOnlineFile)', async () => {
|
|
// Regression: c2c file messages route through `trans0x211 { ccCmd:
|
|
// 4, uid }` (RoutingHead field 15), not `c2c { uin, uid }`. The
|
|
// file metadata lives in `MessageBody.msgContent` (a serialised
|
|
// `FileExtra { file: NotOnlineFile }`), not in `richText.notOnlineFile`.
|
|
// Confirmed against `dev/Lagrange.Core/.../MessagePacker.cs:
|
|
// BuildPacketBase` + `FileEntity.PackMessageContent`. Previous
|
|
// implementation wrote c2c routing + richText.notOnlineFile +
|
|
// c2cCmd=11 — the QQ-NT server rejected every c2c file send.
|
|
|
|
class TestBridge extends Bridge {
|
|
capturedBody: Uint8Array | null = null;
|
|
finalizeCmd: string | null = null;
|
|
|
|
override async resolveUserUid(uin: number): Promise<string> {
|
|
return uin === 10000 ? 'u_self' : `u_${uin}`;
|
|
}
|
|
|
|
override async sendRawPacket(cmd: string, body: Uint8Array): Promise<SendPacketResult> {
|
|
// The c2c file send first runs the 0xE37_800 finalize, then PbSendMsg.
|
|
if (cmd.includes('0xe37_800')) {
|
|
this.finalizeCmd = cmd;
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<OidbBase<OidbOfflineFileFinalizeResp>>({
|
|
body: {
|
|
body: {
|
|
metadata: {
|
|
field3: 11,
|
|
field100: new Uint8Array([1]),
|
|
field101: new Uint8Array([2]),
|
|
field110: 22,
|
|
timestamp1: 1710000123,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
this.capturedBody = body;
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<SendMessageResponse>({
|
|
result: 0,
|
|
errMsg: '',
|
|
privateSequence: 88,
|
|
timestamp1: 1710000000,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
}
|
|
const bridge = new TestBridge(IdentityService.memory('10000'));
|
|
|
|
const fileMd5 = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
|
await bridge.apis.message.sendC2cFile(67890, 'u_peer_xyz', {
|
|
fileId: 'uuid-abc-123',
|
|
fileName: 'doc.txt',
|
|
fileSize: 1024,
|
|
fileMd5,
|
|
fileHash: 'hash-xyz',
|
|
});
|
|
|
|
const request = protobuf_decode<SendMessageRequest>(bridge.capturedBody as Uint8Array);
|
|
|
|
// Routing: must be trans0x211 with ccCmd=4 + uid, no c2c slot.
|
|
expect(request?.routingHead?.trans0x211).toMatchObject({
|
|
ccCmd: 4,
|
|
uid: 'u_peer_xyz',
|
|
});
|
|
expect(request?.routingHead?.c2c ?? undefined).toBeUndefined();
|
|
|
|
// Body: msgContent carries the FileExtra; richText is absent (no
|
|
// elems, no notOnlineFile slot — both lived on the wrong place).
|
|
expect(request?.messageBody?.msgContent).toBeInstanceOf(Uint8Array);
|
|
expect(request?.messageBody?.richText ?? undefined).toBeUndefined();
|
|
|
|
// Decode the msgContent and verify the NotOnlineFile fields land
|
|
// at the correct tags (Lagrange's NotOnlineFile schema, not the
|
|
// dead FileExtraInfo one).
|
|
const fileExtra = protobuf_decode<FileExtra>(
|
|
request!.messageBody!.msgContent as Uint8Array,
|
|
);
|
|
expect(fileExtra?.file).toMatchObject({
|
|
fileUuid: 'uuid-abc-123',
|
|
fileName: 'doc.txt',
|
|
fileSize: 1024n,
|
|
fileHash: 'hash-xyz',
|
|
subcmd: 1, // server-required intake validator field
|
|
// dangerEvel and fileType are 0 — proto3 omits zero values on
|
|
// the wire, so they're not asserted here (matches Lagrange's
|
|
// behaviour: it sets `DangerEvel = 0` but protobuf-net also
|
|
// skips serialising default ints).
|
|
});
|
|
expect(fileExtra?.file?.fileMd5).toEqual(fileMd5);
|
|
// expireTime is now+7d; just sanity-check it landed (non-zero, plausible)
|
|
expect(fileExtra?.file?.expireTime).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
|
|
|
// issue #157: when the 0xE37_800 finalize succeeds, its download routing
|
|
// rides along in field6 (best-effort enrichment on top of the always-
|
|
// present NotOnlineFile).
|
|
expect(bridge.finalizeCmd).toBe('OidbSvcTrpcTcp.0xe37_800');
|
|
expect(fileExtra?.field6?.field2).toMatchObject({
|
|
fileUuid: 'uuid-abc-123',
|
|
fileName: 'doc.txt',
|
|
fileHash: 'hash-xyz',
|
|
selfUid: 'u_self',
|
|
destUid: 'u_peer_xyz',
|
|
field1: 22, // ← finalize metadata.field110
|
|
field6: 11, // ← finalize metadata.field3
|
|
timestamp1: 1710000123,
|
|
});
|
|
expect(fileExtra?.field6?.field2?.field7).toEqual(new Uint8Array([2])); // ← metadata.field101
|
|
expect(fileExtra?.field6?.field2?.field8).toEqual(new Uint8Array([1])); // ← metadata.field100
|
|
|
|
// c2cCmd is left at 0 / undefined — the old `c2cCmd=11` was a
|
|
// stale go-cqhttp value the QQ-NT server doesn't recognise on the
|
|
// c2c-file path.
|
|
expect(request?.contentHead?.c2cCmd ?? 0).toBe(0);
|
|
});
|
|
|
|
it('c2c file send survives a failing 0xE37_800 finalize — sends without field6 (#157 best-effort)', async () => {
|
|
class TestBridge extends Bridge {
|
|
capturedBody: Uint8Array | null = null;
|
|
|
|
override async resolveUserUid(uin: number): Promise<string> {
|
|
return uin === 10000 ? 'u_self' : `u_${uin}`;
|
|
}
|
|
|
|
override async sendRawPacket(cmd: string, body: Uint8Array): Promise<SendPacketResult> {
|
|
// Finalize fails (server error → OidbError). The send must NOT fail
|
|
// because of it.
|
|
if (cmd.includes('0xe37_800')) {
|
|
return {
|
|
success: false,
|
|
gotResponse: true,
|
|
errorCode: 9999,
|
|
errorMessage: 'finalize boom',
|
|
responseData: Buffer.alloc(0),
|
|
};
|
|
}
|
|
|
|
this.capturedBody = body;
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<SendMessageResponse>({
|
|
result: 0,
|
|
errMsg: '',
|
|
privateSequence: 88,
|
|
timestamp1: 1710000000,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
}
|
|
const bridge = new TestBridge(IdentityService.memory('10000'));
|
|
|
|
// Should resolve (not throw) despite the finalize error.
|
|
await bridge.apis.message.sendC2cFile(67890, 'u_peer_xyz', {
|
|
fileId: 'uuid-abc-123',
|
|
fileName: 'doc.txt',
|
|
fileSize: 1024,
|
|
fileMd5: new Uint8Array([1, 2, 3, 4]),
|
|
fileHash: 'hash-xyz',
|
|
});
|
|
|
|
// PbSendMsg still went out, carrying the file but NO field6.
|
|
expect(bridge.capturedBody).toBeInstanceOf(Uint8Array);
|
|
const request = protobuf_decode<SendMessageRequest>(bridge.capturedBody as Uint8Array);
|
|
const fileExtra = protobuf_decode<FileExtra>(
|
|
request!.messageBody!.msgContent as Uint8Array,
|
|
);
|
|
expect(fileExtra?.file?.fileUuid).toBe('uuid-abc-123');
|
|
expect(fileExtra?.field6 ?? undefined).toBeUndefined();
|
|
});
|
|
|
|
it('sends my-device text through trans0x211 instead of ordinary c2c', async () => {
|
|
class TestBridge extends Bridge {
|
|
capturedBody: Uint8Array | null = null;
|
|
|
|
override async sendRawPacket(
|
|
serviceCmd: string,
|
|
body: Uint8Array,
|
|
): Promise<SendPacketResult> {
|
|
expect(serviceCmd).toBe('MessageSvc.PbSendMsg');
|
|
this.capturedBody = body;
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(
|
|
protobuf_encode<SendMessageResponse>({
|
|
result: 0,
|
|
errMsg: '',
|
|
privateSequence: 88,
|
|
timestamp1: 1710000000,
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
const bridge = new TestBridge(IdentityService.memory('10000'));
|
|
await bridge.apis.message.sendPrivate(DATALINE_UIN_PHONE, [
|
|
{ type: 'text', text: 'hello phone' },
|
|
]);
|
|
|
|
const request = protobuf_decode<SendMessageRequest>(bridge.capturedBody as Uint8Array);
|
|
expect(request?.routingHead?.trans0x211).toMatchObject({
|
|
ccCmd: 7,
|
|
uid: 'u_Wcc5rknRRqRO8y5gxMD6sA',
|
|
toUin: 10000n,
|
|
});
|
|
expect(request?.routingHead?.c2c ?? undefined).toBeUndefined();
|
|
const body = protobuf_decode<DatalineMsgBody>(
|
|
request!.messageBody!.msgContent as Uint8Array,
|
|
);
|
|
expect(body?.subCmd).toBe(4);
|
|
expect(body?.header).toMatchObject({
|
|
srcAppId: 1,
|
|
dstAppId: 1001,
|
|
srcTerType: 1,
|
|
dstTerType: 2,
|
|
srcUin: 10000n,
|
|
dstUin: 10000n,
|
|
});
|
|
const text = protobuf_decode<DatalineTextMsg>(body!.generic!.buf as Uint8Array);
|
|
expect(new TextDecoder().decode(text!.items![0]!.text as Uint8Array)).toBe('hello phone');
|
|
});
|
|
|
|
it('rejects non-text my-device sends', async () => {
|
|
class TestBridge extends Bridge {
|
|
override async sendRawPacket(): Promise<SendPacketResult> {
|
|
throw new Error('should not send');
|
|
}
|
|
}
|
|
const bridge = new TestBridge(IdentityService.memory('10000'));
|
|
await expect(
|
|
bridge.apis.message.sendPrivate(DATALINE_UIN_PHONE, [
|
|
{ type: 'image', url: 'file:///tmp/a.png' } as any,
|
|
]),
|
|
).rejects.toThrow('only accepts text');
|
|
});
|
|
});
|