Compare commits

..

12 Commits

Author SHA1 Message Date
手瓜一十雪
e5851624fc Update debug button label in NetworkDisplayCard
Changed the button label from '关闭调试'/'开启调试' to '默认'/'调试' based on the debug state for improved clarity.
2026-01-22 13:20:13 +08:00
时瑾
b296d50d4a feat: support msg_seq parameter in reply message construction
- Add optional 'seq' parameter to OB11MessageReply for using msg_seq
- Prioritize seq over id for querying reply messages
- Maintain backward compatibility with existing id parameter
- Update type definitions across backend and frontend
- Update validation schemas for message nodes

close #1523
2026-01-18 09:30:53 +08:00
手瓜一十雪
5284e0ac5a Update OpenRouter model in release workflow
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Changed the OPENROUTER_MODEL environment variable in the release workflow to use 'copilot/gemini-3-flash-preview' instead of 'copilot/ant/gemini-3-flash-preview'.
2026-01-17 18:57:42 +08:00
手瓜一十雪
67d6cd3f2e Refactor worker restart to control quick login param
Modified the restartWorker and startWorker functions to control whether the quick login parameter (-q/--qq) is passed to the worker process. On restart, quick login is not passed, while on unexpected exits, it is preserved. This improves process management and parameter handling during worker lifecycle events.
2026-01-17 18:56:53 +08:00
手瓜一十雪
0ba5862753 Pass CLI args to worker and update login script example
The quickLoginExample.bat script was updated to use the new launcher script names and argument format. In napcat.ts, the master process now forwards its command line arguments to the worker process, enabling better parameter handling.
2026-01-17 18:54:18 +08:00
手瓜一十雪
d4478275ee Add auto-restart for unexpected worker exits
Introduces an isRestarting flag to distinguish between intentional and unexpected worker restarts. If the worker process exits unexpectedly, the system now attempts to automatically restart it and logs relevant warnings and errors.
2026-01-17 18:38:12 +08:00
手瓜一十雪
163bb88751 Remove unused isFile variable in GetPluginListHandler
Cleaned up the GetPluginListHandler by removing the unused isFile variable, as it was no longer needed for plugin list processing.
2026-01-17 16:27:24 +08:00
手瓜一十雪
ec6762d916 Add plugin enable/disable config and status management
Introduces a persistent plugins.json config to track enabled/disabled status for plugins, updates the plugin manager to respect this config when loading plugins, and adds API and frontend support for toggling plugin status. The backend now reports plugin status as 'active', 'stopped', or 'disabled', and the frontend displays these states with appropriate labels. Also updates the built-in plugin package.json with author info.
2026-01-17 16:24:46 +08:00
手瓜一十雪
ed1872a349 Add plugin management to WebUI backend and frontend
Implemented backend API and router for plugin management (list, reload, enable/disable, uninstall) and exposed corresponding frontend controller and dashboard page. Updated navigation and site config to include plugin management. Refactored plugin manager adapter for public methods and improved plugin metadata handling.
2026-01-17 16:14:46 +08:00
手瓜一十雪
a7fd70ac3a Add napcat-plugin-builtin build step to CI workflows
Updated build and release GitHub Actions workflows to include a build step for napcat-plugin-builtin. This ensures the plugin is built alongside other packages during CI processes.
2026-01-17 15:50:20 +08:00
手瓜一十雪
7e38f1d227 Add builtin plugin package and enhance action map
Introduces the napcat-plugin-builtin package with initialization, message handling, and build configuration. Also adds a type-safe 'call' helper to the action map in napcat-onebot for improved action invocation.
2026-01-17 15:48:48 +08:00
时瑾
0ca68010a5 feat: 优化离线重连机制,支持通过前端实现重新登录
* feat: 优化离线重连机制,增加前端登录错误提示与二维码刷新功能

- 增加全局掉线检测弹窗
- 增强登录错误解析,支持显示 serverErrorCode 和 message
- 优化二维码登录 UI,错误时显示详细原因并提供大按钮重新获取
- 核心层解耦,通过事件抛出 KickedOffLine 通知
- 支持前端点击刷新二维码接口

* feat: 新增看门狗汪汪汪

* cp napcat-shell-loader/launcher-win.bat

* refactor: 重构重启流程,移除旧的重启逻辑,新增基于 WebUI 的重启请求处理

* fix: 刷新二维码清楚错误信息
2026-01-17 15:38:24 +08:00
25 changed files with 1220 additions and 185 deletions

View File

@@ -41,6 +41,7 @@ jobs:
pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-framework/dist framework-dist
cd framework-dist
npm install --omit=dev
@@ -83,6 +84,7 @@ jobs:
pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-shell/dist shell-dist
cd shell-dist
npm install --omit=dev

View File

@@ -10,7 +10,7 @@ permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "copilot/ant/gemini-3-flash-preview"
OPENROUTER_MODEL: "copilot/gemini-3-flash-preview"
RELEASE_NAME: "NapCat"
jobs:
@@ -62,6 +62,7 @@ jobs:
pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-framework/dist framework-dist
cd framework-dist
npm install --omit=dev
@@ -91,6 +92,7 @@ jobs:
pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell
pnpm --filter napcat-plugin-builtin run build || exit 1
mv packages/napcat-shell/dist shell-dist
cd shell-dist
npm install --omit=dev

View File

