948 lines
42 KiB
TypeScript
948 lines
42 KiB
TypeScript
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 };
|
|
}
|
|
}
|