diff --git a/README.md b/README.md index 19a9a00c..6d3d7f25 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,19 @@ _Modern protocol-side framework implemented based on NTQQ._ | Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) | |:-:|:-:|:-:|:-:| -| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://docs.napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) | +| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) | |:-:|:-:|:-:|:-:| -| Contact | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) | -|:-:|:-:|:-:|:-:| +| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | +|:-:|:-:|:-:|:-:|:-:| + +| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) | +|:-:|:-:| ## Thanks + [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权 -+ [LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目部分开发 - + 不过最最重要的 还是需要感谢屏幕前的你哦~ --- @@ -60,3 +61,7 @@ _Modern protocol-side framework implemented based on NTQQ._ 2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE). **本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。** + +## Warnings + +[某框架抄袭部分分析](https://napneko.github.io/other/about-copy) diff --git a/external/LiteLoaderWrapper.zip b/external/LiteLoaderWrapper.zip index 990c0c5b..9861a5a1 100644 Binary files a/external/LiteLoaderWrapper.zip and b/external/LiteLoaderWrapper.zip differ diff --git a/launcher/NapCatWinBootHook.dll b/launcher/NapCatWinBootHook.dll index d7393fac..b1692a73 100644 Binary files a/launcher/NapCatWinBootHook.dll and b/launcher/NapCatWinBootHook.dll differ diff --git a/launcher/NapCatWinBootMain.exe b/launcher/NapCatWinBootMain.exe index 9501691c..66f69e09 100644 Binary files a/launcher/NapCatWinBootMain.exe and b/launcher/NapCatWinBootMain.exe differ diff --git a/launcher/qqnt.json b/launcher/qqnt.json index 549b8c76..74d47b93 100644 --- a/launcher/qqnt.json +++ b/launcher/qqnt.json @@ -1,9 +1,9 @@ { "name": "qq-chat", - "version": "9.9.18-32793", - "verHash": "d43f097e", - "linuxVersion": "3.2.16-32793", - "linuxVerHash": "ee4bd910", + "version": "9.9.18-32869", + "verHash": "e735296c", + "linuxVersion": "3.2.16-32869", + "linuxVerHash": "4c192ba9", "private": true, "description": "QQ", "productName": "QQ", @@ -34,9 +34,9 @@ "vuex@4.1.0": "patches/vuex@4.1.0.patch" } }, - "buildVersion": "32793", + "buildVersion": "32869", "isPureShell": true, "isByteCodeShell": true, "platform": "win32", "eleArch": "x64" -} \ No newline at end of file +} diff --git a/manifest.json b/manifest.json index 6ac5becc..91c06759 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "name": "NapCatQQ", "slug": "NapCat.Framework", "description": "高性能的 OneBot 11 协议实现", - "version": "4.7.8", + "version": "4.7.45", "icon": "./logo.png", "authors": [ { diff --git a/napcat.webui/package.json b/napcat.webui/package.json index bd3c4239..abe025ed 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -55,6 +55,7 @@ "ahooks": "^3.8.4", "axios": "^1.7.9", "clsx": "^2.1.1", + "crypto-js": "^4.2.0", "echarts": "^5.5.1", "event-source-polyfill": "^1.0.31", "framer-motion": "^12.0.6", @@ -88,6 +89,7 @@ "@eslint/js": "^9.19.0", "@react-types/shared": "^3.26.0", "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/crypto-js": "^4.2.2", "@types/event-source-polyfill": "^1.0.5", "@types/fabric": "^5.3.9", "@types/node": "^22.12.0", diff --git a/napcat.webui/src/controllers/webui_manager.ts b/napcat.webui/src/controllers/webui_manager.ts index ac472126..dfd3e741 100644 --- a/napcat.webui/src/controllers/webui_manager.ts +++ b/napcat.webui/src/controllers/webui_manager.ts @@ -3,7 +3,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill' import { LogLevel } from '@/const/enum' import { serverRequest } from '@/utils/request' - +import CryptoJS from "crypto-js"; export interface Log { level: LogLevel message: string @@ -17,9 +17,10 @@ export default class WebUIManager { } public static async loginWithToken(token: string) { + const sha256 = CryptoJS.SHA256(token + '.napcat').toString(); const { data } = await serverRequest.post>( '/auth/login', - { token } + { hash: sha256 } ) return data.data.Credential } diff --git a/napcat.webui/src/pages/web_login.tsx b/napcat.webui/src/pages/web_login.tsx index e2b4ca44..c171bc75 100644 --- a/napcat.webui/src/pages/web_login.tsx +++ b/napcat.webui/src/pages/web_login.tsx @@ -47,6 +47,22 @@ export default function WebLoginPage() { } } + // 处理全局键盘事件 + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !isLoading) { + onSubmit() + } + } + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + + // 清理函数 + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [tokenValue, isLoading]) // 依赖项包含用于登录的状态 + useEffect(() => { if (token) { onSubmit() @@ -79,6 +95,7 @@ export default function WebLoginPage() { = new Map(); + private readonly MAX_RETRIES = 3; + private isProcessing: boolean = false; + private pendingOperations: Array<() => void> = []; + + /** + * 执行队列中的待处理操作,确保异步安全 + */ + private executeNextOperation(): void { + if (this.pendingOperations.length === 0) { + this.isProcessing = false; + return; + } + + this.isProcessing = true; + const operation = this.pendingOperations.shift(); + operation?.(); + + // 使用 setImmediate 允许事件循环继续,防止阻塞 + setImmediate(() => this.executeNextOperation()); + } + + /** + * 安全执行操作,防止竞态条件 + * @param operation 要执行的操作 + */ + private safeExecute(operation: () => void): void { + this.pendingOperations.push(operation); + if (!this.isProcessing) { + this.executeNextOperation(); + } + } + + /** + * 检查文件是否存在 + * @param filePath 文件路径 + * @returns 文件是否存在 + */ + private fileExists(filePath: string): boolean { + try { + return fs.existsSync(filePath); + } catch (error) { + //console.log(`检查文件存在出错: ${filePath}`, error); + return false; + } + } + + /** + * 添加文件到清理队列 + * @param filePath 文件路径 + * @param cleanupDelay 清理延迟时间(毫秒) + */ + addFile(filePath: string, cleanupDelay: number): void { + this.safeExecute(() => { + // 如果文件已在队列中,取消原来的计时器 + if (this.tasks.has(filePath)) { + this.cancelCleanup(filePath); + } + + // 创建新的文件记录 + const fileRecord: FileRecord = { + filePath, + addedTime: Date.now(), + retries: 0 + }; + + // 设置计时器 + const timer = setTimeout(() => { + this.cleanupFile(fileRecord, cleanupDelay); + }, cleanupDelay); + + // 添加到任务队列 + this.tasks.set(filePath, { fileRecord, timer }); + }); + } + + /** + * 批量添加文件到清理队列 + * @param filePaths 文件路径数组 + * @param cleanupDelay 清理延迟时间(毫秒) + */ + addFiles(filePaths: string[], cleanupDelay: number): void { + this.safeExecute(() => { + for (const filePath of filePaths) { + // 内部直接处理,不通过 safeExecute 以保证批量操作的原子性 + if (this.tasks.has(filePath)) { + // 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查 + const existingTask = this.tasks.get(filePath); + if (existingTask) { + clearTimeout(existingTask.timer); + } + } + + const fileRecord: FileRecord = { + filePath, + addedTime: Date.now(), + retries: 0 + }; + + const timer = setTimeout(() => { + this.cleanupFile(fileRecord, cleanupDelay); + }, cleanupDelay); + + this.tasks.set(filePath, { fileRecord, timer }); + } + }); + } + + /** + * 清理文件 + * @param record 文件记录 + * @param delay 延迟时间,用于重试 + */ + private cleanupFile(record: FileRecord, delay: number): void { + this.safeExecute(() => { + // 首先检查文件是否存在,不存在则视为清理成功 + if (!this.fileExists(record.filePath)) { + //console.log(`文件已不存在,跳过清理: ${record.filePath}`); + this.tasks.delete(record.filePath); + return; + } + + try { + // 尝试删除文件 + fs.unlinkSync(record.filePath); + // 删除成功,从队列中移除任务 + this.tasks.delete(record.filePath); + } catch (error) { + const err = error as NodeJS.ErrnoException; + + // 明确处理文件不存在的情况 + if (err.code === 'ENOENT') { + //console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`); + this.tasks.delete(record.filePath); + return; + } + + // 文件没有访问权限等情况 + if (err.code === 'EACCES' || err.code === 'EPERM') { + //console.error(`没有权限删除文件: ${record.filePath}`, err); + } + + // 其他删除失败情况,考虑重试 + if (record.retries < this.MAX_RETRIES - 1) { + // 还有重试机会,增加重试次数 + record.retries++; + //console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`); + + // 设置相同的延迟时间再次尝试 + const timer = setTimeout(() => { + this.cleanupFile(record, delay); + }, delay); + + // 更新任务 + this.tasks.set(record.filePath, { fileRecord: record, timer }); + } else { + // 已达到最大重试次数,从队列中移除任务 + this.tasks.delete(record.filePath); + //console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error); + } + } + }); + } + + /** + * 取消文件的清理任务 + * @param filePath 文件路径 + * @returns 是否成功取消 + */ + cancelCleanup(filePath: string): boolean { + let cancelled = false; + this.safeExecute(() => { + const task = this.tasks.get(filePath); + if (task) { + clearTimeout(task.timer); + this.tasks.delete(filePath); + cancelled = true; + } + }); + return cancelled; + } + + /** + * 获取队列中的文件数量 + * @returns 文件数量 + */ + getQueueSize(): number { + return this.tasks.size; + } + + /** + * 获取所有待清理的文件 + * @returns 文件路径数组 + */ + getPendingFiles(): string[] { + return Array.from(this.tasks.keys()); + } + + /** + * 清空所有清理任务 + */ + clearAll(): void { + this.safeExecute(() => { + // 取消所有定时器 + for (const task of this.tasks.values()) { + clearTimeout(task.timer); + } + this.tasks.clear(); + //console.log('已清空所有清理任务'); + }); + } +} + +export const cleanTaskQueue = new CleanupQueue(); \ No newline at end of file diff --git a/src/common/download-ffmpeg.ts b/src/common/download-ffmpeg.ts new file mode 100644 index 00000000..1eb6dd27 --- /dev/null +++ b/src/common/download-ffmpeg.ts @@ -0,0 +1,352 @@ +// 更正导入语句 +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 './log'; + +const downloadOri = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-04-16-12-54/ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1.zip" +const urls = [ + "https://github.moeyy.xyz/" + downloadOri, + "https://ghp.ci/" + downloadOri, + "https://gh.api.99988866.xyz/" + downloadOri, + downloadOri +]; + +/** + * 测试URL是否可用 + * @param url 待测试的URL + * @returns 如果URL可访问返回true,否则返回false + */ +async function testUrl(url: string): Promise { + return new Promise((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 { + 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 { + return new Promise((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 { + try { + // 确保目标目录存在 + if (!fs.existsSync(extractDir)) { + fs.mkdirSync(extractDir, { recursive: true }); + } + + // 解压文件 + const zipStream = new compressing.zip.UncompressStream({ source: zipPath }); + + return new Promise((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(); + }); + }); + } catch (err) { + throw err; + } +} + +/** + * 下载并设置FFmpeg + * @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹 + * @param tempDir 临时文件目录,默认为系统临时目录 + * @returns 返回ffmpeg可执行文件的路径,如果失败则返回null + */ +export async function downloadFFmpeg( + destDir?: string, + tempDir?: string, + progressCallback?: (percent: number, stage: string) => void +): Promise { + // 仅限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) { + await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => { + log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`); + }); + return { + path: path.join(currentPath, 'ffmpeg'), + reset: true + } + } + + return { + path: path.join(currentPath, 'ffmpeg'), + reset: true + } +} \ No newline at end of file diff --git a/src/common/ffmpeg-worker.ts b/src/common/ffmpeg-worker.ts deleted file mode 100644 index 40228a8d..00000000 --- a/src/common/ffmpeg-worker.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { FFmpeg } from '@ffmpeg.wasm/main'; -import { randomUUID } from 'crypto'; -import { readFileSync, statSync, writeFileSync } from 'fs'; -import type { VideoInfo } from './video'; -import { fileTypeFromFile } from 'file-type'; -import imageSize from 'image-size'; -import { parentPort } from 'worker_threads'; -export function recvTask(cb: (taskData: T) => Promise) { - parentPort?.on('message', async (taskData: T) => { - try { - let ret = await cb(taskData); - parentPort?.postMessage(ret); - } catch (error: unknown) { - parentPort?.postMessage({ error: (error as Error).message }); - } - }); -} -class FFmpegService { - public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - const videoFileName = `${randomUUID()}.mp4`; - const outputFileName = `${randomUUID()}.jpg`; - try { - ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath)); - const code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName); - if (code !== 0) { - throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code); - } - const thumbnail = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(thumbnailPath, thumbnail); - } catch (error) { - console.error('Error extracting thumbnail:', error); - throw error; - } finally { - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(videoFileName); - } catch (unlinkError) { - console.error('Error unlinking video file:', unlinkError); - } - } - } - - public static async convertFile(inputFile: string, outputFile: string, format: string): Promise { - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - const inputFileName = `${randomUUID()}.pcm`; - const outputFileName = `${randomUUID()}.${format}`; - try { - ffmpegInstance.fs.writeFile(inputFileName, readFileSync(inputFile)); - const params = format === 'amr' - ? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName] - : ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName]; - const code = await ffmpegInstance.run(...params); - if (code !== 0) { - throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code); - } - const outputData = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(outputFile, outputData); - } catch (error) { - console.error('Error converting file:', error); - throw error; - } finally { - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(inputFileName); - } catch (unlinkError) { - console.error('Error unlinking input file:', unlinkError); - } - } - } - - public static async convert(filePath: string, pcmPath: string): Promise { - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - const inputFileName = `${randomUUID()}.input`; - const outputFileName = `${randomUUID()}.pcm`; - try { - ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath)); - const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName]; - const code = await ffmpegInstance.run(...params); - if (code !== 0) { - throw new Error('FFmpeg process exited with code ' + code); - } - const outputData = ffmpegInstance.fs.readFile(outputFileName); - writeFileSync(pcmPath, outputData); - return Buffer.from(outputData); - } catch (error: any) { - throw new Error('FFmpeg处理转换出错: ' + error.message); - } finally { - try { - ffmpegInstance.fs.unlink(outputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - try { - ffmpegInstance.fs.unlink(inputFileName); - } catch (unlinkError) { - console.error('Error unlinking output file:', unlinkError); - } - } - } - - public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { - await FFmpegService.extractThumbnail(videoPath, thumbnailPath); - const fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4'; - const inputFileName = `${randomUUID()}.${fileType}`; - const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' }); - ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath)); - ffmpegInstance.setLogging(true); - let duration = 60; - ffmpegInstance.setLogger((_level, ...msg) => { - const message = msg.join(' '); - const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/); - if (durationMatch) { - const hours = parseInt(durationMatch[1] ?? '0', 10); - const minutes = parseInt(durationMatch[2] ?? '0', 10); - const seconds = parseFloat(durationMatch[3] ?? '0'); - duration = hours * 3600 + minutes * 60 + seconds; - } - }); - await ffmpegInstance.run('-i', inputFileName); - const image = imageSize(thumbnailPath); - ffmpegInstance.fs.unlink(inputFileName); - const fileSize = statSync(videoPath).size; - return { - width: image.width ?? 100, - height: image.height ?? 100, - time: duration, - format: fileType, - size: fileSize, - filePath: videoPath - }; - } -} -type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo'; - -interface FFmpegTask { - method: FFmpegMethod; - args: any[]; -} -export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise { - switch (method) { - case 'extractThumbnail': - return await FFmpegService.extractThumbnail(...args as [string, string]); - case 'convertFile': - return await FFmpegService.convertFile(...args as [string, string, string]); - case 'convert': - return await FFmpegService.convert(...args as [string, string]); - case 'getVideoInfo': - return await FFmpegService.getVideoInfo(...args as [string, string]); - default: - throw new Error(`Unknown method: ${method}`); - } -} -recvTask(async ({ method, args }: FFmpegTask) => { - return await handleFFmpegTask({ method, args }); -}); \ No newline at end of file diff --git a/src/common/ffmpeg.ts b/src/common/ffmpeg.ts index 737c761a..40385d23 100644 --- a/src/common/ffmpeg.ts +++ b/src/common/ffmpeg.ts @@ -1,36 +1,195 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { VideoInfo } from './video'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { runTask } from './worker'; - -type EncodeArgs = { - method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo'; - args: any[]; +import { readFileSync, statSync, existsSync, mkdirSync } from 'fs'; +import path, { dirname } from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import type { VideoInfo } from './video'; +import { fileTypeFromFile } from 'file-type'; +import imageSize from 'image-size'; +import { fileURLToPath } from 'node:url'; +import { platform } from 'node:os'; +import { LogWrapper } from './log'; +const currentPath = dirname(fileURLToPath(import.meta.url)); +const execFileAsync = promisify(execFile); +const getFFmpegPath = (tool: string): string => { + if (process.platform === 'win32') { + const exeName = `${tool}.exe`; + const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName)); + return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName; + } + return tool; }; - -type EncodeResult = any; - -function getWorkerPath() { - return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs'); -} - +export let FFMPEG_CMD = getFFmpegPath('ffmpeg'); +export let FFPROBE_CMD = getFFmpegPath('ffprobe'); export class FFmpegService { + // 确保目标目录存在 + public static setFfmpegPath(ffmpegPath: string,logger:LogWrapper): 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); + } + } + private static ensureDirExists(filePath: string): void { + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise { - await runTask(getWorkerPath(), { method: 'extractThumbnail', args: [videoPath, thumbnailPath] }); + try { + this.ensureDirExists(thumbnailPath); + + const { stderr } = await execFileAsync(FFMPEG_CMD, [ + '-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}`); + } } public static async convertFile(inputFile: string, outputFile: string, format: string): Promise { - await runTask(getWorkerPath(), { method: 'convertFile', args: [inputFile, outputFile, format] }); + try { + this.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(FFMPEG_CMD, params); + + if (!existsSync(outputFile)) { + throw new Error('转换失败,输出文件不存在'); + } + } catch (error) { + console.error('Error converting file:', error); + throw new Error(`文件转换失败: ${(error as Error).message}`); + } } public static async convert(filePath: string, pcmPath: string): Promise { - const result = await runTask(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] }); - return result; + try { + this.ensureDirExists(pcmPath); + + await execFileAsync(FFMPEG_CMD, [ + '-y', + '-i', filePath, + '-ar', '24000', + '-ac', '1', + '-f', 's16le', + pcmPath + ]); + + if (!existsSync(pcmPath)) { + throw new Error('转换PCM失败,输出文件不存在'); + } + + return readFileSync(pcmPath); + } catch (error: any) { + throw new Error(`FFmpeg处理转换出错: ${error.message}`); + } } public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise { - const result = await await runTask(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] }); - return result; + try { + // 并行执行获取文件信息和提取缩略图 + const [fileInfo, duration] = await Promise.all([ + this.getFileInfo(videoPath, thumbnailPath), + this.getVideoDuration(videoPath) + ]); + + const result: VideoInfo = { + width: fileInfo.width, + height: fileInfo.height, + time: duration, + format: fileInfo.format, + size: fileInfo.size, + filePath: videoPath + }; + return result; + } catch (error) { + throw error; + } } -} + + private static async getFileInfo(videoPath: string, thumbnailPath: string): Promise<{ + format: string, + size: number, + width: number, + height: number + }> { + + // 获取文件大小和类型 + const [fileType, fileSize] = await Promise.all([ + fileTypeFromFile(videoPath).catch(() => { + return null; + }), + Promise.resolve(statSync(videoPath).size) + ]); + + + try { + await this.extractThumbnail(videoPath, thumbnailPath); + // 获取图片尺寸 + const dimensions = imageSize(thumbnailPath); + + return { + format: fileType?.ext ?? 'mp4', + size: fileSize, + width: dimensions.width ?? 100, + height: dimensions.height ?? 100 + }; + } catch (error) { + return { + format: fileType?.ext ?? 'mp4', + size: fileSize, + width: 100, + height: 100 + }; + } + } + + private static async getVideoDuration(videoPath: string): Promise { + try { + // 使用FFprobe获取时长 + const { stdout } = await execFileAsync(FFPROBE_CMD, [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + videoPath + ]); + + const duration = parseFloat(stdout.trim()); + + return isNaN(duration) ? 60 : duration; + } catch (error) { + return 60; // 默认时长 + } + } +} \ No newline at end of file diff --git a/src/common/file.ts b/src/common/file.ts index 4133cf7b..b62ad7b0 100644 --- a/src/common/file.ts +++ b/src/common/file.ts @@ -76,7 +76,7 @@ export function calculateFileMD5(filePath: string): Promise { const stream = fs.createReadStream(filePath); const hash = crypto.createHash('md5'); - stream.on('data', (data: Buffer) => { + stream.on('data', (data) => { // 当读取到数据时,更新哈希对象的状态 hash.update(data); }); @@ -115,7 +115,7 @@ async function tryDownload(options: string | HttpDownloadOptions, useReferer: bo if (useReferer && !headers['Referer']) { headers['Referer'] = url; } - const fetchRes = await fetch(url, { headers }).catch((err) => { + const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => { if (err.cause) { throw err.cause; } @@ -145,8 +145,8 @@ export enum FileUriType { export async function checkUriType(Uri: string) { const LocalFileRet = await solveProblem((uri: string) => { - if (fs.existsSync(uri)) { - return { Uri: uri, Type: FileUriType.Local }; + if (fs.existsSync(path.normalize(uri))) { + return { Uri: path.normalize(uri), Type: FileUriType.Local }; } return undefined; }, Uri); @@ -182,28 +182,28 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string const filePath = path.join(dir, filename); switch (UriType) { - case FileUriType.Local: { - const fileExt = path.extname(HandledUri); - const localFileName = path.basename(HandledUri, fileExt) + fileExt; - const tempFilePath = path.join(dir, filename + fileExt); - fs.copyFileSync(HandledUri, tempFilePath); - return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath }; - } + case FileUriType.Local: { + const fileExt = path.extname(HandledUri); + const localFileName = path.basename(HandledUri, fileExt) + fileExt; + const tempFilePath = path.join(dir, filename + fileExt); + fs.copyFileSync(HandledUri, tempFilePath); + return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath }; + } - case FileUriType.Remote: { - const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} }); - fs.writeFileSync(filePath, buffer); - return { success: true, errMsg: '', fileName: filename, path: filePath }; - } + case FileUriType.Remote: { + const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} }); + fs.writeFileSync(filePath, buffer); + return { success: true, errMsg: '', fileName: filename, path: filePath }; + } - case FileUriType.Base64: { - const base64 = HandledUri.replace(/^base64:\/\//, ''); - const base64Buffer = Buffer.from(base64, 'base64'); - fs.writeFileSync(filePath, base64Buffer); - return { success: true, errMsg: '', fileName: filename, path: filePath }; - } + case FileUriType.Base64: { + const base64 = HandledUri.replace(/^base64:\/\//, ''); + const base64Buffer = Buffer.from(base64, 'base64'); + fs.writeFileSync(filePath, base64Buffer); + return { success: true, errMsg: '', fileName: filename, path: filePath }; + } - default: - return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' }; + default: + return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' }; } } diff --git a/src/common/version.ts b/src/common/version.ts index 4a8b1eff..9894ffaf 100644 --- a/src/common/version.ts +++ b/src/common/version.ts @@ -1 +1 @@ -export const napCatVersion = '4.7.8'; +export const napCatVersion = '4.7.45'; diff --git a/src/common/worker.ts b/src/common/worker.ts index 55e55cd8..da5c1321 100644 --- a/src/common/worker.ts +++ b/src/common/worker.ts @@ -5,8 +5,11 @@ export async function runTask(workerScript: string, taskData: T): Promise< try { return await new Promise((resolve, reject) => { worker.on('message', (result: R) => { + if ((result as any)?.log) { + console.error('Worker Log--->:', (result as { log: string }).log); + } if ((result as any)?.error) { - reject(new Error((result as { error: string }).error)); + reject(new Error("Worker error: " + (result as { error: string }).error)); } resolve(result); }); diff --git a/src/core/apis/file.ts b/src/core/apis/file.ts index f058a15a..209d6844 100644 --- a/src/core/apis/file.ts +++ b/src/core/apis/file.ts @@ -41,10 +41,10 @@ export class NTQQFileApi { this.context = context; this.core = core; this.rkeyManager = new RkeyManager([ - 'https://ss.xingzhige.com/music_card/rkey', // 国内 - 'https://secret-service.bietiaop.com/rkeys',//国内 + 'https://secret-service.bietiaop.com/rkeys', + 'http://ss.xingzhige.com/music_card/rkey', ], - this.context.logger + this.context.logger ); } @@ -182,23 +182,30 @@ export class NTQQFileApi { filePath = newFilePath; const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO); + context.deleteAfterSentFiles.push(path); if (fileSize === 0) { throw new Error('文件异常,大小为0'); } const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`); fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true }); const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`); - try { - videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath); - } catch { - fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64')); - } if (_diyThumbPath) { try { await this.copyFile(_diyThumbPath, thumbPath); } catch (e) { this.context.logger.logError('复制自定义缩略图失败', e); } + } else { + try { + videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath); + if (!fs.existsSync(thumbPath)) { + this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在')); + throw new Error('获取视频缩略图失败'); + } + } catch (e) { + this.context.logger.logError('获取视频信息失败', e); + fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64')); + } } context.deleteAfterSentFiles.push(thumbPath); const thumbSize = (await fsPromises.stat(thumbPath)).size; @@ -224,7 +231,7 @@ export class NTQQFileApi { }, }; } - async createValidSendPttElement(pttPath: string): Promise { + async createValidSendPttElement(_context: SendMessageContext, pttPath: string): Promise { const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger); if (!silkPath) { @@ -301,18 +308,18 @@ export class NTQQFileApi { element.elementType === ElementType.FILE ) { switch (element.elementType) { - case ElementType.PIC: + case ElementType.PIC: element.picElement!.sourcePath = elementResults?.[elementIndex] ?? ''; - break; - case ElementType.VIDEO: + break; + case ElementType.VIDEO: element.videoElement!.filePath = elementResults?.[elementIndex] ?? ''; - break; - case ElementType.PTT: + break; + case ElementType.PTT: element.pttElement!.filePath = elementResults?.[elementIndex] ?? ''; - break; - case ElementType.FILE: + break; + case ElementType.FILE: element.fileElement!.filePath = elementResults?.[elementIndex] ?? ''; - break; + break; } elementIndex++; } @@ -338,6 +345,7 @@ export class NTQQFileApi { 'NodeIKernelMsgListener/onRichMediaDownloadComplete', [{ fileModelId: '0', + downSourceType: 0, downloadSourceType: 0, triggerType: 1, msgId: msgId, diff --git a/src/core/apis/friend.ts b/src/core/apis/friend.ts index c03a1999..a8254ec1 100644 --- a/src/core/apis/friend.ts +++ b/src/core/apis/friend.ts @@ -86,4 +86,31 @@ export class NTQQFriendApi { accept, }); } + async handleDoubtFriendRequest(friendUid: string, str1: string = '', str2: string = '') { + this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2); + } + async getDoubtFriendRequest(count: number) { + let date = Date.now().toString(); + const [, ret] = await this.core.eventWrapper.callNormalEventV2( + 'NodeIKernelBuddyService/getDoubtBuddyReq', + 'NodeIKernelBuddyListener/onDoubtBuddyReqChange', + [date, count, ''], + () => true, + (data) => data.reqId === date + ); + let 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; + } } diff --git a/src/core/apis/group.ts b/src/core/apis/group.ts index 80555d98..423e1e2d 100644 --- a/src/core/apis/group.ts +++ b/src/core/apis/group.ts @@ -218,6 +218,10 @@ export class NTQQGroupApi { 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, diff --git a/src/core/apis/user.ts b/src/core/apis/user.ts index 64c1f799..1bb2ef42 100644 --- a/src/core/apis/user.ts +++ b/src/core/apis/user.ts @@ -90,7 +90,30 @@ export class NTQQUserApi { () => true, (profile) => profile.uid === uid, ); - const RetUser: User = { + return profile; + } + + async getUserDetailInfo(uid: string, no_cache: boolean = false): Promise { + let profile = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, no_cache ? UserDetailSource.KSERVER : UserDetailSource.KDB), uid); + if (profile && profile.uin !== '0' && profile.commonExt) { + return { + ...profile.simpleInfo.status, + ...profile.simpleInfo.vasInfo, + ...profile.commonExt, + ...profile.simpleInfo.baseInfo, + ...profile.simpleInfo.coreInfo, + qqLevel: profile.commonExt?.qqLevel, + age: profile.simpleInfo.baseInfo.age, + pendantId: '', + nick: profile.simpleInfo.coreInfo.nick || '', + }; + } + this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.'); + profile = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER); + if (profile && profile.uin === '0') { + profile.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0'; + } + return { ...profile.simpleInfo.status, ...profile.simpleInfo.vasInfo, ...profile.commonExt, @@ -101,33 +124,6 @@ export class NTQQUserApi { pendantId: '', nick: profile.simpleInfo.coreInfo.nick || '', }; - return RetUser; - } - - async getUserDetailInfo(uid: string): Promise { - let retUser = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, UserDetailSource.KDB), uid); - if (retUser && retUser.uin !== '0') { - return retUser; - } - this.context.logger.logDebug('[NapCat] [Mark] getUserDetailInfo Mode1 Failed.'); - retUser = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER); - if (retUser && retUser.uin === '0') { - retUser.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0'; - } - return retUser; - } - - async getUserDetailInfoV2(uid: string): Promise { - const fallback = new Fallback((user) => FallbackUtil.boolchecker(user, user !== undefined && user.uin !== '0')) - .add(() => this.fetchUserDetailInfo(uid, UserDetailSource.KDB)) - .add(() => this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER)); - const retUser = await fallback.run().then(async (user) => { - if (user && user.uin === '0') { - user.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0'; - } - return user; - }); - return retUser; } async modifySelfProfile(param: ModifyProfileParams) { diff --git a/src/core/external/appid.json b/src/core/external/appid.json index c7429691..e69c402e 100644 --- a/src/core/external/appid.json +++ b/src/core/external/appid.json @@ -298,5 +298,61 @@ "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" } } \ No newline at end of file diff --git a/src/core/external/offset.json b/src/core/external/offset.json index 224d6026..48f84d17 100644 --- a/src/core/external/offset.json +++ b/src/core/external/offset.json @@ -302,5 +302,57 @@ "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" } } \ No newline at end of file diff --git a/src/core/helper/rkey.ts b/src/core/helper/rkey.ts index ecc3634d..47f748d4 100644 --- a/src/core/helper/rkey.ts +++ b/src/core/helper/rkey.ts @@ -6,6 +6,17 @@ interface ServerRkeyData { 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[] = []; @@ -15,9 +26,8 @@ export class RkeyManager { private_rkey: '', expired_time: 0, }; - private failureCount: number = 0; - private lastFailureTimestamp: number = 0; - private readonly FAILURE_LIMIT: number = 8; + private urlFailures: Map = new Map(); + private readonly FAILURE_LIMIT: number = 4; private readonly ONE_DAY: number = 24 * 60 * 60 * 1000; constructor(serverUrl: string[], logger: LogWrapper) { @@ -26,50 +36,92 @@ export class RkeyManager { } async getRkey() { - const now = new Date().getTime(); - if (now - this.lastFailureTimestamp > this.ONE_DAY) { - this.failureCount = 0; // 重置失败计数器 - } - - if (this.failureCount >= this.FAILURE_LIMIT) { - this.logger.logError('[Rkey] 服务存在异常, 图片使用FallBack机制'); - throw new Error('获取rkey失败次数过多,请稍后再试'); + 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}`);//外抛 + 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() { - //刷新rkey - for (const url of this.serverUrl) { + const availableUrls = this.getAvailableUrls(); + + if (availableUrls.length === 0) { + this.logger.logError('[Rkey] 所有服务均已禁用'); + throw new Error('获取rkey失败:所有服务URL均已被禁用'); + } + + for (const url of availableUrls) { try { - const temp = await RequestUtil.HttpGetJson(url, 'GET'); + let temp = await RequestUtil.HttpGetJson(url, 'GET'); + if ('retcode' in temp) { + // 支持Onebot Ret风格 + temp = (temp as unknown as OneBotApiRet).data; + } this.rkeyData = { group_rkey: temp.group_rkey.slice(6), private_rkey: temp.private_rkey.slice(6), expired_time: temp.expired_time }; - this.failureCount = 0; return; } catch (e) { this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e); - this.failureCount++; - this.lastFailureTimestamp = new Date().getTime(); - //是否为最后一个url - if (url === this.serverUrl[this.serverUrl.length - 1]) { - throw new Error(`获取rkey失败: ${e}`);//外抛 + this.updateUrlFailure(url); + + if (url === availableUrls[availableUrls.length - 1]) { + throw new Error(`获取rkey失败: ${e}`); } } } diff --git a/src/core/listeners/NodeIKernelBuddyListener.ts b/src/core/listeners/NodeIKernelBuddyListener.ts index 5dcfe243..edf29044 100644 --- a/src/core/listeners/NodeIKernelBuddyListener.ts +++ b/src/core/listeners/NodeIKernelBuddyListener.ts @@ -40,12 +40,30 @@ export class NodeIKernelBuddyListener { } onDelBatchBuddyInfos(arg: unknown): any { + console.log('onDelBatchBuddyInfos not implemented', ...arguments); } - onDoubtBuddyReqChange(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 { } - onDoubtBuddyReqUnreadNumChange(arg: unknown): any { + onDoubtBuddyReqUnreadNumChange(_num: number): void | Promise { } onNickUpdated(arg: unknown): any { diff --git a/src/core/listeners/NodeIKernelMsgListener.ts b/src/core/listeners/NodeIKernelMsgListener.ts index 379f0289..6518a82a 100644 --- a/src/core/listeners/NodeIKernelMsgListener.ts +++ b/src/core/listeners/NodeIKernelMsgListener.ts @@ -21,7 +21,8 @@ export interface OnRichMediaDownloadCompleteParams { clientMsg: string, businessId: number, userTotalSpacePerDay: unknown, - userUsedSpacePerDay: unknown + userUsedSpacePerDay: unknown, + chatType: number, } export interface GroupFileInfoUpdateParamType { @@ -97,112 +98,112 @@ export interface TempOnRecvParams { } export class NodeIKernelMsgListener { - onAddSendMsg(msgRecord: RawMessage): any { + onAddSendMsg(_msgRecord: RawMessage): any { } - onBroadcastHelperDownloadComplete(broadcastHelperTransNotifyInfo: unknown): any { + onBroadcastHelperDownloadComplete(_broadcastHelperTransNotifyInfo: unknown): any { } - onBroadcastHelperProgressUpdate(broadcastHelperTransNotifyInfo: unknown): any { + onBroadcastHelperProgressUpdate(_broadcastHelperTransNotifyInfo: unknown): any { } - onChannelFreqLimitInfoUpdate(contact: unknown, z: unknown, freqLimitInfo: unknown): any { + onChannelFreqLimitInfoUpdate(_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any { } - onContactUnreadCntUpdate(hashMap: unknown): any { + onContactUnreadCntUpdate(_hashMap: unknown): any { } - onCustomWithdrawConfigUpdate(customWithdrawConfig: unknown): any { + onCustomWithdrawConfigUpdate(_customWithdrawConfig: unknown): any { } - onDraftUpdate(contact: unknown, arrayList: unknown, j2: unknown): any { + onDraftUpdate(_contact: unknown, _arrayList: unknown, _j2: unknown): any { } - onEmojiDownloadComplete(emojiNotifyInfo: unknown): any { + onEmojiDownloadComplete(_emojiNotifyInfo: unknown): any { } - onEmojiResourceUpdate(emojiResourceInfo: unknown): any { + onEmojiResourceUpdate(_emojiResourceInfo: unknown): any { } - onFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any { + onFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any { } - onFileMsgCome(arrayList: unknown): any { + onFileMsgCome(_arrayList: unknown): any { } - onFirstViewDirectMsgUpdate(firstViewDirectMsgNotifyInfo: unknown): any { + onFirstViewDirectMsgUpdate(_firstViewDirectMsgNotifyInfo: unknown): any { } - onFirstViewGroupGuildMapping(arrayList: unknown): any { + onFirstViewGroupGuildMapping(_arrayList: unknown): any { } - onGrabPasswordRedBag(i2: unknown, str: unknown, i3: unknown, recvdOrder: unknown, msgRecord: unknown): any { + onGrabPasswordRedBag(_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any { } - onGroupFileInfoAdd(groupItem: unknown): any { + onGroupFileInfoAdd(_groupItem: unknown): any { } - onGroupFileInfoUpdate(groupFileListResult: GroupFileInfoUpdateParamType): any { + onGroupFileInfoUpdate(_groupFileListResult: GroupFileInfoUpdateParamType): any { } - onGroupGuildUpdate(groupGuildNotifyInfo: unknown): any { + onGroupGuildUpdate(_groupGuildNotifyInfo: unknown): any { } - onGroupTransferInfoAdd(groupItem: unknown): any { + onGroupTransferInfoAdd(_groupItem: unknown): any { } - onGroupTransferInfoUpdate(groupFileListResult: unknown): any { + onGroupTransferInfoUpdate(_groupFileListResult: unknown): any { } - onGuildInteractiveUpdate(guildInteractiveNotificationItem: unknown): any { + onGuildInteractiveUpdate(_guildInteractiveNotificationItem: unknown): any { } - onGuildMsgAbFlagChanged(guildMsgAbFlag: unknown): any { + onGuildMsgAbFlagChanged(_guildMsgAbFlag: unknown): any { } - onGuildNotificationAbstractUpdate(guildNotificationAbstractInfo: unknown): any { + onGuildNotificationAbstractUpdate(_guildNotificationAbstractInfo: unknown): any { } - onHitCsRelatedEmojiResult(downloadRelateEmojiResultInfo: unknown): any { + onHitCsRelatedEmojiResult(_downloadRelateEmojiResultInfo: unknown): any { } - onHitEmojiKeywordResult(hitRelatedEmojiWordsResult: unknown): any { + onHitEmojiKeywordResult(_hitRelatedEmojiWordsResult: unknown): any { } - onHitRelatedEmojiResult(relatedWordEmojiInfo: unknown): any { + onHitRelatedEmojiResult(_relatedWordEmojiInfo: unknown): any { } - onImportOldDbProgressUpdate(importOldDbMsgNotifyInfo: unknown): any { + onImportOldDbProgressUpdate(_importOldDbMsgNotifyInfo: unknown): any { } - onInputStatusPush(inputStatusInfo: { + onInputStatusPush(_inputStatusInfo: { chatType: number; eventType: number; fromUin: string; @@ -215,55 +216,55 @@ export class NodeIKernelMsgListener { } - onKickedOffLine(kickedInfo: KickedOffLineInfo): any { + onKickedOffLine(_kickedInfo: KickedOffLineInfo): any { } - onLineDev(arrayList: unknown): any { + onLineDev(_arrayList: unknown): any { } - onLogLevelChanged(j2: unknown): any { + onLogLevelChanged(_j2: unknown): any { } - onMsgAbstractUpdate(arrayList: unknown): any { + onMsgAbstractUpdate(_arrayList: unknown): any { } - onMsgBoxChanged(arrayList: unknown): any { + onMsgBoxChanged(_arrayList: unknown): any { } - onMsgDelete(contact: unknown, arrayList: unknown): any { + onMsgDelete(_contact: unknown, _arrayList: unknown): any { } - onMsgEventListUpdate(hashMap: unknown): any { + onMsgEventListUpdate(_hashMap: unknown): any { } - onMsgInfoListAdd(arrayList: unknown): any { + onMsgInfoListAdd(_arrayList: unknown): any { } - onMsgInfoListUpdate(msgList: RawMessage[]): any { + onMsgInfoListUpdate(_msgList: RawMessage[]): any { } - onMsgQRCodeStatusChanged(i2: unknown): any { + onMsgQRCodeStatusChanged(_i2: unknown): any { } - onMsgRecall(chatType: ChatType, uid: string, msgSeq: string): any { + onMsgRecall(_chatType: ChatType, _uid: string, _msgSeq: string): any { } - onMsgSecurityNotify(msgRecord: unknown): any { + onMsgSecurityNotify(_msgRecord: unknown): any { } - onMsgSettingUpdate(msgSetting: unknown): any { + onMsgSettingUpdate(_msgSetting: unknown): any { } @@ -279,108 +280,108 @@ export class NodeIKernelMsgListener { } - onReadFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any { + onReadFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any { } - onRecvGroupGuildFlag(i2: unknown): any { + onRecvGroupGuildFlag(_i2: unknown): any { } - onRecvMsg(arrayList: RawMessage[]): any { + onRecvMsg(_arrayList: RawMessage[]): any { } - onRecvMsgSvrRspTransInfo(j2: unknown, contact: unknown, i2: unknown, i3: unknown, str: unknown, bArr: unknown): any { + onRecvMsgSvrRspTransInfo(_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any { } - onRecvOnlineFileMsg(arrayList: unknown): any { + onRecvOnlineFileMsg(_arrayList: unknown): any { } - onRecvS2CMsg(arrayList: unknown): any { + onRecvS2CMsg(_arrayList: unknown): any { } - onRecvSysMsg(arrayList: Array): any { + onRecvSysMsg(_arrayList: Array): any { } - onRecvUDCFlag(i2: unknown): any { + onRecvUDCFlag(_i2: unknown): any { } - onRichMediaDownloadComplete(fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any { + onRichMediaDownloadComplete(_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any { } - onRichMediaProgerssUpdate(fileTransNotifyInfo: unknown): any { + onRichMediaProgerssUpdate(_fileTransNotifyInfo: unknown): any { } - onRichMediaUploadComplete(fileTransNotifyInfo: unknown): any { + onRichMediaUploadComplete(_fileTransNotifyInfo: unknown): any { } - onSearchGroupFileInfoUpdate(searchGroupFileResult: unknown): any { + onSearchGroupFileInfoUpdate(_searchGroupFileResult: unknown): any { } - onSendMsgError(j2: unknown, contact: unknown, i2: unknown, str: unknown): any { + onSendMsgError(_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any { } - onSysMsgNotification(i2: unknown, j2: unknown, j3: unknown, arrayList: unknown): any { + onSysMsgNotification(_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any { } - onTempChatInfoUpdate(tempChatInfo: TempOnRecvParams): any { + onTempChatInfoUpdate(_tempChatInfo: TempOnRecvParams): any { } - onUnreadCntAfterFirstView(hashMap: unknown): any { + onUnreadCntAfterFirstView(_hashMap: unknown): any { } - onUnreadCntUpdate(hashMap: unknown): any { + onUnreadCntUpdate(_hashMap: unknown): any { } - onUserChannelTabStatusChanged(z: unknown): any { + onUserChannelTabStatusChanged(_z: unknown): any { } - onUserOnlineStatusChanged(z: unknown): any { + onUserOnlineStatusChanged(_z: unknown): any { } - onUserTabStatusChanged(arrayList: unknown): any { + onUserTabStatusChanged(_arrayList: unknown): any { } - onlineStatusBigIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any { + onlineStatusBigIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any { } - onlineStatusSmallIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any { + onlineStatusSmallIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any { } // 第一次发现于Linux - onUserSecQualityChanged(...args: unknown[]): any { + onUserSecQualityChanged(..._args: unknown[]): any { } - onMsgWithRichLinkInfoUpdate(...args: unknown[]): any { + onMsgWithRichLinkInfoUpdate(..._args: unknown[]): any { } - onRedTouchChanged(...args: unknown[]): any { + onRedTouchChanged(..._args: unknown[]): any { } // 第一次发现于Win 9.9.9-23159 - onBroadcastHelperProgerssUpdate(...args: unknown[]): any { + onBroadcastHelperProgerssUpdate(..._args: unknown[]): any { } } diff --git a/src/core/packet/context/operationContext.ts b/src/core/packet/context/operationContext.ts index d69955c6..d89e8899 100644 --- a/src/core/packet/context/operationContext.ts +++ b/src/core/packet/context/operationContext.ts @@ -6,13 +6,14 @@ import { PacketMsgFileElement, PacketMsgPicElement, PacketMsgPttElement, - PacketMsgVideoElement + PacketMsgReplyElement, + PacketMsgVideoElement, } from '@/core/packet/message/element'; import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core'; import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp'; import { AIVoiceChatType } from '@/core/packet/entities/aiChat'; import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core'; -import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto'; +import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/core/packet/transformer/proto'; import { OidbPacket } from '@/core/packet/transformer/base'; import { ImageOcrResult } from '@/core/packet/entities/ocrResult'; import { gunzipSync } from 'zlib'; @@ -68,30 +69,32 @@ export class PacketOperationContext { } } - async SetGroupSpecialTitle(groupUin: number, uid: string, tittle: string) { - const req = trans.SetSpecialTitle.build(groupUin, uid, tittle); + 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 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); - this.context.logger.info(`上传资源${res.length}个,失败${res.filter(r => r.status === 'rejected').length}个`); + this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}个`); res.forEach((result, index) => { if (result.status === 'rejected') { this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`); @@ -100,10 +103,13 @@ export class PacketOperationContext { } async UploadImage(img: PacketMsgPicElement) { - await this.context.highway.uploadImage({ - chatType: ChatType.KCHATTYPEC2C, - peerUid: this.context.napcore.basicInfo.uid - }, img); + 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'); @@ -137,23 +143,79 @@ export class PacketOperationContext { coordinates: item.polygon.coordinates.map((c) => { return { x: c.x, - y: c.y + y: c.y, }; }), }; }), - language: res.ocrRspBody.language + language: res.ocrRspBody.language, } as ImageOcrResult; } - async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) { + 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[] | 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[]> { + 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[]> { + 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 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) { const req = trans.DownloadGroupFile.build(groupUin, fileUUID); const resp = await this.context.client.sendOidbPacket(req, true); @@ -189,12 +251,17 @@ export class PacketOperationContext { return res.content.map((item) => { return { category: item.category, - voices: item.voices + voices: item.voices, }; }); } - async GetAiVoice(groupUin: number, voiceId: string, text: string, chatType: AIVoiceChatType): Promise> { + async GetAiVoice( + groupUin: number, + voiceId: string, + text: string, + chatType: AIVoiceChatType + ): Promise> { let reqTime = 0; const reqMaxTime = 30; const sessionId = crypto.randomBytes(4).readUInt32BE(0); @@ -222,6 +289,7 @@ export class PacketOperationContext { 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) { @@ -237,12 +305,12 @@ export class PacketOperationContext { const groupUin = msg?.responseHead.grp?.groupUin ?? 0; element.picElement = { ...element.picElement, - originImageUrl: await this.GetGroupImageUrl(groupUin, index!) + originImageUrl: await this.GetGroupImageUrl(groupUin, index!), }; } else { element.picElement = { ...element.picElement, - originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!) + originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!), }; } return element; @@ -255,7 +323,7 @@ export class PacketOperationContext { elements: elements, guildId: '', isOnlineMsg: false, - msgId: '7467703692092974645', // TODO: no necessary + msgId: '7467703692092974645', // TODO: no necessary msgRandom: '0', msgSeq: String(msg.contentHead.sequence ?? 0), msgTime: String(msg.contentHead.timeStamp ?? 0), diff --git a/src/core/packet/message/builder.ts b/src/core/packet/message/builder.ts index 2503645b..8080cd48 100644 --- a/src/core/packet/message/builder.ts +++ b/src/core/packet/message/builder.ts @@ -24,12 +24,15 @@ export class PacketMsgBuilder { } return { responseHead: { - fromUid: '', fromUin: node.senderUin, - toUid: node.groupId ? undefined : selfUid, + 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, @@ -40,16 +43,13 @@ export class PacketMsgBuilder { type: node.groupId ? 82 : 9, subType: node.groupId ? undefined : 4, divSeq: node.groupId ? undefined : 4, - msgId: crypto.randomBytes(4).readUInt32LE(0), + autoReply: 0, sequence: crypto.randomBytes(4).readUInt32LE(0), timeStamp: +node.time.toString().substring(0, 10), - field7: BigInt(1), - field8: 0, - field9: 0, forward: { field1: 0, field2: 0, - field3: node.groupId ? 0 : 2, + field3: node.groupId ? 1 : 2, unknownBase64: avatar, avatar: avatar } diff --git a/src/core/packet/message/element.ts b/src/core/packet/message/element.ts index df1a1d4d..7ed98fc1 100644 --- a/src/core/packet/message/element.ts +++ b/src/core/packet/message/element.ts @@ -10,6 +10,7 @@ import { MsgInfo, NotOnlineImage, OidbSvcTrpcTcp0XE37_800Response, + PushMsgBody, QBigFaceExtra, QSmallFaceExtra, } from '@/core/packet/transformer/proto'; @@ -29,7 +30,8 @@ import { SendReplyElement, SendMultiForwardMsgElement, SendTextElement, - SendVideoElement + SendVideoElement, + Peer } from '@/core'; import {ForwardMsgBuilder} from '@/common/forward-msg-builder'; import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message'; @@ -146,41 +148,40 @@ export class PacketMsgAtElement extends PacketMsgTextElement { } export class PacketMsgReplyElement extends IPacketMsgElement { - messageId: bigint; - messageSeq: number; - messageClientSeq: number; + time: number; + targetMessageId: bigint; + targetMessageSeq: number; + targetMessageClientSeq: number; targetUin: number; targetUid: string; - time: number; - elems: PacketMsg[]; + targetElems?: NapProtoEncodeStructType[]; + targetSourceMsg?: NapProtoEncodeStructType; + targetPeer?: Peer; constructor(element: SendReplyElement) { super(element); - this.messageId = BigInt(element.replyElement.replayMsgId ?? 0); - this.messageSeq = +(element.replyElement.replayMsgSeq ?? 0); - this.messageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0); + 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.time = +(element.replyElement.replyMsgTime ?? 0); - this.elems = []; // TODO: in replyElement.sourceMsgTextElems + this.targetPeer = element.replyElement._replyMsgPeer; } get isGroupReply(): boolean { - return this.messageClientSeq === 0; + return this.targetMessageClientSeq === 0; } override buildElement(): NapProtoEncodeStructType[] { return [{ srcMsg: { - origSeqs: [this.isGroupReply ? this.messageClientSeq : this.messageSeq], + origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq], senderUin: BigInt(this.targetUin), time: this.time, - elems: [], // TODO: in replyElement.sourceMsgTextElems - pbReserve: { - messageId: this.messageId, - }, - toUin: BigInt(this.targetUin), - type: 1, + elems: this.targetElems ?? [], + sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}), + toUin: BigInt(0), } }]; } diff --git a/src/core/packet/transformer/action/MoveGroupFile.ts b/src/core/packet/transformer/action/MoveGroupFile.ts new file mode 100644 index 00000000..47e1ec47 --- /dev/null +++ b/src/core/packet/transformer/action/MoveGroupFile.ts @@ -0,0 +1,35 @@ +import * as proto from '@/core/packet/transformer/proto'; +import { NapProtoMsg } from '@napneko/nap-proto-core'; +import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base'; +import OidbBase from '@/core/packet/transformer/oidb/oidbBase'; + +class MoveGroupFile extends PacketTransformer { + constructor() { + super(); + } + + build(groupUin: number, fileUUID: string, currentParentDirectory: string, targetParentDirectory: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({ + move: { + groupUin: groupUin, + appId: 5, + busId: 102, + fileId: fileUUID, + parentDirectory: currentParentDirectory, + targetDirectory: targetParentDirectory, + } + }); + return OidbBase.build(0x6D6, 5, body, true, false); + } + + parse(data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody); + if (res.move.retCode !== 0) { + throw new Error(`sendGroupFileMoveReq error: ${res.move.clientWording} (code=${res.move.retCode})`); + } + return res; + } +} + +export default new MoveGroupFile(); diff --git a/src/core/packet/transformer/action/RenameGroupFile.ts b/src/core/packet/transformer/action/RenameGroupFile.ts new file mode 100644 index 00000000..2cbaeacd --- /dev/null +++ b/src/core/packet/transformer/action/RenameGroupFile.ts @@ -0,0 +1,34 @@ +import * as proto from '@/core/packet/transformer/proto'; +import { NapProtoMsg } from '@napneko/nap-proto-core'; +import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base'; +import OidbBase from '@/core/packet/transformer/oidb/oidbBase'; + +class RenameGroupFile extends PacketTransformer { + constructor() { + super(); + } + + build(groupUin: number, fileUUID: string, currentParentDirectory: string, newName: string): OidbPacket { + const body = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6).encode({ + rename: { + groupUin: groupUin, + busId: 102, + fileId: fileUUID, + parentFolder: currentParentDirectory, + newFileName: newName, + } + }); + return OidbBase.build(0x6D6, 4, body, true, false); + } + + parse(data: Buffer) { + const oidbBody = OidbBase.parse(data).body; + const res = new NapProtoMsg(proto.OidbSvcTrpcTcp0x6D6Response).decode(oidbBody); + if (res.rename.retCode !== 0) { + throw new Error(`sendGroupFileRenameReq error: ${res.rename.clientWording} (code=${res.rename.retCode})`); + } + return res; + } +} + +export default new RenameGroupFile(); diff --git a/src/core/packet/transformer/action/SetSpecialTitle.ts b/src/core/packet/transformer/action/SetSpecialTitle.ts index 9edeb008..3d75fb36 100644 --- a/src/core/packet/transformer/action/SetSpecialTitle.ts +++ b/src/core/packet/transformer/action/SetSpecialTitle.ts @@ -8,14 +8,14 @@ class SetSpecialTitle extends PacketTransformer super(); } - build(groupCode: number, uid: string, tittle: string): OidbPacket { + build(groupCode: number, uid: string, title: string): OidbPacket { const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({ groupUin: +groupCode, body: { targetUid: uid, - specialTitle: tittle, + specialTitle: title, expiredTime: -1, - uinName: tittle + uinName: title } }); return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false); diff --git a/src/core/packet/transformer/action/index.ts b/src/core/packet/transformer/action/index.ts index 7f0987d6..03af3f35 100644 --- a/src/core/packet/transformer/action/index.ts +++ b/src/core/packet/transformer/action/index.ts @@ -6,3 +6,5 @@ export { default as GetStrangerInfo } from './GetStrangerInfo'; export { default as SendPoke } from './SendPoke'; export { default as SetSpecialTitle } from './SetSpecialTitle'; export { default as ImageOCR } from './ImageOCR'; +export { default as MoveGroupFile } from './MoveGroupFile'; +export { default as RenameGroupFile } from './RenameGroupFile'; diff --git a/src/core/packet/transformer/message/FetchC2CMessage.ts b/src/core/packet/transformer/message/FetchC2CMessage.ts new file mode 100644 index 00000000..92785163 --- /dev/null +++ b/src/core/packet/transformer/message/FetchC2CMessage.ts @@ -0,0 +1,27 @@ +import * as proto from '@/core/packet/transformer/proto'; +import { NapProtoMsg } from '@napneko/nap-proto-core'; +import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base'; + +class FetchC2CMessage extends PacketTransformer { + constructor() { + super(); + } + + build(targetUid: string, startSeq: number, endSeq: number): OidbPacket { + const req = new NapProtoMsg(proto.SsoGetC2cMsg).encode({ + friendUid: targetUid, + startSequence: startSeq, + endSequence: endSeq, + }); + return { + cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg', + data: PacketHexStrBuilder(req) + }; + } + + parse(data: Buffer) { + return new NapProtoMsg(proto.SsoGetC2cMsgResponse).decode(data); + } +} + +export default new FetchC2CMessage(); diff --git a/src/core/packet/transformer/message/FetchGroupMessage.ts b/src/core/packet/transformer/message/FetchGroupMessage.ts new file mode 100644 index 00000000..ffd4c672 --- /dev/null +++ b/src/core/packet/transformer/message/FetchGroupMessage.ts @@ -0,0 +1,30 @@ +import * as proto from '@/core/packet/transformer/proto'; +import { NapProtoMsg } from '@napneko/nap-proto-core'; +import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base'; + +class FetchGroupMessage extends PacketTransformer { + constructor() { + super(); + } + + build(groupUin: number, startSeq: number, endSeq: number): OidbPacket { + const req = new NapProtoMsg(proto.SsoGetGroupMsg).encode({ + info: { + groupUin: groupUin, + startSequence: startSeq, + endSequence: endSeq + }, + direction: true + }); + return { + cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg', + data: PacketHexStrBuilder(req) + }; + } + + parse(data: Buffer) { + return new NapProtoMsg(proto.SsoGetGroupMsgResponse).decode(data); + } +} + +export default new FetchGroupMessage(); diff --git a/src/core/packet/transformer/message/index.ts b/src/core/packet/transformer/message/index.ts index 16a5d338..78e757c8 100644 --- a/src/core/packet/transformer/message/index.ts +++ b/src/core/packet/transformer/message/index.ts @@ -1,2 +1,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg'; -export { default as DownloadForwardMsg } from './DownloadForwardMsg'; \ No newline at end of file +export { default as FetchGroupMessage } from './FetchGroupMessage'; +export { default as FetchC2CMessage } from './FetchC2CMessage'; +export { default as DownloadForwardMsg } from './DownloadForwardMsg'; diff --git a/src/core/packet/transformer/proto/message/message.ts b/src/core/packet/transformer/proto/message/message.ts index 24200ca1..fdae58cf 100644 --- a/src/core/packet/transformer/proto/message/message.ts +++ b/src/core/packet/transformer/proto/message/message.ts @@ -13,13 +13,15 @@ import { export const ContentHead = { type: ProtoField(1, ScalarType.UINT32), subType: ProtoField(2, ScalarType.UINT32, true), - divSeq: ProtoField(3, ScalarType.UINT32, true), - msgId: ProtoField(4, ScalarType.UINT32, true), + c2cCmd: ProtoField(3, ScalarType.UINT32, true), + ranDom: ProtoField(4, ScalarType.UINT32, true), sequence: ProtoField(5, ScalarType.UINT32, true), timeStamp: ProtoField(6, ScalarType.UINT32, true), - field7: ProtoField(7, ScalarType.UINT64, true), - field8: ProtoField(8, ScalarType.UINT32, true), - field9: ProtoField(9, ScalarType.UINT32, true), + pkgNum: ProtoField(7, ScalarType.UINT64, true), + pkgIndex: ProtoField(8, ScalarType.UINT32, true), + divSeq: ProtoField(9, ScalarType.UINT32, true), + autoReply: ProtoField(10, ScalarType.UINT32), + ntMsgSeq: ProtoField(10, ScalarType.UINT32, true), newId: ProtoField(12, ScalarType.UINT64, true), forward: ProtoField(15, () => ForwardHead, true), }; diff --git a/src/core/services/NodeIKernelBuddyService.ts b/src/core/services/NodeIKernelBuddyService.ts index 69dcfb05..dd025c08 100644 --- a/src/core/services/NodeIKernelBuddyService.ts +++ b/src/core/services/NodeIKernelBuddyService.ts @@ -16,7 +16,7 @@ export interface NodeIKernelBuddyService { getBuddyListFromCache(reqType: BuddyListReqType): Promise; getDoubtBuddyUnreadNum(): number; - approvalDoubtBuddyReq(uid: number, isAgree: boolean): void; + approvalDoubtBuddyReq(uid: string, str1: string, str2: string): void; delDoubtBuddyReq(uid: number): void; - delAllDoubtBuddyReq(): void; + delAllDoubtBuddyReq(): Promise; reportDoubtBuddyReqUnread(): void; diff --git a/src/core/services/NodeIKernelMsgService.ts b/src/core/services/NodeIKernelMsgService.ts index 53baf999..abd6c969 100644 --- a/src/core/services/NodeIKernelMsgService.ts +++ b/src/core/services/NodeIKernelMsgService.ts @@ -425,7 +425,20 @@ export interface NodeIKernelMsgService { switchToOfflineGetRichMediaElement(...args: unknown[]): unknown; - downloadRichMedia(...args: unknown[]): unknown; + downloadRichMedia(args: { + fileModelId: string, + downSourceType: number, + triggerType: number, + msgId: string, + chatType: number, + peerUid: string, + elementId: string, + thumbSize: number, + downloadType: number, + filePath: string + } & { + downloadSourceType: number, //33800左右一下的老版本 新版34606已经完全上面格式 + }): unknown; getFirstUnreadMsgSeq(args: { peerUid: string diff --git a/src/core/services/NodeIKernelProfileService.ts b/src/core/services/NodeIKernelProfileService.ts index 3a017050..8b07773a 100644 --- a/src/core/services/NodeIKernelProfileService.ts +++ b/src/core/services/NodeIKernelProfileService.ts @@ -1,5 +1,5 @@ import { AnyCnameRecord } from 'node:dns'; -import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailSource } from '@/core'; +import { BizKey, ModifyProfileParams, NodeIKernelProfileListener, ProfileBizType, SimpleInfo, UserDetailInfoByUin, UserDetailInfoListenerArg, UserDetailSource } from '@/core'; import { GeneralCallResult } from '@/core/services/common'; export interface NodeIKernelProfileService { @@ -15,7 +15,13 @@ export interface NodeIKernelProfileService { getCoreAndBaseInfo(callfrom: string, uids: string[]): Promise>; - fetchUserDetailInfo(trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise; + fetchUserDetailInfo(trace: string, uids: string[], source: UserDetailSource, bizType: ProfileBizType[]): Promise detail + detail: Map, + } + >; addKernelProfileListener(listener: NodeIKernelProfileListener): number; diff --git a/src/core/services/NodeIKernelRichMediaService.ts b/src/core/services/NodeIKernelRichMediaService.ts index 83ebeaf8..85c267f3 100644 --- a/src/core/services/NodeIKernelRichMediaService.ts +++ b/src/core/services/NodeIKernelRichMediaService.ts @@ -198,9 +198,29 @@ export interface NodeIKernelRichMediaService { renameGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown; - moveGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown; + moveGroupFile(groupCode: string, busId: Array, fileList: Array, currentParentDirectory: string, targetParentDirectory: string): Promise, + failFileIdList: Array + } + }>; - transGroupFile(arg1: unknown, arg2: unknown): unknown; + transGroupFile(groupCode: string, fileId: string): Promise; searchGroupFile( keywords: Array, diff --git a/src/core/types/element.ts b/src/core/types/element.ts index 193f7a12..dd417b13 100644 --- a/src/core/types/element.ts +++ b/src/core/types/element.ts @@ -1,4 +1,15 @@ -import { ElementType, MessageElement, NTGrayTipElementSubTypeV2, PicSubType, PicType, TipAioOpGrayTipElement, TipGroupElement, NTVideoType, FaceType } from './msg'; +import { + ElementType, + MessageElement, + NTGrayTipElementSubTypeV2, + PicSubType, + PicType, + TipAioOpGrayTipElement, + TipGroupElement, + NTVideoType, + FaceType, + Peer +} from './msg'; type ElementFullBase = Omit; @@ -213,6 +224,9 @@ export interface ReplyElement { senderUidStr?: string; replyMsgTime?: string; replyMsgClientSeq?: string; + // HACK: Attributes that were not originally available, + // but were added due to NTQQ and NapCat's internal implementation, are used to supplement NapCat + _replyMsgPeer?: Peer; } export interface CalendarElement { diff --git a/src/core/types/msg.ts b/src/core/types/msg.ts index 083fb94b..9e542ad8 100644 --- a/src/core/types/msg.ts +++ b/src/core/types/msg.ts @@ -403,7 +403,7 @@ export interface NTGroupGrayMember { } /** * 群灰色提示邀请者和被邀请者接口 - * + * * */ export interface NTGroupGrayInviterAndInvite { invited: NTGroupGrayMember; @@ -501,6 +501,7 @@ export interface RawMessage { elements: MessageElement[];// 消息元素 sourceType: MsgSourceType;// 消息来源类型 isOnlineMsg: boolean;// 是否为在线消息 + clientSeq?: string; } /** @@ -565,4 +566,4 @@ export enum FaceType { AniSticke = 3, // 动画贴纸 Lottie = 4,// 新格式表情 Poke = 5 // 可变Poke -} \ No newline at end of file +} diff --git a/src/core/types/user.ts b/src/core/types/user.ts index f6393ccd..921c709a 100644 --- a/src/core/types/user.ts +++ b/src/core/types/user.ts @@ -207,6 +207,7 @@ interface PhotoWall { // 简单信息 export interface SimpleInfo { + qqLevel?: QQLevel;//临时添加 uid?: string; uin?: string; coreInfo: CoreInfo; diff --git a/src/core/types/webapi.ts b/src/core/types/webapi.ts index c689d860..c9771cba 100644 --- a/src/core/types/webapi.ts +++ b/src/core/types/webapi.ts @@ -115,7 +115,7 @@ export interface GroupEssenceMsg { add_digest_uin: string; add_digest_nick: string; add_digest_time: number; - msg_content: unknown[]; + msg_content: { msg_type: number, text?: string, image_url?: string }[]; can_be_removed: true; } diff --git a/src/framework/napcat.ts b/src/framework/napcat.ts index d96e1ff3..8a8282ee 100644 --- a/src/framework/napcat.ts +++ b/src/framework/napcat.ts @@ -9,6 +9,8 @@ import { NodeIKernelLoginService } from '@/core/services'; import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper'; import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui'; import { NapCatOneBot11Adapter } from '@/onebot'; +import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg'; +import { FFmpegService } from '@/common/ffmpeg'; //Framework ES入口文件 export async function getWebUiUrl() { @@ -36,6 +38,15 @@ export async function NCoreInitFramework( const logger = new LogWrapper(pathWrapper.logsPath); const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); + if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) { + downloadFFmpegIfNotExists(logger).then(({ path, reset }) => { + if (reset && path) { + FFmpegService.setFfmpegPath(path, logger); + } + }).catch(e => { + logger.logError('[Ffmpeg] Error:', e); + }); + } //直到登录成功后,执行下一步 const selfInfo = await new Promise((resolveSelfInfo) => { const loginListener = new NodeIKernelLoginListener(); diff --git a/src/native/packet/MoeHoo.linux.arm64.node b/src/native/packet/MoeHoo.linux.arm64.node index d4ebb90f..4f248425 100644 Binary files a/src/native/packet/MoeHoo.linux.arm64.node and b/src/native/packet/MoeHoo.linux.arm64.node differ diff --git a/src/native/packet/MoeHoo.linux.x64.node b/src/native/packet/MoeHoo.linux.x64.node index 121dde27..98287ac2 100644 Binary files a/src/native/packet/MoeHoo.linux.x64.node and b/src/native/packet/MoeHoo.linux.x64.node differ diff --git a/src/onebot/action/OneBotAction.ts b/src/onebot/action/OneBotAction.ts index e4ec6ebf..818169e2 100644 --- a/src/onebot/action/OneBotAction.ts +++ b/src/onebot/action/OneBotAction.ts @@ -3,6 +3,7 @@ import Ajv, { ErrorObject, ValidateFunction } from 'ajv'; import { NapCatCore } from '@/core'; import { NapCatOneBot11Adapter, OB11Return } from '@/onebot'; import { NetworkAdapterConfig } from '../config/config'; +import { TSchema } from '@sinclair/typebox'; export class OB11Response { private static createResponse(data: T, status: string, retcode: number, message: string = '', echo: unknown = null): OB11Return { @@ -33,7 +34,7 @@ export abstract class OneBotAction { actionName: typeof ActionName[keyof typeof ActionName] = ActionName.Unknown; core: NapCatCore; private validate?: ValidateFunction = undefined; - payloadSchema?: unknown = undefined; + payloadSchema?: TSchema = undefined; obContext: NapCatOneBot11Adapter; constructor(obContext: NapCatOneBot11Adapter, core: NapCatCore) { @@ -43,7 +44,7 @@ export abstract class OneBotAction { protected async check(payload: PayloadType): Promise { if (this.payloadSchema) { - this.validate = new Ajv({ allowUnionTypes: true, useDefaults: true }).compile(this.payloadSchema); + this.validate = new Ajv({ allowUnionTypes: true, useDefaults: true, coerceTypes: true }).compile(this.payloadSchema); } if (this.validate && !this.validate(payload)) { const errors = this.validate.errors as ErrorObject[]; diff --git a/src/onebot/action/extends/MoveGroupFile.ts b/src/onebot/action/extends/MoveGroupFile.ts new file mode 100644 index 00000000..110551bc --- /dev/null +++ b/src/onebot/action/extends/MoveGroupFile.ts @@ -0,0 +1,33 @@ +import { ActionName } from '@/onebot/action/router'; +import { FileNapCatOneBotUUID } from '@/common/file-uuid'; +import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const SchemaData = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()]), + file_id: Type.String(), + current_parent_directory: Type.String(), + target_parent_directory: Type.String(), +}); + +type Payload = Static; + +interface MoveGroupFileResponse { + ok: boolean; +} + +export class MoveGroupFile extends GetPacketStatusDepends { + override actionName = ActionName.MoveGroupFile; + override payloadSchema = SchemaData; + + async _handle(payload: Payload) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + await this.core.apis.PacketApi.pkt.operation.MoveGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.target_parent_directory); + return { + ok: true, + }; + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/src/onebot/action/extends/RenameGroupFile.ts b/src/onebot/action/extends/RenameGroupFile.ts new file mode 100644 index 00000000..a567ea55 --- /dev/null +++ b/src/onebot/action/extends/RenameGroupFile.ts @@ -0,0 +1,33 @@ +import { ActionName } from '@/onebot/action/router'; +import { FileNapCatOneBotUUID } from '@/common/file-uuid'; +import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const SchemaData = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()]), + file_id: Type.String(), + current_parent_directory: Type.String(), + new_name: Type.String(), +}); + +type Payload = Static; + +interface RenameGroupFileResponse { + ok: boolean; +} + +export class RenameGroupFile extends GetPacketStatusDepends { + override actionName = ActionName.RenameGroupFile; + override payloadSchema = SchemaData; + + async _handle(payload: Payload) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + await this.core.apis.PacketApi.pkt.operation.RenameGroupFile(+payload.group_id, contextMsgFile.fileUUID, payload.current_parent_directory, payload.new_name); + return { + ok: true, + }; + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/src/onebot/action/extends/SetSpecialTittle.ts b/src/onebot/action/extends/SetSpecialTitle.ts similarity index 85% rename from src/onebot/action/extends/SetSpecialTittle.ts rename to src/onebot/action/extends/SetSpecialTitle.ts index e344180b..7d68ff36 100644 --- a/src/onebot/action/extends/SetSpecialTittle.ts +++ b/src/onebot/action/extends/SetSpecialTitle.ts @@ -10,8 +10,8 @@ const SchemaData = Type.Object({ type Payload = Static; -export class SetSpecialTittle extends GetPacketStatusDepends { - override actionName = ActionName.SetSpecialTittle; +export class SetSpecialTitle extends GetPacketStatusDepends { + override actionName = ActionName.SetSpecialTitle; override payloadSchema = SchemaData; async _handle(payload: Payload) { diff --git a/src/onebot/action/extends/TransGroupFile.ts b/src/onebot/action/extends/TransGroupFile.ts new file mode 100644 index 00000000..35b3275a --- /dev/null +++ b/src/onebot/action/extends/TransGroupFile.ts @@ -0,0 +1,34 @@ +import { ActionName } from '@/onebot/action/router'; +import { FileNapCatOneBotUUID } from '@/common/file-uuid'; +import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus'; +import { Static, Type } from '@sinclair/typebox'; + +const SchemaData = Type.Object({ + group_id: Type.Union([Type.Number(), Type.String()]), + file_id: Type.String(), +}); + +type Payload = Static; + +interface TransGroupFileResponse { + ok: boolean; +} + +export class TransGroupFile extends GetPacketStatusDepends { + override actionName = ActionName.TransGroupFile; + override payloadSchema = SchemaData; + + async _handle(payload: Payload) { + const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id) || FileNapCatOneBotUUID.decodeModelId(payload.file_id); + if (contextMsgFile?.fileUUID) { + const result = await this.core.apis.GroupApi.transGroupFile(payload.group_id.toString(), contextMsgFile.fileUUID); + if (result.transGroupFileResult.result.retCode === 0) { + return { + ok: true + }; + } + throw new Error(result.transGroupFileResult.result.retMsg); + } + throw new Error('real fileUUID not found!'); + } +} diff --git a/src/onebot/action/go-cqhttp/GetForwardMsg.ts b/src/onebot/action/go-cqhttp/GetForwardMsg.ts index 5552229b..da7f960f 100644 --- a/src/onebot/action/go-cqhttp/GetForwardMsg.ts +++ b/src/onebot/action/go-cqhttp/GetForwardMsg.ts @@ -4,14 +4,14 @@ import { ActionName } from '@/onebot/action/router'; import { MessageUnique } from '@/common/message-unique'; import { Static, Type } from '@sinclair/typebox'; import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from '@/core'; +import { isNumeric } from '@/common/helper'; const SchemaData = Type.Object({ - message_id: Type.Optional(Type.Union([Type.Number(), Type.String()])), - id: Type.Optional(Type.Union([Type.Number(), Type.String()])), + message_id: Type.Optional(Type.String()), + id: Type.Optional(Type.String()), }); type Payload = Static; - export class GoCQHTTPGetForwardMsgAction extends OneBotAction { @@ -53,19 +53,21 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction { + // 2. 定义辅助函数 - 创建伪转发消息对象 + const createFakeForwardMsg = (resId: string): RawMessage => { return { chatType: ChatType.KCHATTYPEGROUP, elements: [{ elementType: ElementType.MULTIFORWARD, elementId: '', multiForwardMsgElement: { - resId: res_id, + resId: resId, fileName: '', xmlContent: '', } @@ -96,8 +98,9 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction { - const ob = (await this.obContext.apis.MsgApi.parseMessageV2(fakeForwardMsg(res_id)))?.arrayMsg; + // 3. 定义协议回退逻辑函数 + const protocolFallbackLogic = async (resId: string) => { + const ob = (await this.obContext.apis.MsgApi.parseMessageV2(createFakeForwardMsg(resId)))?.arrayMsg; if (ob) { return { messages: (ob?.message?.[0] as OB11MessageForward)?.data?.content @@ -105,31 +108,37 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction 0) { + const singleMsg = data.msgList[0]; + if (!singleMsg) { + throw new Error('消息不存在或已过期'); + } + // 6. 解析消息内容 + const resMsg = (await this.obContext.apis.MsgApi.parseMessageV2(singleMsg))?.arrayMsg; - // return { message: resMsg }; + const forwardContent = (resMsg?.message?.[0] as OB11MessageForward)?.data?.content; + if (forwardContent) { + return { messages: forwardContent }; + } + } + } + // 说明消息已过期或者为内层消息 NapCat 一次返回不处理内层消息 + throw new Error('消息已过期或者为内层消息,无法获取转发消息'); } } diff --git a/src/onebot/action/go-cqhttp/GetFriendMsgHistory.ts b/src/onebot/action/go-cqhttp/GetFriendMsgHistory.ts index ef3db8d0..28e32c4e 100644 --- a/src/onebot/action/go-cqhttp/GetFriendMsgHistory.ts +++ b/src/onebot/action/go-cqhttp/GetFriendMsgHistory.ts @@ -11,10 +11,10 @@ interface Response { messages: OB11Message[]; } const SchemaData = Type.Object({ - user_id: Type.Union([Type.Number(), Type.String()]), - message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])), - count: Type.Union([Type.Number(), Type.String()], { default: 20 }), - reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()])) + user_id: Type.String(), + message_seq: Type.Optional(Type.String()), + count: Type.Number({ default: 20 }), + reverseOrder: Type.Boolean({ default: false }) }); @@ -27,18 +27,14 @@ export default class GetFriendMsgHistory extends OneBotAction async _handle(payload: Payload, _adapter: string, config: NetworkAdapterConfig): Promise { //处理参数 const uid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); - - const isReverseOrder = typeof payload.reverseOrder === 'string' ? payload.reverseOrder === 'true' : !!payload.reverseOrder; if (!uid) throw new Error(`记录${payload.user_id}不存在`); const friend = await this.core.apis.FriendApi.isBuddy(uid); const peer = { chatType: friend ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid: uid }; const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; const msgList = hasMessageSeq ? - (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; + (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); - //翻转消息 - if (isReverseOrder) msgList.reverse(); //转换序号 await Promise.all(msgList.map(async msg => { msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId); diff --git a/src/onebot/action/go-cqhttp/GetGroupMsgHistory.ts b/src/onebot/action/go-cqhttp/GetGroupMsgHistory.ts index e2fdf4e8..5dcbedb1 100644 --- a/src/onebot/action/go-cqhttp/GetGroupMsgHistory.ts +++ b/src/onebot/action/go-cqhttp/GetGroupMsgHistory.ts @@ -11,10 +11,10 @@ interface Response { } const SchemaData = Type.Object({ - group_id: Type.Union([Type.Number(), Type.String()]), - message_seq: Type.Optional(Type.Union([Type.Number(), Type.String()])), - count: Type.Union([Type.Number(), Type.String()], { default: 20 }), - reverseOrder: Type.Optional(Type.Union([Type.Boolean(), Type.String()])) + group_id: Type.String(), + message_seq: Type.Optional(Type.String()), + count: Type.Number({ default: 20 }), + reverseOrder: Type.Boolean({ default: false }) }); @@ -26,17 +26,13 @@ export default class GoCQHTTPGetGroupMsgHistory extends OneBotAction { - //处理参数 - const isReverseOrder = typeof payload.reverseOrder === 'string' ? payload.reverseOrder === 'true' : !!payload.reverseOrder; const peer: Peer = { chatType: ChatType.KCHATTYPEGROUP, peerUid: payload.group_id.toString() }; const hasMessageSeq = !payload.message_seq ? !!payload.message_seq : !(payload.message_seq?.toString() === '' || payload.message_seq?.toString() === '0'); //拉取消息 const startMsgId = hasMessageSeq ? (MessageUnique.getMsgIdAndPeerByShortId(+payload.message_seq!)?.MsgId ?? payload.message_seq!.toString()) : '0'; const msgList = hasMessageSeq ? - (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; + (await this.core.apis.MsgApi.getMsgHistory(peer, startMsgId, +payload.count, payload.reverseOrder)).msgList : (await this.core.apis.MsgApi.getAioFirstViewLatestMsgs(peer, +payload.count)).msgList; if (msgList.length === 0) throw new Error(`消息${payload.message_seq}不存在`); - //翻转消息 - if (isReverseOrder) msgList.reverse(); //转换序号 await Promise.all(msgList.map(async msg => { msg.id = MessageUnique.createUniqueMsgId({ guildId: '', chatType: msg.chatType, peerUid: msg.peerUid }, msg.msgId); diff --git a/src/onebot/action/go-cqhttp/GetStrangerInfo.ts b/src/onebot/action/go-cqhttp/GetStrangerInfo.ts index c8d53b0a..796f67f3 100644 --- a/src/onebot/action/go-cqhttp/GetStrangerInfo.ts +++ b/src/onebot/action/go-cqhttp/GetStrangerInfo.ts @@ -7,6 +7,7 @@ import { Static, Type } from '@sinclair/typebox'; const SchemaData = Type.Object({ user_id: Type.Union([Type.Number(), Type.String()]), + no_cache: Type.Union([Type.Boolean(), Type.String()], { default: false }), }); type Payload = Static; @@ -16,10 +17,11 @@ export default class GoCQHTTPGetStrangerInfo extends OneBotAction; diff --git a/src/onebot/action/go-cqhttp/UploadGroupFile.ts b/src/onebot/action/go-cqhttp/UploadGroupFile.ts index 906d7e91..5c636e16 100644 --- a/src/onebot/action/go-cqhttp/UploadGroupFile.ts +++ b/src/onebot/action/go-cqhttp/UploadGroupFile.ts @@ -38,6 +38,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction deleteAfterSentFiles: [] }; const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id); + msgContext.deleteAfterSentFiles.push(downloadResult.path); await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles); return null; } diff --git a/src/onebot/action/go-cqhttp/UploadPrivateFile.ts b/src/onebot/action/go-cqhttp/UploadPrivateFile.ts index f17e3edf..1a37a21f 100644 --- a/src/onebot/action/go-cqhttp/UploadPrivateFile.ts +++ b/src/onebot/action/go-cqhttp/UploadPrivateFile.ts @@ -23,7 +23,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction { const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString()); return { ...data, + group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0, group_remark: '', group_id: +payload.group_id, group_name: data.groupName, diff --git a/src/onebot/action/group/GetGroupMemberInfo.ts b/src/onebot/action/group/GetGroupMemberInfo.ts index 00e277c9..03938f27 100644 --- a/src/onebot/action/group/GetGroupMemberInfo.ts +++ b/src/onebot/action/group/GetGroupMemberInfo.ts @@ -32,7 +32,7 @@ class GetGroupMemberInfo extends OneBotAction { const [member, info] = await Promise.all([ this.core.apis.GroupApi.getGroupMemberEx(payload.group_id.toString(), uid, isNocache), - this.core.apis.UserApi.getUserDetailInfo(uid), + this.core.apis.UserApi.getUserDetailInfo(uid, isNocache), ]); if (!member || !groupMember) throw new Error(`群(${payload.group_id})成员${payload.user_id}不存在`); diff --git a/src/onebot/action/index.ts b/src/onebot/action/index.ts index fd2f74f9..37dc07f5 100644 --- a/src/onebot/action/index.ts +++ b/src/onebot/action/index.ts @@ -81,7 +81,7 @@ import { GetGroupSystemMsg } from './system/GetSystemMsg'; import { GroupPoke } from './group/GroupPoke'; import { GetUserStatus } from './extends/GetUserStatus'; import { GetRkey } from './extends/GetRkey'; -import { SetSpecialTittle } from './extends/SetSpecialTittle'; +import { SetSpecialTitle } from './extends/SetSpecialTitle'; import { GetGroupShutList } from './group/GetGroupShutList'; import { GetGroupMemberList } from './group/GetGroupMemberList'; import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl'; @@ -109,10 +109,24 @@ import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton'; import { GetPrivateFileUrl } from './file/GetPrivateFileUrl'; import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList'; import SetGroupRemark from './extends/SetGroupRemark'; +import { MoveGroupFile } from './extends/MoveGroupFile'; +import { TransGroupFile } from './extends/TransGroupFile'; +import { RenameGroupFile } from './extends/RenameGroupFile'; +import { GetRkeyServer } from './packet/GetRkeyServer'; +import { GetRkeyEx } from './packet/GetRkeyEx'; +import { CleanCache } from './system/CleanCache'; +import SetFriendRemark from './user/SetFriendRemark'; +import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest'; +import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest'; export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) { const actionHandlers = [ + new SetDoubtFriendsAddRequest(obContext, core), + new GetDoubtFriendsAddRequest(obContext, core), + new SetFriendRemark(obContext, core), + new GetRkeyEx(obContext, core), + new GetRkeyServer(obContext, core), new SetGroupRemark(obContext, core), new GetGroupInfoEx(obContext, core), new FetchEmojiLike(obContext, core), @@ -132,6 +146,9 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo new SetGroupSign(obContext, core), new SendGroupSign(obContext, core), new GetClientkey(obContext, core), + new MoveGroupFile(obContext, core), + new RenameGroupFile(obContext, core), + new TransGroupFile(obContext, core), // onebot11 new SendLike(obContext, core), new GetMsg(obContext, core), @@ -215,7 +232,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo new FriendPoke(obContext, core), new GetUserStatus(obContext, core), new GetRkey(obContext, core), - new SetSpecialTittle(obContext, core), + new SetSpecialTitle(obContext, core), new SetDiyOnlineStatus(obContext, core), // new UploadForwardMsg(obContext, core), new GetGroupShutList(obContext, core), @@ -231,6 +248,7 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo new ClickInlineKeyboardButton(obContext, core), new GetPrivateFileUrl(obContext, core), new GetUnidirectionalFriendList(obContext, core), + new CleanCache(obContext, core), ]; type HandlerUnion = typeof actionHandlers[number]; diff --git a/src/onebot/action/msg/SendMsg.ts b/src/onebot/action/msg/SendMsg.ts index 26a93ad2..bb17807c 100644 --- a/src/onebot/action/msg/SendMsg.ts +++ b/src/onebot/action/msg/SendMsg.ts @@ -38,7 +38,7 @@ export function normalize(message: OB11MessageMixType, autoEscape = false): OB11 export async function createContext(core: NapCatCore, payload: OB11PostContext | undefined, contextMode: ContextMode = ContextMode.Normal): Promise { if (!payload) { - throw new Error('请指定 group_id 或 user_id'); + throw new Error('请传递请求内容'); } if ((contextMode === ContextMode.Group || contextMode === ContextMode.Normal) && payload.group_id) { return { @@ -48,7 +48,16 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext | } if ((contextMode === ContextMode.Private || contextMode === ContextMode.Normal) && payload.user_id) { const Uid = await core.apis.UserApi.getUidByUinV2(payload.user_id.toString()); - if (!Uid) throw new Error('无法获取用户信息'); + if (!Uid) { + if (payload.group_id) { + return { + chatType: ChatType.KCHATTYPEGROUP, + peerUid: payload.group_id.toString(), + guildId: '' + } + } + throw new Error('无法获取用户信息'); + } const isBuddy = await core.apis.FriendApi.isBuddy(Uid); if (!isBuddy) { const ret = await core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, Uid); @@ -78,7 +87,13 @@ export async function createContext(core: NapCatCore, payload: OB11PostContext | guildId: '', }; } - throw new Error('请指定 group_id 或 user_id'); + if (contextMode === ContextMode.Private && payload.group_id) { + throw new Error('当前私聊发送,请指定 user_id 而不是 group_id'); + } + if (contextMode === ContextMode.Group && payload.user_id) { + throw new Error('当前群聊发送,请指定 group_id 而不是 user_id'); + } + throw new Error('请指定正确的 group_id 或 user_id'); } function getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType): number { diff --git a/src/onebot/action/new/GetDoubtFriendsAddRequest.ts b/src/onebot/action/new/GetDoubtFriendsAddRequest.ts new file mode 100644 index 00000000..7b8ae921 --- /dev/null +++ b/src/onebot/action/new/GetDoubtFriendsAddRequest.ts @@ -0,0 +1,18 @@ +import { OneBotAction } from '@/onebot/action/OneBotAction'; +import { ActionName } from '@/onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const SchemaData = Type.Object({ + count: Type.Number({ default: 50 }), +}); + +type Payload = Static; + +export class GetDoubtFriendsAddRequest extends OneBotAction { + override actionName = ActionName.GetDoubtFriendsAddRequest; + override payloadSchema = SchemaData; + + async _handle(payload: Payload) { + return await this.core.apis.FriendApi.getDoubtFriendRequest(payload.count); + } +} diff --git a/src/onebot/action/new/SetDoubtFriendsAddRequest.ts b/src/onebot/action/new/SetDoubtFriendsAddRequest.ts new file mode 100644 index 00000000..990d5607 --- /dev/null +++ b/src/onebot/action/new/SetDoubtFriendsAddRequest.ts @@ -0,0 +1,21 @@ +import { OneBotAction } from '@/onebot/action/OneBotAction'; +import { ActionName } from '@/onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const SchemaData = Type.Object({ + flag: Type.String(), + //注意强制String 非isNumeric 不遵守则不符合设计 + approve: Type.Boolean({ default: true }), + //该字段没有语义 仅做保留 强制为True +}); + +type Payload = Static; + +export class SetDoubtFriendsAddRequest extends OneBotAction { + override actionName = ActionName.SetDoubtFriendsAddRequest; + override payloadSchema = SchemaData; + + async _handle(payload: Payload) { + return await this.core.apis.FriendApi.handleDoubtFriendRequest(payload.flag); + } +} diff --git a/src/onebot/action/packet/GetRkeyEx.ts b/src/onebot/action/packet/GetRkeyEx.ts new file mode 100644 index 00000000..d330b8ee --- /dev/null +++ b/src/onebot/action/packet/GetRkeyEx.ts @@ -0,0 +1,18 @@ +import { ActionName } from '@/onebot/action/router'; +import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus'; + +export class GetRkeyEx extends GetPacketStatusDepends { + override actionName = ActionName.GetRkeyEx; + + async _handle() { + let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey(); + return rkeys.map(rkey => { + return { + type: rkey.type === 10 ? "private" : "group", + rkey: rkey.rkey, + created_at: rkey.time, + ttl: rkey.ttl, + }; + }); + } +} \ No newline at end of file diff --git a/src/onebot/action/packet/GetRkeyServer.ts b/src/onebot/action/packet/GetRkeyServer.ts new file mode 100644 index 00000000..ebfa7049 --- /dev/null +++ b/src/onebot/action/packet/GetRkeyServer.ts @@ -0,0 +1,38 @@ +import { ActionName } from '@/onebot/action/router'; +import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus'; + +export class GetRkeyServer extends GetPacketStatusDepends { + override actionName = ActionName.GetRkeyServer; + + private rkeyCache: { + private_rkey?: string; + group_rkey?: string; + expired_time?: number; + name: string; + } | null = null; + private expiryTime: number | null = null; + + async _handle() { + // 检查缓存是否有效 + if (this.expiryTime && this.expiryTime > Math.floor(Date.now() / 1000) && this.rkeyCache) { + return this.rkeyCache; + } + + // 获取新的 Rkey + let rkeys = await this.core.apis.PacketApi.pkt.operation.FetchRkey(); + let privateRkeyItem = rkeys.filter(rkey => rkey.type === 10)[0]; + let groupRkeyItem = rkeys.filter(rkey => rkey.type === 20)[0]; + + this.expiryTime = Math.floor(Date.now() / 1000) + Math.min(+groupRkeyItem!.ttl.toString(),+privateRkeyItem!.ttl.toString()); + + // 更新缓存 + this.rkeyCache = { + private_rkey: privateRkeyItem ? privateRkeyItem.rkey : undefined, + group_rkey: groupRkeyItem ? groupRkeyItem.rkey : undefined, + expired_time: this.expiryTime, + name: "NapCat 4" + }; + + return this.rkeyCache; + } +} \ No newline at end of file diff --git a/src/onebot/action/router.ts b/src/onebot/action/router.ts index 51b3892e..6abfe642 100644 --- a/src/onebot/action/router.ts +++ b/src/onebot/action/router.ts @@ -10,6 +10,12 @@ export interface InvalidCheckResult { } export const ActionName = { + // new extends 完全差异OneBot类别 + GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request', + SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request', + // napcat + GetRkeyEx: 'get_rkey', + GetRkeyServer: 'get_rkey_server', SetGroupRemark: 'set_group_remark', NapCat_GetPrivateFileUrl: 'get_private_file_url', ClickInlineKeyboardButton: 'click_inline_keyboard_button', @@ -31,8 +37,9 @@ export const ActionName = { SetGroupCard: 'set_group_card', SetGroupName: 'set_group_name', SetGroupLeave: 'set_group_leave', - SetSpecialTittle: 'set_group_special_title', + SetSpecialTitle: 'set_group_special_title', SetFriendAddRequest: 'set_friend_add_request', + SetFriendRemark: 'set_friend_remark', SetGroupAddRequest: 'set_group_add_request', GetLoginInfo: 'get_login_info', GoCQHTTP_GetStrangerInfo: 'get_stranger_info', @@ -52,7 +59,7 @@ export const ActionName = { GetStatus: 'get_status', GetVersionInfo: 'get_version_info', // Reboot : 'set_restart', - // CleanCache : 'clean_cache', + CleanCache : 'clean_cache', Exit: 'bot_exit', // go-cqhttp SetQQProfile: 'set_qq_profile', @@ -130,6 +137,10 @@ export const ActionName = { GetRkey: 'nc_get_rkey', GetGroupShutList: 'get_group_shut_list', + MoveGroupFile: 'move_group_file', + TransGroupFile: 'trans_group_file', + RenameGroupFile: 'rename_group_file', + GetGuildList: 'get_guild_list', GetGuildProfile: 'get_guild_service_profile', diff --git a/src/onebot/action/system/CleanCache.ts b/src/onebot/action/system/CleanCache.ts new file mode 100644 index 00000000..d583c736 --- /dev/null +++ b/src/onebot/action/system/CleanCache.ts @@ -0,0 +1,38 @@ +import { OneBotAction } from '@/onebot/action/OneBotAction'; +import { ActionName } from '@/onebot/action/router'; +import { unlink, readdir } from 'fs/promises'; +import { join } from 'path'; + +export class CleanCache extends OneBotAction { + override actionName = ActionName.CleanCache; + + async _handle() { + try { + // 获取临时文件夹路径 + const tempPath = this.core.NapCatTempPath; + + // 读取文件夹中的所有文件 + const files = await readdir(tempPath); + + // 删除每个文件 + const deletePromises = files.map(async (file) => { + const filePath = join(tempPath, file); + try { + await unlink(filePath); + this.core.context.logger.log(`已删除文件: ${filePath}`); + } catch (err: unknown) { + this.core.context.logger.log(`删除文件 ${filePath} 失败: ${(err as Error).message}`); + + } + }); + + // 等待所有删除操作完成 + await Promise.all(deletePromises); + + this.core.context.logger.log(`临时文件夹清理完成: ${tempPath}`); + } catch (err: unknown) { + this.core.context.logger.log(`清理缓存失败: ${(err as Error).message}`); + throw err; + } + } +} \ No newline at end of file diff --git a/src/onebot/action/user/GetFriendList.ts b/src/onebot/action/user/GetFriendList.ts index 263c189d..6850f0f6 100644 --- a/src/onebot/action/user/GetFriendList.ts +++ b/src/onebot/action/user/GetFriendList.ts @@ -14,8 +14,22 @@ export default class GetFriendList extends OneBotAction { override actionName = ActionName.GetFriendList; override payloadSchema = SchemaData; - async _handle(payload: Payload) { - //全新逻辑 - return OB11Construct.friends(await this.core.apis.FriendApi.getBuddy(typeof payload.no_cache === 'string' ? payload.no_cache === 'true' : !!payload.no_cache)); + async _handle(_payload: Payload) { + const buddyMap = await this.core.apis.FriendApi.getBuddyV2SimpleInfoMap(); + const isNocache = typeof _payload.no_cache === 'string' ? _payload.no_cache === 'true' : !!_payload.no_cache; + await Promise.all( + Array.from(buddyMap.values()).map(async (buddyInfo) => { + try { + const userDetail = await this.core.apis.UserApi.getUserDetailInfo(buddyInfo.coreInfo.uid, isNocache); + const data = buddyMap.get(buddyInfo.coreInfo.uid); + if (data) { + data.qqLevel = userDetail.qqLevel; + } + } catch (error) { + this.core.context.logger.logError('获取好友详细信息失败', error); + } + }) + ); + return OB11Construct.friends(Array.from(buddyMap.values())); } -} +} \ No newline at end of file diff --git a/src/onebot/action/user/SetFriendRemark.ts b/src/onebot/action/user/SetFriendRemark.ts new file mode 100644 index 00000000..5cc3559c --- /dev/null +++ b/src/onebot/action/user/SetFriendRemark.ts @@ -0,0 +1,25 @@ +import { OneBotAction } from '@/onebot/action/OneBotAction'; +import { ActionName } from '@/onebot/action/router'; +import { Static, Type } from '@sinclair/typebox'; + +const SchemaData = Type.Object({ + user_id: Type.String(), + remark: Type.String() +}); + +type Payload = Static; + +export default class SetFriendRemark extends OneBotAction { + override actionName = ActionName.SetFriendRemark; + override payloadSchema = SchemaData; + + async _handle(payload: Payload): Promise { + let friendUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id); + let is_friend = await this.core.apis.FriendApi.isBuddy(friendUid); + if (!is_friend) { + throw new Error(`用户 ${payload.user_id} 不是好友`); + } + await this.core.apis.FriendApi.setBuddyRemark(friendUid, payload.remark); + return null; + } +} diff --git a/src/onebot/api/group.ts b/src/onebot/api/group.ts index 6f2f4314..0badb5f1 100644 --- a/src/onebot/api/group.ts +++ b/src/onebot/api/group.ts @@ -114,7 +114,6 @@ export class OneBotGroupApi { async parseCardChangedEvent(msg: RawMessage) { if (msg.senderUin && msg.senderUin !== '0') { const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin); - await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid); if (member && member.cardName !== msg.sendMemberName) { const newCardName = msg.sendMemberName ?? ''; const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName); @@ -130,7 +129,6 @@ export class OneBotGroupApi { async parsePaiYiPai(msg: RawMessage, jsonStr: string) { const json = JSON.parse(jsonStr); - //判断业务类型 //Poke事件 const pokedetail: Array<{ uid: string }> = json.items; @@ -151,14 +149,15 @@ export class OneBotGroupApi { async parseOtherJsonEvent(msg: RawMessage, jsonStr: string, context: InstanceContext) { const json = JSON.parse(jsonStr); const type = json.items[json.items.length - 1]?.txt; + await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid); if (type === '头衔') { const memberUin = json.items[1].param[0]; const title = json.items[3].txt; context.logger.logDebug('收到群成员新头衔消息', json); return new OB11GroupTitleEvent( this.core, - parseInt(msg.peerUid), - parseInt(memberUin), + +msg.peerUid, + +memberUin, title, ); } else if (type === '移出') { @@ -251,7 +250,34 @@ export class OneBotGroupApi { 'invite' ); } - + async parse51TypeEvent(msg: RawMessage, grayTipElement: GrayTipElement) { + // 神经腾讯 没了妈妈想出来的 + // Warn 下面存在高并发危险 + if (grayTipElement.jsonGrayTipElement.jsonStr) { + const json: { + align: string, + items: Array<{ txt: string, type: string }> + } = JSON.parse(grayTipElement.jsonGrayTipElement.jsonStr); + if (json.items.length === 1 && json.items[0]?.txt.endsWith('加入群')) { + let old_members = structuredClone(this.core.apis.GroupApi.groupMemberCache.get(msg.peerUid)); + if (!old_members) return; + let new_members_map = await this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid, true); + if (!new_members_map) return; + let new_members = Array.from(new_members_map.values()); + // 对比members查找新成员 + let new_member = new_members.find((member) => old_members.get(member.uid) == undefined); + if (!new_member) return; + return new OB11GroupIncreaseEvent( + this.core, + +msg.peerUid, + +new_member.uin, + 0, + 'invite', + ); + } + } + return; + } async parseGrayTipElement(msg: RawMessage, grayTipElement: GrayTipElement) { if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) { // 解析群组事件 由sysmsg解析 @@ -283,6 +309,9 @@ export class OneBotGroupApi { return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr); } else if (grayTipElement.jsonGrayTipElement.busiId == JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP) { return await this.parseEssenceMsg(msg, grayTipElement.jsonGrayTipElement.jsonStr); + } else if (+(grayTipElement.jsonGrayTipElement.busiId ?? 0) == 51) { + // 51是什么?{"align":"center","items":[{"txt":"下一秒起床通过王者荣耀加入群","type":"nor"}] + return await this.parse51TypeEvent(msg, grayTipElement); } else { return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context); } diff --git a/src/onebot/api/msg.ts b/src/onebot/api/msg.ts index 73519ff1..ffef73b0 100644 --- a/src/onebot/api/msg.ts +++ b/src/onebot/api/msg.ts @@ -34,7 +34,7 @@ import { EventType } from '@/onebot/event/OneBotEvent'; import { encodeCQCode } from '@/onebot/helper/cqcode'; import { uriToLocalFile } from '@/common/file'; import { RequestUtil } from '@/common/request'; -import fsPromise, { constants } from 'node:fs/promises'; +import fsPromise from 'node:fs/promises'; import { OB11FriendAddNoticeEvent } from '@/onebot/event/notice/OB11FriendAddNoticeEvent'; import { ForwardMsgBuilder } from '@/common/forward-msg-builder'; import { NapProtoMsg } from '@napneko/nap-proto-core'; @@ -45,6 +45,7 @@ import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeE import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/packet/transformer/proto'; import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest'; import { LRUCache } from '@/common/lru-cache'; +import { cleanTaskQueue } from '@/common/clean-task'; type RawToOb11Converters = { [Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: ( @@ -372,7 +373,8 @@ export class OneBotMsgApi { try { multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId); } catch (e) { - this.core.context.logger.logError('Protocol FetchForwardMsg fallback failed!', e); + this.core.context.logger.logError(`Protocol FetchForwardMsg fallback failed! + element = ${JSON.stringify(element)} , error=${e})`); return null; } } @@ -465,6 +467,8 @@ export class OneBotMsgApi { replayMsgId: replyMsg.msgId, // raw.msgId senderUin: replyMsg.senderUin, senderUinStr: replyMsg.senderUin, + replyMsgClientSeq: replyMsg.clientSeq, + _replyMsgPeer: replyMsgM.Peer }, } : undefined; @@ -554,7 +558,7 @@ export class OneBotMsgApi { }, [OB11MessageDataType.voice]: async (sendMsg, context) => - this.core.apis.FileApi.createValidSendPttElement( + this.core.apis.FileApi.createValidSendPttElement(context, (await this.handleOb11FileLikeMessage(sendMsg, context)).path), [OB11MessageDataType.json]: async ({ data: { data } }) => ({ @@ -712,6 +716,56 @@ export class OneBotMsgApi { this.obContext = obContext; this.core = core; } + /** + * 解析带有JSON标记的文本 + * @param text 要解析的文本 + * @returns 解析后的结果数组,每个元素包含类型(text或json)和内容 + */ + parseTextWithJson(text: string) { + // 匹配<{...}>格式的JSON + const regex = /<(\{.*?\})>/g; + const parts: Array<{ type: 'text' | 'json', content: string | object }> = []; + let lastIndex = 0; + let match; + + // 查找所有匹配项 + while ((match = regex.exec(text)) !== null) { + // 添加匹配前的文本 + if (match.index > lastIndex) { + parts.push({ + type: 'text', + content: text.substring(lastIndex, match.index) + }); + } + + // 添加JSON部分 + try { + const jsonContent = JSON.parse(match[1] ?? ''); + parts.push({ + type: 'json', + content: jsonContent + }); + } catch (e) { + // 如果JSON解析失败,作为普通文本处理 + parts.push({ + type: 'text', + content: match[0] + }); + } + + lastIndex = regex.lastIndex; + } + + // 添加最后一部分文本 + if (lastIndex < text.length) { + parts.push({ + type: 'text', + content: text.substring(lastIndex) + }); + } + + return parts; + } async parsePrivateMsgEvent(msg: RawMessage, grayTipElement: GrayTipElement) { if (grayTipElement.subElementType == NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) { @@ -855,10 +909,10 @@ export class OneBotMsgApi { const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin); resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode); resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话'; - resMsg.temp_source = resMsg.group_id; + resMsg.temp_source = 0; } else { resMsg.group_id = 284840486; - resMsg.temp_source = resMsg.group_id; + resMsg.temp_source = 0; resMsg.sender.nickname = '临时会话'; } } @@ -970,7 +1024,6 @@ export class OneBotMsgApi { }); const timeout = 10000 + (totalSize / 1024 / 256 * 1000); - try { const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout); if (!returnMsg) throw new Error('发送消息失败'); @@ -983,18 +1036,19 @@ export class OneBotMsgApi { } catch (error) { throw new Error((error as Error).message); } finally { - setTimeout(async () => { - const deletePromises = deleteAfterSentFiles.map(async file => { - try { - if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) { - await fsPromise.unlink(file); - } - } catch (e) { - this.core.context.logger.logError('发送消息删除文件失败', e); - } - }); - await Promise.all(deletePromises); - }, 60000); + cleanTaskQueue.addFiles(deleteAfterSentFiles, timeout); + // setTimeout(async () => { + // const deletePromises = deleteAfterSentFiles.map(async file => { + // try { + // if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) { + // await fsPromise.unlink(file); + // } + // } catch (e) { + // this.core.context.logger.logError('发送消息删除文件失败', e); + // } + // }); + // await Promise.all(deletePromises); + // }, 60000); } } @@ -1053,6 +1107,8 @@ export class OneBotMsgApi { return 'kick'; case 3: return 'kick_me'; + case 129: + return 'disband'; default: return 'kick'; } @@ -1213,6 +1269,41 @@ export class OneBotMsgApi { } else if (SysMessage.contentHead.type == 528 && SysMessage.contentHead.subType == 39 && SysMessage.body?.msgContent) { return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent); } + // else if (SysMessage.contentHead.type == 732 && SysMessage.contentHead.subType == 16 && SysMessage.body?.msgContent) { + // let data_wrap = PBString(2); + // let user_wrap = PBUint64(5); + // let group_wrap = PBUint64(4); + + // ProtoBuf(class extends ProtoBufBase { + // group = group_wrap; + // content = ProtoBufIn(5, { data: data_wrap, user: user_wrap }); + // }).decode(SysMessage.body?.msgContent.slice(7)); + // let xml_data = UnWrap(data_wrap); + // let group = UnWrap(group_wrap).toString(); + // //let user = UnWrap(user_wrap).toString(); + // const parsedParts = this.parseTextWithJson(xml_data); + // //解析JSON + // if (parsedParts[1] && parsedParts[3]) { + // let set_user_id: string = (parsedParts[1].content as { data: string }).data; + // let uid = await this.core.apis.UserApi.getUidByUinV2(set_user_id); + // let new_title: string = (parsedParts[3].content as { text: string }).text; + // console.log(this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle, new_title) + // if (this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle == new_title) { + // return; + // } + // await this.core.apis.GroupApi.refreshGroupMemberCachePartial(group, uid); + // //let json_data_1_url_search = new URL((parsedParts[3].content as { url: string }).url).searchParams; + // //let is_new: boolean = json_data_1_url_search.get('isnew') === '1'; + + // //console.log(group, set_user_id, is_new, new_title); + // return new GroupMemberTitle( + // this.core, + // +group, + // +set_user_id, + // new_title + // ); + // } + // } return undefined; } } diff --git a/src/onebot/event/notice/OB11GroupDecreaseEvent.ts b/src/onebot/event/notice/OB11GroupDecreaseEvent.ts index 5a52664e..37a38b5e 100644 --- a/src/onebot/event/notice/OB11GroupDecreaseEvent.ts +++ b/src/onebot/event/notice/OB11GroupDecreaseEvent.ts @@ -1,7 +1,7 @@ import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent'; import { NapCatCore } from '@/core'; -export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me'; +export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me' | 'disband'; export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent { notice_type = 'group_decrease'; @@ -11,7 +11,7 @@ export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent { constructor(core: NapCatCore, groupId: number, userId: number, operatorId: number, subType: GroupDecreaseSubType = 'leave') { super(core, groupId, userId); this.group_id = groupId; - this.operator_id = operatorId; + this.operator_id = operatorId; this.user_id = userId; this.sub_type = subType; } diff --git a/src/onebot/helper/data.ts b/src/onebot/helper/data.ts index 8fa7f3cd..d9b0c0fa 100644 --- a/src/onebot/helper/data.ts +++ b/src/onebot/helper/data.ts @@ -20,16 +20,36 @@ export class OB11Construct { static friends(friends: FriendV2[]): OB11User[] { return friends.map(rawFriend => ({ - ...rawFriend.baseInfo, - ...rawFriend.coreInfo, + birthday_year: rawFriend.baseInfo.birthday_year, + birthday_month: rawFriend.baseInfo.birthday_month, + birthday_day: rawFriend.baseInfo.birthday_day, user_id: parseInt(rawFriend.coreInfo.uin), + age: rawFriend.baseInfo.age, + phone_num: rawFriend.baseInfo.phoneNum, + email: rawFriend.baseInfo.eMail, + category_id: rawFriend.baseInfo.categoryId, nickname: rawFriend.coreInfo.nick ?? '', remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick, sex: this.sex(rawFriend.baseInfo.sex), - level: 0, + level: rawFriend.qqLevel && calcQQLevel(rawFriend.qqLevel) || 0, })); } - + static friend(friends: FriendV2): OB11User { + return { + birthday_year: friends.baseInfo.birthday_year, + birthday_month: friends.baseInfo.birthday_month, + birthday_day: friends.baseInfo.birthday_day, + user_id: parseInt(friends.coreInfo.uin), + age: friends.baseInfo.age, + phone_num: friends.baseInfo.phoneNum, + email: friends.baseInfo.eMail, + category_id: friends.baseInfo.categoryId, + nickname: friends.coreInfo.nick ?? '', + remark: friends.coreInfo.remark ?? friends.coreInfo.nick, + sex: this.sex(friends.baseInfo.sex), + level: 0, + }; + } static groupMemberRole(role: number): OB11GroupMemberRole | undefined { return { 4: OB11GroupMemberRole.owner, @@ -73,6 +93,7 @@ export class OB11Construct { static group(group: Group): OB11Group { return { + group_all_shut: (+group.groupShutupExpireTime > 0 )? -1 : 0, group_remark: group.remarkName, group_id: +group.groupCode, group_name: group.groupName, diff --git a/src/onebot/index.ts b/src/onebot/index.ts index f2aec869..770d31be 100644 --- a/src/onebot/index.ts +++ b/src/onebot/index.ts @@ -101,7 +101,7 @@ export class NapCatOneBot11Adapter { const selfInfo = this.core.selfInfo; const ob11Config = this.configLoader.configData; - this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid) + this.core.apis.UserApi.getUserDetailInfo(selfInfo.uid, false) .then((user) => { selfInfo.nick = user.nick; this.context.logger.setLogSelfInfo(selfInfo); diff --git a/src/onebot/network/websocket-client.ts b/src/onebot/network/websocket-client.ts index 34fd6502..132a868e 100644 --- a/src/onebot/network/websocket-client.ts +++ b/src/onebot/network/websocket-client.ts @@ -37,7 +37,13 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter ', error); + + } + } close() { diff --git a/src/onebot/types/data.ts b/src/onebot/types/data.ts index 2957e22a..32e9bbb8 100644 --- a/src/onebot/types/data.ts +++ b/src/onebot/types/data.ts @@ -1,4 +1,10 @@ export interface OB11User { + birthday_year?: number; // 生日 + birthday_month?: number; // 生日 + birthday_day?: number; // 生日 + phone_num?: string; // 手机号 + email?: string; // 邮箱 + category_id?: number; // 分组ID user_id: number; // 用户ID nickname: string; // 昵称 remark?: string; // 备注 @@ -57,6 +63,7 @@ export interface OB11GroupMember { } export interface OB11Group { + group_all_shut: number; // 群全员禁言 group_remark: string; // 群备注 group_id: number; // 群ID group_name: string; // 群名称 diff --git a/src/shell/base.ts b/src/shell/base.ts index 51c730aa..17258697 100644 --- a/src/shell/base.ts +++ b/src/shell/base.ts @@ -31,7 +31,9 @@ import { WebUiDataRuntime } from '@/webui/src/helper/Data'; import { napCatVersion } from '@/common/version'; import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener'; import { sleep } from '@/common/helper'; - +import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg'; +import { FFmpegService } from '@/common/ffmpeg'; +import { connectToNamedPipe } from '@/shell/pipe'; // NapCat Shell App ES 入口文件 async function handleUncaughtExceptions(logger: LogWrapper) { process.on('uncaughtException', (err) => { @@ -139,6 +141,7 @@ async function handleLogin( loginListener.onLoginConnected = () => { waitForNetworkConnection(loginService, logger).then(() => { handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList).then().catch(e => logger.logError(e)); + loginListener.onLoginConnected = () => { }; }); } loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => { @@ -222,6 +225,11 @@ async function handleLoginInner(context: { isLogined: boolean }, logger: LogWrap }`); } loginService.getQRCodePicture(); + try { + await WebUiDataRuntime.runWebUiConfigQuickFunction(); + } catch (error) { + logger.logError('WebUi 快速登录失败 执行失败', error); + } } loginService.getLoginList().then((res) => { @@ -305,6 +313,16 @@ export async function NCoreInitShell() { const pathWrapper = new NapCatPathWrapper(); const logger = new LogWrapper(pathWrapper.logsPath); handleUncaughtExceptions(logger); + await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e)); + if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) { + downloadFFmpegIfNotExists(logger).then(({ path, reset }) => { + if (reset && path) { + FFmpegService.setFfmpegPath(path, logger); + } + }).catch(e => { + logger.logError('[Ffmpeg] Error:', e); + }); + } const basicInfoWrapper = new QQBasicInfoWrapper({ logger }); const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion()); diff --git a/src/shell/pipe.ts b/src/shell/pipe.ts new file mode 100644 index 00000000..bde201cd --- /dev/null +++ b/src/shell/pipe.ts @@ -0,0 +1,74 @@ +import { LogWrapper } from '@/common/log'; +import * as net from 'net'; +import * as process from 'process'; + +/** + * 连接到命名管道并重定向stdout + * @param logger 日志记录器 + * @param timeoutMs 连接超时时间(毫秒),默认5000ms + * @returns Promise,连接成功时resolve,失败时reject + */ +export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000): Promise<{ disconnect: () => void }> { + return new Promise((resolve, reject) => { + if (process.platform !== 'win32') { + logger.log('只有Windows平台支持命名管道'); + // 非Windows平台不reject,而是返回一个空的disconnect函数 + return resolve({ disconnect: () => { } }); + } + + const pid = process.pid; + const pipePath = `\\\\.\\pipe\\NapCat_${pid}`; + + // 设置连接超时 + const timeoutId = setTimeout(() => { + reject(new Error(`连接命名管道超时: ${pipePath}`)); + }, timeoutMs); + + try { + let originalStdoutWrite = process.stdout.write.bind(process.stdout); + const pipeSocket = net.connect(pipePath, () => { + // 清除超时 + clearTimeout(timeoutId); + + logger.log(`[StdOut] 已重定向到命名管道: ${pipePath}`); + process.stdout.write = ( + chunk: any, + encoding?: BufferEncoding | (() => void), + cb?: () => void + ): boolean => { + if (typeof encoding === 'function') { + cb = encoding; + encoding = undefined; + } + return pipeSocket.write(chunk, encoding as BufferEncoding, cb); + }; + // 提供断开连接的方法 + const disconnect = () => { + process.stdout.write = originalStdoutWrite; + pipeSocket.end(); + logger.log(`已手动断开命名管道连接: ${pipePath}`); + }; + + // 返回成功和断开连接的方法 + resolve({ disconnect }); + }); + + pipeSocket.on('error', (err) => { + clearTimeout(timeoutId); + process.stdout.write = originalStdoutWrite; + logger.log(`连接命名管道 ${pipePath} 时出错:`, err); + reject(err); + }); + + pipeSocket.on('end', () => { + process.stdout.write = originalStdoutWrite; + logger.log('命名管道连接已关闭'); + }); + + } catch (error) { + clearTimeout(timeoutId); + logger.log(`尝试连接命名管道 ${pipePath} 时发生异常:`, error); + reject(error); + } + }); +} \ No newline at end of file diff --git a/src/webui/index.ts b/src/webui/index.ts index 716e84fc..37e69b79 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -4,6 +4,7 @@ import express from 'express'; import { createServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; import { LogWrapper } from '@/common/log'; import { NapCatPathWrapper } from '@/common/path'; import { WebUiConfigWrapper } from '@webapi/helper/config'; @@ -13,11 +14,10 @@ import { createUrl } from '@webapi/utils/url'; import { sendError } from '@webapi/utils/response'; import { join } from 'node:path'; import { terminalManager } from '@webapi/terminal/terminal_manager'; -import multer from 'multer'; // 新增:引入multer用于错误捕获 +import multer from 'multer'; // 引入multer用于错误捕获 // 实例化Express const app = express(); -const server = createServer(app); /** * 初始化并启动WebUI服务。 * 该函数配置了Express服务器以支持JSON解析和静态文件服务,并监听6099端口。 @@ -29,6 +29,7 @@ export let webUiPathWrapper: NapCatPathWrapper; const MAX_PORT_TRY = 100; import * as net from 'node:net'; import { WebUiDataRuntime } from './src/helper/Data'; +import { existsSync, readFileSync } from 'node:fs'; export let webUiRuntimePort = 6099; export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> { try { @@ -40,7 +41,23 @@ export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, return ['', 0, '']; } } +async function checkCertificates(logger: LogWrapper): Promise<{ key: string, cert: string } | null> { + try { + const certPath = join(webUiPathWrapper.configPath, 'cert.pem'); + const keyPath = join(webUiPathWrapper.configPath, 'key.pem'); + if (existsSync(certPath) && existsSync(keyPath)) { + const cert = readFileSync(certPath, 'utf8'); + const key = readFileSync(keyPath, 'utf8'); + logger.log('[NapCat] [WebUi] 找到SSL证书,将启用HTTPS模式'); + return { cert, key }; + } + return null; + } catch (error) { + logger.log('[NapCat] [WebUi] 检查SSL证书时出错: ' + error); + return null; + } +} export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) { webUiPathWrapper = pathWrapper; WebUiConfig = new WebUiConfigWrapper(); @@ -50,20 +67,21 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp logger.log('[NapCat] [WebUi] Current WebUi is not run.'); return; } - setTimeout(async () => { - let autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount(); - if (autoLoginAccount) { - try { - const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount); - if (!result) { - throw new Error(message); + WebUiDataRuntime.setWebUiConfigQuickFunction( + async () => { + let autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount(); + if (autoLoginAccount) { + try { + const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount); + if (!result) { + throw new Error(message); + } + console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`); + } catch (error) { + console.log(`[NapCat] [WebUi] Auto login account failed.` + error); } - console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`); - } catch (error) { - console.log(`[NapCat] [WebUi] Auto login account failed.` + error); } - } - }, 30000); + }); // ------------注册中间件------------ // 使用express的json中间件 app.use(express.json()); @@ -106,6 +124,9 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp // 挂载静态路由(前端),路径为 /webui app.use('/webui', express.static(pathWrapper.staticPath)); // 初始化WebSocket服务器 + const sslCerts = await checkCertificates(logger); + const isHttps = !!sslCerts; + let server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app); server.on('upgrade', (request, socket, head) => { terminalManager.initialize(request, socket, head, logger); }); diff --git a/src/webui/src/api/Auth.ts b/src/webui/src/api/Auth.ts index 53e06315..5fb8c1f3 100644 --- a/src/webui/src/api/Auth.ts +++ b/src/webui/src/api/Auth.ts @@ -20,25 +20,26 @@ export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => { export const LoginHandler: RequestHandler = async (req, res) => { // 获取WebUI配置 const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); - // 获取请求体中的token - const { token } = req.body; + // 获取请求体中的hash + const { hash } = req.body; // 获取客户端IP const clientIP = req.ip || req.socket.remoteAddress || ''; // 如果token为空,返回错误信息 - if (isEmpty(token)) { + if (isEmpty(hash)) { return sendError(res, 'token is empty'); } // 检查登录频率 if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) { return sendError(res, 'login rate limit'); } - //验证config.token是否等于token - if (WebUiConfigData.token !== token) { + //验证config.token hash是否等于token hash + if (!AuthHelper.comparePasswordHash(WebUiConfigData.token, hash)) { return sendError(res, 'token is invalid'); } + // 签发凭证 - const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString( + const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(hash))).toString( 'base64' ); // 返回成功信息 diff --git a/src/webui/src/helper/Data.ts b/src/webui/src/helper/Data.ts index bafc5ba5..c151f003 100644 --- a/src/webui/src/helper/Data.ts +++ b/src/webui/src/helper/Data.ts @@ -25,6 +25,9 @@ const LoginRuntime: LoginRuntimeType = { NewQQLoginList: [], }, packageJson: packageJson, + WebUiConfigQuickFunction: async () => { + return; + } }; export const WebUiDataRuntime = { @@ -118,4 +121,11 @@ export const WebUiDataRuntime = { getQQVersion() { return LoginRuntime.QQVersion; }, + + setWebUiConfigQuickFunction(func: LoginRuntimeType['WebUiConfigQuickFunction']): void { + LoginRuntime.WebUiConfigQuickFunction = func; + }, + runWebUiConfigQuickFunction: async function () { + await LoginRuntime.WebUiConfigQuickFunction(); + } }; diff --git a/src/webui/src/helper/SignToken.ts b/src/webui/src/helper/SignToken.ts index 50865b19..495bad56 100644 --- a/src/webui/src/helper/SignToken.ts +++ b/src/webui/src/helper/SignToken.ts @@ -5,13 +5,13 @@ export class AuthHelper { /** * 签名凭证方法。 - * @param token 待签名的凭证字符串。 + * @param hash 待签名的凭证字符串。 * @returns 签名后的凭证对象。 */ - public static signCredential(token: string): WebUiCredentialJson { + public static signCredential(hash: string): WebUiCredentialJson { const innerJson: WebUiCredentialInnerJson = { CreatedTime: Date.now(), - TokenEncoded: token, + HashEncoded: hash, }; const jsonString = JSON.stringify(innerJson); const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex'); @@ -57,8 +57,7 @@ export class AuthHelper { const currentTime = Date.now() / 1000; const createdTime = credentialJson.Data.CreatedTime; const timeDifference = currentTime - createdTime; - - return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token; + return timeDifference <= 3600 && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token); } /** @@ -85,4 +84,23 @@ export class AuthHelper { return store.exists(`revoked:${hmac}`) > 0; } + + /** + * 生成密码Hash + * @param password 密码 + * @returns 生成的Hash值 + */ + public static generatePasswordHash(password: string): string { + return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex') + } + + /** + * 对比密码和Hash值 + * @param password 密码 + * @param hash Hash值 + * @returns 布尔值,表示密码是否匹配Hash值 + */ + public static comparePasswordHash(password: string, hash: string): boolean { + return this.generatePasswordHash(password) === hash; + } } diff --git a/src/webui/src/middleware/auth.ts b/src/webui/src/middleware/auth.ts index 67d73ecd..8e2d756c 100644 --- a/src/webui/src/middleware/auth.ts +++ b/src/webui/src/middleware/auth.ts @@ -21,17 +21,18 @@ export async function auth(req: Request, res: Response, next: NextFunction) { return sendError(res, 'Unauthorized'); } // 获取token - const token = authorization[1]; + const hash = authorization[1]; + if(!hash) return sendError(res, 'Unauthorized'); // 解析token let Credential: WebUiCredentialJson; try { - Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + Credential = JSON.parse(Buffer.from(hash, 'base64').toString('utf-8')); } catch (e) { return sendError(res, 'Unauthorized'); } // 获取配置 const config = await WebUiConfig.GetWebUIConfig(); - // 验证凭证在1小时内有效且token与原始token相同 + // 验证凭证在1小时内有效 const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); if (credentialJson) { // 通过验证 diff --git a/src/webui/src/types/data.d.ts b/src/webui/src/types/data.d.ts index fb9a644e..d881a4b0 100644 --- a/src/webui/src/types/data.d.ts +++ b/src/webui/src/types/data.d.ts @@ -9,6 +9,7 @@ interface LoginRuntimeType { QQLoginUin: string; QQLoginInfo: SelfInfo; QQVersion: string; + WebUiConfigQuickFunction: () => Promise; NapCatHelper: { onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>; onOB11ConfigChanged: (ob11: OneBotConfig) => Promise; diff --git a/src/webui/src/types/sign_token.d.ts b/src/webui/src/types/sign_token.d.ts index 5bd79b69..1b6514d1 100644 --- a/src/webui/src/types/sign_token.d.ts +++ b/src/webui/src/types/sign_token.d.ts @@ -1,6 +1,6 @@ interface WebUiCredentialInnerJson { CreatedTime: number; - TokenEncoded: string; + HashEncoded: string; } interface WebUiCredentialJson { diff --git a/vite.config.ts b/vite.config.ts index 673556b7..3be69089 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,6 @@ const external = [ 'silk-wasm', 'ws', 'express', - '@ffmpeg.wasm/core-mt', '@napi-rs/canvas', '@node-rs/jieba', '@node-rs/jieba/dict.js', @@ -103,7 +102,6 @@ const UniversalBaseConfig = () => entry: { napcat: 'src/universal/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', - 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], @@ -133,7 +131,6 @@ const ShellBaseConfig = () => entry: { napcat: 'src/shell/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', - 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'], @@ -163,7 +160,6 @@ const FrameworkBaseConfig = () => entry: { napcat: 'src/framework/napcat.ts', 'audio-worker': 'src/common/audio-worker.ts', - 'ffmpeg-worker': 'src/common/ffmpeg-worker.ts', 'worker/conoutSocketWorker': 'src/pty/worker/conoutSocketWorker.ts', }, formats: ['es'],