SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

@@ -0,0 +1,27 @@
import { MsfChangeReasonType, MsfStatusType } from '@/snowcat-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, SnowCatCore } from '@/snowcat-core/index';
export class NTQQCollectionApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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, SnowCatCore } from '@/snowcat-core/index';
import {
checkSqliteAvailable,
DatabaseHandle,
openDatabase,
decryptAndOpen,
readSingleDatabase,
scanAllDatabases,
formatScanResults,
decryptDatabaseFile,
type DatabaseScanResult,
type DatabaseReadOptions,
type TableInfo,
} from 'snowcat-database';
type SQLInputValue = import('node:sqlite').SQLInputValue;
export class NTQQDatabaseApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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');
}
/**
* 获取解密缓存目录
* 默认: <SnowCatDataPath>/db_<uin>/
* 可通过 customDir 完全自定义
*/
getDefaultCacheDir (customDir?: string): string {
if (customDir) return customDir;
return path.resolve(this.core.SnowCatDataPath, `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 '@/snowcat-core/types';
import path from 'path';
import fs from 'fs';
import fsPromises from 'fs/promises';
import { InstanceContext, SnowCatCore, SearchResultItem } from '@/snowcat-core/index';
import { fileTypeFromFile } from 'file-type';
import { RkeyManager } from '@/snowcat-core/helper/rkey';
import { calculateFileMD5 } from 'snowcat-common/src/file';
import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from 'snowcat-protobuf';
import { FileId } from '../packet/transformer/proto/misc/fileid';
export class NTQQFileApi {
context: InstanceContext;
core: SnowCatCore;
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: SnowCatCore) {
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 '';
}
}
+307
View File
@@ -0,0 +1,307 @@
import { GeneralCallResult, InstanceContext, SnowCatCore } from '@/snowcat-core';
import {
createFlashTransferResult,
FileListResponse,
FlashFileSetInfo,
SendStatus,
UploadSceneType,
} from '@/snowcat-core/data/flash';
import { Peer } from '@/snowcat-core/types';
export class NTQQFlashApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
/**
* 发起闪传上传任务
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
* @param thumbnailPath
* @param filesetName
*/
async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string): Promise<GeneralCallResult & {
createFlashTransferResult: createFlashTransferResult;
seq: number;
}> {
const flashService = this.context.session.getFlashTransferService();
const timestamp: number = Date.now();
const selfInfo = this.core.selfInfo;
const fileUploadArg = {
screen: 1, // 1
name: filesetName,
uploaders: [{
uin: selfInfo.uin,
uid: selfInfo.uid,
sendEntrance: '',
nickname: selfInfo.nick,
}],
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: new Map(),
},
};
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 '@/snowcat-core/types';
import { BuddyListReqType, InstanceContext, SnowCatCore } from '@/snowcat-core/index';
import { LimitedHashTable } from 'snowcat-common/src/message-unique';
export class NTQQFriendApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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;
}
}
+626
View File
@@ -0,0 +1,626 @@
import {
GeneralCallResult,
GroupMember,
NTGroupMemberRole,
NTGroupRequestOperateTypes,
InstanceContext,
KickMemberV2Req,
MemberExtSourceType,
SnowCatCore,
GroupNotify,
GroupInfoSource,
ShutUpGroupMember,
Peer,
ChatType,
GroupManagementSettings,
} from '@/snowcat-core/index';
import { isNumeric, solveAsyncProblem } from 'snowcat-common/src/helper';
import { LimitedHashTable } from 'snowcat-common/src/message-unique';
import { CancelableTask, TaskExecutor } from 'snowcat-common/src/cancel-task';
import {
createGroupDetailInfoV2Param,
createGroupExtFilter,
createGroupExtInfo,
createGroupManagementRequests,
} from '../data';
import { NTEventWrapper } from '../helper/event';
export class NTQQGroupApi {
context: InstanceContext;
core: SnowCatCore;
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: SnowCatCore) {
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);
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);
if (!members) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
}
const getMember = () => {
if (isNumeric(memberUinOrUidStr)) {
return Array.from(members!.values()).find(member => member.uin === memberUinOrUidStr);
} else {
return members!.get(memberUinOrUidStr);
}
};
let member = getMember();
// 如果缓存中不存在该成员,尝试刷新缓存
if (!member) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
member = getMember();
}
return member;
}
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>) {
return this.context.session.getRichMediaService().deleteGroupFile(groupCode, [102], files);
}
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 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,
},
}
);
}
}
+12
View File
@@ -0,0 +1,12 @@
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';
+325
View File
@@ -0,0 +1,325 @@
import { ChatType, GetFileListParam, Peer, RawMessage, SendMessageElement, SendStatusType } from '@/snowcat-core/types';
import { GroupFileInfoUpdateItem, InstanceContext, SnowCatCore, NodeIKernelMsgService } from '@/snowcat-core/index';
import { GeneralCallResult } from '@/snowcat-core/services/common';
export class NTQQMsgApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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, SnowCatCore } from '@/snowcat-core';
import { Peer } from '@/snowcat-core/types';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GeneralCallResultStatus } from '@/snowcat-core/services/common';
import { sleep } from '@/snowcat-common/src/helper';
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
export class NTQQOnlineApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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(`[SnowCat] 文件不存在: ${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: '[SnowCat] 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: '[SnowCat] 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 '@/snowcat-core/external/napi2native.json';
import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
import { PacketClientSession } from '@/snowcat-core/packet/clientSession';
import { snowCatVersion } from 'snowcat-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: SnowCatCore;
logger: LogWrapper;
qqVersion: string | undefined;
pkt!: PacketClientSession;
errStack: string[] = [];
packetStatus: boolean = false;
constructor (context: InstanceContext, core: SnowCatCore) {
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/SnowCatQQ/releases/tag/v${snowCatVersion} 配置正确的QQ版本!`;
this.logger.logError(err);
this.errStack.push(err);
return false;
}
if (this.core.configLoader.configData.packetBackend === 'disable') {
const err = '[Core] [Packet] 已禁用PacketBackend,NapCat.Packet将不会加载!';
this.logger.logError(err);
this.errStack.push(err);
return false;
}
this.pkt = new PacketClientSession(this.core);
await this.pkt.init(process.pid, table.recv, table.send);
if (!this.pkt.available) {
const err = '[Core] [Packet] NativePacketClient 初始化失败,PacketBackend 发包能力不可用!';
this.logger.logError(err);
this.errStack.push(err);
return false;
}
try {
await this.pkt.operation.FetchRkey(3000);
} catch (error) {
this.logger.logError('测试Packet状态异常', error);
this.pushError(error);
return false;
}
return true;
}
}
@@ -0,0 +1,469 @@
/**
* SnowCat · QQ空间公共空间(空友推荐)Web 线 —— 纯数据构造与请求封装
*
* SnowLuma v2.2.1 (packages/protocol/src/web/qzone-public.ts) 移植改造:
* - 走 h5.qzone.qq.com/http2rpc gotrpc 通道(trpc 服务 + x-oidb 命令头)
* - cookie 串形态对齐 NapCat getQzoneAuth(p_uin/p_skey/skey/uin)
* - cursor 编解码绑 uin 防串号
*/
export type QzonePublicContent = string | Record<string, unknown>[];
export interface QzonePublicFeedsPage {
feeds: Record<string, unknown>[];
has_more: boolean;
next_cursor: string | null;
[key: string]: unknown;
}
interface PublicFeedCursor {
version: 1;
uin: string;
refresh_id: string;
attach_info: string;
last_client_feed_union_id: Record<string, unknown> | null;
}
const reader = 'trpc.qzone.feeds_reader.FeedsReader';
const interaction = 'trpc.qzone.interaction_writer.InteractionWriter';
const upvoteService = 'trpc.public_qzone.interaction.UFOComment';
const relation = 'trpc.public_qzone.relation.RelationService';
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** http2rpc 统一请求封装:retcode!=0或缺 data 抛错。 */
export async function qzonePublicRequest(
cookie: string,
bkn: string,
service: string,
method: string,
oidb: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const [command, serviceType] = oidb.split('_');
const { RequestUtil } = await import('snowcat-common/src/request');
const result = await RequestUtil.HttpGetJson<unknown>(
`https://h5.qzone.qq.com/http2rpc/gotrpc/v1/${service}/${method}?g_tk=${bkn}`,
'POST',
params,
{
Cookie: cookie,
'Content-Type': 'application/json',
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,
}),
},
);
if (!isObject(result) || result['retcode'] !== 0 || !isObject(result['data'])) {
const code = isObject(result) ? result['retcode'] : 'invalid response';
const message =
isObject(result) && typeof result['message'] === 'string' ? result['message'] : '';
throw new Error(`qzone public ${method} failed: retcode=${code} ${message}`.trim());
}
return result['data'];
}
/** 动态寻址参数:从 feed.cell_common.union_id 提取(完整动态才有)。 */
export function qzonePublicFeedParams(feed: Record<string, unknown>): Record<string, unknown> {
const common = feed['cell_common'];
if (!isObject(common) || !isObject(common['union_id']) || !Object.keys(common['union_id']).length) {
throw new Error('空友动态缺少 cell_common.union_id,请使用推荐列表或详情返回的完整动态');
}
return {
union_id: common['union_id'],
...(feed['cell_feed_transfer'] === undefined
? {}
: { cell_feed_transfer: feed['cell_feed_transfer'] }),
};
}
function encodeCursor(state: PublicFeedCursor): string {
const cursor = Buffer.from(JSON.stringify(state)).toString('base64url');
if (cursor.length > 65_536) {
throw new Error('空友推荐分页游标过大');
}
return cursor;
}
function decodeCursor(cursor: string, uin: string): PublicFeedCursor {
if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) {
throw new Error('无效的空友推荐 cursor');
}
let value: unknown;
try {
const bytes = Buffer.from(cursor, 'base64url');
if (bytes.toString('base64url') !== cursor) {
throw new Error('non-canonical encoding');
}
value = JSON.parse(bytes.toString('utf8'));
} catch {
throw new Error('无效的空友推荐 cursor');
}
if (
!isObject(value) ||
value['version'] !== 1 ||
value['uin'] !== uin ||
typeof value['refresh_id'] !== 'string' ||
!value['refresh_id'] ||
typeof value['attach_info'] !== 'string' ||
!(value['last_client_feed_union_id'] === null || isObject(value['last_client_feed_union_id']))
) {
throw new Error('空友推荐 cursor 无效或不属于当前账号');
}
return {
version: 1,
uin,
refresh_id: value['refresh_id'],
attach_info: value['attach_info'],
last_client_feed_union_id: value['last_client_feed_union_id'] as Record<string, unknown> | null,
};
}
/** 查询账号在空友各入口的白名单能力(entrances)。 */
export async function getQzonePublicCapabilities(
cookie: string,
bkn: string,
selfUin: string,
): Promise<Record<string, unknown>> {
const data = await qzonePublicRequest(
cookie, bkn,
'trpc.public_qzone.entrance_ctrl.EntranceCtrl',
'BatchEntranceWhitelist',
'0x9741_0',
{ uins: [selfUin], entrance_types: [1, 15] },
);
const result = Array.isArray(data['results'])
? (data['results'] as unknown[]).find(
(entry) => isObject(entry) && String(entry['uin']) === selfUin,
)
: undefined;
if (!isObject(result) || !isObject(result['entrances'])) {
throw new Error('qzone public BatchEntranceWhitelist 缺少当前账号的 entrances');
}
return result['entrances'];
}
/** 空友推荐动态流(cursor 续翻;广告条不进续翻锚点)。 */
export async function getQzonePublicFeeds(
cookie: string,
bkn: string,
selfUin: string,
cursor?: string,
): Promise<QzonePublicFeedsPage> {
const state: PublicFeedCursor =
cursor === undefined
? {
version: 1,
uin: selfUin,
refresh_id: `${selfUin}_${Date.now()}`,
attach_info: '',
last_client_feed_union_id: null,
}
: decodeCursor(cursor, selfUin);
const data = await qzonePublicRequest(cookie, bkn, reader, 'GetPublicRecomFeeds', '0x9a38_0', {
refresh_id: state.refresh_id,
...(cursor === undefined
? {}
: {
attach_info: state.attach_info,
...(state.last_client_feed_union_id === null
? {}
: { last_client_feed_union_id: state.last_client_feed_union_id }),
}),
});
if (!Array.isArray(data['feeds']) || typeof data['has_more'] !== 'boolean') {
throw new Error('qzone public GetPublicRecomFeeds 分页响应异常');
}
const feeds = (data['feeds'] as unknown[]).map((entry) => {
if (!isObject(entry) || !isObject(entry['feed'])) {
throw new Error('qzone public GetPublicRecomFeeds 动态响应异常');
}
return entry['feed'] as Record<string, unknown>;
});
let lastUnion = state.last_client_feed_union_id;
// web 客户端跨页携带最后一条非广告动态(含纯广告页)
for (const feed of feeds) {
const common = feed['cell_common'];
if (isObject(common) && common['feed_type'] !== 3 && isObject(common['union_id'])) {
lastUnion = common['union_id'] as Record<string, unknown>;
}
}
let nextCursor: string | null = null;
if (data['has_more']) {
if (typeof data['attach_info'] !== 'string') {
throw new Error('qzone public GetPublicRecomFeeds 缺少 attach_info');
}
nextCursor = encodeCursor({
...state,
attach_info: data['attach_info'],
last_client_feed_union_id: lastUnion,
});
}
return { ...data, feeds, has_more: data['has_more'] as boolean, next_cursor: nextCursor };
}
/** 空友单条动态详情(含评论首屏)。 */
export function getQzonePublicFeedDetail(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, reader, 'GetFeedDetail', '0x97b1_1', {
...qzonePublicFeedParams(feed),
feed_action_domain_type: 3,
from_page: 1,
});
}
/** 空友评论分页(cursor 用详情/上页返回的 attach_info)。 */
export function getQzonePublicComments(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
cursor: string,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, reader, 'GetComment', '0x9bfd_1', {
...qzonePublicFeedParams(feed),
attach_info: cursor,
});
}
/** 空友楼中楼回复分页。 */
export function getQzonePublicReplies(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
cursor: string,
pageSize: number,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, reader, 'GetReplies', '0x9bfe_1', {
...qzonePublicFeedParams(feed),
cur_comment: comment,
comment_attach_info: '',
reply_attach_info: cursor || comment['reply_attach_info'] || '',
page_size: pageSize,
});
}
/** 空友点赞/取消赞(UFOComment Upvote/Downvote)。 */
export async function setQzonePublicUpvote(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
enable: boolean,
): Promise<Record<string, unknown>> {
const data = await qzonePublicRequest(
cookie, bkn,
upvoteService,
enable ? 'Upvote' : 'Downvote',
enable ? '0x9d18_1' : '0x9d19_1',
qzonePublicFeedParams(feed),
);
if (!data['upvote']) {
throw new Error('qzone public upvote 响应缺少 upvote');
}
return data;
}
async function setLike(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
likeKey: unknown,
likeType: number,
enable: boolean,
): Promise<Record<string, unknown>> {
if (typeof likeKey !== 'string' || !likeKey) {
throw new Error('空友点赞目标缺少 like_key');
}
const data = await qzonePublicRequest(
cookie, bkn,
interaction,
enable ? 'DoLike' : 'UnLike',
enable ? '0x9699_1' : '0x969c_1',
{
...qzonePublicFeedParams(feed),
like_key: likeKey,
like_type: likeType,
},
);
if (!data['like']) {
throw new Error('qzone public like 响应缺少 like');
}
return data;
}
/** 空友动态点赞/取消赞(like_type=1,key 取 cell_common.cur_like_key)。 */
export function setQzonePublicLike(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
enable: boolean,
): Promise<Record<string, unknown>> {
const common = feed['cell_common'];
return setLike(cookie, bkn, feed, isObject(common) ? common['cur_like_key'] : undefined, 1, enable);
}
/** 空友评论/回复点赞(like_type:评论=2,回复=3,key 取 like.id)。 */
export function setQzonePublicCommentLike(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
enable: boolean,
reply?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const like = (reply ?? comment)['like'];
return setLike(cookie, bkn, feed, isObject(like) ? like['id'] : undefined, reply ? 3 : 2, enable);
}
function richContent(content: QzonePublicContent): Record<string, unknown>[] {
return typeof content === 'string' ? [{ type: 0, content }] : content;
}
/** 空友发评论。user 为评论者身份对象(详情/列表响应里取,或 {uid,uin,nickname} 形态)。 */
export async function commentQzonePublicFeed(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
user: Record<string, unknown>,
content: QzonePublicContent,
): Promise<Record<string, unknown>> {
const data = await qzonePublicRequest(cookie, bkn, interaction, 'AddComment', '0x9676_1', {
...qzonePublicFeedParams(feed),
comment: { user, content: richContent(content) },
});
if (!data['comment']) {
throw new Error('qzone public AddComment 响应缺少 comment');
}
return data;
}
/** 空友回复评论/楼中楼。点名对象自动取 reply?.user ?? comment.user。 */
export async function replyQzonePublicFeed(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
user: Record<string, unknown>,
comment: Record<string, unknown>,
content: QzonePublicContent,
reply?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const targetUser = reply?.['user'] ?? comment['user'];
if (!isObject(targetUser)) {
throw new Error('空友评论或回复缺少 user');
}
const data = await qzonePublicRequest(cookie, bkn, interaction, 'AddReply', '0x9677_1', {
...qzonePublicFeedParams(feed),
comment,
reply: { content: richContent(content), target_user: targetUser, user },
});
if (!data['reply']) {
throw new Error('qzone public AddReply 响应缺少 reply');
}
return data;
}
/** 删空友评论。 */
export function deleteQzonePublicComment(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, interaction, 'DeleteComment', '0x9989_0', {
...qzonePublicFeedParams(feed),
comment,
});
}
/** 删空友楼中楼回复。 */
export function deleteQzonePublicReply(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
reply: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, interaction, 'DeleteReply', '0x998a_0', {
...qzonePublicFeedParams(feed),
comment,
reply,
});
}
/** 空友关注/取关作者(domain_type=3 且有 uid 用 uid,否则用 uin)。 */
export async function setQzonePublicFollow(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
enable: boolean,
): Promise<Record<string, unknown>> {
const params = qzonePublicFeedParams(feed);
const common = feed['cell_common'] as Record<string, unknown>;
const user = isObject(feed['cell_user_info']) ? (feed['cell_user_info'] as Record<string, unknown>)['user'] : undefined;
const uid = isObject(user) && typeof user['uid'] === 'string' ? user['uid'].trim() : '';
const uin = isObject(user) && typeof user['uin'] === 'string' ? user['uin'].trim() : '';
const useUid = (common['domain_type'] === 3 && !!uid) || !uin;
if (useUid ? !uid : !uin) {
throw new Error('空友动态缺少作者 uid/uin');
}
const union = params['union_id'] as Record<string, unknown>;
const data = await qzonePublicRequest(
cookie, bkn,
relation,
enable ? 'Follow' : 'Unfollow',
enable ? '0x9d1a_1' : '0x9d1b_1',
{
follow_from_type: 1,
...(useUid ? { target_uid: uid } : { target_uin: uin }),
...(union['uf_key'] ? { follow_feed_id: union['uf_key'] } : {}),
...(feed['cell_feed_transfer'] === undefined
? {}
: { cell_feed_transfer: feed['cell_feed_transfer'] }),
},
);
if (
data['ret_code'] !== undefined &&
data['ret_code'] !== null &&
data['ret_code'] !== 0 &&
data['ret_code'] !== '0'
) {
throw new Error(
`qzone public follow failed: ret_code=${data['ret_code']} ${data['ret_msg'] ?? ''}`.trim(),
);
}
return data;
}
/** 转发空友动态。 */
export function forwardQzonePublicFeed(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
reason: string,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, 'trpc.qzone.feeds_writer.FeedsWriter', 'ForwardFeed', '0x969a_1', {
...qzonePublicFeedParams(feed),
reason,
});
}
+36
View File
@@ -0,0 +1,36 @@
import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
export class NTQQSystemApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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);
}
}
+248
View File
@@ -0,0 +1,248 @@
import { ModifyProfileParams, User, UserDetailSource } from '@/snowcat-core/types';
import { RequestUtil } from 'snowcat-common/src/request';
import { InstanceContext, SnowCatCore, ProfileBizType } from '..';
import { solveAsyncProblem } from 'snowcat-common/src/helper';
import { Fallback, FallbackUtil } from 'snowcat-common/src/fall-back';
export class NTQQUserApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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('[SnowCat] [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.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('');
}
}
+1000
View File
@@ -0,0 +1,1000 @@
import { RequestUtil } from 'snowcat-common/src/request';
import {
GroupEssenceMsgRet,
InstanceContext,
WebApiGroupMember,
WebApiGroupMemberRet,
WebApiGroupNoticeRet,
WebHonorType, SnowCatCore,
} from '@/snowcat-core/index';
import { createReadStream, readFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { qunAlbumControl } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
import {
buildQzoneDeleteBody,
buildQzonePublishBody,
buildQzoneUploadImageBody,
parseQzoneDeleteResponse,
parseQzonePublishResponse,
parseQzoneUploadImageResponseText,
toQzoneRichval,
QzoneDeleteResponse,
QzonePublishResponse,
} from '../data/qzone';
// SnowCat · qzone Web 线扩展(SnowLuma 移植):读列表/详情/点赞/评论/回复/资料/拉黑/权限
import {
decodeQzoneFeedCursor,
encodeQzoneFeedCursor,
initialQzoneFeedState,
mapQzoneFeeds,
nextQzoneFeedState,
qzoneFeedPosition,
type QzoneFeedState,
type RawFeedsResponse,
} from '../data/qzone-feeds';
import { parseQzoneCallback } from '../data/qzone-web';
import {
buildQzoneBlackBody,
buildQzoneCommentBody,
buildQzoneLikeBody,
buildQzoneMsgDetailQuery,
buildQzoneProfileQuery,
buildQzoneProfileUpdateBody,
buildQzoneReplyBody,
legacyMsgListRoute,
mapQzoneMsgDetail,
mapQzoneMsgList,
parseQzoneBlackResponse,
parseQzoneCommentResponse,
parseQzoneDetailError,
parseQzoneJson,
parseQzoneLikeResponse,
parseQzoneProfileResponse,
parseQzoneProfileUpdateResponse,
parseQzonePublishFull,
parseQzoneUpdateRightResponse,
userMsgListRoute,
buildQzoneUpdatePayload,
type QzoneProfile,
type QzoneProfileUpdate,
type QzoneReplyTarget,
type QzoneUpdateRightResult,
type RawMsgListResponse,
type RawMsgDetailResponse,
type QzoneRoute,
} from '../data/qzone-web';
export interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
export class NTQQWebApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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) {
const img = readFileSync(path);
const img_size = img.length;
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,
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
async uploadQunAlbumSlice (path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) {
const img_size = statSync(path).size;
let seq = 0;
let offset = 0;
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const stream = createReadStream(path, { highWaterMark: slice_size });
for await (const chunk of stream) {
const end = Math.min(offset + chunk.length, img_size);
const form = new FormData();
form.append('uin', uin);
form.append('appid', 'qun');
form.append('session', session);
form.append('offset', 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', seq.toString());
form.append('end', end.toString());
form.append('cmd', 'FileUpload');
form.append('slice_size', slice_size.toString());
form.append('biz_req.iUploadType', '0');
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${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; }; if (post.ret !== 0) {
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
}
offset += chunk.length;
seq++;
}
return { success: true, message: '上传完成' };
}
async uploadImageToQunAlbum (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 img_md5 = createHash('md5').update(readFileSync(path)).digest('hex');
const uin = this.core.selfInfo.uin || '10001';
const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session;
if (!session) throw new Error('创建群相册会话失败');
await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384);
}
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,
content: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
// 16位number数字
const client_key = Date.now() * 1000;
return await this.context.session.getAlbumService().doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
qunId,
2,
createAlbumMediaFeed(uin, albumId, lloc),
createAlbumCommentRequest(uin, content, client_key)
);
}
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 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 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?: 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;
}
// ───────────── SnowCat · qzone Web 线扩展(SnowLuma 移植) ─────────────
/** 获取说说列表(默认自己空间;-10000 限频自动切 user.qzone 备路) */
async getQzoneMsgList (targetUin?: string, pos = 0, num = 20) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const host = targetUin ?? uin;
let route: QzoneRoute = legacyMsgListRoute(cookie, host, pos, num, g_tk);
let text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers);
let data = parseQzoneJson<RawMsgListResponse>(text);
// -10000「使用人数过多」是 legacy 网关限频,切备路重试一次
if (data.code === -10000) {
route = userMsgListRoute(cookie, host, pos, num, g_tk);
text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers);
data = parseQzoneJson<RawMsgListResponse>(text);
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone msglist failed: code=${data.code} ${data.message ?? ''}`.trim());
}
return mapQzoneMsgList(data);
}
/** 获取单条说说详情 + 一页评论(含内联回复) */
async getQzoneMsgDetail (targetUin: string, tid: string, pos = 0, num = 20) {
const { g_tk, cookie } = await this.getQzoneAuth();
const query = buildQzoneMsgDetailQuery(targetUin, tid, g_tk, { pos, num });
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${query}`;
const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
const data = parseQzoneJson<RawMsgDetailResponse>(text);
parseQzoneDetailError(data, tid);
return mapQzoneMsgDetail(data, tid);
}
/** 点赞/取消赞一条说说(abstime 传目标帖的发帖时间更稳,0 可容忍) */
async setQzoneLike (targetUin: string, tid: string, like: boolean, abstime = 0) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const verb = like ? 'like' : 'unlike';
const cgi = like ? 'internal_dolike_app' : 'internal_unlike_app';
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/likes/${cgi}?g_tk=${g_tk}`;
const body = buildQzoneLikeBody(uin, targetUin, tid, abstime);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneLikeResponse(text, verb);
}
/** 评论一条说说(richval 传图片直链,与发说说的 richval 串不同) */
async commentQzoneMsg (hostUin: string, tid: string, content: string, richval?: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds?g_tk=${g_tk}`;
const body = buildQzoneCommentBody(uin, hostUin, tid, content, richval ? 1 : undefined, richval);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzoneCommentResponse(text, hostUin, tid);
}
/** 楼中楼回复(独立端点 emotion_cgi_addreply_ugc;replyTo 自动转 @ 提及) */
async replyQzoneComment (hostUin: string, tid: string, content: string, target: QzoneReplyTarget) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${g_tk}`;
const body = buildQzoneReplyBody(uin, hostUin, tid, content, target);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
// 复用评论解析:回复的 id 在 data.id(与评论的 commentid 不同名)
const response = parseQzoneJson<{ code?: number; subcode?: number; message?: string; data?: { id?: string | number } }>(text);
if (typeof response.code === 'number' && response.code !== 0) {
throw new Error(`qzone reply failed: code=${response.code} ${response.message ?? ''}`.trim());
}
const id = response.data?.id;
return { comment_id: id !== undefined ? String(id) : '' };
}
/** 发说说(富返回:tid + time;SnowCat 原版 publishQzoneMsg 只返回 tid,保留不动) */
async publishQzoneMsgFull (content: string, richvals: string[], ugcRight: number, targetUins?: string[]) {
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 resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzonePublishFull(resultText);
}
/** 获取空间名称与说明(默认自己,可查别人——需有访问权) */
async getQzoneProfile (targetUin?: string): Promise<QzoneProfile> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const query = buildQzoneProfileQuery(uin, targetUin ?? uin, g_tk);
const url = `https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all?${query}`;
const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
return parseQzoneProfileResponse(text);
}
/** 修改空间名称/说明(只动传了的字段;空串=清空) */
async setQzoneProfile (profile: QzoneProfileUpdate): Promise<void> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneProfileUpdateBody(uin, profile);
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new?g_tk=${g_tk}`;
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneProfileUpdateResponse(text);
}
/** 空间拉黑/解拉黑(action=1 拉黑 / 2 解拉黑) */
async setQzoneBlack (targetUin: string, ban: boolean): Promise<void> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new?g_tk=${g_tk}`;
const body = buildQzoneBlackBody(uin, targetUin, ban);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneBlackResponse(text, ban);
}
/** 改已有说说的查看权限(msgdetail_v6 → emotion_cgi_update 两步全量重提) */
async setQzoneMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise<QzoneUpdateRightResult> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
// 第一步:拉详情
const detailQuery = buildQzoneMsgDetailQuery(uin, tid, g_tk);
const detailUrl = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${detailQuery}`;
const detailText = await RequestUtil.HttpGetText(detailUrl, 'GET', '', { Cookie: cookie });
const detail = parseQzoneJson<RawMsgDetailResponse>(detailText);
parseQzoneDetailError(detail, tid);
// 第二步:全量重提 + 覆写权限
const bodyParams = buildQzoneUpdatePayload(detail, uin);
bodyParams.set('ugc_right', String(ugcRight));
if (targetUins && targetUins.length > 0) {
bodyParams.set('allow_uins', targetUins.join('|'));
}
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update?g_tk=${g_tk}`;
const text = await RequestUtil.HttpGetText(url, 'POST', bodyParams.toString(), {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzoneUpdateRightResponse(text, tid, ugcRight);
}
/**
* 获取空间好友动态流(feeds3_html_more,HTML 载荷数据级解析)。
* cursor 传上次返回的 next_cursor 续翻;不传从第 page_num 页走。
*/
async getQzoneFeeds (pageNum = 1, count = 10, cursor?: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) {
throw new Error('空间好友动态页码或数量无效');
}
const start = cursor === undefined
? { page: 1, state: initialQzoneFeedState() }
: decodeQzoneFeedCursor(cursor, uin);
if (cursor !== undefined && pageNum !== 1 && pageNum !== start.page) {
throw new Error('page_num 与空间好友动态 cursor 不匹配');
}
const target = cursor === undefined ? pageNum : start.page;
let state: QzoneFeedState = start.state;
const seen = new Set([qzoneFeedPosition(state)]);
for (let page = start.page; page <= target; page++) {
const query = new URLSearchParams({
uin,
scope: '0',
view: '1',
gid: '',
flag: '1',
filter: 'all',
applist: 'all',
refresh: '0',
firstGetGroup: '0',
mixnocache: '0',
scene: '0',
sidomain: 'qzonestyle.gtimg.cn',
count: String(count),
...state,
getAisort: state.aisortNextTime,
g_tk: g_tk,
callback: '_preloadCallback',
format: 'jsonp',
useutf8: '1',
outputhtmlfeed: '1',
usertime: String(Date.now()),
rd: String(Math.random()),
windowId: String(Math.random()),
});
query.delete('aisortNextTime');
const url = `https://h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${query}`;
const body = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
const data = parseQzoneCallback<RawFeedsResponse>(body);
if (data.code !== 0 || (data.subcode !== undefined && data.subcode !== 0)) {
throw new Error(
`qzone feeds failed: code=${data.code} subcode=${data.subcode ?? 0} ${typeof data.message === 'string' ? data.message : ''}`.trim(),
);
}
const main = data.data?.main;
if (main !== undefined && (main === null || typeof main !== 'object' || Array.isArray(main))) {
throw new Error('空间好友动态分页信息结构异常');
}
if (main?.['error'] !== undefined && main['error'] !== '') {
throw new Error('空间好友动态服务端报告拉取失败');
}
const result = mapQzoneFeeds(data);
if (result.has_more) {
if (!main) throw new Error('空间好友动态缺少下一页游标');
state = nextQzoneFeedState(main, state);
const position = qzoneFeedPosition(state);
if (seen.has(position)) throw new Error('空间好友动态分页游标未推进');
seen.add(position);
result.next_cursor = encodeQzoneFeedCursor(uin, page + 1, state);
}
if (page === target) return result;
if (!result.has_more) {
return { feeds: [], has_more: false, next_cursor: null };
}
}
throw new Error('空间好友动态分页未完成');
}
// ───────────── SnowCat · qzone 公共空间(空友)代理(SnowLuma 移植) ─────────────
async getQzonePublicCapabilities (cookie: string, bkn: string, uin: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicCapabilities(cookie, bkn, uin);
}
async getQzonePublicFeeds (cookie: string, bkn: string, uin: string, cursor?: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicFeeds(cookie, bkn, uin, cursor);
}
async getQzonePublicFeedDetail (cookie: string, bkn: string, feed: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicFeedDetail(cookie, bkn, feed);
}
async getQzonePublicComments (cookie: string, bkn: string, feed: Record<string, unknown>, cursor: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicComments(cookie, bkn, feed, cursor);
}
async getQzonePublicReplies (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, cursor: string, pageSize: number) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicReplies(cookie, bkn, feed, comment, cursor, pageSize);
}
async setQzonePublicUpvote (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicUpvote(cookie, bkn, feed, enable);
}
async setQzonePublicLike (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicLike(cookie, bkn, feed, enable);
}
async setQzonePublicCommentLike (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, enable: boolean, reply?: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicCommentLike(cookie, bkn, feed, comment, enable, reply);
}
async commentQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, user: Record<string, unknown>, content: string | Record<string, unknown>[]) {
const impl = await import('./qzone-public-web');
return impl.commentQzonePublicFeed(cookie, bkn, feed, user, content);
}
async replyQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, user: Record<string, unknown>, comment: Record<string, unknown>, content: string | Record<string, unknown>[], reply?: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.replyQzonePublicFeed(cookie, bkn, feed, user, comment, content, reply);
}
async deleteQzonePublicComment (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.deleteQzonePublicComment(cookie, bkn, feed, comment);
}
async deleteQzonePublicReply (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, reply: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.deleteQzonePublicReply(cookie, bkn, feed, comment, reply);
}
async setQzonePublicFollow (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicFollow(cookie, bkn, feed, enable);
}
async forwardQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, reason: string) {
const impl = await import('./qzone-public-web');
return impl.forwardQzonePublicFeed(cookie, bkn, feed, reason);
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* 群相册列表请求参数接口
*/
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;
};
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
* @returns 媒体请求参数对象
*/
export function createAlbumMediaFeed (
uin: string,
albumId: string,
lloc: string
): AlbumMediaFeed {
return {
cell_common: {
time: '',
},
cell_user_info: {
user: {
uin,
},
},
cell_media: {
album_id: albumId,
batch_id: '',
media_items: [{
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;
};
}
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,
},
};
}
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,
},
};
}
+358
View File
@@ -0,0 +1,358 @@
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,
}
export interface StartFlashTaskRequests {
screen: number; // 1 PC-QQ
name?: string;
uploaders: UploaderInfo[];
permission?: {};
coverPath?: string;
paths: string[]; // 文件的绝对路径,可以是文件夹
excludePaths?: string[];
expireLeftTime?: number, // 0
isNeedDelDeviceInfo: boolean,
isNeedDelLocation: boolean,
coverOriginalInfos?: {
path: string,
thumbnailPath: string,
}[],
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
detectPrivacyInfoResult: {
exists: boolean,
allDetectResults: {};
};
}
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';
+412
View File
@@ -0,0 +1,412 @@
/**
* SnowCat · QQ空间好友动态流(feeds3_html_more)—— 纯数据构造与解析
*
* SnowLuma v2.2.1 (qzone-feed-cursor.ts + qzone-feed-content.ts + qzone.ts feeds 部分) 移植改造:
* - cursor 状态编解码(base64url,绑 uin 防串号)
* - HTML feed 内容解析(htmlparser2,不执行远程内容)
* - feeds 响应映射
*/
// ─────────────── cursor 状态机 ───────────────
const numericFields = [
'begintime',
'dayspac',
'pagenum',
'aisortEndTime',
'aisortOffset',
'aisortBeginTime',
'aisortNextTime',
'icServerTime',
] as const;
const stringFields = ['externparam', 'daylist', 'uinlist'] as const;
const fields = [...numericFields, ...stringFields];
type StateField = (typeof fields)[number];
export type QzoneFeedState = Record<StateField, string>;
export function initialQzoneFeedState(): QzoneFeedState {
return {
begintime: '0',
dayspac: '0',
pagenum: '1',
aisortEndTime: '0',
aisortOffset: '0',
aisortBeginTime: '0',
aisortNextTime: '0',
icServerTime: '0',
externparam: '',
daylist: '',
uinlist: '',
};
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** 承接 web 客户端用的字段,含不透明服务端状态。 */
export function nextQzoneFeedState(
main: Record<string, unknown>,
previous: QzoneFeedState,
): QzoneFeedState {
const next = { ...previous };
for (const field of numericFields) {
const value = main[field];
if (value === undefined || value === '') continue;
if (
(typeof value !== 'number' && typeof value !== 'string') ||
!/^\d+$/.test(String(value)) ||
!Number.isSafeInteger(Number(value)) ||
(field === 'pagenum' && Number(value) < 1)
) {
throw new Error(`空间好友动态分页字段异常: ${field}`);
}
next[field] = String(Number(value));
}
for (const field of stringFields) {
const value = main[field];
if (value === undefined || (value === '' && field !== 'uinlist')) continue;
if (typeof value !== 'string' || value.length > 32_768) {
throw new Error(`空间好友动态分页字段异常: ${field}`);
}
next[field] = value;
}
return next;
}
/** 服务端时钟单独不算翻页进度。 */
export function qzoneFeedPosition(state: QzoneFeedState): string {
return JSON.stringify(
fields.filter((field) => field !== 'icServerTime').map((field) => state[field]),
);
}
export function encodeQzoneFeedCursor(uin: string, page: number, state: QzoneFeedState): string {
if (!Number.isSafeInteger(page) || page < 2) {
throw new Error('空间好友动态分页页码超出范围');
}
const cursor = Buffer.from(JSON.stringify({ version: 1, uin, page, state })).toString(
'base64url',
);
if (cursor.length > 65_536) {
throw new Error('空间好友动态分页游标过大');
}
return cursor;
}
export function decodeQzoneFeedCursor(
cursor: string,
uin: string,
): { page: number; state: QzoneFeedState } {
if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) {
throw new Error('无效的空间好友动态 cursor');
}
let value: unknown;
try {
const bytes = Buffer.from(cursor, 'base64url');
if (bytes.toString('base64url') !== cursor) {
throw new Error('non-canonical encoding');
}
value = JSON.parse(bytes.toString('utf8'));
} catch {
throw new Error('无效的空间好友动态 cursor');
}
if (
!record(value) ||
value['version'] !== 1 ||
value['uin'] !== uin ||
typeof value['page'] !== 'number' ||
!Number.isSafeInteger(value['page']) ||
value['page'] < 2 ||
!record(value['state'])
) {
throw new Error('空间好友动态 cursor 无效或不属于当前账号');
}
const rawState = value['state'];
if (
Object.keys(rawState).length !== fields.length ||
!fields.every((field) => typeof rawState[field] === 'string')
) {
throw new Error('无效的空间好友动态 cursor 状态');
}
const state = nextQzoneFeedState(rawState, initialQzoneFeedState());
if (fields.some((field) => state[field] !== rawState[field])) {
throw new Error('无效的空间好友动态 cursor 状态');
}
return { page: value['page'], state };
}
// ─────────────── HTML feed 内容解析(htmlparser2,数据级不执行) ───────────────
import { DomUtils, parseDocument } from 'htmlparser2';
type Element = ReturnType<typeof DomUtils.findAll>[number];
type Node = ReturnType<typeof parseDocument>['children'][number];
export interface QzoneFeedContent {
tid: string | null;
content: string | null;
content_complete: boolean | null;
images: string[] | null;
like_count: number | null;
comment_count: number | null;
}
function hasClass(node: Element, name: string): boolean {
return (node.attribs['class'] ?? '').split(/\s+/).includes(name);
}
function isContentNode(node: Element, root: Element): boolean {
for (let parent = node.parent; parent && parent !== root; parent = parent.parent) {
if (
'attribs' in parent &&
(hasClass(parent, 'mod-comments') ||
hasClass(parent, 'comments-list') ||
hasClass(parent, 'f-item-passive') ||
hasClass(parent, 'f-ct-video'))
) {
return false;
}
}
return true;
}
function text(node: Element): string {
const chunks: string[] = [];
const stack: (Node | string)[] = [...node.children].reverse();
while (stack.length) {
const next = stack.pop()!;
if (typeof next === 'string') {
chunks.push(next);
} else if (next.type === 'text') {
chunks.push(next.data.replace(/\s+/g, ' '));
} else if (next.type === 'tag') {
if (next.attribs['data-cmd'] === 'qz_toggle' || hasClass(next, 'load_img')) {
continue;
}
if (next.name === 'br') {
chunks.push('\n');
} else if (next.name === 'img') {
chunks.push(next.attribs['alt'] ?? '');
} else {
if (['div', 'p', 'li'].includes(next.name)) {
chunks.push('\n');
stack.push('\n');
}
for (let i = next.children.length - 1; i >= 0; i--) {
const child = next.children[i];
if (child !== undefined) stack.push(child);
}
}
}
}
return chunks
.join('')
.replace(/ *\n */g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
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 imageUrl(value: string | undefined): string | null {
if (!value) return null;
try {
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
} catch {
return null;
}
}
/** 只抽帖子本身的内容;评论/头像/视频封面另算。 */
export function parseQzoneFeedContent(
html: string,
key: string,
uin: number,
appid: number,
likeCount?: unknown,
commentCount?: unknown,
): QzoneFeedContent {
const doc = parseDocument(html);
const elements = DomUtils.findAll(() => true, doc.children);
const body = elements.find(
(node) => hasClass(node, 'f-item') && node.attribs['data-key'] === key,
);
const result: QzoneFeedContent = {
tid: null,
content: null,
content_complete: null,
images: null,
like_count: count(likeCount),
comment_count: count(commentCount),
};
if (!body) return result;
let root = body;
for (let parent = body.parent; parent && 'attribs' in parent; parent = parent.parent) {
if (hasClass(parent, 'f-single')) {
root = parent;
break;
}
}
const bodyElements = DomUtils.findAll((node) => isContentNode(node, body), body.children);
const metadata = bodyElements.find(
(node) =>
node.attribs['name'] === 'feed_data' &&
node.attribs['data-uin'] === String(uin) &&
node.attribs['data-fkey'] === key,
);
if (appid === 311 && metadata?.attribs['data-tid']) {
result.tid = metadata.attribs['data-tid'];
}
const contentNodes = bodyElements.filter((node) => hasClass(node, 'f-info'));
const complete = contentNodes.find(
(node) => hasClass(node, 'qz_info_complete') && text(node) !== '',
);
const content = complete ?? contentNodes.find((node) => !hasClass(node, 'qz_info_complete'));
if (content) {
result.content = text(content);
result.content_complete =
!!complete ||
!DomUtils.findAll((node) => node.attribs['data-cmd'] === 'qz_toggle', content.children)
.length;
}
const images = new Set<string>();
for (const item of bodyElements.filter((node) => hasClass(node, 'img-item'))) {
if (item.attribs['data-v_itemid'] || item.attribs['data-videotype']) {
continue;
}
const full = imageUrl(item.attribs['data-originurl'] ?? item.attribs['data-url3']);
if (full) {
images.add(full);
continue;
}
for (const image of DomUtils.findAll((node) => node.name === 'img', item.children)) {
const url = imageUrl(image.attribs['truesrc'] ?? image.attribs['src']);
if (url) {
images.add(url);
}
}
}
result.images = [...images];
const like = DomUtils.findAll(
(node) => hasClass(node, 'qz_like_btn_v3') && isContentNode(node, root),
root.children,
)[0];
result.like_count ??= count(like?.attribs['data-likecnt']);
return result;
}
// ─────────────── feeds 响应 shape 与映射 ───────────────
export interface RawFeedsItem {
uin?: number | string;
nickname?: string;
abstime?: number | string;
appid?: number | string;
key?: string;
feedskey?: string;
html?: string;
likecnt?: unknown;
commentcnt?: unknown;
}
interface RawFeedsMain {
hasMoreFeeds?: boolean | number | string;
error?: string;
[key: string]: unknown;
}
export interface RawFeedsResponse {
code?: number;
subcode?: number;
message?: string;
data?: {
main?: RawFeedsMain | null;
hasmore?: boolean | number | string;
data?: (RawFeedsItem | null | undefined)[] | null;
} | null;
}
export interface QzoneFeed {
uin: number;
nickname: string;
time: number;
appid: number;
key: string;
html: string;
}
export interface QzoneFeedWithContent extends QzoneFeed, QzoneFeedContent {}
export interface QzoneFeedsPage {
feeds: QzoneFeedWithContent[];
has_more: boolean;
next_cursor: string | null;
}
function feedHasMore(data: RawFeedsResponse): boolean {
const value = data.data?.main === undefined ? data.data?.hasmore : data.data?.main?.hasMoreFeeds;
if (value === true || value === 1 || value === '1') return true;
if (value === false || value === 0 || value === '0') return false;
throw new Error('空间好友动态缺少有效的分页结束标志');
}
/** 纯映射;翻页续传由 getQzoneFeeds 接线。 */
export function mapQzoneFeeds(data: RawFeedsResponse): QzoneFeedsPage {
if (!Array.isArray(data.data?.data)) {
throw new Error('无法获取空间好友动态(响应结构异常)');
}
// web 载荷用 null/undefined 填充列表;这些不是 feed 条目
const list = data.data.data.filter((item) => item != null);
return {
feeds: list.map((item) => {
if (typeof item !== 'object' || Array.isArray(item)) {
throw new Error('空间好友动态条目结构异常');
}
const feed: QzoneFeed = {
uin: Number(item['uin'] ?? 0),
nickname: String(item['nickname'] ?? ''),
time: Number(item['abstime'] ?? 0),
appid: Number(item['appid'] ?? 0),
key: String(item['key'] ?? item['feedskey'] ?? ''),
html: String(item['html'] ?? ''),
};
if (
![feed.uin, feed.time, feed.appid].every(
(n) => Number.isSafeInteger(n) && n >= 0,
) ||
typeof feed.nickname !== 'string' ||
typeof feed.html !== 'string'
) {
throw new Error('空间好友动态条目字段异常');
}
return {
...feed,
...parseQzoneFeedContent(
feed.html,
feed.key,
feed.uin,
feed.appid,
item['likecnt'],
item['commentcnt'],
),
};
}),
has_more: feedHasMore(data),
next_cursor: null,
};
}
+961
View File
@@ -0,0 +1,961 @@
/**
* SnowCat · QQ空间(Qzone)个人空间 Web 线 —— 纯数据构造与解析
*
* 从 SnowLuma v2.2.1 (packages/protocol/src/web/qzone.ts) 移植改造:
* - SnowCat 已有 buildQzoneUploadImageBody/buildQzonePublishBody/buildQzoneDeleteBody
* (data/qzone.ts 原件保留),本文件只补缺的:读列表/详情/点赞/评论/回复/资料/拉黑/权限。
* - 不发起任何网络请求,方便单独测试(对齐 data/qzone.ts 头注)。
* - 统一走 NapCat 的 cookie 串(getQzoneAuth 产出的 p_uin/p_skey/skey/uin 形态),
* 不用 SnowLuma 的 cookieObject Record 形态——但保留 tolerant 解析器。
*/
// ─────────────── tolerant JSON/JSONP 解析(SnowLuma 原样移植) ───────────────
/**
* 解析 Qzone CGI body:可能是裸 JSON,也可能是 JSONP 回调包裹
* (`_Callback({...});` / `frameElement.callback({...});` / `back({...})`)。
* 分层 fallback:直接 JSON.parse {} 切片 → back({...}) → 末个 back(...) 提取。
* 无对象体时抛错(如 HTML 错误页)。
*/
export function parseQzoneJson<T>(text: string): T {
const s = text.trim();
// 1st: 直接 JSON.parse {} 切片
try {
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(s.slice(start, end + 1)) as T;
}
} catch { /* fallback */ }
// 2nd: back({...}) 整体
try {
const start = s.indexOf('back({');
const end = s.lastIndexOf('})');
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(s.slice(start + 5, end + 1)) as T;
}
} catch { /* fallback */ }
// 3rd: 找最后一个 back(...),取内部 {}
const backStart = s.lastIndexOf('back(');
if (backStart === -1) {
throw new Error('invalid response from qzone api');
}
const callEnd = s.indexOf(');', backStart);
if (callEnd === -1) {
throw new Error('invalid response from qzone api');
}
const inner = s.slice(backStart + 5, callEnd).trim();
const braceStart = inner.indexOf('{');
const braceEnd = inner.lastIndexOf('}');
if (braceStart === -1 || braceEnd === -1 || braceEnd < braceStart) {
throw new Error('invalid response from qzone api');
}
return JSON.parse(inner.slice(braceStart, braceEnd + 1)) as T;
}
/**
* 解析 feeds3_html_more 的 JS 对象字面量载荷(不执行)。
* `_preloadCallback({ … })` 的 data 用无引号键/单引号串/\xNN 转义/字面 undefined
* ——给浏览器回调 eval 用的,JSON.parse 咽不下。替代 eval(vm 沙箱不是安全边界),
* 递归下降把字面量当数据解析。`__proto__` 键丢弃(防原型污染)。
* SnowLuma 原样移植。
*/
export function parseJsLiteral(src: string): unknown {
let i = 0;
const n = src.length;
const isWs = (c: string): boolean =>
c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v';
const skipWs = (): void => {
while (i < n && isWs(src[i]!)) i++;
};
function parseString(quote: string): string {
i++; // 开引号
let out = '';
while (i < n) {
const c = src[i]!;
if (c === '\\') {
const e = src[i + 1];
if (e === 'x') {
out += String.fromCharCode(parseInt(src.slice(i + 2, i + 4), 16));
i += 4;
continue;
}
if (e === 'u') {
out += String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16));
i += 6;
continue;
}
const simple: Record<string, string> = {
n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0',
};
out += e !== undefined ? (simple[e] ?? e) : '';
i += 2;
continue;
}
if (c === quote) {
i++;
return out;
}
out += c;
i++;
}
throw new Error('unterminated string in qzone feeds payload');
}
function parseKey(): string {
skipWs();
const c = src[i];
if (c === '"' || c === "'") return parseString(c);
let s = '';
while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) {
s += src[i];
i++;
}
if (!s) throw new Error('expected object key in qzone feeds payload');
return s;
}
function parseObject(): Record<string, unknown> {
i++; // {
const obj: Record<string, unknown> = {};
skipWs();
if (src[i] === '}') { i++; return obj; }
for (;;) {
const key = parseKey();
skipWs();
if (src[i] !== ':') throw new Error('expected ":" in qzone feeds payload');
i++;
const value = parseValue();
if (key !== '__proto__') obj[key] = value;
skipWs();
const ch = src[i];
if (ch === ',') {
i++;
skipWs();
if (src[i] === '}') { i++; return obj; }
continue;
}
if (ch === '}') { i++; return obj; }
throw new Error('expected "," or "}" in qzone feeds payload');
}
}
function parseArray(): unknown[] {
i++; // [
const arr: unknown[] = [];
skipWs();
if (src[i] === ']') { i++; return arr; }
for (;;) {
arr.push(parseValue());
skipWs();
const ch = src[i];
if (ch === ',') {
i++;
skipWs();
if (src[i] === ']') { i++; return arr; }
continue;
}
if (ch === ']') { i++; return arr; }
throw new Error('expected "," or "]" in qzone feeds payload');
}
}
function parseValue(): unknown {
skipWs();
const c = src[i];
if (c === undefined) throw new Error('unexpected end of qzone feeds payload');
if (c === '{') return parseObject();
if (c === '[') return parseArray();
if (c === '"' || c === "'") return parseString(c);
let token = '';
while (i < n && !/[,}\]:\s]/.test(src[i]!)) {
token += src[i];
i++;
}
if (token === 'true') return true;
if (token === 'false') return false;
if (token === 'null') return null;
if (token === 'undefined') return undefined;
if (token !== '') {
const num = Number(token);
if (!Number.isNaN(num)) return num;
}
throw new Error('unexpected token in qzone feeds payload: ' + token.slice(0, 20));
}
return parseValue();
}
/**
* 提取 Qzone feeds JSONP body 回调实参对象,按数据解析(不执行)。
* 从首个 `{` 切片解析一个平衡值;尾部 `);` 忽略。非对象体抛错。
*/
export function parseQzoneCallback<T>(text: string): T {
const start = text.indexOf('{');
if (start === -1) {
throw new Error('invalid feeds response from qzone api');
}
const value = parseJsLiteral(text.slice(start));
if (value === null || typeof value !== 'object') {
throw new Error('invalid feeds response from qzone api');
}
return value as T;
}
// ─────────────── 原始响应 shape(taotao emotion_cgi_msglist_v6) ───────────────
interface RawPic {
url1?: string;
url2?: string;
url3?: string;
smallurl?: string;
}
interface RawEmotion {
tid?: string;
content?: string;
created_time?: number;
cmtnum?: number;
secret?: number;
pic?: RawPic[];
}
export interface RawMsgListResponse {
code?: number;
subcode?: number;
message?: string;
total?: number;
msglist?: RawEmotion[] | null;
}
// ─────────────── OneBot 面向 shape ───────────────
/** 一条说说,规范化后对 OneBot 友好的形态。 */
export interface QzoneEmotion {
tid: string;
content: string;
/** 发帖 unix 秒 */
time: number;
comment_num: number;
/** 仅自己可见标记 */
is_private: boolean;
/** 每张图取最大可用的 URL */
images: string[];
}
export interface QzoneMsgListResult {
/** 该账号说说总数(非本页数量) */
total: number;
msglist: QzoneEmotion[];
}
export interface QzoneProfile {
name: string;
description: string;
}
export interface QzoneProfileUpdate {
name?: string;
description?: string;
}
export interface QzoneCommentReply {
uin: number;
name: string;
/** unix 秒 */
time: number;
content: string;
/** 在所属说说(或父评论)内唯一 */
comment_id: string;
}
export interface QzoneComment extends QzoneCommentReply {
/** Qzone 内联附带的回复;可能不含全部回复 */
replies: QzoneCommentReply[];
}
export interface QzoneMsgDetailResult {
tid: string;
content: string;
/** 服务端总数,非本页评论数 */
comment_num: number;
comments: QzoneComment[];
}
function pickPicUrl(pic: RawPic): string | undefined {
return pic.url1 ?? pic.url2 ?? pic.url3 ?? pic.smallurl;
}
/** 纯映射:msglist_v6 → 规范化列表。 */
export function mapQzoneMsgList(data: RawMsgListResponse): QzoneMsgListResult {
const list = data.msglist ?? [];
return {
total: Number(data.total ?? list.length),
msglist: list.map((e) => ({
tid: String(e.tid ?? ''),
content: e.content ?? '',
time: Number(e.created_time ?? 0),
comment_num: Number(e.cmtnum ?? 0),
is_private: Number(e.secret ?? 0) !== 0,
images: (e.pic ?? []).map(pickPicUrl).filter((u): u is string => !!u),
})),
};
}
// ─────────────── 说说列表路由(双路防 -10000 限频) ───────────────
export interface QzoneRoute {
url: string;
headers: Record<string, string>;
}
/** 主路(legacy):h5.qzone.qq.com → taotao.qzone.qq.com。 */
export function legacyMsgListRoute(
cookie: string,
targetUin: string,
pos: number,
num: number,
bkn: string,
): QzoneRoute {
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams(
{
uin: targetUin,
ftype: '0',
sort: '0',
pos: String(pos),
num: String(num),
replynum: '100',
g_tk: bkn,
callback: '_preloadCallback',
code_version: '1',
format: 'jsonp',
need_private_comment: '1',
},
).toString()}`;
return { url, headers: { Cookie: cookie } };
}
/** 备路:user.qzone.qq.com → taotao.qq.com(注意域不同)。实测不吃 legacy 路的 -10000 限频。 */
export function userMsgListRoute(
cookie: string,
targetUin: string,
pos: number,
num: number,
bkn: string,
): QzoneRoute {
const url = `https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams(
{
uin: targetUin,
ftype: '0',
sort: '0',
pos: String(pos),
num: String(num),
g_tk: bkn,
code_version: '1',
format: 'json',
},
).toString()}`;
return {
url,
headers: {
Cookie: cookie,
Referer: `https://user.qzone.qq.com/${targetUin}`,
},
};
}
// ─────────────── 权限/目标校验 ───────────────
export const QZONE_UGC_RIGHTS = new Set<number>([1, 4, 16, 64, 128]);
export function normalizeQzoneUgcRight(ugcRight: number): number {
if (!QZONE_UGC_RIGHTS.has(ugcRight)) {
throw new Error('ugc_right must be one of 1, 4, 16, 64, 128');
}
return ugcRight;
}
export function normalizeTargetUins(targetUins?: string | number[]): string {
const raw = Array.isArray(targetUins)
? targetUins.join('|')
: (targetUins ?? '').trim();
if (!raw) return '';
const seen = new Set<string>();
for (const part of raw.split('|')) {
const uin = part.trim();
if (!uin) continue;
if (!/^\d+$/.test(uin)) {
throw new Error('target_uins must contain QQ numbers separated by |');
}
seen.add(uin);
}
return [...seen].join('|');
}
// ─────────────── 发说说 body(SnowCat 已有 buildQzonePublishBody,此处为富参版) ───────────────
export interface RawPublishResponse {
code?: number;
subcode?: number;
message?: string;
// publish_v6 成功包络把新 feed id 命名为 t1_tid、时间 t1_time(字符串)。
// tid/now 是防御性 fallback,t1_tid 才是主名。
t1_tid?: string;
t1_time?: string;
tid?: string;
now?: number;
}
export interface QzonePublishResult {
tid: string;
time: number;
}
/** 解析发说说响应,提取 tid+time。code!=0 或缺 tid 都抛错。 */
export function parseQzonePublishFull(text: string): QzonePublishResult {
const data = parseQzoneJson<RawPublishResponse>(text);
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone publish failed: code=${data.code} ${data.message ?? ''}`.trim());
}
const tid = data.t1_tid ?? data.tid;
if (!tid) {
throw new Error('发表说说失败:响应缺少 tid');
}
return { tid: String(tid), time: Number(data.t1_time ?? data.now ?? 0) };
}
// ─────────────── 点赞/取消赞(internal_dolike_app) ───────────────
export interface RawLikeResponse {
code?: number;
subcode?: number;
message?: string;
}
/** 构造点赞/取消赞表单体。unikey/curkey 定位说说(mood, appid 311),fid=tid。 */
export function buildQzoneLikeBody(
opUin: string,
targetUin: string,
tid: string,
abstime = 0,
): string {
const unikey = `http://user.qzone.qq.com/${targetUin}/mood/${tid}`;
return new URLSearchParams({
qzreferrer: `https://user.qzone.qq.com/${opUin}`,
opuin: opUin,
unikey,
curkey: unikey,
appid: '311',
typeid: '0',
abstime: String(abstime),
fid: tid,
from: '1',
active: '0',
fupdate: '1',
format: 'json',
}).toString();
}
/** 解析点赞响应:非零 code/subcode 抛错。 */
export function parseQzoneLikeResponse(text: string, verb: string): void {
const data = parseQzoneJson<RawLikeResponse>(text);
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone ${verb} failed: code=${data.code} ${data.message ?? ''}`.trim());
}
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${data.message ?? ''}`.trim());
}
}
// ─────────────── 评论说说(emotion_cgi_re_feeds) ───────────────
interface RawCommentResponse {
code?: number;
subcode?: number;
message?: string;
commentid?: string | number;
commentId?: string | number;
}
export interface QzoneCommentResult {
/** 新评论 id(响应缺失时为 ''——code 0 不算失败) */
comment_id: string;
}
export interface QzoneReplyTarget {
/** 顶层评论 id(即便回复的是它的楼中楼) */
commentId: string;
commentUin: string;
/** 可选:楼中楼里点名的作者 */
replyTo?: { uin: string; name: string };
}
export interface QzoneReplyResult extends QzoneCommentResult {
parent_comment_id: string;
parent_comment_uin: number;
}
/** 构造评论说说表单体。topicId 形如 `<hostUin>_<tid>__1`(__1 后缀为社区实现主流形态)。 */
export function buildQzoneCommentBody(
selfUin: string,
hostUin: string,
tid: string,
content: string,
richType?: number,
richval?: string,
): string {
return new URLSearchParams({
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
inCharset: 'utf-8',
outCharset: 'utf-8',
hostUin,
format: 'json',
ref: 'feeds',
topicId: `${hostUin}_${tid}__1`,
feedsType: '100',
private: '0',
paramstr: '1',
richtype: richType !== undefined ? String(richType) : '',
richval: richval ?? '',
isSignIn: '',
uin: selfUin,
content,
plat: 'qzone',
source: 'ic',
platformid: '52',
}).toString();
}
/** 解析评论响应。 */
export function parseQzoneCommentResponse(text: string, hostUin: string, tid: string): QzoneCommentResult {
const data = parseQzoneJson<RawCommentResponse>(text);
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone comment failed: code=${data.code} ${data.message ?? ''}`.trim());
}
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(`qzone comment failed: subcode=${data.subcode} ${data.message ?? ''}`.trim());
}
const commentId = data.commentid ?? data.commentId;
void hostUin; void tid; // 日志参数留位
return { comment_id: commentId !== undefined ? String(commentId) : '' };
}
/** 构造楼中楼回复表单体(独立端点 emotion_cgi_addreply_ugc)。 */
export function buildQzoneReplyBody(
selfUin: string,
hostUin: string,
tid: string,
content: string,
target: QzoneReplyTarget,
): string {
let replyContent = content;
if (target.replyTo !== undefined) {
const { uin, name } = target.replyTo;
// 对齐 web 编辑器的 @ 提及编码
const escapedName = name
.trim()
.replace(/%/g, '%25')
.replace(/,/g, '%2C')
.replace(/}/g, '%7D');
replyContent = `@{uin:${uin},nick:${escapedName},auto:1} ${content}`;
}
return new URLSearchParams({
uin: selfUin,
hostUin,
hostuin: selfUin,
topicId: `${hostUin}_${tid}`,
commentId: target.commentId,
commentUin: target.commentUin,
content: replyContent,
inCharset: 'utf-8',
outCharset: 'utf-8',
ref: '',
code_version: '1',
format: 'fs',
qzreferrer: `https://user.qzone.qq.com/${hostUin}`,
}).toString();
}
// ─────────────── 拉黑/解拉黑(cgi_black_action_new) ───────────────
interface RawBlackActionResponse {
code?: number;
subcode?: number;
message?: string;
msg?: string;
}
/** 构造空间拉黑表单体。action=1 拉黑,action=2 解拉黑。 */
export function buildQzoneBlackBody(selfUin: string, targetUin: string, ban: boolean): string {
return new URLSearchParams({
uin: selfUin,
act_uin: targetUin,
action: ban ? '1' : '2',
fupdate: '1',
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
}).toString();
}
/** 解析拉黑响应(响应带 {name:"Ack"} 无引号键碎片,先补引号再解析)。 */
export function parseQzoneBlackResponse(text: string, ban: boolean): void {
const verb = ban ? 'ban' : 'unban';
let data: RawBlackActionResponse;
try {
data = parseQzoneJson<RawBlackActionResponse>(text.replace(/\{\s*name\s*:/, '{"name":'));
} catch {
const snippet = text.trim().slice(0, 300);
throw new Error(`qzone ${verb} failed: ${snippet}${text.length > 300 ? '…' : ''}`);
}
const msg = data.message ?? data.msg ?? '';
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(`qzone ${verb} failed: subcode=${data.subcode} ${msg}`.trim());
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone ${verb} failed: code=${data.code} ${msg}`.trim());
}
}
// ─────────────── 说说详情(emotion_cgi_msgdetail_v6) ───────────────
interface RawMsgDetailPic {
pic_id?: string;
pictype?: number | string;
type?: number | string;
height?: number | string;
b_height?: number | string;
width?: number | string;
b_width?: number | string;
smallurl?: string;
url1?: string;
url2?: string;
url3?: string;
}
export interface RawMsgDetailResponse {
code?: number;
subcode?: number;
message?: string;
msg?: string;
tid?: string;
uin?: number | string;
content?: string;
cmtnum?: unknown;
commentlist?: unknown;
conlist?: Array<{ con?: string }> | null;
pic?: RawMsgDetailPic[] | null;
richtype?: number | string;
richval?: string;
pic_template?: string;
special_url?: string;
t1_subtype?: number | string;
subrichtype?: number | string;
feedversion?: number | string;
ver?: number | string;
ugc_right?: number | string;
to_sign?: number | string;
ugcright_id?: string;
code_version?: number | string;
}
export interface RawUpdateResponse {
code?: number;
subcode?: number;
message?: string;
msg?: string;
ugc_right?: number | string;
}
export interface QzoneUpdateRightResult {
/** 更新后的 ugc_right(服务端回显时用回显值) */
ugc_right: number;
}
/** 构造详情 GET 查询串(注意代理域是 taotao.qq.com 非 taotao.qzone.qq.com)。 */
export function buildQzoneMsgDetailQuery(
targetUin: string,
tid: string,
bkn: string,
page?: { pos: number; num: number },
): string {
return new URLSearchParams({
tid,
uin: targetUin,
t1_source: '1',
not_trunc_con: '1',
need_right: '1',
not_adapt_outpic: '1',
g_tk: bkn,
...(page ? { pos: String(page.pos), num: String(page.num) } : {}),
}).toString();
}
/** 从 msgdetail_v6 响应重建 emotion_cgi_update 全量载荷(php-qzone buildUpdatePayloadFromDetail 逐字段移植)。 */
export function buildQzoneUpdatePayload(
detail: RawMsgDetailResponse,
selfUin: string,
): URLSearchParams {
if (!detail.tid) {
throw new Error('qzone msg detail is missing tid');
}
const pics = detail.pic ?? [];
const richtype =
detail.richtype !== undefined ? Number(detail.richtype) : pics.length === 0 ? '' : 1;
const richvals: string[] = [];
const picBoItems: string[] = [];
for (const pic of pics) {
const picId = String(pic.pic_id ?? '');
if (!picId) continue;
const parts = picId.split(',');
const albumId = parts[1] ?? '';
const lloc = parts[2] ?? '';
if (!albumId || !lloc) {
throw new Error('qzone msg detail has unsupported pic info');
}
const sloc = lloc;
const picType = String(pic.pictype ?? pic.type ?? 22);
const height = String(pic.height ?? pic.b_height ?? 0);
const width = String(pic.width ?? pic.b_width ?? 0);
richvals.push(`,${albumId},${lloc},${sloc},${picType},${height},${width},,0,0`);
for (const urlKey of ['smallurl', 'url1', 'url2', 'url3'] as const) {
const picUrl = pic[urlKey];
if (!picUrl) continue;
const match = /[?&]bo=([^&#]+)/.exec(picUrl);
if (match) {
picBoItems.push(decodeURIComponent(match[1]!));
break;
}
}
}
if (richtype === 1 && richvals.length === 0) {
throw new Error('qzone image post detail is missing pic info');
}
let content = detail.content ?? '';
const conParts = (detail.conlist ?? [])
.map((c) => c.con)
.filter((c): c is string => c !== undefined);
if (conParts.length > 0) {
content = conParts.join('').trim();
}
const subrichtype = detail.t1_subtype ?? detail.subrichtype ?? (richvals.length === 0 ? '' : 1);
const boGroup = picBoItems.join(',');
const picBo = boGroup ? `${boGroup}\t${boGroup}` : '';
const hostuin = String(detail.uin ?? selfUin);
return new URLSearchParams({
syn_tweet_verson: '1',
tid: String(detail.tid),
paramstr: '1',
pic_template: detail.pic_template ?? '',
richtype: String(richtype),
richval: richvals.length > 0 ? richvals.join('\t') : (detail.richval ?? ''),
special_url: detail.special_url ?? '',
subrichtype: String(subrichtype),
pic_bo: picBo,
con: content,
feedversion: String(detail.feedversion ?? 1),
ver: String(detail.ver ?? 1),
ugc_right: String(Number(detail.ugc_right ?? 1)),
to_sign: String(Number(detail.to_sign ?? 0)),
ugcright_id: String(detail.ugcright_id ?? detail.tid),
hostuin,
code_version: String(detail.code_version ?? 1),
format: 'fs',
qzreferrer: `https://user.qzone.qq.com/${hostuin}`,
});
}
// 详情字段严格校验小件
export function detailObject(value: unknown, field: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`invalid qzone msg detail: ${field} must be an object`);
}
return value as Record<string, unknown>;
}
export function detailString(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new Error(`invalid qzone msg detail: ${field} must be a string`);
}
return value;
}
export function detailInteger(value: unknown, field: string, min = 0): number {
const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min) {
throw new Error(`invalid qzone msg detail: ${field} must be an integer >= ${min}`);
}
return number;
}
export function detailList(value: unknown, field: string): unknown[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
throw new Error(`invalid qzone msg detail: ${field} must be an array`);
}
return value;
}
function mapQzoneComment(value: Record<string, unknown>, field: string): QzoneCommentReply {
const id =
typeof value['tid'] === 'number'
? String(detailInteger(value['tid'], `${field}.tid`))
: detailString(value['tid'], `${field}.tid`);
if (!id) {
throw new Error(`invalid qzone msg detail: ${field}.tid is empty`);
}
return {
comment_id: id,
uin: detailInteger(value['uin'], `${field}.uin`, 1),
name: detailString(value['name'], `${field}.name`),
time: detailInteger(value['create_time'], `${field}.create_time`),
content: detailString(value['content'], `${field}.content`),
};
}
/**
* 详情响应 → 规范化(含评论+内联回复)。结构坏数据不得伪装成成功的空页。
* 对齐 SmartHypercube/Qzone-API 的 Comment / Emotion.load。
*/
export function mapQzoneMsgDetail(
data: RawMsgDetailResponse,
tid: string,
): QzoneMsgDetailResult {
const resultTid = detailString(data.tid, 'tid');
if (!resultTid || resultTid !== tid) {
throw new Error('invalid qzone msg detail: tid does not match the requested post');
}
return {
tid: resultTid,
content: detailString(data.content, 'content'),
comment_num: detailInteger(data.cmtnum, 'cmtnum'),
comments: detailList(data.commentlist, 'commentlist').map((value, index) => {
const field = `commentlist[${index}]`;
const comment = detailObject(value, field);
const mappedComment = mapQzoneComment(comment, field);
return {
...mappedComment,
replies: detailList(comment['list_3'], `${field}.list_3`).map((reply, replyIndex) => {
const replyField = `${field}.list_3[${replyIndex}]`;
return {
...mapQzoneComment(detailObject(reply, replyField), replyField),
parent_comment_id: mappedComment.comment_id,
parent_comment_uin: mappedComment.uin,
};
}),
};
}),
};
}
/** 详情响应错误包络统一判定(code 或 subcode 任一非零即失败,subcode=0 不遮掩 code!=0)。 */
export function parseQzoneDetailError(data: RawMsgDetailResponse, tid: string): void {
const code =
data.code !== undefined && data.code !== 0 ? data.code : (data.subcode ?? data.code ?? -1);
if (code !== 0) {
throw new Error(
`qzone msg detail failed: code=${code} ${data.message ?? data.msg ?? ''}`.trim(),
);
}
void tid;
}
/** 解析 emotion_cgi_update(改权限)响应。 */
export function parseQzoneUpdateRightResponse(
text: string,
tid: string,
right: number,
): QzoneUpdateRightResult {
const data = parseQzoneJson<RawUpdateResponse>(text);
if (typeof data.subcode === 'number' && data.subcode !== 0) {
throw new Error(
`qzone update right failed: subcode=${data.subcode} ${data.message ?? data.msg ?? ''}`.trim(),
);
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(
`qzone update right failed: code=${data.code} ${data.message ?? data.msg ?? ''}`.trim(),
);
}
void tid;
return { ugc_right: Number(data.ugc_right ?? right) };
}
// ─────────────── 空间资料 ───────────────
interface RawQzoneProfileResponse {
code?: number;
subcode?: number;
message?: string;
data?: {
spacename?: string;
desc?: string;
};
}
/** 构造空间资料 GET 查询(cgi_userinfo_get_all)。 */
export function buildQzoneProfileQuery(selfUin: string, targetUin: string, bkn: string): string {
return new URLSearchParams({
uin: targetUin,
vuin: selfUin,
fupdate: '1',
rd: String(Math.random()),
g_tk: bkn,
}).toString();
}
/** 解析空间资料响应。 */
export function parseQzoneProfileResponse(text: string): QzoneProfile {
const response = parseQzoneJson<RawQzoneProfileResponse>(text);
if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) {
throw new Error(
`qzone profile failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(),
);
}
if (typeof response.data?.spacename !== 'string' || typeof response.data.desc !== 'string') {
throw new Error('invalid qzone profile response');
}
return { name: response.data.spacename, description: response.data.desc };
}
/** 构造空间资料修改表单体(字段位图 mb:spacename=2048, desc=4096)。 */
export function buildQzoneProfileUpdateBody(
selfUin: string,
profile: QzoneProfileUpdate,
): string {
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
let bitmap = 0;
// 空间设置表单用字段位图;独立富文本签名不含。
// https://qzonestyle.gtimg.cn/qzone/v6/setting/profile/scripts/profile.js
if (profile.name !== undefined) {
params.set('spacename', profile.name.replaceAll('\r', ''));
bitmap |= 2048;
}
if (profile.description !== undefined) {
params.set('desc', profile.description.replaceAll('\r', ''));
bitmap |= 4096;
}
params.set('mb', String(bitmap));
return params.toString();
}
/** 解析空间资料修改响应。 */
export function parseQzoneProfileUpdateResponse(text: string): void {
const response = parseQzoneJson<RawQzoneProfileResponse>(text);
if (response.code !== 0 || (response.subcode !== undefined && response.subcode !== 0)) {
throw new Error(
`qzone profile update failed: code=${response.code} subcode=${response.subcode ?? 0} ${response.message ?? ''}`.trim(),
);
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* 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;
message?: string;
msg?: string;
}
/**
* 解析发表说说接口返回的 JSON, 提取 tid
*/
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; } {
const subcode = json.subcode ?? json.code;
if (subcode !== undefined && subcode !== 0) {
throw new Error(json.message || json.msg || `QQ空间发说说失败, subcode=${subcode}`);
}
const tid = json.t1_tid || json.tid;
if (!tid) {
throw new Error('QQ空间发说说失败, 未返回tid');
}
return { tid };
}
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 };
}
+176
View File
@@ -0,0 +1,176 @@
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;
sAlbumID: string;
sAlbumName: string;
sPicDesc: string;
sPicPath: string;
sPicTitle: string;
stExtendInfo: StExtendInfo;
}
export interface MapExt {
appid: string;
userid: string;
}
export interface StExtendInfo {
mapParams: MapParams;
}
export interface MapParams {
batch_num: string;
photo_num: string;
video_num: string;
}
export interface Env {
deviceInfo: string;
refer: string;
}
export interface Token {
appid: number;
data: string;
type: number;
}
export function qunAlbumControl ({
uin,
group_id,
pskey,
pic_md5,
img_size,
img_name,
sAlbumName,
sAlbumID,
photo_num = '1',
video_num = '0',
batch_num = '1',
}: {
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
}
): {
control_req: ControlReq[]
} {
const timestamp = Math.floor(Date.now() / 1000);
return {
control_req: [
{
uin,
token: {
type: 4,
data: pskey,
appid: 5,
},
appid: 'qun',
checksum: pic_md5,
check_type: 0,
file_len: img_size,
env: {
refer: 'qzone',
deviceInfo: 'h5',
},
model: 0,
biz_req: {
sPicTitle: img_name,
sPicDesc: '',
sAlbumName,
sAlbumID,
iAlbumTypeID: 0,
iBitmap: 0,
iUploadType: 0,
iUpPicType: 0,
iBatchID: timestamp,
sPicPath: '',
iPicWidth: 0,
iPicHight: 0,
iWaterType: 0,
iDistinctUse: 0,
iNeedFeeds: 1,
iUploadTime: timestamp,
mapExt: {
appid: 'qun',
userid: group_id,
},
stExtendInfo: {
mapParams: {
photo_num,
video_num,
batch_num,
},
},
},
session: '',
asy_upload: 0,
cmd: 'FileUpload',
}],
};
}
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,
},
};
}
+638
View File
@@ -0,0 +1,638 @@
{
"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"
},
"9.9.22-40768": {
"appid": 537319804,
"qua": "V1_WIN_NQ_9.9.22_40768_GW_B"
},
"6.9.82-40768": {
"appid": 537319829,
"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": 537319880,
"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"
},
"9.9.23-41679": {
"appid": 537320110,
"qua": "V1_WIN_NQ_9.9.23_41679_GW_B"
},
"6.9.83-41679": {
"appid": 537320135,
"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": 537320135,
"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"
},
"6.9.83-41857": {
"appid": 537320186,
"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"
},
"9.9.23-42086": {
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42086_GW_B"
},
"6.9.85-42086": {
"appid": 537320237,
"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.23_42744_GW_B"
},
"6.9.86-42744": {
"appid": 537328495,
"qua": "V1_MAC_NQ_6.9.85_42744_GW_B"
},
"9.9.25-42905": {
"appid": 537328521,
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
},
"6.9.86-42905": {
"appid": 537328546,
"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"
},
"9.9.25-42941": {
"appid": 537328623,
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
},
"6.9.86-42941": {
"appid": 537328648,
"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"
},
"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": 537337594,
"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"
},
"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"
},
"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": 537345994,
"qua": "V1_LNX_NQ_3.2.26_46928_GW_B"
},
"9.9.28-46928": {
"appid": 537345957,
"qua": "V1_WIN_NQ_9.9.28_46928_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"
},
"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": 537355867,
"qua": "V1_LNX_NQ_3.2.29_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"
},
"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"
},
"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"
},
"9.9.35-52892": {
"appid": 537382819,
"qua": "V1_WIN_NQ_9.9.35_52892_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"
}
}
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
}
}
+314
View File
@@ -0,0 +1,314 @@
{
"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": "0815774",
"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": "0957648"
},
"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.85-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"
},
"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": "0A36152"
},
"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"
},
"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"
},
"3.2.26-46928-x64": {
"send": "5D30910",
"recv": "31814B0"
},
"3.2.26-46928-arm64": {
"send": "3EDC654",
"recv": "14A3124"
},
"9.9.28-46928-x64": {
"send": "0A6D66C",
"recv": "1E9890D"
},
"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": "0A8FD5A"
},
"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"
},
"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"
},
"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"
},
"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"
},
"9.9.35-52892-x64": {
"send": "C361DF",
"recv": "246F3A1"
},
"3.2.33-52892-x64": {
"send": "6DDB4C0",
"recv": "39DA860"
},
"3.2.33-52892-arm64": {
"send": "4A5D658",
"recv": "1813A34"
},
"9.9.37-54395-x64": {
"send": "C5927F",
"recv": "24CC2B5"
},
"9.9.37-54276-x64": {
"send": "C5927F",
"recv": "24CC2B5"
}
}
+814
View File
@@ -0,0 +1,814 @@
{
"3.2.12-28418-x64": {
"recv": "A0723E0",
"send": "A06EAE0"
},
"9.9.15-28418-x64": {
"recv": "37A9004",
"send": "37A4BD0"
},
"6.9.56-28418-x64": {
"send": "4471360",
"recv": "4473BCC"
},
"6.9.56-28418-arm64": {
"send": "3FBDBF8",
"recv": "3FC0410"
},
"9.9.15-28498-x64": {
"recv": "37A9004",
"send": "37A4BD0"
},
"9.9.16-28788-x64": {
"send": "38076D0",
"recv": "380BB04"
},
"3.2.13-28788-x64": {
"send": "A0CEC20",
"recv": "A0D2520"
},
"3.2.13-28788-arm64": {
"send": "6E91018",
"recv": "6E94850"
},
"9.9.16-28971-x64": {
"send": "38079F0",
"recv": "380BE24"
},
"3.2.13-28971-x64": {
"send": "A0CEF60",
"recv": "A0D2860"
},
"3.2.12-28971-arm64": {
"send": "6E91318",
"recv": "6E94B50"
},
"6.9.58-28971-x64": {
"send": "449ACA0",
"recv": "449D50C"
},
"6.9.58-28971-arm64": {
"send": "3FE0DB0",
"recv": "3FE35C8"
},
"9.9.16-29271-x64": {
"send": "3833510",
"recv": "3837944"
},
"3.2.13-29271-x64": {
"send": "A11E680",
"recv": "A121F80"
},
"3.2.13-29271-arm64": {
"send": "6ECA098",
"recv": "6ECD8D0"
},
"9.9.16-29456-x64": {
"send": "3835CD0",
"recv": "383A104"
},
"3.2.13-29456-x64": {
"send": "A11E820",
"recv": "A122120"
},
"3.2.13-29456-arm64": {
"send": "6ECA130",
"recv": "6ECD968"
},
"6.9.59-29456-x64": {
"send": "44C57A0",
"recv": "44C800C"
},
"6.9.59-29456-arm64": {
"send": "4005FE8",
"recv": "4008800"
},
"9.9.16-29927-x64": {
"send": "3869C50",
"recv": "386E084"
},
"3.2.13-29927-x64": {
"send": "A1913A0",
"recv": "A194CA0"
},
"3.2.13-29927-arm64": {
"send": "6F1C7E0",
"recv": "6F20018"
},
"6.9.61-29927-x64": {
"send": "44FCC60",
"recv": "44FF4CC"
},
"6.9.61-29927-arm64": {
"send": "4038740",
"recv": "403AF58"
},
"9.9.17-30366-x64": {
"send": "39AB0B0",
"recv": "39AF4E4"
},
"3.2.15-30366-x64": {
"send": "A402380",
"recv": "A405C80"
},
"3.2.15-30366-arm64": {
"send": "70C3FA8",
"recv": "70C77E0"
},
"6.9.62-30366-x64": {
"send": "4669760",
"recv": "466BFCC"
},
"6.9.62-30366-arm64": {
"send": "4189770",
"recv": "418BF88"
},
"9.9.17-30483-x64": {
"send": "39AC1B0",
"recv": "39B05E4"
},
"6.9.62-30483-arm64": {
"send": "41896B0",
"recv": "418bec8"
},
"6.9.62-30483-x64": {
"send": "4669460",
"recv": "466BCCC"
},
"3.2.15-30483-x64": {
"send": "A402540",
"recv": "A405E40"
},
"3.2.15-30483-arm64": {
"send": "70C40E8",
"recv": "70C7920"
},
"9.9.17-30594-x64": {
"send": "39AC1B0",
"recv": "39B05E4"
},
"6.9.62-30594-arm64": {
"send": "41896B0",
"recv": "418bec8"
},
"6.9.62-30594-x64": {
"send": "4669460",
"recv": "466BCCC"
},
"3.2.15-30594-x64": {
"send": "A402540",
"recv": "A405E40"
},
"3.2.15-30594-arm64": {
"send": "70C40E8",
"recv": "70C7920"
},
"9.9.17-30851-x64": {
"send": "395C150",
"recv": "3960584"
},
"3.2.15-30851-x64": {
"send": "A4A03E0",
"recv": "A4A3CE0"
},
"3.2.15-30851-arm64": {
"send": "713A318",
"recv": "713DB50"
},
"6.9.63-30851-x64": {
"send": "46C8040",
"recv": "46CA8AC"
},
"6.9.63-30851-arm64": {
"send": "41DCBD8",
"recv": "41DF3F0"
},
"9.9.17-30899-x64": {
"send": "395C150",
"recv": "3960584"
},
"3.2.15-30899-x64": {
"send": "A4A03E0",
"recv": "A4A3CE0"
},
"3.2.15-30899-arm64": {
"send": "713A318",
"recv": "713DB50"
},
"6.9.63-30899-x64": {
"send": "46C8040",
"recv": "46CA8AC"
},
"6.9.63-30899-arm64": {
"send": "41DCBD8",
"recv": "41DF3F0"
},
"9.9.17-31219-x64": {
"send": "39C1350",
"recv": "39C5784"
},
"9.9.17-31245-x64": {
"send": "39C1350",
"recv": "39C5784"
},
"6.9.63-31245-x64": {
"send": "4720A40",
"recv": "47232AC"
},
"6.9.63-31245-arm64": {
"send": "41DCBD8",
"recv": "422D4E8"
},
"3.2.15-31245-x64": {
"send": "A550F80",
"recv": "A554880"
},
"3.2.15-31245-arm64": {
"send": "71BEBB8",
"recv": "71C23F0"
},
"9.9.17-31363-x64": {
"send": "39C1910",
"recv": "39C5d44"
},
"3.2.15-31363-x64": {
"send": "A554500",
"recv": "A557E00"
},
"3.2.15-31363-arm64": {
"send": "71BFD48",
"recv": "71C3580"
},
"6.9.65-31363-x64": {
"send": "4720E80",
"recv": "47236EC"
},
"6.9.65-31363-arm64": {
"send": "422CEF8",
"recv": "422F710"
},
"9.9.18-32690-x64": {
"send": "39F9630",
"recv": "39FDE30"
},
"3.2.16-32690-x64": {
"send": "A5E24C0",
"recv": "A5E5EE0"
},
"3.2.16-32690-arm64": {
"send": "7226630",
"recv": "7229F60"
},
"3.2.16-32721-x64": {
"send": "A5E24C0",
"recv": "A5E5EE0"
},
"3.2.16-32721-arm64": {
"send": "7226630",
"recv": "7229F60"
},
"9.9.18-32793-x64": {
"send": "39F9A30",
"recv": "39FE230"
},
"3.2.16-32793-x64": {
"send": "A5E24C0",
"recv": "A5E5EE0"
},
"3.2.16-32793-arm64": {
"send": "7226630",
"recv": "7229F60"
},
"9.9.18-32869-x64": {
"send": "39F9A30",
"recv": "39FE230"
},
"3.2.16-32869-x64": {
"send": "A5E24C0",
"recv": "A5E5EE0"
},
"3.2.16-32869-arm64": {
"send": "7226630",
"recv": "7229F60"
},
"9.9.18-33139-x64": {
"send": "39F5870",
"recv": "39FA070"
},
"3.2.16-33139-x64": {
"send": "A634F60",
"recv": "A638980"
},
"3.2.16-33139-arm64": {
"send": "7262BB0",
"recv": "72664E0"
},
"9.9.18-33800-x64": {
"send": "39F5870",
"recv": "39FA070"
},
"3.2.16-33800-x64": {
"send": "A634F60",
"recv": "A638980"
},
"3.2.16-33800-arm64": {
"send": "7262BB0",
"recv": "72664E0"
},
"9.9.19-34231-x64": {
"send": "3BD73D0",
"recv": "3BDBBD0"
},
"3.2.17-34231-x64": {
"send": "AD787E0",
"recv": "AD7C200"
},
"3.2.17-34231-arm64": {
"send": "770CDC0",
"recv": "77106F0"
},
"9.9.19-34362-x64": {
"send": "3BD80D0",
"recv": "3BDC8D0"
},
"9.9.19-34467-x64": {
"send": "3BD8690",
"recv": "3BDCE90"
},
"9.9.19-34566-x64": {
"send": "3BDA110",
"recv": "3BDE910"
},
"9.9.19-34606-x64": {
"send": "3BDA110",
"recv": "3BDE910"
},
"3.2.17-34606-x64": {
"send": "AD7DC60",
"recv": "AD81680"
},
"3.2.17-34606-arm64": {
"send": "7711270",
"recv": "7714BA0"
},
"9.9.19-34740-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
},
"3.2.17-34740-x64": {
"send": "ADDF0A0",
"recv": "ADE2AC0"
},
"3.2.17-34740-arm64": {
"send": "7753BB8",
"recv": "77574E8"
},
"9.9.19-34958-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
},
"3.2.17-35184-x64": {
"send": "AE0DDE0",
"recv": "AE11800"
},
"3.2.17-35184-arm64": {
"send": "7776028",
"recv": "7779958"
},
"9.9.19-35184-x64": {
"send": "3BE5A10",
"recv": "3BEA210"
},
"9.9.19-35341-x64": {
"send": "3BF1D50",
"recv": "3BF6550"
},
"9.9.19-35469-x64": {
"send": "3BF1D50",
"recv": "3BF6550"
},
"3.2.17-35341-x64": {
"send": "AE2F700",
"recv": "AE33120"
},
"3.2.17-35341-arm64": {
"send": "778D840",
"recv": "7791170"
},
"9.9.20-35951-x64": {
"send": "3034BAC",
"recv": "3038354"
},
"3.2.18-35951-x64": {
"send": "AFBBB00",
"recv": "AFBF520"
},
"9.9.20-36580-x64": {
"send": "30824B8",
"recv": "3085C5C"
},
"3.2.18-36580-x64": {
"send": "B0853E0",
"recv": "B088E60"
},
"3.2.18-36580-arm64": {
"send": "793DAC8",
"recv": "7941458"
},
"3.2.18-37012-x64": {
"send": "B20F960",
"recv": "B2133E0"
},
"3.2.18-37012-arm64": {
"send": "7A19E00",
"recv": "7A1D790"
},
"9.9.20-37012-x64": {
"send": "30CC958",
"recv": "30D00FC"
},
"3.2.18-37051-x64": {
"send": "B20F960",
"recv": "B2133E0"
},
"3.2.18-37051-arm64": {
"send": "7A19E00",
"recv": "7A1D790"
},
"9.9.20-37051-x64": {
"send": "30CC958",
"recv": "30D00FC"
},
"9.9.20-37475-x64": {
"send": "30D30D8",
"recv": "30D687C"
},
"3.2.18-37475-x64": {
"send": "B238EC0",
"recv": "B23C940"
},
"3.2.18-37475-arm64": {
"send": "7A34B38",
"recv": "7A384C8"
},
"9.9.20-37625-x64": {
"send": "30D39D8",
"recv": "30D717C"
},
"3.2.18-37625-x64": {
"send": "B2397E0",
"recv": "B23D260"
},
"3.2.18-37625-arm64": {
"send": "7A350D8",
"recv": "7A38A68"
},
"9.9.21-38503-x64": {
"send": "3105F38",
"recv": "31096DC"
},
"3.2.19-38503-x64": {
"send": "B2C1A60",
"recv": "B2C54E0"
},
"3.2.19-38626-x64": {
"send": "B2C1BE0",
"recv": "B2C5660"
},
"9.9.21-38626-x64": {
"send": "310A758",
"recv": "310DEFC"
},
"3.2.19-38626-arm64": {
"send": "7A8A490",
"recv": "7A8DE20"
},
"9.9.21-38711-x64": {
"send": "310A758",
"recv": "310DEFC"
},
"3.2.19-38960-x64": {
"send": "B3740E0",
"recv": "B377B60"
},
"9.9.21-38960-x64": {
"send": "313F7D8",
"recv": "3142F7C"
},
"3.2.19-38960-arm64": {
"send": "7B01D98",
"recv": "7B05728"
},
"3.2.19-39038-x64": {
"send": "B3759E0",
"recv": "B379460"
},
"3.2.19-39038-arm64": {
"send": "7B025C8",
"recv": "7B05F58"
},
"9.9.21-39038-x64": {
"send": "313FB58",
"recv": "31432FC"
},
"9.9.22-40362-x64": {
"send": "31C0EB8",
"recv": "31C465C"
},
"3.2.20-40768-x64": {
"send": "B69CFE0",
"recv": "B6A0A60"
},
"9.9.22-40768-x64": {
"send": "31C1838",
"recv": "31C4FDC"
},
"3.2.20-40768-arm64": {
"send": "7D49B18",
"recv": "7D4D4A8"
},
"6.9.82-40768-arm64": {
"send": "202A198",
"recv": "202B718"
},
"9.9.22-40824-x64": {
"send": "31C1838",
"recv": "31C4FDC"
},
"3.2.20-40824-arm64": {
"send": "7D49B18",
"recv": "7D4D4A8"
},
"6.9.82-40824-arm64": {
"send": "202A198",
"recv": "202B718"
},
"3.2.20-40990-x64": {
"send": "B69CFE0",
"recv": "B6A0A60"
},
"3.2.20-40990-arm64": {
"send": "7D49B18",
"recv": "7D4D4A8"
},
"9.9.22-40990-x64": {
"send": "31C1838",
"recv": "31C4FDC"
},
"6.9.82-40990-arm64": {
"send": "202A198",
"recv": "202B718"
},
"9.9.23-41679-x64": {
"send": "2C94520",
"recv": "2C97AA0"
},
"6.9.83-41679-arm64": {
"send": "3D718F8",
"recv": "3D74208"
},
"9.9.23-41785-x64": {
"send": "2C944A0",
"recv": "2C97A20"
},
"6.9.83-41785-arm64": {
"send": "3D6DA28",
"recv": "3D70338"
},
"6.9.83-41857-arm64": {
"send": "3D74610",
"recv": "3D76F20"
},
"3.2.21-41857-x64": {
"send": "A7B40A0",
"recv": "A7B7B20"
},
"9.9.23-41857-x64": {
"send": "2C98F00",
"recv": "2C9C480"
},
"3.2.21-41857-arm64": {
"send": "6B159F8",
"recv": "6B19388"
},
"9.9.23-42086-x64": {
"send": "2C99800",
"recv": "2C9CD80"
},
"3.2.21-42086-x64": {
"send": "A7B1060",
"recv": "A7B4AE0"
},
"3.2.21-42086-arm64": {
"send": "6B13038",
"recv": "6B169C8"
},
"9.9.23-42430-x64": {
"send": "2C9A4A0",
"recv": "2C9DA20"
},
"9.9.25-42744-x64": {
"send": "2CD8E40",
"recv": "2CDC3C0"
},
"6.9.86-42744-arm64": {
"send": "3DCC840",
"recv": "3DCF150"
},
"9.9.25-42905-x64": {
"send": "2CE46A0",
"recv": "2CE7C20"
},
"6.9.86-42905-arm64": {
"send": "3DD6098",
"recv": "3DD89A8"
},
"3.2.22-42941-x64": {
"send": "A8AD8A0",
"recv": "A8B1320"
},
"9.9.25-42941-x64": {
"send": "2CE4DA0",
"recv": "2CE8320"
},
"3.2.22-42941-arm64": {
"send": "6BC95E8",
"recv": "6BCCF78"
},
"6.9.86-42941-arm64": {
"send": "3DDDAD0",
"recv": "3DE03E0"
},
"9.9.26-44175-x64": {
"send": "2CD84A0",
"recv": "2CDBA20"
},
"3.2.23-44343-x64": {
"send": "A46F140",
"recv": "A472BE0"
},
"9.9.26-44343-x64": {
"send": "2CD8EE0",
"recv": "2CDC460"
},
"3.2.23-44343-arm64": {
"send": "6926F60",
"recv": "692A910"
},
"9.9.26-44498-x64": {
"send": "2CDAE40",
"recv": "2CDE3C0"
},
"9.9.26-44725-x64": {
"send": "2CEBB20",
"recv": "2CEF0A0"
},
"9.9.27-45627-x64": {
"send": "2E59CC0",
"recv": "2E5D240"
},
"6.9.88-44725-x64": {
"send": "451FE90",
"recv": "4522A40"
},
"6.9.88-44725-arm64": {
"send": "3D79168",
"recv": "3D7BA78"
},
"3.2.25-45758-x64": {
"send": "AAC4E00",
"recv": "AAC88A0"
},
"9.9.27-45758-x64": {
"send": "2E5C4A0",
"recv": "2E5FA20"
},
"3.2.25-45758-arm64": {
"send": "6D6F9A0",
"recv": "6D73350"
},
"3.2.26-46494-x64": {
"send": "AB341C0",
"recv": "AB37C60"
},
"9.9.28-46494-x64": {
"send": "2E77C20",
"recv": "2E7B1A0"
},
"3.2.26-46494-arm64": {
"send": "6DBBBB0",
"recv": "6DBF560"
},
"3.2.26-46928-arm64": {
"send": "6DBBBB0",
"recv": "6DBF560"
},
"3.2.26-46928-x64": {
"send": "AB341C0",
"recv": "AB37C60"
},
"9.9.28-46928-x64": {
"send": "2E77C20",
"recv": "2E7B1A0"
},
"3.2.27-47354-x64": {
"send": "AB6A6C0",
"recv": "AB6E160"
},
"3.2.27-47354-arm64": {
"send": "6DE1438",
"recv": "6DE4DE8"
},
"9.9.29-47354-x64": {
"send": "2E84920",
"recv": "2E87EA0"
},
"6.9.93-47354-arm64": {
"send": "3FD1E98",
"recv": "3FD47A8"
},
"6.9.93-47354-x64": {
"send": "47CD940",
"recv": "47D04F0"
},
"3.2.28-48517-x64": {
"send": "AD7C280",
"recv": "AD7FD20"
},
"9.9.30-48517-x64": {
"send": "2F3B960",
"recv": "2F3EEE0"
},
"3.2.28-48517-arm64": {
"send": "6F48B70",
"recv": "6F4C520"
},
"9.9.30-48762-x64": {
"send": "2F3B960",
"recv": "2F3EEE0"
},
"9.9.31-49599-x64": {
"send": "3082C40",
"recv": "30861C8"
},
"9.9.31-49738-x64": {
"send": "3086C80",
"recv": "308A208"
},
"3.2.29-49738-x64": {
"send": "B3B20A0",
"recv": "B3B5B40"
},
"3.2.29-49738-arm64": {
"send": "7342800",
"recv": "73461B0"
},
"9.9.32-50828-x64": {
"send": "3155200",
"recv": "3158788"
},
"9.9.32-50828-arm64": {
"send": "4327764",
"recv": "15E3184"
},
"3.2.30-50828-x64": {
"send": "B5D0FC0",
"recv": "B5D4A60"
},
"9.9.32-50969-x64": {
"send": "31994E0",
"recv": "319CA68"
},
"3.2.30-50969-x64": {
"send": "B7394C0",
"recv": "B73CF60"
},
"3.2.30-50969-arm64": {
"send": "75BD1D0",
"recv": "75C0B80"
},
"9.9.33-52230-x64": {
"send": "367FE18",
"recv": "36835CC"
},
"3.2.32-52194-x64": {
"send": "C7DBF80",
"recv": "C7DFA20"
},
"3.2.32-52194-arm64": {
"send": "80F36D0",
"recv": "80F7080"
},
"9.9.35-52892-x64": {
"send": "37C30D8",
"recv": "37C688C"
},
"3.2.33-52892-x64": {
"send": "CB06D60",
"recv": "CB0A800"
},
"3.2.33-52892-arm64": {
"send": "832ECA0",
"recv": "8332650"
}
}
@@ -0,0 +1,61 @@
// TODO: further refactor in NapCat.Packet v2
import { NapProtoMsg, ProtoField, ScalarType } from 'snowcat-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);
}
+45
View File
@@ -0,0 +1,45 @@
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
import { LogWrapper } from '@/snowcat-core/helper/log';
import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg';
async function guessDuration (pttPath: string, logger: LogWrapper) {
const pttFileInfo = await fsPromise.stat(pttPath);
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
logger.log('通过文件大小估算语音的时长:', duration);
return duration;
}
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
try {
const pttPath = path.join(TEMP_DIR, randomUUID());
if (!(await FFmpegService.isSilk(filePath))) {
logger.log(`语音文件${filePath}需要转换成silk`);
await FFmpegService.convertToNTSilkTct(filePath, pttPath);
const duration = await FFmpegService.getDuration(filePath);
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', duration);
return {
converted: true,
path: pttPath,
duration,
};
} else {
let duration = 0;
try {
duration = await FFmpegService.getDuration(filePath);
} catch (e: unknown) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
duration = await guessDuration(filePath, logger);
}
return {
converted: false,
path: filePath,
duration,
};
}
} catch (error: unknown) {
logger.logError('convert silk failed', error);
return {};
}
}
@@ -0,0 +1,84 @@
import path from 'node:path';
import fs from 'node:fs';
import type { SnowCatCore } from '@/snowcat-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: SnowCatCore;
configPath: string;
configData: T = {} as T;
configSchema: TSchema;
validate: TypeCheck<TSchema>;
protected constructor (name: string, core: SnowCatCore, 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 '@/snowcat-core/helper/config-base';
import { SnowCatCore } from '@/snowcat-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 SnowCatConfigSchema = 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 SnowCatConfigFile = Static<typeof SnowCatConfigSchema>;
/**
* 从指定配置目录读取 napcat.json,按 SnowCatConfigSchema 校验并填充默认值
* 用于登录前(无 SnowCatCore 实例时)的早期配置读取
*/
export function loadSnowCatConfig (configPath: string): SnowCatConfigFile {
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(SnowCatConfigSchema, data) as Record<string, unknown>;
return data as SnowCatConfigFile;
}
export class SnowCatConfigLoader extends ConfigBase<SnowCatConfigFile> {
constructor (core: SnowCatCore, configPath: string, schema: TSchema) {
super('napcat', core, configPath, schema);
}
}
+272
View File
@@ -0,0 +1,272 @@
import { NodeIQQNTWrapperSession } from '@/snowcat-core/wrapper';
import { randomUUID } from 'crypto';
import { ListenerNamingMapping, ServiceNamingMapping } from '@/snowcat-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 '@/snowcat-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,130 @@
/**
* FFmpeg Adapter Factory
* 自动检测并选择最佳的 FFmpeg 适配器
*/
import { LogWrapper } from '@/snowcat-core/helper/log';
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
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> {
// 1. 优先尝试使用 Native Addon
if (binaryPath) {
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
if (await addonAdapter.isAvailable()) {
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
return addonAdapter;
}
logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具');
} else {
logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测');
}
// 2. 降级到 execFile 实现
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger);
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
if (await execAdapter.isAvailable()) {
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
return execAdapter;
}
// 3. 都不可用,返回 execAdapter 但会在使用时报错
logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败');
return execAdapter;
}
/**
* 重置适配器(用于测试或重新初始化)
*/
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<void> {
// 如果当前使用的是 Exec 适配器,更新路径
if (this.instance && this.instance instanceof FFmpegExecAdapter) {
logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`);
this.instance.setFFmpegPath(ffmpegPath);
this.instance.setFFprobePath(ffprobePath);
// 验证新路径是否可用
if (await this.instance.isAvailable()) {
logger.log('[FFmpeg] 新路径验证成功 ✓');
} else {
logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用');
}
}
}
/**
* 获取当前适配器(不初始化)
*/
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,140 @@
/**
* FFmpeg Native Addon Adapter
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
*/
import { platform, arch } from 'node:os';
import path from 'node:path';
import { existsSync, 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)
*/
function getAddonPath (binaryPath: string): string {
const platformName = platform();
const archName = arch();
const addonFileName: string = process.platform + '.' + process.arch;
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
if (!existsSync(addonPath)) {
throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`);
}
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): Promise<void> {
const addon = this.ensureAddon();
const info = await addon.getVideoInfo(videoPath);
// 将缩略图写入文件
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?: 'bmp' | 'bmp24'): 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,266 @@
/**
* FFmpeg Exec Adapter
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
*/
import { readFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
import { dirname, join } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { fileTypeFromFile } from 'file-type';
import { imageSizeFallBack } from 'snowcat-image-size/src/index';
import { downloadFFmpegIfNotExists } from './download-ffmpeg';
import { LogWrapper } from '@/snowcat-core/helper/log';
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';
private downloadAttempted = false;
constructor (
private ffmpegPath: string = 'ffmpeg',
private ffprobePath: string = 'ffprobe',
private binaryPath?: string,
private logger?: LogWrapper
) { }
/**
* 检查 FFmpeg 是否可用,如果不可用则尝试下载
*/
async isAvailable (): Promise<boolean> {
// 首先检查当前路径
try {
await execFileAsync(this.ffmpegPath, ['-version']);
return true;
} catch {
// 如果失败且未尝试下载,尝试下载
if (!this.downloadAttempted && this.binaryPath && this.logger) {
this.downloadAttempted = true;
if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
return false;
}
this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...');
const result = await downloadFFmpegIfNotExists(this.logger);
if (result.path && result.reset) {
// 更新路径
if (process.platform === 'win32') {
this.ffmpegPath = join(result.path, 'ffmpeg.exe');
this.ffprobePath = join(result.path, 'ffprobe.exe');
this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath);
// 再次检查
try {
await execFileAsync(this.ffmpegPath, ['-version']);
return true;
} catch {
return false;
}
}
}
}
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 [fileType, duration] = await Promise.all([
fileTypeFromFile(videoPath).catch(() => null),
this.getDuration(videoPath).catch(() => 60),
]);
// 创建临时缩略图路径
const thumbnailPath = `${videoPath}.thumbnail.bmp`;
let width = 100;
let height = 100;
let thumbnail: Buffer | undefined;
try {
await this.extractThumbnail(videoPath, thumbnailPath);
// 获取图片尺寸
const dimensions = await imageSizeFallBack(thumbnailPath);
width = dimensions.width ?? 100;
height = dimensions.height ?? 100;
// 读取缩略图
if (existsSync(thumbnailPath)) {
thumbnail = readFileSync(thumbnailPath);
}
} catch (_error) {
// 使用默认值
}
return {
width,
height,
duration,
format: fileType?.ext ?? 'mp4',
thumbnail,
};
}
/**
* 获取时长
*/
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,
]);
const duration = parseFloat(stdout.trim());
if (isNaN(duration)) {
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,
]);
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'
? [
'-f', 's16le',
'-ar', '24000',
'-ac', '1',
'-i', inputFile,
'-ar', '8000',
'-b:a', '12.2k',
'-y',
outputFile,
]
: [
'-f', 's16le',
'-ar', '24000',
'-ac', '1',
'-i', inputFile,
'-y',
outputFile,
];
await execFileAsync(this.ffmpegPath, params);
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,
'-ss', '00:00:01.000',
'-vframes', '1',
'-y', // 覆盖输出文件
thumbnailPath,
]);
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> {
throw new Error('convertToNTSilkTct is not implemented in FFmpegExecAdapter');
}
}
@@ -0,0 +1,182 @@
import { statSync, existsSync, writeFileSync } from 'fs';
import path from 'path';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import { platform } from 'node:os';
import { LogWrapper } from '@/snowcat-core/helper/log';
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
const getFFmpegPath = (tool: string, binaryPath?: string): string => {
if (process.platform === 'win32' && binaryPath) {
const exeName = `${tool}.exe`;
const localPath = path.join(binaryPath, 'ffmpeg', exeName);
const isLocalExeExists = existsSync(localPath);
return isLocalExeExists ? localPath : exeName;
}
return tool;
};
export let FFMPEG_CMD = 'ffmpeg';
export let FFPROBE_CMD = 'ffprobe';
export class FFmpegService {
private static adapter: IFFmpegAdapter | null = null;
private static initialized = false;
/**
* 初始化 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);
// 立即初始化适配器(会触发自动下载等逻辑)
this.adapter = await FFmpegAdapterFactory.getAdapter(
logger,
FFMPEG_CMD,
FFPROBE_CMD,
binaryPath
);
this.initialized = true;
}
public static getAdapterName (): string {
if (!this.adapter) {
throw 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 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> {
if (platform() === 'win32') {
FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe');
FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe');
logger.log('[Check] ffmpeg:', FFMPEG_CMD);
logger.log('[Check] ffprobe:', FFPROBE_CMD);
// 更新适配器路径
await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD);
}
}
/**
* 提取视频缩略图
*/
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 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();
try {
// 获取文件大小
const fileSize = statSync(videoPath).size;
// 使用适配器获取视频信息
const videoInfo = await adapter.getVideoInfo(videoPath);
// 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径
if (thumbnailPath && videoInfo.thumbnail) {
writeFileSync(thumbnailPath, videoInfo.thumbnail);
}
const result: VideoInfo = {
width: videoInfo.width,
height: videoInfo.height,
time: videoInfo.duration,
format: videoInfo.format,
size: fileSize,
filePath: videoPath,
};
return result;
} catch (_error) {
// 降级处理:返回默认值
const fileType = await fileTypeFromFile(videoPath).catch(() => null);
const fileSize = statSync(videoPath).size;
return {
width: 100,
height: 100,
time: 60,
format: fileType?.ext ?? 'mp4',
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 '@/snowcat-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
);
}
}
+345
View File
@@ -0,0 +1,345 @@
import winston, { format, transports } from 'winston';
import { truncateString } from 'snowcat-common/src/helper';
import path from 'node:path';
import fs from 'node:fs/promises';
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/snowcat-core/index';
import { ILogWrapper } from 'snowcat-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,21 @@
import { LogWrapper } from '@/snowcat-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 'snowcat-common/src/system';
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'snowcat-common/src/helper';
import AppidTable from '@/snowcat-core/external/appid.json';
import { LogWrapper } from './log';
import { getMajorPath } from '@/snowcat-core/index';
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'snowcat-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 'snowcat-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 '@/snowcat-core/wrapper';
import { ServiceNamingMapping } from '@/snowcat-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 'snowcat-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();
+362
View File
@@ -0,0 +1,362 @@
import {
NTQQFileApi,
NTQQFriendApi,
NTQQGroupApi,
NTQQMsgApi,
NTQQSystemApi,
NTQQUserApi,
NTQQWebApi,
NTQQFlashApi,
NTQQOnlineApi,
NTQQDatabaseApi,
} from '@/snowcat-core/apis';
import { NTQQCollectionApi } from '@/snowcat-core/apis/collection';
import {
NodeIQQNTWrapperSession,
NodeQQNTWrapperUtil,
PlatformType,
VendorType,
WrapperNodeApi,
WrapperSessionInitConfig,
} from '@/snowcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/snowcat-core/helper/log';
import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info';
import { SnowCatPathWrapper } from 'snowcat-common/src/path';
import path from 'node:path';
import fs from 'node:fs';
import { hostname, systemName, systemVersion } from 'snowcat-common/src/system';
import { NTEventWrapper } from '@/snowcat-core/helper/event';
import { createSessionProxy } from '@/snowcat-core/helper/session-proxy';
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/snowcat-core/types';
import { SnowCatConfigLoader, SnowCatConfigSchema } from '@/snowcat-core/helper/config';
import os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/snowcat-core/listeners';
import { proxiedListenerOf } from '@/snowcat-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 SnowCatCoreWorkingEnv {
Unknown = 0,
Shell = 1,
Framework = 2,
}
export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi {
if (process.env['NAPCAT_WRAPPER_PATH']) {
const wrapperPath = process.env['NAPCAT_WRAPPER_PATH'];
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
process.dlopen(nativemodule, wrapperPath);
return nativemodule.exports;
}
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 nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
process.dlopen(nativemodule, wrapperNodePath);
process.env['NAPCAT_WRAPPER_PATH'] = wrapperNodePath;
return nativemodule.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 SnowCatCore {
readonly context: InstanceContext;
readonly eventWrapper: NTEventWrapper;
event = appEvent;
SnowCatDataPath: string = '';
SnowCatTempPath: string = '';
apis: StableNTApiWrapper;
// runtime info, not readonly
selfInfo: SelfInfo;
util: NodeQQNTWrapperUtil;
configLoader: SnowCatConfigLoader;
/** 数据库 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 SnowCatConfigLoader(this, this.context.pathWrapper.configPath, SnowCatConfigSchema);
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),
};
container.bind(SnowCatCore).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.SnowCatDataPath = path.join(this.dataPath, 'SnowCat');
fs.mkdirSync(this.SnowCatDataPath, { recursive: true });
this.SnowCatTempPath = path.join(this.SnowCatDataPath, 'temp');
// 创建临时目录
if (!fs.existsSync(this.SnowCatTempPath)) {
fs.mkdirSync(this.SnowCatTempPath, { 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 {
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) {
this.selfInfo.online = false;
this.context.logger.log('账号状态变更为离线');
} 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, 'SnowCat', '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: SnowCatCoreWorkingEnv;
readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper;
readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: SnowCatPathWrapper;
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,
}
@@ -0,0 +1,76 @@
import { BuddyCategoryType, FriendRequestNotify } from '@/snowcat-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,105 @@
import { DataSource, Group, GroupDetailInfo, GroupListUpdateType, GroupMember, GroupNotify, ShutUpGroupMember } from '@/snowcat-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 {
}
// 9.9.37-54395 新增:群通知类回调改为 List 形态,兼容映射到旧回调
onGroupAllInfoListChange (..._args: unknown[]): any {
return this.onGroupAllInfoChange(..._args);
}
onGroupDetailInfoListChange (..._args: unknown[]): any {
return (this.onGroupDetailInfoChange as (...a: unknown[]) => unknown)(..._args);
}
onGroupAIOSlotInfoChange (..._args: unknown[]): any {
}
onGroupNotifiesUpdatedV2 (..._args: unknown[]): any {
return (this.onGroupNotifiesUpdated as (...a: unknown[]) => unknown)(..._args);
}
onGroupNotifiesUnreadCountUpdatedV2 (..._args: unknown[]): any {
return this.onGroupNotifiesUnreadCountUpdated(..._args);
}
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,391 @@
import { ChatType, KickedOffLineInfo, RawMessage } from '@/snowcat-core/types';
import { CommonFileInfo } from '@/snowcat-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 {
}
// 9.9.37-54395 新增:联系人未读同步回调,等价 onNtMsgSyncEnd 时机
onNtMsgSyncContactUnread (..._args: unknown[]): any {
return this.onNtMsgSyncEnd();
}
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 '@/snowcat-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 '@/snowcat-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": "snowcat-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",
"inversify": "^7.10.4",
"json5": "^2.2.3",
"snowcat-common": "workspace:*",
"snowcat-database": "workspace:*",
"snowcat-image-size": "workspace:*",
"snowcat-protobuf": "workspace:*",
"reflect-metadata": "^0.2.2",
"winston": "^3.17.0",
"htmlparser2": "10.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}
@@ -0,0 +1,98 @@
import { LogStack } from '@/snowcat-core/packet/context/clientContext';
import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext';
import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext';
import { OidbPacket, PacketBuf } from '@/snowcat-core/packet/transformer/base';
import { Napi2NativeLoader } from '@/snowcat-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> {
const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild('40824');
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,
}));
const timeoutPromise = new Promise<RecvPacketData>((_resolve, reject) => {
setTimeout(
() =>
reject(
new Error(
`[PacketClient] sendPacket 超时 cmd=${cmd} timeout=${timeout}ms`
)
),
timeout
);
});
return Promise.race([sendPromise, timeoutPromise]);
}
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 '@/snowcat-core/packet/context/packetContext';
import { SnowCatCore } from '@/snowcat-core/index';
export class PacketClientSession {
private readonly context: PacketContext;
constructor (core: SnowCatCore) {
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 '@/snowcat-core/packet/client/nativeClient';
import { OidbPacket } from '@/snowcat-core/packet/transformer/base';
import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext';
import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext';
import { Napi2NativeLoader } from '@/snowcat-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 '@/snowcat-core/packet/context/napCoreContext';
import { LogWrapper, LogLevel } from '@/snowcat-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 { SnowCatCore } from '@/snowcat-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: SnowCatCore;
constructor (core: SnowCatCore) {
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 '@/snowcat-core/packet/context/packetContext';
import * as trans from '@/snowcat-core/packet/transformer';
import { PacketMsg } from '@/snowcat-core/packet/message/message';
import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgReplyElement,
PacketMsgVideoElement,
} from '@/snowcat-core/packet/message/element';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/snowcat-core/index';
import { MiniAppRawData, MiniAppReqParams } from '@/snowcat-core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/snowcat-core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf';
import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/snowcat-core/packet/transformer/proto';
import { OidbPacket } from '@/snowcat-core/packet/transformer/base';
import { ImageOcrResult } from '@/snowcat-core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
import { PacketMsgConverter } from '@/snowcat-core/packet/message/converter';
import { UploadForwardMsgParams } from '@/snowcat-core/packet/transformer/message/UploadForwardMsgV2';
import { assertUploadResults } from '@/snowcat-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 '@/snowcat-core/packet/highway/highwayContext';
import { SnowCatCore } from '@/snowcat-core/index';
import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext';
import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/snowcat-core/packet/context/clientContext';
import { PacketOperationContext } from '@/snowcat-core/packet/context/operationContext';
import { PacketMsgConverter } from '@/snowcat-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: SnowCatCore) {
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,226 @@
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { constants } from 'node:os';
import offset from '@/snowcat-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) => boolean;
}
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;
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;
}
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;
}
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);
this.logger.log('[PacketHandler] 初始化成功');
return true;
} catch (error) {
this.logger.logError('NativePacketClient 初始化出错:', error);
return false;
}
}
}
@@ -0,0 +1,7 @@
import { TypedEventEmitter } from './typeEvent';
export interface AppEvents {
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
KickedOffLine: 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 { SnowCatCore } 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 (): SnowCatCore {
return container.get(SnowCatCore);
}
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 '@/snowcat-core/packet/highway/uploader/highwayTcpUploader';
import { HighwayHttpUploader } from '@/snowcat-core/packet/highway/uploader/highwayHttpUploader';
import { PacketHighwaySig } from '@/snowcat-core/packet/highway/highwayContext';
import { PacketLogger } from '@/snowcat-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 '@/snowcat-core/packet/highway/client';
import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext';
import FetchSessionKey from '@/snowcat-core/packet/transformer/highway/FetchSessionKey';
import { int32ip2str, oidbIpv4s2HighwayIpv4s } from '@/snowcat-core/packet/highway/utils';
import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgVideoElement,
} from '@/snowcat-core/packet/message/element';
import { ChatType, Peer } from '@/snowcat-core/index';
import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from '@/snowcat-core/packet/utils/crypto/hash';
import UploadGroupImage from '@/snowcat-core/packet/transformer/highway/UploadGroupImage';
import { NapProtoMsg } from 'snowcat-protobuf';
import * as proto from '@/snowcat-core/packet/transformer/proto';
import * as trans from '@/snowcat-core/packet/transformer';
import fs from 'fs';
import path from 'path';
import { NapCoreContext } from '@/snowcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/snowcat-core/packet/context/clientContext';
import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '@/snowcat-core/helper/ffmpeg/video';
import { calculateFileMD5 } from 'snowcat-common/src/file';
import { randomUUID } from 'node:crypto';
import { imageSizeFallBack } from 'snowcat-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 'snowcat-protobuf';
import { IHighwayUploader } from '@/snowcat-core/packet/highway/uploader/highwayUploader';
import { Frame } from '@/snowcat-core/packet/highway/frame';
import * as proto from '@/snowcat-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 'snowcat-protobuf';
import { BlockSize } from '@/snowcat-core/packet/highway/highwayContext';
import { Frame } from '@/snowcat-core/packet/highway/frame';
import { IHighwayUploader } from '@/snowcat-core/packet/highway/uploader/highwayUploader';
import * as proto from '@/snowcat-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 '@/snowcat-core/packet/utils/crypto/tea';
import { NapProtoMsg } from 'snowcat-protobuf';
import { PacketHighwayTrans } from '@/snowcat-core/packet/highway/client';
import { PacketLogger } from '@/snowcat-core/packet/context/loggerContext';
import * as proto from '@/snowcat-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 'snowcat-protobuf';
import * as proto from '@/snowcat-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>;
});
};
@@ -0,0 +1,70 @@
import * as crypto from 'crypto';
import { PushMsgBody } from '@/snowcat-core/packet/transformer/proto';
import { NapProtoEncodeStructType } from 'snowcat-protobuf';
import { PacketMsg, PacketSendMsgElement } from '@/snowcat-core/packet/message/message';
import { IPacketMsgElement, PacketMsgTextElement } from '@/snowcat-core/packet/message/element';
import { SendTextElement } from '@/snowcat-core/index';
export class PacketMsgBuilder {
protected static failBackText = new PacketMsgTextElement(
{
textElement: { content: '[该消息类型暂不支持查看]' },
} as SendTextElement
);
buildFakeMsg (selfUid: string, element: PacketMsg[]): NapProtoEncodeStructType<typeof PushMsgBody>[] {
return element.map((node): NapProtoEncodeStructType<typeof PushMsgBody> => {
const avatar = `https://q.qlogo.cn/headimg_dl?dst_uin=${node.senderUin}&spec=0&img_type=jpg`;
const msgContent = node.msg.reduceRight((acc: undefined | Uint8Array, msg: IPacketMsgElement<PacketSendMsgElement>) => {
return acc ?? msg.buildContent();
}, undefined);
const msgElement = node.msg.flatMap(msg => msg.buildElement() ?? []);
if (!msgContent && !msgElement.length) {
msgElement.push(PacketMsgBuilder.failBackText.buildElement());
}
return {
responseHead: {
fromUin: node.senderUin,
type: 0,
sigMap: 0,
toUin: 0,
fromUid: '',
forward: node.groupId
? undefined
: {
friendName: node.senderName,
},
toUid: node.groupId ? undefined : selfUid,
grp: node.groupId
? {
groupUin: node.groupId,
memberName: node.senderName,
unknown5: 2,
}
: undefined,
},
contentHead: {
type: node.groupId ? 82 : 9,
subType: node.groupId ? undefined : 4,
divSeq: node.groupId ? undefined : 4,
autoReply: 0,
sequence: crypto.randomBytes(4).readUInt32LE(0),
timeStamp: +node.time.toString().substring(0, 10),
forward: {
field1: 0,
field2: 0,
field3: node.groupId ? 1 : 2,
unknownBase64: avatar,
avatar,
},
},
body: {
richText: {
elems: msgElement,
},
msgContent,
},
};
});
}
}
@@ -0,0 +1,173 @@
import {
ChatType,
ElementType,
MessageElement,
Peer,
RawMessage,
} from '@/snowcat-core/types/msg';
import {
SendArkElement,
SendFaceElement,
SendFileElement,
SendMarkdownElement,
SendMarketFaceElement,
SendMultiForwardMsgElement,
SendPicElement,
SendPttElement,
SendReplyElement,
SendTextElement,
SendVideoElement,
} from '@/snowcat-core/types/element';
import {
IPacketMsgElement,
PacketMsgAtElement,
PacketMsgFaceElement,
PacketMsgFileElement,
PacketMsgLightAppElement,
PacketMsgMarkDownElement,
PacketMsgMarkFaceElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgReplyElement,
PacketMsgTextElement,
PacketMsgVideoElement,
PacketMultiMsgElement,
} from '@/snowcat-core/packet/message/element';
import { PacketMsg, PacketSendMsgElement } from '@/snowcat-core/packet/message/message';
import { NapProtoDecodeStructType } from 'snowcat-protobuf';
import { Elem } from '@/snowcat-core/packet/transformer/proto';
const SupportedElementTypes = [
ElementType.TEXT,
ElementType.PIC,
ElementType.REPLY,
ElementType.FACE,
ElementType.MFACE,
ElementType.VIDEO,
ElementType.FILE,
ElementType.PTT,
ElementType.ARK,
ElementType.MARKDOWN,
ElementType.MULTIFORWARD,
];
type SendMessageTypeElementMap = {
[ElementType.TEXT]: SendTextElement,
[ElementType.PIC]: SendPicElement,
[ElementType.FILE]: SendFileElement,
[ElementType.PTT]: SendPttElement,
[ElementType.VIDEO]: SendVideoElement,
[ElementType.FACE]: SendFaceElement,
[ElementType.REPLY]: SendReplyElement,
[ElementType.ARK]: SendArkElement,
[ElementType.MFACE]: SendMarketFaceElement,
[ElementType.MULTIFORWARD]: SendMultiForwardMsgElement,
[ElementType.MARKDOWN]: SendMarkdownElement,
};
type ElementToPacketMsgConverters = {
[K in keyof SendMessageTypeElementMap]: (
sendElement: MessageElement
) => IPacketMsgElement<SendMessageTypeElementMap[K]>;
};
export type rawMsgWithSendMsg = {
senderUin: number;
senderUid?: string;
senderName: string;
groupId?: number;
time: number;
msg: PacketSendMsgElement[]
};
// TODO: make it become adapter?
export class PacketMsgConverter {
private isValidElementType (type: ElementType): type is keyof ElementToPacketMsgConverters {
return SupportedElementTypes.includes(type);
}
private readonly rawToPacketMsgConverters: ElementToPacketMsgConverters = {
[ElementType.TEXT]: (element) => {
if (element.textElement?.atType) {
return new PacketMsgAtElement(element as SendTextElement);
}
return new PacketMsgTextElement(element as SendTextElement);
},
[ElementType.PIC]: (element) => {
return new PacketMsgPicElement(element as SendPicElement);
},
[ElementType.REPLY]: (element) => {
return new PacketMsgReplyElement(element as SendReplyElement);
},
[ElementType.FACE]: (element) => {
return new PacketMsgFaceElement(element as SendFaceElement);
},
[ElementType.MFACE]: (element) => {
return new PacketMsgMarkFaceElement(element as SendMarketFaceElement);
},
[ElementType.VIDEO]: (element) => {
return new PacketMsgVideoElement(element as SendVideoElement);
},
[ElementType.FILE]: (element) => {
return new PacketMsgFileElement(element as SendFileElement);
},
[ElementType.PTT]: (element) => {
return new PacketMsgPttElement(element as SendPttElement);
},
[ElementType.ARK]: (element) => {
return new PacketMsgLightAppElement(element as SendArkElement);
},
[ElementType.MARKDOWN]: (element) => {
return new PacketMsgMarkDownElement(element as SendMarkdownElement);
},
[ElementType.MULTIFORWARD]: (element) => {
return new PacketMultiMsgElement(element as SendMultiForwardMsgElement);
},
};
rawMsgWithSendMsgToPacketMsg (msg: rawMsgWithSendMsg): PacketMsg {
return {
senderUid: msg.senderUid ?? '',
senderUin: msg.senderUin,
senderName: msg.senderName,
groupId: msg.groupId,
time: msg.time,
msg: msg.msg.map((element) => {
if (!this.isValidElementType(element.elementType)) return null;
return this.rawToPacketMsgConverters[element.elementType](element as MessageElement);
}).filter((e) => e !== null),
};
}
rawMsgToPacketMsg (msg: RawMessage, ctxPeer: Peer): PacketMsg {
return {
seq: +msg.msgSeq,
groupId: ctxPeer.chatType === ChatType.KCHATTYPEGROUP ? +msg.peerUid : undefined,
senderUid: msg.senderUid,
senderUin: +msg.senderUin,
senderName: msg.sendMemberName && msg.sendMemberName !== ''
? msg.sendMemberName
: msg.sendNickName && msg.sendNickName !== ''
? msg.sendNickName
: 'QQ用户',
time: +msg.msgTime,
msg: msg.elements.map((element) => {
if (!this.isValidElementType(element.elementType)) return null;
return this.rawToPacketMsgConverters[element.elementType](element);
}).filter((e) => e !== null),
};
}
packetMsgToRaw (msg: NapProtoDecodeStructType<typeof Elem>[]): [MessageElement, NapProtoDecodeStructType<typeof Elem> | null][] {
const converters = [PacketMsgTextElement.parseElement,
PacketMsgAtElement.parseElement, PacketMsgReplyElement.parseElement,
PacketMsgVideoElement.parseElement, PacketMsgPicElement.parseElement];
return msg.map((element) => {
for (const converter of converters) {
const result = converter(element);
if (result) return result;
}
return null;
}).filter((e) => e !== null);
}
}
@@ -0,0 +1,742 @@
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf';
import {
CustomFace,
Elem,
FileExtra,
GroupFileExtra,
MarkdownData,
MentionExtra,
MsgInfo,
NotOnlineImage,
OidbSvcTrpcTcp0XE37_800Response,
PushMsgBody,
QBigFaceExtra,
QSmallFaceExtra,
} from '@/snowcat-core/packet/transformer/proto';
import {
ElementType,
FaceType,
MessageElement,
NTMsgAtType,
NTVideoType,
PicType,
Peer,
} from '@/snowcat-core/types/msg';
import {
SendArkElement,
SendFaceElement,
SendFileElement,
SendMarkdownElement,
SendMarketFaceElement,
SendPicElement,
SendPttElement,
SendReplyElement,
SendMultiForwardMsgElement,
SendTextElement,
SendVideoElement,
} from '@/snowcat-core/types/element';
import { ForwardMsgBuilder } from '@/snowcat-core/helper/forward-msg-builder';
import { PacketMsg, PacketSendMsgElement } from '@/snowcat-core/packet/message/message';
export type ParseElementFnR = [MessageElement, NapProtoDecodeStructType<typeof Elem> | null] | undefined;
type ParseElementFn = (elem: NapProtoDecodeStructType<typeof Elem>) => ParseElementFnR;
// raw <-> packet
// TODO: SendStructLongMsgElement
export abstract class IPacketMsgElement<T extends PacketSendMsgElement> {
protected constructor (_rawElement: T) {
}
get valid (): boolean {
return true;
}
buildContent (): Uint8Array | undefined {
return undefined;
}
buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [];
}
static parseElement: ParseElementFn;
toPreview (): string {
return '[暂不支持该消息类型喵~]';
}
}
export class PacketMsgTextElement extends IPacketMsgElement<SendTextElement> {
text: string;
constructor (element: SendTextElement) {
super(element);
this.text = element.textElement.content;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
text: {
str: this.text,
},
}];
}
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
if (elem.text?.str && (elem.text?.attr6Buf === undefined || elem.text?.attr6Buf?.length === 0)) {
return [{
textElement: {
content: elem.text?.str,
atType: NTMsgAtType.ATTYPEUNKNOWN,
atUid: '',
atTinyId: '',
atNtUid: '',
},
elementType: ElementType.UNKNOWN,
elementId: '',
}, null];
}
return undefined;
};
override toPreview (): string {
return this.text;
}
}
export class PacketMsgAtElement extends PacketMsgTextElement {
targetUid: string;
atAll: boolean;
constructor (element: SendTextElement) {
super(element);
this.targetUid = element.textElement.atNtUid;
this.atAll = element.textElement.atType === NTMsgAtType.ATTYPEALL;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
text: {
str: this.text,
pbReserve: new NapProtoMsg(MentionExtra).encode({
type: this.atAll ? 1 : 2,
uin: 0,
field5: 0,
uid: this.targetUid,
}
),
},
}];
}
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
if (elem.text?.str && (elem.text?.attr6Buf?.length ?? 100) >= 11) {
return [{
textElement: {
content: elem.text?.str,
atType: NTMsgAtType.ATTYPEONE,
atUid: String(Buffer.from(elem.text!.attr6Buf!).readUInt32BE(7)), // FIXME: hack
atTinyId: '',
atNtUid: '',
},
elementType: ElementType.UNKNOWN,
elementId: '',
}, null];
}
return undefined;
};
}
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
time: number;
targetMessageId: bigint;
targetMessageSeq: number;
targetMessageClientSeq: number;
targetUin: number;
targetUid: string;
targetElems?: NapProtoEncodeStructType<typeof Elem>[];
targetSourceMsg?: NapProtoEncodeStructType<typeof PushMsgBody>;
targetPeer?: Peer;
constructor (element: SendReplyElement) {
super(element);
this.time = +(element.replyElement.replyMsgTime ?? Math.floor(Date.now() / 1000));
this.targetMessageId = BigInt(element.replyElement.replayMsgId ?? 0);
this.targetMessageSeq = +(element.replyElement.replayMsgSeq ?? 0);
this.targetMessageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
this.targetUin = +(element.replyElement.senderUin ?? 0);
this.targetUid = element.replyElement.senderUidStr ?? '';
this.targetPeer = element.replyElement._replyMsgPeer;
}
get isGroupReply (): boolean {
return this.targetMessageClientSeq === 0;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
srcMsg: {
origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq],
senderUin: BigInt(this.targetUin),
time: this.time,
elems: this.targetElems ?? [],
sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}),
toUin: BigInt(0),
},
}];
}
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
if (elem.srcMsg && elem.srcMsg.pbReserve) {
const reserve = elem.srcMsg.pbReserve;
return [{
replyElement: {
replayMsgSeq: String(reserve.friendSeq ?? elem.srcMsg?.origSeqs?.[0] ?? 0),
replayMsgId: String(reserve.messageId ?? 0),
senderUin: String(elem?.srcMsg ?? 0),
},
elementType: ElementType.UNKNOWN,
elementId: '',
}, null];
}
return undefined;
};
override toPreview (): string {
return '[回复消息]';
}
}
export class PacketMsgFaceElement extends IPacketMsgElement<SendFaceElement> {
faceId: number;
isLargeFace: boolean;
resultId?: string;
constructor (element: SendFaceElement) {
super(element);
this.faceId = element.faceElement.faceIndex;
this.resultId = element.faceElement.resultId;
this.isLargeFace = element.faceElement.faceType === FaceType.AniSticke;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
if (this.isLargeFace) {
return [{
commonElem: {
serviceType: 37,
pbElem: new NapProtoMsg(QBigFaceExtra).encode({
aniStickerPackId: '1',
aniStickerId: '8',
faceId: this.faceId,
sourceType: 1,
resultId: this.resultId,
preview: '',
randomType: 1,
}),
businessType: 1,
},
}];
} else if (this.faceId < 260) {
return [{
face: {
index: this.faceId,
},
}];
} else {
return [{
commonElem: {
serviceType: 33,
pbElem: new NapProtoMsg(QSmallFaceExtra).encode({
faceId: this.faceId,
preview: '',
preview2: '',
}),
businessType: 1,
},
}];
}
}
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
if (elem.face?.index) {
return [{
faceElement: {
faceIndex: elem.face.index,
faceType: FaceType.Normal,
},
elementType: ElementType.UNKNOWN,
elementId: '',
}, null];
}
if (elem?.commonElem?.serviceType === 37 && elem?.commonElem?.pbElem) {
const qface = new NapProtoMsg(QBigFaceExtra).decode(elem?.commonElem?.pbElem);
if (qface?.faceId) {
return [{
faceElement: {
faceIndex: qface.faceId,
faceType: FaceType.Normal,
},
elementType: ElementType.UNKNOWN,
elementId: '',
}, null];
}
}
if (elem?.commonElem?.serviceType === 33 && elem?.commonElem?.pbElem) {
const qface = new NapProtoMsg(QSmallFaceExtra).decode(elem?.commonElem?.pbElem);
if (qface?.faceId) {
return [{
faceElement: {
faceIndex: qface.faceId,
faceType: FaceType.Normal,
},
elementType: ElementType.UNKNOWN,
elementId: '',
}, null];
}
}
return undefined;
};
override toPreview (): string {
return '[表情]';
}
}
export class PacketMsgMarkFaceElement extends IPacketMsgElement<SendMarketFaceElement> {
emojiName: string;
emojiId: string;
emojiPackageId: number;
emojiKey: string;
constructor (element: SendMarketFaceElement) {
super(element);
this.emojiName = element.marketFaceElement.faceName;
this.emojiId = element.marketFaceElement.emojiId;
this.emojiPackageId = element.marketFaceElement.emojiPackageId;
this.emojiKey = element.marketFaceElement.key;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
marketFace: {
faceName: this.emojiName,
itemType: 6,
faceInfo: 1,
faceId: Buffer.from(this.emojiId, 'hex'),
tabId: this.emojiPackageId,
subType: 3,
key: this.emojiKey,
imageWidth: 300,
imageHeight: 300,
pbReserve: {
field8: 1,
},
},
}];
}
override toPreview (): string {
return `${this.emojiName}`;
}
}
export class PacketMsgPicElement extends IPacketMsgElement<SendPicElement> {
path: string;
name: string;
size: number;
md5: string;
width: number;
height: number;
picType: PicType;
picSubType: number;
summary: string;
sha1: string | null = null;
msgInfo: NapProtoEncodeStructType<typeof MsgInfo> | null = null;
groupPicExt: NapProtoEncodeStructType<typeof CustomFace> | null = null;
c2cPicExt: NapProtoEncodeStructType<typeof NotOnlineImage> | null = null;
constructor (element: SendPicElement) {
super(element);
this.path = element.picElement.sourcePath;
this.name = element.picElement.fileName;
this.size = +element.picElement.fileSize;
this.md5 = element.picElement.md5HexStr ?? '';
this.width = element.picElement.picWidth;
this.height = element.picElement.picHeight;
this.picType = element.picElement.picType;
this.picSubType = element.picElement.picSubType ?? 0;
this.summary = element.picElement.summary === ''
? (
element.picElement.picSubType === 0 ? '[图片]' : '[动画表情]'
)
: element.picElement.summary;
}
override get valid (): boolean {
return !!this.msgInfo;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
if (!this.msgInfo) return [];
return [{
commonElem: {
serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: 10,
},
}];
}
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
if (elem?.commonElem?.serviceType === 48 &&
[10, 20].includes(elem.commonElem.businessType ?? 0) &&
elem.commonElem.pbElem) {
const extra = new NapProtoMsg(MsgInfo).decode(elem.commonElem.pbElem);
const msgInfoBody = extra.msgInfoBody[0];
const index = msgInfoBody?.index;
return [{
picElement: {
fileSize: index?.info.fileSize ?? 0,
picWidth: index?.info?.width ?? 0,
picHeight: index?.info?.height ?? 0,
fileName: index?.info?.fileHash ?? '',
sourcePath: '',
original: false,
picType: PicType.NEWPIC_APNG,
fileUuid: '',
fileSubId: '',
thumbFileSize: 0,
summary: '[图片]',
thumbPath: new Map(),
},
elementType: ElementType.PIC,
elementId: '',
}, elem];
}
if (elem?.notOnlineImage) {
const img = elem?.notOnlineImage; // url in originImageUrl
const preImg: MessageElement = {
picElement: {
fileSize: img.fileLen ?? 0,
picWidth: img.picWidth ?? 0,
picHeight: img.picHeight ?? 0,
fileName: Buffer.from(img.picMd5!).toString('hex') ?? '',
sourcePath: '',
original: false,
picType: PicType.NEWPIC_APNG,
fileUuid: '',
fileSubId: '',
thumbFileSize: 0,
summary: '[图片]',
thumbPath: new Map(),
},
elementType: ElementType.UNKNOWN,
elementId: '',
};
if (img.origUrl?.includes('&fileid=')) {
preImg.picElement!.originImageUrl = `https://multimedia.nt.qq.com.cn${img.origUrl}`;
} else {
preImg.picElement!.originImageUrl = `https://gchat.qpic.cn${img.origUrl}`;
}
return [preImg, elem];
}
return undefined;
};
override toPreview (): string {
return this.summary;
}
}
export class PacketMsgVideoElement extends IPacketMsgElement<SendVideoElement> {
fileName: string;
fileSize?: string;
filePath?: string;
fileTime: number;
fileFormat: NTVideoType;
fileWidth?: number;
fileHeight?: number;
thumbSize?: number;
thumbPath?: string;
fileMd5?: string;
fileSha1?: string;
thumbMd5?: string;
thumbSha1?: string;
thumbWidth?: number;
thumbHeight?: number;
businessType: 11 | 21 = 21;
msgInfo: NapProtoEncodeStructType<typeof MsgInfo> | null = null;
constructor (element: SendVideoElement) {
super(element);
this.fileName = element.videoElement.fileName;
this.fileSize = element.videoElement.fileSize;
this.filePath = element.videoElement.filePath;
this.fileTime = Math.max(0, Math.round(element.videoElement.fileTime ?? 0));
this.fileFormat = element.videoElement.fileFormat ?? NTVideoType.VIDEO_FORMAT_MP4;
this.fileWidth = element.videoElement.fileWidth;
this.fileHeight = element.videoElement.fileHeight;
this.thumbSize = element.videoElement.thumbSize;
this.thumbPath = element.videoElement.thumbPath?.get(0) as string | undefined;
this.fileMd5 = element.videoElement.videoMd5;
this.thumbMd5 = element.videoElement.thumbMd5;
this.thumbWidth = element.videoElement.thumbWidth;
this.thumbHeight = element.videoElement.thumbHeight;
}
override get valid (): boolean {
return !!this.msgInfo;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
if (!this.msgInfo) return [];
return [{
commonElem: {
serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: this.businessType,
},
}];
}
static override parseElement = (elem: NapProtoDecodeStructType<typeof Elem>): ParseElementFnR => {
if (elem?.commonElem?.serviceType !== 48 ||
![11, 21].includes(elem.commonElem.businessType ?? 0) ||
!elem.commonElem.pbElem) {
return undefined;
}
const extra = new NapProtoMsg(MsgInfo).decode(elem.commonElem.pbElem);
const videoIndex = extra.msgInfoBody[0]?.index;
const thumbIndex = extra.msgInfoBody[1]?.index;
if (!videoIndex) return undefined;
return [{
videoElement: {
filePath: '',
fileName: videoIndex.info.fileName || videoIndex.info.fileHash,
videoMd5: videoIndex.info.fileHash,
thumbMd5: thumbIndex?.info.fileHash,
fileTime: videoIndex.info.time,
fileFormat: (videoIndex.info.type.videoFormat || NTVideoType.VIDEO_FORMAT_MP4) as NTVideoType,
fileSize: String(videoIndex.info.fileSize),
fileWidth: videoIndex.info.width,
fileHeight: videoIndex.info.height,
thumbWidth: thumbIndex?.info.width,
thumbHeight: thumbIndex?.info.height,
thumbSize: thumbIndex?.info.fileSize,
thumbPath: new Map(),
fileUuid: videoIndex.fileUuid,
busiType: 0,
subBusiType: 0,
},
elementType: ElementType.VIDEO,
elementId: '',
}, elem];
};
override toPreview (): string {
return '[视频]';
}
}
export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
filePath: string;
fileSize: number;
fileMd5: string;
fileSha1?: string;
fileDuration: number;
msgInfo: NapProtoEncodeStructType<typeof MsgInfo> | null = null;
constructor (element: SendPttElement) {
super(element);
this.filePath = element.pttElement.filePath;
this.fileSize = +element.pttElement.fileSize; // TODO: cc
this.fileMd5 = element.pttElement.md5HexStr;
this.fileDuration = Math.round(element.pttElement.duration); // TODO: cc
}
override get valid (): boolean {
return false;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
// return [];
if (!this.msgInfo) return [];
return [{
commonElem: {
serviceType: 48,
pbElem: new NapProtoMsg(MsgInfo).encode(this.msgInfo),
businessType: 22,
},
}];
}
override toPreview (): string {
return '[语音]';
}
}
export class PacketMsgFileElement extends IPacketMsgElement<SendFileElement> {
fileName: string;
filePath: string;
fileSize: number;
fileSha1?: Uint8Array;
fileMd5?: Uint8Array;
fileUuid?: string;
fileHash?: string;
isGroupFile?: boolean;
_private_send_uid?: string;
_private_recv_uid?: string;
_e37_800_rsp?: NapProtoEncodeStructType<typeof OidbSvcTrpcTcp0XE37_800Response>;
constructor (element: SendFileElement) {
super(element);
this.fileName = element.fileElement.fileName;
this.filePath = element.fileElement.filePath;
this.fileSize = +element.fileElement.fileSize;
}
override get valid (): boolean {
return this.isGroupFile || Boolean(this._e37_800_rsp);
}
override buildContent (): Uint8Array | undefined {
if (this.isGroupFile || !this._e37_800_rsp) return undefined;
return new NapProtoMsg(FileExtra).encode({
file: {
fileType: 0,
fileUuid: this.fileUuid,
fileMd5: this.fileMd5,
fileName: this.fileName,
fileSize: BigInt(this.fileSize),
subcmd: 1,
dangerEvel: 0,
expireTime: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
fileHash: this.fileHash,
},
field6: {
field2: {
field1: this._e37_800_rsp?.body?.field30?.field110,
fileUuid: this.fileUuid,
fileName: this.fileName,
field6: this._e37_800_rsp?.body?.field30?.field3,
field7: this._e37_800_rsp?.body?.field30?.field101,
field8: this._e37_800_rsp?.body?.field30?.field100,
timestamp1: this._e37_800_rsp?.body?.field30?.timestamp1,
fileHash: this.fileHash,
selfUid: this._private_send_uid,
destUid: this._private_recv_uid,
},
},
});
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
if (!this.isGroupFile) return [];
const lb = Buffer.alloc(2);
const transElemVal = new NapProtoMsg(GroupFileExtra).encode({
field1: 6,
fileName: this.fileName,
inner: {
info: {
busId: 102,
fileId: this.fileUuid,
fileSize: BigInt(this.fileSize),
fileName: this.fileName,
fileSha: this.fileSha1,
extInfoString: '',
fileMd5: this.fileMd5,
},
},
});
lb.writeUInt16BE(transElemVal.length);
return [{
transElem: {
elemType: 24,
elemValue: Buffer.concat([Buffer.from([0x01]), lb, transElemVal]), // TLV
},
}];
}
override toPreview (): string {
return `[文件]${this.fileName}`;
}
}
export class PacketMsgLightAppElement extends IPacketMsgElement<SendArkElement> {
payload: string;
constructor (element: SendArkElement) {
super(element);
this.payload = element.arkElement.bytesData;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
lightAppElem: {
data: Buffer.concat([
Buffer.from([0x01]),
zlib.deflateSync(Buffer.from(this.payload, 'utf-8')),
]),
},
}];
}
override toPreview (): string {
return '[卡片消息]';
}
}
export class PacketMsgMarkDownElement extends IPacketMsgElement<SendMarkdownElement> {
content: string;
constructor (element: SendMarkdownElement) {
super(element);
this.content = element.markdownElement.content;
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
commonElem: {
serviceType: 45,
pbElem: new NapProtoMsg(MarkdownData).encode({
content: this.content,
}),
businessType: 1,
},
}];
}
override toPreview (): string {
return `[Markdown消息 ${this.content}]`;
}
}
export class PacketMultiMsgElement extends IPacketMsgElement<SendMultiForwardMsgElement> {
resid: string;
message: PacketMsg[];
uuid: string;
constructor (rawElement: SendMultiForwardMsgElement, message?: PacketMsg[]) {
super(rawElement);
this.resid = rawElement.multiForwardMsgElement.resId;
this.message = message ?? [];
this.uuid = rawElement.multiForwardMsgElement.fileName || crypto.randomUUID();
}
override buildElement (): NapProtoEncodeStructType<typeof Elem>[] {
return [{
lightAppElem: {
data: Buffer.concat([
Buffer.from([0x01]),
zlib.deflateSync(Buffer.from(JSON.stringify(ForwardMsgBuilder.fromPacketMsg(this.resid, this.message, undefined, undefined, undefined, undefined, this.uuid)), 'utf-8')),
]),
},
}];
}
override toPreview (): string {
return '[聊天记录]';
}
}
@@ -0,0 +1,15 @@
import { IPacketMsgElement } from '@/snowcat-core/packet/message/element';
import { SendMessageElement, SendMultiForwardMsgElement } from '@/snowcat-core/index';
export type PacketSendMsgElement = SendMessageElement | SendMultiForwardMsgElement;
export interface PacketMsg {
seq?: number;
clientSeq?: number;
groupId?: number;
senderUid: string;
senderUin: number;
senderName: string;
time: number;
msg: IPacketMsgElement<PacketSendMsgElement>[]
}
@@ -0,0 +1,20 @@
import * as proto from '@/snowcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'snowcat-protobuf';
import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base';
import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase';
class CancelGroupTodo extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
build (peer: number, msgSeq: string): OidbPacket {
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90).encode({
groupUin: peer,
msgSeq: BigInt(msgSeq),
});
return OidbBase.build(0xF90, 3, data);
}
parse (data: Buffer) {
return OidbBase.parse(data);
}
}
export default new CancelGroupTodo();
@@ -0,0 +1,20 @@
import * as proto from '@/snowcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'snowcat-protobuf';
import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base';
import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase';
class CompleteGroupTodo extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
build (peer: number, msgSeq: string): OidbPacket {
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90).encode({
groupUin: peer,
msgSeq: BigInt(msgSeq),
});
return OidbBase.build(0xF90, 2, data);
}
parse (data: Buffer) {
return OidbBase.parse(data);
}
}
export default new CompleteGroupTodo();
@@ -0,0 +1,22 @@
import * as proto from '@/snowcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'snowcat-protobuf';
import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base';
import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase';
import { AIVoiceChatType } from '@/snowcat-core/packet/entities/aiChat';
class FetchAiVoiceList extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0X929D_0Resp> {
build (groupUin: number, chatType: AIVoiceChatType): OidbPacket {
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0X929D_0).encode({
groupUin,
chatType,
});
return OidbBase.build(0x929D, 0, data);
}
parse (data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.OidbSvcTrpcTcp0X929D_0Resp).decode(oidbBody);
}
}
export default new FetchAiVoiceList();
@@ -0,0 +1,27 @@
import * as proto from '@/snowcat-core/packet/transformer/proto';
import { NapProtoMsg } from 'snowcat-protobuf';
import { OidbPacket, PacketTransformer } from '@/snowcat-core/packet/transformer/base';
import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase';
import { AIVoiceChatType } from '@/snowcat-core/packet/entities/aiChat';
class GetAiVoice extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0X929B_0Resp> {
build (groupUin: number, voiceId: string, text: string, sessionId: number, chatType: AIVoiceChatType): OidbPacket {
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0X929B_0).encode({
groupUin,
voiceId,
text,
chatType,
session: {
sessionId,
},
});
return OidbBase.build(0x929B, 0, data);
}
parse (data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.OidbSvcTrpcTcp0X929B_0Resp).decode(oidbBody);
}
}
export default new GetAiVoice();
Loaded 100 of 1048 files, more files were not shown because too many files have changed in this diff. Show more