NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user