fix(album): restore comments and support batch targets
Initialize comment like data required by QQNT and allow comments on upload batches while preserving single-image targets. Add action and native argument conversion regression tests. Fixes #1979. Closes #2028.
This commit is contained in:
@@ -678,8 +678,9 @@ export class NTQQWebApi {
|
||||
async doAlbumMediaPlainCommentByNTQQ (
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
lloc: string,
|
||||
content: string) {
|
||||
lloc: string | undefined,
|
||||
content: string,
|
||||
batchId: string = '') {
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
// 16位number数字
|
||||
@@ -692,7 +693,7 @@ export class NTQQWebApi {
|
||||
},
|
||||
qunId,
|
||||
2,
|
||||
createAlbumMediaFeed(uin, albumId, lloc),
|
||||
createAlbumMediaFeed(uin, albumId, lloc, batchId),
|
||||
createAlbumCommentRequest(uin, content, client_key)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,14 +65,20 @@ export interface AlbumMediaFeed {
|
||||
* 创建相册媒体请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param lloc
|
||||
* @param lloc 图片ID;不传或为空时评论整个上传批次
|
||||
* @param batchId 上传批次ID;lloc 为空时必填
|
||||
* @returns 媒体请求参数对象
|
||||
*/
|
||||
export function createAlbumMediaFeed (
|
||||
uin: string,
|
||||
albumId: string,
|
||||
lloc: string
|
||||
lloc?: string,
|
||||
batchId: string = ''
|
||||
): AlbumMediaFeed {
|
||||
if (!lloc && !batchId) {
|
||||
throw new Error('lloc 或 batch_id 至少需要提供一个');
|
||||
}
|
||||
|
||||
return {
|
||||
cell_common: {
|
||||
time: '',
|
||||
@@ -84,12 +90,8 @@ export function createAlbumMediaFeed (
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: '',
|
||||
media_items: [{
|
||||
image: {
|
||||
lloc,
|
||||
},
|
||||
}],
|
||||
batch_id: batchId,
|
||||
media_items: lloc ? [{ image: { lloc } }] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -114,6 +116,11 @@ export interface AlbumCommentReplyContent {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
like: {
|
||||
id: string;
|
||||
count: number;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
export enum RichMsgType {
|
||||
KRICHMSGTYPEPLAINTEXT,
|
||||
@@ -148,6 +155,12 @@ export function createAlbumCommentRequest (
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
// QQNT 会将 like 转换为对象,即使新评论没有点赞也不能省略。
|
||||
like: {
|
||||
id: '',
|
||||
count: 0,
|
||||
status: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
export const DoGroupAlbumCommentPayloadSchema = Type.Object({
|
||||
group_id: Type.String({ description: '群号' }),
|
||||
album_id: Type.String({ description: '相册 ID' }),
|
||||
lloc: Type.String({ description: '图片 ID' }),
|
||||
lloc: Type.Optional(Type.String({ description: '图片 ID;不传或为空时评论整个上传批次,此时必须提供 batch_id' })),
|
||||
batch_id: Type.Optional(Type.String({ description: '上传批次 ID,可取自 get_group_album_media_list 返回的 batch.link_id 或 media[].batch_id' })),
|
||||
content: Type.String({ description: '评论内容' }),
|
||||
});
|
||||
|
||||
@@ -14,11 +15,12 @@ export type DoGroupAlbumCommentPayload = Static<typeof DoGroupAlbumCommentPayloa
|
||||
export class DoGroupAlbumComment extends OneBotAction<DoGroupAlbumCommentPayload, any> {
|
||||
override actionName = ActionName.DoGroupAlbumComment;
|
||||
override actionSummary = '发表群相册评论';
|
||||
override actionDescription = 'lloc 与 batch_id 至少提供一项。传入 lloc 时评论单张图片;仅传 batch_id 时评论整个上传批次。';
|
||||
override actionTags = ['群组扩展'];
|
||||
override payloadExample = {
|
||||
group_id: '123456',
|
||||
album_id: 'album_id_1',
|
||||
lloc: 'media_id_1',
|
||||
batch_id: '2147483658',
|
||||
content: '很有意思',
|
||||
};
|
||||
|
||||
@@ -29,12 +31,22 @@ export class DoGroupAlbumComment extends OneBotAction<DoGroupAlbumCommentPayload
|
||||
override payloadSchema = DoGroupAlbumCommentPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '评论结果' });
|
||||
|
||||
protected override async check (payload: DoGroupAlbumCommentPayload): Promise<BaseCheckResult> {
|
||||
const result = await super.check(payload);
|
||||
if (!result.valid) return result;
|
||||
if (!payload.lloc && !payload.batch_id) {
|
||||
return { valid: false, message: 'lloc 或 batch_id 至少需要提供一个' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async _handle (payload: DoGroupAlbumCommentPayload) {
|
||||
return await this.core.apis.WebApi.doAlbumMediaPlainCommentByNTQQ(
|
||||
payload.group_id,
|
||||
payload.album_id,
|
||||
payload.lloc,
|
||||
payload.content
|
||||
payload.content,
|
||||
payload.batch_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because they import each other.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { NTQQWebApi } from '../napcat-core/apis/webapi';
|
||||
import { DoGroupAlbumComment } from '../napcat-onebot/action/extends/DoGroupAlbumComment';
|
||||
|
||||
const GROUP_ID = '123456';
|
||||
const ALBUM_ID = 'album_id_1';
|
||||
const BATCH_ID = '2147483658';
|
||||
const LLOC = 'media_id_1';
|
||||
const UIN = '654321';
|
||||
const CONTENT = '很有意思';
|
||||
const BASE_PAYLOAD = { group_id: GROUP_ID, album_id: ALBUM_ID, content: CONTENT };
|
||||
|
||||
function createAction () {
|
||||
const nativeResult = { result: 0 };
|
||||
const doQunComment = vi.fn().mockResolvedValue(nativeResult);
|
||||
const context = {
|
||||
session: { getAlbumService: () => ({ doQunComment }) },
|
||||
logger: { logError: vi.fn() },
|
||||
};
|
||||
const webApi = new NTQQWebApi(context as never, { selfInfo: { uin: UIN } } as never);
|
||||
const action = new DoGroupAlbumComment(undefined as never, {
|
||||
context,
|
||||
apis: { WebApi: webApi },
|
||||
} as never);
|
||||
return { action, webApi, doQunComment, nativeResult };
|
||||
}
|
||||
|
||||
describe('do_group_album_comment', () => {
|
||||
test.each([
|
||||
{ name: '原有单图评论', target: { lloc: LLOC }, batchId: '', mediaItems: [{ image: { lloc: LLOC } }] },
|
||||
{ name: '仅指定上传批次', target: { batch_id: BATCH_ID }, batchId: BATCH_ID, mediaItems: [] },
|
||||
{ name: '批次评论允许空 lloc', target: { batch_id: BATCH_ID, lloc: '' }, batchId: BATCH_ID, mediaItems: [] },
|
||||
{ name: '同时提供批次和图片时评论单图', target: { batch_id: BATCH_ID, lloc: LLOC }, batchId: BATCH_ID, mediaItems: [{ image: { lloc: LLOC } }] },
|
||||
])('$name', async ({ target, batchId, mediaItems }) => {
|
||||
const { action, doQunComment, nativeResult } = createAction();
|
||||
|
||||
const response = await action.handle({ ...BASE_PAYLOAD, ...target }, 'test', {} as never);
|
||||
|
||||
expect(response.status).toBe('ok');
|
||||
expect(response.data).toBe(nativeResult);
|
||||
expect(doQunComment).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(Number),
|
||||
{ map_info: [], map_bytes_info: [], map_user_account: [] },
|
||||
GROUP_ID,
|
||||
2,
|
||||
{
|
||||
cell_common: { time: '' },
|
||||
cell_user_info: { user: { uin: UIN } },
|
||||
cell_media: { album_id: ALBUM_ID, batch_id: batchId, media_items: mediaItems },
|
||||
},
|
||||
expect.objectContaining({
|
||||
client_key: expect.any(Number),
|
||||
content: [{ type: 0, content: CONTENT, who: 0, uid: '', name: '', url: '' }],
|
||||
user: { uin: UIN },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{},
|
||||
{ lloc: '' },
|
||||
{ batch_id: '' },
|
||||
{ lloc: '', batch_id: '' },
|
||||
])('拒绝未指定有效评论目标的请求:%j', async (target) => {
|
||||
const { action, doQunComment } = createAction();
|
||||
|
||||
const response = await action.handle({ ...BASE_PAYLOAD, ...target }, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(response.message).toContain('lloc');
|
||||
expect(response.message).toContain('batch_id');
|
||||
expect(doQunComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('WebSocket 请求同样校验评论目标并保留 echo', async () => {
|
||||
const { action, doQunComment } = createAction();
|
||||
|
||||
const response = await action.websocketHandle(BASE_PAYLOAD, 'echo', 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 1400, echo: 'echo' });
|
||||
expect(doQunComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('批次评论仍要求提供评论内容', async () => {
|
||||
const { action, doQunComment } = createAction();
|
||||
|
||||
const response = await action.handle({ group_id: GROUP_ID, album_id: ALBUM_ID, batch_id: BATCH_ID } as never, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', retcode: 400 });
|
||||
expect(doQunComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('底层评论失败时返回错误', async () => {
|
||||
const { action, doQunComment } = createAction();
|
||||
doQunComment.mockRejectedValue(new Error('comment failed'));
|
||||
|
||||
const response = await action.handle({ ...BASE_PAYLOAD, batch_id: BATCH_ID }, 'test', {} as never);
|
||||
|
||||
expect(response).toMatchObject({ status: 'failed', message: 'comment failed' });
|
||||
expect(doQunComment).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('doAlbumMediaPlainCommentByNTQQ', () => {
|
||||
test('保留原有四参数调用的单图评论行为', async () => {
|
||||
const { webApi, doQunComment, nativeResult } = createAction();
|
||||
|
||||
await expect(webApi.doAlbumMediaPlainCommentByNTQQ(GROUP_ID, ALBUM_ID, LLOC, CONTENT)).resolves.toBe(nativeResult);
|
||||
|
||||
expect(doQunComment.mock.calls[0]?.[4].cell_media).toEqual({
|
||||
album_id: ALBUM_ID,
|
||||
batch_id: '',
|
||||
media_items: [{ image: { lloc: LLOC } }],
|
||||
});
|
||||
});
|
||||
|
||||
test('直接调用核心 API 时也拒绝空评论目标', async () => {
|
||||
const { webApi, doQunComment } = createAction();
|
||||
|
||||
await expect(webApi.doAlbumMediaPlainCommentByNTQQ(GROUP_ID, ALBUM_ID, undefined, CONTENT))
|
||||
.rejects.toThrow('lloc 或 batch_id');
|
||||
expect(doQunComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { NTQQWebApi } from '@/napcat-core/apis/webapi';
|
||||
import { createAlbumCommentRequest, createAlbumMediaFeed } from '@/napcat-core/data/album';
|
||||
import type { NodeIKernelAlbumService } from '@/napcat-core/services/NodeIKernelAlbumService';
|
||||
import { DoGroupAlbumComment } from '@/napcat-onebot/action/extends/DoGroupAlbumComment';
|
||||
|
||||
describe('do_group_album_comment native arguments', () => {
|
||||
it('submits the documented payload with an empty like object required by QQNT', async () => {
|
||||
const result = { result: 0, comment: { id: 'comment-id' } };
|
||||
const doQunComment = vi.fn<NodeIKernelAlbumService['doQunComment']>().mockResolvedValue(result);
|
||||
const core = { selfInfo: { uin: '10001' }, apis: {} as { WebApi: NTQQWebApi } };
|
||||
core.apis.WebApi = new NTQQWebApi({
|
||||
session: { getAlbumService: () => ({ doQunComment }) },
|
||||
} as never, core as never);
|
||||
const action = new DoGroupAlbumComment(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({
|
||||
group_id: '12345',
|
||||
album_id: 'album-id',
|
||||
lloc: 'photo-id',
|
||||
content: '测试评论',
|
||||
})).resolves.toBe(result);
|
||||
|
||||
expect(doQunComment).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(Number),
|
||||
{ map_info: [], map_bytes_info: [], map_user_account: [] },
|
||||
'12345',
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cell_media: expect.objectContaining({
|
||||
album_id: 'album-id',
|
||||
media_items: [{ image: { lloc: 'photo-id' } }],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
client_key: expect.any(Number),
|
||||
content: [expect.objectContaining({ type: 0, content: '测试评论' })],
|
||||
user: { uin: '10001' },
|
||||
like: { id: '', count: 0, status: 0 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Optional native regression: point this at a QQ installation's wrapper.node.
|
||||
// An unbound service parses arguments but cannot log in or submit a comment.
|
||||
it.skipIf(!process.env['NAPCAT_TEST_QQ_WRAPPER'])('passes native argument conversion (issue #1979)', () => {
|
||||
const wrapperPath = resolve(process.env['NAPCAT_TEST_QQ_WRAPPER']!);
|
||||
const args = [
|
||||
1234,
|
||||
{ map_info: [], map_bytes_info: [], map_user_account: [] },
|
||||
'12345',
|
||||
2,
|
||||
createAlbumMediaFeed('10001', 'album-id', 'photo-id'),
|
||||
createAlbumCommentRequest('10001', '测试评论', 1234567890),
|
||||
];
|
||||
const probe = spawnSync(process.execPath, ['-e', `
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const { wrapperPath, args } = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
const native = { exports: {} };
|
||||
process.dlopen(native, wrapperPath);
|
||||
const service = new native.exports.NodeIKernelAlbumService();
|
||||
const legacyArgs = args.slice();
|
||||
legacyArgs[5] = { ...args[5] };
|
||||
delete legacyArgs[5].like;
|
||||
assert.throws(() => service.doQunComment(...legacyArgs), /Cannot convert undefined or null to object/);
|
||||
// Reaching the unbound-service check proves all six arguments were converted.
|
||||
assert.throws(() => service.doQunComment(...args), /implementation of IKernelAlbumService is not valid/);
|
||||
process.exit(0);
|
||||
`], {
|
||||
cwd: dirname(wrapperPath),
|
||||
input: JSON.stringify({ wrapperPath, args }),
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
expect(probe.error).toBeUndefined();
|
||||
expect(probe.status, probe.stderr || probe.stdout).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user