diff --git a/external/LiteLoaderWrapper.zip b/external/LiteLoaderWrapper.zip index 1e8419c6..b5cf0627 100644 Binary files a/external/LiteLoaderWrapper.zip and b/external/LiteLoaderWrapper.zip differ diff --git a/launcher/qqnt.json b/launcher/qqnt.json index 6bba1895..047c6bbb 100644 --- a/launcher/qqnt.json +++ b/launcher/qqnt.json @@ -1,9 +1,9 @@ { "name": "qq-chat", - "verHash": "cc326038", - "version": "9.9.21-39038", - "linuxVersion": "3.2.19-39038", - "linuxVerHash": "c773cdf7", + "verHash": "c50d6326", + "version": "9.9.22-40768", + "linuxVersion": "3.2.20-40768", + "linuxVerHash": "ab90fdfa", "private": true, "description": "QQ", "productName": "QQ", @@ -17,7 +17,7 @@ "qd": "externals/devtools/cli/index.js" }, "main": "./loadNapCat.js", - "buildVersion": "39038", + "buildVersion": "40768", "isPureShell": true, "isByteCodeShell": true, "platform": "win32", diff --git a/manifest.json b/manifest.json index 88000501..2fe1a7ea 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "name": "NapCatQQ", "slug": "NapCat.Framework", "description": "高性能的 OneBot 11 协议实现", - "version": "4.8.119", + "version": "4.8.124", "icon": "./logo.png", "authors": [ { diff --git a/package.json b/package.json index afe892cc..d578b802 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "napcat", "private": true, "type": "module", - "version": "4.8.119", + "version": "4.8.124", "scripts": { "build:universal": "npm run build:webui && vite build --mode universal || exit 1", "build:framework": "npm run build:webui && vite build --mode framework || exit 1", diff --git a/src/common/health.ts b/src/common/health.ts index 166abaeb..139cffa8 100644 --- a/src/common/health.ts +++ b/src/common/health.ts @@ -9,41 +9,50 @@ export interface ResourceConfig { healthCheckInterval?: number; /** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */ maxHealthCheckFailures?: number; - /** 资源名称(用于日志) */ - name?: string; - /** 测试参数(用于健康检查) */ - testArgs?: T; /** 健康检查函数,如果提供则优先使用此函数进行健康检查 */ healthCheckFn?: (...args: T) => Promise; + /** 测试参数(用于健康检查) */ + testArgs?: T; } -interface ResourceState { - config: ResourceConfig; +interface ResourceTypeState { + /** 资源配置 */ + config: { + resourceFn: (...args: any[]) => Promise; + healthCheckFn?: (...args: any[]) => Promise; + disableTime: number; + maxRetries: number; + healthCheckInterval: number; + maxHealthCheckFailures: number; + testArgs?: any[]; + }; + /** 是否启用 */ isEnabled: boolean; + /** 禁用截止时间 */ disableUntil: number; + /** 当前重试次数 */ currentRetries: number; + /** 健康检查失败次数 */ healthCheckFailureCount: number; + /** 是否永久禁用 */ isPermanentlyDisabled: boolean; - lastError?: Error; + /** 上次健康检查时间 */ lastHealthCheckTime: number; - registrationKey: string; + /** 成功次数统计 */ + successCount: number; + /** 失败次数统计 */ + failureCount: number; } export class ResourceManager { - private resources = new Map>(); + private resourceTypes = new Map(); private destroyed = false; - private healthCheckTimer?: NodeJS.Timeout; - private readonly HEALTH_CHECK_TASK_INTERVAL = 5000; // 5秒执行一次健康检查任务 - - constructor() { - this.startHealthCheckTask(); - } /** - * 注册资源(注册即调用,重复注册只实际注册一次) + * 调用资源(自动注册或复用已有配置) */ - async register( - key: string, + async callResource( + type: string, config: ResourceConfig, ...args: T ): Promise { @@ -51,81 +60,64 @@ export class ResourceManager { throw new Error('ResourceManager has been destroyed'); } - const registrationKey = this.generateRegistrationKey(key, config); + // 获取或创建资源类型状态 + let state = this.resourceTypes.get(type); - // 检查是否已经注册 - if (this.resources.has(key)) { - const existingState = this.resources.get(key)!; - - // 如果是相同的配置,直接调用 - if (existingState.registrationKey === registrationKey) { - return this.callResource(key, ...args); - } - - // 配置不同,清理旧的并重新注册 - this.unregister(key); - } - - // 创建新的资源状态 - const state: ResourceState = { - config: { - disableTime: 30000, - maxRetries: 3, - healthCheckInterval: 60000, - maxHealthCheckFailures: 5, - name: key, - ...config - }, - isEnabled: true, - disableUntil: 0, - currentRetries: 0, - healthCheckFailureCount: 0, - isPermanentlyDisabled: false, - lastHealthCheckTime: 0, - registrationKey - }; - - this.resources.set(key, state); - - // 注册即调用 - return await this.callResource(key, ...args); - } - - /** - * 调用资源 - */ - async callResource(key: string, ...args: T): Promise { - const state = this.resources.get(key) as ResourceState | undefined; if (!state) { - throw new Error(`Resource ${key} not registered`); + // 首次注册 + state = { + config: { + resourceFn: config.resourceFn as (...args: any[]) => Promise, + healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise) | undefined, + disableTime: config.disableTime ?? 30000, + maxRetries: config.maxRetries ?? 3, + healthCheckInterval: config.healthCheckInterval ?? 60000, + maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20, + testArgs: config.testArgs as any[] | undefined, + }, + isEnabled: true, + disableUntil: 0, + currentRetries: 0, + healthCheckFailureCount: 0, + isPermanentlyDisabled: false, + lastHealthCheckTime: 0, + successCount: 0, + failureCount: 0, + }; + this.resourceTypes.set(type, state); } + // 在调用前检查是否需要进行健康检查 + await this.checkAndPerformHealthCheck(state); + + // 检查资源状态 if (state.isPermanentlyDisabled) { - throw new Error(`Resource ${key} is permanently disabled due to repeated health check failures`); + throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`); } - if (!this.isResourceAvailable(key)) { + if (!this.isResourceAvailable(type)) { const disableUntilDate = new Date(state.disableUntil).toISOString(); - throw new Error(`Resource ${key} is currently disabled until ${disableUntilDate}`); + throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`); } + // 调用资源 try { - const result = await state.config.resourceFn(...args); + const result = await config.resourceFn(...args); this.onResourceSuccess(state); return result; } catch (error) { - this.onResourceFailure(state, error as Error); + this.onResourceFailure(state); throw error; } } /** - * 检查资源是否可用 + * 检查资源类型是否可用 */ - isResourceAvailable(key: string): boolean { - const state = this.resources.get(key); + isResourceAvailable(type: string): boolean { + const state = this.resourceTypes.get(type); if (!state) { - return false; + return true; // 未注册的资源类型视为可用 } if (state.isPermanentlyDisabled || !state.isEnabled) { @@ -136,128 +128,97 @@ export class ResourceManager { } /** - * 注销资源 + * 获取资源类型统计信息 */ - unregister(key: string): boolean { - return this.resources.delete(key); + getResourceStats(type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null { + const state = this.resourceTypes.get(type); + if (!state) { + return null; + } + + return { + successCount: state.successCount, + failureCount: state.failureCount, + isEnabled: state.isEnabled, + isPermanentlyDisabled: state.isPermanentlyDisabled, + }; } /** - * 销毁管理器,清理所有资源 + * 获取所有资源类型统计 + */ + getAllResourceStats(): Map { + const stats = new Map(); + for (const [type, state] of this.resourceTypes) { + stats.set(type, { + successCount: state.successCount, + failureCount: state.failureCount, + isEnabled: state.isEnabled, + isPermanentlyDisabled: state.isPermanentlyDisabled, + }); + } + return stats; + } + + /** + * 注销资源类型 + */ + unregister(type: string): boolean { + return this.resourceTypes.delete(type); + } + + /** + * 销毁管理器 */ destroy(): void { if (this.destroyed) { return; } - this.stopHealthCheckTask(); - this.resources.clear(); + this.resourceTypes.clear(); this.destroyed = true; } - private generateRegistrationKey(key: string, config: ResourceConfig): string { - const configStr = JSON.stringify({ - name: config.name, - disableTime: config.disableTime, - maxRetries: config.maxRetries, - healthCheckInterval: config.healthCheckInterval, - maxHealthCheckFailures: config.maxHealthCheckFailures, - functionStr: config.resourceFn.toString(), - healthCheckFnStr: config.healthCheckFn?.toString() - }); - - return `${key}_${this.simpleHash(configStr)}`; - } - - private simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash).toString(36); - } - - private onResourceSuccess(state: ResourceState): void { - state.currentRetries = 0; - state.disableUntil = 0; - state.healthCheckFailureCount = 0; - state.lastError = undefined; - } - - private onResourceFailure(state: ResourceState, error: Error): void { - state.currentRetries++; - state.lastError = error; - - // 如果重试次数达到上限,禁用资源 - if (state.currentRetries >= state.config.maxRetries!) { - state.disableUntil = Date.now() + state.config.disableTime!; - state.currentRetries = 0; - } - } - - private startHealthCheckTask(): void { - if (this.healthCheckTimer) { + /** + * 检查并执行健康检查(如果需要) + */ + private async checkAndPerformHealthCheck(state: ResourceTypeState): Promise { + // 如果资源可用或已永久禁用,无需健康检查 + if (state.isEnabled && Date.now() >= state.disableUntil) { return; } - this.healthCheckTimer = setInterval(() => { - this.runHealthCheckTask(); - }, this.HEALTH_CHECK_TASK_INTERVAL); - } - - private stopHealthCheckTask(): void { - if (this.healthCheckTimer) { - clearInterval(this.healthCheckTimer); - this.healthCheckTimer = undefined; - } - } - - private async runHealthCheckTask(): Promise { - if (this.destroyed) { + if (state.isPermanentlyDisabled) { return; } const now = Date.now(); - for (const [key, state] of this.resources) { - // 跳过永久禁用或可用的资源 - if (state.isPermanentlyDisabled || this.isResourceAvailable(key)) { - continue; - } - - // 跳过还在禁用期内的资源 - if (now < state.disableUntil) { - continue; - } - - // 检查是否需要进行健康检查(根据间隔时间) - const lastHealthCheck = state.lastHealthCheckTime || 0; - const healthCheckInterval = state.config.healthCheckInterval!; - - if (now - lastHealthCheck < healthCheckInterval) { - continue; - } - - // 执行健康检查 - await this.performHealthCheck(state); + // 检查是否还在禁用期内 + if (now < state.disableUntil) { + return; } + + // 检查是否需要进行健康检查(根据间隔时间) + if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) { + return; + } + + // 执行健康检查 + await this.performHealthCheck(state); } - private async performHealthCheck(state: ResourceState): Promise { + private async performHealthCheck(state: ResourceTypeState): Promise { state.lastHealthCheckTime = Date.now(); try { let healthCheckResult: boolean; - // 如果有专门的健康检查函数,使用它 if (state.config.healthCheckFn) { - const testArgs = state.config.testArgs || [] as unknown as T; + const testArgs = state.config.testArgs || []; healthCheckResult = await state.config.healthCheckFn(...testArgs); } else { - // 否则使用原始函数进行检查 - const testArgs = state.config.testArgs || [] as unknown as T; + const testArgs = state.config.testArgs || []; await state.config.resourceFn(...testArgs); healthCheckResult = true; } @@ -268,26 +229,42 @@ export class ResourceManager { state.disableUntil = 0; state.currentRetries = 0; state.healthCheckFailureCount = 0; - state.lastError = undefined; } else { throw new Error('Health check function returned false'); } - } catch (error) { + } catch { // 健康检查失败,增加失败计数 state.healthCheckFailureCount++; - state.lastError = error as Error; // 检查是否达到最大健康检查失败次数 - if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures!) { + if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) { // 永久禁用资源 state.isPermanentlyDisabled = true; state.disableUntil = 0; } else { // 继续禁用一段时间 - state.disableUntil = Date.now() + state.config.disableTime!; + state.disableUntil = Date.now() + state.config.disableTime; } } } + + private onResourceSuccess(state: ResourceTypeState): void { + state.currentRetries = 0; + state.disableUntil = 0; + state.healthCheckFailureCount = 0; + state.successCount++; + } + + private onResourceFailure(state: ResourceTypeState): void { + state.currentRetries++; + state.failureCount++; + + // 如果重试次数达到上限,禁用资源 + if (state.currentRetries >= state.config.maxRetries) { + state.disableUntil = Date.now() + state.config.disableTime; + state.currentRetries = 0; + } + } } // 创建全局实例 @@ -295,34 +272,9 @@ export const resourceManager = new ResourceManager(); // 便捷函数 export async function registerResource( - key: string, + type: string, config: ResourceConfig, ...args: T ): Promise { - return resourceManager.register(key, config, ...args); -} - -// 使用示例: -/* -await registerResource( - 'api-with-health-check', - { - resourceFn: async (id: string) => { - const response = await fetch(`https://api.example.com/data/${id}`); - return response.json(); - }, - healthCheckFn: async (id: string) => { - try { - const response = await fetch(`https://api.example.com/health`); - return response.ok; - } catch { - return false; - } - }, - testArgs: ['health-check-id'], - healthCheckInterval: 30000, - maxHealthCheckFailures: 3 - }, - 'user123' -); -*/ \ No newline at end of file + return resourceManager.callResource(type, config, ...args); +} \ No newline at end of file diff --git a/src/common/helper.ts b/src/common/helper.ts index e97de9b2..9c049059 100644 --- a/src/common/helper.ts +++ b/src/common/helper.ts @@ -163,8 +163,10 @@ export function getQQVersionConfigPath(exePath: string = ''): string | undefined export function calcQQLevel(level?: QQLevel) { if (!level) return 0; - const { crownNum, sunNum, moonNum, starNum } = level; - return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum; + //const { penguinNum, crownNum, sunNum, moonNum, starNum } = level; + const { crownNum, sunNum, moonNum, starNum } = level + //没补类型 + return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum; } export function stringifyWithBigInt(obj: any) { @@ -204,4 +206,4 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined { } return undefined; -} \ No newline at end of file +} diff --git a/src/common/version.ts b/src/common/version.ts index e43456b8..d6dfa798 100644 --- a/src/common/version.ts +++ b/src/common/version.ts @@ -1 +1 @@ -export const napCatVersion = '4.8.119'; +export const napCatVersion = '4.8.124'; diff --git a/src/core/external/appid.json b/src/core/external/appid.json index 60b6085f..9224f4fb 100644 --- a/src/core/external/appid.json +++ b/src/core/external/appid.json @@ -390,5 +390,41 @@ "9.9.22-40362": { "appid": 537314212, "qua": "V1_WIN_NQ_9.9.22_40362_GW_B" + }, + "3.2.20-40768": { + "appid": 537319840, + "qua": "V1_LNX_NQ_3.2.20_40768_GW_B" + }, + "9.9.22-40768": { + "appid": 537319804, + "qua": "V1_WIN_NQ_9.9.22_40768_GW_B" + }, + "6.9.82-40768": { + "appid": 537319829, + "qua": "V1_MAC_NQ_6.9.82_40768_GW_B" + }, + "3.2.20-40824": { + "appid": 537319840, + "qua": "V1_LNX_NQ_3.2.20_40824_GW_B" + }, + "9.9.22-40824": { + "appid": 537319804, + "qua": "V1_WIN_NQ_9.9.22_40824_GW_B" + }, + "6.9.82-40824": { + "appid": 537319829, + "qua": "V1_MAC_NQ_6.9.82_40824_GW_B" + }, + "6.9.82-40990": { + "appid": 537319880, + "qua": "V1_MAC_NQ_6.9.82_40990_GW_B" + }, + "9.9.22.40990": { + "appid": 537319855, + "qua": "V1_WIN_NQ_9.9.22.40990_GW_B" + }, + "3.2.20-40990": { + "appid": 537319891, + "qua": "V1_LNX_NQ_3.2.20_40990_GW_B" } } \ No newline at end of file diff --git a/src/core/external/offset.json b/src/core/external/offset.json index f6806035..a4b7476d 100644 --- a/src/core/external/offset.json +++ b/src/core/external/offset.json @@ -514,5 +514,49 @@ "9.9.22-40362-x64": { "send": "31C0EB8", "recv": "31C465C" + }, + "3.2.20-40768-x64": { + "send": "B69CFE0", + "recv": "B6A0A60" + }, + "9.9.22-40768-x64": { + "send": "31C1838", + "recv": "31C4FDC" + }, + "3.2.20-40768-arm64": { + "send": "7D49B18", + "recv": "7D4D4A8" + }, + "6.9.82-40768-arm64": { + "send": "202A198", + "recv": "202B718" + }, + "9.9.22-40824-x64": { + "send": "31C1838", + "recv": "31C4FDC" + }, + "3.2.20-40824-arm64": { + "send": "7D49B18", + "recv": "7D4D4A8" + }, + "6.9.82-40824-arm64": { + "send": "202A198", + "recv": "202B718" + }, + "3.2.20-40990-x64": { + "send": "B69CFE0", + "recv": "B6A0A60" + }, + "3.2.20-40990-arm64": { + "send": "7D49B18", + "recv": "7D4D4A8" + }, + "9.9.22-40990-x64": { + "send": "31C1838", + "recv": "31C4FDC" + }, + "6.9.82-40990-arm64": { + "send": "202A198", + "recv": "202B718" } } \ No newline at end of file diff --git a/src/native/packet/MoeHoo.darwin.arm64.new.node b/src/native/packet/MoeHoo.darwin.arm64.new.node new file mode 100644 index 00000000..6c2c3e40 Binary files /dev/null and b/src/native/packet/MoeHoo.darwin.arm64.new.node differ diff --git a/src/onebot/action/go-cqhttp/UploadGroupFile.ts b/src/onebot/action/go-cqhttp/UploadGroupFile.ts index 5c636e16..072251a7 100644 --- a/src/onebot/action/go-cqhttp/UploadGroupFile.ts +++ b/src/onebot/action/go-cqhttp/UploadGroupFile.ts @@ -1,6 +1,6 @@ import { OneBotAction } from '@/onebot/action/OneBotAction'; import { ActionName } from '@/onebot/action/router'; -import { ChatType, Peer } from '@/core/types'; +import { ChatType, Peer, ElementType } from '@/core/types'; import fs from 'fs'; import { uriToLocalFile } from '@/common/file'; import { SendMessageContext } from '@/onebot/api'; @@ -16,11 +16,15 @@ const SchemaData = Type.Object({ type Payload = Static; -export default class GoCQHTTPUploadGroupFile extends OneBotAction { +interface UploadGroupFileResponse { + file_id: string | null; +} + +export default class GoCQHTTPUploadGroupFile extends OneBotAction { override actionName = ActionName.GoCQHTTP_UploadGroupFile; override payloadSchema = SchemaData; - async _handle(payload: Payload): Promise { + async _handle(payload: Payload): Promise { let file = payload.file; if (fs.existsSync(file)) { file = `file://${file}`; @@ -39,7 +43,11 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction }; 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; + const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles); + + const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE); + return { + file_id: fileElement?.fileElement?.fileUuid || null + }; } } diff --git a/src/onebot/action/go-cqhttp/UploadPrivateFile.ts b/src/onebot/action/go-cqhttp/UploadPrivateFile.ts index 1a37a21f..b88cab49 100644 --- a/src/onebot/action/go-cqhttp/UploadPrivateFile.ts +++ b/src/onebot/action/go-cqhttp/UploadPrivateFile.ts @@ -1,6 +1,6 @@ import { OneBotAction } from '@/onebot/action/OneBotAction'; import { ActionName } from '@/onebot/action/router'; -import { ChatType, Peer, SendFileElement } from '@/core/types'; +import { ChatType, Peer, SendFileElement, ElementType } from '@/core/types'; import fs from 'fs'; import { uriToLocalFile } from '@/common/file'; import { SendMessageContext } from '@/onebot/api'; @@ -15,7 +15,11 @@ const SchemaData = Type.Object({ type Payload = Static; -export default class GoCQHTTPUploadPrivateFile extends OneBotAction { +interface UploadPrivateFileResponse { + file_id: string | null; +} + +export default class GoCQHTTPUploadPrivateFile extends OneBotAction { override actionName = ActionName.GOCQHTTP_UploadPrivateFile; override payloadSchema = SchemaData; @@ -31,7 +35,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction { + async _handle(payload: Payload): Promise { let file = payload.file; if (fs.existsSync(file)) { file = `file://${file}`; @@ -49,7 +53,11 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction ele.elementType === ElementType.FILE); + return { + file_id: fileElement?.fileElement?.fileUuid || null + }; } } diff --git a/src/onebot/action/group/DelEssenceMsg.ts b/src/onebot/action/group/DelEssenceMsg.ts index ccb20a58..1fdfbc67 100644 --- a/src/onebot/action/group/DelEssenceMsg.ts +++ b/src/onebot/action/group/DelEssenceMsg.ts @@ -4,7 +4,10 @@ import { MessageUnique } from '@/common/message-unique'; import { Static, Type } from '@sinclair/typebox'; const SchemaData = Type.Object({ - message_id: Type.Union([Type.Number(), Type.String()]), + message_id: Type.Optional(Type.Union([Type.Number(), Type.String()])), + msg_seq: Type.Optional(Type.String()), + msg_random: Type.Optional(Type.String()), + group_id: Type.Optional(Type.String()), }); type Payload = Static; @@ -13,6 +16,20 @@ export default class DelEssenceMsg extends OneBotAction { override payloadSchema = SchemaData; async _handle(payload: Payload): Promise { + // 如果直接提供了 msg_seq, msg_random, group_id,优先使用 + if (payload.msg_seq && payload.msg_random && payload.group_id) { + return await this.core.apis.GroupApi.removeGroupEssenceBySeq( + payload.group_id, + payload.msg_random, + payload.msg_seq, + ); + } + + // 如果没有 message_id,则必须提供 msg_seq, msg_random, group_id + if (!payload.message_id) { + throw new Error('必须提供 message_id 或者同时提供 msg_seq, msg_random, group_id'); + } + const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id); if (!msg) { const data = this.core.apis.GroupApi.essenceLRU.getValue(+payload.message_id); diff --git a/src/onebot/action/index.ts b/src/onebot/action/index.ts index f78ceed0..b77ada21 100644 --- a/src/onebot/action/index.ts +++ b/src/onebot/action/index.ts @@ -132,6 +132,8 @@ import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike'; import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia'; import { CleanStreamTempFile } from './stream/CleanStreamTempFile'; import { DownloadFileStream } from './stream/DownloadFileStream'; +import { DownloadFileRecordStream } from './stream/DownloadFileRecordStream'; +import { DownloadFileImageStream } from './stream/DownloadFileImageStream'; import { TestDownloadStream } from './stream/TestStreamDownload'; import { UploadFileStream } from './stream/UploadFileStream'; @@ -140,6 +142,8 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo const actionHandlers = [ new CleanStreamTempFile(obContext, core), new DownloadFileStream(obContext, core), + new DownloadFileRecordStream(obContext, core), + new DownloadFileImageStream(obContext, core), new TestDownloadStream(obContext, core), new UploadFileStream(obContext, core), new DelGroupAlbumMedia(obContext, core), diff --git a/src/onebot/action/router.ts b/src/onebot/action/router.ts index bb6de99f..41872cf8 100644 --- a/src/onebot/action/router.ts +++ b/src/onebot/action/router.ts @@ -17,6 +17,8 @@ export const ActionName = { TestDownloadStream: 'test_download_stream', UploadFileStream: 'upload_file_stream', DownloadFileStream: 'download_file_stream', + DownloadFileRecordStream: 'download_file_record_stream', + DownloadFileImageStream: 'download_file_image_stream', DelGroupAlbumMedia: 'del_group_album_media', SetGroupAlbumMediaLike: 'set_group_album_media_like', diff --git a/src/onebot/action/stream/BaseDownloadStream.ts b/src/onebot/action/stream/BaseDownloadStream.ts new file mode 100644 index 00000000..7a7379ea --- /dev/null +++ b/src/onebot/action/stream/BaseDownloadStream.ts @@ -0,0 +1,99 @@ +import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { FileNapCatOneBotUUID } from '@/common/file-uuid'; + +export interface ResolvedFileInfo { + downloadPath: string; + fileName: string; + fileSize: number; +} + +export interface DownloadResult { + // 文件信息 + file_name?: string; + file_size?: number; + chunk_size?: number; + + // 分片数据 + index?: number; + data?: string; + size?: number; + progress?: number; + base64_size?: number; + + // 完成信息 + total_chunks?: number; + total_bytes?: number; + message?: string; + data_type?: 'file_info' | 'file_chunk' | 'file_complete'; + + // 可选扩展字段 + width?: number; + height?: number; + out_format?: string; +} + +export abstract class BaseDownloadStream extends OneBotAction> { + protected async resolveDownload(file?: string): Promise { + const target = file || ''; + let downloadPath = ''; + let fileName = ''; + let fileSize = 0; + + const contextMsgFile = FileNapCatOneBotUUID.decode(target); + if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) { + const { peer, msgId, elementId } = contextMsgFile; + downloadPath = await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', ''); + const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList + .find(msg => msg.msgId === msgId); + const mixElement = rawMessage?.elements.find(e => e.elementId === elementId); + const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement; + if (!mixElementInner) throw new Error('element not found'); + fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0'); + fileName = mixElementInner.fileName ?? ''; + return { downloadPath, fileName, fileSize }; + } + + const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(target); + if (contextModelIdFile && contextModelIdFile.modelId) { + const { peer, modelId } = contextModelIdFile; + downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, ''); + return { downloadPath, fileName, fileSize }; + } + + const searchResult = (await this.core.apis.FileApi.searchForFile([target])); + if (searchResult) { + downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, parseInt(searchResult.fileSize)); + fileSize = parseInt(searchResult.fileSize); + fileName = searchResult.fileName; + return { downloadPath, fileName, fileSize }; + } + + throw new Error('file not found'); + } + + protected async streamFileChunks(req: OneBotRequestToolkit, streamPath: string, chunkSize: number, chunkDataType: string): Promise<{ totalChunks: number; totalBytes: number }> + { + const stats = await fs.promises.stat(streamPath); + const totalSize = stats.size; + const readStream = fs.createReadStream(streamPath, { highWaterMark: chunkSize }); + let chunkIndex = 0; + let bytesRead = 0; + for await (const chunk of readStream) { + const base64Chunk = (chunk as Buffer).toString('base64'); + bytesRead += (chunk as Buffer).length; + await req.send({ + type: StreamStatus.Stream, + data_type: chunkDataType, + index: chunkIndex, + data: base64Chunk, + size: (chunk as Buffer).length, + progress: Math.round((bytesRead / totalSize) * 100), + base64_size: base64Chunk.length + } as unknown as StreamPacket); + chunkIndex++; + } + return { totalChunks: chunkIndex, totalBytes: bytesRead }; + } +} diff --git a/src/onebot/action/stream/DownloadFileImageStream.ts b/src/onebot/action/stream/DownloadFileImageStream.ts new file mode 100644 index 00000000..a80985fd --- /dev/null +++ b/src/onebot/action/stream/DownloadFileImageStream.ts @@ -0,0 +1,60 @@ +import { ActionName } from '@/onebot/action/router'; +import { OneBotRequestToolkit } from '@/onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { imageSizeFallBack } from '@/image-size'; +import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream'; + +const SchemaData = Type.Object({ + file: Type.Optional(Type.String()), + file_id: Type.Optional(Type.String()), + chunk_size: Type.Optional(Type.Number({ default: 64 * 1024 })) // 默认64KB分块 +}); + +type Payload = Static; + +export class DownloadFileImageStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileImageStream; + override payloadSchema = SchemaData; + override useStream = true; + + async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + try { + payload.file ||= payload.file_id || ''; + const chunkSize = payload.chunk_size || 64 * 1024; + + const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file); + + const stats = await fs.promises.stat(downloadPath); + const totalSize = fileSize || stats.size; + const { width, height } = await imageSizeFallBack(downloadPath); + + // 发送文件信息(与 DownloadFileStream 对齐,但包含宽高) + await req.send({ + type: StreamStatus.Stream, + data_type: 'file_info', + file_name: fileName, + file_size: totalSize, + chunk_size: chunkSize, + width, + height + }); + + const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk'); + + // 返回完成状态(与 DownloadFileStream 对齐) + return { + type: StreamStatus.Response, + data_type: 'file_complete', + total_chunks: totalChunks, + total_bytes: totalBytes, + message: 'Download completed' + }; + + } catch (error) { + throw new Error(`Download failed: ${(error as Error).message}`); + } + } +} diff --git a/src/onebot/action/stream/DownloadFileRecordStream.ts b/src/onebot/action/stream/DownloadFileRecordStream.ts new file mode 100644 index 00000000..e0fdec1e --- /dev/null +++ b/src/onebot/action/stream/DownloadFileRecordStream.ts @@ -0,0 +1,96 @@ + +import { ActionName } from '@/onebot/action/router'; +import { OneBotRequestToolkit } from '@/onebot/action/OneBotAction'; +import { Static, Type } from '@sinclair/typebox'; +import { NetworkAdapterConfig } from '@/onebot/config/config'; +import { StreamPacket, StreamStatus } from './StreamBasic'; +import fs from 'fs'; +import { decode } from 'silk-wasm'; +import { FFmpegService } from '@/common/ffmpeg'; +import { BaseDownloadStream } from './BaseDownloadStream'; + +const out_format = ['mp3', 'amr', 'wma', 'm4a', 'spx', 'ogg', 'wav', 'flac']; + +const SchemaData = Type.Object({ + file: Type.Optional(Type.String()), + file_id: Type.Optional(Type.String()), + chunk_size: Type.Optional(Type.Number({ default: 64 * 1024 })), // 默认64KB分块 + out_format: Type.Optional(Type.String()) +}); + +type Payload = Static; + +import { DownloadResult } from './BaseDownloadStream'; + +export class DownloadFileRecordStream extends BaseDownloadStream { + override actionName = ActionName.DownloadFileRecordStream; + override payloadSchema = SchemaData; + override useStream = true; + + async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise> { + try { + payload.file ||= payload.file_id || ''; + const chunkSize = payload.chunk_size || 64 * 1024; + + const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file); + + // 处理输出格式转换 + let streamPath = downloadPath; + if (payload.out_format && typeof payload.out_format === 'string') { + if (!out_format.includes(payload.out_format)) { + throw new Error('转换失败 out_format 字段可能格式不正确'); + } + + const pcmFile = `${downloadPath}.pcm`; + const outputFile = `${downloadPath}.${payload.out_format}`; + + try { + // 如果已存在目标文件则跳过转换 + await fs.promises.access(outputFile); + streamPath = outputFile; + } catch { + // 尝试解码 silk 到 pcm 再用 ffmpeg 转换 + await this.decodeFile(downloadPath, pcmFile); + await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format); + streamPath = outputFile; + } + } + + const stats = await fs.promises.stat(streamPath); + const totalSize = fileSize || stats.size; + + await req.send({ + type: StreamStatus.Stream, + data_type: 'file_info', + file_name: fileName, + file_size: totalSize, + chunk_size: chunkSize, + out_format: payload.out_format + }); + + const { totalChunks, totalBytes } = await this.streamFileChunks(req, streamPath, chunkSize, 'file_chunk'); + + return { + type: StreamStatus.Response, + data_type: 'file_complete', + total_chunks: totalChunks, + total_bytes: totalBytes, + message: 'Download completed' + }; + + } catch (error) { + throw new Error(`Download failed: ${(error as Error).message}`); + } + } + + private async decodeFile(inputFile: string, outputFile: string): Promise { + try { + const inputData = await fs.promises.readFile(inputFile); + const decodedData = await decode(inputData, 24000); + await fs.promises.writeFile(outputFile, Buffer.from(decodedData.data)); + } catch (error) { + console.error('Error decoding file:', error); + throw error; + } + } +} diff --git a/src/onebot/action/stream/DownloadFileStream.ts b/src/onebot/action/stream/DownloadFileStream.ts index 2c0095ef..7062aab3 100644 --- a/src/onebot/action/stream/DownloadFileStream.ts +++ b/src/onebot/action/stream/DownloadFileStream.ts @@ -1,10 +1,10 @@ import { ActionName } from '@/onebot/action/router'; -import { OneBotAction, OneBotRequestToolkit } from '@/onebot/action/OneBotAction'; +import { OneBotRequestToolkit } from '@/onebot/action/OneBotAction'; import { Static, Type } from '@sinclair/typebox'; import { NetworkAdapterConfig } from '@/onebot/config/config'; import { StreamPacket, StreamStatus } from './StreamBasic'; import fs from 'fs'; -import { FileNapCatOneBotUUID } from '@/common/file-uuid'; +import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream'; const SchemaData = Type.Object({ file: Type.Optional(Type.String()), file_id: Type.Optional(Type.String()), @@ -13,28 +13,7 @@ const SchemaData = Type.Object({ type Payload = Static; -// 下载结果类型 -interface DownloadResult { - // 文件信息 - file_name?: string; - file_size?: number; - chunk_size?: number; - - // 分片数据 - index?: number; - data?: string; - size?: number; - progress?: number; - base64_size?: number; - - // 完成信息 - total_chunks?: number; - total_bytes?: number; - message?: string; - data_type?: 'file_info' | 'file_chunk' | 'file_complete'; -} - -export class DownloadFileStream extends OneBotAction> { +export class DownloadFileStream extends BaseDownloadStream { override actionName = ActionName.DownloadFileStream; override payloadSchema = SchemaData; override useStream = true; @@ -43,50 +22,12 @@ export class DownloadFileStream extends OneBotAction msg.msgId === msgId); - const mixElement = rawMessage?.elements.find(e => e.elementId === elementId); - const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement; - if (!mixElementInner) throw new Error('element not found'); - fileSize = parseInt(mixElementInner.fileSize?.toString() ?? '0'); - fileName = mixElementInner.fileName ?? ''; - } - //群文件模式 - else if (FileNapCatOneBotUUID.decodeModelId(payload.file)) { - const contextModelIdFile = FileNapCatOneBotUUID.decodeModelId(payload.file); - if (contextModelIdFile && contextModelIdFile.modelId) { - const { peer, modelId } = contextModelIdFile; - downloadPath = await this.core.apis.FileApi.downloadFileForModelId(peer, modelId, ''); - } - } - //搜索名字模式 - else { - const searchResult = (await this.core.apis.FileApi.searchForFile([payload.file])); - if (searchResult) { - downloadPath = await this.core.apis.FileApi.downloadFileById(searchResult.id, parseInt(searchResult.fileSize)); - fileSize = parseInt(searchResult.fileSize); - fileName = searchResult.fileName; - } - } + const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file); - if (!downloadPath) { - throw new Error('file not found'); - } - - // 获取文件大小 const stats = await fs.promises.stat(downloadPath); const totalSize = fileSize || stats.size; - // 发送文件信息 await req.send({ type: StreamStatus.Stream, data_type: 'file_info', @@ -95,34 +36,13 @@ export class DownloadFileStream extends OneBotAction