feat(qzone): 完善空间 API 并兼容 SnowLuma API 格式 (#2124)
* feat(qzone): 接入内置空间动态、说说和赞评接口 新增好友动态、个人说说、详情及点赞、取消赞、评论和回复动作。 纯文本发表接入QQ内置空间接口,保留原有图片发表和删除路径。 保留完整动态身份和原始数据,约束游标、权限转换及请求错误处理。 验证:全工作区类型检查,367项测试通过、1项既有条件测试跳过, 变更文件ESLint及WebUI、Shell、Framework、OpenAPI构建通过。 * feat(qzone): 补齐空间管理、评论分页及 SnowLuma 兼容 * fix(qzone): 兼容真实 FormSender 回执与评论字段 * fix(qzone): 兼容黑名单回执中的空间名称 * docs(qzone): remove redundant API readme
This commit is contained in:
@@ -10,4 +10,5 @@ export * from './file';
|
||||
export * from './online';
|
||||
export * from './flash';
|
||||
export * from './database';
|
||||
export * from './qzone';
|
||||
export * from './qzone-public';
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
import type { InstanceContext, NapCatCore } from '@/napcat-core';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import {
|
||||
buildQzonePublishRequest,
|
||||
buildQzoneText,
|
||||
getQzoneGtk,
|
||||
getQzoneTid,
|
||||
normalizeQzoneFeed,
|
||||
parseQzoneRpcResponse,
|
||||
validateQzoneFeedReference,
|
||||
type QzoneComment,
|
||||
type QzoneDetail,
|
||||
type QzoneFeed,
|
||||
type QzoneFeedPage,
|
||||
type QzoneFeedReference,
|
||||
type QzoneMsgPage,
|
||||
type QzoneUnionId,
|
||||
type QzoneUser,
|
||||
} from '../data/qzone-rpc';
|
||||
import {
|
||||
buildQzoneBanBody,
|
||||
buildQzoneProfileBody,
|
||||
buildQzoneRightBody,
|
||||
normalizeQzoneRight,
|
||||
parseQzoneProfile,
|
||||
parseQzoneWebResponse,
|
||||
QzoneWebError,
|
||||
type QzoneProfile,
|
||||
type QzoneProfileUpdate,
|
||||
} from '../data/qzone-web';
|
||||
import {
|
||||
normalizeQzoneComment,
|
||||
normalizeQzoneReply,
|
||||
type QzoneCommentPage,
|
||||
type QzoneReplyPage,
|
||||
} from '../data/qzone-comments';
|
||||
import { buildQzoneUploadImageBody, parseQzonePublishTime } from '../data/qzone';
|
||||
import {
|
||||
buildSlQzoneCommentBody,
|
||||
buildSlQzoneLikeBody,
|
||||
parseSlQzoneComment,
|
||||
parseSlQzoneMsgDetail,
|
||||
parseSlQzoneMsgList,
|
||||
type SlQzoneCommentOptions,
|
||||
type SlQzoneCommentResult,
|
||||
type SlQzoneMsgDetail,
|
||||
type SlQzoneMsgList,
|
||||
} from '../data/qzone-snowluma';
|
||||
import {
|
||||
advanceSlQzoneFeedState,
|
||||
initialSlQzoneFeedState,
|
||||
parseSlQzoneFeeds,
|
||||
slQzoneFeedPosition,
|
||||
type SlQzoneFeedPage,
|
||||
type SlQzoneFeedState,
|
||||
} from '../data/qzone-snowluma-feeds';
|
||||
|
||||
const RPC_ROOT = 'https://h5.qzone.qq.com/http2rpc/gotrpc/v1/';
|
||||
const READER = 'trpc.qzone.feeds_reader.FeedsReader';
|
||||
const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter';
|
||||
const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter';
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_REFERENCE_BYTES = 64 * 1024;
|
||||
const MAX_CURSOR_LENGTH = 16384;
|
||||
const MAX_ATTACH_LENGTH = 8192;
|
||||
const CURSOR_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_CURSORS = 128;
|
||||
const WEB_ROOT = 'https://h5.qzone.qq.com/proxy/domain/';
|
||||
const SL_FEED_SKIP_LIMIT = 20;
|
||||
const SL_FEED_DEADLINE_MS = 45000;
|
||||
|
||||
const WEB_METHODS = {
|
||||
GetProfile: { path: 'base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all', method: 'GET', requireCode: true },
|
||||
SetProfile: { path: 'w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new', method: 'POST', requireCode: true },
|
||||
GetOwnMsgDetail: { path: 'taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6', method: 'GET', requireCode: false },
|
||||
SetMsgRight: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_update', method: 'POST', requireCode: false },
|
||||
SetBan: { path: 'w.qzone.qq.com/cgi-bin/right/cgi_black_action_new', method: 'POST', requireCode: false },
|
||||
GetSlMsgList: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6', method: 'GET', requireCode: false },
|
||||
GetSlMsgListFallback: { path: 'taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6', method: 'GET', requireCode: false, root: 'https://user.qzone.qq.com/proxy/domain/', ownerReferer: true },
|
||||
LikeSlMsg: { path: 'w.qzone.qq.com/cgi-bin/likes/internal_dolike_app', method: 'POST', requireCode: false },
|
||||
UnlikeSlMsg: { path: 'w.qzone.qq.com/cgi-bin/likes/internal_unlike_app', method: 'POST', requireCode: false },
|
||||
CommentSlMsg: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds', method: 'POST', requireCode: false },
|
||||
ReplySlMsg: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc', method: 'POST', requireCode: true },
|
||||
} as const;
|
||||
|
||||
interface QzoneHttpRequest {
|
||||
url: string;
|
||||
method: 'GET' | 'POST';
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// 固定路由,不向调用方暴露任意 RPC、URL 或取票能力。
|
||||
const RPC_METHODS = {
|
||||
GetFriendFeeds: { service: READER, command: '0x97e5', type: '0', write: false },
|
||||
GetShuoshuoList: { service: READER, command: '0x9a97', type: '1', write: false },
|
||||
GetFeedDetail: { service: READER, command: '0x97b1', type: '1', write: false },
|
||||
GetComment: { service: READER, command: '0x9bfd', type: '1', write: false },
|
||||
GetReplies: { service: READER, command: '0x9bfe', type: '1', write: false },
|
||||
PublishFeed: { service: WRITER, command: '0x9775', type: '1', write: true },
|
||||
DoLike: { service: INTERACTION, command: '0x9699', type: '1', write: true },
|
||||
UnLike: { service: INTERACTION, command: '0x969c', type: '1', write: true },
|
||||
AddComment: { service: INTERACTION, command: '0x9676', type: '1', write: true },
|
||||
AddReply: { service: INTERACTION, command: '0x9677', type: '1', write: true },
|
||||
} as const;
|
||||
|
||||
interface PageCursor {
|
||||
account: string;
|
||||
kind: 'friends' | 'moods' | 'comments' | 'replies' | 'sl-feeds';
|
||||
target: string;
|
||||
issuedAt: number;
|
||||
attachInfo: string;
|
||||
refreshId?: string;
|
||||
lastUnionId?: QzoneUnionId;
|
||||
feedReference?: QzoneFeedReference;
|
||||
parentComment?: QzoneComment;
|
||||
slFeedState?: SlQzoneFeedState;
|
||||
slFeedPage?: number;
|
||||
slFeedPositions?: string[];
|
||||
}
|
||||
|
||||
function isRecord (value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validUin (value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[1-9]\d{0,19}$/.test(value);
|
||||
}
|
||||
|
||||
function validIdentity (value: unknown, maxLength = 4096): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
function validateUser (value: unknown): QzoneUser {
|
||||
if (!isRecord(value) || !validUin(value['uin']) ||
|
||||
(value['uid'] !== undefined && value['uid'] !== null && value['uid'] !== '' && !validIdentity(value['uid'])) ||
|
||||
(value['nick'] !== undefined && (typeof value['nick'] !== 'string' || value['nick'].length > 4096))) {
|
||||
throw new Error('QQ空间用户信息无效');
|
||||
}
|
||||
// A raw parent may carry uid:null; keep the parent intact and omit only this target's absent UID.
|
||||
const user = { ...value };
|
||||
if (user['uid'] === null) delete user['uid'];
|
||||
return user as unknown as QzoneUser;
|
||||
}
|
||||
|
||||
function validateComment (value: unknown): QzoneComment {
|
||||
if (!isRecord(value) || !validIdentity(value['id'], 8192)) {
|
||||
throw new Error('QQ空间评论缺少有效标识');
|
||||
}
|
||||
validateUser(value['user']);
|
||||
let size: number;
|
||||
try {
|
||||
size = Buffer.byteLength(JSON.stringify(value));
|
||||
} catch {
|
||||
throw new Error('QQ空间评论格式无效');
|
||||
}
|
||||
if (size > MAX_REFERENCE_BYTES) {
|
||||
throw new Error('QQ空间评论对象过大');
|
||||
}
|
||||
return value as QzoneComment;
|
||||
}
|
||||
|
||||
function assertReadJson (value: unknown, depth = 0): void {
|
||||
if (depth > 32) throw new Error('QQ空间父评论嵌套过深');
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertReadJson(item, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
|
||||
throw new Error('QQ空间父评论必须为JSON数据');
|
||||
}
|
||||
for (const item of Object.values(value)) {
|
||||
if (item !== undefined) assertReadJson(item, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function readParentSnapshot (value: unknown): QzoneComment {
|
||||
if (!isRecord(value) || !(validIdentity(value['id'], 8192) ||
|
||||
(typeof value['id'] === 'number' && Number.isSafeInteger(value['id']) && value['id'] >= 0))) {
|
||||
throw new Error('QQ空间父评论缺少有效标识');
|
||||
}
|
||||
if (value['user'] !== undefined && !isRecord(value['user'])) throw new Error('QQ空间父评论用户信息无效');
|
||||
assertReadJson(value);
|
||||
if (Buffer.byteLength(JSON.stringify(value)) > MAX_REFERENCE_BYTES) throw new Error('QQ空间父评论不能超过64KiB');
|
||||
return structuredClone(value) as QzoneComment;
|
||||
}
|
||||
|
||||
function referenceSnapshot (value: unknown): QzoneFeedReference {
|
||||
return structuredClone(validateQzoneFeedReference(value));
|
||||
}
|
||||
|
||||
function canonicalJson (value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (isRecord(value)) {
|
||||
return `{${Object.keys(value).filter(key => value[key] !== undefined).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
||||
}
|
||||
const json = JSON.stringify(value);
|
||||
if (json === undefined) throw new Error('QQ空间分页上下文格式无效');
|
||||
return json;
|
||||
}
|
||||
|
||||
function commentScope (reference: QzoneFeedReference, parent?: QzoneComment): string {
|
||||
return createHash('sha256').update(canonicalJson(parent === undefined
|
||||
? reference
|
||||
: { reference, parent: { id: parent.id, user: parent.user } })).digest('hex');
|
||||
}
|
||||
|
||||
function observedCount (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const count = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
async function withTimeout<T> (work: () => Promise<T>, message: string, duration = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(work),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), duration);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export class NTQQQzoneApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
private readonly cursors = new Map<string, PageCursor>();
|
||||
private readonly rightUpdates = new Set<string>();
|
||||
|
||||
constructor (context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
private currentUser (): QzoneUser {
|
||||
const info = this.core.selfInfo;
|
||||
if (!info || !validUin(info.uin) || !validIdentity(info.uid)) {
|
||||
throw new Error('QQ空间需要已登录的当前账号');
|
||||
}
|
||||
return validateUser({ uin: info.uin, uid: info.uid, nick: info.nick ?? '' });
|
||||
}
|
||||
|
||||
private accountKey (user: QzoneUser): string {
|
||||
return `${user.uin}:${user.uid ?? ''}`;
|
||||
}
|
||||
|
||||
private assertAccount (user: QzoneUser): void {
|
||||
if (this.accountKey(this.currentUser()) !== this.accountKey(user)) {
|
||||
throw new Error('QQ账号已切换,请重新获取QQ空间数据');
|
||||
}
|
||||
}
|
||||
|
||||
private readCursor (token: string | undefined, user: QzoneUser, kind: PageCursor['kind'], target: string): PageCursor | undefined {
|
||||
if (token === undefined) return undefined;
|
||||
if (typeof token !== 'string' || token.length === 0 || token.length > MAX_CURSOR_LENGTH) {
|
||||
throw new Error('QQ空间游标无效,请重新获取第一页');
|
||||
}
|
||||
const cursor = this.cursors.get(token);
|
||||
const now = Date.now();
|
||||
if (!cursor || cursor.issuedAt > now || now - cursor.issuedAt >= CURSOR_TTL_MS ||
|
||||
cursor.account !== this.accountKey(user) || cursor.kind !== kind || cursor.target !== target) {
|
||||
throw new Error('QQ空间游标无效或已过期,请重新获取第一页');
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private saveCursor (cursor: Omit<PageCursor, 'issuedAt'>, prefix = ''): string {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of this.cursors) {
|
||||
if (entry.issuedAt > now || now - entry.issuedAt >= CURSOR_TTL_MS) this.cursors.delete(token);
|
||||
}
|
||||
while (this.cursors.size >= MAX_CURSORS) {
|
||||
const oldest = this.cursors.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.cursors.delete(oldest);
|
||||
}
|
||||
const token = `${prefix}${randomUUID()}`;
|
||||
this.cursors.set(token, { ...structuredClone(cursor), issuedAt: now });
|
||||
return token;
|
||||
}
|
||||
|
||||
private parsePage (data: Record<string, unknown>): { feeds: QzoneFeed[]; hasMore: boolean; attachInfo: string; } {
|
||||
if (!Array.isArray(data['feeds']) ||
|
||||
(data['has_more'] !== undefined && typeof data['has_more'] !== 'boolean')) {
|
||||
throw new Error('QQ空间列表响应格式异常');
|
||||
}
|
||||
const feeds = data['feeds'].map((item: unknown) => {
|
||||
if (!isRecord(item) || !isRecord(item['feed'])) throw new Error('QQ空间动态响应格式异常');
|
||||
return item['feed'] as QzoneFeed;
|
||||
});
|
||||
const hasMore = data['has_more'] === true;
|
||||
const attachInfo = data['attach_info'];
|
||||
if (hasMore && (typeof attachInfo !== 'string' || attachInfo.length === 0 || attachInfo.length > MAX_ATTACH_LENGTH)) {
|
||||
throw new Error('QQ空间续页信息无效,请重新获取第一页');
|
||||
}
|
||||
return { feeds, hasMore, attachInfo: typeof attachInfo === 'string' ? attachInfo : '' };
|
||||
}
|
||||
|
||||
private async authHeaders (user: QzoneUser, deadline?: number): Promise<{ cookie: string; gtk: number; skey: string; pskey: string; }> {
|
||||
const remaining = deadline === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now());
|
||||
if (remaining <= 0) throw new Error('QQ空间读取超过时间预算,请使用游标分页');
|
||||
const results = await withTimeout(() => Promise.allSettled([
|
||||
this.core.apis.UserApi.getSKey(),
|
||||
this.core.apis.UserApi.getPSkey(['qzone.qq.com']),
|
||||
]), 'QQ空间取票超时,请稍后重试', remaining);
|
||||
this.assertAccount(user);
|
||||
const skey = results[0].status === 'fulfilled' ? results[0].value : undefined;
|
||||
const ticket = results[1].status === 'fulfilled' ? results[1].value : undefined;
|
||||
const pskey = ticket?.result === 0 ? ticket.domainPskeyMap?.get('qzone.qq.com') : undefined;
|
||||
const validKey = (key: unknown): key is string => typeof key === 'string' && key.length > 0 && key.length <= 4096 &&
|
||||
[...key].every(char => char.charCodeAt(0) > 32 && char.charCodeAt(0) !== 127 && char !== ';' && char !== ',');
|
||||
const sk = validKey(skey) ? skey : '';
|
||||
const pk = validKey(pskey) ? pskey : '';
|
||||
if (!sk && !pk) throw new Error('QQ空间取票失败,请确认当前账号登录状态');
|
||||
const cookies = [`uin=o${user.uin}`, `p_uin=o${user.uin}`];
|
||||
if (sk) cookies.push(`skey=${sk}`);
|
||||
if (pk) cookies.push(`p_skey=${pk}`);
|
||||
return { cookie: cookies.join('; '), gtk: getQzoneGtk(pk || sk), skey: sk, pskey: pk };
|
||||
}
|
||||
|
||||
private async requestText (user: QzoneUser, build: (gtk: number, auth: { skey: string; pskey: string; }) => QzoneHttpRequest, write: boolean, deadline?: number): Promise<string> {
|
||||
const auth = await this.authHeaders(user, deadline);
|
||||
const request = build(auth.gtk, auth);
|
||||
const remaining = deadline === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now());
|
||||
if (remaining <= 0) throw new Error('QQ空间读取超过时间预算,请使用游标分页');
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), remaining);
|
||||
let text: string;
|
||||
try {
|
||||
this.assertAccount(user);
|
||||
const response = await fetch(request.url, {
|
||||
method: request.method,
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Cookie: auth.cookie,
|
||||
Origin: 'https://h5.qzone.qq.com',
|
||||
Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
|
||||
...request.headers,
|
||||
},
|
||||
...(request.body !== undefined ? { body: request.body } : {}),
|
||||
});
|
||||
if (response.status !== 200 || !response.body) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Invalid response');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
size += chunk.value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error('Response too large');
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size));
|
||||
} catch {
|
||||
throw new Error(write
|
||||
? 'QQ空间请求未能确认结果,请先查询状态,勿自动重试'
|
||||
: 'QQ空间请求失败或超时,请稍后重试');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.assertAccount(user);
|
||||
return text;
|
||||
}
|
||||
|
||||
private async rpc (method: keyof typeof RPC_METHODS, request: Record<string, unknown>, user: QzoneUser): Promise<Record<string, unknown>> {
|
||||
const route = RPC_METHODS[method];
|
||||
const text = await this.requestText(user, gtk => ({
|
||||
url: `${RPC_ROOT}${route.service}/${method}?g_tk=${gtk}`,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-oidb': JSON.stringify({ uint32_command: route.command, uint32_service_type: route.type }),
|
||||
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
|
||||
},
|
||||
}), route.write);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(route.write
|
||||
? 'QQ空间响应无法解析,操作结果未知,请先查询状态,勿自动重试'
|
||||
: 'QQ空间响应格式异常');
|
||||
}
|
||||
return parseQzoneRpcResponse(value);
|
||||
}
|
||||
|
||||
private async web (method: keyof typeof WEB_METHODS, params: URLSearchParams, user: QzoneUser): Promise<Record<string, unknown>> {
|
||||
const route = WEB_METHODS[method];
|
||||
const write = route.method === 'POST';
|
||||
const text = await this.requestText(user, gtk => {
|
||||
const url = new URL(`${'root' in route ? route.root : WEB_ROOT}${route.path}`);
|
||||
url.searchParams.set('g_tk', String(gtk));
|
||||
if (!write) {
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
if ('ownerReferer' in route) headers['Referer'] = `https://user.qzone.qq.com/${params.get('uin')}`;
|
||||
if (write) headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
return {
|
||||
url: url.href,
|
||||
method: route.method,
|
||||
headers,
|
||||
...(write ? { body: params.toString() } : {}),
|
||||
};
|
||||
}, write);
|
||||
try {
|
||||
return parseQzoneWebResponse(text, route.requireCode);
|
||||
} catch (error) {
|
||||
if (!write) throw error;
|
||||
// Parser errors contain only local descriptions and numeric status codes.
|
||||
throw new Error(`${(error as Error).message};未确认成功,请先查询状态,勿自动重试`);
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile (targetUin?: string): Promise<QzoneProfile> {
|
||||
const user = this.currentUser();
|
||||
const target = targetUin ?? user.uin;
|
||||
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
|
||||
const response = await this.web('GetProfile', new URLSearchParams({
|
||||
uin: target, vuin: user.uin, fupdate: '1', rd: String(Math.random()),
|
||||
}), user);
|
||||
return parseQzoneProfile(response);
|
||||
}
|
||||
|
||||
private slTarget (user: QzoneUser, targetUin?: string): string {
|
||||
const target = targetUin ?? user.uin;
|
||||
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
|
||||
return target;
|
||||
}
|
||||
|
||||
private slPagination (pos: number, num: number): void {
|
||||
if (!Number.isSafeInteger(pos) || pos < 0 || !Number.isSafeInteger(num) || num < 1 || num > 100) {
|
||||
throw new Error('pos 必须为非负安全整数,num 必须在1到100之间');
|
||||
}
|
||||
}
|
||||
|
||||
async getSnowlumaMsgList (targetUin?: string, pos = 0, num = 20): Promise<SlQzoneMsgList> {
|
||||
this.slPagination(pos, num);
|
||||
const user = this.currentUser();
|
||||
const target = this.slTarget(user, targetUin);
|
||||
const params = new URLSearchParams({
|
||||
uin: target,
|
||||
ftype: '0',
|
||||
sort: '0',
|
||||
pos: String(pos),
|
||||
num: String(num),
|
||||
replynum: '100',
|
||||
callback: '_preloadCallback',
|
||||
code_version: '1',
|
||||
format: 'jsonp',
|
||||
need_private_comment: '1',
|
||||
});
|
||||
let response: Record<string, unknown>;
|
||||
try {
|
||||
response = await this.web('GetSlMsgList', params, user);
|
||||
} catch (error) {
|
||||
// 仅对已明确拒绝的只读请求切换已知代理域,不跟随重定向、不重放写操作。
|
||||
if (!(error instanceof QzoneWebError) || error.field !== 'code' || error.code !== -10000) throw error;
|
||||
response = await this.web('GetSlMsgListFallback', new URLSearchParams({
|
||||
uin: target, ftype: '0', sort: '0', pos: String(pos), num: String(num), code_version: '1', format: 'json',
|
||||
}), user);
|
||||
}
|
||||
return parseSlQzoneMsgList(response);
|
||||
}
|
||||
|
||||
async getSnowlumaMsgDetail (tid: string, targetUin?: string, pos = 0, num = 20): Promise<SlQzoneMsgDetail> {
|
||||
if (!validIdentity(tid, 8192)) throw new Error('tid 必须是有效的说说标识');
|
||||
this.slPagination(pos, num);
|
||||
const user = this.currentUser();
|
||||
const target = this.slTarget(user, targetUin);
|
||||
const response = await this.web('GetOwnMsgDetail', new URLSearchParams({
|
||||
tid,
|
||||
uin: target,
|
||||
t1_source: '1',
|
||||
not_trunc_con: '1',
|
||||
need_right: '1',
|
||||
not_adapt_outpic: '1',
|
||||
pos: String(pos),
|
||||
num: String(num),
|
||||
}), user);
|
||||
return parseSlQzoneMsgDetail(response, tid);
|
||||
}
|
||||
|
||||
async setSnowlumaLike (tid: string, targetUin: string | undefined, enable: boolean, abstime = 0): Promise<void> {
|
||||
const user = this.currentUser();
|
||||
const target = this.slTarget(user, targetUin);
|
||||
const body = buildSlQzoneLikeBody(user.uin, target, tid, enable, abstime);
|
||||
await this.web(enable ? 'LikeSlMsg' : 'UnlikeSlMsg', new URLSearchParams(body), user);
|
||||
}
|
||||
|
||||
async commentSnowluma (tid: string, content: string, targetUin?: string, options?: SlQzoneCommentOptions): Promise<SlQzoneCommentResult> {
|
||||
const user = this.currentUser();
|
||||
const target = this.slTarget(user, targetUin);
|
||||
const body = buildSlQzoneCommentBody(user.uin, target, tid, content, options);
|
||||
const response = await this.web(options?.comment_id !== undefined ? 'ReplySlMsg' : 'CommentSlMsg', new URLSearchParams(body), user);
|
||||
return parseSlQzoneComment(response, options);
|
||||
}
|
||||
|
||||
async uploadCommentImage (base64: string): Promise<{ url: string; }> {
|
||||
if (typeof base64 !== 'string' || !base64 || base64.length > 32 * 1024 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
|
||||
throw new Error('QQ空间评论图片数据无效或超过限制');
|
||||
}
|
||||
const user = this.currentUser();
|
||||
const text = await this.requestText(user, (gtk, auth) => ({
|
||||
url: `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${gtk}`,
|
||||
method: 'POST',
|
||||
body: buildQzoneUploadImageBody({ uin: user.uin, skey: auth.skey, pskey: auth.pskey, g_tk: String(gtk), base64 }),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
}), true);
|
||||
let response: Record<string, unknown>;
|
||||
try {
|
||||
// The upload CGI acknowledges with ret:0 rather than code:0.
|
||||
response = parseQzoneWebResponse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`${(error as Error).message};图片上传结果未确认,请先查询状态,勿自动重试`);
|
||||
}
|
||||
const data = response['data'];
|
||||
if (!isRecord(data) || typeof data['url'] !== 'string' || data['url'].trim() !== data['url'] ||
|
||||
[...data['url']].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
|
||||
throw new Error('QQ空间上传未返回可用图片地址');
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(data['url']);
|
||||
} catch {
|
||||
throw new Error('QQ空间上传图片地址无效');
|
||||
}
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('QQ空间上传图片地址无效');
|
||||
return { url: url.href };
|
||||
}
|
||||
|
||||
async getSnowlumaFeeds (pageNum = 1, count = 10, cursor?: string): Promise<SlQzoneFeedPage> {
|
||||
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) {
|
||||
throw new Error('page_num 必须为正安全整数,count 必须在1到50之间');
|
||||
}
|
||||
const user = this.currentUser();
|
||||
const previous = this.readCursor(cursor, user, 'sl-feeds', '');
|
||||
if (previous && (!previous.slFeedState || !previous.slFeedPage)) throw new Error('QQ空间动态游标无效');
|
||||
const requestedPage = previous?.slFeedPage ?? pageNum;
|
||||
if (previous && pageNum !== 1 && pageNum !== requestedPage) throw new Error('page_num 与动态游标不匹配');
|
||||
if (!previous && pageNum > SL_FEED_SKIP_LIMIT) throw new Error('跳页超过20页读取预算,请从首页使用游标续页');
|
||||
let state = previous?.slFeedState ? structuredClone(previous.slFeedState) : initialSlQzoneFeedState();
|
||||
const position = (value: SlQzoneFeedState): string => createHash('sha256').update(slQzoneFeedPosition(value)).digest('hex');
|
||||
const visited = new Set(previous?.slFeedPositions ?? [position(state)]);
|
||||
const deadline = Date.now() + SL_FEED_DEADLINE_MS;
|
||||
for (let page = previous?.slFeedPage ?? 1; page <= requestedPage; page++) {
|
||||
const text = await this.requestText(user, gtk => {
|
||||
const params = new URLSearchParams({
|
||||
uin: user.uin,
|
||||
scope: '0',
|
||||
view: '1',
|
||||
gid: '',
|
||||
flag: '1',
|
||||
filter: 'all',
|
||||
applist: 'all',
|
||||
refresh: '0',
|
||||
firstGetGroup: '0',
|
||||
mixnocache: '0',
|
||||
scene: '0',
|
||||
sidomain: 'qzonestyle.gtimg.cn',
|
||||
callback: '_preloadCallback',
|
||||
format: 'jsonp',
|
||||
useutf8: '1',
|
||||
outputhtmlfeed: '1',
|
||||
count: String(count),
|
||||
usertime: String(Math.floor(Date.now() / 1000)),
|
||||
rd: String(Math.random()),
|
||||
windowId: String(Math.random()),
|
||||
g_tk: String(gtk),
|
||||
});
|
||||
for (const [key, value] of Object.entries(state)) params.set(key === 'aisortNextTime' ? 'getAisort' : key, value);
|
||||
return { url: `${WEB_ROOT}ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${params}`, method: 'GET' };
|
||||
}, false, deadline);
|
||||
const data = parseSlQzoneFeeds(text);
|
||||
if (!data.has_more) return page < requestedPage ? { feeds: [], has_more: false, next_cursor: null } : { feeds: data.feeds, has_more: false, next_cursor: null };
|
||||
if (!data.main) throw new Error('QQ空间动态缺少续页状态');
|
||||
const next = advanceSlQzoneFeedState(state, data.main);
|
||||
const nextPosition = position(next);
|
||||
if (visited.has(nextPosition)) throw new Error('QQ空间动态续页位置未推进,请重新读取');
|
||||
visited.add(nextPosition);
|
||||
if (page === requestedPage) {
|
||||
const token = this.saveCursor({
|
||||
account: this.accountKey(user),
|
||||
kind: 'sl-feeds',
|
||||
target: '',
|
||||
attachInfo: '',
|
||||
slFeedState: next,
|
||||
slFeedPage: page + 1,
|
||||
slFeedPositions: [...visited].slice(-32),
|
||||
}, 'slqf:');
|
||||
return { feeds: data.feeds, has_more: true, next_cursor: token };
|
||||
}
|
||||
state = next;
|
||||
}
|
||||
throw new Error('QQ空间动态页码无效');
|
||||
}
|
||||
|
||||
async setProfile (profile: QzoneProfileUpdate): Promise<void> {
|
||||
const user = this.currentUser();
|
||||
const body = buildQzoneProfileBody(user.uin, profile);
|
||||
await this.web('SetProfile', new URLSearchParams(body), user);
|
||||
}
|
||||
|
||||
async setMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise<{ ugc_right: number; }> {
|
||||
if (!validIdentity(tid, 8192)) throw new Error('tid 必须是有效的说说标识');
|
||||
const normalized = normalizeQzoneRight(ugcRight, targetUins);
|
||||
const user = this.currentUser();
|
||||
const lockKey = `${this.accountKey(user)}:${tid}`;
|
||||
if (this.rightUpdates.has(lockKey)) throw new Error('该说说正在修改权限,请稍后重试');
|
||||
this.rightUpdates.add(lockKey);
|
||||
try {
|
||||
const detail = await this.web('GetOwnMsgDetail', new URLSearchParams({
|
||||
tid, uin: user.uin, t1_source: '1', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1',
|
||||
}), user);
|
||||
const body = buildQzoneRightBody(detail, user.uin, tid, normalized.right, normalized.targetUins);
|
||||
const result = await this.web('SetMsgRight', new URLSearchParams(body), user);
|
||||
if (result['ugc_right'] === undefined) return { ugc_right: normalized.right };
|
||||
const returnedRight = typeof result['ugc_right'] === 'string' && /^\d+$/.test(result['ugc_right'])
|
||||
? Number(result['ugc_right'])
|
||||
: result['ugc_right'];
|
||||
if (typeof returnedRight !== 'number' || ![1, 4, 16, 64, 128].includes(returnedRight)) {
|
||||
throw new Error('QQ空间返回的权限无效,请回读说说确认,勿自动重试');
|
||||
}
|
||||
if (returnedRight !== normalized.right) throw new Error('QQ空间返回权限与请求不一致,请回读说说确认,勿自动重试');
|
||||
return { ugc_right: returnedRight };
|
||||
} finally {
|
||||
this.rightUpdates.delete(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
async setBan (targetUin: string, enable = true): Promise<void> {
|
||||
const user = this.currentUser();
|
||||
const body = buildQzoneBanBody(user.uin, targetUin, enable);
|
||||
await this.web('SetBan', new URLSearchParams(body), user);
|
||||
}
|
||||
|
||||
async getFriendFeeds (cursor?: string): Promise<QzoneFeedPage> {
|
||||
const user = this.currentUser();
|
||||
const previous = this.readCursor(cursor, user, 'friends', '');
|
||||
const refreshId = previous?.refreshId ?? `${user.uin}_${Date.now()}`;
|
||||
const request: Record<string, unknown> = { refresh_id: refreshId };
|
||||
if (previous) {
|
||||
request['attach_info'] = previous.attachInfo;
|
||||
if (previous.lastUnionId) request['last_client_feed_union_id'] = previous.lastUnionId;
|
||||
}
|
||||
const page = this.parsePage(await this.rpc('GetFriendFeeds', request, user));
|
||||
let lastUnionId = previous?.lastUnionId;
|
||||
if (page.hasMore) {
|
||||
for (let index = page.feeds.length - 1; index >= 0; index--) {
|
||||
const feed = page.feeds[index];
|
||||
if (!feed || feed.cell_common?.feed_type === 3 || !feed.cell_common?.union_id) continue;
|
||||
try {
|
||||
lastUnionId = validateQzoneFeedReference({ union_id: feed.cell_common.union_id }).union_id;
|
||||
break;
|
||||
} catch {
|
||||
// 未知动态仍保留在返回页;只有可用身份才能作为后续分页锚点。
|
||||
}
|
||||
}
|
||||
}
|
||||
const nextCursor = page.hasMore
|
||||
? this.saveCursor({
|
||||
account: this.accountKey(user), kind: 'friends', target: '', attachInfo: page.attachInfo, refreshId, lastUnionId,
|
||||
})
|
||||
: null;
|
||||
return { feeds: page.feeds.map(normalizeQzoneFeed), has_more: page.hasMore, next_cursor: nextCursor };
|
||||
}
|
||||
|
||||
async getMsgList (targetUin?: string, cursor?: string): Promise<QzoneMsgPage> {
|
||||
const user = this.currentUser();
|
||||
const target = targetUin ?? user.uin;
|
||||
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
|
||||
const previous = this.readCursor(cursor, user, 'moods', target);
|
||||
let uid = user.uid;
|
||||
if (target !== user.uin) {
|
||||
try {
|
||||
uid = await withTimeout(() => this.core.apis.UserApi.getUidByUinV2(target), 'QQ空间账号解析超时');
|
||||
} catch {
|
||||
throw new Error('无法解析目标QQ号,请确认账号可访问');
|
||||
}
|
||||
}
|
||||
if (!validIdentity(uid) || uid.includes('*')) throw new Error('无法解析目标QQ号,请确认账号可访问');
|
||||
this.assertAccount(user);
|
||||
const data = await this.rpc('GetShuoshuoList', {
|
||||
host_uid: uid, host_uin: target, attach_info: previous?.attachInfo ?? '', feed_action_domain_type: 1,
|
||||
}, user);
|
||||
const page = this.parsePage(data);
|
||||
if (data['seal_off_memory'] !== undefined && data['seal_off_memory'] !== null && !isRecord(data['seal_off_memory'])) {
|
||||
throw new Error('QQ空间封存状态响应格式异常');
|
||||
}
|
||||
const nextCursor = page.hasMore
|
||||
? this.saveCursor({
|
||||
account: this.accountKey(user), kind: 'moods', target, attachInfo: page.attachInfo,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
msglist: page.feeds.map(normalizeQzoneFeed),
|
||||
has_more: page.hasMore,
|
||||
next_cursor: nextCursor,
|
||||
...(isRecord(data['seal_off_memory']) ? { seal_off_memory: data['seal_off_memory'] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async detailResponse (ref: QzoneFeedReference, user: QzoneUser): Promise<Record<string, unknown>> {
|
||||
return await this.rpc('GetFeedDetail', {
|
||||
union_id: ref.union_id,
|
||||
cell_feed_transfer: ref.cell_feed_transfer,
|
||||
feed_action_domain_type: ref.feed_action_domain_type ?? 1,
|
||||
from_page: 1,
|
||||
}, user);
|
||||
}
|
||||
|
||||
private detailFeed (data: Record<string, unknown>): QzoneFeed {
|
||||
if (!isRecord(data['feed']) || !isRecord(data['feed']['feed'])) {
|
||||
throw new Error('QQ空间未返回说说详情,内容可能不可访问或已删除');
|
||||
}
|
||||
const feed = data['feed']['feed'] as QzoneFeed;
|
||||
if (feed.cell_comment !== undefined && !isRecord(feed.cell_comment)) {
|
||||
throw new Error('QQ空间评论响应格式异常');
|
||||
}
|
||||
const comments = feed.cell_comment?.comments;
|
||||
if (comments !== undefined && (!Array.isArray(comments) || comments.some(comment => !isRecord(comment)))) {
|
||||
throw new Error('QQ空间评论响应格式异常');
|
||||
}
|
||||
return feed;
|
||||
}
|
||||
|
||||
private currentReference (ref: QzoneFeedReference, feed: QzoneFeed): QzoneFeedReference {
|
||||
const current = referenceSnapshot(ref);
|
||||
const common = feed.cell_common;
|
||||
if (isRecord(common)) {
|
||||
if (common['union_id'] !== undefined && common['union_id'] !== null) current.union_id = common['union_id'] as QzoneUnionId;
|
||||
if (typeof common['domain_type'] === 'number' && Number.isInteger(common['domain_type']) && common['domain_type'] >= 1 && common['domain_type'] <= 3) {
|
||||
current.feed_action_domain_type = common['domain_type'];
|
||||
}
|
||||
if (validIdentity(common['cur_like_key'], 8192)) current.like_key = common['cur_like_key'];
|
||||
}
|
||||
if (feed.cell_feed_transfer !== undefined && feed.cell_feed_transfer !== null) current.cell_feed_transfer = feed.cell_feed_transfer;
|
||||
return referenceSnapshot(current);
|
||||
}
|
||||
|
||||
private commentCell (value: unknown): { comments: QzoneComment[]; count: number | null; } {
|
||||
if (value === undefined) return { comments: [], count: null };
|
||||
if (!isRecord(value) || (value['comments'] !== undefined && (!Array.isArray(value['comments']) || value['comments'].some(item => !isRecord(item))))) {
|
||||
throw new Error('QQ空间评论响应格式异常');
|
||||
}
|
||||
return { comments: (value['comments'] ?? []) as QzoneComment[], count: observedCount(value['num']) };
|
||||
}
|
||||
|
||||
private commentPageState (data: Record<string, unknown>, field: 'attach_info' | 'reply_attach_info', previous?: string): { hasMore: boolean; attachInfo: string; } {
|
||||
if (data['has_more'] !== undefined && typeof data['has_more'] !== 'boolean') throw new Error('QQ空间评论分页状态无效');
|
||||
const hasMore = data['has_more'] === true;
|
||||
const attachInfo = data[field];
|
||||
if (hasMore && (typeof attachInfo !== 'string' || !attachInfo.length || attachInfo.length > MAX_ATTACH_LENGTH || attachInfo === previous)) {
|
||||
throw new Error('QQ空间评论续页信息缺失或未推进,请重新读取');
|
||||
}
|
||||
return { hasMore, attachInfo: typeof attachInfo === 'string' ? attachInfo : '' };
|
||||
}
|
||||
|
||||
private saveCommentCursor (user: QzoneUser, boundRef: QzoneFeedReference, requestRef: QzoneFeedReference, attachInfo: string): string {
|
||||
return this.saveCursor({
|
||||
account: this.accountKey(user), kind: 'comments', target: commentScope(boundRef), attachInfo, feedReference: requestRef,
|
||||
});
|
||||
}
|
||||
|
||||
async getMsgDetail (reference: QzoneFeedReference): Promise<QzoneDetail> {
|
||||
const ref = referenceSnapshot(reference);
|
||||
const user = this.currentUser();
|
||||
const data = await this.detailResponse(ref, user);
|
||||
const feed = this.detailFeed(data);
|
||||
const item = normalizeQzoneFeed(feed);
|
||||
const state = this.commentPageState(data, 'attach_info');
|
||||
// 旧详情仍返回原来的规范化引用;分页请求在内部保留 H5 返回的完整新上下文。
|
||||
const nextCursor = state.hasMore && item.feed_ref
|
||||
? this.saveCommentCursor(user, item.feed_ref, this.currentReference(ref, feed), state.attachInfo)
|
||||
: null;
|
||||
return {
|
||||
...item,
|
||||
comments: feed.cell_comment?.comments ?? [],
|
||||
comment_has_more: state.hasMore,
|
||||
comment_next_cursor: nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async getComments (reference: QzoneFeedReference, cursor?: string): Promise<QzoneCommentPage> {
|
||||
const ref = referenceSnapshot(reference);
|
||||
const user = this.currentUser();
|
||||
const previous = this.readCursor(cursor, user, 'comments', commentScope(ref));
|
||||
let current = ref;
|
||||
let data: Record<string, unknown>;
|
||||
let cell: { comments: QzoneComment[]; count: number | null; };
|
||||
if (previous) {
|
||||
if (!previous.feedReference) throw new Error('QQ空间评论游标无效,请重新读取');
|
||||
current = referenceSnapshot(previous.feedReference);
|
||||
data = await this.rpc('GetComment', {
|
||||
union_id: current.union_id, attach_info: previous.attachInfo, cell_feed_transfer: current.cell_feed_transfer,
|
||||
}, user);
|
||||
const commentCell = data['cell_comment'];
|
||||
// 空末页可能省略 repeated 字段,只有明确结束时才接受,避免把畸形响应当作读完。
|
||||
if (commentCell === undefined && data['has_more'] === false) {
|
||||
cell = { comments: [], count: null };
|
||||
} else {
|
||||
if (!isRecord(commentCell) || (commentCell['comments'] === undefined && data['has_more'] !== false)) {
|
||||
throw new Error('QQ空间评论响应缺少有效cell_comment对象或评论列表');
|
||||
}
|
||||
cell = this.commentCell(commentCell);
|
||||
}
|
||||
} else {
|
||||
data = await this.detailResponse(ref, user);
|
||||
const feed = this.detailFeed(data);
|
||||
current = this.currentReference(ref, feed);
|
||||
cell = this.commentCell(feed.cell_comment);
|
||||
}
|
||||
const comments = cell.comments.map(normalizeQzoneComment);
|
||||
const state = this.commentPageState(data, 'attach_info', previous?.attachInfo);
|
||||
const nextCursor = state.hasMore ? this.saveCommentCursor(user, current, current, state.attachInfo) : null;
|
||||
return {
|
||||
feed_ref: current,
|
||||
comment_num: cell.count,
|
||||
comments,
|
||||
has_more: state.hasMore,
|
||||
next_cursor: nextCursor,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
|
||||
async getCommentReplies (reference: QzoneFeedReference, parent: QzoneComment, cursor?: string): Promise<QzoneReplyPage> {
|
||||
const ref = referenceSnapshot(reference);
|
||||
const parentSnapshot = readParentSnapshot(parent);
|
||||
const user = this.currentUser();
|
||||
const scope = commentScope(ref, parentSnapshot);
|
||||
const previous = this.readCursor(cursor, user, 'replies', scope);
|
||||
let current = ref;
|
||||
let requestParent = parentSnapshot;
|
||||
if (previous) {
|
||||
if (!previous.feedReference || !previous.parentComment) throw new Error('QQ空间回复游标无效,请重新读取');
|
||||
current = referenceSnapshot(previous.feedReference);
|
||||
requestParent = readParentSnapshot(previous.parentComment);
|
||||
}
|
||||
const initialAttach = requestParent['reply_attach_info'];
|
||||
if (initialAttach !== undefined && initialAttach !== null && (typeof initialAttach !== 'string' || initialAttach.length > MAX_ATTACH_LENGTH)) {
|
||||
throw new Error('QQ空间父评论的回复续页信息无效');
|
||||
}
|
||||
const attachInfo = previous?.attachInfo ?? (typeof initialAttach === 'string' ? initialAttach : '');
|
||||
const data = await this.rpc('GetReplies', {
|
||||
union_id: current.union_id,
|
||||
cur_comment: requestParent,
|
||||
comment_attach_info: '',
|
||||
reply_attach_info: attachInfo,
|
||||
page_size: 20,
|
||||
cell_feed_transfer: current.cell_feed_transfer,
|
||||
}, user);
|
||||
const replyList = data['replys'];
|
||||
if ((replyList === undefined && data['has_more'] !== false) ||
|
||||
(replyList !== undefined && (!Array.isArray(replyList) || replyList.some(item => !isRecord(item))))) {
|
||||
throw new Error('QQ空间回复响应格式异常');
|
||||
}
|
||||
const replies = ((replyList ?? []) as QzoneComment[]).map(normalizeQzoneReply);
|
||||
const state = this.commentPageState(data, 'reply_attach_info', attachInfo);
|
||||
const nextParent = state.hasMore
|
||||
? readParentSnapshot({
|
||||
...requestParent, reply_attach_info: state.attachInfo, has_more_reply: state.hasMore,
|
||||
})
|
||||
: undefined;
|
||||
const nextCursor = state.hasMore
|
||||
? this.saveCursor({
|
||||
account: this.accountKey(user),
|
||||
kind: 'replies',
|
||||
target: scope,
|
||||
attachInfo: state.attachInfo,
|
||||
feedReference: current,
|
||||
// 保留首次完整父对象和内联回复,只推进分页元数据,不累计整段历史到请求中。
|
||||
parentComment: nextParent,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
feed_ref: current,
|
||||
comment_id: String(requestParent.id),
|
||||
replies,
|
||||
reply_num: data['reply_num'] === undefined ? observedCount(requestParent['reply_num']) : observedCount(data['reply_num']),
|
||||
has_more: state.hasMore,
|
||||
next_cursor: nextCursor,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
|
||||
async publishMsg (content: string, ugcRight = 1, targetUins?: string[]): Promise<{ tid: string; time: number; }> {
|
||||
const user = this.currentUser();
|
||||
const request = buildQzonePublishRequest(user, content, ugcRight, targetUins);
|
||||
const data = await this.rpc('PublishFeed', request, user);
|
||||
const tid = isRecord(data['feed']) ? getQzoneTid(data['feed'] as QzoneFeed) : null;
|
||||
if (!tid) throw new Error('QQ空间发表响应缺少说说标识,结果未知,请先查询列表确认,勿自动重试');
|
||||
return { tid, time: parseQzonePublishTime((data['feed'] as QzoneFeed).cell_common?.time) };
|
||||
}
|
||||
|
||||
async setLike (reference: QzoneFeedReference, enable: boolean): Promise<void> {
|
||||
const ref = validateQzoneFeedReference(reference);
|
||||
if (typeof ref.like_key !== 'string' || !ref.like_key.trim() || ref.like_key.length > 8192) {
|
||||
throw new Error('缺少QQ空间点赞标识,请先重新获取说说详情');
|
||||
}
|
||||
if (typeof enable !== 'boolean') throw new Error('QQ空间点赞状态必须是布尔值');
|
||||
const user = this.currentUser();
|
||||
const data = await this.rpc(enable ? 'DoLike' : 'UnLike', {
|
||||
like_key: ref.like_key, union_id: ref.union_id, like_type: 1, cell_feed_transfer: ref.cell_feed_transfer,
|
||||
}, user);
|
||||
if (!isRecord(data['like'])) throw new Error('QQ空间未返回点赞结果,请先查询状态,勿自动重试');
|
||||
}
|
||||
|
||||
async comment (reference: QzoneFeedReference, content: string, parentComment?: QzoneComment, replyUser?: QzoneUser): Promise<{ comment_id: string; comment: QzoneComment; }> {
|
||||
const ref = validateQzoneFeedReference(reference);
|
||||
const segments = buildQzoneText(content);
|
||||
if (replyUser !== undefined && parentComment === undefined) throw new Error('reply_user 必须与父评论一同提供');
|
||||
const parent = parentComment === undefined ? undefined : validateComment(parentComment);
|
||||
const target = parent ? validateUser(replyUser ?? parent.user) : undefined;
|
||||
const user = this.currentUser();
|
||||
const data = await this.rpc(parent ? 'AddReply' : 'AddComment', {
|
||||
union_id: ref.union_id,
|
||||
cell_feed_transfer: ref.cell_feed_transfer,
|
||||
...(parent ? { comment: parent, reply: { content: segments, target_user: target, user } } : { comment: { user, content: segments } }),
|
||||
}, user);
|
||||
const result = data[parent ? 'reply' : 'comment'];
|
||||
if (!isRecord(result) || !validIdentity(result['id'], 8192)) {
|
||||
throw new Error('QQ空间未返回有效评论标识,结果未知,请先查询详情确认,勿自动重试');
|
||||
}
|
||||
return { comment_id: result['id'], comment: result as QzoneComment };
|
||||
}
|
||||
}
|
||||
@@ -749,9 +749,16 @@ export class NTQQWebApi {
|
||||
}
|
||||
|
||||
private async getQzoneAuth () {
|
||||
const uin = this.core.selfInfo.uin;
|
||||
const uid = this.core.selfInfo.uid;
|
||||
if (!uin || !uid) throw new Error('QQ空间需要已登录的当前账号');
|
||||
const assertAccount = () => {
|
||||
if (this.core.selfInfo.uin !== uin || this.core.selfInfo.uid !== uid) throw new Error('QQ账号已切换,请重新确认QQ空间操作');
|
||||
};
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
assertAccount();
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
assertAccount();
|
||||
const g_tk = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
return { skey, pskey, uin, g_tk, cookie };
|
||||
@@ -786,7 +793,7 @@ export class NTQQWebApi {
|
||||
* @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见
|
||||
* @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用
|
||||
*/
|
||||
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: number[]) {
|
||||
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: Array<string | number>) {
|
||||
const { uin, g_tk, cookie } = await this.getQzoneAuth();
|
||||
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
|
||||
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
normalizeQzoneMediaImages,
|
||||
normalizeQzoneRichContent,
|
||||
type QzoneComment,
|
||||
type QzoneFeedReference,
|
||||
} from './qzone-rpc';
|
||||
|
||||
export interface QzoneReplyItem {
|
||||
reply_id: string | null;
|
||||
user_id: string | null;
|
||||
uid: string | null;
|
||||
nickname: string | null;
|
||||
time: number | null;
|
||||
content: string | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
liked: boolean | null;
|
||||
target_user_id: string | null;
|
||||
target_uid: string | null;
|
||||
target_nickname: string | null;
|
||||
reply: QzoneComment;
|
||||
}
|
||||
|
||||
export interface QzoneCommentItem {
|
||||
comment_id: string | null;
|
||||
user_id: string | null;
|
||||
uid: string | null;
|
||||
nickname: string | null;
|
||||
time: number | null;
|
||||
content: string | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
liked: boolean | null;
|
||||
reply_num: number | null;
|
||||
has_more_replies: boolean | null;
|
||||
replies: QzoneReplyItem[];
|
||||
comment: QzoneComment;
|
||||
}
|
||||
|
||||
export interface QzoneCommentPage {
|
||||
feed_ref: QzoneFeedReference;
|
||||
comment_num: number | null;
|
||||
comments: QzoneCommentItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QzoneReplyPage {
|
||||
feed_ref: QzoneFeedReference;
|
||||
comment_id: string;
|
||||
replies: QzoneReplyItem[];
|
||||
reply_num: number | null;
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function identifier (value: unknown): string | null {
|
||||
if (typeof value === 'string') return value.trim().length > 0 && value.length <= 8192 ? value : null;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
function userId (value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
function count (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const result = Number(value);
|
||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||
}
|
||||
|
||||
function nickname (value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function likeState (value: unknown): boolean | null {
|
||||
return value === 1 ? true : value === 0 ? false : null;
|
||||
}
|
||||
|
||||
function author (value: unknown): { user_id: string | null; uid: string | null; nickname: string | null; } {
|
||||
const user = object(value);
|
||||
const uid = user?.['uid'];
|
||||
return {
|
||||
user_id: userId(user?.['uin']),
|
||||
uid: typeof uid === 'string' ? identifier(uid) : null,
|
||||
nickname: nickname(user?.['nick']),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQzoneReply (value: QzoneComment): QzoneReplyItem {
|
||||
const reply = object(value);
|
||||
if (!reply) throw new Error('QQ空间回复数据格式异常');
|
||||
const user = author(reply['user']);
|
||||
const target = author(reply['target_user']);
|
||||
const like = object(reply['like']);
|
||||
return {
|
||||
reply_id: identifier(reply['id']),
|
||||
...user,
|
||||
// The renderer uses time in Unix seconds. create_time is not this wire field.
|
||||
time: count(reply['time']),
|
||||
content: normalizeQzoneRichContent(reply['content']),
|
||||
images: normalizeQzoneMediaImages(reply['media_items']),
|
||||
like_count: count(like?.['count']),
|
||||
liked: likeState(like?.['status']),
|
||||
target_user_id: target.user_id,
|
||||
target_uid: target.uid,
|
||||
target_nickname: target.nickname,
|
||||
reply: value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQzoneComment (value: QzoneComment): QzoneCommentItem {
|
||||
const comment = object(value);
|
||||
if (!comment) throw new Error('QQ空间评论数据格式异常');
|
||||
const user = author(comment['user']);
|
||||
const like = object(comment['like']);
|
||||
const inline = comment['replys'];
|
||||
if (inline !== undefined && inline !== null && !Array.isArray(inline)) throw new Error('QQ空间内联回复列表格式异常');
|
||||
const replies = (inline ?? []) as unknown[];
|
||||
return {
|
||||
comment_id: identifier(comment['id']),
|
||||
...user,
|
||||
time: count(comment['time']),
|
||||
content: normalizeQzoneRichContent(comment['content']),
|
||||
images: normalizeQzoneMediaImages(comment['media_items']),
|
||||
like_count: count(like?.['count']),
|
||||
liked: likeState(like?.['status']),
|
||||
reply_num: count(comment['reply_num']),
|
||||
has_more_replies: typeof comment['has_more_reply'] === 'boolean' ? comment['has_more_reply'] : null,
|
||||
// These are only the inline replies returned on this page, not an exhaustiveness claim.
|
||||
replies: replies.map((reply) => {
|
||||
if (!object(reply)) throw new Error('QQ空间内联回复数据格式异常');
|
||||
return normalizeQzoneReply(reply as QzoneComment);
|
||||
}),
|
||||
comment: value,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export const QZONE_REFERENCE_MAX_BYTES = 64 * 1024;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
export interface QzoneUnionExtension {
|
||||
ext_id_type?: number;
|
||||
numeric?: number | string;
|
||||
str?: string;
|
||||
unsigned_numeric?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneUnionId {
|
||||
uf_key?: string;
|
||||
ugc_id?: string;
|
||||
feed_id?: string;
|
||||
ext_ids?: QzoneUnionExtension[];
|
||||
serialized_data?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneUser {
|
||||
uin: string;
|
||||
uid?: string;
|
||||
nick?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneRichMsg {
|
||||
type: number;
|
||||
content?: string;
|
||||
media_items?: unknown[];
|
||||
name?: string;
|
||||
uid?: string;
|
||||
uin?: string;
|
||||
url?: string;
|
||||
who?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneRawCommentUser {
|
||||
uin?: string;
|
||||
uid?: string | null;
|
||||
nick?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneComment {
|
||||
id?: string | number;
|
||||
user?: QzoneRawCommentUser;
|
||||
content?: QzoneRichMsg[];
|
||||
replys?: QzoneComment[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeed {
|
||||
cell_common?: {
|
||||
union_id?: QzoneUnionId;
|
||||
time?: number | string;
|
||||
app_id?: number;
|
||||
feed_type?: number;
|
||||
feed_sub_type?: number;
|
||||
feed_biz_type?: number;
|
||||
client_key?: string;
|
||||
cur_like_key?: string;
|
||||
domain_type?: number;
|
||||
operate_mask?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
cell_user_info?: { user?: QzoneUser; [key: string]: unknown; };
|
||||
cell_summary?: { summary?: QzoneRichMsg[]; [key: string]: unknown; };
|
||||
cell_media?: { media_items?: unknown[]; [key: string]: unknown; };
|
||||
cell_comment?: { num?: number | string; comments?: QzoneComment[]; [key: string]: unknown; };
|
||||
cell_like?: { num?: number | string; liked?: boolean; [key: string]: unknown; };
|
||||
cell_feed_transfer?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeedReference {
|
||||
union_id: QzoneUnionId;
|
||||
cell_feed_transfer?: unknown;
|
||||
feed_action_domain_type?: number;
|
||||
like_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeedItem {
|
||||
tid: string | null;
|
||||
user_id: string | null;
|
||||
content: string | null;
|
||||
time: number | null;
|
||||
comment_num: number | null;
|
||||
like_num: number | null;
|
||||
images: string[] | null;
|
||||
feed_ref: QzoneFeedReference | null;
|
||||
feed: QzoneFeed;
|
||||
}
|
||||
|
||||
export interface QzoneFeedPage {
|
||||
feeds: QzoneFeedItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface QzoneMsgPage {
|
||||
msglist: QzoneFeedItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
seal_off_memory?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QzoneDetail extends QzoneFeedItem {
|
||||
comments: QzoneComment[];
|
||||
comment_has_more?: boolean;
|
||||
comment_next_cursor?: string | null;
|
||||
}
|
||||
|
||||
function record (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function identity (value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_IDENTITY_LENGTH ? value : null;
|
||||
}
|
||||
|
||||
function nonnegativeInteger (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function numericIdentifier (value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
export function getQzoneGtk (key: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < key.length; i++) hash += (hash << 5) + key.charCodeAt(i);
|
||||
return hash & 0x7fffffff;
|
||||
}
|
||||
|
||||
export function getQzoneTid (feed: QzoneFeed): string | null {
|
||||
const common = record(feed?.cell_common);
|
||||
const union = record(common?.['union_id']);
|
||||
if (!union) return null;
|
||||
const extensions = union['ext_ids'];
|
||||
if (Array.isArray(extensions)) {
|
||||
const extension = extensions.map(record).find(item => item?.['ext_id_type'] === 5);
|
||||
if (extension) {
|
||||
const tid = identity(extension['str']) ?? numericIdentifier(extension['unsigned_numeric']) ?? numericIdentifier(extension['numeric']);
|
||||
if (tid !== null) return tid;
|
||||
}
|
||||
}
|
||||
// Other feed kinds also have ugc_id; an album ID is not a mood tid.
|
||||
if (common?.['feed_sub_type'] !== 1 && common?.['app_id'] !== 311) return null;
|
||||
return identity(union['ugc_id']);
|
||||
}
|
||||
|
||||
export function normalizeQzoneRichContent (value: unknown): string | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const item of value) {
|
||||
const segment = record(item);
|
||||
if (!segment) return null;
|
||||
switch (segment['type']) {
|
||||
case 0:
|
||||
case 2:
|
||||
case 4:
|
||||
if (typeof segment['content'] !== 'string') return null;
|
||||
parts.push(segment['content']);
|
||||
break;
|
||||
case 1:
|
||||
case 5:
|
||||
if (typeof segment['name'] !== 'string') return null;
|
||||
parts.push(segment['type'] === 1 ? `@${segment['name']}` : segment['name']);
|
||||
break;
|
||||
case 3:
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function imageUrl (value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
const url = value.startsWith('//') ? `https:${value}` : value;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !parsed.username && !parsed.password ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeQzoneMediaImages (value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const images: string[] = [];
|
||||
for (const item of value) {
|
||||
const media = record(item);
|
||||
if (!media) return null;
|
||||
if (media['type'] === 1) continue; // Video covers are not post images.
|
||||
if (media['type'] !== 0) return null;
|
||||
const image = record(media['image']);
|
||||
if (!image) return null;
|
||||
const variants = Array.isArray(image['photo_url']) ? image['photo_url'].map(record) : [];
|
||||
let selected: string | null = null;
|
||||
// Same original-image preference as the H5 client: download, origin, big, current.
|
||||
for (const spec of [6, 0, 1, 5]) {
|
||||
const variant = variants.find(candidate => candidate?.['spec'] === spec);
|
||||
selected = imageUrl(record(variant?.['url'])?.['url']);
|
||||
if (selected !== null) break;
|
||||
}
|
||||
selected ??= imageUrl(record(image['default_url'])?.['url']);
|
||||
if (selected === null) return null;
|
||||
images.push(selected);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function assertJson (value: unknown, depth = 0): void {
|
||||
if (depth > 32) throw new Error('QQ空间动态引用嵌套过深');
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertJson(item, depth + 1);
|
||||
return;
|
||||
}
|
||||
const object = record(value);
|
||||
if (!object) throw new Error('QQ空间动态引用必须为JSON数据');
|
||||
for (const item of Object.values(object)) {
|
||||
if (item !== undefined) assertJson(item, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateQzoneFeedReference (value: unknown): QzoneFeedReference {
|
||||
const reference = record(value);
|
||||
const union = record(reference?.['union_id']);
|
||||
if (!reference || !union) throw new Error('QQ空间动态引用缺少union_id');
|
||||
const keys = ['uf_key', 'ugc_id', 'feed_id', 'serialized_data'];
|
||||
let hasIdentity = false;
|
||||
for (const key of keys) {
|
||||
if (union[key] === undefined) continue;
|
||||
const field = union[key];
|
||||
if (typeof field !== 'string' || field.length > MAX_IDENTITY_LENGTH) throw new Error('QQ空间动态标识无效');
|
||||
// Protobuf JSON may include empty defaults alongside an issued identity.
|
||||
if (field.trim().length > 0) hasIdentity = true;
|
||||
}
|
||||
if (!hasIdentity) throw new Error('QQ空间动态引用缺少有效标识');
|
||||
if (union['ext_ids'] !== undefined && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !record(item)))) {
|
||||
throw new Error('QQ空间动态扩展标识无效');
|
||||
}
|
||||
const domain = reference['feed_action_domain_type'];
|
||||
if (domain !== undefined && (typeof domain !== 'number' || !Number.isInteger(domain) || domain < 1 || domain > 3)) {
|
||||
throw new Error('QQ空间动态所属域无效');
|
||||
}
|
||||
if (reference['like_key'] !== undefined && identity(reference['like_key']) === null) throw new Error('QQ空间点赞标识无效');
|
||||
assertJson(reference);
|
||||
let serialized: string;
|
||||
try {
|
||||
const encoded = JSON.stringify(reference);
|
||||
if (typeof encoded !== 'string') throw new Error('Invalid JSON');
|
||||
serialized = encoded;
|
||||
} catch {
|
||||
throw new Error('QQ空间动态引用无法序列化');
|
||||
}
|
||||
if (Buffer.byteLength(serialized, 'utf8') > QZONE_REFERENCE_MAX_BYTES) throw new Error('QQ空间动态引用不能超过64KiB');
|
||||
return reference as unknown as QzoneFeedReference;
|
||||
}
|
||||
|
||||
export function normalizeQzoneFeed (feed: QzoneFeed): QzoneFeedItem {
|
||||
if (!record(feed)) throw new Error('QQ空间动态数据格式无效');
|
||||
const common = record(feed.cell_common);
|
||||
const user = record(record(feed.cell_user_info)?.['user']);
|
||||
let reference: QzoneFeedReference | null = null;
|
||||
if (record(common?.['union_id'])) {
|
||||
const candidate: Record<string, unknown> = { union_id: common?.['union_id'] };
|
||||
if (feed.cell_feed_transfer !== undefined) candidate['cell_feed_transfer'] = feed.cell_feed_transfer;
|
||||
const domain = common?.['domain_type'];
|
||||
if (typeof domain === 'number' && Number.isInteger(domain) && domain >= 1 && domain <= 3) candidate['feed_action_domain_type'] = domain;
|
||||
if (identity(common?.['cur_like_key']) !== null) candidate['like_key'] = common?.['cur_like_key'];
|
||||
try {
|
||||
reference = validateQzoneFeedReference(candidate);
|
||||
} catch {
|
||||
// Preserve the raw feed while declining to issue an unusable action handle.
|
||||
}
|
||||
}
|
||||
return {
|
||||
tid: getQzoneTid(feed),
|
||||
user_id: numericIdentifier(user?.['uin']),
|
||||
content: normalizeQzoneRichContent(record(feed.cell_summary)?.['summary']),
|
||||
time: nonnegativeInteger(common?.['time']),
|
||||
comment_num: nonnegativeInteger(record(feed.cell_comment)?.['num']),
|
||||
like_num: nonnegativeInteger(record(feed.cell_like)?.['num']),
|
||||
images: normalizeQzoneMediaImages(record(feed.cell_media)?.['media_items']),
|
||||
feed_ref: reference,
|
||||
feed,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQzoneText (content: string): QzoneRichMsg[] {
|
||||
if (typeof content !== 'string' || !content.trim()) throw new Error('QQ空间正文不能为空');
|
||||
if (content.length > MAX_TEXT_LENGTH) throw new Error('QQ空间正文不能超过2000字');
|
||||
return [{ content, media_items: [], name: '', type: 0, uid: '', uin: '', url: '', who: 0 }];
|
||||
}
|
||||
|
||||
export function buildQzonePublishRequest (
|
||||
user: QzoneUser,
|
||||
content: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string[],
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
key?: string
|
||||
): { feed: QzoneFeed; from: number; } {
|
||||
if (!user || typeof user.uin !== 'string' || numericIdentifier(user.uin) === null) throw new Error('QQ空间当前账号无效');
|
||||
if (user.uid !== undefined && typeof user.uid !== 'string') throw new Error('QQ空间当前账号UID无效');
|
||||
if (user.nick !== undefined && typeof user.nick !== 'string') throw new Error('QQ空间当前账号昵称无效');
|
||||
const permissions = new Map([[1, 1], [4, 2], [16, 3], [128, 4], [64, 5]]);
|
||||
const flag = permissions.get(ugcRight);
|
||||
if (flag === undefined) throw new Error('ugc_right仅支持1、4、16、64、128');
|
||||
if (targetUins !== undefined && (!Array.isArray(targetUins) || targetUins.some(uin => typeof uin !== 'string' || numericIdentifier(uin) === null))) {
|
||||
throw new Error('target_uins必须为有效QQ号数组');
|
||||
}
|
||||
if ((ugcRight === 16 || ugcRight === 128) && !targetUins?.length) throw new Error('ugc_right为16或128时target_uins不能为空');
|
||||
if (!Number.isSafeInteger(now) || now < 0) throw new Error('QQ空间发表时间无效');
|
||||
const clientKey = key ?? `${user.uin}_${now}_${Math.floor(Math.random() * 1e10)}`;
|
||||
if (identity(clientKey) === null) throw new Error('QQ空间发表请求标识无效');
|
||||
const right: Record<string, unknown> = { flag: [flag] };
|
||||
if (ugcRight === 16) right['white_uins'] = [...targetUins!];
|
||||
if (ugcRight === 128) right['black_uins'] = [...targetUins!];
|
||||
return {
|
||||
feed: {
|
||||
cell_common: {
|
||||
time: now,
|
||||
client_key: clientKey,
|
||||
feed_type: 1,
|
||||
feed_sub_type: 1,
|
||||
union_id: { uf_key: clientKey, ugc_id: clientKey, feed_id: clientKey },
|
||||
feed_biz_type: 0,
|
||||
},
|
||||
cell_user_info: { user: { uin: user.uin, uid: user.uid ?? '', nick: user.nick ?? '' } },
|
||||
cell_summary: { summary: buildQzoneText(content) },
|
||||
cell_permission: { right },
|
||||
},
|
||||
from: 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseQzoneRpcResponse (value: unknown): Record<string, unknown> {
|
||||
const response = record(value);
|
||||
if (!response || response['retcode'] !== 0) {
|
||||
const code = response?.['retcode'];
|
||||
const suffix = typeof code === 'number' && Number.isSafeInteger(code) ? ` (retcode=${code})` : '';
|
||||
throw new Error(`QQ空间请求失败${suffix}`);
|
||||
}
|
||||
const data = record(response['data']);
|
||||
if (!data) throw new Error('QQ空间响应缺少有效data对象');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import JSON5 from 'json5';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export interface SlQzoneFeed {
|
||||
uin: number;
|
||||
nickname: string;
|
||||
time: number;
|
||||
appid: number;
|
||||
key: string;
|
||||
html: string;
|
||||
tid: string | null;
|
||||
content: string | null;
|
||||
content_complete: boolean | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
comment_count: number | null;
|
||||
}
|
||||
|
||||
export interface SlQzoneFeedPage {
|
||||
feeds: SlQzoneFeed[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface SlQzoneFeedState {
|
||||
begintime: string;
|
||||
dayspac: string;
|
||||
pagenum: string;
|
||||
aisortEndTime: string;
|
||||
aisortOffset: string;
|
||||
aisortBeginTime: string;
|
||||
aisortNextTime: string;
|
||||
icServerTime: string;
|
||||
externparam: string;
|
||||
daylist: string;
|
||||
uinlist: string;
|
||||
}
|
||||
|
||||
export interface SlQzoneParsedFeeds {
|
||||
feeds: SlQzoneFeed[];
|
||||
has_more: boolean;
|
||||
main: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
const MAX_TOKENS = 200000;
|
||||
const MAX_HTML_NODES = 50000;
|
||||
const STATE_NUMBERS = ['begintime', 'dayspac', 'pagenum', 'aisortEndTime', 'aisortOffset', 'aisortBeginTime', 'aisortNextTime', 'icServerTime'] as const;
|
||||
const STATE_STRINGS = ['externparam', 'daylist', 'uinlist'] as const;
|
||||
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
function record (value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function count (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function scalar (value: unknown, label: string): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value !== 'string') throw new Error(`QQ空间动态${label}格式异常`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function wireNumber (value: unknown, label: string): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
const result = count(value);
|
||||
if (result === null) throw new Error(`QQ空间动态${label}格式异常`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Only adapt data syntax that JSON5 lacks: undefined values and elided array slots. */
|
||||
function json5DataSource (source: string, undefinedMarker: string): string {
|
||||
const chunks: string[] = [];
|
||||
const nesting: Array<{ array: boolean; awaiting: boolean; }> = [];
|
||||
let offset = 0;
|
||||
let tokens = 0;
|
||||
const statusKeys = new Set<string>();
|
||||
const markValue = () => {
|
||||
const parent = nesting[nesting.length - 1];
|
||||
if (parent?.array) parent.awaiting = false;
|
||||
};
|
||||
const nextSignificant = (start: number): number => {
|
||||
let index = start;
|
||||
while (index < source.length) {
|
||||
if (/\s/.test(source[index]!)) index++;
|
||||
else if (source.startsWith('//', index)) {
|
||||
const end = source.indexOf('\n', index + 2);
|
||||
index = end < 0 ? source.length : end + 1;
|
||||
} else if (source.startsWith('/*', index)) {
|
||||
const end = source.indexOf('*/', index + 2);
|
||||
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
|
||||
index = end + 2;
|
||||
} else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const checkStatusKey = (raw: string, quoted: boolean, end: number) => {
|
||||
if (nesting.length !== 1 || nesting[0]?.array || source[nextSignificant(end)] !== ':') return;
|
||||
let key: unknown = raw;
|
||||
if (quoted) key = JSON5.parse(raw);
|
||||
else if (raw.includes('\\')) key = Object.keys(JSON5.parse(`{${raw}:0}`))[0];
|
||||
if (key !== 'code' && key !== 'subcode') return;
|
||||
if (statusKeys.has(key)) throw new Error('QQ空间动态响应状态字段重复');
|
||||
statusKeys.add(key);
|
||||
};
|
||||
while (offset < source.length) {
|
||||
if (++tokens > MAX_TOKENS) throw new Error('QQ空间动态字面量节点过多');
|
||||
const char = source[offset]!;
|
||||
if (/\s/.test(char)) {
|
||||
const start = offset++;
|
||||
while (offset < source.length && /\s/.test(source[offset]!)) offset++;
|
||||
chunks.push(source.slice(start, offset));
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith('//', offset) || source.startsWith('/*', offset)) {
|
||||
const start = offset;
|
||||
if (source[offset + 1] === '/') {
|
||||
const end = source.indexOf('\n', offset + 2);
|
||||
offset = end < 0 ? source.length : end + 1;
|
||||
} else {
|
||||
const end = source.indexOf('*/', offset + 2);
|
||||
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
|
||||
offset = end + 2;
|
||||
}
|
||||
chunks.push(source.slice(start, offset));
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const start = offset++;
|
||||
let closed = false;
|
||||
while (offset < source.length) {
|
||||
const current = source[offset++]!;
|
||||
if (current === '\\') offset++;
|
||||
else if (current === char) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!closed) throw new Error('QQ空间动态字面量字符串未结束');
|
||||
const literal = source.slice(start, offset);
|
||||
checkStatusKey(literal, true, offset);
|
||||
chunks.push(literal);
|
||||
markValue();
|
||||
continue;
|
||||
}
|
||||
if (char === '[' || char === '{') {
|
||||
markValue();
|
||||
nesting.push({ array: char === '[', awaiting: true });
|
||||
if (nesting.length > MAX_DEPTH) throw new Error('QQ空间动态字面量嵌套过深');
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ']' || char === '}') {
|
||||
const current = nesting.pop();
|
||||
if (!current || current.array !== (char === ']')) throw new Error('QQ空间动态字面量括号不匹配');
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
const current = nesting[nesting.length - 1];
|
||||
if (current?.array) {
|
||||
if (current.awaiting) chunks.push('null');
|
||||
current.awaiting = true;
|
||||
}
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ':') {
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
const start = offset++;
|
||||
while (offset < source.length && !/[\s[\]{},:'"]/.test(source[offset]!) && !source.startsWith('//', offset) && !source.startsWith('/*', offset)) offset++;
|
||||
const token = source.slice(start, offset);
|
||||
checkStatusKey(token, false, offset);
|
||||
chunks.push(token === 'undefined' && source[nextSignificant(offset)] !== ':' ? JSON.stringify(undefinedMarker) : token);
|
||||
markValue();
|
||||
}
|
||||
if (nesting.length) throw new Error('QQ空间动态字面量括号未结束');
|
||||
return chunks.join('');
|
||||
}
|
||||
|
||||
function parseDataBody (text: string): Record<string, unknown> {
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_INPUT_BYTES) throw new Error('QQ空间动态响应过大或无效');
|
||||
let source = text.trim();
|
||||
if (!source.startsWith('{')) {
|
||||
const prefix = /^(?:_preloadCallback|_Callback|callback)\s*\(/.exec(source);
|
||||
const suffix = /\)\s*;?\s*$/.exec(source);
|
||||
if (!prefix || !suffix || suffix.index < prefix[0].length) throw new Error('QQ空间动态回调格式异常');
|
||||
source = source.slice(prefix[0].length, suffix.index).trim();
|
||||
}
|
||||
if (!source.startsWith('{')) throw new Error('QQ空间动态缺少对象响应');
|
||||
let value: unknown;
|
||||
try {
|
||||
// Content-addressed marker keeps optional object fields absent instead of changing undefined into null.
|
||||
const undefinedMarker = `\u0000qz:${createHash('sha256').update(source).digest('hex')}`;
|
||||
value = JSON5.parse(json5DataSource(source, undefinedMarker), (key, item: unknown) => {
|
||||
if (DANGEROUS_KEYS.has(key) || (typeof item === 'number' && !Number.isFinite(item))) throw new Error('Unsupported data');
|
||||
return item === undefinedMarker ? undefined : item;
|
||||
});
|
||||
} catch {
|
||||
throw new Error('QQ空间动态字面量格式异常');
|
||||
}
|
||||
if (!record(value)) throw new Error('QQ空间动态缺少对象响应');
|
||||
return value;
|
||||
}
|
||||
|
||||
interface HtmlElement {
|
||||
tag: string;
|
||||
attributes: Record<string, string>;
|
||||
parent?: HtmlElement;
|
||||
children: Array<HtmlElement | string>;
|
||||
}
|
||||
|
||||
function classes (element: HtmlElement): string[] {
|
||||
return (element.attributes['class'] ?? '').split(/\s+/);
|
||||
}
|
||||
|
||||
const EXCLUDED_CLASSES = new Set(['mod-comments', 'comments-list', 'f-item-passive', 'f-ct-video']);
|
||||
const EXCLUDED_TAGS = new Set(['script', 'style', 'noscript', 'template']);
|
||||
|
||||
function excluded (element: HtmlElement): boolean {
|
||||
return EXCLUDED_TAGS.has(element.tag) || classes(element).some(name => EXCLUDED_CLASSES.has(name));
|
||||
}
|
||||
|
||||
function descendants (root: HtmlElement, prune: boolean): HtmlElement[] {
|
||||
const result: HtmlElement[] = [];
|
||||
const pending: HtmlElement[] = [root];
|
||||
while (pending.length) {
|
||||
const current = pending.pop()!;
|
||||
if (current !== root && prune && excluded(current)) continue;
|
||||
result.push(current);
|
||||
for (let index = current.children.length - 1; index >= 0; index--) {
|
||||
const child = current.children[index];
|
||||
if (typeof child !== 'string' && child) pending.push(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function htmlTree (html: string): HtmlElement {
|
||||
if (Buffer.byteLength(html) > MAX_HTML_BYTES) throw new Error('QQ空间动态HTML过大');
|
||||
const root: HtmlElement = { tag: '#root', attributes: {}, children: [] };
|
||||
const stack = [root];
|
||||
let nodes = 0;
|
||||
const parser = new Parser({
|
||||
onopentag (tag, attributes) {
|
||||
if (++nodes > MAX_HTML_NODES || stack.length > MAX_DEPTH) throw new Error('QQ空间动态HTML结构过大');
|
||||
const parent = stack[stack.length - 1]!;
|
||||
const element: HtmlElement = { tag, attributes: { ...attributes }, parent, children: [] };
|
||||
parent.children.push(element);
|
||||
stack.push(element);
|
||||
},
|
||||
onclosetag (tag) {
|
||||
for (let index = stack.length - 1; index > 0; index--) {
|
||||
if (stack[index]?.tag === tag) {
|
||||
stack.length = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext (text) {
|
||||
if (++nodes > MAX_HTML_NODES) throw new Error('QQ空间动态HTML结构过大');
|
||||
stack[stack.length - 1]!.children.push(text);
|
||||
},
|
||||
}, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
|
||||
parser.end(html);
|
||||
return root;
|
||||
}
|
||||
|
||||
function contentText (element: HtmlElement): string {
|
||||
const pieces: string[] = [];
|
||||
const blocks = new Set(['p', 'div', 'li', 'section', 'article', 'blockquote', 'ul', 'ol']);
|
||||
const visit = (node: HtmlElement | string) => {
|
||||
if (typeof node === 'string') {
|
||||
pieces.push(node.replace(/\s+/g, ' '));
|
||||
return;
|
||||
}
|
||||
if (excluded(node) || node.attributes['data-cmd'] === 'qz_toggle' || classes(node).includes('load_img')) return;
|
||||
if (node.tag === 'br') pieces.push('\n');
|
||||
else if (node.tag === 'img') pieces.push(node.attributes['alt'] ?? '');
|
||||
else {
|
||||
if (blocks.has(node.tag)) pieces.push('\n');
|
||||
for (const child of node.children) visit(child);
|
||||
if (blocks.has(node.tag)) pieces.push('\n');
|
||||
}
|
||||
};
|
||||
visit(element);
|
||||
return pieces.join('').replace(/[^\S\n]*\n[^\S\n]*/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function imageUrl (value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
|
||||
return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectHtml (feed: SlQzoneFeed): void {
|
||||
const document = htmlTree(feed.html);
|
||||
const roots = descendants(document, true).filter(node => classes(node).includes('f-item') && node.attributes['data-key'] === feed.key);
|
||||
if (!feed.key || roots.length !== 1) return;
|
||||
const root = roots[0]!;
|
||||
if (excluded(root)) return;
|
||||
const elements = descendants(root, true);
|
||||
const metadata = elements.find(node => node.attributes['name'] === 'feed_data' && node.attributes['data-fkey'] === feed.key && node.attributes['data-uin'] === String(feed.uin));
|
||||
const tid = metadata?.attributes['data-tid'];
|
||||
if (feed.appid === 311 && tid?.trim() && tid.length <= 8192) feed.tid = tid;
|
||||
const textElements = elements.filter(node => classes(node).includes('f-info'));
|
||||
const full = textElements.find(node => classes(node).includes('qz_info_complete') && contentText(node).length > 0);
|
||||
const selected = full ?? textElements.find(node => !classes(node).includes('qz_info_complete'));
|
||||
if (selected) {
|
||||
feed.content = contentText(selected);
|
||||
feed.content_complete = full !== undefined || !descendants(selected, true).some(node => node.attributes['data-cmd'] === 'qz_toggle');
|
||||
}
|
||||
const pictures = new Set<string>();
|
||||
for (const element of elements) {
|
||||
if (!classes(element).includes('img-item') || element.attributes['data-v_itemid'] !== undefined || element.attributes['data-videotype'] !== undefined) continue;
|
||||
const fullUrl = imageUrl(element.attributes['data-originurl'] ?? element.attributes['data-url3']);
|
||||
if (fullUrl) pictures.add(fullUrl);
|
||||
else {
|
||||
for (const image of descendants(element, true)) {
|
||||
if (image.tag !== 'img') continue;
|
||||
const url = imageUrl(image.attributes['truesrc'] ?? image.attributes['src']);
|
||||
if (url) pictures.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
feed.images = [...pictures];
|
||||
if (feed.like_count === null) {
|
||||
let card = root;
|
||||
for (let parent = root.parent; parent; parent = parent.parent) {
|
||||
if (classes(parent).includes('f-single')) {
|
||||
card = parent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const button = descendants(card, true).find(node => classes(node).includes('qz_like_btn_v3'));
|
||||
feed.like_count = count(button?.attributes['data-likecnt']);
|
||||
}
|
||||
}
|
||||
|
||||
function moreFlag (value: unknown): boolean {
|
||||
if (value === true || value === 1 || value === '1') return true;
|
||||
if (value === false || value === 0 || value === '0') return false;
|
||||
throw new Error('QQ空间动态缺少明确的分页结束标志');
|
||||
}
|
||||
|
||||
export function parseSlQzoneFeeds (text: string): SlQzoneParsedFeeds {
|
||||
const response = parseDataBody(text);
|
||||
if (response['code'] !== 0 || (response['subcode'] !== undefined && response['subcode'] !== 0)) throw new Error('QQ空间好友动态请求失败');
|
||||
const data = response['data'];
|
||||
if (!record(data) || !Array.isArray(data['data']) || data['data'].length > 1000) throw new Error('QQ空间好友动态列表格式异常');
|
||||
const mainValue = data['main'];
|
||||
if (mainValue !== undefined && !record(mainValue)) throw new Error('QQ空间好友动态分页信息无效');
|
||||
const main = record(mainValue) ? mainValue : null;
|
||||
if (main && main['error'] !== undefined && main['error'] !== '') throw new Error('QQ空间好友动态服务端报告失败');
|
||||
const hasMore = moreFlag(main ? main['hasMoreFeeds'] : data['hasmore']);
|
||||
if (hasMore && !main) throw new Error('QQ空间好友动态缺少续页状态');
|
||||
const feeds: SlQzoneFeed[] = [];
|
||||
for (const value of data['data']) {
|
||||
if (value === null || value === undefined) continue;
|
||||
if (!record(value)) throw new Error('QQ空间好友动态条目格式异常');
|
||||
const key = scalar(value['key'] ?? value['feedskey'], '标识');
|
||||
if (key.length > 8192) throw new Error('QQ空间动态标识过长');
|
||||
const feed: SlQzoneFeed = {
|
||||
uin: wireNumber(value['uin'], '账号'),
|
||||
nickname: scalar(value['nickname'], '昵称'),
|
||||
time: wireNumber(value['abstime'], '时间'),
|
||||
appid: wireNumber(value['appid'], '应用'),
|
||||
key,
|
||||
html: scalar(value['html'], 'HTML'),
|
||||
tid: null,
|
||||
content: null,
|
||||
content_complete: null,
|
||||
images: null,
|
||||
like_count: count(value['likecnt']),
|
||||
comment_count: count(value['commentcnt']),
|
||||
};
|
||||
projectHtml(feed);
|
||||
feeds.push(feed);
|
||||
}
|
||||
return { feeds, has_more: hasMore, main };
|
||||
}
|
||||
|
||||
export function initialSlQzoneFeedState (): SlQzoneFeedState {
|
||||
return {
|
||||
begintime: '0',
|
||||
dayspac: '0',
|
||||
pagenum: '1',
|
||||
aisortEndTime: '0',
|
||||
aisortOffset: '0',
|
||||
aisortBeginTime: '0',
|
||||
aisortNextTime: '0',
|
||||
icServerTime: '0',
|
||||
externparam: '',
|
||||
daylist: '',
|
||||
uinlist: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceSlQzoneFeedState (previous: SlQzoneFeedState, main: Record<string, unknown>): SlQzoneFeedState {
|
||||
const next = { ...previous };
|
||||
for (const field of STATE_NUMBERS) {
|
||||
const value = main[field];
|
||||
if (value === undefined || value === '') continue;
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) ||
|
||||
(typeof value === 'number' && !Number.isSafeInteger(value)) || String(value).length > 32) {
|
||||
throw new Error('QQ空间动态分页数值无效');
|
||||
}
|
||||
const canonical = BigInt(value).toString();
|
||||
if (field === 'pagenum' && canonical === '0') throw new Error('QQ空间动态分页页码无效');
|
||||
next[field] = canonical;
|
||||
}
|
||||
for (const field of STATE_STRINGS) {
|
||||
const value = main[field];
|
||||
if (value === undefined || (value === '' && field !== 'uinlist')) continue;
|
||||
if (typeof value !== 'string' || value.length > 32768) throw new Error('QQ空间动态分页字符串无效');
|
||||
next[field] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function slQzoneFeedPosition (state: SlQzoneFeedState): string {
|
||||
const position: Record<string, string> = {};
|
||||
for (const field of Object.keys(state).sort()) {
|
||||
if (field !== 'icServerTime') position[field] = state[field as keyof SlQzoneFeedState];
|
||||
}
|
||||
return JSON.stringify(position);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
export interface SlQzoneEmotion {
|
||||
tid: string;
|
||||
content: string;
|
||||
time: number;
|
||||
comment_num: number;
|
||||
is_private: boolean;
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export interface SlQzoneMsgList {
|
||||
total: number;
|
||||
/** False means total is the legacy page-length fallback, not a measured account total. */
|
||||
total_known?: boolean;
|
||||
msglist: SlQzoneEmotion[];
|
||||
}
|
||||
|
||||
export interface SlQzoneFlatReply {
|
||||
uin: number;
|
||||
name: string;
|
||||
time: number;
|
||||
content: string;
|
||||
comment_id: string;
|
||||
parent_comment_id: string;
|
||||
parent_comment_uin: number;
|
||||
}
|
||||
|
||||
export interface SlQzoneFlatComment {
|
||||
uin: number;
|
||||
name: string;
|
||||
time: number;
|
||||
content: string;
|
||||
comment_id: string;
|
||||
replies: SlQzoneFlatReply[];
|
||||
}
|
||||
|
||||
export interface SlQzoneMsgDetail {
|
||||
tid: string;
|
||||
content: string;
|
||||
comment_num: number;
|
||||
comments: SlQzoneFlatComment[];
|
||||
}
|
||||
|
||||
export interface SlQzoneCommentResult {
|
||||
comment_id: string;
|
||||
parent_comment_id?: string;
|
||||
parent_comment_uin?: number;
|
||||
}
|
||||
|
||||
export interface SlQzoneCommentOptions {
|
||||
imageUrls?: string[];
|
||||
comment_id?: string;
|
||||
comment_uin?: string;
|
||||
reply_uin?: string;
|
||||
reply_name?: string;
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function requireObject (value: unknown): Record<string, unknown> {
|
||||
const result = object(value);
|
||||
if (!result) throw new Error('QQ空间兼容响应对象格式异常');
|
||||
return result;
|
||||
}
|
||||
|
||||
function responseCodes (response: Record<string, unknown>, requireAcknowledgement = false, requireCode = false): void {
|
||||
let acknowledged = false;
|
||||
for (const field of ['code', 'subcode', 'retcode', 'ret']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
||||
const value = response[field];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value !== 0) {
|
||||
const code = typeof value === 'number' && Number.isSafeInteger(value) ? ` (${field}=${value})` : '';
|
||||
throw new Error(`QQ空间兼容接口拒绝请求${code}`);
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if ((requireAcknowledgement && !acknowledged) || (requireCode && response['code'] !== 0)) {
|
||||
throw new Error('QQ空间兼容响应缺少明确成功状态');
|
||||
}
|
||||
}
|
||||
|
||||
function integer (value: unknown, minimum = 0): number {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) throw new Error('QQ空间兼容响应数值无效');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error('QQ空间兼容响应数值无效');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredText (value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('QQ空间兼容响应文本字段无效');
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasControlCharacter (value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 32 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function id (value: unknown, allowEmpty = false): string {
|
||||
if (value === undefined || value === null) {
|
||||
if (allowEmpty) return '';
|
||||
throw new Error('QQ空间兼容响应缺少标识');
|
||||
}
|
||||
if (typeof value === 'number') return String(integer(value));
|
||||
if (typeof value !== 'string' || value.length > 8192 || hasControlCharacter(value) || (!value.trim() && (!allowEmpty || value !== ''))) {
|
||||
throw new Error('QQ空间兼容响应标识无效');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function array (value: unknown): unknown[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) throw new Error('QQ空间兼容响应列表格式异常');
|
||||
return value;
|
||||
}
|
||||
|
||||
function pictureUrls (value: unknown): string[] {
|
||||
const result: string[] = [];
|
||||
for (const picture of array(value)) {
|
||||
const fields = requireObject(picture);
|
||||
for (const key of ['url3', 'url2', 'url1', 'smallurl']) {
|
||||
const url = fields[key];
|
||||
if (url === undefined || url === null || url === '') continue;
|
||||
if (typeof url !== 'string') throw new Error('QQ空间说说图片地址格式异常');
|
||||
result.push(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function privateFlag (value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^-?\d+$/.test(value))) throw new Error('QQ空间说说私密标记无效');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) throw new Error('QQ空间说说私密标记无效');
|
||||
return parsed !== 0;
|
||||
}
|
||||
|
||||
/** Legacy zero/empty sentinels apply only to absent fields, never malformed supplied values. */
|
||||
export function parseSlQzoneMsgList (response: Record<string, unknown>): SlQzoneMsgList {
|
||||
const fields = requireObject(response);
|
||||
responseCodes(fields);
|
||||
if (!Array.isArray(fields['msglist'])) throw new Error('QQ空间兼容说说列表响应不完整');
|
||||
const msglist: SlQzoneEmotion[] = [];
|
||||
for (const entry of fields['msglist']) {
|
||||
const row = requireObject(entry);
|
||||
msglist.push({
|
||||
tid: id(row['tid'], true),
|
||||
content: row['content'] === undefined || row['content'] === null ? '' : requiredText(row['content']),
|
||||
time: row['created_time'] === undefined || row['created_time'] === null ? 0 : integer(row['created_time']),
|
||||
comment_num: row['cmtnum'] === undefined || row['cmtnum'] === null ? 0 : integer(row['cmtnum']),
|
||||
is_private: privateFlag(row['secret']),
|
||||
images: pictureUrls(row['pic']),
|
||||
});
|
||||
}
|
||||
const totalKnown = fields['total'] !== undefined && fields['total'] !== null;
|
||||
return {
|
||||
total: totalKnown ? integer(fields['total']) : msglist.length,
|
||||
total_known: totalKnown,
|
||||
msglist,
|
||||
};
|
||||
}
|
||||
|
||||
function flatComment (value: unknown): Omit<SlQzoneFlatComment, 'replies'> {
|
||||
const fields = requireObject(value);
|
||||
return {
|
||||
comment_id: id(fields['tid']),
|
||||
uin: integer(fields['uin'], 1),
|
||||
name: requiredText(fields['name']),
|
||||
time: integer(fields['create_time']),
|
||||
content: requiredText(fields['content']),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlQzoneMsgDetail (response: Record<string, unknown>, tid: string): SlQzoneMsgDetail {
|
||||
assertTid(tid);
|
||||
const fields = requireObject(response);
|
||||
responseCodes(fields);
|
||||
if (typeof fields['tid'] !== 'string' || fields['tid'] !== tid) throw new Error('QQ空间兼容详情与请求的说说不匹配');
|
||||
const comments: SlQzoneFlatComment[] = [];
|
||||
for (const value of array(fields['commentlist'])) {
|
||||
const original = requireObject(value);
|
||||
const parent = flatComment(original);
|
||||
const replies: SlQzoneFlatReply[] = [];
|
||||
for (const reply of array(original['list_3'])) {
|
||||
replies.push({ ...flatComment(reply), parent_comment_id: parent.comment_id, parent_comment_uin: parent.uin });
|
||||
}
|
||||
comments.push({ ...parent, replies });
|
||||
}
|
||||
return { tid, content: requiredText(fields['content']), comment_num: integer(fields['cmtnum']), comments };
|
||||
}
|
||||
|
||||
function assertUin (value: unknown, safeNumeric = false): asserts value is string {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value) || (safeNumeric && !Number.isSafeInteger(Number(value)))) {
|
||||
throw new Error('QQ空间兼容账号标识无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertTid (value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > 8192 || hasControlCharacter(value)) throw new Error('QQ空间兼容说说标识无效');
|
||||
}
|
||||
|
||||
export function buildSlQzoneLikeBody (self: string, target: string, tid: string, enable: boolean, abstime: number): string {
|
||||
assertUin(self);
|
||||
assertUin(target);
|
||||
assertTid(tid);
|
||||
if (typeof enable !== 'boolean' || typeof abstime !== 'number' || !Number.isSafeInteger(abstime) || abstime < 0) throw new Error('QQ空间兼容点赞参数无效');
|
||||
const resource = `http://user.qzone.qq.com/${target}/mood/${tid}`;
|
||||
const form = new URLSearchParams();
|
||||
form.set('opuin', self);
|
||||
form.set('fid', tid);
|
||||
form.set('abstime', String(abstime));
|
||||
form.set('unikey', resource);
|
||||
form.set('curkey', resource);
|
||||
for (const [key, value] of Object.entries({ appid: '311', typeid: '0', from: '1', active: '0', fupdate: '1', format: 'json' })) form.set(key, value);
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
function commentOptions (options: SlQzoneCommentOptions | undefined): { options: SlQzoneCommentOptions; reply: boolean; } {
|
||||
if (options !== undefined && !object(options)) throw new Error('QQ空间兼容评论选项无效');
|
||||
const fields: SlQzoneCommentOptions = options ?? {};
|
||||
if (Object.keys(fields).some(key => !['imageUrls', 'comment_id', 'comment_uin', 'reply_uin', 'reply_name'].includes(key))) throw new Error('QQ空间兼容评论选项无效');
|
||||
const reply = fields.comment_id !== undefined || fields.comment_uin !== undefined;
|
||||
if (reply) {
|
||||
assertTid(fields.comment_id);
|
||||
assertUin(fields.comment_uin, true);
|
||||
}
|
||||
const addressed = fields.reply_uin !== undefined || fields.reply_name !== undefined;
|
||||
if (addressed) {
|
||||
if (!reply || typeof fields.reply_name !== 'string' || !fields.reply_name.trim() || fields.reply_name.length > 8192) throw new Error('指定回复目标需要完整父评论与昵称');
|
||||
assertUin(fields.reply_uin, true);
|
||||
}
|
||||
if (fields.imageUrls !== undefined) {
|
||||
if (!Array.isArray(fields.imageUrls)) throw new Error('QQ空间评论图片地址必须为数组');
|
||||
if (reply && fields.imageUrls.length > 0) throw new Error('QQ空间楼中楼回复不支持图片');
|
||||
for (const url of fields.imageUrls) {
|
||||
if (typeof url !== 'string' || !url || url.length > 8192 || url.trim() !== url) throw new Error('QQ空间评论图片地址无效');
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || /[\t\r\n]/.test(url)) throw new Error('Invalid URL');
|
||||
} catch {
|
||||
throw new Error('QQ空间评论图片地址无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { options: fields, reply };
|
||||
}
|
||||
|
||||
function addressedContent (content: string, options: SlQzoneCommentOptions): string {
|
||||
if (options.reply_uin === undefined) return content;
|
||||
const escapes: Record<string, string> = { '%': '%25', ',': '%2C', '}': '%7D' };
|
||||
const name = [...options.reply_name!.trim()].map(character => escapes[character] ?? character).join('');
|
||||
return `@{uin:${options.reply_uin},nick:${name},auto:1} ${content}`;
|
||||
}
|
||||
|
||||
export function buildSlQzoneCommentBody (self: string, target: string, tid: string, content: string, options?: SlQzoneCommentOptions): string {
|
||||
assertUin(self);
|
||||
assertUin(target);
|
||||
assertTid(tid);
|
||||
if (typeof content !== 'string' || content === '') throw new Error('QQ空间兼容评论正文不能为空');
|
||||
const branch = commentOptions(options);
|
||||
const form = new URLSearchParams({ uin: self, hostUin: target, inCharset: 'utf-8', outCharset: 'utf-8' });
|
||||
if (branch.reply) {
|
||||
if (!content.trim()) throw new Error('QQ空间兼容回复正文不能为空白');
|
||||
form.set('hostuin', self);
|
||||
form.set('topicId', `${target}_${tid}`);
|
||||
form.set('commentId', branch.options.comment_id!);
|
||||
form.set('commentUin', branch.options.comment_uin!);
|
||||
form.set('content', addressedContent(content, branch.options));
|
||||
form.set('ref', '');
|
||||
form.set('code_version', '1');
|
||||
form.set('format', 'fs');
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${target}`);
|
||||
} else {
|
||||
form.set('topicId', `${target}_${tid}__1`);
|
||||
form.set('content', content);
|
||||
form.set('richtype', branch.options.imageUrls?.length ? '1' : '');
|
||||
form.set('richval', branch.options.imageUrls?.join('\t') ?? '');
|
||||
for (const [key, value] of Object.entries({ format: 'json', ref: 'feeds', feedsType: '100', private: '0', paramstr: '1', isSignIn: '', plat: 'qzone', source: 'ic', platformid: '52' })) form.set(key, value);
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
|
||||
}
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
export function parseSlQzoneComment (response: Record<string, unknown>, options?: SlQzoneCommentOptions): SlQzoneCommentResult {
|
||||
const fields = requireObject(response);
|
||||
const branch = commentOptions(options);
|
||||
responseCodes(fields, true, branch.reply);
|
||||
if (branch.reply) {
|
||||
const result = object(fields['data']);
|
||||
if (!result) throw new Error('QQ空间回复响应缺少新标识,结果未知,请查询后确认,勿自动重试');
|
||||
let replyId: string;
|
||||
try {
|
||||
replyId = id(result['id']);
|
||||
} catch {
|
||||
throw new Error('QQ空间回复响应缺少有效新标识,结果未知,请查询后确认,勿自动重试');
|
||||
}
|
||||
return { comment_id: replyId, parent_comment_id: branch.options.comment_id!, parent_comment_uin: Number(branch.options.comment_uin!) };
|
||||
}
|
||||
// An empty ID is the explicit legacy sentinel after an acknowledged ordinary comment.
|
||||
return { comment_id: id(fields['commentid'] ?? fields['commentId'], true) };
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
export interface QzoneProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface QzoneProfileUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128;
|
||||
|
||||
const SUCCESS_FIELDS = ['code', 'subcode', 'retcode', 'ret'];
|
||||
const MAX_PROFILE_LENGTH = 8192;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
|
||||
export class QzoneWebError extends Error {
|
||||
constructor (public readonly field: string, public readonly code: number) {
|
||||
super(`QQ空间接口拒绝请求 (${field}=${code})`);
|
||||
}
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function assertUin (uin: unknown): asserts uin is string {
|
||||
if (typeof uin !== 'string' || !/^[1-9]\d{0,19}$/.test(uin)) throw new Error('QQ空间账号标识无效');
|
||||
}
|
||||
|
||||
function assertIdentity (value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > MAX_IDENTITY_LENGTH) {
|
||||
throw new Error('QQ空间说说标识无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuccess (response: Record<string, unknown>, requireCode: boolean): void {
|
||||
let acknowledged = false;
|
||||
for (const field of SUCCESS_FIELDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
||||
const code = response[field];
|
||||
if (typeof code !== 'number' || !Number.isSafeInteger(code) || code !== 0) {
|
||||
// CGI messages may contain account content or tickets. Only include a numeric code.
|
||||
if (typeof code === 'number' && Number.isSafeInteger(code)) throw new QzoneWebError(field, code);
|
||||
throw new Error('QQ空间接口拒绝请求');
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if (!acknowledged || (requireCode && response['code'] !== 0)) throw new Error('QQ空间响应缺少明确成功状态');
|
||||
}
|
||||
|
||||
/** Quote only observed bare name keys with string values, never string contents. */
|
||||
function normalizeNameStringKeys (text: string): string {
|
||||
let result = '';
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
const containers: Array<{ object: boolean; key: boolean; }> = [];
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index]!;
|
||||
if (!quoted) {
|
||||
const container = containers[containers.length - 1];
|
||||
if (container?.object && container.key && character === 'n' && /^name(?=\s*:\s*")/.test(text.slice(index))) {
|
||||
result += '"name"';
|
||||
index += 'name'.length - 1;
|
||||
container.key = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '{') containers.push({ object: true, key: true });
|
||||
else if (character === '[') containers.push({ object: false, key: false });
|
||||
else if (character === '}' || character === ']') containers.pop();
|
||||
else if (character === ',' && container?.object) container.key = true;
|
||||
else if (!/\s/.test(character) && container?.object) container.key = false;
|
||||
}
|
||||
result += character;
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === '\\') escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
} else if (character === '"') {
|
||||
quoted = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rejectDuplicateSuccessFields (text: string): void {
|
||||
const seen = new Set<string>();
|
||||
let depth = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index];
|
||||
if (character === '{' || character === '[') depth++;
|
||||
else if (character === '}' || character === ']') depth--;
|
||||
else if (character === '"') {
|
||||
const start = index;
|
||||
let escaped = false;
|
||||
for (index++; index < text.length; index++) {
|
||||
const current = text[index];
|
||||
if (escaped) escaped = false;
|
||||
else if (current === '\\') escaped = true;
|
||||
else if (current === '"') break;
|
||||
}
|
||||
let next = index + 1;
|
||||
while (/\s/.test(text[next] ?? '') && next < text.length) next++;
|
||||
if (depth !== 1 || text[next] !== ':') continue;
|
||||
const field: unknown = JSON.parse(text.slice(start, index + 1));
|
||||
if (typeof field !== 'string' || !SUCCESS_FIELDS.includes(field)) continue;
|
||||
if (seen.has(field)) throw new Error('QQ空间响应包含重复状态字段');
|
||||
seen.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FormSender's response is a data callback inside one fixed, passive HTML shell.
|
||||
// Consume its observed domain setup as text; never run the returned JavaScript.
|
||||
const FORM_SENDER_HTML = /^(?:<!doctype\s+html>\s*)?<html>\s*<head>\s*<meta\s+http-equiv="content-type"\s+content="text\/html;\s*charset=utf-8"\s*\/?>\s*<\/head>\s*<body>\s*<script\s+type="text\/javascript">\s*([\s\S]*?)\s*<\/script>\s*<\/body>\s*<\/html>$/i;
|
||||
const DOMAIN_SETUP = 'document\\s*\\.\\s*domain\\s*=\\s*"h5\\.qzone\\.qq\\.com"\\s*;\\s*';
|
||||
const CALLBACK_SETUP = 'cb\\s*=\\s*frameElement\\s*\\.\\s*callback\\s*;\\s*';
|
||||
const DOMAIN_TRY_SETUP = new RegExp(
|
||||
'^var\\s+cb\\s*;\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
||||
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
||||
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP + '\\}\\s*\\}\\s*'
|
||||
);
|
||||
const DOMAIN_ONLY_SETUP = new RegExp('^' + DOMAIN_SETUP);
|
||||
|
||||
function unwrapFormSenderHtml (text: string): string | undefined {
|
||||
const html = FORM_SENDER_HTML.exec(text);
|
||||
if (!html) return undefined;
|
||||
const script = html[1]!.trim();
|
||||
const setup = DOMAIN_TRY_SETUP.exec(script) ?? DOMAIN_ONLY_SETUP.exec(script);
|
||||
if (!setup) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
const callback = script.slice(setup[0].length).trim();
|
||||
if (!/^frameElement\.callback\s*\(/.test(callback)) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
return callback;
|
||||
}
|
||||
|
||||
/** Parse data only: no script evaluation and no first-brace/last-brace extraction. */
|
||||
export function parseQzoneWebResponse (text: string, requireCode = false): Record<string, unknown> {
|
||||
if (typeof text !== 'string' || text.length > 8 * 1024 * 1024) throw new Error('QQ空间响应格式异常');
|
||||
let payload = text.trim();
|
||||
const formSender = unwrapFormSenderHtml(payload);
|
||||
if (formSender !== undefined) payload = formSender;
|
||||
// Preserve the original single-script and bare callback envelopes as well.
|
||||
const script = /^<script>\s*([\s\S]*?)\s*<\/script>$/i.exec(payload);
|
||||
if (script) {
|
||||
payload = script[1]!.trim();
|
||||
if (!/^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(/.test(payload)) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
} else if (payload.endsWith('</script>') && payload.startsWith('frameElement.callback')) payload = payload.slice(0, -9).trim();
|
||||
if (!payload.startsWith('{')) {
|
||||
const callback = /^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(\s*([\s\S]*)\s*\)\s*;?\s*$/.exec(payload);
|
||||
if (!callback) throw new Error('QQ空间响应格式异常');
|
||||
payload = callback[1]!.trim();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
payload = normalizeNameStringKeys(payload);
|
||||
rejectDuplicateSuccessFields(payload);
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
throw new Error('QQ空间响应JSON格式异常');
|
||||
}
|
||||
const response = object(parsed);
|
||||
if (!response) throw new Error('QQ空间响应必须是对象');
|
||||
assertSuccess(response, requireCode);
|
||||
return response;
|
||||
}
|
||||
|
||||
export function parseQzoneProfile (response: Record<string, unknown>): QzoneProfile {
|
||||
if (!object(response)) throw new Error('QQ空间资料响应格式异常');
|
||||
assertSuccess(response, true);
|
||||
const data = object(response['data']);
|
||||
if (!data || typeof data['spacename'] !== 'string' || typeof data['desc'] !== 'string') {
|
||||
throw new Error('QQ空间资料响应不完整');
|
||||
}
|
||||
return { name: data['spacename'], description: data['desc'] };
|
||||
}
|
||||
|
||||
export function buildQzoneProfileBody (selfUin: string, profile: QzoneProfileUpdate): string {
|
||||
assertUin(selfUin);
|
||||
const fields = object(profile);
|
||||
if (!fields || Object.keys(fields).some(key => key !== 'name' && key !== 'description')) throw new Error('QQ空间资料参数无效');
|
||||
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
|
||||
let bitmap = 0;
|
||||
for (const [field, wireName, bit] of [['name', 'spacename', 2048], ['description', 'desc', 4096]] as const) {
|
||||
const value = fields[field];
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== 'string' || value.length > MAX_PROFILE_LENGTH) throw new Error('QQ空间名称或说明必须是8192字以内的文本');
|
||||
params.set(wireName, value.replaceAll('\r', ''));
|
||||
bitmap |= bit;
|
||||
}
|
||||
if (bitmap === 0) throw new Error('至少提供空间名称或说明');
|
||||
params.set('mb', String(bitmap));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function normalizeQzoneRight (ugcRight: number, targetUins?: string[]): { right: QzoneUgcRight; targetUins: string[]; } {
|
||||
const rights = [1, 4, 16, 64, 128];
|
||||
if (typeof ugcRight !== 'number' || !rights.includes(ugcRight)) throw new Error('ugc_right仅支持1、4、16、64、128');
|
||||
if (targetUins !== undefined && !Array.isArray(targetUins)) throw new Error('target_uins必须为QQ号数组');
|
||||
const targets: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const uin of targetUins ?? []) {
|
||||
assertUin(uin);
|
||||
if (!seen.has(uin)) {
|
||||
targets.push(uin);
|
||||
seen.add(uin);
|
||||
}
|
||||
}
|
||||
if ((ugcRight === 16 || ugcRight === 128) && targets.length === 0) throw new Error('ugc_right为16或128时target_uins不能为空');
|
||||
return { right: ugcRight as QzoneUgcRight, targetUins: targets };
|
||||
}
|
||||
|
||||
function wireText (value: unknown, fallback: string): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== 'string') throw new Error('QQ空间说说重建字段格式异常');
|
||||
return value;
|
||||
}
|
||||
|
||||
function wireInteger (value: unknown, fallback: string, minimum = 0, allowEmpty = false): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (allowEmpty && value === '') return '';
|
||||
if ((typeof value !== 'number' || !Number.isSafeInteger(value)) && (typeof value !== 'string' || !/^\d+$/.test(value))) {
|
||||
throw new Error('QQ空间说说重建数值无效');
|
||||
}
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error('QQ空间说说重建数值无效');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function firstPresent (first: unknown, second: unknown): unknown {
|
||||
return first === undefined ? second : first;
|
||||
}
|
||||
|
||||
function hasMediaValue (value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
const record = object(value);
|
||||
return record ? Object.keys(record).length > 0 : true;
|
||||
}
|
||||
|
||||
function hasObservedEmptyForwardContent (detail: Record<string, unknown>): boolean {
|
||||
if (detail['rt_tid'] !== '' || detail['rt_uin'] !== 0 || detail['rt_source'] !== 0) return false;
|
||||
const content = object(detail['rt_con']);
|
||||
if (!content) return false;
|
||||
const keys = Object.keys(content);
|
||||
return keys.length === 2 && keys.includes('conlist') && keys.includes('content') &&
|
||||
content['conlist'] === null && content['content'] === '';
|
||||
}
|
||||
|
||||
function assertSupportedMedia (detail: Record<string, unknown>): void {
|
||||
for (const field of [
|
||||
'video', 'videos', 'video_info', 'video_url', 'videoid', 'video_id', 'vid',
|
||||
'music', 'music_info', 'audio', 'voice', 'voice_info',
|
||||
'rt_tid', 'rt_uin', 'rt_con', 'rt_source', 'forward', 'forward_info', 'forward_tid',
|
||||
'origin', 'origin_tid', 'origin_feed', 'cell_origin',
|
||||
]) {
|
||||
// The legacy detail CGI supplies this exact empty object for ordinary text and image posts.
|
||||
if (field === 'rt_con' && hasObservedEmptyForwardContent(detail)) continue;
|
||||
if (hasMediaValue(detail[field])) throw new Error('该说说包含暂不支持完整重建的媒体或转发内容');
|
||||
}
|
||||
for (const field of ['appid', 'app_id']) {
|
||||
if (detail[field] !== undefined && detail[field] !== 311 && detail[field] !== '311') throw new Error('该动态不是可重建的说说');
|
||||
}
|
||||
if (wireText(detail['special_url'], '') !== '') throw new Error('暂不支持修改含特殊分享内容的说说权限');
|
||||
}
|
||||
|
||||
function pictureBo (picture: Record<string, unknown>): string | undefined {
|
||||
for (const key of ['smallurl', 'url1', 'url2', 'url3']) {
|
||||
const value = picture[key];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (typeof value !== 'string') throw new Error('QQ空间图片地址元数据无效');
|
||||
const matches = [...value.matchAll(/[?&]bo=([^&#]*)/g)];
|
||||
if (matches.length > 1) throw new Error('QQ空间图片包含歧义元数据');
|
||||
if (matches.length === 0) continue;
|
||||
try {
|
||||
const decoded = decodeURIComponent(matches[0]![1]!);
|
||||
if (!decoded || /[\t\r\n]/.test(decoded)) throw new Error('Invalid picture metadata');
|
||||
return decoded;
|
||||
} catch {
|
||||
throw new Error('QQ空间图片元数据无法完整重建');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rebuildContent (detail: Record<string, unknown>): string {
|
||||
const conlist = detail['conlist'];
|
||||
if (conlist !== undefined && conlist !== null) {
|
||||
if (!Array.isArray(conlist)) throw new Error('QQ空间说说正文分段格式异常');
|
||||
if (conlist.length > 0) {
|
||||
return conlist.map((value) => {
|
||||
const part = object(value);
|
||||
if (!part || typeof part['con'] !== 'string') throw new Error('QQ空间说说正文分段不完整');
|
||||
return part['con'];
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
if (typeof detail['content'] !== 'string') throw new Error('QQ空间说说缺少完整正文');
|
||||
return detail['content'];
|
||||
}
|
||||
|
||||
/** Permission updates resubmit a whole post, so unsupported or partial data must fail before POST. */
|
||||
export function buildQzoneRightBody (
|
||||
detail: Record<string, unknown>,
|
||||
selfUin: string,
|
||||
tid: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string[]
|
||||
): string {
|
||||
assertUin(selfUin);
|
||||
assertIdentity(tid);
|
||||
const source = object(detail);
|
||||
if (!source || source['tid'] !== tid) throw new Error('QQ空间详情与请求的说说标识不匹配');
|
||||
const owner = source['uin'];
|
||||
if ((typeof owner !== 'string' && !(typeof owner === 'number' && Number.isSafeInteger(owner))) || String(owner) !== selfUin) {
|
||||
throw new Error('只能修改当前账号自己的说说权限');
|
||||
}
|
||||
if (SUCCESS_FIELDS.some(field => Object.prototype.hasOwnProperty.call(source, field))) assertSuccess(source, false);
|
||||
const permission = normalizeQzoneRight(ugcRight, targetUins);
|
||||
assertSupportedMedia(source);
|
||||
const pics = source['pic'] ?? [];
|
||||
if (!Array.isArray(pics)) throw new Error('QQ空间说说图片列表格式异常');
|
||||
const richType = wireInteger(source['richtype'], pics.length ? '1' : '', 0, true);
|
||||
if (!['', '0', '1'].includes(richType)) throw new Error('该说说的媒体类型暂不支持完整重建');
|
||||
if ((pics.length > 0 && richType !== '1') || (pics.length === 0 && richType === '1')) throw new Error('QQ空间说说图片信息不完整');
|
||||
const richValues: string[] = [];
|
||||
const boValues: Array<string | undefined> = [];
|
||||
for (const value of pics) {
|
||||
const picture = object(value);
|
||||
const pictureId = picture?.['pic_id'];
|
||||
if (!picture || typeof pictureId !== 'string' || !pictureId || /[\t\r\n]/.test(pictureId)) throw new Error('QQ空间说说图片标识不完整');
|
||||
const parts = pictureId.split(',');
|
||||
const albumId = parts[1];
|
||||
const lloc = parts[2];
|
||||
if (!albumId?.trim() || !lloc?.trim()) throw new Error('QQ空间说说图片信息无法完整重建');
|
||||
// 22 is the observed legacy image default; existing image dimensions cannot be guessed.
|
||||
const pictureType = wireInteger(firstPresent(picture['pictype'], picture['type']), '22');
|
||||
const originalHeight = firstPresent(picture['height'], picture['b_height']);
|
||||
const originalWidth = firstPresent(picture['width'], picture['b_width']);
|
||||
if (originalHeight === undefined || originalWidth === undefined) throw new Error('QQ空间说说图片尺寸不完整');
|
||||
const height = wireInteger(originalHeight, '', 1);
|
||||
const width = wireInteger(originalWidth, '', 1);
|
||||
richValues.push(`,${albumId},${lloc},${lloc},${pictureType},${height},${width},,0,0`);
|
||||
boValues.push(pictureBo(picture));
|
||||
}
|
||||
if (boValues.some(value => value !== undefined) && boValues.some(value => value === undefined)) throw new Error('QQ空间图片元数据数量不完整');
|
||||
const boGroup = boValues.every(value => value !== undefined) ? boValues.join(',') : '';
|
||||
const originalRichValue = wireText(source['richval'], '');
|
||||
if (richValues.length === 0 && originalRichValue !== '') throw new Error('QQ空间说说包含无法完整重建的媒体数据');
|
||||
const subtype = wireInteger(firstPresent(source['t1_subtype'], source['subrichtype']), richValues.length ? '1' : '', 0, true);
|
||||
// Subtype 2 was observed on plain text, without either rich-type field or pictures.
|
||||
const observedPlainTextSubtype = subtype === '2' && hasObservedEmptyForwardContent(source) &&
|
||||
pics.length === 0 && source['richtype'] === undefined && source['subrichtype'] === undefined;
|
||||
if (!['', '0', '1'].includes(subtype) && !observedPlainTextSubtype) throw new Error('该说说的媒体子类型暂不支持完整重建');
|
||||
const sign = wireInteger(source['to_sign'], '0');
|
||||
if (sign !== '0' && sign !== '1') throw new Error('QQ空间说说同步标记无效');
|
||||
const rightId = wireText(source['ugcright_id'], tid);
|
||||
assertIdentity(rightId);
|
||||
const params = new URLSearchParams({
|
||||
syn_tweet_verson: '1',
|
||||
tid,
|
||||
paramstr: '1',
|
||||
pic_template: wireText(source['pic_template'], ''),
|
||||
richtype: richType,
|
||||
richval: richValues.join('\t'),
|
||||
special_url: '',
|
||||
subrichtype: subtype,
|
||||
pic_bo: boGroup ? `${boGroup}\t${boGroup}` : '',
|
||||
con: rebuildContent(source),
|
||||
feedversion: wireInteger(source['feedversion'], '1', 1),
|
||||
ver: wireInteger(source['ver'], '1', 1),
|
||||
ugc_right: String(permission.right),
|
||||
to_sign: sign,
|
||||
ugcright_id: rightId,
|
||||
hostuin: selfUin,
|
||||
code_version: wireInteger(source['code_version'], '1', 1),
|
||||
format: 'fs',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
|
||||
});
|
||||
if (permission.right === 16 || permission.right === 128) params.set('allow_uins', permission.targetUins.join('|'));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function buildQzoneBanBody (selfUin: string, targetUin: string, enable: boolean): string {
|
||||
assertUin(selfUin);
|
||||
assertUin(targetUin);
|
||||
if (typeof enable !== 'boolean') throw new Error('空间黑名单状态必须是布尔值');
|
||||
return new URLSearchParams({
|
||||
uin: selfUin,
|
||||
act_uin: targetUin,
|
||||
action: enable ? '1' : '2',
|
||||
fupdate: '1',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
|
||||
}).toString();
|
||||
}
|
||||
@@ -139,23 +139,36 @@ export interface QzonePublishResponse {
|
||||
code?: number;
|
||||
tid?: string;
|
||||
t1_tid?: string;
|
||||
t1_time?: string | number;
|
||||
t1_ntime?: string | number;
|
||||
now?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export function parseQzonePublishTime (value: unknown): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
const time = (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) ? Number(value) : NaN;
|
||||
if (!Number.isSafeInteger(time) || time < 0) throw new Error('QQ空间发表时间无效,结果需查询确认,勿自动重试');
|
||||
return time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析发表说说接口返回的 JSON, 提取 tid
|
||||
*/
|
||||
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; } {
|
||||
const subcode = json.subcode ?? json.code;
|
||||
if (subcode !== undefined && subcode !== 0) {
|
||||
throw new Error(json.message || json.msg || `QQ空间发说说失败, subcode=${subcode}`);
|
||||
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; time: number; } {
|
||||
for (const code of [json.subcode, json.code]) {
|
||||
if (code !== undefined && code !== 0) throw new Error('QQ空间发说说失败,服务端拒绝请求');
|
||||
}
|
||||
const tid = json.t1_tid || json.tid;
|
||||
if (!tid) {
|
||||
throw new Error('QQ空间发说说失败, 未返回tid');
|
||||
}
|
||||
return { tid };
|
||||
// t1_ntime is Unix seconds; current CGI t1_time is a display label such as "22:08".
|
||||
const displayTime = typeof json.t1_time === 'string' && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(json.t1_time);
|
||||
const time = json.t1_ntime ?? (displayTime ? json.now : json.t1_time ?? json.now);
|
||||
// SL uses zero when no machine-readable creation time is supplied, never the local clock.
|
||||
return { tid, time: parseQzonePublishTime(time) };
|
||||
}
|
||||
|
||||
export interface QzoneDeleteParams {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
NTQQFlashApi,
|
||||
NTQQOnlineApi,
|
||||
NTQQDatabaseApi,
|
||||
NTQQQzoneApi,
|
||||
NTQQQzonePublicApi,
|
||||
} from '@/napcat-core/apis';
|
||||
import { NTQQCollectionApi } from '@/napcat-core/apis/collection';
|
||||
@@ -172,6 +173,7 @@ export class NapCatCore {
|
||||
FlashApi: new NTQQFlashApi(this.context, this),
|
||||
OnlineApi: new NTQQOnlineApi(this.context, this),
|
||||
DatabaseApi: new NTQQDatabaseApi(this.context, this),
|
||||
QzoneApi: new NTQQQzoneApi(this.context, this),
|
||||
QzonePublicApi: new NTQQQzonePublicApi(this.context, this),
|
||||
};
|
||||
container.bind(NapCatCore).toConstantValue(this);
|
||||
@@ -384,5 +386,6 @@ export interface StableNTApiWrapper {
|
||||
FlashApi: NTQQFlashApi,
|
||||
OnlineApi: NTQQOnlineApi,
|
||||
DatabaseApi: NTQQDatabaseApi,
|
||||
QzoneApi: NTQQQzoneApi,
|
||||
QzonePublicApi: NTQQQzonePublicApi,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"compressing": "^1.10.1",
|
||||
"file-type": "^21.0.0",
|
||||
"htmlparser2": "10.0.0",
|
||||
"inversify": "^7.10.4",
|
||||
"json5": "^2.2.3",
|
||||
"napcat-common": "workspace:*",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples';
|
||||
import { checkQzoneFields, qzoneIdentity, qzonePayload, SlQzoneIdentitySchema } from './qzone/common-snowluma';
|
||||
|
||||
const PayloadSchema = Type.Object({
|
||||
tid: Type.String({ description: '说说 tid (来自 get_qzone_msg_list / send_qzone_msg)' }),
|
||||
tid: SlQzoneIdentitySchema,
|
||||
});
|
||||
|
||||
type PayloadType = Static<typeof PayloadSchema>;
|
||||
|
||||
export class DeleteQzoneMsg extends OneBotAction<PayloadType, void> {
|
||||
export class DeleteQzoneMsg extends OneBotAction<PayloadType, null> {
|
||||
override actionName = ActionName.DeleteQzoneMsg;
|
||||
override actionSummary = '删除QQ空间说说';
|
||||
override actionDescription = '删除QQ空间(Qzone)的一条说说, 按 tid 删除';
|
||||
@@ -20,7 +21,17 @@ export class DeleteQzoneMsg extends OneBotAction<PayloadType, void> {
|
||||
override payloadSchema = PayloadSchema;
|
||||
override returnSchema = Type.Null();
|
||||
|
||||
async _handle (payload: PayloadType): Promise<void> {
|
||||
await this.core.apis.WebApi.deleteQzoneMsg(payload.tid);
|
||||
protected override async check (payload: PayloadType): Promise<BaseCheckResult> {
|
||||
try {
|
||||
checkQzoneFields(qzonePayload(payload), ['tid'], false);
|
||||
qzoneIdentity(payload.tid, 'tid');
|
||||
} catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
async _handle (payload: PayloadType): Promise<null> {
|
||||
checkQzoneFields(qzonePayload(payload), ['tid'], false);
|
||||
await this.core.apis.WebApi.deleteQzoneMsg(qzoneIdentity(payload.tid, 'tid'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { uriToLocalFile } from 'napcat-common/src/file';
|
||||
import { OneBotAction } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { ActionName, BaseCheckResult } from '@/napcat-onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
import { parseQzonePublishTime } from '@/napcat-core/data/qzone';
|
||||
import { ExtendsActionsExamples } from '../example/ExtendsActionsExamples';
|
||||
import { checkQzoneFields, qzoneIdentity, qzoneImages, qzoneInteger, qzonePayload, qzoneText, qzoneUin, SlQzoneTextSchema } from './qzone/common-snowluma';
|
||||
|
||||
const ValidUgcRights = [1, 4, 16, 64, 128] as const;
|
||||
|
||||
const PayloadSchema = Type.Object({
|
||||
content: Type.String({ description: '说说正文' }),
|
||||
content: SlQzoneTextSchema,
|
||||
images: Type.Optional(Type.Array(Type.String(), { description: '图片数组, 支持 file:// http(s):// base64:// , 自动上传' })),
|
||||
ugc_right: Type.Optional(Type.Union([Type.Number(), Type.String()], { default: 1, description: '查看权限: 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见' })),
|
||||
target_uins: Type.Optional(Type.Array(Type.Union([Type.Number(), Type.String()]), { description: 'ugc_right为16/128时, 权限作用的QQ号数组' })),
|
||||
@@ -19,6 +21,7 @@ type PayloadType = Static<typeof PayloadSchema>;
|
||||
|
||||
const ReturnSchema = Type.Object({
|
||||
tid: Type.String({ description: '说说Tid' }),
|
||||
time: Type.Integer({ minimum: 0, description: '发表时间(Unix秒);上游未提供时为0,表示未知' }),
|
||||
}, { description: '发说说结果' });
|
||||
|
||||
type ReturnType = Static<typeof ReturnSchema>;
|
||||
@@ -26,28 +29,61 @@ type ReturnType = Static<typeof ReturnSchema>;
|
||||
export class SendQzoneMsg extends OneBotAction<PayloadType, ReturnType> {
|
||||
override actionName = ActionName.SendQzoneMsg;
|
||||
override actionSummary = '发表QQ空间说说';
|
||||
override actionDescription = '在QQ空间(Qzone)发表说说, 支持纯文字或带(多)图, 可设置查看权限';
|
||||
override actionDescription = '在QQ空间(Qzone)发表说说, 支持纯文字或带(多)图, 可设置查看权限。纯文字使用QQ内置空间接口, 图片保留原有上传和发表方式';
|
||||
override actionTags = ['扩展接口'];
|
||||
override payloadExample = ExtendsActionsExamples.SendQzoneMsg.payload;
|
||||
override returnExample = ExtendsActionsExamples.SendQzoneMsg.response;
|
||||
override returnExample = { ...ExtendsActionsExamples.SendQzoneMsg.response, time: 0 };
|
||||
|
||||
override payloadSchema = PayloadSchema;
|
||||
override returnSchema = ReturnSchema;
|
||||
|
||||
async _handle (payload: PayloadType): Promise<ReturnType> {
|
||||
const ugcRight = payload.ugc_right !== undefined ? +payload.ugc_right : 1;
|
||||
private normalize (payload: PayloadType) {
|
||||
checkQzoneFields(qzonePayload(payload), ['content', 'images', 'ugc_right', 'target_uins'], false);
|
||||
const images = qzoneImages(payload.images);
|
||||
// 原有带图发表允许空配文,兼容既有调用。
|
||||
const content = payload.content === '' && images?.length ? '' : qzoneText(payload.content, 'content');
|
||||
const ugcRight = qzoneInteger(payload.ugc_right, 'ugc_right', 1, 1);
|
||||
if (!ValidUgcRights.includes(ugcRight as typeof ValidUgcRights[number])) {
|
||||
throw new Error(`ugc_right 参数不合法, 允许的值为: ${ValidUgcRights.join(', ')}`);
|
||||
}
|
||||
const targetUins = payload.target_uins?.map((uin) => +uin).filter((uin) => !Number.isNaN(uin));
|
||||
if (payload.target_uins !== undefined && !Array.isArray(payload.target_uins)) throw new Error('target_uins 必须为有效QQ号数组');
|
||||
const targetUins = payload.target_uins?.map((uin) => {
|
||||
const target = qzoneUin(uin, 'target_uins');
|
||||
if (target === undefined) throw new Error('target_uins 必须为有效QQ号数组');
|
||||
return target;
|
||||
});
|
||||
if ((ugcRight === 16 || ugcRight === 128) && (!targetUins || targetUins.length === 0)) {
|
||||
throw new Error('ugc_right 为 16 或 128 时, target_uins 不能为空');
|
||||
}
|
||||
|
||||
return { content, images, ugcRight, targetUins };
|
||||
}
|
||||
|
||||
protected override async check (payload: PayloadType): Promise<BaseCheckResult> {
|
||||
try { this.normalize(payload); } catch (error) { return { valid: false, message: (error as Error).message }; }
|
||||
return await super.check(payload);
|
||||
}
|
||||
|
||||
private published (result: { tid: string; time?: number; }): ReturnType {
|
||||
return { tid: qzoneIdentity(result.tid, '返回的tid'), time: parseQzonePublishTime(result.time) };
|
||||
}
|
||||
|
||||
async _handle (payload: PayloadType): Promise<ReturnType> {
|
||||
const { content, images, ugcRight, targetUins } = this.normalize(payload);
|
||||
if (!images?.length) return this.published(await this.core.apis.QzoneApi.publishMsg(content, ugcRight, targetUins));
|
||||
|
||||
const originalAccount = { uin: this.core.selfInfo?.uin, uid: this.core.selfInfo?.uid };
|
||||
if (!originalAccount.uin || !originalAccount.uid) throw new Error('QQ空间图片说说需要已登录的当前账号');
|
||||
const assertAccount = () => {
|
||||
if (this.core.selfInfo?.uin !== originalAccount.uin || this.core.selfInfo?.uid !== originalAccount.uid) {
|
||||
throw new Error('QQ账号已切换,图片说说发表已停止,请重新确认后操作');
|
||||
}
|
||||
};
|
||||
const richvals: string[] = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
try {
|
||||
for (const image of payload.images ?? []) {
|
||||
for (const image of images) {
|
||||
assertAccount();
|
||||
const downloadResult = await uriToLocalFile(this.core.NapCatTempPath, image);
|
||||
if (!downloadResult.success) {
|
||||
throw new Error(`图片${image}处理失败, ${downloadResult.errMsg}`);
|
||||
@@ -55,13 +91,16 @@ export class SendQzoneMsg extends OneBotAction<PayloadType, ReturnType> {
|
||||
if (!downloadResult.isLocal) {
|
||||
cleanupPaths.push(downloadResult.path);
|
||||
}
|
||||
assertAccount();
|
||||
const base64 = readFileSync(downloadResult.path).toString('base64');
|
||||
const richval = await this.core.apis.WebApi.uploadImageToQzone(base64);
|
||||
assertAccount();
|
||||
richvals.push(richval);
|
||||
}
|
||||
|
||||
const result = await this.core.apis.WebApi.publishQzoneMsg(payload.content, richvals, ugcRight, targetUins);
|
||||
return result;
|
||||
assertAccount();
|
||||
const result = await this.core.apis.WebApi.publishQzoneMsg(content, richvals, ugcRight, targetUins);
|
||||
return this.published(result);
|
||||
} finally {
|
||||
for (const p of cleanupPaths) {
|
||||
if (existsSync(p)) {
|
||||
|
||||
@@ -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: '今天天气不错' }] },
|
||||
},
|
||||
};
|
||||
@@ -165,6 +165,18 @@ import { FetchPttText } from '@/napcat-onebot/action/msg/FetchPttText';
|
||||
import { GetGroupSignedList } from './extends/GetGroupSignedList';
|
||||
import { SendQzoneMsg } from './extends/SendQzoneMsg';
|
||||
import { DeleteQzoneMsg } from './extends/DeleteQzoneMsg';
|
||||
import { GetQzoneFeeds } from './extends/qzone/GetQzoneFeeds';
|
||||
import { GetQzoneMsgList } from './extends/qzone/GetQzoneMsgList';
|
||||
import { GetQzoneMsgDetail } from './extends/qzone/GetQzoneMsgDetail';
|
||||
import { GetQzoneCommentList } from './extends/qzone/GetQzoneCommentList';
|
||||
import { GetQzoneCommentReplies } from './extends/qzone/GetQzoneCommentReplies';
|
||||
import { LikeQzone } from './extends/qzone/LikeQzone';
|
||||
import { UnlikeQzone } from './extends/qzone/UnlikeQzone';
|
||||
import { CommentQzone } from './extends/qzone/CommentQzone';
|
||||
import { GetQzoneProfile } from './extends/qzone/GetQzoneProfile';
|
||||
import { SetQzoneProfile } from './extends/qzone/SetQzoneProfile';
|
||||
import { SetQzoneMsgRight } from './extends/qzone/SetQzoneMsgRight';
|
||||
import { SetQzoneBan } from './extends/qzone/SetQzoneBan';
|
||||
import {
|
||||
GetQzonePublicCapabilities,
|
||||
GetQzonePublicFeeds,
|
||||
@@ -245,6 +257,18 @@ export function getAllHandlers (obContext: NapCatOneBot11Adapter, core: NapCatCo
|
||||
new GetGroupSignedList(obContext, core),
|
||||
new SendQzoneMsg(obContext, core),
|
||||
new DeleteQzoneMsg(obContext, core),
|
||||
new GetQzoneFeeds(obContext, core),
|
||||
new GetQzoneMsgList(obContext, core),
|
||||
new GetQzoneMsgDetail(obContext, core),
|
||||
new GetQzoneCommentList(obContext, core),
|
||||
new GetQzoneCommentReplies(obContext, core),
|
||||
new LikeQzone(obContext, core),
|
||||
new UnlikeQzone(obContext, core),
|
||||
new CommentQzone(obContext, core),
|
||||
new GetQzoneProfile(obContext, core),
|
||||
new SetQzoneProfile(obContext, core),
|
||||
new SetQzoneMsgRight(obContext, core),
|
||||
new SetQzoneBan(obContext, core),
|
||||
new GetQzonePublicCapabilities(obContext, core),
|
||||
new GetQzonePublicFeeds(obContext, core),
|
||||
new GetQzonePublicFeedDetail(obContext, core),
|
||||
|
||||
@@ -222,6 +222,18 @@ export const ActionName = {
|
||||
GetGroupSignedList: 'get_group_signed_list',
|
||||
|
||||
// QQ 空间扩展
|
||||
GetQzoneFeeds: 'get_qzone_feeds',
|
||||
GetQzoneMsgList: 'get_qzone_msg_list',
|
||||
GetQzoneMsgDetail: 'get_qzone_msg_detail',
|
||||
GetQzoneCommentList: 'get_qzone_comment_list',
|
||||
GetQzoneCommentReplies: 'get_qzone_comment_replies',
|
||||
LikeQzone: 'like_qzone',
|
||||
UnlikeQzone: 'unlike_qzone',
|
||||
CommentQzone: 'comment_qzone',
|
||||
GetQzoneProfile: 'get_qzone_profile',
|
||||
SetQzoneProfile: 'set_qzone_profile',
|
||||
SetQzoneMsgRight: 'set_qzone_msg_right',
|
||||
SetQzoneBan: 'set_qzone_ban',
|
||||
SendQzoneMsg: 'send_qzone_msg',
|
||||
DeleteQzoneMsg: 'delete_qzone_msg',
|
||||
GetQzonePublicCapabilities: 'get_qzone_public_capabilities',
|
||||
|
||||
@@ -86,8 +86,19 @@ describe('Qzone data helpers', () => {
|
||||
expect(result.tid).toBe('t1tid456');
|
||||
});
|
||||
|
||||
test('keeps server publish time and the SL unknown-time sentinel', () => {
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '1700000000', now: 1700000001 })).toEqual({ tid: 'post', time: 1700000000 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', now: 1700000001 })).toEqual({ tid: 'post', time: 1700000001 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post' })).toEqual({ tid: 'post', time: 0 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '22:08', t1_ntime: 1700000002 })).toEqual({ tid: 'post', time: 1700000002 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '22:08', now: 1700000003 })).toEqual({ tid: 'post', time: 1700000003 });
|
||||
expect(parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: '22:08' })).toEqual({ tid: 'post', time: 0 });
|
||||
expect(() => parseQzonePublishResponse({ code: 0, tid: 'post', t1_time: 'bad' })).toThrow('勿自动重试');
|
||||
expect(() => parseQzonePublishResponse({ code: -1, subcode: 0, tid: 'post' })).toThrow('服务端拒绝');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse throws when subcode indicates failure', () => {
|
||||
expect(() => parseQzonePublishResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足');
|
||||
expect(() => parseQzonePublishResponse({ subcode: -200, message: 'private-response' })).toThrow('服务端拒绝请求');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse throws when tid missing', () => {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { GetQzoneFeeds, GetQzoneFeedsPayloadSchema } from '@/napcat-onebot/action/extends/qzone/GetQzoneFeeds';
|
||||
import { GetQzoneMsgList, GetQzoneMsgListPayloadSchema } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgList';
|
||||
import { GetQzoneMsgDetail, GetQzoneMsgDetailPayloadSchema } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail';
|
||||
import { LikeQzone } from '@/napcat-onebot/action/extends/qzone/LikeQzone';
|
||||
import { UnlikeQzone } from '@/napcat-onebot/action/extends/qzone/UnlikeQzone';
|
||||
import { CommentQzone } from '@/napcat-onebot/action/extends/qzone/CommentQzone';
|
||||
import { QzoneDetailReturnSchema, QzoneFeedItemExample, QzoneFeedReferenceExample, QzoneParentCommentSchema } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
|
||||
function createCore () {
|
||||
const QzoneApi = {
|
||||
getFriendFeeds: vi.fn().mockResolvedValue({ feeds: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgList: vi.fn().mockResolvedValue({ msglist: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgDetail: vi.fn().mockResolvedValue({ ...QzoneFeedItemExample, comments: [] }),
|
||||
setLike: vi.fn().mockResolvedValue(undefined),
|
||||
comment: vi.fn().mockResolvedValue({ comment_id: 'new-comment', comment: { id: 'new-comment' } }),
|
||||
};
|
||||
return { apis: { QzoneApi }, context: { logger: { logError: vi.fn() } } };
|
||||
}
|
||||
|
||||
describe('QQ空间 OneBot actions', () => {
|
||||
it('passes the opaque cursor through and keeps raw feed fields in the result', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneFeeds(undefined as never, core as never);
|
||||
|
||||
const first = await action._handle({ format: 'native' });
|
||||
await action._handle({ cursor: 'next-page' });
|
||||
|
||||
expect(core.apis.QzoneApi.getFriendFeeds.mock.calls).toEqual([[undefined], ['next-page']]);
|
||||
expect(first).toHaveProperty('feeds.0.feed_ref', QzoneFeedReferenceExample);
|
||||
expect(first).toHaveProperty('feeds.0.feed', QzoneFeedItemExample.feed);
|
||||
expect(first).not.toHaveProperty('total');
|
||||
});
|
||||
|
||||
it('uses the current account by default and preserves numeric or string target account IDs', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneMsgList(undefined as never, core as never);
|
||||
|
||||
await action._handle({ format: 'native' });
|
||||
await action._handle({ format: 'native', target_uin: 123456, cursor: 'next-page' });
|
||||
await action._handle({ format: 'native', target_uin: '9007199254740993' });
|
||||
|
||||
expect(core.apis.QzoneApi.getMsgList.mock.calls).toEqual([
|
||||
[undefined, undefined],
|
||||
['123456', 'next-page'],
|
||||
['9007199254740993', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards the complete reference to detail and both like operations', async () => {
|
||||
const core = createCore();
|
||||
const reference = {
|
||||
...QzoneFeedReferenceExample,
|
||||
cell_feed_transfer: { source_token: 'opaque-transfer', unknown_field: 7 },
|
||||
};
|
||||
|
||||
await new GetQzoneMsgDetail(undefined as never, core as never)._handle({ feed_ref: reference });
|
||||
await new LikeQzone(undefined as never, core as never)._handle({ feed_ref: reference });
|
||||
await new UnlikeQzone(undefined as never, core as never)._handle({ feed_ref: reference });
|
||||
|
||||
expect(core.apis.QzoneApi.getMsgDetail).toHaveBeenCalledExactlyOnceWith(reference);
|
||||
expect(core.apis.QzoneApi.setLike.mock.calls).toEqual([[reference, true], [reference, false]]);
|
||||
});
|
||||
|
||||
it('preserves opaque reference fields and empty auxiliary union keys through schema parsing', async () => {
|
||||
const core = createCore();
|
||||
const reference = {
|
||||
union_id: {
|
||||
uf_key: 'feed-key',
|
||||
ugc_id: '',
|
||||
feed_id: '',
|
||||
serialized_data: '',
|
||||
ext_ids: [{ ext_id_type: 5, str: 'tid', unknown_numeric: '9007199254740993' }],
|
||||
unknown_field: { keep: 'unchanged' },
|
||||
},
|
||||
cell_feed_transfer: { unknown_fields: [0, false, 'opaque-transfer'] },
|
||||
};
|
||||
const action = new GetQzoneMsgDetail(undefined as never, core as never);
|
||||
const expected = structuredClone(reference);
|
||||
|
||||
const result = await action.handle({ feed_ref: reference }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.getMsgDetail).toHaveBeenCalledExactlyOnceWith(expected);
|
||||
expect(reference).toEqual(expected);
|
||||
expect(reference.union_id.ext_ids[0]?.unknown_numeric).toBe('9007199254740993');
|
||||
});
|
||||
|
||||
it('preserves a complete parent comment and empty auxiliary UID through the real handle parser', async () => {
|
||||
const core = createCore();
|
||||
const parent = {
|
||||
id: '0',
|
||||
user: { uin: '10002', uid: '', nick: '评论作者', opaque_user: { keep: true } },
|
||||
content: [{ type: 0, content: '原评论', unknown_segment: 'keep' }],
|
||||
replys: [{ id: 'reply-1', content: [{ type: 0, content: '楼中楼' }] }],
|
||||
like: { id: 'comment-like-key', count: 2, status: 0 },
|
||||
unknown_field: { serialized: '9007199254740993' },
|
||||
};
|
||||
const expected = structuredClone(parent);
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({
|
||||
feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parent,
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(
|
||||
QzoneFeedReferenceExample, '回复', expected, undefined
|
||||
);
|
||||
expect(parent).toEqual(expected);
|
||||
});
|
||||
|
||||
it('adds optional detail pagination without changing raw comment shape or write parent requirements', () => {
|
||||
const detail = TypeCompiler.Compile(QzoneDetailReturnSchema);
|
||||
const parent = TypeCompiler.Compile(QzoneParentCommentSchema);
|
||||
expect(detail.Check({ ...QzoneFeedItemExample, comments: [] })).toBe(true);
|
||||
expect(detail.Check({
|
||||
...QzoneFeedItemExample,
|
||||
comments: [{ id: 0, user: { uid: 'u_commenter', portrait: { source: 'opaque' } }, replys: [{ id: 'reply' }], unknown_field: true }],
|
||||
comment_has_more: true,
|
||||
comment_next_cursor: 'opaque-cursor',
|
||||
})).toBe(true);
|
||||
expect(parent.Check({ id: 0, user: { uid: 'u_commenter' } })).toBe(false);
|
||||
expect(parent.Check({ id: '0', user: { uin: '10002', uid: '' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes root comments and replies without synthesizing parent identities', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
const parent = { id: '0', user: { uin: '10002', uid: 'u_parent' }, opaque_field: 'keep' };
|
||||
const target = { uin: '10003', uid: 'u_reply', nick: '回复目标' };
|
||||
|
||||
const result = await action._handle({ feed_ref: QzoneFeedReferenceExample, content: '正文' });
|
||||
await action._handle({ feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parent });
|
||||
await action._handle({ feed_ref: QzoneFeedReferenceExample, content: '楼中楼', comment: parent, reply_user: target });
|
||||
|
||||
expect(core.apis.QzoneApi.comment.mock.calls).toEqual([
|
||||
[QzoneFeedReferenceExample, '正文', undefined, undefined],
|
||||
[QzoneFeedReferenceExample, '回复', parent, undefined],
|
||||
[QzoneFeedReferenceExample, '楼中楼', parent, target],
|
||||
]);
|
||||
expect(result.comment_id).toBe('new-comment');
|
||||
});
|
||||
|
||||
it('rejects empty comments and reply targets without a parent before invoking the API', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
|
||||
const blank = await action.handle({ feed_ref: QzoneFeedReferenceExample, content: ' \n ' }, 'test', {} as never);
|
||||
const orphan = await action.handle({
|
||||
feed_ref: QzoneFeedReferenceExample,
|
||||
content: '正文',
|
||||
reply_user: { uin: '10003' },
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(blank.status).toBe('failed');
|
||||
expect(orphan.status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts both schema families while rejecting invalid references and scalar shapes', () => {
|
||||
const feeds = TypeCompiler.Compile(GetQzoneFeedsPayloadSchema);
|
||||
const list = TypeCompiler.Compile(GetQzoneMsgListPayloadSchema);
|
||||
const detail = TypeCompiler.Compile(GetQzoneMsgDetailPayloadSchema);
|
||||
|
||||
expect(feeds.Check({ cursor: 'a'.repeat(65537) })).toBe(false);
|
||||
expect(feeds.Check({ page_num: 1, count: 10 })).toBe(true);
|
||||
expect(list.Check({ pos: 0, num: 20 })).toBe(true);
|
||||
expect(list.Check({ target_uin: Number.MAX_SAFE_INTEGER + 1 })).toBe(false);
|
||||
expect(list.Check({ target_uin: 'not-a-qq-number' })).toBe(false);
|
||||
expect(detail.Check({ tid: 'shuoshuo-tid' })).toBe(true);
|
||||
expect(detail.Check({ feed_ref: { union_id: [] } })).toBe(false);
|
||||
expect(detail.Check({ feed_ref: { union_id: {} } })).toBe(false);
|
||||
expect(detail.Check({ feed_ref: { ...QzoneFeedReferenceExample, feed_action_domain_type: 0 } })).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces API errors as failed actions and only calls the write API once', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.setLike.mockRejectedValue(new Error('QQ空间返回的点赞结果不完整'));
|
||||
const action = new LikeQzone(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('点赞结果不完整');
|
||||
expect(core.apis.QzoneApi.setLike).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns explicit null data for both successful like actions', async () => {
|
||||
const core = createCore();
|
||||
for (const Action of [LikeQzone, UnlikeQzone]) {
|
||||
const result = await new Action(undefined as never, core as never).handle(
|
||||
{ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never
|
||||
);
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', retcode: 0, data: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('declares the SnowLuma action names with schema-valid payload and result examples', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneFeeds(undefined as never, core as never),
|
||||
new GetQzoneMsgList(undefined as never, core as never),
|
||||
new GetQzoneMsgDetail(undefined as never, core as never),
|
||||
new LikeQzone(undefined as never, core as never),
|
||||
new UnlikeQzone(undefined as never, core as never),
|
||||
new CommentQzone(undefined as never, core as never),
|
||||
];
|
||||
|
||||
expect(actions.map(action => action.actionName)).toEqual([
|
||||
'get_qzone_feeds', 'get_qzone_msg_list', 'get_qzone_msg_detail', 'like_qzone', 'unlike_qzone', 'comment_qzone',
|
||||
]);
|
||||
for (const action of actions) {
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { GetQzoneCommentList } from '@/napcat-onebot/action/extends/qzone/GetQzoneCommentList';
|
||||
import { GetQzoneCommentReplies } from '@/napcat-onebot/action/extends/qzone/GetQzoneCommentReplies';
|
||||
import { QzoneFeedReferenceExample } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
import { QzoneCommentPageExample, QzoneReplyPageExample } from '@/napcat-onebot/action/extends/qzone/common-comment';
|
||||
|
||||
function createCore () {
|
||||
return {
|
||||
apis: {
|
||||
QzoneApi: {
|
||||
getComments: vi.fn().mockResolvedValue(QzoneCommentPageExample),
|
||||
getCommentReplies: vi.fn().mockResolvedValue(QzoneReplyPageExample),
|
||||
},
|
||||
},
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('QQ空间评论读取 actions', () => {
|
||||
it('passes the complete reference and optional opaque cursor to comment reads', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentList(undefined as never, core as never);
|
||||
const reference = {
|
||||
...QzoneFeedReferenceExample,
|
||||
union_id: { ...QzoneFeedReferenceExample.union_id, unknown_field: { large_id: '9007199254740993' } },
|
||||
cell_feed_transfer: { opaque: [0, false, 'keep'] },
|
||||
};
|
||||
const expected = structuredClone(reference);
|
||||
|
||||
const first = await action.handle({ feed_ref: reference }, 'test', {} as never);
|
||||
await action.handle({ feed_ref: reference, cursor: 'next-comment-cursor' }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.getComments.mock.calls).toEqual([[expected, undefined], [expected, 'next-comment-cursor']]);
|
||||
expect(first.data).toBe(QzoneCommentPageExample);
|
||||
expect(reference).toEqual(expected);
|
||||
});
|
||||
|
||||
it('retains raw parent identity types, UID-only authors and every unknown field through handle parsing', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentReplies(undefined as never, core as never);
|
||||
const parent = {
|
||||
id: 0,
|
||||
user: { uid: 'u_commenter', uin: '', nick: '', portrait: { source: 'opaque-portrait' }, unknown_user: 'keep' },
|
||||
content: [{ type: 0, content: '原评论', unknown_segment: true }],
|
||||
replys: [{ id: 'reply-1', content: [{ type: 0, content: '内联回复' }] }],
|
||||
reply_attach_info: 'server-reply-pointer',
|
||||
like: { id: 'like-key', count: 0, status: 0 },
|
||||
unknown_field: { large_id: '9007199254740993' },
|
||||
};
|
||||
const expected = structuredClone(parent);
|
||||
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: parent }, 'test', {} as never);
|
||||
await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: { id: '0' }, cursor: 'next-reply-cursor' }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.getCommentReplies.mock.calls).toEqual([
|
||||
[QzoneFeedReferenceExample, expected, undefined],
|
||||
[QzoneFeedReferenceExample, { id: '0' }, 'next-reply-cursor'],
|
||||
]);
|
||||
expect(typeof core.apis.QzoneApi.getCommentReplies.mock.calls[0]?.[1]?.id).toBe('number');
|
||||
expect(typeof core.apis.QzoneApi.getCommentReplies.mock.calls[1]?.[1]?.id).toBe('string');
|
||||
expect(parent).toEqual(expected);
|
||||
});
|
||||
|
||||
it('allows an omitted author or empty auxiliary user object when the parent ID is exact', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentReplies(undefined as never, core as never);
|
||||
for (const parent of [{ id: 'parent' }, { id: 'parent', user: {} }, { id: 0, user: { uin: '', uid: '' } }]) {
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: parent }, 'test', {} as never);
|
||||
expect(result.status).toBe('ok');
|
||||
}
|
||||
expect(core.apis.QzoneApi.getCommentReplies).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects malformed parent IDs before TypeBox can coerce them or invoke the core', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneCommentReplies(undefined as never, core as never);
|
||||
for (const id of [undefined, null, false, true, '', ' ', -1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, {}, []]) {
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, comment: { id } } as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
}
|
||||
expect(core.apis.QzoneApi.getCommentReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed read payloads and unsupported page controls before invoking the core', async () => {
|
||||
const cases = [
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, page_size: 100 }],
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, cursor: false }],
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, cursor: '' }],
|
||||
[GetQzoneCommentList, { feed_ref: QzoneFeedReferenceExample, cursor: 'x'.repeat(16385) }],
|
||||
[GetQzoneCommentList, { feed_ref: { union_id: {} } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: [] }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent', user: [] } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent', user: null } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent', content: '中'.repeat(22 * 1024) } }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent' }, page_size: 20 }],
|
||||
[GetQzoneCommentReplies, { feed_ref: QzoneFeedReferenceExample, comment: { id: 'parent' }, target_uin: '10002' }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.getComments).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.getCommentReplies).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns partial and unknown metadata exactly instead of claiming complete comment history', async () => {
|
||||
const core = createCore();
|
||||
const page = {
|
||||
...QzoneCommentPageExample,
|
||||
comment_num: null,
|
||||
comments: [{ ...QzoneCommentPageExample.comments[0]!, reply_num: null, has_more_replies: null, replies: [] }],
|
||||
has_more: true,
|
||||
next_cursor: 'next-page',
|
||||
raw: { unknown_server_field: true },
|
||||
};
|
||||
core.apis.QzoneApi.getComments.mockResolvedValueOnce(page);
|
||||
|
||||
const result = await new GetQzoneCommentList(undefined as never, core as never).handle(
|
||||
{ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never
|
||||
);
|
||||
|
||||
expect(result.data).toBe(page);
|
||||
expect(page.comments[0]?.has_more_replies).toBeNull();
|
||||
expect(page.raw).toEqual({ unknown_server_field: true });
|
||||
});
|
||||
|
||||
it('surfaces cursor failures without retrying the read', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.getComments.mockRejectedValueOnce(new Error('QQ空间评论游标无效或已过期'));
|
||||
|
||||
const result = await new GetQzoneCommentList(undefined as never, core as never).handle(
|
||||
{ feed_ref: QzoneFeedReferenceExample, cursor: 'expired-cursor' }, 'test', {} as never
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('游标无效或已过期');
|
||||
expect(core.apis.QzoneApi.getComments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('declares complete API metadata with schema-valid detailed examples', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneCommentList(undefined as never, core as never),
|
||||
new GetQzoneCommentReplies(undefined as never, core as never),
|
||||
];
|
||||
expect(actions.map(action => action.actionName)).toEqual(['get_qzone_comment_list', 'get_qzone_comment_replies']);
|
||||
for (const action of actions) {
|
||||
expect(action.actionSummary).not.toBe('');
|
||||
expect(action.actionDescription).not.toBe('');
|
||||
expect(action.actionTags).toContain('扩展接口');
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
import type { QzoneComment, QzoneFeed, QzoneFeedReference } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
function response (data: Record<string, unknown>, retcode = 0): Response {
|
||||
return new Response(JSON.stringify({ retcode, data }), { status: 200 });
|
||||
}
|
||||
|
||||
function reference (): QzoneFeedReference {
|
||||
return {
|
||||
union_id: { uf_key: 'old-key', ugc_id: 'old-ugc', feed_id: 'old-feed', serialized_data: 'old-opaque' },
|
||||
cell_feed_transfer: { nested: { value: 'old-transfer' } },
|
||||
feed_action_domain_type: 1,
|
||||
like_key: 'old-like',
|
||||
};
|
||||
}
|
||||
|
||||
function parent (): QzoneComment {
|
||||
return {
|
||||
id: 0,
|
||||
user: { uid: 'uid-only-author', nick: '父评论作者', portrait: 'https://example.com/avatar' },
|
||||
time: 1700000000,
|
||||
content: [{ type: 0, content: ' 原评论 ' }],
|
||||
like: { id: 'parent-like', count: 2, status: 1 },
|
||||
reply_num: 8,
|
||||
has_more_reply: true,
|
||||
reply_attach_info: 'inline-reply-end',
|
||||
replys: [{ id: 'inline-reply', content: [{ type: 0, content: '内联回复' }] }],
|
||||
opaque: { keep: 'parent-context' },
|
||||
};
|
||||
}
|
||||
|
||||
function detail (comments: QzoneComment[] = [parent()]): { feed: { feed: QzoneFeed }; attach_info: string; has_more: boolean; extra: string; } {
|
||||
return {
|
||||
feed: {
|
||||
feed: {
|
||||
cell_common: {
|
||||
union_id: { uf_key: 'fresh-key', ugc_id: 'fresh-ugc', feed_id: 'fresh-feed', serialized_data: 'fresh-opaque' },
|
||||
feed_type: 1,
|
||||
feed_sub_type: 1,
|
||||
domain_type: 2,
|
||||
cur_like_key: 'fresh-like',
|
||||
},
|
||||
cell_feed_transfer: { nested: { value: 'fresh-transfer' } },
|
||||
cell_comment: { num: 32, comments },
|
||||
cell_summary: { summary: [{ type: 0, content: '说说正文' }] },
|
||||
},
|
||||
},
|
||||
attach_info: 'comment-page-2',
|
||||
has_more: true,
|
||||
extra: 'retained-business-data',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Qzone comment and reply page transport', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('gets the first comment batch from detail and returns its updated full reference and raw response', async () => {
|
||||
const { api } = createApi();
|
||||
const business = detail();
|
||||
fetchMock.mockResolvedValueOnce(response(business));
|
||||
|
||||
const page = await api.getComments(reference());
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/GetFeedDetail?');
|
||||
expect(page).toMatchObject({
|
||||
comment_num: 32,
|
||||
has_more: true,
|
||||
next_cursor: expect.any(String),
|
||||
raw: business,
|
||||
feed_ref: {
|
||||
union_id: business.feed.feed.cell_common!.union_id,
|
||||
cell_feed_transfer: business.feed.feed.cell_feed_transfer,
|
||||
feed_action_domain_type: 2,
|
||||
like_key: 'fresh-like',
|
||||
},
|
||||
});
|
||||
expect(page.comments[0]).toMatchObject({
|
||||
comment_id: '0',
|
||||
user_id: null,
|
||||
uid: 'uid-only-author',
|
||||
nickname: '父评论作者',
|
||||
time: 1700000000,
|
||||
content: ' 原评论 ',
|
||||
like_count: 2,
|
||||
liked: true,
|
||||
reply_num: 8,
|
||||
has_more_replies: true,
|
||||
comment: parent(),
|
||||
});
|
||||
expect(page.comments[0]?.replies).toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches exactly one continuation page with the fresh context and never invents a page-size or total', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [{ id: 'next-comment', opaque: true }] }, has_more: false, extra: 'next' }));
|
||||
const first = await api.getComments(reference());
|
||||
const next = await api.getComments(first.feed_ref, first.next_cursor!);
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[1]!;
|
||||
expect(String(url)).toContain('/GetComment?');
|
||||
expect(options?.headers).toMatchObject({ 'x-oidb': '{"uint32_command":"0x9bfd","uint32_service_type":"1"}' });
|
||||
expect(JSON.parse(options?.body as string)).toEqual({
|
||||
union_id: first.feed_ref.union_id, attach_info: 'comment-page-2', cell_feed_transfer: first.feed_ref.cell_feed_transfer,
|
||||
});
|
||||
expect(next).toMatchObject({ comment_num: null, has_more: false, next_cursor: null, raw: { extra: 'next' } });
|
||||
expect(next.comments).toHaveLength(1);
|
||||
expect(next.comments[0]?.comment).toEqual({ id: 'next-comment', opaque: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('preserves old detail comments and exposes a usable continuation without another detail read', async () => {
|
||||
const { api } = createApi();
|
||||
const original = reference();
|
||||
original['opaque_entry'] = { preserve: 'internal-context' };
|
||||
const business = detail();
|
||||
delete business.feed.feed.cell_feed_transfer;
|
||||
fetchMock.mockResolvedValueOnce(response(business));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { num: 33, comments: [] }, has_more: false }));
|
||||
|
||||
const first = await api.getMsgDetail(original);
|
||||
expect(first.comments).toEqual([parent()]);
|
||||
expect(first.feed_ref).not.toHaveProperty('cell_feed_transfer');
|
||||
expect(first.feed_ref).not.toHaveProperty('opaque_entry');
|
||||
expect(first.comment_has_more).toBe(true);
|
||||
expect(first.comment_next_cursor).toEqual(expect.any(String));
|
||||
const next = await api.getComments(first.feed_ref!, first.comment_next_cursor!);
|
||||
expect(String(fetchMock.mock.calls[1]![0])).toContain('/GetComment?');
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toMatchObject({
|
||||
union_id: business.feed.feed.cell_common!.union_id, cell_feed_transfer: original.cell_feed_transfer,
|
||||
});
|
||||
expect(next.feed_ref).toMatchObject({ cell_feed_transfer: original.cell_feed_transfer, opaque_entry: { preserve: 'internal-context' } });
|
||||
expect(next.comment_num).toBe(33);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not issue a detail continuation when its old normalized feed_ref is null', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: { cell_comment: { comments: [parent()] } } }, has_more: true, attach_info: 'next' }));
|
||||
const result = await api.getMsgDetail(reference());
|
||||
expect(result.feed_ref).toBeNull();
|
||||
expect(result.comments).toEqual([parent()]);
|
||||
expect(result.comment_has_more).toBe(true);
|
||||
expect(result.comment_next_cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('retains missing detail union/transfer from the supplied context for explicit comment reads', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: { cell_comment: { num: 0, comments: [] } } }, has_more: false }));
|
||||
await expect(api.getComments(reference())).resolves.toMatchObject({
|
||||
feed_ref: reference(), comment_num: 0, comments: [], has_more: false, next_cursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns additional replies using numeric parent id zero and a complete UID-only parent', async () => {
|
||||
const { api } = createApi();
|
||||
const originalParent = parent();
|
||||
const reply = { id: 1, user: { uid: 'reply-uid', nick: '' }, target_user: { uid: 'uid-only-author' }, time: 1700000001, content: [{ type: 0, content: '追加回复' }], opaque: true };
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [reply], has_more: true, reply_attach_info: 'reply-page-2', opaque: 'kept' }));
|
||||
|
||||
const page = await api.getCommentReplies(reference(), originalParent);
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toContain('/GetReplies?');
|
||||
expect(options?.headers).toMatchObject({ 'x-oidb': '{"uint32_command":"0x9bfe","uint32_service_type":"1"}' });
|
||||
const request = JSON.parse(options?.body as string);
|
||||
expect(request).toEqual({
|
||||
union_id: reference().union_id,
|
||||
cur_comment: originalParent,
|
||||
comment_attach_info: '',
|
||||
reply_attach_info: 'inline-reply-end',
|
||||
page_size: 20,
|
||||
cell_feed_transfer: reference().cell_feed_transfer,
|
||||
});
|
||||
expect(request.cur_comment.id).toBe(0);
|
||||
expect(page).toMatchObject({ comment_id: '0', reply_num: 8, has_more: true, next_cursor: expect.any(String), raw: { opaque: 'kept' } });
|
||||
expect(page.replies).toHaveLength(1);
|
||||
expect(page.replies[0]).toMatchObject({ reply_id: '1', uid: 'reply-uid', target_uid: 'uid-only-author', content: '追加回复', reply });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports read parents without user and preserves string id zero without write-author validation', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: false }));
|
||||
const result = await api.getCommentReplies(reference(), { id: '0', opaque: { data: true } });
|
||||
expect(result).toMatchObject({ comment_id: '0', reply_num: null, replies: [], has_more: false, next_cursor: null });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({
|
||||
cur_comment: { id: '0', opaque: { data: true } }, reply_attach_info: '', comment_attach_info: '', page_size: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('snapshots the parent and transfer before awaiting auth and uses stored context on continuation', async () => {
|
||||
const { api } = createApi();
|
||||
const inputRef = reference();
|
||||
const savedRef = structuredClone(inputRef);
|
||||
const inputParent = parent();
|
||||
const savedParent = structuredClone(inputParent);
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [{ id: 'page-one' }], has_more: true, reply_attach_info: 'reply-page-2' }));
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [{ id: 'page-two' }], has_more: false, reply_num: 9 }));
|
||||
const pending = api.getCommentReplies(inputRef, inputParent);
|
||||
(inputRef.cell_feed_transfer as { nested: { value: string; }; }).nested.value = 'caller-transfer-mutation';
|
||||
inputParent.user!.nick = 'caller-user-mutation';
|
||||
inputParent.content![0]!.content = 'caller-content-mutation';
|
||||
inputParent.replys!.push({ id: 'caller-injected' });
|
||||
const first = await pending;
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({ cur_comment: savedParent, cell_feed_transfer: savedRef.cell_feed_transfer });
|
||||
// A different author identity is rejected, while modified display/body fields never replace the cached parent.
|
||||
await expect(api.getCommentReplies(savedRef, inputParent, first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
const callerParent = structuredClone(savedParent);
|
||||
callerParent.content = [{ type: 0, content: 'ignored new body' }];
|
||||
callerParent.replys = [{ id: 'ignored extra reply' }];
|
||||
const last = await api.getCommentReplies(savedRef, callerParent, first.next_cursor!);
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({
|
||||
union_id: savedRef.union_id,
|
||||
cur_comment: { ...savedParent, reply_attach_info: 'reply-page-2', has_more_reply: true },
|
||||
comment_attach_info: '',
|
||||
reply_attach_info: 'reply-page-2',
|
||||
page_size: 20,
|
||||
cell_feed_transfer: savedRef.cell_feed_transfer,
|
||||
});
|
||||
expect(last.replies).toHaveLength(1);
|
||||
expect(last.reply_num).toBe(9);
|
||||
expect(last.next_cursor).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('protects cached comment context from mutations through returned feed_ref and raw objects', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [] }, has_more: false }));
|
||||
const first = await api.getComments(reference());
|
||||
const savedRef = structuredClone(first.feed_ref);
|
||||
(first.feed_ref.cell_feed_transfer as { nested: { value: string; }; }).nested.value = 'response-mutation';
|
||||
((first.raw['feed'] as { feed: QzoneFeed; }).feed.cell_feed_transfer as { nested: { value: string; }; }).nested.value = 'raw-mutation';
|
||||
const next = await api.getComments(savedRef, first.next_cursor!);
|
||||
expect(next.feed_ref).toEqual(savedRef);
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string).cell_feed_transfer).toEqual(savedRef.cell_feed_transfer);
|
||||
});
|
||||
|
||||
it('binds cursors to query kind, full feed reference and exact parent id/user', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const comments = await api.getComments(reference());
|
||||
const ref = comments.feed_ref;
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: true, reply_attach_info: 'reply-next' }));
|
||||
const replies = await api.getCommentReplies(ref, parent());
|
||||
await expect(api.getCommentReplies(ref, parent(), comments.next_cursor!)).rejects.toThrow('游标无效');
|
||||
await expect(api.getComments(ref, replies.next_cursor!)).rejects.toThrow('游标无效');
|
||||
for (const modified of [
|
||||
{ ...ref, union_id: { ...ref.union_id, serialized_data: 'different-union-context' } },
|
||||
{ ...ref, cell_feed_transfer: { different: true } },
|
||||
{ ...ref, feed_action_domain_type: 3 },
|
||||
]) {
|
||||
await expect(api.getComments(modified, comments.next_cursor!)).rejects.toThrow('游标无效');
|
||||
}
|
||||
await expect(api.getCommentReplies(ref, { ...parent(), id: '0' }, replies.next_cursor!)).rejects.toThrow('游标无效');
|
||||
await expect(api.getCommentReplies(ref, { ...parent(), user: { uid: 'other-user' } }, replies.next_cursor!)).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('allows JSON property reordering but rejects account changes and expired cursors before network IO', async () => {
|
||||
const { api, core } = createApi();
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000000);
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [] }, has_more: false }));
|
||||
const first = await api.getComments(reference());
|
||||
const reordered = Object.fromEntries(Object.entries(first.feed_ref).reverse()) as QzoneFeedReference;
|
||||
await api.getComments(reordered, first.next_cursor!);
|
||||
core.selfInfo.uin = '20002';
|
||||
core.selfInfo.uid = 'another-uid';
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
core.selfInfo.uin = '10001';
|
||||
core.selfInfo.uid = 'self-uid';
|
||||
clock.mockReturnValue(1000000 + 10 * 60 * 1000);
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('已过期');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reuses the shared 128-entry cursor bound across existing feeds and new comment pages', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
fetchMock.mockImplementation(async () => response({ feeds: [{ feed: detail().feed.feed }], has_more: true, attach_info: 'feed-next' }));
|
||||
for (let i = 0; i < 128; i++) await api.getFriendFeeds();
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(129);
|
||||
});
|
||||
|
||||
it('rejects malformed parents and reference bodies before acquiring tickets', async () => {
|
||||
const { api, core } = createApi();
|
||||
const cycle: Record<string, unknown> = { id: 0 };
|
||||
cycle['loop'] = cycle;
|
||||
const badParents: unknown[] = [
|
||||
{}, { id: '' }, { id: true }, { id: null }, { id: -1 }, { id: 1.5 }, { id: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ id: 0, user: [] }, { id: 0, user: 'bad' }, { id: 0, opaque: 'x'.repeat(65536) },
|
||||
{ id: 0, number: Number.NaN }, { id: 0, method: () => {} }, cycle,
|
||||
];
|
||||
for (const invalid of badParents) await expect(api.getCommentReplies(reference(), invalid as QzoneComment)).rejects.toThrow();
|
||||
await expect(api.getComments({ union_id: {} })).rejects.toThrow();
|
||||
await expect(api.getCommentReplies(reference(), { id: 0, reply_attach_info: 12 })).rejects.toThrow('续页信息无效');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing or non-advancing server pointers rather than repeating the same page', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ ...detail(), attach_info: undefined }));
|
||||
await expect(api.getComments(reference())).rejects.toThrow('未推进');
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [] }, has_more: true, attach_info: 'comment-page-2' }));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('未推进');
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: true }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('未推进');
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [], has_more: true, reply_attach_info: 'inline-reply-end' }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('未推进');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('does not disguise malformed response objects as successful empty pages', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('cell_comment');
|
||||
fetchMock.mockResolvedValueOnce(response({ cell_comment: { comments: [null] }, has_more: false }));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('评论响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('回复响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(response({ replys: [null], has_more: false }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('回复响应格式异常');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('accepts omitted empty lists only when the server explicitly marks the final page', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail()));
|
||||
const first = await api.getComments(reference());
|
||||
for (const lastPage of [{ has_more: false }, { cell_comment: { num: 0 }, has_more: false }]) {
|
||||
fetchMock.mockResolvedValueOnce(response(lastPage));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).resolves.toMatchObject({
|
||||
comments: [], has_more: false, next_cursor: null, raw: lastPage,
|
||||
});
|
||||
}
|
||||
fetchMock.mockResolvedValueOnce(response({ has_more: false }));
|
||||
await expect(api.getCommentReplies(reference(), parent())).resolves.toMatchObject({
|
||||
replies: [], has_more: false, next_cursor: null, raw: { has_more: false },
|
||||
});
|
||||
for (const badPage of [{ cell_comment: {} }, { has_more: true, attach_info: 'advanced' }, { cell_comment: null, has_more: false }, { cell_comment: { comments: null }, has_more: false }]) {
|
||||
fetchMock.mockResolvedValueOnce(response(badPage));
|
||||
await expect(api.getComments(first.feed_ref, first.next_cursor!)).rejects.toThrow('评论响应');
|
||||
}
|
||||
for (const badPage of [{ has_more: true, reply_attach_info: 'advanced' }, { replys: null, has_more: false }]) {
|
||||
fetchMock.mockResolvedValueOnce(response(badPage));
|
||||
await expect(api.getCommentReplies(reference(), parent())).rejects.toThrow('回复响应');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('retains detail compatibility for omitted initial comment cells and never echoes private RPC errors', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: {} }, has_more: false }));
|
||||
const page = await api.getComments(reference());
|
||||
expect(page).toMatchObject({ comment_num: null, comments: [], has_more: false, next_cursor: null });
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ retcode: 4003, message: 'private credential', data: {} })));
|
||||
try {
|
||||
await api.getCommentReplies(reference(), parent());
|
||||
expect.fail('must reject business error');
|
||||
} catch (error) {
|
||||
expect(String(error)).toContain('4003');
|
||||
expect(String(error)).not.toContain('private');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { normalizeQzoneComment, normalizeQzoneReply } from '@/napcat-core/data/qzone-comments';
|
||||
import { normalizeQzoneFeed, normalizeQzoneMediaImages, normalizeQzoneRichContent, type QzoneComment } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
describe('Qzone comment and reply projections', () => {
|
||||
test('keeps unknown comment values null without inventing history or counts', () => {
|
||||
const comment = { future_field: { opaque: true } };
|
||||
const normalized = normalizeQzoneComment(comment);
|
||||
expect(normalized).toEqual({
|
||||
comment_id: null,
|
||||
user_id: null,
|
||||
uid: null,
|
||||
nickname: null,
|
||||
time: null,
|
||||
content: null,
|
||||
images: null,
|
||||
like_count: null,
|
||||
liked: null,
|
||||
reply_num: null,
|
||||
has_more_replies: null,
|
||||
replies: [],
|
||||
comment,
|
||||
});
|
||||
expect(normalized.comment).toBe(comment);
|
||||
});
|
||||
|
||||
test('normalizes the observed parent fields and preserves its complete raw object', () => {
|
||||
const reply = {
|
||||
id: 'reply-1',
|
||||
user: { uin: '10002', uid: 'u_reply', nick: '回复者' },
|
||||
target_user: { uin: '10001', uid: 'u_parent', nick: '作者' },
|
||||
time: '1700000001',
|
||||
content: [{ type: 0, content: '原始回复' }],
|
||||
like: { id: 'reply-like-key', count: '3', status: 0 },
|
||||
future_reply_field: 'keep',
|
||||
};
|
||||
const comment: QzoneComment = {
|
||||
id: '0',
|
||||
user: { uin: '10001', uid: 'u_parent', nick: '作者', portrait: 'https://example.com/avatar' },
|
||||
time: 1700000000,
|
||||
content: [{ type: 0, content: '第一行\n' }, { type: 1, name: 'Qiao', uin: '10003' }],
|
||||
media_items: [{ type: 0, image: { photo_url: [{ spec: 6, url: { url: 'https://example.com/original' } }] } }],
|
||||
like: { id: 'parent-like-key', count: 7, status: 1 },
|
||||
reply_num: 12,
|
||||
has_more_reply: true,
|
||||
reply_attach_info: 'opaque-token',
|
||||
replys: [reply],
|
||||
unknown_field: [0, false, 'keep'],
|
||||
};
|
||||
const normalized = normalizeQzoneComment(comment);
|
||||
expect(normalized).toMatchObject({
|
||||
comment_id: '0',
|
||||
user_id: '10001',
|
||||
uid: 'u_parent',
|
||||
nickname: '作者',
|
||||
time: 1700000000,
|
||||
content: '第一行\n@Qiao',
|
||||
images: ['https://example.com/original'],
|
||||
like_count: 7,
|
||||
liked: true,
|
||||
reply_num: 12,
|
||||
has_more_replies: true,
|
||||
});
|
||||
expect(normalized.replies).toHaveLength(1);
|
||||
expect(normalized.replies[0]).toMatchObject({
|
||||
reply_id: 'reply-1',
|
||||
user_id: '10002',
|
||||
uid: 'u_reply',
|
||||
nickname: '回复者',
|
||||
time: 1700000001,
|
||||
content: '原始回复',
|
||||
like_count: 3,
|
||||
liked: false,
|
||||
target_user_id: '10001',
|
||||
target_uid: 'u_parent',
|
||||
target_nickname: '作者',
|
||||
});
|
||||
expect(normalized.comment).toBe(comment);
|
||||
expect(normalized.replies[0]?.reply).toBe(reply);
|
||||
expect(comment['reply_attach_info']).toBe('opaque-token');
|
||||
expect(comment.user?.['portrait']).toBe('https://example.com/avatar');
|
||||
expect(comment['unknown_field']).toEqual([0, false, 'keep']);
|
||||
});
|
||||
|
||||
test('uses time rather than create_time and does not turn display defaults into facts', () => {
|
||||
expect(normalizeQzoneComment({ create_time: 1700000000 }).time).toBeNull();
|
||||
expect(normalizeQzoneReply({ create_time: 1700000000 }).time).toBeNull();
|
||||
expect(normalizeQzoneComment({ time: 0, reply_num: 0, has_more_reply: false, like: { count: 0, status: 0 } }))
|
||||
.toMatchObject({ time: 0, reply_num: 0, has_more_replies: false, like_count: 0, liked: false });
|
||||
expect(normalizeQzoneReply({ time: '0', like: { count: '0', status: '1' } })).toMatchObject({ time: 0, like_count: 0, liked: null });
|
||||
expect(normalizeQzoneComment({ has_more_reply: 1, like: { status: 2 } })).toMatchObject({ has_more_replies: null, liked: null });
|
||||
});
|
||||
|
||||
test('accepts ID zero while never inventing a rounded numeric identity', () => {
|
||||
for (const id of [0, '0']) {
|
||||
const raw = { id };
|
||||
expect(normalizeQzoneComment(raw).comment_id).toBe('0');
|
||||
expect(normalizeQzoneReply(raw).reply_id).toBe('0');
|
||||
expect(normalizeQzoneComment(raw).comment.id).toBe(id);
|
||||
}
|
||||
expect(normalizeQzoneReply({ id: '9007199254740993' }).reply_id).toBe('9007199254740993');
|
||||
for (const id of [Number.MAX_SAFE_INTEGER + 1, -1, NaN, Infinity, 0.5, '', ' ']) {
|
||||
expect(normalizeQzoneComment({ id }).comment_id).toBeNull();
|
||||
expect(normalizeQzoneReply({ id }).reply_id).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves UID-only authors and separates author identity from reply target', () => {
|
||||
const reply: QzoneComment = {
|
||||
id: 0,
|
||||
user: { uid: 'uid-only', nick: '' },
|
||||
target_user: { uin: '9007199254740993', uid: 'target-uid', nick: '回复目标' },
|
||||
content: [{ type: 0, content: '仅原文' }],
|
||||
};
|
||||
expect(normalizeQzoneReply(reply)).toMatchObject({
|
||||
reply_id: '0',
|
||||
user_id: null,
|
||||
uid: 'uid-only',
|
||||
nickname: '',
|
||||
content: '仅原文',
|
||||
target_user_id: '9007199254740993',
|
||||
target_uid: 'target-uid',
|
||||
target_nickname: '回复目标',
|
||||
});
|
||||
expect(normalizeQzoneComment({ user: { uin: '', uid: 'uid-only' } })).toMatchObject({ user_id: null, uid: 'uid-only' });
|
||||
expect(normalizeQzoneReply({ user: { uin: '10001', uid: '' } })).toMatchObject({ user_id: '10001', uid: null, target_user_id: null });
|
||||
expect(normalizeQzoneReply({ user: { uin: Number.MAX_SAFE_INTEGER + 1 } } as never).user_id).toBeNull();
|
||||
});
|
||||
|
||||
test('does not infer reply totals or continuation from inline replies', () => {
|
||||
const comment = { replys: [{ id: 'a' }, { id: 'b' }] };
|
||||
expect(normalizeQzoneComment(comment)).toMatchObject({ reply_num: null, has_more_replies: null });
|
||||
expect(normalizeQzoneComment(comment).replies).toHaveLength(2);
|
||||
expect(normalizeQzoneComment({ replys: [] })).toMatchObject({ replies: [], reply_num: null, has_more_replies: null });
|
||||
expect(normalizeQzoneComment({ reply_num: 20, has_more_reply: true })).toMatchObject({ replies: [], reply_num: 20, has_more_replies: true });
|
||||
});
|
||||
|
||||
test('retains unknown reply media while projecting only known images', () => {
|
||||
const video = { type: 1, video: { url: 'https://example.com/video', cover: { default_url: { url: 'https://example.com/cover' } } } };
|
||||
const unknown = { type: 99, vendor_media: { raw: true } };
|
||||
const reply = { media_items: [video, unknown], unknown_reply_field: { keep: true } };
|
||||
const result = normalizeQzoneReply(reply);
|
||||
expect(result.images).toBeNull();
|
||||
expect(result.reply).toBe(reply);
|
||||
expect(result.reply['media_items']).toEqual([video, unknown]);
|
||||
expect(normalizeQzoneReply({ media_items: [video] }).images).toEqual([]);
|
||||
expect(normalizeQzoneReply({ media_items: [] }).images).toEqual([]);
|
||||
});
|
||||
|
||||
test('preserves unsupported text segments and invalid numbers as unknown projections', () => {
|
||||
const reply = { content: [{ type: 99, content: 'future segment' }], time: 'unknown', like: { count: -1, status: true } };
|
||||
expect(normalizeQzoneReply(reply)).toMatchObject({ content: null, time: null, like_count: null, liked: null });
|
||||
expect(normalizeQzoneReply(reply).reply).toBe(reply);
|
||||
expect(normalizeQzoneComment({ reply_num: '9007199254740993' }).reply_num).toBeNull();
|
||||
});
|
||||
|
||||
test('does not silently drop malformed inline reply entries', () => {
|
||||
expect(() => normalizeQzoneComment({ replys: [null] } as never)).toThrow('内联回复');
|
||||
expect(() => normalizeQzoneComment({ replys: 'bad-shape' } as never)).toThrow('内联回复');
|
||||
expect(() => normalizeQzoneReply(null as never)).toThrow('回复数据');
|
||||
expect(() => normalizeQzoneComment(null as never)).toThrow('评论数据');
|
||||
});
|
||||
|
||||
test('shares rich text and image projections with feed normalization without changing feed behavior', () => {
|
||||
const content = [{ type: 0, content: 'same ' }, { type: 1, name: 'Qiao' }];
|
||||
const media = [{ type: 0, image: { default_url: { url: '//example.com/image' } } }];
|
||||
const feed = normalizeQzoneFeed({ cell_summary: { summary: content }, cell_media: { media_items: media } });
|
||||
const comment = normalizeQzoneComment({ content, media_items: media });
|
||||
expect(normalizeQzoneRichContent(content)).toBe(feed.content);
|
||||
expect(normalizeQzoneMediaImages(media)).toEqual(feed.images);
|
||||
expect(comment.content).toBe(feed.content);
|
||||
expect(comment.images).toEqual(feed.images);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as fileUtils from '@/napcat-common/src/file';
|
||||
import { SendQzoneMsg } from '@/napcat-onebot/action/extends/SendQzoneMsg';
|
||||
import { DeleteQzoneMsg } from '@/napcat-onebot/action/extends/DeleteQzoneMsg';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createCore () {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'napcat-qzone-compat-'));
|
||||
tempDirs.push(directory);
|
||||
return {
|
||||
NapCatTempPath: directory,
|
||||
selfInfo: { uin: '10001', uid: 'u_current' },
|
||||
apis: {
|
||||
QzoneApi: { publishMsg: vi.fn().mockResolvedValue({ tid: 'modern-tid' }) },
|
||||
WebApi: {
|
||||
uploadImageToQzone: vi.fn().mockResolvedValue('legacy-richval'),
|
||||
publishQzoneMsg: vi.fn().mockResolvedValue({ tid: 'legacy-tid' }),
|
||||
deleteQzoneMsg: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirs.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Qzone existing action compatibility', () => {
|
||||
it('keeps send_qzone_msg parameters and tid response for pure text', async () => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '朋友可见', ugc_right: '16', target_uins: ['10002'] }))
|
||||
.resolves.toEqual({ tid: 'modern-tid', time: 0 });
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledExactlyOnceWith('朋友可见', 16, ['10002']);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry an uncertain modern publish through the legacy CGI', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.publishMsg.mockRejectedValue(new Error('发表结果未知,请查询后确认'));
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '仅发送一次', images: [] })).rejects.toThrow('结果未知');
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves decimal target identifiers without a lossy numeric conversion', async () => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await action._handle({ content: '指定好友', ugc_right: 16, target_uins: ['9007199254740993'] });
|
||||
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledExactlyOnceWith('指定好友', 16, ['9007199254740993']);
|
||||
});
|
||||
|
||||
it.each([{ images: [] }, { images: ['base64://aGVsbG8='] }])('rejects an invalid exclusion list before any write ($images)', async ({ images }) => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '排除指定好友', ugc_right: 128, target_uins: ['10002', 'invalid'], images }))
|
||||
.rejects.toThrow('target_uins');
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps legacy image publishing and cleans decoded images without deleting user files', async () => {
|
||||
const core = createCore();
|
||||
const localImage = join(core.NapCatTempPath, 'user-image.png');
|
||||
writeFileSync(localImage, 'user image');
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({
|
||||
content: '带图说说',
|
||||
images: ['base64://aGVsbG8=', pathToFileURL(localImage).href],
|
||||
ugc_right: 64,
|
||||
})).resolves.toEqual({ tid: 'legacy-tid', time: 0 });
|
||||
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.uploadImageToQzone.mock.calls.map(args => args[0])).toEqual([
|
||||
Buffer.from('hello').toString('base64'),
|
||||
Buffer.from('user image').toString('base64'),
|
||||
]);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).toHaveBeenCalledExactlyOnceWith(
|
||||
'带图说说', ['legacy-richval', 'legacy-richval'], 64, undefined
|
||||
);
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual(['user-image.png']);
|
||||
expect(readFileSync(localImage, 'utf8')).toBe('user image');
|
||||
});
|
||||
|
||||
it('cleans temporary images and does not publish after an upload failure', async () => {
|
||||
const core = createCore();
|
||||
core.apis.WebApi.uploadImageToQzone.mockRejectedValue(new Error('upload failed'));
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '带图', images: ['base64://aGVsbG8='] })).rejects.toThrow('upload failed');
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops before upload and cleans decoded images if the account changes during image decoding', async () => {
|
||||
for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) {
|
||||
const core = createCore();
|
||||
const decode = fileUtils.uriToLocalFile;
|
||||
const decoder = vi.spyOn(fileUtils, 'uriToLocalFile').mockImplementationOnce(async (...args) => {
|
||||
const result = await decode(...args);
|
||||
Object.assign(core.selfInfo, change);
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await expect(new SendQzoneMsg(undefined as never, core as never)._handle({
|
||||
content: '带图', images: ['base64://aGVsbG8='],
|
||||
})).rejects.toThrow('QQ账号已切换');
|
||||
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual([]);
|
||||
} finally {
|
||||
decoder.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stops the upload chain without publishing to a changed account and preserves user images', async () => {
|
||||
for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) {
|
||||
const core = createCore();
|
||||
const localImage = join(core.NapCatTempPath, 'user-image.png');
|
||||
writeFileSync(localImage, 'user image');
|
||||
core.apis.WebApi.uploadImageToQzone.mockImplementationOnce(async () => {
|
||||
Object.assign(core.selfInfo, change);
|
||||
return 'legacy-richval';
|
||||
});
|
||||
|
||||
await expect(new SendQzoneMsg(undefined as never, core as never)._handle({
|
||||
content: '带图', images: ['base64://aGVsbG8=', pathToFileURL(localImage).href],
|
||||
})).rejects.toThrow('QQ账号已切换');
|
||||
|
||||
expect(core.apis.WebApi.uploadImageToQzone).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(core.NapCatTempPath)).toEqual(['user-image.png']);
|
||||
expect(readFileSync(localImage, 'utf8')).toBe('user image');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates existing selective visibility before uploading or publishing', async () => {
|
||||
const core = createCore();
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: '指定好友', ugc_right: 128, images: ['base64://aGVsbG8='] }))
|
||||
.rejects.toThrow('target_uins 不能为空');
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps delete_qzone_msg on its existing tid-based CGI path', async () => {
|
||||
const core = createCore();
|
||||
const action = new DeleteQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ tid: 'existing-tid' })).resolves.toBeNull();
|
||||
expect(core.apis.WebApi.deleteQzoneMsg).toHaveBeenCalledExactlyOnceWith('existing-tid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { parseQzoneWebResponse } from '@/napcat-core/data/qzone-web';
|
||||
|
||||
const DOMAIN = 'document.domain="h5.qzone.qq.com";';
|
||||
const ASSIGN_CALLBACK = 'cb=frameElement.callback;';
|
||||
const TRY_SETUP = `var cb;try{${DOMAIN}${ASSIGN_CALLBACK}}catch(e){try{${DOMAIN}${ASSIGN_CALLBACK}}catch(e){${DOMAIN}${ASSIGN_CALLBACK}}}`;
|
||||
|
||||
function html (script: string, doctype = true): string {
|
||||
return `${doctype ? '<!DOCTYPE html>\n' : ''}<html>\n<head>\n<meta http-equiv="content-type" content="text/html; charset=utf-8" />\n</head>\n<body>\n<script type="text/javascript">\n${script}\n</script>\n</body>\n</html>`;
|
||||
}
|
||||
|
||||
function callback (value: unknown): string {
|
||||
return `frameElement.callback(${JSON.stringify(value)});`;
|
||||
}
|
||||
|
||||
describe('QQ空间 inert FormSender envelopes', () => {
|
||||
test.each([
|
||||
{ code: 0, subcode: 0, data: '' },
|
||||
{ code: 0, subcode: 0, data: { name: 'Ack' } },
|
||||
{ code: 0, subcode: 0, data: { id: 0, content: 'synthetic reply', postTime: 1700000000 }, err: { code: 0 } },
|
||||
])('accepts the observed three-domain try/catch wrapper with a synthetic response (%j)', (response) => {
|
||||
for (const doctype of [true, false]) {
|
||||
expect(parseQzoneWebResponse(html(`${TRY_SETUP}\n${callback(response)}`, doctype), true)).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts the upload domain preamble and explicit ret=0 without inventing code=0', () => {
|
||||
const response = { ret: 0, data: { url: 'https://images.example.test/upload.png', width: 20, height: 10 } };
|
||||
const body = html(`${DOMAIN}${callback(response)}`, false);
|
||||
|
||||
expect(parseQzoneWebResponse(body)).toEqual(response);
|
||||
expect(() => parseQzoneWebResponse(body, true)).toThrow('成功状态');
|
||||
});
|
||||
|
||||
test('supports formatting and tag case without accepting extra attributes or syntax', () => {
|
||||
const setup = 'var cb ; try { document.domain = "h5.qzone.qq.com" ; cb = frameElement.callback ; } catch (e) { try { document.domain="h5.qzone.qq.com"; cb=frameElement.callback; } catch(e) { document.domain="h5.qzone.qq.com"; cb=frameElement.callback; } }';
|
||||
const body = html(`${setup}\n frameElement.callback ( { "code": 0 } ) ;`)
|
||||
.replace('<html>', '<HTML>')
|
||||
.replace('http-equiv="content-type"', 'http-equiv="Content-Type"')
|
||||
.replace('charset=utf-8', 'charset=UTF-8')
|
||||
.replace(' />', '/>');
|
||||
|
||||
expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0 });
|
||||
});
|
||||
|
||||
test('treats callback-looking content as data and preserves existing Ack normalization', () => {
|
||||
const response = { code: 0, data: { content: 'var cb; document.domain="other.test"; callback({"code":-1});', braces: '{}[]()' } };
|
||||
expect(parseQzoneWebResponse(html(`${TRY_SETUP}${callback(response)}`))).toEqual(response);
|
||||
expect(parseQzoneWebResponse(html(`${DOMAIN}frameElement.callback({"ret":0,"data":{name:"Ack"}});`)))
|
||||
.toEqual({ ret: 0, data: { name: 'Ack' } });
|
||||
});
|
||||
|
||||
test.each(['Example Space', '', '合成空间名称', 'quote " and slash \\', 'line one\nline two', '}; callback({"code":-1});'])('accepts the observed bare name string key without changing its value (%j)', (name) => {
|
||||
const payload = `{"code":0,"subcode":0,"message":"","default":0,"data":{name:${JSON.stringify(name)}}}`;
|
||||
const body = html(`${TRY_SETUP}frameElement.callback(${payload});`);
|
||||
|
||||
expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0, subcode: 0, message: '', default: 0, data: { name } });
|
||||
});
|
||||
|
||||
test('normalizes only object keys and preserves name-looking text inside JSON strings', () => {
|
||||
const content = '{name:"Example"}, name:"Ack", escaped quote \\"';
|
||||
const payload = `{"code":0,"data":{"content":${JSON.stringify(content)},name:"Example","nested":[{name:"Nested"}]}}`;
|
||||
|
||||
expect(parseQzoneWebResponse(html(`${DOMAIN}frameElement.callback(${payload});`)))
|
||||
.toEqual({ code: 0, data: { content, name: 'Example', nested: [{ name: 'Nested' }] } });
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{"code":0,"data":{name:1}}',
|
||||
'{"code":0,"data":{name:null}}',
|
||||
'{"code":0,"data":{name:false}}',
|
||||
'{"code":0,"data":{name:["Example"]}}',
|
||||
'{"code":0,"data":{name:{"value":"Example"}}}',
|
||||
'{"code":0,"data":{name:someName}}',
|
||||
'{"code":0,"data":{name:"Example"+"suffix"}}',
|
||||
'{"code":0,"data":{name:("Example")}}',
|
||||
'{"code":0,"data":{name:\'Example\'}}',
|
||||
'{"code":0,"data":{other:"Example"}}',
|
||||
'{"code":0,"data":{namex:"Example"}}',
|
||||
'{"code":0,"data":[name:"Example"]}',
|
||||
'{"code":0,"data":{name:"Example",other:"value"}}',
|
||||
'{"code":-1,"\\u0063ode":0,"data":{name:"Example"}}',
|
||||
'{"code":0,"subcode":-1,"data":{name:"Example"}}',
|
||||
])('does not extend bare-name compatibility to other syntax or failed statuses (%s)', (payload) => {
|
||||
expect(() => parseQzoneWebResponse(html(`${TRY_SETUP}frameElement.callback(${payload});`))).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('<body>', '<body onload="alert(1)">'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('<head>', '<head><script>alert(1)</script>'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('</body>', '<img src="x" onerror="alert(1)"></body>'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('type="text/javascript"', 'type="text/javascript" src="https://example.test/x.js"'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('type="text/javascript"', 'type="text/javascript" onload="alert(1)"'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('</script>', '</script><script>callback({"code":0});</script>'),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('charset=utf-8', 'charset=gbk'),
|
||||
html(`alert(1);${TRY_SETUP}${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}alert(1);${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}alert(1);`),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('"h5.qzone.qq.com"', '"other.example.test"'),
|
||||
html(`${DOMAIN}document.domain="other.example.test";${callback({ code: 0 })}`),
|
||||
html(`${TRY_SETUP}_Callback({"code":0});`),
|
||||
html(`${TRY_SETUP}cb({"code":0});`),
|
||||
html(callback({ code: 0 })),
|
||||
html(`${DOMAIN}frameElement.callback((function(){return {"code":0};})());`),
|
||||
html(`${DOMAIN}frameElement.callback({"code":(0,1)});`),
|
||||
'<html><body>login required</body></html>',
|
||||
])('rejects unobserved HTML, setup and executable additions', (body) => {
|
||||
expect(() => parseQzoneWebResponse(body)).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{"code":0,"subcode":-1}',
|
||||
'{"code":0,"ret":-1}',
|
||||
'{"ret":0,"code":-1}',
|
||||
'{"ret":"0"}',
|
||||
'{"code":-1,"code":0}',
|
||||
'{"code":-1,"\\u0063ode":0}',
|
||||
'{"ret":-1,"ret":0}',
|
||||
'{"code":0,"data":{}};alert(1);frameElement.callback({"code":0}',
|
||||
'{"message":"success without numeric acknowledgement"}',
|
||||
])('retains strict success and duplicate-code checks inside HTML (%s)', (payload) => {
|
||||
expect(() => parseQzoneWebResponse(html(`${TRY_SETUP}frameElement.callback(${payload});`))).toThrow();
|
||||
});
|
||||
|
||||
test('never exposes callback contents when an HTML response cannot be parsed', () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseQzoneWebResponse(html(`${DOMAIN}frameElement.callback({"code":0,"private":"synthetic-private",broken});`));
|
||||
} catch (value) {
|
||||
error = value;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('QQ空间响应JSON格式异常');
|
||||
expect((error as Error).message).not.toContain('synthetic-private');
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{"code":0}',
|
||||
'frameElement.callback({"code":0});',
|
||||
'_Callback({"code":0});',
|
||||
'<script>frameElement.callback({"code":0});</script>',
|
||||
'frameElement.callback({"code":0});</script>',
|
||||
])('preserves the original accepted response envelopes (%s)', (body) => {
|
||||
expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { GetQzoneProfile } from '@/napcat-onebot/action/extends/qzone/GetQzoneProfile';
|
||||
import { SetQzoneProfile } from '@/napcat-onebot/action/extends/qzone/SetQzoneProfile';
|
||||
import { SetQzoneMsgRight } from '@/napcat-onebot/action/extends/qzone/SetQzoneMsgRight';
|
||||
import { SetQzoneBan } from '@/napcat-onebot/action/extends/qzone/SetQzoneBan';
|
||||
|
||||
function createCore () {
|
||||
const QzoneApi = {
|
||||
getProfile: vi.fn().mockResolvedValue({ name: '', description: '' }),
|
||||
setProfile: vi.fn().mockResolvedValue(undefined),
|
||||
setMsgRight: vi.fn().mockImplementation(async (_tid: string, right: number) => ({ ugc_right: right })),
|
||||
setBan: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return { apis: { QzoneApi }, context: { logger: { logError: vi.fn() } } };
|
||||
}
|
||||
|
||||
describe('QQ空间资料和权限 actions', () => {
|
||||
it('reads the current profile by default and preserves large decimal target strings', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneProfile(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({}, 'test', {} as never);
|
||||
await action.handle({ target_uin: 10002 }, 'test', {} as never);
|
||||
await action.handle({ target_uin: '9007199254740993' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: { name: '', description: '' } });
|
||||
expect(core.apis.QzoneApi.getProfile.mock.calls).toEqual([
|
||||
[undefined], ['10002'], ['9007199254740993'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('distinguishes omitted profile fields from explicit empty strings through handle parsing', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneProfile(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({ name: '' }, 'test', {} as never);
|
||||
await action.handle({ description: '' }, 'test', {} as never);
|
||||
await action.handle({ name: undefined, description: '说明' }, 'test', {} as never);
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', retcode: 0, data: null });
|
||||
expect(core.apis.QzoneApi.setProfile.mock.calls).toEqual([
|
||||
[{ name: '' }], [{ description: '' }], [{ description: '说明' }],
|
||||
]);
|
||||
expect(Object.keys(core.apis.QzoneApi.setProfile.mock.calls[0]?.[0] ?? {})).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('normalizes supported profile scalars to strings without trimming content', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneProfile(undefined as never, core as never);
|
||||
|
||||
await action.handle({ name: 0, description: false }, 'test', {} as never);
|
||||
await action.handle({ name: true, description: ' 保留空白\r\n ' }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.setProfile.mock.calls).toEqual([
|
||||
[{ name: '0', description: 'false' }],
|
||||
[{ name: 'true', description: ' 保留空白\r\n ' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes right codes and target IDs before a single own-post permission request', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneMsgRight(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({
|
||||
tid: 'existing-tid',
|
||||
ugc_right: '16',
|
||||
target_uins: [10002, '9007199254740993', '10002'],
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.setMsgRight).toHaveBeenCalledExactlyOnceWith(
|
||||
'existing-tid', 16, ['10002', '9007199254740993']
|
||||
);
|
||||
expect(result).toMatchObject({ status: 'ok', data: { ugc_right: 16 } });
|
||||
});
|
||||
|
||||
it('keeps enable false and serializes successful ban writes with explicit null data', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneBan(undefined as never, core as never);
|
||||
|
||||
const disabled = await action.handle({ user_id: '9007199254740993', enable: false }, 'test', {} as never);
|
||||
const enabled = await action.handle({ user_id: 10002 }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.QzoneApi.setBan.mock.calls).toEqual([
|
||||
['9007199254740993', false], ['10002', true],
|
||||
]);
|
||||
expect(JSON.parse(JSON.stringify(disabled))).toMatchObject({ status: 'ok', data: null });
|
||||
expect(JSON.parse(JSON.stringify(enabled))).toMatchObject({ status: 'ok', data: null });
|
||||
});
|
||||
|
||||
it('normalizes safe numeric permission-update IDs without accepting lossy values', async () => {
|
||||
const core = createCore();
|
||||
const result = await new SetQzoneMsgRight(undefined as never, core as never).handle({ tid: 0, ugc_right: '4' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: { ugc_right: 4 } });
|
||||
expect(core.apis.QzoneApi.setMsgRight).toHaveBeenCalledExactlyOnceWith('0', 4, []);
|
||||
});
|
||||
|
||||
it('normalizes supported SnowLuma boolean spellings without treating arbitrary text as true', async () => {
|
||||
const core = createCore();
|
||||
const action = new SetQzoneBan(undefined as never, core as never);
|
||||
for (const enable of ['false', '0', ' NO ', 'Off', 0]) {
|
||||
const result = await action.handle({ user_id: '10002', enable }, 'test', {} as never);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.setBan).toHaveBeenLastCalledWith('10002', false);
|
||||
}
|
||||
for (const enable of ['true', '1', ' YES ', 'On', 1]) {
|
||||
const result = await action.handle({ user_id: '10002', enable }, 'test', {} as never);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.setBan).toHaveBeenLastCalledWith('10002', true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed or lossy values before any core call', async () => {
|
||||
const cases = [
|
||||
[GetQzoneProfile, { target_uin: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[GetQzoneProfile, { target_uin: false }],
|
||||
[GetQzoneProfile, { target_uin: 'bad-uin' }],
|
||||
[SetQzoneProfile, {}],
|
||||
[SetQzoneProfile, { name: undefined }],
|
||||
[SetQzoneProfile, { name: null }],
|
||||
[SetQzoneProfile, { name: ['text'] }],
|
||||
[SetQzoneProfile, { description: { content: 'text' } }],
|
||||
[SetQzoneProfile, { name: Infinity }],
|
||||
[SetQzoneProfile, { name: NaN }],
|
||||
[SetQzoneProfile, { name: 'x'.repeat(8193) }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 2 }],
|
||||
[SetQzoneMsgRight, { tid: '', ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: ' ', ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: true, ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: Number.MAX_SAFE_INTEGER + 1, ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: Infinity, ugc_right: 1 }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: true }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16 }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 128, target_uins: [] }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16, target_uins: [Number.MAX_SAFE_INTEGER + 1] }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16, target_uins: ['10002', false] }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 16, target_uins: '10002' }],
|
||||
[SetQzoneBan, { user_id: 0 }],
|
||||
[SetQzoneBan, { user_id: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[SetQzoneBan, { user_id: '10002', enable: 'unknown' }],
|
||||
[SetQzoneBan, { user_id: '10002', enable: 2 }],
|
||||
[SetQzoneBan, { user_id: '10002', enable: null }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
for (const method of Object.values(core.apis.QzoneApi)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown parameters and current-account overrides before any core call', async () => {
|
||||
const cases = [
|
||||
[GetQzoneProfile, { target_uin: '10002', unknown: true }],
|
||||
[SetQzoneProfile, { name: 'text', target_uin: '10002' }],
|
||||
[SetQzoneMsgRight, { tid: 'tid', ugc_right: 1, target_uin: '10002' }],
|
||||
[SetQzoneBan, { user_id: '10002', unknown: true }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
for (const method of Object.values(core.apis.QzoneApi)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a failed write without retrying or claiming null success', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.setProfile.mockRejectedValue(new Error('QQ空间未明确确认资料修改成功'));
|
||||
const result = await new SetQzoneProfile(undefined as never, core as never).handle({ name: 'text' }, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.message).toContain('未明确确认');
|
||||
expect(core.apis.QzoneApi.setProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has complete API metadata and schema-valid examples for all four actions', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneProfile(undefined as never, core as never),
|
||||
new SetQzoneProfile(undefined as never, core as never),
|
||||
new SetQzoneMsgRight(undefined as never, core as never),
|
||||
new SetQzoneBan(undefined as never, core as never),
|
||||
];
|
||||
|
||||
expect(actions.map(action => action.actionName)).toEqual([
|
||||
'get_qzone_profile', 'set_qzone_profile', 'set_qzone_msg_right', 'set_qzone_ban',
|
||||
]);
|
||||
for (const action of actions) {
|
||||
expect(action.actionSummary.length).toBeGreaterThan(0);
|
||||
expect(action.actionDescription.length).toBeGreaterThan(0);
|
||||
expect(action.actionTags).toContain('扩展接口');
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { CommentQzone } from '@/napcat-onebot/action/extends/qzone/CommentQzone';
|
||||
import { QzoneFeedReferenceExample, QzoneParentCommentSchema, QzoneUserSchema } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
import { normalizeQzoneCommentPayload } from '@/napcat-onebot/action/extends/qzone/common-snowluma';
|
||||
import type { QzoneComment } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
function createCore () {
|
||||
return {
|
||||
apis: {
|
||||
QzoneApi: {
|
||||
comment: vi.fn().mockResolvedValue({ comment_id: 'new-reply', comment: { id: 'new-reply' } }),
|
||||
},
|
||||
},
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
function parentComment () {
|
||||
return {
|
||||
id: '0',
|
||||
user: { uin: '10002', uid: null, nick: '作者', portrait: { type: 1, opaque: 'keep' } },
|
||||
content: [{ type: 0, content: '原评论', extra: { keep: true } }],
|
||||
replys: [{ id: 'inline-reply', user: { uid: null }, opaque_reply: [false, 0] }],
|
||||
like: { id: 'raw-like-key', status: 0, count: 1 },
|
||||
opaque_parent: { keep: ['9007199254740993', null] },
|
||||
} satisfies QzoneComment;
|
||||
}
|
||||
|
||||
describe('Qzone native parent with nullable raw UID', () => {
|
||||
it('retains null UID and complete raw parent fields through the real handle parser', async () => {
|
||||
const core = createCore();
|
||||
const parent = parentComment();
|
||||
const snapshot = structuredClone(parent);
|
||||
const payload = { feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parent };
|
||||
const expectedPayload = structuredClone(payload);
|
||||
|
||||
const normalized = normalizeQzoneCommentPayload(payload);
|
||||
expect(normalized.format).toBe('native');
|
||||
if (normalized.format === 'native') expect(normalized.comment).toBe(parent);
|
||||
const result = await new CommentQzone(undefined as never, core as never).handle(payload, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: { comment_id: 'new-reply' } });
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(QzoneFeedReferenceExample, '回复', snapshot, undefined);
|
||||
expect(core.apis.QzoneApi.comment.mock.calls[0]?.[2]).toHaveProperty('user.uid', null);
|
||||
expect(parent).toEqual(snapshot);
|
||||
expect(parent.user.uid).toBeNull();
|
||||
expect(payload).toEqual(expectedPayload);
|
||||
});
|
||||
|
||||
it('keeps a nullable raw parent separate from an explicit reply target', async () => {
|
||||
const core = createCore();
|
||||
const parent = parentComment();
|
||||
const snapshot = structuredClone(parent);
|
||||
const target = { uin: '10003', uid: 'u_target', nick: '接收者', extra: { keep: true } };
|
||||
const expectedTarget = structuredClone(target);
|
||||
|
||||
const result = await new CommentQzone(undefined as never, core as never).handle({
|
||||
feed_ref: QzoneFeedReferenceExample, content: '指定回复', comment: parent, reply_user: target,
|
||||
}, 'test', {} as never);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(QzoneFeedReferenceExample, '指定回复', snapshot, expectedTarget);
|
||||
expect(parent).toEqual(snapshot);
|
||||
expect(target).toEqual(expectedTarget);
|
||||
});
|
||||
|
||||
it('allows null only in raw parent UID without relaxing parent identity or explicit target validation', async () => {
|
||||
const parentSchema = TypeCompiler.Compile(QzoneParentCommentSchema);
|
||||
const userSchema = TypeCompiler.Compile(QzoneUserSchema);
|
||||
expect(parentSchema.Check(parentComment())).toBe(true);
|
||||
expect(userSchema.Check({ uin: '10002', uid: null })).toBe(false);
|
||||
const invalidParents = [
|
||||
{ id: '0', user: { uin: '10002', uid: false } },
|
||||
{ id: '0', user: { uin: '10002', uid: 0 } },
|
||||
{ id: '0', user: { uin: '10002', uid: {} } },
|
||||
{ id: 0, user: { uin: '10002', uid: null } },
|
||||
{ id: '0', user: { uid: null } },
|
||||
{ id: '0', user: { uin: Number.MAX_SAFE_INTEGER + 1, uid: null } },
|
||||
];
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
for (const comment of invalidParents) {
|
||||
const result = await action.handle({ feed_ref: QzoneFeedReferenceExample, content: '回复', comment } as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
}
|
||||
const invalidTarget = await action.handle({
|
||||
feed_ref: QzoneFeedReferenceExample, content: '回复', comment: parentComment(), reply_user: { uin: '10003', uid: null },
|
||||
} as never, 'test', {} as never);
|
||||
expect(invalidTarget.status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
import type { QzoneComment, QzoneFeed, QzoneFeedReference } from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
const unionId = { uf_key: 'union-key', ugc_id: 'ugc-key', feed_id: 'feed-key', serialized_data: 'opaque', ext_ids: [{ ext_id_type: 5, str: 'mood-tid' }] };
|
||||
const feed: QzoneFeed = {
|
||||
cell_common: { union_id: unionId, feed_type: 1, feed_sub_type: 1, cur_like_key: 'like-key', domain_type: 1 },
|
||||
cell_summary: { summary: [{ type: 0, content: '正文' }] },
|
||||
cell_user_info: { user: { uin: '10001', uid: 'self-uid', nick: '当前账号' } },
|
||||
cell_feed_transfer: { opaque: 'context' },
|
||||
};
|
||||
const reference: QzoneFeedReference = {
|
||||
union_id: unionId, cell_feed_transfer: { opaque: 'context' }, feed_action_domain_type: 1, like_key: 'like-key',
|
||||
};
|
||||
|
||||
function response (data: Record<string, unknown>, retcode = 0): Response {
|
||||
return new Response(JSON.stringify({ retcode, data }), { status: 200 });
|
||||
}
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
getUidByUinV2: vi.fn().mockResolvedValue('target-uid'),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
describe('Qzone modern HTTP2RPC API', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses the fixed JSON RPC route, PSKey-first gtk and only current account cookies', async () => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: false }));
|
||||
|
||||
const result = await api.getFriendFeeds();
|
||||
|
||||
expect(result.feeds).toHaveLength(1);
|
||||
expect(result.feeds[0]?.tid).toBe('mood-tid');
|
||||
expect(result.feeds[0]?.feed).toEqual(feed);
|
||||
expect(result.next_cursor).toBeNull();
|
||||
expect(core.apis.UserApi.getPSkey).toHaveBeenCalledExactlyOnceWith(['qzone.qq.com']);
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toBe('https://h5.qzone.qq.com/http2rpc/gotrpc/v1/trpc.qzone.feeds_reader.FeedsReader/GetFriendFeeds?g_tk=177685');
|
||||
expect(options).toMatchObject({ method: 'POST', redirect: 'error', signal: expect.any(AbortSignal) });
|
||||
expect(options?.headers).toMatchObject({
|
||||
Cookie: 'uin=o10001; p_uin=o10001; skey=s; p_skey=p',
|
||||
'Content-Type': 'application/json',
|
||||
'x-oidb': '{"uint32_command":"0x97e5","uint32_service_type":"0"}',
|
||||
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
|
||||
});
|
||||
expect(JSON.parse(options?.body as string)).toEqual({ refresh_id: expect.stringMatching(/^10001_\d+$/) });
|
||||
});
|
||||
|
||||
it('falls back to SKey gtk and rejects missing auth without leaking ticket errors', async () => {
|
||||
const { api, core } = createApi();
|
||||
core.apis.UserApi.getPSkey.mockRejectedValueOnce(new Error('private ticket detail'));
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false }));
|
||||
await api.getFriendFeeds();
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('g_tk=177688');
|
||||
|
||||
core.apis.UserApi.getSKey.mockRejectedValueOnce(new Error('private skey detail'));
|
||||
core.apis.UserApi.getPSkey.mockRejectedValueOnce(new Error('private pskey detail'));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间取票失败');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid identity and account switches during ticket acquisition', async () => {
|
||||
const { api, core } = createApi();
|
||||
core.selfInfo.uin = '';
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('需要已登录');
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
core.selfInfo.uin = '10001';
|
||||
core.apis.UserApi.getPSkey.mockImplementationOnce(async () => {
|
||||
core.selfInfo.uin = '20001';
|
||||
core.selfInfo.uid = 'another-uid';
|
||||
return { result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) };
|
||||
});
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ账号已切换');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the refresh id, attach info and last non-ad full union identity across pages', async () => {
|
||||
const { api } = createApi();
|
||||
const ad: QzoneFeed = { cell_common: { feed_type: 3, union_id: { uf_key: 'ad-key' } } };
|
||||
const unknown: QzoneFeed = { cell_common: { feed_type: 1, union_id: { uf_key: '' } }, new_cell: { opaque: true } };
|
||||
const noIdentity: QzoneFeed = { cell_common: { feed_type: 1 }, new_cell: { opaque: 'also-preserved' } };
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }, { feed: ad }, { feed: unknown }, { feed: noIdentity }], has_more: true, attach_info: 'page-2' }));
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed: unknown }], has_more: false }));
|
||||
const first = await api.getFriendFeeds();
|
||||
expect(first.feeds).toHaveLength(4);
|
||||
expect(first.feeds[2]?.feed).toEqual(unknown);
|
||||
expect(first.feeds[2]?.feed_ref).toBeNull();
|
||||
expect(first.feeds[3]?.feed_ref).toBeNull();
|
||||
expect(first.next_cursor).toEqual(expect.any(String));
|
||||
const firstRequest = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
// A caller mutating returned display data must not change the cached pagination identity.
|
||||
first.feeds[0]!.feed.cell_common!.union_id!.uf_key = 'caller-mutated';
|
||||
const last = await api.getFriendFeeds(first.next_cursor!);
|
||||
expect(last.feeds[0]?.feed).toEqual(unknown);
|
||||
expect(last.has_more).toBe(false);
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({
|
||||
refresh_id: firstRequest.refresh_id, attach_info: 'page-2', last_client_feed_union_id: unionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects cursors for a different query kind, target or account and after expiry', async () => {
|
||||
const { api, core } = createApi();
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000000);
|
||||
fetchMock.mockImplementation(async () => response({ feeds: [{ feed }], has_more: true, attach_info: 'next' }));
|
||||
const friend = await api.getFriendFeeds();
|
||||
await expect(api.getMsgList(undefined, friend.next_cursor!)).rejects.toThrow('游标无效');
|
||||
const moods = await api.getMsgList('20001');
|
||||
await expect(api.getMsgList('30001', moods.next_cursor!)).rejects.toThrow('游标无效');
|
||||
core.selfInfo.uin = '20001';
|
||||
core.selfInfo.uid = 'other-uid';
|
||||
await expect(api.getFriendFeeds(friend.next_cursor!)).rejects.toThrow('游标无效');
|
||||
core.selfInfo.uin = '10001';
|
||||
core.selfInfo.uid = 'self-uid';
|
||||
clock.mockReturnValue(1000000 + 10 * 60 * 1000);
|
||||
await expect(api.getFriendFeeds(friend.next_cursor!)).rejects.toThrow('已过期');
|
||||
await expect(api.getFriendFeeds('x'.repeat(16385))).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bounds saved cursors and evicts the oldest issued token', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockImplementation(async () => response({ feeds: [{ feed }], has_more: true, attach_info: 'next' }));
|
||||
const first = await api.getFriendFeeds();
|
||||
for (let i = 0; i < 128; i++) await api.getFriendFeeds();
|
||||
await expect(api.getFriendFeeds(first.next_cursor!)).rejects.toThrow('游标无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(129);
|
||||
});
|
||||
|
||||
it('uses resolved UID for mood lists and returns explicit seal state without a fake total', async () => {
|
||||
const { api, core } = createApi();
|
||||
const seal = { type: 1, summary: '已封存', additional: 'preserved' };
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [], has_more: false, seal_off_memory: seal }));
|
||||
const result = await api.getMsgList('20001');
|
||||
expect(core.apis.UserApi.getUidByUinV2).toHaveBeenCalledExactlyOnceWith('20001');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
|
||||
host_uid: 'target-uid', host_uin: '20001', attach_info: '', feed_action_domain_type: 1,
|
||||
});
|
||||
expect(result).toEqual({ msglist: [], has_more: false, next_cursor: null, seal_off_memory: seal });
|
||||
expect(result).not.toHaveProperty('total');
|
||||
});
|
||||
|
||||
it('rejects malformed pages, malformed continuation and unresolved target accounts', async () => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ has_more: false }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('列表响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(response({ feeds: [{ feed }], has_more: true }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('续页信息无效');
|
||||
core.apis.UserApi.getUidByUinV2.mockResolvedValueOnce('');
|
||||
await expect(api.getMsgList('20001')).rejects.toThrow('无法解析目标QQ号');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the exact detail nesting and preserves raw comments and feed identity', async () => {
|
||||
const { api } = createApi();
|
||||
const parent: QzoneComment = { id: '0', user: { uin: '20001', uid: 'peer-uid' }, custom: { keep: true } };
|
||||
const detailFeed = { ...feed, cell_comment: { comments: [parent] } };
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { feed: detailFeed } }));
|
||||
const detail = await api.getMsgDetail(reference);
|
||||
expect(detail.comments).toEqual([parent]);
|
||||
expect(detail.feed).toEqual(detailFeed);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
|
||||
union_id: unionId, cell_feed_transfer: { opaque: 'context' }, feed_action_domain_type: 1, from_page: 1,
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: detailFeed }));
|
||||
await expect(api.getMsgDetail(reference)).rejects.toThrow('未返回说说详情');
|
||||
});
|
||||
|
||||
it('publishes with session author and mapped privacy, and accepts only a returned server tid', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed }));
|
||||
await expect(api.publishMsg('正文', 16, ['20001'])).resolves.toEqual({ tid: 'mood-tid', time: 0 });
|
||||
const request = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
expect(request).toMatchObject({
|
||||
from: 3,
|
||||
feed: {
|
||||
cell_user_info: { user: { uin: '10001', uid: 'self-uid', nick: '当前账号' } },
|
||||
cell_summary: { summary: [{ type: 0, content: '正文' }] },
|
||||
cell_permission: { right: { flag: [3], white_uins: ['20001'] } },
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { cell_common: { client_key: 'local-key-only' } } }));
|
||||
await expect(api.publishMsg('第二条')).rejects.toThrow('结果未知');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns publish time only from the server feed without substituting the current clock', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { ...feed, cell_common: { ...feed.cell_common, time: '1700000123' } } }));
|
||||
await expect(api.publishMsg('带服务端时间')).resolves.toEqual({ tid: 'mood-tid', time: 1700000123 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not turn a malformed supplied publish time into a missing-time sentinel', async () => {
|
||||
const { api } = createApi();
|
||||
for (const time of [-1, 'bad', Number.MAX_SAFE_INTEGER + 1]) {
|
||||
fetchMock.mockResolvedValueOnce(response({ feed: { ...feed, cell_common: { ...feed.cell_common, time } } }));
|
||||
await expect(api.publishMsg('仅发表一次')).rejects.toThrow('勿自动重试');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('uses distinct official like/unlike routes and requires the business result', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ like: { status: 1 } }));
|
||||
fetchMock.mockResolvedValueOnce(response({ like: { status: 0 } }));
|
||||
await api.setLike(reference, true);
|
||||
await api.setLike(reference, false);
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/DoLike?');
|
||||
expect(String(fetchMock.mock.calls[1]![0])).toContain('/UnLike?');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
|
||||
like_key: 'like-key', union_id: unionId, like_type: 1, cell_feed_transfer: { opaque: 'context' },
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
await expect(api.setLike(reference, true)).rejects.toThrow('未返回点赞结果');
|
||||
await expect(api.setLike({ union_id: unionId }, true)).rejects.toThrow('缺少QQ空间点赞标识');
|
||||
fetchMock.mockResolvedValueOnce(response({ like: {} }));
|
||||
await api.setLike({ ...reference, like_key: 'k'.repeat(8192) }, true);
|
||||
expect(JSON.parse(fetchMock.mock.calls[3]![1]?.body as string).like_key).toHaveLength(8192);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('passes the full parent comment for replies and never trusts it as the acting author', async () => {
|
||||
const { api } = createApi();
|
||||
const parent: QzoneComment = {
|
||||
id: '0',
|
||||
user: { uin: '20001', uid: 'peer-uid', nick: '父评论作者' },
|
||||
like: { id: 'parent-like' },
|
||||
additional: { opaque: true },
|
||||
};
|
||||
const reply = { id: 'reply-id', user: { uin: '10001' }, opaque: 'returned' };
|
||||
fetchMock.mockResolvedValueOnce(response({ reply }));
|
||||
await expect(api.comment(reference, '回复正文', parent, { uin: '30001', uid: 'reply-target' })).resolves.toEqual({ comment_id: 'reply-id', comment: reply });
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/AddReply?');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({
|
||||
union_id: unionId,
|
||||
comment: parent,
|
||||
reply: { user: { uin: '10001', uid: 'self-uid' }, target_user: { uin: '30001', uid: 'reply-target' }, content: [{ content: '回复正文', type: 0 }] },
|
||||
});
|
||||
await expect(api.comment(reference, '回复', undefined, { uin: '30001' })).rejects.toThrow('必须与父评论');
|
||||
await expect(api.comment(reference, '回复', { id: '0' })).rejects.toThrow('用户信息无效');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts an empty optional UID on a returned parent while retaining the session actor UID', async () => {
|
||||
const { api } = createApi();
|
||||
const parent: QzoneComment = { id: 'parent', user: { uin: '20001', uid: '', nick: '' }, opaque: 'retained' };
|
||||
fetchMock.mockResolvedValueOnce(response({ reply: { id: 'empty-uid-reply' } }));
|
||||
await expect(api.comment(reference, '回复', parent)).resolves.toEqual({ comment_id: 'empty-uid-reply', comment: { id: 'empty-uid-reply' } });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toMatchObject({
|
||||
comment: parent,
|
||||
reply: { target_user: { uin: '20001', uid: '', nick: '' }, user: { uin: '10001', uid: 'self-uid' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a nonempty server comment id and does not replay ambiguous writes', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ comment: { id: '0', additional: true } }));
|
||||
await expect(api.comment(reference, '评论')).resolves.toEqual({ comment_id: '0', comment: { id: '0', additional: true } });
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain('/AddComment?');
|
||||
fetchMock.mockResolvedValueOnce(response({ comment: {} }));
|
||||
await expect(api.comment(reference, '评论')).rejects.toThrow('结果未知');
|
||||
fetchMock.mockRejectedValueOnce(new Error('transport contains private cookie'));
|
||||
await expect(api.publishMsg('正文')).rejects.toThrow('未能确认结果');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('accepts bounded parent and returned comment IDs that pass the public schema', async () => {
|
||||
const { api } = createApi();
|
||||
const id = 'c'.repeat(8192);
|
||||
const parent = { id, user: { uin: '20001', uid: '' } };
|
||||
fetchMock.mockResolvedValueOnce(response({ reply: { id } }));
|
||||
|
||||
await expect(api.comment(reference, '回复', parent)).resolves.toMatchObject({ comment_id: id });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string).comment.id).toBe(id);
|
||||
});
|
||||
|
||||
it('preserves a real parent null UID while omitting it only from the normalized reply target', async () => {
|
||||
const { api } = createApi();
|
||||
const parent = { id: '0', user: { uin: '20001', uid: null, nick: '作者', extra: 'keep' }, content: [{ type: 0, content: '原评论' }], unknown: { keep: true } };
|
||||
const saved = structuredClone(parent);
|
||||
fetchMock.mockResolvedValueOnce(response({ reply: { id: 'new-reply' } }));
|
||||
await expect(api.comment(reference, '回复', parent)).resolves.toMatchObject({ comment_id: 'new-reply' });
|
||||
const request = JSON.parse(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
expect(request.comment).toEqual(saved);
|
||||
expect(request.reply.target_user).toEqual({ uin: '20001', nick: '作者', extra: 'keep' });
|
||||
expect(parent).toEqual(saved);
|
||||
});
|
||||
|
||||
it('rejects HTTP errors, redirects, malformed JSON and outer RPC errors without returning server secrets', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response('private response', { status: 302, headers: { Location: 'https://other.example' } }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间请求失败');
|
||||
fetchMock.mockResolvedValueOnce(new Response('private response', { status: 500 }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间请求失败');
|
||||
fetchMock.mockResolvedValueOnce(new Response('not json but private', { status: 200 }));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('响应格式异常');
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ retcode: 4003, message: 'private credential text', data: {} })));
|
||||
try {
|
||||
await api.getFriendFeeds();
|
||||
expect.fail('must reject business failure');
|
||||
} catch (error) {
|
||||
expect(String(error)).not.toContain('private');
|
||||
expect(String(error)).toContain('4003');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('aborts a hanging request at the finite timeout and never retries a publish', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api } = createApi();
|
||||
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}));
|
||||
const pending = api.publishMsg('正文');
|
||||
const assertion = expect(pending).rejects.toThrow('未能确认结果');
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
await assertion;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds auth waits and streamed response size', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, core } = createApi();
|
||||
core.apis.UserApi.getSKey.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const pending = api.getFriendFeeds();
|
||||
const assertion = expect(pending).rejects.toThrow('取票超时');
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
await assertion;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(8 * 1024 * 1024 + 1)));
|
||||
await expect(api.getFriendFeeds()).rejects.toThrow('QQ空间请求失败');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzonePublishRequest,
|
||||
buildQzoneText,
|
||||
getQzoneGtk,
|
||||
getQzoneTid,
|
||||
normalizeQzoneFeed,
|
||||
parseQzoneRpcResponse,
|
||||
QZONE_REFERENCE_MAX_BYTES,
|
||||
validateQzoneFeedReference,
|
||||
type QzoneFeed,
|
||||
} from '@/napcat-core/data/qzone-rpc';
|
||||
|
||||
describe('Qzone RPC response and feed identity', () => {
|
||||
test('accepts only a successful object data envelope and preserves raw fields', () => {
|
||||
const data = { feeds: [], extra: { server_field: true } };
|
||||
expect(parseQzoneRpcResponse({ retcode: 0, data })).toBe(data);
|
||||
for (const input of [null, [], {}, { retcode: '0', data }, { retcode: 0 }, { retcode: 0, data: [] }]) {
|
||||
expect(() => parseQzoneRpcResponse(input)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not echo credential-shaped server errors', () => {
|
||||
expect(() => parseQzoneRpcResponse({ retcode: 4003, message: 'p_skey=private-value', data: null }))
|
||||
.toThrow('QQ空间请求失败 (retcode=4003)');
|
||||
expect(() => parseQzoneRpcResponse({ retcode: 'p_skey=private-value' }))
|
||||
.toThrow('QQ空间请求失败');
|
||||
});
|
||||
|
||||
test('uses an explicit mood extension ahead of ugc_id and never labels an album ID as a tid', () => {
|
||||
expect(getQzoneTid({ cell_common: { feed_sub_type: 1, union_id: { ugc_id: 'fallback', ext_ids: [{ ext_id_type: 5, str: 'mood-tid' }] } } }))
|
||||
.toBe('mood-tid');
|
||||
expect(getQzoneTid({ cell_common: { app_id: 311, union_id: { ugc_id: 'fallback' } } })).toBe('fallback');
|
||||
expect(getQzoneTid({ cell_common: { feed_sub_type: 2, union_id: { ugc_id: 'album-id' } } })).toBeNull();
|
||||
expect(getQzoneTid({ cell_common: { union_id: { uf_key: 'feed-key', ugc_id: 'unclassified' } } })).toBeNull();
|
||||
});
|
||||
|
||||
test('preserves numeric extension strings and rejects already-rounded numbers', () => {
|
||||
expect(getQzoneTid({ cell_common: { union_id: { ext_ids: [{ ext_id_type: 5, unsigned_numeric: '9007199254740993' }] } } })).toBe('9007199254740993');
|
||||
expect(getQzoneTid({ cell_common: { union_id: { ext_ids: [{ ext_id_type: 5, numeric: 9007199254740992 }] } } })).toBeNull();
|
||||
});
|
||||
|
||||
test('retains complete references without rebuilding identities from tid', () => {
|
||||
const reference = {
|
||||
union_id: { uf_key: 'feed-key', ugc_id: 'mood-id', serialized_data: 'opaque', ext_ids: [{ ext_id_type: 5, str: 'mood-id', extra: 1 }] },
|
||||
cell_feed_transfer: { cookie: 'opaque-server-transfer', nested: [1, true, null] },
|
||||
feed_action_domain_type: 1,
|
||||
like_key: 'server-like-key',
|
||||
};
|
||||
expect(validateQzoneFeedReference(reference)).toBe(reference);
|
||||
const withEmptyDefaults = { union_id: { uf_key: 'issued-key', ugc_id: '', feed_id: '', serialized_data: '' } };
|
||||
expect(validateQzoneFeedReference(withEmptyDefaults)).toBe(withEmptyDefaults);
|
||||
expect(() => validateQzoneFeedReference({ tid: 'mood-id' })).toThrow();
|
||||
expect(() => validateQzoneFeedReference({ union_id: {} })).toThrow();
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: '' } })).toThrow();
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, feed_action_domain_type: 4 })).toThrow();
|
||||
});
|
||||
|
||||
test('bounds UTF-8 reference size and rejects non-JSON or cyclic transfer data', () => {
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, cell_feed_transfer: '中'.repeat(QZONE_REFERENCE_MAX_BYTES / 2) })).toThrow('64KiB');
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, cell_feed_transfer: 1n })).toThrow('JSON');
|
||||
const cycle: Record<string, unknown> = {};
|
||||
cycle['self'] = cycle;
|
||||
expect(() => validateQzoneFeedReference({ union_id: { uf_key: 'key' }, cell_feed_transfer: cycle })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone RPC feed projections', () => {
|
||||
test('keeps missing values unknown while retaining the original feed', () => {
|
||||
const feed = { future_cell: { unknown: 'retained' } };
|
||||
expect(normalizeQzoneFeed(feed)).toEqual({
|
||||
tid: null,
|
||||
user_id: null,
|
||||
content: null,
|
||||
time: null,
|
||||
comment_num: null,
|
||||
like_num: null,
|
||||
images: null,
|
||||
feed_ref: null,
|
||||
feed,
|
||||
});
|
||||
expect(normalizeQzoneFeed(feed).feed).toBe(feed);
|
||||
});
|
||||
|
||||
test('normalizes observed text, counts and image variants without including video covers', () => {
|
||||
const feed: QzoneFeed = {
|
||||
cell_common: { feed_sub_type: 1, time: '1700000000', union_id: { uf_key: 'key', ugc_id: 'tid' }, cur_like_key: 'like-key', domain_type: 1 },
|
||||
cell_user_info: { user: { uin: '10001' } },
|
||||
cell_summary: { summary: [{ type: 0, content: 'Hello ' }, { type: 1, name: 'Qiao' }, { type: 0, content: '[em]e100[/em]' }] },
|
||||
cell_comment: { num: 0, comments: [] },
|
||||
cell_like: { num: 2 },
|
||||
cell_media: {
|
||||
media_items: [
|
||||
{ type: 0, image: { photo_url: [{ spec: 1, url: { url: 'https://example.com/big.jpg' } }, { spec: 6, url: { url: '//example.com/original.jpg' } }] } },
|
||||
{ type: 1, video: { cover: { default_url: { url: 'https://example.com/video-cover.jpg' } } } },
|
||||
],
|
||||
},
|
||||
cell_feed_transfer: { unknown: true },
|
||||
};
|
||||
const result = normalizeQzoneFeed(feed);
|
||||
expect(result).toMatchObject({ tid: 'tid', user_id: '10001', content: 'Hello @Qiao[em]e100[/em]', time: 1700000000, comment_num: 0, like_num: 2, images: ['https://example.com/original.jpg'] });
|
||||
expect(result.feed_ref).toEqual({ union_id: feed.cell_common!.union_id, like_key: 'like-key', feed_action_domain_type: 1, cell_feed_transfer: { unknown: true } });
|
||||
expect(result.feed).toBe(feed);
|
||||
});
|
||||
|
||||
test('distinguishes known empty values from unknown or malformed sections', () => {
|
||||
expect(normalizeQzoneFeed({ cell_summary: { summary: [] }, cell_media: { media_items: [] } })).toMatchObject({ content: '', images: [] });
|
||||
expect(normalizeQzoneFeed({ cell_summary: { summary: [{ type: 99, content: 'future' }] } }).content).toBeNull();
|
||||
expect(normalizeQzoneFeed({ cell_media: { media_items: [{ type: 0, image: {} }] } }).images).toBeNull();
|
||||
expect(normalizeQzoneFeed({ cell_comment: { num: -1 }, cell_like: { num: 'not-a-number' } })).toMatchObject({ comment_num: null, like_num: null });
|
||||
});
|
||||
|
||||
test('declines unusable action handles while keeping their raw source', () => {
|
||||
const feed = { cell_common: { union_id: { uf_key: '' } }, untouched: true };
|
||||
const result = normalizeQzoneFeed(feed);
|
||||
expect(result.feed_ref).toBeNull();
|
||||
expect(result.feed).toBe(feed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone RPC pure-text publish body', () => {
|
||||
test('uses the official RichMsg shape and preserves text exactly', () => {
|
||||
expect(buildQzoneText(' hello\n[em]e100[/em] ')).toEqual([
|
||||
{ content: ' hello\n[em]e100[/em] ', media_items: [], name: '', type: 0, uid: '', uin: '', url: '', who: 0 },
|
||||
]);
|
||||
expect(() => buildQzoneText(' \n ')).toThrow();
|
||||
expect(() => buildQzoneText('x'.repeat(2001))).toThrow('2000');
|
||||
});
|
||||
|
||||
test.each([[1, 1], [4, 2], [16, 3], [128, 4], [64, 5]])('maps legacy permission %i to modern flag %i', (legacy, modern) => {
|
||||
const result = buildQzonePublishRequest({ uin: '10001', uid: 'uid', nick: 'Qiao' }, 'post', legacy, ['10002'], 1700000000, 'client-key');
|
||||
expect(result).toMatchObject({
|
||||
from: 3,
|
||||
feed: {
|
||||
cell_common: { time: 1700000000, client_key: 'client-key', feed_type: 1, feed_sub_type: 1, feed_biz_type: 0, union_id: { uf_key: 'client-key', ugc_id: 'client-key', feed_id: 'client-key' } },
|
||||
cell_user_info: { user: { uin: '10001', uid: 'uid', nick: 'Qiao' } },
|
||||
cell_permission: { right: { flag: [modern] } },
|
||||
},
|
||||
});
|
||||
const permission = result.feed['cell_permission'] as { right: Record<string, unknown>; };
|
||||
expect(permission.right['white_uins']).toEqual(legacy === 16 ? ['10002'] : undefined);
|
||||
expect(permission.right['black_uins']).toEqual(legacy === 128 ? ['10002'] : undefined);
|
||||
expect(result.feed).not.toHaveProperty('cell_media');
|
||||
});
|
||||
|
||||
test('rejects unsupported rights and incomplete target lists before publishing', () => {
|
||||
for (const right of [0, 2, 3, 5, 256, NaN]) {
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', right)).toThrow();
|
||||
}
|
||||
for (const right of [16, 128]) {
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', right)).toThrow('target_uins');
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', right, [])).toThrow('target_uins');
|
||||
}
|
||||
expect(() => buildQzonePublishRequest({ uin: '' }, 'post', 1)).toThrow();
|
||||
expect(() => buildQzonePublishRequest({ uin: '10001' }, 'post', 1, ['bad-uin'])).toThrow();
|
||||
});
|
||||
|
||||
test('matches known gtk values without needing an account key', () => {
|
||||
expect(getQzoneGtk('')).toBe(5381);
|
||||
expect(getQzoneGtk('abc')).toBe(193485963);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// StreamBasic must be evaluated before OneBotAction because of their circular import.
|
||||
import '../napcat-onebot/action/stream/StreamBasic';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { GetQzoneFeeds } from '@/napcat-onebot/action/extends/qzone/GetQzoneFeeds';
|
||||
import { GetQzoneMsgList } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgList';
|
||||
import { GetQzoneMsgDetail } from '@/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail';
|
||||
import { LikeQzone } from '@/napcat-onebot/action/extends/qzone/LikeQzone';
|
||||
import { UnlikeQzone } from '@/napcat-onebot/action/extends/qzone/UnlikeQzone';
|
||||
import { CommentQzone } from '@/napcat-onebot/action/extends/qzone/CommentQzone';
|
||||
import { SendQzoneMsg } from '@/napcat-onebot/action/extends/SendQzoneMsg';
|
||||
import { DeleteQzoneMsg } from '@/napcat-onebot/action/extends/DeleteQzoneMsg';
|
||||
import { QzoneFeedItemExample, QzoneFeedReferenceExample } from '@/napcat-onebot/action/extends/qzone/common';
|
||||
import { SlQzoneFeedsExample, SlQzoneMsgDetailExample, SlQzoneMsgListExample } from '@/napcat-onebot/action/extends/qzone/common-snowluma';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createCore () {
|
||||
const QzoneApi = {
|
||||
getFriendFeeds: vi.fn().mockResolvedValue({ feeds: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgList: vi.fn().mockResolvedValue({ msglist: [QzoneFeedItemExample], has_more: false, next_cursor: null }),
|
||||
getMsgDetail: vi.fn().mockResolvedValue({ ...QzoneFeedItemExample, comments: [] }),
|
||||
setLike: vi.fn().mockResolvedValue(undefined),
|
||||
comment: vi.fn().mockResolvedValue({ comment_id: 'native-comment', comment: { id: 'native-comment', extra: 'raw' } }),
|
||||
getSnowlumaFeeds: vi.fn().mockResolvedValue(SlQzoneFeedsExample),
|
||||
getSnowlumaMsgList: vi.fn().mockResolvedValue(SlQzoneMsgListExample),
|
||||
getSnowlumaMsgDetail: vi.fn().mockResolvedValue(SlQzoneMsgDetailExample),
|
||||
setSnowlumaLike: vi.fn().mockResolvedValue(undefined),
|
||||
commentSnowluma: vi.fn().mockResolvedValue({ comment_id: 'sl-comment' }),
|
||||
uploadCommentImage: vi.fn().mockResolvedValue({ url: 'https://images.example.test/comment.png' }),
|
||||
publishMsg: vi.fn().mockResolvedValue({ tid: 'published', time: 1700000000 }),
|
||||
};
|
||||
return {
|
||||
NapCatTempPath: '',
|
||||
selfInfo: { uin: '10001', uid: 'u_current' },
|
||||
apis: {
|
||||
QzoneApi,
|
||||
WebApi: {
|
||||
uploadImageToQzone: vi.fn().mockResolvedValue('published-richval'),
|
||||
publishQzoneMsg: vi.fn().mockResolvedValue({ tid: 'published-image', time: 1700000001 }),
|
||||
deleteQzoneMsg: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
context: { logger: { logError: vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
function withImageDirectory (core: ReturnType<typeof createCore>) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'napcat-qzone-sl-actions-'));
|
||||
tempDirs.push(directory);
|
||||
core.NapCatTempPath = directory;
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('QQ空间 SnowLuma action compatibility', () => {
|
||||
it('defaults to SL feeds and routes old opaque cursors before applying SL defaults', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneFeeds(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({}, 'test', {} as never);
|
||||
await action.handle({ page_num: '2', count: '5', unrelated: 'ignored' } as never, 'test', {} as never);
|
||||
await action.handle({ cursor: 'slqf:continuation' }, 'test', {} as never);
|
||||
await action.handle({ cursor: 'old-native-cursor' }, 'test', {} as never);
|
||||
await action.handle({ format: 'native' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: SlQzoneFeedsExample });
|
||||
expect(core.apis.QzoneApi.getSnowlumaFeeds.mock.calls).toEqual([
|
||||
[1, 10, undefined], [2, 5, undefined], [1, 10, 'slqf:continuation'],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.getFriendFeeds.mock.calls).toEqual([['old-native-cursor'], [undefined]]);
|
||||
});
|
||||
|
||||
it('defaults to SL list pagination while retaining native raw results and lossless target IDs', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneMsgList(undefined as never, core as never);
|
||||
|
||||
const result = await action.handle({}, 'test', {} as never);
|
||||
await action.handle({ target_uin: '9007199254740993', pos: '40', num: '10' }, 'test', {} as never);
|
||||
const native = await action.handle({ format: 'native', target_uin: 10002 }, 'test', {} as never);
|
||||
await action.handle({ target_uin: '10003', cursor: 'native-list-cursor' }, 'test', {} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 'ok', data: SlQzoneMsgListExample });
|
||||
expect(core.apis.QzoneApi.getSnowlumaMsgList.mock.calls).toEqual([
|
||||
[undefined, 0, 20], ['9007199254740993', 40, 10],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.getMsgList.mock.calls).toEqual([['10002', undefined], ['10003', 'native-list-cursor']]);
|
||||
expect(native).toHaveProperty('data.msglist.0.feed_ref', QzoneFeedReferenceExample);
|
||||
expect(native).toHaveProperty('data.msglist.0.feed', QzoneFeedItemExample.feed);
|
||||
});
|
||||
|
||||
it('routes detail by its explicit anchor and does not project native raw comments', async () => {
|
||||
const core = createCore();
|
||||
const action = new GetQzoneMsgDetail(undefined as never, core as never);
|
||||
const reference = { ...QzoneFeedReferenceExample, cell_feed_transfer: { keep: [0, false, 'raw'] } };
|
||||
|
||||
const sl = await action.handle({ tid: 0, target_uin: 10002, pos: '2', num: '3' }, 'test', {} as never);
|
||||
const native = await action.handle({ feed_ref: reference }, 'test', {} as never);
|
||||
|
||||
expect(sl).toMatchObject({ status: 'ok', data: SlQzoneMsgDetailExample });
|
||||
expect(core.apis.QzoneApi.getSnowlumaMsgDetail).toHaveBeenCalledExactlyOnceWith('0', '10002', 2, 3);
|
||||
expect(core.apis.QzoneApi.getMsgDetail).toHaveBeenCalledExactlyOnceWith(reference);
|
||||
expect(native).toHaveProperty('data.feed', QzoneFeedItemExample.feed);
|
||||
});
|
||||
|
||||
it('uses one like backend per anchor and returns explicit null for both SL operations', async () => {
|
||||
const core = createCore();
|
||||
for (const Action of [LikeQzone, UnlikeQzone]) {
|
||||
const action = new Action(undefined as never, core as never);
|
||||
const result = await action.handle({ tid: 'post', target_uin: '10002', abstime: '123' }, 'test', {} as never);
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', data: null });
|
||||
await action.handle({ feed_ref: QzoneFeedReferenceExample }, 'test', {} as never);
|
||||
}
|
||||
|
||||
expect(core.apis.QzoneApi.setSnowlumaLike.mock.calls).toEqual([
|
||||
['post', '10002', true, 123], ['post', '10002', false, 123],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.setLike.mock.calls).toEqual([[QzoneFeedReferenceExample, true], [QzoneFeedReferenceExample, false]]);
|
||||
});
|
||||
|
||||
it('normalizes SL root comments and complete reply pairs while retaining native comments', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
const root = await action.handle({ tid: 'post', content: false, ignored: true } as never, 'test', {} as never);
|
||||
await action.handle({
|
||||
tid: 'post',
|
||||
target_uin: 10002,
|
||||
content: '回复',
|
||||
comment_id: 0,
|
||||
comment_uin: 10003,
|
||||
reply_uin: '10004',
|
||||
reply_name: ' 回复目标 ',
|
||||
images: [],
|
||||
}, 'test', {} as never);
|
||||
const native = await action.handle({ feed_ref: QzoneFeedReferenceExample, content: '原生评论' }, 'test', {} as never);
|
||||
|
||||
expect(root).toMatchObject({ status: 'ok', data: { comment_id: 'sl-comment' } });
|
||||
expect(core.apis.QzoneApi.commentSnowluma.mock.calls).toEqual([
|
||||
['post', 'false', undefined, undefined],
|
||||
['post', '回复', '10002', { comment_id: '0', comment_uin: '10003', reply_uin: '10004', reply_name: ' 回复目标 ' }],
|
||||
]);
|
||||
expect(native).toMatchObject({ status: 'ok', data: { comment_id: 'native-comment', comment: { id: 'native-comment', extra: 'raw' } } });
|
||||
expect(core.apis.QzoneApi.comment).toHaveBeenCalledExactlyOnceWith(QzoneFeedReferenceExample, '原生评论', undefined, undefined);
|
||||
});
|
||||
|
||||
it('uploads root comment images in order and removes temporary sources while preserving user files', async () => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
const localImage = join(directory, 'user-image.png');
|
||||
writeFileSync(localImage, 'user image');
|
||||
core.apis.QzoneApi.uploadCommentImage
|
||||
.mockResolvedValueOnce({ url: 'https://images.example.test/first.png' })
|
||||
.mockResolvedValueOnce({ url: 'https://images.example.test/second.png' });
|
||||
|
||||
await new CommentQzone(undefined as never, core as never)._handle({
|
||||
tid: 'post', content: '图片评论', images: ['base64://aGVsbG8=', pathToFileURL(localImage).href],
|
||||
});
|
||||
|
||||
expect(core.apis.QzoneApi.uploadCommentImage.mock.calls).toEqual([
|
||||
[Buffer.from('hello').toString('base64')], [Buffer.from('user image').toString('base64')],
|
||||
]);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).toHaveBeenCalledExactlyOnceWith('post', '图片评论', undefined, {
|
||||
imageUrls: ['https://images.example.test/first.png', 'https://images.example.test/second.png'],
|
||||
});
|
||||
expect(readdirSync(directory)).toEqual(['user-image.png']);
|
||||
expect(readFileSync(localImage, 'utf8')).toBe('user image');
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['upload failure', 'invalid URL'])('stops comment publishing after %s and cleans temporary images', async (failure) => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
if (failure === 'upload failure') core.apis.QzoneApi.uploadCommentImage.mockRejectedValue(new Error('upload failed'));
|
||||
else core.apis.QzoneApi.uploadCommentImage.mockResolvedValue({ url: 'javascript:alert(1)' });
|
||||
|
||||
await expect(new CommentQzone(undefined as never, core as never)._handle({
|
||||
tid: 'post', content: '图片评论', images: ['base64://aGVsbG8=', 'base64://d29ybGQ='],
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(core.apis.QzoneApi.uploadCommentImage).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops the image comment chain when either account identifier changes during upload', async () => {
|
||||
for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
core.apis.QzoneApi.uploadCommentImage.mockImplementationOnce(async () => {
|
||||
Object.assign(core.selfInfo, change);
|
||||
return { url: 'https://images.example.test/uploaded.png' };
|
||||
});
|
||||
|
||||
await expect(new CommentQzone(undefined as never, core as never)._handle({
|
||||
tid: 'post', content: '图片评论', images: ['base64://aGVsbG8=', 'base64://d29ybGQ='],
|
||||
})).rejects.toThrow('QQ账号已切换');
|
||||
|
||||
expect(core.apis.QzoneApi.uploadCommentImage).toHaveBeenCalledTimes(1);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).not.toHaveBeenCalled();
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ambiguous routing and malformed known parameters before any API call', async () => {
|
||||
const cases = [
|
||||
[GetQzoneFeeds, { format: 'native', page_num: 1 }],
|
||||
[GetQzoneFeeds, { cursor: 'native-cursor', count: 10 }],
|
||||
[GetQzoneFeeds, { format: 'native', cursor: 'slqf:cursor' }],
|
||||
[GetQzoneFeeds, { format: 'snowluma', cursor: 'native-cursor' }],
|
||||
[GetQzoneFeeds, { page_num: false }],
|
||||
[GetQzoneFeeds, { count: 51 }],
|
||||
[GetQzoneFeeds, { target_uin: '10002' }],
|
||||
[GetQzoneMsgList, { format: 'native', pos: 0 }],
|
||||
[GetQzoneMsgList, { cursor: 'native-cursor', num: 20 }],
|
||||
[GetQzoneMsgList, { format: 'snowluma', cursor: 'native-cursor' }],
|
||||
[GetQzoneMsgList, { cursor: 'slqf:cursor' }],
|
||||
[GetQzoneMsgList, { target_uin: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[GetQzoneMsgList, { pos: null }],
|
||||
[GetQzoneMsgList, { num: 0 }],
|
||||
[GetQzoneMsgDetail, {}],
|
||||
[GetQzoneMsgDetail, { feed_ref: QzoneFeedReferenceExample, tid: 'post' }],
|
||||
[GetQzoneMsgDetail, { feed_ref: QzoneFeedReferenceExample, pos: 0 }],
|
||||
[GetQzoneMsgDetail, { tid: true }],
|
||||
[GetQzoneMsgDetail, { tid: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[LikeQzone, { tid: 'post', abstime: -1 }],
|
||||
[LikeQzone, { tid: 'post', abstime: true }],
|
||||
[UnlikeQzone, { feed_ref: QzoneFeedReferenceExample, target_uin: '10002' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment_id: 0 }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment_uin: '10002' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', reply_uin: '10002' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', reply_name: 'name' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', reply_uin: '10002', reply_name: 'name' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment_id: 0, comment_uin: '9007199254740993' }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', comment: { id: 'native-parent', user: { uin: '10002' } } }],
|
||||
[CommentQzone, { feed_ref: QzoneFeedReferenceExample, content: 'x', images: [] }],
|
||||
[CommentQzone, { tid: 'post', content: null }],
|
||||
[CommentQzone, { tid: 'post', content: 'x', images: [''] }],
|
||||
[DeleteQzoneMsg, { tid: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[DeleteQzoneMsg, { tid: 'post', target_uin: '10002' }],
|
||||
[SendQzoneMsg, { content: 'x', ugc_right: true }],
|
||||
[SendQzoneMsg, { content: 'x', target_uins: [Number.MAX_SAFE_INTEGER + 1] }],
|
||||
] as const;
|
||||
for (const [Action, payload] of cases) {
|
||||
const core = createCore();
|
||||
const result = await new Action(undefined as never, core as never).handle(payload as never, 'test', {} as never);
|
||||
expect(result.status, JSON.stringify(payload)).toBe('failed');
|
||||
for (const method of [...Object.values(core.apis.QzoneApi), ...Object.values(core.apis.WebApi)]) {
|
||||
expect(method, JSON.stringify(payload)).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('validates all reply and identity fields before any image upload', async () => {
|
||||
const core = createCore();
|
||||
const action = new CommentQzone(undefined as never, core as never);
|
||||
const cases = [
|
||||
{ tid: 'post', comment_id: 0, comment_uin: 10002 },
|
||||
{ tid: 'post', comment_id: 0 },
|
||||
{ tid: 'post', reply_uin: 10002, reply_name: 'name' },
|
||||
{ tid: 'post\n' },
|
||||
{ tid: 'post', comment_uin: false },
|
||||
{ tid: 'post', reply_name: ['invalid'] },
|
||||
{ tid: 'post', images: ['base64://aGVsbG8=', 1] },
|
||||
];
|
||||
for (const payload of cases) {
|
||||
const result = await action.handle({ content: 'x', images: ['base64://aGVsbG8='], ...payload } as never, 'test', {} as never);
|
||||
expect(result.status).toBe('failed');
|
||||
}
|
||||
expect(core.apis.QzoneApi.uploadCommentImage).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.commentSnowluma).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry uncertain SL writes through any native backend', async () => {
|
||||
const core = createCore();
|
||||
core.apis.QzoneApi.setSnowlumaLike.mockRejectedValue(new Error('写入结果未知'));
|
||||
core.apis.QzoneApi.commentSnowluma.mockRejectedValue(new Error('写入结果未知'));
|
||||
|
||||
expect((await new LikeQzone(undefined as never, core as never).handle({ tid: 'post' }, 'test', {} as never)).status).toBe('failed');
|
||||
expect((await new CommentQzone(undefined as never, core as never).handle({ tid: 'post', content: 'x' }, 'test', {} as never)).status).toBe('failed');
|
||||
expect(core.apis.QzoneApi.setSnowlumaLike).toHaveBeenCalledExactlyOnceWith('post', undefined, true, 0);
|
||||
expect(core.apis.QzoneApi.commentSnowluma).toHaveBeenCalledExactlyOnceWith('post', 'x', undefined, undefined);
|
||||
expect(core.apis.QzoneApi.setLike).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.comment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps server publish times, preserves empty image captions, and marks missing times as unknown', async () => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
const action = new SendQzoneMsg(undefined as never, core as never);
|
||||
|
||||
await expect(action._handle({ content: false })).resolves.toEqual({ tid: 'published', time: 1700000000 });
|
||||
await expect(action._handle({ content: '', images: ['base64://aGVsbG8='] }))
|
||||
.resolves.toEqual({ tid: 'published-image', time: 1700000001 });
|
||||
core.apis.QzoneApi.publishMsg.mockResolvedValue({ tid: 'old-mock' } as never);
|
||||
await expect(action._handle({ content: 'x' })).resolves.toEqual({ tid: 'old-mock', time: 0 });
|
||||
|
||||
expect(core.apis.QzoneApi.publishMsg).toHaveBeenNthCalledWith(1, 'false', 1, undefined);
|
||||
expect(core.apis.WebApi.publishQzoneMsg).toHaveBeenCalledExactlyOnceWith('', ['published-richval'], 1, undefined);
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes safe numeric delete IDs and serializes success with explicit null', async () => {
|
||||
const core = createCore();
|
||||
const result = await new DeleteQzoneMsg(undefined as never, core as never).handle({ tid: 0 }, 'test', {} as never);
|
||||
|
||||
expect(core.apis.WebApi.deleteQzoneMsg).toHaveBeenCalledExactlyOnceWith('0');
|
||||
expect(JSON.parse(JSON.stringify(result))).toMatchObject({ status: 'ok', data: null });
|
||||
});
|
||||
|
||||
it('requires a complete initial account identity before starting image publishing', async () => {
|
||||
const core = createCore();
|
||||
const directory = withImageDirectory(core);
|
||||
core.selfInfo.uid = '';
|
||||
|
||||
await expect(new SendQzoneMsg(undefined as never, core as never)._handle({
|
||||
content: '带图', images: ['base64://aGVsbG8='],
|
||||
})).rejects.toThrow('需要已登录');
|
||||
|
||||
expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled();
|
||||
expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled();
|
||||
expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled();
|
||||
expect(readdirSync(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it('publishes schema-valid SL examples and accepts the documented unknown-total metadata', () => {
|
||||
const core = createCore();
|
||||
const actions = [
|
||||
new GetQzoneFeeds(undefined as never, core as never), new GetQzoneMsgList(undefined as never, core as never),
|
||||
new GetQzoneMsgDetail(undefined as never, core as never), new LikeQzone(undefined as never, core as never),
|
||||
new UnlikeQzone(undefined as never, core as never), new CommentQzone(undefined as never, core as never),
|
||||
new SendQzoneMsg(undefined as never, core as never), new DeleteQzoneMsg(undefined as never, core as never),
|
||||
];
|
||||
for (const action of actions) {
|
||||
expect(TypeCompiler.Compile(action.payloadSchema).Check(action.payloadExample)).toBe(true);
|
||||
expect(TypeCompiler.Compile(action.returnSchema).Check(action.returnExample)).toBe(true);
|
||||
}
|
||||
expect(TypeCompiler.Compile(actions[1]!.returnSchema).Check({ total: 0, total_known: false, msglist: [] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
import { NTQQWebApi } from '@/napcat-core/apis/webapi';
|
||||
import { RequestUtil } from '@/napcat-common/src/request';
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '本人' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
function response (data: Record<string, unknown>): Response {
|
||||
return new Response(`_preloadCallback(${JSON.stringify(data)});`);
|
||||
}
|
||||
|
||||
function feedPage (key: string, hasMore: boolean, state: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { code: 0, data: { data: [{ uin: 20002, nickname: '好友', abstime: 1700000000, appid: 311, key, html: '' }], main: { hasMoreFeeds: hasMore, ...state } } };
|
||||
}
|
||||
|
||||
describe('SnowLuma Qzone compatibility transport', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('reads the requested legacy list offset/count and keeps a real total', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, total: 100, msglist: [{ tid: 'post', content: '正文', created_time: 1700000000, cmtnum: 5, secret: 1 }] }));
|
||||
await expect(api.getSnowlumaMsgList('20002', 40, 10)).resolves.toMatchObject({
|
||||
total: 100, total_known: true, msglist: [{ tid: 'post', content: '正文', time: 1700000000, comment_num: 5, is_private: true, images: [] }],
|
||||
});
|
||||
const url = new URL(String(fetchMock.mock.calls[0]![0]));
|
||||
expect(url.pathname).toBe('/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ uin: '20002', pos: '40', num: '10', replynum: '100', g_tk: '177685' });
|
||||
expect(fetchMock.mock.calls[0]![1]).toMatchObject({ method: 'GET', redirect: 'error' });
|
||||
});
|
||||
|
||||
it('uses the alternate fixed read route only for explicit code -10000', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: -10000 })).mockResolvedValueOnce(response({ code: 0, total: 0, msglist: [] }));
|
||||
await expect(api.getSnowlumaMsgList()).resolves.toMatchObject({ total: 0, msglist: [] });
|
||||
const [url, options] = fetchMock.mock.calls[1]!;
|
||||
expect(new URL(String(url)).origin).toBe('https://user.qzone.qq.com');
|
||||
expect(new URL(String(url)).pathname).toBe('/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6');
|
||||
expect(options?.headers).toMatchObject({ Referer: 'https://user.qzone.qq.com/10001' });
|
||||
fetchMock.mockResolvedValueOnce(response({ code: -3000 }));
|
||||
await expect(api.getSnowlumaMsgList()).rejects.toThrow('-3000');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reads a tid and one flat comment page without synthesizing a modern feed reference', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, tid: 'post', content: '正文', cmtnum: 90, commentlist: [{ tid: 0, uin: 20002, name: '甲', create_time: 1700000000, content: '评论', list_3: [{ tid: 1, uin: 20003, name: '乙', create_time: 1700000001, content: '回复' }] }] }));
|
||||
const page = await api.getSnowlumaMsgDetail('post', '20002', 20, 5);
|
||||
expect(page).toMatchObject({ tid: 'post', comment_num: 90, comments: [{ comment_id: '0', replies: [{ comment_id: '1', parent_comment_id: '0', parent_comment_uin: 20002 }] }] });
|
||||
const url = new URL(String(fetchMock.mock.calls[0]![0]));
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ tid: 'post', uin: '20002', pos: '20', num: '5', not_trunc_con: '1' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid identities and pagination before ticket acquisition', async () => {
|
||||
const { api, core } = createApi();
|
||||
await expect(api.getSnowlumaMsgList('bad')).rejects.toThrow();
|
||||
await expect(api.getSnowlumaMsgList(undefined, -1)).rejects.toThrow();
|
||||
await expect(api.getSnowlumaMsgDetail('post', undefined, 0, 101)).rejects.toThrow();
|
||||
await expect(api.getSnowlumaFeeds(21)).rejects.toThrow('预算');
|
||||
await expect(api.getSnowlumaFeeds(1, 51)).rejects.toThrow();
|
||||
await expect(api.uploadCommentImage('not base64')).rejects.toThrow();
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts each tid-based like/unlike exactly once with owner and timestamp', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0 })).mockResolvedValueOnce(response({ code: 0 }));
|
||||
await api.setSnowlumaLike('post', '20002', true, 1700000000);
|
||||
await api.setSnowlumaLike('post', '20002', false, 1700000000);
|
||||
expect(new URL(String(fetchMock.mock.calls[0]![0])).pathname).toContain('internal_dolike_app');
|
||||
expect(new URL(String(fetchMock.mock.calls[1]![0])).pathname).toContain('internal_unlike_app');
|
||||
expect(Object.fromEntries(new URLSearchParams(fetchMock.mock.calls[0]![1]?.body as string))).toMatchObject({
|
||||
opuin: '10001', fid: 'post', abstime: '1700000000', unikey: 'http://user.qzone.qq.com/20002/mood/post',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uploads a comment image using native tickets and the returned real URL', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response('frameElement.callback({"code":0,"data":{"url":"https://photo.example/image?a=1&b=2"}});</script>'));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).resolves.toEqual({ url: 'https://photo.example/image?a=1&b=2' });
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toBe('https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=177685');
|
||||
expect(options).toMatchObject({ method: 'POST', redirect: 'error' });
|
||||
expect(new URLSearchParams(options?.body as string).get('picfile')).toBe('aGVsbG8=');
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { url: 'javascript:private()' } }));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).rejects.toThrow('图片地址无效');
|
||||
for (const url of [' https://photo.example/a ', 'https://photo.example/a' + String.fromCharCode(0)]) {
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { url } }));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).rejects.toThrow('可用图片地址');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the observed FormSender upload envelope with ret acknowledgement and rejects conflicting codes', async () => {
|
||||
const { api } = createApi();
|
||||
const html = (body: Record<string, unknown>) => '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body><script type="text/javascript">document.domain="h5.qzone.qq.com";frameElement.callback(' + JSON.stringify(body) + ');</script></body></html>';
|
||||
fetchMock.mockResolvedValueOnce(new Response(html({ ret: 0, data: { url: 'https://photo.example/test.png' } })));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).resolves.toEqual({ url: 'https://photo.example/test.png' });
|
||||
fetchMock.mockResolvedValueOnce(new Response(html({ ret: 0, code: -1, data: { url: 'https://photo.example/test.png' } })));
|
||||
await expect(api.uploadCommentImage('aGVsbG8=')).rejects.toThrow('勿自动重试');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('posts image comments with URLs and preserves the real parent for replies', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, commentid: 0 }));
|
||||
await expect(api.commentSnowluma('post', '图片', '20002', { imageUrls: ['https://photo.example/a'] })).resolves.toEqual({ comment_id: '0' });
|
||||
const commentBody = new URLSearchParams(fetchMock.mock.calls[0]![1]?.body as string);
|
||||
expect(Object.fromEntries(commentBody)).toMatchObject({ hostUin: '20002', uin: '10001', topicId: '20002_post__1', richtype: '1', richval: 'https://photo.example/a' });
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { id: 'new-reply' } }));
|
||||
await expect(api.commentSnowluma('post', '回复', '20002', { comment_id: '0', comment_uin: '20003', reply_uin: '20004', reply_name: '丙' })).resolves.toEqual({ comment_id: 'new-reply', parent_comment_id: '0', parent_comment_uin: 20003 });
|
||||
const replyBody = new URLSearchParams(fetchMock.mock.calls[1]![1]?.body as string);
|
||||
expect(Object.fromEntries(replyBody)).toMatchObject({ uin: '10001', hostUin: '20002', commentId: '0', commentUin: '20003' });
|
||||
expect(replyBody.get('content')).toContain('20004');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never replays an uncertain write and rejects image replies before network IO', async () => {
|
||||
const { api } = createApi();
|
||||
await expect(api.commentSnowluma('post', '回复', '20002', { comment_id: '0', comment_uin: '20003', imageUrls: ['https://photo.example/a'] })).rejects.toThrow();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
fetchMock.mockRejectedValueOnce(new Error('private transport detail'));
|
||||
await expect(api.commentSnowluma('post', '正文')).rejects.toThrow('勿自动重试');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the existing image publisher if the account changes during either native ticket lookup', async () => {
|
||||
const request = vi.spyOn(RequestUtil, 'HttpGetText').mockRejectedValue(new Error('unexpected network request'));
|
||||
for (const stage of ['skey', 'pskey']) {
|
||||
const { core } = createApi();
|
||||
const web = new NTQQWebApi({} as never, core as never);
|
||||
if (stage === 'skey') {
|
||||
core.apis.UserApi.getSKey.mockImplementationOnce(async () => { core.selfInfo.uid = 'switched'; return 's'; });
|
||||
} else {
|
||||
core.apis.UserApi.getPSkey.mockImplementationOnce(async () => {
|
||||
core.selfInfo.uid = 'switched';
|
||||
return { result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) };
|
||||
});
|
||||
}
|
||||
await expect(web.uploadImageToQzone('aGVsbG8=')).rejects.toThrow('QQ账号已切换');
|
||||
}
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gets HTML feeds with bounded opaque continuation and actual server state', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('first', true, { pagenum: '2', begintime: '123', aisortNextTime: '456', externparam: 'opaque' })));
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('second', false)));
|
||||
const first = await api.getSnowlumaFeeds();
|
||||
expect(first.next_cursor).toMatch(/^slqf:/);
|
||||
expect(first.feeds[0]?.key).toBe('first');
|
||||
const next = await api.getSnowlumaFeeds(1, 5, first.next_cursor!);
|
||||
expect(next.feeds[0]?.key).toBe('second');
|
||||
expect(next.next_cursor).toBeNull();
|
||||
const url = new URL(String(fetchMock.mock.calls[1]![0]));
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ count: '5', pagenum: '2', begintime: '123', getAisort: '456', externparam: 'opaque', outputhtmlfeed: '1' });
|
||||
expect(url.searchParams.has('aisortNextTime')).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('walks server continuations for explicit page numbers and stops at the actual end', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('skip', true, { pagenum: '2' }))).mockResolvedValueOnce(response(feedPage('wanted', false)));
|
||||
const page = await api.getSnowlumaFeeds(2);
|
||||
expect(page.feeds.map(item => item.key)).toEqual(['wanted']);
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('last', false)));
|
||||
await expect(api.getSnowlumaFeeds(3)).resolves.toEqual({ feeds: [], has_more: false, next_cursor: null });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('binds feed cursors to account, page, lifetime and prevents clock-only continuation', async () => {
|
||||
const { api, core } = createApi();
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('first', true, { pagenum: '2' })));
|
||||
const first = await api.getSnowlumaFeeds();
|
||||
await expect(api.getSnowlumaFeeds(3, 10, first.next_cursor!)).rejects.toThrow('不匹配');
|
||||
core.selfInfo.uid = 'other-account';
|
||||
await expect(api.getSnowlumaFeeds(1, 10, first.next_cursor!)).rejects.toThrow('游标');
|
||||
core.selfInfo.uid = 'self-uid';
|
||||
clock.mockReturnValue(601000);
|
||||
await expect(api.getSnowlumaFeeds(1, 10, first.next_cursor!)).rejects.toThrow('已过期');
|
||||
clock.mockReturnValue(1000);
|
||||
fetchMock.mockResolvedValueOnce(response(feedPage('stuck', true, { icServerTime: '999' })));
|
||||
await expect(api.getSnowlumaFeeds()).rejects.toThrow('未推进');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enforces a shared 45-second read budget while skipping feed pages', async () => {
|
||||
const { api } = createApi();
|
||||
let now = 1000;
|
||||
let pages = 0;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
fetchMock.mockImplementation(async () => {
|
||||
now += 16000;
|
||||
return response(feedPage('page', true, { pagenum: String(++pages + 1) }));
|
||||
});
|
||||
await expect(api.getSnowlumaFeeds(4)).rejects.toThrow('预算');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildSlQzoneCommentBody,
|
||||
buildSlQzoneLikeBody,
|
||||
parseSlQzoneComment,
|
||||
parseSlQzoneMsgDetail,
|
||||
parseSlQzoneMsgList,
|
||||
} from '@/napcat-core/data/qzone-snowluma';
|
||||
|
||||
describe('SnowLuma-compatible mood list projections', () => {
|
||||
test('maps wire fields and photo preference while retaining exact body text', () => {
|
||||
const result = parseSlQzoneMsgList({
|
||||
code: 0,
|
||||
total: '23',
|
||||
msglist: [{
|
||||
tid: 'post-local-x',
|
||||
content: ' independently written\ntext ',
|
||||
created_time: '31',
|
||||
cmtnum: '8',
|
||||
secret: '1',
|
||||
pic: [{ smallurl: 'https://cdn.example.test/tiny', url2: 'https://cdn.example.test/medium', url3: 'https://cdn.example.test/full' }],
|
||||
}],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
total: 23,
|
||||
total_known: true,
|
||||
msglist: [{
|
||||
tid: 'post-local-x', content: ' independently written\ntext ', time: 31, comment_num: 8, is_private: true, images: ['https://cdn.example.test/full'],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('labels the page-length total fallback and preserves the declared legacy sentinels', () => {
|
||||
expect(parseSlQzoneMsgList({ msglist: [{}] })).toEqual({
|
||||
total: 1, total_known: false, msglist: [{ tid: '', content: '', time: 0, comment_num: 0, is_private: false, images: [] }],
|
||||
});
|
||||
expect(parseSlQzoneMsgList({ total: 0, msglist: [] })).toEqual({ total: 0, total_known: true, msglist: [] });
|
||||
expect(parseSlQzoneMsgList({ total: null, msglist: [] }).total_known).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects missing arrays, unsafe identities and malformed supplied values rather than inventing success', () => {
|
||||
for (const response of [
|
||||
{}, { msglist: null }, { msglist: [null] }, { msglist: [{}], total: 'bad' },
|
||||
{ msglist: [{ tid: Number.MAX_SAFE_INTEGER + 1 }] }, { msglist: [{ created_time: -1 }] },
|
||||
{ msglist: [{ cmtnum: 'bad' }] }, { msglist: [{ content: false }] }, { msglist: [{ pic: 'bad' }] },
|
||||
{ code: -10000, subcode: 0, msglist: [] }, { code: 0, subcode: '0', msglist: [] },
|
||||
]) expect(() => parseSlQzoneMsgList(response)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function detail (overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
code: 0,
|
||||
tid: 'discussion-local-z',
|
||||
content: 'discussion text',
|
||||
cmtnum: '41',
|
||||
commentlist: [{
|
||||
tid: 0,
|
||||
uin: '135790',
|
||||
name: 'parent author',
|
||||
create_time: '7',
|
||||
content: 'parent text',
|
||||
list_3: [
|
||||
{ tid: 'nested-local-b', uin: 246810, name: 'reply author', create_time: 9, content: 'reply text' },
|
||||
],
|
||||
}],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SnowLuma-compatible tid detail', () => {
|
||||
test('normalizes legacy create_time and parent identities while preserving comment ID zero', () => {
|
||||
expect(parseSlQzoneMsgDetail(detail(), 'discussion-local-z')).toEqual({
|
||||
tid: 'discussion-local-z',
|
||||
content: 'discussion text',
|
||||
comment_num: 41,
|
||||
comments: [{
|
||||
comment_id: '0',
|
||||
uin: 135790,
|
||||
name: 'parent author',
|
||||
time: 7,
|
||||
content: 'parent text',
|
||||
replies: [{
|
||||
comment_id: 'nested-local-b', uin: 246810, name: 'reply author', time: 9, content: 'reply text', parent_comment_id: '0', parent_comment_uin: 135790,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('treats only missing or null comment batches as no supplied inline comments', () => {
|
||||
expect(parseSlQzoneMsgDetail(detail({ commentlist: null }), 'discussion-local-z').comments).toEqual([]);
|
||||
expect(parseSlQzoneMsgDetail(detail({ commentlist: undefined }), 'discussion-local-z').comment_num).toBe(41);
|
||||
expect(parseSlQzoneMsgDetail(detail({ commentlist: [{ tid: 'parent-only', uin: 135790, name: '', create_time: 0, content: '', list_3: null }] }), 'discussion-local-z').comments[0]?.replies).toEqual([]);
|
||||
});
|
||||
|
||||
test('requires matching post identity, measured total and complete comment fields', () => {
|
||||
for (const value of [
|
||||
detail({ tid: 'different-post' }), detail({ content: undefined }), detail({ cmtnum: undefined }),
|
||||
detail({ commentlist: {} }), detail({ commentlist: [{ tid: 0, uin: 135790, name: 'author', time: 10, content: 'wrong timestamp field' }] }),
|
||||
detail({ commentlist: [{ tid: Number.MAX_SAFE_INTEGER + 1, uin: 135790, name: 'author', create_time: 10, content: '' }] }),
|
||||
detail({ commentlist: [{ tid: 'parent', uin: '9007199254740993', name: 'author', create_time: 10, content: '' }] }),
|
||||
detail({ code: 0, subcode: -99 }),
|
||||
]) expect(() => parseSlQzoneMsgDetail(value, 'discussion-local-z')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowLuma-compatible like and comment forms', () => {
|
||||
test('builds tid-based like resources with distinct operator and post owner', () => {
|
||||
const enabled = new URLSearchParams(buildSlQzoneLikeBody('246810', '135790', 'post-local-x', true, 42));
|
||||
const disabled = new URLSearchParams(buildSlQzoneLikeBody('246810', '135790', 'post-local-x', false, 42));
|
||||
expect(enabled.get('unikey')).toBe('http://user.qzone.qq.com/135790/mood/post-local-x');
|
||||
expect(enabled.get('curkey')).toBe(enabled.get('unikey'));
|
||||
expect(Object.fromEntries(enabled)).toMatchObject({ opuin: '246810', fid: 'post-local-x', abstime: '42', appid: '311', qzreferrer: 'https://user.qzone.qq.com/246810' });
|
||||
expect([...disabled]).toEqual([...enabled]);
|
||||
expect(() => buildSlQzoneLikeBody('246810', 'bad-owner', 'post-local-x', true, 0)).toThrow();
|
||||
expect(() => buildSlQzoneLikeBody('246810', '135790', 'post-local-x', true, -1)).toThrow();
|
||||
expect(() => buildSlQzoneLikeBody('246810', '135790', 'post-local-x', 'false' as never, 0)).toThrow();
|
||||
});
|
||||
|
||||
test('uses direct uploaded URLs for ordinary image comments and keeps whitespace unchanged', () => {
|
||||
const body = new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', ' image comment\n ', {
|
||||
imageUrls: ['https://cdn.example.test/comment-a', 'https://cdn.example.test/comment-b'],
|
||||
}));
|
||||
expect(Object.fromEntries(body)).toMatchObject({
|
||||
uin: '246810',
|
||||
hostUin: '135790',
|
||||
topicId: '135790_post-local-x__1',
|
||||
content: ' image comment\n ',
|
||||
richtype: '1',
|
||||
richval: 'https://cdn.example.test/comment-a\thttps://cdn.example.test/comment-b',
|
||||
format: 'json',
|
||||
ref: 'feeds',
|
||||
feedsType: '100',
|
||||
});
|
||||
expect(body.has('commentId')).toBe(false);
|
||||
expect(new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'text')).get('richtype')).toBe('');
|
||||
});
|
||||
|
||||
test('separates parent author from addressed recipient and escapes mention grammar before form encoding', () => {
|
||||
const options = { comment_id: '0', comment_uin: '135790', reply_uin: '112233', reply_name: ' recipient%,} ' };
|
||||
const body = new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'reply body', options));
|
||||
expect(Object.fromEntries(body)).toMatchObject({ uin: '246810', hostUin: '135790', hostuin: '246810', topicId: '135790_post-local-x', commentId: '0', commentUin: '135790', format: 'fs', ref: '' });
|
||||
expect(body.get('content')).toBe('@{uin:112233,nick:recipient%25%2C%7D,auto:1} reply body');
|
||||
expect(body.has('replyId')).toBe(false);
|
||||
expect(body.has('richval')).toBe(false);
|
||||
expect(new URLSearchParams(buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'reply body', { comment_id: '0', comment_uin: '135790' })).get('content')).toBe('reply body');
|
||||
});
|
||||
|
||||
test('rejects mismatched parent/recipient pairs, unsafe authors and any image reply before returning a body', () => {
|
||||
for (const options of [
|
||||
{ comment_id: '0' }, { comment_uin: '135790' }, { reply_uin: '112233', reply_name: 'recipient' },
|
||||
{ comment_id: '0', comment_uin: '135790', reply_uin: '112233' },
|
||||
{ comment_id: '0', comment_uin: '9007199254740993' },
|
||||
{ comment_id: '0', comment_uin: '135790', imageUrls: ['https://cdn.example.test/picture'] },
|
||||
{ imageUrls: ['javascript:alert(1)'] }, { imageUrls: ['https://user:password@cdn.example.test/picture'] },
|
||||
]) expect(() => buildSlQzoneCommentBody('246810', '135790', 'post-local-x', 'body', options)).toThrow();
|
||||
expect(() => buildSlQzoneCommentBody('246810', '135790', 'post-local-x', ' ', { comment_id: '0', comment_uin: '135790' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowLuma-compatible comment acknowledgements', () => {
|
||||
test('supports both ordinary ID casings and the documented empty-ID sentinel only after acknowledgement', () => {
|
||||
expect(parseSlQzoneComment({ code: 0, commentid: 0 })).toEqual({ comment_id: '0' });
|
||||
expect(parseSlQzoneComment({ subcode: 0, commentId: 'fresh-comment' })).toEqual({ comment_id: 'fresh-comment' });
|
||||
expect(parseSlQzoneComment({ code: 0 })).toEqual({ comment_id: '' });
|
||||
expect(parseSlQzoneComment({ code: 0, data: { id: 'another-endpoint-shape' } })).toEqual({ comment_id: '' });
|
||||
expect(() => parseSlQzoneComment({})).toThrow('成功状态');
|
||||
expect(() => parseSlQzoneComment({ code: 0, commentid: Number.MAX_SAFE_INTEGER + 1 })).toThrow();
|
||||
});
|
||||
|
||||
test('requires a real reply identity and returns the supplied safe parent identity', () => {
|
||||
const options = { comment_id: 'parent-local-c', comment_uin: '135790' };
|
||||
expect(parseSlQzoneComment({ code: 0, data: { id: 0 } }, options)).toEqual({ comment_id: '0', parent_comment_id: 'parent-local-c', parent_comment_uin: 135790 });
|
||||
for (const response of [{ code: 0 }, { code: 0, data: {} }, { code: 0, data: { id: '' } }, { subcode: 0, data: { id: 'not-acknowledged' } }]) {
|
||||
expect(() => parseSlQzoneComment(response, options)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects either error code without echoing server message text', () => {
|
||||
expect(() => parseSlQzoneComment({ code: 0, subcode: -9, message: 'private response text' })).toThrow('QQ空间兼容接口拒绝请求 (subcode=-9)');
|
||||
expect(() => parseSlQzoneComment({ code: '0', commentid: 'ignored' })).toThrow('QQ空间兼容接口拒绝请求');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
advanceSlQzoneFeedState,
|
||||
initialSlQzoneFeedState,
|
||||
parseSlQzoneFeeds,
|
||||
slQzoneFeedPosition,
|
||||
type SlQzoneFeedState,
|
||||
} from '@/napcat-core/data/qzone-snowluma-feeds';
|
||||
|
||||
// All payloads here are newly synthesized protocol data, not reference-project fixtures.
|
||||
function packet (items: unknown[], main: Record<string, unknown> = { hasMoreFeeds: false }): string {
|
||||
return `_preloadCallback(${JSON.stringify({ code: 0, subcode: 0, data: { main, data: items } })});`;
|
||||
}
|
||||
|
||||
function post (html: string, extra: Record<string, unknown> = {}) {
|
||||
return { uin: '330003', nickname: '林', abstime: '1800000123', appid: '311', key: 'entry-nine', html, ...extra };
|
||||
}
|
||||
|
||||
function ownHtml (body: string, footer = ''): string {
|
||||
return `<article class="f-single"><div class="user-pto"><img src="https://example.test/portrait.png"></div><div class="f-item" data-key="entry-nine"><input name="feed_data" data-uin="330003" data-fkey="entry-nine" data-tid="tid-nine">${body}</div>${footer}</article>`;
|
||||
}
|
||||
|
||||
describe('independent SL feed data-literal parsing', () => {
|
||||
it('accepts the required JS data subset without evaluating it', () => {
|
||||
const input = '_preloadCallback({code:0,subcode:0,data:{main:{hasMoreFeeds:false},data:[undefined,,{uin:\'330003\',nickname:\'云\\u2601\',abstime:1800000123,appid:\'311\',key:\'entry-nine\',html:\'\\x3Cdiv class="f-item" data-key="entry-nine"\\x3E\\x3Cdiv class="f-info"\\x3E文字 undefined, } )\\x3C/div\\x3E\\x3C/div\\x3E\',},],},});';
|
||||
const result = parseSlQzoneFeeds(input);
|
||||
expect(result.has_more).toBe(false);
|
||||
expect(result.feeds).toHaveLength(1);
|
||||
expect(result.feeds[0]).toMatchObject({ uin: 330003, nickname: '云☁', time: 1800000123, appid: 311, content: '文字 undefined, } )', content_complete: true });
|
||||
expect(result.feeds[0]?.html).toContain('<div');
|
||||
});
|
||||
|
||||
it('accepts JSON and recognized complete wrappers, including comments inside data only', () => {
|
||||
const data = { code: 0, data: { data: [], hasmore: '0' } };
|
||||
for (const text of [JSON.stringify(data), `_Callback(${JSON.stringify(data)})`, `callback(${JSON.stringify(data)});`, '{/* data-only */code:0,data:{main:{hasMoreFeeds:false},data:[],},}']) {
|
||||
expect(parseSlQzoneFeeds(text)).toMatchObject({ feeds: [], has_more: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not interpret undefined in strings or as a property name', () => {
|
||||
const result = parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false,undefined:\'ordinary key\',externparam:undefined},data:[{uin:330003,appid:311,key:\'entry-nine\',html:\'undefined\',nickname:\'undefined\',abstime:0}]}}');
|
||||
expect(result.feeds[0]?.nickname).toBe('undefined');
|
||||
expect(result.feeds[0]?.html).toBe('undefined');
|
||||
expect(result.main?.['undefined']).toBe('ordinary key');
|
||||
expect(result.main).not.toHaveProperty('externparam');
|
||||
expect(advanceSlQzoneFeedState({ ...initialSlQzoneFeedState(), externparam: 'retained' }, result.main!).externparam).toBe('retained');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});danger();',
|
||||
'danger();_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});',
|
||||
'unknownCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});',
|
||||
'<script>_preloadCallback({code:0,data:{main:{hasMoreFeeds:false},data:[]}});</script>',
|
||||
'{code:0,data:(function(){throw new Error("private");})()}',
|
||||
'{code:0,data:{get main(){return {hasMoreFeeds:false}},data:[]}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:new Date()}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:Infinity}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:NaN}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],"__proto__":{"polluted":true}}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],"constr\\u0075ctor":{}}}',
|
||||
'{code:0,data:{main:{hasMoreFeeds:false},data:[],x:/regex/}}',
|
||||
])('rejects executable or unsupported bodies without echoing their text (%#)', (text) => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseSlQzoneFeeds(text);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toMatch(/private|danger|polluted|function/);
|
||||
expect(({} as { polluted?: boolean; }).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires explicit numeric success and a recognizable page structure', () => {
|
||||
for (const body of [
|
||||
{}, { code: '0', data: { data: [], hasmore: 0 } }, { code: 0, subcode: -1 },
|
||||
{ code: -7, subcode: 0 }, { code: 0, subcode: '0' },
|
||||
{ code: 0, data: {} }, { code: 0, data: { data: [] } },
|
||||
{ code: 0, data: { data: [], main: null } },
|
||||
{ code: 0, data: { data: [], main: { hasMoreFeeds: 'false' } } },
|
||||
{ code: 0, data: { data: [], main: { hasMoreFeeds: false, error: 'private failure' } } },
|
||||
{ code: 0, data: { data: [], hasmore: true } },
|
||||
]) expect(() => parseSlQzoneFeeds(JSON.stringify(body))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects duplicate root success keys even when quoted or escaped, without rejecting ordinary unknown duplicates', () => {
|
||||
const data = 'data:{main:{hasMoreFeeds:false},data:[]}';
|
||||
for (const keys of [
|
||||
'code:-1,code:0',
|
||||
'code:-1,"code":0',
|
||||
'"co\\u0064e":-1,code:0',
|
||||
'co\\u0064e:-1,code:0',
|
||||
'code:0,subcode:-9,"sub\\u0063ode":0',
|
||||
'code:0,subcode:0,subcode:0',
|
||||
]) expect(() => parseSlQzoneFeeds(`_preloadCallback({${keys},${data}});`)).toThrow();
|
||||
expect(parseSlQzoneFeeds('{code:0,message:\'old\',message:\'new\',data:{main:{hasMoreFeeds:false,code:-9,code:0},data:[]}}').feeds).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses main.hasMoreFeeds before the legacy fallback and keeps the raw main state', () => {
|
||||
const main = { hasMoreFeeds: '1', pagenum: '7', externparam: 'sort=chronological&opaque=a%26b', icServerTime: '1800001000' };
|
||||
const result = parseSlQzoneFeeds(packet([], main));
|
||||
expect(result).toEqual({ feeds: [], has_more: true, main });
|
||||
expect(() => parseSlQzoneFeeds(JSON.stringify({ code: 0, data: { main: {}, hasmore: 0, data: [] } }))).toThrow('结束标志');
|
||||
});
|
||||
|
||||
it('bounds input and nesting before passing data to JSON5', () => {
|
||||
expect(() => parseSlQzoneFeeds(' '.repeat(8 * 1024 * 1024 + 1))).toThrow('过大');
|
||||
expect(() => parseSlQzoneFeeds(`{code:0,data:${'['.repeat(66)}0${']'.repeat(66)}}`)).toThrow();
|
||||
expect(() => parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false},data:[/* never closed')).toThrow();
|
||||
expect(() => parseSlQzoneFeeds('{code:0,data:{main:{hasMoreFeeds:false},data:["unterminated]}}')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed real entries rather than dropping them along with padding', () => {
|
||||
for (const item of [12, 'bad', [], post('', { uin: 'not-a-number' }), post('', { abstime: Number.MAX_SAFE_INTEGER + 1 }), post('', { html: {} }), post('', { key: {} })]) {
|
||||
expect(() => parseSlQzoneFeeds(packet([null, item]))).toThrow();
|
||||
}
|
||||
expect(parseSlQzoneFeeds(packet([null, null])).feeds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('independent passive HTML feed projection', () => {
|
||||
it('extracts own text, matching tid, original images and explicit counters while retaining HTML exactly', () => {
|
||||
const html = ownHtml('<div class="f-info">测试 & 文字<br>第二行<img alt="[云]" src="https://example.test/face.gif"></div><a class="img-item" data-originurl="//cdn.example.test/full.webp?a=1&b=2"><img src="https://example.test/thumb.webp"></a>', '<div class="f-single-foot"><a class="qz_like_btn_v3" data-likecnt="17">赞</a></div>');
|
||||
const feed = parseSlQzoneFeeds(packet([post(html, { commentcnt: '6' })])).feeds[0]!;
|
||||
expect(feed).toEqual({
|
||||
uin: 330003,
|
||||
nickname: '林',
|
||||
time: 1800000123,
|
||||
appid: 311,
|
||||
key: 'entry-nine',
|
||||
html,
|
||||
tid: 'tid-nine',
|
||||
content: '测试 & 文字\n第二行[云]',
|
||||
content_complete: true,
|
||||
images: ['https://cdn.example.test/full.webp?a=1&b=2'],
|
||||
like_count: 17,
|
||||
comment_count: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('distinguishes truncated text from hidden complete text without including expand controls', () => {
|
||||
const partial = ownHtml('<div class="f-info">短内容<a data-cmd="qz_toggle">阅读全文</a></div>');
|
||||
expect(parseSlQzoneFeeds(packet([post(partial)])).feeds[0]).toMatchObject({ content: '短内容', content_complete: false });
|
||||
const full = ownHtml('<div class="f-info">短内容<a data-cmd="qz_toggle">展开</a></div><div class="f-info qz_info_complete" style="display:none">完整内容<p>下一段</p><a data-cmd="qz_toggle">收起</a></div>');
|
||||
expect(parseSlQzoneFeeds(packet([post(full)])).feeds[0]).toMatchObject({ content: '完整内容\n下一段', content_complete: true });
|
||||
});
|
||||
|
||||
it('keeps avatars, comments, repost bodies, scripts and video covers out of the original post', () => {
|
||||
const html = ownHtml(`<div class="f-info">原帖<script>dangerous()</script></div>
|
||||
<div class="mod-comments"><div class="f-info">评论内容</div><a class="img-item"><img src="https://example.test/comment.png"></a><a class="qz_like_btn_v3" data-likecnt="9999"></a></div>
|
||||
<div class="f-item-passive"><div class="f-info">转发正文</div><a class="img-item"><img src="https://example.test/repost.png"></a></div>
|
||||
<div class="f-ct-video"><a class="img-item"><img src="https://example.test/video-cover.png"></a></div>
|
||||
<a class="img-item" data-v_itemid="v"><img src="https://example.test/another-cover.png"></a>
|
||||
<a class="img-item"><img truesrc="https://example.test/photo.png" src="https://example.test/loading.png"></a>`);
|
||||
const feed = parseSlQzoneFeeds(packet([post(html)])).feeds[0]!;
|
||||
expect(feed.content).toBe('原帖');
|
||||
expect(feed.images).toEqual(['https://example.test/photo.png']);
|
||||
expect(feed.like_count).toBeNull();
|
||||
});
|
||||
|
||||
it('does not derive a mood tid from a feed key, another author or a non-mood app', () => {
|
||||
const mismatched = ownHtml('<div class="f-info">文字</div>').replace('data-uin="330003"', 'data-uin="440004"');
|
||||
expect(parseSlQzoneFeeds(packet([post(mismatched)])).feeds[0]?.tid).toBeNull();
|
||||
expect(parseSlQzoneFeeds(packet([post(ownHtml('<div class="f-info">相册说明</div>'), { appid: 4 })])).feeds[0]?.tid).toBeNull();
|
||||
const absent = parseSlQzoneFeeds(packet([post('<div class="unrecognized">没有可确认的正文</div>')])).feeds[0]!;
|
||||
expect(absent).toMatchObject({ tid: null, content: null, content_complete: null, images: null, like_count: null, comment_count: null });
|
||||
});
|
||||
|
||||
it('handles normal void tags/entities and deduplicates images without accepting unsafe URLs', () => {
|
||||
const html = ownHtml('<div class="f-info"><p>段落一 文字</p><p>段落二</p></div><a class="img-item" data-url3="https://example.test/a.png"><img src="https://example.test/ignored.png"></a><a class="img-item"><img src="https://example.test/a.png"></a><a class="img-item" data-originurl="javascript:alert(1)"><img src="data:image/png;base64,AAAA"></a>');
|
||||
const feed = parseSlQzoneFeeds(packet([post(html, { likecnt: '4', commentcnt: 0 })])).feeds[0]!;
|
||||
expect(feed.content).toBe('段落一 文字\n\n段落二');
|
||||
expect(feed.images).toEqual(['https://example.test/a.png']);
|
||||
expect(feed.like_count).toBe(4);
|
||||
expect(feed.comment_count).toBe(0);
|
||||
});
|
||||
|
||||
it('declines ambiguous content roots and bounds excessive HTML structure', () => {
|
||||
const duplicate = '<div class="f-item" data-key="entry-nine"><div class="f-info">A</div></div><div class="f-item" data-key="entry-nine"><div class="f-info">B</div></div>';
|
||||
expect(parseSlQzoneFeeds(packet([post(duplicate)])).feeds[0]?.content).toBeNull();
|
||||
expect(() => parseSlQzoneFeeds(packet([post(`<div>${'<span>'.repeat(70)}deep${'</span>'.repeat(70)}</div>`)]))).toThrow('HTML');
|
||||
expect(() => parseSlQzoneFeeds(packet([post('x'.repeat(2 * 1024 * 1024 + 1))]))).toThrow('HTML过大');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SL feed server state helpers', () => {
|
||||
it('uses explicit initial fields and updates only values actually supplied by the server', () => {
|
||||
const first = initialSlQzoneFeedState();
|
||||
expect(first).toEqual({ begintime: '0', dayspac: '0', pagenum: '1', aisortEndTime: '0', aisortOffset: '0', aisortBeginTime: '0', aisortNextTime: '0', icServerTime: '0', externparam: '', daylist: '', uinlist: '' });
|
||||
const next = advanceSlQzoneFeedState(first, { pagenum: '0007', begintime: '1800000000', aisortNextTime: 3, externparam: 'opaque=%26&sort=x', daylist: 'day-a', uinlist: '330003', ignored: 'never put into query state' });
|
||||
expect(next).toMatchObject({ pagenum: '7', begintime: '1800000000', aisortNextTime: '3', externparam: 'opaque=%26&sort=x', daylist: 'day-a', uinlist: '330003' });
|
||||
expect(first.pagenum).toBe('1');
|
||||
const partial = advanceSlQzoneFeedState(next, { externparam: '', daylist: '', uinlist: '', aisortOffset: '8' });
|
||||
expect(partial).toMatchObject({ pagenum: '7', externparam: next.externparam, daylist: 'day-a', uinlist: '', aisortOffset: '8' });
|
||||
expect(partial).not.toHaveProperty('ignored');
|
||||
});
|
||||
|
||||
it('does not count clock-only changes or property order as pagination progress', () => {
|
||||
const state = advanceSlQzoneFeedState(initialSlQzoneFeedState(), { externparam: 'p=2', pagenum: '2' });
|
||||
const changedClock = advanceSlQzoneFeedState(state, { icServerTime: '1800000123' });
|
||||
expect(slQzoneFeedPosition(changedClock)).toBe(slQzoneFeedPosition(state));
|
||||
const reordered = Object.fromEntries(Object.entries(state).reverse()) as unknown as SlQzoneFeedState;
|
||||
expect(slQzoneFeedPosition(reordered)).toBe(slQzoneFeedPosition(state));
|
||||
expect(slQzoneFeedPosition(advanceSlQzoneFeedState(state, { externparam: 'p=3' }))).not.toBe(slQzoneFeedPosition(state));
|
||||
});
|
||||
|
||||
it('preserves large digit strings without rounding and rejects unsafe numeric or oversized state', () => {
|
||||
expect(advanceSlQzoneFeedState(initialSlQzoneFeedState(), { icServerTime: '9007199254740993' }).icServerTime).toBe('9007199254740993');
|
||||
for (const update of [{ pagenum: 0 }, { pagenum: '-2' }, { dayspac: 1.5 }, { begintime: Number.MAX_SAFE_INTEGER + 1 }, { externparam: {} }, { daylist: 'x'.repeat(32769) }]) {
|
||||
expect(() => advanceSlQzoneFeedState(initialSlQzoneFeedState(), update)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NTQQQzoneApi } from '@/napcat-core/apis/qzone';
|
||||
|
||||
function createApi () {
|
||||
const core = {
|
||||
selfInfo: { uin: '10001', uid: 'self-uid', nick: '当前账号' },
|
||||
apis: {
|
||||
UserApi: {
|
||||
getSKey: vi.fn().mockResolvedValue('s'),
|
||||
getPSkey: vi.fn().mockResolvedValue({ result: 0, domainPskeyMap: new Map([['qzone.qq.com', 'p']]) }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: new NTQQQzoneApi({} as never, core as never), core };
|
||||
}
|
||||
|
||||
function response (value: Record<string, unknown>): Response {
|
||||
return new Response(`_Callback(${JSON.stringify(value)});`, { status: 200 });
|
||||
}
|
||||
|
||||
const ownDetail = { code: 0, tid: 'post-tid', uin: '10001', content: ' 原始正文\n ', ugc_right: 1 };
|
||||
|
||||
describe('Qzone profile, visibility and blacklist HTTP integration', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([undefined, '20002'])('reads profile %s using the current account ticket without exposing other fields', async (target) => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: '名称 & +', desc: '空间说明', private_field: 'secret' } }));
|
||||
|
||||
await expect(api.getProfile(target)).resolves.toEqual({ name: '名称 & +', description: '空间说明' });
|
||||
|
||||
const [rawUrl, options] = fetchMock.mock.calls[0]!;
|
||||
const url = new URL(String(rawUrl));
|
||||
expect(url.origin + url.pathname).toBe('https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ uin: target ?? '10001', vuin: '10001', fupdate: '1', g_tk: '177685' });
|
||||
expect(options).toMatchObject({ method: 'GET', redirect: 'error', headers: { Cookie: 'uin=o10001; p_uin=o10001; skey=s; p_skey=p' } });
|
||||
expect(options?.body).toBeUndefined();
|
||||
expect(core.apis.UserApi.getPSkey).toHaveBeenCalledExactlyOnceWith(['qzone.qq.com']);
|
||||
});
|
||||
|
||||
it('preserves empty profile values and rejects incomplete data', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: '', desc: '' } }));
|
||||
await expect(api.getProfile()).resolves.toEqual({ name: '', description: '' });
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, data: { spacename: 'Name' } }));
|
||||
await expect(api.getProfile()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ patch: { name: '新空间 & +' }, bitmap: '2048', fields: { spacename: '新空间 & +' } },
|
||||
{ patch: { description: '' }, bitmap: '4096', fields: { desc: '' } },
|
||||
{ patch: { name: '', description: '第一行\r\n第二行' }, bitmap: '6144', fields: { spacename: '', desc: '第一行\n第二行' } },
|
||||
])('updates only supplied profile fields (bitmap $bitmap)', async ({ patch, bitmap, fields }) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0 }));
|
||||
|
||||
await expect(api.setProfile(patch)).resolves.toBeUndefined();
|
||||
|
||||
const [rawUrl, options] = fetchMock.mock.calls[0]!;
|
||||
expect(new URL(String(rawUrl)).pathname).toBe('/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new');
|
||||
expect(options).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||||
expect(Object.fromEntries(new URLSearchParams(options?.body as string))).toEqual({ uin: '10001', pageindex: '3', fupdate: '1', mb: bitmap, ...fields });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects invalid management arguments before acquiring tickets or making requests', async () => {
|
||||
const { api, core } = createApi();
|
||||
|
||||
await expect(api.getProfile('invalid')).rejects.toThrow();
|
||||
await expect(api.setProfile({})).rejects.toThrow();
|
||||
await expect(api.setProfile({ name: [] } as never)).rejects.toThrow();
|
||||
await expect(api.setMsgRight('post-tid', 2)).rejects.toThrow();
|
||||
await expect(api.setMsgRight('post-tid', 16, [])).rejects.toThrow();
|
||||
await expect(api.setMsgRight('post-tid', 128, ['20002', 'bad'])).rejects.toThrow();
|
||||
await expect(api.setBan('bad')).rejects.toThrow();
|
||||
await expect(api.setBan('20002', 'false' as never)).rejects.toThrow();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(core.apis.UserApi.getSKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the complete own post before changing visibility without changing content', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, subcode: 0, ugc_right: 16 }));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 16, ['20002', '20002', '9007199254740993'])).resolves.toEqual({ ugc_right: 16 });
|
||||
|
||||
const detailUrl = new URL(String(fetchMock.mock.calls[0]![0]));
|
||||
expect(detailUrl.pathname).toBe('/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6');
|
||||
expect(Object.fromEntries(detailUrl.searchParams)).toMatchObject({
|
||||
tid: 'post-tid', uin: '10001', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1',
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
|
||||
const [updateUrl, options] = fetchMock.mock.calls[1]!;
|
||||
expect(new URL(String(updateUrl)).pathname).toBe('/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update');
|
||||
expect(options?.method).toBe('POST');
|
||||
const body = new URLSearchParams(options?.body as string);
|
||||
expect(body.get('con')).toBe(ownDetail.content);
|
||||
expect(body.get('tid')).toBe('post-tid');
|
||||
expect(body.get('hostuin')).toBe('10001');
|
||||
expect(body.get('ugc_right')).toBe('16');
|
||||
expect(body.get('allow_uins')).toBe('20002|9007199254740993');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...ownDetail, tid: 'other-tid' },
|
||||
{ ...ownDetail, uin: '20002' },
|
||||
{ code: 0, tid: 'post-tid', content: 'no verified owner' },
|
||||
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'unusable' }] },
|
||||
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'X, ,L', height: 100, width: 100 }] },
|
||||
{ ...ownDetail, richtype: 1, pic: [{ pic_id: 'X,A, ', height: 100, width: 100 }] },
|
||||
{ ...ownDetail, video: [{ video_id: 'not-safe-to-rebuild' }] },
|
||||
{ code: -3000, subcode: 0, private_message: 'secret' },
|
||||
])('does not submit a destructive or unverified permission update (%j)', async (detail) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(detail));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('prevents overlapping writes to the same post and releases the lock afterward', async () => {
|
||||
const { api } = createApi();
|
||||
let resolveDetail!: (value: Response) => void;
|
||||
fetchMock.mockImplementationOnce(() => new Promise(resolve => { resolveDetail = resolve; }));
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0 }));
|
||||
const first = api.setMsgRight('post-tid', 64);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await expect(api.setMsgRight('post-tid', 4)).rejects.toThrow('正在修改权限');
|
||||
resolveDetail(response(ownDetail));
|
||||
await expect(first).resolves.toEqual({ ugc_right: 64 });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, ugc_right: '4' }));
|
||||
await expect(api.setMsgRight('post-tid', 4)).resolves.toEqual({ ugc_right: 4 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does not write after the active account changes while reading the post', async () => {
|
||||
const { api, core } = createApi();
|
||||
fetchMock.mockImplementationOnce(async () => {
|
||||
core.selfInfo.uin = '20002';
|
||||
core.selfInfo.uid = 'other-uid';
|
||||
return response(ownDetail);
|
||||
});
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('QQ账号已切换');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not report success when the server echoes a different visibility', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response(ownDetail)).mockResolvedValueOnce(response({ code: 0, ugc_right: 1 }));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('返回权限与请求不一致');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not rewrite a post whose response contains malformed UTF-8 content', async () => {
|
||||
const { api } = createApi();
|
||||
const bytes = Buffer.concat([
|
||||
Buffer.from('{"code":0,"tid":"post-tid","uin":"10001","content":"'),
|
||||
Buffer.from([255]),
|
||||
Buffer.from('","ugc_right":1}'),
|
||||
]);
|
||||
fetchMock.mockResolvedValueOnce(new Response(bytes));
|
||||
|
||||
await expect(api.setMsgRight('post-tid', 64)).rejects.toThrow('QQ空间请求失败');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET');
|
||||
});
|
||||
|
||||
it.each([{ enable: undefined, action: '1' }, { enable: true, action: '1' }, { enable: false, action: '2' }])('updates only the current account blacklist (enable=$enable)', async ({ enable, action }) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(response({ code: 0, subcode: 0 }));
|
||||
|
||||
await expect(api.setBan('9007199254740993', enable)).resolves.toBeUndefined();
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0]!;
|
||||
expect(new URL(String(url)).pathname).toBe('/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new');
|
||||
expect(Object.fromEntries(new URLSearchParams(options?.body as string))).toEqual({
|
||||
uin: '10001', act_uin: '9007199254740993', action, fupdate: '1', qzreferrer: 'https://user.qzone.qq.com/10001/main',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{}',
|
||||
'_Callback({"code":0,"subcode":-1,"message":"private-response"});',
|
||||
'<html>private login page</html>',
|
||||
'_Callback({"code":0});window.privateSideEffect();',
|
||||
])('rejects unconfirmed writes without exposing or executing a response (%s)', async (text) => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response(text));
|
||||
let error: unknown;
|
||||
try {
|
||||
await api.setProfile({ name: 'Name' });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toMatch(/private|window\./);
|
||||
expect(String(error)).toContain('勿自动重试');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never replays an ambiguous write or follows a redirect with credentials', async () => {
|
||||
const { api } = createApi();
|
||||
fetchMock.mockResolvedValueOnce(new Response('private redirect', { status: 302 }));
|
||||
await expect(api.setBan('20002')).rejects.toThrow('未能确认结果');
|
||||
expect(fetchMock.mock.calls[0]![1]?.redirect).toBe('error');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzoneBanBody,
|
||||
buildQzoneProfileBody,
|
||||
buildQzoneRightBody,
|
||||
normalizeQzoneRight,
|
||||
parseQzoneProfile,
|
||||
parseQzoneWebResponse,
|
||||
} from '@/napcat-core/data/qzone-web';
|
||||
|
||||
describe('Qzone management response parsing', () => {
|
||||
test.each([
|
||||
'{"code":0,"subcode":0,"data":{"field":"raw"}}',
|
||||
'_Callback({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'callback({"code":0,"subcode":0,"data":{"field":"raw"}})',
|
||||
'_preloadCallback({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'back({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'frameElement.callback({"code":0,"subcode":0,"data":{"field":"raw"}});',
|
||||
'<script>frameElement.callback({"code":0,"subcode":0,"data":{"field":"raw"}});</script>',
|
||||
])('parses only an inert known response envelope (%s)', (text) => {
|
||||
expect(parseQzoneWebResponse(text, true)).toEqual({ code: 0, subcode: 0, data: { field: 'raw' } });
|
||||
});
|
||||
|
||||
test('repairs observed name string fragments without treating a name alone as success', () => {
|
||||
expect(parseQzoneWebResponse('frameElement.callback({"code":0,"subcode":0,"data":{name:"Ack"}});'))
|
||||
.toEqual({ code: 0, subcode: 0, data: { name: 'Ack' } });
|
||||
expect(() => parseQzoneWebResponse('frameElement.callback({name:"Ack"});')).toThrow('成功状态');
|
||||
expect(parseQzoneWebResponse('callback({"code":0,"data":{name:"Other"}});'))
|
||||
.toEqual({ code: 0, data: { name: 'Other' } });
|
||||
expect(parseQzoneWebResponse('frameElement.callback({name:"Ack","code":0,"subcode":0});'))
|
||||
.toEqual({ name: 'Ack', code: 0, subcode: 0 });
|
||||
expect(parseQzoneWebResponse('frameElement.callback({"code":0,"data":{name:"Ack","extra":true}});'))
|
||||
.toEqual({ code: 0, data: { name: 'Ack', extra: true } });
|
||||
expect(parseQzoneWebResponse('frameElement.callback({name:"Other","code":0});'))
|
||||
.toEqual({ name: 'Other', code: 0 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
'{}',
|
||||
'null',
|
||||
'[]',
|
||||
'{"code":"0"}',
|
||||
'{"code":0,"subcode":null}',
|
||||
'{"code":-1,"subcode":0}',
|
||||
'{"code":0,"subcode":-1}',
|
||||
'{"code":0,"retcode":-1}',
|
||||
'{"code":-1,"code":0}',
|
||||
'{"code":-1,"\\u0063ode":0}',
|
||||
'unknownCallback({"code":0});',
|
||||
'alert("prefix");callback({"code":0});',
|
||||
'callback({"code":0});alert("suffix");',
|
||||
'<html><script>callback({"code":0});</script></html>',
|
||||
'<html>login</html>',
|
||||
'<script>{"code":0}</script>',
|
||||
'<script>alert("prefix");callback({"code":0});</script>',
|
||||
])('rejects ambiguous, failed or executable wrappers (%s)', (text) => {
|
||||
expect(() => parseQzoneWebResponse(text)).toThrow();
|
||||
});
|
||||
|
||||
test('requires explicit code=0 for profile routes and never exposes private response text', () => {
|
||||
expect(parseQzoneWebResponse('{"subcode":0}')).toEqual({ subcode: 0 });
|
||||
expect(() => parseQzoneWebResponse('{"subcode":0}', true)).toThrow('成功状态');
|
||||
let error: unknown;
|
||||
try {
|
||||
parseQzoneWebResponse('{"code":-3000,"message":"p_skey=private-secret"}');
|
||||
} catch (value) {
|
||||
error = value;
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('QQ空间接口拒绝请求 (code=-3000)');
|
||||
expect((error as Error).message).not.toContain('private-secret');
|
||||
});
|
||||
|
||||
test('preserves exact profile text and does not invent missing fields', () => {
|
||||
expect(parseQzoneProfile({ code: 0, data: { spacename: '', desc: ' 第一行\n第二行 ', signature: 'not-returned' } }))
|
||||
.toEqual({ name: '', description: ' 第一行\n第二行 ' });
|
||||
for (const response of [{}, { code: 0 }, { code: 0, data: {} }, { code: 0, data: { spacename: 1, desc: '' } }]) {
|
||||
expect(() => parseQzoneProfile(response)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qzone profile and blacklist form bodies', () => {
|
||||
test.each([
|
||||
{ profile: { name: '名称 & +' }, fields: { spacename: '名称 & +' }, bitmap: '2048' },
|
||||
{ profile: { description: '' }, fields: { desc: '' }, bitmap: '4096' },
|
||||
{ profile: { name: '', description: '一\r\n二\r' }, fields: { spacename: '', desc: '一\n二' }, bitmap: '6144' },
|
||||
])('only submits supplied profile fields (%j)', ({ profile, fields, bitmap }) => {
|
||||
const body = new URLSearchParams(buildQzoneProfileBody('10001', profile));
|
||||
expect(Object.fromEntries(body)).toEqual({ uin: '10001', pageindex: '3', fupdate: '1', mb: bitmap, ...fields });
|
||||
expect(body.has('signature')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects invalid profile input and preserves omitted fields', () => {
|
||||
expect(() => buildQzoneProfileBody('10001', {})).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { name: undefined })).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { name: 'x'.repeat(8193) })).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { name: 42 } as never)).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { description: null } as never)).toThrow();
|
||||
expect(() => buildQzoneProfileBody('invalid', { name: 'space' })).toThrow();
|
||||
expect(() => buildQzoneProfileBody('10001', { signature: 'not-a-supported-field' } as never)).toThrow();
|
||||
expect(new URLSearchParams(buildQzoneProfileBody('10001', { name: 'space' })).has('desc')).toBe(false);
|
||||
});
|
||||
|
||||
test('builds ban and unban independently with an exact decimal target', () => {
|
||||
for (const enable of [true, false]) {
|
||||
expect(Object.fromEntries(new URLSearchParams(buildQzoneBanBody('10001', '9007199254740993', enable)))).toEqual({
|
||||
uin: '10001', act_uin: '9007199254740993', action: enable ? '1' : '2', fupdate: '1', qzreferrer: 'https://user.qzone.qq.com/10001/main',
|
||||
});
|
||||
}
|
||||
expect(() => buildQzoneBanBody('10001', 'bad', true)).toThrow();
|
||||
expect(() => buildQzoneBanBody('10001', '10002', 'false' as never)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function detail (overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { code: 0, subcode: 0, tid: 'T1', uin: '10001', content: ' original\ntext ', ...overrides };
|
||||
}
|
||||
|
||||
function observedEmptyForwardFields (): Record<string, unknown> {
|
||||
return { rt_tid: '', rt_uin: 0, rt_con: { conlist: null, content: '' }, rt_source: 0 };
|
||||
}
|
||||
|
||||
describe('Qzone permission update preflight', () => {
|
||||
test('validates rights and all target IDs without lossy conversion or silent omission', () => {
|
||||
expect(normalizeQzoneRight(16, ['10002', '10002', '9007199254740993']))
|
||||
.toEqual({ right: 16, targetUins: ['10002', '9007199254740993'] });
|
||||
for (const right of [0, 2, 3, 5, 256, NaN, Infinity]) expect(() => normalizeQzoneRight(right)).toThrow();
|
||||
expect(() => normalizeQzoneRight(16)).toThrow('target_uins');
|
||||
expect(() => normalizeQzoneRight(128, [])).toThrow('target_uins');
|
||||
expect(() => normalizeQzoneRight(128, ['10002', 'bad'])).toThrow();
|
||||
expect(() => normalizeQzoneRight(1, [10002] as never)).toThrow();
|
||||
});
|
||||
|
||||
test('rebuilds pure text with exact content, ownership, defaults and selective visibility', () => {
|
||||
const body = new URLSearchParams(buildQzoneRightBody(detail(), '10001', 'T1', 128, ['10002', '10002']));
|
||||
expect(Object.fromEntries(body)).toEqual({
|
||||
syn_tweet_verson: '1',
|
||||
tid: 'T1',
|
||||
paramstr: '1',
|
||||
pic_template: '',
|
||||
richtype: '',
|
||||
richval: '',
|
||||
special_url: '',
|
||||
subrichtype: '',
|
||||
pic_bo: '',
|
||||
con: ' original\ntext ',
|
||||
feedversion: '1',
|
||||
ver: '1',
|
||||
ugc_right: '128',
|
||||
to_sign: '0',
|
||||
ugcright_id: 'T1',
|
||||
hostuin: '10001',
|
||||
code_version: '1',
|
||||
format: 'fs',
|
||||
qzreferrer: 'https://user.qzone.qq.com/10001',
|
||||
allow_uins: '10002',
|
||||
});
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail(), '10001', 'T1', 64, ['10002'])).has('allow_uins')).toBe(false);
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail({ richtype: '', subrichtype: '' }), '10001', 'T1', 4)).get('richtype')).toBe('');
|
||||
});
|
||||
|
||||
test('accepts the observed plain-text forward placeholder and retains subtype 2', () => {
|
||||
const value = detail({ ...observedEmptyForwardFields(), t1_subtype: 2 });
|
||||
const body = new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 4));
|
||||
expect(Object.fromEntries(body)).toMatchObject({ con: ' original\ntext ', richtype: '', richval: '', subrichtype: '2' });
|
||||
expect(value['rt_con']).toEqual({ conlist: null, content: '' });
|
||||
});
|
||||
|
||||
test('accepts the same observed placeholder on complete image posts without dropping image data', () => {
|
||||
const value = detail({
|
||||
...observedEmptyForwardFields(),
|
||||
t1_subtype: 1,
|
||||
pic: [{ pic_id: ',SYNTHETIC-ALBUM,SYNTHETIC-LOCATION', pictype: 3, height: 96, width: 128, smallurl: 'https://photo.example/a?bo=metadata' }],
|
||||
});
|
||||
const body = new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 64));
|
||||
expect(Object.fromEntries(body)).toMatchObject({
|
||||
con: ' original\ntext ',
|
||||
richtype: '1',
|
||||
subrichtype: '1',
|
||||
richval: ',SYNTHETIC-ALBUM,SYNTHETIC-LOCATION,SYNTHETIC-LOCATION,3,96,128,,0,0',
|
||||
pic_bo: 'metadata\tmetadata',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ rt_con: { conlist: null, content: ' ' } },
|
||||
{ rt_con: { conlist: null, content: 'forwarded text' } },
|
||||
{ rt_con: { conlist: [], content: '' } },
|
||||
{ rt_con: { conlist: [{}], content: '' } },
|
||||
{ rt_con: { content: '' } },
|
||||
{ rt_con: { conlist: null } },
|
||||
{ rt_con: { conlist: null, content: '', unknown: 0 } },
|
||||
{ rt_con: [{ conlist: null, content: '' }] },
|
||||
{ rt_con: 'nonempty' },
|
||||
{ rt_tid: 'forwarded' },
|
||||
{ rt_uin: 10002 },
|
||||
{ rt_source: 1 },
|
||||
{ rt_tid: undefined },
|
||||
{ rt_uin: '0' },
|
||||
{ rt_source: undefined },
|
||||
{ video: { vid: 'video-id' } },
|
||||
{ music: { url: 'https://music.example/song' } },
|
||||
{ forward: { tid: 'forwarded' } },
|
||||
{ origin: { tid: 'original' } },
|
||||
])('does not generalize the observed empty forward object to other content (%j)', (overrides) => {
|
||||
const value = detail({ ...observedEmptyForwardFields(), t1_subtype: 1, ...overrides });
|
||||
expect(() => buildQzoneRightBody(value, '10001', 'T1', 4)).toThrow('媒体或转发内容');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ pic: [{ pic_id: ',ALBUM,LOCATION', pictype: 3, height: 96, width: 128 }] },
|
||||
{ richtype: 1 },
|
||||
{ richtype: 2 },
|
||||
{ richtype: '' },
|
||||
{ subrichtype: 2 },
|
||||
{ t1_subtype: 9 },
|
||||
{ rt_con: undefined },
|
||||
])('restricts subtype 2 to the observed ordinary text layout (%j)', (overrides) => {
|
||||
const value = detail({ ...observedEmptyForwardFields(), t1_subtype: 2, ...overrides });
|
||||
expect(() => buildQzoneRightBody(value, '10001', 'T1', 4)).toThrow();
|
||||
});
|
||||
|
||||
test('preserves all conlist segments including whitespace instead of using a truncated summary', () => {
|
||||
const value = detail({ content: 'summary', conlist: [{ con: ' \nfirst' }, { con: ' ' }, { con: 'last\n ' }] });
|
||||
expect(new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 4)).get('con')).toBe(' \nfirst last\n ');
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail({ content: '' }), '10001', 'T1', 4)).get('con')).toBe('');
|
||||
expect(() => buildQzoneRightBody(detail({ conlist: [{ con: 'kept' }, {}] }), '10001', 'T1', 4)).toThrow('分段不完整');
|
||||
expect(() => buildQzoneRightBody(detail({ content: undefined }), '10001', 'T1', 4)).toThrow('完整正文');
|
||||
});
|
||||
|
||||
test('preserves image order, complete richvals, bo alignment and version metadata', () => {
|
||||
const value = detail({
|
||||
conlist: [{ con: 'image text' }],
|
||||
richtype: 1,
|
||||
t1_subtype: 1,
|
||||
pic_template: 'template',
|
||||
feedversion: 3,
|
||||
ver: '2',
|
||||
code_version: 5,
|
||||
to_sign: 1,
|
||||
ugcright_id: 'right-key',
|
||||
pic: [
|
||||
{ pic_id: 'X,ALBUM1,LLOC1,extra', pictype: 22, height: 800, width: 600, url1: 'https://photo.example/a?bo=abc%2Cdef' },
|
||||
{ pic_id: 'X,ALBUM2,LLOC2,extra', type: '23', b_height: '400', b_width: '300', smallurl: 'https://photo.example/b?bo=second' },
|
||||
],
|
||||
});
|
||||
const body = new URLSearchParams(buildQzoneRightBody(value, '10001', 'T1', 64));
|
||||
expect(body.get('richval')).toBe(',ALBUM1,LLOC1,LLOC1,22,800,600,,0,0\t,ALBUM2,LLOC2,LLOC2,23,400,300,,0,0');
|
||||
expect(body.get('pic_bo')).toBe('abc,def,second\tabc,def,second');
|
||||
expect(Object.fromEntries(body)).toMatchObject({ con: 'image text', pic_template: 'template', feedversion: '3', ver: '2', code_version: '5', to_sign: '1', ugcright_id: 'right-key' });
|
||||
expect(value['ugc_right']).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 100, width: 100 }, {}], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'broken' }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X, ,L', height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A, ', height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L' }], richtype: 0 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 'unknown', width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: null, b_height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 100, width: 100, url1: 'https://photo.example/a?bo=one' }, { pic_id: 'X,A,L2', height: 100, width: 100 }], richtype: 1 },
|
||||
{ pic: [{ pic_id: 'X,A,L', height: 100, width: 100, url1: 'https://photo.example/a?bo=%GG' }], richtype: 1 },
|
||||
{ richval: 'unknown-media' },
|
||||
{ richtype: 2 },
|
||||
{ t1_subtype: 9 },
|
||||
{ video: { vid: 'video-id' } },
|
||||
{ music: { url: 'https://music.example/song' } },
|
||||
{ rt_tid: 'forwarded-tid' },
|
||||
{ special_url: 'https://example.com/shared' },
|
||||
])('rejects incomplete or unsupported media instead of partially resubmitting it (%j)', (overrides) => {
|
||||
expect(() => buildQzoneRightBody(detail(overrides), '10001', 'T1', 64)).toThrow();
|
||||
});
|
||||
|
||||
test('requires every existing image to retain positive known dimensions', () => {
|
||||
for (const picture of [
|
||||
{ pic_id: 'X,A,L', width: 100 },
|
||||
{ pic_id: 'X,A,L', height: 100 },
|
||||
{ pic_id: 'X,A,L', height: 0, width: 100 },
|
||||
{ pic_id: 'X,A,L', height: 100, width: '0' },
|
||||
]) {
|
||||
expect(() => buildQzoneRightBody(detail({ richtype: 1, pic: [picture] }), '10001', 'T1', 64)).toThrow();
|
||||
}
|
||||
expect(() => buildQzoneRightBody(detail({
|
||||
richtype: 1,
|
||||
pic: [
|
||||
{ pic_id: 'X,A,L1', height: 100, width: 100 },
|
||||
{ pic_id: 'X,A,L2', width: 100 },
|
||||
],
|
||||
}), '10001', 'T1', 64)).toThrow('尺寸不完整');
|
||||
});
|
||||
|
||||
test('requires the exact requested tid and current-account owner before returning a body', () => {
|
||||
expect(() => buildQzoneRightBody(detail({ tid: 'other-tid' }), '10001', 'T1', 64)).toThrow('不匹配');
|
||||
expect(() => buildQzoneRightBody(detail({ uin: '10002' }), '10001', 'T1', 64)).toThrow('自己的说说');
|
||||
expect(() => buildQzoneRightBody(detail({ uin: undefined }), '10001', 'T1', 64)).toThrow('自己的说说');
|
||||
expect(() => buildQzoneRightBody(detail({ code: -1, subcode: 0 }), '10001', 'T1', 64)).toThrow();
|
||||
expect(new URLSearchParams(buildQzoneRightBody(detail({ uin: 10001 }), '10001', 'T1', 4)).get('hostuin')).toBe('10001');
|
||||
});
|
||||
});
|
||||
Generated
+56
@@ -86,6 +86,9 @@ importers:
|
||||
file-type:
|
||||
specifier: ^21.0.0
|
||||
version: 21.1.0
|
||||
htmlparser2:
|
||||
specifier: 10.0.0
|
||||
version: 10.0.0
|
||||
inversify:
|
||||
specifier: ^7.10.4
|
||||
version: 7.10.4(reflect-metadata@0.2.2)
|
||||
@@ -4081,6 +4084,19 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4123,6 +4139,14 @@ packages:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4761,6 +4785,9 @@ packages:
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
@@ -10949,6 +10976,24 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -10992,6 +11037,10 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
err-code@2.0.3: {}
|
||||
@@ -11879,6 +11928,13 @@ snapshots:
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 6.0.1
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-errors@2.0.0:
|
||||
|
||||
Reference in New Issue
Block a user