feat(onebot): support replies to Qzone comments
Add threaded text replies while preserving existing comments and image uploads. Include parent references and update SDK and MCP contracts. Reject incomplete targets and unsupported reply images before sending. Fixes #573
This commit is contained in:
@@ -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<QzoneCommentResult> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": "<tid-from-get_qzone_msg_detail>",
|
||||
"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": "<tid-from-get_qzone_msg_detail>",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "<tid-from-get_qzone_msg_detail>",
|
||||
"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": "<tid-from-get_qzone_msg_detail>",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '<tid-from-get_qzone_msg_detail>',
|
||||
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: '<tid-from-get_qzone_msg_detail>',
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>,
|
||||
selfUin: string,
|
||||
hostUin: string,
|
||||
tid: string,
|
||||
content: string,
|
||||
target: QzoneReplyTarget,
|
||||
): Promise<QzoneReplyResult> {
|
||||
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<unknown>(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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-1
@@ -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',
|
||||
|
||||
+10
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user