Add config UI and persistence to builtin plugin

Introduces a configuration UI schema and persistent config storage for the napcat-plugin-builtin. The plugin now loads and saves its configuration, supports dynamic prefix and reply toggling, and updates dependencies to napcat-types v0.0.6.
This commit is contained in:
手瓜一十雪 2026-01-28 14:13:48 +08:00
parent d711cdecaf
commit d680328762
10 changed files with 102 additions and 64 deletions

View File

@ -107,6 +107,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly configPath: string; private readonly configPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map(); private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig; declare config: PluginConfig;
public NapCatConfig = NapCatConfig;
override get isActive (): boolean { override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0; return this.isEnable && this.loadedPlugins.size > 0;

View File

@ -3,8 +3,31 @@ import { EventType } from 'napcat-types/napcat-onebot/event/index';
import type { PluginModule } from 'napcat-types/napcat-onebot/network/plugin-manger'; import type { PluginModule } from 'napcat-types/napcat-onebot/network/plugin-manger';
import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index'; import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index';
import fs from 'fs';
import type { PluginConfigSchema, OB11PluginMangerAdapter } from 'napcat-types/napcat-onebot/network/plugin-manger';
let actions: ActionMap | undefined = undefined; let actions: ActionMap | undefined = undefined;
let startTime: number = Date.now(); let startTime: number = Date.now();
let platformInstance: OB11PluginMangerAdapter | undefined = undefined;
interface BuiltinPluginConfig {
prefix: string;
enableReply: boolean;
description: string;
theme?: string;
features?: string[];
[key: string]: unknown;
}
let currentConfig: BuiltinPluginConfig = {
prefix: '#napcat',
enableReply: true,
description: '这是一个内置插件的配置示例'
};
const PLUGIN_NAME = 'napcat-plugin-builtin';
export let plugin_config_ui: PluginConfigSchema = [];
/** /**
* *
@ -12,6 +35,61 @@ let startTime: number = Date.now();
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => { const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: builtin] NapCat 内置插件已初始化'); console.log('[Plugin: builtin] NapCat 内置插件已初始化');
actions = _actions; actions = _actions;
platformInstance = _instance;
if (_instance.NapCatConfig) {
const NapCatConfig = _instance.NapCatConfig;
plugin_config_ui = NapCatConfig.combine(
NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface.</p></div>'),
NapCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'),
NapCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'),
NapCatConfig.select('theme', 'Theme Selection', [
{ label: 'Light Mode', value: 'light' },
{ label: 'Dark Mode', value: 'dark' },
{ label: 'Auto', value: 'auto' }
], 'light', 'Select a theme for the response (Demo purpose only)'),
NapCatConfig.multiSelect('features', 'Enabled Features', [
{ label: 'Version Info', value: 'version' },
{ label: 'Status Report', value: 'status' },
{ label: 'Debug Log', value: 'debug' }
], ['version'], 'Select features to enable'),
NapCatConfig.text('description', 'Description', '这是一个内置插件的配置示例', 'A multi-line text area for notes')
);
}
// Try to load config
try {
if (platformInstance && platformInstance.getPluginConfigPath) {
const configPath = platformInstance.getPluginConfigPath(PLUGIN_NAME);
if (fs.existsSync(configPath)) {
const savedConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
Object.assign(currentConfig, savedConfig);
}
}
} catch (e) {
console.warn('[Plugin: builtin] Failed to load config', e);
}
};
export const plugin_get_config = async () => {
return currentConfig;
};
export const plugin_set_config = async (config: BuiltinPluginConfig) => {
currentConfig = config;
if (platformInstance && platformInstance.getPluginConfigPath) {
try {
const configPath = platformInstance.getPluginConfigPath(PLUGIN_NAME);
const configDir = configPath.substring(0, configPath.lastIndexOf(process.platform === 'win32' ? '\\' : '/'));
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (e) {
console.error('[Plugin: builtin] Failed to save config', e);
throw e;
}
}
}; };
/** /**
@ -19,7 +97,13 @@ const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _acti
* #napcat * #napcat
*/ */
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => { const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) { // Use config logic
const prefix = currentConfig.prefix || '#napcat';
if (currentConfig.enableReply === false) {
return;
}
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith(prefix)) {
return; return;
} }

