Files
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

1421 lines
50 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
CommentReqBodyHeader,
CommentReqBodyUserWrap,
CommentReqPhotoInfo,
DeleteMediasRequest,
DeleteMediasResponse,
DoQunCommentRequest,
DoQunCommentResponse,
GetAlbumListResponse,
GetMediaListResponse,
GetQunFeedDetailRequest,
GetQunFeedDetailResponse,
QunFeedCellCommon,
} from '@snowluma/proto-defs/oidb-actions/group-album';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { GroupAlbumApi } from '../../src/bridge/apis/group-album';
import { mockBridge } from './_helpers';
import type {
GroupAlbumListRequestWireOracle,
GroupAlbumListResponseWireOracle,
GroupAlbumMediaListRequestWireOracle,
GroupAlbumMediaListResponseWireOracle,
} from './group-album-wire-fixture';
function albumListResponseWithCover(): Uint8Array {
return protobuf_encode<GroupAlbumListResponseWireOracle>({
data: {
albumList: [
{
albumId: 'album-id',
owner: '10001',
name: '测试相册',
description: 'desc',
createTime: 1700000000n,
lastUploadTime: 1700000123n,
uploadNumber: 5n,
cover: {
type: 1,
image: {
name: 'cover.jpg',
sloc: 'small-location',
lloc: 'large-location',
photoUrls: [
{
spec: 3,
url: {
url: 'https://example.test/cover-320.jpg',
width: 320,
height: 180,
},
},
],
defaultUrl: {
url: 'https://example.test/cover.jpg',
width: 1280,
height: 720,
},
isGif: false,
hasRaw: true,
},
},
},
],
},
});
}
describe('apis/group-album', () => {
it('uses QQ NT AlbumService and puts the pagination cursor in request body field 2', async () => {
const bridge = mockBridge();
const cursor = '{"IndexType":"next"}';
await new GroupAlbumApi(bridge as never).listQun(12345, cursor);
const [serviceCmd, requestBytes, timeoutMs] = bridge.sendRawPacket.mock.calls[0]!;
expect(serviceCmd).toBe('QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.GetAlbumList');
expect(timeoutMs).toBe(15000);
const request = protobuf_decode<GroupAlbumListRequestWireOracle>(requestBytes);
expect(request.seq).toBe(3331);
expect(request.data).toEqual({ groupId: '12345', cursor });
expect(request.extMap).toEqual([{ key: 'fc-appid', value: '100' }]);
// QQ NT's codec writes the two empty common-envelope byte fields rather
// than omitting them: field1=3331, field2="", field3="".
expect(Buffer.from(requestBytes).subarray(0, 7).toString('hex')).toBe('08831a12001a00');
});
it('preserves Unicode emoji and literal QQ-em tags in the legacy creator nickname', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
seq: 3331,
data: {
albumList: [
{
albumId: 'album-id',
owner: '10001',
name: '测试相册',
description: 'desc',
createTime: 1700000000n,
uploadNumber: 5n,
creator: {
uid: 'u_creator',
uin: '10001',
nick: '😂[em]e328514[/em]',
},
},
],
},
}),
),
});
const result = await new GroupAlbumApi(bridge as never).list(12345);
expect(result).toEqual([
{
id: 'album-id',
name: '测试相册',
picNum: 5,
createTime: 1700000000,
desc: 'desc',
owner: '10001',
createuin: '10001',
createnickname: '😂[em]e328514[/em]',
last_upload_time: 0,
cover: null,
},
]);
});
it('fetches every AlbumService page for the legacy album list action', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
data: {
albumList: [
{
albumId: 'album-1',
name: '第一页',
createTime: 1n,
uploadNumber: 1n,
},
],
attachInfo: 'next-cursor',
hasMore: true,
},
}),
),
})
.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
data: {
albumList: [
{
albumId: 'album-2',
name: '第二页',
createTime: 2n,
uploadNumber: 2n,
},
],
hasMore: false,
},
}),
),
});
const result = await new GroupAlbumApi(bridge as never).list(12345);
expect(result.map((album) => album.id)).toEqual(['album-1', 'album-2']);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
const secondRequest = protobuf_decode<GroupAlbumListRequestWireOracle>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(secondRequest.data?.cursor).toBe('next-cursor');
});
it('rejects a legacy album page that claims more data without a cursor', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
data: {
albumList: [],
hasMore: true,
},
}),
),
});
await expect(new GroupAlbumApi(bridge as never).list(12345)).rejects.toThrow(
'hasMore=true but cursor is empty',
);
});
it('rejects a repeated AlbumService cursor instead of looping or truncating', async () => {
const bridge = mockBridge();
const repeatedPage = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
data: {
albumList: [],
attachInfo: 'same-cursor',
hasMore: true,
},
}),
),
};
bridge.sendRawPacket
.mockResolvedValueOnce(repeatedPage)
.mockResolvedValueOnce(repeatedPage);
await expect(new GroupAlbumApi(bridge as never).list(12345)).rejects.toThrow(
'repeated cursor at page 2',
);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
});
it('returns the native AlbumService cursor and creator fields for get_qun_album_list', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
data: {
albumList: [
{
albumId: 'album-id',
name: '测试相册',
createTime: 1700000000n,
uploadNumber: 5n,
creator: { uin: '10001', nick: '😂' },
},
],
attachInfo: 'next-cursor',
hasMore: true,
},
}),
),
});
const result = await new GroupAlbumApi(bridge as never).listQun(12345);
expect(result.attachInfo).toBe('next-cursor');
expect(result.hasMore).toBe(true);
expect(result.albumList[0]).toMatchObject({
album_id: 'album-id',
name: '测试相册',
create_time: '1700000000',
upload_number: '5',
creator: { uin: '10001', nick: '😂' },
});
});
it('decodes the native album cover field for get_qun_album_list', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(albumListResponseWithCover()),
});
const result = await new GroupAlbumApi(bridge as never).listQun(12345);
expect(result.albumList[0]).toMatchObject({
album_id: 'album-id',
last_upload_time: '1700000123',
cover: {
type: 1,
image: {
name: 'cover.jpg',
sloc: 'small-location',
lloc: 'large-location',
photoUrls: [
{
spec: 3,
url: {
url: 'https://example.test/cover-320.jpg',
width: 320,
height: 180,
},
},
],
defaultUrl: { url: 'https://example.test/cover.jpg', width: 1280, height: 720 },
isGif: false,
hasRaw: true,
},
},
});
});
it('returns cover and last-upload metadata from the legacy album list action', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(albumListResponseWithCover()),
});
const result = await new GroupAlbumApi(bridge as never).list(12345);
expect(result[0]).toMatchObject({
id: 'album-id',
last_upload_time: 1700000123,
cover: {
type: 1,
image: {
name: 'cover.jpg',
defaultUrl: { url: 'https://example.test/cover.jpg', width: 1280, height: 720 },
},
},
});
});
it('surfaces AlbumService response errors instead of falling back to the lossy web endpoint', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetAlbumListResponse>({
seq: 3331,
result: 1001,
errorText: 'permission denied',
}),
),
});
await expect(new GroupAlbumApi(bridge as never).list(12345)).rejects.toThrow(
'result=1001, error=permission denied',
);
});
it('encodes the next-page cursor in the page-info field', async () => {
const bridge = mockBridge();
const cursor = '{"IndexType":"next","Loc":{"batch_id":2147483650}}';
await new GroupAlbumApi(bridge as never).getMediaList(12345, 'album-id', cursor);
const [, requestBytes] = bridge.sendRawPacket.mock.calls[0]!;
const request = protobuf_decode<GroupAlbumMediaListRequestWireOracle>(requestBytes);
expect(request.reqInfo?.pageInfo).toBe(cursor);
expect(request.reqInfo?.reserved).toBeNull();
});
it('accepts a successful media-list response with an omitted zero retCode', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetMediaListResponse>({
data: {
mediaList: [
{
type: 1,
uploader: '10001',
batchId: 123n,
uploadTime: 456n,
},
],
nextAttachInfo: 'next-page',
},
}),
),
});
const result = await new GroupAlbumApi(bridge as never).getMediaList(12345, 'album-id');
expect(result).toEqual({
mediaList: [
{
type: 1,
image: null,
video: null,
uploader: '10001',
batchId: '123',
uploadTime: '456',
},
],
nextAttachInfo: 'next-page',
});
});
it('returns video metadata from the group album media list', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GroupAlbumMediaListResponseWireOracle>({
data: {
mediaList: [
{
type: 2,
video: {
id: 'video-id',
url: 'https://example.test/video.mp4',
cover: {
name: 'cover.jpg',
sloc: 'cover-small',
lloc: 'cover-large',
photoUrls: [
{
spec: 3,
url: {
url: 'https://example.test/cover-320.jpg',
width: 320,
height: 180,
},
},
],
defaultUrl: {
url: 'https://example.test/cover.jpg',
width: 1920,
height: 1080,
},
isGif: true,
hasRaw: true,
},
width: 1920,
height: 1080,
videoTime: 15n,
videoUrl: [
{
spec: 1,
url: {
url: 'https://example.test/video-720p.mp4',
width: 1280,
height: 720,
},
},
],
},
uploader: '10001',
batchId: 123n,
uploadTime: 456n,
},
],
nextAttachInfo: 'next-video-page',
},
}),
),
});
const result = await new GroupAlbumApi(bridge as never).getMediaList(12345, 'album-id');
expect(result).toEqual({
mediaList: [
{
type: 2,
image: null,
video: {
id: 'video-id',
url: 'https://example.test/video.mp4',
cover: {
name: 'cover.jpg',
sloc: 'cover-small',
lloc: 'cover-large',
photoUrls: [
{
spec: 3,
url: {
url: 'https://example.test/cover-320.jpg',
width: 320,
height: 180,
},
},
],
defaultUrl: {
url: 'https://example.test/cover.jpg',
width: 1920,
height: 1080,
},
isGif: true,
hasRaw: true,
},
width: 1920,
height: 1080,
videoTime: '15',
videoUrl: [
{
spec: 1,
url: {
url: 'https://example.test/video-720p.mp4',
width: 1280,
height: 720,
},
},
],
},
uploader: '10001',
batchId: '123',
uploadTime: '456',
},
],
nextAttachInfo: 'next-video-page',
});
});
function packDeleteOk() {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<DeleteMediasResponse>({ field1: 8694 })),
};
}
function packMediaList(body: GetMediaListResponse['data']) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<GetMediaListResponse>({ data: body })),
};
}
it('deletes a photo by lloc and includes the batch id', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd.endsWith('GetMediaList')) {
return packMediaList({
mediaList: [{ type: 1, image: { lloc: 'photo-lloc' }, batchId: 77n }],
});
}
return packDeleteOk();
});
await expect(
new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'photo-lloc'),
).resolves.toEqual({ success: true });
const deleteCall = bridge.sendRawPacket.mock.calls.find((call) =>
String(call[0]).endsWith('DeleteMedias'),
);
expect(deleteCall?.[0]).toBe('QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.DeleteMedias');
const request = protobuf_decode<DeleteMediasRequest>(deleteCall![1] as Uint8Array);
expect(request.body).toMatchObject({
groupId: '12345',
albumId: 'album-id',
mediaIds: ['photo-lloc'],
batchIds: ['77'],
});
});
it('deletes a video by video id using the cover lloc', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd.endsWith('GetMediaList')) {
return packMediaList({
mediaList: [
{
type: 2,
video: { id: 'video-id', cover: { lloc: 'cover-large' } },
batchId: 88n,
},
],
});
}
return packDeleteOk();
});
await expect(
new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'video-id'),
).resolves.toEqual({ success: true });
const deleteCall = bridge.sendRawPacket.mock.calls.find((call) =>
String(call[0]).endsWith('DeleteMedias'),
);
const request = protobuf_decode<DeleteMediasRequest>(deleteCall![1] as Uint8Array);
expect(request.body?.mediaIds).toEqual(['cover-large']);
expect(request.body?.batchIds).toEqual(['88']);
});
it('pages the media list until the video is found', async () => {
const bridge = mockBridge();
let pages = 0;
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd.endsWith('GetMediaList')) {
pages += 1;
if (pages === 1) {
return packMediaList({
mediaList: [{ type: 1, image: { lloc: 'other' }, batchId: 1n }],
nextAttachInfo: 'page-2',
});
}
return packMediaList({
mediaList: [
{
type: 2,
video: { id: 'video-id', cover: { lloc: 'cover-2' } },
batchId: 2n,
},
],
});
}
return packDeleteOk();
});
await new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'video-id');
expect(pages).toBe(2);
const deleteCall = bridge.sendRawPacket.mock.calls.find((call) =>
String(call[0]).endsWith('DeleteMedias'),
);
const request = protobuf_decode<DeleteMediasRequest>(deleteCall![1] as Uint8Array);
expect(request.body?.mediaIds).toEqual(['cover-2']);
expect(request.body?.batchIds).toEqual(['2']);
});
it('falls back to the raw id when the media list has no match', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd.endsWith('GetMediaList')) {
return packMediaList({ mediaList: [] });
}
return packDeleteOk();
});
await new GroupAlbumApi(bridge as never).delete(12345, 'album-id', 'unknown-id');
const deleteCall = bridge.sendRawPacket.mock.calls.find((call) =>
String(call[0]).endsWith('DeleteMedias'),
);
const request = protobuf_decode<DeleteMediasRequest>(deleteCall![1] as Uint8Array);
expect(request.body?.mediaIds).toEqual(['unknown-id']);
expect(request.body?.batchIds ?? []).toEqual([]);
});
function packCommentOk(
comment: DoQunCommentResponse['comment'],
): ReturnType<typeof packDeleteOk> {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<DoQunCommentResponse>({
field1: 8527,
comment,
}),
),
};
}
function packFeedDetail(
feedId = 'official-feed-id',
time = 1700000123n,
extra: {
ownerUin?: string;
cellCommon?: Omit<QunFeedCellCommon, 'time' | 'feedId'>;
cellMedia?: {
medias?: Array<{
type?: number;
image?: { lloc?: string };
video?: { cover?: { lloc?: string } };
}>;
albumId?: string;
batchId?: bigint;
};
} = {},
): ReturnType<typeof packDeleteOk> {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetQunFeedDetailResponse>({
result: 0,
data: {
feed: {
feed: {
cellCommon: { time, feedId, ...extra.cellCommon },
...(extra.ownerUin
? { cellUserInfo: { user: { uin: extra.ownerUin } } }
: {}),
...(extra.cellMedia ? { cellMedia: extra.cellMedia } : {}),
},
},
},
}),
),
};
}
function packPhotoMedia(lloc = 'photo-lloc', batchId = 77n) {
return packMediaList({
mediaList: [{ type: 1, image: { lloc }, batchId }],
});
}
function commentMocks(
extra: (cmd: string) => ReturnType<typeof packDeleteOk> | undefined = () => undefined,
) {
return async (cmd: string) => {
const override = extra(cmd);
if (override) {
return override;
}
if (cmd.endsWith('GetMediaList')) {
return packPhotoMedia();
}
if (cmd.endsWith('GetQunFeedDetail')) {
return packFeedDetail('official-feed-id', 1700000123n, {
ownerUin: '3119936551',
cellCommon: {
type: 422,
cellId: '421_1_0_12345|album-id|77^||^421_1_0_12345|album-id|photo-lloc^||^0',
field6: 3,
},
});
}
return packCommentOk({
data: {
id: 'cmt-1',
user: { uin: '10001' },
content: [{ type: 0, content: 'hello' }],
time: 1700000000n,
clientKey: 'ck',
},
});
};
}
function commentCallBytes(bridge: ReturnType<typeof mockBridge>): Uint8Array {
const commentCall = bridge.sendRawPacket.mock.calls.find((call) =>
String(call[0]).endsWith('DoQunComment'),
);
expect(commentCall?.[0]).toBe(
'QunAlbum.trpc.qzone.webapp_qun_operation.FeedsWriter.DoQunComment',
);
return commentCall![1] as Uint8Array;
}
function commentRequestOf(bridge: ReturnType<typeof mockBridge>): DoQunCommentRequest {
return protobuf_decode<DoQunCommentRequest>(commentCallBytes(bridge));
}
function headerOf(request: DoQunCommentRequest): CommentReqBodyHeader {
const raw = request.body?.reqBody?.field1;
return raw ? protobuf_decode<CommentReqBodyHeader>(raw) : {};
}
function userWrapOf(request: DoQunCommentRequest): CommentReqBodyUserWrap {
const raw = request.body?.reqBody?.field2;
return raw ? protobuf_decode<CommentReqBodyUserWrap>(raw) : {};
}
function photoOf(request: DoQunCommentRequest): CommentReqPhotoInfo {
const raw = request.body?.reqBody?.field5;
return raw ? protobuf_decode<CommentReqPhotoInfo>(raw) : {};
}
function feedDetailRequestOf(bridge: ReturnType<typeof mockBridge>): GetQunFeedDetailRequest {
const feedCall = bridge.sendRawPacket.mock.calls.find((call) =>
String(call[0]).endsWith('GetQunFeedDetail'),
);
expect(feedCall?.[0]).toBe(
'QunAlbum.trpc.qzone.webapp_qun_feeds.FeedsReader.GetQunFeedDetail',
);
return protobuf_decode<GetQunFeedDetailRequest>(feedCall![1] as Uint8Array);
}
it('looks up the official feed then comments with its header and slim photo cell', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(commentMocks());
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).resolves.toEqual({
id: 'cmt-1',
user: { uin: '10001' },
content: [{ type: 0, content: 'hello' }],
time: '1700000000',
clientKey: 'ck',
});
expect(feedDetailRequestOf(bridge).data).toMatchObject({
groupId: '12345',
commentCount: 20,
albumId: 'album-id',
batchId: '77',
lloc: 'photo-lloc',
});
expect(feedDetailRequestOf(bridge).data?.feedId ?? '').toBe('');
expect(feedDetailRequestOf(bridge).data?.attachInfo ?? '').toBe('');
const request = commentRequestOf(bridge);
expect(request.field1).toBe(8527);
expect(request.body).toMatchObject({
groupId: '12345',
field3: 2,
field5: {
user: { uin: '10001', uinNum: 10001n },
contents: [{ type: 0, content: 'hello' }],
},
});
expect(headerOf(request)).toMatchObject({
time: 1700000123n,
feedId: 'official-feed-id',
});
expect(userWrapOf(request).field1?.uin).toBe('3119936551');
expect(photoOf(request)).toMatchObject({
albumId: 'album-id',
batchId: 77n,
medias: [
{
image: { lloc: 'photo-lloc' },
},
],
});
expect(photoOf(request).medias?.[0]?.type ?? 0).toBe(0);
const contentBytes = request.body?.field5?.contents;
expect(contentBytes?.[0]?.type).toBe(0);
expect(Buffer.from(commentCallBytes(bridge)).subarray(0, 7).toString('hex')).toBe(
'08cf4212001a00',
);
});
it('falls back to the media uploader when the official feed has no owner cell', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetMediaList')) {
return packMediaList({
mediaList: [
{
type: 1,
image: { lloc: 'photo-lloc' },
batchId: 77n,
uploader: '3119936551',
},
],
});
}
if (cmd.endsWith('GetQunFeedDetail')) {
return packFeedDetail();
}
return undefined;
}),
);
await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello');
expect(userWrapOf(commentRequestOf(bridge)).field1?.uin).toBe('3119936551');
expect(commentRequestOf(bridge).body?.field5?.user).toMatchObject({
uin: '10001',
uinNum: 10001n,
});
});
it('copies the official feed media cell instead of a reconstructed lloc-only cell', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetQunFeedDetail')) {
return packFeedDetail('official-feed-id', 1700000123n, {
ownerUin: '3119936551',
cellMedia: {
albumId: 'feed-album',
batchId: 88n,
medias: [
{
type: 0,
image: { lloc: 'feed-lloc' },
},
],
},
});
}
return undefined;
}),
);
await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello');
const request = commentRequestOf(bridge);
expect(photoOf(request)).toMatchObject({
albumId: 'feed-album',
batchId: 88n,
medias: [
{
image: { lloc: 'feed-lloc' },
},
],
});
const feedBytes = new Uint8Array(
packFeedDetail('official-feed-id', 1700000123n, {
ownerUin: '3119936551',
cellMedia: {
albumId: 'feed-album',
batchId: 88n,
medias: [{ type: 0, image: { lloc: 'feed-lloc' } }],
},
}).responseData,
);
expect(Buffer.from(request.body?.reqBody?.field5 ?? [])).toEqual(
Buffer.from(cellBytes(feedBytes, 5) ?? []),
);
});
it.each([
{ name: 'six-byte', value: [0x80, 0x80, 0x80, 0x80, 0x80, 0x20] },
{ name: 'ten-byte', value: [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01] },
])('accepts a $name unknown integer before the feed data', async ({ value }) => {
const packed = packFeedDetail('official-feed-id', 1700000123n, { ownerUin: '10002' });
const responseData = Buffer.from(
concatBytes([new Uint8Array([0x30, ...value]), new Uint8Array(packed.responseData)]),
);
expect(
protobuf_decode<GetQunFeedDetailResponse>(responseData).data?.feed?.feed?.cellCommon
?.feedId,
).toBe('official-feed-id');
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) =>
cmd.endsWith('GetQunFeedDetail') ? { ...packed, responseData } : undefined,
),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).resolves.toMatchObject({ id: 'cmt-1' });
});
it('copies an unknown media-cell field through unchanged', async () => {
const packed = packFeedDetail('official-feed-id', 1700000123n, {
ownerUin: '3119936551',
cellMedia: {
albumId: 'feed-album',
batchId: 88n,
medias: [{ type: 0, image: { lloc: 'feed-lloc' } }],
},
});
const base = new Uint8Array(packed.responseData);
const original = cellBytes(base, 5);
expect(original).toBeDefined();
const injected = concatBytes([original!, pbVarintField(99, 7)]);
const responseData = replaceLengthDelimited(base, [4, 2, 1, 5], injected);
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetQunFeedDetail')) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(responseData),
};
}
return undefined;
}),
);
await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello');
expect(Buffer.from(commentRequestOf(bridge).body?.reqBody?.field5 ?? [])).toEqual(
Buffer.from(injected),
);
});
it('copies the official comment header, including locator fields', async () => {
const cellId =
'421_1_0_964445447|album-id|2147483665^||^421_1_0_964445447|album-id|photo-lloc^||^0';
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetQunFeedDetail')) {
return packFeedDetail('422_0_2147483665', 1789315793n, {
ownerUin: '3119936551',
cellCommon: { type: 422, cellId, field6: 3 },
});
}
return undefined;
}),
);
await new GroupAlbumApi(bridge as never).comment(
964445447,
'album-id',
'photo-lloc',
'hello',
);
const request = commentRequestOf(bridge);
expect(headerOf(request)).toMatchObject({
type: 422,
time: 1789315793n,
feedId: '422_0_2147483665',
cellId,
field6: 3,
});
const feedBytes = new Uint8Array(
packFeedDetail('422_0_2147483665', 1789315793n, {
ownerUin: '3119936551',
cellCommon: { type: 422, cellId, field6: 3 },
}).responseData,
);
expect(Buffer.from(request.body?.reqBody?.field1 ?? [])).toEqual(
Buffer.from(cellBytes(feedBytes, 1) ?? []),
);
});
it('comments a video with the cover location and video media type', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetMediaList')) {
return packMediaList({
mediaList: [
{
type: 2,
video: { id: 'video-id', cover: { lloc: 'cover-lloc' } },
batchId: 88n,
},
],
});
}
return undefined;
}),
);
await new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'video-id', 'hello');
expect(feedDetailRequestOf(bridge).data?.lloc).toBe('cover-lloc');
expect(photoOf(commentRequestOf(bridge))).toMatchObject({
albumId: 'album-id',
batchId: 88n,
medias: [
{
type: 1,
video: { cover: { lloc: 'cover-lloc' } },
},
],
});
});
it('rejects a comment when the album media cannot be resolved', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetMediaList')) {
return packMediaList({ mediaList: [] });
}
return undefined;
}),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'missing-lloc', 'hello'),
).rejects.toThrow('comment album media error: media not found');
expect(
bridge.sendRawPacket.mock.calls.some((call) =>
String(call[0]).endsWith('DoQunComment'),
),
).toBe(false);
});
it('rejects a comment when the official feed lookup fails', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetQunFeedDetail')) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<GetQunFeedDetailResponse>({
result: 10016,
errorText: '服务器繁忙',
}),
),
};
}
return undefined;
}),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).rejects.toThrow('fetch album feed error: retCode 10016, 服务器繁忙');
expect(
bridge.sendRawPacket.mock.calls.some((call) =>
String(call[0]).endsWith('DoQunComment'),
),
).toBe(false);
});
it('rejects a comment when the official feed has no id', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('GetQunFeedDetail')) {
return packFeedDetail('');
}
return undefined;
}),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).rejects.toThrow('comment album media error: empty feed');
expect(
bridge.sendRawPacket.mock.calls.some((call) =>
String(call[0]).endsWith('DoQunComment'),
),
).toBe(false);
});
it('rejects a seq-echo 8527 packet that has no comment body', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('DoQunComment')) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<DoQunCommentResponse>({ field1: 8527 }),
),
};
}
return undefined;
}),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).rejects.toThrow('comment album media error: empty comment');
});
it('surfaces the album result code instead of treating seq 8527 as success', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('DoQunComment')) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<DoQunCommentResponse>({
field1: 8527,
result: 1001,
errorText: 'permission denied',
}),
),
};
}
return undefined;
}),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).rejects.toThrow('comment album media error: retCode 1001, permission denied');
});
it('accepts a comment written directly on envelope field 4', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(
commentMocks((cmd) => {
if (cmd.endsWith('DoQunComment')) {
return packCommentOk({
id: 'flat-id',
content: [{ type: 0, content: 'hello' }],
time: 123n,
clientKey: 'ck',
});
}
return undefined;
}),
);
await expect(
new GroupAlbumApi(bridge as never).comment(12345, 'album-id', 'photo-lloc', 'hello'),
).resolves.toMatchObject({
id: 'flat-id',
user: { uin: '10001' },
content: [{ type: 0, content: 'hello' }],
time: '123',
clientKey: 'ck',
});
});
});
function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function pbVarint(n: number): Uint8Array {
const bytes: number[] = [];
let value = n;
while (value > 0x7f) {
bytes.push((value & 0x7f) | 0x80);
value = Math.floor(value / 128);
}
bytes.push(value);
return Uint8Array.from(bytes);
}
function pbVarintField(field: number, n: number): Uint8Array {
return concatBytes([pbVarint((field << 3) | 0), pbVarint(n)]);
}
function readTestVarint(buf: Uint8Array, offset: number): { value: number; next: number } {
let value = 0;
let shift = 0;
let next = offset;
while (next < buf.length && shift <= 28) {
const byte = buf[next++];
value += (byte & 0x7f) * 2 ** shift;
if ((byte & 0x80) === 0) {
return { value, next };
}
shift += 7;
}
throw new Error('truncated protobuf varint');
}
function cellBytes(buf: Uint8Array, fieldNo: number): Uint8Array | undefined {
return lengthDelimitedAt(buf, [4, 2, 1, fieldNo]);
}
function lengthDelimitedAt(buf: Uint8Array, path: number[]): Uint8Array | undefined {
let current: Uint8Array | undefined = buf;
for (const fieldNo of path) {
if (!current) {
return undefined;
}
current = firstDelimited(current, fieldNo);
}
return current;
}
function firstDelimited(buf: Uint8Array, fieldNo: number): Uint8Array | undefined {
let offset = 0;
while (offset < buf.length) {
const key = readTestVarint(buf, offset);
offset = key.next;
const field = Math.floor(key.value / 8);
const wire = key.value % 8;
if (wire === 0) {
offset = readTestVarint(buf, offset).next;
} else if (wire === 1) {
offset += 8;
} else if (wire === 5) {
offset += 4;
} else if (wire === 2) {
const len = readTestVarint(buf, offset);
offset = len.next;
const end = offset + len.value;
if (end > buf.length) {
return undefined;
}
if (field === fieldNo) {
return buf.slice(offset, end);
}
offset = end;
} else {
return undefined;
}
}
return undefined;
}
function replaceLengthDelimited(
buf: Uint8Array,
path: number[],
replacement: Uint8Array,
): Uint8Array {
const [fieldNo, ...rest] = path;
if (fieldNo === undefined) {
return replacement;
}
const parts: Uint8Array[] = [];
let offset = 0;
let replaced = false;
while (offset < buf.length) {
const keyStart = offset;
const key = readTestVarint(buf, offset);
offset = key.next;
const field = Math.floor(key.value / 8);
const wire = key.value % 8;
if (wire === 2) {
const len = readTestVarint(buf, offset);
const innerStart = len.next;
const innerEnd = innerStart + len.value;
const inner = buf.slice(innerStart, innerEnd);
if (!replaced && field === fieldNo) {
const nextInner =
rest.length > 0
? replaceLengthDelimited(inner, rest, replacement)
: replacement;
parts.push(
concatBytes([
buf.slice(keyStart, offset),
pbVarint(nextInner.length),
nextInner,
]),
);
replaced = true;
} else {
parts.push(buf.slice(keyStart, innerEnd));
}
offset = innerEnd;
} else if (wire === 0) {
offset = readTestVarint(buf, offset).next;
parts.push(buf.slice(keyStart, offset));
} else if (wire === 1) {
offset += 8;
parts.push(buf.slice(keyStart, offset));
} else if (wire === 5) {
offset += 4;
parts.push(buf.slice(keyStart, offset));
} else {
throw new Error(`unsupported wire type ${wire}`);
}
}
return concatBytes(parts);
}