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({ 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 { expect(uin).toBe(12345); return 'u_peer_12345'; } override async sendRawPacket( serviceCmd: string, body: Uint8Array, ): Promise { expect(serviceCmd).toBe('MessageSvc.PbSendMsg'); this.capturedBody = body; return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ 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(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 { return uin === 10000 ? 'u_self' : `u_${uin}`; } override async sendRawPacket(cmd: string, body: Uint8Array): Promise { // 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>({ 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({ 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(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( 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 { return uin === 10000 ? 'u_self' : `u_${uin}`; } override async sendRawPacket(cmd: string, body: Uint8Array): Promise { // 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({ 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(bridge.capturedBody as Uint8Array); const fileExtra = protobuf_decode( 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 { expect(serviceCmd).toBe('MessageSvc.PbSendMsg'); this.capturedBody = body; return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.from( protobuf_encode({ 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(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( 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(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 { 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'); }); });