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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+183
View File
@@ -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);
});
});
+494
View File
@@ -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();
});
});
+385
View File
@@ -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');
});
});
+311
View File
@@ -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',
);
});
});
+99
View File
@@ -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');
});
});
+562
View File
@@ -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',
);
});
});
+405
View File
@@ -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();
});
});