@@ -67,6 +67,8 @@ import GoCQHTTPUploadPrivateFile from './go-cqhttp/UploadPrivateFile';
import { FetchEmojiLike } from './extends/FetchEmojiLike';
import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import type { NetworkAdapterConfig } from '../config/config';
import { OneBotAction } from './OneBotAction';
import { SetInputStatus } from './extends/SetInputStatus';
import { GetCSRF } from './system/GetCSRF';
import { DelGroupNotice } from './group/DelGroupNotice';
@@ -322,6 +324,30 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
function get<K extends keyof MapType> (key: K): MapType[K] | undefined {
return _map.get(key as keyof MapType) as MapType[K] | undefined;
}
return { get };
/**
* 类型安全的 action 调用辅助函数
* 根据 action 名称自动推导返回类型
*/
async function call<K extends keyof MapType> (
actionName: K,
params: unknown,
adapter: string,
config: NetworkAdapterConfig
): Promise<MapType[K] extends OneBotAction<any, infer R> ? R : never> {
const action = _map.get(actionName);
if (!action) {
throw new Error(`Action ${String(actionName)} not found`);
}
const result = await (action as any).handle(params, adapter, config);
if (result.status !== 'ok' || !result.data) {
throw new Error(`Action ${String(actionName)} failed: ${result.message || 'No data returned'}`);
}
return result.data;
}
return { get, call };
}
export type ActionMap = ReturnType<typeof createActionMap>;

View File

