mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-08 14:00:26 +00:00
Compare commits
26 Commits
new-networ
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5851624fc | ||
|
|
b296d50d4a | ||
|
|
5284e0ac5a | ||
|
|
67d6cd3f2e | ||
|
|
0ba5862753 | ||
|
|
d4478275ee | ||
|
|
163bb88751 | ||
|
|
ec6762d916 | ||
|
|
ed1872a349 | ||
|
|
a7fd70ac3a | ||
|
|
7e38f1d227 | ||
|
|
0ca68010a5 | ||
|
|
822f683a14 | ||
|
|
f4d3d33954 | ||
|
|
d1abf788a5 | ||
|
|
9ba6b2ed40 | ||
|
|
3a880e389b | ||
|
|
1c7ac42a46 | ||
|
|
3e8b575015 | ||
|
|
7c22170e1e | ||
|
|
f143da6ba8 | ||
|
|
d0d3934869 | ||
|
|
808165b008 | ||
|
|
d23785f34d | ||
|
|
31daf41135 | ||
|
|
a2450b72be |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -21,4 +21,4 @@ export interface IStatusHelperSubscription {
|
||||
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
|
||||
emit (event: 'statusUpdate', status: SystemStatus): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
5
packages/napcat-core/external/appid.json
vendored
5
packages/napcat-core/external/appid.json
vendored
@@ -513,7 +513,10 @@
|
||||
},
|
||||
"9.9.26-44498": {
|
||||
"appid": 537337416,
|
||||
"offset": "0x1809C2810",
|
||||
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
|
||||
},
|
||||
"9.9.26-44725": {
|
||||
"appid": 537337569,
|
||||
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,10 @@
|
||||
"send": "23B0330",
|
||||
"recv": "0957648"
|
||||
},
|
||||
"3.2.21-42086-arm64": {
|
||||
"send": "3D6D98C",
|
||||
"recv": "14797C8"
|
||||
},
|
||||
"3.2.21-42086-x64": {
|
||||
"send": "5B42CF0",
|
||||
"recv": "2FDA6F0"
|
||||
@@ -146,5 +150,9 @@
|
||||
"9.9.26-44498-x64": {
|
||||
"send": "0A1051C",
|
||||
"recv": "1D3BC0D"
|
||||
},
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "0A18D0C",
|
||||
"recv": "1D4BF0D"
|
||||
}
|
||||
}
|
||||
4
packages/napcat-core/external/packet.json
vendored
4
packages/napcat-core/external/packet.json
vendored
@@ -658,5 +658,9 @@
|
||||
"9.9.26-44498-x64": {
|
||||
"send": "2CDAE40",
|
||||
"recv": "2CDE3C0"
|
||||
},
|
||||
"9.9.26-44725-x64": {
|
||||
"send": "2CEBB20",
|
||||
"recv": "2CEF0A0"
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export class NapCatCore {
|
||||
container.bind(TypedEventEmitter).toConstantValue(this.event);
|
||||
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
|
||||
container.bind(ServiceClass).toSelf();
|
||||
//console.log(`Registering service handler for: ${serviceName}`);
|
||||
// console.log(`Registering service handler for: ${serviceName}`);
|
||||
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
|
||||
const serviceInstance = container.get(ServiceClass);
|
||||
return serviceInstance.handler(seq, hex_data);
|
||||
@@ -177,8 +177,10 @@ export class NapCatCore {
|
||||
|
||||
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
|
||||
// 下线通知
|
||||
this.context.logger.logError('[KickedOffLine] [' + Info.tipsTitle + '] ' + Info.tipsDesc);
|
||||
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
|
||||
this.context.logger.logError(tips);
|
||||
this.selfInfo.online = false;
|
||||
this.event.emit('KickedOffLine', tips);
|
||||
};
|
||||
msgListener.onRecvMsg = (msgs) => {
|
||||
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
|
||||
|
||||
@@ -53,6 +53,8 @@ export class NodeIKernelLoginListener {
|
||||
|
||||
onLoginState (..._args: any[]): any {
|
||||
}
|
||||
onLoginRecordUpdate (..._args: any[]): any {
|
||||
}
|
||||
}
|
||||
|
||||
export interface QRCodeLoginSucceedResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TypedEventEmitter } from './typeEvent';
|
||||
|
||||
export interface AppEvents {
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number };
|
||||
'event:emoji_like': { groupId: string; senderUin: string; emojiId: string, msgSeq: string, isAdd: boolean, count: number; };
|
||||
KickedOffLine: string;
|
||||
}
|
||||
export const appEvent = new TypedEventEmitter<AppEvents>();
|
||||
|
||||
@@ -73,6 +73,8 @@ async function copyAll () {
|
||||
process.env.NAPCAT_QQ_PACKAGE_INFO_PATH = path.join(TARGET_DIR, 'package.json');
|
||||
process.env.NAPCAT_QQ_VERSION_CONFIG_PATH = path.join(TARGET_DIR, 'config.json');
|
||||
process.env.NAPCAT_DISABLE_PIPE = '1';
|
||||
// 禁用重启和多进程功能
|
||||
process.env.NAPCAT_DISABLE_MULTI_PROCESS = '1';
|
||||
process.env.NAPCAT_WORKDIR = TARGET_DIR;
|
||||
// 开发环境使用固定密钥
|
||||
process.env.NAPCAT_WEBUI_JWT_SECRET_KEY = 'napcat_dev_secret_key';
|
||||
|
||||
@@ -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';
|
||||
@@ -86,6 +88,7 @@ import { GetGroupMemberList } from './group/GetGroupMemberList';
|
||||
import { GetGroupFileUrl } from '@/napcat-onebot/action/file/GetGroupFileUrl';
|
||||
import { GetPacketStatus } from '@/napcat-onebot/action/packet/GetPacketStatus';
|
||||
import { GetCredentials } from './system/GetCredentials';
|
||||
import { SetRestart } from './system/SetRestart';
|
||||
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
|
||||
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
|
||||
import { GoCQHTTPCheckUrlSafely } from './go-cqhttp/GoCQHTTPCheckUrlSafely';
|
||||
@@ -266,6 +269,7 @@ export function createActionMap (obContext: NapCatOneBot11Adapter, core: NapCatC
|
||||
new GetGroupFileSystemInfo(obContext, core),
|
||||
new GetGroupFilesByFolder(obContext, core),
|
||||
new GetPacketStatus(obContext, core),
|
||||
new SetRestart(obContext, core),
|
||||
new GroupPoke(obContext, core),
|
||||
new FriendPoke(obContext, core),
|
||||
new GetUserStatus(obContext, core),
|
||||
@@ -320,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>;
|
||||
|
||||
@@ -81,7 +81,7 @@ export const ActionName = {
|
||||
CanSendRecord: 'can_send_record',
|
||||
GetStatus: 'get_status',
|
||||
GetVersionInfo: 'get_version_info',
|
||||
// Reboot : 'set_restart',
|
||||
Reboot: 'set_restart',
|
||||
CleanCache: 'clean_cache',
|
||||
Exit: 'bot_exit',
|
||||
// go-cqhttp
|
||||
|
||||
14
packages/napcat-onebot/action/system/SetRestart.ts
Normal file
14
packages/napcat-onebot/action/system/SetRestart.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OneBotAction } from '../OneBotAction';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
|
||||
export class SetRestart extends OneBotAction<void, void> {
|
||||
override actionName = ActionName.Reboot;
|
||||
|
||||
async _handle () {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
if (!result.result) {
|
||||
throw new Error(result.message || '进程重启失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ const HttpServerConfigSchema = Type.Object({
|
||||
port: Type.Number({ default: 3000 }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
@@ -18,7 +18,7 @@ const HttpSseServerConfigSchema = Type.Object({
|
||||
port: Type.Number({ default: 3000 }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: true }),
|
||||
enableWebsocket: Type.Boolean({ default: false }),
|
||||
messagePostFormat: Type.String({ default: 'array' }),
|
||||
token: Type.String({ default: '' }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
|
||||
@@ -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(
|
||||
@@ -246,7 +248,7 @@ export class NapCatOneBot11Adapter {
|
||||
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
|
||||
}
|
||||
|
||||
private async handleConfigChange<CT extends NetworkAdapterConfig>(
|
||||
private async handleConfigChange<CT extends NetworkAdapterConfig> (
|
||||
prevConfig: NetworkAdapterConfig[],
|
||||
nowConfig: NetworkAdapterConfig[],
|
||||
adapterClass: new (
|
||||
@@ -305,6 +307,9 @@ export class NapCatOneBot11Adapter {
|
||||
};
|
||||
|
||||
msgListener.onRecvMsg = async (msg) => {
|
||||
if (!this.networkManager.hasActiveAdapters()) {
|
||||
return;
|
||||
}
|
||||
for (const m of msg) {
|
||||
if (this.bootTime > parseInt(m.msgTime)) {
|
||||
this.context.logger.logDebug(`消息时间${m.msgTime}早于启动时间${this.bootTime},忽略上报`);
|
||||
@@ -384,6 +389,7 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
};
|
||||
msgListener.onKickedOffLine = async (kick) => {
|
||||
WebUiDataRuntime.setQQLoginStatus(false);
|
||||
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
|
||||
this.networkManager
|
||||
.emitEvent(event)
|
||||
@@ -517,15 +523,14 @@ export class NapCatOneBot11Adapter {
|
||||
}
|
||||
|
||||
private async emitMsg (message: RawMessage) {
|
||||
const network = await this.networkManager.getAllConfig();
|
||||
this.context.logger.logDebug('收到新消息 RawMessage', message);
|
||||
await Promise.allSettled([
|
||||
this.handleMsg(message, network),
|
||||
this.handleMsg(message),
|
||||
message.chatType === ChatType.KCHATTYPEGROUP ? this.handleGroupEvent(message) : this.handlePrivateMsgEvent(message),
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleMsg (message: RawMessage, network: Array<NetworkAdapterConfig>) {
|
||||
private async handleMsg (message: RawMessage) {
|
||||
// 过滤无效消息
|
||||
if (message.msgType === NTMsgType.KMSGTYPENULL) {
|
||||
return;
|
||||
@@ -535,10 +540,36 @@ export class NapCatOneBot11Adapter {
|
||||
if (ob11Msg) {
|
||||
const isSelfMsg = this.isSelfMessage(ob11Msg);
|
||||
this.context.logger.logDebug('转化为 OB11Message', ob11Msg);
|
||||
const msgMap = this.createMsgMap(network, ob11Msg, isSelfMsg, message);
|
||||
this.handleDebugNetwork(network, msgMap, message);
|
||||
this.handleNotReportSelfNetwork(network, msgMap, isSelfMsg);
|
||||
this.networkManager.emitEventByNames(msgMap);
|
||||
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
|
||||
const targetId = parseInt(message.peerUin);
|
||||
ob11Msg.stringMsg.target_id = targetId;
|
||||
ob11Msg.arrayMsg.target_id = targetId;
|
||||
}
|
||||
|
||||
const msgMap = new Map<string, OB11Message>();
|
||||
|
||||
for (const adapter of this.networkManager.adapters.values()) {
|
||||
if (!adapter.isActive) continue;
|
||||
const config = adapter.config;
|
||||
if (isSelfMsg) {
|
||||
if (!('reportSelfMessage' in config) || !config.reportSelfMessage) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const msgData = config.messagePostFormat === 'string' ? ob11Msg.stringMsg : ob11Msg.arrayMsg;
|
||||
if (config.debug) {
|
||||
const clone = structuredClone(msgData);
|
||||
clone.raw = message;
|
||||
msgMap.set(adapter.name, clone);
|
||||
} else {
|
||||
msgMap.set(adapter.name, msgData);
|
||||
}
|
||||
}
|
||||
if (msgMap.size > 0) {
|
||||
this.networkManager.emitEventByNames(msgMap);
|
||||
} else if (this.networkManager.hasActiveAdapters()) {
|
||||
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('constructMessage error: ', e);
|
||||
@@ -553,48 +584,6 @@ export class NapCatOneBot11Adapter {
|
||||
ob11Msg.arrayMsg.user_id.toString() === this.core.selfInfo.uin;
|
||||
}
|
||||
|
||||
private createMsgMap (network: Array<NetworkAdapterConfig>, ob11Msg: {
|
||||
stringMsg: OB11Message;
|
||||
arrayMsg: OB11Message;
|
||||
}, isSelfMsg: boolean, message: RawMessage): Map<string, OB11Message> {
|
||||
const msgMap: Map<string, OB11Message> = new Map();
|
||||
network.filter(e => e.enable).forEach(e => {
|
||||
if (isSelfMsg || message.chatType !== ChatType.KCHATTYPEGROUP) {
|
||||
ob11Msg.stringMsg.target_id = parseInt(message.peerUin);
|
||||
ob11Msg.arrayMsg.target_id = parseInt(message.peerUin);
|
||||
}
|
||||
if ('messagePostFormat' in e && e.messagePostFormat === 'string') {
|
||||
msgMap.set(e.name, structuredClone(ob11Msg.stringMsg));
|
||||
} else {
|
||||
msgMap.set(e.name, structuredClone(ob11Msg.arrayMsg));
|
||||
}
|
||||
});
|
||||
return msgMap;
|
||||
}
|
||||
|
||||
private handleDebugNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, message: RawMessage) {
|
||||
const debugNetwork = network.filter(e => e.enable && e.debug);
|
||||
if (debugNetwork.length > 0) {
|
||||
debugNetwork.forEach(adapter => {
|
||||
const msg = msgMap.get(adapter.name);
|
||||
if (msg) {
|
||||
msg.raw = message;
|
||||
}
|
||||
});
|
||||
} else if (msgMap.size === 0) {
|
||||
this.context.logger.logDebug('没有可用的网络适配器发送消息,消息内容:', message);
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotReportSelfNetwork (network: Array<NetworkAdapterConfig>, msgMap: Map<string, OB11Message>, isSelfMsg: boolean) {
|
||||
if (isSelfMsg) {
|
||||
const notReportSelfNetwork = network.filter(e => e.enable && (('reportSelfMessage' in e && !e.reportSelfMessage) || !('reportSelfMessage' in e)));
|
||||
notReportSelfNetwork.forEach(adapter => {
|
||||
msgMap.delete(adapter.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGroupEvent (message: RawMessage) {
|
||||
try {
|
||||
// 群名片修改事件解析 任何都该判断
|
||||
|
||||
@@ -23,11 +23,15 @@ export abstract class IOB11NetworkAdapter<CT extends NetworkAdapterConfig> {
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends OB11EmitEventContent>(event: T): Promise<void>;
|
||||
abstract onEvent<T extends OB11EmitEventContent> (event: T): Promise<void>;
|
||||
|
||||
abstract open (): void | Promise<void>;
|
||||
|
||||
abstract close (): void | Promise<void>;
|
||||
|
||||
abstract reload (config: unknown): OB11NetworkReloadType | Promise<OB11NetworkReloadType>;
|
||||
|
||||
get isActive (): boolean {
|
||||
return this.isEnable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { OB11HttpServerAdapter } from './http-server';
|
||||
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
private sseClients: Response[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && (this.sseClients.length > 0 || super.isActive);
|
||||
}
|
||||
|
||||
override async handleRequest (req: Request, res: Response) {
|
||||
if (req.path === '/_events') {
|
||||
this.createSseSupport(req, res);
|
||||
@@ -25,7 +29,8 @@ export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
super.onEvent(event);
|
||||
const promises: Promise<void>[] = [];
|
||||
this.sseClients.forEach((res) => {
|
||||
promises.push(new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import express, { Express, NextFunction, Request, Response } from 'express';
|
||||
import http from 'http';
|
||||
import http, { IncomingMessage } from 'http';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import cors from 'cors';
|
||||
import { HttpServerConfig } from '@/napcat-onebot/config/config';
|
||||
@@ -8,13 +8,41 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import json5 from 'json5';
|
||||
import { isFinished } from 'on-finished';
|
||||
import typeis from 'type-is';
|
||||
import { WebSocket, WebSocketServer, RawData } from 'ws';
|
||||
import { URL } from 'url';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||
private app: Express | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private wsServer?: WebSocketServer;
|
||||
private wsClients: WebSocket[] = [];
|
||||
private wsClientsMutex = new Mutex();
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
private wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (_event: T) {
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
// http server is passive, no need to emit event
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
}
|
||||
|
||||
open () {
|
||||
@@ -36,11 +64,24 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
this.isEnable = false;
|
||||
this.server?.close();
|
||||
this.app = undefined;
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
});
|
||||
this.wsServer?.close();
|
||||
}
|
||||
|
||||
private initializeServer () {
|
||||
this.app = express();
|
||||
this.server = http.createServer(this.app);
|
||||
if (this.config.enableWebsocket) {
|
||||
this.wsServer = new WebSocketServer({ server: this.server });
|
||||
this.createWSServer(this.wsServer);
|
||||
}
|
||||
|
||||
this.app.use(cors());
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' }));
|
||||
@@ -93,6 +134,137 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
}
|
||||
}
|
||||
|
||||
createWSServer (newServer: WebSocketServer) {
|
||||
newServer.on('connection', async (wsClient, wsReq) => {
|
||||
if (!this.isEnable) {
|
||||
wsClient.close();
|
||||
return;
|
||||
}
|
||||
if (!this.authorizeWS(this.config.token, wsClient, wsReq)) {
|
||||
return;
|
||||
}
|
||||
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
|
||||
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
|
||||
if (!isApiConnect) {
|
||||
this.connectEvent(this.core, wsClient);
|
||||
}
|
||||
|
||||
wsClient.on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Client Error:', err.message));
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('ping', () => {
|
||||
wsClient.pong();
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
});
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
|
||||
}
|
||||
|
||||
connectEvent (core: any, wsClient: WebSocket) {
|
||||
try {
|
||||
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient).catch(e => this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e));
|
||||
} catch (e) {
|
||||
this.logger.logError('[OneBot] [HTTP WebSocket] 发送生命周期失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private stopHeartbeat () {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private authorizeWS (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
|
||||
if (!token || token.length === 0) return true;
|
||||
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
|
||||
const QueryClientToken = url.searchParams.get('access_token');
|
||||
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
|
||||
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
|
||||
if (ClientToken === token) {
|
||||
return true;
|
||||
}
|
||||
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
|
||||
wsClient.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkStateAndReply<T> (data: T, wsClient: WebSocket) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(data));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWSMessage (wsClient: WebSocket, message: RawData) {
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
try {
|
||||
receiveData = json5.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};
|
||||
|
||||
const action = this.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.logger.logError('[OneBot] [HTTP WebSocket] 发生错误', '不支持的API ' + receiveData.action);
|
||||
await this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
|
||||
return;
|
||||
}
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
await this.checkStateAndReply<unknown>({ ...OB11Response.ok(data, echo ?? '', true) }, wsClient);
|
||||
},
|
||||
});
|
||||
await this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
|
||||
}
|
||||
|
||||
async httpApiRequest (req: Request, res: Response, request_sse: boolean = false) {
|
||||
let payload = req.body;
|
||||
if (req.method === 'get') {
|
||||
@@ -152,6 +324,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
async reload (newConfig: HttpServerConfig) {
|
||||
const wasEnabled = this.isEnable;
|
||||
const oldPort = this.config.port;
|
||||
const oldEnableWebsocket = this.config.enableWebsocket;
|
||||
this.config = newConfig;
|
||||
|
||||
if (newConfig.enable && !wasEnabled) {
|
||||
@@ -162,7 +335,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
return OB11NetworkReloadType.NetWorkClose;
|
||||
}
|
||||
|
||||
if (oldPort !== newConfig.port) {
|
||||
if (oldPort !== newConfig.port || oldEnableWebsocket !== newConfig.enableWebsocket) {
|
||||
this.close();
|
||||
if (newConfig.enable) {
|
||||
this.open();
|
||||
|
||||
@@ -21,7 +21,7 @@ export class OB11NetworkManager {
|
||||
|
||||
async emitEvent (event: OB11EmitEventContent) {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||
if (adapter.isEnable) {
|
||||
if (adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
@@ -34,7 +34,7 @@ export class OB11NetworkManager {
|
||||
async emitEventByName (names: string[], event: OB11EmitEventContent) {
|
||||
return Promise.all(names.map(async name => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isEnable) {
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
@@ -43,29 +43,29 @@ export class OB11NetworkManager {
|
||||
async emitEventByNames (map: Map<string, OB11EmitEventContent>) {
|
||||
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isEnable) {
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
registerAdapter<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
|
||||
registerAdapter<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig>(adapter: IOB11NetworkAdapter<CT>) {
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: IOB11NetworkAdapter<CT>) {
|
||||
this.registerAdapter(adapter);
|
||||
await adapter.open();
|
||||
}
|
||||
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig>(adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
async closeSomeAdaterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: IOB11NetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
if (adapter.isEnable) {
|
||||
@@ -88,17 +88,21 @@ export class OB11NetworkManager {
|
||||
this.adapters.clear();
|
||||
}
|
||||
|
||||
async readloadAdapter<T>(name: string, config: T) {
|
||||
async readloadAdapter<T> (name: string, config: T) {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.reload(config);
|
||||
}
|
||||
}
|
||||
|
||||
async readloadSomeAdapters<T>(configMap: Map<string, T>) {
|
||||
async readloadSomeAdapters<T> (configMap: Map<string, T>) {
|
||||
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.readloadAdapter(name, config)));
|
||||
}
|
||||
|
||||
hasActiveAdapters (): boolean {
|
||||
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
|
||||
}
|
||||
|
||||
async getAllConfig () {
|
||||
return Array.from(this.adapters.values()).map(adapter => adapter.config);
|
||||
}
|
||||
|
||||
@@ -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,12 +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,
|
||||
@@ -45,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);
|
||||
@@ -72,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;
|
||||
@@ -89,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;
|
||||
}
|
||||
|
||||
@@ -106,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
|
||||
@@ -126,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;
|
||||
}
|
||||
|
||||
@@ -141,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;
|
||||
}
|
||||
|
||||
@@ -156,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,
|
||||
@@ -184,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) {
|
||||
@@ -240,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +393,70 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
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;
|
||||
}
|
||||
@@ -269,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,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 () {
|
||||
@@ -330,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) {
|
||||
@@ -358,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);
|
||||
@@ -369,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
private readonly pluginPath: 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
|
||||
) {
|
||||
|
||||
@@ -13,6 +13,10 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
private connection: WebSocket | null = null;
|
||||
private heartbeatRef: NodeJS.Timeout | null = null;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && !!this.connection && this.connection.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(event));
|
||||
|
||||
@@ -21,6 +21,10 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.wsClientWithEvent.length > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
|
||||
) {
|
||||
@@ -70,6 +74,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
@@ -77,6 +84,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
});
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
||||
}
|
||||
@@ -114,9 +124,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
|
||||
|
||||
this.isEnable = true;
|
||||
if (this.config.heartInterval > 0) {
|
||||
this.registerHeartBeat();
|
||||
}
|
||||
}
|
||||
|
||||
async close () {
|
||||
@@ -128,10 +135,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
|
||||
}
|
||||
});
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
@@ -141,7 +145,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
});
|
||||
}
|
||||
|
||||
private registerHeartBeat () {
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
@@ -153,6 +158,13 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}, this.config.heartInterval);
|
||||
}
|
||||
|
||||
private stopHeartbeat () {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private authorize (token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
|
||||
if (!token || token.length === 0) return true;// 客户端未设置密钥
|
||||
const url = new URL(wsReq?.url || '', `http://${wsReq.headers.host}`);
|
||||
@@ -235,12 +247,9 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
|
||||
if (oldHeartbeatInterval !== newConfig.heartInterval) {
|
||||
if (this.heartbeatIntervalId) {
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
this.heartbeatIntervalId = null;
|
||||
}
|
||||
if (newConfig.heartInterval > 0 && this.isEnable) {
|
||||
this.registerHeartBeat();
|
||||
this.stopHeartbeat();
|
||||
if (newConfig.heartInterval > 0 && this.isEnable && this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
return OB11NetworkReloadType.NetWorkReload;
|
||||
}
|
||||
|
||||
@@ -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,优先使用
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
84
packages/napcat-plugin-builtin/index.ts
Normal file
84
packages/napcat-plugin-builtin/index.ts
Normal 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 };
|
||||
17
packages/napcat-plugin-builtin/package.json
Normal file
17
packages/napcat-plugin-builtin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
packages/napcat-plugin-builtin/tsconfig.json
Normal file
11
packages/napcat-plugin-builtin/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
77
packages/napcat-plugin-builtin/vite.config.ts
Normal file
77
packages/napcat-plugin-builtin/vite.config.ts
Normal 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()],
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,6 @@ import { napCatVersion } from 'napcat-common/src/version';
|
||||
import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener';
|
||||
import { sleep } from 'napcat-common/src/helper';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
@@ -128,10 +127,13 @@ async function handleLogin (
|
||||
|
||||
const loginListener = new NodeIKernelLoginListener();
|
||||
loginListener.onUserLoggedIn = (userid: string) => {
|
||||
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
|
||||
const tips = `当前账号(${userid})已登录,无法重复登录`;
|
||||
logger.logError(tips);
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
};
|
||||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||||
context.isLogined = true;
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
inner_resolve({
|
||||
uid: loginResult.uid,
|
||||
uin: loginResult.uin,
|
||||
@@ -170,13 +172,16 @@ async function handleLogin (
|
||||
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
|
||||
if (errType === 1 && errCode === 3) {
|
||||
// 二维码过期刷新
|
||||
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
|
||||
}
|
||||
loginService.getQRCodePicture();
|
||||
}
|
||||
};
|
||||
|
||||
loginListener.onLoginFailed = (...args) => {
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
|
||||
const errInfo = JSON.stringify(args);
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
|
||||
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
|
||||
};
|
||||
|
||||
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
|
||||
@@ -184,17 +189,29 @@ async function handleLogin (
|
||||
return await selfInfo;
|
||||
}
|
||||
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||||
// 注册刷新二维码回调
|
||||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||||
loginService.getQRCodePicture();
|
||||
});
|
||||
|
||||
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin) {
|
||||
logger.log('正在快速登录 ', uin);
|
||||
loginService.quickLoginWithUin(uin).then(res => {
|
||||
if (res.loginErrorInfo.errMsg) {
|
||||
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: res.loginErrorInfo.errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
resolve({ result: true, message: '' });
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '快速登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
@@ -209,6 +226,7 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
.then(result => {
|
||||
if (result.loginErrorInfo.errMsg) {
|
||||
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
})
|
||||
@@ -324,9 +342,9 @@ export async function NCoreInitShell () {
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
|
||||
if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
// if (process.env['NAPCAT_DISABLE_PIPE'] !== '1') {
|
||||
// await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
// }
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
@@ -452,6 +470,10 @@ export class NapCatShell {
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
// 监听下线通知并同步到 WebUI
|
||||
this.core.event.on('KickedOffLine', (tips: string) => {
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
});
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
@@ -459,4 +481,3 @@ export class NapCatShell {
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,345 @@
|
||||
import { NCoreInitShell } from './base';
|
||||
NCoreInitShell();
|
||||
import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// ES 模块中获取 __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 环境变量配置
|
||||
const ENV = {
|
||||
isWorkerProcess: process.env['NAPCAT_WORKER_PROCESS'] === '1',
|
||||
isMultiProcessDisabled: process.env['NAPCAT_DISABLE_MULTI_PROCESS'] === '1',
|
||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||
} as const;
|
||||
|
||||
// 初始化日志
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
|
||||
// 进程管理器和当前 Worker 进程引用
|
||||
let processManager: IProcessManager | null = null;
|
||||
let currentWorker: IWorkerProcess | null = null;
|
||||
let isElectron = false;
|
||||
let isRestarting = false;
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
function getProcessTypeName (): string {
|
||||
return isElectron ? 'UtilityProcess' : 'Fork';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Worker 脚本路径
|
||||
*/
|
||||
function getWorkerScriptPath (): string {
|
||||
return __filename.endsWith('.mjs')
|
||||
? path.join(__dirname, 'napcat.mjs')
|
||||
: path.join(__dirname, 'napcat.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查进程是否存在
|
||||
*/
|
||||
function isProcessAlive (pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制终止进程
|
||||
*/
|
||||
function forceKillProcess (pid: number): void {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
logger.log(`[NapCat] [Process] 已强制终止进程 ${pid}`);
|
||||
} catch (error) {
|
||||
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启 Worker 进程
|
||||
*/
|
||||
export async function restartWorker (): Promise<void> {
|
||||
logger.log('[NapCat] [Process] 正在重启Worker进程...');
|
||||
isRestarting = true;
|
||||
|
||||
if (!currentWorker) {
|
||||
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
|
||||
await startWorker(false);
|
||||
isRestarting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const workerPid = currentWorker.pid;
|
||||
logger.log(`[NapCat] [Process] 准备关闭Worker进程,PID: ${workerPid}`);
|
||||
|
||||
// 1. 通知旧进程准备重启(旧进程会自行退出)
|
||||
currentWorker.postMessage({ type: 'restart-prepare' });
|
||||
|
||||
// 2. 等待进程退出(最多 5 秒,给更多时间让进程自行清理)
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.logWarn('[NapCat] [Process] Worker进程未在 5 秒内退出,尝试发送强制关闭信号');
|
||||
currentWorker?.postMessage({ type: 'shutdown' });
|
||||
|
||||
// 再等待 2 秒
|
||||
setTimeout(() => {
|
||||
logger.logWarn('[NapCat] [Process] Worker进程仍未退出,尝试 kill');
|
||||
currentWorker?.kill();
|
||||
resolve();
|
||||
}, 2000);
|
||||
}, 5000);
|
||||
|
||||
currentWorker?.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
logger.log('[NapCat] [Process] Worker进程已正常退出');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 二次确认进程是否真的被终止(兜底检查)
|
||||
if (workerPid) {
|
||||
logger.log(`[NapCat] [Process] 检查进程 ${workerPid} 是否已终止...`);
|
||||
|
||||
if (isProcessAlive(workerPid)) {
|
||||
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉(兜底)`);
|
||||
forceKillProcess(workerPid);
|
||||
|
||||
// 等待 1 秒后再次检查
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
if (isProcessAlive(workerPid)) {
|
||||
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
|
||||
} else {
|
||||
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已被强制终止`);
|
||||
}
|
||||
} else {
|
||||
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已确认终止`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 等待 3 秒后启动新进程
|
||||
logger.log('[NapCat] [Process] Worker进程已关闭,等待 3 秒后启动新进程...');
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// 5. 启动新进程(重启模式不传递快速登录参数)
|
||||
await startWorker(false);
|
||||
isRestarting = false;
|
||||
logger.log('[NapCat] [Process] Worker进程重启完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Worker 进程
|
||||
* @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false
|
||||
*/
|
||||
async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
if (!processManager) {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
|
||||
const workerScript = getWorkerScriptPath();
|
||||
const processType = getProcessTypeName();
|
||||
|
||||
// 只在首次启动时传递 -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',
|
||||
},
|
||||
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
|
||||
currentWorker = child;
|
||||
|
||||
// 监听标准输出(直接转发)
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
process.stdout.write(data);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听标准错误(直接转发)
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听子进程消息
|
||||
child.on('message', (msg: unknown) => {
|
||||
logger.log(`[NapCat] [${processType}] 收到Worker消息:`, msg);
|
||||
|
||||
// 处理重启请求
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
|
||||
logger.log(`[NapCat] [${processType}] 收到重启请求,正在重启Worker进程...`);
|
||||
restartWorker().catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 监听子进程退出
|
||||
child.on('exit', (code: unknown) => {
|
||||
const exitCode = typeof code === 'number' ? code : 0;
|
||||
if (exitCode !== 0) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程退出,退出码: ${exitCode}`);
|
||||
} 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', () => {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已生成`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Master 进程
|
||||
*/
|
||||
async function startMasterProcess (): Promise<void> {
|
||||
const processType = getProcessTypeName();
|
||||
logger.log(`[NapCat] [${processType}] Master进程启动,PID: ${process.pid}`);
|
||||
|
||||
// 连接命名管道(可通过环境变量禁用)
|
||||
if (!ENV.isPipeDisabled) {
|
||||
await connectToNamedPipe(logger).catch(e =>
|
||||
logger.logError('命名管道连接失败', e)
|
||||
);
|
||||
} else {
|
||||
logger.log(`[NapCat] [${processType}] 命名管道已禁用 (NAPCAT_DISABLE_PIPE=1)`);
|
||||
}
|
||||
|
||||
// 启动 Worker 进程
|
||||
await startWorker();
|
||||
|
||||
// 优雅关闭处理
|
||||
const shutdown = (signal: string) => {
|
||||
logger.log(`[NapCat] [Process] 收到${signal}信号,正在关闭...`);
|
||||
|
||||
if (currentWorker) {
|
||||
currentWorker.postMessage({ type: 'shutdown' });
|
||||
setTimeout(() => {
|
||||
currentWorker?.kill();
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Worker 进程(子进程入口)
|
||||
*/
|
||||
async function startWorkerProcess (): Promise<void> {
|
||||
if (!processManager) {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
|
||||
const processType = getProcessTypeName();
|
||||
logger.log(`[NapCat] [${processType}] Worker进程启动,PID: ${process.pid}`);
|
||||
|
||||
// 监听来自父进程的消息
|
||||
processManager.onParentMessage((msg: unknown) => {
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
if (msg.type === 'restart-prepare') {
|
||||
logger.log(`[NapCat] [${processType}] 收到重启准备信号,正在主动退出...`);
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
} else if (msg.type === 'shutdown') {
|
||||
logger.log(`[NapCat] [${processType}] 收到关闭信号,正在退出...`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 注册重启进程函数到 WebUI
|
||||
WebUiDataRuntime.setRestartProcessCall(async () => {
|
||||
try {
|
||||
const success = processManager!.sendToParent({ type: 'restart' });
|
||||
|
||||
if (success) {
|
||||
return { result: true, message: '进程重启请求已发送' };
|
||||
} else {
|
||||
return { result: false, message: '无法与主进程通信' };
|
||||
}
|
||||
} catch (e) {
|
||||
logger.logError('[NapCat] [Process] 发送重启请求失败:', e);
|
||||
return {
|
||||
result: false,
|
||||
message: '发送重启请求失败: ' + (e as Error).message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 启动 NapCat 核心
|
||||
await NCoreInitShell();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口
|
||||
*/
|
||||
async function main (): Promise<void> {
|
||||
// 单进程模式:直接启动核心
|
||||
if (ENV.isMultiProcessDisabled) {
|
||||
logger.log('[NapCat] [SingleProcess] 多进程模式已禁用,直接启动核心');
|
||||
await NCoreInitShell();
|
||||
return;
|
||||
}
|
||||
|
||||
// 多进程模式:初始化进程管理器
|
||||
const result = await createProcessManager();
|
||||
processManager = result.manager;
|
||||
isElectron = result.isElectron;
|
||||
|
||||
logger.log(`[NapCat] [Process] 检测到 ${isElectron ? 'Electron' : 'Node.js'} 环境`);
|
||||
|
||||
// 根据进程类型启动
|
||||
if (ENV.isWorkerProcess) {
|
||||
await startWorkerProcess();
|
||||
} else {
|
||||
await startMasterProcess();
|
||||
}
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
main().catch((e: Error) => {
|
||||
logger.logError('[NapCat] [Process] 启动失败:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
178
packages/napcat-shell/process-api.ts
Normal file
178
packages/napcat-shell/process-api.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Readable } from 'stream';
|
||||
import type { fork as forkType } from 'child_process';
|
||||
|
||||
// 扩展 Process 类型以支持 parentPort
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Process {
|
||||
parentPort?: {
|
||||
on (event: 'message', listener: (e: { data: unknown; }) => void): void;
|
||||
postMessage (message: unknown): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的进程接口
|
||||
*/
|
||||
export interface IWorkerProcess {
|
||||
readonly pid: number | undefined;
|
||||
readonly stdout: Readable | null;
|
||||
readonly stderr: Readable | null;
|
||||
|
||||
postMessage (message: unknown): void;
|
||||
kill (): boolean;
|
||||
on (event: string, listener: (...args: unknown[]) => void): void;
|
||||
once (event: string, listener: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程创建选项
|
||||
*/
|
||||
export interface ProcessOptions {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdio: 'pipe' | 'ignore' | 'inherit' | Array<'pipe' | 'ignore' | 'inherit' | 'ipc'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程管理器接口
|
||||
*/
|
||||
export interface IProcessManager {
|
||||
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess;
|
||||
onParentMessage (handler: (message: unknown) => void): void;
|
||||
sendToParent (message: unknown): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron utilityProcess 包装器
|
||||
*/
|
||||
class ElectronProcessManager implements IProcessManager {
|
||||
private utilityProcess: {
|
||||
fork (modulePath: string, args: string[], options: unknown): unknown;
|
||||
};
|
||||
|
||||
constructor (utilityProcess: { fork (modulePath: string, args: string[], options: unknown): unknown; }) {
|
||||
this.utilityProcess = utilityProcess;
|
||||
}
|
||||
|
||||
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
|
||||
const child: any = this.utilityProcess.fork(modulePath, args, options);
|
||||
|
||||
return {
|
||||
pid: child.pid as number | undefined,
|
||||
stdout: child.stdout as Readable | null,
|
||||
stderr: child.stderr as Readable | null,
|
||||
|
||||
postMessage (message: unknown): void {
|
||||
child.postMessage(message);
|
||||
},
|
||||
|
||||
kill (): boolean {
|
||||
return child.kill() as boolean;
|
||||
},
|
||||
|
||||
on (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.on(event, listener);
|
||||
},
|
||||
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onParentMessage (handler: (message: unknown) => void): void {
|
||||
if (process.parentPort) {
|
||||
process.parentPort.on('message', (e: { data: unknown; }) => {
|
||||
handler(e.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendToParent (message: unknown): boolean {
|
||||
if (process.parentPort) {
|
||||
process.parentPort.postMessage(message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node.js child_process 包装器
|
||||
*/
|
||||
class NodeProcessManager implements IProcessManager {
|
||||
private forkFn: typeof forkType;
|
||||
|
||||
constructor (forkFn: typeof forkType) {
|
||||
this.forkFn = forkFn;
|
||||
}
|
||||
|
||||
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
|
||||
const child = this.forkFn(modulePath, args, options as any);
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
|
||||
postMessage (message: unknown): void {
|
||||
if (child.send) {
|
||||
child.send(message as any);
|
||||
}
|
||||
},
|
||||
|
||||
kill (): boolean {
|
||||
return child.kill();
|
||||
},
|
||||
|
||||
on (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.on(event, listener);
|
||||
},
|
||||
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onParentMessage (handler: (message: unknown) => void): void {
|
||||
process.on('message', (message: unknown) => {
|
||||
handler(message);
|
||||
});
|
||||
}
|
||||
|
||||
sendToParent (message: unknown): boolean {
|
||||
if (process.send) {
|
||||
process.send(message as any);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测运行环境并创建对应的进程管理器
|
||||
*/
|
||||
export async function createProcessManager (): Promise<{
|
||||
manager: IProcessManager;
|
||||
isElectron: boolean;
|
||||
}> {
|
||||
const isElectron = typeof process.versions['electron'] !== 'undefined';
|
||||
|
||||
if (isElectron) {
|
||||
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
||||
const electron = await import('electron');
|
||||
return {
|
||||
manager: new ElectronProcessManager(electron.utilityProcess),
|
||||
isElectron: true,
|
||||
};
|
||||
} else {
|
||||
const { fork } = await import('child_process');
|
||||
return {
|
||||
manager: new NodeProcessManager(fork),
|
||||
isElectron: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import react from '@vitejs/plugin-react-swc';
|
||||
const external = [
|
||||
'ws',
|
||||
'express',
|
||||
'electron'
|
||||
];
|
||||
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "napcat-vite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
"name": "napcat-vite",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"_build": "vite build"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
225
packages/napcat-webui-backend/src/api/Plugin.ts
Normal file
225
packages/napcat-webui-backend/src/api/Plugin.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
21
packages/napcat-webui-backend/src/api/Process.ts
Normal file
21
packages/napcat-webui-backend/src/api/Process.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { WebUiDataRuntime } from '../helper/Data';
|
||||
import { sendError, sendSuccess } from '../utils/response';
|
||||
|
||||
/**
|
||||
* 重启进程处理器
|
||||
* POST /api/Process/Restart
|
||||
*/
|
||||
export async function RestartProcessHandler (_req: Request, res: Response) {
|
||||
try {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
|
||||
if (result.result) {
|
||||
return sendSuccess(res, { message: result.message || '进程重启请求已发送' });
|
||||
} else {
|
||||
return sendError(res, result.message || '进程重启失败');
|
||||
}
|
||||
} catch (e) {
|
||||
return sendError(res, '重启进程时发生错误: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 获取QQ登录二维码
|
||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
@@ -27,9 +27,17 @@ export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
|
||||
// 获取QQ登录状态
|
||||
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
|
||||
// 从 OneBot 上下文获取实时的 selfInfo.online 状态
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
const selfInfo = oneBotContext?.core?.selfInfo;
|
||||
const isOnline = selfInfo?.online;
|
||||
const qqLoginStatus = WebUiDataRuntime.getQQLoginStatus();
|
||||
// 必须同时满足:已登录且在线(online 必须明确为 true)
|
||||
const isLogin = qqLoginStatus && isOnline === true;
|
||||
const data = {
|
||||
isLogin: WebUiDataRuntime.getQQLoginStatus(),
|
||||
isLogin,
|
||||
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
|
||||
loginError: WebUiDataRuntime.getQQLoginError(),
|
||||
};
|
||||
return sendSuccess(res, data);
|
||||
};
|
||||
@@ -88,3 +96,15 @@ export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
|
||||
await WebUiConfig.UpdateAutoLoginAccount(uin);
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 刷新QQ登录二维码
|
||||
export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
// 判断是否已经登录
|
||||
if (WebUiDataRuntime.getQQLoginStatus()) {
|
||||
// 已经登录
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
// 刷新二维码
|
||||
await WebUiDataRuntime.refreshQRCode();
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
uin: '',
|
||||
nick: '',
|
||||
},
|
||||
QQLoginError: '',
|
||||
QQVersion: 'unknown',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
@@ -21,6 +22,9 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
onWebUiTokenChange: async (_token: string) => {
|
||||
|
||||
},
|
||||
onRefreshQRCode: async () => {
|
||||
// 默认空实现,由 shell 注册真实回调
|
||||
},
|
||||
NapCatHelper: {
|
||||
onOB11ConfigChanged: async () => {
|
||||
@@ -29,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
onQuickLoginRequested: async () => {
|
||||
return { result: false, message: '' };
|
||||
},
|
||||
onRestartProcessRequested: async () => {
|
||||
return { result: false, message: '重启功能未初始化' };
|
||||
},
|
||||
QQLoginList: [],
|
||||
NewQQLoginList: [],
|
||||
},
|
||||
@@ -163,4 +170,33 @@ export const WebUiDataRuntime = {
|
||||
getOneBotContext (): any | null {
|
||||
return LoginRuntime.OneBotContext;
|
||||
},
|
||||
|
||||
setRestartProcessCall (func: () => Promise<{ result: boolean; message: string; }>): void {
|
||||
LoginRuntime.NapCatHelper.onRestartProcessRequested = func;
|
||||
},
|
||||
|
||||
requestRestartProcess: async function () {
|
||||
return await LoginRuntime.NapCatHelper.onRestartProcessRequested();
|
||||
},
|
||||
|
||||
setQQLoginError (error: string): void {
|
||||
LoginRuntime.QQLoginError = error;
|
||||
},
|
||||
|
||||
getQQLoginError (): string {
|
||||
return LoginRuntime.QQLoginError;
|
||||
},
|
||||
|
||||
setRefreshQRCodeCallback (func: () => Promise<void>): void {
|
||||
LoginRuntime.onRefreshQRCode = func;
|
||||
},
|
||||
|
||||
getRefreshQRCodeCallback (): () => Promise<void> {
|
||||
return LoginRuntime.onRefreshQRCode;
|
||||
},
|
||||
|
||||
refreshQRCode: async function () {
|
||||
LoginRuntime.QQLoginError = '';
|
||||
await LoginRuntime.onRefreshQRCode();
|
||||
},
|
||||
};
|
||||
|
||||
11
packages/napcat-webui-backend/src/router/Plugin.ts
Normal file
11
packages/napcat-webui-backend/src/router/Plugin.ts
Normal 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 };
|
||||
9
packages/napcat-webui-backend/src/router/Process.ts
Normal file
9
packages/napcat-webui-backend/src/router/Process.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { RestartProcessHandler } from '../api/Process';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/Process/Restart - 重启进程
|
||||
router.post('/Restart', RestartProcessHandler);
|
||||
|
||||
export { router as ProcessRouter };
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getQQLoginInfoHandler,
|
||||
getAutoLoginAccountHandler,
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router = Router();
|
||||
@@ -28,5 +29,7 @@ router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
|
||||
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
|
||||
// router:设置自动登录QQ账号
|
||||
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
// router:刷新QQ登录二维码
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
|
||||
@@ -16,6 +16,8 @@ import { FileRouter } from './File';
|
||||
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();
|
||||
|
||||
@@ -44,5 +46,9 @@ router.use('/WebUIConfig', WebUIConfigRouter);
|
||||
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||
// router:调试相关路由
|
||||
router.use('/Debug', DebugRouter);
|
||||
// router:进程管理相关路由
|
||||
router.use('/Process', ProcessRouter);
|
||||
// router:插件管理相关路由
|
||||
router.use('/Plugin', PluginRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@@ -43,14 +43,17 @@ export interface LoginRuntimeType {
|
||||
QQQRCodeURL: string;
|
||||
QQLoginUin: string;
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQLoginError: string;
|
||||
QQVersion: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
onRefreshQRCode: () => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||
QQLoginList: string[];
|
||||
NewQQLoginList: LoginListItem[];
|
||||
};
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -93,7 +93,7 @@ const NetworkDisplayCard = <T extends keyof NetworkType> ({
|
||||
onPress={handleEnableDebug}
|
||||
isDisabled={editing}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
{debug ? '默认' : '调试'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -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;
|
||||
@@ -52,7 +52,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
onNativeClose();
|
||||
}}
|
||||
classNames={{
|
||||
backdrop: 'z-[99]',
|
||||
backdrop: 'z-[99] backdrop-blur-sm',
|
||||
wrapper: 'z-[99]',
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -2,9 +2,9 @@ import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerFormProps {
|
||||
data?: OneBotConfig['network']['httpServers'][0]
|
||||
onClose: () => void
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>
|
||||
data?: OneBotConfig['network']['httpServers'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: OneBotConfig['network']['httpServers'][0]) => Promise<void>;
|
||||
}
|
||||
|
||||
type HTTPServerFormType = OneBotConfig['network']['httpServers'];
|
||||
@@ -20,7 +20,7 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
|
||||
@@ -2,11 +2,11 @@ import GenericForm, { random_token } from './generic_form';
|
||||
import type { Field } from './generic_form';
|
||||
|
||||
export interface HTTPServerSSEFormProps {
|
||||
data?: OneBotConfig['network']['httpSseServers'][0]
|
||||
onClose: () => void
|
||||
data?: OneBotConfig['network']['httpSseServers'][0];
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
data: OneBotConfig['network']['httpSseServers'][0]
|
||||
) => Promise<void>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
type HTTPServerSSEFormType = OneBotConfig['network']['httpSseServers'];
|
||||
@@ -22,7 +22,7 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Modal, ModalContent, ModalHeader } from '@heroui/modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import HTTPClientForm from './http_client';
|
||||
import HTTPServerForm from './http_server';
|
||||
@@ -31,23 +32,57 @@ const NetworkFormModal = <T extends keyof OneBotConfig['network']> (
|
||||
) => {
|
||||
const { isOpen, onOpenChange, field, data } = props;
|
||||
const { createNetworkConfig, updateNetworkConfig } = useConfig();
|
||||
const dialog = useDialog();
|
||||
const isCreate = !data;
|
||||
|
||||
const onSubmit = async (data: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, data);
|
||||
} else {
|
||||
await updateNetworkConfig(field, data);
|
||||
const saveData = async (dataToSave: OneBotConfig['network'][typeof field][0]) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createNetworkConfig(field, dataToSave);
|
||||
} else {
|
||||
await updateNetworkConfig(field, dataToSave);
|
||||
}
|
||||
toast.success('保存配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
toast.success('保存配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
};
|
||||
|
||||
toast.error(`保存配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
if (['httpServers', 'httpSseServers', 'websocketServers'].includes(field)) {
|
||||
const serverData = data as any;
|
||||
if (!serverData.token) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '安全警告',
|
||||
content: (
|
||||
<div>
|
||||
<p>检测到未配置Token,这可能导致安全风险。确认要继续吗?</p>
|
||||
<p className='text-sm text-gray-500 mt-2'>(未配置Token时,Host将被强制限制为 127.0.0.1)</p>
|
||||
</div>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
serverData.host = '127.0.0.1';
|
||||
try {
|
||||
await saveData(serverData);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
reject(new Error('Cancelled'));
|
||||
},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveData(data);
|
||||
};
|
||||
|
||||
const renderFormComponent = (onClose: () => void) => {
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { IoAlertCircle, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
interface QrCodeLoginProps {
|
||||
qrcode: string
|
||||
qrcode: string;
|
||||
loginError?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode, loginError, onRefresh }) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center'>
|
||||
<Spinner color='primary' />
|
||||
{loginError
|
||||
? (
|
||||
<div className='flex flex-col items-center py-4'>
|
||||
<div className='w-full flex justify-center mb-6'>
|
||||
<div className='p-4 bg-danger-50 rounded-full'>
|
||||
<IoAlertCircle className='text-danger' size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center space-y-2 px-4'>
|
||||
<div className='text-xl font-bold text-danger'>登录失败</div>
|
||||
<div className='text-default-600 text-sm leading-relaxed max-w-[300px]'>
|
||||
{loginError}
|
||||
</div>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
className='mt-8 min-w-[160px]'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startContent={<IoRefresh />}
|
||||
onPress={onRefresh}
|
||||
>
|
||||
重新获取二维码
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className='bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden'>
|
||||
{!qrcode && (
|
||||
<div className='absolute left-0 top-0 right-0 bottom-0 bg-white dark:bg-zinc-900 bg-opacity-90 backdrop-blur-sm flex items-center justify-center z-10'>
|
||||
<Spinner color='primary' />
|
||||
</div>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode || ' '} />
|
||||
</div>
|
||||
<div className='mt-5 text-center text-default-500 text-sm'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
{onRefresh && qrcode && (
|
||||
<Button
|
||||
className='mt-4'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startContent={<IoRefresh />}
|
||||
onPress={onRefresh}
|
||||
>
|
||||
刷新二维码
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode} />
|
||||
</div>
|
||||
<div className='mt-5 text-center'>请使用QQ或者TIM扫描上方二维码</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' />,
|
||||
|
||||
@@ -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('回复消息'),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { serverRequest } from '@/utils/request';
|
||||
|
||||
export default class ProcessManager {
|
||||
/**
|
||||
* 重启进程
|
||||
*/
|
||||
public static async restartProcess () {
|
||||
const data = await serverRequest.post<ServerResponse<{ message: string; }>>(
|
||||
'/Process/Restart'
|
||||
);
|
||||
|
||||
return data.data.data;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { serverRequest } from '@/utils/request';
|
||||
|
||||
import { SelfInfo } from '@/types/user';
|
||||
@@ -20,8 +21,8 @@ export default class QQManager {
|
||||
public static async checkQQLoginStatus () {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{
|
||||
isLogin: string
|
||||
qrcodeurl: string
|
||||
isLogin: string;
|
||||
qrcodeurl: string;
|
||||
}>
|
||||
>('/QQLogin/CheckLoginStatus');
|
||||
|
||||
@@ -30,16 +31,20 @@ export default class QQManager {
|
||||
|
||||
public static async checkQQLoginStatusWithQrcode () {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{ qrcodeurl: string; isLogin: string }>
|
||||
ServerResponse<{ qrcodeurl: string; isLogin: string; loginError?: string; }>
|
||||
>('/QQLogin/CheckLoginStatus');
|
||||
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async refreshQRCode () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/RefreshQRcode');
|
||||
}
|
||||
|
||||
public static async getQQLoginQrcode () {
|
||||
const data = await serverRequest.post<
|
||||
ServerResponse<{
|
||||
qrcode: string
|
||||
qrcode: string;
|
||||
}>
|
||||
>('/QQLogin/GetQQLoginQrcode');
|
||||
|
||||
@@ -67,9 +72,11 @@ export default class QQManager {
|
||||
});
|
||||
}
|
||||
|
||||
public static async getQQLoginInfo () {
|
||||
public static async getQQLoginInfo (config?: AxiosRequestConfig) {
|
||||
const data = await serverRequest.post<ServerResponse<SelfInfo>>(
|
||||
'/QQLogin/GetQQLoginInfo'
|
||||
'/QQLogin/GetQQLoginInfo',
|
||||
{},
|
||||
config
|
||||
);
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@heroui/button';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { MdMenu, MdMenuOpen } from 'react-icons/md';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -11,14 +11,17 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import key from '@/const/key';
|
||||
|
||||
import errorFallbackRender from '@/components/error_fallback';
|
||||
// import PageLoading from "@/components/Loading/PageLoading";
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SideBar from '@/components/sidebar';
|
||||
|
||||
import useAuth from '@/hooks/auth';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import type { MenuItem } from '@/config/site';
|
||||
import { siteConfig } from '@/config/site';
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import ProcessManager from '@/controllers/process_manager';
|
||||
import { waitForBackendReady } from '@/utils/process_utils';
|
||||
|
||||
const menus: MenuItem[] = siteConfig.navItems;
|
||||
|
||||
@@ -48,7 +51,67 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
const [openSideBar, setOpenSideBar] = useLocalStorage(key.sideBarOpen, true);
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const navigate = useNavigate();
|
||||
const { isAuth } = useAuth();
|
||||
const { isAuth, revokeAuth } = useAuth();
|
||||
const dialog = useDialog();
|
||||
const isOnlineRef = useRef(true);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
// 定期检查 QQ 在线状态,掉线时弹窗提示
|
||||
useEffect(() => {
|
||||
if (!isAuth) return;
|
||||
const checkOnlineStatus = async () => {
|
||||
const currentPath = location.pathname;
|
||||
if (currentPath === '/qq_login' || currentPath === '/web_login') return;
|
||||
try {
|
||||
const info = await QQManager.getQQLoginInfo();
|
||||
if (info?.online === false && isOnlineRef.current === true) {
|
||||
isOnlineRef.current = false;
|
||||
dialog.confirm({
|
||||
title: '账号已离线',
|
||||
content: '您的 QQ 账号已下线,请重新登录。',
|
||||
confirmText: '重新登陆',
|
||||
cancelText: '退出账户',
|
||||
onConfirm: async () => {
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await ProcessManager.restartProcess();
|
||||
} catch (_e) {
|
||||
// 忽略错误,因为后端正在重启关闭连接
|
||||
}
|
||||
|
||||
// 轮询探测后端是否恢复
|
||||
await waitForBackendReady(
|
||||
15000, // 15秒超时
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
window.location.reload();
|
||||
},
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
dialog.alert({
|
||||
title: '启动超时',
|
||||
content: '后端在 15 秒内未响应,请检查 NapCat 运行日志或手动重启。',
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
onCancel: () => {
|
||||
revokeAuth();
|
||||
navigate('/web_login');
|
||||
},
|
||||
});
|
||||
} else if (info?.online === true) {
|
||||
isOnlineRef.current = true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// 忽略请求错误
|
||||
}
|
||||
};
|
||||
const timer = setInterval(checkOnlineStatus, 5000);
|
||||
checkOnlineStatus();
|
||||
return () => clearInterval(timer);
|
||||
}, [isAuth, location.pathname]);
|
||||
|
||||
const checkIsQQLogin = async () => {
|
||||
try {
|
||||
const result = await QQManager.checkQQLoginStatus();
|
||||
@@ -86,6 +149,7 @@ const Layout: React.FC<{ children: React.ReactNode; }> = ({ children }) => {
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<PageLoading loading={isRestarting} />
|
||||
<SideBar
|
||||
items={menus}
|
||||
open={openSideBar}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -8,8 +9,11 @@ import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import ProcessManager from '@/controllers/process_manager';
|
||||
import { waitForBackendReady } from '@/utils/process_utils';
|
||||
|
||||
const LoginConfigCard = () => {
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const {
|
||||
data: quickLoginData,
|
||||
loading: quickLoginLoading,
|
||||
@@ -53,6 +57,35 @@ const LoginConfigCard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onRestartProcess = async () => {
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
const result = await ProcessManager.restartProcess();
|
||||
toast.success(result.message || '进程重启请求已发送');
|
||||
|
||||
// 轮询探测后端是否恢复
|
||||
const isReady = await waitForBackendReady(
|
||||
30000, // 30秒超时
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
toast.success('进程重启完成');
|
||||
},
|
||||
() => {
|
||||
setIsRestarting(false);
|
||||
toast.error('后端在 30 秒内未响应,请检查 NapCat 运行日志');
|
||||
}
|
||||
);
|
||||
|
||||
if (!isReady) {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`进程重启失败: ${msg}`);
|
||||
setIsRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [quickLoginData]);
|
||||
@@ -82,6 +115,22 @@ const LoginConfigCard = () => {
|
||||
isSubmitting={isSubmitting || quickLoginLoading}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<div className='flex-shrink-0 w-full mt-6 pt-6 border-t border-divider'>
|
||||
<div className='mb-3 text-sm text-default-600'>进程管理</div>
|
||||
<Button
|
||||
color='warning'
|
||||
variant='flat'
|
||||
onPress={onRestartProcess}
|
||||
isLoading={isRestarting}
|
||||
isDisabled={isRestarting}
|
||||
fullWidth
|
||||
>
|
||||
{isRestarting ? '正在重启进程...' : '重启进程'}
|
||||
</Button>
|
||||
<div className='mt-2 text-xs text-default-500'>
|
||||
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
115
packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx
Normal file
115
packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,14 +16,39 @@ import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const parseLoginError = (errorStr: string) => {
|
||||
if (errorStr.startsWith('登录失败: ')) {
|
||||
const jsonPart = errorStr.substring('登录失败: '.length);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonPart);
|
||||
|
||||
if (Array.isArray(parsed) && parsed[1]) {
|
||||
const info = parsed[1];
|
||||
const codeStr = info.serverErrorCode ? ` (错误码: ${info.serverErrorCode})` : '';
|
||||
|
||||
return `${info.message || errorStr}${codeStr}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
return errorStr;
|
||||
};
|
||||
|
||||
export default function QQLoginPage () {
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const [uinValue, setUinValue] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [qrcode, setQrcode] = useState<string>('');
|
||||
const [loginError, setLoginError] = useState<string>('');
|
||||
const lastErrorRef = useRef<string>('');
|
||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const firstLoad = useRef<boolean>(true);
|
||||
@@ -61,6 +86,20 @@ export default function QQLoginPage () {
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setQrcode(data.qrcodeurl);
|
||||
if (data.loginError && data.loginError !== lastErrorRef.current) {
|
||||
lastErrorRef.current = data.loginError;
|
||||
setLoginError(data.loginError);
|
||||
const friendlyMsg = parseLoginError(data.loginError);
|
||||
|
||||
dialog.alert({
|
||||
title: '登录失败',
|
||||
content: friendlyMsg,
|
||||
confirmText: '确定',
|
||||
});
|
||||
} else if (!data.loginError) {
|
||||
lastErrorRef.current = '';
|
||||
setLoginError('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
@@ -99,6 +138,18 @@ export default function QQLoginPage () {
|
||||
setUinValue(e.target.value);
|
||||
};
|
||||
|
||||
const onRefreshQRCode = async () => {
|
||||
try {
|
||||
lastErrorRef.current = '';
|
||||
setLoginError('');
|
||||
await QQManager.refreshQRCode();
|
||||
toast.success('已发送刷新请求');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`刷新二维码失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
onUpdateQrCode();
|
||||
@@ -159,7 +210,11 @@ export default function QQLoginPage () {
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
<QrCodeLogin qrcode={qrcode} />
|
||||
<QrCodeLogin
|
||||
loginError={parseLoginError(loginError)}
|
||||
qrcode={qrcode}
|
||||
onRefresh={onRefreshQRCode}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Button
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
/** 合并转发消息段 */
|
||||
|
||||
35
packages/napcat-webui-frontend/src/utils/process_utils.ts
Normal file
35
packages/napcat-webui-frontend/src/utils/process_utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
/**
|
||||
* 轮询等待后端进程恢复
|
||||
* @param maxWaitTime 最大等待时间,单位毫秒
|
||||
* @param onSuccess 成功回调
|
||||
* @param onTimeout 超时回调
|
||||
*/
|
||||
export async function waitForBackendReady (
|
||||
maxWaitTime: number = 15000,
|
||||
onSuccess?: () => void,
|
||||
onTimeout?: () => void
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
// 尝试请求后端,设置一个较短的请求超时避免挂起
|
||||
await QQManager.getQQLoginInfo({ timeout: 500 });
|
||||
// 如果能走到这一步说明请求成功了
|
||||
clearInterval(timer);
|
||||
onSuccess?.();
|
||||
resolve(true);
|
||||
} catch (_e) {
|
||||
// 如果请求失败(后端没起来),检查是否超时
|
||||
if (Date.now() - startTime > maxWaitTime) {
|
||||
clearInterval(timer);
|
||||
onTimeout?.();
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
}, 500); // 每 500ms 探测一次
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
// import viteCompression from 'vite-plugin-compression';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
ViteImageOptimizer({})
|
||||
ViteImageOptimizer({}),
|
||||
],
|
||||
base: '/webui/',
|
||||
server: {
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user