Merge branch 'main' into pr/1303

This commit is contained in:
手瓜一十雪 2025-10-25 16:17:51 +08:00
commit 9ce9e46c57
20 changed files with 555 additions and 305 deletions

Binary file not shown.

View File

@ -1,9 +1,9 @@
{ {
"name": "qq-chat", "name": "qq-chat",
"verHash": "cc326038", "verHash": "c50d6326",
"version": "9.9.21-39038", "version": "9.9.22-40768",
"linuxVersion": "3.2.19-39038", "linuxVersion": "3.2.20-40768",
"linuxVerHash": "c773cdf7", "linuxVerHash": "ab90fdfa",
"private": true, "private": true,
"description": "QQ", "description": "QQ",
"productName": "QQ", "productName": "QQ",
@ -17,7 +17,7 @@
"qd": "externals/devtools/cli/index.js" "qd": "externals/devtools/cli/index.js"
}, },
"main": "./loadNapCat.js", "main": "./loadNapCat.js",
"buildVersion": "39038", "buildVersion": "40768",
"isPureShell": true, "isPureShell": true,
"isByteCodeShell": true, "isByteCodeShell": true,
"platform": "win32", "platform": "win32",

View File

@ -4,7 +4,7 @@
"name": "NapCatQQ", "name": "NapCatQQ",
"slug": "NapCat.Framework", "slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现", "description": "高性能的 OneBot 11 协议实现",
"version": "4.8.119", "version": "4.8.124",
"icon": "./logo.png", "icon": "./logo.png",
"authors": [ "authors": [
{ {

View File

@ -2,7 +2,7 @@
"name": "napcat", "name": "napcat",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "4.8.119", "version": "4.8.124",
"scripts": { "scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1", "build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1", "build:framework": "npm run build:webui && vite build --mode framework || exit 1",

View File

@ -9,41 +9,50 @@ export interface ResourceConfig<T extends any[], R> {
healthCheckInterval?: number; healthCheckInterval?: number;
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */ /** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
maxHealthCheckFailures?: number; maxHealthCheckFailures?: number;
/** 资源名称(用于日志) */
name?: string;
/** 测试参数(用于健康检查) */
testArgs?: T;
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */ /** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
healthCheckFn?: (...args: T) => Promise<boolean>; healthCheckFn?: (...args: T) => Promise<boolean>;
/** 测试参数(用于健康检查) */
testArgs?: T;
} }
interface ResourceState<T extends any[], R> { interface ResourceTypeState {
config: ResourceConfig<T, R>; /** 资源配置 */
config: {
resourceFn: (...args: any[]) => Promise<any>;
healthCheckFn?: (...args: any[]) => Promise<boolean>;
disableTime: number;
maxRetries: number;
healthCheckInterval: number;
maxHealthCheckFailures: number;
testArgs?: any[];
};
/** 是否启用 */
isEnabled: boolean; isEnabled: boolean;
/** 禁用截止时间 */
disableUntil: number; disableUntil: number;
/** 当前重试次数 */
currentRetries: number; currentRetries: number;
/** 健康检查失败次数 */
healthCheckFailureCount: number; healthCheckFailureCount: number;
/** 是否永久禁用 */
isPermanentlyDisabled: boolean; isPermanentlyDisabled: boolean;
lastError?: Error; /** 上次健康检查时间 */
lastHealthCheckTime: number; lastHealthCheckTime: number;
registrationKey: string; /** 成功次数统计 */
successCount: number;
/** 失败次数统计 */
failureCount: number;
} }
export class ResourceManager { export class ResourceManager {
private resources = new Map<string, ResourceState<any, any>>(); private resourceTypes = new Map<string, ResourceTypeState>();
private destroyed = false; private destroyed = false;
private healthCheckTimer?: NodeJS.Timeout;
private readonly HEALTH_CHECK_TASK_INTERVAL = 5000; // 5秒执行一次健康检查任务
constructor() {
this.startHealthCheckTask();
}
/** /**
* *
*/ */
async register<T extends any[], R>( async callResource<T extends any[], R>(
key: string, type: string,
config: ResourceConfig<T, R>, config: ResourceConfig<T, R>,
...args: T ...args: T
): Promise<R> { ): Promise<R> {
@ -51,81 +60,64 @@ export class ResourceManager {
throw new Error('ResourceManager has been destroyed'); 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<T, R>(key, ...args);
}
// 配置不同,清理旧的并重新注册
this.unregister(key);
}
// 创建新的资源状态
const state: ResourceState<T, R> = {
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<T, R>(key, ...args);
}
/**
*
*/
async callResource<T extends any[], R>(key: string, ...args: T): Promise<R> {
const state = this.resources.get(key) as ResourceState<T, R> | undefined;
if (!state) { if (!state) {
throw new Error(`Resource ${key} not registered`); // 首次注册
state = {
config: {
resourceFn: config.resourceFn as (...args: any[]) => Promise<any>,
healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise<boolean>) | 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) { 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(); 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 { try {
const result = await state.config.resourceFn(...args); const result = await config.resourceFn(...args);
this.onResourceSuccess(state); this.onResourceSuccess(state);
return result; return result;
} catch (error) { } catch (error) {
this.onResourceFailure(state, error as Error); this.onResourceFailure(state);
throw error; throw error;
} }
} }
/** /**
* *
*/ */
isResourceAvailable(key: string): boolean { isResourceAvailable(type: string): boolean {
const state = this.resources.get(key); const state = this.resourceTypes.get(type);
if (!state) { if (!state) {
return false; return true; // 未注册的资源类型视为可用
} }
if (state.isPermanentlyDisabled || !state.isEnabled) { if (state.isPermanentlyDisabled || !state.isEnabled) {
@ -136,128 +128,97 @@ export class ResourceManager {
} }
/** /**
* *
*/ */
unregister(key: string): boolean { getResourceStats(type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
return this.resources.delete(key); 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<string, { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean }> {
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 { destroy(): void {
if (this.destroyed) { if (this.destroyed) {
return; return;
} }
this.stopHealthCheckTask(); this.resourceTypes.clear();
this.resources.clear();
this.destroyed = true; this.destroyed = true;
} }
private generateRegistrationKey<T extends any[], R>(key: string, config: ResourceConfig<T, R>): string { /**
const configStr = JSON.stringify({ *
name: config.name, */
disableTime: config.disableTime, private async checkAndPerformHealthCheck(state: ResourceTypeState): Promise<void> {
maxRetries: config.maxRetries, // 如果资源可用或已永久禁用,无需健康检查
healthCheckInterval: config.healthCheckInterval, if (state.isEnabled && Date.now() >= state.disableUntil) {
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<T extends any[], R>(state: ResourceState<T, R>): void {
state.currentRetries = 0;
state.disableUntil = 0;
state.healthCheckFailureCount = 0;
state.lastError = undefined;
}
private onResourceFailure<T extends any[], R>(state: ResourceState<T, R>, 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) {
return; return;
} }
this.healthCheckTimer = setInterval(() => { if (state.isPermanentlyDisabled) {
this.runHealthCheckTask();
}, this.HEALTH_CHECK_TASK_INTERVAL);
}
private stopHealthCheckTask(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = undefined;
}
}
private async runHealthCheckTask(): Promise<void> {
if (this.destroyed) {
return; return;
} }
const now = Date.now(); const now = Date.now();
for (const [key, state] of this.resources) { // 检查是否还在禁用期内
// 跳过永久禁用或可用的资源 if (now < state.disableUntil) {
if (state.isPermanentlyDisabled || this.isResourceAvailable(key)) { return;
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.lastHealthCheckTime < state.config.healthCheckInterval) {
return;
}
// 执行健康检查
await this.performHealthCheck(state);
} }
private async performHealthCheck<T extends any[], R>(state: ResourceState<T, R>): Promise<void> { private async performHealthCheck(state: ResourceTypeState): Promise<void> {
state.lastHealthCheckTime = Date.now(); state.lastHealthCheckTime = Date.now();
try { try {
let healthCheckResult: boolean; let healthCheckResult: boolean;
// 如果有专门的健康检查函数,使用它
if (state.config.healthCheckFn) { if (state.config.healthCheckFn) {
const testArgs = state.config.testArgs || [] as unknown as T; const testArgs = state.config.testArgs || [];
healthCheckResult = await state.config.healthCheckFn(...testArgs); healthCheckResult = await state.config.healthCheckFn(...testArgs);
} else { } else {
// 否则使用原始函数进行检查 const testArgs = state.config.testArgs || [];
const testArgs = state.config.testArgs || [] as unknown as T;
await state.config.resourceFn(...testArgs); await state.config.resourceFn(...testArgs);
healthCheckResult = true; healthCheckResult = true;
} }
@ -268,26 +229,42 @@ export class ResourceManager {
state.disableUntil = 0; state.disableUntil = 0;
state.currentRetries = 0; state.currentRetries = 0;
state.healthCheckFailureCount = 0; state.healthCheckFailureCount = 0;
state.lastError = undefined;
} else { } else {
throw new Error('Health check function returned false'); throw new Error('Health check function returned false');
} }
} catch (error) { } catch {
// 健康检查失败,增加失败计数 // 健康检查失败,增加失败计数
state.healthCheckFailureCount++; state.healthCheckFailureCount++;
state.lastError = error as Error;
// 检查是否达到最大健康检查失败次数 // 检查是否达到最大健康检查失败次数
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures!) { if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
// 永久禁用资源 // 永久禁用资源
state.isPermanentlyDisabled = true; state.isPermanentlyDisabled = true;
state.disableUntil = 0; state.disableUntil = 0;
} else { } 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<T extends any[], R>( export async function registerResource<T extends any[], R>(
key: string, type: string,
config: ResourceConfig<T, R>, config: ResourceConfig<T, R>,
...args: T ...args: T
): Promise<R> { ): Promise<R> {
return resourceManager.register(key, config, ...args); return resourceManager.callResource(type, 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'
);
*/

View File

@ -163,8 +163,10 @@ export function getQQVersionConfigPath(exePath: string = ''): string | undefined
export function calcQQLevel(level?: QQLevel) { export function calcQQLevel(level?: QQLevel) {
if (!level) return 0; if (!level) return 0;
const { crownNum, sunNum, moonNum, starNum } = level; //const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum; const { crownNum, sunNum, moonNum, starNum } = level
//没补类型
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
} }
export function stringifyWithBigInt(obj: any) { export function stringifyWithBigInt(obj: any) {
@ -204,4 +206,4 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined {
} }
return undefined; return undefined;
} }

View File

@ -1 +1 @@
export const napCatVersion = '4.8.119'; export const napCatVersion = '4.8.124';

View File

@ -390,5 +390,41 @@
"9.9.22-40362": { "9.9.22-40362": {
"appid": 537314212, "appid": 537314212,
"qua": "V1_WIN_NQ_9.9.22_40362_GW_B" "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"
} }
} }

View File

@ -514,5 +514,49 @@
"9.9.22-40362-x64": { "9.9.22-40362-x64": {
"send": "31C0EB8", "send": "31C0EB8",
"recv": "31C465C" "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"
} }
} }

Binary file not shown.

View File

@ -1,6 +1,6 @@
import { OneBotAction } from '@/onebot/action/OneBotAction'; import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router'; import { ActionName } from '@/onebot/action/router';
import { ChatType, Peer } from '@/core/types'; import { ChatType, Peer, ElementType } from '@/core/types';
import fs from 'fs'; import fs from 'fs';
import { uriToLocalFile } from '@/common/file'; import { uriToLocalFile } from '@/common/file';
import { SendMessageContext } from '@/onebot/api'; import { SendMessageContext } from '@/onebot/api';
@ -16,11 +16,15 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null> { interface UploadGroupFileResponse {
file_id: string | null;
}
export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, UploadGroupFileResponse> {
override actionName = ActionName.GoCQHTTP_UploadGroupFile; override actionName = ActionName.GoCQHTTP_UploadGroupFile;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> { async _handle(payload: Payload): Promise<UploadGroupFileResponse> {
let file = payload.file; let file = payload.file;
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
file = `file://${file}`; file = `file://${file}`;
@ -39,7 +43,11 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null>
}; };
const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id); const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
msgContext.deleteAfterSentFiles.push(downloadResult.path); msgContext.deleteAfterSentFiles.push(downloadResult.path);
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles); const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
return null;
const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE);
return {
file_id: fileElement?.fileElement?.fileUuid || null
};
} }
} }

View File

@ -1,6 +1,6 @@
import { OneBotAction } from '@/onebot/action/OneBotAction'; import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router'; 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 fs from 'fs';
import { uriToLocalFile } from '@/common/file'; import { uriToLocalFile } from '@/common/file';
import { SendMessageContext } from '@/onebot/api'; import { SendMessageContext } from '@/onebot/api';
@ -15,7 +15,11 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, null> { interface UploadPrivateFileResponse {
file_id: string | null;
}
export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, UploadPrivateFileResponse> {
override actionName = ActionName.GOCQHTTP_UploadPrivateFile; override actionName = ActionName.GOCQHTTP_UploadPrivateFile;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
@ -31,7 +35,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
throw new Error('缺少参数 user_id'); throw new Error('缺少参数 user_id');
} }
async _handle(payload: Payload): Promise<null> { async _handle(payload: Payload): Promise<UploadPrivateFileResponse> {
let file = payload.file; let file = payload.file;
if (fs.existsSync(file)) { if (fs.existsSync(file)) {
file = `file://${file}`; file = `file://${file}`;
@ -49,7 +53,11 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
}; };
const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name); const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
msgContext.deleteAfterSentFiles.push(downloadResult.path); msgContext.deleteAfterSentFiles.push(downloadResult.path);
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles); const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
return null;
const fileElement = returnMsg.elements.find(ele => ele.elementType === ElementType.FILE);
return {
file_id: fileElement?.fileElement?.fileUuid || null
};
} }
} }

View File

@ -4,7 +4,10 @@ import { MessageUnique } from '@/common/message-unique';
import { Static, Type } from '@sinclair/typebox'; import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ 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<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@ -13,6 +16,20 @@ export default class DelEssenceMsg extends OneBotAction<Payload, unknown> {
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<unknown> { async _handle(payload: Payload): Promise<unknown> {
// 如果直接提供了 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); const msg = MessageUnique.getMsgIdAndPeerByShortId(+payload.message_id);
if (!msg) { if (!msg) {
const data = this.core.apis.GroupApi.essenceLRU.getValue(+payload.message_id); const data = this.core.apis.GroupApi.essenceLRU.getValue(+payload.message_id);

View File

@ -132,6 +132,8 @@ import { SetGroupAlbumMediaLike } from './extends/SetGroupAlbumMediaLike';
import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia'; import { DelGroupAlbumMedia } from './extends/DelGroupAlbumMedia';
import { CleanStreamTempFile } from './stream/CleanStreamTempFile'; import { CleanStreamTempFile } from './stream/CleanStreamTempFile';
import { DownloadFileStream } from './stream/DownloadFileStream'; import { DownloadFileStream } from './stream/DownloadFileStream';
import { DownloadFileRecordStream } from './stream/DownloadFileRecordStream';
import { DownloadFileImageStream } from './stream/DownloadFileImageStream';
import { TestDownloadStream } from './stream/TestStreamDownload'; import { TestDownloadStream } from './stream/TestStreamDownload';
import { UploadFileStream } from './stream/UploadFileStream'; import { UploadFileStream } from './stream/UploadFileStream';
@ -140,6 +142,8 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
const actionHandlers = [ const actionHandlers = [
new CleanStreamTempFile(obContext, core), new CleanStreamTempFile(obContext, core),
new DownloadFileStream(obContext, core), new DownloadFileStream(obContext, core),
new DownloadFileRecordStream(obContext, core),
new DownloadFileImageStream(obContext, core),
new TestDownloadStream(obContext, core), new TestDownloadStream(obContext, core),
new UploadFileStream(obContext, core), new UploadFileStream(obContext, core),
new DelGroupAlbumMedia(obContext, core), new DelGroupAlbumMedia(obContext, core),

View File

@ -17,6 +17,8 @@ export const ActionName = {
TestDownloadStream: 'test_download_stream', TestDownloadStream: 'test_download_stream',
UploadFileStream: 'upload_file_stream', UploadFileStream: 'upload_file_stream',
DownloadFileStream: 'download_file_stream', DownloadFileStream: 'download_file_stream',
DownloadFileRecordStream: 'download_file_record_stream',
DownloadFileImageStream: 'download_file_image_stream',
DelGroupAlbumMedia: 'del_group_album_media', DelGroupAlbumMedia: 'del_group_album_media',
SetGroupAlbumMediaLike: 'set_group_album_media_like', SetGroupAlbumMediaLike: 'set_group_album_media_like',

View File

@ -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<PayloadType, ResultType> extends OneBotAction<PayloadType, StreamPacket<ResultType>> {
protected async resolveDownload(file?: string): Promise<ResolvedFileInfo> {
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<any>);
chunkIndex++;
}
return { totalChunks: chunkIndex, totalBytes: bytesRead };
}
}

View File

@ -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<typeof SchemaData>;
export class DownloadFileImageStream extends BaseDownloadStream<Payload, DownloadResult> {
override actionName = ActionName.DownloadFileImageStream;
override payloadSchema = SchemaData;
override useStream = true;
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
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}`);
}
}
}

View File

@ -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<typeof SchemaData>;
import { DownloadResult } from './BaseDownloadStream';
export class DownloadFileRecordStream extends BaseDownloadStream<Payload, DownloadResult> {
override actionName = ActionName.DownloadFileRecordStream;
override payloadSchema = SchemaData;
override useStream = true;
async _handle(payload: Payload, _adaptername: string, _config: NetworkAdapterConfig, req: OneBotRequestToolkit): Promise<StreamPacket<DownloadResult>> {
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<void> {
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;
}
}
}

View File

@ -1,10 +1,10 @@
import { ActionName } from '@/onebot/action/router'; 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 { Static, Type } from '@sinclair/typebox';
import { NetworkAdapterConfig } from '@/onebot/config/config'; import { NetworkAdapterConfig } from '@/onebot/config/config';
import { StreamPacket, StreamStatus } from './StreamBasic'; import { StreamPacket, StreamStatus } from './StreamBasic';
import fs from 'fs'; import fs from 'fs';
import { FileNapCatOneBotUUID } from '@/common/file-uuid'; import { BaseDownloadStream, DownloadResult } from './BaseDownloadStream';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
file: Type.Optional(Type.String()), file: Type.Optional(Type.String()),
file_id: Type.Optional(Type.String()), file_id: Type.Optional(Type.String()),
@ -13,28 +13,7 @@ const SchemaData = Type.Object({
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
// 下载结果类型 export class DownloadFileStream extends BaseDownloadStream<Payload, DownloadResult> {
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<Payload, StreamPacket<DownloadResult>> {
override actionName = ActionName.DownloadFileStream; override actionName = ActionName.DownloadFileStream;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
override useStream = true; override useStream = true;
@ -43,50 +22,12 @@ export class DownloadFileStream extends OneBotAction<Payload, StreamPacket<Downl
try { try {
payload.file ||= payload.file_id || ''; payload.file ||= payload.file_id || '';
const chunkSize = payload.chunk_size || 64 * 1024; const chunkSize = payload.chunk_size || 64 * 1024;
let downloadPath = '';
let fileName = '';
let fileSize = 0;
//接收消息标记模式 const { downloadPath, fileName, fileSize } = await this.resolveDownload(payload.file);
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file);
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 ?? '';
}
//群文件模式
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;
}
}
if (!downloadPath) {
throw new Error('file not found');
}
// 获取文件大小
const stats = await fs.promises.stat(downloadPath); const stats = await fs.promises.stat(downloadPath);
const totalSize = fileSize || stats.size; const totalSize = fileSize || stats.size;
// 发送文件信息
await req.send({ await req.send({
type: StreamStatus.Stream, type: StreamStatus.Stream,
data_type: 'file_info', data_type: 'file_info',
@ -95,34 +36,13 @@ export class DownloadFileStream extends OneBotAction<Payload, StreamPacket<Downl
chunk_size: chunkSize chunk_size: chunkSize
}); });
// 创建读取流并分块发送 const { totalChunks, totalBytes } = await this.streamFileChunks(req, downloadPath, chunkSize, 'file_chunk');
const readStream = fs.createReadStream(downloadPath, { highWaterMark: chunkSize });
let chunkIndex = 0;
let bytesRead = 0;
for await (const chunk of readStream) {
const base64Chunk = chunk.toString('base64');
bytesRead += chunk.length;
await req.send({
type: StreamStatus.Stream,
data_type: 'file_chunk',
index: chunkIndex,
data: base64Chunk,
size: chunk.length,
progress: Math.round((bytesRead / totalSize) * 100),
base64_size: base64Chunk.length
});
chunkIndex++;
}
// 返回完成状态
return { return {
type: StreamStatus.Response, type: StreamStatus.Response,
data_type: 'file_complete', data_type: 'file_complete',
total_chunks: chunkIndex, total_chunks: totalChunks,
total_bytes: bytesRead, total_bytes: totalBytes,
message: 'Download completed' message: 'Download completed'
}; };

View File

@ -91,7 +91,9 @@ export const createUrl = (
url.searchParams.set(key, search[key]) url.searchParams.set(key, search[key])
} }
} }
return url.toString()
/** 进行url解码 对特殊字符进行处理 */
return decodeURIComponent(url.toString())
} }
/** /**