@@ -587,15 +587,33 @@ export class OneBotMsgApi {
return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || '');
},
[OB11MessageDataType.reply]: async ({ data: { id } }) => {
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
if (!replyMsgM) {
this.core.context.logger.logWarn('回复消息不存在', id);
[OB11MessageDataType.reply]: async ({ data: { id, seq } }, context) => {
let replyMsg: RawMessage | undefined;
let replyMsgPeer: Peer | undefined;
// 优先使用 seq
if (seq) {
const msgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
context.peer, seq.toString(), 1, true, true
)).msgList;
replyMsg = msgList[0];
replyMsgPeer = context.peer;
} else if (id) {
// 降级使用 id
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
if (!replyMsgM) {
this.core.context.logger.logWarn('回复消息不存在', id);
return undefined;
}
replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(
replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0];
replyMsgPeer = replyMsgM.Peer;
} else {
this.core.context.logger.logWarn('回复消息缺少id或seq参数');
return undefined;
}
const replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(
replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0];
return replyMsg
return replyMsg && replyMsgPeer
? {
elementType: ElementType.REPLY,
elementId: '',
@@ -605,7 +623,7 @@ export class OneBotMsgApi {
senderUin: replyMsg.senderUin,
senderUinStr: replyMsg.senderUin,
replyMsgClientSeq: replyMsg.clientSeq,
_replyMsgPeer: replyMsgM.Peer,
_replyMsgPeer: replyMsgPeer,
},
}
: undefined;

View File

@@ -49,10 +49,11 @@ import {
OneBotConfigSchema,
} from './config/config';
import { OB11Message } from './types';
import { existsSync } from 'node:fs';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
import { OB11PluginMangerAdapter } from './network/plugin-manger';
import { existsSync } from 'node:fs';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { OneBotFileApi } from './api/file';
@@ -160,6 +161,7 @@ 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(

View File

@@ -1,9 +1,9 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { NapCatCore } from 'napcat-core';
import { PluginConfig } from '../config/config';
import { ActionMap } from '../action';
import { NapCatCore } from 'napcat-core';
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
import { PluginConfig } from '../config/config';
import fs from 'fs';
import path from 'path';
@@ -11,13 +11,39 @@ export interface PluginPackageJson {
name?: string;
version?: string;
main?: string;
description?: string;
author?: string;
}
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
plugin_init: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_onmessage?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: OB11Message, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_onevent?: (adapter: string, core: NapCatCore, obCtx: NapCatOneBot11Adapter, event: T, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_cleanup?: (core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap, instance: OB11PluginMangerAdapter) => void | Promise<void>;
plugin_init: (
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_onmessage?: (
adapter: string,
core: NapCatCore,
obCtx: NapCatOneBot11Adapter,
event: OB11Message,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_onevent?: (
adapter: string,
core: NapCatCore,
obCtx: NapCatOneBot11Adapter,
event: T,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
plugin_cleanup?: (
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap,
instance: OB11PluginMangerAdapter
) => void | Promise<void>;
}
export interface LoadedPlugin {
@@ -29,16 +55,25 @@ export interface LoadedPlugin {
module: PluginModule;
}
export interface PluginStatusConfig {
[key: string]: boolean; // key: pluginName, value: enabled
}
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
private readonly pluginPath: string;
private readonly configPath: string;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
declare config: PluginConfig;
override get isActive (): boolean {
return this.isEnable && this.loadedPlugins.size > 0;
}
constructor (
name: string, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
name: string,
core: NapCatCore,
obContext: NapCatOneBot11Adapter,
actions: ActionMap
) {
const config = {
name,
@@ -49,24 +84,60 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
};
super(name, config, core, obContext, actions);
this.pluginPath = this.core.context.pathWrapper.pluginPath;
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
}
private loadPluginConfig (): PluginStatusConfig {
if (fs.existsSync(this.configPath)) {
try {
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
} catch (e) {
this.logger.logWarn('[Plugin Adapter] Error parsing plugins.json', e);
}
}
return {};
}
private savePluginConfig (config: PluginStatusConfig) {
try {
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (e) {
this.logger.logError('[Plugin Adapter] Error saving plugins.json', e);
}
}
/**
* 扫描并加载插件
*/
* 扫描并加载插件
*/
private async loadPlugins (): Promise<void> {
try {
// 确保插件目录存在
if (!fs.existsSync(this.pluginPath)) {
this.logger.logWarn(`[Plugin Adapter] Plugin directory does not exist: ${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 });
const pluginConfig = this.loadPluginConfig();
// 扫描文件和目录
for (const item of items) {
let pluginName = '';
if (item.isFile()) {
pluginName = path.parse(item.name).name;
} else if (item.isDirectory()) {
pluginName = item.name;
}
// Check if plugin is disabled in config
if (pluginConfig[pluginName] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled in config, skipping`);
continue;
}
if (item.isFile()) {
// 处理单文件插件
await this.loadFilePlugin(item.name);
@@ -76,16 +147,18 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}
}
this.logger.log(`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`);
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<void> {
* 加载单文件插件 (.mjs, .js)
*/
public async loadFilePlugin (filename: string): Promise<void> {
// 只处理支持的文件类型
if (!this.isSupportedFile(filename)) {
return;
@@ -93,11 +166,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const filePath = path.join(this.pluginPath, filename);
const pluginName = path.parse(filename).name;
const pluginConfig = this.loadPluginConfig();
// Check if plugin is disabled in config
if (pluginConfig[pluginName] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled by user`);
return;
}
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)`);
this.logger.logWarn(
`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`
);
return;
}
@@ -110,15 +192,31 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading file plugin ${filename}:`, error);
this.logger.logError(
`[Plugin Adapter] Error loading file plugin ${filename}:`,
error
);
}
}
/**
* 加载目录插件
*/
private async loadDirectoryPlugin (dirname: string): Promise<void> {
* 加载目录插件
*/
public async loadDirectoryPlugin (dirname: string): Promise<void> {
const pluginDir = path.join(this.pluginPath, dirname);
const pluginConfig = this.loadPluginConfig();
// Ideally we'd get the name from package.json first, but we can use dirname as a fallback identifier initially.
// However, the list scan uses item.name (dirname) as the key. Let's stick to using dirname/filename as the config key for simplicity and consistency.
// Wait, package.json name might override. But for management, consistent ID is better.
// Let's check config after parsing package.json?
// User expects to disable 'plugin-name'. But if multiple folders have same name? Not handled.
// Let's use dirname as the key for config to be consistent with file system.
if (pluginConfig[dirname] === false) {
this.logger.log(`[Plugin Adapter] Plugin ${dirname} is disabled by user`);
return;
}
try {
// 尝试读取 package.json
@@ -130,14 +228,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
packageJson = JSON.parse(packageContent);
} catch (error) {
this.logger.logWarn(`[Plugin Adapter] Invalid package.json in ${dirname}:`, error);
this.logger.logWarn(
`[Plugin Adapter] Invalid package.json in ${dirname}:`,
error
);
}
}
// Check if disabled by package name IF package.json exists?
// No, file system name is more reliable ID for resource management here.
// 确定入口文件
const entryFile = this.findEntryFile(pluginDir, packageJson);
if (!entryFile) {
this.logger.logWarn(`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`);
this.logger.logWarn(
`[Plugin Adapter] No valid entry file found for plugin directory: ${dirname}`
);
return;
}
@@ -145,7 +251,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
const module = await this.importModule(entryPath);
if (!this.isValidPluginModule(module)) {
this.logger.logWarn(`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`);
this.logger.logWarn(
`[Plugin Adapter] Directory ${dirname} does not contain a valid plugin`
);
return;
}
@@ -160,14 +268,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.registerPlugin(plugin);
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error loading directory plugin ${dirname}:`, error);
this.logger.logError(
`[Plugin Adapter] Error loading directory plugin ${dirname}:`,
error
);
}
}
/**
* 查找插件目录的入口文件
*/
private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
* 查找插件目录的入口文件
*/
private findEntryFile (
pluginDir: string,
packageJson?: PluginPackageJson
): string | null {
// 优先级package.json main > 默认文件名
const possibleEntries = [
packageJson?.main,
@@ -188,53 +302,69 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}
/**
* 检查是否为支持的文件类型
*/
* 检查是否为支持的文件类型
*/
private isSupportedFile (filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return ['.mjs', '.js'].includes(ext);
}
/**
* 动态导入模块
*/
* 动态导入模块
*/
private async importModule (filePath: string): Promise<any> {
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
return await import(fileUrl);
// Add timestamp to force reload cache if supported or just import
// Note: dynamic import caching is tricky in ESM. Adding query param might help?
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
return await import(fileUrlWithQuery);
}
/**
* 检查模块是否为有效的插件模块
*/
* 检查模块是否为有效的插件模块
*/
private isValidPluginModule (module: any): module is PluginModule {
return module && typeof module.plugin_init === 'function';
}
/**
* 注册插件
*/
* 注册插件
*/
private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
// 检查名称冲突
if (this.loadedPlugins.has(plugin.name)) {
this.logger.logWarn(`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`);
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}` : ''}`);
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);
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);
this.logger.logError(
`[Plugin Adapter] Error initializing plugin ${plugin.name}:`,
error
);
}
}
/**
* 卸载插件
*/
* 卸载插件
*/
private async unloadPlugin (pluginName: string): Promise<void> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
@@ -244,10 +374,18 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 调用插件清理方法
if (typeof plugin.module.plugin_cleanup === 'function') {
try {
await plugin.module.plugin_cleanup(this.core, this.obContext, this.actions, this);
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.logger.logError(
`[Plugin Adapter] Error cleaning up plugin ${pluginName}:`,
error
);
}
}
@@ -255,6 +393,69 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
public async unregisterPlugin (pluginName: string): Promise<void> {
return this.unloadPlugin(pluginName);
}
public getPluginPath (): string {
return this.pluginPath;
}
public getPluginConfig (): PluginStatusConfig {
return this.loadPluginConfig();
}
public setPluginStatus (pluginName: string, enable: boolean): void {
const config = this.loadPluginConfig();
config[pluginName] = enable;
this.savePluginConfig(config);
// If disabling, unload immediately if loaded
if (!enable) {
// Note: pluginName passed here might be the package name or the filename/dirname
// But our registerPlugin uses plugin.name which comes from package.json or dirname.
// This mismatch is tricky.
// Ideally, we should use a consistent ID.
// Let's assume pluginName passed here effectively matches the ID used in loadedPlugins.
// But wait, loadDirectoryPlugin logic: name = packageJson.name || dirname.
// config key = dirname.
// If packageJson.name != dirname, we have a problem.
// To fix this properly:
// 1. We need to know which LoadedPlugin corresponds to the enabled/disabled item.
// 2. Or we iterate loadedPlugins and find match.
for (const [_, loaded] of this.loadedPlugins.entries()) {
const dirOrFile = path.basename(loaded.pluginPath === this.pluginPath ? loaded.entryPath : loaded.pluginPath);
const ext = path.extname(dirOrFile);
const simpleName = ext ? path.parse(dirOrFile).name : dirOrFile; // filename without ext
// But wait, config key is the FILENAME (with ext for files?).
// In Scan loop:
// pluginName = path.parse(item.name).name (for file)
// pluginName = item.name (for dir)
// config[pluginName] check.
// So if file is "test.js", pluginName is "test". Config key "test".
// If dir is "test-plugin", pluginName is "test-plugin". Config key "test-plugin".
// loadedPlugin.name might be distinct.
// So we need to match loadedPlugin back to its fs source to unload it?
// loadedPlugin.entryPath or pluginPath helps.
// If it's a file plugin: loaded.entryPath ends with pluginName + ext.
// If it's a dir plugin: loaded.pluginPath ends with pluginName.
if (pluginName === simpleName) {
this.unloadPlugin(loaded.name).catch(e => this.logger.logError('Error unloading', e));
}
}
}
// If enabling, we need to load it.
// But we can just rely on the API handler to call loadFile/DirectoryPlugin which now checks config.
// Wait, if I call loadFilePlugin("test.js") and config says enable=true, it loads.
// API handler needs to change to pass filename/dirname.
}
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) {
return;
@@ -273,21 +474,44 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}
/**
* 调用插件的事件处理方法
*/
private async callPluginEventHandler (plugin: LoadedPlugin, event: OB11EmitEventContent): Promise<void> {
* 调用插件的事件处理方法
*/
private async callPluginEventHandler (
plugin: LoadedPlugin,
event: OB11EmitEventContent
): Promise<void> {
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);
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);
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);
this.logger.logError(
`[Plugin Adapter] Error calling plugin ${plugin.name} event handler:`,
error
);
}
}
@@ -302,7 +526,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 加载所有插件
await this.loadPlugins();
this.logger.log(`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`);
this.logger.log(
`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`
);
}
async close () {
@@ -334,22 +560,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
}
/**
* 获取已加载的插件列表
*/
* 获取已加载的插件列表
*/
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<boolean> {
const plugin = this.loadedPlugins.get(pluginName);
if (!plugin) {
@@ -362,8 +588,10 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.unloadPlugin(pluginName);
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
// Use logic to re-determine if it is directory or file based on original paths
// Note: we can't fully trust fs status if it's gone.
const isDirectory =
plugin.pluginPath !== this.pluginPath; // Simple check: if path is nested, it's a dir plugin
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);
@@ -373,10 +601,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
await this.loadFilePlugin(filename);
}
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`);
this.logger.log(
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
);
return true;
} catch (error) {
this.logger.logError(`[Plugin Adapter] Error reloading plugin ${pluginName}:`, error);
this.logger.logError(
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
error
);
return false;
}
}

View File

@@ -159,7 +159,8 @@ export interface OB11MessageAt {
export interface OB11MessageReply {
type: OB11MessageDataType.reply;
data: {
id: string;
id?: string; // msg_id 的短ID映射
seq?: number; // msg_seq优先使用
};
}

View File

@@ -0,0 +1,84 @@
import type { ActionMap } from 'napcat-onebot/action';
import { EventType } from 'napcat-onebot/event/OneBotEvent';
import type { PluginModule } from 'napcat-onebot/network/plugin';
import type { OB11Message, OB11PostSendMsg } from 'napcat-onebot/types/message';
let actions: ActionMap | undefined = undefined;
/**
* 插件初始化
*/
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
console.log('[Plugin: builtin] NapCat 内置插件已初始化');
actions = _actions;
};
/**
* 消息处理
* 当收到包含 #napcat 的消息时,回复版本信息
*/
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) {
return;
}
try {
const versionInfo = await getVersionInfo(adapter, instance.config);
if (!versionInfo) return;
const message = formatVersionMessage(versionInfo);
await sendMessage(event, message, adapter, instance.config);
console.log('[Plugin: builtin] 已回复版本信息');
} catch (error) {
console.error('[Plugin: builtin] 处理消息时发生错误:', error);
}
};
/**
* 获取版本信息(完美的类型推导,无需 as 断言)
*/
async function getVersionInfo (adapter: string, config: any) {
if (!actions) return null;
try {
const data = await actions.call('get_version_info', void 0, adapter, config);
return {
appName: data.app_name,
appVersion: data.app_version,
protocolVersion: data.protocol_version,
};
} catch (error) {
console.error('[Plugin: builtin] 获取版本信息失败:', error);
return null;
}
}
/**
* 格式化版本信息消息
*/
function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) {
return `NapCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}`;
}
/**
* 发送消息(完美的类型推导)
*/
async function sendMessage (event: OB11Message, message: string, adapter: string, config: any) {
if (!actions) return;
const params: OB11PostSendMsg = {
message,
message_type: event.message_type,
...(event.message_type === 'group' && event.group_id ? { group_id: String(event.group_id) } : {}),
...(event.message_type === 'private' && event.user_id ? { user_id: String(event.user_id) } : {}),
};
try {
await actions.call('send_msg', params, adapter, config);
} catch (error) {
console.error('[Plugin: builtin] 发送消息失败:', error);
}
}
export { plugin_init, plugin_onmessage };

View File

@@ -0,0 +1,17 @@
{
"name": "napcat-plugin-builtin",
"version": "1.0.0",
"type": "module",
"main": "index.mjs",
"description": "NapCat 内置插件",
"author": "NapNeko",
"dependencies": {
"napcat-onebot": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"scripts": {
"build": "vite build"
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"*.ts",
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -0,0 +1,77 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
import fs from 'fs';
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
// 构建后拷贝插件
function copyToShellPlugin () {
return {
name: 'copy-to-shell',
closeBundle () {
try {
const sourceDir = resolve(__dirname, 'dist');
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin');
const packageJsonSource = resolve(__dirname, 'package.json');
// 确保目标目录存在
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
console.log(`[copy-to-shell] Created directory: ${targetDir}`);
}
// 拷贝 dist 目录下的所有文件
const files = fs.readdirSync(sourceDir);
let copiedCount = 0;
files.forEach(file => {
const sourcePath = resolve(sourceDir, file);
const targetPath = resolve(targetDir, file);
if (fs.statSync(sourcePath).isFile()) {
fs.copyFileSync(sourcePath, targetPath);
copiedCount++;
}
});
// 拷贝 package.json
if (fs.existsSync(packageJsonSource)) {
const packageJsonTarget = resolve(targetDir, 'package.json');
fs.copyFileSync(packageJsonSource, packageJsonTarget);
copiedCount++;
}
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
} catch (error) {
console.error('[copy-to-shell] Failed to copy files:', error);
throw error;
}
},
};
}
export default defineConfig({
resolve: {
conditions: ['node', 'default'],
alias: {
'@/napcat-core': resolve(__dirname, '../napcat-core'),
'@': resolve(__dirname, '../'),
},
},
build: {
sourcemap: false,
target: 'esnext',
minify: false,
lib: {
entry: 'index.ts',
formats: ['es'],
fileName: () => 'index.mjs',
},
rollupOptions: {
external: [...nodeModules],
},
},
plugins: [nodeResolve(), copyToShellPlugin()],
});

View File

@@ -3,5 +3,5 @@ REM 快速登录示例脚本
REM -q 参数是可选的,不传则使用二维码登录
REM
REM 使用方法(删掉对应系统那行的 REM
REM ./launcher.bat -q 123456
REM ./launcher-win10.bat -q 123456
REM ./launcher-user.bat 123456
REM ./launcher-win10-user.bat 123456

View File

@@ -26,6 +26,7 @@ const logger = new LogWrapper(pathWrapper.logsPath);
let processManager: IProcessManager | null = null;
let currentWorker: IWorkerProcess | null = null;
let isElectron = false;
let isRestarting = false;
/**
* 获取进程类型名称(用于日志)
@@ -72,10 +73,12 @@ function forceKillProcess (pid: number): void {
*/
export async function restartWorker (): Promise<void> {
logger.log('[NapCat] [Process] 正在重启Worker进程...');
isRestarting = true;
if (!currentWorker) {
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
await startWorker();
await startWorker(false);
isRestarting = false;
return;
}
@@ -131,15 +134,17 @@ export async function restartWorker (): Promise<void> {
logger.log('[NapCat] [Process] Worker进程已关闭等待 3 秒后启动新进程...');
await new Promise(resolve => setTimeout(resolve, 3000));
// 5. 启动新进程
await startWorker();
// 5. 启动新进程(重启模式不传递快速登录参数)
await startWorker(false);
isRestarting = false;
logger.log('[NapCat] [Process] Worker进程重启完成');
}
/**
* 启动 Worker 进程
* @param passQuickLogin 是否传递快速登录参数,默认为 true重启时为 false
*/
async function startWorker (): Promise<void> {
async function startWorker (passQuickLogin: boolean = true): Promise<void> {
if (!processManager) {
throw new Error('进程管理器未初始化');
}
@@ -147,7 +152,21 @@ async function startWorker (): Promise<void> {
const workerScript = getWorkerScriptPath();
const processType = getProcessTypeName();
const child = processManager.createWorker(workerScript, [], {
// 只在首次启动时传递 -q 或 --qq 参数给 worker 进程
const workerArgs: string[] = [];
if (passQuickLogin) {
const args = process.argv.slice(2);
const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq');
if (qIndex !== -1 && qIndex + 1 < args.length) {
const qFlag = args[qIndex];
const qValue = args[qIndex + 1];
if (qFlag && qValue) {
workerArgs.push(qFlag, qValue);
}
}
}
const child = processManager.createWorker(workerScript, workerArgs, {
env: {
...process.env,
NAPCAT_WORKER_PROCESS: '1',
@@ -192,6 +211,13 @@ async function startWorker (): Promise<void> {
} else {
logger.log(`[NapCat] [${processType}] Worker进程正常退出`);
}
// 如果不是由于主动重启引起的退出,尝试自动重新拉起(保留快速登录参数)
if (!isRestarting) {
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出正在尝试重新拉起...`);
startWorker(true).catch(e => {
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
});
}
});
child.on('spawn', () => {

View File

@@ -0,0 +1,225 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
import path from 'path';
import fs from 'fs';
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) return null;
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
};
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// 辅助函数:根据文件名/路径生成唯一ID作为配置键
const getPluginId = (fsName: string, isFile: boolean): string => {
if (isFile) {
return path.parse(fsName).name;
}
return fsName;
};
const loadedPlugins = pluginManager.getLoadedPlugins();
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
// 1. 整理已加载的插件
for (const p of loadedPlugins) {
// 计算 ID需要回溯到加载时的入口信息
// 对于已加载的插件,我们通过判断 pluginPath 是否等于根 pluginPath 来判断它是单文件还是目录
const isFilePlugin = p.pluginPath === pluginManager.getPluginPath();
const fsName = isFilePlugin ? path.basename(p.entryPath) : path.basename(p.pluginPath);
const id = getPluginId(fsName, isFilePlugin);
loadedPluginMap.set(id, {
name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name
id: id,
version: p.version || '0.0.0',
description: p.packageJson?.description || '',
author: p.packageJson?.author || '',
status: 'active',
filename: fsName, // 真实文件/目录名
loadedName: p.name // 运行时注册的名称,用于重载/卸载
});
}
const pluginPath = pluginManager.getPluginPath();
const pluginConfig = pluginManager.getPluginConfig();
const allPlugins: any[] = [];
// 2. 扫描文件系统,合并状态
if (fs.existsSync(pluginPath)) {
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
for (const item of items) {
let id = '';
if (item.isFile()) {
if (!['.js', '.mjs'].includes(path.extname(item.name))) continue;
id = getPluginId(item.name, true);
} else if (item.isDirectory()) {
id = getPluginId(item.name, false);
} else {
continue;
}
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
if (loadedPluginMap.has(id)) {
// 已加载,使用加载的信息
const loadedInfo = loadedPluginMap.get(id);
allPlugins.push(loadedInfo);
} else {
// 未加载 (可能是被禁用,或者加载失败,或者新增未运行)
let version = '0.0.0';
let description = '';
let author = '';
// 默认显示名称为 ID (文件名/目录名)
let name = id;
try {
// 尝试读取 package.json 获取信息
if (item.isDirectory()) {
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
version = pkg.version || version;
description = pkg.description || description;
author = pkg.author || author;
// 如果 package.json 有 name优先使用
name = pkg.name || name;
}
}
} catch (e) { }
allPlugins.push({
name: name,
id: id,
version,
description,
author,
// 如果配置是 false则为 disabled否则是 stopped (应启动但未启动)
status: isActiveConfig ? 'stopped' : 'disabled',
filename: item.name
});
}
}
}
return sendSuccess(res, allPlugins);
};
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
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, 'Plugin Manager not found');
}
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) => {
const { enable, filename } = req.body;
// We Use filename / id to control config
// Front-end should pass the 'filename' or 'id' as the key identifier
if (!filename) return sendError(res, 'Plugin Filename/ID is required');
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// Calculate ID from filename (remove ext if file)
// Or just use the logic consistent with loadPlugins
let id = filename;
// If it has extension .js/.mjs, remove it to get the ID used in config
if (filename.endsWith('.js') || filename.endsWith('.mjs')) {
id = path.parse(filename).name;
}
try {
pluginManager.setPluginStatus(id, enable);
// If enabling, trigger load
if (enable) {
const pluginPath = pluginManager.getPluginPath();
const fullPath = path.join(pluginPath, filename);
if (fs.statSync(fullPath).isDirectory()) {
await pluginManager.loadDirectoryPlugin(filename);
} else {
await pluginManager.loadFilePlugin(filename);
}
} else {
// Disabling is handled inside setPluginStatus usually if implemented,
// OR we can explicitly unload here using the loaded name.
// The Manager's setPluginStatus implementation (if added) might logic this out.
// But our current Manager implementation just saves config.
// Wait, I updated Manager to try to unload.
// Let's rely on Manager's setPluginStatus or do it here?
// I implemented a basic unload loop in Manager.setPluginStatus.
}
return sendSuccess(res, { message: 'Status updated successfully' });
} catch (e: any) {
return sendError(res, 'Failed to update status: ' + e.message);
}
};
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
const { name, filename } = req.body;
// If it's loaded, we use name. If it's disabled, we might use filename.
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
}
// Check if loaded
const plugin = pluginManager.getPluginInfo(name);
let fsPath = '';
if (plugin) {
// Active plugin
await pluginManager.unregisterPlugin(name);
if (plugin.pluginPath === pluginManager.getPluginPath()) {
fsPath = plugin.entryPath;
} else {
fsPath = plugin.pluginPath;
}
} else {
// Disabled or not loaded
if (filename) {
fsPath = path.join(pluginManager.getPluginPath(), filename);
} else {
return sendError(res, 'Plugin not found, provide filename if disabled');
}
}
try {
if (fs.existsSync(fsPath)) {
fs.rmSync(fsPath, { recursive: true, force: true });
}
return sendSuccess(res, { message: 'Uninstalled successfully' });
} catch (e: any) {
return sendError(res, 'Failed to uninstall: ' + e.message);
}
};

