diff --git a/src/common/path.ts b/src/common/path.ts index 47efc3b6..79ffb2c3 100644 --- a/src/common/path.ts +++ b/src/common/path.ts @@ -9,6 +9,7 @@ export class NapCatPathWrapper { configPath: string; cachePath: string; staticPath: string; + pluginPath: string; constructor(mainPath: string = dirname(fileURLToPath(import.meta.url))) { this.binaryPath = mainPath; @@ -24,6 +25,7 @@ export class NapCatPathWrapper { this.logsPath = path.join(writePath, 'logs'); this.configPath = path.join(writePath, 'config'); + this.pluginPath = path.join(writePath, 'plugins');//dynamic load this.cachePath = path.join(writePath, 'cache'); this.staticPath = path.join(this.binaryPath, 'static'); if (!fs.existsSync(this.logsPath)) { diff --git a/src/example-plugin/index.ts b/src/example-plugin/index.ts new file mode 100644 index 00000000..8e4c612e --- /dev/null +++ b/src/example-plugin/index.ts @@ -0,0 +1,12 @@ +import { EventType } from "../onebot/event/OneBotEvent"; +import type { PluginModule } from "../onebot/network/plugin-manger"; + +const plugin_init: PluginModule["plugin_init"] = async (_core, _obContext, _actions, _instance) => { + console.log(`[Plugin: example] 插件已初始化`); +} +const plugin_onmessage: PluginModule["plugin_onmessage"] = async (adapter, _core, _obCtx, event, actions, instance) => { + if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) { + await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config); + } +} +export { plugin_init, plugin_onmessage }; \ No newline at end of file diff --git a/src/example-plugin/package.json b/src/example-plugin/package.json new file mode 100644 index 00000000..9630ef72 --- /dev/null +++ b/src/example-plugin/package.json @@ -0,0 +1,10 @@ +{ + "name": "advanced-plugin", + "version": "1.0.0", + "type": "module", + "main": "index.mjs", + "description": "一个高级的 NapCat 插件示例", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/example-plugin/tsconfig.json b/src/example-plugin/tsconfig.json new file mode 100644 index 00000000..8cab3857 --- /dev/null +++ b/src/example-plugin/tsconfig.json @@ -0,0 +1,113 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "libReplacement": true, /* Enable lib replacement. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "esnext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/src/example-plugin/vite.config.ts b/src/example-plugin/vite.config.ts new file mode 100644 index 00000000..a70d61d6 --- /dev/null +++ b/src/example-plugin/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import { builtinModules } from 'module'; + +const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); + +export default defineConfig({ + resolve: { + conditions: ['node', 'default'], + alias: { + '@/core': resolve(__dirname, '../core'), + '@': resolve(__dirname, '../'), + }, + }, + build: { + sourcemap: false, + target: 'esnext', + minify: false, + lib: { + entry: 'index.ts', + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + external: [...nodeModules], + }, + }, + plugins: [nodeResolve()], +}); diff --git a/src/onebot/index.ts b/src/onebot/index.ts index 70815cee..eef323bb 100644 --- a/src/onebot/index.ts +++ b/src/onebot/index.ts @@ -49,7 +49,8 @@ import { import { OB11Message } from './types'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; import { OB11HttpSSEServerAdapter } from './network/http-server-sse'; -import { OB11PluginAdapter } from './network/plugin'; +import { OB11PluginMangerAdapter } from './network/plugin-manger'; +import { existsSync } from 'node:fs'; //OneBot实现类 export class NapCatOneBot11Adapter { @@ -116,6 +117,12 @@ export class NapCatOneBot11Adapter { // this.networkManager.registerAdapter( // new OB11PluginAdapter('myPlugin', this.core, this,this.actions) // ); + if (existsSync(this.context.pathWrapper.pluginPath)) { + this.context.logger.log(`[Plugins] 插件目录存在,开始加载插件`); + this.networkManager.registerAdapter( + new OB11PluginMangerAdapter('plugin_manager', this.core, this, this.actions) + ); + } for (const key of ob11Config.network.httpServers) { if (key.enable) { this.networkManager.registerAdapter( diff --git a/src/onebot/network/plugin-manger.ts b/src/onebot/network/plugin-manger.ts new file mode 100644 index 00000000..114ef15c --- /dev/null +++ b/src/onebot/network/plugin-manger.ts @@ -0,0 +1,373 @@ +import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; +import { NapCatOneBot11Adapter, OB11Message } from '@/onebot'; +import { NapCatCore } from '@/core'; +import { PluginConfig } from '../config/config'; +import { ActionMap } from '../action'; +import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; +import fs from 'fs'; +import path from 'path'; + +export interface PluginPackageJson { + name?: string; + version?: string; + main?: string; +} + +export interface PluginModule { + plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; + plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; + plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; + plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise; +} + +export interface LoadedPlugin { + name: string; + version?: string; + pluginPath: string; + entryPath: string; + packageJson?: PluginPackageJson; + module: PluginModule; +} + +export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { + private readonly pluginPath: string; + private loadedPlugins: Map = new Map(); + + constructor( + name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap + ) { + const config = { + name: name, + messagePostFormat: 'array', + reportSelfMessage: true, + enable: true, + debug: true, + }; + super(name, config, core, obContext, actions); + this.pluginPath = this.core.context.pathWrapper.pluginPath; + } + + /** + * 扫描并加载插件 + */ + private async loadPlugins(): Promise { + try { + // 确保插件目录存在 + if (!fs.existsSync(this.pluginPath)) { + this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`); + fs.mkdirSync(this.pluginPath, { recursive: true }); + return; + } + + const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); + + // 扫描文件和目录 + for (const item of items) { + if (item.isFile()) { + // 处理单文件插件 + await this.loadFilePlugin(item.name); + } else if (item.isDirectory()) { + // 处理目录插件 + await this.loadDirectoryPlugin(item.name); + } + } + + this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error loading plugins:`, error); + } + } + + /** + * 加载单文件插件 (.mjs, .js) + */ + private async loadFilePlugin(filename: string): Promise { + // 只处理支持的文件类型 + if (!this.isSupportedFile(filename)) { + return; + } + + const filePath = path.join(this.pluginPath, filename); + const pluginName = path.parse(filename).name; + + try { + const module = await this.importModule(filePath); + if (!this.isValidPluginModule(module)) { + this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`); + return; + } + + const plugin: LoadedPlugin = { + name: pluginName, + pluginPath: this.pluginPath, + entryPath: filePath, + module: module + }; + + await this.registerPlugin(plugin); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error); + } + } + + /** + * 加载目录插件 + */ + private async loadDirectoryPlugin(dirname: string): Promise { + const pluginDir = path.join(this.pluginPath, dirname); + + try { + // 尝试读取 package.json + let packageJson: PluginPackageJson | undefined; + const packageJsonPath = path.join(pluginDir, 'package.json'); + + if (fs.existsSync(packageJsonPath)) { + try { + const packageContent = fs.readFileSync(packageJsonPath, 'utf-8'); + packageJson = JSON.parse(packageContent); + } catch (error) { + this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error); + } + } + + // 确定入口文件 + const entryFile = this.findEntryFile(pluginDir, packageJson); + if (!entryFile) { + this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`); + return; + } + + const entryPath = path.join(pluginDir, entryFile); + const module = await this.importModule(entryPath); + + if (!this.isValidPluginModule(module)) { + this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`); + return; + } + + const plugin: LoadedPlugin = { + name: packageJson?.name || dirname, + version: packageJson?.version, + pluginPath: pluginDir, + entryPath: entryPath, + packageJson: packageJson, + module: module + }; + + await this.registerPlugin(plugin); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error); + } + } + + /** + * 查找插件目录的入口文件 + */ + private findEntryFile(pluginDir: string, packageJson?: PluginPackageJson): string | null { + // 优先级:package.json main > 默认文件名 + const possibleEntries = [ + packageJson?.main, + 'index.mjs', + 'index.js', + 'main.mjs', + 'main.js' + ].filter(Boolean) as string[]; + + for (const entry of possibleEntries) { + const entryPath = path.join(pluginDir, entry); + if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) { + return entry; + } + } + + return null; + } + + /** + * 检查是否为支持的文件类型 + */ + private isSupportedFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return ['.mjs', '.js'].includes(ext); + } + + /** + * 动态导入模块 + */ + private async importModule(filePath: string): Promise { + const fileUrl = `file://${filePath.replace(/\\/g, '/')}`; + return await import(fileUrl); + } + + /** + * 检查模块是否为有效的插件模块 + */ + private isValidPluginModule(module: any): module is PluginModule { + return module && typeof module.plugin_init === 'function'; + } + + /** + * 注册插件 + */ + private async registerPlugin(plugin: LoadedPlugin): Promise { + // 检查名称冲突 + if (this.loadedPlugins.has(plugin.name)) { + this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`); + return; + } + + this.loadedPlugins.set(plugin.name, plugin); + this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`); + + // 调用插件初始化方法(必须存在) + try { + await plugin.module.plugin_init(this.core, this.obContext, this.actions, this); + this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error); + } + } + + /** + * 卸载插件 + */ + private async unloadPlugin(pluginName: string): Promise { + const plugin = this.loadedPlugins.get(pluginName); + if (!plugin) { + return; + } + + // 调用插件清理方法 + if (typeof plugin.module.plugin_cleanup === 'function') { + try { + await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this); + this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error); + } + } + + this.loadedPlugins.delete(pluginName); + this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); + } + + onEvent(event: T) { + if (!this.isEnable) { + return; + } + + // 遍历所有已加载的插件,调用它们的事件处理方法 + for (const [, plugin] of this.loadedPlugins) { + this.callPluginEventHandler(plugin, event); + } + } + + /** + * 调用插件的事件处理方法 + */ + private async callPluginEventHandler(plugin: LoadedPlugin, event: OB11EmitEventContent): Promise { + try { + // 优先使用 plugin_onevent 方法 + if (typeof plugin.module.plugin_onevent === 'function') { + await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this); + } + + // 如果是消息事件并且插件有 plugin_onmessage 方法,也调用 + if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') { + await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this); + } + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error); + } + } + + async open() { + if (this.isEnable) { + return; + } + + this.logger.log('[Plugin Adapter] Opening plugin adapter...'); + this.isEnable = true; + + // 加载所有插件 + await this.loadPlugins(); + + this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`); + } + + async close() { + if (!this.isEnable) { + return; + } + + this.logger.log('[Plugin Adapter] Closing plugin adapter...'); + this.isEnable = false; + + // 卸载所有插件 + const pluginNames = Array.from(this.loadedPlugins.keys()); + for (const pluginName of pluginNames) { + await this.unloadPlugin(pluginName); + } + + this.logger.log('[Plugin Adapter] Plugin adapter closed'); + } + + async reload() { + this.logger.log('[Plugin Adapter] Reloading plugin adapter...'); + + // 先关闭然后重新打开 + await this.close(); + await this.open(); + + this.logger.log('[Plugin Adapter] Plugin adapter reloaded'); + return OB11NetworkReloadType.Normal; + } + + /** + * 获取已加载的插件列表 + */ + public getLoadedPlugins(): LoadedPlugin[] { + return Array.from(this.loadedPlugins.values()); + } + + /** + * 获取插件信息 + */ + public getPluginInfo(pluginName: string): LoadedPlugin | undefined { + return this.loadedPlugins.get(pluginName); + } + + /** + * 重载指定插件 + */ + public async reloadPlugin(pluginName: string): Promise { + const plugin = this.loadedPlugins.get(pluginName); + if (!plugin) { + this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`); + return false; + } + + try { + // 卸载插件 + await this.unloadPlugin(pluginName); + + // 重新加载插件 + const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && + plugin.pluginPath !== this.pluginPath; + + if (isDirectory) { + const dirname = path.basename(plugin.pluginPath); + await this.loadDirectoryPlugin(dirname); + } else { + const filename = path.basename(plugin.entryPath); + await this.loadFilePlugin(filename); + } + + this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`); + return true; + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error); + return false; + } + } +} diff --git a/src/onebot/network/plugin.ts b/src/onebot/network/plugin.ts index 257da85d..2d04cd58 100644 --- a/src/onebot/network/plugin.ts +++ b/src/onebot/network/plugin.ts @@ -2,38 +2,372 @@ import { OB11EmitEventContent, OB11NetworkReloadType } from './index'; import { NapCatOneBot11Adapter, OB11Message } from '@/onebot'; import { NapCatCore } from '@/core'; import { PluginConfig } from '../config/config'; -import { plugin_onmessage } from '@/plugin'; import { ActionMap } from '../action'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; +import fs from 'fs'; +import path from 'path'; + +export interface PluginPackageJson { + name?: string; + version?: string; + main?: string; +} + +export interface PluginModule { + plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise; + plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise; + plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise; + plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginAdapter) => void | Promise; +} + +export interface LoadedPlugin { + name: string; + version?: string; + pluginPath: string; + entryPath: string; + packageJson?: PluginPackageJson; + module: PluginModule; +} + export class OB11PluginAdapter extends IOB11NetworkAdapter { + private readonly pluginPath: string; + private loadedPlugins: Map = new Map(); + constructor( name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap ) { const config = { name: name, messagePostFormat: 'array', - reportSelfMessage: false, + reportSelfMessage: true, enable: true, - debug: false, + debug: true, }; super(name, config, core, obContext, actions); + this.pluginPath = this.core.context.pathWrapper.pluginPath; } - onEvent(event: T) { - if (event.post_type === 'message') { - plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch(); + /** + * 扫描并加载插件 + */ + private async loadPlugins(): Promise { + try { + // 确保插件目录存在 + if (!fs.existsSync(this.pluginPath)) { + this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${this.pluginPath}`); + fs.mkdirSync(this.pluginPath, { recursive: true }); + return; + } + + const items = fs.readdirSync(this.pluginPath, { withFileTypes: true }); + + // 扫描文件和目录 + for (const item of items) { + if (item.isFile()) { + // 处理单文件插件 + await this.loadFilePlugin(item.name); + } else if (item.isDirectory()) { + // 处理目录插件 + await this.loadDirectoryPlugin(item.name); + } + } + + this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error loading plugins:`, error); } } - open() { + /** + * 加载单文件插件 (.mjs, .js) + */ + private async loadFilePlugin(filename: string): Promise { + // 只处理支持的文件类型 + if (!this.isSupportedFile(filename)) { + return; + } + + const filePath = path.join(this.pluginPath, filename); + const pluginName = path.parse(filename).name; + + try { + const module = await this.importModule(filePath); + if (!this.isValidPluginModule(module)) { + this.logger.logWarn(`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`); + return; + } + + const plugin: LoadedPlugin = { + name: pluginName, + pluginPath: this.pluginPath, + entryPath: filePath, + module: module + }; + + await this.registerPlugin(plugin); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error); + } + } + + /** + * 加载目录插件 + */ + private async loadDirectoryPlugin(dirname: string): Promise { + const pluginDir = path.join(this.pluginPath, dirname); + + try { + // 尝试读取 package.json + let packageJson: PluginPackageJson | undefined; + const packageJsonPath = path.join(pluginDir, 'package.json'); + + if (fs.existsSync(packageJsonPath)) { + try { + const packageContent = fs.readFileSync(packageJsonPath, 'utf-8'); + packageJson = JSON.parse(packageContent); + } catch (error) { + this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error); + } + } + + // 确定入口文件 + const entryFile = this.findEntryFile(pluginDir, packageJson); + if (!entryFile) { + this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`); + return; + } + + const entryPath = path.join(pluginDir, entryFile); + const module = await this.importModule(entryPath); + + if (!this.isValidPluginModule(module)) { + this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`); + return; + } + + const plugin: LoadedPlugin = { + name: packageJson?.name || dirname, + version: packageJson?.version, + pluginPath: pluginDir, + entryPath: entryPath, + packageJson: packageJson, + module: module + }; + + await this.registerPlugin(plugin); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error); + } + } + + /** + * 查找插件目录的入口文件 + */ + private findEntryFile(pluginDir: string, packageJson?: PluginPackageJson): string | null { + // 优先级:package.json main > 默认文件名 + const possibleEntries = [ + packageJson?.main, + 'index.mjs', + 'index.js', + 'main.mjs', + 'main.js' + ].filter(Boolean) as string[]; + + for (const entry of possibleEntries) { + const entryPath = path.join(pluginDir, entry); + if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) { + return entry; + } + } + + return null; + } + + /** + * 检查是否为支持的文件类型 + */ + private isSupportedFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return ['.mjs', '.js'].includes(ext); + } + + /** + * 动态导入模块 + */ + private async importModule(filePath: string): Promise { + const fileUrl = `file://${filePath.replace(/\\/g, '/')}`; + return await import(fileUrl); + } + + /** + * 检查模块是否为有效的插件模块 + */ + private isValidPluginModule(module: any): module is PluginModule { + return module && typeof module.plugin_init === 'function'; + } + + /** + * 注册插件 + */ + private async registerPlugin(plugin: LoadedPlugin): Promise { + // 检查名称冲突 + if (this.loadedPlugins.has(plugin.name)) { + this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`); + return; + } + + this.loadedPlugins.set(plugin.name, plugin); + this.logger.log(`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''}`); + + // 调用插件初始化方法(必须存在) + try { + await plugin.module.plugin_init(this.core, this.obContext, this.actions, this); + this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error initializing plugin ${plugin.name}:`, error); + } + } + + /** + * 卸载插件 + */ + private async unloadPlugin(pluginName: string): Promise { + const plugin = this.loadedPlugins.get(pluginName); + if (!plugin) { + return; + } + + // 调用插件清理方法 + if (typeof plugin.module.plugin_cleanup === 'function') { + try { + await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this); + this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`); + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`, error); + } + } + + this.loadedPlugins.delete(pluginName); + this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`); + } + + onEvent(event: T) { + if (!this.isEnable) { + return; + } + + // 遍历所有已加载的插件,调用它们的事件处理方法 + for (const [, plugin] of this.loadedPlugins) { + this.callPluginEventHandler(plugin, event); + } + } + + /** + * 调用插件的事件处理方法 + */ + private async callPluginEventHandler(plugin: LoadedPlugin, event: OB11EmitEventContent): Promise { + try { + // 优先使用 plugin_onevent 方法 + if (typeof plugin.module.plugin_onevent === 'function') { + await plugin.module.plugin_onevent(this.name, this.core, this.obContext, event, this.actions, this); + } + + // 如果是消息事件并且插件有 plugin_onmessage 方法,也调用 + if ((event as any).message_type && typeof plugin.module.plugin_onmessage === 'function') { + await plugin.module.plugin_onmessage(this.name, this.core, this.obContext, event as OB11Message, this.actions, this); + } + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`, error); + } + } + + async open() { + if (this.isEnable) { + return; + } + + this.logger.log('[Plugin Adapter] Opening plugin adapter...'); this.isEnable = true; + + // 加载所有插件 + await this.loadPlugins(); + + this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`); } async close() { + if (!this.isEnable) { + return; + } + + this.logger.log('[Plugin Adapter] Closing plugin adapter...'); this.isEnable = false; + + // 卸载所有插件 + const pluginNames = Array.from(this.loadedPlugins.keys()); + for (const pluginName of pluginNames) { + await this.unloadPlugin(pluginName); + } + + this.logger.log('[Plugin Adapter] Plugin adapter closed'); } async reload() { + this.logger.log('[Plugin Adapter] Reloading plugin adapter...'); + + // 先关闭然后重新打开 + await this.close(); + await this.open(); + + this.logger.log('[Plugin Adapter] Plugin adapter reloaded'); return OB11NetworkReloadType.Normal; } + + /** + * 获取已加载的插件列表 + */ + public getLoadedPlugins(): LoadedPlugin[] { + return Array.from(this.loadedPlugins.values()); + } + + /** + * 获取插件信息 + */ + public getPluginInfo(pluginName: string): LoadedPlugin | undefined { + return this.loadedPlugins.get(pluginName); + } + + /** + * 重载指定插件 + */ + public async reloadPlugin(pluginName: string): Promise { + const plugin = this.loadedPlugins.get(pluginName); + if (!plugin) { + this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`); + return false; + } + + try { + // 卸载插件 + await this.unloadPlugin(pluginName); + + // 重新加载插件 + const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() && + plugin.pluginPath !== this.pluginPath; + + if (isDirectory) { + const dirname = path.basename(plugin.pluginPath); + await this.loadDirectoryPlugin(dirname); + } else { + const filename = path.basename(plugin.entryPath); + await this.loadFilePlugin(filename); + } + + this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`); + return true; + } catch (error) { + this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error); + return false; + } + } } diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 484a58b5..6f17f54f 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,9 +1,9 @@ import { NapCatOneBot11Adapter, OB11Message } from '@/onebot'; import { NapCatCore } from '@/core'; import { ActionMap } from '@/onebot/action'; -import { OB11PluginAdapter } from '@/onebot/network/plugin'; +import { OB11PluginMangerAdapter } from '@/onebot/network/plugin-manger'; -export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginAdapter) => { +export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginMangerAdapter) => { if (message.raw_message === 'ping') { const ret = await action.get('send_group_msg')?.handle({ group_id: String(message.group_id), message: 'pong' }, adapter, instance.config); console.log(ret);