feat(group-album): expose cover and upload metadata

Decode the verified album cover response field and expose normalized cover details and last-upload timestamps through both album list actions. Preserve the legacy numeric timestamp shape, retain string timestamps for the native response, return null when no cover exists, and avoid additional network requests.

Document the new output contracts in the OneBot and MCP catalogs. Add independent wire fixtures and action-level regression tests for the protocol mapping and public response shapes.

Refs #257
This commit is contained in:
DrSmoothl
2026-07-20 23:37:41 +08:00
parent 4aa2cd1693
commit feb7fcac0e
9 changed files with 841 additions and 5 deletions
@@ -14,6 +14,7 @@ import type {
GetMediaListRequest,
GetMediaListResponse,
MediaInfo,
UrlInfo,
} from '@snowluma/proto-defs/oidb-actions/group-album';
import { uploadImageToGroupAlbum } from '@snowluma/protocol/web/group-album';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
@@ -53,6 +54,35 @@ function normalizeAlbumCreator(creator: AlbumCreator | undefined): QunAlbumCreat
};
}
function normalizeCoverUrl(url: UrlInfo | null | undefined): QunAlbumCoverUrl | null {
if (!url) return null;
return {
url: url.url ?? '',
width: url.width ?? 0,
height: url.height ?? 0,
};
}
function normalizeAlbumCover(cover: MediaInfo | undefined): QunAlbumCover | null {
if (!cover) return null;
const image = cover.image;
return {
type: cover.type ?? 0,
image: image ? {
name: image.name ?? '',
sloc: image.sloc ?? '',
lloc: image.lloc ?? '',
photoUrls: (image.photoUrls ?? []).map((photoUrl) => ({
spec: photoUrl.spec ?? 0,
url: normalizeCoverUrl(photoUrl.url),
})),
defaultUrl: normalizeCoverUrl(image.defaultUrl),
isGif: image.isGif ?? false,
hasRaw: image.hasRaw ?? false,
} : null,
};
}
function normalizeQunAlbum(album: GroupAlbumInfoWire): QunAlbumInfo {
const normalized: QunAlbumInfo = {
album_id: album.albumId ?? '',
@@ -63,6 +93,7 @@ function normalizeQunAlbum(album: GroupAlbumInfoWire): QunAlbumInfo {
modify_time: uint64ToString(album.modifyTime),
last_upload_time: uint64ToString(album.lastUploadTime),
upload_number: uint64ToString(album.uploadNumber),
cover: normalizeAlbumCover(album.cover),
top_flag: uint64ToString(album.topFlag),
busi_type: album.busiType ?? 0,
status: album.status ?? 0,
@@ -94,6 +125,8 @@ function toLegacyAlbum(album: GroupAlbumInfoWire): GroupAlbumInfo {
// Unlike the legacy Qzone HTTP endpoint, it does not rewrite Unicode
// emoji into ambiguous [em]...[/em] tags.
createnickname: creator?.nick ?? '',
last_upload_time: uint64ToSafeNumber(album.lastUploadTime, 'last_upload_time'),
cover: normalizeAlbumCover(album.cover),
};
}
@@ -426,6 +459,8 @@ export interface GroupAlbumInfo {
owner: string;
createuin: string;
createnickname: string;
last_upload_time: number;
cover: QunAlbumCover | null;
[key: string]: JsonValue;
}
@@ -446,6 +481,32 @@ export interface QunAlbumCreator {
ditto_uin: string;
}
export interface QunAlbumCoverUrl extends JsonObject {
url: string;
width: number;
height: number;
}
export interface QunAlbumCoverPhotoUrl extends JsonObject {
spec: number;
url: QunAlbumCoverUrl | null;
}
export interface QunAlbumCoverImage extends JsonObject {
name: string;
sloc: string;
lloc: string;
photoUrls: QunAlbumCoverPhotoUrl[];
defaultUrl: QunAlbumCoverUrl | null;
isGif: boolean;
hasRaw: boolean;
}
export interface QunAlbumCover extends JsonObject {
type: number;
image: QunAlbumCoverImage | null;
}
export interface QunAlbumInfo {
album_id: string;
owner: string;
@@ -455,6 +516,7 @@ export interface QunAlbumInfo {
modify_time: string;
last_upload_time: string;
upload_number: string;
cover: QunAlbumCover | null;
creator?: QunAlbumCreator;
top_flag: string;
busi_type: number;
@@ -1,4 +1,12 @@
import type { bytes, int_32, pb, pb_repeated } from '@snowluma/proton';
import type {
bool,
bytes,
int_32,
pb,
pb_repeated,
uint_32,
uint_64,
} from '@snowluma/proton';
interface GroupAlbumListReqDataWireOracle {
groupId?: pb<1, string>;
@@ -19,6 +27,52 @@ export interface GroupAlbumListRequestWireOracle {
extMap?: pb_repeated<10, GroupAlbumListExtWireOracle>;
}
interface GroupAlbumCoverUrlWireOracle {
url?: pb<1, string>;
width?: pb<2, uint_32>;
height?: pb<3, uint_32>;
}
interface GroupAlbumCoverPhotoUrlWireOracle {
spec?: pb<1, uint_32>;
url?: pb<2, GroupAlbumCoverUrlWireOracle>;
}
interface GroupAlbumCoverImageWireOracle {
name?: pb<1, string>;
sloc?: pb<2, string>;
lloc?: pb<3, string>;
photoUrls?: pb_repeated<4, GroupAlbumCoverPhotoUrlWireOracle>;
defaultUrl?: pb<5, GroupAlbumCoverUrlWireOracle>;
isGif?: pb<6, bool>;
hasRaw?: pb<7, bool>;
}
interface GroupAlbumCoverWireOracle {
type?: pb<1, uint_32>;
image?: pb<2, GroupAlbumCoverImageWireOracle>;
}
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>;
@@ -8,9 +8,41 @@ import { GroupAlbumApi } from '../../src/bridge/apis/group-album';
import { mockBridge } from './_helpers';
import type {
GroupAlbumListRequestWireOracle,
GroupAlbumListResponseWireOracle,
GroupAlbumMediaListRequestWireOracle,
} 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();
@@ -70,6 +102,8 @@ describe('apis/group-album', () => {
owner: '10001',
createuin: '10001',
createnickname: '😂[em]e328514[/em]',
last_upload_time: 0,
cover: null,
}]);
});
@@ -108,6 +142,64 @@ describe('apis/group-album', () => {
});
});
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({
+257 -1
View File
@@ -3435,6 +3435,132 @@
"type": "integer",
"description": "相册创建时间(unix 秒)"
},
"last_upload_time": {
"type": "integer",
"description": "最后上传时间(unix 秒)"
},
"cover": {
"type": [
"object",
"null"
],
"description": "相册封面;没有封面时为 null",
"properties": {
"type": {
"type": "integer",
"description": "封面媒体类型"
},
"image": {
"type": [
"object",
"null"
],
"description": "封面图片信息",
"properties": {
"name": {
"type": "string",
"description": "图片名称"
},
"sloc": {
"type": "string",
"description": "图片短定位标识"
},
"lloc": {
"type": "string",
"description": "图片长定位标识"
},
"photoUrls": {
"type": "array",
"description": "不同规格的封面地址",
"items": {
"type": "object",
"properties": {
"spec": {
"type": "integer",
"description": "图片规格"
},
"url": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
}
},
"required": [
"spec",
"url"
]
}
},
"defaultUrl": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
},
"isGif": {
"type": "boolean",
"description": "是否为动图"
},
"hasRaw": {
"type": "boolean",
"description": "是否有原图"
}
},
"required": [
"name",
"sloc",
"lloc",
"photoUrls",
"defaultUrl",
"isGif",
"hasRaw"
]
}
},
"required": [
"type",
"image"
]
},
"createuin": {
"type": "string",
"description": "相册创建者 QQ 号"
@@ -3449,6 +3575,8 @@
"name",
"picNum",
"createTime",
"last_upload_time",
"cover",
"createuin",
"createnickname"
]
@@ -5514,10 +5642,136 @@
"type": "string",
"description": "相册创建时间(unix 秒)"
},
"last_upload_time": {
"type": "string",
"description": "最后上传时间(unix 秒)"
},
"upload_number": {
"type": "string",
"description": "相册内媒体数量"
},
"cover": {
"type": [
"object",
"null"
],
"description": "相册封面;没有封面时为 null",
"properties": {
"type": {
"type": "integer",
"description": "封面媒体类型"
},
"image": {
"type": [
"object",
"null"
],
"description": "封面图片信息",
"properties": {
"name": {
"type": "string",
"description": "图片名称"
},
"sloc": {
"type": "string",
"description": "图片短定位标识"
},
"lloc": {
"type": "string",
"description": "图片长定位标识"
},
"photoUrls": {
"type": "array",
"description": "不同规格的封面地址",
"items": {
"type": "object",
"properties": {
"spec": {
"type": "integer",
"description": "图片规格"
},
"url": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
}
},
"required": [
"spec",
"url"
]
}
},
"defaultUrl": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
},
"isGif": {
"type": "boolean",
"description": "是否为动图"
},
"hasRaw": {
"type": "boolean",
"description": "是否有原图"
}
},
"required": [
"name",
"sloc",
"lloc",
"photoUrls",
"defaultUrl",
"isGif",
"hasRaw"
]
}
},
"required": [
"type",
"image"
]
},
"creator": {
"type": "object",
"description": "相册创建者信息",
@@ -5541,7 +5795,9 @@
"album_id",
"name",
"create_time",
"upload_number"
"last_upload_time",
"upload_number",
"cover"
]
}
},
+257 -1
View File
@@ -3438,6 +3438,132 @@ export const ACTIONS: CatalogAction[] = [
"type": "integer",
"description": "相册创建时间(unix 秒)"
},
"last_upload_time": {
"type": "integer",
"description": "最后上传时间(unix 秒)"
},
"cover": {
"type": [
"object",
"null"
],
"description": "相册封面;没有封面时为 null",
"properties": {
"type": {
"type": "integer",
"description": "封面媒体类型"
},
"image": {
"type": [
"object",
"null"
],
"description": "封面图片信息",
"properties": {
"name": {
"type": "string",
"description": "图片名称"
},
"sloc": {
"type": "string",
"description": "图片短定位标识"
},
"lloc": {
"type": "string",
"description": "图片长定位标识"
},
"photoUrls": {
"type": "array",
"description": "不同规格的封面地址",
"items": {
"type": "object",
"properties": {
"spec": {
"type": "integer",
"description": "图片规格"
},
"url": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
}
},
"required": [
"spec",
"url"
]
}
},
"defaultUrl": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
},
"isGif": {
"type": "boolean",
"description": "是否为动图"
},
"hasRaw": {
"type": "boolean",
"description": "是否有原图"
}
},
"required": [
"name",
"sloc",
"lloc",
"photoUrls",
"defaultUrl",
"isGif",
"hasRaw"
]
}
},
"required": [
"type",
"image"
]
},
"createuin": {
"type": "string",
"description": "相册创建者 QQ 号"
@@ -3452,6 +3578,8 @@ export const ACTIONS: CatalogAction[] = [
"name",
"picNum",
"createTime",
"last_upload_time",
"cover",
"createuin",
"createnickname"
]
@@ -5517,10 +5645,136 @@ export const ACTIONS: CatalogAction[] = [
"type": "string",
"description": "相册创建时间(unix 秒)"
},
"last_upload_time": {
"type": "string",
"description": "最后上传时间(unix 秒)"
},
"upload_number": {
"type": "string",
"description": "相册内媒体数量"
},
"cover": {
"type": [
"object",
"null"
],
"description": "相册封面;没有封面时为 null",
"properties": {
"type": {
"type": "integer",
"description": "封面媒体类型"
},
"image": {
"type": [
"object",
"null"
],
"description": "封面图片信息",
"properties": {
"name": {
"type": "string",
"description": "图片名称"
},
"sloc": {
"type": "string",
"description": "图片短定位标识"
},
"lloc": {
"type": "string",
"description": "图片长定位标识"
},
"photoUrls": {
"type": "array",
"description": "不同规格的封面地址",
"items": {
"type": "object",
"properties": {
"spec": {
"type": "integer",
"description": "图片规格"
},
"url": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
}
},
"required": [
"spec",
"url"
]
}
},
"defaultUrl": {
"type": [
"object",
"null"
],
"properties": {
"url": {
"type": "string",
"description": "封面地址"
},
"width": {
"type": "integer",
"description": "封面宽度"
},
"height": {
"type": "integer",
"description": "封面高度"
}
},
"required": [
"url",
"width",
"height"
]
},
"isGif": {
"type": "boolean",
"description": "是否为动图"
},
"hasRaw": {
"type": "boolean",
"description": "是否有原图"
}
},
"required": [
"name",
"sloc",
"lloc",
"photoUrls",
"defaultUrl",
"isGif",
"hasRaw"
]
}
},
"required": [
"type",
"image"
]
},
"creator": {
"type": "object",
"description": "相册创建者信息",
@@ -5544,7 +5798,9 @@ export const ACTIONS: CatalogAction[] = [
"album_id",
"name",
"create_time",
"upload_number"
"last_upload_time",
"upload_number",
"cover"
]
}
},
+66 -2
View File
@@ -2,6 +2,50 @@ import { groupAction, f } from '../action-kit';
import type { JsonValue } from '../types';
import { okResponse } from '../types';
const albumCoverUrlSchema = {
type: ['object', 'null'],
properties: {
url: { type: 'string', description: '封面地址' },
width: { type: 'integer', description: '封面宽度' },
height: { type: 'integer', description: '封面高度' },
},
required: ['url', 'width', 'height'],
};
const albumCoverSchema = {
type: ['object', 'null'],
description: '相册封面;没有封面时为 null',
properties: {
type: { type: 'integer', description: '封面媒体类型' },
image: {
type: ['object', 'null'],
description: '封面图片信息',
properties: {
name: { type: 'string', description: '图片名称' },
sloc: { type: 'string', description: '图片短定位标识' },
lloc: { type: 'string', description: '图片长定位标识' },
photoUrls: {
type: 'array',
description: '不同规格的封面地址',
items: {
type: 'object',
properties: {
spec: { type: 'integer', description: '图片规格' },
url: albumCoverUrlSchema,
},
required: ['spec', 'url'],
},
},
defaultUrl: albumCoverUrlSchema,
isGif: { type: 'boolean', description: '是否为动图' },
hasRaw: { type: 'boolean', description: '是否有原图' },
},
required: ['name', 'sloc', 'lloc', 'photoUrls', 'defaultUrl', 'isGif', 'hasRaw'],
},
},
required: ['type', 'image'],
};
export const actions = [
groupAction({
name: 'get_group_album_list',
@@ -17,10 +61,21 @@ export const actions = [
name: { type: 'string', description: '相册名称' },
picNum: { type: 'integer', description: '相册内照片数量' },
createTime: { type: 'integer', description: '相册创建时间(unix 秒)' },
last_upload_time: { type: 'integer', description: '最后上传时间(unix 秒)' },
cover: albumCoverSchema,
createuin: { type: 'string', description: '相册创建者 QQ 号' },
createnickname: { type: 'string', description: '相册创建者昵称(原始 Unicode)' },
},
required: ['id', 'name', 'picNum', 'createTime', 'createuin', 'createnickname'],
required: [
'id',
'name',
'picNum',
'createTime',
'last_upload_time',
'cover',
'createuin',
'createnickname',
],
},
},
run: async (p, ctx) => {
@@ -46,7 +101,9 @@ export const actions = [
album_id: { type: 'string', description: '相册 id' },
name: { type: 'string', description: '相册名称' },
create_time: { type: 'string', description: '相册创建时间(unix 秒)' },
last_upload_time: { type: 'string', description: '最后上传时间(unix 秒)' },
upload_number: { type: 'string', description: '相册内媒体数量' },
cover: albumCoverSchema,
creator: {
type: 'object',
description: '相册创建者信息',
@@ -57,7 +114,14 @@ export const actions = [
},
},
},
required: ['album_id', 'name', 'create_time', 'upload_number'],
required: [
'album_id',
'name',
'create_time',
'last_upload_time',
'upload_number',
'cover',
],
},
},
attach_info: { type: 'string', description: '下一页分页游标' },
+24
View File
@@ -37,6 +37,30 @@ describe('action-docs', () => {
},
);
it.each(['get_group_album_list', 'get_qun_album_list'])(
'documents album cover and last-upload metadata for %s',
(name) => {
const action = docs.find((d) => d.name === name);
const rootProperties = action?.returnsSchema?.properties as Record<string, any> | undefined;
const itemProperties = name === 'get_group_album_list'
? (action?.returnsSchema?.items as any)?.properties
: rootProperties?.album_list?.items?.properties;
expect(itemProperties).toEqual(expect.objectContaining({
last_upload_time: expect.objectContaining({
type: name === 'get_group_album_list' ? 'integer' : 'string',
}),
cover: expect.objectContaining({ type: ['object', 'null'] }),
}));
expect(itemProperties.cover.properties.image.properties).toEqual(expect.objectContaining({
photoUrls: expect.objectContaining({ type: 'array' }),
defaultUrl: expect.objectContaining({ type: ['object', 'null'] }),
isGif: expect.objectContaining({ type: 'boolean' }),
hasRaw: expect.objectContaining({ type: 'boolean' }),
}));
},
);
it('renders markdown with header + an action section', () => {
const md = renderActionDocsMarkdown(docs);
expect(md).toContain('# OneBot Actions');
@@ -1324,13 +1324,39 @@ describe('extended-actions / TierB ③ share + doubt + robot-option', () => {
});
// ─── napcat-parity: get_qun_album_list (QQ NT AlbumService) ───
describe('extended-actions / get_group_album_list', () => {
it('returns the cover and last-upload metadata from the core facade', async () => {
const albumList = [{
id: 'a1',
name: '相册一',
picNum: 5,
createTime: 1700000000,
desc: '',
owner: '10001',
createuin: '10001',
createnickname: '测试用户',
last_upload_time: 1700000123,
cover: { type: 1, image: null },
}];
const list = vi.fn(async () => albumList);
const bridge = fakeBridge({ apis: { groupAlbum: { list } } });
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_album_list', { group_id: 12345 });
expect(list).toHaveBeenCalledWith(12345);
expect(res).toMatchObject({ status: 'ok', data: albumList });
});
});
describe('extended-actions / get_qun_album_list', () => {
it('forwards the pagination cursor and returns the native AlbumService envelope', async () => {
const albumList = [{
album_id: 'a1',
name: '相册一',
create_time: '1700000000',
last_upload_time: '1700000123',
upload_number: '5',
cover: { type: 1, image: null },
creator: { uin: '10001', nick: '😂[em]e328514[/em]' },
}];
const listQun = vi.fn(async () => ({
@@ -46,6 +46,8 @@ export interface GroupAlbumInfo {
modifyTime?: pb<6, uint_64>;
lastUploadTime?: pb<7, uint_64>;
uploadNumber?: pb<8, uint_64>;
// QQ NT AlbumCodec_DecodeAlbumInfo tag 9 reuses the media-list MediaInfo codec.
cover?: pb<9, MediaInfo>;
creator?: pb<10, AlbumCreator>;
topFlag?: pb<11, uint_64>;
busiType?: pb<12, int_32>;