View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
const router = Router();
router.get('/List', GetPluginListHandler);
router.post('/Reload', ReloadPluginHandler);
router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler);
export { router as PluginRouter };

View File

@@ -17,6 +17,7 @@ import { WebUIConfigRouter } from './WebUIConfig';
import { UpdateNapCatRouter } from './UpdateNapCat';
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
import { ProcessRouter } from './Process';
import { PluginRouter } from './Plugin';
const router = Router();
@@ -47,5 +48,7 @@ router.use('/UpdateNapCat', UpdateNapCatRouter);
router.use('/Debug', DebugRouter);
// router:进程管理相关路由
router.use('/Process', ProcessRouter);
// router:插件管理相关路由
router.use('/Plugin', PluginRouter);
export { router as ALLRouter };

View File

@@ -25,6 +25,7 @@ const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
function App () {
return (
@@ -42,7 +43,7 @@ function App () {
);
}
function AuthChecker ({ children }: { children: React.ReactNode }) {
function AuthChecker ({ children }: { children: React.ReactNode; }) {
const { isAuth } = useAuth();
const navigate = useNavigate();
@@ -76,6 +77,7 @@ function AppRoutes () {
</Route>
<Route path='file_manager' element={<FileManagerPage />} />
<Route path='terminal' element={<TerminalPage />} />
<Route path='plugins' element={<PluginPage />} />
<Route path='about' element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />

View File

@@ -93,7 +93,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
onPress={handleEnableDebug}
isDisabled={editing}
>
{debug ? '关闭调试' : '开启调试'}
{debug ? '默认' : '调试'}
</Button>
<Button
fullWidth

View File

@@ -0,0 +1,127 @@
import { Button } from '@heroui/button';
import { Switch } from '@heroui/switch';
import { Chip } from '@heroui/chip';
import { useState } from 'react';
import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md';
import DisplayCardContainer from './container';
import { PluginItem } from '@/controllers/plugin_manager';
export interface PluginDisplayCardProps {
data: PluginItem;
onReload: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onUninstall: () => Promise<void>;
}
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
data,
onReload,
onToggleStatus,
onUninstall,
}) => {
const { name, version, author, description, status } = data;
const isEnabled = status !== 'disabled';
const [processing, setProcessing] = useState(false);
const handleToggle = () => {
setProcessing(true);
onToggleStatus().finally(() => setProcessing(false));
};
const handleReload = () => {
setProcessing(true);
onReload().finally(() => setProcessing(false));
};
const handleUninstall = () => {
setProcessing(true);
onUninstall().finally(() => setProcessing(false));
};
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
action={
<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
fullWidth
radius='full'
size='sm'
variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleUninstall}
isDisabled={processing}
>
</Button>
</div>
}
enableSwitch={
<Switch
isDisabled={processing}
isSelected={isEnabled}
onChange={handleToggle}
classNames={{
wrapper: 'group-data-[selected=true]:bg-primary-400',
}}
/>
}
title={name}
tag={
<Chip
className="ml-auto"
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
size="sm"
variant="flat"
>
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
</Chip>
}
>
<div className='grid grid-cols-2 gap-3'>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{version}
</div>
</div>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{author || '未知'}
</div>
</div>
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2'>
{description || '暂无描述'}
</div>
</div>
</div>
</DisplayCardContainer>
);
};
export default PluginDisplayCard;

