NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { MsfChangeReasonType, MsfStatusType } from '@/napcat-core/types/adapter';
|
||||
|
||||
export class NodeIDependsAdapter {
|
||||
onMSFStatusChange (_statusType: MsfStatusType, _changeReasonType: MsfChangeReasonType) {
|
||||
|
||||
}
|
||||
|
||||
onMSFSsoError (_code: number, _desc: string) {
|
||||
|
||||
}
|
||||
|
||||
getGroupCode (_args: unknown) {
|
||||
|
||||
}
|
||||
|
||||
// onSendMsfReply (_seq: string, _cmd: string, _uk1: number, _uk2: string, _rsp: {
|
||||
// ssoRetCode: 0,
|
||||
// trpcRetCode: 0,
|
||||
// trpcFuncCode: 0,
|
||||
// errorMsg: '',
|
||||
// pbBuffer: Uint8Array,
|
||||
// transInfoMap: Map<unknown, unknown>;
|
||||
// }) {
|
||||
|
||||
// console.log('[NodeIDependsAdapter] onSendMsfReply', _seq, _cmd, _uk1, _uk2, Buffer.from(_rsp.pbBuffer).toString('hex'));
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class NodeIDispatcherAdapter {
|
||||
dispatchRequest (_arg: unknown) {
|
||||
}
|
||||
|
||||
dispatchCall (_arg: unknown) {
|
||||
}
|
||||
|
||||
dispatchCallWithJson (_arg: unknown) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class NodeIGlobalAdapter {
|
||||
onLog (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
onGetSrvCalTime (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
onShowErrUITips (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
fixPicImgType (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
getAppSetting (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
onInstallFinished (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
onUpdateGeneralFlag (..._args: unknown[]) {
|
||||
}
|
||||
|
||||
onGetOfflineMsg (..._args: unknown[]) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './NodeIDependsAdapter';
|
||||
export * from './NodeIDispatcherAdapter';
|
||||
export * from './NodeIGlobalAdapter';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 群相册列表请求参数接口
|
||||
*/
|
||||
export interface AlbumListRequest {
|
||||
qun_id: string;
|
||||
attach_info: string;
|
||||
seq: number;
|
||||
request_time_line: {
|
||||
request_invoke_time: string;
|
||||
};
|
||||
album_id: string;
|
||||
lloc: string;
|
||||
batch_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群相册列表请求参数
|
||||
* @param qunId 群号
|
||||
* @param albumId 相册ID
|
||||
* @param seq 请求序列号,默认值为0
|
||||
* @returns 请求参数对象
|
||||
*/
|
||||
export function createAlbumListRequest (
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
seq: number = 0
|
||||
): AlbumListRequest {
|
||||
return {
|
||||
qun_id: qunId,
|
||||
attach_info: '',
|
||||
seq,
|
||||
request_time_line: {
|
||||
request_invoke_time: '0',
|
||||
},
|
||||
album_id: albumId,
|
||||
lloc: '',
|
||||
batch_id: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册媒体项请求接口
|
||||
*/
|
||||
export interface AlbumMediaFeed {
|
||||
cell_common: {
|
||||
time: string;
|
||||
feed_id?: string;
|
||||
};
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
};
|
||||
cell_media: {
|
||||
album_id: string;
|
||||
batch_id: string;
|
||||
media_items: Array<{
|
||||
image: {
|
||||
lloc: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册媒体请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param lloc 图片ID;不传或为空时评论整个上传批次
|
||||
* @param batchId 上传批次ID;lloc 为空时必填
|
||||
* @returns 媒体请求参数对象
|
||||
*/
|
||||
export function createAlbumMediaFeed (
|
||||
uin: string,
|
||||
albumId: string,
|
||||
lloc?: string,
|
||||
batchId: string = ''
|
||||
): AlbumMediaFeed {
|
||||
if (!lloc && !batchId) {
|
||||
throw new Error('lloc 或 batch_id 至少需要提供一个');
|
||||
}
|
||||
|
||||
return {
|
||||
cell_common: {
|
||||
time: '',
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: batchId,
|
||||
media_items: lloc ? [{ image: { lloc } }] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 相册评论内容接口
|
||||
*/
|
||||
export interface AlbumCommentContent {
|
||||
type: number;
|
||||
content: string;
|
||||
who: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册评论请求接口
|
||||
*/
|
||||
export interface AlbumCommentReplyContent {
|
||||
client_key: number;
|
||||
content: AlbumCommentContent[];
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
like: {
|
||||
id: string;
|
||||
count: number;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
export enum RichMsgType {
|
||||
KRICHMSGTYPEPLAINTEXT,
|
||||
KRICHMSGTYPEAT,
|
||||
KRICHMSGTYPEURL,
|
||||
KRICHMSGTYPEMEDIA,
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册评论请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param content 评论内容
|
||||
* @param client_key 客户端鉴权密钥
|
||||
* @returns 评论请求参数对象
|
||||
*/
|
||||
export function createAlbumCommentRequest (
|
||||
uin: string,
|
||||
content: string,
|
||||
client_key: number
|
||||
): AlbumCommentReplyContent {
|
||||
return {
|
||||
client_key,
|
||||
// 暂时只支持纯文本吧
|
||||
content: [{
|
||||
type: RichMsgType.KRICHMSGTYPEPLAINTEXT,
|
||||
content,
|
||||
who: 0,
|
||||
uid: '',
|
||||
name: '',
|
||||
url: '',
|
||||
}],
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
// QQNT 会将 like 转换为对象,即使新评论没有点赞也不能省略。
|
||||
like: {
|
||||
id: '',
|
||||
count: 0,
|
||||
status: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlbumFeedLikePublish {
|
||||
cell_common: {
|
||||
time: number;
|
||||
feed_id: string;
|
||||
};
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
};
|
||||
cell_media: {
|
||||
album_id: string;
|
||||
batch_id: string | number;
|
||||
media_items?: {
|
||||
type: number;
|
||||
image: {
|
||||
lloc: string;
|
||||
sloc: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
cell_qun_info: {
|
||||
qun_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册动态发布请求参数
|
||||
* @param qunId 群号
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param batchId 批次ID
|
||||
* @returns 动态发布请求参数对象
|
||||
*/
|
||||
export function createAlbumFeedPublish (
|
||||
qunId: string,
|
||||
uin: string,
|
||||
albumId: string,
|
||||
batchId: string
|
||||
) {
|
||||
return {
|
||||
cell_common: {
|
||||
time: Date.now(),
|
||||
feed_id: `422_0_${batchId}`, // 需要补全feed_id
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin,
|
||||
},
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: batchId,
|
||||
},
|
||||
cell_qun_info: {
|
||||
qun_id: qunId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
export interface FlashBaseRequest {
|
||||
fileSetId: string;
|
||||
}
|
||||
|
||||
export interface UploaderInfo {
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string, // ""
|
||||
}
|
||||
|
||||
export interface thumbnailInfo {
|
||||
id: string,
|
||||
url: {
|
||||
spec: number,
|
||||
uri: string,
|
||||
}[],
|
||||
localCachePath: string,
|
||||
}
|
||||
|
||||
export interface SendTarget {
|
||||
destType: number; // 1私聊
|
||||
destUin?: string,
|
||||
destUid: string,
|
||||
}
|
||||
|
||||
export interface SendTargetRequests {
|
||||
fileSetId: string;
|
||||
targets: SendTarget[];
|
||||
}
|
||||
|
||||
export interface DownloadStatusInfo {
|
||||
result: number; // 0
|
||||
fileSetId: string;
|
||||
status: number;
|
||||
info: {
|
||||
curDownLoadFailFileNum: number,
|
||||
curDownLoadedPauseFileNum: number,
|
||||
curDownLoadedFileNum: number,
|
||||
curRealDownLoadedFileNum: number,
|
||||
curDownloadingFileNum: number,
|
||||
totalDownLoadedFileNum: number,
|
||||
curDownLoadedBytes: string, // "0"
|
||||
totalDownLoadedBytes: string,
|
||||
curSpeedBps: number,
|
||||
avgSpeedBps: number,
|
||||
maxSpeedBps: number,
|
||||
remainDownLoadSeconds: number,
|
||||
failFileIdList: [],
|
||||
allFileIdList: [],
|
||||
hasNormalFileDownloading: boolean,
|
||||
onlyCompressInnerFileDownloading: boolean,
|
||||
isAllFileAlreadyDownloaded: boolean,
|
||||
saveFileSetDir: string,
|
||||
allWaitingStatusTask: boolean,
|
||||
downloadSceneType: DownloadSceneType,
|
||||
retryCount: number,
|
||||
statisticInfo: {
|
||||
downloadTaskId: string,
|
||||
downloadFilesetName: string,
|
||||
downloadFileTypeDistribution: string,
|
||||
downloadFileSizeDistribution: string;
|
||||
},
|
||||
albumStorageFailImageNum: number,
|
||||
albumStorageFailVideoNum: number,
|
||||
albumStorageFailFileIdList: [],
|
||||
albumStorageSucImageNum: number,
|
||||
albumStorageSucVideoNum: number,
|
||||
albumStorageSucFileIdList: [],
|
||||
albumStorageFileNum: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface physicalInfo {
|
||||
id: string,
|
||||
url: string,
|
||||
status: number, // 2 已下载
|
||||
processing: string,
|
||||
localPath: string,
|
||||
width: 0,
|
||||
height: 0,
|
||||
time: number,
|
||||
}
|
||||
|
||||
export interface downloadInfo {
|
||||
status: number,
|
||||
curDownLoadBytes: string,
|
||||
totalFileBytes: string,
|
||||
errorCode: number,
|
||||
}
|
||||
|
||||
export interface uploadInfo {
|
||||
uploadedBytes: string,
|
||||
errorCode: number,
|
||||
svrRrrCode: number,
|
||||
errMsg: string,
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
thumbnailUploadState: number;
|
||||
isSecondHit: boolean,
|
||||
hasModifiedErr: boolean,
|
||||
}
|
||||
|
||||
export interface folderUploadInfo {
|
||||
totalUploadedFileSize: string;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
export interface folderDownloadInfo {
|
||||
totalDownloadedFileSize: string;
|
||||
totalFileSize: string;
|
||||
totalDownloadFileCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
pausedCount: number;
|
||||
cancelCount: number;
|
||||
downloadingCount: number;
|
||||
partialDownloadCount: number;
|
||||
curLevelDownloadedFileCount: number;
|
||||
curLevelUnDownloadedFileCount: number;
|
||||
}
|
||||
|
||||
export interface compressFileFolderInfo {
|
||||
downloadStatus: number;
|
||||
saveFileDirPath: string;
|
||||
totalFileCount: string;
|
||||
totalFileSize: string;
|
||||
}
|
||||
|
||||
export interface albumStorgeInfo {
|
||||
status: number;
|
||||
localIdentifier: string;
|
||||
errorCode: number;
|
||||
timeCost: number;
|
||||
}
|
||||
|
||||
export interface FlashOneFileInfo {
|
||||
fileSetId: string;
|
||||
cliFileId: string; // client?? 或许可以换取url
|
||||
compressedFileFolderId: string;
|
||||
archiveIndex: 0;
|
||||
indexPath: string;
|
||||
isDir: boolean; // 文件或者文件夹!!
|
||||
parentId: string;
|
||||
depth: number; // 1
|
||||
cliFileIndex: number;
|
||||
fileType: number; // 枚举!! 已完成枚举!!
|
||||
name: string;
|
||||
namePinyin: string;
|
||||
isCover: boolean;
|
||||
isCoverOriginal: boolean;
|
||||
fileSize: string;
|
||||
fileCount: number;
|
||||
thumbnail: thumbnailInfo;
|
||||
physical: physicalInfo;
|
||||
srvFileId: string; // service?? 服务器上面的id吗?
|
||||
srvParentFileId: string;
|
||||
svrLastUpdateTimestamp: string;
|
||||
downloadInfo: downloadInfo;
|
||||
saveFilePath: string;
|
||||
search_relative_path: string;
|
||||
disk_relative_path: string;
|
||||
uploadInfo: uploadInfo;
|
||||
status: number;
|
||||
uploadStatus: number; // 3已上传成功
|
||||
downloadStatus: number; // 0未下载
|
||||
folderUploadInfo: folderUploadInfo;
|
||||
folderDownloadInfo: folderDownloadInfo;
|
||||
sha1: string;
|
||||
bookmark: string;
|
||||
compressFileFolderInfo: compressFileFolderInfo;
|
||||
uploadPauseReason: string;
|
||||
downloadPauseReason: string;
|
||||
filePhysicalSize: string;
|
||||
thumbnail_sha1: string | null;
|
||||
thumbnail_size: string | null;
|
||||
needAlbumStorage: boolean;
|
||||
albumStorageInfo: albumStorgeInfo;
|
||||
}
|
||||
|
||||
export interface fileListsInfo {
|
||||
parentId: string,
|
||||
depth: number, // 1
|
||||
fileList: FlashOneFileInfo[],
|
||||
paginationInfo: {};
|
||||
isEnd: boolean,
|
||||
isCache: boolean,
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
seq: number,
|
||||
result: number,
|
||||
errMs: string,
|
||||
fileLists: fileListsInfo[],
|
||||
}
|
||||
|
||||
export interface createFlashTransferResult {
|
||||
fileSetId: string,
|
||||
shareLink: string,
|
||||
expireTime: string,
|
||||
expireLeftTime: string,
|
||||
}
|
||||
export enum UploadSceneType {
|
||||
KUPLOADSCENEUNKNOWN,
|
||||
KUPLOADSCENEFLOATWINDOWRIGHTCLICKMENU,
|
||||
KUPLOADSCENEFLOATWINDOWDRAG,
|
||||
KUPLOADSCENEFLOATWINDOWFILESELECTOR,
|
||||
KUPLOADSCENEFLOATWINDOWSHORTCUTKEYCTRLCV,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTRIGHTCLICKMENU,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTDRAG,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTFILESELECTOR,
|
||||
KUPLOADSCENEH5LAUNCHCLIENTSHORTCUTKEYCTRLCV,
|
||||
KUPLOADSCENEAIODRAG,
|
||||
KUPLOADSCENEAIOFILESELECTOR,
|
||||
KUPLOADSCENEAIOSHORTCUTKEYCTRLCV,
|
||||
}
|
||||
/** 闪传文件集默认有效期(秒):14 天 */
|
||||
export const DEFAULT_FLASH_VALIDITY_SECONDS = 1209600;
|
||||
|
||||
export interface StartFlashTaskRequests {
|
||||
scene: number; // 1 PC-QQ
|
||||
name?: string;
|
||||
uploaders: UploaderInfo[];
|
||||
permission?: { code: number; };
|
||||
coverPath?: string;
|
||||
paths: string[]; // 文件的绝对路径,可以是文件夹
|
||||
excludePaths?: string[];
|
||||
expireLeftTime?: number, // 0
|
||||
validitySeconds: number, // 文件集有效期(秒):14 天 1209600 / 90 天 7776000 / 180 天 15552000,必须传,否则新版内核段错误
|
||||
isNeedDelDeviceInfo: boolean,
|
||||
isNeedDelLocation: boolean,
|
||||
coverOriginalInfos?: {
|
||||
path: string,
|
||||
thumbnailPath: string,
|
||||
}[],
|
||||
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
|
||||
detectPrivacyInfoResult: {
|
||||
exists: boolean,
|
||||
allDetectResults: {};
|
||||
};
|
||||
announcement: {
|
||||
announcementSegments: unknown[],
|
||||
modifyTime: string,
|
||||
avatarUrl: string;
|
||||
};
|
||||
albumFilePathList: unknown[];
|
||||
}
|
||||
export enum BusiScene {
|
||||
KBUSISCENEINVALID,
|
||||
KBUSISCENEFLASHSCENE,
|
||||
}
|
||||
export interface FileListInfoRequests {
|
||||
seq: number, // 0
|
||||
fileSetId: string,
|
||||
isUseCache: boolean,
|
||||
sceneType: BusiScene, // 1
|
||||
reqInfos: {
|
||||
count: number, // 18 ?? 硬编码吧 不懂
|
||||
paginationInfo: {},
|
||||
parentId: string,
|
||||
reqIndexPath: string,
|
||||
reqDepth: number, // 1
|
||||
filterCondition: {
|
||||
fileCategory: number,
|
||||
filterType: number,
|
||||
}, // 0
|
||||
sortConditions: {
|
||||
sortField: number,
|
||||
sortOrder: number,
|
||||
}[],
|
||||
isNeedPhysicalInfoReady: boolean;
|
||||
}[];
|
||||
}
|
||||
export enum DownloadSceneType {
|
||||
KDOWNLOADSCENEUNKNOWN,
|
||||
KDOWNLOADSCENEARKC2C,
|
||||
KDOWNLOADSCENEARKC2CDETAILPAGE,
|
||||
KDOWNLOADSCENEARKGROUP,
|
||||
KDOWNLOADSCENEARKGROUPDETAILPAGE,
|
||||
KDOWNLOADSCENELINKC2C,
|
||||
KDOWNLOADSCENELINKGROUP,
|
||||
KDOWNLOADSCENELINKCHANNEL,
|
||||
KDOWNLOADSCENELINKTEMPCHAT,
|
||||
KDOWNLOADSCENELINKOTHERINQQ,
|
||||
KDOWNLOADSCENESCANQRCODE,
|
||||
KDWONLOADSCENEFLASHTRANSFERCENTERCLIENT,
|
||||
KDWONLOADSCENEFLASHTRANSFERCENTERSCHEMA,
|
||||
}
|
||||
export interface FlashFileSetInfo {
|
||||
fileSetId: string,
|
||||
name: string,
|
||||
namePinyin: string,
|
||||
totalFileCount: number,
|
||||
totalFileSize: number,
|
||||
permission: {},
|
||||
shareInfo: {
|
||||
shareLink: string,
|
||||
extractionCode: string,
|
||||
},
|
||||
cover: {
|
||||
id: string,
|
||||
urls: [
|
||||
{
|
||||
spec: number, // 2
|
||||
url: string;
|
||||
}
|
||||
],
|
||||
localCachePath: string;
|
||||
},
|
||||
uploaders: [
|
||||
{
|
||||
uin: string,
|
||||
nickname: string,
|
||||
uid: string,
|
||||
sendEntrance: string;
|
||||
}
|
||||
],
|
||||
expireLeftTime: number,
|
||||
aiClusteringStatus: {
|
||||
firstClusteringList: [],
|
||||
shouldPull: boolean;
|
||||
},
|
||||
createTime: number,
|
||||
expireTime: number,
|
||||
firstLevelItemCount: 1,
|
||||
svrLastUpdateTimestamp: 0,
|
||||
taskId: string, // 同 fileSetId
|
||||
uploadInfo: {
|
||||
totalUploadedFileSize: number,
|
||||
successCount: number,
|
||||
failedCount: number;
|
||||
},
|
||||
downloadInfo: {
|
||||
totalDownloadedFileSize: 0,
|
||||
totalFileSize: 0,
|
||||
totalDownloadFileCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
cancelCount: 0,
|
||||
status: 0,
|
||||
curLevelDownloadedFileCount: number,
|
||||
curLevelUnDownloadedFileCount: 0;
|
||||
},
|
||||
transferType: number,
|
||||
isLocalCreate: true,
|
||||
status: number, // todo 枚举全部状态
|
||||
uploadStatus: number, // todo 同上
|
||||
uploadPauseReason: 0,
|
||||
downloadStatus: 0,
|
||||
downloadPauseReason: 0,
|
||||
saveFileSetDir: string,
|
||||
uploadSceneType: UploadSceneType,
|
||||
downloadSceneType: DownloadSceneType, // 0 PC-QQ 103 web
|
||||
retryCount: number,
|
||||
isMergeShareUpload: 0,
|
||||
isRemoveDeviceInfo: boolean,
|
||||
isRemoveLocation: boolean;
|
||||
}
|
||||
|
||||
export interface SendStatus {
|
||||
result: number,
|
||||
msg: string,
|
||||
target: {
|
||||
destType: number,
|
||||
destUid: string,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter, GroupManagementSettings } from '../types';
|
||||
|
||||
const MEMBER_INVITE_PRIVILEGE_MASK = 0x06100000;
|
||||
const MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK = 0x1;
|
||||
const MEMBER_CREATE_GROUP_FLAG = 0x8000;
|
||||
const MEMBER_TEMPORARY_SESSION_FLAG = 0x10000;
|
||||
const NEW_MEMBER_RECENT_HISTORY_FLAG = 0x4;
|
||||
|
||||
function mergeMaskedFlag (currentFlag: number, requestedFlag: number, maskValue: number): number {
|
||||
const mask = BigInt(maskValue);
|
||||
return Number((BigInt(currentFlag) & ~mask) | (BigInt(requestedFlag) & mask));
|
||||
}
|
||||
|
||||
export function applyGroupManagementSettings (
|
||||
param: GroupDetailInfoV2Param,
|
||||
settings: GroupManagementSettings
|
||||
): boolean {
|
||||
let changed = false;
|
||||
|
||||
if (settings.memberInvite !== undefined) {
|
||||
const privilegeByPolicy = {
|
||||
disabled: 0x04000000,
|
||||
require_approval: 0,
|
||||
no_approval: 0x00100000,
|
||||
no_approval_under_100: 0x02000000,
|
||||
} satisfies Record<NonNullable<GroupManagementSettings['memberInvite']>, number>;
|
||||
|
||||
param.filter.allowMemberInvite = 1;
|
||||
param.modifyInfo.allowMemberInvite = settings.memberInvite === 'disabled' ? 0 : 1;
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_INVITE_PRIVILEGE_MASK;
|
||||
param.modifyInfo.appPrivilegeFlag |= privilegeByPolicy[settings.memberInvite];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberUploadAlbum !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
|
||||
if (!settings.allowMemberUploadAlbum) param.modifyInfo.appPrivilegeFlag |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberTemporarySession !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_TEMPORARY_SESSION_FLAG;
|
||||
if (!settings.allowMemberTemporarySession) param.modifyInfo.appPrivilegeFlag |= MEMBER_TEMPORARY_SESSION_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberCreateGroup !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_CREATE_GROUP_FLAG;
|
||||
if (!settings.allowMemberCreateGroup) param.modifyInfo.appPrivilegeFlag |= MEMBER_CREATE_GROUP_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.newMembersSeeRecentHistory !== undefined) {
|
||||
param.filter.groupFlagExt4 = 1;
|
||||
param.filter.groupFlagExt4Mask = 1;
|
||||
param.modifyInfo.groupFlagExt4Mask |= NEW_MEMBER_RECENT_HISTORY_FLAG;
|
||||
if (settings.newMembersSeeRecentHistory) param.modifyInfo.groupFlagExt4 |= NEW_MEMBER_RECENT_HISTORY_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function createGroupManagementRequests (
|
||||
groupCode: string,
|
||||
settings: GroupManagementSettings,
|
||||
initialAppPrivilegeFlag = 0,
|
||||
initialGroupFlagExt4 = 0
|
||||
) {
|
||||
const requests: Array<{ setting: string, param: GroupDetailInfoV2Param, operationType: number; }> = [];
|
||||
const requestSettings: Array<{
|
||||
setting: string,
|
||||
value: GroupManagementSettings,
|
||||
operationType: number;
|
||||
}> = [
|
||||
{ setting: 'member_invite', value: { memberInvite: settings.memberInvite }, operationType: 0 },
|
||||
{
|
||||
setting: 'new_members_see_recent_history',
|
||||
value: { newMembersSeeRecentHistory: settings.newMembersSeeRecentHistory },
|
||||
operationType: 0,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_upload_album',
|
||||
value: { allowMemberUploadAlbum: settings.allowMemberUploadAlbum },
|
||||
operationType: 8,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_temporary_session',
|
||||
value: { allowMemberTemporarySession: settings.allowMemberTemporarySession },
|
||||
operationType: 8,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_create_group',
|
||||
value: { allowMemberCreateGroup: settings.allowMemberCreateGroup },
|
||||
operationType: 8,
|
||||
},
|
||||
];
|
||||
|
||||
let currentAppPrivilegeFlag = initialAppPrivilegeFlag;
|
||||
let currentGroupFlagExt4 = initialGroupFlagExt4;
|
||||
for (const requestSetting of requestSettings) {
|
||||
const param = createGroupDetailInfoV2Param(groupCode);
|
||||
if (applyGroupManagementSettings(param, requestSetting.value)) {
|
||||
if (param.filter.appPrivilegeFlag) {
|
||||
const nextAppPrivilegeFlag = mergeMaskedFlag(
|
||||
currentAppPrivilegeFlag,
|
||||
param.modifyInfo.appPrivilegeFlag,
|
||||
param.modifyInfo.appPrivilegeMask
|
||||
);
|
||||
if (requestSetting.operationType === 8 && nextAppPrivilegeFlag === currentAppPrivilegeFlag) continue;
|
||||
currentAppPrivilegeFlag = nextAppPrivilegeFlag;
|
||||
// Linux NTQQ's wrapper builds this field from the current complete privilege value.
|
||||
// Supplying only the targeted bit makes zero-valued clears get acknowledged but ignored.
|
||||
param.modifyInfo.appPrivilegeFlag = currentAppPrivilegeFlag;
|
||||
}
|
||||
if (param.filter.groupFlagExt4) {
|
||||
const nextGroupFlagExt4 = mergeMaskedFlag(
|
||||
currentGroupFlagExt4,
|
||||
param.modifyInfo.groupFlagExt4,
|
||||
param.modifyInfo.groupFlagExt4Mask
|
||||
);
|
||||
if (nextGroupFlagExt4 === currentGroupFlagExt4) continue;
|
||||
currentGroupFlagExt4 = nextGroupFlagExt4;
|
||||
param.modifyInfo.groupFlagExt4 = currentGroupFlagExt4;
|
||||
}
|
||||
requests.push({ setting: requestSetting.setting, param, operationType: requestSetting.operationType });
|
||||
}
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
export function createGroupDetailInfoV2Param (group_code: string): GroupDetailInfoV2Param {
|
||||
return {
|
||||
groupCode: group_code,
|
||||
filter:
|
||||
{
|
||||
noCodeFingerOpenFlag: 0,
|
||||
noFingerOpenFlag: 0,
|
||||
groupName: 0,
|
||||
classExt: 0,
|
||||
classText: 0,
|
||||
fingerMemo: 0,
|
||||
richFingerMemo: 0,
|
||||
tagRecord: 0,
|
||||
groupGeoInfo:
|
||||
{
|
||||
ownerUid: 0,
|
||||
setTime: 0,
|
||||
cityId: 0,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
geoContent: 0,
|
||||
poiId: 0,
|
||||
},
|
||||
groupExtAdminNum: 0,
|
||||
flag: 0,
|
||||
groupMemo: 0,
|
||||
groupAioSkinUrl: 0,
|
||||
groupBoardSkinUrl: 0,
|
||||
groupCoverSkinUrl: 0,
|
||||
groupGrade: 0,
|
||||
activeMemberNum: 0,
|
||||
certificationType: 0,
|
||||
certificationText: 0,
|
||||
groupNewGuideLines:
|
||||
{
|
||||
enabled: 0,
|
||||
content: 0,
|
||||
},
|
||||
groupFace: 0,
|
||||
addOption: 0,
|
||||
shutUpTime: 0,
|
||||
groupTypeFlag: 0,
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0,
|
||||
groupExtOnly:
|
||||
{
|
||||
tribeId: 0,
|
||||
moneyForAddGroup: 0,
|
||||
},
|
||||
groupSecLevel: 0,
|
||||
groupSecLevelInfo: 0,
|
||||
subscriptionUin: 0,
|
||||
subscriptionUid: '',
|
||||
allowMemberInvite: 0,
|
||||
groupQuestion: 0,
|
||||
groupAnswer: 0,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupOpenAppid: 0,
|
||||
rootId: 0,
|
||||
msgLimitFrequency: 0,
|
||||
hlGuildAppid: 0,
|
||||
hlGuildSubType: 0,
|
||||
hlGuildOrgId: 0,
|
||||
groupFlagExt4: 0,
|
||||
groupFlagExt4Mask: 0,
|
||||
groupSchoolInfo: {
|
||||
location: 0,
|
||||
grade: 0,
|
||||
school: 0,
|
||||
},
|
||||
groupCardPrefix:
|
||||
{
|
||||
introduction: 0,
|
||||
rptPrefix: 0,
|
||||
},
|
||||
allianceId: 0,
|
||||
groupFlagPro1: 0,
|
||||
groupFlagPro1Mask: 0,
|
||||
},
|
||||
modifyInfo: {
|
||||
noCodeFingerOpenFlag: 0,
|
||||
noFingerOpenFlag: 0,
|
||||
groupName: '',
|
||||
classExt: 0,
|
||||
classText: '',
|
||||
fingerMemo: '',
|
||||
richFingerMemo: '',
|
||||
tagRecord: [],
|
||||
groupGeoInfo: {
|
||||
ownerUid: '',
|
||||
SetTime: 0,
|
||||
CityId: 0,
|
||||
Longitude: '',
|
||||
Latitude: '',
|
||||
GeoContent: '',
|
||||
poiId: '',
|
||||
},
|
||||
groupExtAdminNum: 0,
|
||||
flag: 0,
|
||||
groupMemo: '',
|
||||
groupAioSkinUrl: '',
|
||||
groupBoardSkinUrl: '',
|
||||
groupCoverSkinUrl: '',
|
||||
groupGrade: 0,
|
||||
activeMemberNum: 0,
|
||||
certificationType: 0,
|
||||
certificationText: '',
|
||||
groupNewGuideLines: {
|
||||
enabled: false,
|
||||
content: '',
|
||||
},
|
||||
groupFace: 0,
|
||||
addOption: 0,
|
||||
shutUpTime: 0,
|
||||
groupTypeFlag: 0,
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0,
|
||||
groupExtOnly: {
|
||||
tribeId: 0,
|
||||
moneyForAddGroup: 0,
|
||||
},
|
||||
groupSecLevel: 0,
|
||||
groupSecLevelInfo: 0,
|
||||
subscriptionUin: '',
|
||||
subscriptionUid: '',
|
||||
allowMemberInvite: 0,
|
||||
groupQuestion: '',
|
||||
groupAnswer: '',
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupOpenAppid: 0,
|
||||
rootId: '',
|
||||
msgLimitFrequency: 0,
|
||||
hlGuildAppid: 0,
|
||||
hlGuildSubType: 0,
|
||||
hlGuildOrgId: 0,
|
||||
groupFlagExt4: 0,
|
||||
groupFlagExt4Mask: 0,
|
||||
groupSchoolInfo: {
|
||||
location: '',
|
||||
grade: 0,
|
||||
school: '',
|
||||
},
|
||||
groupCardPrefix:
|
||||
{
|
||||
introduction: '',
|
||||
rptPrefix: [],
|
||||
},
|
||||
allianceId: '',
|
||||
groupFlagPro1: 0,
|
||||
groupFlagPro1Mask: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createGroupExtInfo (group_code: string): GroupExtInfo {
|
||||
return {
|
||||
groupCode: group_code,
|
||||
resultCode: 0,
|
||||
extInfo: {
|
||||
groupInfoExtSeq: 0,
|
||||
reserve: 0,
|
||||
luckyWordId: '',
|
||||
lightCharNum: 0,
|
||||
luckyWord: '',
|
||||
starId: 0,
|
||||
essentialMsgSwitch: 0,
|
||||
todoSeq: 0,
|
||||
blacklistExpireTime: 0,
|
||||
isLimitGroupRtc: 0,
|
||||
companyId: 0,
|
||||
hasGroupCustomPortrait: 0,
|
||||
bindGuildId: '',
|
||||
groupOwnerId: {
|
||||
memberUin: '',
|
||||
memberUid: '',
|
||||
memberQid: '',
|
||||
},
|
||||
essentialMsgPrivilege: 0,
|
||||
msgEventSeq: '',
|
||||
inviteRobotSwitch: 0,
|
||||
gangUpId: '',
|
||||
qqMusicMedalSwitch: 0,
|
||||
showPlayTogetherSwitch: 0,
|
||||
groupFlagPro1: '',
|
||||
groupBindGuildIds: {
|
||||
guildIds: [],
|
||||
},
|
||||
viewedMsgDisappearTime: '',
|
||||
groupExtFlameData: {
|
||||
switchState: 0,
|
||||
state: 0,
|
||||
dayNums: [],
|
||||
version: 0,
|
||||
updateTime: '',
|
||||
isDisplayDayNum: false,
|
||||
},
|
||||
groupBindGuildSwitch: 0,
|
||||
groupAioBindGuildId: '',
|
||||
groupExcludeGuildIds: {
|
||||
guildIds: [],
|
||||
},
|
||||
fullGroupExpansionSwitch: 0,
|
||||
fullGroupExpansionSeq: '',
|
||||
inviteRobotMemberSwitch: 0,
|
||||
inviteRobotMemberExamine: 0,
|
||||
groupSquareSwitch: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createGroupExtFilter (): GroupExtFilter {
|
||||
return {
|
||||
groupInfoExtSeq: 0,
|
||||
reserve: 0,
|
||||
luckyWordId: 0,
|
||||
lightCharNum: 0,
|
||||
luckyWord: 0,
|
||||
starId: 0,
|
||||
essentialMsgSwitch: 0,
|
||||
todoSeq: 0,
|
||||
blacklistExpireTime: 0,
|
||||
isLimitGroupRtc: 0,
|
||||
companyId: 0,
|
||||
hasGroupCustomPortrait: 0,
|
||||
bindGuildId: 0,
|
||||
groupOwnerId: 0,
|
||||
essentialMsgPrivilege: 0,
|
||||
msgEventSeq: 0,
|
||||
inviteRobotSwitch: 0,
|
||||
gangUpId: 0,
|
||||
qqMusicMedalSwitch: 0,
|
||||
showPlayTogetherSwitch: 0,
|
||||
groupFlagPro1: 0,
|
||||
groupBindGuildIds: 0,
|
||||
viewedMsgDisappearTime: 0,
|
||||
groupExtFlameData: 0,
|
||||
groupBindGuildSwitch: 0,
|
||||
groupAioBindGuildId: 0,
|
||||
groupExcludeGuildIds: 0,
|
||||
fullGroupExpansionSwitch: 0,
|
||||
fullGroupExpansionSeq: 0,
|
||||
inviteRobotMemberSwitch: 0,
|
||||
inviteRobotMemberExamine: 0,
|
||||
groupSquareSwitch: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './group';
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
normalizeQzoneMediaImages,
|
||||
normalizeQzoneRichContent,
|
||||
type QzoneComment,
|
||||
type QzoneFeedReference,
|
||||
} from './qzone-rpc';
|
||||
|
||||
export interface QzoneReplyItem {
|
||||
reply_id: string | null;
|
||||
user_id: string | null;
|
||||
uid: string | null;
|
||||
nickname: string | null;
|
||||
time: number | null;
|
||||
content: string | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
liked: boolean | null;
|
||||
target_user_id: string | null;
|
||||
target_uid: string | null;
|
||||
target_nickname: string | null;
|
||||
reply: QzoneComment;
|
||||
}
|
||||
|
||||
export interface QzoneCommentItem {
|
||||
comment_id: string | null;
|
||||
user_id: string | null;
|
||||
uid: string | null;
|
||||
nickname: string | null;
|
||||
time: number | null;
|
||||
content: string | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
liked: boolean | null;
|
||||
reply_num: number | null;
|
||||
has_more_replies: boolean | null;
|
||||
replies: QzoneReplyItem[];
|
||||
comment: QzoneComment;
|
||||
}
|
||||
|
||||
export interface QzoneCommentPage {
|
||||
feed_ref: QzoneFeedReference;
|
||||
comment_num: number | null;
|
||||
comments: QzoneCommentItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QzoneReplyPage {
|
||||
feed_ref: QzoneFeedReference;
|
||||
comment_id: string;
|
||||
replies: QzoneReplyItem[];
|
||||
reply_num: number | null;
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function identifier (value: unknown): string | null {
|
||||
if (typeof value === 'string') return value.trim().length > 0 && value.length <= 8192 ? value : null;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
function userId (value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
function count (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const result = Number(value);
|
||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||
}
|
||||
|
||||
function nickname (value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function likeState (value: unknown): boolean | null {
|
||||
return value === 1 ? true : value === 0 ? false : null;
|
||||
}
|
||||
|
||||
function author (value: unknown): { user_id: string | null; uid: string | null; nickname: string | null; } {
|
||||
const user = object(value);
|
||||
const uid = user?.['uid'];
|
||||
return {
|
||||
user_id: userId(user?.['uin']),
|
||||
uid: typeof uid === 'string' ? identifier(uid) : null,
|
||||
nickname: nickname(user?.['nick']),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQzoneReply (value: QzoneComment): QzoneReplyItem {
|
||||
const reply = object(value);
|
||||
if (!reply) throw new Error('QQ空间回复数据格式异常');
|
||||
const user = author(reply['user']);
|
||||
const target = author(reply['target_user']);
|
||||
const like = object(reply['like']);
|
||||
return {
|
||||
reply_id: identifier(reply['id']),
|
||||
...user,
|
||||
// The renderer uses time in Unix seconds. create_time is not this wire field.
|
||||
time: count(reply['time']),
|
||||
content: normalizeQzoneRichContent(reply['content']),
|
||||
images: normalizeQzoneMediaImages(reply['media_items']),
|
||||
like_count: count(like?.['count']),
|
||||
liked: likeState(like?.['status']),
|
||||
target_user_id: target.user_id,
|
||||
target_uid: target.uid,
|
||||
target_nickname: target.nickname,
|
||||
reply: value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQzoneComment (value: QzoneComment): QzoneCommentItem {
|
||||
const comment = object(value);
|
||||
if (!comment) throw new Error('QQ空间评论数据格式异常');
|
||||
const user = author(comment['user']);
|
||||
const like = object(comment['like']);
|
||||
const inline = comment['replys'];
|
||||
if (inline !== undefined && inline !== null && !Array.isArray(inline)) throw new Error('QQ空间内联回复列表格式异常');
|
||||
const replies = (inline ?? []) as unknown[];
|
||||
return {
|
||||
comment_id: identifier(comment['id']),
|
||||
...user,
|
||||
time: count(comment['time']),
|
||||
content: normalizeQzoneRichContent(comment['content']),
|
||||
images: normalizeQzoneMediaImages(comment['media_items']),
|
||||
like_count: count(like?.['count']),
|
||||
liked: likeState(like?.['status']),
|
||||
reply_num: count(comment['reply_num']),
|
||||
has_more_replies: typeof comment['has_more_reply'] === 'boolean' ? comment['has_more_reply'] : null,
|
||||
// These are only the inline replies returned on this page, not an exhaustiveness claim.
|
||||
replies: replies.map((reply) => {
|
||||
if (!object(reply)) throw new Error('QQ空间内联回复数据格式异常');
|
||||
return normalizeQzoneReply(reply as QzoneComment);
|
||||
}),
|
||||
comment: value,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
export type QzonePublicObject = Record<string, unknown>;
|
||||
export type QzonePublicContent = string | QzonePublicObject[];
|
||||
export type QzonePublicOperation =
|
||||
| 'capabilities' | 'feeds' | 'detail' | 'comments' | 'replies'
|
||||
| 'upvote' | 'downvote' | 'like' | 'unlike' | 'follow' | 'unfollow'
|
||||
| 'commentLike' | 'commentUnlike' | 'addComment' | 'addReply'
|
||||
| 'deleteComment' | 'deleteReply' | 'forward';
|
||||
|
||||
export interface QzonePublicFeedsPage extends QzonePublicObject {
|
||||
feeds: QzonePublicObject[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface QzonePublicRequestOptions extends QzonePublicObject {
|
||||
feed?: QzonePublicObject;
|
||||
comment?: QzonePublicObject;
|
||||
reply?: QzonePublicObject;
|
||||
content?: QzonePublicContent;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
reason?: string;
|
||||
selfUin?: string;
|
||||
user?: QzonePublicObject;
|
||||
refreshId?: string;
|
||||
lastUnionId?: QzonePublicObject;
|
||||
}
|
||||
|
||||
export interface QzonePublicRoute {
|
||||
service: string;
|
||||
method: string;
|
||||
oidb: string;
|
||||
}
|
||||
|
||||
const READER = 'trpc.qzone.feeds_reader.FeedsReader';
|
||||
const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter';
|
||||
const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter';
|
||||
const RELATION = 'trpc.public_qzone.relation.RelationService';
|
||||
const UPVOTE = 'trpc.public_qzone.interaction.UFOComment';
|
||||
const MAX_OBJECT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
|
||||
function route (service: string, method: string, oidb: string): QzonePublicRoute {
|
||||
return Object.freeze({ service, method, oidb });
|
||||
}
|
||||
|
||||
export const QZONE_PUBLIC_ROUTES: Record<QzonePublicOperation, QzonePublicRoute> = Object.freeze({
|
||||
capabilities: route('trpc.public_qzone.entrance_ctrl.EntranceCtrl', 'BatchEntranceWhitelist', '0x9741_0'),
|
||||
feeds: route(READER, 'GetPublicRecomFeeds', '0x9a38_0'),
|
||||
detail: route(READER, 'GetFeedDetail', '0x97b1_1'),
|
||||
comments: route(READER, 'GetComment', '0x9bfd_1'),
|
||||
replies: route(READER, 'GetReplies', '0x9bfe_1'),
|
||||
upvote: route(UPVOTE, 'Upvote', '0x9d18_1'),
|
||||
downvote: route(UPVOTE, 'Downvote', '0x9d19_1'),
|
||||
like: route(INTERACTION, 'DoLike', '0x9699_1'),
|
||||
unlike: route(INTERACTION, 'UnLike', '0x969c_1'),
|
||||
follow: route(RELATION, 'Follow', '0x9d1a_1'),
|
||||
unfollow: route(RELATION, 'Unfollow', '0x9d1b_1'),
|
||||
commentLike: route(INTERACTION, 'DoLike', '0x9699_1'),
|
||||
commentUnlike: route(INTERACTION, 'UnLike', '0x969c_1'),
|
||||
addComment: route(INTERACTION, 'AddComment', '0x9676_1'),
|
||||
addReply: route(INTERACTION, 'AddReply', '0x9677_1'),
|
||||
deleteComment: route(INTERACTION, 'DeleteComment', '0x9989_0'),
|
||||
deleteReply: route(INTERACTION, 'DeleteReply', '0x998a_0'),
|
||||
forward: route(WRITER, 'ForwardFeed', '0x969a_1'),
|
||||
});
|
||||
|
||||
function object (value: unknown): value is QzonePublicObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function assertOperation (operation: QzonePublicOperation): void {
|
||||
if (!Object.prototype.hasOwnProperty.call(QZONE_PUBLIC_ROUTES, operation)) throw new Error('QQ空间公开接口操作无效');
|
||||
}
|
||||
|
||||
/** Clone JSON without calling toJSON or assigning prototype setters. */
|
||||
function snapshot<T> (value: T, name: string, maxBytes = MAX_OBJECT_BYTES): T {
|
||||
const seen = new Set<object>();
|
||||
let nodes = 0;
|
||||
const maxNodes = 200000;
|
||||
const maxDepth = 64;
|
||||
function copy (input: unknown, depth: number): unknown {
|
||||
if (++nodes > maxNodes || depth > maxDepth) throw new Error(`${name}结构超出限制`);
|
||||
if (input === null || typeof input === 'boolean') return input;
|
||||
if (typeof input === 'string') {
|
||||
if (Buffer.byteLength(input, 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`);
|
||||
return input;
|
||||
}
|
||||
if (typeof input === 'number' && Number.isFinite(input)) return input;
|
||||
if (!Array.isArray(input) && !object(input)) throw new Error(`${name}必须为JSON数据`);
|
||||
if (seen.has(input)) throw new Error(`${name}不能包含循环引用`);
|
||||
seen.add(input);
|
||||
let output: unknown;
|
||||
if (Array.isArray(input)) {
|
||||
const items: unknown[] = [];
|
||||
for (let index = 0; index < input.length; index++) items.push(copy(input[index], depth + 1));
|
||||
output = items;
|
||||
} else {
|
||||
const result: QzonePublicObject = {};
|
||||
for (const key of Object.keys(input)) {
|
||||
const item = input[key];
|
||||
if (item === undefined) continue;
|
||||
Object.defineProperty(result, key, { value: copy(item, depth + 1), enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
output = result;
|
||||
}
|
||||
seen.delete(input);
|
||||
return output;
|
||||
}
|
||||
const result = copy(value, 0) as T;
|
||||
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function jsonObject (value: unknown, name: string): QzonePublicObject {
|
||||
if (!object(value)) throw new Error(`${name}必须为JSON对象`);
|
||||
return snapshot(value, name);
|
||||
}
|
||||
|
||||
function identity (value: unknown, name: string, allowEmpty = false): string {
|
||||
if (typeof value !== 'string' || value.length > MAX_IDENTITY_LENGTH || (!allowEmpty && !value.trim())) {
|
||||
throw new Error(`${name}无效`);
|
||||
}
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 32 || code === 127) throw new Error(`${name}无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uin (value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value)) throw new Error(`${name}无效`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function pageToken (value: unknown, name: string, allowEmpty = false, maxLength = MAX_OBJECT_BYTES): string {
|
||||
if (typeof value !== 'string' || value.length > maxLength || (!allowEmpty && value.length === 0)) throw new Error(`${name}无效`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function unionId (value: unknown): QzonePublicObject {
|
||||
const union = jsonObject(value, 'QQ空间完整动态标识');
|
||||
let identified = false;
|
||||
for (const key of ['uf_key', 'ugc_id', 'feed_id', 'serialized_data']) {
|
||||
if (union[key] === undefined || union[key] === null) continue;
|
||||
const field = identity(union[key], 'QQ空间动态身份字段', true);
|
||||
if (field.trim()) identified = true;
|
||||
}
|
||||
if (!identified) throw new Error('QQ空间动态缺少完整有效身份');
|
||||
if (union['ext_ids'] !== undefined && union['ext_ids'] !== null && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !object(item)))) {
|
||||
throw new Error('QQ空间动态扩展身份无效');
|
||||
}
|
||||
return union;
|
||||
}
|
||||
|
||||
function readComment (value: unknown, name: string): QzonePublicObject {
|
||||
const comment = jsonObject(value, name);
|
||||
const id = comment['id'];
|
||||
if (typeof id === 'string') identity(id, `${name}标识`);
|
||||
else if (typeof id !== 'number' || !Number.isSafeInteger(id) || id < 0) throw new Error(`${name}标识无效`);
|
||||
if (comment['user'] !== undefined && !object(comment['user'])) throw new Error(`${name}用户必须为JSON对象`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
function user (value: unknown, name: string): QzonePublicObject {
|
||||
const result = jsonObject(value, name);
|
||||
const userUin = result['uin'];
|
||||
const userUid = result['uid'];
|
||||
const absentUin = userUin === undefined || userUin === null || userUin === '' || userUin === '0' || userUin === 0;
|
||||
if (!absentUin) uin(userUin, `${name}QQ号`);
|
||||
if (userUid !== undefined && userUid !== null && userUid !== '') identity(userUid, `${name}UID`);
|
||||
if (absentUin && !userUid) throw new Error(`${name}缺少QQ号或UID`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function content (value: unknown): QzonePublicObject[] {
|
||||
if (typeof value === 'string') {
|
||||
if (!value.length) throw new Error('QQ空间评论正文不能为空');
|
||||
return snapshot([{ type: 0, content: value }], 'QQ空间评论正文');
|
||||
}
|
||||
if (!Array.isArray(value) || !value.length) throw new Error('QQ空间评论正文必须为非空文本或RichMsg数组');
|
||||
const items = snapshot(value, 'QQ空间评论正文');
|
||||
for (const item of items) {
|
||||
if (!object(item)) throw new Error('QQ空间RichMsg条目必须为JSON对象');
|
||||
}
|
||||
return items as QzonePublicObject[];
|
||||
}
|
||||
|
||||
export function buildQzonePublicRequest (operation: QzonePublicOperation, options: QzonePublicRequestOptions): QzonePublicObject {
|
||||
assertOperation(operation);
|
||||
if (!object(options)) throw new Error('QQ空间公开接口参数必须为对象');
|
||||
if (operation === 'capabilities') return { uins: [uin(options.selfUin, '当前QQ号')], entrance_types: [1, 15] };
|
||||
if (operation === 'feeds') {
|
||||
const body: QzonePublicObject = { refresh_id: identity(options.refreshId, 'QQ空间刷新标识') };
|
||||
if (options.cursor !== undefined) body['attach_info'] = pageToken(options.cursor, 'QQ空间分页标识', true, 65536);
|
||||
if (options.lastUnionId !== undefined) body['last_client_feed_union_id'] = unionId(options.lastUnionId);
|
||||
return body;
|
||||
}
|
||||
|
||||
const feed = jsonObject(options.feed, 'QQ空间原始Feed');
|
||||
const common = feed['cell_common'];
|
||||
if (!object(common)) throw new Error('QQ空间原始Feed缺少cell_common');
|
||||
const union = unionId(common['union_id']);
|
||||
const body: QzonePublicObject = { union_id: union };
|
||||
if (feed['cell_feed_transfer'] !== undefined) body['cell_feed_transfer'] = snapshot(feed['cell_feed_transfer'], 'QQ空间动态上下文');
|
||||
|
||||
if (operation === 'detail') return { ...body, feed_action_domain_type: 3, from_page: 1 };
|
||||
if (operation === 'comments') return { ...body, attach_info: pageToken(options.cursor, 'QQ空间评论分页标识') };
|
||||
if (operation === 'replies') {
|
||||
const parent = readComment(options.comment, 'QQ空间父评论');
|
||||
const size = options.pageSize === undefined ? 20 : options.pageSize;
|
||||
if (typeof size !== 'number' || !Number.isFinite(size) || size < 1) throw new Error('QQ空间回复page_size必须为不小于1的有限数值');
|
||||
const pointer = options.cursor || parent['reply_attach_info'] || '';
|
||||
return { ...body, cur_comment: parent, comment_attach_info: '', reply_attach_info: pageToken(pointer, 'QQ空间回复分页标识', true), page_size: Math.trunc(size) };
|
||||
}
|
||||
if (operation === 'upvote' || operation === 'downvote') return body;
|
||||
if (operation === 'like' || operation === 'unlike') {
|
||||
return { ...body, like_key: identity(common['cur_like_key'], 'QQ空间动态点赞标识'), like_type: 1 };
|
||||
}
|
||||
if (operation === 'follow' || operation === 'unfollow') {
|
||||
const userInfo = feed['cell_user_info'];
|
||||
const author = user(object(userInfo) ? userInfo['user'] : undefined, 'QQ空间动态作者');
|
||||
const authorUin = typeof author['uin'] === 'string' && /^[1-9]\d{0,19}$/.test(author['uin']) ? author['uin'] : '';
|
||||
const authorUid = typeof author['uid'] === 'string' ? author['uid'].trim() : '';
|
||||
const useUid = (common['domain_type'] === 3 && !!authorUid) || !authorUin;
|
||||
const request: QzonePublicObject = { follow_from_type: 1, ...(useUid ? { target_uid: authorUid } : { target_uin: authorUin }) };
|
||||
if ((useUid && !authorUid) || (!useUid && !authorUin)) throw new Error('QQ空间动态缺少可蹲的作者身份');
|
||||
if (typeof union['uf_key'] === 'string' && union['uf_key']) request['follow_feed_id'] = union['uf_key'];
|
||||
if (feed['cell_feed_transfer']) request['cell_feed_transfer'] = body['cell_feed_transfer'];
|
||||
return request;
|
||||
}
|
||||
if (operation === 'commentLike' || operation === 'commentUnlike') {
|
||||
const parent = readComment(options.comment, 'QQ空间父评论');
|
||||
const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复');
|
||||
const like = target['like'];
|
||||
return { ...body, like_key: identity(object(like) ? like['id'] : undefined, 'QQ空间评论点赞标识'), like_type: options.reply === undefined ? 2 : 3 };
|
||||
}
|
||||
if (operation === 'addComment') return { ...body, comment: { user: user(options.user, '当前用户'), content: content(options.content) } };
|
||||
if (operation === 'addReply') {
|
||||
const parent = readComment(options.comment, 'QQ空间父评论');
|
||||
const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复');
|
||||
return { ...body, comment: parent, reply: { content: content(options.content), target_user: user(target['user'], 'QQ空间回复目标'), user: user(options.user, '当前用户') } };
|
||||
}
|
||||
if (operation === 'deleteComment') return { ...body, comment: readComment(options.comment, 'QQ空间父评论') };
|
||||
if (operation === 'deleteReply') return { ...body, comment: readComment(options.comment, 'QQ空间父评论'), reply: readComment(options.reply, 'QQ空间回复') };
|
||||
if (operation === 'forward') {
|
||||
const reason = options.reason ?? '';
|
||||
if (typeof reason !== 'string') throw new Error('QQ空间转发理由必须为文本');
|
||||
return { ...body, reason: snapshot(reason, 'QQ空间转发理由') };
|
||||
}
|
||||
throw new Error('QQ空间公开接口操作无效');
|
||||
}
|
||||
|
||||
/** Return the business data, preserving unknown fields while checking the observed acknowledgements. */
|
||||
export function parseQzonePublicResponse (value: unknown, operation: QzonePublicOperation): QzonePublicObject {
|
||||
assertOperation(operation);
|
||||
if (object(value) && typeof value['retcode'] === 'number' && Number.isSafeInteger(value['retcode']) && value['retcode'] !== 0) {
|
||||
throw new Error(`QQ空间公开接口拒绝请求 (retcode=${value['retcode']})`);
|
||||
}
|
||||
if (!object(value) || value['retcode'] !== 0 || !object(value['data'])) throw new Error('QQ空间公开接口未返回明确成功响应');
|
||||
const data = snapshot(value['data'], 'QQ空间公开接口响应', MAX_RESPONSE_BYTES);
|
||||
if (operation === 'follow' || operation === 'unfollow') {
|
||||
const code = data['ret_code'];
|
||||
if (code !== undefined && code !== null && code !== 0 && code !== '0') throw new Error('QQ空间蹲操作未确认成功');
|
||||
}
|
||||
const resultFields: Partial<Record<QzonePublicOperation, string>> = {
|
||||
upvote: 'upvote',
|
||||
downvote: 'upvote',
|
||||
like: 'like',
|
||||
unlike: 'like',
|
||||
commentLike: 'like',
|
||||
commentUnlike: 'like',
|
||||
addComment: 'comment',
|
||||
addReply: 'reply',
|
||||
};
|
||||
const field = resultFields[operation];
|
||||
if (field && !data[field]) throw new Error('QQ空间公开互动未返回业务成功结果');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export const QZONE_REFERENCE_MAX_BYTES = 64 * 1024;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
export interface QzoneUnionExtension {
|
||||
ext_id_type?: number;
|
||||
numeric?: number | string;
|
||||
str?: string;
|
||||
unsigned_numeric?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneUnionId {
|
||||
uf_key?: string;
|
||||
ugc_id?: string;
|
||||
feed_id?: string;
|
||||
ext_ids?: QzoneUnionExtension[];
|
||||
serialized_data?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneUser {
|
||||
uin: string;
|
||||
uid?: string;
|
||||
nick?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneRichMsg {
|
||||
type: number;
|
||||
content?: string;
|
||||
media_items?: unknown[];
|
||||
name?: string;
|
||||
uid?: string;
|
||||
uin?: string;
|
||||
url?: string;
|
||||
who?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneRawCommentUser {
|
||||
uin?: string;
|
||||
uid?: string | null;
|
||||
nick?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneComment {
|
||||
id?: string | number;
|
||||
user?: QzoneRawCommentUser;
|
||||
content?: QzoneRichMsg[];
|
||||
replys?: QzoneComment[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeed {
|
||||
cell_common?: {
|
||||
union_id?: QzoneUnionId;
|
||||
time?: number | string;
|
||||
app_id?: number;
|
||||
feed_type?: number;
|
||||
feed_sub_type?: number;
|
||||
feed_biz_type?: number;
|
||||
client_key?: string;
|
||||
cur_like_key?: string;
|
||||
domain_type?: number;
|
||||
operate_mask?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
cell_user_info?: { user?: QzoneUser; [key: string]: unknown; };
|
||||
cell_summary?: { summary?: QzoneRichMsg[]; [key: string]: unknown; };
|
||||
cell_media?: { media_items?: unknown[]; [key: string]: unknown; };
|
||||
cell_comment?: { num?: number | string; comments?: QzoneComment[]; [key: string]: unknown; };
|
||||
cell_like?: { num?: number | string; liked?: boolean; [key: string]: unknown; };
|
||||
cell_feed_transfer?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeedReference {
|
||||
union_id: QzoneUnionId;
|
||||
cell_feed_transfer?: unknown;
|
||||
feed_action_domain_type?: number;
|
||||
like_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QzoneFeedItem {
|
||||
tid: string | null;
|
||||
user_id: string | null;
|
||||
content: string | null;
|
||||
time: number | null;
|
||||
comment_num: number | null;
|
||||
like_num: number | null;
|
||||
images: string[] | null;
|
||||
feed_ref: QzoneFeedReference | null;
|
||||
feed: QzoneFeed;
|
||||
}
|
||||
|
||||
export interface QzoneFeedPage {
|
||||
feeds: QzoneFeedItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface QzoneMsgPage {
|
||||
msglist: QzoneFeedItem[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
seal_off_memory?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QzoneDetail extends QzoneFeedItem {
|
||||
comments: QzoneComment[];
|
||||
comment_has_more?: boolean;
|
||||
comment_next_cursor?: string | null;
|
||||
}
|
||||
|
||||
function record (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function identity (value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_IDENTITY_LENGTH ? value : null;
|
||||
}
|
||||
|
||||
function nonnegativeInteger (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function numericIdentifier (value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
|
||||
}
|
||||
|
||||
export function getQzoneGtk (key: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < key.length; i++) hash += (hash << 5) + key.charCodeAt(i);
|
||||
return hash & 0x7fffffff;
|
||||
}
|
||||
|
||||
export function getQzoneTid (feed: QzoneFeed): string | null {
|
||||
const common = record(feed?.cell_common);
|
||||
const union = record(common?.['union_id']);
|
||||
if (!union) return null;
|
||||
const extensions = union['ext_ids'];
|
||||
if (Array.isArray(extensions)) {
|
||||
const extension = extensions.map(record).find(item => item?.['ext_id_type'] === 5);
|
||||
if (extension) {
|
||||
const tid = identity(extension['str']) ?? numericIdentifier(extension['unsigned_numeric']) ?? numericIdentifier(extension['numeric']);
|
||||
if (tid !== null) return tid;
|
||||
}
|
||||
}
|
||||
// Other feed kinds also have ugc_id; an album ID is not a mood tid.
|
||||
if (common?.['feed_sub_type'] !== 1 && common?.['app_id'] !== 311) return null;
|
||||
return identity(union['ugc_id']);
|
||||
}
|
||||
|
||||
export function normalizeQzoneRichContent (value: unknown): string | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const item of value) {
|
||||
const segment = record(item);
|
||||
if (!segment) return null;
|
||||
switch (segment['type']) {
|
||||
case 0:
|
||||
case 2:
|
||||
case 4:
|
||||
if (typeof segment['content'] !== 'string') return null;
|
||||
parts.push(segment['content']);
|
||||
break;
|
||||
case 1:
|
||||
case 5:
|
||||
if (typeof segment['name'] !== 'string') return null;
|
||||
parts.push(segment['type'] === 1 ? `@${segment['name']}` : segment['name']);
|
||||
break;
|
||||
case 3:
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function imageUrl (value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
const url = value.startsWith('//') ? `https:${value}` : value;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !parsed.username && !parsed.password ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeQzoneMediaImages (value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const images: string[] = [];
|
||||
for (const item of value) {
|
||||
const media = record(item);
|
||||
if (!media) return null;
|
||||
if (media['type'] === 1) continue; // Video covers are not post images.
|
||||
if (media['type'] !== 0) return null;
|
||||
const image = record(media['image']);
|
||||
if (!image) return null;
|
||||
const variants = Array.isArray(image['photo_url']) ? image['photo_url'].map(record) : [];
|
||||
let selected: string | null = null;
|
||||
// Same original-image preference as the H5 client: download, origin, big, current.
|
||||
for (const spec of [6, 0, 1, 5]) {
|
||||
const variant = variants.find(candidate => candidate?.['spec'] === spec);
|
||||
selected = imageUrl(record(variant?.['url'])?.['url']);
|
||||
if (selected !== null) break;
|
||||
}
|
||||
selected ??= imageUrl(record(image['default_url'])?.['url']);
|
||||
if (selected === null) return null;
|
||||
images.push(selected);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function assertJson (value: unknown, depth = 0): void {
|
||||
if (depth > 32) throw new Error('QQ空间动态引用嵌套过深');
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) assertJson(item, depth + 1);
|
||||
return;
|
||||
}
|
||||
const object = record(value);
|
||||
if (!object) throw new Error('QQ空间动态引用必须为JSON数据');
|
||||
for (const item of Object.values(object)) {
|
||||
if (item !== undefined) assertJson(item, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateQzoneFeedReference (value: unknown): QzoneFeedReference {
|
||||
const reference = record(value);
|
||||
const union = record(reference?.['union_id']);
|
||||
if (!reference || !union) throw new Error('QQ空间动态引用缺少union_id');
|
||||
const keys = ['uf_key', 'ugc_id', 'feed_id', 'serialized_data'];
|
||||
let hasIdentity = false;
|
||||
for (const key of keys) {
|
||||
if (union[key] === undefined) continue;
|
||||
const field = union[key];
|
||||
if (typeof field !== 'string' || field.length > MAX_IDENTITY_LENGTH) throw new Error('QQ空间动态标识无效');
|
||||
// Protobuf JSON may include empty defaults alongside an issued identity.
|
||||
if (field.trim().length > 0) hasIdentity = true;
|
||||
}
|
||||
if (!hasIdentity) throw new Error('QQ空间动态引用缺少有效标识');
|
||||
if (union['ext_ids'] !== undefined && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !record(item)))) {
|
||||
throw new Error('QQ空间动态扩展标识无效');
|
||||
}
|
||||
const domain = reference['feed_action_domain_type'];
|
||||
if (domain !== undefined && (typeof domain !== 'number' || !Number.isInteger(domain) || domain < 1 || domain > 3)) {
|
||||
throw new Error('QQ空间动态所属域无效');
|
||||
}
|
||||
if (reference['like_key'] !== undefined && identity(reference['like_key']) === null) throw new Error('QQ空间点赞标识无效');
|
||||
assertJson(reference);
|
||||
let serialized: string;
|
||||
try {
|
||||
const encoded = JSON.stringify(reference);
|
||||
if (typeof encoded !== 'string') throw new Error('Invalid JSON');
|
||||
serialized = encoded;
|
||||
} catch {
|
||||
throw new Error('QQ空间动态引用无法序列化');
|
||||
}
|
||||
if (Buffer.byteLength(serialized, 'utf8') > QZONE_REFERENCE_MAX_BYTES) throw new Error('QQ空间动态引用不能超过64KiB');
|
||||
return reference as unknown as QzoneFeedReference;
|
||||
}
|
||||
|
||||
export function normalizeQzoneFeed (feed: QzoneFeed): QzoneFeedItem {
|
||||
if (!record(feed)) throw new Error('QQ空间动态数据格式无效');
|
||||
const common = record(feed.cell_common);
|
||||
const user = record(record(feed.cell_user_info)?.['user']);
|
||||
let reference: QzoneFeedReference | null = null;
|
||||
if (record(common?.['union_id'])) {
|
||||
const candidate: Record<string, unknown> = { union_id: common?.['union_id'] };
|
||||
if (feed.cell_feed_transfer !== undefined) candidate['cell_feed_transfer'] = feed.cell_feed_transfer;
|
||||
const domain = common?.['domain_type'];
|
||||
if (typeof domain === 'number' && Number.isInteger(domain) && domain >= 1 && domain <= 3) candidate['feed_action_domain_type'] = domain;
|
||||
if (identity(common?.['cur_like_key']) !== null) candidate['like_key'] = common?.['cur_like_key'];
|
||||
try {
|
||||
reference = validateQzoneFeedReference(candidate);
|
||||
} catch {
|
||||
// Preserve the raw feed while declining to issue an unusable action handle.
|
||||
}
|
||||
}
|
||||
return {
|
||||
tid: getQzoneTid(feed),
|
||||
user_id: numericIdentifier(user?.['uin']),
|
||||
content: normalizeQzoneRichContent(record(feed.cell_summary)?.['summary']),
|
||||
time: nonnegativeInteger(common?.['time']),
|
||||
comment_num: nonnegativeInteger(record(feed.cell_comment)?.['num']),
|
||||
like_num: nonnegativeInteger(record(feed.cell_like)?.['num']),
|
||||
images: normalizeQzoneMediaImages(record(feed.cell_media)?.['media_items']),
|
||||
feed_ref: reference,
|
||||
feed,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQzoneText (content: string): QzoneRichMsg[] {
|
||||
if (typeof content !== 'string' || !content.trim()) throw new Error('QQ空间正文不能为空');
|
||||
if (content.length > MAX_TEXT_LENGTH) throw new Error('QQ空间正文不能超过2000字');
|
||||
return [{ content, media_items: [], name: '', type: 0, uid: '', uin: '', url: '', who: 0 }];
|
||||
}
|
||||
|
||||
export function buildQzonePublishRequest (
|
||||
user: QzoneUser,
|
||||
content: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string[],
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
key?: string
|
||||
): { feed: QzoneFeed; from: number; } {
|
||||
if (!user || typeof user.uin !== 'string' || numericIdentifier(user.uin) === null) throw new Error('QQ空间当前账号无效');
|
||||
if (user.uid !== undefined && typeof user.uid !== 'string') throw new Error('QQ空间当前账号UID无效');
|
||||
if (user.nick !== undefined && typeof user.nick !== 'string') throw new Error('QQ空间当前账号昵称无效');
|
||||
const permissions = new Map([[1, 1], [4, 2], [16, 3], [128, 4], [64, 5]]);
|
||||
const flag = permissions.get(ugcRight);
|
||||
if (flag === undefined) throw new Error('ugc_right仅支持1、4、16、64、128');
|
||||
if (targetUins !== undefined && (!Array.isArray(targetUins) || targetUins.some(uin => typeof uin !== 'string' || numericIdentifier(uin) === null))) {
|
||||
throw new Error('target_uins必须为有效QQ号数组');
|
||||
}
|
||||
if ((ugcRight === 16 || ugcRight === 128) && !targetUins?.length) throw new Error('ugc_right为16或128时target_uins不能为空');
|
||||
if (!Number.isSafeInteger(now) || now < 0) throw new Error('QQ空间发表时间无效');
|
||||
const clientKey = key ?? `${user.uin}_${now}_${Math.floor(Math.random() * 1e10)}`;
|
||||
if (identity(clientKey) === null) throw new Error('QQ空间发表请求标识无效');
|
||||
const right: Record<string, unknown> = { flag: [flag] };
|
||||
if (ugcRight === 16) right['white_uins'] = [...targetUins!];
|
||||
if (ugcRight === 128) right['black_uins'] = [...targetUins!];
|
||||
return {
|
||||
feed: {
|
||||
cell_common: {
|
||||
time: now,
|
||||
client_key: clientKey,
|
||||
feed_type: 1,
|
||||
feed_sub_type: 1,
|
||||
union_id: { uf_key: clientKey, ugc_id: clientKey, feed_id: clientKey },
|
||||
feed_biz_type: 0,
|
||||
},
|
||||
cell_user_info: { user: { uin: user.uin, uid: user.uid ?? '', nick: user.nick ?? '' } },
|
||||
cell_summary: { summary: buildQzoneText(content) },
|
||||
cell_permission: { right },
|
||||
},
|
||||
from: 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseQzoneRpcResponse (value: unknown): Record<string, unknown> {
|
||||
const response = record(value);
|
||||
if (!response || response['retcode'] !== 0) {
|
||||
const code = response?.['retcode'];
|
||||
const suffix = typeof code === 'number' && Number.isSafeInteger(code) ? ` (retcode=${code})` : '';
|
||||
throw new Error(`QQ空间请求失败${suffix}`);
|
||||
}
|
||||
const data = record(response['data']);
|
||||
if (!data) throw new Error('QQ空间响应缺少有效data对象');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import JSON5 from 'json5';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export interface SlQzoneFeed {
|
||||
uin: number;
|
||||
nickname: string;
|
||||
time: number;
|
||||
appid: number;
|
||||
key: string;
|
||||
html: string;
|
||||
tid: string | null;
|
||||
content: string | null;
|
||||
content_complete: boolean | null;
|
||||
images: string[] | null;
|
||||
like_count: number | null;
|
||||
comment_count: number | null;
|
||||
}
|
||||
|
||||
export interface SlQzoneFeedPage {
|
||||
feeds: SlQzoneFeed[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface SlQzoneFeedState {
|
||||
begintime: string;
|
||||
dayspac: string;
|
||||
pagenum: string;
|
||||
aisortEndTime: string;
|
||||
aisortOffset: string;
|
||||
aisortBeginTime: string;
|
||||
aisortNextTime: string;
|
||||
icServerTime: string;
|
||||
externparam: string;
|
||||
daylist: string;
|
||||
uinlist: string;
|
||||
}
|
||||
|
||||
export interface SlQzoneParsedFeeds {
|
||||
feeds: SlQzoneFeed[];
|
||||
has_more: boolean;
|
||||
main: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
const MAX_TOKENS = 200000;
|
||||
const MAX_HTML_NODES = 50000;
|
||||
const STATE_NUMBERS = ['begintime', 'dayspac', 'pagenum', 'aisortEndTime', 'aisortOffset', 'aisortBeginTime', 'aisortNextTime', 'icServerTime'] as const;
|
||||
const STATE_STRINGS = ['externparam', 'daylist', 'uinlist'] as const;
|
||||
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
function record (value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function count (value: unknown): number | null {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function scalar (value: unknown, label: string): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value !== 'string') throw new Error(`QQ空间动态${label}格式异常`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function wireNumber (value: unknown, label: string): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
const result = count(value);
|
||||
if (result === null) throw new Error(`QQ空间动态${label}格式异常`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Only adapt data syntax that JSON5 lacks: undefined values and elided array slots. */
|
||||
function json5DataSource (source: string, undefinedMarker: string): string {
|
||||
const chunks: string[] = [];
|
||||
const nesting: Array<{ array: boolean; awaiting: boolean; }> = [];
|
||||
let offset = 0;
|
||||
let tokens = 0;
|
||||
const statusKeys = new Set<string>();
|
||||
const markValue = () => {
|
||||
const parent = nesting[nesting.length - 1];
|
||||
if (parent?.array) parent.awaiting = false;
|
||||
};
|
||||
const nextSignificant = (start: number): number => {
|
||||
let index = start;
|
||||
while (index < source.length) {
|
||||
if (/\s/.test(source[index]!)) index++;
|
||||
else if (source.startsWith('//', index)) {
|
||||
const end = source.indexOf('\n', index + 2);
|
||||
index = end < 0 ? source.length : end + 1;
|
||||
} else if (source.startsWith('/*', index)) {
|
||||
const end = source.indexOf('*/', index + 2);
|
||||
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
|
||||
index = end + 2;
|
||||
} else break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const checkStatusKey = (raw: string, quoted: boolean, end: number) => {
|
||||
if (nesting.length !== 1 || nesting[0]?.array || source[nextSignificant(end)] !== ':') return;
|
||||
let key: unknown = raw;
|
||||
if (quoted) key = JSON5.parse(raw);
|
||||
else if (raw.includes('\\')) key = Object.keys(JSON5.parse(`{${raw}:0}`))[0];
|
||||
if (key !== 'code' && key !== 'subcode') return;
|
||||
if (statusKeys.has(key)) throw new Error('QQ空间动态响应状态字段重复');
|
||||
statusKeys.add(key);
|
||||
};
|
||||
while (offset < source.length) {
|
||||
if (++tokens > MAX_TOKENS) throw new Error('QQ空间动态字面量节点过多');
|
||||
const char = source[offset]!;
|
||||
if (/\s/.test(char)) {
|
||||
const start = offset++;
|
||||
while (offset < source.length && /\s/.test(source[offset]!)) offset++;
|
||||
chunks.push(source.slice(start, offset));
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith('//', offset) || source.startsWith('/*', offset)) {
|
||||
const start = offset;
|
||||
if (source[offset + 1] === '/') {
|
||||
const end = source.indexOf('\n', offset + 2);
|
||||
offset = end < 0 ? source.length : end + 1;
|
||||
} else {
|
||||
const end = source.indexOf('*/', offset + 2);
|
||||
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
|
||||
offset = end + 2;
|
||||
}
|
||||
chunks.push(source.slice(start, offset));
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const start = offset++;
|
||||
let closed = false;
|
||||
while (offset < source.length) {
|
||||
const current = source[offset++]!;
|
||||
if (current === '\\') offset++;
|
||||
else if (current === char) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!closed) throw new Error('QQ空间动态字面量字符串未结束');
|
||||
const literal = source.slice(start, offset);
|
||||
checkStatusKey(literal, true, offset);
|
||||
chunks.push(literal);
|
||||
markValue();
|
||||
continue;
|
||||
}
|
||||
if (char === '[' || char === '{') {
|
||||
markValue();
|
||||
nesting.push({ array: char === '[', awaiting: true });
|
||||
if (nesting.length > MAX_DEPTH) throw new Error('QQ空间动态字面量嵌套过深');
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ']' || char === '}') {
|
||||
const current = nesting.pop();
|
||||
if (!current || current.array !== (char === ']')) throw new Error('QQ空间动态字面量括号不匹配');
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
const current = nesting[nesting.length - 1];
|
||||
if (current?.array) {
|
||||
if (current.awaiting) chunks.push('null');
|
||||
current.awaiting = true;
|
||||
}
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
if (char === ':') {
|
||||
chunks.push(char);
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
const start = offset++;
|
||||
while (offset < source.length && !/[\s[\]{},:'"]/.test(source[offset]!) && !source.startsWith('//', offset) && !source.startsWith('/*', offset)) offset++;
|
||||
const token = source.slice(start, offset);
|
||||
checkStatusKey(token, false, offset);
|
||||
chunks.push(token === 'undefined' && source[nextSignificant(offset)] !== ':' ? JSON.stringify(undefinedMarker) : token);
|
||||
markValue();
|
||||
}
|
||||
if (nesting.length) throw new Error('QQ空间动态字面量括号未结束');
|
||||
return chunks.join('');
|
||||
}
|
||||
|
||||
function parseDataBody (text: string): Record<string, unknown> {
|
||||
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_INPUT_BYTES) throw new Error('QQ空间动态响应过大或无效');
|
||||
let source = text.trim();
|
||||
if (!source.startsWith('{')) {
|
||||
const prefix = /^(?:_preloadCallback|_Callback|callback)\s*\(/.exec(source);
|
||||
const suffix = /\)\s*;?\s*$/.exec(source);
|
||||
if (!prefix || !suffix || suffix.index < prefix[0].length) throw new Error('QQ空间动态回调格式异常');
|
||||
source = source.slice(prefix[0].length, suffix.index).trim();
|
||||
}
|
||||
if (!source.startsWith('{')) throw new Error('QQ空间动态缺少对象响应');
|
||||
let value: unknown;
|
||||
try {
|
||||
// Content-addressed marker keeps optional object fields absent instead of changing undefined into null.
|
||||
const undefinedMarker = `\u0000qz:${createHash('sha256').update(source).digest('hex')}`;
|
||||
value = JSON5.parse(json5DataSource(source, undefinedMarker), (key, item: unknown) => {
|
||||
if (DANGEROUS_KEYS.has(key) || (typeof item === 'number' && !Number.isFinite(item))) throw new Error('Unsupported data');
|
||||
return item === undefinedMarker ? undefined : item;
|
||||
});
|
||||
} catch {
|
||||
throw new Error('QQ空间动态字面量格式异常');
|
||||
}
|
||||
if (!record(value)) throw new Error('QQ空间动态缺少对象响应');
|
||||
return value;
|
||||
}
|
||||
|
||||
interface HtmlElement {
|
||||
tag: string;
|
||||
attributes: Record<string, string>;
|
||||
parent?: HtmlElement;
|
||||
children: Array<HtmlElement | string>;
|
||||
}
|
||||
|
||||
function classes (element: HtmlElement): string[] {
|
||||
return (element.attributes['class'] ?? '').split(/\s+/);
|
||||
}
|
||||
|
||||
const EXCLUDED_CLASSES = new Set(['mod-comments', 'comments-list', 'f-item-passive', 'f-ct-video']);
|
||||
const EXCLUDED_TAGS = new Set(['script', 'style', 'noscript', 'template']);
|
||||
|
||||
function excluded (element: HtmlElement): boolean {
|
||||
return EXCLUDED_TAGS.has(element.tag) || classes(element).some(name => EXCLUDED_CLASSES.has(name));
|
||||
}
|
||||
|
||||
function descendants (root: HtmlElement, prune: boolean): HtmlElement[] {
|
||||
const result: HtmlElement[] = [];
|
||||
const pending: HtmlElement[] = [root];
|
||||
while (pending.length) {
|
||||
const current = pending.pop()!;
|
||||
if (current !== root && prune && excluded(current)) continue;
|
||||
result.push(current);
|
||||
for (let index = current.children.length - 1; index >= 0; index--) {
|
||||
const child = current.children[index];
|
||||
if (typeof child !== 'string' && child) pending.push(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function htmlTree (html: string): HtmlElement {
|
||||
if (Buffer.byteLength(html) > MAX_HTML_BYTES) throw new Error('QQ空间动态HTML过大');
|
||||
const root: HtmlElement = { tag: '#root', attributes: {}, children: [] };
|
||||
const stack = [root];
|
||||
let nodes = 0;
|
||||
const parser = new Parser({
|
||||
onopentag (tag, attributes) {
|
||||
if (++nodes > MAX_HTML_NODES || stack.length > MAX_DEPTH) throw new Error('QQ空间动态HTML结构过大');
|
||||
const parent = stack[stack.length - 1]!;
|
||||
const element: HtmlElement = { tag, attributes: { ...attributes }, parent, children: [] };
|
||||
parent.children.push(element);
|
||||
stack.push(element);
|
||||
},
|
||||
onclosetag (tag) {
|
||||
for (let index = stack.length - 1; index > 0; index--) {
|
||||
if (stack[index]?.tag === tag) {
|
||||
stack.length = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
ontext (text) {
|
||||
if (++nodes > MAX_HTML_NODES) throw new Error('QQ空间动态HTML结构过大');
|
||||
stack[stack.length - 1]!.children.push(text);
|
||||
},
|
||||
}, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
|
||||
parser.end(html);
|
||||
return root;
|
||||
}
|
||||
|
||||
function contentText (element: HtmlElement): string {
|
||||
const pieces: string[] = [];
|
||||
const blocks = new Set(['p', 'div', 'li', 'section', 'article', 'blockquote', 'ul', 'ol']);
|
||||
const visit = (node: HtmlElement | string) => {
|
||||
if (typeof node === 'string') {
|
||||
pieces.push(node.replace(/\s+/g, ' '));
|
||||
return;
|
||||
}
|
||||
if (excluded(node) || node.attributes['data-cmd'] === 'qz_toggle' || classes(node).includes('load_img')) return;
|
||||
if (node.tag === 'br') pieces.push('\n');
|
||||
else if (node.tag === 'img') pieces.push(node.attributes['alt'] ?? '');
|
||||
else {
|
||||
if (blocks.has(node.tag)) pieces.push('\n');
|
||||
for (const child of node.children) visit(child);
|
||||
if (blocks.has(node.tag)) pieces.push('\n');
|
||||
}
|
||||
};
|
||||
visit(element);
|
||||
return pieces.join('').replace(/[^\S\n]*\n[^\S\n]*/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function imageUrl (value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
|
||||
return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectHtml (feed: SlQzoneFeed): void {
|
||||
const document = htmlTree(feed.html);
|
||||
const roots = descendants(document, true).filter(node => classes(node).includes('f-item') && node.attributes['data-key'] === feed.key);
|
||||
if (!feed.key || roots.length !== 1) return;
|
||||
const root = roots[0]!;
|
||||
if (excluded(root)) return;
|
||||
const elements = descendants(root, true);
|
||||
const metadata = elements.find(node => node.attributes['name'] === 'feed_data' && node.attributes['data-fkey'] === feed.key && node.attributes['data-uin'] === String(feed.uin));
|
||||
const tid = metadata?.attributes['data-tid'];
|
||||
if (feed.appid === 311 && tid?.trim() && tid.length <= 8192) feed.tid = tid;
|
||||
const textElements = elements.filter(node => classes(node).includes('f-info'));
|
||||
const full = textElements.find(node => classes(node).includes('qz_info_complete') && contentText(node).length > 0);
|
||||
const selected = full ?? textElements.find(node => !classes(node).includes('qz_info_complete'));
|
||||
if (selected) {
|
||||
feed.content = contentText(selected);
|
||||
feed.content_complete = full !== undefined || !descendants(selected, true).some(node => node.attributes['data-cmd'] === 'qz_toggle');
|
||||
}
|
||||
const pictures = new Set<string>();
|
||||
for (const element of elements) {
|
||||
if (!classes(element).includes('img-item') || element.attributes['data-v_itemid'] !== undefined || element.attributes['data-videotype'] !== undefined) continue;
|
||||
const fullUrl = imageUrl(element.attributes['data-originurl'] ?? element.attributes['data-url3']);
|
||||
if (fullUrl) pictures.add(fullUrl);
|
||||
else {
|
||||
for (const image of descendants(element, true)) {
|
||||
if (image.tag !== 'img') continue;
|
||||
const url = imageUrl(image.attributes['truesrc'] ?? image.attributes['src']);
|
||||
if (url) pictures.add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
feed.images = [...pictures];
|
||||
if (feed.like_count === null) {
|
||||
let card = root;
|
||||
for (let parent = root.parent; parent; parent = parent.parent) {
|
||||
if (classes(parent).includes('f-single')) {
|
||||
card = parent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const button = descendants(card, true).find(node => classes(node).includes('qz_like_btn_v3'));
|
||||
feed.like_count = count(button?.attributes['data-likecnt']);
|
||||
}
|
||||
}
|
||||
|
||||
function moreFlag (value: unknown): boolean {
|
||||
if (value === true || value === 1 || value === '1') return true;
|
||||
if (value === false || value === 0 || value === '0') return false;
|
||||
throw new Error('QQ空间动态缺少明确的分页结束标志');
|
||||
}
|
||||
|
||||
export function parseSlQzoneFeeds (text: string): SlQzoneParsedFeeds {
|
||||
const response = parseDataBody(text);
|
||||
if (response['code'] !== 0 || (response['subcode'] !== undefined && response['subcode'] !== 0)) throw new Error('QQ空间好友动态请求失败');
|
||||
const data = response['data'];
|
||||
if (!record(data) || !Array.isArray(data['data']) || data['data'].length > 1000) throw new Error('QQ空间好友动态列表格式异常');
|
||||
const mainValue = data['main'];
|
||||
if (mainValue !== undefined && !record(mainValue)) throw new Error('QQ空间好友动态分页信息无效');
|
||||
const main = record(mainValue) ? mainValue : null;
|
||||
if (main && main['error'] !== undefined && main['error'] !== '') throw new Error('QQ空间好友动态服务端报告失败');
|
||||
const hasMore = moreFlag(main ? main['hasMoreFeeds'] : data['hasmore']);
|
||||
if (hasMore && !main) throw new Error('QQ空间好友动态缺少续页状态');
|
||||
const feeds: SlQzoneFeed[] = [];
|
||||
for (const value of data['data']) {
|
||||
if (value === null || value === undefined) continue;
|
||||
if (!record(value)) throw new Error('QQ空间好友动态条目格式异常');
|
||||
const key = scalar(value['key'] ?? value['feedskey'], '标识');
|
||||
if (key.length > 8192) throw new Error('QQ空间动态标识过长');
|
||||
const feed: SlQzoneFeed = {
|
||||
uin: wireNumber(value['uin'], '账号'),
|
||||
nickname: scalar(value['nickname'], '昵称'),
|
||||
time: wireNumber(value['abstime'], '时间'),
|
||||
appid: wireNumber(value['appid'], '应用'),
|
||||
key,
|
||||
html: scalar(value['html'], 'HTML'),
|
||||
tid: null,
|
||||
content: null,
|
||||
content_complete: null,
|
||||
images: null,
|
||||
like_count: count(value['likecnt']),
|
||||
comment_count: count(value['commentcnt']),
|
||||
};
|
||||
projectHtml(feed);
|
||||
feeds.push(feed);
|
||||
}
|
||||
return { feeds, has_more: hasMore, main };
|
||||
}
|
||||
|
||||
export function initialSlQzoneFeedState (): SlQzoneFeedState {
|
||||
return {
|
||||
begintime: '0',
|
||||
dayspac: '0',
|
||||
pagenum: '1',
|
||||
aisortEndTime: '0',
|
||||
aisortOffset: '0',
|
||||
aisortBeginTime: '0',
|
||||
aisortNextTime: '0',
|
||||
icServerTime: '0',
|
||||
externparam: '',
|
||||
daylist: '',
|
||||
uinlist: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceSlQzoneFeedState (previous: SlQzoneFeedState, main: Record<string, unknown>): SlQzoneFeedState {
|
||||
const next = { ...previous };
|
||||
for (const field of STATE_NUMBERS) {
|
||||
const value = main[field];
|
||||
if (value === undefined || value === '') continue;
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) ||
|
||||
(typeof value === 'number' && !Number.isSafeInteger(value)) || String(value).length > 32) {
|
||||
throw new Error('QQ空间动态分页数值无效');
|
||||
}
|
||||
const canonical = BigInt(value).toString();
|
||||
if (field === 'pagenum' && canonical === '0') throw new Error('QQ空间动态分页页码无效');
|
||||
next[field] = canonical;
|
||||
}
|
||||
for (const field of STATE_STRINGS) {
|
||||
const value = main[field];
|
||||
if (value === undefined || (value === '' && field !== 'uinlist')) continue;
|
||||
if (typeof value !== 'string' || value.length > 32768) throw new Error('QQ空间动态分页字符串无效');
|
||||
next[field] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function slQzoneFeedPosition (state: SlQzoneFeedState): string {
|
||||
const position: Record<string, string> = {};
|
||||
for (const field of Object.keys(state).sort()) {
|
||||
if (field !== 'icServerTime') position[field] = state[field as keyof SlQzoneFeedState];
|
||||
}
|
||||
return JSON.stringify(position);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
export interface SlQzoneEmotion {
|
||||
tid: string;
|
||||
content: string;
|
||||
time: number;
|
||||
comment_num: number;
|
||||
is_private: boolean;
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export interface SlQzoneMsgList {
|
||||
total: number;
|
||||
/** False means total is the legacy page-length fallback, not a measured account total. */
|
||||
total_known?: boolean;
|
||||
msglist: SlQzoneEmotion[];
|
||||
}
|
||||
|
||||
export interface SlQzoneFlatReply {
|
||||
uin: number;
|
||||
name: string;
|
||||
time: number;
|
||||
content: string;
|
||||
comment_id: string;
|
||||
parent_comment_id: string;
|
||||
parent_comment_uin: number;
|
||||
}
|
||||
|
||||
export interface SlQzoneFlatComment {
|
||||
uin: number;
|
||||
name: string;
|
||||
time: number;
|
||||
content: string;
|
||||
comment_id: string;
|
||||
replies: SlQzoneFlatReply[];
|
||||
}
|
||||
|
||||
export interface SlQzoneMsgDetail {
|
||||
tid: string;
|
||||
content: string;
|
||||
comment_num: number;
|
||||
comments: SlQzoneFlatComment[];
|
||||
}
|
||||
|
||||
export interface SlQzoneCommentResult {
|
||||
comment_id: string;
|
||||
parent_comment_id?: string;
|
||||
parent_comment_uin?: number;
|
||||
}
|
||||
|
||||
export interface SlQzoneCommentOptions {
|
||||
imageUrls?: string[];
|
||||
comment_id?: string;
|
||||
comment_uin?: string;
|
||||
reply_uin?: string;
|
||||
reply_name?: string;
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function requireObject (value: unknown): Record<string, unknown> {
|
||||
const result = object(value);
|
||||
if (!result) throw new Error('QQ空间兼容响应对象格式异常');
|
||||
return result;
|
||||
}
|
||||
|
||||
function responseCodes (response: Record<string, unknown>, requireAcknowledgement = false, requireCode = false): void {
|
||||
let acknowledged = false;
|
||||
for (const field of ['code', 'subcode', 'retcode', 'ret']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
||||
const value = response[field];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value !== 0) {
|
||||
const code = typeof value === 'number' && Number.isSafeInteger(value) ? ` (${field}=${value})` : '';
|
||||
throw new Error(`QQ空间兼容接口拒绝请求${code}`);
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if ((requireAcknowledgement && !acknowledged) || (requireCode && response['code'] !== 0)) {
|
||||
throw new Error('QQ空间兼容响应缺少明确成功状态');
|
||||
}
|
||||
}
|
||||
|
||||
function integer (value: unknown, minimum = 0): number {
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) throw new Error('QQ空间兼容响应数值无效');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error('QQ空间兼容响应数值无效');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requiredText (value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('QQ空间兼容响应文本字段无效');
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasControlCharacter (value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 32 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function id (value: unknown, allowEmpty = false): string {
|
||||
if (value === undefined || value === null) {
|
||||
if (allowEmpty) return '';
|
||||
throw new Error('QQ空间兼容响应缺少标识');
|
||||
}
|
||||
if (typeof value === 'number') return String(integer(value));
|
||||
if (typeof value !== 'string' || value.length > 8192 || hasControlCharacter(value) || (!value.trim() && (!allowEmpty || value !== ''))) {
|
||||
throw new Error('QQ空间兼容响应标识无效');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function array (value: unknown): unknown[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) throw new Error('QQ空间兼容响应列表格式异常');
|
||||
return value;
|
||||
}
|
||||
|
||||
function pictureUrls (value: unknown): string[] {
|
||||
const result: string[] = [];
|
||||
for (const picture of array(value)) {
|
||||
const fields = requireObject(picture);
|
||||
for (const key of ['url3', 'url2', 'url1', 'smallurl']) {
|
||||
const url = fields[key];
|
||||
if (url === undefined || url === null || url === '') continue;
|
||||
if (typeof url !== 'string') throw new Error('QQ空间说说图片地址格式异常');
|
||||
result.push(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function privateFlag (value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value !== 'number' && !(typeof value === 'string' && /^-?\d+$/.test(value))) throw new Error('QQ空间说说私密标记无效');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) throw new Error('QQ空间说说私密标记无效');
|
||||
return parsed !== 0;
|
||||
}
|
||||
|
||||
/** Legacy zero/empty sentinels apply only to absent fields, never malformed supplied values. */
|
||||
export function parseSlQzoneMsgList (response: Record<string, unknown>): SlQzoneMsgList {
|
||||
const fields = requireObject(response);
|
||||
responseCodes(fields);
|
||||
if (!Array.isArray(fields['msglist'])) throw new Error('QQ空间兼容说说列表响应不完整');
|
||||
const msglist: SlQzoneEmotion[] = [];
|
||||
for (const entry of fields['msglist']) {
|
||||
const row = requireObject(entry);
|
||||
msglist.push({
|
||||
tid: id(row['tid'], true),
|
||||
content: row['content'] === undefined || row['content'] === null ? '' : requiredText(row['content']),
|
||||
time: row['created_time'] === undefined || row['created_time'] === null ? 0 : integer(row['created_time']),
|
||||
comment_num: row['cmtnum'] === undefined || row['cmtnum'] === null ? 0 : integer(row['cmtnum']),
|
||||
is_private: privateFlag(row['secret']),
|
||||
images: pictureUrls(row['pic']),
|
||||
});
|
||||
}
|
||||
const totalKnown = fields['total'] !== undefined && fields['total'] !== null;
|
||||
return {
|
||||
total: totalKnown ? integer(fields['total']) : msglist.length,
|
||||
total_known: totalKnown,
|
||||
msglist,
|
||||
};
|
||||
}
|
||||
|
||||
function flatComment (value: unknown): Omit<SlQzoneFlatComment, 'replies'> {
|
||||
const fields = requireObject(value);
|
||||
return {
|
||||
comment_id: id(fields['tid']),
|
||||
uin: integer(fields['uin'], 1),
|
||||
name: requiredText(fields['name']),
|
||||
time: integer(fields['create_time']),
|
||||
content: requiredText(fields['content']),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlQzoneMsgDetail (response: Record<string, unknown>, tid: string): SlQzoneMsgDetail {
|
||||
assertTid(tid);
|
||||
const fields = requireObject(response);
|
||||
responseCodes(fields);
|
||||
if (typeof fields['tid'] !== 'string' || fields['tid'] !== tid) throw new Error('QQ空间兼容详情与请求的说说不匹配');
|
||||
const comments: SlQzoneFlatComment[] = [];
|
||||
for (const value of array(fields['commentlist'])) {
|
||||
const original = requireObject(value);
|
||||
const parent = flatComment(original);
|
||||
const replies: SlQzoneFlatReply[] = [];
|
||||
for (const reply of array(original['list_3'])) {
|
||||
replies.push({ ...flatComment(reply), parent_comment_id: parent.comment_id, parent_comment_uin: parent.uin });
|
||||
}
|
||||
comments.push({ ...parent, replies });
|
||||
}
|
||||
return { tid, content: requiredText(fields['content']), comment_num: integer(fields['cmtnum']), comments };
|
||||
}
|
||||
|
||||
function assertUin (value: unknown, safeNumeric = false): asserts value is string {
|
||||
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value) || (safeNumeric && !Number.isSafeInteger(Number(value)))) {
|
||||
throw new Error('QQ空间兼容账号标识无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertTid (value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > 8192 || hasControlCharacter(value)) throw new Error('QQ空间兼容说说标识无效');
|
||||
}
|
||||
|
||||
export function buildSlQzoneLikeBody (self: string, target: string, tid: string, enable: boolean, abstime: number): string {
|
||||
assertUin(self);
|
||||
assertUin(target);
|
||||
assertTid(tid);
|
||||
if (typeof enable !== 'boolean' || typeof abstime !== 'number' || !Number.isSafeInteger(abstime) || abstime < 0) throw new Error('QQ空间兼容点赞参数无效');
|
||||
const resource = `http://user.qzone.qq.com/${target}/mood/${tid}`;
|
||||
const form = new URLSearchParams();
|
||||
form.set('opuin', self);
|
||||
form.set('fid', tid);
|
||||
form.set('abstime', String(abstime));
|
||||
form.set('unikey', resource);
|
||||
form.set('curkey', resource);
|
||||
for (const [key, value] of Object.entries({ appid: '311', typeid: '0', from: '1', active: '0', fupdate: '1', format: 'json' })) form.set(key, value);
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
function commentOptions (options: SlQzoneCommentOptions | undefined): { options: SlQzoneCommentOptions; reply: boolean; } {
|
||||
if (options !== undefined && !object(options)) throw new Error('QQ空间兼容评论选项无效');
|
||||
const fields: SlQzoneCommentOptions = options ?? {};
|
||||
if (Object.keys(fields).some(key => !['imageUrls', 'comment_id', 'comment_uin', 'reply_uin', 'reply_name'].includes(key))) throw new Error('QQ空间兼容评论选项无效');
|
||||
const reply = fields.comment_id !== undefined || fields.comment_uin !== undefined;
|
||||
if (reply) {
|
||||
assertTid(fields.comment_id);
|
||||
assertUin(fields.comment_uin, true);
|
||||
}
|
||||
const addressed = fields.reply_uin !== undefined || fields.reply_name !== undefined;
|
||||
if (addressed) {
|
||||
if (!reply || typeof fields.reply_name !== 'string' || !fields.reply_name.trim() || fields.reply_name.length > 8192) throw new Error('指定回复目标需要完整父评论与昵称');
|
||||
assertUin(fields.reply_uin, true);
|
||||
}
|
||||
if (fields.imageUrls !== undefined) {
|
||||
if (!Array.isArray(fields.imageUrls)) throw new Error('QQ空间评论图片地址必须为数组');
|
||||
if (reply && fields.imageUrls.length > 0) throw new Error('QQ空间楼中楼回复不支持图片');
|
||||
for (const url of fields.imageUrls) {
|
||||
if (typeof url !== 'string' || !url || url.length > 8192 || url.trim() !== url) throw new Error('QQ空间评论图片地址无效');
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || /[\t\r\n]/.test(url)) throw new Error('Invalid URL');
|
||||
} catch {
|
||||
throw new Error('QQ空间评论图片地址无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { options: fields, reply };
|
||||
}
|
||||
|
||||
function addressedContent (content: string, options: SlQzoneCommentOptions): string {
|
||||
if (options.reply_uin === undefined) return content;
|
||||
const escapes: Record<string, string> = { '%': '%25', ',': '%2C', '}': '%7D' };
|
||||
const name = [...options.reply_name!.trim()].map(character => escapes[character] ?? character).join('');
|
||||
return `@{uin:${options.reply_uin},nick:${name},auto:1} ${content}`;
|
||||
}
|
||||
|
||||
export function buildSlQzoneCommentBody (self: string, target: string, tid: string, content: string, options?: SlQzoneCommentOptions): string {
|
||||
assertUin(self);
|
||||
assertUin(target);
|
||||
assertTid(tid);
|
||||
if (typeof content !== 'string' || content === '') throw new Error('QQ空间兼容评论正文不能为空');
|
||||
const branch = commentOptions(options);
|
||||
const form = new URLSearchParams({ uin: self, hostUin: target, inCharset: 'utf-8', outCharset: 'utf-8' });
|
||||
if (branch.reply) {
|
||||
if (!content.trim()) throw new Error('QQ空间兼容回复正文不能为空白');
|
||||
form.set('hostuin', self);
|
||||
form.set('topicId', `${target}_${tid}`);
|
||||
form.set('commentId', branch.options.comment_id!);
|
||||
form.set('commentUin', branch.options.comment_uin!);
|
||||
form.set('content', addressedContent(content, branch.options));
|
||||
form.set('ref', '');
|
||||
form.set('code_version', '1');
|
||||
form.set('format', 'fs');
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${target}`);
|
||||
} else {
|
||||
form.set('topicId', `${target}_${tid}__1`);
|
||||
form.set('content', content);
|
||||
form.set('richtype', branch.options.imageUrls?.length ? '1' : '');
|
||||
form.set('richval', branch.options.imageUrls?.join('\t') ?? '');
|
||||
for (const [key, value] of Object.entries({ format: 'json', ref: 'feeds', feedsType: '100', private: '0', paramstr: '1', isSignIn: '', plat: 'qzone', source: 'ic', platformid: '52' })) form.set(key, value);
|
||||
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
|
||||
}
|
||||
return form.toString();
|
||||
}
|
||||
|
||||
export function parseSlQzoneComment (response: Record<string, unknown>, options?: SlQzoneCommentOptions): SlQzoneCommentResult {
|
||||
const fields = requireObject(response);
|
||||
const branch = commentOptions(options);
|
||||
responseCodes(fields, true, branch.reply);
|
||||
if (branch.reply) {
|
||||
const result = object(fields['data']);
|
||||
if (!result) throw new Error('QQ空间回复响应缺少新标识,结果未知,请查询后确认,勿自动重试');
|
||||
let replyId: string;
|
||||
try {
|
||||
replyId = id(result['id']);
|
||||
} catch {
|
||||
throw new Error('QQ空间回复响应缺少有效新标识,结果未知,请查询后确认,勿自动重试');
|
||||
}
|
||||
return { comment_id: replyId, parent_comment_id: branch.options.comment_id!, parent_comment_uin: Number(branch.options.comment_uin!) };
|
||||
}
|
||||
// An empty ID is the explicit legacy sentinel after an acknowledged ordinary comment.
|
||||
return { comment_id: id(fields['commentid'] ?? fields['commentId'], true) };
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
export interface QzoneProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface QzoneProfileUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128;
|
||||
|
||||
const SUCCESS_FIELDS = ['code', 'subcode', 'retcode', 'ret'];
|
||||
const MAX_PROFILE_LENGTH = 8192;
|
||||
const MAX_IDENTITY_LENGTH = 8192;
|
||||
|
||||
export class QzoneWebError extends Error {
|
||||
constructor (public readonly field: string, public readonly code: number) {
|
||||
super(`QQ空间接口拒绝请求 (${field}=${code})`);
|
||||
}
|
||||
}
|
||||
|
||||
function object (value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function assertUin (uin: unknown): asserts uin is string {
|
||||
if (typeof uin !== 'string' || !/^[1-9]\d{0,19}$/.test(uin)) throw new Error('QQ空间账号标识无效');
|
||||
}
|
||||
|
||||
function assertIdentity (value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > MAX_IDENTITY_LENGTH) {
|
||||
throw new Error('QQ空间说说标识无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuccess (response: Record<string, unknown>, requireCode: boolean): void {
|
||||
let acknowledged = false;
|
||||
for (const field of SUCCESS_FIELDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
|
||||
const code = response[field];
|
||||
if (typeof code !== 'number' || !Number.isSafeInteger(code) || code !== 0) {
|
||||
// CGI messages may contain account content or tickets. Only include a numeric code.
|
||||
if (typeof code === 'number' && Number.isSafeInteger(code)) throw new QzoneWebError(field, code);
|
||||
throw new Error('QQ空间接口拒绝请求');
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if (!acknowledged || (requireCode && response['code'] !== 0)) throw new Error('QQ空间响应缺少明确成功状态');
|
||||
}
|
||||
|
||||
/** Quote only observed bare name keys with string values, never string contents. */
|
||||
function normalizeNameStringKeys (text: string): string {
|
||||
let result = '';
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
const containers: Array<{ object: boolean; key: boolean; }> = [];
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index]!;
|
||||
if (!quoted) {
|
||||
const container = containers[containers.length - 1];
|
||||
if (container?.object && container.key && character === 'n' && /^name(?=\s*:\s*")/.test(text.slice(index))) {
|
||||
result += '"name"';
|
||||
index += 'name'.length - 1;
|
||||
container.key = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '{') containers.push({ object: true, key: true });
|
||||
else if (character === '[') containers.push({ object: false, key: false });
|
||||
else if (character === '}' || character === ']') containers.pop();
|
||||
else if (character === ',' && container?.object) container.key = true;
|
||||
else if (!/\s/.test(character) && container?.object) container.key = false;
|
||||
}
|
||||
result += character;
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === '\\') escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
} else if (character === '"') {
|
||||
quoted = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rejectDuplicateSuccessFields (text: string): void {
|
||||
const seen = new Set<string>();
|
||||
let depth = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index];
|
||||
if (character === '{' || character === '[') depth++;
|
||||
else if (character === '}' || character === ']') depth--;
|
||||
else if (character === '"') {
|
||||
const start = index;
|
||||
let escaped = false;
|
||||
for (index++; index < text.length; index++) {
|
||||
const current = text[index];
|
||||
if (escaped) escaped = false;
|
||||
else if (current === '\\') escaped = true;
|
||||
else if (current === '"') break;
|
||||
}
|
||||
let next = index + 1;
|
||||
while (/\s/.test(text[next] ?? '') && next < text.length) next++;
|
||||
if (depth !== 1 || text[next] !== ':') continue;
|
||||
const field: unknown = JSON.parse(text.slice(start, index + 1));
|
||||
if (typeof field !== 'string' || !SUCCESS_FIELDS.includes(field)) continue;
|
||||
if (seen.has(field)) throw new Error('QQ空间响应包含重复状态字段');
|
||||
seen.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FormSender's response is a data callback inside one fixed, passive HTML shell.
|
||||
// Consume its observed domain setup as text; never run the returned JavaScript.
|
||||
const FORM_SENDER_HTML = /^(?:<!doctype\s+html>\s*)?<html>\s*<head>\s*<meta\s+http-equiv="content-type"\s+content="text\/html;\s*charset=utf-8"\s*\/?>\s*<\/head>\s*<body>\s*<script\s+type="text\/javascript">\s*([\s\S]*?)\s*<\/script>\s*<\/body>\s*<\/html>$/i;
|
||||
const DOMAIN_SETUP = 'document\\s*\\.\\s*domain\\s*=\\s*"h5\\.qzone\\.qq\\.com"\\s*;\\s*';
|
||||
const CALLBACK_SETUP = 'cb\\s*=\\s*frameElement\\s*\\.\\s*callback\\s*;\\s*';
|
||||
const DOMAIN_TRY_SETUP = new RegExp(
|
||||
'^var\\s+cb\\s*;\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
||||
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
|
||||
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP + '\\}\\s*\\}\\s*'
|
||||
);
|
||||
const DOMAIN_ONLY_SETUP = new RegExp('^' + DOMAIN_SETUP);
|
||||
|
||||
function unwrapFormSenderHtml (text: string): string | undefined {
|
||||
const html = FORM_SENDER_HTML.exec(text);
|
||||
if (!html) return undefined;
|
||||
const script = html[1]!.trim();
|
||||
const setup = DOMAIN_TRY_SETUP.exec(script) ?? DOMAIN_ONLY_SETUP.exec(script);
|
||||
if (!setup) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
const callback = script.slice(setup[0].length).trim();
|
||||
if (!/^frameElement\.callback\s*\(/.test(callback)) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
return callback;
|
||||
}
|
||||
|
||||
/** Parse data only: no script evaluation and no first-brace/last-brace extraction. */
|
||||
export function parseQzoneWebResponse (text: string, requireCode = false): Record<string, unknown> {
|
||||
if (typeof text !== 'string' || text.length > 8 * 1024 * 1024) throw new Error('QQ空间响应格式异常');
|
||||
let payload = text.trim();
|
||||
const formSender = unwrapFormSenderHtml(payload);
|
||||
if (formSender !== undefined) payload = formSender;
|
||||
// Preserve the original single-script and bare callback envelopes as well.
|
||||
const script = /^<script>\s*([\s\S]*?)\s*<\/script>$/i.exec(payload);
|
||||
if (script) {
|
||||
payload = script[1]!.trim();
|
||||
if (!/^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(/.test(payload)) throw new Error('QQ空间响应脚本包裹格式异常');
|
||||
} else if (payload.endsWith('</script>') && payload.startsWith('frameElement.callback')) payload = payload.slice(0, -9).trim();
|
||||
if (!payload.startsWith('{')) {
|
||||
const callback = /^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(\s*([\s\S]*)\s*\)\s*;?\s*$/.exec(payload);
|
||||
if (!callback) throw new Error('QQ空间响应格式异常');
|
||||
payload = callback[1]!.trim();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
payload = normalizeNameStringKeys(payload);
|
||||
rejectDuplicateSuccessFields(payload);
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
throw new Error('QQ空间响应JSON格式异常');
|
||||
}
|
||||
const response = object(parsed);
|
||||
if (!response) throw new Error('QQ空间响应必须是对象');
|
||||
assertSuccess(response, requireCode);
|
||||
return response;
|
||||
}
|
||||
|
||||
export function parseQzoneProfile (response: Record<string, unknown>): QzoneProfile {
|
||||
if (!object(response)) throw new Error('QQ空间资料响应格式异常');
|
||||
assertSuccess(response, true);
|
||||
const data = object(response['data']);
|
||||
if (!data || typeof data['spacename'] !== 'string' || typeof data['desc'] !== 'string') {
|
||||
throw new Error('QQ空间资料响应不完整');
|
||||
}
|
||||
return { name: data['spacename'], description: data['desc'] };
|
||||
}
|
||||
|
||||
export function buildQzoneProfileBody (selfUin: string, profile: QzoneProfileUpdate): string {
|
||||
assertUin(selfUin);
|
||||
const fields = object(profile);
|
||||
if (!fields || Object.keys(fields).some(key => key !== 'name' && key !== 'description')) throw new Error('QQ空间资料参数无效');
|
||||
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
|
||||
let bitmap = 0;
|
||||
for (const [field, wireName, bit] of [['name', 'spacename', 2048], ['description', 'desc', 4096]] as const) {
|
||||
const value = fields[field];
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== 'string' || value.length > MAX_PROFILE_LENGTH) throw new Error('QQ空间名称或说明必须是8192字以内的文本');
|
||||
params.set(wireName, value.replaceAll('\r', ''));
|
||||
bitmap |= bit;
|
||||
}
|
||||
if (bitmap === 0) throw new Error('至少提供空间名称或说明');
|
||||
params.set('mb', String(bitmap));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function normalizeQzoneRight (ugcRight: number, targetUins?: string[]): { right: QzoneUgcRight; targetUins: string[]; } {
|
||||
const rights = [1, 4, 16, 64, 128];
|
||||
if (typeof ugcRight !== 'number' || !rights.includes(ugcRight)) throw new Error('ugc_right仅支持1、4、16、64、128');
|
||||
if (targetUins !== undefined && !Array.isArray(targetUins)) throw new Error('target_uins必须为QQ号数组');
|
||||
const targets: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const uin of targetUins ?? []) {
|
||||
assertUin(uin);
|
||||
if (!seen.has(uin)) {
|
||||
targets.push(uin);
|
||||
seen.add(uin);
|
||||
}
|
||||
}
|
||||
if ((ugcRight === 16 || ugcRight === 128) && targets.length === 0) throw new Error('ugc_right为16或128时target_uins不能为空');
|
||||
return { right: ugcRight as QzoneUgcRight, targetUins: targets };
|
||||
}
|
||||
|
||||
function wireText (value: unknown, fallback: string): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== 'string') throw new Error('QQ空间说说重建字段格式异常');
|
||||
return value;
|
||||
}
|
||||
|
||||
function wireInteger (value: unknown, fallback: string, minimum = 0, allowEmpty = false): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (allowEmpty && value === '') return '';
|
||||
if ((typeof value !== 'number' || !Number.isSafeInteger(value)) && (typeof value !== 'string' || !/^\d+$/.test(value))) {
|
||||
throw new Error('QQ空间说说重建数值无效');
|
||||
}
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error('QQ空间说说重建数值无效');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function firstPresent (first: unknown, second: unknown): unknown {
|
||||
return first === undefined ? second : first;
|
||||
}
|
||||
|
||||
function hasMediaValue (value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
const record = object(value);
|
||||
return record ? Object.keys(record).length > 0 : true;
|
||||
}
|
||||
|
||||
function hasObservedEmptyForwardContent (detail: Record<string, unknown>): boolean {
|
||||
if (detail['rt_tid'] !== '' || detail['rt_uin'] !== 0 || detail['rt_source'] !== 0) return false;
|
||||
const content = object(detail['rt_con']);
|
||||
if (!content) return false;
|
||||
const keys = Object.keys(content);
|
||||
return keys.length === 2 && keys.includes('conlist') && keys.includes('content') &&
|
||||
content['conlist'] === null && content['content'] === '';
|
||||
}
|
||||
|
||||
function assertSupportedMedia (detail: Record<string, unknown>): void {
|
||||
for (const field of [
|
||||
'video', 'videos', 'video_info', 'video_url', 'videoid', 'video_id', 'vid',
|
||||
'music', 'music_info', 'audio', 'voice', 'voice_info',
|
||||
'rt_tid', 'rt_uin', 'rt_con', 'rt_source', 'forward', 'forward_info', 'forward_tid',
|
||||
'origin', 'origin_tid', 'origin_feed', 'cell_origin',
|
||||
]) {
|
||||
// The legacy detail CGI supplies this exact empty object for ordinary text and image posts.
|
||||
if (field === 'rt_con' && hasObservedEmptyForwardContent(detail)) continue;
|
||||
if (hasMediaValue(detail[field])) throw new Error('该说说包含暂不支持完整重建的媒体或转发内容');
|
||||
}
|
||||
for (const field of ['appid', 'app_id']) {
|
||||
if (detail[field] !== undefined && detail[field] !== 311 && detail[field] !== '311') throw new Error('该动态不是可重建的说说');
|
||||
}
|
||||
if (wireText(detail['special_url'], '') !== '') throw new Error('暂不支持修改含特殊分享内容的说说权限');
|
||||
}
|
||||
|
||||
function pictureBo (picture: Record<string, unknown>): string | undefined {
|
||||
for (const key of ['smallurl', 'url1', 'url2', 'url3']) {
|
||||
const value = picture[key];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (typeof value !== 'string') throw new Error('QQ空间图片地址元数据无效');
|
||||
const matches = [...value.matchAll(/[?&]bo=([^&#]*)/g)];
|
||||
if (matches.length > 1) throw new Error('QQ空间图片包含歧义元数据');
|
||||
if (matches.length === 0) continue;
|
||||
try {
|
||||
const decoded = decodeURIComponent(matches[0]![1]!);
|
||||
if (!decoded || /[\t\r\n]/.test(decoded)) throw new Error('Invalid picture metadata');
|
||||
return decoded;
|
||||
} catch {
|
||||
throw new Error('QQ空间图片元数据无法完整重建');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rebuildContent (detail: Record<string, unknown>): string {
|
||||
const conlist = detail['conlist'];
|
||||
if (conlist !== undefined && conlist !== null) {
|
||||
if (!Array.isArray(conlist)) throw new Error('QQ空间说说正文分段格式异常');
|
||||
if (conlist.length > 0) {
|
||||
return conlist.map((value) => {
|
||||
const part = object(value);
|
||||
if (!part || typeof part['con'] !== 'string') throw new Error('QQ空间说说正文分段不完整');
|
||||
return part['con'];
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
if (typeof detail['content'] !== 'string') throw new Error('QQ空间说说缺少完整正文');
|
||||
return detail['content'];
|
||||
}
|
||||
|
||||
/** Permission updates resubmit a whole post, so unsupported or partial data must fail before POST. */
|
||||
export function buildQzoneRightBody (
|
||||
detail: Record<string, unknown>,
|
||||
selfUin: string,
|
||||
tid: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string[]
|
||||
): string {
|
||||
assertUin(selfUin);
|
||||
assertIdentity(tid);
|
||||
const source = object(detail);
|
||||
if (!source || source['tid'] !== tid) throw new Error('QQ空间详情与请求的说说标识不匹配');
|
||||
const owner = source['uin'];
|
||||
if ((typeof owner !== 'string' && !(typeof owner === 'number' && Number.isSafeInteger(owner))) || String(owner) !== selfUin) {
|
||||
throw new Error('只能修改当前账号自己的说说权限');
|
||||
}
|
||||
if (SUCCESS_FIELDS.some(field => Object.prototype.hasOwnProperty.call(source, field))) assertSuccess(source, false);
|
||||
const permission = normalizeQzoneRight(ugcRight, targetUins);
|
||||
assertSupportedMedia(source);
|
||||
const pics = source['pic'] ?? [];
|
||||
if (!Array.isArray(pics)) throw new Error('QQ空间说说图片列表格式异常');
|
||||
const richType = wireInteger(source['richtype'], pics.length ? '1' : '', 0, true);
|
||||
if (!['', '0', '1'].includes(richType)) throw new Error('该说说的媒体类型暂不支持完整重建');
|
||||
if ((pics.length > 0 && richType !== '1') || (pics.length === 0 && richType === '1')) throw new Error('QQ空间说说图片信息不完整');
|
||||
const richValues: string[] = [];
|
||||
const boValues: Array<string | undefined> = [];
|
||||
for (const value of pics) {
|
||||
const picture = object(value);
|
||||
const pictureId = picture?.['pic_id'];
|
||||
if (!picture || typeof pictureId !== 'string' || !pictureId || /[\t\r\n]/.test(pictureId)) throw new Error('QQ空间说说图片标识不完整');
|
||||
const parts = pictureId.split(',');
|
||||
const albumId = parts[1];
|
||||
const lloc = parts[2];
|
||||
if (!albumId?.trim() || !lloc?.trim()) throw new Error('QQ空间说说图片信息无法完整重建');
|
||||
// 22 is the observed legacy image default; existing image dimensions cannot be guessed.
|
||||
const pictureType = wireInteger(firstPresent(picture['pictype'], picture['type']), '22');
|
||||
const originalHeight = firstPresent(picture['height'], picture['b_height']);
|
||||
const originalWidth = firstPresent(picture['width'], picture['b_width']);
|
||||
if (originalHeight === undefined || originalWidth === undefined) throw new Error('QQ空间说说图片尺寸不完整');
|
||||
const height = wireInteger(originalHeight, '', 1);
|
||||
const width = wireInteger(originalWidth, '', 1);
|
||||
richValues.push(`,${albumId},${lloc},${lloc},${pictureType},${height},${width},,0,0`);
|
||||
boValues.push(pictureBo(picture));
|
||||
}
|
||||
if (boValues.some(value => value !== undefined) && boValues.some(value => value === undefined)) throw new Error('QQ空间图片元数据数量不完整');
|
||||
const boGroup = boValues.every(value => value !== undefined) ? boValues.join(',') : '';
|
||||
const originalRichValue = wireText(source['richval'], '');
|
||||
if (richValues.length === 0 && originalRichValue !== '') throw new Error('QQ空间说说包含无法完整重建的媒体数据');
|
||||
const subtype = wireInteger(firstPresent(source['t1_subtype'], source['subrichtype']), richValues.length ? '1' : '', 0, true);
|
||||
// Subtype 2 was observed on plain text, without either rich-type field or pictures.
|
||||
const observedPlainTextSubtype = subtype === '2' && hasObservedEmptyForwardContent(source) &&
|
||||
pics.length === 0 && source['richtype'] === undefined && source['subrichtype'] === undefined;
|
||||
if (!['', '0', '1'].includes(subtype) && !observedPlainTextSubtype) throw new Error('该说说的媒体子类型暂不支持完整重建');
|
||||
const sign = wireInteger(source['to_sign'], '0');
|
||||
if (sign !== '0' && sign !== '1') throw new Error('QQ空间说说同步标记无效');
|
||||
const rightId = wireText(source['ugcright_id'], tid);
|
||||
assertIdentity(rightId);
|
||||
const params = new URLSearchParams({
|
||||
syn_tweet_verson: '1',
|
||||
tid,
|
||||
paramstr: '1',
|
||||
pic_template: wireText(source['pic_template'], ''),
|
||||
richtype: richType,
|
||||
richval: richValues.join('\t'),
|
||||
special_url: '',
|
||||
subrichtype: subtype,
|
||||
pic_bo: boGroup ? `${boGroup}\t${boGroup}` : '',
|
||||
con: rebuildContent(source),
|
||||
feedversion: wireInteger(source['feedversion'], '1', 1),
|
||||
ver: wireInteger(source['ver'], '1', 1),
|
||||
ugc_right: String(permission.right),
|
||||
to_sign: sign,
|
||||
ugcright_id: rightId,
|
||||
hostuin: selfUin,
|
||||
code_version: wireInteger(source['code_version'], '1', 1),
|
||||
format: 'fs',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
|
||||
});
|
||||
if (permission.right === 16 || permission.right === 128) params.set('allow_uins', permission.targetUins.join('|'));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function buildQzoneBanBody (selfUin: string, targetUin: string, enable: boolean): string {
|
||||
assertUin(selfUin);
|
||||
assertUin(targetUin);
|
||||
if (typeof enable !== 'boolean') throw new Error('空间黑名单状态必须是布尔值');
|
||||
return new URLSearchParams({
|
||||
uin: selfUin,
|
||||
act_uin: targetUin,
|
||||
action: enable ? '1' : '2',
|
||||
fupdate: '1',
|
||||
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
|
||||
}).toString();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* QQ空间(Qzone)发说说/上传图片相关的纯数据构造与解析函数
|
||||
* 不发起任何网络请求,方便单独测试
|
||||
*/
|
||||
|
||||
export interface QzoneUploadImageParams {
|
||||
uin: string;
|
||||
skey: string;
|
||||
pskey: string;
|
||||
g_tk: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造上传图片到 QQ 空间的表单请求体 (application/x-www-form-urlencoded)
|
||||
* 对齐 https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image
|
||||
*/
|
||||
export function buildQzoneUploadImageBody (params: QzoneUploadImageParams): string {
|
||||
const { uin, skey, pskey, g_tk, base64 } = params;
|
||||
const backUrls = `http://upbak.photo.qzone.qq.com/cgi-bin/upload/cgi_upload_image,http://119.147.64.75/cgi-bin/upload/cgi_upload_image&url=https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
|
||||
const query = new URLSearchParams({
|
||||
filename: 'filename',
|
||||
uin,
|
||||
skey,
|
||||
zzpaneluin: uin,
|
||||
p_uin: uin,
|
||||
p_skey: pskey,
|
||||
uploadtype: '1',
|
||||
albumtype: '7',
|
||||
exttype: '0',
|
||||
refer: 'shuoshuo',
|
||||
output_type: 'jsonhtml',
|
||||
charset: 'utf-8',
|
||||
output_charset: 'utf-8',
|
||||
upload_hd: '1',
|
||||
hd_width: '2048',
|
||||
hd_height: '10000',
|
||||
hd_quality: '96',
|
||||
backUrls,
|
||||
base64: '1',
|
||||
jsonhtml_callback: 'callback',
|
||||
picfile: base64,
|
||||
qzreferrer: `https://user.qzone.qq.com/${uin}/main`,
|
||||
});
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export interface QzoneUploadImageData {
|
||||
albumid: string;
|
||||
lloc: string;
|
||||
type: string;
|
||||
height: string;
|
||||
width: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface QzoneUploadImageRawResponse {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: QzoneUploadImageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析上传图片接口返回的 `frameElement.callback({...});</script>` 包裹响应
|
||||
*/
|
||||
export function parseQzoneUploadImageResponseText (raw: string): QzoneUploadImageRawResponse {
|
||||
const startTag = 'frameElement.callback';
|
||||
const endTag = '</script>';
|
||||
const startIdx = raw.indexOf(startTag);
|
||||
const endIdx = raw.indexOf(endTag);
|
||||
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
||||
throw new Error('QQ空间图片上传响应格式异常');
|
||||
}
|
||||
const segment = raw.slice(startIdx + startTag.length, endIdx);
|
||||
const jsonStart = segment.indexOf('(');
|
||||
const jsonEnd = segment.lastIndexOf(')');
|
||||
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) {
|
||||
throw new Error('QQ空间图片上传响应解析失败');
|
||||
}
|
||||
const jsonText = segment.slice(jsonStart + 1, jsonEnd);
|
||||
try {
|
||||
return JSON.parse(jsonText) as QzoneUploadImageRawResponse;
|
||||
} catch {
|
||||
throw new Error('QQ空间图片上传响应JSON解析失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据上传图片返回数据拼接 richval 字段
|
||||
* 格式: ,albumid,lloc,sloc,type,height,width,,height,width (sloc 与 lloc 一致)
|
||||
*/
|
||||
export function toQzoneRichval (data: QzoneUploadImageData): string {
|
||||
const { albumid, lloc, type, height, width } = data;
|
||||
return `,${albumid},${lloc},${lloc},${type},${height},${width},,${height},${width}`;
|
||||
}
|
||||
|
||||
export interface QzonePublishParams {
|
||||
hostuin: string;
|
||||
content: string;
|
||||
/** 多张图片使用 \t 拼接后的 richval,无图时不传 */
|
||||
richval?: string;
|
||||
ugcRight: number;
|
||||
/** ugc_right 为 16/128 时必填,多个QQ号使用 | 拼接 */
|
||||
allowUins?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造发表说说的表单请求体 (application/x-www-form-urlencoded)
|
||||
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6
|
||||
*/
|
||||
export function buildQzonePublishBody (params: QzonePublishParams): string {
|
||||
const { hostuin, content, richval, ugcRight, allowUins } = params;
|
||||
const fields: Record<string, string> = {
|
||||
syn_tweet_verson: '1',
|
||||
paramstr: '1',
|
||||
con: content,
|
||||
feedversion: '1',
|
||||
ver: '1',
|
||||
ugc_right: String(ugcRight),
|
||||
to_sign: '0',
|
||||
hostuin,
|
||||
code_version: '1',
|
||||
format: 'json',
|
||||
qzreferrer: `https://user.qzone.qq.com/${hostuin}/main`,
|
||||
};
|
||||
if (richval) {
|
||||
fields['richtype'] = '1';
|
||||
fields['richval'] = richval;
|
||||
}
|
||||
if (allowUins) {
|
||||
fields['allow_uins'] = allowUins;
|
||||
fields['who'] = '1';
|
||||
}
|
||||
return new URLSearchParams(fields).toString();
|
||||
}
|
||||
|
||||
export interface QzonePublishResponse {
|
||||
subcode?: number;
|
||||
code?: number;
|
||||
tid?: string;
|
||||
t1_tid?: string;
|
||||
t1_time?: string | number;
|
||||
t1_ntime?: string | number;
|
||||
now?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export function parseQzonePublishTime (value: unknown): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
const time = (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) ? Number(value) : NaN;
|
||||
if (!Number.isSafeInteger(time) || time < 0) throw new Error('QQ空间发表时间无效,结果需查询确认,勿自动重试');
|
||||
return time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析发表说说接口返回的 JSON, 提取 tid
|
||||
*/
|
||||
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; time: number; } {
|
||||
for (const code of [json.subcode, json.code]) {
|
||||
if (code !== undefined && code !== 0) throw new Error('QQ空间发说说失败,服务端拒绝请求');
|
||||
}
|
||||
const tid = json.t1_tid || json.tid;
|
||||
if (!tid) {
|
||||
throw new Error('QQ空间发说说失败, 未返回tid');
|
||||
}
|
||||
// t1_ntime is Unix seconds; current CGI t1_time is a display label such as "22:08".
|
||||
const displayTime = typeof json.t1_time === 'string' && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(json.t1_time);
|
||||
const time = json.t1_ntime ?? (displayTime ? json.now : json.t1_time ?? json.now);
|
||||
// SL uses zero when no machine-readable creation time is supplied, never the local clock.
|
||||
return { tid, time: parseQzonePublishTime(time) };
|
||||
}
|
||||
|
||||
export interface QzoneDeleteParams {
|
||||
hostuin: string;
|
||||
tid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造删除说说的表单请求体 (application/x-www-form-urlencoded)
|
||||
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6
|
||||
*/
|
||||
export function buildQzoneDeleteBody (params: QzoneDeleteParams): string {
|
||||
const { hostuin, tid } = params;
|
||||
const fields: Record<string, string> = {
|
||||
hostuin,
|
||||
tid,
|
||||
t1_source: '1',
|
||||
code_version: '1',
|
||||
format: 'json',
|
||||
qzreferrer: `https://user.qzone.qq.com/${hostuin}`,
|
||||
};
|
||||
return new URLSearchParams(fields).toString();
|
||||
}
|
||||
|
||||
export interface QzoneDeleteResponse {
|
||||
subcode?: number;
|
||||
code?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析删除说说接口返回的 JSON
|
||||
*/
|
||||
export function parseQzoneDeleteResponse (json: QzoneDeleteResponse): { success: true; } {
|
||||
const subcode = json.subcode ?? json.code;
|
||||
if (subcode !== undefined && subcode !== 0) {
|
||||
throw new Error(json.message || json.msg || `QQ空间删说说失败, subcode=${subcode}`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
export interface ControlReq {
|
||||
appid?: string;
|
||||
asy_upload?: number;
|
||||
biz_req?: BizReq;
|
||||
check_type?: number;
|
||||
checksum?: string;
|
||||
cmd?: string;
|
||||
env?: Env;
|
||||
file_len?: number;
|
||||
model?: number;
|
||||
session?: string;
|
||||
token?: Token;
|
||||
uin?: string;
|
||||
}
|
||||
|
||||
export interface BizReq {
|
||||
iAlbumTypeID: number;
|
||||
iBatchID: number;
|
||||
iBitmap: number;
|
||||
iDistinctUse: number;
|
||||
iNeedFeeds?: number;
|
||||
iPicHight: number;
|
||||
iPicWidth: number;
|
||||
iUploadTime: number;
|
||||
iUploadType: number;
|
||||
iUpPicType: number;
|
||||
iWaterType: number;
|
||||
mapExt?: MapExt;
|
||||
mutliPicInfo?: MutliPicInfo;
|
||||
sAlbumID: string;
|
||||
sAlbumName: string;
|
||||
sPicDesc: string;
|
||||
sPicPath: string;
|
||||
sPicTitle: string;
|
||||
stExtendInfo?: StExtendInfo;
|
||||
// 视频主体上传专用
|
||||
extend_info?: VideoExtendInfo;
|
||||
iFlag?: number;
|
||||
iIsFormatF20?: number;
|
||||
iIsNew?: number;
|
||||
iIsOriginalVideo?: number;
|
||||
iPlayTime?: number;
|
||||
sCoverUrl?: string;
|
||||
sDesc?: string;
|
||||
sTitle?: string;
|
||||
// 视频封面二段上传专用
|
||||
stExternalMapExt?: StExternalMapExt;
|
||||
sExif_CameraMaker?: string;
|
||||
sExif_CameraModel?: string;
|
||||
sExif_Latitude?: string;
|
||||
sExif_LatitudeRef?: string;
|
||||
sExif_Longitude?: string;
|
||||
sExif_LongitudeRef?: string;
|
||||
sExif_Time?: string;
|
||||
}
|
||||
|
||||
// 多图同批上传时描述「本批共几张 / 当前第几张」,相册里据此合并成同一条
|
||||
export interface MutliPicInfo {
|
||||
iBatUploadNum: string;
|
||||
iCurUpload: string;
|
||||
iSuccNum: number;
|
||||
iFailNum: number;
|
||||
}
|
||||
|
||||
// 视频主体上传用(视频不走 stExtendInfo.mapParams,而是这段扩展信息)
|
||||
export interface VideoExtendInfo {
|
||||
video_type: string;
|
||||
domainid: string;
|
||||
photo_num: string;
|
||||
video_num: string;
|
||||
batch_num: string;
|
||||
qun_id: string;
|
||||
}
|
||||
|
||||
// 视频封面二段上传用:声明这是客户端上传的封面,且与图片混合在同一条动态里
|
||||
export interface StExternalMapExt {
|
||||
is_client_upload_cover: string;
|
||||
is_pic_video_mix_feeds: string;
|
||||
}
|
||||
|
||||
export interface MapExt {
|
||||
appid: string;
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export interface StExtendInfo {
|
||||
mapParams: MapParams;
|
||||
}
|
||||
|
||||
export interface MapParams {
|
||||
batch_num: string;
|
||||
photo_num: string;
|
||||
video_num: string;
|
||||
// 视频封面二段上传时用它把封面挂到已上传的视频上
|
||||
vid?: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
deviceInfo: string;
|
||||
refer: string;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
appid: number;
|
||||
data: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
// 一次上传的批次信息:同批的多张图必须共用 batchId,相册里才会显示成同一条。
|
||||
export interface QunAlbumBatch {
|
||||
batchId: number,
|
||||
count: string,
|
||||
index: string
|
||||
}
|
||||
|
||||
let lastQunAlbumBatchId = 0;
|
||||
|
||||
// 批次号取秒级时间戳。同一秒里发起的两次上传要错开,不然会被相册并成同一条
|
||||
export function nextQunAlbumBatchId () {
|
||||
lastQunAlbumBatchId = Math.max(Math.floor(Date.now() / 1000), lastQunAlbumBatchId + 1);
|
||||
return lastQunAlbumBatchId;
|
||||
}
|
||||
|
||||
// control_req 的外层(鉴权、来源、会话),图片、视频、封面都一样
|
||||
function buildQunAlbumControlReq (
|
||||
{ uin, pskey, appid, checksum, check_type, file_len, refer = 'qzone', cmd }: {
|
||||
uin: string,
|
||||
pskey: string,
|
||||
appid: string,
|
||||
checksum: string,
|
||||
check_type: number,
|
||||
file_len: number,
|
||||
refer?: string,
|
||||
cmd?: string
|
||||
},
|
||||
biz_req: BizReq
|
||||
): ControlReq {
|
||||
const req: ControlReq = {
|
||||
uin,
|
||||
token: { type: 4, data: pskey, appid: 5 },
|
||||
appid,
|
||||
checksum,
|
||||
check_type,
|
||||
file_len,
|
||||
env: { refer, deviceInfo: 'h5' },
|
||||
model: 0,
|
||||
biz_req,
|
||||
session: '',
|
||||
asy_upload: 0,
|
||||
};
|
||||
if (cmd) req.cmd = cmd;
|
||||
return req;
|
||||
}
|
||||
|
||||
// biz_req 里三种上传都要带的基础字段
|
||||
function buildQunAlbumBizBase (
|
||||
{ iUploadType, sAlbumName = '', sAlbumID = '', iBatchID = 0, sPicTitle = '' }: {
|
||||
iUploadType: number,
|
||||
sAlbumName?: string,
|
||||
sAlbumID?: string,
|
||||
iBatchID?: number,
|
||||
sPicTitle?: string
|
||||
}
|
||||
): BizReq {
|
||||
return {
|
||||
sPicTitle,
|
||||
sPicDesc: '',
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
iAlbumTypeID: 0,
|
||||
iBitmap: 0,
|
||||
iUploadType,
|
||||
iUpPicType: 0,
|
||||
iBatchID,
|
||||
sPicPath: '',
|
||||
iPicWidth: 0,
|
||||
iPicHight: 0,
|
||||
iWaterType: 0,
|
||||
iDistinctUse: 0,
|
||||
iUploadTime: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
// 进相册的那一步(图片、视频封面)要带的群与批次信息
|
||||
function buildQunAlbumFeedFields (group_id: string, mapParams: MapParams, batchIndex: string) {
|
||||
return {
|
||||
iNeedFeeds: 1,
|
||||
mapExt: { appid: 'qun', userid: group_id },
|
||||
stExtendInfo: { mapParams },
|
||||
mutliPicInfo: {
|
||||
iBatUploadNum: mapParams.batch_num,
|
||||
iCurUpload: batchIndex,
|
||||
iSuccNum: 0,
|
||||
iFailNum: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function qunAlbumControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
pic_md5,
|
||||
img_size,
|
||||
img_name,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
photo_num = '1',
|
||||
video_num = '0',
|
||||
batch_num = '1',
|
||||
batchId = nextQunAlbumBatchId(),
|
||||
batchIndex = '0',
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
pic_md5: string,
|
||||
img_size: number,
|
||||
img_name: string,
|
||||
sAlbumName: string,
|
||||
sAlbumID: string,
|
||||
photo_num?: string,
|
||||
video_num?: string,
|
||||
batch_num?: string,
|
||||
batchId?: number,
|
||||
batchIndex?: string
|
||||
}
|
||||
): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
return {
|
||||
control_req: [buildQunAlbumControlReq(
|
||||
{ uin, pskey, appid: 'qun', checksum: pic_md5, check_type: 0, file_len: img_size, cmd: 'FileUpload' },
|
||||
{
|
||||
...buildQunAlbumBizBase({ iUploadType: 0, sAlbumName, sAlbumID, iBatchID: batchId, sPicTitle: img_name }),
|
||||
...buildQunAlbumFeedFields(group_id, { photo_num, video_num, batch_num }, batchIndex),
|
||||
}
|
||||
)],
|
||||
};
|
||||
}
|
||||
|
||||
export function createStreamUpload (
|
||||
{
|
||||
uin,
|
||||
session,
|
||||
offset,
|
||||
seq,
|
||||
end,
|
||||
slice_size,
|
||||
data,
|
||||
|
||||
}: { uin: string, session: string, offset: number, seq: number, end: number, slice_size: number, data: string }
|
||||
) {
|
||||
return {
|
||||
uin,
|
||||
appid: 'qun',
|
||||
session,
|
||||
offset, // 分片起始位置
|
||||
data, // base64编码数据
|
||||
checksum: '',
|
||||
check_type: 0,
|
||||
retry: 0, // 重试次数
|
||||
seq, // 分片序号
|
||||
end, // 分片结束位置 文件总大小
|
||||
cmd: 'FileUpload',
|
||||
slice_size, // 分片大小16KB 16384
|
||||
biz_req: {
|
||||
iUploadType: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频主体上传的控制体。
|
||||
*
|
||||
* 视频这一步不进相册(相册留空,iBatchID 为 0),只是先把视频传成素材;
|
||||
* 分片响应里拿到 sVid 后,再由封面上传(qunAlbumVideoCoverControl)把视频挂进相册。
|
||||
*/
|
||||
export function qunAlbumVideoControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
video_sha1,
|
||||
video_size,
|
||||
play_time = 0,
|
||||
photo_num = '0',
|
||||
video_num = '1',
|
||||
batch_num = '1',
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
video_sha1: string,
|
||||
video_size: number,
|
||||
play_time?: number,
|
||||
photo_num?: string,
|
||||
video_num?: string,
|
||||
batch_num?: string
|
||||
}): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
return {
|
||||
control_req: [buildQunAlbumControlReq(
|
||||
{ uin, pskey, appid: 'video_qun', checksum: video_sha1, check_type: 1, file_len: video_size, cmd: 'FileUploadVideo' },
|
||||
{
|
||||
...buildQunAlbumBizBase({ iUploadType: 3 }),
|
||||
sTitle: '',
|
||||
sDesc: '',
|
||||
sCoverUrl: '',
|
||||
iFlag: 0,
|
||||
iPlayTime: play_time,
|
||||
iIsNew: 111,
|
||||
iIsOriginalVideo: 0,
|
||||
iIsFormatF20: 0,
|
||||
extend_info: {
|
||||
video_type: '3',
|
||||
domainid: '5',
|
||||
photo_num,
|
||||
video_num,
|
||||
batch_num,
|
||||
qun_id: group_id,
|
||||
},
|
||||
}
|
||||
)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频封面上传的控制体。
|
||||
*
|
||||
* 按图片上传,但 iUploadType 为 2,并用 mapParams.vid 指向已上传的视频。
|
||||
* 相册和批次号只在这一步出现,视频是靠封面进的相册。
|
||||
*/
|
||||
export function qunAlbumVideoCoverControl ({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
cover_md5,
|
||||
cover_size,
|
||||
vid,
|
||||
batchId = nextQunAlbumBatchId(),
|
||||
batchIndex = '0',
|
||||
photo_num = '0',
|
||||
video_num = '1',
|
||||
batch_num = '1',
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
sAlbumName: string,
|
||||
sAlbumID: string,
|
||||
cover_md5: string,
|
||||
cover_size: number,
|
||||
vid: string,
|
||||
batchId?: number,
|
||||
batchIndex?: string,
|
||||
photo_num?: string,
|
||||
video_num?: string,
|
||||
batch_num?: string
|
||||
}): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
return {
|
||||
control_req: [buildQunAlbumControlReq(
|
||||
{ uin, pskey, appid: 'qun', checksum: cover_md5, check_type: 0, file_len: cover_size, refer: 'huodong' },
|
||||
{
|
||||
...buildQunAlbumBizBase({ iUploadType: 2, sAlbumName, sAlbumID, iBatchID: batchId }),
|
||||
...buildQunAlbumFeedFields(group_id, { vid, photo_num, video_num, batch_num }, batchIndex),
|
||||
// 标明封面由客户端上传,且允许和图片出现在同一条动态里
|
||||
stExternalMapExt: {
|
||||
is_client_upload_cover: '1',
|
||||
is_pic_video_mix_feeds: '1',
|
||||
},
|
||||
sExif_CameraMaker: '',
|
||||
sExif_CameraModel: '',
|
||||
sExif_Time: '',
|
||||
sExif_LatitudeRef: '',
|
||||
sExif_Latitude: '',
|
||||
sExif_LongitudeRef: '',
|
||||
sExif_Longitude: '',
|
||||
}
|
||||
)],
|
||||
};
|
||||
}
|
||||
+790
@@ -0,0 +1,790 @@
|
||||
{
|
||||
"9.9.15-28060": {
|
||||
"appid": 537246092,
|
||||
"qua": "V1_WIN_NQ_9.9.15_28060_GW_B"
|
||||
},
|
||||
"9.9.15-28131": {
|
||||
"appid": 537246092,
|
||||
"qua": "V1_WIN_NQ_9.9.15_28131_GW_B"
|
||||
},
|
||||
"3.2.12-28060": {
|
||||
"appid": 537246140,
|
||||
"qua": "V1_LNX_NQ_3.2.12_28060_GW_B"
|
||||
},
|
||||
"3.2.12-28131": {
|
||||
"appid": 537246140,
|
||||
"qua": "V1_LNX_NQ_3.2.12_28131_GW_B"
|
||||
},
|
||||
"6.9.55-28131": {
|
||||
"appid": 537246115,
|
||||
"qua": "V1_MAC_NQ_6.9.55_28131_GW_B"
|
||||
},
|
||||
"9.9.15-28327": {
|
||||
"appid": 537249321,
|
||||
"qua": "V1_WIN_NQ_9.9.15_28327_GW_B"
|
||||
},
|
||||
"3.2.12-28327": {
|
||||
"appid": 537249393,
|
||||
"qua": "V1_LNX_NQ_3.2.12_28327_GW_B"
|
||||
},
|
||||
"9.9.15-28418": {
|
||||
"appid": 537249321,
|
||||
"qua": "V1_WIN_NQ_9.9.15_28418_GW_B"
|
||||
},
|
||||
"3.2.12-28418": {
|
||||
"appid": 537249393,
|
||||
"qua": "V1_LNX_NQ_3.2.12_28418_GW_B"
|
||||
},
|
||||
"6.9.56-28418": {
|
||||
"appid": 537249367,
|
||||
"qua": "V1_MAC_NQ_6.9.56_28418_GW_B"
|
||||
},
|
||||
"9.9.15-28498": {
|
||||
"appid": 537249321,
|
||||
"qua": "V1_WIN_NQ_9.9.15_28498_GW_B"
|
||||
},
|
||||
"3.2.13-28788": {
|
||||
"appid": 537249787,
|
||||
"qua": "V1_LNX_NQ_3.2.13_28788_GW_B"
|
||||
},
|
||||
"9.9.16-28788": {
|
||||
"appid": 537249739,
|
||||
"qua": "V1_WIN_NQ_9.9.16_28788_GW_B"
|
||||
},
|
||||
"9.9.16-28971": {
|
||||
"appid": 537249775,
|
||||
"qua": "V1_WIN_NQ_9.9.16_28971_GW_B"
|
||||
},
|
||||
"3.2.13-28971": {
|
||||
"appid": 537249848,
|
||||
"qua": "V1_LNX_NQ_3.2.13_28971_GW_B"
|
||||
},
|
||||
"6.9.58-28971": {
|
||||
"appid": 537249826,
|
||||
"qua": "V1_MAC_NQ_6.9.58_28971_GW_B"
|
||||
},
|
||||
"9.9.16-29271": {
|
||||
"appid": 537249813,
|
||||
"qua": "V1_WIN_NQ_9.9.16_29271_GW_B"
|
||||
},
|
||||
"3.2.13-29271": {
|
||||
"appid": 537249913,
|
||||
"qua": "V1_LNX_NQ_3.2.13_29271_GW_B"
|
||||
},
|
||||
"6.9.59-29271": {
|
||||
"appid": 537249863,
|
||||
"qua": "V1_MAC_NQ_6.9.59_29271_GW_B"
|
||||
},
|
||||
"9.9.16-29456": {
|
||||
"appid": 537249875,
|
||||
"qua": "V1_WIN_NQ_9.9.16_29456_GW_B"
|
||||
},
|
||||
"3.2.13-29456": {
|
||||
"appid": 537249996,
|
||||
"qua": "V1_LNX_NQ_3.2.13_29456_GW_B"
|
||||
},
|
||||
"6.9.59-29456": {
|
||||
"appid": 537249961,
|
||||
"qua": "V1_MAC_NQ_6.9.59_29456_GW_B"
|
||||
},
|
||||
"9.9.16-29927": {
|
||||
"appid": 537255812,
|
||||
"qua": "V1_WIN_NQ_9.9.16_29927_GW_B"
|
||||
},
|
||||
"3.2.13-29927": {
|
||||
"appid": 537255847,
|
||||
"qua": "V1_LNX_NQ_3.2.13_29927_GW_B"
|
||||
},
|
||||
"6.9.61-29927": {
|
||||
"appid": 537255836,
|
||||
"qua": "V1_MAC_NQ_6.9.61_29927_GW_B"
|
||||
},
|
||||
"9.9.17-30366": {
|
||||
"appid": 537258389,
|
||||
"qua": "V1_WIN_NQ_9.9.17_30366_GW_B"
|
||||
},
|
||||
"3.2.15-30366": {
|
||||
"appid": 537258413,
|
||||
"qua": "V1_LNX_NQ_3.2.15_30366_GW_B"
|
||||
},
|
||||
"6.9.62-30366": {
|
||||
"appid": 537258401,
|
||||
"qua": "V1_MAC_NQ_6.9.62_30366_GW_B"
|
||||
},
|
||||
"9.9.17-30483": {
|
||||
"appid": 537258439,
|
||||
"qua": "V1_WIN_NQ_9.9.17_30483_GW_B"
|
||||
},
|
||||
"6.9.62-30483": {
|
||||
"appid": 537258463,
|
||||
"qua": "V1_MAC_NQ_6.9.62_30483_GW_B"
|
||||
},
|
||||
"3.2.15-30483": {
|
||||
"appid": 537258474,
|
||||
"qua": "V1_LNX_NQ_3.2.15_30483_GW_B"
|
||||
},
|
||||
"9.9.17-30594": {
|
||||
"appid": 537258439,
|
||||
"qua": "V1_WIN_NQ_9.9.17_30594_GW_B"
|
||||
},
|
||||
"6.9.62-30594": {
|
||||
"appid": 537258463,
|
||||
"qua": "V1_MAC_NQ_6.9.62_30594_GW_B"
|
||||
},
|
||||
"3.2.15-30594": {
|
||||
"appid": 537258474,
|
||||
"qua": "V1_LNX_NQ_3.2.15_30594_GW_B"
|
||||
},
|
||||
"9.9.17-30851": {
|
||||
"appid": 537263796,
|
||||
"qua": "V1_WIN_NQ_9.9.17_30851_GW_B"
|
||||
},
|
||||
"3.2.15-30851": {
|
||||
"appid": 537263831,
|
||||
"qua": "V1_LNX_NQ_3.2.15_30851_GW_B"
|
||||
},
|
||||
"6.9.63-30851": {
|
||||
"appid": 537263820,
|
||||
"qua": "V1_MAC_NQ_6.9.63_30851_GW_B"
|
||||
},
|
||||
"9.9.17-30899": {
|
||||
"appid": 537263796,
|
||||
"qua": "V1_WIN_NQ_9.9.17_30899_GW_B"
|
||||
},
|
||||
"3.2.15-30899": {
|
||||
"appid": 537263831,
|
||||
"qua": "V1_LNX_NQ_3.2.15_30899_GW_B"
|
||||
},
|
||||
"6.9.63-30899": {
|
||||
"appid": 537263820,
|
||||
"qua": "V1_MAC_NQ_6.9.63_30899_GW_B"
|
||||
},
|
||||
"9.9.17-31219": {
|
||||
"appid": 537266450,
|
||||
"qua": "V1_WIN_NQ_9.9.17_31219_GW_B"
|
||||
},
|
||||
"9.9.17-31245": {
|
||||
"appid": 537266450,
|
||||
"qua": "V1_WIN_NQ_9.9.17_31245_GW_B"
|
||||
},
|
||||
"3.2.15-31245": {
|
||||
"appid": 537266485,
|
||||
"qua": "V1_LNX_NQ_3.2.15_31245_GW_B"
|
||||
},
|
||||
"6.9.63-31245": {
|
||||
"appid": 537266474,
|
||||
"qua": "V1_MAC_NQ_6.9.63_31245_GW_B"
|
||||
},
|
||||
"3.2.15-31363": {
|
||||
"appid": 537266535,
|
||||
"qua": "V1_LNX_NQ_3.2.15_31363_GW_B"
|
||||
},
|
||||
"6.9.65-31363": {
|
||||
"appid": 537266524,
|
||||
"qua": "V1_MAC_NQ_6.9.65_31363_GW_B"
|
||||
},
|
||||
"9.9.17-31363": {
|
||||
"appid": 537266500,
|
||||
"qua": "V1_WIN_NQ_9.9.17_31363_GW_B"
|
||||
},
|
||||
"3.2.16-32690": {
|
||||
"appid": 537271229,
|
||||
"qua": "V1_LNX_NQ_3.2.16_32690_GW_B"
|
||||
},
|
||||
"9.9.18-32690": {
|
||||
"appid": 537271194,
|
||||
"qua": "V1_WIN_NQ_9.9.18_32690_GW_B"
|
||||
},
|
||||
"6.9.66-32690": {
|
||||
"appid": 537271218,
|
||||
"qua": "V1_MAC_NQ_6.9.66_32690_GW_B"
|
||||
},
|
||||
"3.2.16-32721": {
|
||||
"appid": 537271229,
|
||||
"qua": "V1_LNX_NQ_3.2.16_32721_GW_B"
|
||||
},
|
||||
"9.9.18-32793": {
|
||||
"appid": 537271244,
|
||||
"qua": "V1_WIN_NQ_9.9.18_32793_GW_B"
|
||||
},
|
||||
"3.2.16-32793": {
|
||||
"appid": 537271279,
|
||||
"qua": "V1_LNX_NQ_3.2.16_32793_GW_B"
|
||||
},
|
||||
"3.2.16-32869": {
|
||||
"appid": 537271329,
|
||||
"qua": "V1_LNX_NQ_3.2.16_32869_GW_B"
|
||||
},
|
||||
"9.9.18-32869": {
|
||||
"appid": 537271294,
|
||||
"qua": "V1_WIN_NQ_9.9.18_32869_GW_B"
|
||||
},
|
||||
"3.2.16-33139": {
|
||||
"appid": 537273909,
|
||||
"qua": "V1_LNX_NQ_3.2.16_33139_GW_B"
|
||||
},
|
||||
"9.9.18-33139": {
|
||||
"appid": 537273874,
|
||||
"qua": "V1_WIN_NQ_9.9.18_33139_GW_B"
|
||||
},
|
||||
"9.9.18-33800": {
|
||||
"appid": 537273974,
|
||||
"qua": "V1_WIN_NQ_9.9.18_33800_GW_B"
|
||||
},
|
||||
"3.2.16-33800": {
|
||||
"appid": 537274009,
|
||||
"qua": "V1_LNX_NQ_3.2.16_33800_GW_B"
|
||||
},
|
||||
"9.9.19-34231": {
|
||||
"appid": 537279209,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34231_GW_B"
|
||||
},
|
||||
"3.2.17-34231": {
|
||||
"appid": 537279245,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34231_GW_B"
|
||||
},
|
||||
"9.9.19-34362": {
|
||||
"appid": 537279260,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34362_GW_B"
|
||||
},
|
||||
"3.2.17-34362": {
|
||||
"appid": 537279296,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34362_GW_B"
|
||||
},
|
||||
"9.9.19-34467": {
|
||||
"appid": 537282256,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34467_GW_B"
|
||||
},
|
||||
"3.2.17-34467": {
|
||||
"appid": 537282292,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34467_GW_B"
|
||||
},
|
||||
"9.9.19-34566": {
|
||||
"appid": 537282307,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34566_GW_B"
|
||||
},
|
||||
"3.2.17-34566": {
|
||||
"appid": 537282343,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34566_GW_B"
|
||||
},
|
||||
"3.2.17-34606": {
|
||||
"appid": 537282343,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34606_GW_B"
|
||||
},
|
||||
"9.9.19-34606": {
|
||||
"appid": 537282307,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34606_GW_B"
|
||||
},
|
||||
"9.9.19-34740": {
|
||||
"appid": 537290691,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34740_GW_B"
|
||||
},
|
||||
"3.2.17-34740": {
|
||||
"appid": 537290727,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34740_GW_B"
|
||||
},
|
||||
"9.9.19-34958": {
|
||||
"appid": 537290742,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34958_GW_B"
|
||||
},
|
||||
"3.2.17-35184": {
|
||||
"appid": 537291084,
|
||||
"qua": "V1_LNX_NQ_3.2.17_35184_GW_B"
|
||||
},
|
||||
"9.9.19-35184": {
|
||||
"appid": 537291048,
|
||||
"qua": "V1_WIN_NQ_9.9.19_35184_GW_B"
|
||||
},
|
||||
"3.2.17-35341": {
|
||||
"appid": 537291383,
|
||||
"qua": "V1_LNX_NQ_3.2.17_35341_GW_B"
|
||||
},
|
||||
"9.9.19-35341": {
|
||||
"appid": 537291347,
|
||||
"qua": "V1_WIN_NQ_9.9.19_35341_GW_B"
|
||||
},
|
||||
"9.9.19-35469": {
|
||||
"appid": 537291398,
|
||||
"qua": "V1_WIN_NQ_9.9.19_35469_GW_B"
|
||||
},
|
||||
"3.2.18-35951": {
|
||||
"appid": 537296013,
|
||||
"qua": "V1_LNX_NQ_3.2.18_35951_GW_B"
|
||||
},
|
||||
"9.9.20-35951": {
|
||||
"appid": 537295977,
|
||||
"qua": "V1_WIN_NQ_9.9.20_35951_GW_B"
|
||||
},
|
||||
"3.2.18-36580": {
|
||||
"appid": 537298509,
|
||||
"qua": "V1_LNX_NQ_3.2.18_36580_GW_B"
|
||||
},
|
||||
"9.9.20-36580": {
|
||||
"appid": 537298473,
|
||||
"qua": "V1_WIN_NQ_9.9.20_36580_GW_B"
|
||||
},
|
||||
"9.9.20-37012": {
|
||||
"appid": 537304071,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37012_GW_B"
|
||||
},
|
||||
"3.2.18-37012": {
|
||||
"appid": 537304107,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37012_GW_B"
|
||||
},
|
||||
"3.2.18-37051": {
|
||||
"appid": 537304158,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37051_GW_B"
|
||||
},
|
||||
"9.9.20-37051": {
|
||||
"appid": 537304122,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37051_GW_B"
|
||||
},
|
||||
"9.9.20-37475": {
|
||||
"appid": 537304173,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37475_GW_B"
|
||||
},
|
||||
"3.2.18-37475": {
|
||||
"appid": 537304210,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37475_GW_B"
|
||||
},
|
||||
"9.9.20-37625": {
|
||||
"appid": 537304224,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37625_GW_B"
|
||||
},
|
||||
"3.2.18-37625": {
|
||||
"appid": 537304261,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37625_GW_B"
|
||||
},
|
||||
"9.9.21-38503": {
|
||||
"appid": 537307604,
|
||||
"qua": "V1_WIN_NQ_9.9.21_38503_GW_B"
|
||||
},
|
||||
"3.2.19-38503": {
|
||||
"appid": 537307640,
|
||||
"qua": "V1_LNX_NQ_3.2.19_38503_GW_B"
|
||||
},
|
||||
"3.2.19-38626": {
|
||||
"appid": 537307691,
|
||||
"qua": "V1_LNX_NQ_3.2.19_38626_GW_B"
|
||||
},
|
||||
"9.9.21-38711": {
|
||||
"appid": 537307655,
|
||||
"qua": "V1_WIN_NQ_9.9.21_38626_GW_B"
|
||||
},
|
||||
"9.9.21-38960": {
|
||||
"appid": 537313855,
|
||||
"qua": "V1_WIN_NQ_9.9.21_38960_GW_B"
|
||||
},
|
||||
"3.2.19-38960": {
|
||||
"appid": 537313891,
|
||||
"qua": "V1_LNX_NQ_3.2.19_38960_GW_B"
|
||||
},
|
||||
"3.2.19-39038": {
|
||||
"appid": 537313942,
|
||||
"qua": "V1_LNX_NQ_3.2.19_39038_GW_B"
|
||||
},
|
||||
"9.9.21-39038": {
|
||||
"appid": 537313906,
|
||||
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
|
||||
},
|
||||
"9.9.22-40362": {
|
||||
"appid": 537314212,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40362_GW_B"
|
||||
},
|
||||
"3.2.20-40768": {
|
||||
"appid": 537319840,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40768_GW_B"
|
||||
},
|
||||
"3.2.20-40768-arm64": {
|
||||
"appid": 537319841,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40768_GW_B"
|
||||
},
|
||||
"9.9.22-40768": {
|
||||
"appid": 537319804,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40768_GW_B"
|
||||
},
|
||||
"6.9.82-40768": {
|
||||
"appid": 537319828,
|
||||
"qua": "V1_MAC_NQ_6.9.82_40768_GW_B"
|
||||
},
|
||||
"3.2.20-40824": {
|
||||
"appid": 537319840,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40824_GW_B"
|
||||
},
|
||||
"9.9.22-40824": {
|
||||
"appid": 537319804,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40824_GW_B"
|
||||
},
|
||||
"6.9.82-40824": {
|
||||
"appid": 537319829,
|
||||
"qua": "V1_MAC_NQ_6.9.82_40824_GW_B"
|
||||
},
|
||||
"6.9.82-40990": {
|
||||
"appid": 537319879,
|
||||
"qua": "V1_MAC_NQ_6.9.82_40990_GW_B"
|
||||
},
|
||||
"9.9.22-40990": {
|
||||
"appid": 537319855,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40990_GW_B"
|
||||
},
|
||||
"3.2.20-40990": {
|
||||
"appid": 537319891,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40990_GW_B"
|
||||
},
|
||||
"3.2.20-40990-arm64": {
|
||||
"appid": 537319892,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40990_GW_B"
|
||||
},
|
||||
"9.9.23-41679": {
|
||||
"appid": 537320110,
|
||||
"qua": "V1_WIN_NQ_9.9.23_41679_GW_B"
|
||||
},
|
||||
"6.9.83-41679": {
|
||||
"appid": 537320134,
|
||||
"qua": "V1_MAC_NQ_6.9.83_41679_GW_B"
|
||||
},
|
||||
"9.9.23-41785": {
|
||||
"appid": 537320110,
|
||||
"qua": "V1_WIN_NQ_9.9.23_41785_GW_B"
|
||||
},
|
||||
"6.9.83-41785": {
|
||||
"appid": 537320134,
|
||||
"qua": "V1_MAC_NQ_6.9.83_41785_GW_B"
|
||||
},
|
||||
"9.9.23-41857": {
|
||||
"appid": 537320161,
|
||||
"qua": "V1_WIN_NQ_9.9.23_41857_GW_B"
|
||||
},
|
||||
"3.2.21-41857": {
|
||||
"appid": 537320197,
|
||||
"qua": "V1_LNX_NQ_3.2.21_41857_GW_B"
|
||||
},
|
||||
"3.2.21-41857-arm64": {
|
||||
"appid": 537320198,
|
||||
"qua": "V1_LNX_NQ_3.2.21_41857_GW_B"
|
||||
},
|
||||
"6.9.83-41857": {
|
||||
"appid": 537320185,
|
||||
"qua": "V1_MAC_NQ_6.9.83_41857_GW_B"
|
||||
},
|
||||
"3.2.21-42086": {
|
||||
"appid": 537320248,
|
||||
"qua": "V1_LNX_NQ_3.2.21_42086_GW_B"
|
||||
},
|
||||
"3.2.21-42086-arm64": {
|
||||
"appid": 537320249,
|
||||
"qua": "V1_LNX_NQ_3.2.21_42086_GW_B"
|
||||
},
|
||||
"9.9.23-42086": {
|
||||
"appid": 537320212,
|
||||
"qua": "V1_WIN_NQ_9.9.23_42086_GW_B"
|
||||
},
|
||||
"6.9.85-42086": {
|
||||
"appid": 537320236,
|
||||
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
|
||||
},
|
||||
"9.9.23-42430": {
|
||||
"appid": 537320212,
|
||||
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
|
||||
},
|
||||
"9.9.25-42744": {
|
||||
"appid": 537328470,
|
||||
"qua": "V1_WIN_NQ_9.9.25_42744_GW_B"
|
||||
},
|
||||
"6.9.86-42744": {
|
||||
"appid": 537328494,
|
||||
"qua": "V1_MAC_NQ_6.9.86_42744_GW_B"
|
||||
},
|
||||
"9.9.25-42905": {
|
||||
"appid": 537328521,
|
||||
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
|
||||
},
|
||||
"6.9.86-42905": {
|
||||
"appid": 537328545,
|
||||
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
|
||||
},
|
||||
"3.2.22-42941": {
|
||||
"appid": 537328659,
|
||||
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
|
||||
},
|
||||
"3.2.22-42941-arm64": {
|
||||
"appid": 537328660,
|
||||
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
|
||||
},
|
||||
"9.9.25-42941": {
|
||||
"appid": 537328623,
|
||||
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
|
||||
},
|
||||
"6.9.86-42941": {
|
||||
"appid": 537328647,
|
||||
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
|
||||
},
|
||||
"9.9.26-44175": {
|
||||
"appid": 537336450,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
|
||||
},
|
||||
"9.9.26-44343": {
|
||||
"appid": 537336603,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44343_GW_B"
|
||||
},
|
||||
"3.2.23-44343": {
|
||||
"appid": 537336639,
|
||||
"qua": "V1_LNX_NQ_3.2.23_44343_GW_B"
|
||||
},
|
||||
"3.2.23-44343-arm64": {
|
||||
"appid": 537336640,
|
||||
"qua": "V1_LNX_NQ_3.2.23_44343_GW_B"
|
||||
},
|
||||
"6.9.87-44343": {
|
||||
"appid": 537336627,
|
||||
"qua": "V1_MAC_NQ_6.9.87_44343_GW_B"
|
||||
},
|
||||
"9.9.26-44498": {
|
||||
"appid": 537337416,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
|
||||
},
|
||||
"9.9.26-44725": {
|
||||
"appid": 537337569,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||
},
|
||||
"9.9.27-45627": {
|
||||
"appid": 537340060,
|
||||
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
|
||||
},
|
||||
"6.9.88-44725": {
|
||||
"appid": 537337593,
|
||||
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
|
||||
},
|
||||
"3.2.25-45758": {
|
||||
"appid": 537340249,
|
||||
"qua": "V1_LNX_NQ_3.2.25_45758_GW_B"
|
||||
},
|
||||
"3.2.25-45758-arm64": {
|
||||
"appid": 537340250,
|
||||
"qua": "V1_LNX_NQ_3.2.25_45758_GW_B"
|
||||
},
|
||||
"6.9.89-45758": {
|
||||
"appid": 537340237,
|
||||
"qua": "V1_MAC_NQ_6.9.89_45758_GW_B"
|
||||
},
|
||||
"9.9.27-45758": {
|
||||
"appid": 537340213,
|
||||
"qua": "V1_WIN_NQ_9.9.27_45758_GW_B"
|
||||
},
|
||||
"3.2.26-46494": {
|
||||
"appid": 537345891,
|
||||
"qua": "V1_LNX_NQ_3.2.26_46494_GW_B"
|
||||
},
|
||||
"3.2.26-46494-arm64": {
|
||||
"appid": 537345892,
|
||||
"qua": "V1_LNX_NQ_3.2.26_46494_GW_B"
|
||||
},
|
||||
"9.9.28-46494": {
|
||||
"appid": 537345855,
|
||||
"qua": "V1_WIN_NQ_9.9.28_46494_GW_B"
|
||||
},
|
||||
"6.9.90-46494": {
|
||||
"appid": 537345879,
|
||||
"qua": "V1_MAC_NQ_6.9.90_46494_GW_B"
|
||||
},
|
||||
"3.2.26-46928": {
|
||||
"appid": 537345993,
|
||||
"qua": "V1_LNX_NQ_3.2.26_46928_GW_B"
|
||||
},
|
||||
"3.2.26-46928-arm64": {
|
||||
"appid": 537345994,
|
||||
"qua": "V1_LNX_NQ_3.2.26_46928_GW_B"
|
||||
},
|
||||
"6.9.91-46928": {
|
||||
"appid": 537345981,
|
||||
"qua": "V1_MAC_NQ_6.9.91_46928_GW_B"
|
||||
},
|
||||
"9.9.28-46928": {
|
||||
"appid": 537345957,
|
||||
"qua": "V1_WIN_NQ_9.9.28_46928_GW_B"
|
||||
},
|
||||
"3.2.27-47256": {
|
||||
"appid": 537346857,
|
||||
"qua": "V1_LNX_NQ_3.2.27_47256_GW_B"
|
||||
},
|
||||
"3.2.27-47354": {
|
||||
"appid": 537346908,
|
||||
"qua": "V1_LNX_NQ_3.2.27_47354_GW_B"
|
||||
},
|
||||
"9.9.29-47354": {
|
||||
"appid": 537346872,
|
||||
"qua": "V1_WIN_NQ_9.9.29_47354_GW_B"
|
||||
},
|
||||
"6.9.93-47354": {
|
||||
"appid": 537346896,
|
||||
"qua": "V1_MAC_NQ_6.9.93_47354_GW_B"
|
||||
},
|
||||
"9.9.30-48517": {
|
||||
"appid": 537352474,
|
||||
"qua": "V1_WIN_NQ_9.9.30_48517_GW_B"
|
||||
},
|
||||
"3.2.28-48517": {
|
||||
"appid": 537352510,
|
||||
"qua": "V1_LNX_NQ_3.2.28_48517_GW_B"
|
||||
},
|
||||
"3.2.28-48517-arm64": {
|
||||
"appid": 537352511,
|
||||
"qua": "V1_LNX_NQ_3.2.28_48517_GW_B"
|
||||
},
|
||||
"6.9.95-48517": {
|
||||
"appid": 537352498,
|
||||
"qua": "V1_MAC_NQ_6.9.95_48517_GW_B"
|
||||
},
|
||||
"9.9.30-48762": {
|
||||
"appid": 537352525,
|
||||
"qua": "V1_WIN_NQ_9.9.30_48762_GW_B"
|
||||
},
|
||||
"9.9.31-49599": {
|
||||
"appid": 537355779,
|
||||
"qua": "V1_WIN_NQ_9.9.31_49599_GW_B"
|
||||
},
|
||||
"9.9.31-49738": {
|
||||
"appid": 537355830,
|
||||
"qua": "V1_WIN_NQ_9.9.31_49738_GW_B"
|
||||
},
|
||||
"3.2.29-49738": {
|
||||
"appid": 537355866,
|
||||
"qua": "V1_LNX_NQ_3.2.29_49738_GW_B"
|
||||
},
|
||||
"3.2.29-49738-arm64": {
|
||||
"appid": 537355867,
|
||||
"qua": "V1_LNX_NQ_3.2.29_49738_GW_B"
|
||||
},
|
||||
"6.9.96-49738": {
|
||||
"appid": 537355854,
|
||||
"qua": "V1_MAC_NQ_6.9.96_49738_GW_B"
|
||||
},
|
||||
"9.9.32-50828": {
|
||||
"appid": 537358739,
|
||||
"qua": "V1_WIN_NQ_9.9.32_50828_GW_B"
|
||||
},
|
||||
"3.2.30-50828": {
|
||||
"appid": 537358775,
|
||||
"qua": "V1_LNX_NQ_3.2.30_50828_GW_B"
|
||||
},
|
||||
"3.2.30-50828-arm64": {
|
||||
"appid": 537358776,
|
||||
"qua": "V1_LNX_NQ_3.2.30_50828_GW_B"
|
||||
},
|
||||
"9.9.32-50969": {
|
||||
"appid": 537376308,
|
||||
"qua": "V1_WIN_NQ_9.9.32_50969_GW_B"
|
||||
},
|
||||
"3.2.30-50969": {
|
||||
"appid": 537376344,
|
||||
"qua": "V1_LNX_NQ_3.2.30_50969_GW_B"
|
||||
},
|
||||
"3.2.30-50969-arm64": {
|
||||
"appid": 537376345,
|
||||
"qua": "V1_LNX_NQ_3.2.30_50969_GW_B"
|
||||
},
|
||||
"3.2.31-51102": {
|
||||
"appid": 537376497,
|
||||
"qua": "V1_LNX_NQ_3.2.31_51102_GW_B"
|
||||
},
|
||||
"3.2.31-51102-arm64": {
|
||||
"appid": 537376498,
|
||||
"qua": "V1_LNX_NQ_3.2.31_51102_GW_B"
|
||||
},
|
||||
"6.9.98-51102": {
|
||||
"appid": 537376485,
|
||||
"qua": "V1_MAC_NQ_6.9.98_51102_GW_B"
|
||||
},
|
||||
"9.9.32-51246": {
|
||||
"appid": 537376512,
|
||||
"qua": "V1_WIN_NQ_9.9.32_51246_GW_B"
|
||||
},
|
||||
"3.2.32-51728": {
|
||||
"appid": 537376803,
|
||||
"qua": "V1_LNX_NQ_3.2.32_51728_GW_B"
|
||||
},
|
||||
"3.2.32-51728-arm64": {
|
||||
"appid": 537376804,
|
||||
"qua": "V1_LNX_NQ_3.2.32_51728_GW_B"
|
||||
},
|
||||
"3.2.32-51802": {
|
||||
"appid": 537376854,
|
||||
"qua": "V1_LNX_NQ_3.2.32_51802_GW_B"
|
||||
},
|
||||
"3.2.32-51802-arm64": {
|
||||
"appid": 537376855,
|
||||
"qua": "V1_LNX_NQ_3.2.32_51802_GW_B"
|
||||
},
|
||||
"6.9.99-51802": {
|
||||
"appid": 537376842,
|
||||
"qua": "V1_MAC_NQ_6.9.99_51802_GW_B"
|
||||
},
|
||||
"9.9.33-51802": {
|
||||
"appid": 537376818,
|
||||
"qua": "V1_WIN_NQ_9.9.33_51802_GW_B"
|
||||
},
|
||||
"9.9.33-52230": {
|
||||
"appid": 537379411,
|
||||
"qua": "V1_WIN_NQ_9.9.33_52230_GW_B"
|
||||
},
|
||||
"3.2.32-52194": {
|
||||
"appid": 537379447,
|
||||
"qua": "V1_LNX_NQ_3.2.32_52194_GW_B"
|
||||
},
|
||||
"3.2.32-52194-arm64": {
|
||||
"appid": 537379448,
|
||||
"qua": "V1_LNX_NQ_3.2.32_52194_GW_B"
|
||||
},
|
||||
"7.0.0-52194": {
|
||||
"appid": 537379435,
|
||||
"qua": "V1_MAC_NQ_7.0.0_52194_GW_B"
|
||||
},
|
||||
"9.9.35-52804": {
|
||||
"appid": 537382717,
|
||||
"qua": "V1_WIN_NQ_9.9.35_52804_GW_B"
|
||||
},
|
||||
"9.9.35-52892": {
|
||||
"appid": 537382819,
|
||||
"qua": "V1_WIN_NQ_9.9.35_52892_GW_B"
|
||||
},
|
||||
"9.9.36-53489": {
|
||||
"appid": 537391475,
|
||||
"qua": "V1_WIN_NQ_9.9.36_53489_GW_B"
|
||||
},
|
||||
"3.2.33-52892": {
|
||||
"appid": 537382855,
|
||||
"qua": "V1_LNX_NQ_3.2.33_52892_GW_B"
|
||||
},
|
||||
"3.2.33-52892-arm64": {
|
||||
"appid": 537382856,
|
||||
"qua": "V1_LNX_NQ_3.2.33_52892_GW_B"
|
||||
},
|
||||
"7.0.1-52892": {
|
||||
"appid": 537382843,
|
||||
"qua": "V1_MAC_NQ_7.0.1_52892_GW_B"
|
||||
},
|
||||
"3.2.34-53644": {
|
||||
"appid": 537391664,
|
||||
"qua": "V1_LNX_NQ_3.2.34_53644_GW_B"
|
||||
},
|
||||
"3.2.34-53644-arm64": {
|
||||
"appid": 537391665,
|
||||
"qua": "V1_LNX_NQ_3.2.34_53644_GW_B"
|
||||
},
|
||||
"7.0.2-53644": {
|
||||
"appid": 537391652,
|
||||
"qua": "V1_MAC_NQ_7.0.2_53644_GW_B"
|
||||
},
|
||||
"9.9.36-53644": {
|
||||
"appid": 537391628,
|
||||
"qua": "V1_WIN_NQ_9.9.36_53644_GW_B"
|
||||
},
|
||||
"9.9.37-54276": {
|
||||
"appid": 537403570,
|
||||
"qua": "V1_WIN_NQ_9.9.37_54276_GW_B"
|
||||
},
|
||||
"7.0.3-54276": {
|
||||
"appid": 537403594,
|
||||
"qua": "V1_MAC_NQ_7.0.3_54276_GW_B"
|
||||
}
|
||||
}
|
||||
+4017
File diff suppressed because it is too large.
Load diff
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"fileLog": false,
|
||||
"consoleLog": true,
|
||||
"fileLogLevel": "debug",
|
||||
"consoleLogLevel": "info",
|
||||
"packetBackend": "auto",
|
||||
"packetServer": "",
|
||||
"o3HookMode": 1,
|
||||
"bypass": {
|
||||
"hook": false,
|
||||
"window": false,
|
||||
"module": false,
|
||||
"process": false,
|
||||
"container": false,
|
||||
"js": false
|
||||
}
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
{
|
||||
"9.9.22-40990-x64": {
|
||||
"send": "1B5699C",
|
||||
"recv": "1D8CA9D"
|
||||
},
|
||||
"9.9.22-40824-x64": {
|
||||
"send": "1B5699C",
|
||||
"recv": "1D8CA9D"
|
||||
},
|
||||
"9.9.22-40768-x64": {
|
||||
"send": "1B5699C",
|
||||
"recv": "1D8CA9D"
|
||||
},
|
||||
"3.2.20-40768-x64": {
|
||||
"send": "2A1B840",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40824-x64": {
|
||||
"send": "2A1B840",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40990-x64": {
|
||||
"send": "2A1B840",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40990-arm64": {
|
||||
"send": "157C0E8",
|
||||
"recv": "1546658"
|
||||
},
|
||||
"3.2.20-40824-arm64": {
|
||||
"send": "157C0E8",
|
||||
"recv": "1546658"
|
||||
},
|
||||
"3.2.20-40768-arm64": {
|
||||
"send": "157C0E8",
|
||||
"recv": "1546658"
|
||||
},
|
||||
"9.9.23-41679-x64": {
|
||||
"send": "09FF0F4",
|
||||
"recv": "1D1A039"
|
||||
},
|
||||
"6.9.82-40824-arm64": {
|
||||
"send": "05FA930",
|
||||
"recv": "0B41B90"
|
||||
},
|
||||
"6.9.82-40768-arm64": {
|
||||
"send": "05FA930",
|
||||
"recv": "0B41B90"
|
||||
},
|
||||
"6.9.82-40990-arm64": {
|
||||
"send": "05FA930",
|
||||
"recv": "0B41B90"
|
||||
},
|
||||
"6.9.83-41679-arm64": {
|
||||
"send": "237D114",
|
||||
"recv": "0957648"
|
||||
},
|
||||
"6.9.83-41785-arm64": {
|
||||
"send": "23B0BF0",
|
||||
"recv": "095567C"
|
||||
},
|
||||
"9.9.23-41785-x64": {
|
||||
"send": "09FF0A4",
|
||||
"recv": "1D19FF9"
|
||||
},
|
||||
"6.9.83-41857-arm64": {
|
||||
"send": "23B41F0",
|
||||
"recv": "0958B3C"
|
||||
},
|
||||
"3.2.21-41857-x64": {
|
||||
"send": "5B44510",
|
||||
"recv": "2FDB0B0"
|
||||
},
|
||||
"3.2.21-41857-arm64": {
|
||||
"send": "3D6EE6C",
|
||||
"recv": "1479EDC"
|
||||
},
|
||||
"9.9.23-41857-x64": {
|
||||
"send": "0A01394",
|
||||
"recv": "1D1C4F9"
|
||||
},
|
||||
"9.9.23-42086-x64": {
|
||||
"send": "0A01814",
|
||||
"recv": "1D1C9B9"
|
||||
},
|
||||
"6.9.85-42086-arm64": {
|
||||
"send": "23B0330",
|
||||
"recv": "9556B8"
|
||||
},
|
||||
"3.2.21-42086-arm64": {
|
||||
"send": "3D6D98C",
|
||||
"recv": "14797C8"
|
||||
},
|
||||
"3.2.21-42086-x64": {
|
||||
"send": "5B42CF0",
|
||||
"recv": "2FDA6F0"
|
||||
},
|
||||
"9.9.23-42430-x64": {
|
||||
"send": "0A01A34",
|
||||
"recv": "1D1CFF9"
|
||||
},
|
||||
"9.9.25-42744-x64": {
|
||||
"send": "0A0D104",
|
||||
"recv": "1D3E7F9"
|
||||
},
|
||||
"6.9.86-42744-arm64": {
|
||||
"send": "23DFEF0",
|
||||
"recv": "095FD80"
|
||||
},
|
||||
"9.9.25-42905-x64": {
|
||||
"send": "0A12E74",
|
||||
"recv": "1D450FD"
|
||||
},
|
||||
"6.9.86-42905-arm64": {
|
||||
"send": "2342408",
|
||||
"recv": "09639B8"
|
||||
},
|
||||
"3.2.22-42941-x64": {
|
||||
"send": "5BC1630",
|
||||
"recv": "3011E00"
|
||||
},
|
||||
"3.2.22-42941-arm64": {
|
||||
"send": "3DC90AC",
|
||||
"recv": "1497A70"
|
||||
},
|
||||
"9.9.25-42941-x64": {
|
||||
"send": "0A131D4",
|
||||
"recv": "1D4547D"
|
||||
},
|
||||
"6.9.86-42941-arm64": {
|
||||
"send": "2346108",
|
||||
"recv": "09675F0"
|
||||
},
|
||||
"9.9.26-44175-x64": {
|
||||
"send": "0A0F2EC",
|
||||
"recv": "1D3AD4D"
|
||||
},
|
||||
"9.9.26-44343-x64": {
|
||||
"send": "0A0F7BC",
|
||||
"recv": "1D3C3CD"
|
||||
},
|
||||
"3.2.23-44343-arm64": {
|
||||
"send": "3C867DC",
|
||||
"recv": "1404938"
|
||||
},
|
||||
"3.2.23-44343-x64": {
|
||||
"send": "59A27B0",
|
||||
"recv": "2FFBE90"
|
||||
},
|
||||
"6.9.87-44343-arm64": {
|
||||
"send": "2311B68",
|
||||
"recv": "9673A4"
|
||||
},
|
||||
"9.9.26-44498-x64": {
|
||||
"send": "0A1051C",
|
||||
"recv": "1D3BC0D"
|
||||
},
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "0A18D0C",
|
||||
"recv": "1D4BF0D"
|
||||
},
|
||||
"9.9.27-45627-x64": {
|
||||
"send": "0A697CC",
|
||||
"recv": "1E86AC1"
|
||||
},
|
||||
"6.9.88-44725-x64": {
|
||||
"send": "2756EF6",
|
||||
"recv": "A36152"
|
||||
},
|
||||
"6.9.88-44725-arm64": {
|
||||
"send": "2313C68",
|
||||
"recv": "09693E4"
|
||||
},
|
||||
"3.2.25-45758-x64": {
|
||||
"send": "5CF8F30",
|
||||
"recv": "31667E0"
|
||||
},
|
||||
"3.2.25-45758-arm64": {
|
||||
"send": "3EB6554",
|
||||
"recv": "149AF0C"
|
||||
},
|
||||
"6.9.89-45758-arm64": {
|
||||
"send": "2425228",
|
||||
"recv": "9B6770"
|
||||
},
|
||||
"9.9.27-45758-x64": {
|
||||
"send": "0A6A6BC",
|
||||
"recv": "1E87EC1"
|
||||
},
|
||||
"9.9.28-46494-x64": {
|
||||
"send": "0A6D66C",
|
||||
"recv": "1E9890D"
|
||||
},
|
||||
"3.2.26-46494-x64": {
|
||||
"send": "5D30910",
|
||||
"recv": "31814B0"
|
||||
},
|
||||
"3.2.26-46494-arm64": {
|
||||
"send": "3EDC654",
|
||||
"recv": "14A3124"
|
||||
},
|
||||
"6.9.90-46494-arm64": {
|
||||
"send": "2438B4C",
|
||||
"recv": "9BBE00"
|
||||
},
|
||||
"3.2.26-46928-x64": {
|
||||
"send": "5D30910",
|
||||
"recv": "31814B0"
|
||||
},
|
||||
"3.2.26-46928-arm64": {
|
||||
"send": "3EDC654",
|
||||
"recv": "14A3124"
|
||||
},
|
||||
"6.9.91-46928-arm64": {
|
||||
"send": "2438B4C",
|
||||
"recv": "9BBE00"
|
||||
},
|
||||
"9.9.28-46928-x64": {
|
||||
"send": "0A6D66C",
|
||||
"recv": "1E9890D"
|
||||
},
|
||||
"3.2.27-47256-x64": {
|
||||
"send": "5D389C0",
|
||||
"recv": "318D090"
|
||||
},
|
||||
"3.2.27-47354-x64": {
|
||||
"send": "5D38930",
|
||||
"recv": "318D090"
|
||||
},
|
||||
"3.2.27-47354-arm64": {
|
||||
"send": "3EE8B24",
|
||||
"recv": "14D422C"
|
||||
},
|
||||
"9.9.29-47354-x64": {
|
||||
"send": "0A70B3C",
|
||||
"recv": "1EAFD51"
|
||||
},
|
||||
"6.9.93-47354-arm64": {
|
||||
"send": "244066C",
|
||||
"recv": "09BCCF8"
|
||||
},
|
||||
"6.9.93-47354-x64": {
|
||||
"send": "28ADE46",
|
||||
"recv": "A8FD5A"
|
||||
},
|
||||
"9.9.30-48517-x64": {
|
||||
"send": "0A9B1BC",
|
||||
"recv": "1F0324D"
|
||||
},
|
||||
"3.2.28-48517-x64": {
|
||||
"send": "5E41890",
|
||||
"recv": "3216D10"
|
||||
},
|
||||
"3.2.28-48517-arm64": {
|
||||
"send": "3F9C7E4",
|
||||
"recv": "14E1AA8"
|
||||
},
|
||||
"6.9.95-48517-arm64": {
|
||||
"send": "25120A8",
|
||||
"recv": "9DB00C"
|
||||
},
|
||||
"9.9.30-48762-x64": {
|
||||
"send": "0A9B1BC",
|
||||
"recv": "1F0324D"
|
||||
},
|
||||
"9.9.31-49599-x64": {
|
||||
"send": "0AF885C",
|
||||
"recv": "20058E1"
|
||||
},
|
||||
"9.9.31-49738-x64": {
|
||||
"send": "0AF9FCC",
|
||||
"recv": "2007901"
|
||||
},
|
||||
"3.2.29-49738-x64": {
|
||||
"send": "6234600",
|
||||
"recv": "33D89D0"
|
||||
},
|
||||
"3.2.29-49738-arm64": {
|
||||
"send": "426AA04",
|
||||
"recv": "1596254"
|
||||
},
|
||||
"6.9.96-49738-arm64": {
|
||||
"send": "2785BB8",
|
||||
"recv": "A29880"
|
||||
},
|
||||
"9.9.32-50828-x64": {
|
||||
"send": "0B245CC",
|
||||
"recv": "2073D65"
|
||||
},
|
||||
"3.2.30-50828-x64": {
|
||||
"send": "6340530",
|
||||
"recv": "346DA50"
|
||||
},
|
||||
"3.2.30-50828-arm64": {
|
||||
"send": "4327764",
|
||||
"recv": "15e3184"
|
||||
},
|
||||
"9.9.32-50969-x64": {
|
||||
"send": "0B2E2AC",
|
||||
"recv": "209C709"
|
||||
},
|
||||
"3.2.30-50969-x64": {
|
||||
"send": "63F47B0",
|
||||
"recv": "34B9E30"
|
||||
},
|
||||
"3.2.30-50969-arm64": {
|
||||
"send": "43A4804",
|
||||
"recv": "1608CC8"
|
||||
},
|
||||
"3.2.31-51102-x64": {
|
||||
"send": "63F4910",
|
||||
"recv": "34B9F90"
|
||||
},
|
||||
"3.2.31-51102-arm64": {
|
||||
"send": "43A48E4",
|
||||
"recv": "1608D98"
|
||||
},
|
||||
"6.9.98-51102-arm64": {
|
||||
"send": "2849378",
|
||||
"recv": "A66CFC"
|
||||
},
|
||||
"9.9.32-51246-x64": {
|
||||
"send": "B2E37C",
|
||||
"recv": "209C7D9"
|
||||
},
|
||||
"3.2.32-51728-arm64": {
|
||||
"send": "48CA8F8",
|
||||
"recv": "17D5A78"
|
||||
},
|
||||
"3.2.32-51728-x64": {
|
||||
"send": "6B9ADD0",
|
||||
"recv": "38F5F30"
|
||||
},
|
||||
"3.2.32-51802-x64": {
|
||||
"send": "6B9B790",
|
||||
"recv": "38F68F0"
|
||||
},
|
||||
"3.2.32-51802-arm64": {
|
||||
"send": "48CB078",
|
||||
"recv": "17D620C"
|
||||
},
|
||||
"6.9.99-51802-arm64": {
|
||||
"send": "2B0B384",
|
||||
"recv": "B4526C"
|
||||
},
|
||||
"9.9.33-51802-x64": {
|
||||
"send": "BDE15F",
|
||||
"recv": "237BC91"
|
||||
},
|
||||
"9.9.33-52230-x64": {
|
||||
"send": "BF2B5F",
|
||||
"recv": "23BB4DD"
|
||||
},
|
||||
"3.2.32-52194-x64": {
|
||||
"send": "6C45D20",
|
||||
"recv": "393DE00"
|
||||
},
|
||||
"3.2.32-52194-arm64": {
|
||||
"send": "493FAD8",
|
||||
"recv": "17EA1DC"
|
||||
},
|
||||
"7.0.0-52194-arm64": {
|
||||
"send": "2B3FBA4",
|
||||
"recv": "B4E85C"
|
||||
},
|
||||
"9.9.35-52804-x64": {
|
||||
"send": "C3614F",
|
||||
"recv": "246F481"
|
||||
},
|
||||
"9.9.35-52892-x64": {
|
||||
"send": "C361DF",
|
||||
"recv": "246F3A1"
|
||||
},
|
||||
"9.9.36-53489-x64": {
|
||||
"send": "C3ED0F",
|
||||
"recv": "24A6235"
|
||||
},
|
||||
"3.2.33-52892-x64": {
|
||||
"send": "6DDB4C0",
|
||||
"recv": "39DA860"
|
||||
},
|
||||
"3.2.33-52892-arm64": {
|
||||
"send": "4A5D658",
|
||||
"recv": "1813A34"
|
||||
},
|
||||
"7.0.1-52892-arm64": {
|
||||
"send": "2B3FBCC",
|
||||
"recv": "B64170"
|
||||
},
|
||||
"3.2.34-53644-x64": {
|
||||
"send": "6E5AB20",
|
||||
"recv": "3A12CD0"
|
||||
},
|
||||
"3.2.34-53644-arm64": {
|
||||
"send": "4AABA5C",
|
||||
"recv": "182C534"
|
||||
},
|
||||
"7.0.2-53644-arm64": {
|
||||
"send": "2B82DEC",
|
||||
"recv": "B71088"
|
||||
},
|
||||
"9.9.36-53644-x64": {
|
||||
"send": "C3EE2F",
|
||||
"recv": "24A6355"
|
||||
},
|
||||
"6.9.82-40768-x64": {
|
||||
"send": "6587B8",
|
||||
"recv": "C45C6E"
|
||||
},
|
||||
"6.9.82-40990-x64": {
|
||||
"send": "6587B8",
|
||||
"recv": "C45C6E"
|
||||
},
|
||||
"6.9.83-41679-x64": {
|
||||
"send": "271A206",
|
||||
"recv": "A1E04A"
|
||||
},
|
||||
"6.9.83-41785-x64": {
|
||||
"send": "2744756",
|
||||
"recv": "A1DE4A"
|
||||
},
|
||||
"6.9.83-41857-x64": {
|
||||
"send": "2745186",
|
||||
"recv": "A1E96E"
|
||||
},
|
||||
"6.9.85-42086-x64": {
|
||||
"send": "27462F6",
|
||||
"recv": "A1FC0A"
|
||||
},
|
||||
"6.9.86-42744-x64": {
|
||||
"send": "27770E6",
|
||||
"recv": "A2725A"
|
||||
},
|
||||
"6.9.86-42905-x64": {
|
||||
"send": "2786206",
|
||||
"recv": "A2D6A6"
|
||||
},
|
||||
"6.9.86-42941-x64": {
|
||||
"send": "2786366",
|
||||
"recv": "A2D2E2"
|
||||
},
|
||||
"6.9.87-44343-x64": {
|
||||
"send": "2750306",
|
||||
"recv": "A2F432"
|
||||
},
|
||||
"6.9.89-45758-x64": {
|
||||
"send": "288CBB6",
|
||||
"recv": "A87816"
|
||||
},
|
||||
"6.9.90-46494-x64": {
|
||||
"send": "28A2F96",
|
||||
"recv": "A8D2FE"
|
||||
},
|
||||
"6.9.91-46928-x64": {
|
||||
"send": "28A2F96",
|
||||
"recv": "A8D2FE"
|
||||
},
|
||||
"6.9.95-48517-x64": {
|
||||
"send": "28DC616",
|
||||
"recv": "AAED2A"
|
||||
},
|
||||
"6.9.96-49738-x64": {
|
||||
"send": "2B8C936",
|
||||
"recv": "AFF03E"
|
||||
},
|
||||
"6.9.98-51102-x64": {
|
||||
"send": "2C6CD06",
|
||||
"recv": "B42922"
|
||||
},
|
||||
"6.9.99-51802-x64": {
|
||||
"send": "2F6C1AB",
|
||||
"recv": "C16A4A"
|
||||
},
|
||||
"7.0.0-52194-x64": {
|
||||
"send": "2FAC58B",
|
||||
"recv": "C20DB2"
|
||||
},
|
||||
"7.0.1-52892-x64": {
|
||||
"send": "304CC8B",
|
||||
"recv": "C3A54E"
|
||||
},
|
||||
"7.0.2-53644-x64": {
|
||||
"send": "309B1BB",
|
||||
"recv": "C47262"
|
||||
},
|
||||
"9.9.37-54276-x64": {
|
||||
"send": "C5927F",
|
||||
"recv": "24CC2B5"
|
||||
},
|
||||
"7.0.3-54276-arm64": {
|
||||
"send": "2BA91CC",
|
||||
"recv": "B84728"
|
||||
},
|
||||
"7.0.3-54276-x64": {
|
||||
"send": "30C611B",
|
||||
"recv": "C5B84E"
|
||||
}
|
||||
}
|
||||
+1010
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
// TODO: further refactor in NapCat.Packet v2
|
||||
import { NapProtoMsg, ProtoField, ScalarType } from 'napcat-protobuf';
|
||||
|
||||
const LikeDetail = {
|
||||
txt: ProtoField(1, ScalarType.STRING),
|
||||
uin: ProtoField(3, ScalarType.INT64),
|
||||
nickname: ProtoField(5, ScalarType.STRING),
|
||||
};
|
||||
|
||||
const LikeMsg = {
|
||||
times: ProtoField(1, ScalarType.INT32),
|
||||
time: ProtoField(2, ScalarType.INT32),
|
||||
detail: ProtoField(3, () => LikeDetail),
|
||||
};
|
||||
|
||||
const ProfileLikeSubTip = {
|
||||
msg: ProtoField(14, () => LikeMsg),
|
||||
};
|
||||
|
||||
const ProfileLikeTip = {
|
||||
msgType: ProtoField(1, ScalarType.INT32),
|
||||
subType: ProtoField(2, ScalarType.INT32),
|
||||
content: ProtoField(203, () => ProfileLikeSubTip),
|
||||
};
|
||||
|
||||
const SysMessageHeader = {
|
||||
PeerNumber: ProtoField(1, ScalarType.UINT32),
|
||||
PeerString: ProtoField(2, ScalarType.STRING),
|
||||
Uin: ProtoField(5, ScalarType.UINT32),
|
||||
Uid: ProtoField(6, ScalarType.STRING, true),
|
||||
};
|
||||
|
||||
const SysMessageMsgSpec = {
|
||||
msgType: ProtoField(1, ScalarType.UINT32),
|
||||
subType: ProtoField(2, ScalarType.UINT32),
|
||||
subSubType: ProtoField(3, ScalarType.UINT32),
|
||||
msgSeq: ProtoField(5, ScalarType.UINT32),
|
||||
time: ProtoField(6, ScalarType.UINT32),
|
||||
msgId: ProtoField(12, ScalarType.UINT64),
|
||||
other: ProtoField(13, ScalarType.UINT32),
|
||||
};
|
||||
|
||||
const SysMessageBodyWrapper = {
|
||||
wrappedBody: ProtoField(2, ScalarType.BYTES),
|
||||
};
|
||||
|
||||
const SysMessage = {
|
||||
header: ProtoField(1, () => SysMessageHeader, false, true),
|
||||
msgSpec: ProtoField(2, () => SysMessageMsgSpec, false, true),
|
||||
bodyWrapper: ProtoField(3, () => SysMessageBodyWrapper),
|
||||
};
|
||||
|
||||
export function decodeProfileLikeTip (buffer: Uint8Array) {
|
||||
const msg = new NapProtoMsg(ProfileLikeTip);
|
||||
return msg.decode(buffer);
|
||||
}
|
||||
|
||||
export function decodeSysMessage (buffer: Uint8Array) {
|
||||
const msg = new NapProtoMsg(SysMessage);
|
||||
return msg.decode(buffer);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
|
||||
export async function encodeSilk (filePath: string, temporaryDirectory: string, logger: LogWrapper) {
|
||||
const converted = !(await FFmpegService.isSilk(filePath));
|
||||
const pttPath = converted ? path.join(temporaryDirectory, randomUUID()) : filePath;
|
||||
if (converted) {
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
await FFmpegService.convertToNTSilkTct(filePath, pttPath);
|
||||
}
|
||||
const duration = await FFmpegService.getDuration(pttPath);
|
||||
logger.log('语音文件已就绪:', pttPath, '时长:', duration);
|
||||
return { converted, path: pttPath, duration };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { NapCatCore } from '@/napcat-core';
|
||||
import json5 from 'json5';
|
||||
import { TypeCompiler, TypeCheck } from '@sinclair/typebox/compiler';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
|
||||
export abstract class ConfigBase<T> {
|
||||
name: string;
|
||||
core: NapCatCore;
|
||||
configPath: string;
|
||||
configData: T = {} as T;
|
||||
configSchema: TSchema;
|
||||
validate: TypeCheck<TSchema>;
|
||||
|
||||
protected constructor (name: string, core: NapCatCore, configPath: string, ConfigSchema: TSchema) {
|
||||
this.name = name;
|
||||
this.core = core;
|
||||
this.configPath = configPath;
|
||||
this.configSchema = ConfigSchema;
|
||||
this.validate = TypeCompiler.Compile(ConfigSchema);
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
this.read();
|
||||
}
|
||||
|
||||
getConfigPath (pathName?: string): string {
|
||||
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||
return path.join(this.configPath, filename);
|
||||
}
|
||||
|
||||
read (): T {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
const defaultConfigPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
if (fs.existsSync(defaultConfigPath)) {
|
||||
this.configData = this.loadConfig(defaultConfigPath);
|
||||
}
|
||||
this.save();
|
||||
return this.configData;
|
||||
}
|
||||
return this.loadConfig(configPath);
|
||||
}
|
||||
|
||||
private loadConfig (configPath: string): T {
|
||||
try {
|
||||
const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
let data: unknown = newConfigData;
|
||||
data = Value.Parse(this.configSchema, data);
|
||||
if (!this.validate.Check(data)) {
|
||||
throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', '));
|
||||
}
|
||||
this.configData = data as T;
|
||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||
return this.configData;
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, '读取配置文件时发生错误');
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
save (newConfigData: T = this.configData): void {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
let data: unknown = newConfigData;
|
||||
data = Value.Parse(this.configSchema, data);
|
||||
if (!this.validate.Check(data)) {
|
||||
throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', '));
|
||||
}
|
||||
this.configData = data as T;
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError (e: unknown, message: string): void {
|
||||
if (e instanceof SyntaxError) {
|
||||
this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message);
|
||||
} else {
|
||||
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ConfigBase } from '@/napcat-core/helper/config-base';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { Type, Static, TSchema } from '@sinclair/typebox';
|
||||
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import json5 from 'json5';
|
||||
|
||||
export const BypassOptionsSchema = Type.Object({
|
||||
hook: Type.Boolean({ default: true }),
|
||||
window: Type.Boolean({ default: true }),
|
||||
module: Type.Boolean({ default: true }),
|
||||
process: Type.Boolean({ default: true }),
|
||||
container: Type.Boolean({ default: true }),
|
||||
js: Type.Boolean({ default: true }),
|
||||
});
|
||||
|
||||
export const NapcatConfigSchema = Type.Object({
|
||||
fileLog: Type.Boolean({ default: false }),
|
||||
consoleLog: Type.Boolean({ default: true }),
|
||||
fileLogLevel: Type.String({ default: 'debug' }),
|
||||
consoleLogLevel: Type.String({ default: 'info' }),
|
||||
packetBackend: Type.String({ default: 'auto' }),
|
||||
packetServer: Type.String({ default: '' }),
|
||||
o3HookMode: Type.Number({ default: 0 }),
|
||||
autoTimeSync: Type.Boolean({ default: true }),
|
||||
bypass: Type.Optional(BypassOptionsSchema),
|
||||
});
|
||||
|
||||
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||
|
||||
/**
|
||||
* 从指定配置目录读取 napcat.json,按 NapcatConfigSchema 校验并填充默认值
|
||||
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
|
||||
*/
|
||||
export function loadNapcatConfig (configPath: string): NapcatConfig {
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const configFile = path.join(configPath, 'napcat.json');
|
||||
if (fs.existsSync(configFile)) {
|
||||
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时使用 schema 默认值
|
||||
}
|
||||
data = Value.Parse(NapcatConfigSchema, data) as Record<string, unknown>;
|
||||
return data as NapcatConfig;
|
||||
}
|
||||
|
||||
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||
constructor (core: NapCatCore, configPath: string, schema: TSchema) {
|
||||
super('napcat', core, configPath, schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core/index';
|
||||
|
||||
interface InternalMapKey {
|
||||
timeout: number;
|
||||
createtime: number;
|
||||
func: (...arg: any[]) => any;
|
||||
checker: ((...args: any[]) => boolean) | undefined;
|
||||
}
|
||||
|
||||
type EnsureFunc<T> = T extends (...args: any) => any ? T : never;
|
||||
|
||||
type FuncKeys<T> = Extract<
|
||||
{
|
||||
[K in keyof T]: EnsureFunc<T[K]> extends never ? never : K;
|
||||
}[keyof T],
|
||||
string
|
||||
>;
|
||||
|
||||
export type ListenerClassBase = Record<string, string>;
|
||||
|
||||
export class NTEventWrapper {
|
||||
private readonly WrapperSession: NodeIQQNTWrapperSession | undefined; // WrapperSession
|
||||
private readonly listenerManager: Map<string, ListenerClassBase> = new Map<string, ListenerClassBase>(); // ListenerName-Unique -> Listener实例
|
||||
private readonly EventTask = new Map<string, Map<string, Map<string, InternalMapKey>>>(); // tasks ListenerMainName -> ListenerSubName-> uuid -> {timeout,createtime,func}
|
||||
|
||||
constructor (
|
||||
wrapperSession: NodeIQQNTWrapperSession
|
||||
) {
|
||||
this.WrapperSession = wrapperSession;
|
||||
}
|
||||
|
||||
createProxyDispatch (ListenerMainName: string) {
|
||||
const dispatcherListenerFunc = this.dispatcherListener.bind(this);
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get (target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
return (...args: any[]) => {
|
||||
dispatcherListenerFunc(ListenerMainName, prop, ...args).then();
|
||||
};
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createEventFunction<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
T extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||
>(eventName: `${Service}/${ServiceMethod}`): T | undefined {
|
||||
const eventNameArr = eventName.split('/');
|
||||
type eventType = {
|
||||
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>>; };
|
||||
};
|
||||
if (eventNameArr.length > 1) {
|
||||
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
|
||||
const eventName = eventNameArr[1];
|
||||
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
|
||||
if (!services || !eventName) {
|
||||
return undefined;
|
||||
}
|
||||
let event = services[eventName];
|
||||
|
||||
// 重新绑定this
|
||||
event = event?.bind(services);
|
||||
if (event) {
|
||||
return event as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
createListenerFunction<T> (listenerMainName: string, uniqueCode: string = ''): T {
|
||||
const existListener = this.listenerManager.get(listenerMainName + uniqueCode);
|
||||
if (!existListener) {
|
||||
const Listener = this.createProxyDispatch(listenerMainName);
|
||||
const ServiceSubName = /^NodeIKernel(.*?)Listener$/.exec(listenerMainName)![1];
|
||||
const Service = `NodeIKernel${ServiceSubName}Service/addKernel${ServiceSubName}Listener`;
|
||||
|
||||
// @ts-ignore
|
||||
this.createEventFunction(Service)(Listener as T);
|
||||
this.listenerManager.set(listenerMainName + uniqueCode, Listener);
|
||||
return Listener as T;
|
||||
}
|
||||
return existListener as T;
|
||||
}
|
||||
|
||||
// 统一回调清理事件
|
||||
async dispatcherListener (ListenerMainName: string, ListenerSubName: string, ...args: any[]) {
|
||||
this.EventTask.get(ListenerMainName)
|
||||
?.get(ListenerSubName)
|
||||
?.forEach((task, uuid) => {
|
||||
if (task.createtime + task.timeout < Date.now()) {
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.delete(uuid);
|
||||
return;
|
||||
}
|
||||
if (task?.checker?.(...args)) {
|
||||
task.func(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callNoListenerEvent<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||
>(
|
||||
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||
...args: Parameters<EventType>
|
||||
): Promise<Awaited<ReturnType<EventType>>> {
|
||||
return (this.createEventFunction(serviceAndMethod))!(...args);
|
||||
}
|
||||
|
||||
async registerListen<
|
||||
Listener extends keyof ListenerNamingMapping,
|
||||
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||
>(
|
||||
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||
checker: (...args: Parameters<ListenerType>) => boolean,
|
||||
waitTimes = 1,
|
||||
timeout = 5000
|
||||
) {
|
||||
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined;
|
||||
|
||||
function sendDataCallback () {
|
||||
if (complete === 0) {
|
||||
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
|
||||
} else {
|
||||
resolve(retData!);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutRef = setTimeout(sendDataCallback, timeout);
|
||||
const eventCallback = {
|
||||
timeout,
|
||||
createtime: Date.now(),
|
||||
checker,
|
||||
func: (...args: Parameters<ListenerType>) => {
|
||||
complete++;
|
||||
retData = args;
|
||||
if (complete >= waitTimes) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback();
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!this.EventTask.get(ListenerMainName)) {
|
||||
this.EventTask.set(ListenerMainName, new Map());
|
||||
}
|
||||
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||
}
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||
this.createListenerFunction(ListenerMainName);
|
||||
});
|
||||
}
|
||||
|
||||
async callNormalEventV2<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
Listener extends keyof ListenerNamingMapping,
|
||||
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>,
|
||||
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||
>(
|
||||
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||
args: Parameters<EventType>,
|
||||
checkerEvent: (ret: Awaited<ReturnType<EventType>>) => boolean = () => true,
|
||||
checkerListener: (...args: Parameters<ListenerType>) => boolean = () => true,
|
||||
callbackTimesToWait = 1,
|
||||
timeout = 5000
|
||||
) {
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined;
|
||||
let retEvent: any = {};
|
||||
|
||||
function sendDataCallback (resolve: any, reject: any) {
|
||||
if (complete === 0) {
|
||||
reject(
|
||||
new Error(
|
||||
'Timeout: NTEvent serviceAndMethod:' +
|
||||
serviceAndMethod +
|
||||
' ListenerName:' +
|
||||
listenerAndMethod +
|
||||
' EventRet:\n' +
|
||||
JSON.stringify(retEvent, null, 4) +
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve([retEvent as Awaited<ReturnType<EventType>>, ...retData!]);
|
||||
}
|
||||
}
|
||||
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
|
||||
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||
(resolve, reject) => {
|
||||
const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout);
|
||||
|
||||
const eventCallback = {
|
||||
timeout,
|
||||
createtime: Date.now(),
|
||||
checker: checkerListener,
|
||||
func: (...args: any[]) => {
|
||||
complete++;
|
||||
retData = args as Parameters<ListenerType>;
|
||||
if (complete >= callbackTimesToWait) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback(resolve, reject);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!this.EventTask.get(ListenerMainName)) {
|
||||
this.EventTask.set(ListenerMainName, new Map());
|
||||
}
|
||||
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||
}
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||
this.createListenerFunction(ListenerMainName);
|
||||
|
||||
const eventResult = this.createEventFunction(serviceAndMethod)!(...(args));
|
||||
|
||||
const eventRetHandle = (eventData: any) => {
|
||||
retEvent = eventData;
|
||||
if (!checkerEvent(retEvent) && timeoutRef.hasRef()) {
|
||||
clearTimeout(timeoutRef);
|
||||
reject(
|
||||
new Error(
|
||||
'EventChecker Failed: NTEvent serviceAndMethod:' +
|
||||
serviceAndMethod +
|
||||
' ListenerName:' +
|
||||
listenerAndMethod +
|
||||
' EventRet:\n' +
|
||||
JSON.stringify(retEvent, null, 4) +
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
if (eventResult instanceof Promise) {
|
||||
eventResult.then((eventResult: any) => {
|
||||
eventRetHandle(eventResult);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
eventRetHandle(eventResult);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// 更正导入语句
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as os from 'os';
|
||||
import * as compressing from 'compressing'; // 修正导入方式
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
|
||||
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
|
||||
const urls = [
|
||||
'https://j.1win.ggff.net/' + downloadOri,
|
||||
'https://git.yylx.win/' + downloadOri,
|
||||
'https://ghfile.geekertao.top/' + downloadOri,
|
||||
'https://gh-proxy.net/' + downloadOri,
|
||||
'https://ghm.078465.xyz/' + downloadOri,
|
||||
'https://gitproxy.127731.xyz/' + downloadOri,
|
||||
'https://jiashu.1win.eu.org/' + downloadOri,
|
||||
'https://github.tbedu.top/' + downloadOri,
|
||||
downloadOri,
|
||||
];
|
||||
|
||||
/**
|
||||
* 测试URL是否可用
|
||||
* @param url 待测试的URL
|
||||
* @returns 如果URL可访问返回true,否则返回false
|
||||
*/
|
||||
async function testUrl (url: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const req = https.get(url, { timeout: 5000 }, (res) => {
|
||||
// 检查状态码是否表示成功
|
||||
const statusCode = res.statusCode || 0;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 终止请求并返回true
|
||||
req.destroy();
|
||||
resolve(true);
|
||||
} else {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找第一个可用的URL
|
||||
* @returns 返回第一个可用的URL,如果都不可用则返回null
|
||||
*/
|
||||
async function findAvailableUrl (): Promise<string | null> {
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const available = await testUrl(url);
|
||||
if (available) {
|
||||
return url;
|
||||
}
|
||||
} catch (_error) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 下载文件
|
||||
* @param url 下载URL
|
||||
* @param destPath 目标保存路径
|
||||
* @returns 成功返回true,失败返回false
|
||||
*/
|
||||
async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const req = https.get(url, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 获取文件总大小
|
||||
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let downloadedSize = 0;
|
||||
let lastReportedPercent = -1; // 上次报告的百分比
|
||||
let lastReportTime = 0; // 上次报告的时间戳
|
||||
|
||||
// 如果有内容长度和进度回调,则添加数据监听
|
||||
if (totalSize > 0 && progressCallback) {
|
||||
// 初始报告 0%
|
||||
progressCallback(0);
|
||||
lastReportTime = Date.now();
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
|
||||
const now = Date.now();
|
||||
|
||||
// 只在以下条件触发回调:
|
||||
// 1. 百分比变化至少为1%
|
||||
// 2. 距离上次报告至少500毫秒
|
||||
// 3. 确保报告100%完成
|
||||
if ((currentPercent !== lastReportedPercent &&
|
||||
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
|
||||
(now - lastReportTime >= 1000 || currentPercent === 100)) {
|
||||
progressCallback(currentPercent);
|
||||
lastReportedPercent = currentPercent;
|
||||
lastReportTime = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pipeline(res, file)
|
||||
.then(() => {
|
||||
// 确保最后报告100%
|
||||
if (progressCallback && lastReportedPercent !== 100) {
|
||||
progressCallback(100);
|
||||
}
|
||||
resolve(true);
|
||||
})
|
||||
.catch(() => resolve(false));
|
||||
} else {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩zip文件中的特定内容
|
||||
* 只解压bin目录中的文件到目标目录
|
||||
* @param zipPath 压缩文件路径
|
||||
* @param extractDir 解压目标路径
|
||||
*/
|
||||
async function extractBinDirectory (zipPath: string, extractDir: string): Promise<void> {
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(extractDir)) {
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// 监听条目事件
|
||||
zipStream.on('entry', (header, stream, next) => {
|
||||
// 获取文件路径
|
||||
const filePath = header.name;
|
||||
|
||||
// 匹配内层bin目录中的文件
|
||||
// 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
|
||||
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
|
||||
// 提取文件名
|
||||
const fileName = path.basename(filePath);
|
||||
const targetPath = path.join(extractDir, fileName);
|
||||
|
||||
// 创建写入流
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
|
||||
// 将流管道连接到文件
|
||||
stream.pipe(writeStream);
|
||||
|
||||
// 监听写入完成事件
|
||||
writeStream.on('finish', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
writeStream.on('error', () => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
// 跳过不需要的文件
|
||||
stream.resume();
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
zipStream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
zipStream.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并设置FFmpeg
|
||||
* @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹
|
||||
* @param tempDir 临时文件目录,默认为系统临时目录
|
||||
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
|
||||
*/
|
||||
export async function downloadFFmpeg (
|
||||
destDir?: string,
|
||||
tempDir?: string,
|
||||
progressCallback?: (percent: number, stage: string) => void
|
||||
): Promise<string | null> {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
|
||||
const tempDirectory = tempDir || os.tmpdir();
|
||||
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
|
||||
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(destinationDir)) {
|
||||
fs.mkdirSync(destinationDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(tempDirectory)) {
|
||||
fs.mkdirSync(tempDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
// 如果ffmpeg已经存在,直接返回路径
|
||||
if (fs.existsSync(ffmpegExePath)) {
|
||||
if (progressCallback) progressCallback(100, '已找到FFmpeg');
|
||||
return ffmpegExePath;
|
||||
}
|
||||
|
||||
// 查找可用URL
|
||||
if (progressCallback) progressCallback(0, '查找可用下载源');
|
||||
const availableUrl = await findAvailableUrl();
|
||||
if (!availableUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
|
||||
const downloaded = await downloadFile(
|
||||
availableUrl,
|
||||
zipFilePath,
|
||||
(percent) => {
|
||||
// 下载占总进度的70%
|
||||
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
|
||||
}
|
||||
);
|
||||
|
||||
if (!downloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接解压bin目录文件到目标目录
|
||||
if (progressCallback) progressCallback(75, '解压FFmpeg');
|
||||
await extractBinDirectory(zipFilePath, destinationDir);
|
||||
|
||||
// 清理下载文件
|
||||
if (progressCallback) progressCallback(95, '清理临时文件');
|
||||
try {
|
||||
fs.unlinkSync(zipFilePath);
|
||||
} catch (_err) {
|
||||
// 忽略清理临时文件失败的错误
|
||||
}
|
||||
|
||||
// 检查ffmpeg.exe是否成功解压
|
||||
if (fs.existsSync(ffmpegExePath)) {
|
||||
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
|
||||
return ffmpegExePath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查系统PATH环境变量中是否存在指定可执行文件
|
||||
* @param executable 可执行文件名
|
||||
* @returns 如果找到返回完整路径,否则返回null
|
||||
*/
|
||||
function findExecutableInPath (executable: string): string | null {
|
||||
// 仅适用于Windows系统
|
||||
if (os.platform() !== 'win32') return null;
|
||||
|
||||
// 获取PATH环境变量
|
||||
const pathEnv = process.env['PATH'] || '';
|
||||
const pathDirs = pathEnv.split(';');
|
||||
|
||||
// 检查每个目录
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir) continue;
|
||||
try {
|
||||
const filePath = path.join(dir, executable);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
} catch (_error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return {
|
||||
path: null,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
|
||||
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
|
||||
|
||||
if (ffmpegInPath && ffprobeInPath) {
|
||||
const ffmpegDir = path.dirname(ffmpegInPath);
|
||||
return {
|
||||
path: ffmpegDir,
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果环境变量中没有,检查项目目录中是否存在
|
||||
const currentPath = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
|
||||
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
|
||||
|
||||
if (!ffmpeg_exist || !ffprobe_exist) {
|
||||
const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
|
||||
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
|
||||
});
|
||||
if (!url) {
|
||||
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
|
||||
return {
|
||||
path: null,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* FFmpeg Adapter Factory
|
||||
* 自动检测并选择最佳的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { FFmpegAddonAdapter, getAddonPath } from './ffmpeg-addon-adapter';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器工厂
|
||||
*/
|
||||
export class FFmpegAdapterFactory {
|
||||
private static instance: IFFmpegAdapter | null = null;
|
||||
private static initPromise: Promise<IFFmpegAdapter> | null = null;
|
||||
|
||||
/**
|
||||
* 初始化并获取最佳的 FFmpeg 适配器
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器)
|
||||
* @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器)
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器)
|
||||
*/
|
||||
static async getAdapter (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string = 'ffmpeg',
|
||||
ffprobePath: string = 'ffprobe',
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
// 如果已经初始化,直接返回
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// 如果正在初始化,等待初始化完成
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// 开始初始化
|
||||
this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath);
|
||||
|
||||
try {
|
||||
this.instance = await this.initPromise;
|
||||
return this.instance;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化适配器
|
||||
*/
|
||||
private static async initialize (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string,
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
const useCommandLine = Boolean(process.env['FFMPEG_PATH'] || process.env['FFPROBE_PATH']);
|
||||
if (binaryPath && !useCommandLine && existsSync(getAddonPath(binaryPath))) {
|
||||
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||
|
||||
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||
if (await addonAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||
return addonAdapter;
|
||||
}
|
||||
|
||||
throw new Error('FFmpeg Native Addon 加载失败: ' + getAddonPath(binaryPath));
|
||||
}
|
||||
|
||||
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath);
|
||||
|
||||
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||
if (await execAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
throw new Error('FFmpeg 命令行工具不可用,请检查 ffmpeg 和 ffprobe 路径: ' + ffmpegPath + ', ' + ffprobePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置适配器(用于测试或重新初始化)
|
||||
*/
|
||||
static reset (): void {
|
||||
this.instance = null;
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FFmpeg 路径并重新初始化
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径
|
||||
* @param ffprobePath FFprobe 可执行文件路径
|
||||
*/
|
||||
static async updateFFmpegPath (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
const adapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath);
|
||||
if (!await adapter.isAvailable()) {
|
||||
throw new Error('FFmpeg 路径不可用: ' + ffmpegPath + ', ' + ffprobePath);
|
||||
}
|
||||
this.instance = adapter;
|
||||
logger.log('[FFmpeg] 已更新命令行工具路径:', ffmpegPath, ffprobePath);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前适配器(不初始化)
|
||||
*/
|
||||
static getCurrentAdapter (): IFFmpegAdapter | null {
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* FFmpeg Adapter Interface
|
||||
* 定义统一的 FFmpeg 操作接口,支持多种实现方式
|
||||
*/
|
||||
|
||||
/**
|
||||
* 视频信息结果
|
||||
*/
|
||||
export interface VideoInfoResult {
|
||||
/** 视频宽度(像素) */
|
||||
width: number;
|
||||
/** 视频高度(像素) */
|
||||
height: number;
|
||||
/** 视频时长(秒) */
|
||||
duration: number;
|
||||
/** 容器格式 */
|
||||
format: string;
|
||||
/** 缩略图 Buffer */
|
||||
thumbnail?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器接口
|
||||
*/
|
||||
export interface IFFmpegAdapter {
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 是否可用 */
|
||||
isAvailable (): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取视频信息(包含缩略图)
|
||||
* @param videoPath 视频文件路径
|
||||
* @returns 视频信息
|
||||
*/
|
||||
getVideoInfo (videoPath: string): Promise<VideoInfoResult>;
|
||||
|
||||
/**
|
||||
* 获取音视频文件时长
|
||||
* @param filePath 文件路径
|
||||
* @returns 时长(秒)
|
||||
*/
|
||||
getDuration (filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
isSilk (filePath: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 转换音频为 PCM 格式
|
||||
* @param filePath 输入文件路径
|
||||
* @param pcmPath 输出 PCM 文件路径
|
||||
* @returns PCM 数据 Buffer
|
||||
*/
|
||||
convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>;
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
* @param inputFile 输入文件路径
|
||||
* @param outputFile 输出文件路径
|
||||
* @param format 目标格式 ('amr' | 'silk' 等)
|
||||
*/
|
||||
convertFile (inputFile: string, outputFile: string, format: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
* @param videoPath 视频文件路径
|
||||
* @param thumbnailPath 缩略图输出路径
|
||||
*/
|
||||
extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void>;
|
||||
|
||||
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FFmpeg Native Addon Adapter
|
||||
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { openSync, readSync, closeSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { FFmpeg } from './ffmpeg-addon';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
import { dlopen } from 'node:process';
|
||||
|
||||
/**
|
||||
* 获取 Native Addon 路径
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
*/
|
||||
export function getAddonPath (binaryPath: string): string {
|
||||
const addonFileName: string = process.platform + '.' + process.arch;
|
||||
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
|
||||
return addonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg Native Addon 适配器实现
|
||||
*/
|
||||
export class FFmpegAddonAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegAddon';
|
||||
private addon: FFmpeg | null = null;
|
||||
private binaryPath: string;
|
||||
|
||||
constructor (binaryPath: string) {
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Addon 是否可用
|
||||
*/
|
||||
async isAvailable (): Promise<boolean> {
|
||||
try {
|
||||
const temp_addon = { exports: {} };
|
||||
dlopen(temp_addon, getAddonPath(this.binaryPath));
|
||||
this.addon = temp_addon.exports as FFmpeg;
|
||||
return this.addon !== null;
|
||||
} catch (error) {
|
||||
console.log('[FFmpegAddonAdapter] Failed to load addon:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAddon (): FFmpeg {
|
||||
if (!this.addon) {
|
||||
throw new Error('FFmpeg Addon is not available');
|
||||
}
|
||||
return this.addon;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
|
||||
const format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
|
||||
console.log('[FFmpegAddonAdapter] Detected format:', format);
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
duration: info.duration,
|
||||
format,
|
||||
thumbnail: info.image,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
const addon = this.ensureAddon();
|
||||
return addon.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
*/
|
||||
async isSilk (filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fd = openSync(filePath, 'r');
|
||||
const buffer = Buffer.alloc(10);
|
||||
readSync(fd, buffer, 0, 10, 0);
|
||||
closeSync(fd);
|
||||
const header = buffer.toString();
|
||||
return header.includes('#!SILK') || header.includes('\x02#!SILK');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
const addon = this.ensureAddon();
|
||||
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
console.log('[FFmpegAddonAdapter] Converting file:', inputFile, 'to', outputFile, 'as', format);
|
||||
await addon.decodeAudioToFmt(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
await addon.convertToNTSilkTct(inputFile, outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string, format: 'jpg' | 'png' = 'jpg'): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath, format);
|
||||
|
||||
// 将缩略图写入文件
|
||||
await writeFile(thumbnailPath, info.image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* FFmpeg Node.js Native Addon Type Definitions
|
||||
*
|
||||
* This addon provides FFmpeg functionality for Node.js including:
|
||||
* - Video information extraction with thumbnail generation
|
||||
* - Audio/Video duration detection
|
||||
* - Audio format conversion to NTSILK
|
||||
* - Audio decoding to PCM
|
||||
*/
|
||||
|
||||
/**
|
||||
* Video information result object
|
||||
*/
|
||||
export interface VideoInfo {
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Generated thumbnail image format */
|
||||
format: string;
|
||||
|
||||
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||
videoCodec: string;
|
||||
|
||||
/** First frame thumbnail as JPEG image buffer */
|
||||
image: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio PCM decoding result object
|
||||
*/
|
||||
export interface AudioPCMResult {
|
||||
/** PCM audio data as 16-bit signed integer samples */
|
||||
pcm: Buffer;
|
||||
|
||||
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||
sampleRate: number;
|
||||
|
||||
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg interface providing all audio/video processing methods
|
||||
*/
|
||||
export interface FFmpeg {
|
||||
convertFile (inputFile: string, outputFile: string, format: string): Promise<{ success: boolean; }>;
|
||||
/**
|
||||
* Get video information including resolution, duration, format, codec and first frame thumbnail
|
||||
*/
|
||||
getVideoInfo (filePath: string, format?: 'jpg' | 'png'): Promise<VideoInfo>;
|
||||
|
||||
/**
|
||||
* Get duration of audio or video file in seconds
|
||||
*/
|
||||
getDuration (filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Convert audio file to NTSILK format (WeChat voice message format)
|
||||
*/
|
||||
convertToNTSilkTct (inputPath: string, outputPath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Decode audio file to raw PCM data
|
||||
*/
|
||||
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
|
||||
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
|
||||
|
||||
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* FFmpeg Exec Adapter
|
||||
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDirExists (filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 命令行适配器实现
|
||||
*/
|
||||
export class FFmpegExecAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegExec';
|
||||
|
||||
constructor (
|
||||
private ffmpegPath: string = 'ffmpeg',
|
||||
private ffprobePath: string = 'ffprobe'
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 检查 FFmpeg 和 FFprobe 是否可用
|
||||
*/
|
||||
async isAvailable (): Promise<boolean> {
|
||||
// 首先检查当前路径
|
||||
try {
|
||||
await Promise.all([
|
||||
execFileAsync(this.ffmpegPath, ['-version'], { timeout: 10000, windowsHide: true }),
|
||||
execFileAsync(this.ffprobePath, ['-version'], { timeout: 10000, windowsHide: true }),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径
|
||||
*/
|
||||
setFFmpegPath (ffmpegPath: string): void {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFprobe 路径
|
||||
*/
|
||||
setFFprobePath (ffprobePath: string): void {
|
||||
this.ffprobePath = ffprobePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
const [probe, frame] = await Promise.all([
|
||||
execFileAsync(this.ffprobePath, [
|
||||
'-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height:format=duration,format_name',
|
||||
'-of', 'json', videoPath,
|
||||
], { timeout: 30000, windowsHide: true }),
|
||||
execFileAsync(this.ffmpegPath, [
|
||||
'-v', 'error', '-i', videoPath, '-frames:v', '1',
|
||||
'-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1',
|
||||
], { encoding: 'buffer', maxBuffer: 16 * 1024 * 1024, timeout: 30000, windowsHide: true }),
|
||||
]);
|
||||
const metadata = JSON.parse(probe.stdout);
|
||||
const stream = metadata.streams[0];
|
||||
const duration = Number(metadata.format.duration);
|
||||
if (!stream || !Number.isFinite(duration) || duration < 0 || frame.stdout.length === 0) {
|
||||
throw new Error('FFmpeg 无法读取视频信息或首帧: ' + videoPath);
|
||||
}
|
||||
return {
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
duration,
|
||||
format: metadata.format.format_name,
|
||||
thumbnail: frame.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
const { stdout } = await execFileAsync(this.ffprobePath, [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
filePath,
|
||||
], { timeout: 30000, windowsHide: true });
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
throw new Error(`ffprobe 返回了无效的时长值: "${stdout.trim()}"`);
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
*/
|
||||
async isSilk (filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const fd = openSync(filePath, 'r');
|
||||
const buffer = Buffer.alloc(10);
|
||||
readSync(fd, buffer, 0, 10, 0);
|
||||
closeSync(fd);
|
||||
const header = buffer.toString();
|
||||
return header.includes('#!SILK') || header.includes('\x02#!SILK');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
try {
|
||||
ensureDirExists(pcmPath);
|
||||
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-f', 's16le',
|
||||
pcmPath,
|
||||
], { timeout: 120000, windowsHide: true });
|
||||
|
||||
if (!existsSync(pcmPath)) {
|
||||
throw new Error('转换PCM失败,输出文件不存在');
|
||||
}
|
||||
|
||||
return { result: true, sampleRate: 24000 };
|
||||
} catch (error: any) {
|
||||
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(outputFile);
|
||||
|
||||
const params = format === 'amr'
|
||||
? [
|
||||
'-i', inputFile,
|
||||
'-vn', '-ac', '1',
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile,
|
||||
]
|
||||
: [
|
||||
'-i', inputFile,
|
||||
'-vn', '-ac', '1',
|
||||
...(format === 'spx' ? ['-ar', '32000'] : []),
|
||||
'-y',
|
||||
outputFile,
|
||||
];
|
||||
|
||||
await execFileAsync(this.ffmpegPath, params, { timeout: 120000, windowsHide: true });
|
||||
|
||||
if (!existsSync(outputFile)) {
|
||||
throw new Error('转换失败,输出文件不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error);
|
||||
throw new Error(`文件转换失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(thumbnailPath);
|
||||
|
||||
const { stderr } = await execFileAsync(this.ffmpegPath, [
|
||||
'-i', videoPath,
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath,
|
||||
], { timeout: 30000, windowsHide: true });
|
||||
|
||||
if (!existsSync(thumbnailPath)) {
|
||||
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
|
||||
ensureDirExists(outputFile);
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-v', 'error', '-y', '-i', inputFile, '-vn', '-ac', '1', '-ar', '24000',
|
||||
'-c:a', 'ntsilk_s16le', '-f', 'ntsilk_s16le', outputFile,
|
||||
], { timeout: 120000, windowsHide: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { statSync, existsSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { VideoInfo } from './video';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
|
||||
|
||||
export const getFFmpegPath = (tool: 'ffmpeg' | 'ffprobe', binaryPath?: string): string => {
|
||||
const configuredPath = process.env[tool === 'ffmpeg' ? 'FFMPEG_PATH' : 'FFPROBE_PATH'];
|
||||
if (configuredPath) return configuredPath;
|
||||
const executable = tool + (process.platform === 'win32' ? '.exe' : '');
|
||||
if (binaryPath) {
|
||||
const localPath = path.join(binaryPath, 'ffmpeg', executable);
|
||||
if (existsSync(localPath)) return localPath;
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const directories = (process.env['PATH'] ?? '').split(':').filter(Boolean);
|
||||
directories.push(process.arch === 'arm64' ? '/opt/homebrew/bin' : '/usr/local/bin');
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(directory, executable);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return executable;
|
||||
};
|
||||
|
||||
export let FFMPEG_CMD = 'ffmpeg';
|
||||
export let FFPROBE_CMD = 'ffprobe';
|
||||
export class FFmpegService {
|
||||
private static adapter: IFFmpegAdapter | null = null;
|
||||
private static initialized = false;
|
||||
private static initializationError: unknown;
|
||||
|
||||
/**
|
||||
* 初始化 FFmpeg 服务
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
* @param logger 日志记录器
|
||||
*/
|
||||
public static async init (binaryPath: string, logger: LogWrapper): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查本地 ffmpeg 路径
|
||||
FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath);
|
||||
FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath);
|
||||
|
||||
try {
|
||||
this.adapter = await FFmpegAdapterFactory.getAdapter(
|
||||
logger,
|
||||
FFMPEG_CMD,
|
||||
FFPROBE_CMD,
|
||||
binaryPath
|
||||
);
|
||||
} catch (error) {
|
||||
this.initializationError = error;
|
||||
logger.logError('[FFmpeg] 媒体功能初始化失败:', error);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
public static getAdapterName (): string {
|
||||
if (!this.adapter) {
|
||||
throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 FFmpeg 适配器
|
||||
*/
|
||||
private static async getAdapter (): Promise<IFFmpegAdapter> {
|
||||
if (!this.adapter) {
|
||||
throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertToNTSilkTct(inputFile, outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径并更新适配器
|
||||
* @deprecated 建议使用 init() 方法初始化
|
||||
*/
|
||||
public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise<void> {
|
||||
const suffix = process.platform === 'win32' ? '.exe' : '';
|
||||
const ffmpegCommand = path.join(ffmpegPath, 'ffmpeg' + suffix);
|
||||
const ffprobeCommand = path.join(ffmpegPath, 'ffprobe' + suffix);
|
||||
this.adapter = await FFmpegAdapterFactory.updateFFmpegPath(logger, ffmpegCommand, ffprobeCommand);
|
||||
FFMPEG_CMD = ffmpegCommand;
|
||||
FFPROBE_CMD = ffprobeCommand;
|
||||
this.initializationError = undefined;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
*/
|
||||
public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接发送到 QQ kernel 的旧视频元素使用 PNG 缩略图。
|
||||
* Packet/forward 路径继续使用当前适配器生成的 JPEG。
|
||||
*/
|
||||
public static async extractLegacyVideoThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const currentAdapter = await this.getAdapter();
|
||||
if (currentAdapter instanceof FFmpegAddonAdapter) {
|
||||
await currentAdapter.extractThumbnail(videoPath, thumbnailPath, 'png');
|
||||
return;
|
||||
}
|
||||
const adapter = new FFmpegExecAdapter(FFMPEG_CMD, FFPROBE_CMD);
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
*/
|
||||
public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertFile(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频时长
|
||||
*/
|
||||
public static async getDuration (filePath: string): Promise<number> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Silk 格式
|
||||
*/
|
||||
public static async isSilk (filePath: string): Promise<boolean> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.isSilk(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM 格式
|
||||
*/
|
||||
public static async convert (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.convertToPCM(filePath, pcmPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||
const adapter = await this.getAdapter();
|
||||
|
||||
const fileSize = statSync(videoPath).size;
|
||||
const videoInfo = await adapter.getVideoInfo(videoPath);
|
||||
if (thumbnailPath && videoInfo.thumbnail) {
|
||||
writeFileSync(thumbnailPath, videoInfo.thumbnail);
|
||||
}
|
||||
return {
|
||||
width: videoInfo.width,
|
||||
height: videoInfo.height,
|
||||
time: videoInfo.duration,
|
||||
format: videoInfo.format,
|
||||
size: fileSize,
|
||||
filePath: videoPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const defaultVideoThumbB64 =
|
||||
'/9j/4AAQSkZJRgABAQAAAQABAAD//gAXR2VuZXJhdGVkIGJ5IFNuaXBhc3Rl/9sAhAAKBwcIBwYKCAgICwoKCw4YEA4NDQ4dFRYRGCMfJSQiHyIhJis3LyYpNCkhIjBBMTQ5Oz4+PiUuRElDPEg3PT47AQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAF/APADAREAAhEBAxEB/8QBogAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoLEAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+foBAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKCxEAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDiAayNxwagBwNAC5oAM0xBmgBM0ANJoAjY0AQsaBkTGgCM0DEpAFAC0AFMBaACgAoEJTASgQlACUwCgQ4UAOFADhQA4UAOFADxQIkBqDQUGgBwagBQaBC5pgGaAELUAMLUARs1AETGgBhNAxhoASkAUALQIKYxaBBQAUwEoAQ0CEoASmAUAOoEKKAHCgBwoAeKAHigQ7NZmoZpgLmgBd1Ahd1ABupgNLUAMLUAMY0AMJoAYaAENACUCCgAoAWgAoAWgBKYCUAJQISgApgLQAooEOFACigB4oAeKBDxQAVmaiZpgGaAFzQAbqAE3UAIWpgNJoAYTQIaaAEoAQ0CEoASgBaACgBaACmAUAJQAlAgoAKYC0AKKBCigB4FADgKBDwKAHigBuazNRM0DEzTAM0AJmgAzQAhNAhpNACGmA2gQlACUCEoAKACgBaAFpgFACUAJQAUCCmAUALQIcBQA4CgB4FADgKBDhQA4UAMzWZqNzTGJQAZoATNABmgBKAEoEIaYCUCEoASgQlABQAtABQAtMBKACgAoEFABimAYoEKBQA4CgB4FADwKBDgKAFFADhQBCazNhKAEpgFACUAFACUAFAhDTAbQISgAoEJQAUALQAtMAoAKADFABigQYoAMUALimIUCgBwFAh4FADgKAHUALQAtAENZmwlACUwEoAKAEoAKACgQlMBpoEJQAUCCgBcUAFABTAXFAC4oAMUAGKBBigAxQIKYCigQ8UAOFADhQAtAC0ALQBDWZqJQMSgBKYBQAlABQISgBKYCGgQlAC0CCgBcUAFABTAUCkA7FMAxQAYoEJQAUCCmAooEOFADxQA4UAFAC0ALQBDWZqJQAlACUxhQAlABQIKAEoASmISgBcUCCgBaACgBcUAKBQAuKYC0CEoAQ0AJQISmAooEPFADhQA4UALQAtAC0AQ1maiUAFACUAJTAKAEoAKAEoAMUxBigAxQIWgAoAKAFAoAWgBaYBQIQ0ANNACUCCmIUUAOFADxQA4UALQAtABQBFWZqFACUAFACYpgFACUAFACUAFAgxTEFABQAUALQAooAWgAoAKYDTQIaaAEpiCgQ4UAOFAh4oGOFAC0ALSAKYEdZmglABQAUDDFACUwEoASgAoAKBBQIKYBQAUALQAtAC0AJQAhpgNJoENJoATNMQCgQ8UCHigB4oAWgYtABQAUAMrM0CgAoAKADFACUxiUAJQAlAgoAKYgoAKACgYtAC0AFAhDTAQmgBhNAhpNACZpiFBoEPFAEi0CHigB1ABQAUDEoAbWZoFABQAtABTAQ0ANNAxDQAlAhaAEpiCgAoGFAC0AFABmgBCaYhpNADCaBDSaBBmgABpiJFNAEimgB4NADqAFzQAlACE0AJWZoFAC0AFAC0wEIoAaaAG0AJQAUCCgApjCgAoAKADNABmgBpNMQ0mgBpNAhhNAgzQAoNADwaAHqaAJAaBDgaYC5oATNACZoAWszQKACgBaBDqYCGgBpoAYaBiUCCgBKYBQMKACgAoAM0AITQIaTQA0mmA0mgQ3NAhKAHCgBwNADwaAHg0AOBpiFzQAZoATNAD6zNAoAKAFoEOpgBoAaaAGGmAw0AJmgAzQMM0AGaADNABmgBM0AITQIaTQAhNMQw0AJQIKAFFADhQA4GgBwNADs0xC5oAM0CDNAEtZmoUCCgBaAHUwCgBppgRtQAw0ANzQAZoAM0AGaADNABmgBKAEoAQ0ANNMQhoEJQAlMBaQDgaAFBoAcDTAdmgQuaADNAgzQBPWZqFAgoAWgBaYC0CGmmBG1AyM0ANJoATNACZoAXNABmgAzQAUAJQAhoAQ0xDTQISmAUALQAUgHA0AKDTAdmgQuaBBQAtAFiszQKACgBaAFFMAoEIaYEbUDI2oAYaAEoASgAzQAuaACgAoAKAENMQ00AJTEFAhKACgAoAXNACg0AOBoAWgQtAC0AWazNAoAKACgBaYBQIQ0AMNMYw0AMIoAbQAlMAoAKACgAzSAKYhKAENACUxBQIKACgBKACgBaAHCgQ4UALQAUAWqzNAoAKACgApgFACGgQ00xjTQAwigBCKAG4pgJQAlABQAUCCgBKACgBKYgoEFABQISgAoAWgBRQA4UALQAUCLdZmoUAFABQAlMAoASgBDQA00wENACYoATFMBpFADSKAEoEJQAUAFABQAlMQtAgoASgQUAJQAUAKKAHCgBaBBQBbrM1CgAoAKACmAUAJQAlADaYBQAlACYpgIRQA0igBpFAhtABQAUAFMAoEFABQIKAEoASgQUALQAooAWgQUAW81mbC0CCgApgFACUAIaAEpgJQAUAFABQAhFMBpFADSKAGkUCExQAYoAMUAGKADFMQYoAMUCExSATFABQIKYBQAtABQIt5qDYM0ALmgQtIApgIaAENADaACmAlAC0ALQAUwGkUANIoAaRQAmKBBigAxQAYoAMUAGKBBigBMUAJigQmKAExTAKBC0AFAFnNQaig0AKDQAtAgoASgBDQAlMBKACgAFADhQAtMBCKAGkUAIRQAmKADFABigQmKADFACYoAXFABigQmKAExQAmKBCYpgJigAoAnzUGgZoAcDQAuaBC0AJQAhoASmAlABQAtADhQAtMAoATFACEUAJigAxQAYoATFAhMUAFABQAuKADFABigBpWgBCKBCYpgJigB+ag0DNADgaBDgaAFzQITNACUAJTAKACgBRQAopgOoAWgBKAEoAKACgAoASgBpoEJQAooAWgBaBhigBMUCEIoAQigBMUAJSLCgBQaBDgaQC5oEFACUwCgBKACmAtADhQA4UALQAUAJQAUAJQAUAJQAhoENoAWgBRQAooGLQAUAGKAGkUAIRQIZSKEoGKKBDhQAUCCgAoAKBBQAUwFoGKKAHCgBaACgAoASgAoASgBCaAEoEJmgAoAUGgBQaAHZoGFABQAUANoAjpDEoAWgBaAFoEFACUALQAUCCmAUAOFAxRQAtAC0AJQAUAJQAmaBDSaAEzQAmaYBmgBQaAHA0gFzQAuaBhmgAzQAlAEdIYUALQAtAgoAKAEoEFAC0AFMAoAUUDFFAC0ALQAUAJQAhoENNACE0wEoATNABmgBc0ALmgBc0gDNAC5oATNABmgBKRQlACigB1AgoASgQlABTAWgBKACgBaBi0ALQAZoAM0AFACGgQ00wENACUAJQAUCFzQMM0ALmgAzQAZoAM0AGaQC0igoAUUALQIWgBDQISmAUAFACUAFABQAuaBi5oAM0AGaBBmgBKAEpgIaAG0AJQAUCFoAM0DDNAC5oATNABmgAzQBJUlBQAooAWgQtACGmIaaACgAoASgBKACgBc0DCgQUAGaADNABTASgBDQAlACUAFAgoAKBhQAUAFABQAlAE1SUFAxRQIWgQtMBDQIQ0AJQAlAhKBiUAFABmgBc0AGaADNABTAKACgBKAEoASgQlABQAUAFAC0AFACUAFAE1SaBQAUCHCgQtMBKBCUAJQISgBDQA00DEzQAuaADNMBc0AGaADNABQAUAJQAlABQISgAoAKACgBaACgBKAEoAnqTQSgBRQIcKBC0xCUAJQISgBKAENADDQAmaYwzQAuaADNAC0AFABQAUAFAhKACgBKACgAoAWgAoELQAlAxKAJqk0EoAWgQooELTEFADaBCUABoENNMY00ANNAwzQAZoAXNAC0AFAC0CFoASgAoASgBKACgAoAWgQtABQAUANNAyWpNAoAKBCimIWgQUCEoASmIQ0ANNADTQMaaAEoGLmgAzQAtADhQIWgBaACgQhoASgYlACUALQIWgBaACgBKAENAyWpNBKYBQIcKBC0CEoEJTAKBCUANNADDQMQ0ANoGFAC5oAUGgBwNAhRQIWgBaAENACGgBtAwoAKAFzQIXNABmgAoAQ0DJKRoJQAtAhRQSLQIKYCUCCgBDQA00AMNAxpoGNoAM0AGaAFBoAcDQIcKBDqACgBDQAhoAQ0DEoAKADNAC5oEGaBhmgAoAkpGgUCCgQooELQIKYhKACgBKAGmgBpoGMNAxDQAlAwzQIUUAOFAhwoAcKBC0AJQAhoGNNACUAFABQAZoAXNABQAUAS0ixKACgQoNAhaYgoEFACUABoAaaAGmgYw0DENAxtABQAooEOFADhQIcKAFoASgBDQAhoGJQAUAF
Line truncated
|
||||
|
||||
export interface VideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
time: number;
|
||||
format: string;
|
||||
size: number;
|
||||
filePath: string;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
autosize: number,
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
filename: string,
|
||||
tsum: number,
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
senderName?: string;
|
||||
isGroupMsg?: boolean;
|
||||
msg?: ForwardAdaptMsgElement[];
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsgElement {
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||
}
|
||||
if (!news) {
|
||||
news = msg.length === 0
|
||||
? [{
|
||||
text: 'Nya~ This message is send from NapCat.Packet!',
|
||||
}]
|
||||
: msg.map(m => ({
|
||||
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
|
||||
}));
|
||||
}
|
||||
if (!summary) {
|
||||
summary = `查看${msg.length}条转发消息`;
|
||||
}
|
||||
if (!prompt) {
|
||||
prompt = '[聊天记录]';
|
||||
}
|
||||
return {
|
||||
app: 'com.tencent.multimsg',
|
||||
config: {
|
||||
autosize: 1,
|
||||
forward: 1,
|
||||
round: 1,
|
||||
type: 'normal',
|
||||
width: 300,
|
||||
},
|
||||
desc: prompt,
|
||||
extra: {
|
||||
filename: id,
|
||||
tsum: msg.length,
|
||||
},
|
||||
meta: {
|
||||
detail: {
|
||||
news,
|
||||
resid: resId,
|
||||
source,
|
||||
summary,
|
||||
uniseq: id,
|
||||
},
|
||||
},
|
||||
prompt,
|
||||
ver: '0.0.0.5',
|
||||
view: 'contact',
|
||||
};
|
||||
}
|
||||
|
||||
static fromResId (resId: string): ForwardMsgJson {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
return this.build(resId, packetMsg.map(msg => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
})),
|
||||
})),
|
||||
source,
|
||||
news,
|
||||
summary,
|
||||
prompt,
|
||||
uuid
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { NodeIKernelRichMediaService } from '../services/NodeIKernelRichMediaService';
|
||||
|
||||
type GroupFileService = Pick<NodeIKernelRichMediaService, 'getGroupFileInfo' | 'deleteGroupFile'>;
|
||||
|
||||
function withTimeout<T> (promise: Promise<T>, operation: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${operation}超时,请重新查询群文件列表确认文件状态`)), 15000);
|
||||
promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
function assertBusId (busId: number): void {
|
||||
if (!Number.isInteger(busId) || busId <= 0 || busId > 0xFFFFFFFF) {
|
||||
throw new Error('Invalid bus_id');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGroupFiles (service: GroupFileService, groupCode: string, files: string[], busIds?: number[]) {
|
||||
if (!files.length || files.some(fileId => !fileId)) {
|
||||
throw new Error('Invalid file_id');
|
||||
}
|
||||
if (busIds && busIds.length !== files.length) {
|
||||
throw new Error('bus_id 数量必须与文件数量一致');
|
||||
}
|
||||
|
||||
const resolvedBusIds: number[] = [];
|
||||
for (const [index, fileId] of files.entries()) {
|
||||
let busId = busIds?.[index];
|
||||
if (busId === undefined) {
|
||||
const info = await withTimeout(service.getGroupFileInfo(groupCode, fileId), '查询群文件信息');
|
||||
if (info?.result !== 0) {
|
||||
throw new Error(`查询群文件信息失败: ${info?.errMsg || info?.result}`);
|
||||
}
|
||||
const detail = info.groupFileInfoResult;
|
||||
if (detail?.retCode !== 0 || detail.fileInfo?.fileId !== fileId) {
|
||||
throw new Error(`查询群文件信息失败: ${detail?.clientWording || detail?.retMsg || '未返回目标文件信息'}`);
|
||||
}
|
||||
busId = detail.fileInfo.busId;
|
||||
}
|
||||
assertBusId(busId);
|
||||
resolvedBusIds.push(busId);
|
||||
}
|
||||
|
||||
const response = await withTimeout(service.deleteGroupFile(groupCode, resolvedBusIds, files), '删除群文件');
|
||||
if (response?.result !== 0) {
|
||||
throw new Error(`删除群文件失败: ${response?.errMsg || response?.result}`);
|
||||
}
|
||||
const detail = response.transGroupFileResult;
|
||||
if (detail?.result?.retCode !== 0) {
|
||||
throw new Error(`删除群文件失败: ${detail?.result?.clientWording || detail?.result?.retMsg || detail?.result?.retCode || '缺少删除结果'}`);
|
||||
}
|
||||
if (!Array.isArray(detail.successFileIdList) || !Array.isArray(detail.failFileIdList)) {
|
||||
throw new Error('删除群文件失败: 缺少逐文件删除结果');
|
||||
}
|
||||
const missing = files.filter(fileId => !detail.successFileIdList.includes(fileId));
|
||||
if (detail.failFileIdList.length || missing.length) {
|
||||
const failed = [...new Set([...detail.failFileIdList, ...missing])];
|
||||
throw new Error(`删除群文件失败: ${failed.join(', ')}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import winston, { format, transports } from 'winston';
|
||||
import { truncateString } from 'napcat-common/src/helper';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/napcat-core/index';
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
function getFormattedTimestamp () {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = now.getDate().toString().padStart(2, '0');
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||
const milliseconds = now.getMilliseconds().toString().padStart(3, '0');
|
||||
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
const logEmitter = new EventEmitter();
|
||||
export type LogListener = (msg: string) => void;
|
||||
class Subscription {
|
||||
public static MAX_HISTORY = 100;
|
||||
public static history: string[] = [];
|
||||
|
||||
subscribe (listener: LogListener) {
|
||||
for (const history of Subscription.history) {
|
||||
try {
|
||||
listener(history);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
logEmitter.on('log', listener);
|
||||
}
|
||||
|
||||
unsubscribe (listener: LogListener) {
|
||||
logEmitter.off('log', listener);
|
||||
}
|
||||
|
||||
notify (msg: string) {
|
||||
logEmitter.emit('log', msg);
|
||||
if (Subscription.history.length >= Subscription.MAX_HISTORY) {
|
||||
Subscription.history.shift();
|
||||
}
|
||||
Subscription.history.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export const logSubscription = new Subscription();
|
||||
|
||||
export class LogWrapper implements ILogWrapper {
|
||||
fileLogEnabled = false;
|
||||
consoleLogEnabled = true;
|
||||
logger: winston.Logger;
|
||||
|
||||
private logPath: string;
|
||||
private fileTransportAdded = false;
|
||||
private fileLogLevel: string = LogLevel.DEBUG;
|
||||
|
||||
constructor (logDir: string) {
|
||||
const filename = `${getFormattedTimestamp()}.log`;
|
||||
this.logPath = path.join(logDir, filename);
|
||||
|
||||
this.logger = winston.createLogger({
|
||||
level: 'debug',
|
||||
format: format.combine(
|
||||
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
|
||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new transports.Console({
|
||||
format: format.combine(
|
||||
format.colorize(),
|
||||
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||
})
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
this.setLogSelfInfo({ nick: '', uid: '' });
|
||||
this.cleanOldLogs(logDir);
|
||||
}
|
||||
|
||||
cleanOldLogs (logDir: string) {
|
||||
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
fs.readdir(logDir).then((files) => {
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(logDir, file);
|
||||
this.deleteOldLogFile(filePath, oneWeekAgo);
|
||||
});
|
||||
}).catch((err) => {
|
||||
this.logError('Failed to read log directory', err);
|
||||
});
|
||||
}
|
||||
|
||||
private deleteOldLogFile (filePath: string, oneWeekAgo: number) {
|
||||
fs.stat(filePath).then((stats) => {
|
||||
if (stats.mtime.getTime() < oneWeekAgo) {
|
||||
fs.unlink(filePath).catch((err) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
this.logWarn(`File already deleted: ${filePath}`);
|
||||
} else {
|
||||
this.logError('Failed to delete old log file', err);
|
||||
}
|
||||
} else {
|
||||
this.log(`Deleted old log file: ${filePath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.logError('Failed to get file stats', err);
|
||||
});
|
||||
}
|
||||
|
||||
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel) {
|
||||
this.fileLogLevel = fileLogLevel;
|
||||
this.logger.transports.forEach((transport) => {
|
||||
if (transport instanceof transports.File) {
|
||||
transport.level = fileLogLevel;
|
||||
} else if (transport instanceof transports.Console) {
|
||||
transport.level = consoleLogLevel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLogSelfInfo (selfInfo: { nick: string; uid: string; }) {
|
||||
const userInfo = `${selfInfo.nick}`;
|
||||
this.logger.defaultMeta = { userInfo };
|
||||
}
|
||||
|
||||
setFileLogEnabled (isEnabled: boolean) {
|
||||
this.fileLogEnabled = isEnabled;
|
||||
if (isEnabled && !this.fileTransportAdded) {
|
||||
this.fileTransportAdded = true;
|
||||
this.logger.add(new transports.File({
|
||||
filename: this.logPath,
|
||||
level: this.fileLogLevel,
|
||||
maxsize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 5,
|
||||
}));
|
||||
}
|
||||
this.logger.transports.forEach((transport) => {
|
||||
if (transport instanceof transports.File) {
|
||||
transport.silent = !isEnabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConsoleLogEnabled (isEnabled: boolean) {
|
||||
this.consoleLogEnabled = isEnabled;
|
||||
this.logger.transports.forEach((transport) => {
|
||||
if (transport instanceof transports.Console) {
|
||||
transport.silent = !isEnabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatMsg (msg: any[]) {
|
||||
return msg
|
||||
.map((msgItem) => {
|
||||
if (msgItem instanceof Error) {
|
||||
return msgItem.stack;
|
||||
} else if (typeof msgItem === 'object') {
|
||||
return JSON.stringify(truncateString(JSON.parse(JSON.stringify(msgItem, null, 2))));
|
||||
}
|
||||
return msgItem;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
_log (level: LogLevel, ...args: any[]) {
|
||||
if (this.consoleLogEnabled || this.fileLogEnabled) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const message = this.formatMsg(args).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1B\[[0-?]*[ -/]*[@-~]|\x1B/g, '');
|
||||
if (this.consoleLogEnabled && this.fileLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.consoleLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
} else if (this.fileLogEnabled) {
|
||||
this.logger.log(level, message);
|
||||
}
|
||||
logSubscription.notify(JSON.stringify({ level, message }));
|
||||
}
|
||||
}
|
||||
|
||||
log (...args: any[]) {
|
||||
this._log(LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
logDebug (...args: any[]) {
|
||||
this._log(LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
logError (...args: any[]) {
|
||||
this._log(LogLevel.ERROR, ...args);
|
||||
}
|
||||
|
||||
logWarn (...args: any[]) {
|
||||
this._log(LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
logFatal (...args: any[]) {
|
||||
this._log(LogLevel.FATAL, ...args);
|
||||
}
|
||||
|
||||
logMessage (msg: RawMessage, selfInfo: SelfInfo) {
|
||||
const isSelfSent = msg.senderUin === selfInfo.uin;
|
||||
|
||||
if (msg.elements[0]?.elementType === ElementType.GreyTip) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`${isSelfSent ? '发送 ->' : '接收 <-'} ${rawMessageToText(msg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function rawMessageToText (msg: RawMessage, recursiveLevel = 0): string {
|
||||
if (recursiveLevel > 2) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
|
||||
if (msg.chatType === ChatType.KCHATTYPEC2C) {
|
||||
tokens.push(`私聊 (${msg.peerUin})`);
|
||||
} else if (msg.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
if (recursiveLevel < 1) {
|
||||
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
|
||||
}
|
||||
if (msg.senderUin !== '0') {
|
||||
tokens.push(`[${msg.sendMemberName || msg.sendRemarkName || msg.sendNickName}(${msg.senderUin})]`);
|
||||
}
|
||||
} else if (msg.chatType === ChatType.KCHATTYPEDATALINE) {
|
||||
tokens.push('移动设备');
|
||||
} else {
|
||||
tokens.push(`临时消息 (${msg.peerUin})`);
|
||||
}
|
||||
|
||||
for (const element of msg.elements) {
|
||||
tokens.push(msgElementToText(element, msg, recursiveLevel));
|
||||
}
|
||||
|
||||
return tokens.join(' ');
|
||||
}
|
||||
|
||||
function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLevel: number): string {
|
||||
if (element.textElement) {
|
||||
return textElementToText(element.textElement);
|
||||
}
|
||||
|
||||
if (element.replyElement) {
|
||||
return replyElementToText(element.replyElement, msg, recursiveLevel);
|
||||
}
|
||||
|
||||
if (element.picElement) {
|
||||
return '[图片]';
|
||||
}
|
||||
|
||||
if (element.fileElement) {
|
||||
if (element.fileElement.fileUuid) {
|
||||
return `[文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.TOFURECORD) {
|
||||
return `[在线文件 ${element.fileElement.fileName}]`;
|
||||
} else if (element.elementType === ElementType.ONLINEFOLDER) {
|
||||
return `[在线文件夹 ${element.fileElement.fileName}/]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.videoElement) {
|
||||
return '[视频]';
|
||||
}
|
||||
|
||||
if (element.pttElement) {
|
||||
return `[语音 ${element.pttElement.duration}s]`;
|
||||
}
|
||||
|
||||
if (element.arkElement) {
|
||||
return '[卡片消息]';
|
||||
}
|
||||
|
||||
if (element.faceElement) {
|
||||
return `[表情 ${element.faceElement.faceText ?? ''}]`;
|
||||
}
|
||||
|
||||
if (element.marketFaceElement) {
|
||||
return element.marketFaceElement.faceName;
|
||||
}
|
||||
|
||||
if (element.markdownElement) {
|
||||
if (element.markdownElement?.mdSummary) {
|
||||
return element.markdownElement.mdSummary;
|
||||
} else {
|
||||
return '[Markdown 消息]';
|
||||
}
|
||||
}
|
||||
|
||||
if (element.multiForwardMsgElement) {
|
||||
return '[转发消息]';
|
||||
}
|
||||
|
||||
if (element.elementType === ElementType.GreyTip) {
|
||||
return '[灰条消息]';
|
||||
} else if (element.elementType === ElementType.FILE) {
|
||||
return '[文件发送中]';
|
||||
}
|
||||
|
||||
return `[未实现 (ElementType = ${element.elementType})]`;
|
||||
}
|
||||
|
||||
function textElementToText (textElement: any): string {
|
||||
if (textElement.atType === NTMsgAtType.ATTYPEUNKNOWN) {
|
||||
const originalContentLines = textElement.content.split('\n');
|
||||
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
|
||||
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
|
||||
return '@全体成员';
|
||||
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
|
||||
return `${textElement.content} (${textElement.atUid})`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function replyElementToText (replyElement: any, msg: RawMessage, recursiveLevel: number): string {
|
||||
const recordMsgOrNull = msg.records.find((record) => replyElement.sourceMsgIdInRecords === record.msgId);
|
||||
return `[回复消息 ${recordMsgOrNull && recordMsgOrNull.peerUin !== '284840486' && recordMsgOrNull.peerUin !== '1094950020'
|
||||
? rawMessageToText(recordMsgOrNull, recursiveLevel + 1)
|
||||
: `未找到消息记录 (MsgId = ${replyElement.sourceMsgIdInRecords})`
|
||||
}]`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { PicType } from '../types';
|
||||
export async function getFileTypeForSendType (picPath: string): Promise<PicType> {
|
||||
const fileTypeResult = (await fileTypeFromFile(picPath))?.ext ?? 'jpg';
|
||||
const picTypeMap: { [key: string]: PicType } = {
|
||||
// 'webp': PicType.NEWPIC_WEBP,
|
||||
gif: PicType.NEWPIC_GIF,
|
||||
// 'png': PicType.NEWPIC_APNG,
|
||||
// 'jpg': PicType.NEWPIC_JPEG,
|
||||
// 'jpeg': PicType.NEWPIC_JPEG,
|
||||
// 'bmp': PicType.NEWPIC_BMP,
|
||||
};
|
||||
return picTypeMap[fileTypeResult] ?? PicType.NEWPIC_JPEG;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface OfflineRecoveryOptions {
|
||||
// 当前账号是否在线
|
||||
isOnline: () => boolean;
|
||||
// 刚下线:同步 WebUI 状态
|
||||
onOffline: (tips: string) => void;
|
||||
// 静默离线期间账号自己恢复了,不需要重启
|
||||
onRecovered: () => void;
|
||||
// 重启 Worker;返回 false 或抛错表示失败,之后可以再次触发
|
||||
restart: (tips: string) => Promise<boolean>;
|
||||
// 被踢下线后多久重启。常规状态轮询为 3 秒,留出一个轮询周期,确保浏览器先清除旧二维码
|
||||
kickedDelayMs?: number;
|
||||
// 静默离线后多久重启。网络短暂断开时 QQ 也可能报离线,先给它自己重连的机会,
|
||||
// 不然一次网络抖动就会把整个 Worker 重启掉
|
||||
selfOfflineDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下线恢复:被踢下线(KickedOffLine)和静默离线(SelfOffline)共用。
|
||||
* 返回的函数在每次收到下线事件时调用,allowSelfRecovery 为 true 表示静默离线。
|
||||
*/
|
||||
export function createOfflineRecovery ({
|
||||
isOnline,
|
||||
onOffline,
|
||||
onRecovered,
|
||||
restart,
|
||||
kickedDelayMs = 3_500,
|
||||
selfOfflineDelayMs = 60_000,
|
||||
}: OfflineRecoveryOptions) {
|
||||
let restartRequested = false;
|
||||
let selfRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (tips: string, allowSelfRecovery: boolean) => {
|
||||
onOffline(tips);
|
||||
|
||||
if (restartRequested) {
|
||||
// 被踢时离线状态可能先到:还在等静默离线自行恢复的话,不用再等了
|
||||
if (allowSelfRecovery || !selfRecoveryTimer) return;
|
||||
clearTimeout(selfRecoveryTimer);
|
||||
}
|
||||
restartRequested = true;
|
||||
// 不在 RefreshQRcode 请求里直接重启 Worker,否则请求在返回前就被中断,
|
||||
// 浏览器只能得到 ERR_EMPTY_RESPONSE;延后调度也让前端先看到「二维码已清空、正在恢复」
|
||||
const timer = setTimeout(() => {
|
||||
selfRecoveryTimer = undefined;
|
||||
if (allowSelfRecovery && isOnline()) {
|
||||
restartRequested = false;
|
||||
onRecovered();
|
||||
return;
|
||||
}
|
||||
restart(tips).then((ok) => {
|
||||
if (!ok) restartRequested = false;
|
||||
}, () => {
|
||||
restartRequested = false;
|
||||
});
|
||||
}, allowSelfRecovery ? selfOfflineDelayMs : kickedDelayMs);
|
||||
selfRecoveryTimer = allowSelfRecovery ? timer : undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Self-reported profile region only; never infer a location from other fields. */
|
||||
export interface ProfileRegion {
|
||||
country?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
function text (value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/** Keep one source together: mixing old/new components can invent a region. */
|
||||
export function selectProfileRegion (fresh?: ProfileRegion, fallback?: ProfileRegion): Required<ProfileRegion> {
|
||||
for (const source of [fresh, fallback]) {
|
||||
const region = { country: text(source?.country), province: text(source?.province), city: text(source?.city) };
|
||||
if (Object.values(region).some(Boolean)) return region;
|
||||
}
|
||||
return { country: '', province: '', city: '' };
|
||||
}
|
||||
|
||||
export function profileRegionArea (source?: ProfileRegion): string {
|
||||
const region = selectProfileRegion(source);
|
||||
return [...new Set(Object.values(region).filter(Boolean))].join(' ');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
|
||||
export function proxyHandlerOf (logger: LogWrapper) {
|
||||
return {
|
||||
get (target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
|
||||
return (..._args: unknown[]) => {
|
||||
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
|
||||
};
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function proxiedListenerOf<T extends object> (listener: T, logger: LogWrapper) {
|
||||
return new Proxy<T>(listener, proxyHandlerOf(logger));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import fs from 'node:fs';
|
||||
import { systemPlatform } from 'napcat-common/src/system';
|
||||
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'napcat-common/src/helper';
|
||||
import AppidTable from '@/napcat-core/external/appid.json';
|
||||
import { LogWrapper } from './log';
|
||||
import { getMajorPath } from '@/napcat-core/index';
|
||||
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'napcat-common/src/types';
|
||||
import path from 'node:path';
|
||||
|
||||
export class QQBasicInfoWrapper {
|
||||
QQMainPath: string | undefined;
|
||||
QQPackageInfoPath: string | undefined;
|
||||
QQVersionConfigPath: string | undefined;
|
||||
isQuickUpdate: boolean | undefined;
|
||||
QQVersionConfig: QQVersionConfigType | undefined;
|
||||
QQPackageInfo: QQPackageInfoType | undefined;
|
||||
QQVersionAppid: string | undefined;
|
||||
QQVersionQua: string | undefined;
|
||||
context: { logger: LogWrapper; };
|
||||
|
||||
constructor (context: { logger: LogWrapper; }) {
|
||||
// 基础目录获取
|
||||
this.context = context;
|
||||
this.QQMainPath = process.execPath;
|
||||
if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') {
|
||||
// 实用进程特殊处理 实用进程目录和QQ差远了
|
||||
this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ');
|
||||
}
|
||||
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||
|
||||
// 基础信息获取 无快更则启用默认模板填充
|
||||
this.isQuickUpdate = !!this.QQVersionConfigPath;
|
||||
this.QQVersionConfig = this.isQuickUpdate
|
||||
? JSON.parse(fs.readFileSync(this.QQVersionConfigPath!).toString())
|
||||
: getDefaultQQVersionConfigInfo();
|
||||
|
||||
this.QQPackageInfoPath = getQQPackageInfoPath(this.QQMainPath, this.QQVersionConfig?.curVersion);
|
||||
this.QQPackageInfo = JSON.parse(fs.readFileSync(this.QQPackageInfoPath).toString());
|
||||
const { appid: IQQVersionAppid, qua: IQQVersionQua } = this.getAppidV2();
|
||||
this.QQVersionAppid = IQQVersionAppid;
|
||||
this.QQVersionQua = IQQVersionQua;
|
||||
}
|
||||
|
||||
// 基础函数
|
||||
getQQBuildStr () {
|
||||
return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion;
|
||||
}
|
||||
|
||||
getFullQQVersion () {
|
||||
const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version;
|
||||
if (!version) throw new Error('QQ版本获取失败');
|
||||
return version;
|
||||
}
|
||||
|
||||
requireMinNTQQBuild (buildStr: string) {
|
||||
const currentBuild = +(this.getQQBuildStr() ?? '0');
|
||||
if (currentBuild === 0) throw new Error('QQBuildStr获取失败');
|
||||
return currentBuild >= parseInt(buildStr);
|
||||
}
|
||||
|
||||
// 此方法不要直接使用
|
||||
getQUAFallback () {
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
};
|
||||
return platformMapping[systemPlatform] ?? (platformMapping.win32)!;
|
||||
}
|
||||
|
||||
getAppIdFallback () {
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: '537246092',
|
||||
darwin: '537246140',
|
||||
linux: '537246140',
|
||||
};
|
||||
return platformMapping[systemPlatform] ?? '537246092';
|
||||
}
|
||||
|
||||
getAppidV2 (): { appid: string; qua: string; } {
|
||||
// 通过已有表 性能好
|
||||
const appidTable = AppidTable as unknown as QQAppidTableType;
|
||||
const fullVersion = this.getFullQQVersion();
|
||||
if (fullVersion) {
|
||||
// 同一 Linux 版本的 x64 和 arm64 使用不同的 AppID,优先匹配架构。
|
||||
const data = appidTable[`${fullVersion}-${process.arch}`] ?? appidTable[fullVersion];
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
// 通过Major拉取 性能差
|
||||
try {
|
||||
const majorAppid = this.getAppidV2ByMajor(fullVersion);
|
||||
if (majorAppid) {
|
||||
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
|
||||
return { appid: majorAppid, qua: this.getQUAFallback() };
|
||||
}
|
||||
} catch {
|
||||
this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||
}
|
||||
// 最终兜底为老版本
|
||||
this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`);
|
||||
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
|
||||
}
|
||||
|
||||
getAppidV2ByMajor (QQVersion: string) {
|
||||
if (!this.QQMainPath) {
|
||||
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
||||
}
|
||||
const majorPath = getMajorPath(this.QQMainPath, QQVersion);
|
||||
// 优先通过 QQAppId/ 标记搜索
|
||||
const appidV2 = parseAppidFromMajorV2(majorPath);
|
||||
if (appidV2) {
|
||||
return appidV2;
|
||||
}
|
||||
// 回落到旧方式
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { RequestUtil } from 'napcat-common/src/request';
|
||||
import { LogWrapper } from './log';
|
||||
|
||||
interface ServerRkeyData {
|
||||
group_rkey: string;
|
||||
private_rkey: string;
|
||||
expired_time: number;
|
||||
}
|
||||
interface OneBotApiRet {
|
||||
status: string,
|
||||
retcode: number,
|
||||
data: ServerRkeyData,
|
||||
message: string,
|
||||
wording: string,
|
||||
}
|
||||
interface UrlFailureInfo {
|
||||
count: number;
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
export class RkeyManager {
|
||||
serverUrl: string[] = [];
|
||||
logger: LogWrapper;
|
||||
private rkeyData: ServerRkeyData = {
|
||||
group_rkey: '',
|
||||
private_rkey: '',
|
||||
expired_time: 0,
|
||||
};
|
||||
|
||||
private urlFailures: Map<string, UrlFailureInfo> = new Map();
|
||||
private readonly FAILURE_LIMIT: number = 4;
|
||||
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor (serverUrl: string[], logger: LogWrapper) {
|
||||
this.logger = logger;
|
||||
this.serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
async getRkey () {
|
||||
const availableUrls = this.getAvailableUrls();
|
||||
if (availableUrls.length === 0) {
|
||||
this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制');
|
||||
throw new Error('获取rkey失败:所有服务URL均已被禁用');
|
||||
}
|
||||
|
||||
if (this.isExpired()) {
|
||||
try {
|
||||
await this.refreshRkey();
|
||||
} catch (e) {
|
||||
throw new Error(`${e}`);
|
||||
}
|
||||
}
|
||||
return this.rkeyData;
|
||||
}
|
||||
|
||||
private getAvailableUrls (): string[] {
|
||||
return this.serverUrl.filter(url => !this.isUrlDisabled(url));
|
||||
}
|
||||
|
||||
private isUrlDisabled (url: string): boolean {
|
||||
const failureInfo = this.urlFailures.get(url);
|
||||
if (!failureInfo) return false;
|
||||
|
||||
const now = new Date().getTime();
|
||||
// 如果已经过了一天,重置失败计数
|
||||
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
|
||||
failureInfo.count = 0;
|
||||
this.urlFailures.set(url, failureInfo);
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureInfo.count >= this.FAILURE_LIMIT;
|
||||
}
|
||||
|
||||
private updateUrlFailure (url: string) {
|
||||
const now = new Date().getTime();
|
||||
const failureInfo = this.urlFailures.get(url) || { count: 0, lastTimestamp: 0 };
|
||||
|
||||
// 如果已经过了一天,重置失败计数
|
||||
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
|
||||
failureInfo.count = 1;
|
||||
} else {
|
||||
failureInfo.count++;
|
||||
}
|
||||
|
||||
failureInfo.lastTimestamp = now;
|
||||
this.urlFailures.set(url, failureInfo);
|
||||
|
||||
if (failureInfo.count >= this.FAILURE_LIMIT) {
|
||||
this.logger.logError(`[Rkey] URL ${url} 已被禁用,失败次数达到 ${this.FAILURE_LIMIT} 次`);
|
||||
}
|
||||
}
|
||||
|
||||
isExpired (): boolean {
|
||||
const now = new Date().getTime() / 1000;
|
||||
return now > this.rkeyData.expired_time;
|
||||
}
|
||||
|
||||
async refreshRkey () {
|
||||
const availableUrls = this.getAvailableUrls();
|
||||
|
||||
if (availableUrls.length === 0) {
|
||||
this.logger.logError('[Rkey] 所有服务均已禁用');
|
||||
throw new Error('获取rkey失败:所有服务URL均已被禁用');
|
||||
}
|
||||
|
||||
for (const url of availableUrls) {
|
||||
try {
|
||||
let temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET');
|
||||
if (typeof temp === 'object' && temp !== null && 'retcode' in temp) {
|
||||
// 支持Onebot Ret风格
|
||||
temp = (temp as unknown as OneBotApiRet).data;
|
||||
}
|
||||
if (
|
||||
typeof temp?.group_rkey !== 'string' ||
|
||||
typeof temp?.private_rkey !== 'string' ||
|
||||
typeof temp?.expired_time !== 'number' ||
|
||||
temp.group_rkey.length <= 6 ||
|
||||
temp.private_rkey.length <= 6 ||
|
||||
temp.expired_time <= 0
|
||||
) {
|
||||
throw new Error('Rkey 服务返回格式无效:group_rkey/private_rkey/expired_time 缺失或不合法');
|
||||
}
|
||||
this.rkeyData = {
|
||||
group_rkey: temp.group_rkey.slice(6),
|
||||
private_rkey: temp.private_rkey.slice(6),
|
||||
expired_time: temp.expired_time,
|
||||
};
|
||||
return;
|
||||
} catch (e) {
|
||||
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
|
||||
this.updateUrlFailure(url);
|
||||
|
||||
if (url === availableUrls[availableUrls.length - 1]) {
|
||||
throw new Error(`获取rkey失败: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 服务器时间矫正 - 全局 Hook
|
||||
*
|
||||
* 保留 Date.now 原始引用,启动后通过 getServerTime 计算偏移量,
|
||||
* 直接覆盖 Date.now(),使全局所有调用自动获得矫正后的时间戳。
|
||||
*/
|
||||
|
||||
/** 原始 Date.now 引用,不受 hook 影响 */
|
||||
export const OriginalDateNow: () => number = Date.now.bind(Date);
|
||||
|
||||
/**
|
||||
* 初始化全局时间矫正,覆盖 Date.now()
|
||||
* @param getServerTimeFn 获取服务器时间的函数,返回毫秒级时间戳字符串
|
||||
* @returns 偏移量(毫秒)
|
||||
*/
|
||||
export function hookGlobalDateNow (getServerTimeFn: () => string): number {
|
||||
const start = OriginalDateNow();
|
||||
const serverTimeStr = getServerTimeFn();
|
||||
const end = OriginalDateNow();
|
||||
const serverTimeMs = parseInt(serverTimeStr, 10);
|
||||
|
||||
if (isNaN(serverTimeMs) || serverTimeMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 补偿网络延迟:假定往返时间对称,服务器时间对应本地时间的中点 (start + end) / 2
|
||||
const offsetMs = Math.round(serverTimeMs - (start + end) / 2);
|
||||
|
||||
if (Math.abs(offsetMs) > 5000) {
|
||||
Date.now = () => OriginalDateNow() + offsetMs;
|
||||
}
|
||||
|
||||
return offsetMs;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { ServiceNamingMapping } from '@/napcat-core/services/index';
|
||||
import { NTEventWrapper } from './event';
|
||||
|
||||
/**
|
||||
* 创建 Service 方法的代理
|
||||
* 拦截所有方法调用,通过 EventWrapper 进行调用
|
||||
*/
|
||||
function createServiceMethodProxy<S extends keyof ServiceNamingMapping> (
|
||||
serviceName: S,
|
||||
originalService: ServiceNamingMapping[S],
|
||||
eventWrapper: NTEventWrapper
|
||||
): ServiceNamingMapping[S] {
|
||||
return new Proxy(originalService as object, {
|
||||
get (target, prop, receiver) {
|
||||
const originalValue = Reflect.get(target, prop, receiver);
|
||||
|
||||
// 如果不是函数,直接返回原始值
|
||||
if (typeof originalValue !== 'function') {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
const methodName = prop as string;
|
||||
|
||||
// 返回一个包装函数,通过 EventWrapper 调用
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
|
||||
const eventPath = `${serviceName}/${methodName}`;
|
||||
|
||||
// 尝试通过 EventWrapper 调用
|
||||
try {
|
||||
// 使用 callNoListenerEvent 的底层实现逻辑
|
||||
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
|
||||
if (eventFunc) {
|
||||
return eventFunc(...args);
|
||||
}
|
||||
} catch {
|
||||
// 如果 EventWrapper 调用失败,回退到原始调用
|
||||
}
|
||||
|
||||
// 回退到原始方法调用
|
||||
return originalValue.apply(originalService, args);
|
||||
};
|
||||
},
|
||||
}) as ServiceNamingMapping[S];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Session 的双层代理
|
||||
* 第一层:拦截 getXXXService 方法
|
||||
* 第二层:拦截 Service 上的具体方法调用
|
||||
*/
|
||||
export function createSessionProxy (
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
// 缓存已代理的 Service,避免重复创建
|
||||
const serviceProxyCache = new Map<string, unknown>();
|
||||
|
||||
return new Proxy(session, {
|
||||
get (target, prop, receiver) {
|
||||
const propName = prop as string;
|
||||
|
||||
// 检查是否是 getXXXService 方法
|
||||
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
|
||||
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
|
||||
const servicePart = propName.slice(3); // 移除 'get' 前缀
|
||||
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
|
||||
|
||||
// 返回一个函数,该函数返回代理后的 Service
|
||||
return function () {
|
||||
// 检查缓存
|
||||
if (serviceProxyCache.has(serviceName)) {
|
||||
return serviceProxyCache.get(serviceName);
|
||||
}
|
||||
|
||||
// 获取原始 Service
|
||||
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
|
||||
const originalService = originalGetter.call(target);
|
||||
|
||||
// 检查是否在 ServiceNamingMapping 中定义
|
||||
if (isKnownService(serviceName)) {
|
||||
// 创建 Service 方法代理
|
||||
const proxiedService = createServiceMethodProxy(
|
||||
serviceName,
|
||||
originalService as ServiceNamingMapping[typeof serviceName],
|
||||
eventWrapper
|
||||
);
|
||||
serviceProxyCache.set(serviceName, proxiedService);
|
||||
return proxiedService;
|
||||
}
|
||||
|
||||
// 未知的 Service,直接返回原始对象
|
||||
serviceProxyCache.set(serviceName, originalService);
|
||||
return originalService;
|
||||
};
|
||||
}
|
||||
|
||||
// 非 getXXXService 方法,直接返回原始值
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Service 名称是否在已知的映射中
|
||||
*/
|
||||
function isKnownService (serviceName: string): serviceName is keyof ServiceNamingMapping {
|
||||
const knownServices: string[] = [
|
||||
'NodeIKernelAvatarService',
|
||||
'NodeIKernelBuddyService',
|
||||
'NodeIKernelFileAssistantService',
|
||||
'NodeIKernelGroupService',
|
||||
'NodeIKernelLoginService',
|
||||
'NodeIKernelMsgService',
|
||||
'NodeIKernelOnlineStatusService',
|
||||
'NodeIKernelProfileLikeService',
|
||||
'NodeIKernelProfileService',
|
||||
'NodeIKernelTicketService',
|
||||
'NodeIKernelStorageCleanService',
|
||||
'NodeIKernelRobotService',
|
||||
'NodeIKernelRichMediaService',
|
||||
'NodeIKernelDbToolsService',
|
||||
'NodeIKernelTipOffService',
|
||||
'NodeIKernelSearchService',
|
||||
'NodeIKernelCollectionService',
|
||||
];
|
||||
return knownServices.includes(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有 EventWrapper 集成的 InstanceContext
|
||||
* 这是推荐的使用方式,在创建 context 时自动代理 session
|
||||
*/
|
||||
export function createProxiedSession (
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
return createSessionProxy(session, eventWrapper);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import os from 'node:os';
|
||||
import EventEmitter from 'node:events';
|
||||
import { IStatusHelperSubscription } from 'napcat-common/src/status-interface';
|
||||
export interface SystemStatus {
|
||||
cpu: {
|
||||
model: string,
|
||||
speed: string;
|
||||
usage: {
|
||||
system: string;
|
||||
qq: string;
|
||||
},
|
||||
core: number;
|
||||
},
|
||||
memory: {
|
||||
total: string;
|
||||
usage: {
|
||||
system: string;
|
||||
qq: string;
|
||||
};
|
||||
},
|
||||
arch: string;
|
||||
}
|
||||
|
||||
export class StatusHelper {
|
||||
private psCpuUsage = process.cpuUsage();
|
||||
private psCurrentTime = process.hrtime();
|
||||
private cpuTimes = os.cpus().map(cpu => cpu.times);
|
||||
|
||||
private replaceNaN (value: number) {
|
||||
return isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
private sysCpuInfo () {
|
||||
const currentTimes = os.cpus().map(cpu => cpu.times);
|
||||
const { total, active } = currentTimes.map((times, index) => {
|
||||
const prevTimes = this.cpuTimes[index];
|
||||
const totalCurrent = times.user + times.nice + times.sys + times.idle + times.irq;
|
||||
const totalPrev = (prevTimes?.user ?? 0) + (prevTimes?.nice ?? 0) + (prevTimes?.sys ?? 0) + (prevTimes?.idle ?? 0) + (prevTimes?.irq ?? 0);
|
||||
const activeCurrent = totalCurrent - times.idle;
|
||||
const activePrev = totalPrev - (prevTimes?.idle ?? 0);
|
||||
return {
|
||||
total: totalCurrent - totalPrev,
|
||||
active: activeCurrent - activePrev,
|
||||
};
|
||||
}).reduce((acc, cur) => ({
|
||||
total: acc.total + cur.total,
|
||||
active: acc.active + cur.active,
|
||||
}), { total: 0, active: 0 });
|
||||
this.cpuTimes = currentTimes;
|
||||
return {
|
||||
usage: this.replaceNaN(((active / total) * 100)).toFixed(2),
|
||||
model: os.cpus()[0]?.model ?? 'none',
|
||||
speed: os.cpus()[0]?.speed ?? 0,
|
||||
core: os.cpus().length,
|
||||
};
|
||||
}
|
||||
|
||||
private sysMemoryUsage () {
|
||||
const { total, free } = { total: os.totalmem(), free: os.freemem() };
|
||||
return ((total - free) / 1024 / 1024).toFixed(2);
|
||||
}
|
||||
|
||||
private qqUsage () {
|
||||
const mem = process.memoryUsage();
|
||||
const numCpus = os.cpus().length;
|
||||
const usageDiff = process.cpuUsage(this.psCpuUsage);
|
||||
const endTime = process.hrtime(this.psCurrentTime);
|
||||
this.psCpuUsage = process.cpuUsage();
|
||||
this.psCurrentTime = process.hrtime();
|
||||
const usageMS = (usageDiff.user + usageDiff.system) / 1e3;
|
||||
const totalMS = endTime[0] * 1e3 + endTime[1] / 1e6;
|
||||
const normPercent = (usageMS / totalMS / numCpus) * 100;
|
||||
return {
|
||||
cpu: this.replaceNaN(normPercent).toFixed(2),
|
||||
memory: ((mem.heapTotal + mem.external + mem.arrayBuffers) / 1024 / 1024).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
systemStatus (): SystemStatus {
|
||||
const qqUsage = this.qqUsage();
|
||||
const sysCpuInfo = this.sysCpuInfo();
|
||||
return {
|
||||
cpu: {
|
||||
core: sysCpuInfo.core,
|
||||
model: sysCpuInfo.model,
|
||||
speed: (sysCpuInfo.speed / 1000).toFixed(2),
|
||||
usage: {
|
||||
system: sysCpuInfo.usage,
|
||||
qq: qqUsage.cpu,
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
total: (os.totalmem() / 1024 / 1024).toFixed(2),
|
||||
usage: {
|
||||
system: this.sysMemoryUsage(),
|
||||
qq: qqUsage.memory,
|
||||
},
|
||||
},
|
||||
arch: `${os.platform()} ${os.arch()} ${os.release()}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StatusHelperSubscription extends EventEmitter implements IStatusHelperSubscription {
|
||||
private statusHelper: StatusHelper;
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor (time: number = 3000) {
|
||||
super();
|
||||
this.statusHelper = new StatusHelper();
|
||||
this.on('newListener', (event: string) => {
|
||||
if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) {
|
||||
this.startInterval(time);
|
||||
}
|
||||
});
|
||||
this.on('removeListener', (event: string) => {
|
||||
if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) {
|
||||
this.stopInterval();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startInterval (time: number) {
|
||||
this.interval ??= setInterval(() => {
|
||||
const status = this.statusHelper.systemStatus();
|
||||
this.emit('statusUpdate', status);
|
||||
}, time);
|
||||
}
|
||||
|
||||
private stopInterval () {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const statusHelperSubscription = new StatusHelperSubscription();
|
||||
@@ -0,0 +1,393 @@
|
||||
import {
|
||||
NTQQFileApi,
|
||||
NTQQFriendApi,
|
||||
NTQQGroupApi,
|
||||
NTQQMsgApi,
|
||||
NTQQSystemApi,
|
||||
NTQQUserApi,
|
||||
NTQQWebApi,
|
||||
NTQQFlashApi,
|
||||
NTQQOnlineApi,
|
||||
NTQQDatabaseApi,
|
||||
NTQQQzoneApi,
|
||||
NTQQQzonePublicApi,
|
||||
} from '@/napcat-core/apis';
|
||||
import { NTQQCollectionApi } from '@/napcat-core/apis/collection';
|
||||
import {
|
||||
NodeIQQNTWrapperSession,
|
||||
NodeQQNTWrapperUtil,
|
||||
PlatformType,
|
||||
VendorType,
|
||||
WrapperNodeApi,
|
||||
WrapperSessionInitConfig,
|
||||
} from '@/napcat-core/wrapper';
|
||||
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
import { getQQDataDirOverride, NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
|
||||
import { NTEventWrapper } from '@/napcat-core/helper/event';
|
||||
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
|
||||
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
import os from 'node:os';
|
||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/napcat-core/listeners';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { NTQQPacketApi } from './apis/packet';
|
||||
import { NativePacketHandler } from './packet/handler/client';
|
||||
import { Napi2NativeLoader } from './packet/handler/napi2nativeLoader';
|
||||
import { container, ReceiverServiceRegistry } from './packet/handler/serviceRegister';
|
||||
import { appEvent } from './packet/handler/eventList';
|
||||
import { TypedEventEmitter } from './packet/handler/typeEvent';
|
||||
import { hookGlobalDateNow } from './helper/server-time';
|
||||
export * from './wrapper';
|
||||
export * from './types/index';
|
||||
export * from './services/index';
|
||||
export * from './listeners/index';
|
||||
export * from './apis/index';
|
||||
export * from './helper/log';
|
||||
export * from './helper/qq-basic-info';
|
||||
export * from './helper/event';
|
||||
export * from './helper/config';
|
||||
export * from './helper/config-base';
|
||||
export * from './helper/proxy-handler';
|
||||
export * from './helper/session-proxy';
|
||||
export * from './helper/server-time';
|
||||
|
||||
export enum NapCatCoreWorkingEnv {
|
||||
Unknown = 0,
|
||||
Shell = 1,
|
||||
Framework = 2,
|
||||
}
|
||||
|
||||
function dlopenWrapper (wrapperPath: string): WrapperNodeApi {
|
||||
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
|
||||
try {
|
||||
process.dlopen(nativemodule, wrapperPath);
|
||||
} catch (error) {
|
||||
// QQNT.dll 依赖媒体基础(mfplat.dll 等),Windows Server 默认不装这个组件。
|
||||
// 缺了它系统只会报 The specified module could not be found,看不出缺的是什么
|
||||
const mfplatPath = path.join(process.env['SystemRoot'] || 'C:\\Windows', 'System32', 'mfplat.dll');
|
||||
if (os.platform() === 'win32' && !fs.existsSync(mfplatPath)) {
|
||||
throw new Error(
|
||||
'加载 wrapper.node 失败:系统缺少「媒体基础」组件(找不到 mfplat.dll),Windows Server 默认不安装它。' +
|
||||
'请在服务器管理器的「添加角色和功能」里勾选「媒体基础」,' +
|
||||
'或在管理员 PowerShell 中执行 Install-WindowsFeature Server-Media-Foundation,重启后再启动。' +
|
||||
`原始错误:${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return nativemodule.exports;
|
||||
}
|
||||
|
||||
export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi {
|
||||
if (process.env['NAPCAT_WRAPPER_PATH']) {
|
||||
return dlopenWrapper(process.env['NAPCAT_WRAPPER_PATH']);
|
||||
}
|
||||
if (!execPath) {
|
||||
throw new Error('无法加载Wrapper,execPath未定义');
|
||||
}
|
||||
let appPath;
|
||||
if (os.platform() === 'darwin') {
|
||||
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
|
||||
} else if (os.platform() === 'linux') {
|
||||
appPath = path.resolve(path.dirname(execPath), './resources/app');
|
||||
} else {
|
||||
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
|
||||
}
|
||||
let wrapperNodePath = path.resolve(appPath, 'wrapper.node');
|
||||
if (!fs.existsSync(wrapperNodePath)) {
|
||||
wrapperNodePath = path.join(appPath, './resources/app/wrapper.node');
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(wrapperNodePath)) {
|
||||
wrapperNodePath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
|
||||
}
|
||||
const exports = dlopenWrapper(wrapperNodePath);
|
||||
process.env['NAPCAT_WRAPPER_PATH'] = wrapperNodePath;
|
||||
return exports;
|
||||
}
|
||||
export function getMajorPath (execPath: string, QQVersion: string): string {
|
||||
// major.node
|
||||
let appPath;
|
||||
if (os.platform() === 'darwin') {
|
||||
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
|
||||
} else if (os.platform() === 'linux') {
|
||||
appPath = path.resolve(path.dirname(execPath), './resources/app');
|
||||
} else {
|
||||
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
|
||||
}
|
||||
let majorPath = path.resolve(appPath, 'major.node');
|
||||
if (!fs.existsSync(majorPath)) {
|
||||
majorPath = path.join(appPath, './resources/app/major.node');
|
||||
}
|
||||
// 老版本兼容 未来去掉
|
||||
if (!fs.existsSync(majorPath)) {
|
||||
majorPath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/major.node`);
|
||||
}
|
||||
return majorPath;
|
||||
}
|
||||
export class NapCatCore {
|
||||
readonly context: InstanceContext;
|
||||
readonly eventWrapper: NTEventWrapper;
|
||||
event = appEvent;
|
||||
NapCatDataPath: string = '';
|
||||
NapCatTempPath: string = '';
|
||||
apis: StableNTApiWrapper;
|
||||
// runtime info, not readonly
|
||||
selfInfo: SelfInfo;
|
||||
util: NodeQQNTWrapperUtil;
|
||||
configLoader: NapCatConfigLoader;
|
||||
/** 数据库 passphrase,由 OidbSvcTrpcTcp.0xcde_2 包获取 */
|
||||
dbPassphrase: string | undefined;
|
||||
|
||||
// 通过构造器递过去的 runtime info 应该尽量少
|
||||
constructor (context: InstanceContext, selfInfo: SelfInfo) {
|
||||
this.selfInfo = selfInfo;
|
||||
// 先用原始 session 创建 eventWrapper
|
||||
this.eventWrapper = new NTEventWrapper(context.session);
|
||||
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
|
||||
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
|
||||
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
|
||||
this.context = {
|
||||
...context,
|
||||
session: proxiedSession,
|
||||
};
|
||||
} else {
|
||||
this.context = context;
|
||||
}
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
|
||||
this.apis = {
|
||||
FileApi: new NTQQFileApi(this.context, this),
|
||||
SystemApi: new NTQQSystemApi(this.context, this),
|
||||
CollectionApi: new NTQQCollectionApi(this.context, this),
|
||||
PacketApi: new NTQQPacketApi(this.context, this),
|
||||
WebApi: new NTQQWebApi(this.context, this),
|
||||
FriendApi: new NTQQFriendApi(this.context, this),
|
||||
MsgApi: new NTQQMsgApi(this.context, this),
|
||||
UserApi: new NTQQUserApi(this.context, this),
|
||||
GroupApi: new NTQQGroupApi(this.context, this),
|
||||
FlashApi: new NTQQFlashApi(this.context, this),
|
||||
OnlineApi: new NTQQOnlineApi(this.context, this),
|
||||
DatabaseApi: new NTQQDatabaseApi(this.context, this),
|
||||
QzoneApi: new NTQQQzoneApi(this.context, this),
|
||||
QzonePublicApi: new NTQQQzonePublicApi(this.context, this),
|
||||
};
|
||||
container.bind(NapCatCore).toConstantValue(this);
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
// console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initCore () {
|
||||
this.NapCatDataPath = path.join(this.dataPath, 'NapCat');
|
||||
fs.mkdirSync(this.NapCatDataPath, { recursive: true });
|
||||
this.NapCatTempPath = path.join(this.NapCatDataPath, 'temp');
|
||||
// 创建临时目录
|
||||
if (!fs.existsSync(this.NapCatTempPath)) {
|
||||
fs.mkdirSync(this.NapCatTempPath, { recursive: true });
|
||||
}
|
||||
// 通过 getServerTime 全局 hook Date.now() 矫正本地时间偏差
|
||||
// 可通过环境变量 NAPCAT_DISABLE_TIME_SYNC=1 或配置 autoTimeSync: false 禁用
|
||||
if (process.env['NAPCAT_DISABLE_TIME_SYNC'] === '1' || !this.configLoader.configData.autoTimeSync) {
|
||||
this.context.logger.log('[ServerTime] 自动对时已禁用');
|
||||
} else {
|
||||
const doTimeSync = () => {
|
||||
try {
|
||||
const offsetMs = hookGlobalDateNow(() => this.context.session.getMSFService().getServerTime());
|
||||
if (Math.abs(offsetMs) > 5000) {
|
||||
this.context.logger.logWarn(`[ServerTime] 本地时间与服务器时间偏差 ${(offsetMs / 1000).toFixed(1)}s,已自动矫正`);
|
||||
} else {
|
||||
this.context.logger.log(`[ServerTime] 时间同步完成,偏差 ${offsetMs}ms`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[ServerTime] 时间矫正失败', e);
|
||||
}
|
||||
};
|
||||
doTimeSync();
|
||||
// 每小时定期重同步,防止长时间运行后本地时钟漂移触发风控
|
||||
const syncTimer = setInterval(doTimeSync, 3600000);
|
||||
syncTimer.unref();
|
||||
}
|
||||
// 遍历this.apis[i].initApi 如果存在该函数进行async 调用
|
||||
for (const apiKey in this.apis) {
|
||||
const api = this.apis[apiKey as keyof StableNTApiWrapper];
|
||||
if ('initApi' in api && typeof api.initApi === 'function') {
|
||||
await api.initApi();
|
||||
}
|
||||
}
|
||||
this.initNapCatCoreListeners().then().catch((e) => this.context.logger.logError(e));
|
||||
|
||||
this.context.logger.setFileLogEnabled(
|
||||
this.configLoader.configData.fileLog
|
||||
);
|
||||
this.context.logger.setConsoleLogEnabled(
|
||||
this.configLoader.configData.consoleLog
|
||||
);
|
||||
this.context.logger.setFileAndConsoleLogLevel(
|
||||
this.configLoader.configData.fileLogLevel as LogLevel,
|
||||
this.configLoader.configData.consoleLogLevel as LogLevel
|
||||
);
|
||||
}
|
||||
|
||||
get dataPath (): string {
|
||||
const override = getQQDataDirOverride();
|
||||
if (override) return override;
|
||||
let result = this.context.wrapper.NodeQQNTWrapperUtil.getNTUserDataInfoConfig();
|
||||
if (!result) {
|
||||
result = path.resolve(os.homedir(), './.config/QQ');
|
||||
fs.mkdirSync(result, { recursive: true });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Renamed from 'InitDataListener'
|
||||
async initNapCatCoreListeners () {
|
||||
const msgListener = new NodeIKernelMsgListener();
|
||||
|
||||
// 在线文件/文件夹消息
|
||||
msgListener.onRecvOnlineFileMsg = (msgs: RawMessage[]) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
};
|
||||
|
||||
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
|
||||
// 下线通知
|
||||
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
|
||||
this.context.logger.logError(tips);
|
||||
this.selfInfo.online = false;
|
||||
this.event.emit('KickedOffLine', tips);
|
||||
};
|
||||
msgListener.onRecvMsg = (msgs) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
};
|
||||
msgListener.onAddSendMsg = (msg) => {
|
||||
this.context.logger.logMessage(msg, this.selfInfo);
|
||||
};
|
||||
this.context.session.getMsgService().addKernelMsgListener(
|
||||
proxiedListenerOf(msgListener, this.context.logger)
|
||||
);
|
||||
|
||||
const profileListener = new NodeIKernelProfileListener();
|
||||
profileListener.onProfileDetailInfoChanged = (profile) => {
|
||||
if (profile.uid === this.selfInfo.uid) {
|
||||
Object.assign(this.selfInfo, profile);
|
||||
}
|
||||
};
|
||||
profileListener.onSelfStatusChanged = (Info: SelfStatusInfo) => {
|
||||
if (Info.status === 20) {
|
||||
// 静默离线:服务端判定会话失效,但不会下发 KickedOffLine 通知。
|
||||
// 仅在 在线 -> 离线 的跳变上广播一次,供上层复用下线恢复链路。
|
||||
const wasOnline = this.selfInfo.online === true;
|
||||
this.selfInfo.online = false;
|
||||
this.context.logger.log('账号状态变更为离线');
|
||||
if (wasOnline) {
|
||||
this.event.emit('SelfOffline', '[SelfOffline] 账号状态变更为离线(未收到 KickedOffLine 通知)');
|
||||
}
|
||||
} else {
|
||||
this.selfInfo.online = true;
|
||||
}
|
||||
};
|
||||
this.context.session.getProfileService().addKernelProfileListener(
|
||||
proxiedListenerOf(profileListener, this.context.logger)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function genSessionConfig (
|
||||
guid: string,
|
||||
QQVersionAppid: string,
|
||||
QQVersion: string,
|
||||
selfUin: string,
|
||||
selfUid: string,
|
||||
account_path: string
|
||||
): Promise<WrapperSessionInitConfig> {
|
||||
const downloadPath = path.join(account_path, 'NapCat', 'temp');
|
||||
fs.mkdirSync(downloadPath, { recursive: true });
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, PlatformType>> = {
|
||||
win32: PlatformType.KWINDOWS,
|
||||
darwin: PlatformType.KMAC,
|
||||
linux: PlatformType.KLINUX,
|
||||
};
|
||||
const systemPlatform = platformMapping[os.platform()] ?? PlatformType.KWINDOWS;
|
||||
return {
|
||||
selfUin,
|
||||
selfUid,
|
||||
desktopPathConfig: {
|
||||
account_path, // 可以通过NodeQQNTWrapperUtil().getNTUserDataInfoConfig()获取
|
||||
},
|
||||
clientVer: QQVersion,
|
||||
a2: '',
|
||||
d2: '',
|
||||
d2Key: '',
|
||||
machineId: '',
|
||||
platform: systemPlatform, // 3是Windows?
|
||||
platVer: systemVersion, // 系统版本号, 应该可以固定
|
||||
appid: QQVersionAppid,
|
||||
rdeliveryConfig: {
|
||||
appKey: '',
|
||||
systemId: 0,
|
||||
appId: '',
|
||||
logicEnvironment: '',
|
||||
platform: systemPlatform,
|
||||
language: '',
|
||||
sdkVersion: '',
|
||||
userId: '',
|
||||
appVersion: '',
|
||||
osVersion: '',
|
||||
bundleId: '',
|
||||
serverUrl: '',
|
||||
fixedAfterHitKeys: [''],
|
||||
},
|
||||
defaultFileDownloadPath: downloadPath,
|
||||
deviceInfo: {
|
||||
guid,
|
||||
buildVer: QQVersion,
|
||||
localId: 2052,
|
||||
devName: hostname,
|
||||
devType: systemName,
|
||||
vendorName: '',
|
||||
osVer: systemVersion,
|
||||
vendorOsName: systemName,
|
||||
setMute: false,
|
||||
vendorType: VendorType.KNOSETONIOS,
|
||||
},
|
||||
deviceConfig: '{"appearance":{"isSplitViewMode":true},"msg":{}}',
|
||||
};
|
||||
}
|
||||
|
||||
export interface InstanceContext {
|
||||
readonly workingEnv: NapCatCoreWorkingEnv;
|
||||
readonly wrapper: WrapperNodeApi;
|
||||
readonly session: NodeIQQNTWrapperSession;
|
||||
readonly logger: LogWrapper;
|
||||
readonly basicInfoWrapper: QQBasicInfoWrapper;
|
||||
readonly pathWrapper: NapCatPathWrapper;
|
||||
readonly packetHandler: NativePacketHandler;
|
||||
readonly napi2nativeLoader: Napi2NativeLoader;
|
||||
}
|
||||
|
||||
export interface StableNTApiWrapper {
|
||||
FileApi: NTQQFileApi,
|
||||
SystemApi: NTQQSystemApi,
|
||||
PacketApi: NTQQPacketApi,
|
||||
CollectionApi: NTQQCollectionApi,
|
||||
WebApi: NTQQWebApi,
|
||||
FriendApi: NTQQFriendApi,
|
||||
MsgApi: NTQQMsgApi,
|
||||
UserApi: NTQQUserApi,
|
||||
GroupApi: NTQQGroupApi;
|
||||
FlashApi: NTQQFlashApi,
|
||||
OnlineApi: NTQQOnlineApi,
|
||||
DatabaseApi: NTQQDatabaseApi,
|
||||
QzoneApi: NTQQQzoneApi,
|
||||
QzonePublicApi: NTQQQzonePublicApi,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BuddyCategoryType, FriendRequestNotify } from '@/napcat-core/types';
|
||||
|
||||
export type OnBuddyChangeParams = BuddyCategoryType[];
|
||||
|
||||
export class NodeIKernelBuddyListener {
|
||||
onBuddyListChangedV2 (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onAddBuddyNeedVerify (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onAddMeSettingChanged (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onAvatarUrlUpdated (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onBlockChanged (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onBuddyDetailInfoChange (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onBuddyInfoChange (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onBuddyListChange (_arg: OnBuddyChangeParams): any {
|
||||
}
|
||||
|
||||
onBuddyRemarkUpdated (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onBuddyReqChange (_arg: FriendRequestNotify): any {
|
||||
}
|
||||
|
||||
onBuddyReqUnreadCntChange (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onCheckBuddySettingResult (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onDelBatchBuddyInfos (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onDoubtBuddyReqChange (_arg:
|
||||
{
|
||||
reqId: string;
|
||||
cookie: string;
|
||||
doubtList: Array<{
|
||||
uid: string;
|
||||
nick: string;
|
||||
age: number,
|
||||
sex: number;
|
||||
commFriendNum: number;
|
||||
reqTime: string;
|
||||
msg: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
groupCode: string;
|
||||
nameMore?: null;
|
||||
}>;
|
||||
}): void | Promise<void> {
|
||||
}
|
||||
|
||||
onDoubtBuddyReqUnreadNumChange (_num: number): void | Promise<void> {
|
||||
}
|
||||
|
||||
onNickUpdated (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onSmartInfos (_arg: unknown): any {
|
||||
}
|
||||
|
||||
onSpacePermissionInfos (_arg: unknown): any {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export class NodeIKernelFileAssistantListener {
|
||||
onFileStatusChanged (_fileStatus: {
|
||||
id: string,
|
||||
fileStatus: number,
|
||||
fileProgress: `${number}`,
|
||||
fileSize: `${number}`,
|
||||
fileSpeed: number,
|
||||
thumbPath: string | null,
|
||||
filePath: string | null,
|
||||
}): any {
|
||||
}
|
||||
|
||||
onSessionListChanged (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onSessionChanged (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onFileListChanged (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onFileSearch (_searchResult: SearchResultWrapper): any {
|
||||
}
|
||||
}
|
||||
|
||||
export type SearchResultWrapper = {
|
||||
searchId: number,
|
||||
resultId: number,
|
||||
hasMore: boolean,
|
||||
resultItems: SearchResultItem[],
|
||||
};
|
||||
|
||||
export type SearchResultItem = {
|
||||
id: string,
|
||||
fileName: string,
|
||||
fileNameHits: string[],
|
||||
fileStatus: number,
|
||||
fileSize: string,
|
||||
isSend: boolean,
|
||||
source: number,
|
||||
fileTime: string,
|
||||
expTime: string,
|
||||
session: {
|
||||
context: null,
|
||||
uid: string,
|
||||
nick: string,
|
||||
remark: string,
|
||||
memberCard: string,
|
||||
groupCode: string,
|
||||
groupName: string,
|
||||
groupRemark: string,
|
||||
count: number,
|
||||
},
|
||||
thumbPath: string,
|
||||
filePath: string,
|
||||
msgId: string,
|
||||
chatType: number,
|
||||
peerUid: string,
|
||||
fileType: number,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DataSource, Group, GroupDetailInfo, GroupListUpdateType, GroupMember, GroupNotify, ShutUpGroupMember } from '@/napcat-core/types';
|
||||
|
||||
export class NodeIKernelGroupListener {
|
||||
onGroupListInited (_listEmpty: boolean): any { }
|
||||
// 发现于Win 9.9.9 23159
|
||||
onGroupMemberLevelInfoChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGetGroupBulletinListResult (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupAllInfoChange (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupBulletinChange (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupBulletinRemindNotify (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupArkInviteStateResult (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupBulletinRichMediaDownloadComplete (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupConfMemberChange (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupDetailInfoChange (_detailInfo: GroupDetailInfo): any {
|
||||
}
|
||||
|
||||
onGroupExtListUpdate (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupFirstBulletinNotify (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupListUpdate (_updateType: GroupListUpdateType, _groupList: Group[]): any {
|
||||
}
|
||||
|
||||
onGroupNotifiesUpdated (_dboubt: boolean, _notifies: GroupNotify[]): any {
|
||||
}
|
||||
|
||||
onGroupBulletinRichMediaProgressUpdate (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupNotifiesUnreadCountUpdated (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupSingleScreenNotifies (_doubt: boolean, _seq: string, _notifies: GroupNotify[]): any {
|
||||
}
|
||||
|
||||
onGroupsMsgMaskResult (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onGroupStatisticInfoChange (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onJoinGroupNotify (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onJoinGroupNoVerifyFlag (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onMemberInfoChange (_groupCode: string, _dateSource: DataSource, _members: Map<string, GroupMember>): any {
|
||||
}
|
||||
|
||||
onMemberListChange (_arg: {
|
||||
sceneId: string,
|
||||
ids: string[],
|
||||
infos: Map<string, GroupMember>, // uid -> GroupMember
|
||||
hasPrev: boolean,
|
||||
hasNext: boolean,
|
||||
hasRobot: boolean;
|
||||
}): any {
|
||||
}
|
||||
|
||||
onSearchMemberChange (..._args: unknown[]): any {
|
||||
}
|
||||
|
||||
onShutUpMemberListChanged (_groupCode: string, _members: Array<ShutUpGroupMember>): any {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export class NodeIKernelLoginListener {
|
||||
onLoginConnected (): Promise<void> | void {
|
||||
}
|
||||
|
||||
onLoginDisConnected (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onLoginConnecting (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onQRCodeGetPicture (_arg: { pngBase64QrcodeData: string, qrcodeUrl: string; }): any {
|
||||
// let base64Data: string = arg.pngBase64QrcodeData
|
||||
// base64Data = base64Data.split("data:image/png;base64,")[1]
|
||||
// let buffer = Buffer.from(base64Data, 'base64')
|
||||
// console.log("onQRCodeGetPicture", arg);
|
||||
}
|
||||
|
||||
onQRCodeLoginPollingStarted (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onQRCodeSessionUserScaned (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onQRCodeLoginSucceed (_arg: QRCodeLoginSucceedResult): any {
|
||||
}
|
||||
|
||||
onQRCodeSessionFailed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onLoginFailed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onLogoutSucceed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onLogoutFailed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onUserLoggedIn (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onQRCodeSessionQuickLoginFailed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onPasswordLoginFailed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
OnConfirmUnusualDeviceFailed (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onQQLoginNumLimited (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onLoginState (..._args: any[]): any {
|
||||
}
|
||||
|
||||
onLoginRecordUpdate (..._args: any[]): any {
|
||||
}
|
||||
}
|
||||
|
||||
export interface QRCodeLoginSucceedResult {
|
||||
account: string;
|
||||
mainAccount: string;
|
||||
uin: string; // 拿UIN
|
||||
uid: string; // 拿UID
|
||||
nickName: string; // 一般是空的 拿不到
|
||||
gender: number;
|
||||
age: number;
|
||||
faceUrl: string;// 一般是空的 拿不到
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { ChatType, KickedOffLineInfo, RawMessage } from '@/napcat-core/types';
|
||||
import { CommonFileInfo } from '@/napcat-core/index';
|
||||
|
||||
export interface OnRichMediaDownloadCompleteParams {
|
||||
fileModelId: string,
|
||||
msgElementId: string,
|
||||
msgId: string,
|
||||
fileId: string,
|
||||
fileProgress: string, // '0'
|
||||
fileSpeed: string, // '0'
|
||||
fileErrCode: string, // '0'
|
||||
fileErrMsg: string,
|
||||
fileDownType: number, // 暂时未知
|
||||
thumbSize: number,
|
||||
filePath: string,
|
||||
totalSize: string,
|
||||
trasferStatus: number,
|
||||
step: number,
|
||||
commonFileInfo?: CommonFileInfo,
|
||||
fileSrvErrCode: string,
|
||||
clientMsg: string,
|
||||
businessId: number,
|
||||
userTotalSpacePerDay: unknown,
|
||||
userUsedSpacePerDay: unknown,
|
||||
chatType: number,
|
||||
}
|
||||
|
||||
export interface GroupFileInfoUpdateParamType {
|
||||
retCode: number;
|
||||
retMsg: string;
|
||||
clientWording: string;
|
||||
isEnd: boolean;
|
||||
item: Array<GroupFileInfoUpdateItem>;
|
||||
allFileCount: number;
|
||||
nextIndex: number;
|
||||
reqId: number;
|
||||
}
|
||||
|
||||
// {
|
||||
// sessionType: 1,
|
||||
// chatType: 100,
|
||||
// peerUid: 'u_PVQ3tl6K78xxxx',
|
||||
// groupCode: '809079648',
|
||||
// fromNick: '拾xxxx,
|
||||
// sig: '0x'
|
||||
// }
|
||||
|
||||
export interface GroupFileInfoUpdateItem {
|
||||
peerId: string;
|
||||
type: number;
|
||||
folderInfo?: {
|
||||
folderId: string;
|
||||
parentFolderId: string;
|
||||
folderName: string;
|
||||
createTime: number;
|
||||
modifyTime: number;
|
||||
createUin: string;
|
||||
creatorName: string;
|
||||
totalFileCount: number;
|
||||
modifyUin: string;
|
||||
modifyName: string;
|
||||
usedSpace: string;
|
||||
},
|
||||
fileInfo?: {
|
||||
fileModelId: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
busId: number;
|
||||
uploadedSize: string;
|
||||
uploadTime: number;
|
||||
deadTime: number;
|
||||
modifyTime: number;
|
||||
downloadTimes: number;
|
||||
sha: string;
|
||||
sha3: string;
|
||||
md5: string;
|
||||
uploaderLocalPath: string;
|
||||
uploaderName: string;
|
||||
uploaderUin: string;
|
||||
parentFolderId: string;
|
||||
localPath: string;
|
||||
transStatus: number;
|
||||
transType: number;
|
||||
elementId: string;
|
||||
isFolder: boolean;
|
||||
},
|
||||
}
|
||||
|
||||
export interface TempOnRecvParams {
|
||||
sessionType: number, // 1
|
||||
chatType: ChatType, // 100
|
||||
peerUid: string, // uid
|
||||
groupCode: string, // gc
|
||||
fromNick: string, // gc name
|
||||
sig: string,
|
||||
|
||||
}
|
||||
|
||||
export class NodeIKernelMsgListener {
|
||||
onAddSendMsg (_msgRecord: RawMessage): any {
|
||||
|
||||
}
|
||||
|
||||
onBroadcastHelperDownloadComplete (_broadcastHelperTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onBroadcastHelperProgressUpdate (_broadcastHelperTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onChannelFreqLimitInfoUpdate (_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onContactUnreadCntUpdate (_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onCustomWithdrawConfigUpdate (_customWithdrawConfig: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onDraftUpdate (_contact: unknown, _arrayList: unknown, _j2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onEmojiDownloadComplete (_emojiNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onEmojiResourceUpdate (_emojiResourceInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFeedEventUpdate (_firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFileMsgCome (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFirstViewDirectMsgUpdate (_firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFirstViewGroupGuildMapping (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGrabPasswordRedBag (_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupFileInfoAdd (_groupItem: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupFileInfoUpdate (_groupFileListResult: GroupFileInfoUpdateParamType): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupGuildUpdate (_groupGuildNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupTransferInfoAdd (_groupItem: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupTransferInfoUpdate (_groupFileListResult: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildInteractiveUpdate (_guildInteractiveNotificationItem: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildMsgAbFlagChanged (_guildMsgAbFlag: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildNotificationAbstractUpdate (_guildNotificationAbstractInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onHitCsRelatedEmojiResult (_downloadRelateEmojiResultInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onHitEmojiKeywordResult (_hitRelatedEmojiWordsResult: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onHitRelatedEmojiResult (_relatedWordEmojiInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onImportOldDbProgressUpdate (_importOldDbMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onInputStatusPush (_inputStatusInfo: {
|
||||
chatType: number;
|
||||
eventType: number;
|
||||
fromUin: string;
|
||||
interval: string;
|
||||
showTime: string;
|
||||
statusText: string;
|
||||
timestamp: string;
|
||||
toUin: string;
|
||||
}): any {
|
||||
|
||||
}
|
||||
|
||||
onKickedOffLine (_kickedInfo: KickedOffLineInfo): any {
|
||||
|
||||
}
|
||||
|
||||
onLineDev (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onLogLevelChanged (_j2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgAbstractUpdate (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgBoxChanged (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgDelete (_contact: unknown, _arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgEventListUpdate (_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgInfoListAdd (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgInfoListUpdate (_msgList: RawMessage[]): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgQRCodeStatusChanged (_i2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgRecall (_chatType: ChatType, _uid: string, _msgSeq: string): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgSecurityNotify (_msgRecord: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgSettingUpdate (_msgSetting: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onNtFirstViewMsgSyncEnd (): any {
|
||||
|
||||
}
|
||||
|
||||
onNtMsgSyncEnd (): any {
|
||||
|
||||
}
|
||||
|
||||
onNtMsgSyncStart (): any {
|
||||
|
||||
}
|
||||
|
||||
onReadFeedEventUpdate (_firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvGroupGuildFlag (_i2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvMsg (_arrayList: RawMessage[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvMsgSvrRspTransInfo (_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvOnlineFileMsg (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvS2CMsg (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvSysMsg (_arrayList: Array<number>): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvUDCFlag (_i2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRichMediaDownloadComplete (_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
|
||||
}
|
||||
|
||||
onRichMediaProgerssUpdate (_fileTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRichMediaUploadComplete (_fileTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSearchGroupFileInfoUpdate (_searchGroupFileResult: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSendMsgError (_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSysMsgNotification (_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onTempChatInfoUpdate (_tempChatInfo: TempOnRecvParams): any {
|
||||
|
||||
}
|
||||
|
||||
onUnreadCntAfterFirstView (_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUnreadCntUpdate (_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUserChannelTabStatusChanged (_z: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUserOnlineStatusChanged (_z: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUserTabStatusChanged (_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onlineStatusBigIconDownloadPush (_i2: unknown, _j2: unknown, _str: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onlineStatusSmallIconDownloadPush (_i2: unknown, _j2: unknown, _str: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
// 第一次发现于Linux
|
||||
onUserSecQualityChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgWithRichLinkInfoUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRedTouchChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
// 第一次发现于Win 9.9.9-23159
|
||||
onBroadcastHelperProgerssUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { User, UserDetailInfoListenerArg } from '@/napcat-core/types';
|
||||
|
||||
export class NodeIKernelProfileListener {
|
||||
onUserDetailInfoChanged (_arg: UserDetailInfoListenerArg): void {
|
||||
|
||||
}
|
||||
|
||||
onProfileSimpleChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onProfileDetailInfoChanged (_profile: User): any {
|
||||
|
||||
}
|
||||
|
||||
onStatusUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onSelfStatusChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onStrangerRemarkChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onMemberListChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onMemberInfoChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupListUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupAllInfoChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupDetailInfoChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupConfMemberChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupExtListUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupNotifiesUpdated (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupNotifiesUnreadCountUpdated (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupMemberLevelInfoChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupBulletinChange (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class NodeIKernelRecentContactListener {
|
||||
onDeletedContactsNotify (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRecentContactNotification (_msgList: any, _arg0: { msgListUnreadCnt: string; }, _arg1: number): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgUnreadCountUpdate (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildDisplayRecentContactListChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRecentContactListChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRecentContactListChangedVer2 (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class NodeIKernelRobotListener {
|
||||
onRobotFriendListChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRobotListChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRobotProfileChanged (..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ChatType, RawMessage } from '@/napcat-core/index';
|
||||
export interface SearchGroupInfo {
|
||||
groupCode: string;
|
||||
ownerUid: string;
|
||||
groupFlag: number;
|
||||
groupFlagExt: number;
|
||||
maxMemberNum: number;
|
||||
memberNum: number;
|
||||
groupOption: number;
|
||||
classExt: number;
|
||||
groupName: string;
|
||||
fingerMemo: string;
|
||||
groupQuestion: string;
|
||||
certType: number;
|
||||
shutUpAllTimestamp: number;
|
||||
shutUpMeTimestamp: number;
|
||||
groupTypeFlag: number;
|
||||
privilegeFlag: number;
|
||||
groupSecLevel: number;
|
||||
groupFlagExt3: number;
|
||||
isConfGroup: number;
|
||||
isModifyConfGroupFace: number;
|
||||
isModifyConfGroupName: number;
|
||||
noFigerOpenFlag: number;
|
||||
noCodeFingerOpenFlag: number;
|
||||
groupFlagExt4: number;
|
||||
groupMemo: string;
|
||||
cmdUinMsgSeq: number;
|
||||
cmdUinJoinTime: number;
|
||||
cmdUinUinFlag: number;
|
||||
cmdUinMsgMask: number;
|
||||
groupSecLevelInfo: number;
|
||||
cmdUinPrivilege: number;
|
||||
cmdUinFlagEx2: number;
|
||||
appealDeadline: number;
|
||||
remarkName: string;
|
||||
isTop: boolean;
|
||||
richFingerMemo: string;
|
||||
groupAnswer: string;
|
||||
joinGroupAuth: string;
|
||||
isAllowModifyConfGroupName: number;
|
||||
}
|
||||
|
||||
export interface GroupInfo {
|
||||
groupCode: string;
|
||||
searchGroupInfo: SearchGroupInfo;
|
||||
privilege: number;
|
||||
}
|
||||
|
||||
export interface GroupSearchResult {
|
||||
keyWord: string;
|
||||
errorCode: number;
|
||||
groupInfos: GroupInfo[];
|
||||
penetrate: string;
|
||||
isEnd: boolean;
|
||||
nextPos: number;
|
||||
}
|
||||
export interface NodeIKernelSearchListener {
|
||||
|
||||
onSearchGroupResult(params: GroupSearchResult): any;
|
||||
|
||||
onSearchFileKeywordsResult(params: {
|
||||
searchId: string,
|
||||
hasMore: boolean,
|
||||
resultItems: {
|
||||
chatType: ChatType,
|
||||
buddyChatInfo: any[],
|
||||
discussChatInfo: any[],
|
||||
groupChatInfo: {
|
||||
groupCode: string,
|
||||
isConf: boolean,
|
||||
hasModifyConfGroupFace: boolean,
|
||||
hasModifyConfGroupName: boolean,
|
||||
groupName: string,
|
||||
remark: string
|
||||
}[],
|
||||
dataLineChatInfo: any[],
|
||||
tmpChatInfo: any[],
|
||||
msgId: string,
|
||||
msgSeq: string,
|
||||
msgTime: string,
|
||||
senderUid: string,
|
||||
senderNick: string,
|
||||
senderRemark: string,
|
||||
senderCard: string,
|
||||
elemId: string,
|
||||
elemType: number,
|
||||
fileSize: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
hits: {
|
||||
start: number,
|
||||
end: number
|
||||
}[]
|
||||
}[]
|
||||
}): any;
|
||||
|
||||
onSearchMsgKeywordsResult(params: {
|
||||
searchId: string,
|
||||
hasMore: boolean,
|
||||
resultItems: Array<{
|
||||
msgId: string,
|
||||
msgSeq: string,
|
||||
msgTime: string,
|
||||
senderUid: string,
|
||||
senderUin: string,
|
||||
senderNick: string,
|
||||
senderNickHits: unknown[],
|
||||
senderRemark: string,
|
||||
senderRemarkHits: unknown[],
|
||||
senderCard: string,
|
||||
senderCardHits: unknown[],
|
||||
fieldType: number,
|
||||
fieldText: string,
|
||||
msgRecord: RawMessage;
|
||||
hitsInfo: Array<unknown>,
|
||||
msgAbstract: unknown,
|
||||
}>
|
||||
}): void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class NodeIKernelSessionListener {
|
||||
onNTSessionCreate (_args: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGProSessionCreate (_args: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSessionInitComplete (_args: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onOpentelemetryInit (_info: { is_init: boolean, is_report: boolean; }): any {
|
||||
|
||||
}
|
||||
|
||||
onUserOnlineResult (_args: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGetSelfTinyId (_args: unknown): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export class NodeIKernelStorageCleanListener {
|
||||
onCleanCacheProgressChanged (_args: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any {
|
||||
|
||||
}
|
||||
|
||||
onCleanCacheStorageChanged (_args: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFinishScan (_sizes: Array<`${number}`>): any {
|
||||
|
||||
}
|
||||
|
||||
onChatCleanDone (_args: unknown): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class NodeIKernelTicketListener {
|
||||
listener (): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class NodeIO3MiscListener {
|
||||
getOnAmgomDataPiece (..._arg: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
NodeIKernelBuddyListener,
|
||||
NodeIKernelFileAssistantListener,
|
||||
NodeIKernelGroupListener,
|
||||
NodeIKernelLoginListener,
|
||||
NodeIKernelMsgListener,
|
||||
NodeIKernelProfileListener,
|
||||
NodeIKernelRobotListener,
|
||||
NodeIKernelSessionListener,
|
||||
NodeIKernelStorageCleanListener,
|
||||
NodeIKernelTicketListener,
|
||||
} from '.';
|
||||
import { NodeIKernelSearchListener } from './NodeIKernelSearchListener';
|
||||
|
||||
export * from './NodeIKernelSessionListener';
|
||||
export * from './NodeIKernelLoginListener';
|
||||
export * from './NodeIKernelMsgListener';
|
||||
export * from './NodeIKernelGroupListener';
|
||||
export * from './NodeIKernelBuddyListener';
|
||||
export * from './NodeIKernelProfileListener';
|
||||
export * from './NodeIKernelRobotListener';
|
||||
export * from './NodeIKernelProfileListener';
|
||||
export * from './NodeIKernelTicketListener';
|
||||
export * from './NodeIKernelStorageCleanListener';
|
||||
export * from './NodeIKernelFileAssistantListener';
|
||||
export * from './NodeIKernelSearchListener';
|
||||
|
||||
export type ListenerNamingMapping = {
|
||||
NodeIKernelSessionListener: NodeIKernelSessionListener;
|
||||
NodeIKernelLoginListener: NodeIKernelLoginListener;
|
||||
NodeIKernelMsgListener: NodeIKernelMsgListener;
|
||||
NodeIKernelGroupListener: NodeIKernelGroupListener;
|
||||
NodeIKernelBuddyListener: NodeIKernelBuddyListener;
|
||||
NodeIKernelProfileListener: NodeIKernelProfileListener;
|
||||
NodeIKernelRobotListener: NodeIKernelRobotListener;
|
||||
NodeIKernelTicketListener: NodeIKernelTicketListener;
|
||||
NodeIKernelStorageCleanListener: NodeIKernelStorageCleanListener;
|
||||
NodeIKernelFileAssistantListener: NodeIKernelFileAssistantListener;
|
||||
NodeIKernelSearchListener: NodeIKernelSearchListener;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "napcat-core",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@protobuf-ts/runtime": "^2.11.1",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"compressing": "^1.10.1",
|
||||
"file-type": "^21.0.0",
|
||||
"htmlparser2": "10.0.0",
|
||||
"inversify": "^7.10.4",
|
||||
"json5": "^2.2.3",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-database": "workspace:*",
|
||||
"napcat-image-size": "workspace:*",
|
||||
"napcat-protobuf": "workspace:*",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { LogStack } from '@/napcat-core/packet/context/clientContext';
|
||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
import { OidbPacket, PacketBuf } from '@/napcat-core/packet/transformer/base';
|
||||
import { Napi2NativeLoader } from '@/napcat-core/packet/handler/napi2nativeLoader';
|
||||
export interface RecvPacket {
|
||||
type: string, // 仅recv
|
||||
data: RecvPacketData;
|
||||
}
|
||||
|
||||
export interface RecvPacketData {
|
||||
seq: number;
|
||||
cmd: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export class NativePacketClient {
|
||||
protected readonly napcore: NapCoreContext;
|
||||
protected readonly logger: PacketLogger;
|
||||
protected readonly cb = new Map<string, (json: RecvPacketData) => Promise<any> | any>(); // hash-type callback
|
||||
protected readonly napi2nativeLoader: Napi2NativeLoader;
|
||||
logStack: LogStack;
|
||||
available: boolean = false;
|
||||
|
||||
constructor (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack, napi2nativeLoader: Napi2NativeLoader) {
|
||||
this.napcore = napCore;
|
||||
this.logger = logger;
|
||||
this.logStack = logStack;
|
||||
this.napi2nativeLoader = napi2nativeLoader;
|
||||
}
|
||||
|
||||
check (): boolean {
|
||||
if (!this.napi2nativeLoader.loaded) {
|
||||
this.logStack.pushLogWarn('NativePacketClient: Napi2NativeLoader 未成功加载');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async init (_pid: number, recv: string, send: string): Promise<void> {
|
||||
this.available = false;
|
||||
const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild('40768');
|
||||
if (!isNewQQ) {
|
||||
this.logStack.pushLogWarn('[PacketClient] 当前 QQ 版本低于 NativePacketClient 要求,跳过 Hook 初始化');
|
||||
return;
|
||||
}
|
||||
const success = this.napi2nativeLoader.initHook(send, recv);
|
||||
if (success) {
|
||||
this.available = true;
|
||||
this.logStack.pushLogInfo(`[PacketClient] NativePacketClient Hook 初始化成功 platform=${process.platform}.${process.arch}`);
|
||||
return;
|
||||
}
|
||||
this.logStack.pushLogError(`[PacketClient] NativePacketClient Hook 初始化失败 platform=${process.platform}.${process.arch} send=${send} recv=${recv}`);
|
||||
}
|
||||
|
||||
async sendPacket (
|
||||
cmd: string,
|
||||
data: PacketBuf,
|
||||
rsp = false,
|
||||
timeout = 5000
|
||||
): Promise<RecvPacketData> {
|
||||
if (!rsp) {
|
||||
this.napcore
|
||||
.sendSsoCmdReqByContend(cmd, data)
|
||||
.catch((err: any) =>
|
||||
this.logger.error(
|
||||
`[PacketClient] sendPacket 无响应命令发送失败 cmd=${cmd} err=${err}`
|
||||
)
|
||||
);
|
||||
return { seq: 0, cmd, data: Buffer.alloc(0) };
|
||||
}
|
||||
|
||||
const sendPromise = this.napcore
|
||||
.sendSsoCmdReqByContend(cmd, data)
|
||||
.then(ret => ({
|
||||
seq: 0,
|
||||
cmd,
|
||||
data: (ret as { rspbuffer: Buffer; }).rspbuffer,
|
||||
}));
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
const timeoutPromise = new Promise<RecvPacketData>((_resolve, reject) => {
|
||||
timeoutHandle = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`[PacketClient] sendPacket 超时 cmd=${cmd} timeout=${timeout}ms`
|
||||
)
|
||||
),
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([sendPromise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle!);
|
||||
}
|
||||
}
|
||||
|
||||
async sendOidbPacket (pkt: OidbPacket, rsp = false, timeout = 5000): Promise<RecvPacketData> {
|
||||
return await this.sendPacket(pkt.cmd, pkt.data, rsp, timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PacketContext } from '@/napcat-core/packet/context/packetContext';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
|
||||
export class PacketClientSession {
|
||||
private readonly context: PacketContext;
|
||||
|
||||
constructor (core: NapCatCore) {
|
||||
this.context = new PacketContext(core);
|
||||
}
|
||||
|
||||
init (pid: number, recv: string, send: string): Promise<void> {
|
||||
return this.context.client.init(pid, recv, send);
|
||||
}
|
||||
|
||||
get clientLogStack () {
|
||||
return this.context.client.clientLogStack;
|
||||
}
|
||||
|
||||
get available () {
|
||||
return this.context.client.available;
|
||||
}
|
||||
|
||||
get operation () {
|
||||
return this.context.operation;
|
||||
}
|
||||
|
||||
// TODO: global message element adapter (?
|
||||
get msgConverter () {
|
||||
return this.context.msgConverter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NativePacketClient } from '@/napcat-core/packet/client/nativeClient';
|
||||
import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
||||
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import { Napi2NativeLoader } from '@/napcat-core/packet/handler/napi2nativeLoader';
|
||||
|
||||
export class LogStack {
|
||||
private stack: string[] = [];
|
||||
private readonly logger: PacketLogger;
|
||||
|
||||
constructor (logger: PacketLogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
push (msg: string) {
|
||||
this.stack.push(msg);
|
||||
}
|
||||
|
||||
pushLogInfo (msg: string) {
|
||||
this.logger.info(msg);
|
||||
this.stack.push(`${new Date().toISOString()} [INFO] ${msg}`);
|
||||
}
|
||||
|
||||
pushLogWarn (msg: string) {
|
||||
this.logger.warn(msg);
|
||||
this.stack.push(`${new Date().toISOString()} [WARN] ${msg}`);
|
||||
}
|
||||
|
||||
pushLogError (msg: string) {
|
||||
this.logger.error(msg);
|
||||
this.stack.push(`${new Date().toISOString()} [ERROR] ${msg}`);
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.stack = [];
|
||||
}
|
||||
|
||||
content () {
|
||||
return this.stack.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export class PacketClientContext {
|
||||
private readonly napCore: NapCoreContext;
|
||||
private readonly logger: PacketLogger;
|
||||
private readonly logStack: LogStack;
|
||||
private readonly napi2nativeLoader: Napi2NativeLoader;
|
||||
private readonly _client: NativePacketClient;
|
||||
|
||||
constructor (napCore: NapCoreContext, logger: PacketLogger, napi2nativeLoader: Napi2NativeLoader) {
|
||||
this.napCore = napCore;
|
||||
this.logger = logger;
|
||||
this.logStack = new LogStack(logger);
|
||||
this.napi2nativeLoader = napi2nativeLoader;
|
||||
this._client = this.newClient();
|
||||
}
|
||||
|
||||
get available (): boolean {
|
||||
return this._client.available;
|
||||
}
|
||||
|
||||
get clientLogStack (): string {
|
||||
return this._client.logStack.content();
|
||||
}
|
||||
|
||||
async init (pid: number, recv: string, send: string): Promise<void> {
|
||||
await this._client.init(pid, recv, send);
|
||||
}
|
||||
|
||||
async sendOidbPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T, timeout?: number): Promise<T extends true ? Buffer : void> {
|
||||
const raw = await this._client.sendOidbPacket(pkt, rsp, timeout);
|
||||
return raw.data as T extends true ? Buffer : void;
|
||||
}
|
||||
|
||||
private newClient (): NativePacketClient {
|
||||
this.logger.info('使用 NativePacketClient 作为后端');
|
||||
const client = new NativePacketClient(this.napCore, this.logger, this.logStack, this.napi2nativeLoader);
|
||||
if (!client.check()) {
|
||||
throw new Error('[Core] [Packet] NativePacketClient 不可用,NapCat.Packet将不会加载!');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import { LogWrapper, LogLevel } from '@/napcat-core/helper/log';
|
||||
|
||||
// TODO: check bind?
|
||||
export class PacketLogger {
|
||||
private readonly napLogger: LogWrapper;
|
||||
|
||||
constructor (napcore: NapCoreContext) {
|
||||
this.napLogger = napcore.logger;
|
||||
}
|
||||
|
||||
private _log (level: LogLevel, ...msg: any[]): void {
|
||||
this.napLogger._log(level, '[Core] [Packet] ' + msg);
|
||||
}
|
||||
|
||||
debug (...msg: any[]): void {
|
||||
this._log(LogLevel.DEBUG, msg);
|
||||
}
|
||||
|
||||
info (...msg: any[]): void {
|
||||
this._log(LogLevel.INFO, msg);
|
||||
}
|
||||
|
||||
warn (...msg: any[]): void {
|
||||
this._log(LogLevel.WARN, msg);
|
||||
}
|
||||
|
||||
error (...msg: any[]): void {
|
||||
this._log(LogLevel.ERROR, msg);
|
||||
}
|
||||
|
||||
fatal (...msg: any[]): void {
|
||||
this._log(LogLevel.FATAL, msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
|
||||
export interface NapCoreCompatBasicInfo {
|
||||
readonly requireMinNTQQBuild: (buildVer: string) => boolean;
|
||||
readonly uin: number;
|
||||
readonly uid: string;
|
||||
readonly uin2uid: (uin: number) => Promise<string>;
|
||||
readonly uid2uin: (uid: string) => Promise<number>;
|
||||
readonly sendSsoCmdReqByContend: (cmd: string, trace_id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class NapCoreContext {
|
||||
private readonly core: NapCatCore;
|
||||
|
||||
constructor (core: NapCatCore) {
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
get logger () {
|
||||
return this.core.context.logger;
|
||||
}
|
||||
|
||||
get basicInfo () {
|
||||
return {
|
||||
requireMinNTQQBuild: (buildVer: string) => this.core.context.basicInfoWrapper.requireMinNTQQBuild(buildVer),
|
||||
uin: +this.core.selfInfo.uin,
|
||||
uid: this.core.selfInfo.uid,
|
||||
uin2uid: (uin: number) => this.core.apis.UserApi.getUidByUinV2(String(uin)).then(res => res ?? ''),
|
||||
uid2uin: (uid: string) => this.core.apis.UserApi.getUinByUidV2(uid).then(res => +res),
|
||||
} as NapCoreCompatBasicInfo;
|
||||
}
|
||||
|
||||
get config () {
|
||||
return this.core.configLoader.configData;
|
||||
}
|
||||
|
||||
get napi2nativeLoader () {
|
||||
return this.core.context.napi2nativeLoader;
|
||||
}
|
||||
|
||||
sendSsoCmdReqByContend = (cmd: string, data: Buffer) => this.core.context.session.getMsgService().sendSsoCmdReqByContend(cmd, data);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { PacketContext } from '@/napcat-core/packet/context/packetContext';
|
||||
import * as trans from '@/napcat-core/packet/transformer';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
import {
|
||||
PacketMsgFileElement,
|
||||
PacketMsgPicElement,
|
||||
PacketMsgPttElement,
|
||||
PacketMsgReplyElement,
|
||||
PacketMsgVideoElement,
|
||||
} from '@/napcat-core/packet/message/element';
|
||||
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/napcat-core/index';
|
||||
import { MiniAppRawData, MiniAppReqParams } from '@/napcat-core/packet/entities/miniApp';
|
||||
import { AIVoiceChatType } from '@/napcat-core/packet/entities/aiChat';
|
||||
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'napcat-protobuf';
|
||||
import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/napcat-core/packet/transformer/proto';
|
||||
import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||
import { gunzipSync } from 'zlib';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
import { assertUploadResults } from '@/napcat-core/packet/context/uploadResult';
|
||||
|
||||
export class PacketOperationContext {
|
||||
private readonly context: PacketContext;
|
||||
|
||||
constructor (context: PacketContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||
}
|
||||
|
||||
async SendPoke (is_group: boolean, peer: number, target?: number) {
|
||||
const req = trans.SendPoke.build(is_group, peer, target ?? peer);
|
||||
await this.context.client.sendOidbPacket(req);
|
||||
}
|
||||
|
||||
async SetGroupTodo (groupUin: number, msgSeq: string) {
|
||||
const req = trans.SetGroupTodo.build(groupUin, msgSeq);
|
||||
await this.context.client.sendOidbPacket(req, true);
|
||||
}
|
||||
|
||||
async CompleteGroupTodo (groupUin: number, msgSeq: string) {
|
||||
const req = trans.CompleteGroupTodo.build(groupUin, msgSeq);
|
||||
await this.context.client.sendOidbPacket(req, true);
|
||||
}
|
||||
|
||||
async CancelGroupTodo (groupUin: number, msgSeq: string) {
|
||||
const req = trans.CancelGroupTodo.build(groupUin, msgSeq);
|
||||
await this.context.client.sendOidbPacket(req, true);
|
||||
}
|
||||
|
||||
async FetchRkey (timeout: number = 10000) {
|
||||
const req = trans.FetchRkey.build();
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.FetchRkey.parse(resp);
|
||||
return res.data.rkeyList;
|
||||
}
|
||||
|
||||
async GroupSign (groupUin: number) {
|
||||
const req = trans.GroupSign.build(this.context.napcore.basicInfo.uin, groupUin);
|
||||
await this.context.client.sendOidbPacket(req);
|
||||
}
|
||||
|
||||
async GetStrangerStatus (uin: number) {
|
||||
let status = 0;
|
||||
try {
|
||||
const req = trans.GetStrangerInfo.build(uin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.GetStrangerInfo.parse(resp);
|
||||
const extBigInt = BigInt(res.data.status.value);
|
||||
if (extBigInt <= 10n) {
|
||||
return { status: Number(extBigInt) * 10, ext_status: 0 };
|
||||
}
|
||||
status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn));
|
||||
return { status: 10, ext_status: status };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async SetGroupSpecialTitle (groupUin: number, uid: string, title: string) {
|
||||
const req = trans.SetSpecialTitle.build(groupUin, uid, title);
|
||||
await this.context.client.sendOidbPacket(req);
|
||||
}
|
||||
|
||||
async UploadResources (msg: PacketMsg[], groupUin: number = 0) {
|
||||
const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C;
|
||||
const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid;
|
||||
const reqList = msg.flatMap((m) =>
|
||||
m.msg
|
||||
.map((e) => {
|
||||
if (e instanceof PacketMsgPicElement) {
|
||||
return this.context.highway.uploadImage({ chatType, peerUid }, e);
|
||||
} else if (e instanceof PacketMsgVideoElement) {
|
||||
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
|
||||
} else if (e instanceof PacketMsgPttElement) {
|
||||
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
|
||||
} else if (e instanceof PacketMsgFileElement) {
|
||||
return this.context.highway.uploadFile({ chatType, peerUid }, e);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
const res = await Promise.allSettled(reqList);
|
||||
assertUploadResults(res, this.context.logger);
|
||||
}
|
||||
|
||||
async UploadImage (img: PacketMsgPicElement) {
|
||||
await this.context.highway.uploadImage(
|
||||
{
|
||||
chatType: ChatType.KCHATTYPEC2C,
|
||||
peerUid: this.context.napcore.basicInfo.uid,
|
||||
},
|
||||
img
|
||||
);
|
||||
const index = img.msgInfo?.msgInfoBody?.at(0)?.index;
|
||||
if (!index) {
|
||||
throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined');
|
||||
}
|
||||
return await this.GetImageUrl(this.context.napcore.basicInfo.uid, index);
|
||||
}
|
||||
|
||||
async GetImageUrl (selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||
const req = trans.DownloadImage.build(selfUid, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetPttUrl (selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadPtt.build(selfUid, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadPtt.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetVideoUrl (selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadVideo.build(selfUid, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadVideo.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetGroupImageUrl (groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadGroupImage.build(groupUin, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetGroupPttUrl (groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadGroupPtt.build(groupUin, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetGroupVideoUrl (groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
|
||||
const req = trans.DownloadGroupVideo.build(groupUin, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async ImageOCR (imgUrl: string) {
|
||||
const req = trans.ImageOCR.build(imgUrl);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.ImageOCR.parse(resp);
|
||||
return {
|
||||
texts: res.ocrRspBody.textDetections.map((item) => {
|
||||
return {
|
||||
text: item.detectedText,
|
||||
confidence: item.confidence,
|
||||
coordinates: item.polygon.coordinates.map((c) => {
|
||||
return {
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
language: res.ocrRspBody.language,
|
||||
} as ImageOcrResult;
|
||||
}
|
||||
|
||||
private async SendPreprocess (msg: PacketMsg[], groupUin: number = 0) {
|
||||
const ps = msg.map((m) => {
|
||||
return m.msg.map(async (e) => {
|
||||
if (e instanceof PacketMsgReplyElement && !e.targetElems) {
|
||||
this.context.logger.debug('Cannot find reply element\'s targetElems, prepare to fetch it...');
|
||||
if (!e.targetPeer?.peerUid) {
|
||||
this.context.logger.error('targetPeer is undefined!');
|
||||
}
|
||||
let targetMsg: NapProtoEncodeStructType<typeof PushMsgBody>[] | undefined;
|
||||
if (e.isGroupReply) {
|
||||
targetMsg = await this.FetchGroupMessage(+(e.targetPeer?.peerUid ?? 0), e.targetMessageSeq, e.targetMessageSeq);
|
||||
} else {
|
||||
targetMsg = await this.FetchC2CMessage(await this.context.napcore.basicInfo.uin2uid(e.targetUin), e.targetMessageSeq, e.targetMessageSeq);
|
||||
}
|
||||
e.targetElems = targetMsg.at(0)?.body?.richText?.elems;
|
||||
e.targetSourceMsg = targetMsg.at(0);
|
||||
}
|
||||
});
|
||||
}).flat();
|
||||
await Promise.all(ps);
|
||||
await this.UploadResources(msg, groupUin);
|
||||
}
|
||||
|
||||
async FetchGroupMessage (groupUin: number, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
|
||||
const req = trans.FetchGroupMessage.build(groupUin, startSeq, endSeq);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.FetchGroupMessage.parse(resp);
|
||||
return res.body.messages;
|
||||
}
|
||||
|
||||
async FetchC2CMessage (targetUid: string, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
|
||||
const req = trans.FetchC2CMessage.build(targetUid, startSeq, endSeq);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.FetchC2CMessage.parse(resp);
|
||||
return res.messages;
|
||||
}
|
||||
|
||||
async UploadForwardMsg (msg: PacketMsg[], groupUin: number = 0) {
|
||||
await this.SendPreprocess(msg, groupUin);
|
||||
const req = trans.UploadForwardMsg.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
|
||||
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||
// 遍历上传资源(跳过已有原始数据的项)
|
||||
await Promise.all(msg.filter(item => item.actionMsg).map(async (item) => {
|
||||
await this.SendPreprocess(item.actionMsg!, groupUin);
|
||||
}));
|
||||
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
|
||||
async MoveGroupFile (
|
||||
groupUin: number,
|
||||
fileUUID: string,
|
||||
currentParentDirectory: string,
|
||||
targetParentDirectory: string
|
||||
) {
|
||||
const req = trans.MoveGroupFile.build(groupUin, fileUUID, currentParentDirectory, targetParentDirectory);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.MoveGroupFile.parse(resp);
|
||||
return res.move.retCode;
|
||||
}
|
||||
|
||||
async RenameGroupFile (groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string) {
|
||||
const req = trans.RenameGroupFile.build(groupUin, fileUUID, currentParentDirectory, newName);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.RenameGroupFile.parse(resp);
|
||||
return res.rename.retCode;
|
||||
}
|
||||
|
||||
async GetGroupFileUrl (groupUin: number, fileUUID: string, timeout?: number) {
|
||||
const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadGroupFile.parse(resp);
|
||||
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
|
||||
}
|
||||
|
||||
async GetPrivateFileUrl (self_id: string, fileUUID: string, md5: string, timeout?: number) {
|
||||
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadPrivateFile.parse(resp);
|
||||
return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`;
|
||||
}
|
||||
|
||||
async GetMiniAppAdaptShareInfo (param: MiniAppReqParams) {
|
||||
const req = trans.GetMiniAppAdaptShareInfo.build(param);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.GetMiniAppAdaptShareInfo.parse(resp);
|
||||
return JSON.parse(res.content.jsonContent) as MiniAppRawData;
|
||||
}
|
||||
|
||||
async FetchAiVoiceList (groupUin: number, chatType: AIVoiceChatType) {
|
||||
const req = trans.FetchAiVoiceList.build(groupUin, chatType);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.FetchAiVoiceList.parse(resp);
|
||||
if (!res.content) return null;
|
||||
return res.content.map((item) => {
|
||||
return {
|
||||
category: item.category,
|
||||
voices: item.voices,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async GetAiVoice (
|
||||
groupUin: number,
|
||||
voiceId: string,
|
||||
text: string,
|
||||
chatType: AIVoiceChatType
|
||||
): Promise<NapProtoDecodeStructType<typeof MsgInfo>> {
|
||||
let reqTime = 0;
|
||||
const reqMaxTime = 30;
|
||||
const sessionId = crypto.randomBytes(4).readUInt32BE(0);
|
||||
while (true) {
|
||||
if (reqTime >= reqMaxTime) {
|
||||
throw new Error(`sendAiVoiceChatReq failed after ${reqMaxTime} times`);
|
||||
}
|
||||
reqTime++;
|
||||
const req = trans.GetAiVoice.build(groupUin, voiceId, text, sessionId, chatType);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.GetAiVoice.parse(resp);
|
||||
if (!res.msgInfo) continue;
|
||||
return res.msgInfo;
|
||||
}
|
||||
}
|
||||
|
||||
async FetchForwardMsgRaw (resId: string) {
|
||||
const req = trans.DownloadForwardMsg.build(this.context.napcore.basicInfo.uid, resId);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.DownloadForwardMsg.parse(resp);
|
||||
const inflate = gunzipSync(res.result.payload);
|
||||
const result = new NapProtoMsg(LongMsgResult).decode(inflate);
|
||||
return result.action;
|
||||
}
|
||||
|
||||
async FetchForwardMsg (res_id: string): Promise<RawMessage[]> {
|
||||
const req = trans.DownloadForwardMsg.build(this.context.napcore.basicInfo.uid, res_id);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.DownloadForwardMsg.parse(resp);
|
||||
const inflate = gunzipSync(res.result.payload);
|
||||
const result = new NapProtoMsg(LongMsgResult).decode(inflate);
|
||||
|
||||
const main = result.action.find((r) => r.actionCommand === 'MultiMsg');
|
||||
if (!main?.actionData.msgBody) {
|
||||
throw new Error('msgBody is empty');
|
||||
}
|
||||
this.context.logger.debug('rawChains ', inflate.toString('hex'));
|
||||
|
||||
const messagesPromises = main.actionData.msgBody.map(async (msg) => {
|
||||
if (!msg?.body?.richText?.elems) {
|
||||
throw new Error('msg.body.richText.elems is empty');
|
||||
}
|
||||
const rawChains = new PacketMsgConverter().packetMsgToRaw(msg?.body?.richText?.elems);
|
||||
const elements = await Promise.all(
|
||||
rawChains.map(async ([element, rawElem]) => {
|
||||
if (element.picElement && rawElem?.commonElem?.pbElem) {
|
||||
const extra = new NapProtoMsg(MsgInfo).decode(rawElem.commonElem.pbElem);
|
||||
const index = extra?.msgInfoBody[0]?.index;
|
||||
if (msg?.responseHead.grp !== undefined) {
|
||||
const groupUin = msg?.responseHead.grp?.groupUin ?? 0;
|
||||
element.picElement = {
|
||||
...element.picElement,
|
||||
originImageUrl: await this.GetGroupImageUrl(groupUin, index!),
|
||||
};
|
||||
} else {
|
||||
element.picElement = {
|
||||
...element.picElement,
|
||||
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!),
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}
|
||||
if (element.videoElement && rawElem?.commonElem?.pbElem) {
|
||||
const extra = new NapProtoMsg(MsgInfo).decode(rawElem.commonElem.pbElem);
|
||||
const index = extra?.msgInfoBody[0]?.index;
|
||||
if (!index) return element;
|
||||
if (msg?.responseHead.grp !== undefined) {
|
||||
element.videoElement = {
|
||||
...element.videoElement,
|
||||
filePath: await this.GetGroupVideoUrl(msg.responseHead.grp.groupUin ?? 0, index),
|
||||
};
|
||||
} else {
|
||||
element.videoElement = {
|
||||
...element.videoElement,
|
||||
filePath: await this.GetVideoUrl(this.context.napcore.basicInfo.uid, index),
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}
|
||||
return element;
|
||||
})
|
||||
);
|
||||
const isGroup = msg?.responseHead.grp !== undefined;
|
||||
const peerUin = isGroup
|
||||
? String(msg?.responseHead.grp?.groupUin ?? 0)
|
||||
: String(msg?.responseHead.fromUin ?? 0);
|
||||
const peerUid = isGroup
|
||||
? String(msg?.responseHead.grp?.groupUin ?? 0)
|
||||
: String(msg?.responseHead.fromUid ?? '');
|
||||
const sendNickName = isGroup
|
||||
? (msg?.responseHead.grp?.memberName ?? '')
|
||||
: (msg?.responseHead.forward?.friendName ?? '');
|
||||
|
||||
return {
|
||||
chatType: isGroup ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C,
|
||||
elements,
|
||||
guildId: '',
|
||||
isOnlineMsg: false,
|
||||
msgId: '7467703692092974645', // TODO: no necessary
|
||||
msgRandom: '0',
|
||||
msgSeq: String(msg.contentHead.sequence ?? 0),
|
||||
msgTime: String(msg.contentHead.timeStamp ?? 0),
|
||||
msgType: NTMsgType.KMSGTYPEMIX,
|
||||
parentMsgIdList: [],
|
||||
parentMsgPeer: {
|
||||
chatType: isGroup ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C,
|
||||
peerUid,
|
||||
},
|
||||
peerName: '',
|
||||
peerUid,
|
||||
peerUin,
|
||||
recallTime: '0',
|
||||
records: [],
|
||||
sendNickName,
|
||||
sendRemarkName: sendNickName,
|
||||
senderUid: msg?.responseHead.fromUid ?? '',
|
||||
senderUin: String(msg?.responseHead.fromUin ?? 0),
|
||||
sourceType: MsgSourceType.K_DOWN_SOURCETYPE_UNKNOWN,
|
||||
subMsgType: 1,
|
||||
};
|
||||
});
|
||||
return await Promise.all(messagesPromises);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PacketHighwayContext } from '@/napcat-core/packet/highway/highwayContext';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
|
||||
import { PacketOperationContext } from '@/napcat-core/packet/context/operationContext';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
|
||||
export class PacketContext {
|
||||
readonly msgConverter: PacketMsgConverter;
|
||||
readonly napcore: NapCoreContext;
|
||||
readonly logger: PacketLogger;
|
||||
readonly client: PacketClientContext;
|
||||
readonly highway: PacketHighwayContext;
|
||||
readonly operation: PacketOperationContext;
|
||||
|
||||
constructor (core: NapCatCore) {
|
||||
this.msgConverter = new PacketMsgConverter();
|
||||
this.napcore = new NapCoreContext(core);
|
||||
this.logger = new PacketLogger(this.napcore);
|
||||
this.client = new PacketClientContext(this.napcore, this.logger, this.napcore.napi2nativeLoader);
|
||||
this.highway = new PacketHighwayContext(this.napcore, this.logger, this.client);
|
||||
this.operation = new PacketOperationContext(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface UploadResultLogger {
|
||||
warn (message: string): unknown;
|
||||
error (message: string): unknown;
|
||||
}
|
||||
|
||||
export function assertUploadResults (
|
||||
results: PromiseSettledResult<unknown>[],
|
||||
logger: UploadResultLogger
|
||||
): void {
|
||||
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
if (failures.length === 0) return;
|
||||
|
||||
logger.warn(`上传资源${results.length}个,失败${failures.length}个`);
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const reason = result.reason instanceof Error
|
||||
? result.reason.stack ?? result.reason.message
|
||||
: String(result.reason);
|
||||
logger.error(`上传第${index + 1}个资源失败:${reason}`);
|
||||
}
|
||||
});
|
||||
throw new AggregateError(
|
||||
failures.map(result => result.reason),
|
||||
`上传转发消息资源失败:${failures.length}/${results.length}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export enum AIVoiceChatType {
|
||||
Unknown = 0,
|
||||
Sound = 1,
|
||||
Sing = 2,
|
||||
}
|
||||
|
||||
export interface AIVoiceItem {
|
||||
voiceId: string;
|
||||
voiceDisplayName: string;
|
||||
voiceExampleUrl: string;
|
||||
}
|
||||
|
||||
export interface AIVoiceItemList {
|
||||
category: string;
|
||||
voices: AIVoiceItem[];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface MiniAppReqCustomParams {
|
||||
title: string;
|
||||
desc: string;
|
||||
picUrl: string;
|
||||
jumpUrl: string;
|
||||
webUrl?: string;
|
||||
}
|
||||
|
||||
export interface MiniAppReqTemplateParams {
|
||||
sdkId: string;
|
||||
appId: string;
|
||||
scene: number;
|
||||
iconUrl: string;
|
||||
templateType: number;
|
||||
businessType: number;
|
||||
verType: number;
|
||||
shareType: number;
|
||||
versionId: string;
|
||||
withShareTicket: number;
|
||||
}
|
||||
|
||||
export interface MiniAppReqParams extends MiniAppReqCustomParams, MiniAppReqTemplateParams {}
|
||||
|
||||
export interface MiniAppData {
|
||||
ver: string;
|
||||
prompt: string;
|
||||
config: Config;
|
||||
app: string;
|
||||
view: string;
|
||||
meta: MetaData;
|
||||
miniappShareOrigin: number;
|
||||
miniappOpenRefer: string;
|
||||
}
|
||||
|
||||
export interface MiniAppRawData {
|
||||
appName: string;
|
||||
appView: string;
|
||||
ver: string;
|
||||
desc: string;
|
||||
prompt: string;
|
||||
metaData: MetaData;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
forward: number;
|
||||
autoSize: number;
|
||||
ctime: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface Host {
|
||||
uin: number;
|
||||
nick: string;
|
||||
}
|
||||
|
||||
interface Detail {
|
||||
appid: string;
|
||||
appType: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
preview: string;
|
||||
url: string;
|
||||
scene: number;
|
||||
host: Host;
|
||||
shareTemplateId: string;
|
||||
shareTemplateData: Record<string, unknown>;
|
||||
showLittleTail: string;
|
||||
gamePoints: string;
|
||||
gamePointsUrl: string;
|
||||
shareOrigin: number;
|
||||
}
|
||||
|
||||
interface MetaData {
|
||||
detail_1: Detail;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface ImageOcrResult {
|
||||
texts: TextDetection[];
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface TextDetection {
|
||||
text: string;
|
||||
confidence: number;
|
||||
coordinates: Coordinate[];
|
||||
}
|
||||
|
||||
export interface Coordinate {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { constants } from 'node:os';
|
||||
import offset from '@/napcat-core/external/packet.json';
|
||||
import { LogWrapper } from '../../helper/log';
|
||||
interface OffsetType {
|
||||
[key: string]: {
|
||||
recv: string;
|
||||
send: string;
|
||||
};
|
||||
}
|
||||
|
||||
const typedOffset: OffsetType = offset;
|
||||
// 0 send 1 recv
|
||||
export interface NativePacketExportType {
|
||||
initHook?: (send: string, recv: string, callback: (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => string;
|
||||
}
|
||||
|
||||
export type PacketType = 0 | 1; // 0: send, 1: recv
|
||||
export type PacketCallback = (data: { type: PacketType, uin: string, cmd: string, seq: number, hex_data: string; }) => void;
|
||||
|
||||
interface ListenerEntry {
|
||||
callback: PacketCallback;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
export class NativePacketHandler {
|
||||
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
|
||||
private readonly MoeHooExport: { exports: NativePacketExportType; } = { exports: {} };
|
||||
protected readonly logger: LogWrapper;
|
||||
private loaded: boolean = false;
|
||||
|
||||
// 统一的监听器存储 - key: 'all' | 'type:0' | 'type:1' | 'cmd:xxx' | 'exact:type:cmd'
|
||||
private readonly listeners: Map<string, Set<ListenerEntry>> = new Map();
|
||||
|
||||
constructor ({ logger }: { logger: LogWrapper; }) {
|
||||
this.logger = logger;
|
||||
try {
|
||||
const platform = process.platform + '.' + process.arch;
|
||||
if (!this.supportedPlatforms.includes(platform)) {
|
||||
this.logger.logWarn(`NativePacketClient: 不支持的平台: ${platform}`);
|
||||
return;
|
||||
}
|
||||
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/packet/MoeHoo.' + platform + '.node');
|
||||
if (!fs.existsSync(moehoo_path)) {
|
||||
this.logger.logWarn(`NativePacketClient: 缺失运行时文件: ${moehoo_path}`);
|
||||
this.loaded = false;
|
||||
return;
|
||||
}
|
||||
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
|
||||
this.loaded = true;
|
||||
this.logger.log('[PacketHandler] 加载成功');
|
||||
} catch (error) {
|
||||
this.logger.logError('NativePacketClient 加载出错:', error);
|
||||
this.loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加监听器的通用方法
|
||||
*/
|
||||
private addListener (key: string, callback: PacketCallback, once: boolean = false): () => void {
|
||||
if (!this.listeners.has(key)) {
|
||||
this.listeners.set(key, new Set());
|
||||
}
|
||||
const entry: ListenerEntry = { callback, once };
|
||||
this.listeners.get(key)!.add(entry);
|
||||
return () => this.removeListener(key, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除监听器的通用方法
|
||||
*/
|
||||
private removeListener (key: string, callback: PacketCallback): boolean {
|
||||
const entries = this.listeners.get(key);
|
||||
if (!entries) return false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.callback === callback) {
|
||||
return entries.delete(entry);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===== 永久监听器 =====
|
||||
|
||||
/** 监听所有数据包 */
|
||||
onAll (callback: PacketCallback): () => void {
|
||||
return this.addListener('all', callback);
|
||||
}
|
||||
|
||||
/** 监听特定类型的数据包 (0: send, 1: recv) */
|
||||
onType (type: PacketType, callback: PacketCallback): () => void {
|
||||
return this.addListener(`type:${type}`, callback);
|
||||
}
|
||||
|
||||
/** 监听所有发送的数据包 */
|
||||
onSend (callback: PacketCallback): () => void {
|
||||
return this.onType(0, callback);
|
||||
}
|
||||
|
||||
/** 监听所有接收的数据包 */
|
||||
onRecv (callback: PacketCallback): () => void {
|
||||
return this.onType(1, callback);
|
||||
}
|
||||
|
||||
/** 监听特定cmd的数据包(不限type) */
|
||||
onCmd (cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`cmd:${cmd}`, callback);
|
||||
}
|
||||
|
||||
/** 监听特定type和cmd的数据包(精确匹配) */
|
||||
onExact (type: PacketType, cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`exact:${type}:${cmd}`, callback);
|
||||
}
|
||||
|
||||
// ===== 一次性监听器 =====
|
||||
|
||||
/** 一次性监听所有数据包 */
|
||||
onceAll (callback: PacketCallback): () => void {
|
||||
return this.addListener('all', callback, true);
|
||||
}
|
||||
|
||||
/** 一次性监听特定类型的数据包 */
|
||||
onceType (type: PacketType, callback: PacketCallback): () => void {
|
||||
return this.addListener(`type:${type}`, callback, true);
|
||||
}
|
||||
|
||||
/** 一次性监听所有发送的数据包 */
|
||||
onceSend (callback: PacketCallback): () => void {
|
||||
return this.onceType(0, callback);
|
||||
}
|
||||
|
||||
/** 一次性监听所有接收的数据包 */
|
||||
onceRecv (callback: PacketCallback): () => void {
|
||||
return this.onceType(1, callback);
|
||||
}
|
||||
|
||||
/** 一次性监听特定cmd的数据包 */
|
||||
onceCmd (cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`cmd:${cmd}`, callback, true);
|
||||
}
|
||||
|
||||
/** 一次性监听特定type和cmd的数据包 */
|
||||
onceExact (type: PacketType, cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`exact:${type}:${cmd}`, callback, true);
|
||||
}
|
||||
|
||||
// ===== 移除监听器 =====
|
||||
|
||||
/** 移除特定的全局监听器 */
|
||||
off (key: string, callback: PacketCallback): boolean {
|
||||
return this.removeListener(key, callback);
|
||||
}
|
||||
|
||||
/** 移除特定key下的所有监听器 */
|
||||
offAll (key: string): void {
|
||||
this.listeners.delete(key);
|
||||
}
|
||||
|
||||
/** 移除所有监听器 */
|
||||
removeAllListeners (): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发监听器 - 按优先级触发: 精确匹配 > cmd匹配 > type匹配 > 全局
|
||||
*/
|
||||
private emitPacket (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string): void {
|
||||
const keys = [
|
||||
`exact:${type}:${cmd}`, // 精确匹配
|
||||
`cmd:${cmd}`, // cmd匹配
|
||||
`type:${type}`, // type匹配
|
||||
'all', // 全局
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const entries = this.listeners.get(key);
|
||||
if (!entries) continue;
|
||||
|
||||
const toRemove: ListenerEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
entry.callback({ type, uin, cmd, seq, hex_data });
|
||||
if (entry.once) {
|
||||
toRemove.push(entry);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError('监听器回调执行出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除一次性监听器
|
||||
for (const entry of toRemove) {
|
||||
entries.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async init (version: string, o3HookMode: boolean = false): Promise<boolean> {
|
||||
const version_arch = version + '-' + process.arch;
|
||||
try {
|
||||
if (!this.loaded) {
|
||||
this.logger.logWarn('NativePacketClient 未成功加载,无法初始化');
|
||||
return false;
|
||||
}
|
||||
const send = typedOffset[version_arch]?.send;
|
||||
const recv = typedOffset[version_arch]?.recv;
|
||||
if (!send || !recv) {
|
||||
this.logger.logWarn(`NativePacketClient: 未找到对应版本的偏移数据: ${version_arch}`);
|
||||
return false;
|
||||
}
|
||||
const platform = process.platform + '.' + process.arch;
|
||||
if (!this.supportedPlatforms.includes(platform)) {
|
||||
this.logger.logWarn(`NativePacketClient: 不支持的平台: ${platform}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => {
|
||||
this.emitPacket(type, uin, cmd, seq, hex_data);
|
||||
}, o3HookMode);
|
||||
if (result !== 'success') {
|
||||
this.logger.logError(`[PacketHandler] 初始化失败: ${result}`);
|
||||
return false;
|
||||
}
|
||||
this.logger.log('[PacketHandler] 初始化成功');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError('NativePacketClient 初始化出错:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TypedEventEmitter } from './typeEvent';
|
||||
|
||||
export interface AppEvents {
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
|
||||
KickedOffLine: string;
|
||||
// 静默离线:服务端会话已失效,但没有下发 KickedOffLine 通知
|
||||
SelfOffline: string;
|
||||
}
|
||||
export const appEvent = new TypedEventEmitter<AppEvents>();
|
||||
@@ -0,0 +1,90 @@
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { constants } from 'node:os';
|
||||
import { LogWrapper } from '../../helper/log';
|
||||
|
||||
export interface BypassOptions {
|
||||
hook?: boolean;
|
||||
window?: boolean;
|
||||
module?: boolean;
|
||||
process?: boolean;
|
||||
container?: boolean;
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
export interface Napi2NativeExportType {
|
||||
initHook?: (send: string, recv: string) => boolean;
|
||||
setVerbose?: (verbose: boolean) => void; // 默认关闭日志
|
||||
enableAllBypasses?: (options?: BypassOptions) => boolean;
|
||||
}
|
||||
|
||||
export class Napi2NativeLoader {
|
||||
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
|
||||
private readonly exports: { exports: Napi2NativeExportType; } = { exports: {} };
|
||||
protected readonly logger: LogWrapper;
|
||||
private _loaded: boolean = false;
|
||||
|
||||
constructor ({ logger }: { logger: LogWrapper; }) {
|
||||
this.logger = logger;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load (): void {
|
||||
const platform = process.platform + '.' + process.arch;
|
||||
|
||||
if (!this.supportedPlatforms.includes(platform)) {
|
||||
this.logger.logWarn(`Napi2NativeLoader: 不支持的平台: ${platform}`);
|
||||
this._loaded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeModulePath = path.join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'./native/napi2native/napi2native.' + platform + '.node'
|
||||
);
|
||||
|
||||
if (!fs.existsSync(nativeModulePath)) {
|
||||
this.logger.logWarn(`Napi2NativeLoader: 缺失运行时文件: ${nativeModulePath}`);
|
||||
this._loaded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.dlopen(this.exports, nativeModulePath, constants.dlopen.RTLD_LAZY);
|
||||
this._loaded = true;
|
||||
this.logger.log('[Napi2NativeLoader] 加载成功');
|
||||
} catch (error) {
|
||||
this.logger.logError('Napi2NativeLoader 加载出错:', error);
|
||||
this._loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
get loaded (): boolean {
|
||||
return this._loaded;
|
||||
}
|
||||
|
||||
get nativeExports (): Napi2NativeExportType {
|
||||
return this.exports.exports;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Hook
|
||||
* @param send send 偏移地址
|
||||
* @param recv recv 偏移地址
|
||||
* @returns 是否初始化成功
|
||||
*/
|
||||
initHook (send: string, recv: string): boolean {
|
||||
if (!this._loaded) {
|
||||
this.logger.logWarn('Napi2NativeLoader 未成功加载,无法初始化 Hook');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.nativeExports.initHook?.(send, recv) ?? false;
|
||||
} catch (error) {
|
||||
this.logger.logError('Napi2NativeLoader initHook 出错:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { Container, injectable } from 'inversify';
|
||||
import { NapCatCore } from '../..';
|
||||
import { TypedEventEmitter } from './typeEvent';
|
||||
|
||||
export const container = new Container();
|
||||
|
||||
export const ReceiverServiceRegistry = new Map<string, new (...args: any[]) => ServiceBase>();
|
||||
|
||||
export abstract class ServiceBase {
|
||||
get core (): NapCatCore {
|
||||
return container.get(NapCatCore);
|
||||
}
|
||||
|
||||
get event () {
|
||||
return container.get(TypedEventEmitter);
|
||||
}
|
||||
|
||||
abstract handler (seq: number, hex_data: string): Promise<void> | void;
|
||||
}
|
||||
|
||||
export function ReceiveService (serviceName: string) {
|
||||
return function <T extends new (...args: any[]) => ServiceBase>(constructor: T) {
|
||||
injectable()(constructor);
|
||||
ReceiverServiceRegistry.set(serviceName, constructor);
|
||||
return constructor;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export class TypedEventEmitter<E extends Record<string, any>> {
|
||||
private emitter = new EventEmitter();
|
||||
|
||||
on<K extends keyof E>(event: K, listener: (payload: E[K]) => void) {
|
||||
this.emitter.on(event as string, listener);
|
||||
return () => this.off(event, listener);
|
||||
}
|
||||
|
||||
once<K extends keyof E>(event: K, listener: (payload: E[K]) => void) {
|
||||
this.emitter.once(event as string, listener);
|
||||
}
|
||||
|
||||
off<K extends keyof E>(event: K, listener: (payload: E[K]) => void) {
|
||||
this.emitter.off(event as string, listener);
|
||||
}
|
||||
|
||||
emit<K extends keyof E>(event: K, payload: E[K]) {
|
||||
this.emitter.emit(event as string, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as stream from 'node:stream';
|
||||
import { ReadStream } from 'node:fs';
|
||||
import { HighwayTcpUploader } from '@/napcat-core/packet/highway/uploader/highwayTcpUploader';
|
||||
import { HighwayHttpUploader } from '@/napcat-core/packet/highway/uploader/highwayHttpUploader';
|
||||
import { PacketHighwaySig } from '@/napcat-core/packet/highway/highwayContext';
|
||||
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
|
||||
export interface PacketHighwayTrans {
|
||||
uin: string;
|
||||
cmd: number;
|
||||
command: string;
|
||||
data: stream.Readable;
|
||||
sum: Uint8Array;
|
||||
size: number;
|
||||
ticket: Uint8Array;
|
||||
loginSig?: Uint8Array;
|
||||
ext: Uint8Array;
|
||||
encrypt: boolean;
|
||||
timeout?: number;
|
||||
server: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export class PacketHighwayClient {
|
||||
sig: PacketHighwaySig;
|
||||
server: string = 'htdata3.qq.com';
|
||||
port: number = 80;
|
||||
logger: PacketLogger;
|
||||
|
||||
constructor (sig: PacketHighwaySig, logger: PacketLogger, _server: string = 'htdata3.qq.com', _port: number = 80) {
|
||||
this.sig = sig;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
changeServer (server: string, port: number) {
|
||||
this.server = server;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
private buildDataUpTrans (cmd: number, data: ReadStream, fileSize: number, md5: Uint8Array, extendInfo: Uint8Array, timeout: number = 1200): PacketHighwayTrans {
|
||||
return {
|
||||
uin: this.sig.uin,
|
||||
cmd,
|
||||
command: 'PicUp.DataUp',
|
||||
data,
|
||||
sum: md5,
|
||||
size: fileSize,
|
||||
ticket: this.sig.sigSession!,
|
||||
ext: extendInfo,
|
||||
encrypt: false,
|
||||
timeout,
|
||||
server: this.server,
|
||||
port: this.port,
|
||||
} as PacketHighwayTrans;
|
||||
}
|
||||
|
||||
async upload (cmd: number, data: ReadStream, fileSize: number, md5: Uint8Array, extendInfo: Uint8Array): Promise<void> {
|
||||
const trans = this.buildDataUpTrans(cmd, data, fileSize, md5, extendInfo);
|
||||
try {
|
||||
const tcpUploader = new HighwayTcpUploader(trans, this.logger);
|
||||
await tcpUploader.upload();
|
||||
} catch (e) {
|
||||
this.logger.error(`[Highway] upload failed: ${e}, fallback to http upload`);
|
||||
try {
|
||||
const httpUploader = new HighwayHttpUploader(trans, this.logger);
|
||||
await httpUploader.upload();
|
||||
} catch (e) {
|
||||
this.logger.error(`[Highway] http upload failed: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
export class Frame {
|
||||
static pack (head: Buffer, body: Buffer): Buffer {
|
||||
const totalLength = 9 + head.length + body.length + 1;
|
||||
const buffer = Buffer.allocUnsafe(totalLength);
|
||||
buffer[0] = 0x28;
|
||||
buffer.writeUInt32BE(head.length, 1);
|
||||
buffer.writeUInt32BE(body.length, 5);
|
||||
head.copy(buffer, 9);
|
||||
body.copy(buffer, 9 + head.length);
|
||||
buffer[totalLength - 1] = 0x29;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static unpack (frame: Buffer): [Buffer, Buffer] {
|
||||
assert(frame[0] === 0x28 && frame[frame.length - 1] === 0x29, 'Invalid frame!');
|
||||
const headLen = frame.readUInt32BE(1);
|
||||
const bodyLen = frame.readUInt32BE(5);
|
||||
// assert(frame.length === 9 + headLen + bodyLen + 1, `Frame ${frame.toString('hex')} length does not match head and body lengths!`);
|
||||
return [frame.subarray(9, 9 + headLen), frame.subarray(9 + headLen, 9 + headLen + bodyLen)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { PacketHighwayClient } from '@/napcat-core/packet/highway/client';
|
||||
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
import FetchSessionKey from '@/napcat-core/packet/transformer/highway/FetchSessionKey';
|
||||
import { int32ip2str, oidbIpv4s2HighwayIpv4s } from '@/napcat-core/packet/highway/utils';
|
||||
import {
|
||||
PacketMsgFileElement,
|
||||
PacketMsgPicElement,
|
||||
PacketMsgPttElement,
|
||||
PacketMsgVideoElement,
|
||||
} from '@/napcat-core/packet/message/element';
|
||||
import { ChatType, Peer } from '@/napcat-core/index';
|
||||
import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from '@/napcat-core/packet/utils/crypto/hash';
|
||||
import UploadGroupImage from '@/napcat-core/packet/transformer/highway/UploadGroupImage';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
import * as trans from '@/napcat-core/packet/transformer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
|
||||
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video';
|
||||
import { calculateFileMD5 } from 'napcat-common/src/file';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { imageSizeFallBack } from 'napcat-image-size/src/index';
|
||||
|
||||
export const BlockSize = 1024 * 1024;
|
||||
const VIDEO_THUMBNAIL_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
interface HighwayServerAddr {
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface PacketHighwaySig {
|
||||
uin: string;
|
||||
uid: string;
|
||||
sigSession: Uint8Array | null;
|
||||
sessionKey: Uint8Array | null;
|
||||
serverAddr: HighwayServerAddr[];
|
||||
}
|
||||
|
||||
export class PacketHighwayContext {
|
||||
private readonly napcore: NapCoreContext;
|
||||
private readonly client: PacketClientContext;
|
||||
protected sig: PacketHighwaySig;
|
||||
protected logger: PacketLogger;
|
||||
protected hwClient: PacketHighwayClient;
|
||||
private cachedPrepareReq: Promise<void> | null = null;
|
||||
|
||||
constructor (napcore: NapCoreContext, logger: PacketLogger, client: PacketClientContext) {
|
||||
this.napcore = napcore;
|
||||
this.client = client;
|
||||
this.sig = {
|
||||
uin: String(this.napcore.basicInfo.uin),
|
||||
uid: this.napcore.basicInfo.uid,
|
||||
sigSession: null,
|
||||
sessionKey: null,
|
||||
serverAddr: [],
|
||||
};
|
||||
this.logger = logger;
|
||||
this.hwClient = new PacketHighwayClient(this.sig, this.logger);
|
||||
}
|
||||
|
||||
private async checkAvailable () {
|
||||
if (this.sig.sigSession === null || this.sig.sessionKey === null) {
|
||||
if (this.cachedPrepareReq === null) {
|
||||
this.cachedPrepareReq = this.prepareUpload().finally(() => {
|
||||
this.cachedPrepareReq = null;
|
||||
});
|
||||
}
|
||||
await this.cachedPrepareReq;
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareUpload (): Promise<void> {
|
||||
this.logger.debug('[Highway] on prepareUpload!');
|
||||
const packet = FetchSessionKey.build();
|
||||
const req = await this.client.sendOidbPacket(packet, true);
|
||||
const rsp = FetchSessionKey.parse(req);
|
||||
this.sig.sigSession = rsp.httpConn.sigSession;
|
||||
this.sig.sessionKey = rsp.httpConn.sessionKey;
|
||||
for (const info of rsp.httpConn.serverInfos) {
|
||||
if (info.serviceType !== 1) continue;
|
||||
for (const addr of info.serverAddrs) {
|
||||
this.logger.debug(`[Highway PrepareUpload] server addr add: ${int32ip2str(addr.ip)}:${addr.port}`);
|
||||
this.sig.serverAddr.push({
|
||||
ip: int32ip2str(addr.ip),
|
||||
port: addr.port,
|
||||
});
|
||||
this.hwClient.changeServer(int32ip2str(addr.ip), addr.port);
|
||||
}
|
||||
}
|
||||
if (this.sig.serverAddr.length === 0) {
|
||||
this.logger.warn('[Highway PrepareUpload] server addr is empty!');
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage (peer: Peer, img: PacketMsgPicElement): Promise<void> {
|
||||
await this.checkAvailable();
|
||||
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
await this.uploadGroupImage(+peer.peerUid, img);
|
||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||
await this.uploadC2CImage(peer.peerUid, img);
|
||||
} else {
|
||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadVideo (peer: Peer, video: PacketMsgVideoElement): Promise<void> {
|
||||
await this.checkAvailable();
|
||||
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
|
||||
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB,请使用文件上传!`);
|
||||
}
|
||||
|
||||
const cleanupThumbPaths: string[] = [];
|
||||
try {
|
||||
let thumbExists = false;
|
||||
if (video.thumbPath) {
|
||||
try {
|
||||
await fs.promises.access(video.thumbPath, fs.constants.F_OK);
|
||||
thumbExists = true;
|
||||
} catch {
|
||||
thumbExists = false;
|
||||
}
|
||||
}
|
||||
|
||||
let shouldGenerateThumb = !video.thumbPath || !thumbExists;
|
||||
if (!shouldGenerateThumb) {
|
||||
try {
|
||||
await this.updateVideoThumbInfo(video);
|
||||
if ((video.thumbSize ?? 0) > VIDEO_THUMBNAIL_MAX_BYTES) {
|
||||
this.logger.warn(`[Highway] Video thumbnail is too large: ${video.thumbSize} bytes, regenerating`);
|
||||
shouldGenerateThumb = true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`[Highway] Failed to inspect video thumbnail, regenerating. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
shouldGenerateThumb = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldGenerateThumb) {
|
||||
cleanupThumbPaths.push(await this.ensureVideoThumb(video));
|
||||
}
|
||||
video.businessType = peer.chatType === ChatType.KCHATTYPEGROUP ? 21 : 11;
|
||||
|
||||
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
await this.uploadGroupVideo(+peer.peerUid, video);
|
||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||
await this.uploadC2CVideo(peer.peerUid, video);
|
||||
} else {
|
||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled(cleanupThumbPaths.map(async (thumbToClean) => {
|
||||
try {
|
||||
await fs.promises.unlink(thumbToClean);
|
||||
this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前 FFmpeg 适配器生成符合 QQ 大小限制的视频缩略图
|
||||
* @returns 生成的临时缩略图路径,用于后续清理
|
||||
*/
|
||||
private async ensureVideoThumb (video: PacketMsgVideoElement): Promise<string> {
|
||||
if (!video.filePath) {
|
||||
throw new Error('video.filePath is empty, cannot generate thumbnail');
|
||||
}
|
||||
|
||||
// 生成缩略图路径
|
||||
const videoDir = path.dirname(video.filePath);
|
||||
const videoBasename = path.basename(video.filePath, path.extname(video.filePath));
|
||||
const thumbPath = path.join(videoDir, `${videoBasename}_thumb_${randomUUID()}.jpg`);
|
||||
|
||||
this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`);
|
||||
video.thumbPath = thumbPath;
|
||||
|
||||
try {
|
||||
// 尝试使用 FFmpeg 提取视频缩略图
|
||||
await FFmpegService.extractThumbnail(video.filePath, thumbPath);
|
||||
try {
|
||||
await fs.promises.access(thumbPath, fs.constants.F_OK);
|
||||
this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg');
|
||||
} catch {
|
||||
throw new Error('FFmpeg failed to generate thumbnail');
|
||||
}
|
||||
await this.updateVideoThumbInfo(video);
|
||||
if ((video.thumbSize ?? 0) > VIDEO_THUMBNAIL_MAX_BYTES) {
|
||||
throw new Error(`generated thumbnail is too large: ${video.thumbSize} bytes`);
|
||||
}
|
||||
} catch (e) {
|
||||
// FFmpeg 失败时(包括未初始化的情况)使用默认缩略图
|
||||
this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
await this.updateVideoThumbInfo(video);
|
||||
}
|
||||
|
||||
return thumbPath;
|
||||
}
|
||||
|
||||
private async updateVideoThumbInfo (video: PacketMsgVideoElement): Promise<void> {
|
||||
if (!video.thumbPath) {
|
||||
throw new Error('video.thumbPath is empty, cannot inspect thumbnail');
|
||||
}
|
||||
const [thumbStat, thumbMd5, dimensions] = await Promise.all([
|
||||
fs.promises.stat(video.thumbPath),
|
||||
calculateFileMD5(video.thumbPath),
|
||||
imageSizeFallBack(video.thumbPath),
|
||||
]);
|
||||
video.thumbSize = thumbStat.size;
|
||||
video.thumbMd5 = thumbMd5;
|
||||
video.thumbWidth = dimensions.width;
|
||||
video.thumbHeight = dimensions.height;
|
||||
this.logger.debug(`[Highway] Video thumbnail ready: ${dimensions.width}x${dimensions.height}, ${thumbStat.size} bytes`);
|
||||
}
|
||||
|
||||
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {
|
||||
await this.checkAvailable();
|
||||
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
await this.uploadGroupPtt(+peer.peerUid, ptt);
|
||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||
await this.uploadC2CPtt(peer.peerUid, ptt);
|
||||
} else {
|
||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile (peer: Peer, file: PacketMsgFileElement): Promise<void> {
|
||||
await this.checkAvailable();
|
||||
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
|
||||
await this.uploadGroupFile(+peer.peerUid, file);
|
||||
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
|
||||
await this.uploadC2CFile(peer.peerUid, file);
|
||||
} else {
|
||||
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadGroupImage (groupUin: number, img: PacketMsgPicElement): Promise<void> {
|
||||
img.sha1 = Buffer.from(await calculateSha1(img.path)).toString('hex');
|
||||
const req = UploadGroupImage.build(groupUin, img);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = UploadGroupImage.parse(resp);
|
||||
const ukey = preRespData.upload.uKey;
|
||||
if (ukey && ukey !== '') {
|
||||
this.logger.debug(`[Highway] uploadGroupImageReq get upload ukey: ${ukey}, need upload!`);
|
||||
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
|
||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: index.fileUuid,
|
||||
uKey: ukey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: [sha1],
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1004,
|
||||
fs.createReadStream(img.path, { highWaterMark: BlockSize }),
|
||||
img.size,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadGroupImageReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||
}
|
||||
img.msgInfo = preRespData.upload.msgInfo;
|
||||
// img.groupPicExt = new NapProtoMsg(CustomFace).decode(preRespData.tcpUpload.compatQMsg)
|
||||
}
|
||||
|
||||
private async uploadC2CImage (peerUid: string, img: PacketMsgPicElement): Promise<void> {
|
||||
img.sha1 = Buffer.from(await calculateSha1(img.path)).toString('hex');
|
||||
const req = trans.UploadPrivateImage.build(peerUid, img);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadPrivateImage.parse(resp);
|
||||
const ukey = preRespData.upload.uKey;
|
||||
if (ukey && ukey !== '') {
|
||||
this.logger.debug(`[Highway] uploadC2CImageReq get upload ukey: ${ukey}, need upload!`);
|
||||
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
|
||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: index.fileUuid,
|
||||
uKey: ukey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: [sha1],
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1003,
|
||||
fs.createReadStream(img.path, { highWaterMark: BlockSize }),
|
||||
img.size,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadC2CImageReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||
}
|
||||
img.msgInfo = preRespData.upload.msgInfo;
|
||||
}
|
||||
|
||||
private async uploadGroupVideo (groupUin: number, video: PacketMsgVideoElement): Promise<void> {
|
||||
if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty');
|
||||
video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex');
|
||||
video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex');
|
||||
const req = trans.UploadGroupVideo.build(groupUin, video);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadGroupVideo.parse(resp);
|
||||
const videoIndex = preRespData.upload.msgInfo.msgInfoBody[0]?.index;
|
||||
const thumbIndex = preRespData.upload.msgInfo.msgInfoBody[1]?.index;
|
||||
if (!videoIndex || !thumbIndex) {
|
||||
throw new Error('[Highway] uploadGroupVideo response is missing video or thumbnail MsgInfo');
|
||||
}
|
||||
const ukey = preRespData.upload.uKey;
|
||||
if (ukey && ukey !== '') {
|
||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video ukey: ${ukey}, need upload!`);
|
||||
const md5 = Buffer.from(videoIndex.info.fileHash, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: videoIndex.fileUuid,
|
||||
uKey: ukey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: await calculateSha1StreamBytes(video.filePath),
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1005,
|
||||
fs.createReadStream(video.filePath, { highWaterMark: BlockSize }),
|
||||
+video.fileSize!,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||
}
|
||||
const subFile = preRespData.upload.subFileInfos[0];
|
||||
if (subFile?.uKey && subFile.uKey !== '') {
|
||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
|
||||
const md5 = Buffer.from(thumbIndex.info.fileHash, 'hex');
|
||||
const sha1 = Buffer.from(thumbIndex.info.fileSha1, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: thumbIndex.fileUuid,
|
||||
uKey: subFile.uKey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: [sha1],
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1006,
|
||||
fs.createReadStream(video.thumbPath, { highWaterMark: BlockSize }),
|
||||
+video.thumbSize!,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid thumb ukey ${subFile?.uKey ?? ''}, don't need upload!`);
|
||||
}
|
||||
video.msgInfo = preRespData.upload.msgInfo;
|
||||
}
|
||||
|
||||
private async uploadC2CVideo (peerUid: string, video: PacketMsgVideoElement): Promise<void> {
|
||||
if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty');
|
||||
video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex');
|
||||
video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex');
|
||||
const req = trans.UploadPrivateVideo.build(peerUid, video);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadPrivateVideo.parse(resp);
|
||||
const videoIndex = preRespData.upload.msgInfo.msgInfoBody[0]?.index;
|
||||
const thumbIndex = preRespData.upload.msgInfo.msgInfoBody[1]?.index;
|
||||
if (!videoIndex || !thumbIndex) {
|
||||
throw new Error('[Highway] uploadC2CVideo response is missing video or thumbnail MsgInfo');
|
||||
}
|
||||
const ukey = preRespData.upload.uKey;
|
||||
if (ukey && ukey !== '') {
|
||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video ukey: ${ukey}, need upload!`);
|
||||
const md5 = Buffer.from(videoIndex.info.fileHash, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: videoIndex.fileUuid,
|
||||
uKey: ukey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: await calculateSha1StreamBytes(video.filePath),
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1001,
|
||||
fs.createReadStream(video.filePath, { highWaterMark: BlockSize }),
|
||||
+video.fileSize!,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||
}
|
||||
const subFile = preRespData.upload.subFileInfos[0];
|
||||
if (subFile?.uKey && subFile.uKey !== '') {
|
||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
|
||||
const md5 = Buffer.from(thumbIndex.info.fileHash, 'hex');
|
||||
const sha1 = Buffer.from(thumbIndex.info.fileSha1, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: thumbIndex.fileUuid,
|
||||
uKey: subFile.uKey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: [sha1],
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1002,
|
||||
fs.createReadStream(video.thumbPath, { highWaterMark: BlockSize }),
|
||||
+video.thumbSize!,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid thumb ukey ${subFile?.uKey ?? ''}, don't need upload!`);
|
||||
}
|
||||
video.msgInfo = preRespData.upload.msgInfo;
|
||||
}
|
||||
|
||||
private async uploadGroupPtt (groupUin: number, ptt: PacketMsgPttElement): Promise<void> {
|
||||
ptt.fileSha1 = Buffer.from(await calculateSha1(ptt.filePath)).toString('hex');
|
||||
const req = trans.UploadGroupPtt.build(groupUin, ptt);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadGroupPtt.parse(resp);
|
||||
const ukey = preRespData.upload.uKey;
|
||||
if (ukey && ukey !== '') {
|
||||
this.logger.debug(`[Highway] uploadGroupPttReq get upload ptt ukey: ${ukey}, need upload!`);
|
||||
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
|
||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: index.fileUuid,
|
||||
uKey: ukey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: [sha1],
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1008,
|
||||
fs.createReadStream(ptt.filePath, { highWaterMark: BlockSize }),
|
||||
ptt.fileSize,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadGroupPttReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||
}
|
||||
ptt.msgInfo = preRespData.upload.msgInfo;
|
||||
}
|
||||
|
||||
private async uploadC2CPtt (peerUid: string, ptt: PacketMsgPttElement): Promise<void> {
|
||||
ptt.fileSha1 = Buffer.from(await calculateSha1(ptt.filePath)).toString('hex');
|
||||
const req = trans.UploadPrivatePtt.build(peerUid, ptt);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadPrivatePtt.parse(resp);
|
||||
const ukey = preRespData.upload.uKey;
|
||||
if (ukey && ukey !== '') {
|
||||
this.logger.debug(`[Highway] uploadC2CPttReq get upload ptt ukey: ${ukey}, need upload!`);
|
||||
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
|
||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||
fileUuid: index.fileUuid,
|
||||
uKey: ukey,
|
||||
network: {
|
||||
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
|
||||
},
|
||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||
blockSize: BlockSize,
|
||||
hash: {
|
||||
fileSha1: [sha1],
|
||||
},
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
1007,
|
||||
fs.createReadStream(ptt.filePath, { highWaterMark: BlockSize }),
|
||||
ptt.fileSize,
|
||||
md5,
|
||||
extend
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`[Highway] uploadC2CPttReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||
}
|
||||
ptt.msgInfo = preRespData.upload.msgInfo;
|
||||
}
|
||||
|
||||
private async uploadGroupFile (groupUin: number, file: PacketMsgFileElement): Promise<void> {
|
||||
file.isGroupFile = true;
|
||||
file.fileMd5 = await computeMd5AndLengthWithLimit(file.filePath);
|
||||
file.fileSha1 = await calculateSha1(file.filePath);
|
||||
const req = trans.UploadGroupFile.build(groupUin, file);
|
||||
const resp = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadGroupFile.parse(resp);
|
||||
if (!preRespData?.upload?.boolFileExist) {
|
||||
this.logger.debug('[Highway] uploadGroupFileReq file not exist, need upload!');
|
||||
const ext = new NapProtoMsg(proto.FileUploadExt).encode({
|
||||
unknown1: 100,
|
||||
unknown2: 1,
|
||||
entry: {
|
||||
busiBuff: {
|
||||
senderUin: BigInt(this.sig.uin),
|
||||
receiverUin: BigInt(groupUin),
|
||||
groupCode: BigInt(groupUin),
|
||||
},
|
||||
fileEntry: {
|
||||
fileSize: BigInt(file.fileSize),
|
||||
md5: file.fileMd5,
|
||||
md5S2: file.fileMd5,
|
||||
checkKey: preRespData.upload.checkKey,
|
||||
fileId: preRespData.upload.fileId,
|
||||
uploadKey: preRespData.upload.fileKey,
|
||||
},
|
||||
clientInfo: {
|
||||
clientType: 3,
|
||||
appId: '100',
|
||||
terminalType: 3,
|
||||
clientVer: '1.1.1',
|
||||
unknown: 4,
|
||||
},
|
||||
fileNameInfo: {
|
||||
fileName: file.fileName,
|
||||
},
|
||||
host: {
|
||||
hosts: [
|
||||
{
|
||||
url: {
|
||||
host: preRespData.upload.uploadIp,
|
||||
unknown: 1,
|
||||
},
|
||||
port: preRespData.upload.uploadPort,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unknown200: 0,
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
71,
|
||||
fs.createReadStream(file.filePath, { highWaterMark: BlockSize }),
|
||||
file.fileSize,
|
||||
file.fileMd5,
|
||||
ext
|
||||
);
|
||||
} else {
|
||||
this.logger.debug('[Highway] uploadGroupFileReq file exist, don\'t need upload!');
|
||||
}
|
||||
file.fileUuid = preRespData.upload.fileId;
|
||||
}
|
||||
|
||||
private async uploadC2CFile (peerUid: string, file: PacketMsgFileElement): Promise<void> {
|
||||
file.isGroupFile = false;
|
||||
file.fileMd5 = await computeMd5AndLengthWithLimit(file.filePath);
|
||||
file.fileSha1 = await calculateSha1(file.filePath);
|
||||
const req = await trans.UploadPrivateFile.build(this.sig.uid, peerUid, file);
|
||||
const res = await this.client.sendOidbPacket(req, true);
|
||||
const preRespData = trans.UploadPrivateFile.parse(res);
|
||||
if (!preRespData.upload?.boolFileExist) {
|
||||
this.logger.debug('[Highway] uploadC2CFileReq file not exist, need upload!');
|
||||
const ext = new NapProtoMsg(proto.FileUploadExt).encode({
|
||||
unknown1: 100,
|
||||
unknown2: 1,
|
||||
entry: {
|
||||
busiBuff: {
|
||||
senderUin: BigInt(this.sig.uin),
|
||||
},
|
||||
fileEntry: {
|
||||
fileSize: BigInt(file.fileSize),
|
||||
md5: file.fileMd5,
|
||||
md5S2: file.fileMd5,
|
||||
checkKey: file.fileSha1,
|
||||
fileId: preRespData.upload?.uuid,
|
||||
uploadKey: preRespData.upload?.mediaPlatformUploadKey,
|
||||
},
|
||||
clientInfo: {
|
||||
clientType: 3,
|
||||
appId: '100',
|
||||
terminalType: 3,
|
||||
clientVer: '1.1.1',
|
||||
unknown: 4,
|
||||
},
|
||||
fileNameInfo: {
|
||||
fileName: file.fileName,
|
||||
},
|
||||
host: {
|
||||
hosts: [
|
||||
{
|
||||
url: {
|
||||
host: preRespData.upload?.uploadIp,
|
||||
unknown: 1,
|
||||
},
|
||||
port: preRespData.upload?.uploadPort,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unknown200: 1,
|
||||
unknown3: 0,
|
||||
});
|
||||
await this.hwClient.upload(
|
||||
95,
|
||||
fs.createReadStream(file.filePath, { highWaterMark: BlockSize }),
|
||||
file.fileSize,
|
||||
file.fileMd5,
|
||||
ext
|
||||
);
|
||||
}
|
||||
file.fileUuid = preRespData.upload?.uuid;
|
||||
file.fileHash = preRespData.upload?.fileAddon;
|
||||
const fileExistReq = trans.DownloadOfflineFile.build(file.fileUuid!, file.fileHash!, this.sig.uid, peerUid);
|
||||
const fileExistRes = await this.client.sendOidbPacket(fileExistReq, true);
|
||||
file._e37_800_rsp = trans.DownloadOfflineFile.parse(fileExistRes);
|
||||
file._private_send_uid = this.sig.uid;
|
||||
file._private_recv_uid = peerUid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { IHighwayUploader } from '@/napcat-core/packet/highway/uploader/highwayUploader';
|
||||
import { Frame } from '@/napcat-core/packet/highway/frame';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
|
||||
export class HighwayHttpUploader extends IHighwayUploader {
|
||||
async upload (): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const upload = (async () => {
|
||||
let offset = 0;
|
||||
for await (const chunk of this.trans.data) {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Upload aborted due to timeout');
|
||||
}
|
||||
const block = chunk as Buffer;
|
||||
try {
|
||||
await this.uploadBlock(block, offset);
|
||||
} catch (err) {
|
||||
throw new Error(`[Highway] httpUpload Error uploading block at offset ${offset}: ${err}`);
|
||||
}
|
||||
offset += block.length;
|
||||
}
|
||||
})();
|
||||
const timeout = this.timeout().catch((err) => {
|
||||
controller.abort();
|
||||
throw new Error(err.message);
|
||||
});
|
||||
await Promise.race([upload, timeout]);
|
||||
}
|
||||
|
||||
private async uploadBlock (block: Buffer, offset: number): Promise<void> {
|
||||
const chunkMD5 = crypto.createHash('md5').update(block).digest();
|
||||
const payload = this.buildPicUpHead(offset, block.length, chunkMD5);
|
||||
const frame = Frame.pack(Buffer.from(payload), block);
|
||||
const resp = await this.httpPostHighwayContent(frame, `http://${this.trans.server}:${this.trans.port}/cgi-bin/httpconn?htcmd=0x6FF0087&uin=${this.trans.uin}`);
|
||||
const [head, body] = Frame.unpack(resp);
|
||||
const headData = new NapProtoMsg(proto.RespDataHighwayHead).decode(head);
|
||||
this.logger.debug(`[Highway] httpUploadBlock: ${headData.errorCode} | ${headData.msgSegHead?.retCode} | ${headData.bytesRspExtendInfo} | ${head.toString('hex')} | ${body.toString('hex')}`);
|
||||
if (headData.errorCode !== 0) throw new Error(`[Highway] httpUploadBlock failed (code=${headData.errorCode})`);
|
||||
}
|
||||
|
||||
private async httpPostHighwayContent (frame: Buffer, serverURL: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const options: http.RequestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Connection: 'keep-alive',
|
||||
'Accept-Encoding': 'identity',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2)',
|
||||
'Content-Length': frame.length.toString(),
|
||||
},
|
||||
};
|
||||
const req = http.request(serverURL, options, (res) => {
|
||||
const data: Buffer[] = [];
|
||||
res.on('data', (chunk) => {
|
||||
data.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve(Buffer.concat(data));
|
||||
});
|
||||
});
|
||||
req.write(frame);
|
||||
req.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
reject(new Error((error as Error).message));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import net from 'node:net';
|
||||
import stream from 'node:stream';
|
||||
import crypto from 'node:crypto';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { BlockSize } from '@/napcat-core/packet/highway/highwayContext';
|
||||
import { Frame } from '@/napcat-core/packet/highway/frame';
|
||||
import { IHighwayUploader } from '@/napcat-core/packet/highway/uploader/highwayUploader';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
|
||||
class HighwayTcpUploaderTransform extends stream.Transform {
|
||||
uploader: HighwayTcpUploader;
|
||||
offset: number;
|
||||
|
||||
constructor (uploader: HighwayTcpUploader) {
|
||||
super();
|
||||
this.uploader = uploader;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
override _transform (data: Buffer, _: BufferEncoding, callback: stream.TransformCallback) {
|
||||
let chunkOffset = 0;
|
||||
while (chunkOffset < data.length) {
|
||||
const chunkSize = Math.min(BlockSize, data.length - chunkOffset);
|
||||
const chunk = data.subarray(chunkOffset, chunkOffset + chunkSize);
|
||||
const chunkMd5 = crypto.createHash('md5').update(chunk).digest();
|
||||
const head = this.uploader.buildPicUpHead(this.offset, chunk.length, chunkMd5);
|
||||
chunkOffset += chunk.length;
|
||||
this.offset += chunk.length;
|
||||
this.push(Frame.pack(Buffer.from(head), chunk));
|
||||
}
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
|
||||
export class HighwayTcpUploader extends IHighwayUploader {
|
||||
async upload (): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const upload = new Promise<void>((resolve, reject) => {
|
||||
const highwayTransForm = new HighwayTcpUploaderTransform(this);
|
||||
const socket = net.connect(this.trans.port, this.trans.server, () => {
|
||||
this.trans.data.pipe(highwayTransForm).pipe(socket, { end: false });
|
||||
});
|
||||
const handleRspHeader = (header: Buffer) => {
|
||||
const rsp = new NapProtoMsg(proto.RespDataHighwayHead).decode(header);
|
||||
if (rsp.errorCode !== 0) {
|
||||
socket.end();
|
||||
reject(new Error(`[Highway] tcpUpload failed (code=${rsp.errorCode})`));
|
||||
}
|
||||
const percent = ((Number(rsp.msgSegHead?.dataOffset) + Number(rsp.msgSegHead?.dataLength)) / Number(rsp.msgSegHead?.filesize)).toFixed(2);
|
||||
this.logger.debug(`[Highway] tcpUpload ${rsp.errorCode} | ${percent} | ${Buffer.from(header).toString('hex')}`);
|
||||
if (Number(rsp.msgSegHead?.dataOffset) + Number(rsp.msgSegHead?.dataLength) >= Number(rsp.msgSegHead?.filesize)) {
|
||||
this.logger.debug('[Highway] tcpUpload finished.');
|
||||
socket.end();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
if (signal.aborted) {
|
||||
socket.end();
|
||||
reject(new Error('Upload aborted due to timeout'));
|
||||
}
|
||||
|
||||
const [head, _] = Frame.unpack(chunk);
|
||||
handleRspHeader(head);
|
||||
});
|
||||
socket.on('close', () => {
|
||||
this.logger.debug('[Highway] tcpUpload socket closed.');
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
socket.end();
|
||||
reject(new Error(`[Highway] tcpUpload socket.on error: ${err}`));
|
||||
});
|
||||
this.trans.data.on('error', (err) => {
|
||||
socket.end();
|
||||
reject(new Error(`[Highway] tcpUpload readable error: ${err}`));
|
||||
});
|
||||
});
|
||||
const timeout = this.timeout().catch((err) => {
|
||||
controller.abort();
|
||||
throw new Error(err.message);
|
||||
});
|
||||
await Promise.race([upload, timeout]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// import * as tea from '@/napcat-core/packet/utils/crypto/tea';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { PacketHighwayTrans } from '@/napcat-core/packet/highway/client';
|
||||
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
|
||||
export abstract class IHighwayUploader {
|
||||
readonly trans: PacketHighwayTrans;
|
||||
readonly logger: PacketLogger;
|
||||
|
||||
constructor (trans: PacketHighwayTrans, logger: PacketLogger) {
|
||||
this.trans = trans;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// TODO: 没用到的加密方法先注释掉
|
||||
// private encryptTransExt (key: Uint8Array) {
|
||||
// if (!this.trans.encrypt) return;
|
||||
// this.trans.ext = tea.encrypt(Buffer.from(this.trans.ext), Buffer.from(key));
|
||||
// }
|
||||
|
||||
protected timeout (): Promise<void> {
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`[Highway] timeout after ${this.trans.timeout}s`));
|
||||
}, (this.trans.timeout ?? Infinity) * 1000
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
buildPicUpHead (offset: number, bodyLength: number, bodyMd5: Uint8Array): Uint8Array {
|
||||
return new NapProtoMsg(proto.ReqDataHighwayHead).encode({
|
||||
msgBaseHead: {
|
||||
version: 1,
|
||||
uin: this.trans.uin,
|
||||
command: 'PicUp.DataUp',
|
||||
seq: 0,
|
||||
retryTimes: 0,
|
||||
appId: 1600001604,
|
||||
dataFlag: 16,
|
||||
commandId: this.trans.cmd,
|
||||
},
|
||||
msgSegHead: {
|
||||
serviceId: 0,
|
||||
filesize: BigInt(this.trans.size),
|
||||
dataOffset: BigInt(offset),
|
||||
dataLength: bodyLength,
|
||||
serviceTicket: this.trans.ticket,
|
||||
md5: bodyMd5,
|
||||
fileMd5: this.trans.sum,
|
||||
cacheAddr: 0,
|
||||
cachePort: 0,
|
||||
},
|
||||
bytesReqExtendInfo: this.trans.ext,
|
||||
timestamp: BigInt(0),
|
||||
msgLoginSigHead: {
|
||||
uint32LoginSigType: 8,
|
||||
appId: 1600001604,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
abstract upload (): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NapProtoEncodeStructType } from 'napcat-protobuf';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
|
||||
export const int32ip2str = (ip: number) => {
|
||||
ip = ip & 0xffffffff;
|
||||
return [ip & 0xff, (ip & 0xff00) >> 8, (ip & 0xff0000) >> 16, ((ip & 0xff000000) >> 24) & 0xff].join('.');
|
||||
};
|
||||
|
||||
export const oidbIpv4s2HighwayIpv4s = (ipv4s: NapProtoEncodeStructType<typeof proto.IPv4>[]): NapProtoEncodeStructType<typeof proto.NTHighwayIPv4>[] => {
|
||||
return ipv4s.map((ip) => {
|
||||
return {
|
||||
domain: {
|
||||
isEnable: true,
|
||||
ip: int32ip2str(ip.outIP ?? 0),
|
||||
},
|
||||
port: ip.outPort!,
|
||||
} as NapProtoEncodeStructType<typeof proto.NTHighwayIPv4>;
|
||||
});
|
||||
};
|
||||
Loaded 100 of 1111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user