View File

@ -6,7 +6,7 @@
"description": "NapCat 内置插件", "description": "NapCat 内置插件",
"author": "NapNeko", "author": "NapNeko",
"dependencies": { "dependencies": {
"napcat-types": "0.0.5" "napcat-types": "0.0.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.1" "@types/node": "^22.0.1"

View File

@ -1,6 +1,6 @@
{ {
"name": "napcat-types", "name": "napcat-types",
"version": "0.0.5", "version": "0.0.6",
"private": false, "private": false,
"type": "module", "type": "module",
"types": "./napcat-types/index.d.ts", "types": "./napcat-types/index.d.ts",

View File

@ -118,24 +118,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false }); return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
}; };
export const ReloadPluginHandler: RequestHandler = async (req, res) => { // ReloadPluginHandler removed
const { name } = req.body;
// Note: we should probably accept ID or Name. But ReloadPlugin uses valid loaded name.
// Let's stick to name for now, but be aware of ambiguity.
if (!name) return sendError(res, 'Plugin Name is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, '插件管理器未加载,请检查 plugins 目录是否存在');
}
const success = await pluginManager.reloadPlugin(name);
if (success) {
return sendSuccess(res, { message: 'Reloaded successfully' });
} else {
return sendError(res, 'Failed to reload plugin');
}
};
export const SetPluginStatusHandler: RequestHandler = async (req, res) => { export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
const { enable, filename } = req.body; const { enable, filename } = req.body;
@ -291,7 +274,11 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
} }
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
return sendSuccess(res, { message: 'Config saved' });
// Auto-Reload plugin to apply changes
await pluginManager.reloadPlugin(name);
return sendSuccess(res, { message: 'Config saved and plugin reloaded' });
} catch (e: any) { } catch (e: any) {
return sendError(res, 'Error saving config: ' + e.message); return sendError(res, 'Error saving config: ' + e.message);
} }

View File