View File

@@ -8,6 +8,7 @@ import {
LuSignal,
LuTerminal,
LuZap,
LuPackage,
} from 'react-icons/lu';
export type SiteConfig = typeof siteConfig;
@@ -59,6 +60,11 @@ export const siteConfig = {
icon: <LuFolderOpen className='w-5 h-5' />,
href: '/file_manager',
},
{
label: '插件管理',
icon: <LuPackage className='w-5 h-5' />,
href: '/plugins',
},
{
label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />,

View File

@@ -61,7 +61,8 @@ const messageNode = z.union([
.object({
type: z.literal('reply'),
data: z.object({
id: z.number(),
id: z.number().optional(),
seq: z.number().optional(),
}),
})
.describe('回复消息'),

View File

@@ -0,0 +1,35 @@
import { serverRequest } from '@/utils/request';
export interface PluginItem {
name: string;
version: string;
description: string;
author: string;
status: 'active' | 'disabled' | 'stopped';
filename?: string;
}
export interface ServerResponse<T> {
code: number;
message: string;
data: T;
}
export default class PluginManager {
public static async getPluginList () {
const { data } = await serverRequest.get<ServerResponse<PluginItem[]>>('/Plugin/List');
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) {
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
}
public static async uninstallPlugin (name: string, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
}
}

View File

@@ -0,0 +1,115 @@
import { Button } from '@heroui/button';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import PageLoading from '@/components/page_loading';
import PluginDisplayCard from '@/components/display_card/plugin_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
import useDialog from '@/hooks/use-dialog';
export default function PluginPage () {
const [plugins, setPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const dialog = useDialog();
const loadPlugins = async () => {
setLoading(true);
try {
const data = await PluginManager.getPluginList();
setPlugins(data);
} catch (e: any) {
toast.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
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 isEnable = plugin.status !== 'active';
const actionText = isEnable ? '启用' : '禁用';
const loadingToast = toast.loading(`${actionText}中...`);
try {
await PluginManager.setPluginStatus(plugin.name, isEnable, plugin.filename);
toast.success(`${actionText}成功`, { id: loadingToast });
loadPlugins();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
}
};
const handleUninstall = async (plugin: PluginItem) => {
return new Promise<void>((resolve, reject) => {
dialog.confirm({
title: '卸载插件',
content: `确定要卸载插件「${plugin.name}」吗? 此操作不可恢复。`,
onConfirm: async () => {
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.name, plugin.filename);
toast.success('卸载成功', { id: loadingToast });
loadPlugins();
resolve();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
reject(e);
}
},
onCancel: () => {
resolve();
}
});
});
};
return (
<>
<title> - NapCat WebUI</title>
<div className='p-2 md:p-4 relative'>
<PageLoading loading={loading} />
<div className='flex mb-6 items-center gap-4'>
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius='full'
onPress={loadPlugins}
>
<IoMdRefresh size={24} />
</Button>
</div>
{plugins.length === 0 ? (
<div className="text-default-400"></div>
) : (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>
{plugins.map(plugin => (
<PluginDisplayCard
key={plugin.name}
data={plugin}
onReload={() => handleReload(plugin.name)}
onToggleStatus={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}
/>
))}
</div>
)}
</div>
</>
);
}

View File

@@ -24,196 +24,197 @@ export type OB11SegmentType =
| 'file';
export interface Segment {
type: OB11SegmentType
type: OB11SegmentType;
}
/** 纯文本 */
export interface TextSegment extends Segment {
type: 'text'
type: 'text';
data: {
text: string
}
text: string;
};
}
/** QQ表情 */
export interface FaceSegment extends Segment {
type: 'face'
type: 'face';
data: {
id: string
}
id: string;
};
}
/** 图片消息段 */
export interface ImageSegment extends Segment {
type: 'image'
type: 'image';
data: {
file: string
type?: 'flash'
url?: string
cache?: 0 | 1
proxy?: 0 | 1
timeout?: number
}
file: string;
type?: 'flash';
url?: string;
cache?: 0 | 1;
proxy?: 0 | 1;
timeout?: number;
};
}
/** 语音消息段 */
export interface RecordSegment extends Segment {
type: 'record'
type: 'record';
data: {
file: string
magic?: 0 | 1
url?: string
cache?: 0 | 1
proxy?: 0 | 1
timeout?: number
}
file: string;
magic?: 0 | 1;
url?: string;
cache?: 0 | 1;
proxy?: 0 | 1;
timeout?: number;
};
}
/** 短视频消息段 */
export interface VideoSegment extends Segment {
type: 'video'
type: 'video';
data: {
file: string
url?: string
cache?: 0 | 1
proxy?: 0 | 1
timeout?: number
}
file: string;
url?: string;
cache?: 0 | 1;
proxy?: 0 | 1;
timeout?: number;
};
}
/** @某人消息段 */
export interface AtSegment extends Segment {
type: 'at'
type: 'at';
data: {
qq: string | 'all'
name?: string
}
qq: string | 'all';
name?: string;
};
}
/** 猜拳魔法表情消息段 */
export interface RpsSegment extends Segment {
type: 'rps'
type: 'rps';
}
/** 掷骰子魔法表情消息段 */
export interface DiceSegment extends Segment {
type: 'dice'
type: 'dice';
}
/** 窗口抖动(戳一戳)消息段 */
export interface ShakeSegment extends Segment {
type: 'shake'
data: object
type: 'shake';
data: object;
}
/** 戳一戳消息段 */
export interface PokeSegment extends Segment {
type: 'poke'
type: 'poke';
data: {
type: string
id: string
name?: string
}
type: string;
id: string;
name?: string;
};
}
/** 匿名发消息消息段 */
export interface AnonymousSegment extends Segment {
type: 'anonymous'
type: 'anonymous';
data: {
ignore?: 0 | 1
}
ignore?: 0 | 1;
};
}
/** 链接分享消息段 */
export interface ShareSegment extends Segment {
type: 'share'
type: 'share';
data: {
url: string
title: string
content?: string
image?: string
}
url: string;
title: string;
content?: string;
image?: string;
};
}
/** 推荐好友/群消息段 */
export interface ContactSegment extends Segment {
type: 'contact'
type: 'contact';
data: {
type: 'qq' | 'group'
id: string
}
type: 'qq' | 'group';
id: string;
};
}
/** 位置消息段 */
export interface LocationSegment extends Segment {
type: 'location'
type: 'location';
data: {
lat: string
lon: string
title?: string
content?: string
}
lat: string;
lon: string;
title?: string;
content?: string;
};
}
/** 音乐分享消息段 */
export interface MusicSegment extends Segment {
type: 'music'
type: 'music';
data: {
type: 'qq' | '163' | 'xm'
id: string
}
type: 'qq' | '163' | 'xm';
id: string;
};
}
/** 音乐自定义分享消息段 */
export interface CustomMusicSegment extends Segment {
type: 'music'
type: 'music';
data: {
type: 'custom'
url: string
audio: string
title: string
content?: string
image?: string
}
type: 'custom';
url: string;
audio: string;
title: string;
content?: string;
image?: string;
};
}
/** 回复消息段 */
export interface ReplySegment extends Segment {
type: 'reply'
type: 'reply';
data: {
id: string
}
id?: string; // msg_id 的短ID映射
seq?: number; // msg_seq优先使用
};
}
export interface FileSegment extends Segment {
type: 'file'
type: 'file';
data: {
file: string
}
file: string;
};
}
/** 合并转发消息段 */
export interface ForwardSegment extends Segment {
type: 'forward'
type: 'forward';
data: {
id: string
}
id: string;
};
}
/** XML消息段 */
export interface XmlSegment extends Segment {
type: 'xml'
type: 'xml';
data: {
data: string
}
data: string;
};
}
/** JSON消息段 */
export interface JsonSegment extends Segment {
type: 'json'
type: 'json';
data: {
data: string
}
data: string;
};
}
/** OneBot11消息段 */
@@ -242,23 +243,23 @@ export type OB11SegmentBase =
/** 合并转发已有消息节点消息段 */
export interface DirectNodeSegment extends Segment {
type: 'node'
type: 'node';
data: {
id: string
}
id: string;
};
}
/** 合并转发自定义节点消息段 */
export interface CustomNodeSegments extends Segment {
type: 'node'
type: 'node';
data: {
user_id: string
nickname: string
content: OB11Segment[]
prompt?: string
summary?: string
source?: string
}
user_id: string;
nickname: string;
content: OB11Segment[];
prompt?: string;
summary?: string;
source?: string;
};
}
/** 合并转发消息段 */

10
pnpm-lock.yaml generated
View File

@@ -220,6 +220,16 @@ importers:
specifier: ^22.0.1
version: 22.19.1
packages/napcat-plugin-builtin:
dependencies:
napcat-onebot:
specifier: workspace:*
version: link:../napcat-onebot
devDependencies:
'@types/node':
specifier: ^22.0.1
version: 22.19.1
packages/napcat-protobuf:
dependencies:
'@protobuf-ts/runtime':