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

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

No files matched your search

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