NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
export class NTQQCollectionApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async createCollection (authorUin: string, authorUid: string, authorName: string, brief: string, rawData: string) {
return this.context.session.getCollectionService().createNewCollectionItem({
commInfo: {
bid: 1,
category: 2,
author: {
type: 1,
numId: authorUin,
strId: authorName,
groupId: '0',
groupName: '',
uid: authorUid,
},
customGroupId: '0',
createTime: Date.now().toString(),
sequence: Date.now().toString(),
},
richMediaSummary: {
originalUri: '',
publisher: '',
richMediaVersion: 0,
subTitle: '',
title: '',
brief,
picList: [],
contentType: 1,
},
richMediaContent: {
rawData,
bizDataList: [],
picList: [],
fileList: [],
},
need_share_url: false,
});
}
async getAllCollection (category: number = 0, count: number = 50) {
return this.context.session.getCollectionService().getCollectionItemList({
category,
groupId: -1,
forceSync: true,
forceFromDb: false,
timeStamp: '0',
count,
searchDown: true,
});
}
}
+201
View File
@@ -0,0 +1,201 @@
import path from 'node:path';
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
import {
checkSqliteAvailable,
DatabaseHandle,
openDatabase,
decryptAndOpen,
readSingleDatabase,
scanAllDatabases,
formatScanResults,
decryptDatabaseFile,
type DatabaseScanResult,
type DatabaseReadOptions,
type TableInfo,
} from 'napcat-database';
type SQLInputValue = import('node:sqlite').SQLInputValue;
export class NTQQDatabaseApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/** node:sqlite 是否可用 */
async isSqliteAvailable (): Promise<boolean> {
return checkSqliteAvailable();
}
/** 获取当前账号的 nt_db 目录 */
getNtDbDir (): string {
return path.resolve(this.core.dataPath, this.core.selfInfo.uin, 'nt_qq', 'nt_db');
}
/**
* 获取解密缓存目录
* 默认: <NapCatDataPath>/db_<uin>/
* 可通过 customDir 完全自定义
*/
getDefaultCacheDir (customDir?: string): string {
if (customDir) return customDir;
return path.resolve(this.core.NapCatDataPath, `db_${this.core.selfInfo.uin}`);
}
/** 当前是否已获取到数据库 passphrase */
hasPassphrase (): boolean {
return !!this.core.dbPassphrase;
}
/** 获取 passphrase Buffer,未获取时返回 null */
getPassphraseBuffer (): Buffer | null {
return this.core.dbPassphrase ? Buffer.from(this.core.dbPassphrase) : null;
}
// ======================== SQLite 数据库操作 ========================
/**
* 打开一个已解密的 SQLite 数据库文件
* @param filePath 解密后 .db 文件路径
* @param readOnly 是否只读 (默认 true)
* @returns DatabaseHandle 实例,使用完毕后需调用 .close()
*/
openDecryptedDb (filePath: string, readOnly = true): DatabaseHandle {
return openDatabase(filePath, readOnly);
}
/**
* 解密并打开一个 NTQQ 加密数据库
* @param dbName 数据库文件名 (如 'nt_msg.db') 或完整路径
* @param readOnly 是否只读 (默认 true)
* @param cacheDir 自定义缓存目录,默认 db_<uin>
* @returns DatabaseHandle 实例,使用完毕后需调用 .close()
*/
openDatabase (dbName: string, readOnly = true, cacheDir?: string): DatabaseHandle | null {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return null;
const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
return decryptAndOpen(dbPath, passphrase, resolvedCacheDir, readOnly);
}
/**
* 对加密数据库执行查询并自动关闭连接
* @param dbName 数据库文件名或完整路径
* @param sql SQL 查询语句
* @param params 绑定参数
* @returns 查询结果行数组,passphrase 不可用时返回 null
*/
query<T = Record<string, unknown>> (
dbName: string,
sql: string,
params?: SQLInputValue[] | Record<string, SQLInputValue>
): T[] | null {
const db = this.openDatabase(dbName);
if (!db) return null;
try {
return db.query<T>(sql, params);
} finally {
db.close();
}
}
/**
* 对加密数据库执行查询,仅返回第一行
* @param dbName 数据库文件名或完整路径
* @param sql SQL 查询语句
* @param params 绑定参数
*/
queryOne<T = Record<string, unknown>> (
dbName: string,
sql: string,
params?: SQLInputValue[] | Record<string, SQLInputValue>
): T | undefined | null {
const db = this.openDatabase(dbName);
if (!db) return null;
try {
return db.queryOne<T>(sql, params);
} finally {
db.close();
}
}
/**
* 对加密数据库执行非查询 SQL (INSERT/UPDATE/DELETE 等) 并自动关闭
* @param dbName 数据库文件名或完整路径
* @param sql SQL 语句
* @param params 绑定参数
*/
execute (
dbName: string,
sql: string,
params?: SQLInputValue[] | Record<string, SQLInputValue>
): { changes: number | bigint; lastInsertRowid: number | bigint; } | null {
const db = this.openDatabase(dbName, false);
if (!db) return null;
try {
return db.execute(sql, params);
} finally {
db.close();
}
}
// ======================== 批量扫描 ========================
/**
* 读取单个加密数据库的表信息
* @param dbName 数据库文件名 (如 'nt_msg.db'),或完整路径
* @param cacheDir 自定义缓存目录,默认 db_<uin>
* @param options 其他选项
*/
readDatabase (dbName: string, cacheDir?: string, options?: Omit<DatabaseReadOptions, 'cacheDir'>): DatabaseScanResult | null {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return null;
const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
return readSingleDatabase(dbPath, passphrase, { cacheDir: resolvedCacheDir, ...options });
}
/**
* 扫描所有加密数据库
* @param cacheDir 自定义缓存目录,默认 db_<uin>
* @param options 其他选项
*/
scanDatabases (cacheDir?: string, options?: Omit<DatabaseReadOptions, 'cacheDir'>): DatabaseScanResult[] {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return [];
const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
return scanAllDatabases(this.getNtDbDir(), passphrase, { cacheDir: resolvedCacheDir, ...options });
}
/**
* 仅解密数据库文件(不读取表信息,不需要 node:sqlite)
* @param dbName 数据库文件名或完整路径
* @param outputPath 输出路径,默认保存到 db_<uin>/<dbName>
*/
decryptDatabase (dbName: string, outputPath?: string): string | null {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return null;
const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
const outPath = outputPath ?? path.resolve(this.getDefaultCacheDir(), path.basename(dbName));
return decryptDatabaseFile(dbPath, passphrase, outPath);
}
/** 格式化扫描结果为可读字符串 */
formatResults (results: DatabaseScanResult[]): string {
return formatScanResults(results);
}
}
export { DatabaseHandle };
export type { DatabaseScanResult, DatabaseReadOptions, TableInfo };
+390
View File
@@ -0,0 +1,390 @@
import {
ChatType,
ElementType,
IMAGE_HTTP_HOST,
IMAGE_HTTP_HOST_NT,
Peer,
PicElement,
RawMessage,
} from '@/napcat-core/types';
import path from 'path';
import fs from 'fs';
import fsPromises from 'fs/promises';
import { InstanceContext, NapCatCore, SearchResultItem } from '@/napcat-core/index';
import { fileTypeFromFile } from 'file-type';
import { RkeyManager } from '@/napcat-core/helper/rkey';
import { calculateFileMD5 } from 'napcat-common/src/file';
import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from 'napcat-protobuf';
import { FileId } from '../packet/transformer/proto/misc/fileid';
export class NTQQFileApi {
context: InstanceContext;
core: NapCatCore;
rkeyManager: RkeyManager;
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint; }> | undefined;
private fetchRkeyFailures: number = 0;
private readonly MAX_RKEY_FAILURES: number = 8;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
this.rkeyManager = new RkeyManager([
'http://ss.xingzhige.com/music_card/rkey',
'https://secret-service.bietiaop.com/rkeys',
],
this.context.logger
);
}
private async fetchRkeyWithRetry () {
if (this.fetchRkeyFailures >= this.MAX_RKEY_FAILURES) {
throw new Error('Native.FetchRkey 已被禁用');
}
try {
const ret = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
this.fetchRkeyFailures = 0; // Reset failures on success
return ret;
} catch (error) {
this.fetchRkeyFailures++;
this.context.logger.logError('FetchRkey 失败', (error as Error).message);
throw error;
}
}
async getFileUrl (chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined, timeout: number = 5000) {
if (this.core.apis.PacketApi.packetStatus) {
try {
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID, timeout);
} else if (file10MMd5 && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('fileUUID or file10MMd5 is undefined');
}
async getPttUrl (peer: string, fileUUID?: string, timeout: number = 5000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1403) {
return this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get ptt url');
}
async getVideoUrlPacket (peer: string, fileUUID?: string, timeout: number = 5000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1415) {
return this.core.apis.PacketApi.pkt.operation.GetGroupVideoUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get video url');
}
async copyFile (filePath: string, destPath: string) {
await this.core.util.copyFile(filePath, destPath);
}
async getFileSize (filePath: string): Promise<number> {
return await this.core.util.getFileSize(filePath);
}
async getVideoUrl (peer: Peer, msgId: string, elementId: string) {
return (await this.context.session.getRichMediaService().getVideoPlayUrlV2(peer, msgId, elementId, 0, {
downSourceType: 1,
triggerType: 1,
})).urlResult.domainUrl;
}
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) {
const fileMd5 = await calculateFileMD5(filePath);
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
let fileName = `${path.basename(filePath)}`;
if (fileName.indexOf('.') === -1) {
fileName += ext;
}
const fileSize = await this.getFileSize(filePath);
if (uploadGroupFile) {
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
return {
md5: fileMd5,
fileName,
path: mediaPath,
fileSize,
ext,
};
}
return {
md5: fileMd5,
fileName,
path: filePath,
fileSize,
ext,
};
}
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/downloadFileForModelId',
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[peer, [modelId], unknown],
() => true,
(arg) => arg?.commonFileInfo?.fileModelId === modelId,
1,
timeout
);
return fileTransNotifyInfo.filePath;
}
async downloadRawMsgMedia (msg: RawMessage[]) {
const res = await Promise.all(
msg.map(m =>
Promise.all(
m.elements
.filter(element =>
element.elementType === ElementType.PIC ||
element.elementType === ElementType.VIDEO ||
element.elementType === ElementType.PTT ||
element.elementType === ElementType.FILE
)
.map(element =>
this.downloadMedia(m.msgId, m.chatType, m.peerUid, element.elementId, '', '', 1000 * 60 * 2, true)
)
)
)
);
msg.forEach((m, msgIndex) => {
const elementResults = res[msgIndex];
let elementIndex = 0;
m.elements.forEach(element => {
if (
element.elementType === ElementType.PIC ||
element.elementType === ElementType.VIDEO ||
element.elementType === ElementType.PTT ||
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
}
elementIndex++;
}
});
});
return res.flat();
}
async downloadMedia (msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
// 用于下载文件
if (sourcePath && fs.existsSync(sourcePath)) {
if (force) {
try {
await fsPromises.unlink(sourcePath);
} catch {
//
}
} else {
return sourcePath;
}
}
const [, completeRetData] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/downloadRichMedia',
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[{
fileModelId: '0',
downSourceType: 0,
downloadSourceType: 0,
triggerType: 1,
msgId,
chatType,
peerUid,
elementId,
thumbSize: 0,
downloadType: 1,
filePath: thumbPath,
}],
() => true,
(arg) => arg.msgElementId === elementId && arg.msgId === msgId,
1,
timeout
);
return completeRetData.filePath;
}
async searchForFile (keys: string[]): Promise<SearchResultItem | undefined> {
const randomResultId = 100000 + Math.floor(Math.random() * 10000);
let searchId = 0;
const [, searchResult] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelFileAssistantService/searchFile',
'NodeIKernelFileAssistantListener/onFileSearch',
[
keys,
{ resultType: 2, pageLimit: 1 },
randomResultId,
],
(ret) => {
searchId = ret;
return true;
},
result => result.searchId === searchId && result.resultId === randomResultId
);
return searchResult.resultItems[0];
}
async downloadFileById (
fileId: string,
fileSize: number = 1024576,
estimatedTime: number = (fileSize * 1000 / 1024576) + 5000
) {
const [, fileData] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelFileAssistantService/downloadFile',
'NodeIKernelFileAssistantListener/onFileStatusChanged',
[[fileId]],
ret => ret.result === 0,
status => status.fileStatus === 2 && status.fileProgress === '0',
1,
estimatedTime // estimate 1MB/s
);
return fileData.filePath!;
}
async getImageUrl (element: PicElement): Promise<string> {
if (!element) {
return '';
}
const url: string = element.originImageUrl ?? '';
const md5HexStr = element.md5HexStr;
const fileMd5 = element.md5HexStr;
const parsedUrl = new URL(IMAGE_HTTP_HOST + url);
const imageAppid = parsedUrl.searchParams.get('appid');
const isNTV2 = imageAppid && ['1406', '1407'].includes(imageAppid);
const imageFileId = parsedUrl.searchParams.get('fileid');
if (url && isNTV2 && imageFileId) {
const rkeyData = await this.getRkeyData();
return this.getImageUrlFromParsedUrl(imageFileId, imageAppid, rkeyData);
}
return this.getImageUrlFromMd5(fileMd5, md5HexStr);
}
private async getRkeyData () {
const rkeyData: rkeyDataType = {
private_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qEc3Rbib9LP4',
group_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qffcqm614gds',
online_rkey: false,
};
try {
if (this.core.apis.PacketApi.packetStatus) {
const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
if (rkey_expired_private || rkey_expired_group) {
this.packetRkey = await this.fetchRkeyWithRetry();
}
if (this.packetRkey && this.packetRkey.length > 0) {
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
rkeyData.private_rkey = this.packetRkey[0]?.rkey.slice(6) ?? '';
rkeyData.online_rkey = true;
}
}
} catch (error: unknown) {
this.context.logger.logDebug('获取native.rkey失败', (error as Error).message);
}
if (!rkeyData.online_rkey) {
try {
const tempRkeyData = await this.rkeyManager.getRkey();
rkeyData.group_rkey = tempRkeyData.group_rkey;
rkeyData.private_rkey = tempRkeyData.private_rkey;
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
} catch (error: unknown) {
this.context.logger.logDebug('获取remote.rkey失败', (error as Error).message);
}
}
// 进行 fallback.rkey 模式
return rkeyData;
}
private getImageUrlFromParsedUrl (imageFileId: string, appid: string, rkeyData: rkeyDataType): string {
const rkey = appid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
if (rkeyData.online_rkey) {
return IMAGE_HTTP_HOST_NT + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}`;
}
return IMAGE_HTTP_HOST + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}&spec=0`;
}
private getImageUrlFromMd5 (fileMd5: string | undefined, md5HexStr: string | undefined): string {
if (fileMd5 || md5HexStr) {
return `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${(fileMd5 ?? md5HexStr ?? '').toUpperCase()}/0`;
}
this.context.logger.logDebug('图片url获取失败', { fileMd5, md5HexStr });
return '';
}
}
+319
View File
@@ -0,0 +1,319 @@
import { GeneralCallResult, InstanceContext, NapCatCore } from '@/napcat-core';
import {
createFlashTransferResult,
DEFAULT_FLASH_VALIDITY_SECONDS,
FileListResponse,
FlashFileSetInfo,
SendStatus,
UploadSceneType,
} from '@/napcat-core/data/flash';
import { Peer } from '@/napcat-core/types';
export class NTQQFlashApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/**
* 发起闪传上传任务
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
* @param thumbnailPath
* @param filesetName
* @param validitySeconds 文件集有效期(秒),默认 1209600(14 天),可选 90 天 / 180 天,必须传否则新版内核段错误
*/
async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string, validitySeconds: number = DEFAULT_FLASH_VALIDITY_SECONDS): Promise<GeneralCallResult & {
createFlashTransferResult: createFlashTransferResult;
seq: number;
}> {
const flashService = this.context.session.getFlashTransferService();
const timestamp: number = Date.now();
const selfInfo = this.core.selfInfo;
const fileUploadArg = {
scene: 1, // 1
name: filesetName,
uploaders: [{
uin: selfInfo.uin,
nickname: selfInfo.nick,
uid: selfInfo.uid,
sendEntrance: '',
}],
permission: {
code: 0,
},
coverPath: thumbnailPath,
paths: fileListToUpload,
excludePaths: [],
expireLeftTime: 0,
isNeedDelDeviceInfo: false,
isNeedDelLocation: false,
coverOriginalInfos: [
{
path: fileListToUpload[0] || '',
thumbnailPath,
},
],
uploadSceneType: UploadSceneType.KUPLOADSCENEAIOFILESELECTOR, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
detectPrivacyInfoResult: {
exists: false,
allDetectResults: {},
},
announcement: {
announcementSegments: [],
modifyTime: '',
avatarUrl: '',
},
albumFilePathList: [],
validitySeconds,
};
const uploadResult = await flashService.createFlashTransferUploadTask(timestamp, fileUploadArg);
if (uploadResult.result === 0) {
this.context.logger.log('[Flash] 发起闪传任务成功');
return uploadResult;
} else {
this.context.logger.logError('[Flash] 发起闪传上传任务失败!!');
return uploadResult;
}
}
/**
* 下载闪传文件集
* @param fileSetId
*/
async downloadFileSetBySetId (fileSetId: string): Promise<GeneralCallResult & {
extraInfo: unknown;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.startFileSetDownload(fileSetId, 1, { isIncludeCompressInnerFiles: false }); // 为了方便,暂时硬编码
if (result.result === 0) {
this.context.logger.log('[Flash] 成功开始下载文件集');
} else {
this.context.logger.logError('[Flash] 尝试下载文件集失败!');
}
return result;
}
/**
* 获取闪传的外链分享
* @param fileSetId
*/
async getShareLinkBySetId (fileSetId: string): Promise<GeneralCallResult & {
shareLink: string;
expireTimestamp: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.getShareLinkReq(fileSetId);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取闪传外链分享成功:', result.shareLink);
} else {
this.context.logger.logError('[Flash] 获取闪传外链失败!!');
}
return result;
}
/**
* 从分享外链获取文件集id
* @param shareCode
*/
async fromShareLinkFindSetId (shareCode: string): Promise<GeneralCallResult & {
fileSetId: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.getFileSetIdByCode(shareCode);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取shareCode的文件集Id成功!');
} else {
this.context.logger.logError('[Flash] 获取文件集ID失败!!');
}
return result;
}
/**
* 获取fileSet的文件结构信息 (未来可能需要深度遍历)
* == 注意返回结构和其它的不同,没有GeneralCallResult!!! ==
* @param fileSetId
*/
async getFileListBySetId (fileSetId: string): Promise<FileListResponse> {
const flashService = this.context.session.getFlashTransferService();
const requestArg = {
seq: 0,
fileSetId,
isUseCache: false,
sceneType: 1, // 硬编码
reqInfos: [
{
count: 18, // 18 ??
paginationInfo: {},
parentId: '',
reqIndexPath: '',
reqDepth: 1,
filterCondition: {
fileCategory: 0,
filterType: 0,
},
sortConditions: [
{
sortField: 0,
sortOrder: 0,
},
],
isNeedPhysicalInfoReady: false,
},
],
};
const result = await flashService.getFileList(requestArg);
if (result.rsp.result === 0) {
this.context.logger.log('[Flash] 获取fileSet文件信息成功!');
return result.rsp;
} else {
this.context.logger.logError(`[Flash] 获取文件信息失败:ErrMsg: ${result.rsp.errMs}`);
return result.rsp;
}
}
/**
* 获取闪传文件集合信息
* @param fileSetId
*/
async getFileSetIndoBySetId (fileSetId: string): Promise<GeneralCallResult & {
seq: number;
isCache: boolean;
fileSet: FlashFileSetInfo;
}> {
const flashService = this.context.session.getFlashTransferService();
const requestArg = {
fileSetId,
};
const result = await flashService.getFileSet(requestArg);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取闪传文件集信息成功!');
} else {
this.context.logger.logError('[Flash] 获取闪传文件信息失败!!');
}
return result;
}
/**
* 发送闪传消息(私聊/群聊)
* @param fileSetId
* @param peer
*/
async sendFlashMessage (fileSetId: string, peer: Peer): Promise<{
errCode: number,
errMsg: string,
rsp: {
sendStatus: SendStatus[];
};
}> {
const flashService = this.context.session.getFlashTransferService();
const target = {
destUid: peer.peerUid,
destType: peer.chatType,
// destUin: peer.peerUin,
};
const requestsArg = {
fileSetId,
targets: [target],
};
const result = await flashService.sendFlashTransferMsg(requestsArg);
if (result.errCode === 0) {
this.context.logger.log('[Flash] 消息发送成功');
} else {
this.context.logger.logError(`[Flash] 消息发送失败!!原因:${result.errMsg}`);
}
return result;
}
/**
* 获取闪传文件集中某个文件的下载URL(外链)
* @param fileSetId
* @param options
*/
async getFileTransUrl (fileSetId: string, options: { fileName?: string; fileIndex?: number; }): Promise<GeneralCallResult & {
transferUrl: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await this.getFileListBySetId(fileSetId);
const { fileName, fileIndex } = options;
let targetFile: any;
let file: any;
const allFolder = result.fileLists;
// eslint-disable-next-line no-labels
searchLoop: for (const folder of allFolder) {
const fileList = folder.fileList;
for (let i = 0; i < fileList.length; i++) {
file = fileList[i];
if (fileName !== undefined && file.name === fileName) {
targetFile = file;
// eslint-disable-next-line no-labels
break searchLoop;
}
if (fileIndex !== undefined && i === fileIndex) {
targetFile = file;
// eslint-disable-next-line no-labels
break searchLoop;
}
}
}
if (targetFile === undefined) {
this.context.logger.logError('[Flash] 未找到对应文件!!');
return {
result: -1,
errMsg: '未找到对应文件',
transferUrl: '',
};
} else {
this.context.logger.log('[Flash] 找到对应文件,准备尝试获取传输链接');
const res = await flashService.startFileTransferUrl(targetFile);
return {
result: 0,
errMsg: '',
transferUrl: res.url,
};
}
}
async createFileThumbnail (filePath: string): Promise<any> {
const msgService = this.context.session.getMsgService();
const savePath = msgService.getFileThumbSavePathForSend(750, true);
const result = await this.core.util.createThumbnailImage(
'flashtransfer',
filePath,
savePath,
{
width: 520,
height: 520,
},
'jpeg',
null
);
if (result.result === 0) {
this.context.logger.log('获取缩略图成功!!');
result.targetPath = savePath;
return result;
}
return result;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { FriendRequest, FriendV2 } from '@/napcat-core/types';
import { BuddyListReqType, InstanceContext, NapCatCore } from '@/napcat-core/index';
import { LimitedHashTable } from 'napcat-common/src/message-unique';
export class NTQQFriendApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async setBuddyRemark (uid: string, remark: string) {
return this.context.session.getBuddyService().setBuddyRemark({ uid, remark });
}
async getBuddyV2SimpleInfoMap () {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
} else {
const buddyListV2 = await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL);
uids = buddyListV2.data.flatMap(item => item.buddyUids);
}
return await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getCoreAndBaseInfo',
'nodeStore',
uids
);
}
async getBuddy (): Promise<FriendV2[]> {
return Array.from((await this.getBuddyV2SimpleInfoMap()).values());
}
async getBuddyIdMap (): Promise<LimitedHashTable<string, string>> {
const retMap: LimitedHashTable<string, string> = new LimitedHashTable<string, string>(5000);
const data = await this.getBuddyV2SimpleInfoMap();
data.forEach((value) => retMap.set(value.uin!, value.uid!));
return retMap;
}
async delBuddy (uid: string, tempBlock = false, tempBothDel = false) {
return this.context.session.getBuddyService().delBuddy({
friendUid: uid,
tempBlock,
tempBothDel,
});
}
async getBuddyV2ExWithCate () {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids);
} else {
buddyListV2 = (await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids);
}
const data = await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getCoreAndBaseInfo',
'nodeStore',
uids
);
return buddyListV2.map(category => ({
categoryId: category.categoryId,
categorySortId: category.categorySortId,
categoryName: category.categroyName,
categoryMbCount: category.categroyMbCount,
onlineCount: category.onlineCount,
buddyList: category.buddyUids.map(uid => data.get(uid)).filter(value => !!value),
}));
}
async isBuddy (uid: string) {
return this.context.session.getBuddyService().isBuddy(uid);
}
async clearBuddyReqUnreadCnt () {
return this.context.session.getBuddyService().clearBuddyReqUnreadCnt();
}
async getBuddyReq () {
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelBuddyService/getBuddyReq',
'NodeIKernelBuddyListener/onBuddyReqChange',
[]
);
return ret;
}
async handleFriendRequest (notify: FriendRequest, accept: boolean) {
this.context.session.getBuddyService()?.approvalFriendRequest({
friendUid: notify.friendUid,
reqTime: notify.reqTime,
accept,
});
}
async handleDoubtFriendRequest (friendUid: string, str1: string = '', str2: string = '') {
this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
}
async getDoubtFriendRequest (count: number) {
const date = Date.now().toString();
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelBuddyService/getDoubtBuddyReq',
'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
[date, count, ''],
() => true,
(data) => data.reqId === date
);
const requests = Promise.all(ret.doubtList.map(async (item) => {
return {
flag: item.uid, // 注意强制String 非isNumeric 不遵守则不符合设计
uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0, // 信息字段
nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
source: item.source, // 信息字段
reason: item.reason, // 信息字段
msg: item.msg, // 信息字段
group_code: item.groupCode, // 信息字段
time: item.reqTime, // 信息字段
type: 'doubt', // 保留字段
};
}));
return requests;
}
}
+651
View File
@@ -0,0 +1,651 @@
import {
GeneralCallResult,
GroupMember,
NTGroupMemberRole,
NTGroupRequestOperateTypes,
InstanceContext,
KickMemberV2Req,
MemberExtSourceType,
NapCatCore,
GroupNotify,
GroupInfoSource,
ShutUpGroupMember,
Peer,
ChatType,
GroupManagementSettings,
} from '@/napcat-core/index';
import { isNumeric, solveAsyncProblem } from 'napcat-common/src/helper';
import { LimitedHashTable } from 'napcat-common/src/message-unique';
import { CancelableTask, TaskExecutor } from 'napcat-common/src/cancel-task';
import {
createGroupDetailInfoV2Param,
createGroupExtFilter,
createGroupExtInfo,
createGroupManagementRequests,
} from '../data';
import { NTEventWrapper } from '../helper/event';
import { deleteGroupFiles } from '../helper/group-file';
export class NTQQGroupApi {
context: InstanceContext;
core: NapCatCore;
groupMemberCache: Map<string, Map<string, GroupMember>> = new Map<string, Map<string, GroupMember>>();
essenceLRU = new LimitedHashTable<number, string>(1000);
// psKey 滑动缓存,TTL 60 秒,惰性删除(不主动清理,等下次访问检测到过期再刷新)
private _qunPskeyCache: { value: string; expireAt: number; } | null = null;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/** 获取 qun.qq.com 域名的 psKey,滑动 TTL 60 秒(每次访问顺延) */
private async getQunPskey (): Promise<string> {
const now = Date.now();
if (this._qunPskeyCache && this._qunPskeyCache.expireAt > now) {
// 滑动 TTL:每次访问都顺延过期时间
this._qunPskeyCache.expireAt = now + 60 * 1000;
return this._qunPskeyCache.value;
}
// 过期或为空,惰性删除旧值(直接覆盖)并重新获取
const psKey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
this._qunPskeyCache = { value: psKey, expireAt: now + 60 * 1000 };
return psKey;
}
async setGroupRemark (groupCode: string, remark: string) {
return this.context.session.getGroupService().modifyGroupRemark(groupCode, remark);
}
async fetchGroupDetail (groupCode: string) {
const [, detailInfo] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getGroupDetailInfo',
'NodeIKernelGroupListener/onGroupDetailInfoChange',
[groupCode, GroupInfoSource.KDATACARD],
(ret) => ret.result === 0,
(detailInfo) => detailInfo.groupCode === groupCode,
1,
5000
);
return detailInfo;
}
async initApi () {
this.initCache().then().catch(e => this.context.logger.logError(e));
}
async createGrayTip (groupCode: string, tip: string) {
return this.context.session.getMsgService().addLocalJsonGrayTipMsg(
{
chatType: ChatType.KCHATTYPEGROUP,
peerUid: groupCode,
} as Peer,
{
busiId: 2201,
jsonStr: JSON.stringify({ align: 'center', items: [{ txt: tip, type: 'nor' }] }),
recentAbstract: tip,
isServer: false,
},
true,
true
);
}
async initCache () {
for (const group of await this.getGroups(true)) {
this.refreshGroupMemberCache(group.groupCode, false).then().catch(e => this.context.logger.logError(e));
}
}
async fetchGroupEssenceList (groupCode: string) {
const pskey = await this.getQunPskey();
return this.context.session.getGroupService().fetchGroupEssenceList({
groupCode,
pageStart: 0,
pageLimit: 300,
}, pskey);
}
async getGroupShutUpMemberList (groupCode: string): Promise<ShutUpGroupMember[]> {
const executor: TaskExecutor<ShutUpGroupMember[]> = async (resolve, reject, onCancel) => {
this.core.eventWrapper.registerListen(
'NodeIKernelGroupListener/onShutUpMemberListChanged',
(group_id) => group_id === groupCode,
1,
1000
).then((data) => {
resolve(data[1]);
}).catch(reject);
onCancel(() => {
reject(new Error('Task was canceled'));
});
};
const task = new CancelableTask(executor);
this.context.session.getGroupService().getGroupShutUpMemberList(groupCode).then(e => {
if (e.result !== 0) {
task.cancel();
}
});
return await task.catch(() => []);
}
async clearGroupNotifiesUnreadCount (doubt: boolean) {
return this.context.session.getGroupService().clearGroupNotifiesUnreadCount(doubt);
}
async setGroupAvatar (groupCode: string, filePath: string) {
return this.context.session.getGroupService().setHeader(groupCode, filePath);
}
// 0 0 无需管理员审核
// 0 2 需要管理员审核
// 1 2 禁止Bot入群( 最好只传一个1 ?)
async setGroupRobotAddOption (groupCode: string, robotMemberSwitch?: number, robotMemberExamine?: number) {
const extInfo = createGroupExtInfo(groupCode);
const groupExtFilter = createGroupExtFilter();
if (robotMemberSwitch !== undefined) {
extInfo.extInfo.inviteRobotMemberSwitch = robotMemberSwitch;
groupExtFilter.inviteRobotMemberSwitch = 1;
}
if (robotMemberExamine !== undefined) {
extInfo.extInfo.inviteRobotMemberExamine = robotMemberExamine;
groupExtFilter.inviteRobotMemberExamine = 1;
}
return this.context.session.getGroupService().modifyGroupExtInfoV2(extInfo, groupExtFilter);
}
async setGroupAddOption (groupCode: string, option: {
addOption: number;
groupQuestion?: string;
groupAnswer?: string;
}) {
const param = createGroupDetailInfoV2Param(groupCode);
// 设置要修改的目标
param.filter.addOption = 1;
if (option.addOption === 4 || option.addOption === 5) {
// 4 问题进入答案 5 问题管理员批准
param.filter.groupQuestion = 1;
param.filter.groupAnswer = option.addOption === 4 ? 1 : 0;
param.modifyInfo.groupQuestion = option.groupQuestion || '';
param.modifyInfo.groupAnswer = option.addOption === 4 ? option.groupAnswer || '' : '';
}
param.modifyInfo.addOption = option.addOption;
return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
}
async setGroupMemberInvitePolicy (
groupCode: string,
policy: NonNullable<GroupManagementSettings['memberInvite']>
) {
return this.modifyGroupManagementSettings(groupCode, { memberInvite: policy });
}
async setGroupMemberPermissions (
groupCode: string,
permissions: Pick<
GroupManagementSettings,
'allowMemberUploadAlbum' | 'allowMemberTemporarySession' | 'allowMemberCreateGroup'
>
) {
return this.modifyGroupManagementSettings(groupCode, permissions);
}
async setGroupNewMemberHistoryVisibility (groupCode: string, visible: boolean) {
return this.modifyGroupManagementSettings(groupCode, { newMembersSeeRecentHistory: visible });
}
private async modifyGroupManagementSettings (groupCode: string, settings: GroupManagementSettings) {
const results: Array<{ setting: string, result: GeneralCallResult; }> = [];
const changesKnownFlags = settings.memberInvite !== undefined ||
settings.allowMemberUploadAlbum !== undefined ||
settings.allowMemberTemporarySession !== undefined ||
settings.allowMemberCreateGroup !== undefined ||
settings.newMembersSeeRecentHistory !== undefined;
const currentDetail = changesKnownFlags ? await this.fetchGroupDetail(groupCode) : undefined;
for (const request of createGroupManagementRequests(
groupCode,
settings,
currentDetail?.privilegeFlag,
currentDetail?.groupFlagExt4
)) {
results.push({
setting: request.setting,
result: await this.context.session.getGroupService().modifyGroupDetailInfoV2(
request.param,
request.operationType
),
});
}
return results;
}
async setGroupSearch (groupCode: string, option: {
noCodeFingerOpenFlag?: number;
noFingerOpenFlag?: number;
}) {
const param = createGroupDetailInfoV2Param(groupCode);
if (option.noCodeFingerOpenFlag) {
param.filter.noCodeFingerOpenFlag = 1;
param.modifyInfo.noCodeFingerOpenFlag = option.noCodeFingerOpenFlag;
}
if (option.noFingerOpenFlag) {
param.filter.noFingerOpenFlag = 1;
param.modifyInfo.noFingerOpenFlag = option.noFingerOpenFlag;
}
return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
}
async getGroups (forced: boolean = false) {
const [, , groupList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getGroupList',
'NodeIKernelGroupListener/onGroupListUpdate',
[forced]
);
return groupList;
}
async getGroupExtFE0Info (groupCodes: Array<string>, forced = true) {
return this.context.session.getGroupService().getGroupExt0xEF0Info(
groupCodes,
[],
{
bindGuildId: 1,
blacklistExpireTime: 1,
companyId: 1,
essentialMsgPrivilege: 1,
essentialMsgSwitch: 1,
fullGroupExpansionSeq: 1,
fullGroupExpansionSwitch: 1,
gangUpId: 1,
groupAioBindGuildId: 1,
groupBindGuildIds: 1,
groupBindGuildSwitch: 1,
groupExcludeGuildIds: 1,
groupExtFlameData: 1,
groupFlagPro1: 1,
groupInfoExtSeq: 1,
groupOwnerId: 1,
groupSquareSwitch: 1,
hasGroupCustomPortrait: 1,
inviteRobotMemberExamine: 1,
inviteRobotMemberSwitch: 1,
inviteRobotSwitch: 1,
isLimitGroupRtc: 1,
lightCharNum: 1,
luckyWord: 1,
luckyWordId: 1,
msgEventSeq: 1,
qqMusicMedalSwitch: 1,
reserve: 1,
showPlayTogetherSwitch: 1,
starId: 1,
todoSeq: 1,
viewedMsgDisappearTime: 1,
},
forced
);
}
async getGroupMemberAll (groupCode: string, forced = false) {
return this.context.session.getGroupService().getAllMemberList(groupCode, forced);
}
async refreshGroupMemberCache (groupCode: string, isWait = true) {
const updateCache = async () => {
try {
const members = await this.getGroupMemberAll(groupCode, true);
if (!members?.result?.infos) {
throw new Error(`errCode: ${members?.errCode}, errMsg: ${members?.errMsg}`);
}
this.groupMemberCache.set(groupCode, members.result.infos);
} catch (e) {
this.context.logger.logError(`刷新群成员缓存失败, 群号: ${groupCode}, 错误: ${e}`);
}
};
if (isWait) {
await updateCache();
} else {
updateCache();
}
return this.groupMemberCache.get(groupCode);
}
async refreshGroupMemberCachePartial (groupCode: string, uid: string) {
const member = await this.getGroupMemberEx(groupCode, uid, true);
if (member) {
this.groupMemberCache.get(groupCode)?.set(uid, member);
}
return member;
}
async getGroupMember (groupCode: string | number, memberUinOrUid: string | number) {
const groupCodeStr = groupCode.toString();
const memberUinOrUidStr = memberUinOrUid.toString();
// 获取群成员缓存
let members = this.groupMemberCache.get(groupCodeStr);
const cached = !!members;
if (!members) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
}
// 刚入的群可能拉不到成员列表,这时 members 为空,不能直接抛错,否则整条群消息都上报不出去
const getMember = () => {
if (!members) {
return undefined;
}
if (isNumeric(memberUinOrUidStr)) {
return Array.from(members.values()).find(member => member.uin?.toString() === memberUinOrUidStr);
} else {
return members.get(memberUinOrUidStr);
}
};
let member = getMember();
// 缓存里没有该成员(比如新入群),刷新一次;上面刚刷新过就不再重复拉取
if (!member && cached) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
member = getMember();
}
return member;
}
// 从已缓存的群成员里按 QQ 号找 uid,供 uin 转 uid 兜底
getUidFromMemberCache (uin: string) {
for (const members of this.groupMemberCache.values()) {
for (const [uid, member] of members) {
if (member.uin?.toString() === uin) {
return uid;
}
}
}
return undefined;
}
async getGroupRecommendContactArkJson (groupCode: string) {
return this.context.session.getGroupService().getGroupRecommendContactArkJson(groupCode);
}
async getJoinGroupLink (groupCode: string, needShortUrl = true, srcId = 73, additionalParam = '') {
return this.context.session.getGroupService().getJoinGroupLink({
groupCode,
srcId,
needShortUrl,
additionalParam,
});
}
async creatGroupFileFolder (groupCode: string, folderName: string) {
return this.context.session.getRichMediaService().createGroupFolder(groupCode, folderName);
}
async delGroupFile (groupCode: string, files: Array<string>, busIds?: Array<number>) {
return deleteGroupFiles(this.context.session.getRichMediaService(), groupCode, files, busIds);
}
async delGroupFileFolder (groupCode: string, folderId: string) {
return this.context.session.getRichMediaService().deleteGroupFolder(groupCode, folderId);
}
async transGroupFile (groupCode: string, fileId: string) {
return this.context.session.getRichMediaService().transGroupFile(groupCode, fileId);
}
async addGroupEssence (groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2,
guildId: '',
peerUid: groupCode,
}, msgId, 1, false);
if (!MsgData.msgList[0]) {
throw new Error('消息不存在');
}
const param = {
groupCode,
msgRandom: parseInt(MsgData.msgList[0].msgRandom),
msgSeq: parseInt(MsgData.msgList[0].msgSeq),
};
return this.context.session.getGroupService().addGroupEssence(param);
}
async kickMemberV2Inner (param: KickMemberV2Req) {
return this.context.session.getGroupService().kickMemberV2(param);
}
async deleteGroupBulletin (groupCode: string, noticeId: string) {
const psKey = await this.getQunPskey();
return this.context.session.getGroupService().deleteGroupBulletin(groupCode, psKey, noticeId);
}
async quitGroupV2 (GroupCode: string, needDeleteLocalMsg: boolean) {
const param = {
groupCode: GroupCode,
needDeleteLocalMsg,
};
return this.context.session.getGroupService().quitGroupV2(param);
}
async removeGroupEssenceBySeq (groupCode: string, msgRandom: string, msgSeq: string) {
const param = {
groupCode,
msgRandom: parseInt(msgRandom),
msgSeq: parseInt(msgSeq),
};
return this.context.session.getGroupService().removeGroupEssence(param);
}
async removeGroupEssence (groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2,
guildId: '',
peerUid: groupCode,
}, msgId, 1, false);
if (!MsgData.msgList[0]) {
throw new Error('消息不存在');
}
const param = {
groupCode,
msgRandom: parseInt(MsgData.msgList[0].msgRandom),
msgSeq: parseInt(MsgData.msgList[0].msgSeq),
};
return this.context.session.getGroupService().removeGroupEssence(param);
}
async getSingleScreenNotifies (doubt: boolean, count: number) {
const [, , , notifies] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getSingleScreenNotifies',
'NodeIKernelGroupListener/onGroupSingleScreenNotifies',
[
doubt,
'',
count,
]
);
return notifies;
}
async searchGroup (groupCode: string) {
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelSearchService/searchGroup',
'NodeIKernelSearchListener/onSearchGroupResult',
[{
keyWords: groupCode,
groupNum: 25,
exactSearch: false,
penetrate: '',
}],
(ret) => ret.result === 0,
(params) => !!params.groupInfos.find(g => g.groupCode === groupCode),
1,
5000
);
return ret.groupInfos.find(g => g.groupCode === groupCode);
}
async getGroupMemberEx (groupCode: string, uid: string, forced: boolean = false, retry: number = 2) {
const data = await solveAsyncProblem((eventWrapper: NTEventWrapper, GroupCode: string, uid: string, forced = false) => {
return eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getMemberInfo',
'NodeIKernelGroupListener/onMemberInfoChange',
[groupCode, [uid], forced],
(ret) => ret.result === 0,
(params: string, _: any, members: Map<string, GroupMember>) => params === GroupCode && members.size > 0 && members.has(uid),
1,
forced ? 2500 : 250
);
}, this.core.eventWrapper, groupCode, uid, forced);
if (data && data[3] instanceof Map && data[3].has(uid)) {
return data[3].get(uid);
}
if (retry > 0) {
const trydata = await this.getGroupMemberEx(groupCode, uid, true, retry - 1) as GroupMember | undefined;
if (trydata) return trydata;
}
return undefined;
}
async getGroupFileCount (groupCodes: Array<string>) {
return this.context.session.getRichMediaService().batchGetGroupFileCount(groupCodes);
}
// getGroupFileList also carries the group's used/total space in its call
// result's `groupSpaceResult`; a single-item fetch is enough to read it.
async getGroupFileSpace (groupCode: string) {
const [result] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/getGroupFileList',
'NodeIKernelMsgListener/onGroupFileInfoUpdate',
[
groupCode,
{ sortType: 1, fileCount: 1, startIndex: 0, sortOrder: 1, showOnlinedocFolder: 0 },
],
() => true,
() => true,
1,
5000
);
return result?.groupSpaceResult;
}
async getArkJsonGroupShare (groupCode: string) {
const ret = await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelGroupService/getGroupRecommendContactArkJson',
groupCode
) as GeneralCallResult & { arkJson: string; };
return ret.arkJson;
}
async uploadGroupBulletinPic (groupCode: string, imageurl: string) {
const _Pskey = await this.getQunPskey();
return this.context.session.getGroupService().uploadGroupBulletinPic(groupCode, _Pskey, imageurl);
}
async handleGroupRequest (doubt: boolean, notify: GroupNotify, operateType: NTGroupRequestOperateTypes, reason?: string) {
return this.context.session.getGroupService().operateSysNotify(
doubt,
{
operateType,
targetMsg: {
seq: notify.seq, // 通知序列号
type: notify.type,
groupCode: notify.group.groupCode,
postscript: reason ?? ' ', // 仅传空值可能导致处理失败,故默认给个空格
},
});
}
async quitGroup (groupCode: string) {
return this.context.session.getGroupService().quitGroup(groupCode);
}
async destroyGroup (groupCode: string) {
return this.context.session.getGroupService().destroyGroup(groupCode);
}
async kickMember (groupCode: string, kickUids: string[], refuseForever: boolean = false, kickReason: string = '') {
return this.context.session.getGroupService().kickMember(groupCode, kickUids, refuseForever, kickReason);
}
async banMember (groupCode: string, memList: Array<{ uid: string, timeStamp: number; }>) {
// timeStamp为秒数, 0为解除禁言
return this.context.session.getGroupService().setMemberShutUp(groupCode, memList);
}
async banGroup (groupCode: string, shutUp: boolean) {
return this.context.session.getGroupService().setGroupShutUp(groupCode, shutUp);
}
async setMemberCard (groupCode: string, memberUid: string, cardName: string) {
return this.context.session.getGroupService().modifyMemberCardName(groupCode, memberUid, cardName);
}
async setMemberRole (groupCode: string, memberUid: string, role: NTGroupMemberRole) {
return this.context.session.getGroupService().modifyMemberRole(groupCode, memberUid, role);
}
async setGroupName (groupCode: string, groupName: string, isNormalMember: boolean = false) {
return this.context.session.getGroupService().modifyGroupName(groupCode, groupName, isNormalMember);
}
async publishGroupBulletin (groupCode: string, content: string, picInfo: {
id: string,
width: number,
height: number;
} | undefined = undefined, pinned: number = 0, confirmRequired: number = 0) {
const psKey = await this.getQunPskey();
// text是content内容url编码
const data = {
text: encodeURI(content),
picInfo,
oldFeedsId: '',
pinned,
confirmRequired,
};
return this.context.session.getGroupService().publishGroupBulletin(groupCode, psKey!, data);
}
async getGroupRemainAtTimes (groupCode: string) {
return this.context.session.getGroupService().getGroupRemainAtTimes(groupCode);
}
async getMemberExtInfo (groupCode: string, uin: string) {
return this.context.session.getGroupService().getMemberExtInfo(
{
groupCode,
sourceType: MemberExtSourceType.TITLETYPE,
beginUin: '0',
dataTime: '0',
uinList: [uin],
uinNum: '',
seq: '',
groupType: '',
richCardNameVer: '',
memberExtFilter: {
memberLevelInfoUin: 1,
memberLevelInfoPoint: 1,
memberLevelInfoActiveDay: 1,
memberLevelInfoLevel: 1,
memberLevelInfoName: 1,
levelName: 1,
dataTime: 1,
userShowFlag: 1,
sysShowFlag: 1,
timeToUpdate: 1,
nickName: 1,
specialTitle: 1,
levelNameNew: 1,
userShowFlagNew: 1,
msgNeedField: 1,
cmdUinFlagExt3Grocery: 1,
memberIcon: 1,
memberInfoSeq: 1,
},
}
);
}
}
+14
View File
@@ -0,0 +1,14 @@
export * from './file';
export * from './friend';
export * from './group';
export * from './msg';
export * from './user';
export * from './webapi';
export * from './system';
export * from './packet';
export * from './file';
export * from './online';
export * from './flash';
export * from './database';
export * from './qzone';
export * from './qzone-public';
+325
View File
@@ -0,0 +1,325 @@
import { ChatType, GetFileListParam, Peer, RawMessage, SendMessageElement, SendStatusType } from '@/napcat-core/types';
import { GroupFileInfoUpdateItem, InstanceContext, NapCatCore, NodeIKernelMsgService } from '@/napcat-core/index';
import { GeneralCallResult } from '@/napcat-core/services/common';
export class NTQQMsgApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async clickInlineKeyboardButton (...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
}
getMsgByClientSeqAndTime (peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) {
// https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
}
async getAioFirstViewLatestMsgs (peer: Peer, MsgCount: number) {
return this.context.session.getMsgService().getAioFirstViewLatestMsgs(peer, MsgCount);
}
async sendShowInputStatusReq (peer: Peer, eventType: number) {
return this.context.session.getMsgService().sendShowInputStatusReq(peer.chatType, eventType, peer.peerUid);
}
async getSourceOfReplyMsgV2 (peer: Peer, clientSeq: string, time: string) {
return this.context.session.getMsgService().getSourceOfReplyMsgV2(peer, clientSeq, time);
}
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string = '', count: number = 20) {
// 注意此处emojiType 可选值一般为1-2 2好像是unicode表情dec值 大部分情况 Taged Mlikiowa
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, cookie, false, count);
}
async setEmojiLike (peer: Peer, msgSeq: string, emojiId: string, set: boolean = true) {
emojiId = emojiId.toString();
return this.context.session.getMsgService().setMsgEmojiLikes(peer, msgSeq, emojiId, emojiId.length > 3 ? '2' : '1', set);
}
async getMultiMsg (peer: Peer, rootMsgId: string, parentMsgId: string): Promise<GeneralCallResult & {
msgList: RawMessage[];
} | undefined> {
return this.context.session.getMsgService().getMultiMsg(peer, rootMsgId, parentMsgId);
}
async ForwardMsg (peer: Peer, msgIds: string[]) {
return this.context.session.getMsgService().forwardMsg(msgIds, peer, [peer], new Map());
}
async getMsgsByMsgId (peer: Peer | undefined, msgIds: string[] | undefined) {
if (!peer) throw new Error('peer is not allowed');
if (!msgIds) throw new Error('msgIds is not allowed');
// MliKiowa: 参数不合规会导致NC异常崩溃 原因是TX未对进入参数判断 对应Android标记@NotNull AndroidJADX分析可得
return await this.context.session.getMsgService().getMsgsByMsgId(peer, msgIds);
}
async getSingleMsg (peer: Peer, seq: string) {
return await this.context.session.getMsgService().getSingleMsg(peer, seq);
}
async fetchFavEmojiList (num: number) {
return this.context.session.getMsgService().fetchFavEmojiList('', num, true, true);
}
async addFavEmoji (req: Parameters<NodeIKernelMsgService['addFavEmoji']>[0]) {
return this.context.session.getMsgService().addFavEmoji(req);
}
async deleteFavEmoji (ids: string[]) {
return this.context.session.getMsgService().deleteFavEmoji(ids);
}
async modifyFavEmojiDesc (items: Parameters<NodeIKernelMsgService['modifyFavEmojiDesc']>[0]) {
return this.context.session.getMsgService().modifyFavEmojiDesc(items);
}
async queryMsgsWithFilterExWithSeq (peer: Peer, msgSeq: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
// searchFields: 3,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: false,
isIncludeCurrent: true,
pageLimit: 1,
});
}
async queryMsgsWithFilterExWithSeqV2 (peer: Peer, msgSeq: string, MsgTime: string, SendersUid: string[]) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
// searchFields: 3,
filterSendersUid: SendersUid,
filterMsgToTime: MsgTime,
filterMsgFromTime: MsgTime,
isReverseOrder: false,
isIncludeCurrent: true,
pageLimit: 1,
});
}
async queryMsgsWithFilterExWithSeqV3 (peer: Peer, msgSeq: string, SendersUid: string[]) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: false,
// searchFields: 3,
isIncludeCurrent: true,
pageLimit: 1,
});
}
async queryFirstMsgBySeq (peer: Peer, msgSeq: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
// searchFields: 3,
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 1,
});
}
// 客户端还在用别慌
async getMsgsBySeqAndCount (peer: Peer, seq: string, count: number, desc: boolean, isReverseOrder: boolean) {
return await this.context.session.getMsgService().getMsgsBySeqAndCount(peer, seq, count, desc, isReverseOrder);
}
async getMsgExBySeq (peer: Peer, msgSeq: string) {
const DateNow = Math.floor(Date.now() / 1000);
const filterMsgFromTime = (DateNow - 300).toString();
const filterMsgToTime = DateNow.toString();
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer, // 此处为Peer 为关键查询参数 没有啥也没有 by mlik iowa
filterMsgType: [],
filterSendersUid: [],
// searchFields: 3,
filterMsgToTime,
filterMsgFromTime,
isReverseOrder: false,
isIncludeCurrent: true,
pageLimit: 100,
});
}
async queryFirstMsgBySender (peer: Peer, SendersUid: string[]) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
// searchFields: 3,
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async setMsgRead (peer: Peer) {
return this.context.session.getMsgService().setMsgRead(peer);
}
async getGroupFileList (GroupCode: string, params: GetFileListParam) {
const item: GroupFileInfoUpdateItem[] = [];
let index = params.startIndex;
while (true) {
params.startIndex = index;
const [, groupFileListResult] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/getGroupFileList',
'NodeIKernelMsgListener/onGroupFileInfoUpdate',
[
GroupCode,
params,
],
() => true,
() => true, // 应当通过 groupFileListResult 判断
1,
5000
);
if (!groupFileListResult?.item?.length) break;
item.push(...groupFileListResult.item);
if (groupFileListResult.isEnd) break;
if (item.length === params.fileCount) break;
index = groupFileListResult.nextIndex;
}
return item;
}
async getMsgHistory (peer: Peer, msgId: string, count: number, isReverseOrder: boolean = false) {
// 消息时间从旧到新
return this.context.session.getMsgService().getMsgsIncludeSelf(peer, msgId, count, isReverseOrder);
}
async recallMsg (peer: Peer, msgId: string) {
return await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/recallMsg',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[peer, [msgId]],
() => true,
(updatedList) => updatedList.find(m => m.msgId === msgId && m.recallTime !== '0') !== undefined,
1,
1000
);
}
async PrepareTempChat (toUserUid: string, GroupCode: string, nickname: string) {
return this.context.session.getMsgService().prepareTempChat({
chatType: ChatType.KCHATTYPETEMPC2CFROMGROUP,
peerUid: toUserUid,
peerNickname: nickname,
fromGroupCode: GroupCode,
sig: '',
selfPhone: '',
selfUid: this.core.selfInfo.uid,
gameSession: {
nickname: '',
gameAppId: '',
selfTinyId: '',
peerRoleId: '',
peerOpenId: '',
},
});
}
async getTempChatInfo (chatType: ChatType, peerUid: string) {
return this.context.session.getMsgService().getTempChatInfo(chatType, peerUid);
}
async sendMsg (peer: Peer, msgElements: SendMessageElement[], timeout = 10000) {
// 唉?!我有个想法
if (peer.chatType === ChatType.KCHATTYPETEMPC2CFROMGROUP && peer.guildId && peer.guildId !== '') {
const member = await this.core.apis.GroupApi.getGroupMember(peer.guildId, peer.peerUid);
if (member) {
await this.PrepareTempChat(peer.peerUid, peer.guildId, member.nick);
}
}
const msgId = await this.generateMsgUniqueId(peer.chatType);
peer.guildId = msgId;
const [, msgList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/sendMsg',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[
'0',
peer,
msgElements,
new Map(),
],
(ret) => ret.result === 0,
msgRecords => {
for (const msgRecord of msgRecords) {
if (msgRecord.guildId === msgId && msgRecord.sendStatus === SendStatusType.KSEND_STATUS_SUCCESS) {
return true;
}
}
return false;
},
1,
timeout
);
return msgList.find(msgRecord => msgRecord.guildId === msgId);
}
async generateMsgUniqueId (chatType: number) {
return this.context.session.getMsgService().generateMsgUniqueId(chatType, this.context.session.getMSFService().getServerTime());
}
async forwardMsg (srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
return this.context.session.getMsgService().forwardMsg(msgIds, srcPeer, [destPeer], new Map());
}
async multiForwardMsg (srcPeer: Peer, destPeer: Peer, msgIds: string[]): Promise<RawMessage> {
const msgInfos = msgIds.map(id => {
return { msgId: id, senderShowName: this.core.selfInfo.nick };
});
const [, msgList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/multiForwardMsgWithComment',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[
msgInfos,
srcPeer,
destPeer,
[],
new Map(),
],
() => true,
(msgRecords) => msgRecords.some(
msgRecord => msgRecord.peerUid === destPeer.peerUid &&
msgRecord.senderUid === this.core.selfInfo.uid
)
);
for (const msg of msgList) {
const arkElement = msg.elements.find(ele => ele.arkElement);
if (!arkElement) {
continue;
}
const forwardData: { app: string; } = JSON.parse(arkElement.arkElement?.bytesData ?? '');
if (forwardData.app !== 'com.tencent.multimsg') {
continue;
}
if (msg.peerUid === destPeer.peerUid && msg.senderUid === this.core.selfInfo.uid) {
return msg;
}
}
throw new Error('转发消息超时');
}
async markAllMsgAsRead () {
return this.context.session.getMsgService().setAllC2CAndGroupMsgRead();
}
}
+241
View File
@@ -0,0 +1,241 @@
import { InstanceContext, NapCatCore } from '@/napcat-core';
import { Peer } from '@/napcat-core/types';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GeneralCallResultStatus } from '@/napcat-core/services/common';
import { sleep } from '@/napcat-common/src/helper';
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
export class NTQQOnlineApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/**
* 这里不等待node返回,因为the fuck wrapper.node 根本不返回(会卡死不知道为什么)!!! 只能手动查询判断死活
* @param peer
* @param filePath
* @param fileName
*/
async sendOnlineFile (peer: Peer, filePath: string, fileName: string): Promise<any> {
if (!fs.existsSync(filePath)) {
throw new Error(`[NapCat] 文件不存在: ${filePath}`);
}
const actualFileName = fileName || path.basename(filePath);
const fileSize = fs.statSync(filePath).size.toString();
const fileElementToSend = [{
elementType: 23,
elementId: '',
fileElement: {
fileName: actualFileName,
filePath,
fileSize,
},
}];
const msgService = this.context.session.getMsgService();
const startTime = Math.floor(Date.now() / 1000) - 2; // 容错时间窗口
msgService.sendMsg('0', peer, fileElementToSend, new Map()).catch((_e: any) => {
});
const maxRetries = 10;
let retryCount = 0;
while (retryCount < maxRetries) {
await sleep(1000);
retryCount++;
try {
const msgListResult = await msgService.getOnlineFileMsgs(peer);
const msgs = msgListResult?.msgList || [];
const foundMsg = msgs.find((msg: any) => {
if (parseInt(msg.msgTime) < startTime) return false;
const validElement = msg.elements.find((el: any) => {
if (el.elementType !== 23 || !el.fileElement) return false;
const isNameMatch = el.fileElement.fileName === actualFileName;
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(filePath);
return isNameMatch && isPathMatch;
});
return !!validElement;
});
if (foundMsg) {
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 23);
this.context.logger.log('[OnlineFile] 在线文件发送成功!');
return {
result: GeneralCallResultStatus.OK,
errMsg: '',
msgId: foundMsg.msgId,
elementId: targetElement?.elementId || '',
};
}
} catch (_e) {
}
}
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
return {
result: GeneralCallResultStatus.ERROR,
errMsg: '[NapCat] Send Online File Timeout: Message not found in history.',
};
}
/**
* 发送在线文件夹
* @param peer
* @param folderPath
* @param folderName
*/
async sendOnlineFolder (peer: Peer, folderPath: string, folderName?: string): Promise<any> {
const actualFolderName = folderName || path.basename(folderPath);
if (!fs.existsSync(folderPath)) {
return { result: GeneralCallResultStatus.ERROR, errMsg: `Folder not found: ${folderPath}` };
}
if (!fs.statSync(folderPath).isDirectory()) {
return { result: GeneralCallResultStatus.ERROR, errMsg: `Path is not a directory: ${folderPath}` };
}
const folderElementItem = {
elementType: 30,
elementId: '',
fileElement: {
fileName: actualFolderName,
filePath: folderPath,
fileSize: '',
},
};
const msgService = this.context.session.getMsgService();
const startTime = Math.floor(Date.now() / 1000) - 2;
msgService.sendMsg('0', peer, [folderElementItem], new Map()).catch((_e: any) => {
});
const maxRetries = 10;
let retryCount = 0;
while (retryCount < maxRetries) {
await sleep(1000);
retryCount++;
try {
const msgListResult = await msgService.getOnlineFileMsgs(peer);
const msgs = msgListResult?.msgList || [];
const foundMsg = msgs.find((msg: any) => {
if (parseInt(msg.msgTime) < startTime) return false;
const validElement = msg.elements.find((el: any) => {
if (el.elementType !== 30 || !el.fileElement) return false;
const isNameMatch = el.fileElement.fileName === actualFolderName;
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(folderPath);
return isNameMatch && isPathMatch;
});
return !!validElement;
});
if (foundMsg) {
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 30);
this.context.logger.log('[OnlineFile] 在线文件夹发送成功!');
return {
result: GeneralCallResultStatus.OK,
errMsg: '',
msgId: foundMsg.msgId,
elementId: targetElement?.elementId || '',
};
}
} catch (_e) {
}
}
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
return {
result: GeneralCallResultStatus.ERROR,
errMsg: '[NapCat] Send Online Folder Timeout: Message not found in history.',
};
}
/**
* 获取好友的在线文件消息
* @param peer
*/
async getOnlineFileMsg (peer: Peer): Promise<any> {
const msgService = this.context.session.getMsgService();
return await msgService.getOnlineFileMsgs(peer);
}
/**
* 取消在线文件的发送
* @param peer
* @param msgId
*/
async cancelMyOnlineFileMsg (peer: Peer, msgId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
await msgService.cancelSendMsg(peer, msgId);
}
/**
* 拒绝接收在线文件
* @param peer
* @param msgId
* @param elementId
*/
async refuseOnlineFileMsg (peer: Peer, msgId: string, elementId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
const arrToSend = {
msgId,
peerUid: peer.peerUid,
chatType: 1,
elementId,
downloadType: 1,
downSourceType: 1,
};
await msgService.refuseGetRichMediaElement(arrToSend);
}
/**
* 接收在线文件/文件夹
* @param peer
* @param msgId
* @param elementId
* @constructor
*/
async receiveOnlineFileOrFolder (peer: Peer, msgId: string, elementId: string): Promise<any> {
const msgService = this.context.session.getMsgService();
const arrToSend = {
msgId,
peerUid: peer.peerUid,
chatType: 1,
elementId,
downSourceType: 1,
downloadType: 1,
};
return await msgService.getRichMediaElement(arrToSend);
}
/**
* 在线文件/文件夹转离线
* @param peer
* @param msgId
*/
async switchFileToOffline (peer: Peer, msgId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
await msgService.switchToOfflineSendMsg(peer, msgId);
}
}
+93
View File
@@ -0,0 +1,93 @@
import * as os from 'os';
import offset from '@/napcat-core/external/napi2native.json';
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
import { PacketClientSession } from '@/napcat-core/packet/clientSession';
import { napCatVersion } from 'napcat-common/src/version';
import { LogWrapper } from '../helper/log';
interface OffsetType {
[key: string]: {
recv: string;
send: string;
};
}
const typedOffset: OffsetType = offset;
export class NTQQPacketApi {
context: InstanceContext;
core: NapCatCore;
logger: LogWrapper;
qqVersion: string | undefined;
pkt!: PacketClientSession;
errStack: string[] = [];
packetStatus: boolean = false;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
this.logger = core.context.logger;
}
async initApi () {
this.packetStatus = (await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVersion())
.then((result) => {
return result;
})
.catch((err) => {
this.logger.logError(err);
this.pushError(err);
return false;
})) && this.pkt?.available;
}
get available (): boolean {
return this.pkt?.available ?? false;
}
get clientLogStack () {
return [this.pkt?.clientLogStack, ...this.errStack].filter(Boolean).join('\n');
}
private pushError (error: unknown) {
if (error instanceof Error) {
this.errStack.push(error.stack || error.message);
return;
}
this.errStack.push(String(error));
}
async InitSendPacket (qqVer: string) {
this.qqVersion = qqVer;
const table = typedOffset[qqVer + '-' + os.arch()];
if (!table) {
const err = `[Core] [Packet] PacketBackend 不支持当前QQ版本架构:${qqVer}-${os.arch()},
请参照 https://github.com/NapNeko/NapCatQQ/releases/tag/v${napCatVersion} 配置正确的QQ版本!`;
this.logger.logError(err);
this.errStack.push(err);
return false;
}
if (this.core.configLoader.configData.packetBackend === 'disable') {
const err = '[Core] [Packet] 已禁用PacketBackend,NapCat.Packet将不会加载!';
this.logger.logError(err);
this.errStack.push(err);
return false;
}
this.pkt = new PacketClientSession(this.core);
await this.pkt.init(process.pid, table.recv, table.send);
if (!this.pkt.available) {
const err = '[Core] [Packet] NativePacketClient 初始化失败,PacketBackend 发包能力不可用!';
this.logger.logError(err);
this.errStack.push(err);
return false;
}
try {
await this.pkt.operation.FetchRkey(3000);
} catch (error) {
this.logger.logError('测试Packet状态异常', error);
this.pushError(error);
return false;
}
return true;
}
}
+274
View File
@@ -0,0 +1,274 @@
import type { InstanceContext, NapCatCore } from '@/napcat-core';
import { randomUUID } from 'node:crypto';
import {
buildQzonePublicRequest,
parseQzonePublicResponse,
QZONE_PUBLIC_ROUTES,
type QzonePublicContent,
type QzonePublicFeedsPage,
type QzonePublicObject,
type QzonePublicOperation,
} from '../data/qzone-public';
const RPC_ROOT = 'https://h5.qzone.qq.com/http2rpc/gotrpc/v1/';
const TIMEOUT_MS = 15000;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const MAX_CURSOR_COUNT = 128;
const CURSOR_TTL_MS = 10 * 60 * 1000;
const READ_OPERATIONS = new Set<QzonePublicOperation>(['capabilities', 'feeds', 'detail', 'comments', 'replies']);
interface CurrentUser extends QzonePublicObject { uin: string; uid: string; nick: string; }
interface PublicCursor {
account: string;
issuedAt: number;
refreshId: string;
attachInfo: string;
lastUnionId?: QzonePublicObject;
}
function object (value: unknown): value is QzonePublicObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
async function withTimeout<T> (work: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(work),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('空间登录态读取超时')), TIMEOUT_MS);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/** Public recommendation uses its own feed identities, interaction routes and cursor chain. */
export class NTQQQzonePublicApi {
private readonly cursors = new Map<string, PublicCursor>();
constructor (readonly context: InstanceContext, readonly core: NapCatCore) {}
private currentUser (): CurrentUser {
const info = this.core.selfInfo;
if (!info || !/^[1-9]\d{0,19}$/.test(info.uin) || typeof info.uid !== 'string' || !info.uid.trim()) {
throw new Error('空友爱看需要已登录的当前账号');
}
return { uin: info.uin, uid: info.uid, nick: info.nick ?? '' };
}
private assertAccount (user: CurrentUser): void {
const current = this.currentUser();
if (current.uin !== user.uin || current.uid !== user.uid) throw new Error('QQ账号已切换,请重新获取空友动态');
}
private async auth (user: CurrentUser): Promise<{ cookie: string; gtk: number; }> {
this.assertAccount(user);
const [skey, pskey] = await withTimeout(() => Promise.allSettled([
this.core.apis.UserApi.getSKey(),
this.core.apis.UserApi.getPSkey(['qzone.qq.com']),
]));
this.assertAccount(user);
const sk = skey.status === 'fulfilled' && typeof skey.value === 'string' ? skey.value : '';
const pk = pskey.status === 'fulfilled' ? pskey.value.domainPskeyMap.get('qzone.qq.com') ?? '' : '';
if (!sk && !pk) throw new Error('无法取得当前账号的空间登录态');
let hash = 5381;
const key = pk || sk;
for (let index = 0; index < key.length; index++) hash += (hash << 5) + key.charCodeAt(index);
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: hash & 0x7fffffff };
}
private async rpc (
operation: QzonePublicOperation,
options: Parameters<typeof buildQzonePublicRequest>[1] = {},
user = this.currentUser()
): Promise<QzonePublicObject> {
// Validate complete caller data before acquiring tickets or starting any request.
const body = buildQzonePublicRequest(operation, { ...options, selfUin: user.uin, user });
const encoded = JSON.stringify(body);
if (Buffer.byteLength(encoded) > MAX_RESPONSE_BYTES) throw new Error('空友爱看请求超过大小限制');
const auth = await this.auth(user);
const route = QZONE_PUBLIC_ROUTES[operation];
const [command, serviceType] = route.oidb.split('_');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let text: string;
try {
this.assertAccount(user);
const response = await fetch(`${RPC_ROOT}${route.service}/${route.method}?g_tk=${auth.gtk}`, {
method: 'POST',
redirect: 'error',
signal: controller.signal,
headers: {
Cookie: auth.cookie,
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://h5.qzone.qq.com',
Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
'x-oidb': JSON.stringify({ uint32_command: command, uint32_service_type: serviceType }),
},
body: encoded,
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('HTTP请求失败');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('响应为空');
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new Error('响应超过大小限制');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
text = Buffer.concat(chunks, bytes).toString('utf8');
} catch {
throw new Error(READ_OPERATIONS.has(operation)
? '空友爱看请求失败或超时'
: '空友爱看写入结果未确认,请查询状态,勿自动重试');
} finally {
clearTimeout(timer);
}
this.assertAccount(user);
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(READ_OPERATIONS.has(operation)
? '空友爱看响应不是有效JSON'
: '空友爱看写入回执无效,请查询状态,勿自动重试');
}
return parseQzonePublicResponse(value, operation);
}
private readCursor (token: string | undefined, user: CurrentUser): PublicCursor | undefined {
if (token === undefined) return undefined;
const state = typeof token === 'string' && token.length <= 128 ? this.cursors.get(token) : undefined;
if (!state || state.account !== `${user.uin}:${user.uid}` || Date.now() - state.issuedAt > CURSOR_TTL_MS) {
throw new Error('空友爱看游标无效或已过期,请重新获取第一页');
}
return state;
}
private issueCursor (state: PublicCursor): string {
const now = Date.now();
for (const [key, value] of this.cursors) {
if (now - value.issuedAt > CURSOR_TTL_MS) this.cursors.delete(key);
}
while (this.cursors.size >= MAX_CURSOR_COUNT) this.cursors.delete(this.cursors.keys().next().value!);
const token = `qzone-public:${randomUUID()}`;
this.cursors.set(token, state);
return token;
}
getCapabilities (): Promise<QzonePublicObject> {
return this.rpc('capabilities');
}
async getFeeds (cursor?: string): Promise<QzonePublicFeedsPage> {
const user = this.currentUser();
const state = this.readCursor(cursor, user);
const refreshId = state?.refreshId ?? `${user.uin}_${Date.now()}`;
const page = await this.rpc('feeds', {
refreshId,
cursor: state?.attachInfo,
lastUnionId: state?.lastUnionId,
}, user);
if (!Array.isArray(page['feeds']) || typeof page['has_more'] !== 'boolean') throw new Error('空友动态分页响应不完整');
const feeds: QzonePublicObject[] = page['feeds'].map((entry: unknown) => {
if (!object(entry)) throw new Error('空友动态条目格式无效');
const feed = Object.prototype.hasOwnProperty.call(entry, 'feed') ? entry['feed'] : entry;
if (!object(feed)) throw new Error('空友动态条目缺少完整内容');
return feed;
});
let lastUnionId = state?.lastUnionId;
for (let index = feeds.length - 1; index >= 0; index--) {
const common = feeds[index]!['cell_common'];
if (object(common) && common['feed_type'] !== 3 && object(common['union_id'])) {
lastUnionId = common['union_id'];
break;
}
}
const hasMore = page['has_more'];
let nextCursor: string | null = null;
if (hasMore) {
const attachInfo = page['attach_info'];
if (typeof attachInfo !== 'string' || !attachInfo || attachInfo.length > 65536) throw new Error('空友动态缺少有效续页信息');
nextCursor = this.issueCursor({
account: `${user.uin}:${user.uid}`,
issuedAt: Date.now(),
refreshId,
attachInfo,
lastUnionId: lastUnionId === undefined ? undefined : structuredClone(lastUnionId),
});
}
return { ...page, feeds, has_more: hasMore, next_cursor: nextCursor };
}
getFeedDetail (feed: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('detail', { feed });
}
getComments (feed: QzonePublicObject, cursor: string): Promise<QzonePublicObject> {
return this.rpc('comments', { feed, cursor });
}
getReplies (feed: QzonePublicObject, comment: QzonePublicObject, cursor?: string, pageSize = 20): Promise<QzonePublicObject> {
return this.rpc('replies', { feed, comment, cursor, pageSize });
}
setUpvote (feed: QzonePublicObject, enable = true): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'upvote' : 'downvote', { feed });
}
setLike (feed: QzonePublicObject, enable = true): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'like' : 'unlike', { feed });
}
setFollow (feed: QzonePublicObject, enable = true): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'follow' : 'unfollow', { feed });
}
setCommentLike (feed: QzonePublicObject, comment: QzonePublicObject, enable = true, reply?: QzonePublicObject): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'commentLike' : 'commentUnlike', { feed, comment, reply });
}
addComment (feed: QzonePublicObject, content: QzonePublicContent): Promise<QzonePublicObject> {
return this.rpc('addComment', { feed, content });
}
addReply (feed: QzonePublicObject, comment: QzonePublicObject, content: QzonePublicContent, reply?: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('addReply', { feed, comment, content, reply });
}
deleteComment (feed: QzonePublicObject, comment: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('deleteComment', { feed, comment });
}
deleteReply (feed: QzonePublicObject, comment: QzonePublicObject, reply: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('deleteReply', { feed, comment, reply });
}
forwardFeed (feed: QzonePublicObject, reason = ''): Promise<QzonePublicObject> {
return this.rpc('forward', { feed, reason });
}
}
+947
View File
@@ -0,0 +1,947 @@
import type { InstanceContext, NapCatCore } from '@/napcat-core';
import { createHash, randomUUID } from 'node:crypto';
import { TextDecoder } from 'node:util';
import {
buildQzonePublishRequest,
buildQzoneText,
getQzoneGtk,
getQzoneTid,
normalizeQzoneFeed,
parseQzoneRpcResponse,
validateQzoneFeedReference,
type QzoneComment,
type QzoneDetail,
type QzoneFeed,
type QzoneFeedPage,
type QzoneFeedReference,
type QzoneMsgPage,
type QzoneUnionId,
type QzoneUser,
} from '../data/qzone-rpc';
import {
buildQzoneBanBody,
buildQzoneProfileBody,
buildQzoneRightBody,
normalizeQzoneRight,
parseQzoneProfile,
parseQzoneWebResponse,
QzoneWebError,
type QzoneProfile,
type QzoneProfileUpdate,
} from '../data/qzone-web';
import {
normalizeQzoneComment,
normalizeQzoneReply,
type QzoneCommentPage,
type QzoneReplyPage,
} from '../data/qzone-comments';
import { buildQzoneUploadImageBody, parseQzonePublishTime } from '../data/qzone';
import {
buildSlQzoneCommentBody,
buildSlQzoneLikeBody,
parseSlQzoneComment,
parseSlQzoneMsgDetail,
parseSlQzoneMsgList,
type SlQzoneCommentOptions,
type SlQzoneCommentResult,
type SlQzoneMsgDetail,
type SlQzoneMsgList,
} from '../data/qzone-snowluma';
import {
advanceSlQzoneFeedState,
initialSlQzoneFeedState,
parseSlQzoneFeeds,
slQzoneFeedPosition,
type SlQzoneFeedPage,
type SlQzoneFeedState,
} from '../data/qzone-snowluma-feeds';
const RPC_ROOT = 'https://h5.qzone.qq.com/http2rpc/gotrpc/v1/';
const READER = 'trpc.qzone.feeds_reader.FeedsReader';
const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter';
const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter';
const REQUEST_TIMEOUT_MS = 15000;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const MAX_REFERENCE_BYTES = 64 * 1024;
const MAX_CURSOR_LENGTH = 16384;
const MAX_ATTACH_LENGTH = 8192;
const CURSOR_TTL_MS = 10 * 60 * 1000;
const MAX_CURSORS = 128;
const WEB_ROOT = 'https://h5.qzone.qq.com/proxy/domain/';
const SL_FEED_SKIP_LIMIT = 20;
const SL_FEED_DEADLINE_MS = 45000;
const WEB_METHODS = {
GetProfile: { path: 'base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all', method: 'GET', requireCode: true },
SetProfile: { path: 'w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new', method: 'POST', requireCode: true },
GetOwnMsgDetail: { path: 'taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6', method: 'GET', requireCode: false },
SetMsgRight: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_update', method: 'POST', requireCode: false },
SetBan: { path: 'w.qzone.qq.com/cgi-bin/right/cgi_black_action_new', method: 'POST', requireCode: false },
GetSlMsgList: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6', method: 'GET', requireCode: false },
GetSlMsgListFallback: { path: 'taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6', method: 'GET', requireCode: false, root: 'https://user.qzone.qq.com/proxy/domain/', ownerReferer: true },
LikeSlMsg: { path: 'w.qzone.qq.com/cgi-bin/likes/internal_dolike_app', method: 'POST', requireCode: false },
UnlikeSlMsg: { path: 'w.qzone.qq.com/cgi-bin/likes/internal_unlike_app', method: 'POST', requireCode: false },
CommentSlMsg: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds', method: 'POST', requireCode: false },
ReplySlMsg: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc', method: 'POST', requireCode: true },
} as const;
interface QzoneHttpRequest {
url: string;
method: 'GET' | 'POST';
body?: string;
headers?: Record<string, string>;
}
// 固定路由,不向调用方暴露任意 RPC、URL 或取票能力。
const RPC_METHODS = {
GetFriendFeeds: { service: READER, command: '0x97e5', type: '0', write: false },
GetShuoshuoList: { service: READER, command: '0x9a97', type: '1', write: false },
GetFeedDetail: { service: READER, command: '0x97b1', type: '1', write: false },
GetComment: { service: READER, command: '0x9bfd', type: '1', write: false },
GetReplies: { service: READER, command: '0x9bfe', type: '1', write: false },
PublishFeed: { service: WRITER, command: '0x9775', type: '1', write: true },
DoLike: { service: INTERACTION, command: '0x9699', type: '1', write: true },
UnLike: { service: INTERACTION, command: '0x969c', type: '1', write: true },
AddComment: { service: INTERACTION, command: '0x9676', type: '1', write: true },
AddReply: { service: INTERACTION, command: '0x9677', type: '1', write: true },
} as const;
interface PageCursor {
account: string;
kind: 'friends' | 'moods' | 'comments' | 'replies' | 'sl-feeds';
target: string;
issuedAt: number;
attachInfo: string;
refreshId?: string;
lastUnionId?: QzoneUnionId;
feedReference?: QzoneFeedReference;
parentComment?: QzoneComment;
slFeedState?: SlQzoneFeedState;
slFeedPage?: number;
slFeedPositions?: string[];
}
function isRecord (value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function validUin (value: unknown): value is string {
return typeof value === 'string' && /^[1-9]\d{0,19}$/.test(value);
}
function validIdentity (value: unknown, maxLength = 4096): value is string {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
function validateUser (value: unknown): QzoneUser {
if (!isRecord(value) || !validUin(value['uin']) ||
(value['uid'] !== undefined && value['uid'] !== null && value['uid'] !== '' && !validIdentity(value['uid'])) ||
(value['nick'] !== undefined && (typeof value['nick'] !== 'string' || value['nick'].length > 4096))) {
throw new Error('QQ空间用户信息无效');
}
// A raw parent may carry uid:null; keep the parent intact and omit only this target's absent UID.
const user = { ...value };
if (user['uid'] === null) delete user['uid'];
return user as unknown as QzoneUser;
}
function validateComment (value: unknown): QzoneComment {
if (!isRecord(value) || !validIdentity(value['id'], 8192)) {
throw new Error('QQ空间评论缺少有效标识');
}
validateUser(value['user']);
let size: number;
try {
size = Buffer.byteLength(JSON.stringify(value));
} catch {
throw new Error('QQ空间评论格式无效');
}
if (size > MAX_REFERENCE_BYTES) {
throw new Error('QQ空间评论对象过大');
}
return value as QzoneComment;
}
function assertReadJson (value: unknown, depth = 0): void {
if (depth > 32) throw new Error('QQ空间父评论嵌套过深');
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
if (typeof value === 'number' && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const item of value) assertReadJson(item, depth + 1);
return;
}
if (!isRecord(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
throw new Error('QQ空间父评论必须为JSON数据');
}
for (const item of Object.values(value)) {
if (item !== undefined) assertReadJson(item, depth + 1);
}
}
function readParentSnapshot (value: unknown): QzoneComment {
if (!isRecord(value) || !(validIdentity(value['id'], 8192) ||
(typeof value['id'] === 'number' && Number.isSafeInteger(value['id']) && value['id'] >= 0))) {
throw new Error('QQ空间父评论缺少有效标识');
}
if (value['user'] !== undefined && !isRecord(value['user'])) throw new Error('QQ空间父评论用户信息无效');
assertReadJson(value);
if (Buffer.byteLength(JSON.stringify(value)) > MAX_REFERENCE_BYTES) throw new Error('QQ空间父评论不能超过64KiB');
return structuredClone(value) as QzoneComment;
}
function referenceSnapshot (value: unknown): QzoneFeedReference {
return structuredClone(validateQzoneFeedReference(value));
}
function canonicalJson (value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (isRecord(value)) {
return `{${Object.keys(value).filter(key => value[key] !== undefined).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
}
const json = JSON.stringify(value);
if (json === undefined) throw new Error('QQ空间分页上下文格式无效');
return json;
}
function commentScope (reference: QzoneFeedReference, parent?: QzoneComment): string {
return createHash('sha256').update(canonicalJson(parent === undefined
? reference
: { reference, parent: { id: parent.id, user: parent.user } })).digest('hex');
}
function observedCount (value: unknown): number | null {
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
const count = typeof value === 'number' ? value : Number(value);
return Number.isSafeInteger(count) && count >= 0 ? count : null;
}
async function withTimeout<T> (work: () => Promise<T>, message: string, duration = REQUEST_TIMEOUT_MS): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(work),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), duration);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
export class NTQQQzoneApi {
context: InstanceContext;
core: NapCatCore;
private readonly cursors = new Map<string, PageCursor>();
private readonly rightUpdates = new Set<string>();
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
private currentUser (): QzoneUser {
const info = this.core.selfInfo;
if (!info || !validUin(info.uin) || !validIdentity(info.uid)) {
throw new Error('QQ空间需要已登录的当前账号');
}
return validateUser({ uin: info.uin, uid: info.uid, nick: info.nick ?? '' });
}
private accountKey (user: QzoneUser): string {
return `${user.uin}:${user.uid ?? ''}`;
}
private assertAccount (user: QzoneUser): void {
if (this.accountKey(this.currentUser()) !== this.accountKey(user)) {
throw new Error('QQ账号已切换,请重新获取QQ空间数据');
}
}
private readCursor (token: string | undefined, user: QzoneUser, kind: PageCursor['kind'], target: string): PageCursor | undefined {
if (token === undefined) return undefined;
if (typeof token !== 'string' || token.length === 0 || token.length > MAX_CURSOR_LENGTH) {
throw new Error('QQ空间游标无效,请重新获取第一页');
}
const cursor = this.cursors.get(token);
const now = Date.now();
if (!cursor || cursor.issuedAt > now || now - cursor.issuedAt >= CURSOR_TTL_MS ||
cursor.account !== this.accountKey(user) || cursor.kind !== kind || cursor.target !== target) {
throw new Error('QQ空间游标无效或已过期,请重新获取第一页');
}
return cursor;
}
private saveCursor (cursor: Omit<PageCursor, 'issuedAt'>, prefix = ''): string {
const now = Date.now();
for (const [token, entry] of this.cursors) {
if (entry.issuedAt > now || now - entry.issuedAt >= CURSOR_TTL_MS) this.cursors.delete(token);
}
while (this.cursors.size >= MAX_CURSORS) {
const oldest = this.cursors.keys().next().value;
if (oldest === undefined) break;
this.cursors.delete(oldest);
}
const token = `${prefix}${randomUUID()}`;
this.cursors.set(token, { ...structuredClone(cursor), issuedAt: now });
return token;
}
private parsePage (data: Record<string, unknown>): { feeds: QzoneFeed[]; hasMore: boolean; attachInfo: string; } {
if (!Array.isArray(data['feeds']) ||
(data['has_more'] !== undefined && typeof data['has_more'] !== 'boolean')) {
throw new Error('QQ空间列表响应格式异常');
}
const feeds = data['feeds'].map((item: unknown) => {
if (!isRecord(item) || !isRecord(item['feed'])) throw new Error('QQ空间动态响应格式异常');
return item['feed'] as QzoneFeed;
});
const hasMore = data['has_more'] === true;
const attachInfo = data['attach_info'];
if (hasMore && (typeof attachInfo !== 'string' || attachInfo.length === 0 || attachInfo.length > MAX_ATTACH_LENGTH)) {
throw new Error('QQ空间续页信息无效,请重新获取第一页');
}
return { feeds, hasMore, attachInfo: typeof attachInfo === 'string' ? attachInfo : '' };
}
private async authHeaders (user: QzoneUser, deadline?: number): Promise<{ cookie: string; gtk: number; skey: string; pskey: string; }> {
const remaining = deadline === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now());
if (remaining <= 0) throw new Error('QQ空间读取超过时间预算,请使用游标分页');
const results = await withTimeout(() => Promise.allSettled([
this.core.apis.UserApi.getSKey(),
this.core.apis.UserApi.getPSkey(['qzone.qq.com']),
]), 'QQ空间取票超时,请稍后重试', remaining);
this.assertAccount(user);
const skey = results[0].status === 'fulfilled' ? results[0].value : undefined;
const ticket = results[1].status === 'fulfilled' ? results[1].value : undefined;
const pskey = ticket?.result === 0 ? ticket.domainPskeyMap?.get('qzone.qq.com') : undefined;
const validKey = (key: unknown): key is string => typeof key === 'string' && key.length > 0 && key.length <= 4096 &&
[...key].every(char => char.charCodeAt(0) > 32 && char.charCodeAt(0) !== 127 && char !== ';' && char !== ',');
const sk = validKey(skey) ? skey : '';
const pk = validKey(pskey) ? pskey : '';
if (!sk && !pk) throw new Error('QQ空间取票失败,请确认当前账号登录状态');
const cookies = [`uin=o${user.uin}`, `p_uin=o${user.uin}`];
if (sk) cookies.push(`skey=${sk}`);
if (pk) cookies.push(`p_skey=${pk}`);
return { cookie: cookies.join('; '), gtk: getQzoneGtk(pk || sk), skey: sk, pskey: pk };
}
private async requestText (user: QzoneUser, build: (gtk: number, auth: { skey: string; pskey: string; }) => QzoneHttpRequest, write: boolean, deadline?: number): Promise<string> {
const auth = await this.authHeaders(user, deadline);
const request = build(auth.gtk, auth);
const remaining = deadline === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now());
if (remaining <= 0) throw new Error('QQ空间读取超过时间预算,请使用游标分页');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), remaining);
let text: string;
try {
this.assertAccount(user);
const response = await fetch(request.url, {
method: request.method,
redirect: 'error',
signal: controller.signal,
headers: {
Accept: 'application/json',
Cookie: auth.cookie,
Origin: 'https://h5.qzone.qq.com',
Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
...request.headers,
},
...(request.body !== undefined ? { body: request.body } : {}),
});
if (response.status !== 200 || !response.body) {
await response.body?.cancel();
throw new Error('Invalid response');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new Error('Response too large');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size));
} catch {
throw new Error(write
? 'QQ空间请求未能确认结果,请先查询状态,勿自动重试'
: 'QQ空间请求失败或超时,请稍后重试');
} finally {
clearTimeout(timer);
}
this.assertAccount(user);
return text;
}
private async rpc (method: keyof typeof RPC_METHODS, request: Record<string, unknown>, user: QzoneUser): Promise<Record<string, unknown>> {
const route = RPC_METHODS[method];
const text = await this.requestText(user, gtk => ({
url: `${RPC_ROOT}${route.service}/${method}?g_tk=${gtk}`,
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'x-oidb': JSON.stringify({ uint32_command: route.command, uint32_service_type: route.type }),
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
},
}), route.write);
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(route.write
? 'QQ空间响应无法解析,操作结果未知,请先查询状态,勿自动重试'
: 'QQ空间响应格式异常');
}
return parseQzoneRpcResponse(value);
}
private async web (method: keyof typeof WEB_METHODS, params: URLSearchParams, user: QzoneUser): Promise<Record<string, unknown>> {
const route = WEB_METHODS[method];
const write = route.method === 'POST';
const text = await this.requestText(user, gtk => {
const url = new URL(`${'root' in route ? route.root : WEB_ROOT}${route.path}`);
url.searchParams.set('g_tk', String(gtk));
if (!write) {
for (const [key, value] of params) url.searchParams.set(key, value);
}
const headers: Record<string, string> = {};
if ('ownerReferer' in route) headers['Referer'] = `https://user.qzone.qq.com/${params.get('uin')}`;
if (write) headers['Content-Type'] = 'application/x-www-form-urlencoded';
return {
url: url.href,
method: route.method,
headers,
...(write ? { body: params.toString() } : {}),
};
}, write);
try {
return parseQzoneWebResponse(text, route.requireCode);
} catch (error) {
if (!write) throw error;
// Parser errors contain only local descriptions and numeric status codes.
throw new Error(`${(error as Error).message};未确认成功,请先查询状态,勿自动重试`);
}
}
async getProfile (targetUin?: string): Promise<QzoneProfile> {
const user = this.currentUser();
const target = targetUin ?? user.uin;
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
const response = await this.web('GetProfile', new URLSearchParams({
uin: target, vuin: user.uin, fupdate: '1', rd: String(Math.random()),
}), user);
return parseQzoneProfile(response);
}
private slTarget (user: QzoneUser, targetUin?: string): string {
const target = targetUin ?? user.uin;
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
return target;
}
private slPagination (pos: number, num: number): void {
if (!Number.isSafeInteger(pos) || pos < 0 || !Number.isSafeInteger(num) || num < 1 || num > 100) {
throw new Error('pos 必须为非负安全整数,num 必须在1到100之间');
}
}
async getSnowlumaMsgList (targetUin?: string, pos = 0, num = 20): Promise<SlQzoneMsgList> {
this.slPagination(pos, num);
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const params = new URLSearchParams({
uin: target,
ftype: '0',
sort: '0',
pos: String(pos),
num: String(num),
replynum: '100',
callback: '_preloadCallback',
code_version: '1',
format: 'jsonp',
need_private_comment: '1',
});
let response: Record<string, unknown>;
try {
response = await this.web('GetSlMsgList', params, user);
} catch (error) {
// 仅对已明确拒绝的只读请求切换已知代理域,不跟随重定向、不重放写操作。
if (!(error instanceof QzoneWebError) || error.field !== 'code' || error.code !== -10000) throw error;
response = await this.web('GetSlMsgListFallback', new URLSearchParams({
uin: target, ftype: '0', sort: '0', pos: String(pos), num: String(num), code_version: '1', format: 'json',
}), user);
}
return parseSlQzoneMsgList(response);
}
async getSnowlumaMsgDetail (tid: string, targetUin?: string, pos = 0, num = 20): Promise<SlQzoneMsgDetail> {
if (!validIdentity(tid, 8192)) throw new Error('tid 必须是有效的说说标识');
this.slPagination(pos, num);
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const response = await this.web('GetOwnMsgDetail', new URLSearchParams({
tid,
uin: target,
t1_source: '1',
not_trunc_con: '1',
need_right: '1',
not_adapt_outpic: '1',
pos: String(pos),
num: String(num),
}), user);
return parseSlQzoneMsgDetail(response, tid);
}
async setSnowlumaLike (tid: string, targetUin: string | undefined, enable: boolean, abstime = 0): Promise<void> {
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const body = buildSlQzoneLikeBody(user.uin, target, tid, enable, abstime);
await this.web(enable ? 'LikeSlMsg' : 'UnlikeSlMsg', new URLSearchParams(body), user);
}
async commentSnowluma (tid: string, content: string, targetUin?: string, options?: SlQzoneCommentOptions): Promise<SlQzoneCommentResult> {
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const body = buildSlQzoneCommentBody(user.uin, target, tid, content, options);
const response = await this.web(options?.comment_id !== undefined ? 'ReplySlMsg' : 'CommentSlMsg', new URLSearchParams(body), user);
return parseSlQzoneComment(response, options);
}
async uploadCommentImage (base64: string): Promise<{ url: string; }> {
if (typeof base64 !== 'string' || !base64 || base64.length > 32 * 1024 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
throw new Error('QQ空间评论图片数据无效或超过限制');
}
const user = this.currentUser();
const text = await this.requestText(user, (gtk, auth) => ({
url: `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${gtk}`,
method: 'POST',
body: buildQzoneUploadImageBody({ uin: user.uin, skey: auth.skey, pskey: auth.pskey, g_tk: String(gtk), base64 }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}), true);
let response: Record<string, unknown>;
try {
// The upload CGI acknowledges with ret:0 rather than code:0.
response = parseQzoneWebResponse(text);
} catch (error) {
throw new Error(`${(error as Error).message};图片上传结果未确认,请先查询状态,勿自动重试`);
}
const data = response['data'];
if (!isRecord(data) || typeof data['url'] !== 'string' || data['url'].trim() !== data['url'] ||
[...data['url']].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
throw new Error('QQ空间上传未返回可用图片地址');
}
let url: URL;
try {
url = new URL(data['url']);
} catch {
throw new Error('QQ空间上传图片地址无效');
}
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('QQ空间上传图片地址无效');
return { url: url.href };
}
async getSnowlumaFeeds (pageNum = 1, count = 10, cursor?: string): Promise<SlQzoneFeedPage> {
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) {
throw new Error('page_num 必须为正安全整数,count 必须在1到50之间');
}
const user = this.currentUser();
const previous = this.readCursor(cursor, user, 'sl-feeds', '');
if (previous && (!previous.slFeedState || !previous.slFeedPage)) throw new Error('QQ空间动态游标无效');
const requestedPage = previous?.slFeedPage ?? pageNum;
if (previous && pageNum !== 1 && pageNum !== requestedPage) throw new Error('page_num 与动态游标不匹配');
if (!previous && pageNum > SL_FEED_SKIP_LIMIT) throw new Error('跳页超过20页读取预算,请从首页使用游标续页');
let state = previous?.slFeedState ? structuredClone(previous.slFeedState) : initialSlQzoneFeedState();
const position = (value: SlQzoneFeedState): string => createHash('sha256').update(slQzoneFeedPosition(value)).digest('hex');
const visited = new Set(previous?.slFeedPositions ?? [position(state)]);
const deadline = Date.now() + SL_FEED_DEADLINE_MS;
for (let page = previous?.slFeedPage ?? 1; page <= requestedPage; page++) {
const text = await this.requestText(user, gtk => {
const params = new URLSearchParams({
uin: user.uin,
scope: '0',
view: '1',
gid: '',
flag: '1',
filter: 'all',
applist: 'all',
refresh: '0',
firstGetGroup: '0',
mixnocache: '0',
scene: '0',
sidomain: 'qzonestyle.gtimg.cn',
callback: '_preloadCallback',
format: 'jsonp',
useutf8: '1',
outputhtmlfeed: '1',
count: String(count),
usertime: String(Math.floor(Date.now() / 1000)),
rd: String(Math.random()),
windowId: String(Math.random()),
g_tk: String(gtk),
});
for (const [key, value] of Object.entries(state)) params.set(key === 'aisortNextTime' ? 'getAisort' : key, value);
return { url: `${WEB_ROOT}ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${params}`, method: 'GET' };
}, false, deadline);
const data = parseSlQzoneFeeds(text);
if (!data.has_more) return page < requestedPage ? { feeds: [], has_more: false, next_cursor: null } : { feeds: data.feeds, has_more: false, next_cursor: null };
if (!data.main) throw new Error('QQ空间动态缺少续页状态');
const next = advanceSlQzoneFeedState(state, data.main);
const nextPosition = position(next);
if (visited.has(nextPosition)) throw new Error('QQ空间动态续页位置未推进,请重新读取');
visited.add(nextPosition);
if (page === requestedPage) {
const token = this.saveCursor({
account: this.accountKey(user),
kind: 'sl-feeds',
target: '',
attachInfo: '',
slFeedState: next,
slFeedPage: page + 1,
slFeedPositions: [...visited].slice(-32),
}, 'slqf:');
return { feeds: data.feeds, has_more: true, next_cursor: token };
}
state = next;
}
throw new Error('QQ空间动态页码无效');
}
async setProfile (profile: QzoneProfileUpdate): Promise<void> {
const user = this.currentUser();
const body = buildQzoneProfileBody(user.uin, profile);
await this.web('SetProfile', new URLSearchParams(body), user);
}
async setMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise<{ ugc_right: number; }> {
if (!validIdentity(tid, 8192)) throw new Error('tid 必须是有效的说说标识');
const normalized = normalizeQzoneRight(ugcRight, targetUins);
const user = this.currentUser();
const lockKey = `${this.accountKey(user)}:${tid}`;
if (this.rightUpdates.has(lockKey)) throw new Error('该说说正在修改权限,请稍后重试');
this.rightUpdates.add(lockKey);
try {
const detail = await this.web('GetOwnMsgDetail', new URLSearchParams({
tid, uin: user.uin, t1_source: '1', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1',
}), user);
const body = buildQzoneRightBody(detail, user.uin, tid, normalized.right, normalized.targetUins);
const result = await this.web('SetMsgRight', new URLSearchParams(body), user);
if (result['ugc_right'] === undefined) return { ugc_right: normalized.right };
const returnedRight = typeof result['ugc_right'] === 'string' && /^\d+$/.test(result['ugc_right'])
? Number(result['ugc_right'])
: result['ugc_right'];
if (typeof returnedRight !== 'number' || ![1, 4, 16, 64, 128].includes(returnedRight)) {
throw new Error('QQ空间返回的权限无效,请回读说说确认,勿自动重试');
}
if (returnedRight !== normalized.right) throw new Error('QQ空间返回权限与请求不一致,请回读说说确认,勿自动重试');
return { ugc_right: returnedRight };
} finally {
this.rightUpdates.delete(lockKey);
}
}
async setBan (targetUin: string, enable = true): Promise<void> {
const user = this.currentUser();
const body = buildQzoneBanBody(user.uin, targetUin, enable);
await this.web('SetBan', new URLSearchParams(body), user);
}
async getFriendFeeds (cursor?: string): Promise<QzoneFeedPage> {
const user = this.currentUser();
const previous = this.readCursor(cursor, user, 'friends', '');
const refreshId = previous?.refreshId ?? `${user.uin}_${Date.now()}`;
const request: Record<string, unknown> = { refresh_id: refreshId };
if (previous) {
request['attach_info'] = previous.attachInfo;
if (previous.lastUnionId) request['last_client_feed_union_id'] = previous.lastUnionId;
}
const page = this.parsePage(await this.rpc('GetFriendFeeds', request, user));
let lastUnionId = previous?.lastUnionId;
if (page.hasMore) {
for (let index = page.feeds.length - 1; index >= 0; index--) {
const feed = page.feeds[index];
if (!feed || feed.cell_common?.feed_type === 3 || !feed.cell_common?.union_id) continue;
try {
lastUnionId = validateQzoneFeedReference({ union_id: feed.cell_common.union_id }).union_id;
break;
} catch {
// 未知动态仍保留在返回页;只有可用身份才能作为后续分页锚点。
}
}
}
const nextCursor = page.hasMore
? this.saveCursor({
account: this.accountKey(user), kind: 'friends', target: '', attachInfo: page.attachInfo, refreshId, lastUnionId,
})
: null;
return { feeds: page.feeds.map(normalizeQzoneFeed), has_more: page.hasMore, next_cursor: nextCursor };
}
async getMsgList (targetUin?: string, cursor?: string): Promise<QzoneMsgPage> {
const user = this.currentUser();
const target = targetUin ?? user.uin;
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
const previous = this.readCursor(cursor, user, 'moods', target);
let uid = user.uid;
if (target !== user.uin) {
try {
uid = await withTimeout(() => this.core.apis.UserApi.getUidByUinV2(target), 'QQ空间账号解析超时');
} catch {
throw new Error('无法解析目标QQ号,请确认账号可访问');
}
}
if (!validIdentity(uid) || uid.includes('*')) throw new Error('无法解析目标QQ号,请确认账号可访问');
this.assertAccount(user);
const data = await this.rpc('GetShuoshuoList', {
host_uid: uid, host_uin: target, attach_info: previous?.attachInfo ?? '', feed_action_domain_type: 1,
}, user);
const page = this.parsePage(data);
if (data['seal_off_memory'] !== undefined && data['seal_off_memory'] !== null && !isRecord(data['seal_off_memory'])) {
throw new Error('QQ空间封存状态响应格式异常');
}
const nextCursor = page.hasMore
? this.saveCursor({
account: this.accountKey(user), kind: 'moods', target, attachInfo: page.attachInfo,
})
: null;
return {
msglist: page.feeds.map(normalizeQzoneFeed),
has_more: page.hasMore,
next_cursor: nextCursor,
...(isRecord(data['seal_off_memory']) ? { seal_off_memory: data['seal_off_memory'] } : {}),
};
}
private async detailResponse (ref: QzoneFeedReference, user: QzoneUser): Promise<Record<string, unknown>> {
return await this.rpc('GetFeedDetail', {
union_id: ref.union_id,
cell_feed_transfer: ref.cell_feed_transfer,
feed_action_domain_type: ref.feed_action_domain_type ?? 1,
from_page: 1,
}, user);
}
private detailFeed (data: Record<string, unknown>): QzoneFeed {
if (!isRecord(data['feed']) || !isRecord(data['feed']['feed'])) {
throw new Error('QQ空间未返回说说详情,内容可能不可访问或已删除');
}
const feed = data['feed']['feed'] as QzoneFeed;
if (feed.cell_comment !== undefined && !isRecord(feed.cell_comment)) {
throw new Error('QQ空间评论响应格式异常');
}
const comments = feed.cell_comment?.comments;
if (comments !== undefined && (!Array.isArray(comments) || comments.some(comment => !isRecord(comment)))) {
throw new Error('QQ空间评论响应格式异常');
}
return feed;
}
private currentReference (ref: QzoneFeedReference, feed: QzoneFeed): QzoneFeedReference {
const current = referenceSnapshot(ref);
const common = feed.cell_common;
if (isRecord(common)) {
if (common['union_id'] !== undefined && common['union_id'] !== null) current.union_id = common['union_id'] as QzoneUnionId;
if (typeof common['domain_type'] === 'number' && Number.isInteger(common['domain_type']) && common['domain_type'] >= 1 && common['domain_type'] <= 3) {
current.feed_action_domain_type = common['domain_type'];
}
if (validIdentity(common['cur_like_key'], 8192)) current.like_key = common['cur_like_key'];
}
if (feed.cell_feed_transfer !== undefined && feed.cell_feed_transfer !== null) current.cell_feed_transfer = feed.cell_feed_transfer;
return referenceSnapshot(current);
}
private commentCell (value: unknown): { comments: QzoneComment[]; count: number | null; } {
if (value === undefined) return { comments: [], count: null };
if (!isRecord(value) || (value['comments'] !== undefined && (!Array.isArray(value['comments']) || value['comments'].some(item => !isRecord(item))))) {
throw new Error('QQ空间评论响应格式异常');
}
return { comments: (value['comments'] ?? []) as QzoneComment[], count: observedCount(value['num']) };
}
private commentPageState (data: Record<string, unknown>, field: 'attach_info' | 'reply_attach_info', previous?: string): { hasMore: boolean; attachInfo: string; } {
if (data['has_more'] !== undefined && typeof data['has_more'] !== 'boolean') throw new Error('QQ空间评论分页状态无效');
const hasMore = data['has_more'] === true;
const attachInfo = data[field];
if (hasMore && (typeof attachInfo !== 'string' || !attachInfo.length || attachInfo.length > MAX_ATTACH_LENGTH || attachInfo === previous)) {
throw new Error('QQ空间评论续页信息缺失或未推进,请重新读取');
}
return { hasMore, attachInfo: typeof attachInfo === 'string' ? attachInfo : '' };
}
private saveCommentCursor (user: QzoneUser, boundRef: QzoneFeedReference, requestRef: QzoneFeedReference, attachInfo: string): string {
return this.saveCursor({
account: this.accountKey(user), kind: 'comments', target: commentScope(boundRef), attachInfo, feedReference: requestRef,
});
}
async getMsgDetail (reference: QzoneFeedReference): Promise<QzoneDetail> {
const ref = referenceSnapshot(reference);
const user = this.currentUser();
const data = await this.detailResponse(ref, user);
const feed = this.detailFeed(data);
const item = normalizeQzoneFeed(feed);
const state = this.commentPageState(data, 'attach_info');
// 旧详情仍返回原来的规范化引用;分页请求在内部保留 H5 返回的完整新上下文。
const nextCursor = state.hasMore && item.feed_ref
? this.saveCommentCursor(user, item.feed_ref, this.currentReference(ref, feed), state.attachInfo)
: null;
return {
...item,
comments: feed.cell_comment?.comments ?? [],
comment_has_more: state.hasMore,
comment_next_cursor: nextCursor,
};
}
async getComments (reference: QzoneFeedReference, cursor?: string): Promise<QzoneCommentPage> {
const ref = referenceSnapshot(reference);
const user = this.currentUser();
const previous = this.readCursor(cursor, user, 'comments', commentScope(ref));
let current = ref;
let data: Record<string, unknown>;
let cell: { comments: QzoneComment[]; count: number | null; };
if (previous) {
if (!previous.feedReference) throw new Error('QQ空间评论游标无效,请重新读取');
current = referenceSnapshot(previous.feedReference);
data = await this.rpc('GetComment', {
union_id: current.union_id, attach_info: previous.attachInfo, cell_feed_transfer: current.cell_feed_transfer,
}, user);
const commentCell = data['cell_comment'];
// 空末页可能省略 repeated 字段,只有明确结束时才接受,避免把畸形响应当作读完。
if (commentCell === undefined && data['has_more'] === false) {
cell = { comments: [], count: null };
} else {
if (!isRecord(commentCell) || (commentCell['comments'] === undefined && data['has_more'] !== false)) {
throw new Error('QQ空间评论响应缺少有效cell_comment对象或评论列表');
}
cell = this.commentCell(commentCell);
}
} else {
data = await this.detailResponse(ref, user);
const feed = this.detailFeed(data);
current = this.currentReference(ref, feed);
cell = this.commentCell(feed.cell_comment);
}
const comments = cell.comments.map(normalizeQzoneComment);
const state = this.commentPageState(data, 'attach_info', previous?.attachInfo);
const nextCursor = state.hasMore ? this.saveCommentCursor(user, current, current, state.attachInfo) : null;
return {
feed_ref: current,
comment_num: cell.count,
comments,
has_more: state.hasMore,
next_cursor: nextCursor,
raw: data,
};
}
async getCommentReplies (reference: QzoneFeedReference, parent: QzoneComment, cursor?: string): Promise<QzoneReplyPage> {
const ref = referenceSnapshot(reference);
const parentSnapshot = readParentSnapshot(parent);
const user = this.currentUser();
const scope = commentScope(ref, parentSnapshot);
const previous = this.readCursor(cursor, user, 'replies', scope);
let current = ref;
let requestParent = parentSnapshot;
if (previous) {
if (!previous.feedReference || !previous.parentComment) throw new Error('QQ空间回复游标无效,请重新读取');
current = referenceSnapshot(previous.feedReference);
requestParent = readParentSnapshot(previous.parentComment);
}
const initialAttach = requestParent['reply_attach_info'];
if (initialAttach !== undefined && initialAttach !== null && (typeof initialAttach !== 'string' || initialAttach.length > MAX_ATTACH_LENGTH)) {
throw new Error('QQ空间父评论的回复续页信息无效');
}
const attachInfo = previous?.attachInfo ?? (typeof initialAttach === 'string' ? initialAttach : '');
const data = await this.rpc('GetReplies', {
union_id: current.union_id,
cur_comment: requestParent,
comment_attach_info: '',
reply_attach_info: attachInfo,
page_size: 20,
cell_feed_transfer: current.cell_feed_transfer,
}, user);
const replyList = data['replys'];
if ((replyList === undefined && data['has_more'] !== false) ||
(replyList !== undefined && (!Array.isArray(replyList) || replyList.some(item => !isRecord(item))))) {
throw new Error('QQ空间回复响应格式异常');
}
const replies = ((replyList ?? []) as QzoneComment[]).map(normalizeQzoneReply);
const state = this.commentPageState(data, 'reply_attach_info', attachInfo);
const nextParent = state.hasMore
? readParentSnapshot({
...requestParent, reply_attach_info: state.attachInfo, has_more_reply: state.hasMore,
})
: undefined;
const nextCursor = state.hasMore
? this.saveCursor({
account: this.accountKey(user),
kind: 'replies',
target: scope,
attachInfo: state.attachInfo,
feedReference: current,
// 保留首次完整父对象和内联回复,只推进分页元数据,不累计整段历史到请求中。
parentComment: nextParent,
})
: null;
return {
feed_ref: current,
comment_id: String(requestParent.id),
replies,
reply_num: data['reply_num'] === undefined ? observedCount(requestParent['reply_num']) : observedCount(data['reply_num']),
has_more: state.hasMore,
next_cursor: nextCursor,
raw: data,
};
}
async publishMsg (content: string, ugcRight = 1, targetUins?: string[]): Promise<{ tid: string; time: number; }> {
const user = this.currentUser();
const request = buildQzonePublishRequest(user, content, ugcRight, targetUins);
const data = await this.rpc('PublishFeed', request, user);
const tid = isRecord(data['feed']) ? getQzoneTid(data['feed'] as QzoneFeed) : null;
if (!tid) throw new Error('QQ空间发表响应缺少说说标识,结果未知,请先查询列表确认,勿自动重试');
return { tid, time: parseQzonePublishTime((data['feed'] as QzoneFeed).cell_common?.time) };
}
async setLike (reference: QzoneFeedReference, enable: boolean): Promise<void> {
const ref = validateQzoneFeedReference(reference);
if (typeof ref.like_key !== 'string' || !ref.like_key.trim() || ref.like_key.length > 8192) {
throw new Error('缺少QQ空间点赞标识,请先重新获取说说详情');
}
if (typeof enable !== 'boolean') throw new Error('QQ空间点赞状态必须是布尔值');
const user = this.currentUser();
const data = await this.rpc(enable ? 'DoLike' : 'UnLike', {
like_key: ref.like_key, union_id: ref.union_id, like_type: 1, cell_feed_transfer: ref.cell_feed_transfer,
}, user);
if (!isRecord(data['like'])) throw new Error('QQ空间未返回点赞结果,请先查询状态,勿自动重试');
}
async comment (reference: QzoneFeedReference, content: string, parentComment?: QzoneComment, replyUser?: QzoneUser): Promise<{ comment_id: string; comment: QzoneComment; }> {
const ref = validateQzoneFeedReference(reference);
const segments = buildQzoneText(content);
if (replyUser !== undefined && parentComment === undefined) throw new Error('reply_user 必须与父评论一同提供');
const parent = parentComment === undefined ? undefined : validateComment(parentComment);
const target = parent ? validateUser(replyUser ?? parent.user) : undefined;
const user = this.currentUser();
const data = await this.rpc(parent ? 'AddReply' : 'AddComment', {
union_id: ref.union_id,
cell_feed_transfer: ref.cell_feed_transfer,
...(parent ? { comment: parent, reply: { content: segments, target_user: target, user } } : { comment: { user, content: segments } }),
}, user);
const result = data[parent ? 'reply' : 'comment'];
if (!isRecord(result) || !validIdentity(result['id'], 8192)) {
throw new Error('QQ空间未返回有效评论标识,结果未知,请先查询详情确认,勿自动重试');
}
return { comment_id: result['id'], comment: result as QzoneComment };
}
}
+36
View File
@@ -0,0 +1,36 @@
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
export class NTQQSystemApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async hasOtherRunningQQProcess () {
return this.core.util.hasOtherRunningQQProcess();
}
async ocrImage (filePath: string) {
return this.context.session.getNodeMiscService().wantWinScreenOCR(filePath);
}
async translateEnWordToZn (words: string[]) {
return this.context.session.getRichMediaService().translateEnWordToZn(words);
}
async getOnlineDev () {
this.context.session.getMsgService().getOnLineDev();
}
async getArkJsonCollection () {
return await this.core.eventWrapper.callNoListenerEvent('NodeIKernelCollectionService/collectionArkShare', '1717662698058');
}
async bootMiniApp (appFile: string, params: string) {
await this.context.session.getNodeMiscService().setMiniAppVersion('2.16.4');
return this.context.session.getNodeMiscService().startNewMiniApp(appFile, params);
}
}
+249
View File
@@ -0,0 +1,249 @@
import { ModifyProfileParams, User, UserDetailSource } from '@/napcat-core/types';
import { RequestUtil } from 'napcat-common/src/request';
import { InstanceContext, NapCatCore, ProfileBizType } from '..';
import { solveAsyncProblem } from 'napcat-common/src/helper';
import { Fallback, FallbackUtil } from 'napcat-common/src/fall-back';
export class NTQQUserApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async getCoreAndBaseInfo (uids: string[]) {
return await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getCoreAndBaseInfo',
'nodeStore',
uids
);
}
// 默认获取自己的 type = 2 获取别人 type = 1
async getProfileLike (uid: string, start: number, count: number, type: number = 2) {
return this.context.session.getProfileLikeService().getBuddyProfileLike({
friendUids: [uid],
basic: 1,
vote: 1,
favorite: 0,
userProfile: 1,
type,
start,
limit: count,
});
}
async setLongNick (longNick: string) {
return this.context.session.getProfileService().setLongNick(longNick);
}
async setSelfOnlineStatus (status: number, extStatus: number, batteryStatus: number) {
return this.context.session.getMsgService().setStatus({
status,
extStatus,
batteryStatus,
});
}
async setDiySelfOnlineStatus (faceId: string, wording: string, faceType: string) {
return this.context.session.getMsgService().setStatus({
status: 10,
extStatus: 2000,
customStatus: { faceId, wording, faceType },
batteryStatus: 0,
});
}
async getBuddyRecommendContactArkJson (uin: string, sencenID = '') {
return this.context.session.getBuddyService().getBuddyRecommendContactArkJson(uin, sencenID);
}
async like (uid: string, count = 1): Promise<{ result: number, errMsg: string, succCounts: number; }> {
return this.context.session.getProfileLikeService().setBuddyProfileLike({
friendUid: uid,
sourceId: 71,
doLikeCount: count,
doLikeTollCount: 0,
});
}
async setQQAvatar (filePath: string) {
const ret = await this.context.session.getProfileService().setHeader(filePath);
return { result: ret?.result, errMsg: ret?.errMsg };
}
async setGroupAvatar (gc: string, filePath: string) {
return this.context.session.getGroupService().setHeader(gc, filePath);
}
async fetchUserDetailInfo (uid: string, mode: UserDetailSource = UserDetailSource.KDB) {
const [, profile] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelProfileService/fetchUserDetailInfo',
'NodeIKernelProfileListener/onUserDetailInfoChanged',
[
'BuddyProfileStore',
[uid],
mode,
[ProfileBizType.KALL],
],
() => true,
(profile) => profile.uid === uid
);
return profile;
}
async getUserDetailInfo (uid: string, no_cache: boolean = false): Promise<User> {
let profile = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, no_cache ? UserDetailSource.KSERVER : UserDetailSource.KDB), uid);
if (profile && profile.uin !== '0' && profile.commonExt) {
return {
...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo,
...profile.commonExt,
...profile.simpleInfo.baseInfo,
...profile.simpleInfo.coreInfo,
qqLevel: profile.commonExt?.qqLevel,
age: profile.simpleInfo.baseInfo.age,
pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '',
};
}
this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.');
profile = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER);
if (profile && profile.uin === '0') {
profile.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return {
...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo,
...profile.commonExt,
...profile.simpleInfo.baseInfo,
...profile.simpleInfo.coreInfo,
qqLevel: profile.commonExt?.qqLevel,
age: profile.simpleInfo.baseInfo.age,
pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '',
};
}
async modifySelfProfile (param: ModifyProfileParams) {
return this.context.session.getProfileService().modifyDesktopMiniProfile(param);
}
async getCookies (domain: string) {
const ClientKeyData = await this.forceFetchClientKey();
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin +
'&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2F' + domain + '%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27';
const data = await RequestUtil.HttpsGetCookies(requestUrl);
if (!data['p_skey'] || data['p_skey'].length === 0) {
try {
const pskey = (await this.getPSkey([domain])).domainPskeyMap.get(domain);
if (pskey) data['p_skey'] = pskey;
} catch {
return data;
}
}
return data;
}
async getPSkey (domainList: string[]) {
return await this.context.session.getTipOffService().getPskey(domainList, true);
}
async getRobotUinRange (): Promise<Array<unknown>> {
const robotUinRanges = await this.context.session.getRobotService().getRobotUinRange({
justFetchMsgConfig: '1',
type: 1,
version: 0,
aioKeywordVersion: 0,
});
return robotUinRanges?.response?.robotUinRanges;
}
// 需要异常处理
async getQzoneCookies () {
const ClientKeyData = await this.forceFetchClientKey();
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + '&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2Fuser.qzone.qq.com%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27';
return await RequestUtil.HttpsGetCookies(requestUrl);
}
// 需要异常处理
async getSKey (): Promise<string | undefined> {
const ClientKeyData = await this.forceFetchClientKey();
if (ClientKeyData.result !== 0) {
throw new Error('getClientKey Error');
}
const clientKey = ClientKeyData.clientKey;
// const keyIndex = ClientKeyData.keyIndex;
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + '&clientkey=' + clientKey + '&u1=https%3A%2F%2Fh5.qzone.qq.com%2Fqqnt%2Fqzoneinpcqq%2Ffriend%3Frefresh%3D0%26clientuin%3D0%26darkMode%3D0&keyindex=19%27';
const cookies: { [key: string]: string; } = await RequestUtil.HttpsGetCookies(requestUrl);
const skey = cookies['skey'];
if (!skey) {
throw new Error('SKey is Empty');
}
return skey;
}
async getUidByUinV2 (uin: string) {
if (!uin) {
return '';
}
const fallback =
new Fallback<string | undefined>((uid) => FallbackUtil.boolchecker(uid, uid !== undefined && uid.indexOf('*') === -1 && uid !== ''))
.add(() => this.context.session.getUixConvertService().getUid([uin]).then((data) => data.uidInfo.get(uin)))
.add(() => this.context.session.getProfileService().getUidByUin('FriendsServiceImpl', [uin]).get(uin))
.add(() => this.context.session.getGroupService().getUidByUins([uin]).then((data) => data.uids.get(uin)))
.add(() => this.core.apis.GroupApi.getUidFromMemberCache(uin))
.add(() => this.getUserDetailInfoByUin(uin).then((data) => data.detail.uid));
const uid = await fallback.run().catch(() => '');
return uid ?? '';
}
async getUinByUidV2 (uid: string) {
if (!uid) {
return '0';
}
const fallback = new Fallback<string | undefined>((uin) => FallbackUtil.boolchecker(uin, uin !== undefined && uin !== '0' && uin !== ''))
.add(() => this.context.session.getUixConvertService().getUin([uid]).then((data) => data.uinInfo.get(uid)))
.add(() => this.context.session.getProfileService().getUinByUid('FriendsServiceImpl', [uid]).get(uid))
.add(() => this.context.session.getGroupService().getUinByUids([uid]).then((data) => data.uins.get(uid)))
.add(() => this.getUserDetailInfo(uid).then((data) => data.uin));
const uin = await fallback.run().catch(() => '0');
return uin ?? '0';
}
async getRecentContactListSnapShot (count: number) {
return await this.context.session.getRecentContactService().getRecentContactListSnapShot(count);
}
async getRecentContactListSyncLimit (count: number) {
return await this.context.session.getRecentContactService().getRecentContactListSyncLimit(count);
}
async getRecentContactListSync () {
return await this.context.session.getRecentContactService().getRecentContactListSync();
}
async getRecentContactList () {
return await this.context.session.getRecentContactService().getRecentContactList();
}
async getUserDetailInfoByUin (Uin: string) {
return await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getUserDetailInfoByUin',
Uin
);
}
async forceFetchClientKey () {
return await this.context.session.getTicketService().forceFetchClientKey('');
}
}
+903
View File
@@ -0,0 +1,903 @@
import { RequestUtil } from 'napcat-common/src/request';
import {
GroupEssenceMsgRet,
InstanceContext,
WebApiGroupMember,
WebApiGroupMemberRet,
WebApiGroupNoticeRet,
WebHonorType, NapCatCore,
} from '@/napcat-core/index';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { open, unlink, writeFile, type FileHandle } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { basename, join } from 'node:path';
import { qunAlbumControl, qunAlbumVideoControl, qunAlbumVideoCoverControl, QunAlbumBatch, nextQunAlbumBatchId } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
import { FFmpegService } from '../helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '../helper/ffmpeg/video';
import {
buildQzoneDeleteBody,
buildQzonePublishBody,
buildQzoneUploadImageBody,
parseQzoneDeleteResponse,
parseQzonePublishResponse,
parseQzoneUploadImageResponseText,
toQzoneRichval,
QzoneDeleteResponse,
QzonePublishResponse,
} from '../data/qzone';
export interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
// 群相册分片上传的并发数
const QUN_ALBUM_SLICE_CONCURRENCY = 8;
// 服务端没给分片大小时的下限,以及防止异常值的上限
const QUN_ALBUM_SLICE_MIN = 16384;
const QUN_ALBUM_SLICE_MAX = 4 * 1024 * 1024;
// 流式计算文件摘要,避免为了算 MD5/SHA1 把整个文件读进内存(视频可能有几十上百 MB)
function hashFile (path: string, algorithm: 'md5' | 'sha1'): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash(algorithm);
createReadStream(path)
.on('data', chunk => hash.update(chunk))
.on('end', () => resolve(hash.digest('hex')))
.on('error', reject);
});
}
// 按需读取单个分片:并发上传时的内存占用只有「并发数 × 分片大小」
async function readSlice (handle: FileHandle, offset: number, length: number): Promise<Buffer> {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, offset);
// 读不满说明文件在上传过程中被改短了,继续传只会传上去一段补零的数据
if (bytesRead !== length) {
throw new Error(`读取分片失败: offset=${offset} 预期 ${length} 字节,实际 ${bytesRead} 字节`);
}
return buffer;
}
export class NTQQWebApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
group_code: groupCode,
msg_seq: msgSeq,
msg_random: msgRandom,
target_group_code: targetGroupCode,
}).toString()}`;
try {
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
} catch {
return undefined;
}
}
async getGroupEssenceMsgAll (GroupCode: string) {
const ret: GroupEssenceMsgRet[] = [];
for (let i = 0; i < 20; i++) {
const data = await this.getGroupEssenceMsg(GroupCode, i, 50);
if (!data) break;
ret.push(data);
if (data.data.is_end) break;
}
return ret;
}
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
page_start: page_start.toString(),
page_limit: page_limit.toString(),
group_code: GroupCode,
}).toString()}`;
try {
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
url,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret.retcode === 0 ? ret : undefined;
} catch {
return undefined;
}
}
async getGroupMembers (GroupCode: string): Promise<WebApiGroupMember[]> {
// logDebug('webapi 获取群成员', GroupCode);
const memberData: Array<WebApiGroupMember> = new Array<WebApiGroupMember>();
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const retList: Promise<WebApiGroupMemberRet>[] = [];
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: '0',
end: '40',
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
return [];
} else {
for (const key in fastRet.mems) {
if (fastRet.mems[key]) {
memberData.push(fastRet.mems[key]);
}
}
}
// 初始化获取PageNum
const PageNum = Math.ceil(fastRet.count / 40);
// 遍历批量请求
for (let i = 2; i <= PageNum; i++) {
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: ((i - 1) * 40).toString(),
end: (i * 40).toString(),
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
retList.push(ret);
}
// 批量等待
for (let i = 1; i <= PageNum; i++) {
const ret = await (retList[i]);
if (!ret?.count || ret?.errcode !== 0 || !ret?.mems) {
continue;
}
for (const key in ret.mems) {
if (ret.mems[key]) {
memberData.push(ret.mems[key]);
}
}
}
return memberData;
}
// public async addGroupDigest(groupCode: string, msgSeq: string) {
// const url = `https://qun.qq.com/cgi-bin/group_digest/cancel_digest?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&msg_seq=${msgSeq}&msg_random=444021292`;
// const res = await this.request(url);
// return await res.json();
// }
// public async getGroupDigest(groupCode: string) {
// const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&page_start=0&page_limit=20`;
// const res = await this.request(url);
// return await res.json();
// }
async setGroupNotice (
GroupCode: string,
Content: string,
pinned: number = 0,
type: number = 1,
is_show_edit_card: number = 1,
tip_window_type: number = 1,
confirm_required: number = 1,
picId: string = '',
imgWidth: number = 540,
imgHeight: number = 300
) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const settings = JSON.stringify({
is_show_edit_card,
tip_window_type,
confirm_required,
});
const externalParam = {
pic: picId,
imgWidth: imgWidth.toString(),
imgHeight: imgHeight.toString(),
};
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
text: Content,
pinned: pinned.toString(),
type: type.toString(),
settings,
...(picId === '' ? {} : externalParam),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret;
} catch {
return undefined;
}
}
async getGroupNotice (GroupCode: string): Promise<undefined | WebApiGroupNoticeRet> {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
ft: '23',
ni: '1',
n: '1',
i: '1',
log_read: '1',
platform: '1',
s: '-1',
}).toString()}&n=20`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret?.ec === 0 ? ret : undefined;
} catch {
return undefined;
}
}
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
let resJson;
try {
const res = await RequestUtil.HttpGetText(
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
gc: groupCode,
type: type.toString(),
}).toString()}`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
if (match?.[1]) {
resJson = JSON.parse(match[1].trim());
}
return type === 1 ? resJson?.talkativeList : resJson?.actorList;
} catch (e) {
this.context.logger.logDebug('获取当前群荣耀失败', e);
return undefined;
}
}
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
const data = await this.getDataInternal(cookieObject, groupCode, type);
if (!data) {
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
return [];
}
return data.map((item: {
uin: string,
name: string,
avatar: string,
desc: string,
}) => ({
user_id: item?.uin,
nickname: item?.name,
avatar: item?.avatar,
description: item?.desc,
}));
}
async getGroupHonorInfo (groupCode: string, getType: WebHonorType) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const HonorInfo = {
group_id: Number(groupCode),
current_talkative: {},
talkative_list: [],
performer_list: [],
legend_list: [],
emotion_list: [],
strong_newbie_list: [],
};
if (getType === WebHonorType.TALKATIVE || getType === WebHonorType.ALL) {
const talkativeList = await this.getHonorList(cookieObject, groupCode, 1);
if (talkativeList.length > 0) {
HonorInfo.current_talkative = talkativeList[0];
HonorInfo.talkative_list = talkativeList;
}
}
if (getType === WebHonorType.PERFORMER || getType === WebHonorType.ALL) {
HonorInfo.performer_list = await this.getHonorList(cookieObject, groupCode, 2);
}
if (getType === WebHonorType.LEGEND || getType === WebHonorType.ALL) {
HonorInfo.legend_list = await this.getHonorList(cookieObject, groupCode, 3);
}
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
HonorInfo.emotion_list = await this.getHonorList(cookieObject, groupCode, 6);
}
// 冒尖小春笋好像已经被tx扬了 R.I.P.
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
HonorInfo.strong_newbie_list = [];
}
return HonorInfo;
}
private cookieToString (cookieObject: { [key: string]: string; }) {
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
}
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
const sKey = cookieObject['skey'] as string;
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i);
hash = hash + (hash << 5) + code;
}
return (hash & 0x7FFFFFFF).toString();
}
public getBknFromSKey (sKey: string) {
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i);
hash = hash + (hash << 5) + code;
}
return (hash & 0x7FFFFFFF).toString();
}
public getBknFromPSKey (psKey: string) {
return this.getBknFromSKey(psKey);
}
async getAlbumListByNTQQ (gc: string, attach_info: string = '') {
return await this.context.session.getAlbumService().getAlbumList({
qun_id: gc,
attach_info,
seq: 3331,
request_time_line: {
request_invoke_time: '0',
},
});
}
async getAlbumList (gc: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const bkn = this.getBknFromSKey(skey);
const uin = this.core.selfInfo.uin || '10001';
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
const api = 'https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?';
const params = new URLSearchParams({
random: '7570',
g_tk: bkn,
format: 'json',
inCharset: 'utf-8',
outCharset: 'utf-8',
qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T',
cmd: 'qunGetAlbumList',
qunId: gc,
qunid: gc,
start: '0',
num: '1000',
uin,
getMemberRole: '0',
});
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
Cookie: cookies,
});
return response.data.album;
}
async createQunAlbumSession (
gc: string, sAlbumID: string, sAlbumName: string, path: string,
skey: string, pskey: string, img_md5: string, uin: string, batch?: QunAlbumBatch
) {
const img_size = statSync(path).size;
const img_name = basename(path);
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const body = qunAlbumControl({
uin,
group_id: gc,
pskey,
pic_md5: img_md5,
img_size,
img_name,
sAlbumName,
sAlbumID,
// 同批多图共用 batchId,并按「第几张 / 共几张」上报,相册里才会合并成同一条
batchId: batch?.batchId,
batchIndex: batch?.index,
photo_num: batch?.count,
batch_num: batch?.count,
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson<{
data: { session: string; slice_size?: number | string; },
ret: number,
msg: string;
}>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
// 服务端会在会话响应里给出建议的分片大小;取不到或异常时退回 16384。
private resolveQunAlbumSliceSize (raw: unknown) {
const value = Number(raw);
if (!Number.isFinite(value) || value < QUN_ALBUM_SLICE_MIN) return QUN_ALBUM_SLICE_MIN;
return Math.min(value, QUN_ALBUM_SLICE_MAX);
}
async uploadQunAlbumSlice (
path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number,
options: { appid?: string, cmd?: string; } = {}
) {
const appid = options.appid ?? 'qun';
const cmd = options.cmd ?? 'FileUpload';
const img_size = statSync(path).size;
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
// 只记分片位置,不预先读内容;再并发上传(串行上传在几 MB 的图上要几十秒)
const slices: { seq: number, offset: number, end: number; }[] = [];
let seq = 0;
let offset = 0;
while (offset < img_size) {
const end = Math.min(offset + slice_size, img_size);
slices.push({ seq, offset, end });
offset = end;
seq++;
}
// 视频分片响应里会带 sVid,封面二段上传要靠它把封面挂到视频上
let sVid: string | undefined;
const handle = await open(path, 'r');
const uploadSlice = async (slice: { seq: number, offset: number, end: number; }) => {
const chunk = await readSlice(handle, slice.offset, slice.end - slice.offset);
const form = new FormData();
form.append('uin', uin);
form.append('appid', appid);
form.append('session', session);
form.append('offset', slice.offset.toString());
form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob');
form.append('checksum', '');
form.append('check_type', '0');
form.append('retry', '0');
form.append('seq', slice.seq.toString());
form.append('end', slice.end.toString());
form.append('cmd', cmd);
form.append('slice_size', slice_size.toString());
form.append('biz_req.iUploadType', '0');
// 分片路径必须与 cmd 一致:视频走 /sliceUpload/FileUploadVideo,
// 否则服务端按图片处理,分片会「成功」但响应里没有 sVid
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/${cmd}?seq=${slice.seq}&retry=0&offset=${slice.offset}&end=${slice.end}&total=${img_size}&type=form&g_tk=${GTK}`;
const response = await fetch(api, {
method: 'POST',
headers: {
Cookie: cookie,
},
body: form,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json() as { ret: number, msg: string, data?: { biz?: { sVid?: string; }; }; };
if (post.ret !== 0) {
throw new Error(`分片 ${slice.seq} 上传失败: ${post.msg}`);
}
if (!sVid && typeof post.data?.biz?.sVid === 'string' && post.data.biz.sVid) {
sVid = post.data.biz.sVid;
}
};
try {
// 最后一片要等前面的全部传完再发:服务端收到最后一片就认为文件传完了,
// 并发时它可能比前面的分片先到
const lastSlice = slices.pop();
let next = 0;
let failure: { error: unknown; } | undefined;
// 固定数量的 worker 各自领下一片,慢的分片不会拖住整批
const worker = async () => {
while (!failure) {
const slice = slices[next++];
if (!slice) break;
try {
await uploadSlice(slice);
} catch (error) {
if (!failure) failure = { error };
}
}
};
await Promise.all(Array.from({ length: Math.min(QUN_ALBUM_SLICE_CONCURRENCY, slices.length) }, worker));
if (failure) throw failure.error;
if (lastSlice) await uploadSlice(lastSlice);
} finally {
await handle.close();
}
return { success: true, message: '上传完成', sVid };
}
// 多张图片一次上传:共用同一个 batchId,相册里显示为同一条
async uploadImagesToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, paths: string[]) {
if (!paths.length) throw new Error('上传列表为空');
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const uin = this.core.selfInfo.uin || '10001';
const batchId = nextQunAlbumBatchId();
const count = paths.length.toString();
for (const [index, path] of paths.entries()) {
const img_md5 = await hashFile(path, 'md5');
const post = await this.createQunAlbumSession(
gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin,
{ batchId, count, index: index.toString() }
);
if (!post.data?.session) throw new Error(`创建群相册会话失败: ${post.msg || post.ret}`);
await this.uploadQunAlbumSlice(path, post.data.session, skey, pskey, uin, this.resolveQunAlbumSliceSize(post.data.slice_size));
}
}
async uploadImageToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
await this.uploadImagesToQunAlbum(gc, sAlbumID, sAlbumName, [path]);
}
/**
* 上传视频到群相册。
*
* 分三步:
* 1. 建视频会话:appid=video_qun / cmd=FileUploadVideo / SHA1 校验,此时不带相册
* 2. 分片上传视频,从分片响应里拿 sVid
* 3. 按图片上传封面并用 vid 关联视频,相册和批次号在这一步带上,视频随封面进相册
*/
async uploadVideoToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const uin = this.core.selfInfo.uin || '10001';
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const video_sha1 = await hashFile(path, 'sha1');
// 时长:ffmpeg 不可用时上报 0
let playTime = 0;
try {
playTime = Math.max(0, Math.round(await FFmpegService.getDuration(path)));
} catch (e) {
this.context.logger.logWarn('获取视频时长失败,iPlayTime 上报 0', e);
}
// 1) 视频主体(不带相册,只传成素材)
const videoSession = await RequestUtil.HttpGetJson<{
data: { session: string; slice_size?: number | string; },
ret: number,
msg: string;
}>(`https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${video_sha1}?g_tk=${GTK}`, 'POST', qunAlbumVideoControl({
uin,
group_id: gc,
pskey,
video_sha1,
video_size: statSync(path).size,
play_time: playTime,
}), {
Cookie: cookie,
'Content-Type': 'application/json',
});
if (!videoSession.data?.session) throw new Error(`创建群相册视频会话失败: ${videoSession.msg || videoSession.ret}`);
const uploaded = await this.uploadQunAlbumSlice(path, videoSession.data.session, skey, pskey, uin,
this.resolveQunAlbumSliceSize(videoSession.data.slice_size), { appid: 'video_qun', cmd: 'FileUploadVideo' });
if (!uploaded.sVid) throw new Error('视频上传完成但未取得 sVid,无法挂进相册');
// 2) 封面:抽首帧,失败(含 ffmpeg 不可用)回落内置默认封面
// 用 .png:Native Addon 路径产出的就是 png(日志里可见 Detected format: png),
// 而命令行路径会按扩展名编码,两条路都能得到真正的 PNG
const coverPath = join(this.core.NapCatTempPath, `${randomUUID()}.png`);
try {
try {
await FFmpegService.extractThumbnail(path, coverPath);
} catch (e) {
this.context.logger.logWarn('提取视频封面失败,使用默认封面', e);
await writeFile(coverPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
// 3) 封面二段上传:这一步才带相册和批次号,视频靠它进相册
const cover_md5 = await hashFile(coverPath, 'md5');
const coverSession = await RequestUtil.HttpGetJson<{
data: { session: string; slice_size?: number | string; },
ret: number,
msg: string;
}>(`https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${cover_md5}?g_tk=${GTK}`, 'POST', qunAlbumVideoCoverControl({
uin,
group_id: gc,
pskey,
sAlbumName,
sAlbumID,
cover_md5,
cover_size: statSync(coverPath).size,
vid: uploaded.sVid,
}), {
Cookie: cookie,
'Content-Type': 'application/json',
});
if (!coverSession.data?.session) throw new Error(`创建群相册视频封面会话失败: ${coverSession.msg || coverSession.ret}`);
await this.uploadQunAlbumSlice(coverPath, coverSession.data.session, skey, pskey, uin,
this.resolveQunAlbumSliceSize(coverSession.data.slice_size));
} finally {
if (existsSync(coverPath)) {
await unlink(coverPath);
}
}
}
async getAlbumMediaListByNTQQ (gc: string, albumId: string, attach_info: string = '') {
return (await this.context.session.getAlbumService().getMediaList({
qun_id: gc,
attach_info,
seq: 0,
request_time_line: {
request_invoke_time: '0',
},
album_id: albumId,
lloc: '',
batch_id: '',
})).response;
}
async doAlbumMediaPlainCommentByNTQQ (
qunId: string,
albumId: string,
lloc: string | undefined,
content: string,
batchId: string = '') {
if (!lloc && !batchId) {
throw new Error('lloc 或 batch_id 至少需要提供一个');
}
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
const albumService = this.context.session.getAlbumService();
let feed;
if (lloc) {
feed = createAlbumMediaFeed(uin, albumId, lloc, batchId);
} else {
// Batch comments need the server's feed identity and uploader, not an
// empty media cell with the commenter substituted for the feed owner.
const { response } = await albumService.getQunFeedDetail({
seq: random_seq,
request_time_line: { request_invoke_time: '0' },
ext_info: { map_info: [], map_bytes_info: [], map_user_account: [] },
qun_id: qunId,
feed_id: '',
comment_count: 20,
attach_info: '',
album_id: albumId,
batch_id: batchId,
lloc: '',
});
if (response?.result !== 0) {
throw new Error(`获取群相册批次动态失败, ${response?.result ?? 'unknown'}:${response?.errMs || '未知错误'}`);
}
feed = response.feed?.feed;
if (!feed?.cell_common?.feed_id || !feed.cell_user_info?.user || !feed.cell_media) {
throw new Error('获取群相册批次动态失败: 未返回有效动态');
}
}
// 16位number数字
const client_key = Date.now() * 1000;
const result = await albumService.doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
qunId,
2,
feed,
createAlbumCommentRequest(uin, content, client_key)
);
if (result?.result !== 0) {
throw new Error(`群相册评论失败, ${result?.result ?? 'unknown'}:${result?.errMs || '未知错误'}`);
}
if (!result.comment?.id) {
throw new Error('群相册评论失败: 未返回有效评论');
}
return result;
}
async deleteAlbumMediaByNTQQ (
qunId: string,
albumId: string,
lloc: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
return await this.context.session.getAlbumService().deleteMedias(
random_seq,
qunId,
albumId,
[lloc],
[]
);
}
async doAlbumMediaLikeByNTQQ (
qunId: string,
albumId: string,
batchId: string,
lloc: string | undefined,
isLike: boolean
) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
const type = isLike ? 2 : 1;
const status = isLike ? 0 : 1;
let id = '';
if (lloc) {
id = `421_1_0_${qunId}|${albumId}|${batchId}^||^421_1_0_${qunId}|${albumId}|${lloc}^||^0`;
} else {
id = `421_1_0_${qunId}|${albumId}|${batchId}`;
}
return await this.context.session.getAlbumService().doQunLike(
random_seq,
{
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
type,
{
id,
status,
},
createAlbumFeedPublish(qunId, uin, albumId, batchId)
);
}
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') || '';
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 };
}
/**
* 上传图片到 QQ 空间相册, 返回可用于发说说的 richval 字符串
* @param base64 图片Base64编码内容(不带data:前缀)
*/
async uploadImageToQzone (base64: string): Promise<string> {
const { skey, pskey, uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneUploadImageBody({ uin, skey, pskey, g_tk, base64 });
const api = `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
const resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
const parsed = parseQzoneUploadImageResponseText(resultText);
if (parsed.code !== undefined && parsed.code !== 0) {
throw new Error(parsed.msg || `QQ空间图片上传失败, code=${parsed.code}`);
}
if (!parsed.data) {
throw new Error('QQ空间图片上传失败, 响应缺少data字段');
}
return toQzoneRichval(parsed.data);
}
/**
* 发表QQ空间说说
* @param content 说说正文
* @param richvals 每张图片对应的richval数组, 为空表示纯文字说说
* @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见
* @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用
*/
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: Array<string | number>) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
const body = buildQzonePublishBody({
hostuin: uin,
content,
richval,
ugcRight,
allowUins,
});
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`;
const result = await RequestUtil.HttpGetJson<QzonePublishResponse>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
}, true, false);
return parseQzonePublishResponse(result);
}
/**
* 删除QQ空间说说
* @param tid 说说Tid, 来自 publishQzoneMsg 的返回值
*/
async deleteQzoneMsg (tid: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneDeleteBody({ hostuin: uin, tid });
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6?g_tk=${g_tk}`;
const result = await RequestUtil.HttpGetJson<QzoneDeleteResponse>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
}, true, false);
return parseQzoneDeleteResponse(result);
}
async getDaySignedList (groupCode: string) {
const pSkey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
const selfUin = this.core.selfInfo.uin;
const cookie = `p_uin=o${selfUin}; p_skey=${pSkey}; uin=o${selfUin}`;
const post = await RequestUtil.HttpGetJson<{
retCode: number,
costTime: number,
response: {
ret?: {
code: string,
msg: string,
},
page?: {
infos?: {
uid: string,
uidGroupNick: string,
signedTimeStamp: string,
signInRank: number,
}[],
offset: number,
total: number,
}[],
},
funcCode: number,
}>(`https://qun.qq.com/v2/signin/trpc/GetDaySignedList?g_tk=${this.getBknFromPSKey(pSkey)}`, 'POST', {
dayYmd: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
offset: 0,
limit: 100,
uid: selfUin,
groupId: groupCode,
}, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
}