SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,333 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user