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:
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[]) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './NodeIDependsAdapter';
|
||||
export * from './NodeIDispatcherAdapter';
|
||||
export * from './NodeIGlobalAdapter';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter, GroupManagementSettings } from '../types';
|
||||
|
||||
const MEMBER_INVITE_PRIVILEGE_MASK = 0x06100000;
|
||||
const MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK = 0x1;
|
||||
const MEMBER_CREATE_GROUP_FLAG = 0x8000;
|
||||
const MEMBER_TEMPORARY_SESSION_FLAG = 0x10000;
|
||||
const NEW_MEMBER_RECENT_HISTORY_FLAG = 0x4;
|
||||
|
||||
function mergeMaskedFlag (currentFlag: number, requestedFlag: number, maskValue: number): number {
|
||||
const mask = BigInt(maskValue);
|
||||
return Number((BigInt(currentFlag) & ~mask) | (BigInt(requestedFlag) & mask));
|
||||
}
|
||||
|
||||
export function applyGroupManagementSettings (
|
||||
param: GroupDetailInfoV2Param,
|
||||
settings: GroupManagementSettings
|
||||
): boolean {
|
||||
let changed = false;
|
||||
|
||||
if (settings.memberInvite !== undefined) {
|
||||
const privilegeByPolicy = {
|
||||
disabled: 0x04000000,
|
||||
require_approval: 0,
|
||||
no_approval: 0x00100000,
|
||||
no_approval_under_100: 0x02000000,
|
||||
} satisfies Record<NonNullable<GroupManagementSettings['memberInvite']>, number>;
|
||||
|
||||
param.filter.allowMemberInvite = 1;
|
||||
param.modifyInfo.allowMemberInvite = settings.memberInvite === 'disabled' ? 0 : 1;
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_INVITE_PRIVILEGE_MASK;
|
||||
param.modifyInfo.appPrivilegeFlag |= privilegeByPolicy[settings.memberInvite];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberUploadAlbum !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
|
||||
if (!settings.allowMemberUploadAlbum) param.modifyInfo.appPrivilegeFlag |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberTemporarySession !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_TEMPORARY_SESSION_FLAG;
|
||||
if (!settings.allowMemberTemporarySession) param.modifyInfo.appPrivilegeFlag |= MEMBER_TEMPORARY_SESSION_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.allowMemberCreateGroup !== undefined) {
|
||||
param.filter.appPrivilegeFlag = 1;
|
||||
param.filter.appPrivilegeMask = 1;
|
||||
param.modifyInfo.appPrivilegeMask |= MEMBER_CREATE_GROUP_FLAG;
|
||||
if (!settings.allowMemberCreateGroup) param.modifyInfo.appPrivilegeFlag |= MEMBER_CREATE_GROUP_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (settings.newMembersSeeRecentHistory !== undefined) {
|
||||
param.filter.groupFlagExt4 = 1;
|
||||
param.filter.groupFlagExt4Mask = 1;
|
||||
param.modifyInfo.groupFlagExt4Mask |= NEW_MEMBER_RECENT_HISTORY_FLAG;
|
||||
if (settings.newMembersSeeRecentHistory) param.modifyInfo.groupFlagExt4 |= NEW_MEMBER_RECENT_HISTORY_FLAG;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function createGroupManagementRequests (
|
||||
groupCode: string,
|
||||
settings: GroupManagementSettings,
|
||||
initialAppPrivilegeFlag = 0,
|
||||
initialGroupFlagExt4 = 0
|
||||
) {
|
||||
const requests: Array<{ setting: string, param: GroupDetailInfoV2Param, operationType: number; }> = [];
|
||||
const requestSettings: Array<{
|
||||
setting: string,
|
||||
value: GroupManagementSettings,
|
||||
operationType: number;
|
||||
}> = [
|
||||
{ setting: 'member_invite', value: { memberInvite: settings.memberInvite }, operationType: 0 },
|
||||
{
|
||||
setting: 'new_members_see_recent_history',
|
||||
value: { newMembersSeeRecentHistory: settings.newMembersSeeRecentHistory },
|
||||
operationType: 0,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_upload_album',
|
||||
value: { allowMemberUploadAlbum: settings.allowMemberUploadAlbum },
|
||||
operationType: 8,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_temporary_session',
|
||||
value: { allowMemberTemporarySession: settings.allowMemberTemporarySession },
|
||||
operationType: 8,
|
||||
},
|
||||
{
|
||||
setting: 'allow_member_create_group',
|
||||
value: { allowMemberCreateGroup: settings.allowMemberCreateGroup },
|
||||
operationType: 8,
|
||||
},
|
||||
];
|
||||
|
||||
let currentAppPrivilegeFlag = initialAppPrivilegeFlag;
|
||||
let currentGroupFlagExt4 = initialGroupFlagExt4;
|
||||
for (const requestSetting of requestSettings) {
|
||||
const param = createGroupDetailInfoV2Param(groupCode);
|
||||
if (applyGroupManagementSettings(param, requestSetting.value)) {
|
||||
if (param.filter.appPrivilegeFlag) {
|
||||
const nextAppPrivilegeFlag = mergeMaskedFlag(
|
||||
currentAppPrivilegeFlag,
|
||||
param.modifyInfo.appPrivilegeFlag,
|
||||
param.modifyInfo.appPrivilegeMask
|
||||
);
|
||||
if (requestSetting.operationType === 8 && nextAppPrivilegeFlag === currentAppPrivilegeFlag) continue;
|
||||
currentAppPrivilegeFlag = nextAppPrivilegeFlag;
|
||||
// Linux NTQQ's wrapper builds this field from the current complete privilege value.
|
||||
// Supplying only the targeted bit makes zero-valued clears get acknowledged but ignored.
|
||||
param.modifyInfo.appPrivilegeFlag = currentAppPrivilegeFlag;
|
||||
}
|
||||
if (param.filter.groupFlagExt4) {
|
||||
const nextGroupFlagExt4 = mergeMaskedFlag(
|
||||
currentGroupFlagExt4,
|
||||
param.modifyInfo.groupFlagExt4,
|
||||
param.modifyInfo.groupFlagExt4Mask
|
||||
);
|
||||
if (nextGroupFlagExt4 === currentGroupFlagExt4) continue;
|
||||
currentGroupFlagExt4 = nextGroupFlagExt4;
|
||||
param.modifyInfo.groupFlagExt4 = currentGroupFlagExt4;
|
||||
}
|
||||
requests.push({ setting: requestSetting.setting, param, operationType: requestSetting.operationType });
|
||||
}
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
export function createGroupDetailInfoV2Param (group_code: string): GroupDetailInfoV2Param {
|
||||
return {
|
||||
groupCode: group_code,
|
||||
filter:
|
||||
{
|
||||
noCodeFingerOpenFlag: 0,
|
||||
noFingerOpenFlag: 0,
|
||||
groupName: 0,
|
||||
classExt: 0,
|
||||
classText: 0,
|
||||
fingerMemo: 0,
|
||||
richFingerMemo: 0,
|
||||
tagRecord: 0,
|
||||
groupGeoInfo:
|
||||
{
|
||||
ownerUid: 0,
|
||||
setTime: 0,
|
||||
cityId: 0,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
geoContent: 0,
|
||||
poiId: 0,
|
||||
},
|
||||
groupExtAdminNum: 0,
|
||||
flag: 0,
|
||||
groupMemo: 0,
|
||||
groupAioSkinUrl: 0,
|
||||
groupBoardSkinUrl: 0,
|
||||
groupCoverSkinUrl: 0,
|
||||
groupGrade: 0,
|
||||
activeMemberNum: 0,
|
||||
certificationType: 0,
|
||||
certificationText: 0,
|
||||
groupNewGuideLines:
|
||||
{
|
||||
enabled: 0,
|
||||
content: 0,
|
||||
},
|
||||
groupFace: 0,
|
||||
addOption: 0,
|
||||
shutUpTime: 0,
|
||||
groupTypeFlag: 0,
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0,
|
||||
groupExtOnly:
|
||||
{
|
||||
tribeId: 0,
|
||||
moneyForAddGroup: 0,
|
||||
},
|
||||
groupSecLevel: 0,
|
||||
groupSecLevelInfo: 0,
|
||||
subscriptionUin: 0,
|
||||
subscriptionUid: '',
|
||||
allowMemberInvite: 0,
|
||||
groupQuestion: 0,
|
||||
groupAnswer: 0,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupOpenAppid: 0,
|
||||
rootId: 0,
|
||||
msgLimitFrequency: 0,
|
||||
hlGuildAppid: 0,
|
||||
hlGuildSubType: 0,
|
||||
hlGuildOrgId: 0,
|
||||
groupFlagExt4: 0,
|
||||
groupFlagExt4Mask: 0,
|
||||
groupSchoolInfo: {
|
||||
location: 0,
|
||||
grade: 0,
|
||||
school: 0,
|
||||
},
|
||||
groupCardPrefix:
|
||||
{
|
||||
introduction: 0,
|
||||
rptPrefix: 0,
|
||||
},
|
||||
allianceId: 0,
|
||||
groupFlagPro1: 0,
|
||||
groupFlagPro1Mask: 0,
|
||||
},
|
||||
modifyInfo: {
|
||||
noCodeFingerOpenFlag: 0,
|
||||
noFingerOpenFlag: 0,
|
||||
groupName: '',
|
||||
classExt: 0,
|
||||
classText: '',
|
||||
fingerMemo: '',
|
||||
richFingerMemo: '',
|
||||
tagRecord: [],
|
||||
groupGeoInfo: {
|
||||
ownerUid: '',
|
||||
SetTime: 0,
|
||||
CityId: 0,
|
||||
Longitude: '',
|
||||
Latitude: '',
|
||||
GeoContent: '',
|
||||
poiId: '',
|
||||
},
|
||||
groupExtAdminNum: 0,
|
||||
flag: 0,
|
||||
groupMemo: '',
|
||||
groupAioSkinUrl: '',
|
||||
groupBoardSkinUrl: '',
|
||||
groupCoverSkinUrl: '',
|
||||
groupGrade: 0,
|
||||
activeMemberNum: 0,
|
||||
certificationType: 0,
|
||||
certificationText: '',
|
||||
groupNewGuideLines: {
|
||||
enabled: false,
|
||||
content: '',
|
||||
},
|
||||
groupFace: 0,
|
||||
addOption: 0,
|
||||
shutUpTime: 0,
|
||||
groupTypeFlag: 0,
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0,
|
||||
groupExtOnly: {
|
||||
tribeId: 0,
|
||||
moneyForAddGroup: 0,
|
||||
},
|
||||
groupSecLevel: 0,
|
||||
groupSecLevelInfo: 0,
|
||||
subscriptionUin: '',
|
||||
subscriptionUid: '',
|
||||
allowMemberInvite: 0,
|
||||
groupQuestion: '',
|
||||
groupAnswer: '',
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupOpenAppid: 0,
|
||||
rootId: '',
|
||||
msgLimitFrequency: 0,
|
||||
hlGuildAppid: 0,
|
||||
hlGuildSubType: 0,
|
||||
hlGuildOrgId: 0,
|
||||
groupFlagExt4: 0,
|
||||
groupFlagExt4Mask: 0,
|
||||
groupSchoolInfo: {
|
||||
location: '',
|
||||
grade: 0,
|
||||
school: '',
|
||||
},
|
||||
groupCardPrefix:
|
||||
{
|
||||
introduction: '',
|
||||
rptPrefix: [],
|
||||
},
|
||||
allianceId: '',
|
||||
groupFlagPro1: 0,
|
||||
groupFlagPro1Mask: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createGroupExtInfo (group_code: string): GroupExtInfo {
|
||||
return {
|
||||
groupCode: group_code,
|
||||
resultCode: 0,
|
||||
extInfo: {
|
||||
groupInfoExtSeq: 0,
|
||||
reserve: 0,
|
||||
luckyWordId: '',
|
||||
lightCharNum: 0,
|
||||
luckyWord: '',
|
||||
starId: 0,
|
||||
essentialMsgSwitch: 0,
|
||||
todoSeq: 0,
|
||||
blacklistExpireTime: 0,
|
||||
isLimitGroupRtc: 0,
|
||||
companyId: 0,
|
||||
hasGroupCustomPortrait: 0,
|
||||
bindGuildId: '',
|
||||
groupOwnerId: {
|
||||
memberUin: '',
|
||||
memberUid: '',
|
||||
memberQid: '',
|
||||
},
|
||||
essentialMsgPrivilege: 0,
|
||||
msgEventSeq: '',
|
||||
inviteRobotSwitch: 0,
|
||||
gangUpId: '',
|
||||
qqMusicMedalSwitch: 0,
|
||||
showPlayTogetherSwitch: 0,
|
||||
groupFlagPro1: '',
|
||||
groupBindGuildIds: {
|
||||
guildIds: [],
|
||||
},
|
||||
viewedMsgDisappearTime: '',
|
||||
groupExtFlameData: {
|
||||
switchState: 0,
|
||||
state: 0,
|
||||
dayNums: [],
|
||||
version: 0,
|
||||
updateTime: '',
|
||||
isDisplayDayNum: false,
|
||||
},
|
||||
groupBindGuildSwitch: 0,
|
||||
groupAioBindGuildId: '',
|
||||
groupExcludeGuildIds: {
|
||||
guildIds: [],
|
||||
},
|
||||
fullGroupExpansionSwitch: 0,
|
||||
fullGroupExpansionSeq: '',
|
||||
inviteRobotMemberSwitch: 0,
|
||||
inviteRobotMemberExamine: 0,
|
||||
groupSquareSwitch: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createGroupExtFilter (): GroupExtFilter {
|
||||
return {
|
||||
groupInfoExtSeq: 0,
|
||||
reserve: 0,
|
||||
luckyWordId: 0,
|
||||
lightCharNum: 0,
|
||||
luckyWord: 0,
|
||||
starId: 0,
|
||||
essentialMsgSwitch: 0,
|
||||
todoSeq: 0,
|
||||
blacklistExpireTime: 0,
|
||||
isLimitGroupRtc: 0,
|
||||
companyId: 0,
|
||||
hasGroupCustomPortrait: 0,
|
||||
bindGuildId: 0,
|
||||
groupOwnerId: 0,
|
||||
essentialMsgPrivilege: 0,
|
||||
msgEventSeq: 0,
|
||||
inviteRobotSwitch: 0,
|
||||
gangUpId: 0,
|
||||
qqMusicMedalSwitch: 0,
|
||||
showPlayTogetherSwitch: 0,
|
||||
groupFlagPro1: 0,
|
||||
groupBindGuildIds: 0,
|
||||
viewedMsgDisappearTime: 0,
|
||||
groupExtFlameData: 0,
|
||||
groupBindGuildSwitch: 0,
|
||||
groupAioBindGuildId: 0,
|
||||
groupExcludeGuildIds: 0,
|
||||
fullGroupExpansionSwitch: 0,
|
||||
fullGroupExpansionSeq: 0,
|
||||
inviteRobotMemberSwitch: 0,
|
||||
inviteRobotMemberExamine: 0,
|
||||
groupSquareSwitch: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './group';
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
+4017
File diff suppressed because it is too large.
Load diff
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"fileLog": false,
|
||||
"consoleLog": true,
|
||||
"fileLogLevel": "debug",
|
||||
"consoleLogLevel": "info",
|
||||
"packetBackend": "auto",
|
||||
"packetServer": "",
|
||||
"o3HookMode": 1,
|
||||
"bypass": {
|
||||
"hook": false,
|
||||
"window": false,
|
||||
"module": false,
|
||||
"process": false,
|
||||
"container": false,
|
||||
"js": false
|
||||
}
|
||||
}
|
||||
+314
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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})`
|
||||
}]`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
NodeIKernelBuddyListener,
|
||||
NodeIKernelFileAssistantListener,
|
||||
NodeIKernelGroupListener,
|
||||
NodeIKernelLoginListener,
|
||||
NodeIKernelMsgListener,
|
||||
NodeIKernelProfileListener,
|
||||
NodeIKernelRobotListener,
|
||||
NodeIKernelSessionListener,
|
||||
NodeIKernelStorageCleanListener,
|
||||
NodeIKernelTicketListener,
|
||||
} from '.';
|
||||
import { NodeIKernelSearchListener } from './NodeIKernelSearchListener';
|
||||
|
||||
export * from './NodeIKernelSessionListener';
|
||||
export * from './NodeIKernelLoginListener';
|
||||
export * from './NodeIKernelMsgListener';
|
||||
export * from './NodeIKernelGroupListener';
|
||||
export * from './NodeIKernelBuddyListener';
|
||||
export * from './NodeIKernelProfileListener';
|
||||
export * from './NodeIKernelRobotListener';
|
||||
export * from './NodeIKernelProfileListener';
|
||||
export * from './NodeIKernelTicketListener';
|
||||
export * from './NodeIKernelStorageCleanListener';
|
||||
export * from './NodeIKernelFileAssistantListener';
|
||||
export * from './NodeIKernelSearchListener';
|
||||
|
||||
export type ListenerNamingMapping = {
|
||||
NodeIKernelSessionListener: NodeIKernelSessionListener;
|
||||
NodeIKernelLoginListener: NodeIKernelLoginListener;
|
||||
NodeIKernelMsgListener: NodeIKernelMsgListener;
|
||||
NodeIKernelGroupListener: NodeIKernelGroupListener;
|
||||
NodeIKernelBuddyListener: NodeIKernelBuddyListener;
|
||||
NodeIKernelProfileListener: NodeIKernelProfileListener;
|
||||
NodeIKernelRobotListener: NodeIKernelRobotListener;
|
||||
NodeIKernelTicketListener: NodeIKernelTicketListener;
|
||||
NodeIKernelStorageCleanListener: NodeIKernelStorageCleanListener;
|
||||
NodeIKernelFileAssistantListener: NodeIKernelFileAssistantListener;
|
||||
NodeIKernelSearchListener: NodeIKernelSearchListener;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "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
Reference in new issue
Block a user