@ -1,7 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { import {
GetPluginListHandler, GetPluginListHandler,
ReloadPluginHandler,
SetPluginStatusHandler, SetPluginStatusHandler,
UninstallPluginHandler, UninstallPluginHandler,
GetPluginConfigHandler, GetPluginConfigHandler,
@ -17,7 +16,6 @@ import {
const router: Router = Router(); const router: Router = Router();
router.get('/List', GetPluginListHandler); router.get('/List', GetPluginListHandler);
router.post('/Reload', ReloadPluginHandler);
router.post('/SetStatus', SetPluginStatusHandler); router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler); router.post('/Uninstall', UninstallPluginHandler);
router.get('/Config', GetPluginConfigHandler); router.get('/Config', GetPluginConfigHandler);

View File

@ -3,14 +3,13 @@ import { Switch } from '@heroui/switch';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { useState } from 'react'; import { useState } from 'react';
import { MdDeleteForever, MdPublishedWithChanges, MdSettings } from 'react-icons/md'; import { MdDeleteForever, MdSettings } from 'react-icons/md';
import DisplayCardContainer from './container'; import DisplayCardContainer from './container';
import { PluginItem } from '@/controllers/plugin_manager'; import { PluginItem } from '@/controllers/plugin_manager';
export interface PluginDisplayCardProps { export interface PluginDisplayCardProps {
data: PluginItem; data: PluginItem;
onReload: () => Promise<void>;
onToggleStatus: () => Promise<void>; onToggleStatus: () => Promise<void>;
onUninstall: () => Promise<void>; onUninstall: () => Promise<void>;
onConfig?: () => void; onConfig?: () => void;
@ -19,7 +18,6 @@ export interface PluginDisplayCardProps {
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
data, data,
onReload,
onToggleStatus, onToggleStatus,
onUninstall, onUninstall,
onConfig, onConfig,
@ -34,11 +32,6 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
onToggleStatus().finally(() => setProcessing(false)); onToggleStatus().finally(() => setProcessing(false));
}; };
const handleReload = () => {
setProcessing(true);
onReload().finally(() => setProcessing(false));
};
const handleUninstall = () => { const handleUninstall = () => {
setProcessing(true); setProcessing(true);
onUninstall().finally(() => setProcessing(false)); onUninstall().finally(() => setProcessing(false));
@ -50,19 +43,6 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
action={ action={
<div className='flex flex-col gap-2 w-full'> <div className='flex flex-col gap-2 w-full'>
<div className='flex gap-2 w-full'> <div className='flex gap-2 w-full'>
<Button
fullWidth
radius='full'
size='sm'
variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-primary/20 hover:text-primary transition-colors'
startContent={<MdPublishedWithChanges size={16} />}
onPress={handleReload}
isDisabled={!isEnabled || processing}
>
</Button>
<Button <Button
fullWidth fullWidth
radius='full' radius='full'

View File

@ -43,9 +43,7 @@ export default class PluginManager {
return data.data; return data.data;
} }
public static async reloadPlugin (name: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Reload', { name });
}
public static async setPluginStatus (name: string, enable: boolean, filename?: string) { public static async setPluginStatus (name: string, enable: boolean, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename }); await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });

View File

@ -42,16 +42,7 @@ export default function PluginPage () {
loadPlugins(); loadPlugins();
}, []); }, []);
const handleReload = async (name: string) => {
const loadingToast = toast.loading('重载中...');
try {
await PluginManager.reloadPlugin(name);
toast.success('重载成功', { id: loadingToast });
loadPlugins();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
}
};
const handleToggle = async (plugin: PluginItem) => { const handleToggle = async (plugin: PluginItem) => {
const isEnable = plugin.status !== 'active'; const isEnable = plugin.status !== 'active';
@ -156,7 +147,6 @@ export default function PluginPage () {
<PluginDisplayCard <PluginDisplayCard
key={plugin.name} key={plugin.name}
data={plugin} data={plugin}
onReload={() => handleReload(plugin.name)}
onToggleStatus={() => handleToggle(plugin)} onToggleStatus={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)} onUninstall={() => handleUninstall(plugin)}
onConfig={() => { onConfig={() => {

View File

@ -223,8 +223,8 @@ importers:
packages/napcat-plugin-builtin: packages/napcat-plugin-builtin:
dependencies: dependencies:
napcat-types: napcat-types:
specifier: 0.0.5 specifier: 0.0.6
version: 0.0.5 version: 0.0.6
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.0.1 specifier: ^22.0.1
@ -5448,8 +5448,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
napcat-types@0.0.5: napcat-types@0.0.6:
resolution: {integrity: sha512-ihbIdCAsqx4wdiSaKfGgsbC5nn3rjk/xbPO1lqr+3GzU+w/oeWsP0XzdF44Z3Uz6ODOijsoR1VR15HXuP8ukcQ==} resolution: {integrity: sha512-KyIEr/uFC8w1bGF2Oyvk+2Kdr6ENklWK9bHwrGGbAKnUUJ4GRhsUYQdX/dDhhiZrLFWisYslQyLFD6530YtTlg==}
napcat.protobuf@1.1.4: napcat.protobuf@1.1.4:
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==} resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
@ -12819,7 +12819,7 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
napcat-types@0.0.5: napcat-types@0.0.6:
dependencies: dependencies:
'@sinclair/typebox': 0.34.41 '@sinclair/typebox': 0.34.41
'@types/cors': 2.8.19 '@types/cors': 2.8.19