NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneComment } from '@/napcat-core/data/qzone-rpc';
|
||||
import type { SlQzoneCommentOptions, SlQzoneCommentResult } from '@/napcat-core/data/qzone-snowluma';
|
||||
import { uriToLocalFile } from 'napcat-common/src/file';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
import { QzoneCommentSchema, QzoneFeedReferenceSchema, QzoneParentCommentSchema, QzoneUserSchema } from './common';
|
||||
import { normalizeQzoneCommentPayload, SlQzoneCommentReturnSchema, SlQzoneIdentitySchema, SlQzoneImagesSchema, SlQzoneTextSchema, SlQzoneUinSchema } from './common-snowluma';
|
||||
|
||||
export const CommentQzonePayloadSchema = Type.Object({
|
||||
feed_ref: Type.Optional(QzoneFeedReferenceSchema),
|
||||
tid: Type.Optional(SlQzoneIdentitySchema),
|
||||
target_uin: Type.Optional(SlQzoneUinSchema),
|
||||
content: SlQzoneTextSchema,
|
||||
comment: Type.Optional(QzoneParentCommentSchema),
|
||||
reply_user: Type.Optional(QzoneUserSchema),
|
||||
comment_id: Type.Optional(SlQzoneIdentitySchema),
|
||||
comment_uin: Type.Optional(SlQzoneUinSchema),
|
||||
reply_uin: Type.Optional(SlQzoneUinSchema),
|
||||
reply_name: Type.Optional(SlQzoneTextSchema),
|
||||
images: SlQzoneImagesSchema,
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
export type CommentQzonePayload = Static<typeof CommentQzonePayloadSchema>;
|
||||
|
||||
const NativeReturnSchema = Type.Object({
|
||||
comment_id: Type.String({ description: '新评论或回复 ID' }),
|
||||
comment: QzoneCommentSchema,
|
||||
});
|
||||
|
||||
type ReturnType = { comment_id: string; comment: QzoneComment; } | SlQzoneCommentResult;
|
||||
|
||||
export class CommentQzone extends OneBotAction<CommentQzonePayload, ReturnType> {
|
||||
override actionName = ActionName.CommentQzone;
|
||||
override actionSummary = '评论QQ空间说说';
|
||||
override actionDescription = '传 tid 使用 SnowLuma 参数,一级评论支持 images;回复须成对提供 comment_id/comment_uin,指定接收者时还需 reply_uin/reply_name,楼中楼仅文字。传 feed_ref 则保留原 comment/reply_user 方式及原始结果;两种参数不能混用。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { tid: 'sample-post', target_uin: '10002', content: '很有意思' };
|
||||
override returnExample = { comment_id: 'sample-comment' };
|
||||
|
||||
override payloadSchema = CommentQzonePayloadSchema;
|
||||
override returnSchema = Type.Union([SlQzoneCommentReturnSchema, NativeReturnSchema]);
|
||||
|
||||
protected override async check (payload: CommentQzonePayload): Promise<BaseCheckResult> {
|
||||
try { normalizeQzoneCommentPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: CommentQzonePayload): Promise<ReturnType> {
|
||||
const normalized = normalizeQzoneCommentPayload(payload);
|
||||
if (normalized.format === 'native') {
|
||||
return await this.core.apis.QzoneApi.comment(normalized.reference, normalized.content, normalized.comment, normalized.replyUser);
|
||||
}
|
||||
const originalAccount = normalized.images?.length
|
||||
? { uin: this.core.selfInfo?.uin, uid: this.core.selfInfo?.uid }
|
||||
: undefined;
|
||||
if (originalAccount && (!originalAccount.uin || !originalAccount.uid)) throw new Error('QQ空间图片评论需要已登录的当前账号');
|
||||
const assertAccount = () => {
|
||||
if (originalAccount && (this.core.selfInfo?.uin !== originalAccount.uin || this.core.selfInfo?.uid !== originalAccount.uid)) {
|
||||
throw new Error('QQ账号已切换,图片评论已停止,请重新确认后操作');
|
||||
}
|
||||
};
|
||||
const cleanupPaths: string[] = [];
|
||||
const urls: string[] = [];
|
||||
try {
|
||||
for (const [index, source] of (normalized.images ?? []).entries()) {
|
||||
const image = await uriToLocalFile(this.core.NapCatTempPath, source);
|
||||
if (!image.success) throw new Error(`第${index + 1}张图片处理失败,${image.errMsg}`);
|
||||
if (!image.isLocal) cleanupPaths.push(image.path);
|
||||
assertAccount();
|
||||
const upload = await this.core.apis.QzoneApi.uploadCommentImage(readFileSync(image.path).toString('base64'));
|
||||
assertAccount();
|
||||
let url: URL;
|
||||
try { url = new URL(upload.url); } catch { throw new Error(`第${index + 1}张图片上传未返回有效地址`); }
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || upload.url.length > 8192 || upload.url.trim() !== upload.url || /[\t\r\n]/.test(upload.url)) {
|
||||
throw new Error(`第${index + 1}张图片上传未返回有效地址`);
|
||||
}
|
||||
urls.push(upload.url);
|
||||
}
|
||||
const options: SlQzoneCommentOptions | undefined = urls.length
|
||||
? { ...normalized.options, imageUrls: urls }
|
||||
: normalized.options;
|
||||
assertAccount();
|
||||
return await this.core.apis.QzoneApi.commentSnowluma(normalized.tid, normalized.content, normalized.targetUin, options);
|
||||
} finally {
|
||||
for (const path of cleanupPaths) await unlink(path).catch(() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneCommentPage } from '@/napcat-core/data/qzone-comments';
|
||||
import { QzoneCursorSchema, QzoneFeedReferenceExample, QzoneFeedReferenceSchema } from './common';
|
||||
import { QzoneCommentPageExample, QzoneCommentPageSchema, validateQzoneCommentReadPayload } from './common-comment';
|
||||
|
||||
export const GetQzoneCommentListPayloadSchema = Type.Object({
|
||||
feed_ref: QzoneFeedReferenceSchema,
|
||||
cursor: QzoneCursorSchema,
|
||||
}, { additionalProperties: false });
|
||||
export type GetQzoneCommentListPayload = Static<typeof GetQzoneCommentListPayloadSchema>;
|
||||
|
||||
export class GetQzoneCommentList extends OneBotAction<GetQzoneCommentListPayload, QzoneCommentPage> {
|
||||
override actionName = ActionName.GetQzoneCommentList;
|
||||
override actionSummary = '获取QQ空间说说评论';
|
||||
override actionDescription = '不传 cursor 读取详情中的首批评论,之后用返回的 feed_ref 和 next_cursor 续页。返回作者、正文、图片、赞数、内联回复及完整原始对象;未知值为 null,不自动拉取全部历史。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { feed_ref: QzoneFeedReferenceExample };
|
||||
override returnExample = QzoneCommentPageExample;
|
||||
override payloadSchema = GetQzoneCommentListPayloadSchema;
|
||||
override returnSchema = QzoneCommentPageSchema;
|
||||
|
||||
protected override async check (payload: GetQzoneCommentListPayload): Promise<BaseCheckResult> {
|
||||
try {
|
||||
validateQzoneCommentReadPayload(payload, false);
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: GetQzoneCommentListPayload): Promise<QzoneCommentPage> {
|
||||
validateQzoneCommentReadPayload(payload, false);
|
||||
return await this.core.apis.QzoneApi.getComments(payload.feed_ref, payload.cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneComment } from '@/napcat-core/data/qzone-rpc';
|
||||
import type { QzoneReplyPage } from '@/napcat-core/data/qzone-comments';
|
||||
import { QzoneCursorSchema, QzoneFeedReferenceExample, QzoneFeedReferenceSchema } from './common';
|
||||
import { QzoneReadParentCommentExample, QzoneReadParentCommentSchema, QzoneReplyPageExample, QzoneReplyPageSchema, validateQzoneCommentReadPayload } from './common-comment';
|
||||
|
||||
export const GetQzoneCommentRepliesPayloadSchema = Type.Object({
|
||||
feed_ref: QzoneFeedReferenceSchema,
|
||||
comment: QzoneReadParentCommentSchema,
|
||||
cursor: QzoneCursorSchema,
|
||||
}, { additionalProperties: false });
|
||||
export type GetQzoneCommentRepliesPayload = Static<typeof GetQzoneCommentRepliesPayloadSchema>;
|
||||
|
||||
export class GetQzoneCommentReplies extends OneBotAction<GetQzoneCommentRepliesPayload, QzoneReplyPage> {
|
||||
override actionName = ActionName.GetQzoneCommentReplies;
|
||||
override actionSummary = '获取QQ空间评论楼中楼回复';
|
||||
override actionDescription = '传入读取结果的完整 comment,使用其回复游标读取追加回复;之后用返回的 feed_ref、同一父评论和 next_cursor 续页。与内联回复按 ID 合并,不自动拉取全部历史;每页使用空间固定页大小。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { feed_ref: QzoneFeedReferenceExample, comment: QzoneReadParentCommentExample };
|
||||
override returnExample = QzoneReplyPageExample;
|
||||
override payloadSchema = GetQzoneCommentRepliesPayloadSchema;
|
||||
override returnSchema = QzoneReplyPageSchema;
|
||||
|
||||
protected override async check (payload: GetQzoneCommentRepliesPayload): Promise<BaseCheckResult> {
|
||||
try {
|
||||
validateQzoneCommentReadPayload(payload, true);
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: GetQzoneCommentRepliesPayload): Promise<QzoneReplyPage> {
|
||||
validateQzoneCommentReadPayload(payload, true);
|
||||
return await this.core.apis.QzoneApi.getCommentReplies(payload.feed_ref, payload.comment as QzoneComment, payload.cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneFeedPage } from '@/napcat-core/data/qzone-rpc';
|
||||
import type { SlQzoneFeedPage } from '@/napcat-core/data/qzone-snowluma-feeds';
|
||||
import { QzoneFeedsReturnSchema } from './common';
|
||||
import { normalizeQzoneFeedsPayload, QzoneFormatSchema, SlQzoneCursorSchema, SlQzoneFeedsExample, SlQzoneFeedsReturnSchema, SlQzoneIntegerSchema } from './common-snowluma';
|
||||
|
||||
export const GetQzoneFeedsPayloadSchema = Type.Object({
|
||||
format: QzoneFormatSchema,
|
||||
page_num: Type.Optional(SlQzoneIntegerSchema),
|
||||
count: Type.Optional(SlQzoneIntegerSchema),
|
||||
cursor: SlQzoneCursorSchema,
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
export type GetQzoneFeedsPayload = Static<typeof GetQzoneFeedsPayloadSchema>;
|
||||
|
||||
export class GetQzoneFeeds extends OneBotAction<GetQzoneFeedsPayload, QzoneFeedPage | SlQzoneFeedPage> {
|
||||
override actionName = ActionName.GetQzoneFeeds;
|
||||
override actionSummary = '获取QQ空间好友动态';
|
||||
override actionDescription = '默认返回 SnowLuma 格式,page_num 默认1、count 默认10,续页原样传 cursor。format=native 返回原 H5 Feed 和 feed_ref;旧 native 游标可自动识别,native 模式不接受 page_num/count。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { page_num: 1, count: 10 };
|
||||
override returnExample = SlQzoneFeedsExample;
|
||||
override payloadSchema = GetQzoneFeedsPayloadSchema;
|
||||
override returnSchema = Type.Union([SlQzoneFeedsReturnSchema, QzoneFeedsReturnSchema]);
|
||||
|
||||
protected override async check (payload: GetQzoneFeedsPayload): Promise<BaseCheckResult> {
|
||||
try { normalizeQzoneFeedsPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: GetQzoneFeedsPayload): Promise<QzoneFeedPage | SlQzoneFeedPage> {
|
||||
const normalized = normalizeQzoneFeedsPayload(payload);
|
||||
if (normalized.format === 'native') return await this.core.apis.QzoneApi.getFriendFeeds(normalized.cursor);
|
||||
return await this.core.apis.QzoneApi.getSnowlumaFeeds(normalized.pageNum, normalized.count, normalized.cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneDetail } from '@/napcat-core/data/qzone-rpc';
|
||||
import type { SlQzoneMsgDetail } from '@/napcat-core/data/qzone-snowluma';
|
||||
import { QzoneDetailReturnSchema, QzoneFeedReferenceSchema } from './common';
|
||||
import { normalizeQzoneDetailPayload, SlQzoneIdentitySchema, SlQzoneIntegerSchema, SlQzoneMsgDetailExample, SlQzoneMsgDetailReturnSchema, SlQzoneUinSchema } from './common-snowluma';
|
||||
|
||||
export const GetQzoneMsgDetailPayloadSchema = Type.Object({
|
||||
feed_ref: Type.Optional(QzoneFeedReferenceSchema),
|
||||
tid: Type.Optional(SlQzoneIdentitySchema),
|
||||
target_uin: Type.Optional(SlQzoneUinSchema),
|
||||
pos: Type.Optional(SlQzoneIntegerSchema),
|
||||
num: Type.Optional(SlQzoneIntegerSchema),
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
export type GetQzoneMsgDetailPayload = Static<typeof GetQzoneMsgDetailPayloadSchema>;
|
||||
|
||||
export class GetQzoneMsgDetail extends OneBotAction<GetQzoneMsgDetailPayload, QzoneDetail | SlQzoneMsgDetail> {
|
||||
override actionName = ActionName.GetQzoneMsgDetail;
|
||||
override actionSummary = '获取QQ空间说说详情';
|
||||
override actionDescription = '传 tid 返回 SnowLuma 详情和评论,target_uin 默认当前账号、pos 默认0、num 默认20。传完整 feed_ref 则保留原 H5 详情、原始评论和续页信息;两种定位方式不能混用。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { tid: 'sample-post', pos: 0, num: 20 };
|
||||
override returnExample = SlQzoneMsgDetailExample;
|
||||
override payloadSchema = GetQzoneMsgDetailPayloadSchema;
|
||||
override returnSchema = Type.Union([SlQzoneMsgDetailReturnSchema, QzoneDetailReturnSchema]);
|
||||
|
||||
protected override async check (payload: GetQzoneMsgDetailPayload): Promise<BaseCheckResult> {
|
||||
try { normalizeQzoneDetailPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: GetQzoneMsgDetailPayload): Promise<QzoneDetail | SlQzoneMsgDetail> {
|
||||
const normalized = normalizeQzoneDetailPayload(payload);
|
||||
if (normalized.format === 'native') return await this.core.apis.QzoneApi.getMsgDetail(normalized.reference);
|
||||
return await this.core.apis.QzoneApi.getSnowlumaMsgDetail(normalized.tid, normalized.targetUin, normalized.pos, normalized.num);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneMsgPage } from '@/napcat-core/data/qzone-rpc';
|
||||
import type { SlQzoneMsgList } from '@/napcat-core/data/qzone-snowluma';
|
||||
import { QzoneCursorSchema, QzoneMsgListReturnSchema } from './common';
|
||||
import { normalizeQzoneListPayload, QzoneFormatSchema, SlQzoneIntegerSchema, SlQzoneMsgListExample, SlQzoneMsgListReturnSchema, SlQzoneUinSchema } from './common-snowluma';
|
||||
|
||||
export const GetQzoneMsgListPayloadSchema = Type.Object({
|
||||
format: QzoneFormatSchema,
|
||||
target_uin: Type.Optional(SlQzoneUinSchema),
|
||||
pos: Type.Optional(SlQzoneIntegerSchema),
|
||||
num: Type.Optional(SlQzoneIntegerSchema),
|
||||
cursor: QzoneCursorSchema,
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
export type GetQzoneMsgListPayload = Static<typeof GetQzoneMsgListPayloadSchema>;
|
||||
|
||||
export class GetQzoneMsgList extends OneBotAction<GetQzoneMsgListPayload, QzoneMsgPage | SlQzoneMsgList> {
|
||||
override actionName = ActionName.GetQzoneMsgList;
|
||||
override actionSummary = '获取QQ空间说说列表';
|
||||
override actionDescription = '默认返回 SnowLuma 的 total/msglist,pos 默认0、num 默认20(1至100)。format=native 保留原 H5 Feed、feed_ref 和游标;仅传旧 cursor 也会选择 native。两种分页参数不能混用。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { target_uin: '10001', pos: 0, num: 20 };
|
||||
override returnExample = SlQzoneMsgListExample;
|
||||
override payloadSchema = GetQzoneMsgListPayloadSchema;
|
||||
override returnSchema = Type.Union([SlQzoneMsgListReturnSchema, QzoneMsgListReturnSchema]);
|
||||
|
||||
protected override async check (payload: GetQzoneMsgListPayload): Promise<BaseCheckResult> {
|
||||
try { normalizeQzoneListPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: GetQzoneMsgListPayload): Promise<QzoneMsgPage | SlQzoneMsgList> {
|
||||
const normalized = normalizeQzoneListPayload(payload);
|
||||
if (normalized.format === 'native') return await this.core.apis.QzoneApi.getMsgList(normalized.targetUin, normalized.cursor);
|
||||
return await this.core.apis.QzoneApi.getSnowlumaMsgList(normalized.targetUin, normalized.pos, normalized.num);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneProfile } from '@/napcat-core/data/qzone-web';
|
||||
|
||||
export const GetQzoneProfilePayloadSchema = Type.Object({
|
||||
target_uin: Type.Optional(Type.Union([
|
||||
Type.String({ pattern: '^[1-9][0-9]*$', maxLength: 20 }),
|
||||
Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
], { description: '空间所属 QQ 号;不传时为当前账号' })),
|
||||
}, { additionalProperties: false });
|
||||
export type GetQzoneProfilePayload = Static<typeof GetQzoneProfilePayloadSchema>;
|
||||
|
||||
const ReturnSchema = Type.Object({
|
||||
name: Type.String({ description: '空间名称' }),
|
||||
description: Type.String({ description: '空间说明' }),
|
||||
});
|
||||
|
||||
function targetUin (payload: GetQzoneProfilePayload): string | undefined {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).some(key => key !== 'target_uin')) {
|
||||
throw new Error('获取空间资料只支持 target_uin 参数');
|
||||
}
|
||||
const value = payload.target_uin;
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) throw new Error('target_uin 必须为有效QQ号');
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]{0,19}$/.test(String(value))) {
|
||||
throw new Error('target_uin 必须为有效QQ号');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export class GetQzoneProfile extends OneBotAction<GetQzoneProfilePayload, QzoneProfile> {
|
||||
override actionName = ActionName.GetQzoneProfile;
|
||||
override actionSummary = '获取QQ空间资料';
|
||||
override actionDescription = '获取空间名称和空间说明;target_uin 不传时读取当前账号,空名称或说明按原值返回。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { target_uin: '10001' };
|
||||
override returnExample = { name: '我的空间', description: '记录生活' };
|
||||
override payloadSchema = GetQzoneProfilePayloadSchema;
|
||||
override returnSchema = ReturnSchema;
|
||||
|
||||
protected override async check (payload: GetQzoneProfilePayload): Promise<BaseCheckResult> {
|
||||
try {
|
||||
targetUin(payload);
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: GetQzoneProfilePayload): Promise<QzoneProfile> {
|
||||
return await this.core.apis.QzoneApi.getProfile(targetUin(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { QzoneFeedReferenceSchema } from './common';
|
||||
import { normalizeQzoneLikePayload, SlQzoneIdentitySchema, SlQzoneIntegerSchema, SlQzoneUinSchema } from './common-snowluma';
|
||||
|
||||
export const LikeQzonePayloadSchema = Type.Object({
|
||||
feed_ref: Type.Optional(QzoneFeedReferenceSchema),
|
||||
tid: Type.Optional(SlQzoneIdentitySchema),
|
||||
target_uin: Type.Optional(SlQzoneUinSchema),
|
||||
abstime: Type.Optional(SlQzoneIntegerSchema),
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
export type LikeQzonePayload = Static<typeof LikeQzonePayloadSchema>;
|
||||
|
||||
export class LikeQzone extends OneBotAction<LikeQzonePayload, null> {
|
||||
override actionName = ActionName.LikeQzone;
|
||||
override actionSummary = '点赞QQ空间说说';
|
||||
override actionDescription = '支持 SnowLuma 的 tid、target_uin 和 abstime(默认0);传 feed_ref 则沿用原 H5 点赞方式。两种定位方式不能混用,写入失败不会切换接口重试。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { tid: 'sample-post', target_uin: '10002', abstime: 1700000000 };
|
||||
override returnExample = null;
|
||||
override payloadSchema = LikeQzonePayloadSchema;
|
||||
override returnSchema = Type.Null();
|
||||
|
||||
protected override async check (payload: LikeQzonePayload): Promise<BaseCheckResult> {
|
||||
try { normalizeQzoneLikePayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: LikeQzonePayload): Promise<null> {
|
||||
const normalized = normalizeQzoneLikePayload(payload);
|
||||
if (normalized.format === 'native') await this.core.apis.QzoneApi.setLike(normalized.reference, true);
|
||||
else await this.core.apis.QzoneApi.setSnowlumaLike(normalized.tid, normalized.targetUin, true, normalized.abstime);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { qzoneBoolean } from './common-snowluma';
|
||||
|
||||
export const SetQzoneBanPayloadSchema = Type.Object({
|
||||
user_id: Type.Union([
|
||||
Type.String({ pattern: '^[1-9][0-9]*$', maxLength: 20 }),
|
||||
Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
], { description: '需要设置空间访问限制的QQ号' }),
|
||||
enable: Type.Optional(Type.Union([
|
||||
Type.Boolean(), Type.Integer({ minimum: 0, maximum: 1 }), Type.String({ maxLength: 128 }),
|
||||
], { description: '默认true;支持布尔值及true/false、1/0、yes/no、on/off拼写' })),
|
||||
}, { additionalProperties: false });
|
||||
export type SetQzoneBanPayload = Static<typeof SetQzoneBanPayloadSchema>;
|
||||
|
||||
function banUpdate (payload: SetQzoneBanPayload): { uin: string; enable: boolean; } {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).some(key => key !== 'user_id' && key !== 'enable')) {
|
||||
throw new Error('设置空间访问限制只支持 user_id 和 enable 参数');
|
||||
}
|
||||
const value = payload.user_id;
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) throw new Error('user_id 必须为有效QQ号');
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]{0,19}$/.test(String(value))) {
|
||||
throw new Error('user_id 必须为有效QQ号');
|
||||
}
|
||||
if (typeof payload.enable === 'string' && payload.enable.length > 128) throw new Error('enable 超出允许长度');
|
||||
return { uin: String(value), enable: qzoneBoolean(payload.enable) };
|
||||
}
|
||||
|
||||
export class SetQzoneBan extends OneBotAction<SetQzoneBanPayload, null> {
|
||||
override actionName = ActionName.SetQzoneBan;
|
||||
override actionSummary = '设置QQ空间访问限制';
|
||||
override actionDescription = '在当前账号的空间设置指定用户的访问限制;enable 默认为 true,传 false 取消禁止。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { user_id: '10002', enable: true };
|
||||
override returnExample = null;
|
||||
override payloadSchema = SetQzoneBanPayloadSchema;
|
||||
override returnSchema = Type.Null();
|
||||
|
||||
protected override async check (payload: SetQzoneBanPayload): Promise<BaseCheckResult> {
|
||||
try {
|
||||
banUpdate(payload);
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: SetQzoneBanPayload): Promise<null> {
|
||||
const normalized = banUpdate(payload);
|
||||
await this.core.apis.QzoneApi.setBan(normalized.uin, normalized.enable);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { normalizeQzoneRight } from '@/napcat-core/data/qzone-web';
|
||||
import { qzoneIdentity, SlQzoneIdentitySchema } from './common-snowluma';
|
||||
|
||||
export const SetQzoneMsgRightPayloadSchema = Type.Object({
|
||||
tid: SlQzoneIdentitySchema,
|
||||
ugc_right: Type.Union([Type.Number(), Type.String({ maxLength: 32 })], { description: '查看权限:1所有人,4好友,16部分好友,64仅自己,128排除部分好友' }),
|
||||
target_uins: Type.Optional(Type.Array(Type.Union([
|
||||
Type.String({ pattern: '^[1-9][0-9]*$', maxLength: 20 }),
|
||||
Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
]), { description: 'ugc_right 为16或128时的QQ号列表' })),
|
||||
}, { additionalProperties: false });
|
||||
export type SetQzoneMsgRightPayload = Static<typeof SetQzoneMsgRightPayloadSchema>;
|
||||
|
||||
const ReturnSchema = Type.Object({
|
||||
ugc_right: Type.Number({ description: '设置成功的查看权限' }),
|
||||
});
|
||||
|
||||
function rightUpdate (payload: SetQzoneMsgRightPayload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).some(key => !['tid', 'ugc_right', 'target_uins'].includes(key))) {
|
||||
throw new Error('修改说说权限只支持 tid、ugc_right 和 target_uins 参数');
|
||||
}
|
||||
const tid = qzoneIdentity(payload.tid, 'tid');
|
||||
if ((typeof payload.ugc_right !== 'number' && typeof payload.ugc_right !== 'string') ||
|
||||
(typeof payload.ugc_right === 'string' && payload.ugc_right.length > 32)) {
|
||||
throw new Error('ugc_right 必须为有效权限数字');
|
||||
}
|
||||
const right = Number(payload.ugc_right);
|
||||
if (!Number.isFinite(right)) throw new Error('ugc_right 必须为有效权限数字');
|
||||
if (payload.target_uins !== undefined && !Array.isArray(payload.target_uins)) throw new Error('target_uins 必须为QQ号数组');
|
||||
const targets = payload.target_uins?.map(value => {
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) throw new Error('target_uins 包含无效QQ号');
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]{0,19}$/.test(String(value))) {
|
||||
throw new Error('target_uins 包含无效QQ号');
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
return { ...normalizeQzoneRight(right, targets), tid };
|
||||
}
|
||||
|
||||
export class SetQzoneMsgRight extends OneBotAction<SetQzoneMsgRightPayload, { ugc_right: number; }> {
|
||||
override actionName = ActionName.SetQzoneMsgRight;
|
||||
override actionSummary = '修改QQ空间说说权限';
|
||||
override actionDescription = '仅修改当前账号发表的说说。ugc_right 为16或128时 target_uins 不能为空;其他权限不使用该列表。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { tid: 'shuoshuo-tid', ugc_right: 4 };
|
||||
override returnExample = { ugc_right: 4 };
|
||||
override payloadSchema = SetQzoneMsgRightPayloadSchema;
|
||||
override returnSchema = ReturnSchema;
|
||||
|
||||
protected override async check (payload: SetQzoneMsgRightPayload): Promise<BaseCheckResult> {
|
||||
try {
|
||||
rightUpdate(payload);
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: SetQzoneMsgRightPayload): Promise<{ ugc_right: number; }> {
|
||||
const normalized = rightUpdate(payload);
|
||||
return await this.core.apis.QzoneApi.setMsgRight(normalized.tid, normalized.right, normalized.targetUins);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import type { QzoneProfileUpdate } from '@/napcat-core/data/qzone-web';
|
||||
|
||||
const profileValueSchema = (description: string) => Type.Union([
|
||||
Type.String({ maxLength: 8192 }), Type.Number(), Type.Boolean(),
|
||||
], { description });
|
||||
export const SetQzoneProfilePayloadSchema = Type.Object({
|
||||
name: Type.Optional(profileValueSchema('空间名称;空字符串表示清空,省略时不修改')),
|
||||
description: Type.Optional(profileValueSchema('空间说明;空字符串表示清空,省略时不修改')),
|
||||
}, { additionalProperties: false });
|
||||
export type SetQzoneProfilePayload = Static<typeof SetQzoneProfilePayloadSchema>;
|
||||
|
||||
function profileUpdate (payload: SetQzoneProfilePayload): QzoneProfileUpdate {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).some(key => key !== 'name' && key !== 'description')) {
|
||||
throw new Error('修改空间资料只支持 name 和 description 参数');
|
||||
}
|
||||
const profile: QzoneProfileUpdate = {};
|
||||
for (const key of ['name', 'description'] as const) {
|
||||
const value = payload[key];
|
||||
if (value === undefined) continue;
|
||||
if ((typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') || (typeof value === 'number' && !Number.isFinite(value))) {
|
||||
throw new Error(`${key} 必须为字符串、有限数字或布尔值`);
|
||||
}
|
||||
const normalized = String(value);
|
||||
if (normalized.length > 8192) throw new Error(`${key} 不能超过8192个字符`);
|
||||
profile[key] = normalized;
|
||||
}
|
||||
if (profile.name === undefined && profile.description === undefined) throw new Error('name 或 description 至少提供一项');
|
||||
return profile;
|
||||
}
|
||||
|
||||
export class SetQzoneProfile extends OneBotAction<SetQzoneProfilePayload, null> {
|
||||
override actionName = ActionName.SetQzoneProfile;
|
||||
override actionSummary = '修改QQ空间资料';
|
||||
override actionDescription = '仅修改当前账号的空间名称或说明,至少提供一项。省略字段保持不变;空字符串清空字段;数字和布尔值转换为字符串。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { name: '我的空间', description: '记录生活' };
|
||||
override returnExample = null;
|
||||
override payloadSchema = SetQzoneProfilePayloadSchema;
|
||||
override returnSchema = Type.Null();
|
||||
|
||||
protected override async check (payload: SetQzoneProfilePayload): Promise<BaseCheckResult> {
|
||||
try {
|
||||
profileUpdate(payload);
|
||||
} catch (error) {
|
||||
return { valid: false, message: (error as Error).message };
|
||||
}
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: SetQzoneProfilePayload): Promise<null> {
|
||||
await this.core.apis.QzoneApi.setProfile(profileUpdate(payload));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { QzoneFeedReferenceSchema } from './common';
|
||||
import { normalizeQzoneLikePayload, SlQzoneIdentitySchema, SlQzoneIntegerSchema, SlQzoneUinSchema } from './common-snowluma';
|
||||
|
||||
export const UnlikeQzonePayloadSchema = Type.Object({
|
||||
feed_ref: Type.Optional(QzoneFeedReferenceSchema),
|
||||
tid: Type.Optional(SlQzoneIdentitySchema),
|
||||
target_uin: Type.Optional(SlQzoneUinSchema),
|
||||
abstime: Type.Optional(SlQzoneIntegerSchema),
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
export type UnlikeQzonePayload = Static<typeof UnlikeQzonePayloadSchema>;
|
||||
|
||||
export class UnlikeQzone extends OneBotAction<UnlikeQzonePayload, null> {
|
||||
override actionName = ActionName.UnlikeQzone;
|
||||
override actionSummary = '取消QQ空间说说点赞';
|
||||
override actionDescription = '支持 SnowLuma 的 tid、target_uin 和 abstime(默认0);传 feed_ref 则沿用原 H5 取消点赞方式。两种定位方式不能混用,写入失败不会切换接口重试。';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = { tid: 'sample-post', target_uin: '10002', abstime: 1700000000 };
|
||||
override returnExample = null;
|
||||
override payloadSchema = UnlikeQzonePayloadSchema;
|
||||
override returnSchema = Type.Null();
|
||||
|
||||
protected override async check (payload: UnlikeQzonePayload): Promise<BaseCheckResult> {
|
||||
try { normalizeQzoneLikePayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: UnlikeQzonePayload): Promise<null> {
|
||||
const normalized = normalizeQzoneLikePayload(payload);
|
||||
if (normalized.format === 'native') await this.core.apis.QzoneApi.setLike(normalized.reference, false);
|
||||
else await this.core.apis.QzoneApi.setSnowlumaLike(normalized.tid, normalized.targetUin, false, normalized.abstime);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { QZONE_REFERENCE_MAX_BYTES, validateQzoneFeedReference } from '@/napcat-core/data/qzone-rpc';
|
||||
import { QzoneFeedReferenceExample, QzoneFeedReferenceSchema } from './common';
|
||||
|
||||
const NullableString = Type.Union([Type.String(), Type.Null()]);
|
||||
const NullableNumber = Type.Union([Type.Number(), Type.Null()]);
|
||||
const NullableBoolean = Type.Union([Type.Boolean(), Type.Null()]);
|
||||
const RawObject = Type.Record(Type.String(), Type.Unknown());
|
||||
|
||||
export const QzoneReadParentCommentSchema = Type.Object({
|
||||
id: Type.Union([
|
||||
Type.String({ minLength: 1, maxLength: 8192 }),
|
||||
Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
], { description: '原始父评论 ID,字符串或安全整数,0 是有效 ID' }),
|
||||
user: Type.Optional(RawObject),
|
||||
}, { additionalProperties: Type.Unknown(), description: '原样传回读取结果中的完整 comment 对象;整体不得超过64KiB' });
|
||||
|
||||
export const QzoneReplyItemSchema = Type.Object({
|
||||
reply_id: NullableString,
|
||||
user_id: NullableString,
|
||||
uid: NullableString,
|
||||
nickname: NullableString,
|
||||
time: NullableNumber,
|
||||
content: NullableString,
|
||||
images: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
like_count: NullableNumber,
|
||||
liked: NullableBoolean,
|
||||
target_user_id: NullableString,
|
||||
target_uid: NullableString,
|
||||
target_nickname: NullableString,
|
||||
reply: RawObject,
|
||||
}, { description: '楼中楼回复;未知字段为 null,完整原始对象保留在 reply' });
|
||||
|
||||
export const QzoneCommentItemSchema = Type.Object({
|
||||
comment_id: NullableString,
|
||||
user_id: NullableString,
|
||||
uid: NullableString,
|
||||
nickname: NullableString,
|
||||
time: NullableNumber,
|
||||
content: NullableString,
|
||||
images: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
like_count: NullableNumber,
|
||||
liked: NullableBoolean,
|
||||
reply_num: NullableNumber,
|
||||
has_more_replies: NullableBoolean,
|
||||
replies: Type.Array(QzoneReplyItemSchema),
|
||||
comment: RawObject,
|
||||
}, { description: '评论及其内联回复;replies 为空不代表没有尚未读取的历史回复' });
|
||||
|
||||
export const QzoneCommentPageSchema = Type.Object({
|
||||
feed_ref: QzoneFeedReferenceSchema,
|
||||
comment_num: NullableNumber,
|
||||
comments: Type.Array(QzoneCommentItemSchema),
|
||||
has_more: Type.Boolean(),
|
||||
next_cursor: NullableString,
|
||||
raw: RawObject,
|
||||
}, { description: '一页评论;续页时原样传回本页 feed_ref 和 next_cursor' });
|
||||
|
||||
export const QzoneReplyPageSchema = Type.Object({
|
||||
feed_ref: QzoneFeedReferenceSchema,
|
||||
comment_id: Type.String(),
|
||||
replies: Type.Array(QzoneReplyItemSchema),
|
||||
reply_num: NullableNumber,
|
||||
has_more: Type.Boolean(),
|
||||
next_cursor: NullableString,
|
||||
raw: RawObject,
|
||||
}, { description: '一页追加楼中楼回复;与父评论内联 replys 按 ID 合并,不代表完整历史' });
|
||||
|
||||
function record (value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function validateQzoneCommentReadPayload (payload: unknown, withParent: boolean): void {
|
||||
const allowed = withParent ? ['feed_ref', 'comment', 'cursor'] : ['feed_ref', 'cursor'];
|
||||
if (!record(payload) || Object.keys(payload).some(key => !allowed.includes(key))) throw new Error('QQ空间评论读取参数无效');
|
||||
validateQzoneFeedReference(payload['feed_ref']);
|
||||
const cursor = payload['cursor'];
|
||||
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursor.length > 16384)) throw new Error('QQ空间评论游标无效');
|
||||
if (!withParent) return;
|
||||
const comment = payload['comment'];
|
||||
if (!record(comment)) throw new Error('请提供读取结果中的完整父评论 comment');
|
||||
const id = comment['id'];
|
||||
if ((typeof id !== 'string' || !id.trim() || id.length > 8192) &&
|
||||
(typeof id !== 'number' || !Number.isSafeInteger(id) || id < 0)) {
|
||||
throw new Error('父评论 ID 必须为非空字符串或安全的非负整数');
|
||||
}
|
||||
if (comment['user'] !== undefined && !record(comment['user'])) throw new Error('父评论 user 必须为原始JSON对象');
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(comment);
|
||||
} catch {
|
||||
throw new Error('父评论必须为可序列化的JSON对象');
|
||||
}
|
||||
if (Buffer.byteLength(serialized) > QZONE_REFERENCE_MAX_BYTES) throw new Error('父评论不能超过64KiB');
|
||||
}
|
||||
|
||||
export const QzoneReadParentCommentExample = {
|
||||
id: 0,
|
||||
user: { uid: 'u_commenter', uin: '', nick: '评论作者', portrait: 'https://example.com/avatar.png' },
|
||||
time: 1792926000,
|
||||
content: [{ type: 0, content: '很有意思' }],
|
||||
media_items: [],
|
||||
like: { id: 'comment-like-key', count: 2, status: 1 },
|
||||
reply_num: 1,
|
||||
has_more_reply: true,
|
||||
replys: [],
|
||||
reply_attach_info: 'server-reply-pointer',
|
||||
};
|
||||
|
||||
export const QzoneCommentPageExample = {
|
||||
feed_ref: QzoneFeedReferenceExample,
|
||||
comment_num: 1,
|
||||
comments: [{
|
||||
comment_id: '0',
|
||||
user_id: null,
|
||||
uid: 'u_commenter',
|
||||
nickname: '评论作者',
|
||||
time: 1792926000,
|
||||
content: '很有意思',
|
||||
images: [],
|
||||
like_count: 2,
|
||||
liked: true,
|
||||
reply_num: 1,
|
||||
has_more_replies: true,
|
||||
replies: [],
|
||||
comment: QzoneReadParentCommentExample,
|
||||
}],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
raw: { cell_comment: { num: 1, comments: [QzoneReadParentCommentExample] }, has_more: false },
|
||||
};
|
||||
|
||||
const ReplyExample = {
|
||||
id: 'reply-1',
|
||||
user: { uid: 'u_commenter', nick: '回复作者' },
|
||||
target_user: { uin: '10001', uid: 'u_self', nick: 'Qiao' },
|
||||
time: 1792926001,
|
||||
content: [{ type: 0, content: '谢谢' }],
|
||||
};
|
||||
|
||||
export const QzoneReplyPageExample = {
|
||||
feed_ref: QzoneFeedReferenceExample,
|
||||
comment_id: '0',
|
||||
replies: [{
|
||||
reply_id: 'reply-1',
|
||||
user_id: null,
|
||||
uid: 'u_commenter',
|
||||
nickname: '回复作者',
|
||||
time: 1792926001,
|
||||
content: '谢谢',
|
||||
images: null,
|
||||
like_count: null,
|
||||
liked: null,
|
||||
target_user_id: '10001',
|
||||
target_uid: 'u_self',
|
||||
target_nickname: 'Qiao',
|
||||
reply: ReplyExample,
|
||||
}],
|
||||
reply_num: 1,
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
raw: { replys: [ReplyExample], has_more: false, reply_attach_info: '' },
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import {
|
||||
validateQzoneFeedReference,
|
||||
type QzoneComment,
|
||||
type QzoneFeedReference,
|
||||
type QzoneUser,
|
||||
} from '@/napcat-core/data/qzone-rpc';
|
||||
import type { SlQzoneCommentOptions } from '@/napcat-core/data/qzone-snowluma';
|
||||
import { QzoneParentCommentSchema, QzoneUserSchema } from './common';
|
||||
|
||||
export const QzoneFormatSchema = Type.Optional(Type.Union([Type.Literal('snowluma'), Type.Literal('native')], {
|
||||
description: '默认 snowluma;native 保留原来的 H5 列表格式',
|
||||
}));
|
||||
export const SlQzoneUinSchema = Type.Union([
|
||||
Type.String({ pattern: '^[1-9][0-9]*$', maxLength: 20 }),
|
||||
Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
]);
|
||||
export const SlQzoneIdentitySchema = Type.Union([
|
||||
Type.String({ minLength: 1, maxLength: 8192 }),
|
||||
Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
|
||||
]);
|
||||
export const SlQzoneIntegerSchema = Type.Union([Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), Type.String({ minLength: 1, maxLength: 128 })]);
|
||||
export const SlQzoneTextSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
|
||||
export const SlQzoneCursorSchema = Type.Optional(Type.String({ minLength: 1, maxLength: 65536 }));
|
||||
export const SlQzoneImagesSchema = Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: '图片来源数组,支持 file://、HTTP(S)、base64://;仅一级评论支持图片',
|
||||
}));
|
||||
|
||||
export const SlQzoneEmotionSchema = Type.Object({
|
||||
tid: Type.String(), content: Type.String(), time: Type.Integer(), comment_num: Type.Integer(), is_private: Type.Boolean(), images: Type.Array(Type.String()),
|
||||
});
|
||||
export const SlQzoneMsgListReturnSchema = Type.Object({
|
||||
total: Type.Integer(), msglist: Type.Array(SlQzoneEmotionSchema), total_known: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SlCommentFields = {
|
||||
uin: Type.Integer(), name: Type.String(), time: Type.Integer(), content: Type.String(), comment_id: Type.String(),
|
||||
};
|
||||
export const SlQzoneReplySchema = Type.Object({
|
||||
...SlCommentFields, parent_comment_id: Type.String(), parent_comment_uin: Type.Integer(),
|
||||
});
|
||||
export const SlQzoneCommentSchema = Type.Object({ ...SlCommentFields, replies: Type.Array(SlQzoneReplySchema) });
|
||||
export const SlQzoneMsgDetailReturnSchema = Type.Object({
|
||||
tid: Type.String(), content: Type.String(), comment_num: Type.Integer(), comments: Type.Array(SlQzoneCommentSchema),
|
||||
});
|
||||
const NullableString = Type.Union([Type.String(), Type.Null()]);
|
||||
const NullableNumber = Type.Union([Type.Integer(), Type.Null()]);
|
||||
export const SlQzoneFeedSchema = Type.Object({
|
||||
uin: Type.Integer(),
|
||||
nickname: Type.String(),
|
||||
time: Type.Integer(),
|
||||
appid: Type.Integer(),
|
||||
key: Type.String(),
|
||||
html: Type.String(),
|
||||
tid: NullableString,
|
||||
content: NullableString,
|
||||
content_complete: Type.Union([Type.Boolean(), Type.Null()]),
|
||||
images: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
like_count: NullableNumber,
|
||||
comment_count: NullableNumber,
|
||||
});
|
||||
export const SlQzoneFeedsReturnSchema = Type.Object({
|
||||
feeds: Type.Array(SlQzoneFeedSchema), has_more: Type.Boolean(), next_cursor: NullableString,
|
||||
});
|
||||
export const SlQzoneCommentReturnSchema = Type.Object({
|
||||
comment_id: Type.String(), parent_comment_id: Type.Optional(Type.String()), parent_comment_uin: Type.Optional(Type.Integer()),
|
||||
});
|
||||
|
||||
const KNOWN_FIELDS = new Set([
|
||||
'format', 'feed_ref', 'tid', 'target_uin', 'pos', 'num', 'page_num', 'count', 'cursor', 'abstime',
|
||||
'comment', 'reply_user', 'comment_id', 'comment_uin', 'reply_uin', 'reply_name', 'images', 'content',
|
||||
'ugc_right', 'target_uins', 'user_id', 'enable', 'name', 'description',
|
||||
]);
|
||||
|
||||
export function qzonePayload (value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('QQ空间参数必须为对象');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function checkQzoneFields (payload: Record<string, unknown>, allowed: string[], native: boolean): void {
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (allowed.includes(key) || payload[key] === undefined) continue;
|
||||
if (native || KNOWN_FIELDS.has(key)) throw new Error(`当前QQ空间调用不支持 ${key} 参数`);
|
||||
}
|
||||
}
|
||||
|
||||
export function qzoneInteger (value: unknown, name: string, fallback: number, minimum: number, maximum = Number.MAX_SAFE_INTEGER): number {
|
||||
if (value === undefined) return fallback;
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') ||
|
||||
(typeof value === 'string' && (!value.trim() || value.length > 128))) throw new Error(`${name} 必须为整数`);
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) throw new Error(`${name} 超出允许范围`);
|
||||
return number;
|
||||
}
|
||||
|
||||
export function qzoneUin (value: unknown, name: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(`${name} 必须为有效QQ号`);
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]{0,19}$/.test(String(value))) throw new Error(`${name} 必须为有效QQ号`);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function qzoneIdentity (value: unknown, name: string): string {
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value < 0)) throw new Error(`${name} 必须为有效标识`);
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !String(value).trim() || String(value).length > 8192) throw new Error(`${name} 必须为有效标识`);
|
||||
for (const character of String(value)) {
|
||||
if (character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) throw new Error(`${name} 必须为有效标识`);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function qzoneText (value: unknown, name: string): string {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') || (typeof value === 'number' && !Number.isFinite(value))) {
|
||||
throw new Error(`${name} 必须为文本或有限标量`);
|
||||
}
|
||||
const text = String(value);
|
||||
if (!text.length) throw new Error(`${name} 不能为空`);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function qzoneImages (value: unknown): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || !item.trim())) throw new Error('images 必须为非空图片来源字符串数组');
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
export function qzoneBoolean (value: unknown, fallback = true): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 1 || value === 0) return value === 1;
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'on'].includes(text)) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(text)) return false;
|
||||
}
|
||||
throw new Error('enable 必须为有效布尔值');
|
||||
}
|
||||
|
||||
function format (value: unknown): 'native' | 'snowluma' | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== 'native' && value !== 'snowluma') throw new Error('format 仅支持 snowluma 或 native');
|
||||
return value;
|
||||
}
|
||||
|
||||
function cursor (value: unknown, maximum: number): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !value || value.length > maximum) throw new Error('QQ空间游标无效');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeQzoneFeedsPayload (value: unknown) {
|
||||
const payload = qzonePayload(value);
|
||||
const pointer = cursor(payload['cursor'], 65536);
|
||||
const explicit = format(payload['format']);
|
||||
const selected = explicit ?? (pointer !== undefined && !pointer.startsWith('slqf:') ? 'native' : 'snowluma');
|
||||
if (selected === 'native') {
|
||||
checkQzoneFields(payload, ['format', 'cursor'], true);
|
||||
if (pointer?.startsWith('slqf:')) throw new Error('SnowLuma游标不能用于 native 格式');
|
||||
return { format: selected, cursor: cursor(pointer, 16384) } as const;
|
||||
}
|
||||
checkQzoneFields(payload, ['format', 'page_num', 'count', 'cursor'], false);
|
||||
if (pointer !== undefined && !pointer.startsWith('slqf:')) throw new Error('native 游标不能用于 SnowLuma 格式');
|
||||
return {
|
||||
format: selected,
|
||||
pageNum: qzoneInteger(payload['page_num'], 'page_num', 1, 1),
|
||||
count: qzoneInteger(payload['count'], 'count', 10, 1, 50),
|
||||
cursor: pointer,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function normalizeQzoneListPayload (value: unknown) {
|
||||
const payload = qzonePayload(value);
|
||||
const pointer = cursor(payload['cursor'], 16384);
|
||||
const selected = format(payload['format']) ?? (pointer !== undefined ? 'native' : 'snowluma');
|
||||
const targetUin = qzoneUin(payload['target_uin'], 'target_uin');
|
||||
if (selected === 'native') {
|
||||
checkQzoneFields(payload, ['format', 'target_uin', 'cursor'], true);
|
||||
if (pointer?.startsWith('slqf:')) throw new Error('好友动态游标不能用于说说列表');
|
||||
return { format: selected, targetUin, cursor: pointer } as const;
|
||||
}
|
||||
checkQzoneFields(payload, ['format', 'target_uin', 'pos', 'num'], false);
|
||||
return {
|
||||
format: selected,
|
||||
targetUin,
|
||||
pos: qzoneInteger(payload['pos'], 'pos', 0, 0),
|
||||
num: qzoneInteger(payload['num'], 'num', 20, 1, 100),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function normalizeQzoneDetailPayload (value: unknown) {
|
||||
const payload = qzonePayload(value);
|
||||
if (payload['feed_ref'] !== undefined) {
|
||||
checkQzoneFields(payload, ['feed_ref'], true);
|
||||
return { format: 'native', reference: validateQzoneFeedReference(payload['feed_ref']) } as const;
|
||||
}
|
||||
checkQzoneFields(payload, ['tid', 'target_uin', 'pos', 'num'], false);
|
||||
return {
|
||||
format: 'snowluma',
|
||||
tid: qzoneIdentity(payload['tid'], 'tid'),
|
||||
targetUin: qzoneUin(payload['target_uin'], 'target_uin'),
|
||||
pos: qzoneInteger(payload['pos'], 'pos', 0, 0),
|
||||
num: qzoneInteger(payload['num'], 'num', 20, 1, 100),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function normalizeQzoneLikePayload (value: unknown) {
|
||||
const payload = qzonePayload(value);
|
||||
if (payload['feed_ref'] !== undefined) {
|
||||
checkQzoneFields(payload, ['feed_ref'], true);
|
||||
return { format: 'native', reference: validateQzoneFeedReference(payload['feed_ref']) } as const;
|
||||
}
|
||||
checkQzoneFields(payload, ['tid', 'target_uin', 'abstime'], false);
|
||||
return {
|
||||
format: 'snowluma',
|
||||
tid: qzoneIdentity(payload['tid'], 'tid'),
|
||||
targetUin: qzoneUin(payload['target_uin'], 'target_uin'),
|
||||
abstime: qzoneInteger(payload['abstime'], 'abstime', 0, 0),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function nativeComment (payload: Record<string, unknown>): { format: 'native'; reference: QzoneFeedReference; content: string; comment?: QzoneComment; replyUser?: QzoneUser; } {
|
||||
checkQzoneFields(payload, ['feed_ref', 'content', 'comment', 'reply_user'], true);
|
||||
const reference = validateQzoneFeedReference(payload['feed_ref']);
|
||||
if (typeof payload['content'] !== 'string' || !payload['content'].trim() || payload['content'].length > 2000) throw new Error('native 评论正文必须为2000字以内的非空文本');
|
||||
const parent = payload['comment'];
|
||||
const target = payload['reply_user'];
|
||||
if (parent !== undefined) {
|
||||
if (!Value.Check(QzoneParentCommentSchema, parent)) throw new Error('请提供符合写接口约束的完整父评论');
|
||||
let size: number;
|
||||
try { size = Buffer.byteLength(JSON.stringify(parent)); } catch { throw new Error('父评论必须为JSON对象'); }
|
||||
if (size > 64 * 1024) throw new Error('父评论不能超过64KiB');
|
||||
}
|
||||
if (target !== undefined && (!Value.Check(QzoneUserSchema, target) || parent === undefined)) throw new Error('reply_user 必须与有效父评论一起提供');
|
||||
return { format: 'native', reference, content: payload['content'], comment: parent as QzoneComment | undefined, replyUser: target as QzoneUser | undefined };
|
||||
}
|
||||
|
||||
export function normalizeQzoneCommentPayload (value: unknown) {
|
||||
const payload = qzonePayload(value);
|
||||
if (payload['feed_ref'] !== undefined) return nativeComment(payload);
|
||||
checkQzoneFields(payload, ['tid', 'target_uin', 'content', 'images', 'comment_id', 'comment_uin', 'reply_uin', 'reply_name'], false);
|
||||
const tid = qzoneIdentity(payload['tid'], 'tid');
|
||||
const content = qzoneText(payload['content'], 'content');
|
||||
const targetUin = qzoneUin(payload['target_uin'], 'target_uin');
|
||||
const images = qzoneImages(payload['images']);
|
||||
const hasParent = payload['comment_id'] !== undefined;
|
||||
const hasParentUin = payload['comment_uin'] !== undefined;
|
||||
const hasRecipient = payload['reply_uin'] !== undefined;
|
||||
const hasRecipientName = payload['reply_name'] !== undefined;
|
||||
if (hasParent !== hasParentUin) throw new Error('comment_id 和 comment_uin 必须同时提供');
|
||||
if (hasRecipient !== hasRecipientName) throw new Error('reply_uin 和 reply_name 必须同时提供');
|
||||
if (hasRecipient && !hasParent) throw new Error('指定回复目标时必须提供父评论');
|
||||
if (hasParent && images?.length) throw new Error('楼中楼回复不支持图片');
|
||||
const options: SlQzoneCommentOptions = {};
|
||||
if (hasParent) {
|
||||
options.comment_id = qzoneIdentity(payload['comment_id'], 'comment_id');
|
||||
options.comment_uin = qzoneUin(payload['comment_uin'], 'comment_uin');
|
||||
if (!Number.isSafeInteger(Number(options.comment_uin))) throw new Error('comment_uin 超出回复结果可安全表示的范围');
|
||||
if (!content.trim()) throw new Error('回复正文不能为空白');
|
||||
}
|
||||
if (hasRecipient) {
|
||||
options.reply_uin = qzoneUin(payload['reply_uin'], 'reply_uin');
|
||||
if (!Number.isSafeInteger(Number(options.reply_uin))) throw new Error('reply_uin 超出回复结果可安全表示的范围');
|
||||
options.reply_name = qzoneText(payload['reply_name'], 'reply_name');
|
||||
if (!options.reply_name.trim() || options.reply_name.length > 8192) throw new Error('reply_name 必须为8192字以内的非空昵称');
|
||||
}
|
||||
return { format: 'snowluma', tid, content, targetUin, images, options: Object.keys(options).length ? options : undefined } as const;
|
||||
}
|
||||
|
||||
export const SlQzoneMsgListExample = {
|
||||
total: 1,
|
||||
total_known: true,
|
||||
msglist: [{ tid: 'sample-post', content: '今天拍到了晚霞', time: 1700000000, comment_num: 0, is_private: false, images: [] }],
|
||||
};
|
||||
export const SlQzoneMsgDetailExample = { tid: 'sample-post', content: '今天拍到了晚霞', comment_num: 0, comments: [] };
|
||||
export const SlQzoneFeedsExample = {
|
||||
feeds: [{ uin: 10002, nickname: '好友', time: 1700000000, appid: 311, key: 'sample-feed', html: '<div>今天拍到了晚霞</div>', tid: 'sample-post', content: '今天拍到了晚霞', content_complete: true, images: [], like_count: 0, comment_count: 0 }],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
const NullableString = Type.Union([Type.String(), Type.Null()]);
|
||||
const NullableNumber = Type.Union([Type.Number(), Type.Null()]);
|
||||
const RawObject = Type.Record(Type.String(), Type.Unknown());
|
||||
|
||||
export const QzoneCursorSchema = Type.Optional(Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 16384,
|
||||
description: '上一页返回的 next_cursor;不传时读取第一页,游标不能跨账号或查询使用',
|
||||
}));
|
||||
|
||||
export const QzoneUserSchema = Type.Object({
|
||||
uin: Type.String({ pattern: '^[1-9][0-9]*$', maxLength: 20, description: 'QQ 号' }),
|
||||
uid: Type.Optional(Type.String({ maxLength: 256, description: 'QQ UID;部分空间用户返回空字符串' })),
|
||||
nick: Type.Optional(Type.String({ maxLength: 256, description: '昵称' })),
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
|
||||
const QzoneParentUserSchema = Type.Object({
|
||||
...QzoneUserSchema.properties,
|
||||
uid: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Null()], {
|
||||
description: '原始父评论作者的 UID;允许上游返回 null 并原样保留',
|
||||
})),
|
||||
}, { additionalProperties: Type.Unknown() });
|
||||
|
||||
export const QzoneUnionIdSchema = Type.Object({
|
||||
uf_key: Type.Optional(Type.String({ maxLength: 8192 })),
|
||||
ugc_id: Type.Optional(Type.String({ maxLength: 8192 })),
|
||||
feed_id: Type.Optional(Type.String({ maxLength: 8192 })),
|
||||
ext_ids: Type.Optional(Type.Array(RawObject)),
|
||||
serialized_data: Type.Optional(Type.String({ maxLength: 8192 })),
|
||||
}, { minProperties: 1, additionalProperties: Type.Unknown(), description: '读取结果中的完整动态身份,不要由 tid 拼造' });
|
||||
|
||||
export const QzoneFeedReferenceSchema = Type.Object({
|
||||
union_id: QzoneUnionIdSchema,
|
||||
cell_feed_transfer: Type.Optional(Type.Unknown()),
|
||||
feed_action_domain_type: Type.Optional(Type.Integer({ minimum: 1, maximum: 3 })),
|
||||
like_key: Type.Optional(Type.String({ minLength: 1, maxLength: 8192 })),
|
||||
}, {
|
||||
additionalProperties: false,
|
||||
description: '原样传回列表或详情返回的 feed_ref;整体不得超过 64 KiB',
|
||||
});
|
||||
|
||||
export const QzoneCommentSchema = Type.Object({
|
||||
id: Type.Optional(Type.Union([Type.String(), Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })])),
|
||||
user: Type.Optional(RawObject),
|
||||
content: Type.Optional(Type.Array(RawObject)),
|
||||
}, { additionalProperties: Type.Unknown(), description: '空间返回的完整评论或回复对象,保留未知字段' });
|
||||
|
||||
export const QzoneParentCommentSchema = Type.Object({
|
||||
id: Type.String({ minLength: 1, maxLength: 8192, description: '父评论 ID' }),
|
||||
user: QzoneParentUserSchema,
|
||||
}, { additionalProperties: Type.Unknown(), description: '从详情 comments 中取得的完整父评论对象' });
|
||||
|
||||
export const QzoneFeedItemSchema = Type.Object({
|
||||
tid: NullableString,
|
||||
user_id: NullableString,
|
||||
content: NullableString,
|
||||
time: NullableNumber,
|
||||
comment_num: NullableNumber,
|
||||
like_num: NullableNumber,
|
||||
images: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
feed_ref: Type.Union([QzoneFeedReferenceSchema, Type.Null()]),
|
||||
feed: RawObject,
|
||||
}, { description: '空间动态;无法确定的字段为 null,完整原始数据保留在 feed' });
|
||||
|
||||
export const QzoneFeedsReturnSchema = Type.Object({
|
||||
feeds: Type.Array(QzoneFeedItemSchema),
|
||||
has_more: Type.Boolean(),
|
||||
next_cursor: NullableString,
|
||||
});
|
||||
|
||||
export const QzoneMsgListReturnSchema = Type.Object({
|
||||
msglist: Type.Array(QzoneFeedItemSchema),
|
||||
has_more: Type.Boolean(),
|
||||
next_cursor: NullableString,
|
||||
seal_off_memory: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: '空间返回的封存状态,按原始字段保留' })),
|
||||
});
|
||||
|
||||
export const QzoneDetailReturnSchema = Type.Composite([
|
||||
QzoneFeedItemSchema,
|
||||
Type.Object({
|
||||
comments: Type.Array(QzoneCommentSchema),
|
||||
comment_has_more: Type.Optional(Type.Boolean()),
|
||||
comment_next_cursor: Type.Optional(NullableString),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const QzoneFeedReferenceExample = {
|
||||
union_id: { uf_key: 'feed-key', ugc_id: 'shuoshuo-tid', feed_id: 'feed-id' },
|
||||
feed_action_domain_type: 1,
|
||||
like_key: 'like-key',
|
||||
};
|
||||
|
||||
export const QzoneFeedItemExample = {
|
||||
tid: 'shuoshuo-tid',
|
||||
user_id: '10001',
|
||||
content: '今天天气不错',
|
||||
time: 1792926000,
|
||||
comment_num: 0,
|
||||
like_num: 0,
|
||||
images: null,
|
||||
feed_ref: QzoneFeedReferenceExample,
|
||||
feed: {
|
||||
cell_common: { union_id: QzoneFeedReferenceExample.union_id, time: 1792926000, feed_type: 1, feed_sub_type: 1, cur_like_key: 'like-key' },
|
||||
cell_user_info: { user: { uin: '10001' } },
|
||||
cell_summary: { summary: [{ type: 0, content: '今天天气不错' }] },
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user