From 3f66b369017cf1e202583257baa0c19406cd186c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Mon, 12 Oct 2026 12:46:50 +0800 Subject: [PATCH] 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. --- packages/napcat-core/apis/webapi.ts | 7 +- packages/napcat-core/data/album.ts | 29 ++-- .../action/extends/DoGroupAlbumComment.ts | 20 ++- .../napcat-test/groupAlbumComment.test.ts | 126 ++++++++++++++++++ .../groupAlbumCommentNative.test.ts | 85 ++++++++++++ 5 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 packages/napcat-test/groupAlbumComment.test.ts create mode 100644 packages/napcat-test/groupAlbumCommentNative.test.ts diff --git a/packages/napcat-core/apis/webapi.ts b/packages/napcat-core/apis/webapi.ts index f5a0f60e..34a34d82 100644 --- a/packages/napcat-core/apis/webapi.ts +++ b/packages/napcat-core/apis/webapi.ts @@ -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) ); } diff --git a/packages/napcat-core/data/album.ts b/packages/napcat-core/data/album.ts index 6a5181ce..d52a2b9c 100644 --- a/packages/napcat-core/data/album.ts +++ b/packages/napcat-core/data/album.ts @@ -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, + }, }; } diff --git a/packages/napcat-onebot/action/extends/DoGroupAlbumComment.ts b/packages/napcat-onebot/action/extends/DoGroupAlbumComment.ts index e7aaf755..bb1599a6 100644 --- a/packages/napcat-onebot/action/extends/DoGroupAlbumComment.ts +++ b/packages/napcat-onebot/action/extends/DoGroupAlbumComment.ts @@ -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 { 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 { + 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 ); } } diff --git a/packages/napcat-test/groupAlbumComment.test.ts b/packages/napcat-test/groupAlbumComment.test.ts new file mode 100644 index 00000000..ec5de4a2 --- /dev/null +++ b/packages/napcat-test/groupAlbumComment.test.ts @@ -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(); + }); +}); diff --git a/packages/napcat-test/groupAlbumCommentNative.test.ts b/packages/napcat-test/groupAlbumCommentNative.test.ts new file mode 100644 index 00000000..64069fd5 --- /dev/null +++ b/packages/napcat-test/groupAlbumCommentNative.test.ts @@ -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().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); + }); +});