diff --git a/src/common/utils/QQBasicInfo.ts b/src/common/utils/QQBasicInfo.ts new file mode 100644 index 00000000..e69c3827 --- /dev/null +++ b/src/common/utils/QQBasicInfo.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { systemPlatform } from '@/common/utils/system'; +import { getDefaultQQVersionConfigInfo, getQQVersionConfigPath } from './helper'; +import AppidTable from '@/core/external/appid.json'; +import { log } from './log'; + +//基础目录获取 +export const QQMainPath = process.execPath; +export const QQPackageInfoPath: string = path.join(path.dirname(QQMainPath), 'resources', 'app', 'package.json'); +export const QQVersionConfigPath: string | undefined = getQQVersionConfigPath(QQMainPath); + +//基础信息获取 无快更则启用默认模板填充 +export const isQuickUpdate: boolean = !!QQVersionConfigPath; +export const QQVersionConfig: QQVersionConfigType = isQuickUpdate ? JSON.parse(fs.readFileSync(QQVersionConfigPath!).toString()) : getDefaultQQVersionConfigInfo(); +export const QQPackageInfo: QQPackageInfoType = JSON.parse(fs.readFileSync(QQPackageInfoPath).toString()); +export const { appid: QQVersionAppid, qua: QQVersionQua } = getAppidV2(); + +//基础函数 +export function getQQBuildStr() { + return isQuickUpdate ? QQVersionConfig.buildId : QQPackageInfo.buildVersion; +} +export function getFullQQVesion() { + return isQuickUpdate ? QQVersionConfig.curVersion : QQPackageInfo.version; +} +export function requireMinNTQQBuild(buildStr: string) { + return parseInt(getQQBuildStr()) >= parseInt(buildStr); +} +//此方法不要直接使用 +export function getQUAInternal() { + return systemPlatform === 'linux' ? `V1_LNX_NQ_${getFullQQVesion()}_${getQQBuildStr()}_GW_B` : `V1_WIN_NQ_${getFullQQVesion()}_${getQQBuildStr()}_GW_B`; +} +export function getAppidV2(): { appid: string, qua: string } { + const appidTbale = AppidTable as unknown as QQAppidTableType; + try { + const data = appidTbale[getFullQQVesion()]; + if (data) { + return data; + } + } + catch (e) { + log(`[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常`); + } + // 以下是兜底措施 + log(`[QQ版本兼容性检测] ${getFullQQVesion()} 版本兼容性不佳,可能会导致一些功能无法正常使用`); + return { appid: systemPlatform === 'linux' ? '537237950' : '537237765', qua: getQUAInternal() }; +} +// platform_type: 3, +// app_type: 4, +// app_version: '9.9.12-25765', +// qua: 'V1_WIN_NQ_9.9.12_25765_GW_B', +// appid: '537234702', +// platVer: '10.0.26100', +// clientVer: '9.9.9-25765', +// Linux +// app_version: '3.2.9-25765', +// qua: 'V1_LNX_NQ_3.2.10_25765_GW_B', diff --git a/src/common/utils/helper.ts b/src/common/utils/helper.ts new file mode 100644 index 00000000..f311d7a2 --- /dev/null +++ b/src/common/utils/helper.ts @@ -0,0 +1,405 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; +import fs from 'fs'; +import { log, logDebug } from './log'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as fsPromise from 'node:fs/promises'; +import os from 'node:os'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +//下面这个类是用于将uid+msgid合并的类 +export class UUIDConverter { + static encode(highStr: string, lowStr: string): string { + const high = BigInt(highStr); + const low = BigInt(lowStr); + const highHex = high.toString(16).padStart(16, '0'); + const lowHex = low.toString(16).padStart(16, '0'); + const combinedHex = highHex + lowHex; + const uuid = `${combinedHex.substring(0, 8)}-${combinedHex.substring(8, 12)}-${combinedHex.substring(12, 16)}-${combinedHex.substring(16, 20)}-${combinedHex.substring(20)}`; + return uuid; + } + static decode(uuid: string): { high: string, low: string } { + const hex = uuid.replace(/-/g, ''); + const high = BigInt('0x' + hex.substring(0, 16)); + const low = BigInt('0x' + hex.substring(16)); + return { high: high.toString(), low: low.toString() }; + } +} + + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function PromiseTimer(promise: Promise, ms: number): Promise { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('PromiseTimer: Operation timed out')), ms) + ); + return Promise.race([promise, timeoutPromise]); +} +export async function runAllWithTimeout(tasks: Promise[], timeout: number): Promise { + const wrappedTasks = tasks.map(task => + PromiseTimer(task, timeout).then( + result => ({ status: 'fulfilled', value: result }), + error => ({ status: 'rejected', reason: error }) + ) + ); + const results = await Promise.all(wrappedTasks); + return results + .filter(result => result.status === 'fulfilled') + .map(result => (result as { status: 'fulfilled'; value: T }).value); +} + +export function getMd5(s: string) { + + const h = crypto.createHash('md5'); + h.update(s); + return h.digest('hex'); +} + +export function isNull(value: any) { + return value === undefined || value === null; +} + +export function isNumeric(str: string) { + return /^\d+$/.test(str); +} + +export function truncateString(obj: any, maxLength = 500) { + if (obj !== null && typeof obj === 'object') { + Object.keys(obj).forEach(key => { + if (typeof obj[key] === 'string') { + // 如果是字符串且超过指定长度,则截断 + if (obj[key].length > maxLength) { + obj[key] = obj[key].substring(0, maxLength) + '...'; + } + } else if (typeof obj[key] === 'object') { + // 如果是对象或数组,则递归调用 + truncateString(obj[key], maxLength); + } + }); + } + return obj; +} +export function simpleDecorator(target: any, context: any) { +} + +// export function CacheClassFunc(ttl: number = 3600 * 1000, customKey: string = '') { +// const cache = new Map(); +// return function CacheClassFuncDecorator(originalMethod: Function, context: ClassMethodDecoratorContext) { +// async function CacheClassFuncDecoratorInternal(this: any, ...args: any[]) { +// const key = `${customKey}${String(context.name)}.(${args.map(arg => JSON.stringify(arg)).join(', ')})`; +// const cachedValue = cache.get(key); +// if (cachedValue && cachedValue.expiry > Date.now()) { +// return cachedValue.value; +// } +// const result = originalMethod.call(this, ...args); +// cache.set(key, { expiry: Date.now() + ttl, value: result }); +// return result; +// } +// return CacheClassFuncDecoratorInternal; +// } +// } +export function CacheClassFuncAsync(ttl: number = 3600 * 1000, customKey: string = '') { + //console.log('CacheClassFuncAsync', ttl, customKey); + function logExecutionTime(target: any, methodName: string, descriptor: PropertyDescriptor) { + //console.log('logExecutionTime', target, methodName, descriptor); + const cache = new Map(); + const originalMethod = descriptor.value; + descriptor.value = async function (...args: any[]) { + const key = `${customKey}${String(methodName)}.(${args.map(arg => JSON.stringify(arg)).join(', ')})`; + cache.forEach((value, key) => { + if (value.expiry < Date.now()) { + cache.delete(key); + } + }); + const cachedValue = cache.get(key); + if (cachedValue && cachedValue.expiry > Date.now()) { + return cachedValue.value; + } + // const start = Date.now(); + const result = await originalMethod.apply(this, args); + // const end = Date.now(); + // console.log(`Method ${methodName} executed in ${end - start} ms.`); + cache.set(key, { expiry: Date.now() + ttl, value: result }); + return result; + }; + } + return logExecutionTime; +} +export function CacheClassFuncAsyncExtend(ttl: number = 3600 * 1000, customKey: string = '', checker: any = (...data: any[]) => { return true; }) { + //console.log('CacheClassFuncAsync', ttl, customKey); + function logExecutionTime(target: any, methodName: string, descriptor: PropertyDescriptor) { + //console.log('logExecutionTime', target, methodName, descriptor); + const cache = new Map(); + const originalMethod = descriptor.value; + descriptor.value = async function (...args: any[]) { + const key = `${customKey}${String(methodName)}.(${args.map(arg => JSON.stringify(arg)).join(', ')})`; + cache.forEach((value, key) => { + if (value.expiry < Date.now()) { + cache.delete(key); + } + }); + const cachedValue = cache.get(key); + if (cachedValue && cachedValue.expiry > Date.now()) { + return cachedValue.value; + } + // const start = Date.now(); + const result = await originalMethod.apply(this, args); + if (!checker(...args, result)) { + return result;//丢弃缓存 + } + // const end = Date.now(); + // console.log(`Method ${methodName} executed in ${end - start} ms.`); + cache.set(key, { expiry: Date.now() + ttl, value: result }); + return result; + }; + } + return logExecutionTime; +} +// export function CacheClassFuncAsync(ttl: number = 3600 * 1000, customKey: string = ''): any { +// const cache = new Map(); + +// // 注意:在JavaScript装饰器中,我们通常不直接处理ClassMethodDecoratorContext这样的类型, +// // 因为装饰器的参数通常是目标类(对于类装饰器)、属性名(对于属性装饰器)等。 +// // 对于方法装饰器,我们关注的是方法本身及其描述符。 +// // 但这里我们维持原逻辑,假设有一个自定义的处理上下文的方式。 + +// return function (originalMethod: Function): any { +// console.log(originalMethod); +// // 由于JavaScript装饰器原生不支持异步直接定义,我们保持async定义以便处理异步方法。 +// async function decoratorWrapper(this: any, ...args: any[]): Promise { +// console.log(...args); +// const key = `${customKey}${originalMethod.name}.(${args.map(arg => JSON.stringify(arg)).join(', ')})`; +// const cachedValue = cache.get(key); +// // 遍历cache 清除expiry内容 +// cache.forEach((value, key) => { +// if (value.expiry < Date.now()) { +// cache.delete(key); +// } +// }); +// if (cachedValue && cachedValue.expiry > Date.now()) { +// return cachedValue.value; +// } + +// // 直接await异步方法的结果 +// const result = await originalMethod.apply(this, args); +// cache.set(key, { expiry: Date.now() + ttl, value: result }); +// return result; +// } + +// // 返回装饰后的方法,保持与原方法相同的名称和描述符(如果需要更精细的控制,可以考虑使用Object.getOwnPropertyDescriptor等) +// return decoratorWrapper; +// }; +// } + +/** + * 函数缓存装饰器,根据方法名、参数、自定义key生成缓存键,在一定时间内返回缓存结果 + * @param ttl 超时时间,单位毫秒 + * @param customKey 自定义缓存键前缀,可为空,防止方法名参数名一致时导致缓存键冲突 + * @returns 处理后缓存或调用原方法的结果 + */ +export function cacheFunc(ttl: number, customKey: string = '') { + const cache = new Map(); + + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor { + const originalMethod = descriptor.value; + const className = target.constructor.name; // 获取类名 + const methodName = propertyKey; // 获取方法名 + descriptor.value = async function (...args: any[]) { + const cacheKey = `${customKey}${className}.${methodName}:${JSON.stringify(args)}`; + const cached = cache.get(cacheKey); + if (cached && cached.expiry > Date.now()) { + return cached.value; + } else { + const result = await originalMethod.apply(this, args); + cache.set(cacheKey, { value: result, expiry: Date.now() + ttl }); + return result; + } + }; + + return descriptor; + }; +} +export function isValidOldConfig(config: any) { + if (typeof config !== 'object') { + return false; + } + const requiredKeys = [ + 'httpHost', 'httpPort', 'httpPostUrls', 'httpSecret', + 'wsHost', 'wsPort', 'wsReverseUrls', 'enableHttp', + 'enableHttpHeart', 'enableHttpPost', 'enableWs', 'enableWsReverse', + 'messagePostFormat', 'reportSelfMessage', 'enableLocalFile2Url', + 'debug', 'heartInterval', 'token', 'musicSignUrl' + ]; + for (const key of requiredKeys) { + if (!(key in config)) { + return false; + } + } + if (!Array.isArray(config.httpPostUrls) || !Array.isArray(config.wsReverseUrls)) { + return false; + } + if (config.httpPostUrls.some((url: any) => typeof url !== 'string')) { + return false; + } + if (config.wsReverseUrls.some((url: any) => typeof url !== 'string')) { + return false; + } + if (typeof config.httpPort !== 'number' || typeof config.wsPort !== 'number' || typeof config.heartInterval !== 'number') { + return false; + } + if ( + typeof config.enableHttp !== 'boolean' || + typeof config.enableHttpHeart !== 'boolean' || + typeof config.enableHttpPost !== 'boolean' || + typeof config.enableWs !== 'boolean' || + typeof config.enableWsReverse !== 'boolean' || + typeof config.enableLocalFile2Url !== 'boolean' || + typeof config.reportSelfMessage !== 'boolean' + ) { + return false; + } + if (config.messagePostFormat !== 'array' && config.messagePostFormat !== 'string') { + return false; + } + return true; +} +export function migrateConfig(oldConfig: any) { + const newConfig = { + http: { + enable: oldConfig.enableHttp, + host: oldConfig.httpHost, + port: oldConfig.httpPort, + secret: oldConfig.httpSecret, + enableHeart: oldConfig.enableHttpHeart, + enablePost: oldConfig.enableHttpPost, + postUrls: oldConfig.httpPostUrls, + }, + ws: { + enable: oldConfig.enableWs, + host: oldConfig.wsHost, + port: oldConfig.wsPort, + }, + reverseWs: { + enable: oldConfig.enableWsReverse, + urls: oldConfig.wsReverseUrls, + }, + GroupLocalTime: { + Record: false, + RecordList: [] + }, + debug: oldConfig.debug, + heartInterval: oldConfig.heartInterval, + messagePostFormat: oldConfig.messagePostFormat, + enableLocalFile2Url: oldConfig.enableLocalFile2Url, + musicSignUrl: oldConfig.musicSignUrl, + reportSelfMessage: oldConfig.reportSelfMessage, + token: oldConfig.token, + + }; + return newConfig; +} +// 升级旧的配置到新的 +export async function UpdateConfig() { + const configFiles = await fsPromise.readdir(path.join(__dirname, 'config')); + for (const file of configFiles) { + if (file.match(/^onebot11_\d+.json$/)) { + const CurrentConfig = JSON.parse(await fsPromise.readFile(path.join(__dirname, 'config', file), 'utf8')); + if (isValidOldConfig(CurrentConfig)) { + log('正在迁移旧配置到新配置 File:', file); + const NewConfig = migrateConfig(CurrentConfig); + await fsPromise.writeFile(path.join(__dirname, 'config', file), JSON.stringify(NewConfig, null, 2)); + } + } + } +} +export function isEqual(obj1: any, obj2: any) { + if (obj1 === obj2) return true; + if (obj1 == null || obj2 == null) return false; + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2; + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!isEqual(obj1[key], obj2[key])) return false; + } + return true; +} +export function getDefaultQQVersionConfigInfo(): QQVersionConfigType { + if (os.platform() === 'linux') { + return { + baseVersion: '3.2.12-26702', + curVersion: '3.2.12-26702', + prevVersion: '', + onErrorVersions: [], + buildId: '26702' + }; + } + return { + baseVersion: '9.9.15-26702', + curVersion: '9.9.15-26702', + prevVersion: '', + onErrorVersions: [], + buildId: '26702' + }; +} +export async function promisePipeline(promises: Promise[], callback: (result: any) => boolean): Promise { + let callbackCalled = false; + for (const promise of promises) { + if (callbackCalled) break; + try { + const result = await promise; + if (!callbackCalled) { + callbackCalled = callback(result); + } + } catch (error) { + console.error('Error in promise pipeline:', error); + } + } +} + +export function getQQVersionConfigPath(exePath: string = ''): string | undefined { + let configVersionInfoPath; + if (os.platform() !== 'linux') { + configVersionInfoPath = path.join(path.dirname(exePath), 'resources', 'app', 'versions', 'config.json'); + } else { + const userPath = os.homedir(); + const appDataPath = path.resolve(userPath, './.config/QQ'); + configVersionInfoPath = path.resolve(appDataPath, './versions/config.json'); + } + if (typeof configVersionInfoPath !== 'string') { + return undefined; + } + if (!fs.existsSync(configVersionInfoPath)) { + return undefined; + } + return configVersionInfoPath; +} +export async function deleteOldFiles(directoryPath: string, daysThreshold: number) { + try { + const files = await fsPromise.readdir(directoryPath); + + for (const file of files) { + const filePath = path.join(directoryPath, file); + const stats = await fsPromise.stat(filePath); + const lastModifiedTime = stats.mtimeMs; + const currentTime = Date.now(); + const timeDifference = currentTime - lastModifiedTime; + const daysDifference = timeDifference / (1000 * 60 * 60 * 24); + + if (daysDifference > daysThreshold) { + await fsPromise.unlink(filePath); // Delete the file + //console.log(`Deleted: ${filePath}`); + } + } + } catch (error) { + //console.error('Error deleting files:', error); + } +} diff --git a/src/common/utils/log.ts b/src/common/utils/log.ts new file mode 100644 index 00000000..b21ddfb6 --- /dev/null +++ b/src/common/utils/log.ts @@ -0,0 +1,137 @@ +import log4js, { Configuration } from 'log4js'; +import { truncateString } from '@/common/utils/helper'; +import path from 'node:path'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import chalk from 'chalk'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export enum LogLevel { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +const logDir = path.join(path.resolve(__dirname), 'logs'); + +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 filename = `${getFormattedTimestamp()}.log`; +const logPath = path.join(logDir, filename); + +const logConfig: Configuration = { + appenders: { + FileAppender: { // 输出到文件的appender + type: 'file', + filename: logPath, // 指定日志文件的位置和文件名 + maxLogSize: 10485760, // 日志文件的最大大小(单位:字节),这里设置为10MB + layout: { + type: 'pattern', + pattern: '%d{yyyy-MM-dd hh:mm:ss} [%p] %X{userInfo} | %m' + } + }, + ConsoleAppender: { // 输出到控制台的appender + type: 'console', + layout: { + type: 'pattern', + pattern: `%d{yyyy-MM-dd hh:mm:ss} [%[%p%]] ${chalk.magenta('%X{userInfo}')} | %m` + } + } + }, + categories: { + default: { appenders: ['FileAppender', 'ConsoleAppender'], level: 'debug' }, // 默认情况下同时输出到文件和控制台 + file: { appenders: ['FileAppender'], level: 'debug' }, + console: { appenders: ['ConsoleAppender'], level: 'debug' } + } +}; + +log4js.configure(logConfig); +const loggerConsole = log4js.getLogger('console'); +const loggerFile = log4js.getLogger('file'); +const loggerDefault = log4js.getLogger('default'); + +export function setLogLevel(fileLogLevel: LogLevel, consoleLogLevel: LogLevel) { + logConfig.categories.file.level = fileLogLevel; + logConfig.categories.console.level = consoleLogLevel; + log4js.configure(logConfig); +} + +export function setLogSelfInfo(selfInfo: { nick: string, uin: string, uid: string }) { + const userInfo = `${selfInfo.nick}(${selfInfo.uin})`; + loggerConsole.addContext('userInfo', userInfo); + loggerFile.addContext('userInfo', userInfo); + loggerDefault.addContext('userInfo', userInfo); +} +setLogSelfInfo({ nick: '', uin: '', uid: '' }); + +let fileLogEnabled = true; +let consoleLogEnabled = true; +export function enableFileLog(enable: boolean) { + fileLogEnabled = enable; +} +export function enableConsoleLog(enable: boolean) { + consoleLogEnabled = enable; +} + +function formatMsg(msg: any[]) { + let logMsg = ''; + for (const msgItem of msg) { + if (msgItem instanceof Error) { // 判断是否是错误 + logMsg += msgItem.stack + ' '; + continue; + } else if (typeof msgItem === 'object') { // 判断是否是对象 + const obj = JSON.parse(JSON.stringify(msgItem, null, 2)); + logMsg += JSON.stringify(truncateString(obj)) + ' '; + continue; + } + logMsg += msgItem + ' '; + } + return logMsg; +} + +// eslint-disable-next-line no-control-regex +const colorEscape = /\x1B[@-_][0-?]*[ -/]*[@-~]/g; + +function _log(level: LogLevel, ...args: any[]) { + if (consoleLogEnabled) { + loggerConsole[level](formatMsg(args)); + } + if (fileLogEnabled) { + loggerFile[level](formatMsg(args).replace(colorEscape, '')); + } +} + +export function log(...args: any[]) { + // info 等级 + _log(LogLevel.INFO, ...args); +} + +export function logDebug(...args: any[]) { + _log(LogLevel.DEBUG, ...args); +} + +export function logError(...args: any[]) { + _log(LogLevel.ERROR, ...args); +} + +export function logWarn(...args: any[]) { + _log(LogLevel.WARN, ...args); +} + +export function logFatal(...args: any[]) { + _log(LogLevel.FATAL, ...args); +} \ No newline at end of file diff --git a/src/common/utils/type.ts b/src/common/utils/type.ts new file mode 100644 index 00000000..7e62606c --- /dev/null +++ b/src/common/utils/type.ts @@ -0,0 +1,17 @@ +//QQVersionType +type QQPackageInfoType = { + version: string; + buildVersion: string; + platform: string; + eleArch: string; +} +type QQVersionConfigType = { + baseVersion: string; + curVersion: string; + prevVersion: string; + onErrorVersions: Array; + buildId: string; +} +type QQAppidTableType = { + [key: string]: { appid: string, qua: string }; +} \ No newline at end of file diff --git a/src/core/core.ts b/src/core/core.ts index ba488a2f..623ebd90 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -1,3 +1,5 @@ +import { logDebug } from "@/common/utils/log"; +import { NodeIKernelLoginService } from "./services"; import { NodeIQQNTWrapperSession } from "./wrapper/wrapper"; export enum NCoreWorkMode { @@ -9,6 +11,19 @@ export class NapCatCore { public WorkMode: NCoreWorkMode = NCoreWorkMode.Unknown; public isInit: boolean = false; public session: NodeIQQNTWrapperSession | undefined; + private proxyHandler = { + get(target: any, prop: any, receiver: any) { + // console.log('get', prop, typeof target[prop]); + if (typeof target[prop] === 'undefined') { + // 如果方法不存在,返回一个函数,这个函数调用existentMethod + return (...args: unknown[]) => { + logDebug(`${target.constructor.name} has no method ${prop}`); + }; + } + // 如果方法存在,正常返回 + return Reflect.get(target, prop, receiver); + } + }; get IsInit(): boolean { return this.isInit; } @@ -16,12 +31,12 @@ export class NapCatCore { export class NapCatShell extends NapCatCore { public WorkMode: NCoreWorkMode = NCoreWorkMode.Shell; Init() { - + } } export class NapCatLiteLoader extends NapCatCore { public WorkMode: NCoreWorkMode = NCoreWorkMode.LiteLoader; - Init(LoginService: any, WrapperSession: any) { + Init(WrapperSession: NodeIQQNTWrapperSession, LoginService: NodeIKernelLoginService) { } } \ No newline at end of file