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,183 @@
|
||||
// Shared fixtures for the per-theme bridge-action tests.
|
||||
//
|
||||
// Each action test file mocks `../src/bridge/bridge-oidb` and (where
|
||||
// needed) `../src/bridge/highway/*` so we can assert what the action
|
||||
// asked the OIDB / Highway layer to do without booting a real Bridge.
|
||||
//
|
||||
// `mockBridge()` returns a minimal stand-in: enough state for the
|
||||
// actions to thread but no real packet/event machinery.
|
||||
//
|
||||
// As the #6 Api-on-ctx refactor moves business methods OFF Bridge and
|
||||
// onto `bridge.apis.<area>.method()`, the mock grows an `apis` block
|
||||
// matching the ApiHub shape. Each Api gets its own stub helper
|
||||
// (e.g. `mockMessageApi()`) and tests can override individual entries
|
||||
// via `mockBridge({ apis: { message: ... } })`.
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
|
||||
/** Default receipt returned by sendGroup / sendPrivate / sendC2cFile mocks. */
|
||||
const STUB_RECEIPT = { messageId: 1, sequence: 1, clientSequence: 0, random: 1, timestamp: 0 };
|
||||
|
||||
export interface MockMessageApi {
|
||||
sendGroup: ReturnType<typeof vi.fn>;
|
||||
sendPrivate: ReturnType<typeof vi.fn>;
|
||||
sendC2cFile: ReturnType<typeof vi.fn>;
|
||||
recallGroup: ReturnType<typeof vi.fn>;
|
||||
recallPrivate: ReturnType<typeof vi.fn>;
|
||||
markGroupRead: ReturnType<typeof vi.fn>;
|
||||
markPrivateRead: ReturnType<typeof vi.fn>;
|
||||
markAllRead: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockMessageApi(): MockMessageApi {
|
||||
return {
|
||||
sendGroup: vi.fn(async () => STUB_RECEIPT),
|
||||
sendPrivate: vi.fn(async () => STUB_RECEIPT),
|
||||
sendC2cFile: vi.fn(async () => STUB_RECEIPT),
|
||||
recallGroup: vi.fn(async () => undefined),
|
||||
recallPrivate: vi.fn(async () => undefined),
|
||||
markGroupRead: vi.fn(async () => undefined),
|
||||
markPrivateRead: vi.fn(async () => undefined),
|
||||
markAllRead: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockContactsApi {
|
||||
fetchFriendList: ReturnType<typeof vi.fn>;
|
||||
fetchGroupList: ReturnType<typeof vi.fn>;
|
||||
fetchGroupMemberList: ReturnType<typeof vi.fn>;
|
||||
fetchUserProfile: ReturnType<typeof vi.fn>;
|
||||
fetchGroupRequests: ReturnType<typeof vi.fn>;
|
||||
fetchDownloadRKeys: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockContactsApi(): MockContactsApi {
|
||||
return {
|
||||
fetchFriendList: vi.fn(async () => []),
|
||||
fetchGroupList: vi.fn(async () => []),
|
||||
fetchGroupMemberList: vi.fn(async () => []),
|
||||
fetchUserProfile: vi.fn(async () => ({ uid: 'profile-uid' })),
|
||||
fetchGroupRequests: vi.fn(async () => []),
|
||||
fetchDownloadRKeys: vi.fn(async () => []),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockGroupFileApi {
|
||||
upload: ReturnType<typeof vi.fn>;
|
||||
uploadPrivate: ReturnType<typeof vi.fn>;
|
||||
publish: ReturnType<typeof vi.fn>;
|
||||
trans: ReturnType<typeof vi.fn>;
|
||||
getCount: ReturnType<typeof vi.fn>;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
getUrl: ReturnType<typeof vi.fn>;
|
||||
getPrivateUrl: ReturnType<typeof vi.fn>;
|
||||
getPttUrl: ReturnType<typeof vi.fn>;
|
||||
getPrivatePttUrl: ReturnType<typeof vi.fn>;
|
||||
getVideoUrl: ReturnType<typeof vi.fn>;
|
||||
getPrivateVideoUrl: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
move: ReturnType<typeof vi.fn>;
|
||||
createFolder: ReturnType<typeof vi.fn>;
|
||||
deleteFolder: ReturnType<typeof vi.fn>;
|
||||
renameFolder: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockGroupFileApi(): MockGroupFileApi {
|
||||
return {
|
||||
upload: vi.fn(async () => ({ fileId: 'stub-fid' })),
|
||||
uploadPrivate: vi.fn(async () => ({ fileId: 'stub-pfid', fileHash: 'stub-hash' })),
|
||||
publish: vi.fn(async () => undefined),
|
||||
trans: vi.fn(async () => ({ saveBusId: 102, saveFilePath: '/stub-path' })),
|
||||
getCount: vi.fn(async () => ({ fileCount: 0, maxCount: 10000 })),
|
||||
list: vi.fn(async () => ({ files: [], folders: [] })),
|
||||
getUrl: vi.fn(async () => 'stub://url'),
|
||||
getPrivateUrl: vi.fn(async () => 'stub://private-url'),
|
||||
getPttUrl: vi.fn(async () => 'stub://ptt-url'),
|
||||
getPrivatePttUrl: vi.fn(async () => 'stub://private-ptt-url'),
|
||||
getVideoUrl: vi.fn(async () => 'stub://video-url'),
|
||||
getPrivateVideoUrl: vi.fn(async () => 'stub://private-video-url'),
|
||||
delete: vi.fn(async () => undefined),
|
||||
move: vi.fn(async () => undefined),
|
||||
createFolder: vi.fn(async () => undefined),
|
||||
deleteFolder: vi.fn(async () => undefined),
|
||||
renameFolder: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockApiHub {
|
||||
message: MockMessageApi;
|
||||
contacts: MockContactsApi;
|
||||
groupFile: MockGroupFileApi;
|
||||
// additional Apis added commit-by-commit as #6 progresses
|
||||
}
|
||||
|
||||
export function mockApiHub(overrides: Partial<MockApiHub> = {}): MockApiHub {
|
||||
return {
|
||||
message: overrides.message ?? mockMessageApi(),
|
||||
contacts: overrides.contacts ?? mockContactsApi(),
|
||||
groupFile: overrides.groupFile ?? mockGroupFileApi(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockBridge {
|
||||
identity: {
|
||||
uin: string;
|
||||
selfUid: string;
|
||||
nickname: string;
|
||||
findUidByUin: ReturnType<typeof vi.fn>;
|
||||
findUinByUid: ReturnType<typeof vi.fn>;
|
||||
resolveUin: ReturnType<typeof vi.fn>;
|
||||
findGroupMember: ReturnType<typeof vi.fn>;
|
||||
updateFriendRemark: ReturnType<typeof vi.fn>;
|
||||
updateGroupRemark: ReturnType<typeof vi.fn>;
|
||||
forgetGroup: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
events: { emit: ReturnType<typeof vi.fn> };
|
||||
apis: MockApiHub;
|
||||
sendRawPacket: ReturnType<typeof vi.fn>;
|
||||
fetchFriendList: ReturnType<typeof vi.fn>;
|
||||
fetchGroupMemberList: ReturnType<typeof vi.fn>;
|
||||
fetchUserProfile: ReturnType<typeof vi.fn>;
|
||||
resolveUserUid: ReturnType<typeof vi.fn>;
|
||||
// Uploaded-file metadata cache helpers — GroupFileApi.upload /
|
||||
// uploadPrivate call these to remember the upload, so tests
|
||||
// covering those code paths get a default-no-op shim.
|
||||
rememberUploadedFile: ReturnType<typeof vi.fn>;
|
||||
recallUploadedFile: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockBridge(overrides: Partial<MockBridge> = {}): MockBridge {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
identity: {
|
||||
uin: '10001',
|
||||
selfUid: 'self-uid',
|
||||
nickname: 'self-nick',
|
||||
findUidByUin: vi.fn(() => 'cached-uid'),
|
||||
findUinByUid: vi.fn(() => 0),
|
||||
resolveUin: vi.fn(async () => null),
|
||||
findGroupMember: vi.fn(() => null),
|
||||
updateFriendRemark: vi.fn(() => true),
|
||||
updateGroupRemark: vi.fn(() => true),
|
||||
forgetGroup: vi.fn(),
|
||||
...(overrides.identity ?? {}),
|
||||
} as MockBridge['identity'],
|
||||
events: overrides.events ?? { emit: vi.fn(async () => undefined) },
|
||||
apis: overrides.apis ?? mockApiHub(),
|
||||
sendRawPacket: vi.fn(async () => defaultResp),
|
||||
fetchFriendList: vi.fn(async () => []),
|
||||
fetchGroupMemberList: vi.fn(async () => []),
|
||||
fetchUserProfile: vi.fn(async () => ({ uid: 'profile-uid' })),
|
||||
resolveUserUid: vi.fn(async () => 'resolved-uid'),
|
||||
rememberUploadedFile: vi.fn(),
|
||||
recallUploadedFile: vi.fn(() => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GetPskey } from '@snowluma/protocol/oidb-services/web/get-pskey';
|
||||
import * as collectionWeb from '@snowluma/protocol/web/collection';
|
||||
import { CollectionApi } from '../../src/bridge/apis/collection';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/collection', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('obtains a Weiyun p_skey and fetches the collection list', async () => {
|
||||
const bridge = mockBridge();
|
||||
const expected = {
|
||||
errCode: 0 as const,
|
||||
errMsg: '',
|
||||
collectionSearchList: {
|
||||
collectionItemList: [],
|
||||
hasMore: false,
|
||||
bottomTimeStamp: '0',
|
||||
},
|
||||
};
|
||||
const ticketSpy = vi.spyOn(GetPskey, 'invoke').mockResolvedValue({
|
||||
domainPskeyMap: new Map([['weiyun.com', 'weiyun-ticket']]),
|
||||
});
|
||||
const listSpy = vi.spyOn(collectionWeb, 'getCollectionList').mockResolvedValue(expected);
|
||||
|
||||
const result = await new CollectionApi(bridge as never).list(4, 25);
|
||||
|
||||
expect(ticketSpy).toHaveBeenCalledWith(bridge, { domainList: ['weiyun.com'] });
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
uin: '10001',
|
||||
pskey: 'weiyun-ticket',
|
||||
category: 4,
|
||||
count: 25,
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('fails explicitly when QQ does not return a Weiyun p_skey', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.spyOn(GetPskey, 'invoke').mockResolvedValue({ domainPskeyMap: new Map() });
|
||||
const listSpy = vi.spyOn(collectionWeb, 'getCollectionList');
|
||||
|
||||
await expect(new CollectionApi(bridge as never).list(0, 50)).rejects.toThrow(
|
||||
'QQ did not return a p_skey for weiyun.com',
|
||||
);
|
||||
expect(listSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,777 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type {
|
||||
OidbBase,
|
||||
OidbEmpty,
|
||||
OidbFriend,
|
||||
OidbFriendCategory,
|
||||
OidbRobotUinRangeResponse,
|
||||
OidbSvcTrpcTcp0x88D_0Response,
|
||||
OidbSvcTrpcTcp0xFD4_1Response,
|
||||
OidbSvcTrpcTcp0xFE5_2Response,
|
||||
OidbSvcTrpcTcp0xFE7_3Response,
|
||||
} from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbFriendListRequest,
|
||||
OidbRobotUinRangeRequest,
|
||||
OidbSetFriendCategoryRequest,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { ContactsApi } from '../../src/bridge/apis/contacts';
|
||||
|
||||
interface ObservedGroupRequestUserByUin {
|
||||
uin?: pb<1, uint_32>;
|
||||
name?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestUserByUid {
|
||||
uid?: pb<1, string>;
|
||||
name?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestGroup {
|
||||
groupUin?: pb<1, uint_32>;
|
||||
groupName?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequest<TUser> {
|
||||
sequence?: pb<1, uint_64>;
|
||||
eventType?: pb<2, uint_32>;
|
||||
state?: pb<3, uint_32>;
|
||||
group?: pb<4, ObservedGroupRequestGroup>;
|
||||
target?: pb<5, TUser>;
|
||||
invitor?: pb<6, TUser>;
|
||||
operatorUser?: pb<7, TUser>;
|
||||
comment?: pb<10, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestResponse<TUser> {
|
||||
requests?: pb_repeated<1, ObservedGroupRequest<TUser>>;
|
||||
}
|
||||
|
||||
function friend(
|
||||
uin: number,
|
||||
categoryId: number,
|
||||
uid: string,
|
||||
nickname: string,
|
||||
remark = '',
|
||||
): OidbFriend {
|
||||
return {
|
||||
uin,
|
||||
uid,
|
||||
customGroup: categoryId,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: [
|
||||
{ code: 20002, value: Buffer.from(nickname) },
|
||||
{ code: 103, value: Buffer.from(remark) },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function category(
|
||||
categoryId: number,
|
||||
categoryName: string,
|
||||
memberCount: number,
|
||||
sortId: number,
|
||||
): OidbFriendCategory {
|
||||
return { categoryId, categoryName, memberCount, sortId };
|
||||
}
|
||||
|
||||
function packet(body: OidbSvcTrpcTcp0xFD4_1Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function robotRangePacket(body: OidbRobotUinRangeResponse): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbRobotUinRangeResponse>>({ body })),
|
||||
};
|
||||
}
|
||||
|
||||
function memberListPacket(body: OidbSvcTrpcTcp0xFE7_3Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupListPacket(body: OidbSvcTrpcTcp0xFE5_2Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE5_2Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupDetailPacket(body: OidbSvcTrpcTcp0x88D_0Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupRequestPacketByUin(
|
||||
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>,
|
||||
): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>>>({
|
||||
body,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupRequestPacketByUid(
|
||||
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>,
|
||||
): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>>>({
|
||||
body,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function apiForPages(pages: OidbSvcTrpcTcp0xFD4_1Response[]) {
|
||||
let index = 0;
|
||||
const sendRawPacket = vi.fn(
|
||||
async (_cmd: string, _body: Uint8Array): Promise<SendPacketResult> => {
|
||||
const page = pages[index++];
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`unexpected friend-list page ${index}`);
|
||||
}
|
||||
|
||||
return packet(page);
|
||||
},
|
||||
);
|
||||
const rememberFriends = vi.fn();
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberFriends },
|
||||
} as any);
|
||||
return { api, sendRawPacket, rememberFriends };
|
||||
}
|
||||
|
||||
function apiForCategoryChange(roster: OidbSvcTrpcTcp0xFD4_1Response) {
|
||||
const sendRawPacket = vi.fn(
|
||||
async (command: string, _body: Uint8Array): Promise<SendPacketResult> => {
|
||||
if (command === 'OidbSvcTrpcTcp.0xfd4_1') {
|
||||
return packet(roster);
|
||||
}
|
||||
|
||||
if (command === 'OidbSvcTrpcTcp.0x1255_0') {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0x1255,
|
||||
subCommand: 0,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { uin: '10001', rememberFriends: vi.fn() },
|
||||
} as any);
|
||||
return { api, sendRawPacket };
|
||||
}
|
||||
|
||||
describe('apis/contacts / group roster', () => {
|
||||
it('maps QQ group remarks into the returned and remembered roster', async () => {
|
||||
const rememberGroups = vi.fn();
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Project',
|
||||
memberCount: 42,
|
||||
memberMax: 500,
|
||||
shutUpAllTimestamp: 4_294_967_295,
|
||||
},
|
||||
customInfo: { remark: ' My Project ' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups },
|
||||
} as any);
|
||||
|
||||
const groups = await api.fetchGroupList();
|
||||
|
||||
expect(groups).toEqual([
|
||||
expect.objectContaining({
|
||||
groupId: 123456789,
|
||||
groupName: 'Project',
|
||||
remark: ' My Project ',
|
||||
memberCount: 42,
|
||||
memberMax: 500,
|
||||
allMuted: true,
|
||||
}),
|
||||
]);
|
||||
expect(rememberGroups).toHaveBeenCalledWith(groups);
|
||||
});
|
||||
|
||||
// #490: `memo` prefers the announcement, so a group with both fields set used
|
||||
// to lose its description entirely. The description must stay readable.
|
||||
it('keeps the group description readable when an announcement is also set (#490)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Both Set',
|
||||
description: '简介正文',
|
||||
announcement: '公告正文',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const [group] = await api.fetchGroupList();
|
||||
|
||||
expect(group?.memo).toBe('公告正文');
|
||||
expect(group?.description).toBe('简介正文');
|
||||
});
|
||||
|
||||
it('reports the description for a group that has no announcement (#490)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: { groupName: 'Description Only', description: '只有简介' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const [group] = await api.fetchGroupList();
|
||||
|
||||
// memo still falls back to the description (unchanged pre-#490 behaviour).
|
||||
expect(group?.memo).toBe('只有简介');
|
||||
expect(group?.description).toBe('只有简介');
|
||||
});
|
||||
|
||||
it('leaves the description unset on a non-member detail lookup (#490)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupDetailPacket({
|
||||
groupInfo: {
|
||||
uin: 123456789n,
|
||||
results: { name: 'Not Joined', noticePreview: '公告预览' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
|
||||
|
||||
const detail = await api.fetchGroupDetail(123456789);
|
||||
|
||||
// 0x88D_0 has no description tag, so there is nothing to expose — the field
|
||||
// must stay absent rather than echo the announcement preview.
|
||||
expect(detail?.memo).toBe('公告预览');
|
||||
expect(detail?.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps the group-wide mute timestamp from a single-group detail', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupDetailPacket({
|
||||
groupInfo: {
|
||||
uin: 123456789n,
|
||||
results: {
|
||||
name: 'Muted Group',
|
||||
shutUpAllTimestamp: 4_294_967_295,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
|
||||
|
||||
const detail = await api.fetchGroupDetail(123456789);
|
||||
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
groupId: 123456789,
|
||||
groupName: 'Muted Group',
|
||||
allMuted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a past group-mute expire as unmuted (#356)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Was Muted',
|
||||
shutUpAllTimestamp: 1_700_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const groups = await api.fetchGroupList();
|
||||
expect(groups[0]?.allMuted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / group requests', () => {
|
||||
it('maps the native numeric-account response into directly actionable requests', async () => {
|
||||
const rememberGroupRequests = vi.fn();
|
||||
const findUidByUin = vi.fn((uin: number) =>
|
||||
uin === 1_234_567_890 ? 'cached_target_uid' : null,
|
||||
);
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupRequestPacketByUin({
|
||||
requests: [
|
||||
{
|
||||
sequence: 1_785_525_232_784_291n,
|
||||
eventType: 1,
|
||||
state: 1,
|
||||
group: { groupUin: 1_095_186_374, groupName: 'group' },
|
||||
target: { uin: 1_234_567_890, name: 'requester' },
|
||||
invitor: { uin: 2_345_678_901, name: 'inviter' },
|
||||
operatorUser: { uin: 3_456_789_012, name: 'operator' },
|
||||
comment: 'please',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { findUidByUin, rememberGroupRequests },
|
||||
} as any);
|
||||
|
||||
const requests = await api.fetchGroupRequests(false);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({
|
||||
groupId: 1_095_186_374,
|
||||
targetUid: 'cached_target_uid',
|
||||
targetUin: 1_234_567_890,
|
||||
invitorUin: 2_345_678_901,
|
||||
operatorUin: 3_456_789_012,
|
||||
sequence: 1_785_525_232_784_291,
|
||||
notifyType: 7,
|
||||
eventType: 1,
|
||||
filtered: false,
|
||||
}),
|
||||
]);
|
||||
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
|
||||
});
|
||||
|
||||
it('retains the UID-form response for correlating real-time request pushes', async () => {
|
||||
const rememberGroupRequests = vi.fn();
|
||||
const findUinByUid = vi.fn((uid: string) => (uid === 'target_uid' ? 1_234_567_890 : null));
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupRequestPacketByUid({
|
||||
requests: [
|
||||
{
|
||||
sequence: 42n,
|
||||
eventType: 1,
|
||||
state: 1,
|
||||
group: { groupUin: 999, groupName: 'group' },
|
||||
target: { uid: 'target_uid', name: 'requester' },
|
||||
invitor: { uid: 'inviter_uid', name: 'inviter' },
|
||||
operatorUser: { uid: 'operator_uid', name: 'operator' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { findUinByUid, rememberGroupRequests },
|
||||
} as any);
|
||||
|
||||
const requests = await api.fetchGroupRequestsByUid(true);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({
|
||||
targetUid: 'target_uid',
|
||||
targetUin: 1_234_567_890,
|
||||
invitorUid: 'inviter_uid',
|
||||
operatorUid: 'operator_uid',
|
||||
notifyType: 7,
|
||||
eventType: 1,
|
||||
filtered: true,
|
||||
}),
|
||||
]);
|
||||
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / categorized friend roster', () => {
|
||||
it('keeps fetchFriendList flat while traversing cookie pages', async () => {
|
||||
const cookie = Uint8Array.from([0x01]);
|
||||
const { api, sendRawPacket } = apiForPages([
|
||||
{ cookie, friends: [friend(10001, 0, 'u1', 'Default')] },
|
||||
{ friends: [friend(10002, 7, 'u2', 'Alice', 'A')] },
|
||||
]);
|
||||
|
||||
await expect(api.fetchFriendList()).resolves.toEqual([
|
||||
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
||||
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
|
||||
]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('groups a complete multi-page roster and preserves empty categories', async () => {
|
||||
const cookie = Uint8Array.from([0xaa, 0xbb]);
|
||||
const { api, sendRawPacket, rememberFriends } = apiForPages([
|
||||
{
|
||||
cookie,
|
||||
friends: [friend(10001, 0, 'u1', 'Default')],
|
||||
categories: [
|
||||
category(0, '我的好友', 1, 0),
|
||||
category(7, 'Work', 1, 1),
|
||||
category(9, 'Empty', 0, 2),
|
||||
],
|
||||
},
|
||||
{
|
||||
friends: [friend(10002, 7, 'u2', 'Alice', 'A')],
|
||||
categories: [category(7, 'Work', 1, 1)],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(api.fetchFriendCategories()).resolves.toEqual([
|
||||
{
|
||||
categoryId: 0,
|
||||
categoryName: '我的好友',
|
||||
memberCount: 1,
|
||||
sortId: 0,
|
||||
friends: [
|
||||
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 1,
|
||||
sortId: 1,
|
||||
friends: [{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }],
|
||||
},
|
||||
{
|
||||
categoryId: 9,
|
||||
categoryName: 'Empty',
|
||||
memberCount: 0,
|
||||
sortId: 2,
|
||||
friends: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
const secondRequest = protobuf_decode<OidbBase<OidbFriendListRequest>>(
|
||||
sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(secondRequest.body?.cookie).toEqual(cookie);
|
||||
expect(rememberFriends).toHaveBeenCalledOnce();
|
||||
expect(rememberFriends).toHaveBeenCalledWith([
|
||||
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
||||
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a friend whose category metadata never arrives', async () => {
|
||||
const { api } = apiForPages([
|
||||
{
|
||||
friends: [friend(10001, 99, 'u1', 'Orphan')],
|
||||
categories: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(api.fetchFriendCategories()).rejects.toThrow('missing category 99');
|
||||
});
|
||||
|
||||
it('rejects a repeated non-empty pagination cookie', async () => {
|
||||
const cookie = Uint8Array.from([0xaa]);
|
||||
const { api } = apiForPages([{ cookie }, { cookie }]);
|
||||
|
||||
await expect(api.fetchFriendCategories()).rejects.toThrow('repeated friend-list cookie aa');
|
||||
});
|
||||
|
||||
it('moves a live friend to a category selected by ID', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
|
||||
});
|
||||
|
||||
await api.setFriendCategory({ uin: 10001, categoryId: 7 });
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
const [command, bytes] = sendRawPacket.mock.calls[1]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
|
||||
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(bytes);
|
||||
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
|
||||
});
|
||||
|
||||
it('resolves an exact unique category name before moving the friend', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
|
||||
});
|
||||
|
||||
await api.setFriendCategory({ uin: 10001, categoryName: 'Work' });
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(
|
||||
sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
|
||||
});
|
||||
|
||||
it('rejects unknown friends and categories without sending a mutation', async () => {
|
||||
const unknownFriend = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0)],
|
||||
});
|
||||
await expect(
|
||||
unknownFriend.api.setFriendCategory({
|
||||
uin: 10002,
|
||||
categoryId: 0,
|
||||
}),
|
||||
).rejects.toThrow('friend 10002 is not in the live roster');
|
||||
expect(unknownFriend.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
|
||||
const unknownCategory = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0)],
|
||||
});
|
||||
await expect(
|
||||
unknownCategory.api.setFriendCategory({
|
||||
uin: 10001,
|
||||
categoryId: 7,
|
||||
}),
|
||||
).rejects.toThrow('friend category 7 does not exist');
|
||||
expect(unknownCategory.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects duplicate category names instead of selecting one arbitrarily', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, 'Work', 1, 0), category(7, 'Work', 0, 1)],
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.setFriendCategory({
|
||||
uin: 10001,
|
||||
categoryName: 'Work',
|
||||
}),
|
||||
).rejects.toThrow('friend category name "Work" is ambiguous');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects missing or conflicting selectors before reading the roster', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
await expect(api.setFriendCategory({ uin: 10001 })).rejects.toThrow(
|
||||
'exactly one of categoryId or categoryName is required',
|
||||
);
|
||||
await expect(
|
||||
api.setFriendCategory({
|
||||
uin: 10001,
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
}),
|
||||
).rejects.toThrow('exactly one of categoryId or categoryName is required');
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / robot group-member classification', () => {
|
||||
it('loads QQ robot ranges once and marks every fetched member', async () => {
|
||||
const rememberGroupMembers = vi.fn();
|
||||
const sendRawPacket = vi.fn(
|
||||
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
|
||||
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
|
||||
return robotRangePacket({
|
||||
robotConfig: {
|
||||
version: 206,
|
||||
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
|
||||
return memberListPacket({
|
||||
groupUin: 42,
|
||||
members: [
|
||||
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
|
||||
{ uin: { uid: 'u_person', uin: 1_234_567_890 }, memberName: 'person' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
},
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { uin: '10001', rememberGroupMembers },
|
||||
} as any);
|
||||
|
||||
const first = await api.fetchGroupMemberList(42);
|
||||
const second = await api.fetchGroupMemberList(42, { force: true });
|
||||
|
||||
expect(first).toEqual([
|
||||
expect.objectContaining({ uin: 3_889_054_356, isRobot: true }),
|
||||
expect.objectContaining({ uin: 1_234_567_890, isRobot: false }),
|
||||
]);
|
||||
expect(second).toEqual(first);
|
||||
expect(rememberGroupMembers).toHaveBeenCalledWith(42, first);
|
||||
expect(sendRawPacket.mock.calls.map(([cmd]) => cmd).sort()).toEqual([
|
||||
'OidbSvcTrpcTcp.0x496_0',
|
||||
'OidbSvcTrpcTcp.0xfe7_3',
|
||||
'OidbSvcTrpcTcp.0xfe7_3',
|
||||
]);
|
||||
expect(
|
||||
sendRawPacket.mock.calls.filter(([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0'),
|
||||
).toHaveLength(1);
|
||||
|
||||
const rangeCall = sendRawPacket.mock.calls.find(
|
||||
([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0',
|
||||
)!;
|
||||
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(rangeCall[1]);
|
||||
expect(request.body).toMatchObject({ justFetchMsgConfig: 1, type: 1 });
|
||||
});
|
||||
|
||||
it('propagates an invalid range snapshot and retries it on the next fetch', async () => {
|
||||
let rangeAttempts = 0;
|
||||
const sendRawPacket = vi.fn(
|
||||
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
|
||||
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
|
||||
rangeAttempts += 1;
|
||||
return rangeAttempts === 1
|
||||
? robotRangePacket({})
|
||||
: robotRangePacket({
|
||||
robotConfig: {
|
||||
version: 206,
|
||||
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
|
||||
return memberListPacket({
|
||||
groupUin: 42,
|
||||
members: [
|
||||
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
},
|
||||
);
|
||||
const rememberGroupMembers = vi.fn();
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { uin: '10001', rememberGroupMembers },
|
||||
} as any);
|
||||
|
||||
await expect(api.fetchGroupMemberList(42)).rejects.toThrow(
|
||||
'0x496_0 response missing robot range config',
|
||||
);
|
||||
await expect(api.fetchGroupMemberList(42)).resolves.toEqual([
|
||||
expect.objectContaining({ isRobot: true }),
|
||||
]);
|
||||
|
||||
expect(rangeAttempts).toBe(2);
|
||||
expect(rememberGroupMembers).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / invite-card pending application', () => {
|
||||
it('remembers, reads, and reverse-looks-up a card sequence', () => {
|
||||
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
|
||||
expect(api.getGroupInviteCardSequence(12345)).toBeUndefined();
|
||||
|
||||
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(true);
|
||||
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(false);
|
||||
|
||||
expect(api.getGroupInviteCardSequence(12345)).toBe(778899);
|
||||
expect(api.findGroupInviteCardGroupBySequence(778899)).toBe(12345);
|
||||
expect(api.findGroupInviteCardGroupBySequence(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires a group when different groups have the same card sequence', () => {
|
||||
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
|
||||
api.rememberGroupInviteCardSequence(12345, 778899);
|
||||
api.rememberGroupInviteCardSequence(67890, 778899);
|
||||
|
||||
expect(() => api.findGroupInviteCardGroupBySequence(778899)).toThrow('multiple groups');
|
||||
expect(api.findGroupInviteCardGroupBySequence(778899, 67890)).toBe(67890);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
|
||||
import type { QQEventVariant } from '@snowluma/protocol/events';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbGroupTodo,
|
||||
OidbQueryGroupTopBannersReq,
|
||||
OidbQueryGroupTopBannersResp,
|
||||
OidbStrangerStatusReq,
|
||||
OidbStrangerStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type {
|
||||
OidbAiVoiceListReq,
|
||||
OidbAiVoiceListResp,
|
||||
OidbAiVoiceReq,
|
||||
OidbAiVoiceResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/media';
|
||||
|
||||
// Post-namespace migration: ExtrasApi forwards OIDB cmds through
|
||||
// namespaces under @snowluma/protocol/oidb-services/extras. Tests assert
|
||||
// against bridge.sendRawPacket directly.
|
||||
import { ExtrasApi, AiVoiceChatType } from '../../src/bridge/apis/extras';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function packResponse(body: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apis/extras / group todo (0x9474 / 0xF90)', () => {
|
||||
it('getGroupTodoList dispatches the todo-only banner query', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbQueryGroupTopBannersResp>>({ body: {} })),
|
||||
);
|
||||
|
||||
await expect(new ExtrasApi(bridge as any).getGroupTodoList(12345)).resolves.toEqual([]);
|
||||
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9474_0');
|
||||
const env = protobuf_decode<OidbBase<OidbQueryGroupTopBannersReq>>(bytes);
|
||||
expect(env.body).toEqual({ groupId: 12345n, bannerFlag: 1 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['setGroupTodo', 'OidbSvcTrpcTcp.0xf90_1'] as const,
|
||||
['completeGroupTodo', 'OidbSvcTrpcTcp.0xf90_2'] as const,
|
||||
['cancelGroupTodo', 'OidbSvcTrpcTcp.0xf90_3'] as const,
|
||||
])('%s dispatches the right subCmd with shared body', async (method, cmd) => {
|
||||
const bridge = mockBridge();
|
||||
const api = new ExtrasApi(bridge as any);
|
||||
await (api as any)[method](12345, 9876543210n);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe(cmd);
|
||||
const env = protobuf_decode<OidbBase<OidbGroupTodo>>(bytes);
|
||||
expect(env.body).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/extras / getStrangerStatus (0xFE1_2)', () => {
|
||||
it('issues the FE1_2 query with key=27372', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
|
||||
body: { data: { properties: { entries: [{ key: 27372, value: 5n }] } } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await new ExtrasApi(bridge as any).getStrangerStatus(100200);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbStrangerStatusReq>>(bytes);
|
||||
expect(env.command).toBe(0xfe1);
|
||||
expect(env.subCommand).toBe(2);
|
||||
expect(env.body).toMatchObject({ uin: 100200, key: [{ key: 27372 }] });
|
||||
});
|
||||
|
||||
it('low-band values (≤10) map to status*10 with ext_status=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
|
||||
body: { data: { properties: { entries: [{ key: 27372, value: 7n }] } } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toEqual({
|
||||
status: 70,
|
||||
ext_status: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('high-band values decompose into the (0xff00 + (>>16 & 0xff)) status word', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
|
||||
body: { data: { properties: { entries: [{ key: 27372, value: 0x42f100n }] } } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const status = await new ExtrasApi(bridge as any).getStrangerStatus(1);
|
||||
expect(status).toEqual({ status: 10, ext_status: 0xf142 });
|
||||
});
|
||||
|
||||
it('returns null when the runner throws (transport error)', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockRejectedValueOnce(new Error('boom'));
|
||||
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the server omits the status field', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbStrangerStatusResp>>({ body: {} })),
|
||||
);
|
||||
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/extras / AI voice (0x929D / 0x929B)', () => {
|
||||
it('fetchAiVoiceList sends 0x929D_0 and returns server content verbatim', async () => {
|
||||
const bridge = mockBridge();
|
||||
const fake = [
|
||||
{
|
||||
category: 'cute',
|
||||
voices: [{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' }],
|
||||
},
|
||||
];
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body: { content: fake } } as any),
|
||||
),
|
||||
);
|
||||
const out = await new ExtrasApi(bridge as any).fetchAiVoiceList(
|
||||
4242,
|
||||
AiVoiceChatType.Sound,
|
||||
);
|
||||
expect(out).toEqual(fake);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x929d_0');
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceListReq>>(bytes);
|
||||
expect(env.body).toEqual({ groupUin: 4242, chatType: 1 });
|
||||
});
|
||||
|
||||
it('fetchAiVoiceList returns [] when server replies with no content', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body: {} })),
|
||||
);
|
||||
const out = await new ExtrasApi(bridge as any).fetchAiVoiceList(1, 1);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetchAiVoice retries while msgInfo is empty and returns the media node', async () => {
|
||||
const bridge = mockBridge();
|
||||
const node = { fileUuid: 'uuid-1', subType: 1 };
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: { statusCode: 2 } } as any),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [] } } as any,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: {
|
||||
field2: 319,
|
||||
field3: 20,
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await new ExtrasApi(bridge as any).fetchAiVoice(
|
||||
100,
|
||||
'voice-id',
|
||||
'hi',
|
||||
AiVoiceChatType.Sound,
|
||||
);
|
||||
expect(out).toMatchObject(node);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x929b_0');
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 100,
|
||||
voiceId: 'voice-id',
|
||||
text: 'hi',
|
||||
chatType: 1,
|
||||
});
|
||||
const sessionId = env.body?.session?.sessionId;
|
||||
expect(typeof sessionId).toBe('number');
|
||||
expect(sessionId).toBeGreaterThanOrEqual(0);
|
||||
expect(sessionId).toBeLessThanOrEqual(0xffffffff);
|
||||
});
|
||||
|
||||
it('fetchAiVoice throws after exhausting the retry budget', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValue(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: { statusCode: 1 } as any }),
|
||||
),
|
||||
);
|
||||
await expect(new ExtrasApi(bridge as any).fetchAiVoice(1, 'v', 't', 1, 3)).rejects.toThrow(
|
||||
/AI voice synthesis did not complete/,
|
||||
);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fetchAiVoice keeps the same sessionId across retries', async () => {
|
||||
const bridge = mockBridge();
|
||||
const node = { fileUuid: 'uuid', subType: 1 };
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: {} })),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await new ExtrasApi(bridge as any).fetchAiVoice(1, 'v', 't', 1);
|
||||
const env1 = protobuf_decode<OidbBase<OidbAiVoiceReq>>(
|
||||
bridge.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
const env2 = protobuf_decode<OidbBase<OidbAiVoiceReq>>(
|
||||
bridge.sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(env1.body?.session?.sessionId).toBe(env2.body?.session?.sessionId);
|
||||
});
|
||||
|
||||
it('sendAiVoice correlates an echo that arrives before the synthesis response', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
const node = { fileUuid: 'voice-uuid', info: { fileHash: 'AABB' } };
|
||||
bridge.sendRawPacket.mockImplementationOnce(async () => {
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 1234,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 319,
|
||||
msgId: 456,
|
||||
elements: [{ type: 'record', fileId: 'voice-uuid', fileHash: 'AABB' }],
|
||||
});
|
||||
return packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
|
||||
} as any),
|
||||
);
|
||||
});
|
||||
|
||||
const receipt = await new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 50);
|
||||
expect(receipt).toEqual({ sequence: 319 });
|
||||
});
|
||||
|
||||
it('sendAiVoice ignores unrelated self voice messages and matches the media UUID', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
const node = { fileUuid: 'expected-uuid', info: { fileHash: 'aabb' } };
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
const pending = new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 100);
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 0,
|
||||
selfUin: 10001,
|
||||
groupId: 999,
|
||||
groupName: 'other',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 10,
|
||||
msgId: 20,
|
||||
elements: [{ type: 'record', fileId: 'expected-uuid' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 0,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 20002,
|
||||
senderNick: 'other',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 11,
|
||||
msgId: 21,
|
||||
elements: [{ type: 'record', fileId: 'expected-uuid' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 0,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 12,
|
||||
msgId: 22,
|
||||
elements: [{ type: 'text', text: 'not a voice' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 1,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 1,
|
||||
msgId: 2,
|
||||
elements: [{ type: 'record', fileId: 'EXPECTED-UUID', fileHash: 'AABB' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 3,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 4,
|
||||
msgId: 5,
|
||||
elements: [{ type: 'record', mediaNode: { fileUuid: 'expected-uuid' } }],
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ sequence: 4 });
|
||||
});
|
||||
|
||||
it('sendAiVoice lets each self-message receipt satisfy only one concurrent request', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
const node = { fileUuid: 'shared-uuid', info: { fileHash: 'AABB' } };
|
||||
bridge.sendRawPacket.mockResolvedValue(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
const api = new ExtrasApi(bridge as any);
|
||||
const first = api.sendAiVoice(100, 'v', 'same text', 1, 100);
|
||||
const second = api.sendAiVoice(100, 'v', 'same text', 1, 100);
|
||||
|
||||
const firstReceipt: Extract<QQEventVariant, { kind: 'group_message' }> = {
|
||||
kind: 'group_message',
|
||||
time: 1,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 10,
|
||||
msgId: 20,
|
||||
elements: [{ type: 'record', fileId: 'shared-uuid' }],
|
||||
};
|
||||
await events.emit(firstReceipt);
|
||||
// A second QQ process can decode the same push into a different object.
|
||||
await events.emit({ ...firstReceipt, elements: [...firstReceipt.elements] });
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 2,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 11,
|
||||
msgId: 21,
|
||||
elements: [{ type: 'record', fileId: 'shared-uuid' }],
|
||||
});
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ sequence: 10 },
|
||||
{ sequence: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sendAiVoice fails explicitly when QQ omits every correlation key', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { subType: 1 } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 10),
|
||||
).rejects.toThrow(/no exact media identifier/i);
|
||||
});
|
||||
|
||||
it('sendAiVoice refuses hash-only correlation because concurrent audio can be identical', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { info: { fileHash: 'AABB' } } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 10),
|
||||
).rejects.toThrow(/no exact media identifier/i);
|
||||
});
|
||||
|
||||
it('sendAiVoice rejects a matched receipt with an invalid message sequence', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uuid' } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
const pending = new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 50);
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 1,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 0,
|
||||
msgId: 2,
|
||||
elements: [{ type: 'record', fileId: 'uuid' }],
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toThrow(/invalid message sequence: 0/i);
|
||||
});
|
||||
|
||||
it('sendAiVoice fails explicitly when the canonical echo never arrives', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uuid' } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 5)).rejects.toThrow(
|
||||
/published but no matching group-message receipt/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
// Regression coverage for the file-inside-send_forward_msg bug.
|
||||
//
|
||||
// Symptom: caller put `{type:'file', file_id}` inside a forward node;
|
||||
// the element-builder dropped it (`[ElemBuilder] BUG: {type:"file"}
|
||||
// reached element-builder — must be split out at the OneBot layer`)
|
||||
// because the live-send paths route files through dedicated methods
|
||||
// (sendC2cFileMessage / sendGroupFileMessage), and the forward
|
||||
// builder was naïvely calling buildSendElems with the same ctx. The
|
||||
// forward upload succeeded but the bubble shipped without the file
|
||||
// element.
|
||||
//
|
||||
// Fix: forward upload passes `forwardFake: true` on the SendContext.
|
||||
// The element-builder honours that flag by emitting the receive-side
|
||||
// shapes:
|
||||
// * group file → transElem(elemType=24, GroupFileExtra)
|
||||
// * c2c file → handled at the forward-builder level, written into
|
||||
// `body.msgContent` as FileExtra { file: NotOnlineFile }
|
||||
//
|
||||
// These shapes are RECEIVE-side: the QQ-NT live-send pipeline rejects
|
||||
// transElem(24) (result=79), but the long-msg upload service stores the
|
||||
// gzipped protobuf verbatim and the recipient's msg-push decoder pulls
|
||||
// the file entity back out via the normal path (rich-body-decoder.ts).
|
||||
// Mirrors NapCat's `PacketMsgFileElement.{buildElement,buildContent}`
|
||||
// split.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', () => ({
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
encodeOidbEnv: vi.fn(() => new Uint8Array()),
|
||||
decodeOidbEnv: vi.fn(() => ({ body: {} })),
|
||||
}));
|
||||
|
||||
// `vi.mock` factories are hoisted above the imports — declare the spy
|
||||
// inside a `vi.hoisted` block so it's available at the same moment.
|
||||
// Declare parameter types on the fn so `mock.calls[0]` infers a tuple
|
||||
// of [elements, ctx] instead of `[]`, which makes the destructuring
|
||||
// a tsc error under strict tuple checking on CI.
|
||||
const { buildSendElemsMock } = vi.hoisted(() => ({
|
||||
buildSendElemsMock: vi.fn(async (_elements: unknown[], _ctx?: Record<string, unknown>) => []),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/element-builder', () => ({
|
||||
buildSendElems: buildSendElemsMock,
|
||||
}));
|
||||
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { LongMsgResult, SendLongMsgReq, SendLongMsgResp } from '@snowluma/proto-defs/longmsg';
|
||||
import type { FileExtra } from '@snowluma/proto-defs/message';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
const encoded = protobuf_encode<SendLongMsgResp>({ result: { resId } });
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(encoded),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLongMsgRequest(rawBytes: Uint8Array): LongMsgResult {
|
||||
const env = protobuf_decode<SendLongMsgReq>(rawBytes);
|
||||
const payload = env.info?.payload;
|
||||
|
||||
if (!(payload instanceof Uint8Array)) {
|
||||
throw new Error('payload missing on SendLongMsgReq');
|
||||
}
|
||||
|
||||
return protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
}
|
||||
|
||||
describe('actions/forward — file segment inside forward node', () => {
|
||||
it('rejects cached temporary files mixed with text before building a forward payload', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const bridge = mockBridge({
|
||||
recallUploadedFile: vi.fn(() => ({ scope: 'group', groupId: 12345, busId: 104 })),
|
||||
});
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'text', text: 'caption' },
|
||||
{ type: 'file', fileId: 'temp' },
|
||||
],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
),
|
||||
).rejects.toThrow(/temporary group files.*only file or image/i);
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disallows temporary fallback when a forward node mixes a new file with text', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const bridge = mockBridge();
|
||||
bridge.apis.groupFile.upload.mockRejectedValueOnce(
|
||||
new Error('temporary group files require only file or image segments'),
|
||||
);
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'text', text: 'caption' },
|
||||
{ type: 'file', url: 'base64://AQID', fileName: 'note.txt' },
|
||||
],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
),
|
||||
).rejects.toThrow(/temporary group files/);
|
||||
expect(bridge.apis.groupFile.upload).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'base64://AQID',
|
||||
'note.txt',
|
||||
'/',
|
||||
true,
|
||||
false,
|
||||
{ allowTemporary: false },
|
||||
);
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a recursive window shake before identity resolution or upload', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const resolveUserUid = vi.fn();
|
||||
const uploadPrivate = vi.fn();
|
||||
const sendRawPacket = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid,
|
||||
sendRawPacket: sendRawPacket as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
uploadPrivate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'image', url: 'base64://AQID' }],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 10002,
|
||||
nickname: 'bob',
|
||||
elements: [{ type: 'poke', subType: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNSENDABLE_TYPE',
|
||||
elementType: 'poke',
|
||||
});
|
||||
|
||||
expect(resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(uploadPrivate).not.toHaveBeenCalled();
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('group forward sets forwardFake:true on the SendContext so transElem(24) is emitted', async () => {
|
||||
// Group case: the file element rides on elems[] as transElem(24).
|
||||
// The element-builder receives ctx.forwardFake=true so it knows to
|
||||
// emit the receive-side shape instead of dropping the segment.
|
||||
buildSendElemsMock.mockClear();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-grp-file')) as any,
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'file', fileId: 'gfid-1', fileName: 'a.txt', fileSize: 99 } as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
);
|
||||
|
||||
expect(buildSendElemsMock).toHaveBeenCalled();
|
||||
const [, ctx] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(ctx).toMatchObject({ groupId: 12345, forwardFake: true });
|
||||
});
|
||||
|
||||
it('c2c forward writes the file as msgContent (FileExtra { file: NotOnlineFile })', async () => {
|
||||
// Private case: c2c files live on `body.msgContent`, not in elems[].
|
||||
// The forward-builder pulls the file segment off and synthesises a
|
||||
// FileExtra payload so the recipient's decoder (which reads
|
||||
// msgContent first) renders the file bubble.
|
||||
buildSendElemsMock.mockClear();
|
||||
let captured: Uint8Array | undefined;
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async (_cmd: string, body: Uint8Array) => {
|
||||
captured = body;
|
||||
return uploadResponseWithResId('res-c2c-file');
|
||||
}) as any,
|
||||
// Inline fields cover everything we need; cache lookup must not be
|
||||
// required for the path to work.
|
||||
recallUploadedFile: vi.fn(() => undefined),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{
|
||||
type: 'file',
|
||||
fileId: 'pfid-1',
|
||||
fileName: 'invoice.pdf',
|
||||
fileSize: 4096,
|
||||
md5Hex: 'aabbccddeeff00112233445566778899',
|
||||
fileHash: 'srv-hash-abc',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(buildSendElemsMock).toHaveBeenCalled();
|
||||
const [, ctx] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(ctx).toMatchObject({ forwardFake: true });
|
||||
expect(ctx?.groupId).toBeUndefined();
|
||||
|
||||
expect(captured).toBeDefined();
|
||||
const longMsg = decodeLongMsgRequest(captured!);
|
||||
const body = longMsg.action?.[0]?.actionData?.msgBody?.[0]?.body;
|
||||
expect(body?.msgContent).toBeInstanceOf(Uint8Array);
|
||||
expect(body?.richText?.notOnlineFile ?? undefined).toBeUndefined();
|
||||
|
||||
const fileExtra = protobuf_decode<FileExtra>(body!.msgContent as Uint8Array);
|
||||
expect(fileExtra.file).toMatchObject({
|
||||
fileUuid: 'pfid-1',
|
||||
fileName: 'invoice.pdf',
|
||||
fileHash: 'srv-hash-abc',
|
||||
});
|
||||
expect(fileExtra.file?.fileSize).toBe(4096n);
|
||||
});
|
||||
|
||||
it('c2c forward falls back to the upload metadata cache when inline fields are missing', async () => {
|
||||
// The OneBot caller often passes only `file_id` — the rest comes
|
||||
// from the bridge's uploaded-file cache. This is the same
|
||||
// hydration path send_private_msg uses; the forward builder must
|
||||
// honour it too or every cached-only forward ships a 0 B bubble.
|
||||
buildSendElemsMock.mockClear();
|
||||
const recallUploadedFile = vi.fn((id: string) =>
|
||||
id === 'pfid-cached'
|
||||
? {
|
||||
fileId: 'pfid-cached',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'cached-name.zip',
|
||||
fileSize: 8192,
|
||||
fileMd5: Buffer.from('ffeeddccbbaa99887766554433221100', 'hex'),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'cached-hash',
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-cache')) as any,
|
||||
recallUploadedFile,
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', fileId: 'pfid-cached' } as any],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(recallUploadedFile).toHaveBeenCalledWith('pfid-cached');
|
||||
});
|
||||
|
||||
it('group forward pre-uploads file sources into the target group without publishing a separate file message', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const upload = vi.fn(async () => ({ fileId: 'gfid-from-base64' }));
|
||||
const publish = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-grp-source')) as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
upload,
|
||||
publish,
|
||||
},
|
||||
},
|
||||
recallUploadedFile: vi.fn((id: string) =>
|
||||
id === 'gfid-from-base64'
|
||||
? {
|
||||
fileId: 'gfid-from-base64',
|
||||
scope: 'group' as const,
|
||||
groupId: 12345,
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', url: 'base64://AQID', fileName: 'note.txt' } as any],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
);
|
||||
|
||||
expect(upload).toHaveBeenCalledWith(12345, 'base64://AQID', 'note.txt', '/', true, false, {
|
||||
allowTemporary: true,
|
||||
});
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
const [elements] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
fileId: 'gfid-from-base64',
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('private forward pre-uploads file sources without sending a standalone c2c file message', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const uploadPrivate = vi.fn(async () => ({
|
||||
fileId: 'pfid-from-base64',
|
||||
fileHash: 'phash',
|
||||
}));
|
||||
const sendC2cFile = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-c2c-source')) as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
message: {
|
||||
...mockBridge().apis.message,
|
||||
sendC2cFile,
|
||||
},
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
uploadPrivate,
|
||||
},
|
||||
},
|
||||
recallUploadedFile: vi.fn((id: string) =>
|
||||
id === 'pfid-from-base64'
|
||||
? {
|
||||
fileId: 'pfid-from-base64',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'phash',
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', url: 'base64://AQID', fileName: 'note.txt' } as any],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(uploadPrivate).toHaveBeenCalledWith(67890, 'base64://AQID', 'note.txt', true, false);
|
||||
expect(sendC2cFile).not.toHaveBeenCalled();
|
||||
const [elements] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
fileId: 'pfid-from-base64',
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
fileHash: 'phash',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects two files in one private forward node before uploading either file', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const uploadPrivate = vi.fn();
|
||||
const sendRawPacket = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: sendRawPacket as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
uploadPrivate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'file', url: 'base64://AQID', fileName: 'one.txt' },
|
||||
{ type: 'file', url: 'base64://BAUG', fileName: 'two.txt' },
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNSENDABLE_TYPE',
|
||||
elementType: 'file',
|
||||
});
|
||||
|
||||
expect(uploadPrivate).not.toHaveBeenCalled();
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('private file forward keeps the long-msg upload under the self uid namespace', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
let captured: Uint8Array | undefined;
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async (_cmd: string, body: Uint8Array) => {
|
||||
captured = body;
|
||||
return uploadResponseWithResId('res-c2c-target-uid');
|
||||
}) as any,
|
||||
resolveUserUid: vi.fn(async () => 'target-uid'),
|
||||
recallUploadedFile: vi.fn((id: string) =>
|
||||
id === 'pfid-1'
|
||||
? {
|
||||
fileId: 'pfid-1',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'invoice.pdf',
|
||||
fileSize: 4096,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'srv-hash',
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', fileId: 'pfid-1' } as any],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(captured).toBeDefined();
|
||||
const request = protobuf_decode<SendLongMsgReq>(captured!);
|
||||
expect(request.info?.uid?.uid).toBe('self-uid');
|
||||
});
|
||||
|
||||
it('group forward re-uploads a cached private file_id into the target group scope', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const getPrivateUrl = vi.fn(async () => 'https://download.test/private-file');
|
||||
const upload = vi.fn(async () => ({ fileId: 'gfid-reuploaded' }));
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-cross-scope')) as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
getPrivateUrl,
|
||||
upload,
|
||||
},
|
||||
},
|
||||
recallUploadedFile: vi.fn((id: string) => {
|
||||
if (id === 'pfid-cached') {
|
||||
return {
|
||||
fileId: 'pfid-cached',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'cached.txt',
|
||||
fileSize: 5,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'cached-hash',
|
||||
rememberedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (id === 'gfid-reuploaded') {
|
||||
return {
|
||||
fileId: 'gfid-reuploaded',
|
||||
scope: 'group' as const,
|
||||
groupId: 12345,
|
||||
fileName: 'cached.txt',
|
||||
fileSize: 5,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
rememberedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', fileId: 'pfid-cached' } as any],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
);
|
||||
|
||||
expect(getPrivateUrl).toHaveBeenCalledWith(67890, 'pfid-cached', 'cached-hash');
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'https://download.test/private-file',
|
||||
'cached.txt',
|
||||
'/',
|
||||
true,
|
||||
false,
|
||||
{ allowTemporary: true },
|
||||
);
|
||||
const [elements] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
fileId: 'gfid-reuploaded',
|
||||
fileName: 'cached.txt',
|
||||
fileSize: 5,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendLongMsgResp } from '@snowluma/proto-defs/longmsg';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/image-upload', () => ({ uploadImageMsgInfo: vi.fn() }));
|
||||
vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({ uploadPtt: vi.fn() }));
|
||||
vi.mock('@snowluma/protocol/highway/video-upload', () => ({ uploadVideoMsgInfo: vi.fn() }));
|
||||
|
||||
describe('forward metadata through upload and cached reads', () => {
|
||||
it.each([
|
||||
{ nested: false, enrich: false },
|
||||
{ nested: true, enrich: false },
|
||||
{ nested: false, enrich: true },
|
||||
{ nested: true, enrich: true },
|
||||
])(
|
||||
'preserves titles and previews with nesting=$nested and enrichment=$enrich',
|
||||
async ({ nested, enrich }) => {
|
||||
let uploadCount = 0;
|
||||
const sendRawPacket = vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<SendLongMsgResp>({
|
||||
result: { resId: `metadata-${nested}-${enrich}-${++uploadCount}` },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
bridge.apis.contacts.fetchGroupMemberList.mockResolvedValue([
|
||||
{ uin: 111, nickname: 'resolved outer', card: '' },
|
||||
]);
|
||||
const forward = new ForwardApi(bridge as any);
|
||||
const ctx = {
|
||||
selfId: 10001,
|
||||
bridge: {
|
||||
identity: { nickname: 'SnowLuma' },
|
||||
apis: {
|
||||
forward,
|
||||
message: {
|
||||
sendGroup: vi.fn(async () => ({
|
||||
messageId: 1,
|
||||
sequence: 2,
|
||||
clientSequence: 0,
|
||||
random: 3,
|
||||
timestamp: 1700000000,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
messageStore: { findEvent: () => null, findMedia: () => null, storeEvent: vi.fn() },
|
||||
cacheMessageMeta: vi.fn(),
|
||||
converterCtx: {
|
||||
selfId: 10001,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
} as unknown as OneBotInstanceContext;
|
||||
const api = buildApiContext(ctx);
|
||||
const text = [{ type: 'text', data: { text: 'hello' } }];
|
||||
const preview = {
|
||||
title: 'outer title',
|
||||
source: 'custom source',
|
||||
summary: 'custom summary',
|
||||
prompt: 'custom prompt',
|
||||
news: [{ text: 'custom preview' }],
|
||||
};
|
||||
const { forwardId } = await api.sendGroupForwardMsg(12345, [
|
||||
{
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: 111,
|
||||
nickname: enrich ? '' : 'outer',
|
||||
...preview,
|
||||
content: nested
|
||||
? [
|
||||
{
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: 222,
|
||||
nickname: 'inner',
|
||||
title: 'inner title',
|
||||
content: text,
|
||||
},
|
||||
},
|
||||
]
|
||||
: text,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const first = await api.getForwardMsg(forwardId);
|
||||
expect(first[0]!.sender).toMatchObject({ title: 'outer title' });
|
||||
expect(first[0]!.sender).toMatchObject({
|
||||
nickname: enrich ? 'resolved outer' : 'outer',
|
||||
});
|
||||
expect((await forward.fetch(forwardId!))[0]).toMatchObject(preview);
|
||||
|
||||
if (nested) {
|
||||
expect(
|
||||
(await api.getForwardMsg(`metadata-true-${enrich}-1`))[0]!.sender,
|
||||
).toMatchObject({ title: 'inner title' });
|
||||
}
|
||||
|
||||
const fetched = await forward.fetch(forwardId!);
|
||||
fetched[0]!.title = 'changed title';
|
||||
fetched[0]!.news![0]!.text = 'changed preview';
|
||||
preview.news[0]!.text = 'changed input';
|
||||
const again = await forward.fetch(forwardId!);
|
||||
expect(again[0]!.title).toBe('outer title');
|
||||
expect(again[0]!.news).toEqual([{ text: 'custom preview' }]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(nested ? 2 : 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { deflateSync, gzipSync } from 'zlib';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
import type { LongMsgResult, RecvLongMsgReq, RecvLongMsgResp } from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function forwardCard(format: 'xml' | 'lightApp', resId: string, key?: string): Elem {
|
||||
const content =
|
||||
format === 'xml'
|
||||
? `<msg serviceID="35" m_resid="${resId}"${key ? ` m_fileName="${key}"` : ''} />`
|
||||
: JSON.stringify({
|
||||
app: 'com.tencent.multimsg',
|
||||
meta: { detail: { resid: resId, uniseq: key } },
|
||||
});
|
||||
const data = Buffer.concat([Buffer.from([1]), deflateSync(Buffer.from(content))]);
|
||||
return format === 'xml'
|
||||
? { richMsg: { serviceId: 35, template1: data } }
|
||||
: { lightApp: { data } };
|
||||
}
|
||||
|
||||
function message(...elems: Elem[]): PushMsgBody {
|
||||
return {
|
||||
responseHead: {
|
||||
fromUin: 20002,
|
||||
fromUid: 'sender-uid',
|
||||
grp: { groupUin: 12345, memberCard: Buffer.from('alice') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 },
|
||||
body: { richText: { elems } },
|
||||
};
|
||||
}
|
||||
|
||||
function setup(downloads: Map<string, LongMsgResult>, selfUin = '10001') {
|
||||
const requested: string[] = [];
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async (command: string, bytes: Uint8Array) => {
|
||||
expect(command).toBe('trpc.group.long_msg_interface.MsgService.SsoRecvLongMsg');
|
||||
const resId = protobuf_decode<RecvLongMsgReq>(bytes).info!.resId!;
|
||||
requested.push(resId);
|
||||
const data = downloads.get(resId);
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<RecvLongMsgResp>({
|
||||
result: {
|
||||
resId,
|
||||
payload: data
|
||||
? gzipSync(protobuf_encode<LongMsgResult>(data))
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
}),
|
||||
});
|
||||
bridge.identity.uin = selfUin;
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const ctx = {
|
||||
selfId: Number(selfUin),
|
||||
bridge: { apis: { forward } },
|
||||
converterCtx: {
|
||||
selfId: Number(selfUin),
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
} as unknown as OneBotInstanceContext;
|
||||
return { api: buildApiContext(ctx), requested, bridge, forward };
|
||||
}
|
||||
|
||||
function plainResult(text: string): LongMsgResult {
|
||||
return {
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [message({ text: { str: text } })] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function nestedResult(inner: string, key: string, body?: PushMsgBody): LongMsgResult {
|
||||
return {
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [message(forwardCard('xml', inner, key))] },
|
||||
},
|
||||
{
|
||||
actionCommand: key,
|
||||
actionData: { msgBody: [body ?? message({ text: { str: 'inner content' } })] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function fillCache(
|
||||
api: ReturnType<typeof setup>['api'],
|
||||
downloads: Map<string, LongMsgResult>,
|
||||
prefix: string,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const id = `${prefix}-${index}`;
|
||||
downloads.set(id, plainResult(id));
|
||||
await api.getForwardMsg(id);
|
||||
}
|
||||
}
|
||||
|
||||
describe('get_forward_msg / cache lifetime', () => {
|
||||
it('bounds cached results while refreshing recently read results', async () => {
|
||||
const downloads = new Map([
|
||||
['cache-cold', plainResult('cold')],
|
||||
['cache-hot', plainResult('hot')],
|
||||
]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('cache-cold');
|
||||
await api.getForwardMsg('cache-hot');
|
||||
await fillCache(api, downloads, 'cache-pressure', 510);
|
||||
await api.getForwardMsg('cache-hot');
|
||||
await fillCache(api, downloads, 'cache-overflow', 1);
|
||||
requested.length = 0;
|
||||
await api.getForwardMsg('cache-hot');
|
||||
await api.getForwardMsg('cache-cold');
|
||||
expect(requested).toEqual(['cache-cold']);
|
||||
});
|
||||
|
||||
it('isolates cached content between bridge accounts', async () => {
|
||||
const first = setup(new Map([['same-resource', plainResult('account one')]]));
|
||||
const second = setup(new Map([['same-resource', plainResult('account two')]]), '10002');
|
||||
await first.api.getForwardMsg('same-resource');
|
||||
expect((await second.api.getForwardMsg('same-resource'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'account two' } },
|
||||
]);
|
||||
expect(second.requested).toEqual(['same-resource']);
|
||||
});
|
||||
|
||||
it('keeps nested results with a hot outer result under cache pressure', async () => {
|
||||
const downloads = new Map([['hot-outer', nestedResult('hot-inner', 'hot-inner-key')]]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('hot-outer');
|
||||
await fillCache(api, downloads, 'nested-pressure', 511);
|
||||
await api.getForwardMsg('hot-inner');
|
||||
await fillCache(api, downloads, 'nested-overflow', 1);
|
||||
requested.length = 0;
|
||||
expect((await api.getForwardMsg('hot-outer'))[0]?.message).toEqual([
|
||||
{ type: 'forward', data: { id: 'hot-inner' } },
|
||||
]);
|
||||
expect((await api.getForwardMsg('hot-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'inner content' } },
|
||||
]);
|
||||
expect(requested).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an inner result attached to its outer result after sender enrichment', async () => {
|
||||
const nameless = message({ text: { str: 'unnamed sender' } });
|
||||
nameless.responseHead!.grp!.memberCard = new Uint8Array();
|
||||
const downloads = new Map([
|
||||
['enrich-outer', nestedResult('enrich-inner', 'enrich-key', nameless)],
|
||||
]);
|
||||
const { api, requested, bridge } = setup(downloads);
|
||||
bridge.apis.contacts.fetchGroupMemberList.mockResolvedValue([
|
||||
{ uin: 20002, nickname: 'resolved nickname', card: 'resolved card' },
|
||||
]);
|
||||
await api.getForwardMsg('enrich-outer');
|
||||
await fillCache(api, downloads, 'enrich-pressure', 511);
|
||||
requested.length = 0;
|
||||
expect((await api.getForwardMsg('enrich-inner'))[0]).toMatchObject({
|
||||
sender: { nickname: 'resolved card' },
|
||||
});
|
||||
expect(bridge.apis.contacts.fetchGroupMemberList).toHaveBeenCalledOnce();
|
||||
await api.getForwardMsg('enrich-outer');
|
||||
await api.getForwardMsg('enrich-inner');
|
||||
expect(bridge.apis.contacts.fetchGroupMemberList).toHaveBeenCalledOnce();
|
||||
expect(requested).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains a shared inner result while another containing outer result remains cached', async () => {
|
||||
const downloads = new Map([
|
||||
['shared-outer-a', nestedResult('shared-inner', 'shared-key-a')],
|
||||
['shared-outer-b', nestedResult('shared-inner', 'shared-key-b')],
|
||||
]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('shared-outer-a');
|
||||
await api.getForwardMsg('shared-outer-b');
|
||||
await fillCache(api, downloads, 'shared-pressure', 510);
|
||||
await api.getForwardMsg('shared-outer-a');
|
||||
await fillCache(api, downloads, 'shared-overflow', 1);
|
||||
requested.length = 0;
|
||||
expect((await api.getForwardMsg('shared-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'inner content' } },
|
||||
]);
|
||||
expect(requested).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores all nested results when an evicted outer result is downloaded again', async () => {
|
||||
const downloads = new Map([
|
||||
['restore-outer', nestedResult('restore-inner', 'restore-key')],
|
||||
]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('restore-outer');
|
||||
await api.getForwardMsg('restore-inner');
|
||||
await fillCache(api, downloads, 'restore-pressure', 512);
|
||||
requested.length = 0;
|
||||
await api.getForwardMsg('restore-outer');
|
||||
expect((await api.getForwardMsg('restore-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'inner content' } },
|
||||
]);
|
||||
expect(requested).toEqual(['restore-outer']);
|
||||
});
|
||||
|
||||
it('does not resurrect evicted nested results when enrichment finishes late', async () => {
|
||||
const nameless = message({ text: { str: 'late sender' } });
|
||||
nameless.responseHead!.grp!.memberCard = new Uint8Array();
|
||||
const downloads = new Map([
|
||||
['late-outer', nestedResult('late-inner', 'late-key', nameless)],
|
||||
]);
|
||||
const { api, requested, bridge } = setup(downloads);
|
||||
const members = [{ uin: 20002, nickname: 'late name', card: '' }];
|
||||
let finishEnrichment!: () => void;
|
||||
const enrichment = new Promise<typeof members>((resolve) => {
|
||||
finishEnrichment = () => resolve(members);
|
||||
});
|
||||
bridge.apis.contacts.fetchGroupMemberList.mockReturnValueOnce(enrichment);
|
||||
await api.getForwardMsg('late-outer');
|
||||
const pending = api.getForwardMsg('late-inner');
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.apis.contacts.fetchGroupMemberList).toHaveBeenCalledOnce(),
|
||||
);
|
||||
await fillCache(api, downloads, 'late-pressure', 512);
|
||||
finishEnrichment();
|
||||
expect((await pending)[0]).toMatchObject({ sender: { nickname: 'late name' } });
|
||||
requested.length = 0;
|
||||
await expect(api.getForwardMsg('late-inner')).rejects.toThrow('payload is empty');
|
||||
await api.getForwardMsg('late-outer');
|
||||
expect((await api.getForwardMsg('late-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'late sender' } },
|
||||
]);
|
||||
expect(requested).toEqual(['late-inner', 'late-outer']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_forward_msg / nested downloads (#529)', () => {
|
||||
it.each(['xml', 'lightApp'] as const)(
|
||||
'resolves siblings and three levels of %s forwards from one outer payload',
|
||||
async (format) => {
|
||||
const outer = `nested-fetch-${format}-outer`;
|
||||
const inner = `nested-fetch-${format}-inner`;
|
||||
const sibling = `nested-fetch-${format}-sibling`;
|
||||
const leaf = `nested-fetch-${format}-leaf`;
|
||||
const { api, requested } = setup(
|
||||
new Map([
|
||||
[
|
||||
outer,
|
||||
{
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message(forwardCard(format, inner, 'inner-key')),
|
||||
message(forwardCard(format, sibling, 'sibling-key')),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'leaf-key',
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message(
|
||||
{ text: { str: 'deepest content' } },
|
||||
{
|
||||
customFace: {
|
||||
md5: Buffer.alloc(16, 0xab),
|
||||
origUrl: 'https://example.com/forward.png',
|
||||
width: 32,
|
||||
height: 24,
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'sibling-key',
|
||||
actionData: {
|
||||
msgBody: [message({ text: { str: 'sibling content' } })],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'inner-key',
|
||||
actionData: {
|
||||
msgBody: [message(forwardCard(format, leaf, 'leaf-key'))],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const parent = await api.getForwardMsg(outer);
|
||||
expect(parent.map((node) => node.message)).toEqual([
|
||||
[{ type: 'forward', data: { id: inner } }],
|
||||
[{ type: 'forward', data: { id: sibling } }],
|
||||
]);
|
||||
const middle = await api.getForwardMsg(inner);
|
||||
expect(middle[0]!.message).toEqual([{ type: 'forward', data: { id: leaf } }]);
|
||||
expect((await api.getForwardMsg(leaf))[0]).toMatchObject({
|
||||
sender: { user_id: 20002, nickname: 'alice' },
|
||||
time: 1700000000,
|
||||
message: [
|
||||
{ type: 'text', data: { text: 'deepest content' } },
|
||||
{ type: 'image', data: { url: 'https://example.com/forward.png' } },
|
||||
],
|
||||
});
|
||||
expect((await api.getForwardMsg(sibling))[0]!.message).toEqual([
|
||||
{ type: 'text', data: { text: 'sibling content' } },
|
||||
]);
|
||||
expect(requested).toEqual([outer]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, 'unmatched-key'])(
|
||||
'downloads by resId when the XML filename is %s and preserves empty-payload errors',
|
||||
async (key) => {
|
||||
const outer = `nested-fetch-fallback-${key}-outer`;
|
||||
const inner = `nested-fetch-fallback-${key}-inner`;
|
||||
const empty = `nested-fetch-fallback-${key}-empty`;
|
||||
const { api, requested } = setup(
|
||||
new Map([
|
||||
[
|
||||
outer,
|
||||
{
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message(forwardCard('xml', inner, key)),
|
||||
message(forwardCard('xml', empty, key)),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'unrelated-key',
|
||||
actionData: {
|
||||
msgBody: [message({ text: { str: 'unrelated content' } })],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
inner,
|
||||
{
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: {
|
||||
msgBody: [message({ text: { str: 'downloaded content' } })],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
await api.getForwardMsg(outer);
|
||||
expect((await api.getForwardMsg(inner))[0]!.message).toEqual([
|
||||
{ type: 'text', data: { text: 'downloaded content' } },
|
||||
]);
|
||||
await expect(api.getForwardMsg(empty)).rejects.toThrow(
|
||||
'download forward message payload is empty',
|
||||
);
|
||||
expect(requested).toEqual([outer, inner, empty]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
// Wire-byte alignment regression for nested forward upload — verifies
|
||||
// that the OUTER LongMsgResult's `actions[]` carries:
|
||||
// - `{actionCommand: 'MultiMsg', actionData.msgBody: <outer nodes>}`
|
||||
// - `{actionCommand: <inner.uuid>, actionData.msgBody: <inner nodes>}`
|
||||
// AND that the same uuid appears as `meta.detail.uniseq` inside the
|
||||
// LightApp `com.tencent.multimsg` JSON of the outer's preview element
|
||||
// pointing at the inner res_id.
|
||||
//
|
||||
// Without this alignment the QQ-NT recipient has no way to walk the
|
||||
// piggyback — they see the inner preview's resid, look for a matching
|
||||
// uniseq among the actions, find none, and either fall back to a fresh
|
||||
// server fetch (extra latency) or render an empty bubble.
|
||||
//
|
||||
// Reference impls cross-checked:
|
||||
// dev/NapCatQQ/.../SendMsg.ts:208-347
|
||||
// dev/Lagrange.Core/.../Message/Entity/MultiMsgEntity.cs:43-115
|
||||
// dev/NapCatQQ/.../helper/forward-msg-builder.ts:52-122
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { gunzipSync, inflateSync } from 'zlib';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendLongMsgReq, SendLongMsgResp, LongMsgResult } from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
|
||||
// Don't mock element-builder — we WANT the real forward preview
|
||||
// element to land in the encoded wire bytes so we can verify the
|
||||
// `uniseq` round-trip against the piggyback's `actionCommand`.
|
||||
|
||||
// Mock highway uploads only — they're irrelevant for text-only nodes
|
||||
// but the import would otherwise pull in the native ffmpeg addon.
|
||||
vi.mock('@snowluma/protocol/highway/image-upload', () => ({
|
||||
uploadImageMsgInfo: vi.fn(async () => new Uint8Array()),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({
|
||||
uploadPtt: vi.fn(async () => ({ msgInfo: new Uint8Array() })),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/video-upload', () => ({
|
||||
uploadVideoMsgInfo: vi.fn(async () => new Uint8Array()),
|
||||
}));
|
||||
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SendLongMsgResp>({ result: { resId } })),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeOuterLongMsg(rawBytes: Uint8Array): LongMsgResult {
|
||||
const env = protobuf_decode<SendLongMsgReq>(rawBytes);
|
||||
const payload = env.info?.payload;
|
||||
|
||||
if (!(payload instanceof Uint8Array)) {
|
||||
throw new Error('payload missing on the SendLongMsgReq');
|
||||
}
|
||||
|
||||
return protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
}
|
||||
|
||||
function findLightAppJson(msgBody: PushMsgBody[]): {
|
||||
resid?: string;
|
||||
uniseq?: string;
|
||||
app?: string;
|
||||
news?: Array<{ text: string }>;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
} | null {
|
||||
for (const body of msgBody) {
|
||||
const elems: Elem[] = body.body?.richText?.elems ?? [];
|
||||
|
||||
for (const elem of elems) {
|
||||
if (elem.lightApp?.data && elem.lightApp.data.length > 1) {
|
||||
const data = elem.lightApp.data;
|
||||
|
||||
if (data[0] !== 0x01) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = inflateSync(Buffer.from(data.subarray(1))).toString('utf8');
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
if (parsed?.app === 'com.tencent.multimsg') {
|
||||
return {
|
||||
app: parsed.app,
|
||||
resid: parsed.meta?.detail?.resid,
|
||||
uniseq: parsed.meta?.detail?.uniseq,
|
||||
news: parsed.meta?.detail?.news,
|
||||
source: parsed.meta?.detail?.source,
|
||||
summary: parsed.meta?.detail?.summary,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('forward / nested upload wire alignment', () => {
|
||||
it('bounds uploaded results while keeping a recently read nested upload together', async () => {
|
||||
let sequence = 0;
|
||||
const sendRawPacket = vi.fn(async (command: string) => {
|
||||
if (command !== 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg') {
|
||||
throw new Error('test cache miss');
|
||||
}
|
||||
|
||||
return uploadResponseWithResId(`upload-cache-${++sequence}`);
|
||||
});
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const outer = await forward.upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: 'inner',
|
||||
elements: [{ type: 'text', text: 'nested upload' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const filler = [{ userUin: 111, nickname: 'filler', elements: [] }];
|
||||
|
||||
for (let index = 0; index < 511; index++) {
|
||||
await forward.upload(filler);
|
||||
}
|
||||
|
||||
await forward.fetch('upload-cache-1');
|
||||
await forward.upload(filler);
|
||||
const uploadedCount = sendRawPacket.mock.calls.length;
|
||||
const sameBridgeApi = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
expect((await sameBridgeApi.fetch(outer))[0]?.elements[0]?.resId).toBe('upload-cache-1');
|
||||
expect((await sameBridgeApi.fetch('upload-cache-1'))[0]?.elements).toEqual([
|
||||
{ type: 'text', text: 'nested upload' },
|
||||
]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(uploadedCount);
|
||||
await expect(forward.fetch('upload-cache-3')).rejects.toThrow('test cache miss');
|
||||
});
|
||||
|
||||
it('outer LongMsgResult contains both MultiMsg and the inner uuid piggyback action', async () => {
|
||||
// Inner upload returns inner-res; outer upload returns outer-res.
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resId).toBe('outer-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second call is the OUTER's upload (the inner went first).
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerLongMsg = decodeOuterLongMsg(outerBytes);
|
||||
|
||||
const actions = outerLongMsg.action ?? [];
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]!.actionCommand).toBe('MultiMsg');
|
||||
// The second action carries the inner's msgBody under a uuid
|
||||
// actionCommand (NOT 'MultiMsg').
|
||||
const piggybackUuid = actions[1]!.actionCommand!;
|
||||
expect(piggybackUuid).not.toBe('MultiMsg');
|
||||
expect(piggybackUuid).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(actions[1]!.actionData?.msgBody?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('outer preview LightApp uniseq matches the piggyback actionCommand uuid', async () => {
|
||||
// This is the alignment bug: without it the QQ-NT receiver
|
||||
// can't link the inner preview to its piggyback in actions[].
|
||||
const responses = ['inner-res-A', 'outer-res-A'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerLongMsg = decodeOuterLongMsg(outerBytes);
|
||||
|
||||
const piggybackUuid = outerLongMsg.action![1]!.actionCommand!;
|
||||
const outerMsgBody = outerLongMsg.action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview!.app).toBe('com.tencent.multimsg');
|
||||
expect(preview!.resid).toBe('inner-res-A');
|
||||
expect(preview!.uniseq).toBe(piggybackUuid);
|
||||
});
|
||||
|
||||
it('three-deep nesting: outermost action[] carries 1+2 entries (MultiMsg + 2 piggybacks)', async () => {
|
||||
// The middle level's upload produces 1 MultiMsg + 1 piggyback
|
||||
// (for the deepest). The outermost level then concatenates its
|
||||
// own MultiMsg + the middle's piggyback set, so action[].length = 3.
|
||||
const responses = ['deepest', 'middle', 'outermost'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 1,
|
||||
nickname: 'L1',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 2,
|
||||
nickname: 'L2',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 3,
|
||||
nickname: 'L3',
|
||||
elements: [{ type: 'text', text: 'deep' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
const outermostBytes = sendRawPacket.mock.calls[2]![1] as Uint8Array;
|
||||
const outermost = decodeOuterLongMsg(outermostBytes);
|
||||
expect(outermost.action).toHaveLength(3);
|
||||
expect(outermost.action![0]!.actionCommand).toBe('MultiMsg');
|
||||
expect(outermost.action![1]!.actionCommand).not.toBe('MultiMsg');
|
||||
expect(outermost.action![2]!.actionCommand).not.toBe('MultiMsg');
|
||||
expect(outermost.action![1]!.actionCommand).not.toBe(outermost.action![2]!.actionCommand);
|
||||
});
|
||||
|
||||
it('inner forward bubble prefers caller-supplied node news over auto-generated lines', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const customNews = [{ text: '自定义预览行 1' }, { text: '自定义预览行 2' }];
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
news: customNews,
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: 'inner',
|
||||
elements: [{ type: 'text', text: '会被覆盖的正文' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
// Caller-supplied news wins over `inner: 会被覆盖的正文`.
|
||||
expect(preview!.news).toEqual(customNews);
|
||||
});
|
||||
|
||||
it('inner forward bubble falls back to auto-generated lines when node has no news', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
// no `news` field — auto-generate from inner nodes
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: '内层说话人',
|
||||
elements: [{ type: 'text', text: '第一句台词' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview!.news).toEqual([{ text: '内层说话人: 第一句台词' }]);
|
||||
});
|
||||
|
||||
it('inner forward card prefers caller-supplied source/summary/prompt metadata', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
source: '万叶的手记',
|
||||
summary: '共 12 条剧情消息',
|
||||
prompt: '查看剧情',
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
// decodeOuterLongMsg → lightApp meta.detail as built by element-builder
|
||||
expect(preview!.app).toBe('com.tencent.multimsg');
|
||||
// The plaintext preview lines of the wire bubble.
|
||||
// Caller metadata wins: source = 万叶的手记, summary used instead of
|
||||
// the auto-generated `查看N条转发消息`.
|
||||
expect(preview!.source).toBe('万叶的手记');
|
||||
expect(preview!.summary).toBe('共 12 条剧情消息');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { deflateSync, gunzipSync, gzipSync } from 'node:zlib';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
import type {
|
||||
LongMsgResult,
|
||||
RecvLongMsgReq,
|
||||
RecvLongMsgResp,
|
||||
SendLongMsgReq,
|
||||
SendLongMsgResp,
|
||||
} from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import type { MessageElement } from '@snowluma/protocol/events';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
const OUTER_RES_ID = 'outer-resid';
|
||||
const INNER_UUID = 'C125CE25-AE7E-4E1A-ABDC-9750918162EB';
|
||||
const INNER_FILE_NAME = `MultiMsg_${INNER_UUID}`;
|
||||
|
||||
function packetResult(responseData: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
};
|
||||
}
|
||||
|
||||
function residLessForwardCard(): Elem {
|
||||
const xml =
|
||||
`<msg serviceID="35" templateID="1" action="viewMultiMsg" ` +
|
||||
`m_fileName="${INNER_FILE_NAME}" tSum="2" flag="3" />`;
|
||||
const data = Buffer.concat([Buffer.from([1]), deflateSync(Buffer.from(xml))]);
|
||||
return { richMsg: { serviceId: 35, template1: data } };
|
||||
}
|
||||
|
||||
function message(...elems: Elem[]): PushMsgBody {
|
||||
return {
|
||||
responseHead: {
|
||||
fromUin: 20002,
|
||||
fromUid: 'sender-uid',
|
||||
grp: { groupUin: 12345, memberCard: Buffer.from('alice') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 },
|
||||
body: { richText: { elems } },
|
||||
};
|
||||
}
|
||||
|
||||
function outerResult(innerActionCommand?: string): LongMsgResult {
|
||||
return {
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [message(residLessForwardCard())] },
|
||||
},
|
||||
...(innerActionCommand
|
||||
? [
|
||||
{
|
||||
actionCommand: innerActionCommand,
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message({ text: { str: 'inner one' } }),
|
||||
message({ text: { str: 'inner two' } }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function setup(innerActionCommand?: string) {
|
||||
const requested: string[] = [];
|
||||
const uploads: SendLongMsgReq[] = [];
|
||||
const sendRawPacket = vi.fn(async (command: string, bytes: Uint8Array) => {
|
||||
if (command.endsWith('SsoRecvLongMsg')) {
|
||||
const resId = protobuf_decode<RecvLongMsgReq>(bytes).info!.resId!;
|
||||
requested.push(resId);
|
||||
return packetResult(
|
||||
protobuf_encode<RecvLongMsgResp>({
|
||||
result: {
|
||||
resId,
|
||||
payload:
|
||||
resId === OUTER_RES_ID
|
||||
? gzipSync(
|
||||
protobuf_encode<LongMsgResult>(
|
||||
outerResult(innerActionCommand),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(command).toMatch(/SsoSendLongMsg$/);
|
||||
const upload = protobuf_decode<SendLongMsgReq>(bytes);
|
||||
uploads.push(upload);
|
||||
return packetResult(
|
||||
protobuf_encode<SendLongMsgResp>({ result: { resId: `rebuilt-${uploads.length}` } }),
|
||||
);
|
||||
});
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const sendGroup = vi.fn(async (_groupId: number, _elements: MessageElement[]) => ({
|
||||
messageId: 1,
|
||||
sequence: 2,
|
||||
clientSequence: 3,
|
||||
random: 4,
|
||||
timestamp: 1700000001,
|
||||
}));
|
||||
const ctx = {
|
||||
selfId: 10001,
|
||||
bridge: {
|
||||
identity: { nickname: 'SnowLuma' },
|
||||
apis: { forward, message: { sendGroup } },
|
||||
},
|
||||
messageStore: {
|
||||
findEvent: () => ({
|
||||
message: [{ type: 'forward', data: { id: OUTER_RES_ID } }],
|
||||
}),
|
||||
storeEvent: vi.fn(),
|
||||
},
|
||||
converterCtx: {
|
||||
selfId: 10001,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
cacheMessageMeta: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as unknown as OneBotInstanceContext;
|
||||
|
||||
return { api: buildApiContext(ctx), requested, uploads, sendGroup };
|
||||
}
|
||||
|
||||
describe('resid-less nested forwards (#589)', () => {
|
||||
it.each([INNER_UUID, INNER_FILE_NAME])(
|
||||
'resolves an outer piggyback keyed by %s without another download',
|
||||
async (innerActionCommand) => {
|
||||
const { api, requested } = setup(innerActionCommand);
|
||||
const outer = await api.getForwardMsg(OUTER_RES_ID);
|
||||
expect(outer[0]?.message).toEqual([{ type: 'forward', data: { id: INNER_FILE_NAME } }]);
|
||||
|
||||
const inner = await api.getForwardMsg(INNER_FILE_NAME);
|
||||
expect(inner.map((node) => node.message)).toEqual([
|
||||
[{ type: 'text', data: { text: 'inner one' } }],
|
||||
[{ type: 'text', data: { text: 'inner two' } }],
|
||||
]);
|
||||
expect(requested).toEqual([OUTER_RES_ID]);
|
||||
},
|
||||
);
|
||||
|
||||
it('rebuilds both layers before sending to another group', async () => {
|
||||
const { api, requested, uploads, sendGroup } = setup(INNER_UUID);
|
||||
await expect(api.forwardSingleMsg(77, { groupId: 65432 })).resolves.toHaveProperty(
|
||||
'messageId',
|
||||
);
|
||||
|
||||
expect(requested).toEqual([OUTER_RES_ID]);
|
||||
expect(uploads).toHaveLength(2);
|
||||
const outerBytes = gunzipSync(Buffer.from(uploads[1]!.info!.payload!));
|
||||
const outer = protobuf_decode<LongMsgResult>(outerBytes);
|
||||
expect(outer.action).toHaveLength(2);
|
||||
expect(outerBytes.toString()).not.toContain(INNER_FILE_NAME);
|
||||
expect(sendGroup).toHaveBeenCalledOnce();
|
||||
expect(sendGroup.mock.calls[0]![1]).toEqual([
|
||||
expect.objectContaining({ type: 'forward', resId: 'rebuilt-2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails before upload or send when the inner piggyback is unavailable', async () => {
|
||||
const { api, requested, uploads, sendGroup } = setup();
|
||||
await expect(api.forwardSingleMsg(77, { groupId: 65432 })).rejects.toThrow(
|
||||
'download forward message payload is empty',
|
||||
);
|
||||
|
||||
expect(requested).toEqual([OUTER_RES_ID, INNER_FILE_NAME]);
|
||||
expect(uploads).toHaveLength(0);
|
||||
expect(sendGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { gunzipSync, gzipSync, inflateSync } from 'node:zlib';
|
||||
import type { MsgInfo } from '@snowluma/proto-defs/element';
|
||||
import type {
|
||||
LongMsgResult,
|
||||
RecvLongMsgResp,
|
||||
SendLongMsgReq,
|
||||
SendLongMsgResp,
|
||||
} from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import { buildSendElems } from '@snowluma/protocol/element-builder';
|
||||
import type { MessageElement } from '@snowluma/protocol/events';
|
||||
import { runNtv2Upload } from '@snowluma/protocol/highway/pipeline';
|
||||
import { loadBinarySource } from '@snowluma/protocol/highway/utils';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
// Keep the image uploader, element builder and Proton codecs real. Only the
|
||||
// network boundaries are faked, including a source URL that needs an rkey.
|
||||
vi.mock('@snowluma/protocol/highway/pipeline', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@snowluma/protocol/highway/pipeline')>()),
|
||||
runNtv2Upload: vi.fn(),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@snowluma/protocol/highway/utils')>()),
|
||||
loadBinarySource: vi.fn(async () => {
|
||||
throw new Error('source image URL requires an rkey');
|
||||
}),
|
||||
}));
|
||||
|
||||
const md5Hex = 'ab'.repeat(16);
|
||||
const sha1Hex = 'cd'.repeat(20);
|
||||
const sourceInfo: MsgInfo = {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
fileUuid: 'source-group-image',
|
||||
info: {
|
||||
fileHash: md5Hex,
|
||||
fileSha1: sha1Hex,
|
||||
fileSize: 1234,
|
||||
fileName: 'image.png',
|
||||
width: 32,
|
||||
height: 24,
|
||||
type: { type: 1, picFormat: 1001 },
|
||||
},
|
||||
},
|
||||
picture: {
|
||||
domain: 'multimedia.nt.qq.com.cn',
|
||||
urlPath: '/download?appid=1407&fileid=source-group-image',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function packetResult(responseData: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(id: string) {
|
||||
const sourceBody: PushMsgBody = {
|
||||
responseHead: {
|
||||
fromUin: 20002,
|
||||
fromUid: 'sender-uid',
|
||||
grp: { groupUin: 12345, memberCard: Buffer.from('alice') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 },
|
||||
body: {
|
||||
richText: {
|
||||
elems: [
|
||||
{ text: { str: 'picture' } },
|
||||
{
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
businessType: 20,
|
||||
pbElem: protobuf_encode<MsgInfo>(sourceInfo),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const sentLongMessages: SendLongMsgReq[] = [];
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid: vi.fn(async () => 'self-uid'),
|
||||
sendRawPacket: vi.fn(async (command: string, bytes: Uint8Array) => {
|
||||
if (command.endsWith('SsoRecvLongMsg')) {
|
||||
return packetResult(
|
||||
protobuf_encode<RecvLongMsgResp>({
|
||||
result: {
|
||||
payload: gzipSync(
|
||||
protobuf_encode<LongMsgResult>({
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [sourceBody] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(command).toMatch(/SsoSendLongMsg$/);
|
||||
sentLongMessages.push(protobuf_decode<SendLongMsgReq>(bytes));
|
||||
return packetResult(
|
||||
protobuf_encode<SendLongMsgResp>({ result: { resId: `new-${id}` } }),
|
||||
);
|
||||
}),
|
||||
});
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const sendPrivate = vi.fn(async (_userId: number, elements: MessageElement[]) => {
|
||||
const [card] = await buildSendElems(elements);
|
||||
expect(card?.richMsg?.serviceId).toBe(35);
|
||||
const xml = inflateSync(card!.richMsg!.template1!.subarray(1)).toString();
|
||||
expect(xml).toContain(`m_resid="new-${id}"`);
|
||||
expect(xml).toContain('alice: picture [图片]');
|
||||
return { messageId: 1, sequence: 2, clientSequence: 3, random: 4, timestamp: 1700000001 };
|
||||
});
|
||||
const ctx = {
|
||||
selfId: 10001,
|
||||
bridge: { identity: { nickname: 'SnowLuma' }, apis: { forward, message: { sendPrivate } } },
|
||||
messageStore: {
|
||||
findEvent: () => ({ message: [{ type: 'forward', data: { id } }] }),
|
||||
storeEvent: vi.fn(),
|
||||
},
|
||||
converterCtx: {
|
||||
selfId: 10001,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
cacheMessageMeta: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as unknown as OneBotInstanceContext;
|
||||
return { api: buildApiContext(ctx), forward, sendPrivate, sentLongMessages, bridge };
|
||||
}
|
||||
|
||||
describe('single merged-forward media (#437)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(runNtv2Upload).mockImplementation(async (params) => ({
|
||||
msgInfo: {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
fileUuid: 'target-private-image',
|
||||
info: params.uploadInfo[0]!.fileInfo,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('reuploads a decoded group image for self-chat without downloading its unsigned source URL', async () => {
|
||||
const { api, forward, sentLongMessages, bridge } = setup('image-to-self');
|
||||
await expect(api.forwardSingleMsg(77, { userId: 10001 })).resolves.toHaveProperty(
|
||||
'messageId',
|
||||
);
|
||||
|
||||
expect(loadBinarySource).not.toHaveBeenCalled();
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
expect(runNtv2Upload).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(runNtv2Upload).mock.calls[0]![0]).toMatchObject({
|
||||
isGroup: false,
|
||||
targetIdOrUid: 'self-uid',
|
||||
oidbCmd: 0x11c5,
|
||||
uploadInfo: [
|
||||
{
|
||||
fileInfo: {
|
||||
fileHash: md5Hex,
|
||||
fileSha1: sha1Hex,
|
||||
width: 32,
|
||||
height: 24,
|
||||
fileSize: 1234,
|
||||
},
|
||||
},
|
||||
],
|
||||
uploads: [{ bytes: new Uint8Array(0) }],
|
||||
});
|
||||
expect(sentLongMessages).toHaveLength(1);
|
||||
expect(sentLongMessages[0]!.info?.type).toBe(1);
|
||||
const body = protobuf_decode<LongMsgResult>(
|
||||
gunzipSync(sentLongMessages[0]!.info!.payload!),
|
||||
);
|
||||
const elems = body.action![0]!.actionData!.msgBody![0]!.body!.richText!.elems!;
|
||||
expect(elems.find((e) => e.commonElem)?.commonElem?.businessType).toBe(10);
|
||||
const media = protobuf_decode<MsgInfo>(
|
||||
elems.find((e) => e.commonElem)!.commonElem!.pbElem!,
|
||||
);
|
||||
expect(media.msgInfoBody?.[0]?.index?.fileUuid).toBe('target-private-image');
|
||||
expect(elems.find((e) => e.notOnlineImage)?.notOnlineImage?.picMd5).toEqual(
|
||||
Buffer.from(md5Hex, 'hex'),
|
||||
);
|
||||
expect(
|
||||
(await forward.fetch('image-to-self'))[0]!.elements[1]!.noByteFallback,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not send a card or upload a long message when target image upload fails', async () => {
|
||||
vi.mocked(runNtv2Upload).mockRejectedValueOnce(
|
||||
new Error('image fast-upload not available (server requires bytes)'),
|
||||
);
|
||||
const { api, sendPrivate, sentLongMessages } = setup('image-upload-failure');
|
||||
await expect(api.forwardSingleMsg(77, { userId: 10001 })).rejects.toThrow(
|
||||
'image fast-upload not available',
|
||||
);
|
||||
expect(sendPrivate).not.toHaveBeenCalled();
|
||||
expect(sentLongMessages).toHaveLength(0);
|
||||
expect(loadBinarySource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', () => ({
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
encodeOidbEnv: vi.fn(() => new Uint8Array()),
|
||||
decodeOidbEnv: vi.fn(() => ({ body: {} })),
|
||||
}));
|
||||
|
||||
// element-builder reaches into protoEncode with element-specific schemas
|
||||
// that we don't want to construct manually in tests; stub it to return
|
||||
// a benign placeholder.
|
||||
vi.mock('@snowluma/protocol/element-builder', () => ({
|
||||
buildSendElems: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { gunzipSync, gzipSync } from 'zlib';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
SendLongMsgReq,
|
||||
SendLongMsgResp,
|
||||
LongMsgResult,
|
||||
RecvLongMsgResp,
|
||||
} from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
/** Decode the SsoSendLongMsg packet body back into the per-node contentHead
|
||||
* timestamps of the MultiMsg action — how #209's custom time is verified on
|
||||
* the wire. */
|
||||
function sentNodeTimestamps(body: Uint8Array): number[] {
|
||||
const req = protobuf_decode<SendLongMsgReq>(body);
|
||||
const payload = req.info?.payload;
|
||||
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const longMsg = protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
const main = longMsg.action?.find((a) => a?.actionCommand === 'MultiMsg');
|
||||
const bodies = Array.isArray(main?.actionData?.msgBody) ? main!.actionData!.msgBody : [];
|
||||
return bodies.map((b) => Number(b.contentHead?.timestamp ?? 0));
|
||||
}
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
const encoded = protobuf_encode<SendLongMsgResp>({ result: { resId } });
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(encoded),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an SsoRecvLongMsg response wrapping the given forward nodes. */
|
||||
function recvLongMsgResp(bodies: PushMsgBody[]) {
|
||||
const longMsg: LongMsgResult = {
|
||||
action: [{ actionCommand: 'MultiMsg', actionData: { msgBody: bodies } }],
|
||||
};
|
||||
const gz = gzipSync(Buffer.from(protobuf_encode<LongMsgResult>(longMsg)));
|
||||
return recvCompressedLongMsgResp(gz);
|
||||
}
|
||||
|
||||
function recvCompressedLongMsgResp(payload: Uint8Array) {
|
||||
const resp = protobuf_encode<RecvLongMsgResp>({ result: { payload } });
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(resp),
|
||||
};
|
||||
}
|
||||
|
||||
describe('actions/forward', () => {
|
||||
it('uploadForwardNodes rejects empty arrays', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new ForwardApi(bridge as any).upload([])).rejects.toThrow(/required/);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes dispatches to SsoSendLongMsg with a non-empty body and returns the res_id', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-001')) as any,
|
||||
});
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{ userUin: 10001, nickname: 'alice', elements: [] },
|
||||
]);
|
||||
|
||||
expect(resId).toBe('res-001');
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [serviceCmd, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.group.long_msg_interface.MsgService.SsoSendLongMsg');
|
||||
expect((body as Uint8Array).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('honours a custom per-node time on the wire, and defaults to now when absent (#209)', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-time')) as any,
|
||||
});
|
||||
|
||||
const customTime = 1600000000; // 2020-09-13, clearly not "now"
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{ userUin: 10001, nickname: 'alice', elements: [], time: customTime },
|
||||
{ userUin: 10002, nickname: 'bob', elements: [] }, // no time → fallback
|
||||
]);
|
||||
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const [tsAlice, tsBob] = sentNodeTimestamps(body as Uint8Array);
|
||||
|
||||
expect(tsAlice).toBe(customTime);
|
||||
// Bob falls back to the current time (seconds).
|
||||
expect(Math.abs(tsBob - Math.floor(Date.now() / 1000))).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes throws when sendRawPacket reports failure', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'pipe broken',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload([{ userUin: 10001, nickname: 'a', elements: [] }]),
|
||||
).rejects.toThrow(/pipe broken/);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes throws when the response is missing res_id', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('')) as any,
|
||||
});
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload([{ userUin: 10001, nickname: 'a', elements: [] }]),
|
||||
).rejects.toThrow(/missing res_id/);
|
||||
});
|
||||
|
||||
it('cached fetch after upload keeps a usable image source (#441)', async () => {
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId('res-img-cache')) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const source = 'https://cdn.example/bot-built.png';
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'image', url: source }],
|
||||
},
|
||||
]);
|
||||
const fetched = await new ForwardApi(bridge as any).fetch(resId);
|
||||
|
||||
expect(fetched).toHaveLength(1);
|
||||
expect(fetched[0]!.elements[0]).toMatchObject({
|
||||
type: 'image',
|
||||
url: source,
|
||||
imageUrl: source,
|
||||
});
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetchForwardNodes serves from cache after a successful upload (no second sendRawPacket)', async () => {
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId('res-cache')) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const nodes = [
|
||||
{ userUin: 10001, nickname: 'alice', elements: [] },
|
||||
{ userUin: 10002, nickname: 'bob', elements: [] },
|
||||
];
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload(nodes);
|
||||
expect(resId).toBe('res-cache');
|
||||
|
||||
const fetched = await new ForwardApi(bridge as any).fetch('res-cache');
|
||||
// Same nicknames + uins; elements have been deep-copied so the shape
|
||||
// is preserved but the array reference is different.
|
||||
expect(fetched).toHaveLength(2);
|
||||
expect(fetched.map((n: { nickname: string }) => n.nickname)).toEqual(['alice', 'bob']);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('[#201] fills a group forward node nickname from grp.groupName when memberName is empty', async () => {
|
||||
// Real merged-forward nodes leave grp.memberName (field 2) null and carry the
|
||||
// sender display name in grp.memberCard (field 4). Verified on-target: fromUin
|
||||
// 1787882683 ↔ grp.memberCard "墨梓柒". Before the fix the node nickname came
|
||||
// back "" and enrichSenders 0x899-AUTHORITY_FAIL'd on the placeholder group.
|
||||
const node: PushMsgBody = {
|
||||
responseHead: {
|
||||
fromUin: 1787882683,
|
||||
fromUid: 'u_x',
|
||||
grp: { groupUin: 284840486, memberCard: Buffer.from('墨梓柒') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 1, timestamp: 100 }, // 82 = PkgType.GroupMessage
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
};
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => recvLongMsgResp([node])) as any,
|
||||
});
|
||||
const nodes = await new ForwardApi(bridge as any).fetch('res-201');
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]!.userUin).toBe(1787882683);
|
||||
expect(nodes[0]!.nickname).toBe('墨梓柒'); // was '' before the fix
|
||||
expect(nodes[0]!.groupId).toBe(284840486);
|
||||
// The nickname is present, so no group-member-list enrichment fetch fires.
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetchForwardNodes throws on transport failure when nothing is cached', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'down',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(new ForwardApi(bridge as any).fetch('cold-cache-miss')).rejects.toThrow(
|
||||
/download forward message failed|down/,
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds long-message gunzip output and reports decompression context', async () => {
|
||||
// This is one byte beyond the production 32 MiB ceiling but compresses to
|
||||
// a small response, modelling an otherwise cheap gzip bomb from QQ.
|
||||
const expandedBytes = 32 * 1024 * 1024 + 1;
|
||||
const payload = gzipSync(Buffer.alloc(expandedBytes, 0x61));
|
||||
const resId = 'oversized-compressed-forward';
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => recvCompressedLongMsgResp(payload)) as any,
|
||||
});
|
||||
|
||||
await expect(new ForwardApi(bridge as any).fetch(resId)).rejects.toThrow(
|
||||
/download forward message decompression failed .*res_id=oversized-compressed-forward.*max_output_bytes=33554432/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes recursively uploads nested innerForward chains (NapCat piggyback model)', async () => {
|
||||
// Regression: nested forward needs the inner chain to be uploaded
|
||||
// first (so we have its res_id for the outer ARK preview), AND
|
||||
// the inner level's msgBody to be carried up to the outermost
|
||||
// long-msg upload as an extra `actionCommand` slot keyed on a
|
||||
// uuid. Matches `dev/NapCatQQ/.../SendMsg.uploadForwardedNodesPacket`
|
||||
// — the receiver gets the whole tree from a single fetch instead
|
||||
// of resolving each layer's res_id separately.
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Two server roundtrips: one for the inner chain, then one for
|
||||
// the outer (which piggybacks the inner msgBody onto its actions
|
||||
// array). The outer res_id is what the caller gets back.
|
||||
expect(resId).toBe('outer-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes leaves flat (non-nested) sends at a single roundtrip', async () => {
|
||||
// Backwards-compat: the common case (no inner forward) must still
|
||||
// be one SsoSendLongMsg call. Without this we'd double upload
|
||||
// every regular forward send.
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId('flat-res')) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{ userUin: 10001, nickname: 'a', elements: [{ type: 'text', text: 'hello' }] },
|
||||
{ userUin: 10002, nickname: 'b', elements: [{ type: 'text', text: 'world' }] },
|
||||
]);
|
||||
|
||||
expect(resId).toBe('flat-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions/forward — sender name enrichment (#174)', () => {
|
||||
function apiWith(contacts: { fetchGroupMemberList: any; fetchUserProfile: any }) {
|
||||
return new ForwardApi({ apis: { contacts } } as any);
|
||||
}
|
||||
|
||||
it('fills empty / "QQ用户" names via the group member list (L3), card preferred', async () => {
|
||||
const fetchGroupMemberList = vi.fn(async () => [
|
||||
{ uin: 10001, nickname: 'AliceNick', card: 'AliceCard' },
|
||||
{ uin: 10002, nickname: 'BobNick', card: '' },
|
||||
]);
|
||||
const fetchUserProfile = vi.fn(async (uin: number) => ({ uin, nickname: 'Stranger' }));
|
||||
const api = apiWith({ fetchGroupMemberList, fetchUserProfile });
|
||||
|
||||
const nodes: any[] = [
|
||||
{ userUin: 10001, nickname: '', groupId: 700, messageType: 'group', elements: [] },
|
||||
{
|
||||
userUin: 10002,
|
||||
nickname: 'QQ用户',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
},
|
||||
{ userUin: 10003, nickname: '', groupId: 700, messageType: 'group', elements: [] }, // not in list
|
||||
{ userUin: 10009, nickname: '', messageType: 'private', elements: [] }, // private
|
||||
{
|
||||
userUin: 10005,
|
||||
nickname: 'KeepMe',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
},
|
||||
];
|
||||
const changed = await (api as any).enrichSenders(nodes);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(nodes[0].nickname).toBe('AliceCard'); // card preferred over nick
|
||||
expect(nodes[0].senderCard).toBe('AliceCard');
|
||||
expect(nodes[1].nickname).toBe('BobNick'); // card empty → nickname
|
||||
expect(nodes[2].nickname).toBe('Stranger'); // not in member list → L4 profile
|
||||
expect(nodes[3].nickname).toBe('Stranger'); // private node → L4 profile
|
||||
expect(nodes[4].nickname).toBe('KeepMe'); // already named → untouched
|
||||
expect(fetchGroupMemberList).toHaveBeenCalledTimes(1); // one fetch covers the whole group
|
||||
});
|
||||
|
||||
it('is a no-op (no fetch) when every node already has a name', async () => {
|
||||
const fetchGroupMemberList = vi.fn();
|
||||
const fetchUserProfile = vi.fn();
|
||||
const api = apiWith({ fetchGroupMemberList, fetchUserProfile });
|
||||
const nodes: any[] = [
|
||||
{ userUin: 1, nickname: 'has-name', groupId: 7, messageType: 'group', elements: [] },
|
||||
];
|
||||
expect(await (api as any).enrichSenders(nodes)).toBe(false);
|
||||
expect(fetchGroupMemberList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the placeholder and never throws when resolution fails', async () => {
|
||||
const api = apiWith({
|
||||
fetchGroupMemberList: vi.fn(async () => {
|
||||
throw new Error('net down');
|
||||
}),
|
||||
fetchUserProfile: vi.fn(async () => {
|
||||
throw new Error('net down');
|
||||
}),
|
||||
});
|
||||
const nodes: any[] = [
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'QQ用户',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
},
|
||||
];
|
||||
await expect((api as any).enrichSenders(nodes)).resolves.toBe(false);
|
||||
expect(nodes[0].nickname).toBe('QQ用户');
|
||||
});
|
||||
|
||||
it('does NOT fan a member-list failure out to per-uin profile lookups (rate-limit guard)', async () => {
|
||||
const fetchUserProfile = vi.fn(async (uin: number) => ({ uin, nickname: 'X' }));
|
||||
const api = apiWith({
|
||||
fetchGroupMemberList: vi.fn(async () => {
|
||||
throw new Error('net');
|
||||
}),
|
||||
fetchUserProfile,
|
||||
});
|
||||
// 5 distinct group senders whose member list errors → must keep placeholder,
|
||||
// NOT trigger 5 profile calls.
|
||||
const nodes: any[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
userUin: 20000 + i,
|
||||
nickname: '',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
}));
|
||||
expect(await (api as any).enrichSenders(nodes)).toBe(false);
|
||||
expect(fetchUserProfile).not.toHaveBeenCalled();
|
||||
expect(nodes.every((n) => n.nickname === '')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
OidbBase,
|
||||
OidbFriend,
|
||||
OidbSvcTrpcTcp0xFD4_1Response,
|
||||
} from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbUserInfoRequest,
|
||||
OidbUserInfoResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { Bridge } from '../../src/bridge/bridge';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<void>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const bridges: Bridge[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function friend(uin: number, name = 'Roster'): OidbFriend {
|
||||
return {
|
||||
uin,
|
||||
uid: `u_${uin}`,
|
||||
customGroup: 7,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: [
|
||||
{ code: 20002, value: Buffer.from(name) },
|
||||
{ code: 102, value: Buffer.from('') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function setup(roster: () => OidbFriend[], load: (uin: number) => Promise<void> = async () => {}) {
|
||||
const bridge = new Bridge(IdentityService.memory('90001'));
|
||||
bridges.push(bridge);
|
||||
const profileCalls: number[] = [];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const send = vi.spyOn(bridge, 'sendRawPacket').mockImplementation(async (cmd, payload) => {
|
||||
let responseData: Uint8Array;
|
||||
|
||||
if (cmd === 'OidbSvcTrpcTcp.0xfd4_1') {
|
||||
const friends = roster();
|
||||
responseData = protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
|
||||
body: {
|
||||
friends,
|
||||
categories: [
|
||||
{ categoryId: 7, categoryName: 'Friends', memberCount: friends.length },
|
||||
],
|
||||
},
|
||||
});
|
||||
} else if (cmd === 'OidbSvcTrpcTcp.0xfe1_2') {
|
||||
const uin = protobuf_decode<OidbBase<OidbUserInfoRequest>>(payload).body!.uin!;
|
||||
profileCalls.push(uin);
|
||||
peak = Math.max(peak, ++active);
|
||||
|
||||
try {
|
||||
await load(uin);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
|
||||
responseData = protobuf_encode<OidbBase<OidbUserInfoResponse>>({
|
||||
body: {
|
||||
body: {
|
||||
uin,
|
||||
uid: `u_${uin}`,
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 102, value: Buffer.from('Profile sign') },
|
||||
{ code: 20020, value: Buffer.from('City') },
|
||||
],
|
||||
numberProperties: [
|
||||
{ number1: 20037, number2: 0 },
|
||||
{ number1: 41756, number2: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error(`unexpected request ${cmd}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
};
|
||||
});
|
||||
return {
|
||||
bridge,
|
||||
api: bridge.apis.contacts,
|
||||
send,
|
||||
profileCalls,
|
||||
peak: () => peak,
|
||||
active: () => active,
|
||||
};
|
||||
}
|
||||
|
||||
describe('explicit friend profile enrichment', () => {
|
||||
it('releases profile cache timers when its Bridge is disposed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, bridge, profileCalls } = setup(() => [friend(10001)]);
|
||||
await api.fetchFriendList({ withProfile: true });
|
||||
expect(profileCalls).toEqual([10001]);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await bridge.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not start profile cache loads when a roster response arrives after disposal', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, bridge, send, profileCalls } = setup(() => [friend(10001)]);
|
||||
const response = await send.getMockImplementation()!(
|
||||
'OidbSvcTrpcTcp.0xfd4_1',
|
||||
new Uint8Array(),
|
||||
);
|
||||
let resolve!: (value: typeof response) => void;
|
||||
send.mockReturnValueOnce(
|
||||
new Promise((yes) => {
|
||||
resolve = yes;
|
||||
}),
|
||||
);
|
||||
const pending = api.fetchFriendList({ withProfile: true });
|
||||
const checked = expect(pending).rejects.toThrow('friend profile enrichment failed');
|
||||
await bridge.dispose();
|
||||
|
||||
resolve(response);
|
||||
await checked;
|
||||
expect(profileCalls).toEqual([]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not fan out on a cold 160-friend ordinary or categorized list', async () => {
|
||||
const { api, profileCalls, send } = setup(() =>
|
||||
Array.from({ length: 160 }, (_, i) => friend(10000 + i)),
|
||||
);
|
||||
expect(await api.fetchFriendList()).toHaveLength(160);
|
||||
expect((await api.fetchFriendCategories())[0]?.friends).toHaveLength(160);
|
||||
expect(profileCalls).toEqual([]);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares the cache across list forms, preserves roster values and isolates accounts', async () => {
|
||||
let now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const one = setup(() => [friend(10001)]);
|
||||
const first = await one.api.fetchFriendList({ withProfile: true });
|
||||
expect(first[0]).toMatchObject({
|
||||
nickname: 'Roster',
|
||||
profile: { sign: '', age: 0, vipFlag: false, city: 'City' },
|
||||
});
|
||||
first[0]!.profile!.city = 'caller mutation';
|
||||
const grouped = await one.api.fetchFriendCategories({ withProfile: true });
|
||||
expect(grouped[0]?.friends[0]?.profile?.city).toBe('City');
|
||||
expect(one.profileCalls).toEqual([10001]);
|
||||
const two = setup(() => [friend(10001)]);
|
||||
await two.api.fetchFriendList({ withProfile: true });
|
||||
expect(two.profileCalls).toEqual([10001]);
|
||||
now += 300_001;
|
||||
await one.api.fetchFriendList({ withProfile: true });
|
||||
expect(one.profileCalls).toEqual([10001, 10001]);
|
||||
});
|
||||
|
||||
it('deduplicates overlapping lists and caps total concurrent queries at four', async () => {
|
||||
const gates = new Map<number, ReturnType<typeof deferred>>();
|
||||
let page = 0;
|
||||
const { api, profileCalls, peak } = setup(
|
||||
() => (page++ === 0 ? [1, 2, 3, 4] : [3, 4, 5, 6]).map((id) => friend(10000 + id)),
|
||||
(uin) => {
|
||||
const gate = deferred();
|
||||
gates.set(uin, gate);
|
||||
return gate.promise;
|
||||
},
|
||||
);
|
||||
const first = api.fetchFriendList({ withProfile: true });
|
||||
const second = api.fetchFriendCategories({ withProfile: true });
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(4));
|
||||
|
||||
for (const gate of gates.values()) {
|
||||
gate.resolve();
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(6));
|
||||
|
||||
for (const gate of gates.values()) {
|
||||
gate.resolve();
|
||||
}
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(new Set(profileCalls).size).toBe(6);
|
||||
expect(peak()).toBe(4);
|
||||
});
|
||||
|
||||
it('drains a failed batch, surfaces the failure and retries only on a later request', async () => {
|
||||
const gates = new Map<number, ReturnType<typeof deferred>>();
|
||||
let blocking = true;
|
||||
const { api, bridge, profileCalls, active } = setup(
|
||||
() => [1, 2, 3, 4, 5].map((id) => friend(10000 + id)),
|
||||
(uin) => {
|
||||
if (!blocking) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const gate = deferred();
|
||||
gates.set(uin, gate);
|
||||
return gate.promise;
|
||||
},
|
||||
);
|
||||
let finished = false;
|
||||
const result = api.fetchFriendList({ withProfile: true });
|
||||
const checked = expect(result).rejects.toThrow('friend profile enrichment failed');
|
||||
void result.then(
|
||||
() => {
|
||||
finished = true;
|
||||
},
|
||||
() => {
|
||||
finished = true;
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(4));
|
||||
gates.get(10001)!.reject(new Error('synthetic timeout'));
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
|
||||
for (const [uin, gate] of gates) {
|
||||
if (uin !== 10001) {
|
||||
gate.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
await checked;
|
||||
expect(active()).toBe(0);
|
||||
expect(profileCalls).toHaveLength(4);
|
||||
expect(bridge.identity.friends).toEqual([]);
|
||||
blocking = false;
|
||||
await api.fetchFriendList({ withProfile: true });
|
||||
expect(profileCalls).toEqual([10001, 10002, 10003, 10004, 10001, 10005]);
|
||||
});
|
||||
|
||||
it('does not let a slow enriched snapshot replace a newer ordinary roster', async () => {
|
||||
const gate = deferred();
|
||||
let page = 0;
|
||||
const { api, bridge, profileCalls } = setup(
|
||||
() => [friend(10001, page++ === 0 ? 'Old' : 'New')],
|
||||
() => gate.promise,
|
||||
);
|
||||
const old = api.fetchFriendList({ withProfile: true });
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(1));
|
||||
await api.fetchFriendList();
|
||||
gate.resolve();
|
||||
expect((await old)[0]?.nickname).toBe('Old');
|
||||
expect(bridge.identity.findFriend(10001)?.nickname).toBe('New');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbDeleteFriend,
|
||||
OidbFriendRequestAction,
|
||||
OidbSetFriendRemarkResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type {
|
||||
OidbDoubtApprovalReq,
|
||||
OidbDoubtDelReq,
|
||||
OidbDoubtGetResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
|
||||
|
||||
// Post-namespace migration: FriendApi is a thin facade over the
|
||||
// namespaces under @snowluma/protocol/oidb-services/friend. Tests assert
|
||||
// against the bridge mock's sendRawPacket directly — no need for
|
||||
// module-level bridge-oidb mocks anymore.
|
||||
import { DATALINE_UIN_PAD } from '@snowluma/protocol/dataline/device-contacts';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { FriendApi } from '../../src/bridge/apis/friend';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/friend', () => {
|
||||
it('handleRequest: numeric input is treated as UIN and resolved', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).handleRequest('10001', true);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xb5d_44');
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body).toMatchObject({ accept: 3, targetUid: 'resolved-uid' });
|
||||
});
|
||||
|
||||
it('handleRequest: non-numeric flag is forwarded as-is', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).handleRequest('flag-abc', false);
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body).toMatchObject({ accept: 5, targetUid: 'flag-abc' });
|
||||
});
|
||||
|
||||
it('delete resolves UID, calls 0x126b_0, and triggers a friend-list refresh', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).delete(10001, true);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x126b_0');
|
||||
const env = protobuf_decode<OidbBase<OidbDeleteFriend>>(bytes);
|
||||
expect(env.body?.field1?.block).toBe(true);
|
||||
expect(bridge.apis.contacts.fetchFriendList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a friend-list refresh failure without misreporting the completed delete', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.apis.contacts.fetchFriendList = vi.fn(async () => {
|
||||
throw new Error('cache miss');
|
||||
});
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => {
|
||||
if (entry.scope === 'Bridge.Friend') {
|
||||
captured.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(new FriendApi(bridge as any).delete(10001)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(captured.map(({ level, message }) => ({ level, message }))).toEqual([
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'friend-list refresh failed after deleting user=10001: cache miss',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('setRemark uses the current set command for a non-empty remark', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSetFriendRemarkResponse>>({
|
||||
body: {
|
||||
result: {
|
||||
target: { targetUid: 'resolved-uid', targetUin: 10001n },
|
||||
remark: 'best-friend',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
})),
|
||||
});
|
||||
await new FriendApi(bridge as any).setRemark(10001, 'best-friend');
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x912e_0');
|
||||
expect(bridge.identity.updateFriendRemark).toHaveBeenCalledWith(
|
||||
'resolved-uid',
|
||||
10001,
|
||||
'best-friend',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to delete or rename a my-device contact', async () => {
|
||||
const bridge = mockBridge();
|
||||
const api = new FriendApi(bridge as any);
|
||||
await expect(api.delete(DATALINE_UIN_PAD)).rejects.toThrow('cannot be deleted');
|
||||
await expect(api.setRemark(DATALINE_UIN_PAD, 'pad')).rejects.toThrow(
|
||||
'does not support remarks',
|
||||
);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setRemark uses the dedicated clear command for an empty remark', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).setRemark(10001, '');
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x912f_0');
|
||||
});
|
||||
|
||||
it('getDoubtRequests fills a missing uid via resolveUserUid', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: encodeNumericDoubtList([
|
||||
{ nick: 'Alice', uin: 12345n, reqTime: 1700000000n },
|
||||
]),
|
||||
})),
|
||||
});
|
||||
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
|
||||
expect(list).toEqual([
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
user_id: 12345,
|
||||
nick: 'Alice',
|
||||
source: '',
|
||||
reason: '',
|
||||
msg: '',
|
||||
group_code: '',
|
||||
reqTime: 1700000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getDoubtRequests keeps a wire uid and does not resolve', async () => {
|
||||
const bridge = mockBridge({
|
||||
identity: { findUinByUid: vi.fn(() => 12345) } as any,
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: encodeDoubtList([
|
||||
{ uid: 'u_alice', nick: 'Alice', reqTime: 1700000000n },
|
||||
]),
|
||||
})),
|
||||
});
|
||||
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(list[0]?.uid).toBe('u_alice');
|
||||
expect(list[0]?.user_id).toBe(12345);
|
||||
});
|
||||
|
||||
it('getDoubtRequests keeps an empty uid when resolveUserUid fails', async () => {
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid: vi.fn(async () => {
|
||||
throw new Error('no mapping');
|
||||
}),
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: encodeNumericDoubtList([{ nick: 'Alice', uin: 12345n }]),
|
||||
})),
|
||||
});
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => {
|
||||
if (entry.scope === 'Bridge.Friend') {
|
||||
captured.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
|
||||
expect(list[0]?.uid).toBe('');
|
||||
expect(list[0]?.user_id).toBe(12345);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(captured.map(({ level, message }) => ({ level, message }))).toEqual([
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'doubt-request uid resolve failed: user=12345 err=no mapping',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('approveDoubtRequest resolves a digit-only flag before sending', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).approveDoubtRequest('12345');
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
|
||||
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd72_0');
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
selfUid: 'self-uid',
|
||||
targetUid: 'resolved-uid',
|
||||
field3: 0,
|
||||
field4: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('approveDoubtRequest forwards a uid flag without resolving', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).approveDoubtRequest('u_abc');
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ selfUid: 'self-uid', targetUid: 'u_abc' });
|
||||
});
|
||||
|
||||
it('approves a filtered stranger through Identity without requiring a shared roster', async () => {
|
||||
const identity = new IdentityService('10001', null);
|
||||
await identity.initialization;
|
||||
const bridge = mockBridge();
|
||||
const fetchProfile = vi.fn(async (uin: number) => ({
|
||||
uin,
|
||||
uid: uin === 10001 ? 'u_self' : 'u_stranger',
|
||||
}));
|
||||
identity.setFetcher({ fetchProfile: fetchProfile as any });
|
||||
bridge.identity = identity as any;
|
||||
bridge.resolveUserUid = vi.fn((uin: number) => identity.resolveUid(uin));
|
||||
|
||||
try {
|
||||
await new FriendApi(bridge as any).approveDoubtRequest('12345');
|
||||
expect(fetchProfile.mock.calls.map(([uin]) => uin)).toEqual([12345, 10001]);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ selfUid: 'u_self', targetUid: 'u_stranger' });
|
||||
} finally {
|
||||
await identity.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not send an approval when the applicant cannot be resolved', async () => {
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid: vi.fn(async () => {
|
||||
throw new Error('applicant lookup failed');
|
||||
}),
|
||||
});
|
||||
await expect(new FriendApi(bridge as any).approveDoubtRequest('12345')).rejects.toThrow(
|
||||
'applicant lookup failed',
|
||||
);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejectDoubtRequest resolves a digit-only flag before sending', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).rejectDoubtRequest('12345');
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtDelReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ field1: 3, inner: { uid: 'resolved-uid' } });
|
||||
});
|
||||
});
|
||||
|
||||
function encodeDoubtList(list: NonNullable<NonNullable<OidbDoubtGetResp['body']>['list']>): Buffer {
|
||||
return Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbDoubtGetResp>>({
|
||||
command: 0xd69,
|
||||
subCommand: 0,
|
||||
body: { status: 1, body: { list } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface NumericDoubtItem {
|
||||
uin?: pb<1, uint_64>;
|
||||
nick?: pb<2, string>;
|
||||
reqTime?: pb<8, uint_64>;
|
||||
}
|
||||
interface NumericDoubtBody {
|
||||
list?: pb_repeated<1, NumericDoubtItem>;
|
||||
}
|
||||
interface NumericDoubtResponse {
|
||||
status?: pb<1, uint_32>;
|
||||
body?: pb<2, NumericDoubtBody>;
|
||||
}
|
||||
|
||||
function encodeNumericDoubtList(list: NumericDoubtItem[]): Buffer {
|
||||
return Buffer.from(
|
||||
protobuf_encode<OidbBase<NumericDoubtResponse>>({
|
||||
command: 0xd69,
|
||||
subCommand: 0,
|
||||
body: { status: 1, body: { list } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,104 @@
|
||||
import type { bool, bytes, int_32, pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
|
||||
interface GroupAlbumListReqDataWireOracle {
|
||||
groupId?: pb<1, string>;
|
||||
cursor?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface GroupAlbumListExtWireOracle {
|
||||
key?: pb<1, string>;
|
||||
value?: pb<2, string>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumListRequestWireOracle {
|
||||
seq?: pb<1, int_32>;
|
||||
field2?: pb<2, bytes>;
|
||||
field3?: pb<3, bytes>;
|
||||
data?: pb<4, GroupAlbumListReqDataWireOracle>;
|
||||
traceId?: pb<5, string>;
|
||||
extMap?: pb_repeated<10, GroupAlbumListExtWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumUrlInfoWireOracle {
|
||||
url?: pb<1, string>;
|
||||
width?: pb<2, uint_32>;
|
||||
height?: pb<3, uint_32>;
|
||||
}
|
||||
|
||||
interface GroupAlbumPhotoUrlWireOracle {
|
||||
spec?: pb<1, uint_32>;
|
||||
url?: pb<2, GroupAlbumUrlInfoWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumImageWireOracle {
|
||||
name?: pb<1, string>;
|
||||
sloc?: pb<2, string>;
|
||||
lloc?: pb<3, string>;
|
||||
photoUrls?: pb_repeated<4, GroupAlbumPhotoUrlWireOracle>;
|
||||
defaultUrl?: pb<5, GroupAlbumUrlInfoWireOracle>;
|
||||
isGif?: pb<6, bool>;
|
||||
hasRaw?: pb<7, bool>;
|
||||
}
|
||||
|
||||
interface GroupAlbumCoverWireOracle {
|
||||
type?: pb<1, uint_32>;
|
||||
image?: pb<2, GroupAlbumImageWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumListItemWireOracle {
|
||||
albumId?: pb<1, string>;
|
||||
owner?: pb<2, string>;
|
||||
name?: pb<3, string>;
|
||||
description?: pb<4, string>;
|
||||
createTime?: pb<5, uint_64>;
|
||||
lastUploadTime?: pb<7, uint_64>;
|
||||
uploadNumber?: pb<8, uint_64>;
|
||||
cover?: pb<9, GroupAlbumCoverWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumListResponseDataWireOracle {
|
||||
albumList?: pb_repeated<1, GroupAlbumListItemWireOracle>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumListResponseWireOracle {
|
||||
result?: pb<2, int_32>;
|
||||
data?: pb<4, GroupAlbumListResponseDataWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumMediaListReqInfoWireOracle {
|
||||
reserved?: pb<4, string>;
|
||||
pageInfo?: pb<5, string>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumMediaListRequestWireOracle {
|
||||
reqInfo?: pb<4, GroupAlbumMediaListReqInfoWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumVideoWireOracle {
|
||||
id?: pb<1, string>;
|
||||
url?: pb<2, string>;
|
||||
cover?: pb<3, GroupAlbumImageWireOracle>;
|
||||
width?: pb<4, uint_32>;
|
||||
height?: pb<5, uint_32>;
|
||||
videoTime?: pb<6, uint_64>;
|
||||
videoUrl?: pb_repeated<7, GroupAlbumPhotoUrlWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumMediaWireOracle {
|
||||
type?: pb<1, uint_32>;
|
||||
image?: pb<2, GroupAlbumImageWireOracle>;
|
||||
video?: pb<3, GroupAlbumVideoWireOracle>;
|
||||
uploader?: pb<6, string>;
|
||||
batchId?: pb<7, uint_64>;
|
||||
uploadTime?: pb<8, uint_64>;
|
||||
}
|
||||
|
||||
interface GroupAlbumMediaListResponseDataWireOracle {
|
||||
mediaList?: pb_repeated<3, GroupAlbumMediaWireOracle>;
|
||||
nextAttachInfo?: pb<5, string>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumMediaListResponseWireOracle {
|
||||
result?: pb<1, int_32>;
|
||||
data?: pb<4, GroupAlbumMediaListResponseDataWireOracle>;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
// MessageApi recall + markRead coverage. (The send* paths live in their
|
||||
// own test file because they need element-builder fixtures the recall
|
||||
// paths don't.) Renamed from the legacy `actions/group-message` shape
|
||||
// after #6 commit 1 moved the recall + markRead helpers onto MessageApi
|
||||
// and #6 commit 6 absorbed setEssence into InteractionApi.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
C2CRecallRequest,
|
||||
SsoReadedReportReq,
|
||||
SsoReadedReportResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', () => ({
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
encodeOidbEnv: vi.fn(() => new Uint8Array()),
|
||||
decodeOidbEnv: vi.fn(() => ({ body: {} })),
|
||||
}));
|
||||
|
||||
import { MessageApi } from '../../src/bridge/apis/message';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function readReportResult(response: SsoReadedReportResp) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SsoReadedReportResp>(response)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apis/message — recall + markRead', () => {
|
||||
beforeEach(() => {
|
||||
// No global mock state to reset — sendRawPacket is per-bridge.
|
||||
});
|
||||
|
||||
it('recallGroup sends to SsoGroupRecallMsg with the group sequence', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new MessageApi(bridge as any).recallGroup(12345, 999);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.msg.msg_svc.MsgService.SsoGroupRecallMsg');
|
||||
});
|
||||
|
||||
it('recallGroup throws when sendRawPacket reports failure', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'network down',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(new MessageApi(bridge as any).recallGroup(12345, 999)).rejects.toThrow(
|
||||
/network down/,
|
||||
);
|
||||
});
|
||||
|
||||
it('recallPrivate resolves the target UID before dispatch', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new MessageApi(bridge as any).recallPrivate(10001, 100, 200, 123, 1700000000);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.msg.msg_svc.MsgService.SsoC2CRecallMsg');
|
||||
});
|
||||
|
||||
it('recallPrivate keeps the 64-bit message id exact for any random', async () => {
|
||||
const random = 0x1234567;
|
||||
const bridge = mockBridge();
|
||||
await new MessageApi(bridge as any).recallPrivate(10001, 11110, 200, random, 1700000000);
|
||||
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const request = protobuf_decode<C2CRecallRequest>(body);
|
||||
expect(request.info?.random).toBe(random);
|
||||
expect(request.info?.messageId).toBe(0x0100000001234567n);
|
||||
});
|
||||
|
||||
it('probes and confirms group plus private reads in the same packets', async () => {
|
||||
const sendRawPacket = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
groupList: [{ groupUin: 1081372778n, readSeq: 66569n, latestSeq: 66629n }],
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 160n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
groupList: [{ groupUin: 1081372778n, readSeq: 66629n, latestSeq: 66629n }],
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 171n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new MessageApi(bridge as any).markAllRead([1081372778], [10001]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
expect(sendRawPacket.mock.calls.map((call: any) => call[0])).toEqual([
|
||||
'trpc.msg.msg_svc.MsgService.SsoReadedReport',
|
||||
'trpc.msg.msg_svc.MsgService.SsoReadedReport',
|
||||
]);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
|
||||
const probeBytes = sendRawPacket.mock.calls[0]![1];
|
||||
expect(Buffer.from(probeBytes).toString('hex')).toBe(
|
||||
'0a0608eae0d18304120e120c7265736f6c7665642d756964',
|
||||
);
|
||||
const probe = protobuf_decode<SsoReadedReportReq>(probeBytes);
|
||||
expect(probe).toEqual({
|
||||
groupList: [{ groupUin: 1081372778n, lastReadSeq: null }],
|
||||
c2cList: [{ uid: 'resolved-uid', lastReadTime: null, lastReadSeq: null }],
|
||||
});
|
||||
|
||||
const confirm = protobuf_decode<SsoReadedReportReq>(sendRawPacket.mock.calls[1]![1]);
|
||||
expect(confirm).toEqual({
|
||||
groupList: [{ groupUin: 1081372778n, lastReadSeq: 66629n }],
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
lastReadTime: 1783952859n,
|
||||
lastReadSeq: 171n,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send a confirm packet when every read marker is current', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
readReportResult({
|
||||
groupList: [{ groupUin: 12345n, readSeq: 50n, latestSeq: 50n }],
|
||||
}),
|
||||
);
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new MessageApi(bridge as any).markGroupRead(12345);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('markGroupRead rejects a transport success with no business response', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new MessageApi(bridge as any).markGroupRead(12345)).rejects.toThrow(
|
||||
/response is empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it('markGroupRead surfaces the per-group business error', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () =>
|
||||
readReportResult({
|
||||
groupList: [
|
||||
{ resultCode: 100000010, errorMessage: 'send group msg failed, ret:2' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
await expect(new MessageApi(bridge as any).markGroupRead(1)).rejects.toThrow(
|
||||
/send group msg failed, ret:2/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a top-level success when the private read marker did not advance', async () => {
|
||||
const sendRawPacket = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 160n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 160n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await expect(new MessageApi(bridge as any).markPrivateRead(10001)).rejects.toThrow(
|
||||
/confirmed sequence 160 before requested 171/,
|
||||
);
|
||||
});
|
||||
|
||||
it('caps mixed read reports at 100 + 100 and confirms each page before probing the next', async () => {
|
||||
const sendRawPacket = vi.fn(async (_cmd: string, body: Uint8Array) => {
|
||||
const request = protobuf_decode<SsoReadedReportReq>(body);
|
||||
return readReportResult({
|
||||
groupList: request.groupList?.map((item) => {
|
||||
const requested = item.lastReadSeq ?? 0n;
|
||||
return {
|
||||
groupUin: item.groupUin,
|
||||
readSeq: requested,
|
||||
latestSeq: requested === 0n ? 1n : requested,
|
||||
};
|
||||
}),
|
||||
c2cList: request.c2cList?.map((item) => {
|
||||
const requested = item.lastReadSeq ?? 0n;
|
||||
return {
|
||||
uid: item.uid,
|
||||
readSeq: requested,
|
||||
latestSeq: requested === 0n ? 1n : requested,
|
||||
lastMsgTime: 1700000000n,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
const resolveUserUid = vi.fn(async (userId: number) => `uid-${userId}`);
|
||||
const bridge = mockBridge({ sendRawPacket, resolveUserUid });
|
||||
const groups = Array.from({ length: 101 }, (_, i) => 1000 + i);
|
||||
const users = Array.from({ length: 101 }, (_, i) => 2000 + i);
|
||||
|
||||
await new MessageApi(bridge as any).markAllRead(groups, users);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(4);
|
||||
const requests = sendRawPacket.mock.calls.map((call: any) =>
|
||||
protobuf_decode<SsoReadedReportReq>(call[1]),
|
||||
);
|
||||
expect(
|
||||
requests.map((request) => [request.groupList?.length, request.c2cList?.length]),
|
||||
).toEqual([
|
||||
[100, 100],
|
||||
[100, 100],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
]);
|
||||
expect(requests[0]!.groupList![0]!.lastReadSeq).toBeNull();
|
||||
expect(requests[1]!.groupList![0]!.lastReadSeq).toBe(1n);
|
||||
expect(requests[2]!.groupList![0]!.lastReadSeq).toBeNull();
|
||||
expect(requests[3]!.groupList![0]!.lastReadSeq).toBe(1n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x9083Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
// Post-namespace-migration: InteractionApi is a thin facade and
|
||||
// production code routes through the namespace path (sender →
|
||||
// sendRawPacket). Tests now assert against the bridge mock's
|
||||
// sendRawPacket directly — no need for module-level mocks on the
|
||||
// bridge-oidb internals.
|
||||
import { InteractionApi } from '../../src/bridge/apis/interaction';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/interaction', () => {
|
||||
it('sendPoke group: groupUin set, friendUin=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new InteractionApi(bridge as any).sendPoke(true, 12345, 67890);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xed3_1');
|
||||
});
|
||||
|
||||
it('sendPoke friend: friendUin set, groupUin=0, targetUin defaults to peer', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new InteractionApi(bridge as any).sendPoke(false, 67890);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xed3_1');
|
||||
});
|
||||
|
||||
it('sendLike forwards target + count to 0x7e5_104', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new InteractionApi(bridge as any).sendLike(10001, 3);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7e5_104');
|
||||
});
|
||||
|
||||
it('setReaction picks _1 for set and _2 for unset', async () => {
|
||||
// setReaction now forwards through the SetReaction namespace, which
|
||||
// calls bridge.sendRawPacket directly.
|
||||
const bridge = mockBridge();
|
||||
const api = new InteractionApi(bridge as any);
|
||||
await api.setReaction(12345, 99, '128516', true);
|
||||
await api.setReaction(12345, 99, '128516', false);
|
||||
const cmds = bridge.sendRawPacket.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(cmds).toEqual(['OidbSvcTrpcTcp.0x9082_1', 'OidbSvcTrpcTcp.0x9082_2']);
|
||||
});
|
||||
|
||||
it('setEssence picks _1 for enable and _2 for disable', async () => {
|
||||
const bridge = mockBridge();
|
||||
const api = new InteractionApi(bridge as any);
|
||||
await api.setEssence(12345, 99, 0, true);
|
||||
await api.setEssence(12345, 99, 0, false);
|
||||
const cmds = bridge.sendRawPacket.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(cmds).toEqual(['OidbSvcTrpcTcp.0xeac_1', 'OidbSvcTrpcTcp.0xeac_2']);
|
||||
});
|
||||
|
||||
it('getEmojiLikes decodes user list, returns the cookie, and reports isLast', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x9083Resp>>({
|
||||
body: {
|
||||
users: [{ uin: 10001n }, { uin: 20002n }],
|
||||
cookie: 'next-page',
|
||||
isLast: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
const out = await new InteractionApi(bridge as any).getEmojiLikes(12345, 99, '128516');
|
||||
expect(out.users).toEqual([{ uin: 10001 }, { uin: 20002 }]);
|
||||
expect(out.cookie).toBe('next-page');
|
||||
expect(out.isLast).toBe(false);
|
||||
});
|
||||
|
||||
it('getEmojiLikes reports isLast=true when no cookie comes back', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<Oidb0x9083Resp>>({ body: {} })),
|
||||
});
|
||||
const out = await new InteractionApi(bridge as any).getEmojiLikes(12345, 99, '128516');
|
||||
expect(out.users).toEqual([]);
|
||||
expect(out.isLast).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { MessageApi } from '../../src/bridge/apis/message';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function sender(responseHex: string) {
|
||||
const ctx = {
|
||||
...mockBridge(),
|
||||
nextMessageRandom: () => 1234,
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseHex, 'hex'),
|
||||
})),
|
||||
};
|
||||
return { api: new MessageApi(ctx as unknown as BridgeContext), send: ctx.sendRawPacket };
|
||||
}
|
||||
|
||||
describe('group send receipts (#492)', () => {
|
||||
it('accepts the confirmed response from the issue log', async () => {
|
||||
const { api } = sender('080018abb3e8d506500058844a60abb3e8d506700078df0d');
|
||||
await expect(api.sendGroup(10001, [{ type: 'text', text: 'test' }])).resolves.toMatchObject(
|
||||
{
|
||||
sequence: 9476,
|
||||
messageId: 1234,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', '080018c9b3e8d506500060c9b3e8d5067000'],
|
||||
['zero', '08005800'],
|
||||
])('rejects a %s receipt without automatically resending', async (_name, response) => {
|
||||
const { api, send } = sender(response);
|
||||
await expect(api.sendGroup(10001, [{ type: 'text', text: 'test' }])).rejects.toThrow(
|
||||
'group message delivery was not confirmed',
|
||||
);
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('preserves an explicit server rejection', async () => {
|
||||
const { api } = sender('0801');
|
||||
await expect(api.sendGroup(10001, [{ type: 'text', text: 'test' }])).rejects.toThrow(
|
||||
'send group message rejected: result=1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x990Resp, Oidb0x112eResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
// Post-namespace migration: MiscApi forwards 3 OIDB cmds through
|
||||
// @snowluma/protocol/oidb-services/misc namespaces. The non-OIDB
|
||||
// `getMiniAppArk` (LightAppSvc) stays inline on the facade. Tests
|
||||
// hit bridge.sendRawPacket directly.
|
||||
import { MiscApi } from '../../src/bridge/apis/misc';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function packResponse(body: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apis/misc', () => {
|
||||
it('translateEn2Zh returns dstWords when present', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<Oidb0x990Resp>>({
|
||||
body: { translateResp: { dstWords: ['你好', '世界'] } } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await new MiscApi(bridge as any).translateEn2Zh(['hello', 'world']);
|
||||
expect(out).toEqual(['你好', '世界']);
|
||||
});
|
||||
|
||||
it('translateEn2Zh throws on empty response', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<Oidb0x990Resp>>({ body: {} })),
|
||||
);
|
||||
await expect(new MiscApi(bridge as any).translateEn2Zh(['hello'])).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it('getMiniAppArk rejects unsupported types', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(
|
||||
new MiscApi(bridge as any).getMiniAppArk('youtube', 't', 'd', 'p', 'u'),
|
||||
).rejects.toThrow(/unsupported type/);
|
||||
});
|
||||
|
||||
it('getMiniAppArk routes to LightAppSvc with the encoded body', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(
|
||||
new MiscApi(bridge as any).getMiniAppArk('bili', 't', 'd', 'p', 'u'),
|
||||
).rejects.toThrow();
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe(
|
||||
'LightAppSvc.mini_app_share.AdaptShareInfo',
|
||||
);
|
||||
});
|
||||
|
||||
it('clickInlineKeyboardButton returns shaped result', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<Oidb0x112eResp>>({
|
||||
body: { result: 1, errMsg: 'ok', promptText: 'hi' } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await new MiscApi(bridge as any).clickInlineKeyboardButton(
|
||||
1,
|
||||
2,
|
||||
'btn',
|
||||
'data',
|
||||
100,
|
||||
);
|
||||
expect(out).toMatchObject({ result: 1, errMsg: 'ok', promptText: 'hi', status: 0 });
|
||||
});
|
||||
|
||||
it('sendGroupSign dispatches to 0xEB7_1 (uppercase preserved)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new MiscApi(bridge as any).sendGroupSign(12345);
|
||||
const [cmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
// **Important**: this cmd keeps the historic UPPERCASE EB7 wire
|
||||
// name. Default scheme would emit lowercase; the namespace
|
||||
// overrides `wireName` to preserve byte-equality with NTQQ wire.
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xEB7_1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
CustomFaceModifyResp,
|
||||
CustomFaceMoveBody,
|
||||
FaceroamOpResp,
|
||||
GroupAvatarExtra,
|
||||
Oidb0x7edResp,
|
||||
Oidb0xe17Resp,
|
||||
SetStatusReq,
|
||||
SetStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', async () => {
|
||||
const actual = await vi.importActual<typeof import('@snowluma/protocol/bridge-oidb')>(
|
||||
'@snowluma/protocol/bridge-oidb',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@snowluma/protocol/highway', () => ({
|
||||
fetchHighwaySession: vi.fn(async () => ({})),
|
||||
uploadHighwayHttp: vi.fn(async () => undefined),
|
||||
BufferChunkSource: class BufferChunkSource {
|
||||
constructor(readonly bytes: Uint8Array) {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/utils', () => ({
|
||||
loadBinarySource: vi.fn(async () => ({
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
fileName: 'avatar.bin',
|
||||
})),
|
||||
computeHashes: vi.fn(() => ({ md5: new Uint8Array(16), sha1: new Uint8Array(20) })),
|
||||
computeMd5: vi.fn(() => new Uint8Array(16)),
|
||||
}));
|
||||
|
||||
import * as oidb from '@snowluma/protocol/bridge-oidb';
|
||||
import * as highwayClient from '@snowluma/protocol/highway';
|
||||
import { ProfileApi } from '../../src/bridge/apis/profile';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/profile', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(oidb.makeOidbEnvelope).mockClear();
|
||||
vi.mocked(highwayClient.fetchHighwaySession).mockClear();
|
||||
vi.mocked(highwayClient.uploadHighwayHttp).mockClear();
|
||||
});
|
||||
|
||||
it('setOnlineStatus sends to status_svc.SetStatus and accepts an empty response', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setOnlineStatus(11, 0, 100);
|
||||
const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus');
|
||||
});
|
||||
|
||||
it('setOnlineStatus does NOT include the customExt field 4 (varint 0x22 absent in body)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setOnlineStatus(11, 0, 100);
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
// proto field 4 length-delimited tag = (4 << 3) | 2 = 0x22. The
|
||||
// body should not contain that byte; if encoder ever leaks an
|
||||
// empty customExt it'd appear here.
|
||||
expect(Buffer.from(body).includes(0x22)).toBe(false);
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus hardcodes status=10 / extStatus=2000 and packs faceId+wording+faceType into customExt', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setDiyOnlineStatus(1234, '摸鱼中', 2);
|
||||
const [serviceCmd, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus');
|
||||
// Decode the wire bytes back through the same schema to assert
|
||||
// every field landed in the right place.
|
||||
const decoded = protobuf_decode<SetStatusReq>(body as Uint8Array);
|
||||
expect(decoded).toMatchObject({
|
||||
status: 10,
|
||||
extStatus: 2000,
|
||||
customExt: { faceId: 1234, text: '摸鱼中', faceType: 2 },
|
||||
});
|
||||
expect(decoded.batteryStatus ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("setOnlineStatus accepts QQ's success reply even when the reply code is non-zero (#405)", async () => {
|
||||
const bridge = mockBridge();
|
||||
const respBuf = Buffer.from(
|
||||
protobuf_encode<SetStatusResp>({
|
||||
errCode: 1,
|
||||
errMsg: 'set status success',
|
||||
}),
|
||||
);
|
||||
expect(respBuf.length).toBeGreaterThan(0);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: respBuf,
|
||||
} as any);
|
||||
await expect(
|
||||
new ProfileApi(bridge as any).setOnlineStatus(10, 0, 100),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('setOnlineStatus accepts a success-only reply body (#405)', async () => {
|
||||
const bridge = mockBridge();
|
||||
const respBuf = Buffer.from(
|
||||
protobuf_encode<SetStatusResp>({ errMsg: 'set status success' }),
|
||||
);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: respBuf,
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setOnlineStatus(10, 0)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus surfaces server errors via the same path as setOnlineStatus', async () => {
|
||||
const bridge = mockBridge();
|
||||
const respBuf = Buffer.from(
|
||||
protobuf_encode<SetStatusResp>({ errCode: 1, errMsg: 'denied' }),
|
||||
);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: respBuf,
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setDiyOnlineStatus(1, 't', 1)).rejects.toThrow(
|
||||
/denied/,
|
||||
);
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus rejects when the transport itself fails', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'pipe closed',
|
||||
responseData: null,
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setDiyOnlineStatus(1, 't', 1)).rejects.toThrow(
|
||||
/pipe closed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('setProfile is a no-op when both arguments are undefined', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setProfile();
|
||||
// Namespace migration: no SSO packet should fire at all.
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setProfile only sends non-undefined fields', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setProfile('New Nick');
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2');
|
||||
});
|
||||
|
||||
it('setSelfLongNick targets 0x112a_2 wire cmd', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setSelfLongNick('hello world');
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2');
|
||||
});
|
||||
|
||||
it('setInputStatus resolves UID first and sends 0xcd4_1', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setInputStatus(10001, 1);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xcd4_1');
|
||||
});
|
||||
|
||||
it('setAvatar loads bytes and pushes through the highway upload path (cmd 90)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setAvatar('/some/avatar.png');
|
||||
expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
expect(cmdId).toBe(90);
|
||||
expect((extend as Uint8Array).length).toBe(0); // personal avatar has no extra payload
|
||||
});
|
||||
|
||||
it('setGroupAvatar uses cmdId 3000 and packs the Lagrange GroupAvatarExtra constants', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setGroupAvatar(12345, '/some/group-avatar.png');
|
||||
expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
expect(cmdId).toBe(3000);
|
||||
// Decode the extra blob back through the schema and assert every
|
||||
// protocol-prescribed constant lands where it should.
|
||||
const decoded = protobuf_decode<GroupAvatarExtra>(extend as Uint8Array);
|
||||
expect(decoded).toEqual({
|
||||
type: 101,
|
||||
groupUin: 12345,
|
||||
field3: { field1: 1 },
|
||||
field5: 3,
|
||||
field6: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('setGroupAvatar rejects an empty file before hitting highway', async () => {
|
||||
const bridge = mockBridge();
|
||||
const { loadBinarySource } = await import('@snowluma/protocol/highway/utils');
|
||||
vi.mocked(loadBinarySource).mockResolvedValueOnce({
|
||||
bytes: new Uint8Array(0),
|
||||
fileName: 'empty.png',
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setGroupAvatar(1, 'empty.png')).rejects.toThrow(
|
||||
/empty/,
|
||||
);
|
||||
expect(highwayClient.fetchHighwaySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getProfileLike (self): resolves self UID, returns formatted favorite + vote info', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.identity.findUinByUid.mockImplementation((uid: string) =>
|
||||
uid === 'liker-uid' ? 20002 : 0,
|
||||
);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({
|
||||
body: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'u',
|
||||
time: 1700000000,
|
||||
favoriteInfo: { totalCount: 5, lastTime: 1, todayCount: 1 },
|
||||
voteInfo: {
|
||||
totalCount: 7,
|
||||
newCount: 2,
|
||||
newNearbyCount: 1,
|
||||
lastVisitTime: 2,
|
||||
userInfos: [{ uid: 'liker-uid', nick: '点赞者', count: 3 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const out = await new ProfileApi(bridge as any).getLike();
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_13');
|
||||
expect(out.favoriteInfo.total_count).toBe(5);
|
||||
expect(out.voteInfo.total_count).toBe(7);
|
||||
expect(out.voteInfo.userInfos).toEqual([
|
||||
expect.objectContaining({ uid: 'liker-uid', uin: 20002, nick: '点赞者', count: 3 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('getProfileLike throws on empty result', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({ body: { userLikeInfos: [] } as any }),
|
||||
),
|
||||
});
|
||||
await expect(new ProfileApi(bridge as any).getLike()).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it('getProfileLike resolves uncached liker uins through Identity.resolveUin', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.identity.findUinByUid.mockReturnValue(null);
|
||||
bridge.identity.resolveUin.mockImplementation(async (uid: string) =>
|
||||
uid === 'stranger-uid' ? 30003 : null,
|
||||
);
|
||||
const fetchUserProfileByUid = vi.fn();
|
||||
(bridge.apis.contacts as any).fetchUserProfileByUid = fetchUserProfileByUid;
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({
|
||||
body: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'self-uid',
|
||||
voteInfo: {
|
||||
userInfos: [{ uid: 'stranger-uid', nick: '陌生点赞者' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const out = await new ProfileApi(bridge as any).getLike();
|
||||
|
||||
expect(bridge.identity.resolveUin).toHaveBeenCalledOnce();
|
||||
expect(bridge.identity.resolveUin).toHaveBeenCalledWith('stranger-uid');
|
||||
expect(fetchUserProfileByUid).not.toHaveBeenCalled();
|
||||
expect(out.voteInfo.userInfos[0]?.uin).toBe(30003);
|
||||
});
|
||||
|
||||
it('getProfileLike keeps uin=0 when Identity.resolveUin misses', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.identity.findUinByUid.mockReturnValue(null);
|
||||
bridge.identity.resolveUin.mockResolvedValue(null);
|
||||
const fetchUserProfileByUid = vi.fn(async () => {
|
||||
throw new Error('profile lookup failed');
|
||||
});
|
||||
(bridge.apis.contacts as any).fetchUserProfileByUid = fetchUserProfileByUid;
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({
|
||||
body: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'self-uid',
|
||||
voteInfo: { userInfos: [{ uid: 'stranger-uid' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const out = await new ProfileApi(bridge as any).getLike();
|
||||
|
||||
expect(out.voteInfo.userInfos[0]?.uin).toBe(0);
|
||||
expect(fetchUserProfileByUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getUnidirectionalFriendList parses the embedded JSON body', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<Oidb0xe17Resp>({
|
||||
jsonBody: JSON.stringify({
|
||||
ActionStatus: 'OK',
|
||||
ErrorCode: 0,
|
||||
rpt_block_list: [{ uin: 10001 }, { uin: 10002 }],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const out = await new ProfileApi(bridge as any).getUnidirectionalFriendList();
|
||||
expect(out).toEqual([{ uin: 10001 }, { uin: 10002 }]);
|
||||
});
|
||||
|
||||
it('fetchCustomFaceIds slices the Faceroam list and skips the packet for count=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA, emojiB] },
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
const api = new ProfileApi(bridge as any);
|
||||
await expect(api.fetchCustomFaceIds(1)).resolves.toEqual([emojiA]);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
|
||||
|
||||
await expect(api.fetchCustomFaceIds(0)).resolves.toEqual([]);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fetchCustomFace maps ids to image urls', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA] },
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
await expect(new ProfileApi(bridge as any).fetchCustomFace(1)).resolves.toEqual([
|
||||
`https://p.qpic.cn/qq_expression/10001/${emojiA}/0`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['resource', 'md5'])(
|
||||
'resolves a saved sticker by %s without fetching every description',
|
||||
async (kind) => {
|
||||
const bridge = mockBridge();
|
||||
const md5 = 'ABCDEF0123456789ABCDEF0123456789';
|
||||
const emojiId = `10001_0_0_0_${md5}_0_0`;
|
||||
bridge.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({ item: { faceIds: [emojiId] } }),
|
||||
),
|
||||
} as any);
|
||||
const api = new ProfileApi(bridge as any);
|
||||
expect(
|
||||
await api.resolveCustomFace(kind === 'md5' ? md5.toLowerCase() : emojiId),
|
||||
).toEqual({
|
||||
emojiId,
|
||||
md5,
|
||||
url: `https://p.qpic.cn/qq_expression/10001/${emojiId}/0`,
|
||||
});
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
await expect(api.resolveCustomFace('missing')).rejects.toThrow(
|
||||
/not in the saved sticker list/,
|
||||
);
|
||||
await expect(api.resolveCustomFace(' ')).rejects.toThrow(/identifier is empty/);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a negative custom-face count before sending', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new ProfileApi(bridge as any).fetchCustomFaceIds(-1)).rejects.toThrow(
|
||||
/non-negative integer/,
|
||||
);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moveCustomFaceToFront reorders from the id list without parsing urls', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA, emojiB] },
|
||||
}),
|
||||
),
|
||||
} as any)
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<CustomFaceModifyResp>>({
|
||||
body: { retCode: 0 },
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
await new ProfileApi(bridge as any).moveCustomFaceToFront(emojiB);
|
||||
|
||||
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
||||
'Faceroam.OpReq',
|
||||
'OidbSvcTrpcTcp.0x902f_1',
|
||||
'OidbSvcTrpcTcp.0x902e_1',
|
||||
]);
|
||||
const moveBody = protobuf_decode<OidbBase<CustomFaceMoveBody>>(
|
||||
bridge.sendRawPacket.mock.calls[2]![1],
|
||||
);
|
||||
expect(moveBody.body?.emojis?.map((emoji) => emoji.emojiId)).toEqual([emojiB, emojiA]);
|
||||
});
|
||||
|
||||
it('fetchCustomFaceDetails supplements the Faceroam ids with server descriptions (#333)', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA, emojiB] },
|
||||
}),
|
||||
),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<CustomFaceModifyResp>>({
|
||||
body: {
|
||||
retCode: 0,
|
||||
entries: [{ emoji: { emojiId: emojiB }, desc: '第二个表情' }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
const out = await new ProfileApi(bridge as any).fetchCustomFaceDetails(2);
|
||||
|
||||
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
||||
'Faceroam.OpReq',
|
||||
'OidbSvcTrpcTcp.0x902e_1',
|
||||
]);
|
||||
const detailRequest = protobuf_decode<OidbBase<CustomFaceMoveBody>>(
|
||||
bridge.sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(detailRequest.body?.emojis).toEqual([
|
||||
{ emojiId: emojiA, md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' },
|
||||
{ emojiId: emojiB, md5: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
emojiId: emojiA,
|
||||
url: `https://p.qpic.cn/qq_expression/10001/${emojiA}/0`,
|
||||
md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
desc: '',
|
||||
},
|
||||
{
|
||||
emojiId: emojiB,
|
||||
url: `https://p.qpic.cn/qq_expression/10001/${emojiB}/0`,
|
||||
md5: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
||||
desc: '第二个表情',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fetchCustomFaceDetails returns immediately for count=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new ProfileApi(bridge as any).fetchCustomFaceDetails(0)).resolves.toEqual([]);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as qzonePublic from '@snowluma/protocol/web/qzone-public';
|
||||
import { QzoneApi } from '../../src/bridge/apis/qzone';
|
||||
import { mockApiHub, mockBridge } from './_helpers';
|
||||
|
||||
const cookies = { p_skey: 'PSK' };
|
||||
const feed = {
|
||||
cell_common: { union_id: { feed_id: 'opaque-id' } },
|
||||
cell_feed_transfer: { future: true },
|
||||
};
|
||||
const comment = { id: 'comment-id', user: { uid: 'commenter' }, unknown: [1, 2] };
|
||||
const reply = { id: 'reply-id', user: { uid: 'recipient' } };
|
||||
const content = [{ type: 0, content: 'text', future: { enabled: true } }];
|
||||
const user = { uin: '10001', uid: 'self-uid', nick: 'self-nick' };
|
||||
const result = { feeds: [], has_more: false, next_cursor: null, unknown: { preserved: true } };
|
||||
|
||||
function makeApi() {
|
||||
const getCookies = vi.fn().mockResolvedValue(cookies);
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
return { api: new QzoneApi(bridge as never), bridge, getCookies };
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
method: 'getQzoneHomeFeeds',
|
||||
invoke: (api: QzoneApi) => api.getHomeFeeds(20002, 'cursor'),
|
||||
args: [cookies, '10001', '20002', 'cursor'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicCapabilities',
|
||||
invoke: (api: QzoneApi) => api.getPublicCapabilities(),
|
||||
args: [cookies, '10001'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicFeeds',
|
||||
invoke: (api: QzoneApi) => api.getPublicFeeds('cursor'),
|
||||
args: [cookies, '10001', 'cursor'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicFeedDetail',
|
||||
invoke: (api: QzoneApi) => api.getPublicFeedDetail(feed),
|
||||
args: [cookies, feed],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicComments',
|
||||
invoke: (api: QzoneApi) => api.getPublicComments(feed, 'cursor'),
|
||||
args: [cookies, feed, 'cursor'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicReplies',
|
||||
invoke: (api: QzoneApi) => api.getPublicReplies(feed, comment, 'cursor', 5),
|
||||
args: [cookies, feed, comment, 'cursor', 5],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicUpvote',
|
||||
invoke: (api: QzoneApi) => api.setPublicUpvote(feed, false),
|
||||
args: [cookies, feed, false],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicLike',
|
||||
invoke: (api: QzoneApi) => api.setPublicLike(feed, false),
|
||||
args: [cookies, feed, false],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicCommentLike',
|
||||
invoke: (api: QzoneApi) => api.setPublicCommentLike(feed, comment, false, reply),
|
||||
args: [cookies, feed, comment, false, reply],
|
||||
},
|
||||
{
|
||||
method: 'commentQzonePublicFeed',
|
||||
invoke: (api: QzoneApi) => api.commentPublicFeed(feed, content),
|
||||
args: [cookies, feed, user, content],
|
||||
},
|
||||
{
|
||||
method: 'replyQzonePublicFeed',
|
||||
invoke: (api: QzoneApi) => api.replyPublicFeed(feed, comment, content, reply),
|
||||
args: [cookies, feed, user, comment, content, reply],
|
||||
},
|
||||
{
|
||||
method: 'deleteQzonePublicComment',
|
||||
invoke: (api: QzoneApi) => api.deletePublicComment(feed, comment),
|
||||
args: [cookies, feed, comment],
|
||||
},
|
||||
{
|
||||
method: 'deleteQzonePublicReply',
|
||||
invoke: (api: QzoneApi) => api.deletePublicReply(feed, comment, reply),
|
||||
args: [cookies, feed, comment, reply],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicFollow',
|
||||
invoke: (api: QzoneApi) => api.setPublicFollow(feed, false),
|
||||
args: [cookies, feed, false],
|
||||
},
|
||||
{
|
||||
method: 'forwardQzonePublicFeed',
|
||||
invoke: (api: QzoneApi) => api.forwardPublicFeed(feed, 'reason'),
|
||||
args: [cookies, feed, 'reason'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe('apis/qzone public', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('reads a post by tid using current-account credentials', async () => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const tid = '1127000000f1536501000000';
|
||||
const detail = { feed, attach_info: 'comments-next' };
|
||||
const request = vi.spyOn(qzonePublic, 'getQzoneFeedDetail').mockResolvedValue(detail);
|
||||
|
||||
expect(await api.getFeedDetail(tid)).toBe(detail);
|
||||
expect(getCookies).toHaveBeenCalledExactlyOnceWith('qzone.qq.com');
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(cookies, tid);
|
||||
});
|
||||
|
||||
it('propagates feed detail and cookie failures without replay', async () => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const request = vi
|
||||
.spyOn(qzonePublic, 'getQzoneFeedDetail')
|
||||
.mockRejectedValue(new Error('denied'));
|
||||
|
||||
await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow('denied');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow(
|
||||
'cookie failure',
|
||||
);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('generates a share card with the account cookies and preserves its notice', async () => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const card = { ark: '{}', requires_confirmation: true, notice: '所有人可见' };
|
||||
const request = vi.spyOn(qzonePublic, 'getQzoneShareArk').mockResolvedValue(card);
|
||||
expect(await api.getShareArk(feed)).toBe(card);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(cookies, feed);
|
||||
request.mockRejectedValueOnce(new Error('share denied'));
|
||||
await expect(api.getShareArk(feed)).rejects.toThrow('share denied');
|
||||
});
|
||||
|
||||
it.each(cases)(
|
||||
'$method uses current-account cookies and preserves upstream objects',
|
||||
async ({ method, invoke, args }) => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const request = vi.spyOn(qzonePublic, method).mockResolvedValue(result);
|
||||
|
||||
expect(await invoke(api)).toBe(result);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(...args);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'$method propagates cookie and upstream failures without replay',
|
||||
async ({ method, invoke }) => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const request = vi
|
||||
.spyOn(qzonePublic, method)
|
||||
.mockRejectedValue(new Error('upstream denied'));
|
||||
|
||||
await expect(invoke(api)).rejects.toThrow('upstream denied');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(invoke(api)).rejects.toThrow('cookie failure');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps documented defaults and omits an optional reply target', async () => {
|
||||
const { api } = makeApi();
|
||||
const feeds = vi.spyOn(qzonePublic, 'getQzonePublicFeeds').mockResolvedValue(result);
|
||||
const replies = vi.spyOn(qzonePublic, 'getQzonePublicReplies').mockResolvedValue(result);
|
||||
const upvote = vi.spyOn(qzonePublic, 'setQzonePublicUpvote').mockResolvedValue(result);
|
||||
const like = vi.spyOn(qzonePublic, 'setQzonePublicLike').mockResolvedValue(result);
|
||||
const commentLike = vi
|
||||
.spyOn(qzonePublic, 'setQzonePublicCommentLike')
|
||||
.mockResolvedValue(result);
|
||||
const follow = vi.spyOn(qzonePublic, 'setQzonePublicFollow').mockResolvedValue(result);
|
||||
const forward = vi.spyOn(qzonePublic, 'forwardQzonePublicFeed').mockResolvedValue(result);
|
||||
const replyRequest = vi
|
||||
.spyOn(qzonePublic, 'replyQzonePublicFeed')
|
||||
.mockResolvedValue(result);
|
||||
|
||||
await api.getPublicFeeds();
|
||||
await api.getPublicReplies(feed, comment);
|
||||
await api.setPublicUpvote(feed);
|
||||
await api.setPublicLike(feed);
|
||||
await api.setPublicCommentLike(feed, comment);
|
||||
await api.setPublicFollow(feed);
|
||||
await api.forwardPublicFeed(feed);
|
||||
await api.replyPublicFeed(feed, comment, 'text');
|
||||
|
||||
expect(feeds).toHaveBeenCalledWith(cookies, '10001', undefined);
|
||||
expect(replies).toHaveBeenCalledWith(cookies, feed, comment, '', 20);
|
||||
expect(upvote).toHaveBeenCalledWith(cookies, feed, true);
|
||||
expect(like).toHaveBeenCalledWith(cookies, feed, true);
|
||||
expect(commentLike).toHaveBeenCalledWith(cookies, feed, comment, true, undefined);
|
||||
expect(follow).toHaveBeenCalledWith(cookies, feed, true);
|
||||
expect(forward).toHaveBeenCalledWith(cookies, feed, '');
|
||||
expect(replyRequest).toHaveBeenCalledWith(cookies, feed, user, comment, 'text', undefined);
|
||||
});
|
||||
|
||||
it('binds comment authors to the current account even when its uid is unavailable', async () => {
|
||||
const { api, bridge } = makeApi();
|
||||
Object.assign(bridge.identity, { selfUid: undefined });
|
||||
const request = vi.spyOn(qzonePublic, 'commentQzonePublicFeed').mockResolvedValue(result);
|
||||
|
||||
await api.commentPublicFeed(feed, 'text');
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
cookies,
|
||||
feed,
|
||||
{ uin: '10001', uid: '', nick: 'self-nick' },
|
||||
'text',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import * as qzoneWeb from '@snowluma/protocol/web/qzone';
|
||||
import { QzoneApi } from '../../src/bridge/apis/qzone';
|
||||
import { mockApiHub, mockBridge } from './_helpers';
|
||||
|
||||
// QzoneApi is the only place the string(identity.uin)/number(action param)
|
||||
// boundary is crossed, so its target_uin defaulting + the `> 0` guard are
|
||||
// worth locking. We stub the protocol-layer getQzoneMsgList and the web
|
||||
// cookie fetch so the test asserts purely what the bridge passes down.
|
||||
|
||||
describe('apis/qzone', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function bridgeWithWeb() {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const fetchSpy = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneMsgList')
|
||||
.mockResolvedValue({ total: 0, msglist: [] });
|
||||
return { bridge, getCookies, fetchSpy };
|
||||
}
|
||||
|
||||
it.each([undefined, 20002])(
|
||||
'getProfile reads owner %s with the current account credentials',
|
||||
async (targetUin) => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const profile = { name: 'My space', description: 'Notes' };
|
||||
const getProfile = vi.spyOn(qzoneWeb, 'getQzoneProfile').mockResolvedValue(profile);
|
||||
|
||||
expect(await new QzoneApi(bridge as never).getProfile(targetUin)).toEqual(profile);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(getProfile).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
targetUin?.toString(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('getProfile propagates cookie and protocol failures', async () => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const api = new QzoneApi(bridge as never);
|
||||
const getProfile = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneProfile')
|
||||
.mockRejectedValue(new Error('denied'));
|
||||
|
||||
await expect(api.getProfile(20002)).rejects.toThrow('denied');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.getProfile()).rejects.toThrow('cookie failure');
|
||||
expect(getProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([{ name: 'New name' }, { description: '' }, { name: '', description: 'New notes' }])(
|
||||
'setProfile writes only the supplied fields to the current account (%j)',
|
||||
async (profile) => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const setProfile = vi.spyOn(qzoneWeb, 'setQzoneProfile').mockResolvedValue();
|
||||
|
||||
await new QzoneApi(bridge as never).setProfile(profile);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(setProfile).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', profile);
|
||||
},
|
||||
);
|
||||
|
||||
it('setProfile propagates cookie and protocol failures', async () => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const api = new QzoneApi(bridge as never);
|
||||
const setProfile = vi
|
||||
.spyOn(qzoneWeb, 'setQzoneProfile')
|
||||
.mockRejectedValue(new Error('captcha required'));
|
||||
|
||||
await expect(api.setProfile({ name: 'New name' })).rejects.toThrow('captcha required');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.setProfile({ description: 'Notes' })).rejects.toThrow('cookie failure');
|
||||
expect(setProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 20002])(
|
||||
'getMsgDetail uses current-account credentials for owner %s',
|
||||
async (targetUin) => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const detail = { tid: 'T1', content: 'Post', comment_num: 0, comments: [] };
|
||||
const fetchDetail = vi.spyOn(qzoneWeb, 'getQzoneMsgDetail').mockResolvedValue(detail);
|
||||
const result = await new QzoneApi(bridge as never).getMsgDetail('T1', targetUin);
|
||||
expect(result).toEqual(detail);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(fetchDetail).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
String(targetUin ?? 10001),
|
||||
'T1',
|
||||
0,
|
||||
20,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('getMsgDetail passes pagination and propagates cookie and protocol failures', async () => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const api = new QzoneApi(bridge as never);
|
||||
const fetchDetail = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneMsgDetail')
|
||||
.mockRejectedValue(new Error('denied'));
|
||||
await expect(api.getMsgDetail('T1', 20002, 10, 5)).rejects.toThrow('denied');
|
||||
expect(fetchDetail).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 'T1', 10, 5);
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.getMsgDetail('T1')).rejects.toThrow('cookie failure');
|
||||
expect(fetchDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defaults target_uin to the bot's own uin and uses default pos/num", async () => {
|
||||
const { bridge, getCookies, fetchSpy } = bridgeWithWeb();
|
||||
await new QzoneApi(bridge as never).getMsgList();
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
// identity.uin is '10001' (a string) — passed straight through.
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 0, 20);
|
||||
});
|
||||
|
||||
it('treats target_uin 0 as absent and falls back to own uin', async () => {
|
||||
const { bridge, fetchSpy } = bridgeWithWeb();
|
||||
await new QzoneApi(bridge as never).getMsgList(0);
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 0, 20);
|
||||
});
|
||||
|
||||
it('passes a real target_uin (stringified) plus pos/num through', async () => {
|
||||
const { bridge, fetchSpy } = bridgeWithWeb();
|
||||
await new QzoneApi(bridge as never).getMsgList(20002, 5, 50);
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 5, 50);
|
||||
});
|
||||
|
||||
it("getFeeds always uses the bot's own uin and threads pageNum/count", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const feedsSpy = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneFeeds')
|
||||
.mockResolvedValue({ feeds: [], has_more: false, next_cursor: null });
|
||||
await new QzoneApi(bridge as never).getFeeds(3, 20);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(feedsSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 3, 20, undefined);
|
||||
await new QzoneApi(bridge as never).getFeeds(1, 20, 'opaque-cursor');
|
||||
expect(feedsSpy).toHaveBeenLastCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
1,
|
||||
20,
|
||||
'opaque-cursor',
|
||||
);
|
||||
});
|
||||
|
||||
it('uploadImageFromSource uploads from file:// http:// base64:// and returns richval + url', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK', skey: 'SK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const uploadSpy = vi.spyOn(qzoneWeb, 'uploadQzoneImageFromSource').mockResolvedValue({
|
||||
richval: ',12345,abc,abc,22,800,600,,800,600',
|
||||
url: 'https://example.com/img.jpg',
|
||||
albumid: '12345',
|
||||
lloc: 'abc',
|
||||
type: 22,
|
||||
width: 600,
|
||||
height: 800,
|
||||
});
|
||||
const out = await new QzoneApi(bridge as never).uploadImageFromSource(
|
||||
'file:///path/to/image.jpg',
|
||||
);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(uploadSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK', skey: 'SK' },
|
||||
'10001',
|
||||
'file:///path/to/image.jpg',
|
||||
);
|
||||
expect(out.richval).toBe(',12345,abc,abc,22,800,600,,800,600');
|
||||
expect(out.url).toBe('https://example.com/img.jpg');
|
||||
});
|
||||
|
||||
it("publish posts to the bot's own space with the given content (text-only)", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T', time: 1 });
|
||||
const out = await new QzoneApi(bridge as never).publish('hello');
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'hello',
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ tid: 'T', time: 1 });
|
||||
});
|
||||
|
||||
it('publish posts with richtype and richval for image post', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T2', time: 2 });
|
||||
const richval = ',12345,abc,abc,22,800,600,,800,600';
|
||||
const out = await new QzoneApi(bridge as never).publish('look at this', 1, richval);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'look at this',
|
||||
1,
|
||||
richval,
|
||||
1,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ tid: 'T2', time: 2 });
|
||||
});
|
||||
|
||||
it('publish posts with multiple images (richval joined with tab)', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T3', time: 3 });
|
||||
const richval = ',12345,a,a,22,800,600,,800,600\t,12346,b,b,22,1024,768,,1024,768';
|
||||
const out = await new QzoneApi(bridge as never).publish('two images', 1, richval);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'two images',
|
||||
1,
|
||||
richval,
|
||||
1,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ tid: 'T3', time: 3 });
|
||||
});
|
||||
|
||||
it('publish threads qzone visibility params', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T', time: 1 });
|
||||
await new QzoneApi(bridge as never).publish(
|
||||
'hello',
|
||||
undefined,
|
||||
undefined,
|
||||
16,
|
||||
'10002|10003',
|
||||
);
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'hello',
|
||||
undefined,
|
||||
undefined,
|
||||
16,
|
||||
'10002|10003',
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes a feed by tid from the bot's own space", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const delSpy = vi.spyOn(qzoneWeb, 'deleteQzoneMsg').mockResolvedValue();
|
||||
await new QzoneApi(bridge as never).delete('TID9');
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(delSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 'TID9');
|
||||
});
|
||||
|
||||
it("updateRight changes a feed's visibility on the bot's own space", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const updSpy = vi
|
||||
.spyOn(qzoneWeb, 'updateQzoneMsgRight')
|
||||
.mockResolvedValue({ ugc_right: 16 });
|
||||
const out = await new QzoneApi(bridge as never).updateRight('TID9', 16, '10002|10003');
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(updSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 'TID9', 16, '10002|10003');
|
||||
expect(out).toEqual({ ugc_right: 16 });
|
||||
});
|
||||
|
||||
it('like defaults the feed owner to self and passes opUin=self + like flag', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const likeSpy = vi.spyOn(qzoneWeb, 'setQzoneLike').mockResolvedValue();
|
||||
await new QzoneApi(bridge as never).like('TIDX', undefined, true, 1700000000);
|
||||
// opUin = self ('10001'), owner defaults to self when target_uin absent; abstime threaded
|
||||
expect(likeSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'10001',
|
||||
'TIDX',
|
||||
true,
|
||||
1700000000,
|
||||
);
|
||||
});
|
||||
|
||||
it("like targets a friend's space (owner = target_uin) and threads the unlike flag + abstime default", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const likeSpy = vi.spyOn(qzoneWeb, 'setQzoneLike').mockResolvedValue();
|
||||
await new QzoneApi(bridge as never).like('TIDX', 20002, false);
|
||||
// abstime defaults to 0 when omitted
|
||||
expect(likeSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', '20002', 'TIDX', false, 0);
|
||||
});
|
||||
|
||||
it('comment defaults the feed owner to self and posts as self', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '1' });
|
||||
const out = await new QzoneApi(bridge as never).comment('TIDX', 'nice', undefined);
|
||||
// selfUin='10001' commenter; owner defaults to self
|
||||
expect(cmtSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'10001',
|
||||
'TIDX',
|
||||
'nice',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ comment_id: '1' });
|
||||
});
|
||||
|
||||
it("comment targets a friend's feed (owner = target_uin), commenter stays self", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '2' });
|
||||
await new QzoneApi(bridge as never).comment('TIDX', 'nice', 20002);
|
||||
expect(cmtSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'20002',
|
||||
'TIDX',
|
||||
'nice',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('comment with images (richType=1, richval=direct URLs joined with tab)', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '3' });
|
||||
const richval = 'https://example.com/a.jpg\thttps://example.com/b.jpg';
|
||||
await new QzoneApi(bridge as never).comment('TIDX', 'nice pic', undefined, 1, richval);
|
||||
expect(cmtSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'10001',
|
||||
'TIDX',
|
||||
'nice pic',
|
||||
1,
|
||||
richval,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, 20002])(
|
||||
'routes replies with the account cookies and post owner (%s)',
|
||||
async (targetUin) => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const target = {
|
||||
commentId: '0',
|
||||
commentUin: '30003',
|
||||
replyTo: { uin: '40004', name: 'Reader' },
|
||||
};
|
||||
const result = { comment_id: '8', parent_comment_id: '0', parent_comment_uin: 30003 };
|
||||
const replySpy = vi.spyOn(qzoneWeb, 'replyQzoneComment').mockResolvedValue(result);
|
||||
const commentSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg');
|
||||
|
||||
const out = await new QzoneApi(bridge as never).comment(
|
||||
'TIDX',
|
||||
'Reply',
|
||||
targetUin,
|
||||
undefined,
|
||||
undefined,
|
||||
target,
|
||||
);
|
||||
|
||||
expect(out).toEqual(result);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(replySpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
String(targetUin ?? 10001),
|
||||
'TIDX',
|
||||
'Reply',
|
||||
target,
|
||||
);
|
||||
expect(commentSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects reply images before acquiring cookies or sending', async () => {
|
||||
const getCookies = vi.fn();
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
await expect(
|
||||
new QzoneApi(bridge as never).comment('TIDX', 'Reply', undefined, 1, 'URL', {
|
||||
commentId: '0',
|
||||
commentUin: '30003',
|
||||
}),
|
||||
).rejects.toThrow('images are not supported');
|
||||
expect(getCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user