diff --git a/packages/napcat-core/apis/index.ts b/packages/napcat-core/apis/index.ts index b6102bfc..3fdcdaf4 100644 --- a/packages/napcat-core/apis/index.ts +++ b/packages/napcat-core/apis/index.ts @@ -10,4 +10,5 @@ export * from './file'; export * from './online'; export * from './flash'; export * from './database'; +export * from './qzone'; export * from './qzone-public'; diff --git a/packages/napcat-core/apis/qzone.ts b/packages/napcat-core/apis/qzone.ts new file mode 100644 index 00000000..e3bf7819 --- /dev/null +++ b/packages/napcat-core/apis/qzone.ts @@ -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; +} + +// 固定路由,不向调用方暴露任意 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 { + 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 (work: () => Promise, message: string, duration = REQUEST_TIMEOUT_MS): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(work), + new Promise((_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(); + private readonly rightUpdates = new Set(); + + 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, 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): { 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 { + 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, user: QzoneUser): Promise> { + 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> { + 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 = {}; + 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 { + 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 { + 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; + 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 { + 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 { + 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 { + 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; + 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 { + 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 { + 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 { + const user = this.currentUser(); + const body = buildQzoneBanBody(user.uin, targetUin, enable); + await this.web('SetBan', new URLSearchParams(body), user); + } + + async getFriendFeeds (cursor?: string): Promise { + const user = this.currentUser(); + const previous = this.readCursor(cursor, user, 'friends', ''); + const refreshId = previous?.refreshId ?? `${user.uin}_${Date.now()}`; + const request: Record = { 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 { + 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> { + 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): 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, 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 { + 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 { + const ref = referenceSnapshot(reference); + const user = this.currentUser(); + const previous = this.readCursor(cursor, user, 'comments', commentScope(ref)); + let current = ref; + let data: Record; + 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 { + 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 { + 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 }; + } +} diff --git a/packages/napcat-core/apis/webapi.ts b/packages/napcat-core/apis/webapi.ts index 34a34d82..38bcf289 100644 --- a/packages/napcat-core/apis/webapi.ts +++ b/packages/napcat-core/apis/webapi.ts @@ -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) { 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; diff --git a/packages/napcat-core/data/qzone-comments.ts b/packages/napcat-core/data/qzone-comments.ts new file mode 100644 index 00000000..383cb36f --- /dev/null +++ b/packages/napcat-core/data/qzone-comments.ts @@ -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; +} + +export interface QzoneReplyPage { + feed_ref: QzoneFeedReference; + comment_id: string; + replies: QzoneReplyItem[]; + reply_num: number | null; + has_more: boolean; + next_cursor: string | null; + raw: Record; +} + +function object (value: unknown): Record | 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 : 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, + }; +} diff --git a/packages/napcat-core/data/qzone-rpc.ts b/packages/napcat-core/data/qzone-rpc.ts new file mode 100644 index 00000000..658aa984 --- /dev/null +++ b/packages/napcat-core/data/qzone-rpc.ts @@ -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; +} + +export interface QzoneDetail extends QzoneFeedItem { + comments: QzoneComment[]; + comment_has_more?: boolean; + comment_next_cursor?: string | null; +} + +function record (value: unknown): Record | 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 : 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 = { 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 = { 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 { + 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; +} diff --git a/packages/napcat-core/data/qzone-snowluma-feeds.ts b/packages/napcat-core/data/qzone-snowluma-feeds.ts new file mode 100644 index 00000000..72040d72 --- /dev/null +++ b/packages/napcat-core/data/qzone-snowluma-feeds.ts @@ -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 | 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 { + 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(); + 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 { + 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; + parent?: HtmlElement; + children: Array; +} + +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(); + 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): 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 = {}; + for (const field of Object.keys(state).sort()) { + if (field !== 'icServerTime') position[field] = state[field as keyof SlQzoneFeedState]; + } + return JSON.stringify(position); +} diff --git a/packages/napcat-core/data/qzone-snowluma.ts b/packages/napcat-core/data/qzone-snowluma.ts new file mode 100644 index 00000000..f459470f --- /dev/null +++ b/packages/napcat-core/data/qzone-snowluma.ts @@ -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 | 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 : undefined; +} + +function requireObject (value: unknown): Record { + const result = object(value); + if (!result) throw new Error('QQ空间兼容响应对象格式异常'); + return result; +} + +function responseCodes (response: Record, 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): 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 { + 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, 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 = { '%': '%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, 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) }; +} diff --git a/packages/napcat-core/data/qzone-web.ts b/packages/napcat-core/data/qzone-web.ts new file mode 100644 index 00000000..ab2ccde6 --- /dev/null +++ b/packages/napcat-core/data/qzone-web.ts @@ -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 | 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 : 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, 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(); + 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 = /^(?:\s*)?\s*\s*\s*<\/head>\s*\s*\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 { + 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 = /^') && 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): 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(); + 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): 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): 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 | 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 { + 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, + 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 = []; + 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(); +} diff --git a/packages/napcat-core/data/qzone.ts b/packages/napcat-core/data/qzone.ts index 8c48f483..538ad66f 100644 --- a/packages/napcat-core/data/qzone.ts +++ b/packages/napcat-core/data/qzone.ts @@ -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 { diff --git a/packages/napcat-core/index.ts b/packages/napcat-core/index.ts index 67de48dc..b68224f7 100644 --- a/packages/napcat-core/index.ts +++ b/packages/napcat-core/index.ts @@ -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, } diff --git a/packages/napcat-core/package.json b/packages/napcat-core/package.json index fb0d0572..2287e957 100644 --- a/packages/napcat-core/package.json +++ b/packages/napcat-core/package.json @@ -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:*", diff --git a/packages/napcat-onebot/action/extends/DeleteQzoneMsg.ts b/packages/napcat-onebot/action/extends/DeleteQzoneMsg.ts index dc308ce0..f75b9518 100644 --- a/packages/napcat-onebot/action/extends/DeleteQzoneMsg.ts +++ b/packages/napcat-onebot/action/extends/DeleteQzoneMsg.ts @@ -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; -export class DeleteQzoneMsg extends OneBotAction { +export class DeleteQzoneMsg extends OneBotAction { override actionName = ActionName.DeleteQzoneMsg; override actionSummary = '删除QQ空间说说'; override actionDescription = '删除QQ空间(Qzone)的一条说说, 按 tid 删除'; @@ -20,7 +21,17 @@ export class DeleteQzoneMsg extends OneBotAction { override payloadSchema = PayloadSchema; override returnSchema = Type.Null(); - async _handle (payload: PayloadType): Promise { - await this.core.apis.WebApi.deleteQzoneMsg(payload.tid); + protected override async check (payload: PayloadType): Promise { + 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 { + checkQzoneFields(qzonePayload(payload), ['tid'], false); + await this.core.apis.WebApi.deleteQzoneMsg(qzoneIdentity(payload.tid, 'tid')); + return null; } } diff --git a/packages/napcat-onebot/action/extends/SendQzoneMsg.ts b/packages/napcat-onebot/action/extends/SendQzoneMsg.ts index 70f837dc..9a2c2054 100644 --- a/packages/napcat-onebot/action/extends/SendQzoneMsg.ts +++ b/packages/napcat-onebot/action/extends/SendQzoneMsg.ts @@ -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; const ReturnSchema = Type.Object({ tid: Type.String({ description: '说说Tid' }), + time: Type.Integer({ minimum: 0, description: '发表时间(Unix秒);上游未提供时为0,表示未知' }), }, { description: '发说说结果' }); type ReturnType = Static; @@ -26,28 +29,61 @@ type ReturnType = Static; export class SendQzoneMsg extends OneBotAction { 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 { - 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 { + 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 { + 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 { 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)) { diff --git a/packages/napcat-onebot/action/extends/qzone/CommentQzone.ts b/packages/napcat-onebot/action/extends/qzone/CommentQzone.ts new file mode 100644 index 00000000..a80d9f3f --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/CommentQzone.ts @@ -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; + +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 { + 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 { + try { normalizeQzoneCommentPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; } + return await super.check(payload); + } + + async _handle (payload: CommentQzonePayload): Promise { + 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(() => { }); + } + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/GetQzoneCommentList.ts b/packages/napcat-onebot/action/extends/qzone/GetQzoneCommentList.ts new file mode 100644 index 00000000..98917ae6 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/GetQzoneCommentList.ts @@ -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; + +export class GetQzoneCommentList extends OneBotAction { + 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 { + try { + validateQzoneCommentReadPayload(payload, false); + } catch (error) { + return { valid: false, message: (error as Error).message }; + } + return await super.check(payload); + } + + async _handle (payload: GetQzoneCommentListPayload): Promise { + validateQzoneCommentReadPayload(payload, false); + return await this.core.apis.QzoneApi.getComments(payload.feed_ref, payload.cursor); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/GetQzoneCommentReplies.ts b/packages/napcat-onebot/action/extends/qzone/GetQzoneCommentReplies.ts new file mode 100644 index 00000000..839948ba --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/GetQzoneCommentReplies.ts @@ -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; + +export class GetQzoneCommentReplies extends OneBotAction { + 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 { + try { + validateQzoneCommentReadPayload(payload, true); + } catch (error) { + return { valid: false, message: (error as Error).message }; + } + return await super.check(payload); + } + + async _handle (payload: GetQzoneCommentRepliesPayload): Promise { + validateQzoneCommentReadPayload(payload, true); + return await this.core.apis.QzoneApi.getCommentReplies(payload.feed_ref, payload.comment as QzoneComment, payload.cursor); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/GetQzoneFeeds.ts b/packages/napcat-onebot/action/extends/qzone/GetQzoneFeeds.ts new file mode 100644 index 00000000..92b70224 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/GetQzoneFeeds.ts @@ -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; + +export class GetQzoneFeeds extends OneBotAction { + 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 { + try { normalizeQzoneFeedsPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; } + return await super.check(payload); + } + + async _handle (payload: GetQzoneFeedsPayload): Promise { + 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); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail.ts b/packages/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail.ts new file mode 100644 index 00000000..f110fb9c --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/GetQzoneMsgDetail.ts @@ -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; + +export class GetQzoneMsgDetail extends OneBotAction { + 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 { + try { normalizeQzoneDetailPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; } + return await super.check(payload); + } + + async _handle (payload: GetQzoneMsgDetailPayload): Promise { + 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); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/GetQzoneMsgList.ts b/packages/napcat-onebot/action/extends/qzone/GetQzoneMsgList.ts new file mode 100644 index 00000000..9bff87a1 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/GetQzoneMsgList.ts @@ -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; + +export class GetQzoneMsgList extends OneBotAction { + 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 { + try { normalizeQzoneListPayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; } + return await super.check(payload); + } + + async _handle (payload: GetQzoneMsgListPayload): Promise { + 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); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/GetQzoneProfile.ts b/packages/napcat-onebot/action/extends/qzone/GetQzoneProfile.ts new file mode 100644 index 00000000..1ce6b714 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/GetQzoneProfile.ts @@ -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; + +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 { + 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 { + try { + targetUin(payload); + } catch (error) { + return { valid: false, message: (error as Error).message }; + } + return await super.check(payload); + } + + async _handle (payload: GetQzoneProfilePayload): Promise { + return await this.core.apis.QzoneApi.getProfile(targetUin(payload)); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/LikeQzone.ts b/packages/napcat-onebot/action/extends/qzone/LikeQzone.ts new file mode 100644 index 00000000..f16cebb8 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/LikeQzone.ts @@ -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; + +export class LikeQzone extends OneBotAction { + 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 { + try { normalizeQzoneLikePayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; } + return await super.check(payload); + } + + async _handle (payload: LikeQzonePayload): Promise { + 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; + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/SetQzoneBan.ts b/packages/napcat-onebot/action/extends/qzone/SetQzoneBan.ts new file mode 100644 index 00000000..536620bd --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/SetQzoneBan.ts @@ -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; + +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 { + 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 { + try { + banUpdate(payload); + } catch (error) { + return { valid: false, message: (error as Error).message }; + } + return await super.check(payload); + } + + async _handle (payload: SetQzoneBanPayload): Promise { + const normalized = banUpdate(payload); + await this.core.apis.QzoneApi.setBan(normalized.uin, normalized.enable); + return null; + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/SetQzoneMsgRight.ts b/packages/napcat-onebot/action/extends/qzone/SetQzoneMsgRight.ts new file mode 100644 index 00000000..cea82d0f --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/SetQzoneMsgRight.ts @@ -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; + +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 { + 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 { + 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); + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/SetQzoneProfile.ts b/packages/napcat-onebot/action/extends/qzone/SetQzoneProfile.ts new file mode 100644 index 00000000..205dd071 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/SetQzoneProfile.ts @@ -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; + +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 { + 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 { + try { + profileUpdate(payload); + } catch (error) { + return { valid: false, message: (error as Error).message }; + } + return await super.check(payload); + } + + async _handle (payload: SetQzoneProfilePayload): Promise { + await this.core.apis.QzoneApi.setProfile(profileUpdate(payload)); + return null; + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/UnlikeQzone.ts b/packages/napcat-onebot/action/extends/qzone/UnlikeQzone.ts new file mode 100644 index 00000000..0477f9c6 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/UnlikeQzone.ts @@ -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; + +export class UnlikeQzone extends OneBotAction { + 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 { + try { normalizeQzoneLikePayload(payload); } catch (error) { return { valid: false, message: (error as Error).message }; } + return await super.check(payload); + } + + async _handle (payload: UnlikeQzonePayload): Promise { + 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; + } +} diff --git a/packages/napcat-onebot/action/extends/qzone/common-comment.ts b/packages/napcat-onebot/action/extends/qzone/common-comment.ts new file mode 100644 index 00000000..9cb3ae47 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/common-comment.ts @@ -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 { + 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: '' }, +}; diff --git a/packages/napcat-onebot/action/extends/qzone/common-snowluma.ts b/packages/napcat-onebot/action/extends/qzone/common-snowluma.ts new file mode 100644 index 00000000..3ea69141 --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/common-snowluma.ts @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('QQ空间参数必须为对象'); + return value as Record; +} + +export function checkQzoneFields (payload: Record, 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): { 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: '
今天拍到了晚霞
', tid: 'sample-post', content: '今天拍到了晚霞', content_complete: true, images: [], like_count: 0, comment_count: 0 }], + has_more: false, + next_cursor: null, +}; diff --git a/packages/napcat-onebot/action/extends/qzone/common.ts b/packages/napcat-onebot/action/extends/qzone/common.ts new file mode 100644 index 00000000..6e32655e --- /dev/null +++ b/packages/napcat-onebot/action/extends/qzone/common.ts @@ -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: '今天天气不错' }] }, + }, +}; diff --git a/packages/napcat-onebot/action/index.ts b/packages/napcat-onebot/action/index.ts index fad49fb8..40cb1f36 100644 --- a/packages/napcat-onebot/action/index.ts +++ b/packages/napcat-onebot/action/index.ts @@ -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), diff --git a/packages/napcat-onebot/action/router.ts b/packages/napcat-onebot/action/router.ts index 4c5ae64a..66ed0661 100644 --- a/packages/napcat-onebot/action/router.ts +++ b/packages/napcat-onebot/action/router.ts @@ -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', diff --git a/packages/napcat-test/qzone.test.ts b/packages/napcat-test/qzone.test.ts index 7b1c8d48..085a622c 100644 --- a/packages/napcat-test/qzone.test.ts +++ b/packages/napcat-test/qzone.test.ts @@ -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', () => { diff --git a/packages/napcat-test/qzoneActions.test.ts b/packages/napcat-test/qzoneActions.test.ts new file mode 100644 index 00000000..8b61c91c --- /dev/null +++ b/packages/napcat-test/qzoneActions.test.ts @@ -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); + } + }); +}); diff --git a/packages/napcat-test/qzoneCommentActions.test.ts b/packages/napcat-test/qzoneCommentActions.test.ts new file mode 100644 index 00000000..18c1dbc4 --- /dev/null +++ b/packages/napcat-test/qzoneCommentActions.test.ts @@ -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); + } + }); +}); diff --git a/packages/napcat-test/qzoneCommentApi.test.ts b/packages/napcat-test/qzoneCommentApi.test.ts new file mode 100644 index 00000000..ab6a5e65 --- /dev/null +++ b/packages/napcat-test/qzoneCommentApi.test.ts @@ -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, 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>; + + beforeEach(() => { + fetchMock = vi.fn(); + 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 = { 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); + }); +}); diff --git a/packages/napcat-test/qzoneCommentsData.test.ts b/packages/napcat-test/qzoneCommentsData.test.ts new file mode 100644 index 00000000..0d59b910 --- /dev/null +++ b/packages/napcat-test/qzoneCommentsData.test.ts @@ -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); + }); +}); diff --git a/packages/napcat-test/qzoneCompatibility.test.ts b/packages/napcat-test/qzoneCompatibility.test.ts new file mode 100644 index 00000000..0bb5c94d --- /dev/null +++ b/packages/napcat-test/qzoneCompatibility.test.ts @@ -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'); + }); +}); diff --git a/packages/napcat-test/qzoneFormSender.test.ts b/packages/napcat-test/qzoneFormSender.test.ts new file mode 100644 index 00000000..ae6ff23a --- /dev/null +++ b/packages/napcat-test/qzoneFormSender.test.ts @@ -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 ? '\n' : ''}\n\n\n\n\n\n\n`; +} + +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('', '') + .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('', ''), + html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('', ''), + html(`${TRY_SETUP}${callback({ code: 0 })}`).replace('', ''), + 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('', ''), + 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)});`), + 'login required', + ])('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});', + '', + 'frameElement.callback({"code":0});', + ])('preserves the original accepted response envelopes (%s)', (body) => { + expect(parseQzoneWebResponse(body, true)).toEqual({ code: 0 }); + }); +}); diff --git a/packages/napcat-test/qzoneManagementActions.test.ts b/packages/napcat-test/qzoneManagementActions.test.ts new file mode 100644 index 00000000..1767324f --- /dev/null +++ b/packages/napcat-test/qzoneManagementActions.test.ts @@ -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); + } + }); +}); diff --git a/packages/napcat-test/qzoneNativeParent.test.ts b/packages/napcat-test/qzoneNativeParent.test.ts new file mode 100644 index 00000000..c8a2ded6 --- /dev/null +++ b/packages/napcat-test/qzoneNativeParent.test.ts @@ -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(); + }); +}); diff --git a/packages/napcat-test/qzoneRpcApi.test.ts b/packages/napcat-test/qzoneRpcApi.test.ts new file mode 100644 index 00000000..6bc2a93f --- /dev/null +++ b/packages/napcat-test/qzoneRpcApi.test.ts @@ -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, 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>; + + beforeEach(() => { + fetchMock = vi.fn(); + 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); + }); +}); diff --git a/packages/napcat-test/qzoneRpcData.test.ts b/packages/napcat-test/qzoneRpcData.test.ts new file mode 100644 index 00000000..f2384729 --- /dev/null +++ b/packages/napcat-test/qzoneRpcData.test.ts @@ -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 = {}; + 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; }; + 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); + }); +}); diff --git a/packages/napcat-test/qzoneSnowlumaActions.test.ts b/packages/napcat-test/qzoneSnowlumaActions.test.ts new file mode 100644 index 00000000..63d402a7 --- /dev/null +++ b/packages/napcat-test/qzoneSnowlumaActions.test.ts @@ -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) { + 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); + }); +}); diff --git a/packages/napcat-test/qzoneSnowlumaApi.test.ts b/packages/napcat-test/qzoneSnowlumaApi.test.ts new file mode 100644 index 00000000..c5520ec0 --- /dev/null +++ b/packages/napcat-test/qzoneSnowlumaApi.test.ts @@ -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): Response { + return new Response(`_preloadCallback(${JSON.stringify(data)});`); +} + +function feedPage (key: string, hasMore: boolean, state: Record = {}): Record { + 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>; + beforeEach(() => { + fetchMock = vi.fn(); + 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"}});')); + 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) => ''; + 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); + }); +}); diff --git a/packages/napcat-test/qzoneSnowlumaData.test.ts b/packages/napcat-test/qzoneSnowlumaData.test.ts new file mode 100644 index 00000000..214aa4f0 --- /dev/null +++ b/packages/napcat-test/qzoneSnowlumaData.test.ts @@ -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 = {}): Record { + 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空间兼容接口拒绝请求'); + }); +}); diff --git a/packages/napcat-test/qzoneSnowlumaFeeds.test.ts b/packages/napcat-test/qzoneSnowlumaFeeds.test.ts new file mode 100644 index 00000000..0aac6f74 --- /dev/null +++ b/packages/napcat-test/qzoneSnowlumaFeeds.test.ts @@ -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 = { hasMoreFeeds: false }): string { + return `_preloadCallback(${JSON.stringify({ code: 0, subcode: 0, data: { main, data: items } })});`; +} + +function post (html: string, extra: Record = {}) { + return { uin: '330003', nickname: '林', abstime: '1800000123', appid: '311', key: 'entry-nine', html, ...extra }; +} + +function ownHtml (body: string, footer = ''): string { + return `
${body}
${footer}
`; +} + +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(' { + 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:[]}});', + '', + '{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('
测试 & 文字
第二行[云]
', ''); + 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('
短内容阅读全文
'); + expect(parseSlQzoneFeeds(packet([post(partial)])).feeds[0]).toMatchObject({ content: '短内容', content_complete: false }); + const full = ownHtml('
短内容展开
'); + 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(`
原帖
+
评论内容
+
转发正文
+
+ + `); + 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('
文字
').replace('data-uin="330003"', 'data-uin="440004"'); + expect(parseSlQzoneFeeds(packet([post(mismatched)])).feeds[0]?.tid).toBeNull(); + expect(parseSlQzoneFeeds(packet([post(ownHtml('
相册说明
'), { appid: 4 })])).feeds[0]?.tid).toBeNull(); + const absent = parseSlQzoneFeeds(packet([post('
没有可确认的正文
')])).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('

段落一 文字

段落二

'); + 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 = '
A
B
'; + expect(parseSlQzoneFeeds(packet([post(duplicate)])).feeds[0]?.content).toBeNull(); + expect(() => parseSlQzoneFeeds(packet([post(`
${''.repeat(70)}deep${''.repeat(70)}
`)]))).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(); + } + }); +}); diff --git a/packages/napcat-test/qzoneWebApi.test.ts b/packages/napcat-test/qzoneWebApi.test.ts new file mode 100644 index 00000000..7d0f7d58 --- /dev/null +++ b/packages/napcat-test/qzoneWebApi.test.ts @@ -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): 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>; + + beforeEach(() => { + fetchMock = vi.fn(); + 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"});', + 'private login page', + '_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); + }); +}); diff --git a/packages/napcat-test/qzoneWebData.test.ts b/packages/napcat-test/qzoneWebData.test.ts new file mode 100644 index 00000000..189e9e99 --- /dev/null +++ b/packages/napcat-test/qzoneWebData.test.ts @@ -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"}});', + '', + ])('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");', + '', + 'login', + '', + '', + ])('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 = {}): Record { + return { code: 0, subcode: 0, tid: 'T1', uin: '10001', content: ' original\ntext ', ...overrides }; +} + +function observedEmptyForwardFields (): Record { + 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'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54c12a0d..835940b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: