diff --git a/packages/core/src/bridge/apis/qzone.ts b/packages/core/src/bridge/apis/qzone.ts index 273822dc..6decc7c3 100644 --- a/packages/core/src/bridge/apis/qzone.ts +++ b/packages/core/src/bridge/apis/qzone.ts @@ -5,6 +5,7 @@ import { getQzoneMsgDetail, getQzoneMsgList, publishQzoneMsg, + replyQzoneComment, setQzoneBlack, setQzoneLike, updateQzoneMsgRight, @@ -14,6 +15,7 @@ import { type QzoneMsgDetailResult, type QzoneMsgListResult, type QzonePublishResult, + type QzoneReplyTarget, type QzoneUpdateRightResult, type QzoneUploadImageResult, } from '@snowluma/protocol/web/qzone'; @@ -133,6 +135,7 @@ export class QzoneApi { * 始终以机器人身份发表评论。返回新评论 id(尽力而为)。 * `richType` / `richval`: 带图评论时传 richType=1 和图片直链 URL * (从 uploadImageFromSource 的 url 字段获取)。 + * `reply` 指定父评论及可选回复对象;楼中楼回复仅支持文字。 */ async comment( tid: string, @@ -140,10 +143,20 @@ export class QzoneApi { targetUin: number | undefined, richType?: number, richval?: string, + reply?: QzoneReplyTarget, ): Promise { + if (reply !== undefined && (richType !== undefined || richval !== undefined)) { + throw new Error('images are not supported for Qzone comment replies'); + } + const selfUin = this.ctx.identity.uin; const owner = targetUin && targetUin > 0 ? targetUin.toString() : selfUin; const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com'); + + if (reply !== undefined) { + return replyQzoneComment(cookieObject, selfUin, owner, tid, content, reply); + } + return commentQzoneMsg(cookieObject, selfUin, owner, tid, content, richType, richval); } diff --git a/packages/core/tests/actions/qzone.test.ts b/packages/core/tests/actions/qzone.test.ts index 8aeb04c5..1fe91407 100644 --- a/packages/core/tests/actions/qzone.test.ts +++ b/packages/core/tests/actions/qzone.test.ts @@ -290,4 +290,53 @@ describe('apis/qzone', () => { 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(); + }); }); diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 0f5f8d89..02327ad4 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -1283,13 +1283,21 @@ { "name": "comment_qzone", "aliases": [], - "summary": "评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)", + "summary": "评论说说或回复指定评论(普通评论支持带图,楼中楼仅支持文字)", "returnsSchema": { "type": "object", "properties": { "comment_id": { "type": "string", - "description": "服务端未提供评论 ID 时为空串" + "description": "新评论或新回复的 ID;普通评论未返回 ID 时为空串,楼中楼回复不会用父编号代替" + }, + "parent_comment_id": { + "type": "string", + "description": "仅楼中楼回复返回,所属一级评论 ID" + }, + "parent_comment_uin": { + "type": "integer", + "description": "仅楼中楼回复返回,所属一级评论作者 QQ 号" } }, "required": [ @@ -1305,6 +1313,30 @@ "content": "感谢分享" }, "notes": "tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。" + }, + { + "summary": "回复指定一级评论,保留说说所属账号", + "params": { + "tid": "", + "target_uin": 10001, + "content": "感谢回复", + "comment_id": "0", + "comment_uin": 10002 + }, + "notes": "comment_id 和 comment_uin 分别取 get_qzone_msg_detail 的一级评论 comment_id 和 uin;编号 0 有效。楼中楼仅支持文字。" + }, + { + "summary": "回复楼中楼中的某位评论者", + "params": { + "tid": "", + "target_uin": 10001, + "content": "我也这么认为", + "comment_id": "0", + "comment_uin": 10002, + "reply_uin": 10003, + "reply_name": "评论者" + }, + "notes": "comment_id/comment_uin 使用该回复的 parent_comment_id/parent_comment_uin;reply_uin/reply_name 使用该回复的 uin/name,不把二级回复自身的 comment_id 当成父评论编号。" } ], "readOnly": false, @@ -1340,6 +1372,48 @@ }, "desc": "说说所属 QQ 号,省略则为机器人自己" }, + { + "name": "comment_id", + "type": "string", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + }, + "desc": "一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID" + }, + { + "name": "comment_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号" + }, + { + "name": "reply_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论" + }, + { + "name": "reply_name", + "type": "string", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + }, + "desc": "要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供" + }, { "name": "images", "type": "string[]", @@ -1354,7 +1428,14 @@ "desc": "图片数组(可选),支持 file:// http:// base64://;自动上传" } ], - "invariants": [], + "invariants": [ + "all or none of: comment_id, comment_uin", + "all or none of: reply_uin, reply_name", + "reply_uin/reply_name require comment_id/comment_uin", + "images are not supported for comment replies", + "comment_id, reply_name and reply content must not be blank", + "comment_uin and reply_uin must be safe integers" + ], "inputSchema": { "type": "object", "properties": { @@ -1374,6 +1455,28 @@ "description": "说说所属 QQ 号,省略则为机器人自己", "x-role": "user_id" }, + "comment_id": { + "type": "string", + "minLength": 1, + "description": "一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID" + }, + "comment_uin": { + "type": "integer", + "minimum": 1, + "description": "一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号", + "x-role": "user_id" + }, + "reply_uin": { + "type": "integer", + "minimum": 1, + "description": "楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论", + "x-role": "user_id" + }, + "reply_name": { + "type": "string", + "minLength": 1, + "description": "要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供" + }, "images": { "type": "array", "items": { @@ -10799,6 +10902,14 @@ "comment_id": { "type": "string", "description": "评论 ID,在所属说说或父评论内唯一" + }, + "parent_comment_id": { + "type": "string", + "description": "所属一级评论 ID;回复时作为 comment_id" + }, + "parent_comment_uin": { + "type": "integer", + "description": "所属一级评论作者;回复时作为 comment_uin" } }, "required": [ @@ -10806,7 +10917,9 @@ "name", "time", "content", - "comment_id" + "comment_id", + "parent_comment_id", + "parent_comment_uin" ] } } diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 85af012b..8ae0a2a8 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -1286,13 +1286,21 @@ export const ACTIONS: CatalogAction[] = [ { "name": "comment_qzone", "aliases": [], - "summary": "评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)", + "summary": "评论说说或回复指定评论(普通评论支持带图,楼中楼仅支持文字)", "returnsSchema": { "type": "object", "properties": { "comment_id": { "type": "string", - "description": "服务端未提供评论 ID 时为空串" + "description": "新评论或新回复的 ID;普通评论未返回 ID 时为空串,楼中楼回复不会用父编号代替" + }, + "parent_comment_id": { + "type": "string", + "description": "仅楼中楼回复返回,所属一级评论 ID" + }, + "parent_comment_uin": { + "type": "integer", + "description": "仅楼中楼回复返回,所属一级评论作者 QQ 号" } }, "required": [ @@ -1308,6 +1316,30 @@ export const ACTIONS: CatalogAction[] = [ "content": "感谢分享" }, "notes": "tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。" + }, + { + "summary": "回复指定一级评论,保留说说所属账号", + "params": { + "tid": "", + "target_uin": 10001, + "content": "感谢回复", + "comment_id": "0", + "comment_uin": 10002 + }, + "notes": "comment_id 和 comment_uin 分别取 get_qzone_msg_detail 的一级评论 comment_id 和 uin;编号 0 有效。楼中楼仅支持文字。" + }, + { + "summary": "回复楼中楼中的某位评论者", + "params": { + "tid": "", + "target_uin": 10001, + "content": "我也这么认为", + "comment_id": "0", + "comment_uin": 10002, + "reply_uin": 10003, + "reply_name": "评论者" + }, + "notes": "comment_id/comment_uin 使用该回复的 parent_comment_id/parent_comment_uin;reply_uin/reply_name 使用该回复的 uin/name,不把二级回复自身的 comment_id 当成父评论编号。" } ], "readOnly": false, @@ -1343,6 +1375,48 @@ export const ACTIONS: CatalogAction[] = [ }, "desc": "说说所属 QQ 号,省略则为机器人自己" }, + { + "name": "comment_id", + "type": "string", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + }, + "desc": "一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID" + }, + { + "name": "comment_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号" + }, + { + "name": "reply_uin", + "type": "uint", + "required": false, + "role": "user_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论" + }, + { + "name": "reply_name", + "type": "string", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + }, + "desc": "要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供" + }, { "name": "images", "type": "string[]", @@ -1357,7 +1431,14 @@ export const ACTIONS: CatalogAction[] = [ "desc": "图片数组(可选),支持 file:// http:// base64://;自动上传" } ], - "invariants": [], + "invariants": [ + "all or none of: comment_id, comment_uin", + "all or none of: reply_uin, reply_name", + "reply_uin/reply_name require comment_id/comment_uin", + "images are not supported for comment replies", + "comment_id, reply_name and reply content must not be blank", + "comment_uin and reply_uin must be safe integers" + ], "inputSchema": { "type": "object", "properties": { @@ -1377,6 +1458,28 @@ export const ACTIONS: CatalogAction[] = [ "description": "说说所属 QQ 号,省略则为机器人自己", "x-role": "user_id" }, + "comment_id": { + "type": "string", + "minLength": 1, + "description": "一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID" + }, + "comment_uin": { + "type": "integer", + "minimum": 1, + "description": "一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号", + "x-role": "user_id" + }, + "reply_uin": { + "type": "integer", + "minimum": 1, + "description": "楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论", + "x-role": "user_id" + }, + "reply_name": { + "type": "string", + "minLength": 1, + "description": "要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供" + }, "images": { "type": "array", "items": { @@ -10802,6 +10905,14 @@ export const ACTIONS: CatalogAction[] = [ "comment_id": { "type": "string", "description": "评论 ID,在所属说说或父评论内唯一" + }, + "parent_comment_id": { + "type": "string", + "description": "所属一级评论 ID;回复时作为 comment_id" + }, + "parent_comment_uin": { + "type": "integer", + "description": "所属一级评论作者;回复时作为 comment_uin" } }, "required": [ @@ -10809,7 +10920,9 @@ export const ACTIONS: CatalogAction[] = [ "name", "time", "content", - "comment_id" + "comment_id", + "parent_comment_id", + "parent_comment_uin" ] } } diff --git a/packages/onebot/src/actions/qzone.ts b/packages/onebot/src/actions/qzone.ts index 8e5751fa..d70aeca7 100644 --- a/packages/onebot/src/actions/qzone.ts +++ b/packages/onebot/src/actions/qzone.ts @@ -130,8 +130,22 @@ export const actions = [ description: '服务端内联二级回复,可能不完整', items: { type: 'object', - properties: qzoneCommentProperties, - required: qzoneCommentRequired, + properties: { + ...qzoneCommentProperties, + parent_comment_id: { + type: 'string', + description: '所属一级评论 ID;回复时作为 comment_id', + }, + parent_comment_uin: { + type: 'integer', + description: '所属一级评论作者;回复时作为 comment_uin', + }, + }, + required: [ + ...qzoneCommentRequired, + 'parent_comment_id', + 'parent_comment_uin', + ], }, }, }, @@ -462,28 +476,123 @@ export const actions = [ }, notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。', }, + { + summary: '回复指定一级评论,保留说说所属账号', + params: { + tid: '', + target_uin: 10001, + content: '感谢回复', + comment_id: '0', + comment_uin: 10002, + }, + notes: 'comment_id 和 comment_uin 分别取 get_qzone_msg_detail 的一级评论 comment_id 和 uin;编号 0 有效。楼中楼仅支持文字。', + }, + { + summary: '回复楼中楼中的某位评论者', + params: { + tid: '', + target_uin: 10001, + content: '我也这么认为', + comment_id: '0', + comment_uin: 10002, + reply_uin: 10003, + reply_name: '评论者', + }, + notes: 'comment_id/comment_uin 使用该回复的 parent_comment_id/parent_comment_uin;reply_uin/reply_name 使用该回复的 uin/name,不把二级回复自身的 comment_id 当成父评论编号。', + }, ], returnsSchema: { type: 'object', properties: { comment_id: { type: 'string', - description: '服务端未提供评论 ID 时为空串', + description: + '新评论或新回复的 ID;普通评论未返回 ID 时为空串,楼中楼回复不会用父编号代替', + }, + parent_comment_id: { + type: 'string', + description: '仅楼中楼回复返回,所属一级评论 ID', + }, + parent_comment_uin: { + type: 'integer', + description: '仅楼中楼回复返回,所属一级评论作者 QQ 号', }, }, required: ['comment_id'], }, - summary: '评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)', + summary: '评论说说或回复指定评论(普通评论支持带图,楼中楼仅支持文字)', params: { tid: f.string({ allowEmpty: false }).describe('说说 tid'), content: f.string({ allowEmpty: false }).describe('评论内容'), target_uin: f.userId().describe('说说所属 QQ 号,省略则为机器人自己').optional(), + comment_id: f + .string({ allowEmpty: false }) + .describe( + '一级父评论 ID,与 comment_uin 同时提供;编号 0 有效,不能传二级回复自身的 ID', + ) + .optional(), + comment_uin: f + .userId() + .describe('一级父评论作者 QQ 号,与 comment_id 同时提供;不是说说所属账号') + .optional(), + reply_uin: f + .userId() + .describe('楼中楼中要回复的用户 QQ 号,与 reply_name 同时提供,需指定父评论') + .optional(), + reply_name: f + .string({ allowEmpty: false }) + .describe('要回复的用户昵称,来自评论详情的 name;与 reply_uin 同时提供') + .optional(), images: f .array(f.string({ allowEmpty: false })) .describe('图片数组(可选),支持 file:// http:// base64://;自动上传') .optional(), }, + rules: (r) => [ + r.requiredTogether('comment_id', 'comment_uin'), + r.requiredTogether('reply_uin', 'reply_name'), + r.rule( + 'reply_uin/reply_name require comment_id/comment_uin', + (p) => p.reply_uin === undefined || p.comment_id !== undefined, + ), + r.rule( + 'images are not supported for comment replies', + (p) => p.comment_id === undefined || !p.images?.length, + ), + r.rule( + 'comment_id, reply_name and reply content must not be blank', + (p) => + (p.comment_id === undefined || + (p.comment_id.trim().length > 0 && p.content.trim().length > 0)) && + (p.reply_name === undefined || p.reply_name.trim().length > 0), + ), + r.rule( + 'comment_uin and reply_uin must be safe integers', + (p) => + (p.comment_uin === undefined || Number.isSafeInteger(p.comment_uin)) && + (p.reply_uin === undefined || Number.isSafeInteger(p.reply_uin)), + ), + ], run: async (p, ctx) => { + if (p.comment_id !== undefined && p.comment_uin !== undefined) { + return okResponse( + await ctx.bridge.apis.qzone.comment( + p.tid, + p.content, + p.target_uin, + undefined, + undefined, + { + commentId: p.comment_id, + commentUin: String(p.comment_uin), + ...(p.reply_uin !== undefined && p.reply_name !== undefined + ? { replyTo: { uin: String(p.reply_uin), name: p.reply_name } } + : {}), + }, + ), + ); + } + if (p.images && p.images.length > 0) { const richval = await uploadQzoneImages(ctx, p.images, (upload) => upload.url); const res = await ctx.bridge.apis.qzone.comment( diff --git a/packages/onebot/tests/qzone-actions.test.ts b/packages/onebot/tests/qzone-actions.test.ts index 1230ea4d..a641896a 100644 --- a/packages/onebot/tests/qzone-actions.test.ts +++ b/packages/onebot/tests/qzone-actions.test.ts @@ -116,6 +116,85 @@ describe('qzone actions', () => { expect(comment).toHaveBeenCalledWith('T1', 'nice', 20002, 1, 'URL1\tURL2'); }); + it('comment_qzone replies within the selected parent without changing the post owner', async () => { + const result = { comment_id: '8', parent_comment_id: '0', parent_comment_uin: 30003 }; + const comment = vi.fn().mockResolvedValue(result); + const res = await makeHandler({ comment }).handle('comment_qzone', { + tid: 'T1', + content: 'Reply', + target_uin: '20002', + comment_id: '0', + comment_uin: '30003', + reply_uin: '40004', + reply_name: 'Reader', + }); + + expect(res).toMatchObject({ status: 'ok', retcode: 0, data: result }); + expect(comment).toHaveBeenCalledWith('T1', 'Reply', 20002, undefined, undefined, { + commentId: '0', + commentUin: '30003', + replyTo: { uin: '40004', name: 'Reader' }, + }); + }); + + it('accepts parent ID zero without a nested recipient and leaves ordinary text comments unchanged', async () => { + const comment = vi.fn().mockResolvedValue({ comment_id: '8' }); + const handler = makeHandler({ comment }); + await handler.handle('comment_qzone', { + tid: 'T1', + content: 'Reply', + comment_id: 0, + comment_uin: 30003, + images: [], + }); + expect(comment).toHaveBeenNthCalledWith(1, 'T1', 'Reply', undefined, undefined, undefined, { + commentId: '0', + commentUin: '30003', + }); + await handler.handle('comment_qzone', { tid: 'T1', content: 'Comment' }); + expect(comment).toHaveBeenNthCalledWith(2, 'T1', 'Comment', undefined); + }); + + it('exposes reply failures without falling back to an ordinary comment', async () => { + const comment = vi + .fn() + .mockRejectedValue(new Error('qzone reply failed: code=-3000 denied')); + const res = await makeHandler({ comment }).handle('comment_qzone', { + tid: 'T1', + content: 'Reply', + comment_id: '0', + comment_uin: 30003, + }); + expect(res).toMatchObject({ status: 'failed', retcode: 100 }); + expect(res.wording).toContain('code=-3000 denied'); + expect(comment).toHaveBeenCalledTimes(1); + }); + + it.each([ + { comment_id: '0' }, + { comment_uin: 30003 }, + { comment_id: '', comment_uin: 30003 }, + { comment_id: ' ', comment_uin: 30003 }, + { comment_id: '0', comment_uin: 0 }, + { comment_id: '0', comment_uin: 30003, reply_uin: 40004 }, + { comment_id: '0', comment_uin: 30003, reply_name: 'Reader' }, + { comment_id: '0', comment_uin: 30003, reply_uin: 40004, reply_name: ' ' }, + { reply_uin: 40004, reply_name: 'Reader' }, + { comment_id: '0', comment_uin: 30003, images: ['file:///a.jpg'] }, + ])('rejects invalid reply parameters before uploads or comments (%j)', async (params) => { + const comment = vi.fn(); + const uploadImageFromSource = vi.fn(); + const res = await makeHandler({ comment, uploadImageFromSource }).handle('comment_qzone', { + tid: 'T1', + content: 'Reply', + ...params, + }); + + expect(res).toMatchObject({ status: 'failed', retcode: 1400 }); + expect(comment).not.toHaveBeenCalled(); + expect(uploadImageFromSource).not.toHaveBeenCalled(); + }); + it('returns a clear error and skips publish when one image upload fails', async () => { const uploadImageFromSource = vi .fn() diff --git a/packages/protocol/src/web/qzone.ts b/packages/protocol/src/web/qzone.ts index 0b6be0c3..b87dab35 100644 --- a/packages/protocol/src/web/qzone.ts +++ b/packages/protocol/src/web/qzone.ts @@ -1356,6 +1356,19 @@ export interface QzoneCommentResult { comment_id: string; } +export interface QzoneReplyTarget { + /** The top-level comment, even when replying to one of its replies. */ + commentId: string; + commentUin: string; + /** Optional author to address within that thread. */ + replyTo?: { uin: string; name: string }; +} + +export interface QzoneReplyResult extends QzoneCommentResult { + parent_comment_id: string; + parent_comment_uin: number; +} + /** * Comment on a 说说 (`tid`, owned by `hostUin`) as the bot (`selfUin`) via * taotao.qzone.qq.com's emotion_cgi_re_feeds CGI (proxied through @@ -1544,6 +1557,154 @@ export async function commentQzoneMsg( return { comment_id: commentId !== undefined ? String(commentId) : '' }; } +/** + * Reply within a Qzone comment thread. The web comment module uses a separate + * reply endpoint: commentId/commentUin identify the parent, not a nested reply. + * The addressed author is an automatic mention in content; there is no replyId + * request field. Images are not part of this request's supported contract. + * Request/result contract: qzonestyle.gtimg.cn/qzone/app/controls/commentModule/ + * viewModel/viewModel_3.0_common.js (ReplyViewModel.post). + */ +export async function replyQzoneComment( + cookieObject: Record, + selfUin: string, + hostUin: string, + tid: string, + content: string, + target: QzoneReplyTarget, +): Promise { + if (!cookieObject || typeof cookieObject !== 'object') { + throw new Error('cookieObject is required'); + } + + for (const [field, value] of Object.entries({ + selfUin, + hostUin, + commentUin: target.commentUin, + })) { + if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) { + throw new Error(`${field} must be a positive safe integer`); + } + } + + if (!tid.trim() || !content.trim() || !target.commentId.trim()) { + throw new Error('tid, content and commentId must not be blank'); + } + + let replyContent = content; + + if (target.replyTo !== undefined) { + const { uin, name } = target.replyTo; + + if (!/^[1-9]\d*$/.test(uin) || !Number.isSafeInteger(Number(uin)) || !name.trim()) { + throw new Error('replyTo requires a positive safe integer uin and a non-blank name'); + } + + // Match the web editor's mention encoding, not HTML or URL encoding. + const escapedName = name + .trim() + .replace(/%/g, '%25') + .replace(/,/g, '%2C') + .replace(/}/g, '%7D'); + replyContent = `@{uin:${uin},nick:${escapedName},auto:1} ${content}`; + } + + const bkn = getBknFromCookie(cookieObject); + const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${bkn}`; + const body = new URLSearchParams({ + uin: selfUin, + hostUin, + hostuin: selfUin, + topicId: `${hostUin}_${tid}`, + commentId: target.commentId, + commentUin: target.commentUin, + content: replyContent, + inCharset: 'utf-8', + outCharset: 'utf-8', + ref: '', + code_version: '1', + format: 'fs', + qzreferrer: `https://user.qzone.qq.com/${hostUin}`, + }).toString(); + + log.debug( + 'replyQzoneComment: posting reply (host=%s tid=%s parent=%s)', + hostUin, + tid, + target.commentId, + ); + const text = await RequestUtil.HttpGetText(url, 'POST', body, { + Cookie: cookieToString(cookieObject), + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const response = parseQzoneJson(text); + + if ( + response === null || + typeof response !== 'object' || + Array.isArray(response) || + !('code' in response) || + typeof response.code !== 'number' || + !Number.isInteger(response.code) + ) { + throw new Error( + 'invalid qzone reply response: missing or invalid code; check delivery before retrying', + ); + } + + const subcode = 'subcode' in response ? response.subcode : undefined; + + if (subcode !== undefined && (typeof subcode !== 'number' || !Number.isInteger(subcode))) { + throw new Error( + 'invalid qzone reply response: invalid subcode; check delivery before retrying', + ); + } + + if (response.code !== 0 || (subcode !== undefined && subcode !== 0)) { + const message = + 'message' in response && typeof response.message === 'string' ? response.message : ''; + log.warn( + 'replyQzoneComment: rejected (host=%s tid=%s parent=%s) code=%s subcode=%s', + hostUin, + tid, + target.commentId, + response.code, + subcode, + ); + throw new Error( + `qzone reply failed: code=${response.code} subcode=${subcode ?? 0} ${message}`.trim(), + ); + } + + const data = 'data' in response ? response.data : undefined; + const id = + data !== null && typeof data === 'object' && !Array.isArray(data) && 'id' in data + ? data.id + : undefined; + + if (!( + (typeof id === 'string' && id.trim() !== '') || + (typeof id === 'number' && Number.isSafeInteger(id) && id >= 0) + )) { + throw new Error( + 'qzone reply accepted but returned an invalid reply ID; check delivery before retrying', + ); + } + + log.debug( + 'replyQzoneComment: accepted (host=%s tid=%s parent=%s reply=%s)', + hostUin, + tid, + target.commentId, + id, + ); + return { + comment_id: String(id), + parent_comment_id: target.commentId, + parent_comment_uin: Number(target.commentUin), + }; +} + // ─────── 改说说权限 (update right) — emotion_cgi_msgdetail_v6 + emotion_cgi_update ─────── // Changes the view permission (ugc_right) of an EXISTING 说说. The update CGI // is not a partial patch — it re-submits the whole feed (content, richval, @@ -1844,11 +2005,16 @@ export async function getQzoneMsgDetail( comments: detailList(data.commentlist, 'commentlist').map((value, index) => { const field = `commentlist[${index}]`; const comment = detailObject(value, field); + const mappedComment = mapQzoneComment(comment, field); return { - ...mapQzoneComment(comment, field), + ...mappedComment, replies: detailList(comment.list_3, `${field}.list_3`).map((reply, replyIndex) => { const replyField = `${field}.list_3[${replyIndex}]`; - return mapQzoneComment(detailObject(reply, replyField), replyField); + return { + ...mapQzoneComment(detailObject(reply, replyField), replyField), + parent_comment_id: mappedComment.comment_id, + parent_comment_uin: mappedComment.uin, + }; }), }; }), diff --git a/packages/protocol/tests/web/qzone-detail.test.ts b/packages/protocol/tests/web/qzone-detail.test.ts index 4c161bbb..ec4fa53e 100644 --- a/packages/protocol/tests/web/qzone-detail.test.ts +++ b/packages/protocol/tests/web/qzone-detail.test.ts @@ -37,6 +37,8 @@ describe('qzone / getQzoneMsgDetail', () => { replies: [ { comment_id: '1', + parent_comment_id: '0', + parent_comment_uin: 20002, uin: 10000, name: 'Bot', time: 1700000002, diff --git a/packages/protocol/tests/web/qzone-reply.test.ts b/packages/protocol/tests/web/qzone-reply.test.ts new file mode 100644 index 00000000..5bd82705 --- /dev/null +++ b/packages/protocol/tests/web/qzone-reply.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { replyQzoneComment } from '@snowluma/protocol/web/qzone'; +import { RequestUtil, getBknFromCookie } from '@snowluma/protocol/web/request-util'; + +const cookies = { p_skey: 'PSK', skey: 'SK', uin: 'o10000' }; +const parent = { commentId: '0', commentUin: '30003' }; + +// Synthetic envelopes and parameters following the public web comment module. +// These tests do not contact Qzone or claim live delivery/rendering coverage. +describe('qzone / replyQzoneComment', () => { + afterEach(() => vi.restoreAllMocks()); + + it('uses the reply endpoint and keeps login, post owner, parent and new reply IDs distinct', async () => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValue('frameElement.callback({"code":0,"data":{"id":8}});'); + const result = await replyQzoneComment( + cookies, + '10000', + '20002', + 'POST', + '回复 & 好', + parent, + ); + + expect(result).toEqual({ + comment_id: '8', + parent_comment_id: '0', + parent_comment_uin: 30003, + }); + expect(request).toHaveBeenCalledTimes(1); + const [url, method, body, headers] = request.mock.calls[0]!; + expect(url).toBe( + `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${getBknFromCookie(cookies)}`, + ); + expect(method).toBe('POST'); + expect(headers).toMatchObject({ + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: expect.stringContaining('p_skey=PSK'), + }); + expect(Object.fromEntries(new URLSearchParams(body as string))).toEqual({ + uin: '10000', + hostUin: '20002', + hostuin: '10000', + topicId: '20002_POST', + commentId: '0', + commentUin: '30003', + content: '回复 & 好', + inCharset: 'utf-8', + outCharset: 'utf-8', + ref: '', + code_version: '1', + format: 'fs', + qzreferrer: 'https://user.qzone.qq.com/20002', + }); + }); + + it('addresses a nested reply author without substituting that author for the parent', async () => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValue('{"code":0,"data":{"id":"9007199254740993"}}'); + const result = await replyQzoneComment(cookies, '10000', '20002', 'POST', '内容 😀 & +', { + ...parent, + replyTo: { uin: '40004', name: ' 名%,}😀 ' }, + }); + + const form = new URLSearchParams(request.mock.calls[0]![2] as string); + expect(form.get('commentId')).toBe('0'); + expect(form.get('commentUin')).toBe('30003'); + expect(form.get('content')).toBe('@{uin:40004,nick:名%25%2C%7D😀,auto:1} 内容 😀 & +'); + expect(form.has('replyId')).toBe(false); + expect(result.comment_id).toBe('9007199254740993'); + }); + + it('preserves a zero new reply ID', async () => { + vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"data":{"id":0}}'); + expect( + (await replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent)) + .comment_id, + ).toBe('0'); + }); + + it.each([ + { ...parent, commentId: '' }, + { ...parent, commentId: ' ' }, + { ...parent, commentUin: '0' }, + { ...parent, commentUin: 'wrong' }, + { ...parent, commentUin: '9007199254740993' }, + { ...parent, replyTo: { uin: '0', name: 'Reader' } }, + { ...parent, replyTo: { uin: '40004', name: ' ' } }, + ])('rejects invalid parent/recipient before network access (%j)', async (target) => { + const request = vi.spyOn(RequestUtil, 'HttpGetText'); + await expect( + replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', target), + ).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + }); + + it.each([ + ['0', '20002', 'POST', 'Reply'], + ['10000', '0', 'POST', 'Reply'], + ['10000', '20002', ' ', 'Reply'], + ['10000', '20002', 'POST', ' '], + ])( + 'rejects invalid post identity or body (%s, %s, %s, %s)', + async (self, owner, tid, content) => { + const request = vi.spyOn(RequestUtil, 'HttpGetText'); + await expect( + replyQzoneComment(cookies, self, owner, tid, content, parent), + ).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { code: -3000, message: 'permission denied' }, + { code: -3001, message: 'verification required' }, + { code: 0, subcode: -7, message: 'comment removed' }, + ])( + 'preserves server errors without retrying or posting an ordinary comment (%j)', + async (response) => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValue(JSON.stringify(response)); + await expect( + replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent), + ).rejects.toThrow(response.message); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + null, + [], + {}, + { code: '0' }, + { code: 0, subcode: '0' }, + { code: 0 }, + { code: 0, commentid: 9 }, + { code: 0, data: null }, + { code: 0, data: { id: '' } }, + { code: 0, data: { id: ' ' } }, + { code: 0, data: { id: {} } }, + { code: 0, data: { id: -1 } }, + { code: 0, data: { id: 9007199254740992 } }, + ])('does not report malformed acknowledgements as success (%j)', async (response) => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockResolvedValue(JSON.stringify(response)); + await expect( + replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent), + ).rejects.toThrow(); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('warns against blind retries when an accepted reply omits its ID', async () => { + vi.spyOn(RequestUtil, 'HttpGetText').mockResolvedValue('{"code":0,"data":{}}'); + await expect( + replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent), + ).rejects.toThrow( + 'accepted but returned an invalid reply ID; check delivery before retrying', + ); + }); + + it('propagates network failures without another write', async () => { + const request = vi + .spyOn(RequestUtil, 'HttpGetText') + .mockRejectedValue(new Error('network down')); + await expect( + replyQzoneComment(cookies, '10000', '20002', 'POST', 'Reply', parent), + ).rejects.toThrow('network down'); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sdk/MESSAGING.md b/packages/sdk/MESSAGING.md index 17ddac9d..091054d4 100644 --- a/packages/sdk/MESSAGING.md +++ b/packages/sdk/MESSAGING.md @@ -99,3 +99,26 @@ try { Waits register synchronously and do not connect, replay history, consume events from other listeners, or persist across reconnects. They observe the same public event stream as `onEvent`, **after middleware**; events stopped by middleware are not visible. `waitForMessage` includes both `message` and `message_sent`, so filter direction, conversation, and sender when needed. Multiple waits can resolve from the same event. The default timeout is the client's `requestTimeoutMs` (30 seconds by default); `0` disables it. Timeout rejects with `SnowLumaTimeoutError`, cancellation with `SnowLumaAbortError`, and disconnect or explicit `close()` with `SnowLumaConnectionError`. Predicate exceptions reject with their original error. Every completion path removes its listener, abort handler, and timer. Cancellation affects the wait only; it does not close the client or undo a sent message. + +## Reply to a Qzone comment + +Both clients expose this through the typed `raw('comment_qzone', params)` action. Omitting `comment_id` and `comment_uin` preserves ordinary comments, including image uploads. `target_uin` always identifies the **post owner**, not the person being replied to. + +Read `get_qzone_msg_detail` to obtain a parent comment's `comment_id` and `uin`. Pass both to create a text reply under that comment; `"0"` is a valid comment ID. To address someone inside the thread, also pass their `uin` and `name` as `reply_uin` and `reply_name`: + +```ts +const result = await bot.raw('comment_qzone', { + tid: post.tid, + target_uin: postOwnerUin, + comment_id: parentComment.comment_id, + comment_uin: parentComment.uin, + reply_uin: selectedReply.uin, + reply_name: selectedReply.name, + content: 'Thanks for the explanation!', +}); +console.log(result.comment_id, result.parent_comment_id); +``` + +`post`, `postOwnerUin`, `parentComment` and `selectedReply` above are the post and comments chosen by the caller. For an ordinary reply to the parent, omit `reply_uin` and `reply_name`. For a nested reply, keep the **top-level parent** in `comment_id`/`comment_uin`; do not substitute the nested reply's own ID. New servers also include `parent_comment_id` and `parent_comment_uin` on each inline reply to preserve this association. Inline replies may be incomplete; absence from a page does not mean a comment was deleted. + +On success, `comment_id` is the **new reply's** ID and the result includes its parent ID and author. Those parent fields are optional in SDK types for compatibility with older servers. Non-empty `images` with a parent comment are rejected before upload: threaded replies currently support text only. Incomplete recipient/parent parameters are errors, and a failed reply never falls back to an ordinary comment or retries automatically. If an acknowledgement is malformed after sending, check the thread before retrying; the reply may already exist. diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index 513a76ce..cd7d5ef9 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -606,7 +606,17 @@ export interface LikeQzoneParams extends QzoneMsgIdParams { export interface CommentQzoneParams extends QzoneMsgIdParams { content: ActionStringInput; + /** Post owner, not the author being replied to. Defaults to the current account. */ target_uin?: ActionNumberInput; + /** Top-level parent ID, including "0". Requires comment_uin; omit for an ordinary comment. */ + comment_id?: ActionStringInput; + /** Parent comment author from get_qzone_msg_detail. Requires comment_id. */ + comment_uin?: ActionNumberInput; + /** Author to address inside the parent thread. Requires reply_name and a parent comment. */ + reply_uin?: ActionNumberInput; + /** Addressed author's name from comment details. Requires reply_uin. */ + reply_name?: ActionStringInput; + /** Ordinary comments only; non-empty images with comment_id are rejected before upload. */ images?: ActionStringInput[]; } diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index e0c29061..03d241c4 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -8,6 +8,9 @@ export interface QzoneCommentReply { content: string; /** Scoped to the post, or parent comment for replies. */ comment_id: string; + /** Present on nested replies from newer servers; identifies their top-level parent. */ + parent_comment_id?: string; + parent_comment_uin?: number; } export interface QzoneComment extends QzoneCommentReply { @@ -413,8 +416,11 @@ export interface QzonePublishResult extends JsonObject { } export interface QzoneCommentResult extends JsonObject { - /** Empty string when the upstream response omits the new comment ID. */ + /** New comment/reply ID. Ordinary comments may return an empty string if upstream omits it. */ comment_id: string; + /** Present when creating a nested reply, distinct from its new comment_id. */ + parent_comment_id?: string; + parent_comment_uin?: number; } export interface QzoneUpdateRightResult extends JsonObject { diff --git a/packages/sdk/tests/fixtures/action-contracts.ts b/packages/sdk/tests/fixtures/action-contracts.ts index 8c70ebf7..4fd8ba1d 100644 --- a/packages/sdk/tests/fixtures/action-contracts.ts +++ b/packages/sdk/tests/fixtures/action-contracts.ts @@ -16,7 +16,16 @@ export const actionParams = { add_custom_face: { file: 'file:///fixture.png' }, cancel_group_album_media_like: mediaLike, clean_stream_temp_file: {}, - comment_qzone: { tid: 'post', content: 'Comment', target_uin: '10002', images: [] }, + comment_qzone: { + tid: 'post', + content: 'Comment', + target_uin: '10002', + images: [], + comment_id: '0', + comment_uin: '10003', + reply_uin: '10004', + reply_name: 'Reader', + }, create_flash_task: { files: ['file:///one', { file: 'file:///two', name: 'two.txt' }], name: 'Files', diff --git a/packages/sdk/tests/fixtures/action-types.mts b/packages/sdk/tests/fixtures/action-types.mts index 89d4ea07..5aabf96c 100644 --- a/packages/sdk/tests/fixtures/action-types.mts +++ b/packages/sdk/tests/fixtures/action-types.mts @@ -69,6 +69,16 @@ ws.downloadFileStream({}); const feeds = await http.raw('get_qzone_feeds', { page_num: '2', count: 10 }); const more: boolean = feeds.has_more; const author: number = feeds.feeds[0].uin; +const qzoneReply = await http.raw('comment_qzone', actionParams.comment_qzone); +export const qzoneReplyId: string = qzoneReply.comment_id; +export const qzoneParentId: string | undefined = qzoneReply.parent_comment_id; +export const qzoneParentUin: number | undefined = qzoneReply.parent_comment_uin; +export const legacyQzoneComment: ActionParams<'comment_qzone'> = { + tid: 'post', + content: 'Comment', + images: ['https://example.com/image.png'], +}; +export const legacyQzoneResult: ActionResult<'comment_qzone'> = { comment_id: '' }; const speech = await ws.raw('get_record_text', { message_id: -42 }); const transcription: string = speech.text; const flash = await http.raw('get_fileset_info', { fileset_id: 'files' });