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:
Qiao
2026-10-30 00:17:49 +08:00
committed by GitHub
parent ba0a53dd37
commit 49ecfe0f56
48 changed files with 7534 additions and 23 deletions
+1
View File
@@ -10,4 +10,5 @@ export * from './file';
export * from './online';
export * from './flash';
export * from './database';
export * from './qzone';
export * from './qzone-public';
+947
View File
@@ -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 };
}
}
+9 -2
View File
@@ -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;
+146
View File
@@ -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,
};
}
+365
View File
@@ -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);
}
+310
View File
@@ -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) };
}
+396
View File
@@ -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();
}
+18 -5
View File
@@ -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 {
+3
View File
@@ -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,
}
+1
View File
@@ -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: '今天天气不错' }] },
},
};
+24
View File
@@ -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),
+12
View File
@@ -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',
+12 -1
View File
@@ -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', () => {
+221
View File
@@ -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();
});
});
+357
View File
@@ -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);
});
});
+162
View File
@@ -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">测试 &amp; 文字<br>第二行<img alt="[云]" src="https://example.test/face.gif"></div><a class="img-item" data-originurl="//cdn.example.test/full.webp?a=1&amp;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>段落一&nbsp;文字</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();
}
});
});
+225
View File
@@ -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);
});
});
+302
View File
@@ -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');
});
});
+56
View File
@@ -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: